Skip to content

policy: 3,900,000 WU standard txs, global 1 sat dust limit, $DOG Mode preferential peering - #3

Merged
leonidaskingdev merged 5 commits into
bitcoindogmode:31.1-dogmodefrom
DogModeMillionaire:dogmode-policy
Sep 2, 2026
Merged

leonidaskingdev merged 5 commits into
bitcoindogmode:31.1-dogmodefrom
DogModeMillionaire:dogmode-policy

Conversation

@DogModeMillionaire

Copy link
Copy Markdown

Summary

Three $DOG Mode policy changes, split into reviewable commits:

  1. policy: raise maximum standard transaction weight to 3,900,000 WU (975 kvB, from 400,000 WU) — just under the 4,000,000 WU consensus block weight limit, so a maximum size transaction still fits in a block with a minimal coinbase and within the 4 MB MAX_PROTOCOL_MESSAGE_LENGTH p2p limit. Dependent limits are raised to stay consistent: -limitclustersize default 101 -> 976 kvB and MAX_PACKAGE_WEIGHT 404,000 -> 3,904,000 WU. The -maxmempool sanity floor is rescaled (40x -> 5x the cluster size limit) so the minimum stays ~5 MB. Because MAX_OP_RETURN_RELAY = MAX_STANDARD_TX_WEIGHT / WITNESS_SCALE_FACTOR, the default -datacarriersize rises to 975,000 vbytes.

  2. policy: lower the dust limit to a global 1 sat (from 294-546 sats depending on output type). GetDustThreshold() now caps the feerate-based computation at 1 sat; -dustrelayfee can only lower the limit further (0 disables it entirely, as before), never raise it above 1 sat.

  3. net: add $DOG Mode preferential peering — a port of Libre Relay's preferential peering (commit 66518dbe2f8f, Peter Todd). Nodes advertise NODE_DOG_MODE (service bit 28, distinct from Libre Relay's bit 29) and open up to 4 extra long-lived outbound dog connections to peers advertising the same flag, so $DOG Mode nodes form a relay subnetwork for transactions that are non-standard elsewhere. Peers on dog connections that don't advertise the flag are disconnected after the version handshake. Visible in getpeerinfo, bitcoin-cli -netinfo, and the GUI peers window.

Release notes included in doc/release-notes-dogmode-policy.md.

Tests

  • New functional test p2p_dogmode_peering.py (v1+v2 transports): flag advertisement, dog connection setup, transaction relay over dog connections, connection capacity limit, and disconnection of peers missing the flag. New unit test cnode_dog_mode_conn_test covers the new connection type and service flag.
  • Existing unit/functional tests updated for the new limits (transaction_tests, txpackage_tests, orphanage_tests, coinselector_tests, mempool_accept, mempool_dust, mempool_ephemeral_dust, mempool_cluster, mempool_package_limits, mempool_sigoplimit, wallet_send, wallet_sendall, wallet_fundrawtransaction, p2p_orphan_handling, p2p_segwit, p2p_eviction, p2p_node_network_limited, rpc_psbt, interface_bitcoin_cli, test framework constants).
  • p2p_eviction.py is kept enabled by bumping -maxconnections by the 4 new outbound slots (Libre Relay disabled it instead).

Test plan

  • test_bitcoin (full unit suite): passes.
  • Full functional suite: passes. Sole failure is the pre-existing tool_bitcoin.py, which requires the multiprocess bitcoin-node binary (not built without ENABLE_IPC); verified to fail identically on the base branch with the same build config.
  • test/lint/lint-files.py passes; branch diff is git diff --check clean.

@Mehdalon

Copy link
Copy Markdown

Went through this properly — built it from source, ran the full unit and functional suites myself instead of trusting the PR description, read the diff line by line. Results below.

Tests

  • ctest: 153/153 passed. Matches what the PR says.
  • Full functional suite (~267 files): 262 passed, 21 skipped, 3 failed. All 3 failures are on me, not the code — rpc_signer.py/wallet_signer.py broke on a space in my checkout path (ran the script manually with proper quoting, works fine), wallet_importdescriptors.py timed out under full parallel load and passed clean on its own. So: zero real functional regressions from this diff.
  • Lint (full test/lint runner, all categories): clean.

One thing to fix in the PR description itself: it says tool_bitcoin.py is the only failure and blames ENABLE_IPC. Didn't happen for me — that test guards its IPC checks behind is_ipc_compiled() and just passes without it. End conclusion is still right (no real functional failures), the stated reason for it isn't.

Code

Checked the constants and the dust change by hand rather than trusting the comments:

  • GetDustThreshold() = min(fee, 1 sat). At -dustrelayfee=0 this still fully disables dust (min(0,1)=0), at default it clamps to exactly 1 sat. Correct.
  • Both static_asserts in packages.h hold with WITNESS_SCALE_FACTOR=4. The second one is an exact equality at these numbers (3,904,000 == 3,904,000) — not a coincidence, whoever picked these did the math.
  • Eviction: ProtectOutboundConnections already exempts everything that isn't INBOUND, so dogmode peers are protected with zero code changes needed there. Confirmed, not assumed.
  • RPC/GUI plumbing for the new peer type is complete, no dead fields.

Two things I'd fix before merging:

1. The -maxmempool floor change (40x → 5x in Flatten()) quietly cuts a DoS margin someone put there on purpose.
That *40 goes back to commit 794a8cec (2015), and the commit message says exactly why: 40x was chosen because an attacker can cheaply spam min-fee transactions near -limitdescendantsize. Dropping to 5x to keep the floor near ~5MB means a mempool sitting at that floor now fills with 5 max-size clusters instead of 40 — 8x less margin against the exact thing that commit was defending against. Default 300MB users don't care, but anyone running near the floor does, and the PR only argues usability, never touches the DoS side. Either justify 5x or don't change the multiplier.

2. m_max_outbound_dog_mode isn't clamped like the other outbound slot counters.
m_max_outbound_full_relay and m_max_outbound_block_relay both go through std::min(..., m_max_automatic_connections) before being subtracted from inbound capacity. This one doesn't. Around -maxconnections=11-14 that zeroes out inbound when a clamped version wouldn't. Not a security bug, just an inconsistency — one line to fix: m_max_outbound_dog_mode = std::min(MAX_DOG_MODE_CONNECTIONS, m_max_automatic_connections);

Also: there's no -dogmode/-nodogmode flag, it's on unconditionally. Fine if that's intentional (matches Libre Relay), but the release notes don't mention it and probably should — it's a behavior change operators can't turn off.

Rest of it checks out — dust semantics, the weight/cluster math, eviction handling, plumbing. Good work overall, just want those two points addressed or explicitly justified before this merges.

Build/test verification and the commit-history trace on the mempool floor were done with Claude Code — happy to share the full build/test logs if useful.

@DogModeMillionaire

Copy link
Copy Markdown
Author

Point 1. The margin 794a8ce created is byte-denominated: the cost of the cheap-fill games is floor bytes × min feerate, and the floor is preserved in bytes — 40 × 101 kvB = 4.04 MB → 5 × 976 kvB = 4.88 MB, so that cost is unchanged (marginally higher). What 5x preserves is the minimum permitted -maxmempool of ~5 MB; keeping 40x would raise it to 39 MB and break every existing -maxmempool=5..38 config. Not going to fix.

Point 2: Valid and updated in 9b6f684

