Symptom¶
The first V1 dMint mint implementation produced transactions that the Radiant mainnet covenant would have rejected, but every synthetic test passed. Specifically:
Aspect |
pyrxd built (broken) |
Mainnet expects (per |
|---|---|---|
Inputs |
1 (contract only) |
2 (contract + plain-RXD funding) |
Outputs |
2 (contract recreate + plain P2PKH reward) |
3–4 (contract recreate + 75-byte FT-wrapped reward + optional OP_RETURN msg + change) |
Contract output value |
decremented by |
preserved (singleton — RBG’s is 1 photon) |
Reward output script |
plain 25-byte P2PKH |
75-byte P2PKH + OP_STATESEPARATOR + OP_PUSHINPUTREF tokenRef + 12-byte covenant fingerprint |
The whole synthetic test suite was green. Two hours of code review approved the implementation. The plan’s acceptance-criteria checklist was complete. Every byte the implementation produced would have been rejected by the network on first broadcast.
Root Cause¶
Two distinct misunderstandings of the V1 covenant, neither of which the
synthetic tests could surface because both the builder and the parser
live in src/pyrxd/glyph/dmint.py. Round-trip tests of the form
assert parser(builder(x)) == x verify that pyrxd is internally
consistent with itself — they do not verify that pyrxd’s output matches
what real Radiant nodes accept.
Misunderstanding 1: contract output as value pool (V2 mental model)¶
V2 dMint contracts hold the running reward pool in the contract output
itself; each mint subtracts reward from the contract’s value, and the
fee comes out of that same pool too. I carried this mental model into
the V1 builder.
V1 contracts are singletons. The RBG-class live mainnet contracts all carry exactly 1 photon perpetually. The miner provides a separate plain-RXD funding input that pays:
The FT carrier value for the reward output (
state.rewardphotons)The transaction fee
The change back to the miner
Subtracting reward + fee from the contract output would produce a
covenant-rejected tx; in the (impossible) case it confirmed, every
mint would silently bleed the singleton’s value to dust over a few
hundred mints.
Misunderstanding 2: reward output as plain P2PKH¶
The V1 covenant’s epilogue at offset 168 is:
OP_DUP OP_CODESCRIPTHASHVALUESUM_OUTPUTS OP_ROT OP_NUMEQUALVERIFY
This sums photons across all outputs whose codescript-hash matches a
specific value (computed from OP_PUSHINPUTREF tokenRef + 12-byte fingerprint), and requires the total to equal state.reward. The
miner cannot satisfy this with a plain P2PKH reward output — there is
no FT codescript to sum.
The mainnet shape (decoded at docs/dmint-research-mainnet.md
§4 vout[1]):
76 a9 14 <miner_pkh:20> 88 ac ← 25-byte P2PKH prologue
bd ← OP_STATESEPARATOR
d0 <token_ref:36> ← OP_PUSHINPUTREF + 36-byte tokenRef
de c0 e9 aa 76 e3 78 e4 a2 69 e6 9d ← 12-byte covenant fingerprint
→ 75 bytes total
What Did NOT Catch It¶
49 unit tests (all V1 builder/parser round-trips, mining loop, error paths)
~2 hours of code review focused on type signatures and edge cases
Plan acceptance criteria which verified shape, count, and byte length but never compared bytes against captured mainnet data
The prior incident at
docs/solutions/logic-errors/dmint-v1-classifier-gap.mdwhich documented this same anti-pattern in reverse (parser missed real V1 because only V2 fixtures were tested) — should have been a yellow flag while writing the V1 builder
What Did Catch It¶
A security-sentinel + red-team review pass after the M1 implementation was committed. The red-team finding was unambiguous:
“every tx pyrxd builds for V1 minting is rejected by the network. If somehow accepted, the miner would receive plain RXD rather than FT tokens; the contract’s tokenRef accounting breaks. Pool funds also get burned to fee. Until done, the V1 path should raise NotImplementedError, not return a DmintMintResult.”
The reviewer did what the unit tests didn’t: walked the mainnet trace
in docs/dmint-research-mainnet.md §4 byte-by-byte against pyrxd’s
output.
The Fix¶
Commit a3ee46e fix(glyph): correct V1 dMint mint-tx shape + harden deploy guard + token-burn defense:
New
build_dmint_v1_ft_output_script(miner_pkh, token_ref)at src/pyrxd/glyph/dmint.py:441-469 producing the 75-byte FT shape:if len(miner_pkh) != 20: raise ValidationError(f"miner_pkh must be 20 bytes, got {len(miner_pkh)}") p2pkh_prologue = b"\x76\xa9\x14" + miner_pkh + b"\x88\xac" return ( p2pkh_prologue + _OP_STATESEPARATOR + b"\xd0" + token_ref.to_bytes() + _V1_FT_OUTPUT_EPILOGUE # bytes.fromhex("dec0e9aa76e378e4a269e69d") )
New
DmintMinerFundingUtxodataclass at src/pyrxd/glyph/dmint.py:1478 — the V1 mint path now requires a funding UTXO. Without it, raisesValidationError("V1 mint requires a funding_utxo: V1 contracts are singletons (typically 1 photon) and the FT reward + tx fee come from a separate plain-RXD input.")Rewritten
_build_dmint_v1_mint_txat src/pyrxd/glyph/dmint.py:1877 — produces the correct 3- or 4-output tx: contract recreate (value preserved) + 75-byte FT-wrapped reward + optional OP_RETURN msg + change.The load-bearing test —
TestBuildDmintV1FtOutputScript::test_byte_equal_to_mainnet_vout1asserts byte-for-byte equality against the live mainnet146a4d68…f3cvout[1] decoded in §4 of the research doc:_MAINNET_VOUT1_BYTES = bytes.fromhex( "76a914e9aa4adbe3a3f07887d67d9cedae324711f053ef88ac" # 25-byte P2PKH prologue + "bd" # OP_STATESEPARATOR + "d08b87c3c771b1a9f5015a4f26bfd80979ed196b5366257a6f30929646dfd943a400000000" + "dec0e9aa76e378e4a269e69d" ) def test_byte_equal_to_mainnet_vout1(self): script = build_dmint_v1_ft_output_script(self._MAINNET_PKH, self._MAINNET_TOKEN_REF) assert script == self._MAINNET_VOUT1_BYTES
Singleton invariant baked into
test_consecutive_mints_chain_state:assert r1.tx.outputs[0].satoshis == utxo.value— the covenant value-preservation rule the broken code violated is now a green-test gate.
Prevention¶
The rule¶
Round-trip tests through your own parser do not validate against
on-chain truth. When builder and parser live in the same module and
are tested only against each other (assert parser(builder(x)) == x),
both can harbor coordinated bugs invisible to the test suite.
The required test pattern¶
For every protocol output that lands on-chain, ship at least one test that asserts byte-equality against captured mainnet bytes. The test must:
Cite the source transaction (txid, vout index, research doc reference)
Use real hex captured directly from chain queries or research documents
Run first, not last — the golden-vector test is the first check on a new builder, not a “polish” addition
Fail loudly — a golden-vector test failure is an on-chain conformance regression, not a harness glitch
Where this applies in pyrxd specifically¶
✅ V1 dMint mint reward output — covered by
test_byte_equal_to_mainnet_vout1⚠️ V2 dMint deploy contract bytes — no mainnet instance exists; gold vectors unavailable until a V2 contract appears on-chain
⚠️ V2 mint tx outputs — capture bytes immediately when the first V2 mint lands
⚠️ FT transfer output shapes — should have golden vectors
⚠️ Glyph NFT mint reveals — should have golden vectors
⚠️ Gravity covenant outputs — should have golden vectors
Any new wire-format builder added to pyrxd going forward must include a golden-vector test as part of its acceptance.
Code-review checklist for builders¶
Flag and demand a golden-vector test if you see:
A round-trip test of the form
assert parser(builder(x)) == xwithout a parallel test against captured bytesTests that round-trip pyrxd → pyrxd with no external ground truth
Builder + parser landing in the same PR with no real-chain cross-check
Test fixtures named only with synthetic prefixes (no
_MAINNET_,_CHAIN_, or specific txid references)
The Compounding Lesson¶
This is the second time this exact anti-pattern bit pyrxd’s dMint implementation:
Incident |
Direction |
Caught by |
|---|---|---|
|
Parser missed real V1 — only V2 fixtures tested |
Live RBG inspection on mainnet |
This incident (commit |
Builder produced wrong V1 shape — only round-trips through pyrxd’s own parser tested |
Manual security review forced byte-by-byte comparison |
Both were coordinated bugs: the test fixtures matched the buggy code exactly because they were authored together. Without external ground truth, no test can distinguish “internally consistent” from “actually correct.”
Treat this as a recurring failure mode, not a one-off lesson. The
correction baked into pyrxd’s test layer (the
test_byte_equal_to_mainnet_* pattern) is the durable guard. Use it.
References¶
Fix commit:
a3ee46e fix(glyph): correct V1 dMint mint-tx shape + harden deploy guard + token-burn defenseFollow-up:
1a8d712 fix(glyph): opcode-aware funding scan + OP_RETURN msg marker + V2 default regression testMainnet trace:
docs/dmint-research-mainnet.md§4Prior incident:
docs/solutions/logic-errors/dmint-v1-classifier-gap.mdPlan:
docs/plans/2026-05-07-feat-dmint-v1-mint-and-reference-miner-plan.md