tialkan added a commit to tialkan/bitcoin-dust-utxo-lab that referenced this pull request Aug 27, 2026
… PR #3

Review of bitcoindogmode/bitcoin#3 is arguing the 40x to 5x multiplier
change from first principles: cluster-count margin versus
byte-denominated margin. Neither side measured it.

Same -maxmempool=5 on both builds, filled with independent maximum-size
transactions. Stock holds 30 and its minimum feerate stays at the floor.
The patched build holds 3, one transaction is 19% of the mempool, and
four transactions raise the minimum feerate 13x. A control run with
small transactions on the patched build reproduces stock exactly, which
places the effect on transaction size against mempool size rather than
on the multiplier alone.

Bounded: an ordinary 10 sat/vB transaction was accepted in every case.
@tialkan

tialkan commented Aug 27, 2026

Copy link
Copy Markdown

Not a code review, that ground is covered above. What I can add is measurement: I implemented these same three changes independently against v31.1 last week and measured their consequences, before this PR existed. Harness and raw JSON: https://github.com/tialkan/bitcoin-dust-utxo-lab

Four things, none of which I think have come up yet.

1. The dust cap removes a choice operators currently have

GetDustThreshold() becoming min(fee_based, 1 sat) is a cap, not a default. The help text says it plainly: -dustrelayfee "can only lower it further". An operator on this build cannot run a dust policy more conservative than 1 sat.

That matters because the 1 sat threshold does not need a code change at all. On unmodified v31.1, -dustrelayfee=0 already produces it. Measured by binary-searching the smallest output testmempoolaccept does not reject as dust:

Output type default -dustrelayfee=100 sat/kvB -dustrelayfee=0
P2PKH 546 19 1
P2SH-P2WPKH 540 18 1
P2WPKH 294 10 1
P2TR 330 11 1

-dustrelayfee is registered ALLOW_ANY | DEBUG_ONLY and, unlike -acceptnonstdtxn a dozen lines below it in the same function, is not gated on chainparams.IsTestChain(). It works on mainnet today.

So the same user-visible outcome is available by changing the default DUST_RELAY_TX_FEE instead of capping the function. That keeps the diff against upstream smaller, which matters for the rebase burden on every Core release, and it leaves operators able to choose. Capping buys nothing that a default does not, and costs configurability in one direction.

Incidentally, the "294-546 sat" range as usually quoted is incomplete: P2TR is 330 and P2SH-P2WPKH is 540.

2. What a 1 sat output costs, measured

The benefit side of this change has a number attached to it. The cost side has not, so far as I can see.

450,000 1-sat outputs created and mined on regtest. Marginal chainstate cost per additional UTXO settles and stays flat:

Output type steady-state chainstate B/UTXO disk_size bogosize
P2TR 54.62 39.45 83.99
P2WPKH 42.66 30.59 72.00
P2PKH 40.47 28.98 75.00

P2TR is both the type most likely to carry these transactions and the most expensive to hold. Note the inversion between the last two rows: P2PKH has the larger bogosize but the smaller on-disk cost, because the chainstate compresses recognised script templates while P2WPKH and P2TR are stored close to verbatim.

Per block, from the measured transaction shape: a block whose 1,000,000 vB is entirely 1-sat dust holds about 32,219 outputs, costs its creator 0.01 BTC at 1 sat/vB, and adds about 1.37 MB of chainstate to every node, permanently.

Permanently is the operative word. An output is economically spendable only if its value exceeds the fee to spend it. A P2TR key path input is 57.5 vB, so a 1-sat output would need a feerate below 0.017 sat/vB to be worth spending, far under the 1 sat/vB relay floor. These are not transient entries that clear when fees fall.

That is not an argument against the change. It is the other half of the ledger, and it belongs in the release notes: this is a decision to grow the UTXO set permanently, paid for by node operators rather than by the party creating the outputs.

3. Preferential peering has a cost that is charged whether or not it works

The four reserved slots are added to m_max_automatic_outbound, and m_max_inbound is derived by subtraction in CConnman::Init. Measured by opening raw TCP connections until connections_in stops rising:

-maxconnections stock inbound patched inbound
20 9 5
125 (default) 114 110

Exactly four, as the arithmetic predicts. 3.5% of inbound capacity at the default. Worth a release note line: a node running this improves its own transaction reach by slightly reducing what it serves back.

The part that surprised me is when it is charged. The reserved-slot branch in ThreadOpenConnections sits after the full-relay and block-relay branches. Measured on a 24-node harness:

Signalling peers Slots filled
6 of 24 4 of 4, in 15s
3 of 24 3 of 4 (only three exist)
6 of 6 0 of 4

The last row is the interesting one, and it reproduced three times. With six reachable peers, all six go to the eight full-relay slots and the reserved branch is never reached at all, at 100% adoption. Starting from gossip instead of a pre-filled addrman, on a 24-node network, the observer plateaued at 9 known addresses and ended with 8 full-relay and 1 block-relay connection: one address short of the second block-relay slot, so preferential peering never engaged in 25 minutes.

Caveat, because it matters: a small closed network is a much thinner address supply than a node bootstrapping from DNS seeds, so those timings do not transfer to mainnet. What does transfer is the ordering. A node short of peers pays four inbound slots for a mechanism it never reaches. If this is meant to work for nodes with modest connectivity, moving the reserved branch ahead of the block-relay branch, or reserving fewer slots, would be worth considering.

4. The -maxmempool floor

The 40x to 5x change is being argued above from first principles, so I measured it. Same -maxmempool=5 on both builds, each filled with independent maximum-size transactions at 1.2 sat/vB, plus a control.

Build Max tx vsize One tx as share of mempool Txs held mempoolminfee (BTC/kvB)
stock v31.1, 100 kvB txs 99,312 1.99% 30 0.00000100
PR#3, 975 kvB txs 961,112 19.22% 3 0.00001299
PR#3, 100 kvB txs (control) 99,312 1.99% 30 0.00000100

The control matters: PR#3 fed small transactions behaves identically to stock. So this is not the multiplier in isolation. It is what happens when maximum-size transactions meet a mempool near the floor.

What changes is sensitivity. Thirty transactions leave stock's minimum feerate untouched at the floor. Four leave PR#3's at thirteen times the floor, and the fifth is rejected with mempool min fee not met, 1153332 < 1248485 at the same feerate its four predecessors were accepted at, with the mempool around 90% full. One of the four had already been evicted by then.

So I do not think either framing above is quite it. The attacker's byte cost is preserved, as you say. The cluster count margin does drop, as raised. But the consequence a node operator actually sees at the floor is a minimum feerate that moves in ~20% steps because that is the granularity of one eviction. A node there will reject transactions the rest of the network accepts, and its fee estimation will be reading its own eviction noise.

Bounding the severity honestly: an ordinary transaction at 10 sat/vB was still accepted in every case, including after the fill. This is not a node that stops working. It is a node whose mempool policy diverges from the network's for reasons unrelated to fee market conditions.

Whether that is acceptable is a judgement call, and at the 300 MB default it does not arise at all. But it is a different question from the one being argued, and it is the one that bites the operators the floor exists for. If the goal is not breaking existing -maxmempool=5..38 configurations, raising those configs' effective value with a startup warning would achieve that without putting anyone's mempool at three transactions.


On the constants themselves, independently arrived at and identical: the three-way coupling through the two static_asserts in packages.h is real and easy to miss, and 3'904'000 / 976 is a tidier resolution of it than the 3'900'000 / 975 I had used. Same conclusion on the service bit: not reusing Libre Relay's 29, since the bit signals acceptance of a policy set and the two projects do not run the same one.

@Mehdalon

Copy link
Copy Markdown

On point 3 (peering cost charged whether or not it works) — checked this against the actual code rather than just the measurement, and it holds up structurally, no need to redo the 24-node harness to believe it.

ThreadOpenConnections' priority chain (src/net.cpp:2744-2778 at current head) is: anchor block-relay → full-relay → block-relay → GetTryNewOutboundPeer() (a second full-relay path) → the periodic anti-eclipse extra-block-relay branch → dogmode → feeler. That's six higher-priority tiers ahead of it, not two — worse than the writeup implies. Since each attempt commits to one type via if/else-if, a permanently-unmet senior quota (e.g. full-relay stuck below 8 because the address pool is thin) blocks the chain from ever reaching dogmode, no matter how many attempts run. Matches the 6-of-6/0-of-4 result exactly, and it's a general property of the ordering, not specific to that harness size.

Agree the fix is cheap either way — move the branch up, or drop the reserved count — but given it's six tiers down, I'd lean toward reordering over shrinking the count.

@cre8tor-devops cre8tor-devops left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Recommendation: do not merge this as-is. The 3.9M WU / 976 kvB / 3.904M package constants and NODE_DOG_MODE on bit 28 are correct. Three other choices in this patch are product bugs, not style nits. Fix them before this becomes v1, because they are painful to unwind once nodes are running the software.

Merge these

  • MAX_STANDARD_TX_WEIGHT = 3,900,000, MAX_PACKAGE_WEIGHT = 3,904,000, DEFAULT_CLUSTER_SIZE_LIMIT_KVB = 976. The static_assert chain in packages.h is real. 3,904,000 / 976 is the right resolution of it (same 4k WU headroom Core uses). Independently reached by this PR and by the measurements in #1.
  • NODE_DOG_MODE on bit 28, not Libre Relay's 29. The bit means "I accept this policy set." The two clients do not run the same set.
  • Tests, RPC/GUI plumbing, and the dedicated p2p_dogmode_peering.py harness. That is the right shape of a first release: a small overlay, tested.

Change these three before merge

1. Pin MAX_OP_RETURN_RELAY at 100,000. Do not inherit 975,000.

src/policy/policy.h still has MAX_OP_RETURN_RELAY = MAX_STANDARD_TX_WEIGHT / WITNESS_SCALE_FACTOR. Raising tx weight therefore silently raises default -datacarriersize from 100k to 975k. The release notes mention this as a consequence. It is a second policy change, not a consequence.

Ordinals/inscriptions live in the witness. Runes use OP_RETURN, but 100k vB is already enormous. The announcement was 3.9M txs and 1-sat dust. It was not "almost-a-megabyte OP_RETURN by default."

Make it a literal:

static constexpr unsigned int MAX_OP_RETURN_RELAY{100'000};

If 975k datacarrier is actually wanted, say that in the README and the original announcement, not as a footnote about a macro. Leaving it coupled also means every future weight tweak silently moves OP_RETURN again.

2. 1-sat dust is a default, not a cap. Do not change GetDustThreshold.

This PR keeps DUST_RELAY_TX_FEE{3000} and does return std::min(fee, CAmount{1}). That is the wrong mechanism.

Unmodified v31.1 already produces a 1-sat floor on every standard output type with -dustrelayfee=0. That flag is ALLOW_ANY | DEBUG_ONLY and is not gated on IsTestChain(). It works on mainnet today (measured in #1).

So the user-visible 1-sat outcome needs one line:

static constexpr unsigned int DUST_RELAY_TX_FEE{0};

and no change to GetDustThreshold. The cap in this PR:

  • removes the ability to run a stricter dust policy on this client (operators cannot restore 546)
  • is extra fork delta against Core on every rebase
  • buys nothing a default does not

Also put the cost side in the release notes, which currently only state the benefit. 1-sat P2TR is ~43–55 bytes of chainstate forever, paid by every full node. A P2TR key-path spend is ~57.5 vB, so these outputs are not economically spendable at the 1 sat/vB relay floor. They are markers. Say that.

3. Do not change Flatten from 40x to 5x.

src/txmempool.cpp Flatten() now uses cluster_size_vbytes * 5 so -maxmempool=5 still boots. That is optimizing for a config file at the expense of mempool behaviour.

The 40x factor is from Core 794a8cec (2015): cheap min-fee fill against the descendant/cluster limit. Byte-cost of a fill at the floor is similar either way (tialkan's point). What actually changes, measured on this PR vs stock at -maxmempool=5:

  • stock holds ~30 × 100 kvB txs; min fee stays at the floor
  • this PR holds 3 × 975 kvB txs; min fee jumps ~13× after four txs; the fifth is rejected while the mempool is ~90% full

A node at the floor then diverges from the network's mempool for reasons that are not the fee market. Default 300 MB users never see it. The operators the floor exists for do.

Keep * 40. The honest number is -maxmempool must be ≥ 40 MB. Put a startup error (already exists) plus a release-note migration line. If compatibility with -maxmempool=5 is mandatory, clamp with a warning and bump, not a quieter DoS margin.

-blocksonly currently implies -maxmempool=5. Raise that default to the same floor or blocksonly nodes will not start.

Peering: keep it, fix the slot order

Four reserved outbound slots costing four inbound is the right idea. Document the 114 → 110 inbound drop at default -maxconnections.

Do not ship it at the bottom of ThreadOpenConnections. The chain is: anchor block-relay → full-relay → block-relay → extra full-relay → extra block-relay → dogmode → feeler. Six senior tiers. If full-relay is short, dogmode never runs, but inbound capacity was already subtracted. tialkan measured 6-of-6 signalling peers → 0 of 4 dog slots. Mehdalon confirmed that in the source.

Move the dogmode branch up (after full-relay, before or with block-relay), or do not reserve slots you will not fill. Also clamp m_max_outbound_dog_mode the same way full-relay and block-relay are clamped (Mehdalon's point 2; the follow-up commit is good if it is actually in this head).

Add -nodogmode even if the default is on. Unconditional extra outbound slots with no off switch is an ops surprise. Libre Relay can get away with that as a one-person experiment. A named client should not.

Change the user-agent from /Satoshi: to /DOGMode:. Identity belongs in the version handshake, not only in a service bit.

Project-level, not this diff

  • Enable Issues. Two RFCs exist as draft PRs only because they are off. That is how you lose reviewers.
  • Do not rebrand the rest of Core. User-agent + README + these release notes are enough. A 300k-line string replace is rebase poison.
  • No binaries until Guix-reproducible builds and a security contact that is this project, not Core's SECURITY.md.
  • One miner path on signet before mainnet slogans. Relay policy without a miner is a diary.

What I would ship as v1

Four commits on top of v31.1:

  1. weight/package/cluster constants, OP_RETURN pinned at 100k, Flatten stays 40x, -blocksonly mempool default raised, tests updated
  2. DUST_RELAY_TX_FEE = 0 only
  3. NODE_DOG_MODE bit 28, 4 slots, reordered in ThreadOpenConnections, clamped, -nodogmode, user-agent /DOGMode/
  4. release notes that include the 40 MB floor, the inbound-slot cost, and the UTXO cost of 1-sat outputs

That is the entire client. Everything else is Core and should stay Core.

Happy to turn (1)–(3) into a patch on this branch if the author wants it. I am not going to open a competing PR.

tialkan added a commit to tialkan/bitcoin-dust-utxo-lab that referenced this pull request Aug 28, 2026
… two

The finding described the reserved-slot branch as sitting after the
full-relay and block-relay branches, implying two tiers ahead of it. The
if/else-if chain in ThreadOpenConnections actually has five ahead:
anchor block-relay, full-relay, block-relay, GetTryNewOutboundPeer, and
the periodic extra-block-relay branch.

This strengthens rather than weakens the result. Each pass commits to one
connection type, so any permanently unmet senior quota blocks the chain
from reaching the reserved branch at all, which is what the 6-of-6 run
shows.

Correction due to Mehdalon on bitcoindogmode/bitcoin#3; verified against
the source before applying.
$DOG Mode: raise the maximum transaction weight we are willing to
relay/mine from 400,000 WU to 3,900,000 WU (975 kvB), just under the
4,000,000 WU consensus block weight limit. A maximum size transaction
still fits into a block (leaving room for a minimal coinbase) and its
serialization always fits within the 4 MB MAX_PROTOCOL_MESSAGE_LENGTH
p2p limit.

Dependent limits are raised to keep the policy consistent:
- DEFAULT_CLUSTER_SIZE_LIMIT_KVB: 101 -> 976 kvB, so that a maximum
  size transaction is accepted to the mempool as a cluster of one.
- MAX_PACKAGE_WEIGHT: 404,000 -> 3,904,000 WU, preserving the
  invariant MAX_PACKAGE_WEIGHT >= MAX_STANDARD_TX_WEIGHT.

Note that MAX_OP_RETURN_RELAY is defined as
MAX_STANDARD_TX_WEIGHT / WITNESS_SCALE_FACTOR, so the default
-datacarriersize rises from 100,000 to 975,000 vbytes.

The -maxmempool floor check scales with the cluster size limit
(40x -> 5x), keeping the minimum at ~5 MB instead of rising to 40 MB,
so small-mempool configurations remain usable.

Tests are updated for the new limits; wallet_miniscript.py keeps
testing at the legacy ~330KB miniscript size since building a ~3.8MB
miniscript string is impractical.
$DOG Mode: the dust limit for spendable outputs is lowered from
294-546 sats (depending on output type) to a global 1 sat, i.e. only
zero-value outputs are considered dust.

GetDustThreshold() now caps the fee-rate-based computation at 1 sat,
so -dustrelayfee can only lower the dust limit further (0 disables it
entirely, as before) but never raise it above 1 sat.

Tests are updated for the new limit. Note that the wallet's change
output viability is dominated by the discard feerate and is largely
unaffected; mempool_ephemeral_dust.py keeps exercising ephemeral dust
with 0-value outputs, the only dust left.
$DOG Mode nodes advertise the NODE_DOG_MODE service flag (bit 28) and
open up to MAX_DOG_MODE_CONNECTIONS (4) extra long-lived outbound
connections to peers advertising the same flag, so that $DOG Mode
nodes form a relay subnetwork for transactions that are non-standard
on the rest of the network.

The implementation is a port of Libre Relay's preferential peering
(commit 66518db "Peer with 4 other Libre Relay nodes" by Peter
Todd), renamed to $DOG Mode and adapted to current connection types.
We use service bit 28 so as to stay distinct from Libre Relay's
NODE_LIBRE_RELAY (bit 29).

A new "dog" connection type is plumbed through net, net_processing,
RPC (getpeerinfo, addconnection for tests), bitcoin-cli -netinfo and
the GUI. Peers connected to via a dog connection that do not advertise
NODE_DOG_MODE are disconnected after the version handshake.

Tests:
- new functional test p2p_dogmode_peering.py covering the advertised
  service flag, dog connection setup, transaction relay over dog
  connections, the connection capacity limit and disconnection of
  peers missing the flag
- unit test coverage for the new connection type and service flag
- p2p_eviction.py: bump -maxconnections by the 4 new outbound slots,
  preserving the tested number of inbound slots (Libre Relay disabled
  this test instead)
- p2p_node_network_limited.py: expect the new service flag
@DogModeMillionaire

Copy link
Copy Markdown
Author

@Mehdalon Thanks for the detailed review. Addressed:

  1. -maxmempool floor (40x→5x): the margin 794a8cec created is byte-denominated — 40×101kvB = 4.04 MB vs 5×976kvB = 4.88 MB, so attacker fill cost is preserved. Keeping 40x would break every -maxmempool=5..38 config by raising the floor to ~39 MB. Declined (see tialkan#4 as well).
  2. Dogmode slot clamp: fixed (squashed into peering commit).
  3. interface_usdt_net.py: fixed (-maxconnections=36 like p2p_eviction.py).
  4. tool_bitcoin.py/ENABLE_IPC wording: will correct.
  5. No opt-out flag / UTXO cost / PR-description: will update release notes + body.

Squashed into policy/peering commits.

@DogModeMillionaire

Copy link
Copy Markdown
Author

@tialkan Thanks for the independent measurements — catching the six-tier peering-priority point is the most useful structural critique here.

  • Dust cap vs default: fair point, will answer explicitly. The cap's intent is to remove operator choice for dust rather than the fee knob; will weigh default-vs-cap and update.
  • UTXO cost side: accurate framing (P2TR ~55 B/UTXO measured) — will add to release notes.
  • Peering ordering: agree it's six tiers and blocks engagement at full adoption. Evaluating reorder vs shrink; leaning to moving the dogmode branch up.
  • Mempool floor: the byte-cost rebuttal stands; if it feels wrong from an operator too — a -maxmempool=5..38 startup warning is a good compromise.
    Thanks for doing the math.

@DogModeMillionaire

Copy link
Copy Markdown
Author

@Mehdalon Right — six tiers of priority ahead, not two. Agree reorder over shrink. Will fold that in when deciding the change in the peering commit.

@DogModeMillionaire

Copy link
Copy Markdown
Author

@Mehdalon Thanks for the detailed review. Addressed:

  1. -maxmempool floor (40x→5x): the margin 794a8cec created is byte-denominated — 40×101kvB = 4.04 MB vs 5×976kvB = 4.88 MB, so attacker fill cost is preserved. Keeping 40x would break every -maxmempool=5..38 config by raising the floor to ~39 MB. Declined (see tialkan#4 as well).
  2. Dogmode slot clamp: fixed (squashed into peering commit).
  3. interface_usdt_net.py: fixed (-maxconnections=36 like p2p_eviction.py).
  4. tool_bitcoin.py/ENABLE_IPC wording: will correct.
  5. No opt-out flag / UTXO cost / PR-description: will update release notes + body.

Squashed into policy/peering commits.

@DogModeMillionaire

Copy link
Copy Markdown
Author

@tialkan Thanks for the independent measurements — catching the six-tier peering-priority point is the most useful structural critique here.

  • Dust cap vs default: fair point, will answer explicitly. The cap's intent is to remove operator choice for dust rather than the fee knob; will weigh default-vs-cap and update.
  • UTXO cost side: accurate framing (P2TR ~55 B/UTXO measured) — will add to release notes.
  • Peering ordering: agree it's six tiers and blocks engagement at full adoption. Evaluating reorder vs shrink; leaning to moving the dogmode branch up.
  • Mempool floor: the byte-cost rebuttal stands; if it feels wrong from an operator too — a -maxmempool=5..38 startup warning is a good compromise.
    Thanks for doing the math.

@DogModeMillionaire

Copy link
Copy Markdown
Author

@Mehdalon Right — six tiers of priority ahead, not two. Agree reorder over shrink. Will fold that in when deciding the change in the peering commit.

@DogModeMillionaire

Copy link
Copy Markdown
Author

tl;dr: All review points addressed in 067450b974 (force-pushed) — bench fix, dogmode clamp, interface_usdt_net.py inbound arithmetic squashed into their right commits; interface_usdt_net.py inbound-slot fix is in the peering commit next to p2p_eviction.py. CI re-run is gated by action_required on the org side, so head is locally-verified: tool_bench_sanity_check.py --bench=OrphanageSinglePeerEviction green + full test_bitcoin green. Once the fork-hand flips Actions green, this is merge-ready.

@CryptograssMan

Copy link
Copy Markdown

Not a code review, the experts above have the internals covered, and from
where I sit the direction here is right. 3.9M WU standard txs and a 1 sat dust
floor are the whole point of $DOG Mode, and shipping them as a tested overlay
with its own service bit and its own functional test is the right way to do it.

What I'd push on is release notes, because that's what determines whether
operators have a good first week with this build. Three things a node runner
learns the hard way otherwise:

  1. NODE_DOG_MODE is on unconditionally, no -dogmode off switch. Anyone
    running this build joins the overlay. I'm fine with that being the default,
    it should just be stated, not discovered.
  2. Default inbound capacity drops 114 → 110, per @tialkan's measurement. Four
    slots is a fair price for the reach, and worth one line so nobody thinks
    their node is misconfigured.
  3. Default -datacarriersize lands at 975,000 vB as a consequence of the
    weight change. However that gets resolved, it belongs in the announcement
    rather than a footnote, it's a selling point or a surprise depending on
    whether we say it first.

Adoption is the thing that makes preferential peering work at all. Documented
behaviour changes are how you get it.

@Mehdalon

Copy link
Copy Markdown

Went further on this — sanitizers, fuzzing, and two stress scenarios nobody had measured yet. Results below.

Sanitizers (TSan + ASan)

Rebuilt with -DSANITIZERS=thread and separately -DSANITIZERS=address. Ran the new unit tests (cnode_dog_mode_conn_test, cconnman_dog_mode_clamp_test), p2p_dogmode_peering.py, p2p_eviction.py, p2p_node_network_limited.py, plus p2p_add_connections.py/p2p_outbound_eviction.py/p2p_net_deadlock.py/p2p_handshake.py for broader connection-lifecycle coverage. Clean under both — no race reports, no ASan errors. The only snag was a timeout that needed --timeout-factor=6 under TSan's normal 5-15x slowdown, not a real finding.

Fuzzing (cold, no seed corpus)

tx_out (directly exercises GetDustThreshold/IsDust, the PR's core change): 1.83M runs in 8 min, clean. tx_package_eval (package/mempool validation, touched by the raised weight/cluster constants): 82.5K runs, clean, though its exec/s is low enough that deep interaction paths are only lightly explored — flagging that honestly rather than claiming thorough coverage. process_message (P2P parsing incl. the version handshake / service-flag path): 351K runs, clean.

Wallet performance with many 1-sat UTXOs

Nobody had measured what a wallet actually holding dust does. Filled one to 10,000+ 1-sat UTXOs on regtest:

UTXOs listunspent
30 (baseline) 2.1ms
1,030 66.9ms
10,030 1013.5ms

Linear scaling, ~0.1ms/UTXO. getbalances/getwalletinfo stay flat (cached, don't scan the UTXO set). Coin selection correctly excluded all 10,000 dust UTXOs when spending — 1 clean coinbase input selected, zero dust pulled in, so the effective-value filter is doing its job. Rescan (full chain): ~1.1s. Wallet load: ~925ms.

None of this is catastrophic, but a 1-second listunspent at 10k dust UTXOs is a real, previously-unmeasured cost of the dust change, worth a release-note mention alongside the chainstate-cost numbers already discussed.

Mixed-size mempool at the -maxmempool=5 floor

This one actually changes the picture on point raised earlier in this thread. tialkan's measurement used uniform 975kvB transactions and found mempoolminfee jumping ~13x after 4-5 evictions. Reran the same scenario with a realistic mixed-size load instead — 85% small transactions (~110-150vB), 15% larger (~400-700vB), varied feerates — same -maxmempool=5 floor:

The minfee rose smoothly, 3-8% per ~125-transaction step, and needed ~2,000+ evictions over 92 seconds to reach a comparable ~52x cumulative rise, versus tialkan's 4-5 evictions for ~13x. So the sharp, discrete-jump eviction noise looks specific to the synthetic all-max-size scenario — under realistic mixed traffic it doesn't reproduce the same way, because evicting one small transaction out of thousands barely moves the floor. Caveat: I stopped collecting data while minfee was still climbing, so I don't have a steady-state value, just the shape of the curve.

Net: this doesn't reopen the maxmempool-floor question, but it does narrow it — the concern is real under an adversarial fill pattern and much less visible under organic traffic.

Build/test scripts and full logs available if useful — happy to share. Verification done with Claude Code.

@DogModeMillionaire

Copy link
Copy Markdown
Author

Everything here is more than reasonable. Further optimizations should be placed into a followup commit.

@DogModeMillionaire

Copy link
Copy Markdown
Author

Are there any remaining objections to a merge?

@tialkan

tialkan commented Aug 31, 2026

Copy link
Copy Markdown

The mixed-size mempool run is the right control. The sharp minfee jumps I reported are the all-max-size fill, not organic traffic. That still matters for someone sitting on -maxmempool=5 under an adversarial pattern, and it belongs in the notes, but it is not a merge blocker.

The wallet listunspent numbers fit the same ledger as the chainstate cost: real, linear, not catastrophic. One line next to the ~55 B/UTXO figure is enough.

No remaining objection to merge from me.

@kryptokat2022

Copy link
Copy Markdown

reviewed the notes / will test when binaries drop

@tialkan

tialkan commented Sep 1, 2026

Copy link
Copy Markdown

Are there any remaining objections to a merge?

One, and it is mine to raise because it is mine to correct: 1 << 28 was my suggestion in #2, and it is not free.

I ran into it building a node-adoption chart, working out how a node on this branch would be identifiable on the network at all — which is its own finding, at the bottom. Surveying the bit space to answer that turned up bit 28 already declared, by Knots, for something considerably more awkward than another relay policy.

Bit 28 is NODE_BLAKE2B in Bitcoin Knots

Declared in src/protocol.h at v29.4.1.knots20260508rc4:

// NODE_BLAKE2B means the node enforces the BLAKE2b hardfork rules
// (BLAKE2b proof-of-work and ReducedData) as applicable
NODE_BLAKE2B = (1 << 28),

It is in neither Knots master nor 29.x-knots — it arrived with the 29.4.1 release candidates, which is why reading master does not show it. I did read master, concluded the bit was undocumented, and was about to say so.

What the network shows

Decomposed the services field of every node in one bitnodes crawl (26,385 nodes with a services field, timestamp: 1788259450). Occupancy of the top of the range:

bit nodes declared as dominant user agent
24 0 NODE_UTREEXO_TMP (Knots) —
26 406 NODE_REPLACE_BY_FEE (Knots) /Satoshi:28.1.0/Knots:20250305/ (218)
27 1,622 NODE_REDUCED_DATA (Knots) /Satoshi:29.3.0/Knots:20260508/ (783)
28 350 NODE_BLAKE2B (Knots) /Satoshi:29.4.1/Knots:20260508rc4/ (304)
29 32 NODE_LIBRE_RELAY / NODE_MALICIOUS (Knots) /Satoshi:30.2.0/ (24)

348 of the 350 report Knots 29.4.1/20260508rc4 or rc5; the remaining 2 report /Satoshi:29.1.0/, which I cannot account for. And it is unconditional on those builds rather than opt-in:

Knots build nodes setting bit 28
20260508 (current release) 1,473 0
29.4.1/20260508rc4 318 318 (100%)
29.4.1/20260508rc5 30 30 (100%)

So 350 is a release-candidate population. Knots is 4,662 reachable nodes on this crawl, so when 29.4.1 ships, occupancy of bit 28 grows by roughly an order of magnitude on its own.

Unoccupied on this crawl: bits 1, 5, 7–9, 14–24, 30, 31.

Reproducible from the raw snapshot; index 1 of each node record is the user agent, index 3 the services bitfield:

import json, urllib.request
from collections import Counter

req = urllib.request.Request(
    "https://bitnodes.io/api/v1/snapshots/latest/",
    headers={"user-agent": "service-bit-survey/1.0"},  # bitnodes 403s the urllib default
)
snap = json.load(urllib.request.urlopen(req, timeout=60))

BIT = 28
seen = Counter()
for v in snap["nodes"].values():
    if isinstance(v, list) and len(v) > 3 and isinstance(v[3], int) and v[3] >> BIT & 1:
        seen[v[1] or "(none)"] += 1

print(f"bit {BIT}: {sum(seen.values())} nodes")
for agent, n in seen.most_common(5):
    print(f"{n:6d}  {agent}")

Why this is worse than the bit 29 case

In #2 I argued against reusing Libre Relay's bit because the two policy sets differ. That argument applies here with more force, because the collision is not with another relay policy — it is with a hardfork signal.

  1. The handshake check cannot help. This PR disconnects a dog peer that does not advertise the flag. These nodes do advertise it. They pass.
  2. The semantics invert the project's central claim. DOG Mode's whole position is that it changes no consensus rule and cannot split the chain. On this bit, a node built from this branch is indistinguishable from a node announcing that it enforces a BLAKE2b proof-of-work hardfork — and to a Knots peer reading the bit, that is what it is saying. I would not want to have to defend that sentence, and I do not think this PR should have to.
  3. The peers are useless in the ordinary sense too. They are Knots, the client whose relay policy sits furthest in the opposite direction from this one. A peer occupying a dog slot is not merely unhelpful for propagating a 3.9M WU transaction — it is guaranteed to reject it.
  4. The slots fill wrong from day one. 350 nodes on bit 28, zero running this branch. A node's four reserved outbound slots would go to Knots nodes essentially every time, and that worsens as 29.4.1 rolls out. The mechanism arrives inverted rather than degrading gradually.
  5. It compounds with the priority ordering @Mehdalon found. Six higher-priority tiers ahead of the dogmode branch is already thin; spending what gets through on peers that cannot relay for you leaves close to nothing.

There is also no second signal to fall back on. This PR does not change CLIENT_NAME or the user agent — a node built from it announces itself the way Core does — so the service bit is the only way a DOG Mode node is identifiable, by a peer or by anyone counting adoption.

Suggestion

Take one from 14–23 rather than the 24–31 neighbourhood. Knots occupies 24, 26, 27 and 28 there and labels 29 NODE_MALICIOUS; the low range is empty by a wide margin and nothing is near it.

Whichever bit is chosen, check it two ways before release: read the flag declarations in the clients that are actually deployed — including their release branches, not just master — and count the live network. I did neither properly when I suggested 28 in #2. I read Libre Relay's patch and picked an adjacent number, which is exactly the "inheriting by copying a patch" I was warning against three paragraphs earlier in the same comment.

Caveats, as with my earlier measurements: bitnodes sees listening nodes only, this is a single crawl, and the counts move. The declaration in Knots' source does not move, and the occupancy is not marginal enough for the counts to change the conclusion.


Flagging the provenance rather than leaving you to find it: this came out of the node page on bitcoindogmode.com, which counts client adoption from the same crawl. That page will show the bit 28 figure beside what those nodes report themselves as running, and will not fold it into a DOG Mode total — 350 nodes attributed to a client with no binary would be wrong in the direction that flatters us. Happy to word it differently if you think it reads unfairly against this PR.

Bit 28 is declared as NODE_BLAKE2B by Bitcoin Knots in the v29.4.1
release candidates: a BLAKE2b proof-of-work hardfork signal, set
unconditionally on those builds and included in their
SeedsServiceFlags().  Mode nodes on bit 28 would be
indistinguishable from hardfork-signalling Knots nodes, and the
reserved dog outbound slots would fill with Knots peers that reject
3.9M WU transactions.

Bit 14 is declared by no deployed client (checked Core, Knots master
and v29.4.1 rc4, Libre Relay v30.2) and unadvertised on the live
network. It deliberately sits outside the 24-31 experimental range,
which Knots is actively occupying (bits 24, 26-29).
@DogModeMillionaire

Copy link
Copy Markdown
Author

Confirmed and fixed in 76ae6e2 — thank you for catching this, and for making it reproducible.

Verification (independent of your crawl):

  • NODE_BLAKE2B = (1 << 28) confirmed in Knots v29.4.1.knots20260508rc4 src/protocol.h — and it's in that build's SeedsServiceFlags(), so Knots' seeding actively treats bit-28 nodes as hardfork nodes. Absent from Knots master, exactly as you described; the release-candidate-only arrival is how this slipped through.
  • Your network numbers reproduce. A fresh crawl (26,877 nodes) taken ~10 h after yours shows bit 28 occupancy grew 350 → 398; 100% of rc4/rc5 nodes set it, 0% of current-release Knots do. Also found a third non-RC claimant, /GrayersDad:08.30.2026=Blake-2b/ — bit 28 is already the de-facto BLAKE2b-hardfork bit, set by more than one piece of software.
  • Bits 14–23 and 30–31 at zero occupancy, matching your survey.

Fix: NODE_DOG_MODE moved to bit 14. Chosen over 30/31 (the last two free bits in the 24–31 range Knots is actively occupying — highest forward-collision risk) and over 32–63 (untested territory for crawlers/seeds/parsers). Collision audit for bit 14, all from source: Core (bits 0, 2, 3, 6, 10, 11), Knots master + 29.4.1rc4 (12, 13, 24, 26–29), Libre Relay v30.2 (29 only), live network (0 nodes on 14). The protocol.h comment records the reasoning, including the deliberate step outside the 24–31 experimental range.

Change surface was three lines (src/protocol.h, test_framework/messages.py, release notes) — everything else keys on the symbol. Rebuilt; net_tests, p2p_dogmode_peering.py and p2p_node_network_limited.py all pass.

Process fix adopted from your comment: before each release, re-check flag declarations in deployed clients' release branches/tags (not just master) and re-count the live network. The bit-28 population moving 13% in 10 hours is exhibit A for why the second check can't be a one-time thing.

On the bitcoindogmode.com node page: attributing the bit-28 population to Knots rather than folding it into a Mode total reads fairly to me — that's what those nodes are actually running.

@dogofbitcoin

dogofbitcoin commented Sep 1, 2026 •

Copy link
Copy Markdown

Not a code review, the ground above is well covered. One point is still open in the head and
it is the one a runes project is placed to speak to: the OP_RETURN size.

What the head does. MAX_OP_RETURN_RELAY stays MAX_STANDARD_TX_WEIGHT / WITNESS_SCALE_FACTOR
(upstream v31.1 src/policy/policy.h line 83), so raising the weight to 3,900,000 lifts the
default -datacarriersize from 100,000 to 975,000 vbytes. cre8tor-devops asked for a literal
100,000; that point has not been answered.

Two facts.

  1. Runes do not need any of it. The $DOG etching runestone
    (e79134080a83fe3e0e06ed6990c5a9b63b362313341745707a2bff7d788a1375, output 2) is a 36-byte
    OP_RETURN script. A transfer runestone with one edict is on the order of 15 bytes and each
    further edict adds single digits to low tens of bytes. Core's 100,000 already covers thousands
    of edicts in one transaction. Nothing on the runes side changes at 975,000, in either
    direction. Nor is this a chainstate question: OP_RETURN outputs are provably unspendable and
    never enter the UTXO set, so the cost of a large one is block space, not the permanent
    per-node cost the 1-sat outputs carry.

  2. The coupling is upstream's design, not an accident of this PR. Core 30.0's release notes on
    policy: uncap datacarrier by default bitcoin/bitcoin#32406: "-datacarriersize is increased to 100,000 by default, which effectively uncaps the
    limit (as the maximum transaction size limit will be hit first)." Under that reading a client
    that raises the standard transaction size raises the data carrier with it, on purpose. Pinning
    at 100,000 would be Dog Mode adding a separate OP_RETURN cap that Core deliberately removed,
    and carrying that delta on every rebase.

So the question is not whether runes need it (they do not) and not whether Core forbids it
(Core's stated intent is the opposite). It is whether Dog Mode wants "data carrier bounded only
by the standard transaction size" as one of its named policies. Either answer is defensible.
What is not defensible is deciding it by macro and mentioning it in a footnote of the release
notes.

The ask, whichever way it goes: make it a headline line in the README and the release notes,
next to the weight line and the dust line, with the number in it. An operator who reads "3.9M WU
standard transactions and 1-sat dust" should not learn about a 975 kB OP_RETURN default from
getmempoolinfo. If the project would rather keep Core's 100,000 for now, say that as a choice
too, and cre8tor's literal is the right shape for it.

From our side: our node will run whatever ships. We are neutral on the number and firm
on it being said out loud.

@leonidaskingdev

Copy link
Copy Markdown

ack 76ae6e2

@leonidaskingdev
leonidaskingdev merged commit 7503240 into bitcoindogmode:31.1-dogmode Sep 2, 2026
26 checks passed
@tialkan

tialkan commented Sep 7, 2026

Copy link
Copy Markdown

Since this merged, a follow-up rather than a review — two lines I measured and you accepted here on 28 August ("will add to release notes") haven't landed in doc/release-notes-dogmode-policy.md yet, and @dogofbitcoin's OP_RETURN comment sent me back to check. Re-verified against the merge commit today (2026-09-08).

One correction first: the datacarrier line @dogofbitcoin's ask implied was missing is already in the release notes, with the number in it —

Since MAX_OP_RETURN_RELAY is defined as one quarter of the maximum standard transaction weight, the default -datacarriersize rises from 100,000 to 975,000 bytes.

So that half was already done. The gap is the README, which still opens with "Website: Coming Soon" and says nothing about the weight, the dust limit or the data carrier — an operator reading it today learns none of the three things that actually shipped.

The other two are the ones you accepted and that haven't landed:

  • Lowering the dust limit grows the UTXO set, paid for by every node rather than the party creating the outputs. Measured steady-state marginal chainstate cost per additional UTXO: ~55 bytes P2TR, 43 P2WPKH, 40 P2PKH. Effectively permanent — a P2TR key path input is 57.5 vB, so a 1 satoshi output is economic to spend only below ~0.017 sat/vB, far under the 1 sat/vB relay floor.
  • The 4 reserved outbound slots reduce inbound capacity by the same 4 at any -maxconnections (114 → 110 at the default).

Paste-ready, in the file's existing voice:

doc/release-notes-dogmode-policy.md, under Policy, after the dust bullet:

- Lowering the dust limit grows the UTXO set, and the cost is borne by every
  node rather than by the party creating the outputs. Measured steady-state
  marginal chainstate cost per additional UTXO is approximately 55 bytes for
  P2TR, 43 for P2WPKH and 40 for P2PKH. These entries are effectively
  permanent: a P2TR key path input is 57.5 vB, so a 1 satoshi output is
  economic to spend only below roughly 0.017 sat/vB, far under the 1 sat/vB
  relay floor. Operators sizing storage should treat this as growth that does
  not reverse when fees fall.

Under P2P, after the peering bullets:

- The 4 reserved outbound slots are added to the automatic outbound maximum,
  so inbound capacity falls by the same 4 at any given `-maxconnections`
  (114 -> 110 at the default). A node running this improves its own
  transaction reach by slightly reducing what it serves back to the network.

README.md, replacing "Website: Coming Soon":

Bitcoin DOG Mode is a fork of Bitcoin Core with three relay policy changes:
a maximum standard transaction weight of 3,900,000 WU instead of 400,000, a
global 1 satoshi dust limit instead of 294-546, and preferential peering
between nodes advertising `NODE_DOG_MODE` (service bit 14). Because
`MAX_OP_RETURN_RELAY` is one quarter of the standard transaction weight, the
default `-datacarriersize` rises to 975,000 bytes as a consequence.

Consensus rules are unchanged. A DOG Mode node validates blocks exactly as
Bitcoin Core does and cannot cause a chain split. See
`doc/release-notes-dogmode-policy.md` for the full list, including what the
dust change costs node operators.

Adjust or ignore the wording freely — the ask is only that the four numbers land where someone deciding whether to run this will see them, rather than staying in a merged thread. On the OP_RETURN size itself I have no position; I care that whichever default is kept is written down as a choice rather than inherited silently.

@J25dunn

J25dunn commented Sep 14, 2026 •

Copy link
Copy Markdown

Independent Linux/Qt build and regtest validation of the merged commit 75032400914250c7ad857dc29043761680a66485 completed successfully.

Environment: native linux/amd64 Ubuntu 24.04 container, GCC 13.3, Qt 6.4.2, Release build with wallet, GUI tests, QR, DBus, and external-signer support compiled in; IPC was disabled. The source checkout was mounted read-only and the build/runtime container had no network access.

Results:

  • test_bitcoin-qt: all six suites passed, zero failures or skips.
  • Remaining CTest entries: 152 passed, zero failed, one skipped. The skip was script_assets_tests because the optional external script_assets_test.json dataset was not configured.
  • Eight targeted functional invocations passed: wallet creation (normal and --usecli), wallet send, funded PSBT, PSBT RPC, dust policy, and DOG peering over both v1 and v2.
  • Rendered Qt checks passed for wallet creation, receive request and QR rendering, a normal send, unsigned PSBT load/sign/broadcast, Outbound Dog Mode peer display over v2, and wallet/transaction persistence across restart.

The receiving wallet confirmed 1.25 regtest BTC from the normal GUI send and 2 BTC from the GUI PSBT flow. No failure was found in the tested paths. This is a test report rather than a code-review ACK or release certification. Windows/macOS, hardware signers, wallet encryption/backup UI, public-network sync, the full functional suite, fuzzing, and multiprocess IPC were outside scope.

Full report, dependency recipe, exact package versions, configure log, test logs, result CSV, and binary hashes: https://gist.github.com/J25dunn/bde00e07f72f8fa45d19ad3054d02812

@dogofbitcoin

dogofbitcoin commented Sep 14, 2026 •

Copy link
Copy Markdown

Two mainnet measurements against the merge commit 75032400914250c7ad857dc29043761680a66485, for the "test on mainnet that things actually work" list. Both nodes are that commit: one built from source on Debian 13 (full, port 8333), one the Umbrel package (pruned, port 8433, behind a VPN, outbound only). Everything here can be re-read by anyone with a DOG Mode node.

1 sat output, relayed. 7a2aacd3ca2bf1b180c55d5c40254d627632ba91e2f3d956b061a6acc34b3e8a: one P2WPKH input, a 1 sat P2WPKH output plus change, 141 vB at 1 sat/vB. Sent to the full node with sendrawtransaction at 15:43:18 UTC on 2026-09-14; the Umbrel node's getmempoolentry shows it entered at 15:43:23 UTC, five seconds later, across the VPN tunnel. mempool.space answers 404 for it and still does, which is Core policy doing what it should. Any DOG Mode node runner: bitcoin-cli getmempoolentry 7a2aacd3ca2bf1b180c55d5c40254d627632ba91e2f3d956b061a6acc34b3e8a. It stays in dog mempools until we spend the coin through a Core node.

Peering, what we see after 42 hours. The full node has two inbound peers advertising bit 14 (the Umbrel above and a third party's Umbrel package on 8433, both connected since 2026-09-12) and has filled 0 of its 4 dog outbound slots. Its address manager holds 11 addresses with bit 14: its own, and 10 whose services field is 0xffffffffffffffff, every bit set. The third party's 8433 address is not in that address manager after 42 hours of direct connection. The Umbrel node, with a small address manager (3 bit-14 entries: the third party, the full node, one all-bits address), filled a dog slot to the third party's node within days and has held it 84 hours. So the reserved slots fill when the candidate pool is small and honest, and starve on a well-connected node whose bit-14 candidates are all-bits addresses. Two things worth a look: whether the dog selector should skip addresses advertising every service bit, and why an inbound dog peer's self-advertised address did not land in the address manager. Full readings, including getrawaddrman rows, in the evidence file linked from seed.dogofbitcoin.org's operator page.

The 3.9 MB test is built (a taproot script-path reveal at 3,899,868 WU, the shape that passes a node with datacarriersize=100000, which Umbrel's UI sets) and runs the day the coin for it is funded; the txid goes here the same hour.

The DNS seed now reports two numbers on seed.dogofbitcoin.org: nodes advertising bit 14 that the crawler can reach (2 today), and the subset it can answer with (1, since an A record carries no port and the reference seeder hands out full nodes on 8333 only). The seeder build is public at github.com/dogofbitcoin/bitcoin-seeder, one commit on sipa's pinned base.

@dogofbitcoin

Copy link
Copy Markdown

The second mainnet test from the 2026-09-06 list, run tonight against the merge commit 75032400914250c7ad857dc29043761680a66485. With the 1 sat test on 09-14 (7a2aacd3ca2bf1b180c55d5c40254d627632ba91e2f3d956b061a6acc34b3e8a), both of them are now done.

A 3,847,522 WU transaction, relayed from one DOG Mode node to another.
5d072e6797b1ecec273f5e9b0eb60aa5fc231f70fee86d3a0d52e4184ee3c29a: one input, 961,881 vB, 96,189 sats at 0.100 sat/vB. That is 98.7 percent of the 3,900,000 limit and 9.6 times what Bitcoin Core will relay. Sent to the full node (built from source, Debian 13, port 8333) with sendrawtransaction. Each node's own getmempoolentry gives the timing rather than our poll: 23:14:21 UTC on the full node, 23:14:25 UTC on the second one, so 3.85 MB crossed in four seconds. The second node is the Umbrel package: pruned, port 8433, no inbound, everything through a VPN.

The control is the pair, not the transaction alone. Its parent commit b7edb847dfa0a150f70874096d8ea70c6b13958f777ea8e84d77063d7f09c0d4 is 153 vB and entirely ordinary, and mempool.space answers 200 for it: it is in the public mempool, relayed by Core-policy nodes everywhere. The child that spends it answers 404 there, and is in both DOG Mode mempools. Same parent, one policy apart.

What it carries. A real ordinals envelope, video/mp4, 3,824,965 bytes. We pulled the raw transaction back out of each node's own mempool with getrawtransaction and read the payload out of the witness: sha256 b3c9e3d8cb88dad1407dd43da8c5b30f114f42112eec1753589ff376b39ec937 from both nodes, equal to the file that went in. So the bytes survive the relay whole, not just the transaction.

Anyone running a DOG Mode node can check it on their own machine while it is in mempools:

bitcoin-cli getmempoolentry 5d072e6797b1ecec273f5e9b0eb60aa5fc231f70fee86d3a0d52e4184ee3c29a

The 96,189 sat fee is only paid if a miner includes it, so the coin will be spent back through a Core node shortly and every dog mempool will drop the test when that confirms. The txid, both hashes and the readings are recorded either way.

Two things worth passing on to anyone else testing this policy, both of which cost us a run to find:

  1. A 3.9 MB transaction is 7.7 MB of hex, and execve refuses any single argument over 128 KB. It has to reach the node over stdin (bitcoin-cli -stdin) or in an RPC body; as a command line argument it fails before the node ever sees it.
  2. If you build the reveal with a Python signing library, check how it writes spend_type. Ours takes the tapscript flag as a separate argument that defaults to off, so passing the leaf script alone produced a message it signed and verified happily while every node answered mempool-script-verify-flag-failed (Invalid Schnorr signature). An offline check that only compares a library with itself will not catch that; the node is the only authority.

One correction we owe, from 09-07. @tialkan pointed out that our OP_RETURN comment implied the datacarrier line was missing from the release notes when it was already there with the number in it. He was right, and we should have said so then: the release notes carry that line. The README gap he named in the same comment is the one still open.

@dogofbitcoin

Copy link
Copy Markdown

One measurement before a tag, with a fix offered.

With any dog slot empty, the merged tip (7503240091) opens no feeler and no network-specific connection. On our node, with its 4 dog slots empty, 15 min 42 s of net debug showed 0 feelers where about 8 were due. The dog choice sits above those two in ThreadOpenConnections and has no timer of its own, and on today's network the slots stay empty.

#10 moves it below them, with a regtest test that fails on 7503240091 and passes with the change. It is independent of #7, and yours to take or leave.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

9 participants