feat: dMint V1 deploy support (M2)¶
Enhancement Summary¶
Deepened on: 2026-05-08 (two review passes) Round 1 reviewers: security-sentinel, code-simplicity-reviewer, pattern-recognition-specialist, learnings-researcher Round 2 reviewers (technical_review): kieran-python-reviewer, architecture-strategist
Round-2 critical bugs caught (would have shipped broken)¶
Bare-alias deprecation doesn’t warn. Original draft said
DmintFullDeployParams = DmintV2DeployParams— but bare aliasing is just a name binding; noDeprecationWarningfires on construction, and the acceptance criterion “legacy alias raises DeprecationWarning” would have been unverifiable. Fixed: subclass with__init__warner.Same method name with different arity. Both result types had
build_reveal_scripts(commit_txid, ...)but V1’s takes 1 arg, V2’s takes 3. Polymorphic call-sites would TypeError at runtime. Fixed: V1 renamed tobuild_reveal_outputs(commit_txid), different signatures get different names.Missing result-type deprecation alias. Plan only kept
DmintFullDeployParamsas alias;DmintDeployResultrename had no alias, soisinstance(result, DmintDeployResult)callers would break silently. Fixed: both renames now have aliases.
Round-2 Pythonic improvements¶
@overloadstubs onprepare_dmint_deploy— gives static call- site type narrowing without runtimeisinstancechecks.Result dataclasses are
@dataclass(frozen=True)withtuple[bytes, ...]notlist[bytes]— matches M1 precedent (DmintMineResult,DmintState,DmintContractUtxo).New
DmintV1RevealScriptsfrozen dataclass for V1’s reveal- builder return type — mirrorsFtDeployRevealScriptsshape.Dispatcher uses
match+typing.assert_neverinstead ofisinstancechain — exhaustiveness checked by mypy strict.find_dmint_contract_utxostypes itsclientparam underTYPE_CHECKING— breaks theAny-propagation chain from M1.
Round-2 architectural improvements¶
Acceptance criteria split into “PR-merge criteria” (synthetic + VPS) and “operational ship-it criteria” (real mainnet deploy). The mainnet gate is post-merge; bugs found there fix in M2.1.
find_dmint_contract_utxoscarved out as parallelizable with Phase 2a — its inputs don’t depend on Phase 2a research.“M2.5” framing dropped; joint NFT+FT V1 deploy filed as deferred work without a milestone number.
V1 reveal sighash input value source clarified (FT commit value from caller’s stored
commit_value; ref-seeds always 1 photon).script_hash_for_scripthelper inconsistency in files-to-modify list resolved — codescript-hash computed inline.
Round-1 critical fix from pattern-recognition¶
The original plan cited build_dmint_mint_tx as precedent for “single
public function with version dispatch via version kwarg.” That
citation was wrong. build_dmint_mint_tx dispatches on
state.is_v1 (auto-detection from data), not a version kwarg. The
real pyrxd convention is auto-detect on params shape.
This cascades: split DmintFullDeployParams into sibling
DmintV1DeployParams and DmintV2DeployParams dataclasses; rename
the existing DmintDeployResult to DmintV2DeployResult and add
DmintV1DeployResult as its sibling; prepare_dmint_deploy
dispatches on the params type, no version kwarg. This is the same
architectural recommendation the M1 architect-strategist made about
the V1-only funding_utxo field — applied consistently in M2 instead
of repeating the smell.
Round-1 other findings applied¶
Security S1 — V2 self-test inventory step added to Phase 2b.1 to prevent silent V1 downgrade after default flip.
Security S2 —
find_dmint_contract_utxosmirrors M1 round-4’stx.txid() == u.tx_hashdefense; verifies returned UTXO’s actual script byte-equals the expected codescript.Security S3 —
num_contractscap enforced as a construction- time validator on the dataclass, not runtime in the dispatcher.Security S4 — Multi-input signing must be atomic (build-then- sign-then-attach in three passes, never mid-loop attach).
Simplicity — Phase 2c “Acceptance Gating” deleted (duplicates Acceptance Criteria);
script_hash_for_scripthelper inlined;num_contractscap raised to 250 (real standardness ceiling) with citation; Phase 2b.4 OR-decision resolved to “example helper, no public signing module”; doc rewrite deferred to sibling PR.Learnings — Mainnet-not-synthetic golden vectors clause; opcode-walker guidance for the Phase 2a.0 “snk” classification walk; hypothesis property test for dispatcher; Photonic-divergence section in research doc when mainnet disagrees with source.
Findings deferred to coding-time¶
Singular
find_dmint_contract_utxo(client, *, contract_ref)wrapper alongside the plural — promote if a third caller materializesWhether to ship a
pyrxd.transaction.signingmodule — wait until a third caller needs multi-input P2PKH signingStronger token-name guardrails for the mainnet acceptance gate (ticker prefix, README note) — implementation-time decision
Overview¶
Make pyrxd capable of issuing fresh V1 dMint FT tokens that the rest of the Radiant ecosystem (glyph-miner, RXinDexer, Photonic explorer) recognizes. After this milestone, a developer can deploy a multi-contract V1 dMint FT token, find its live contracts on-chain, and feed them to the M1 mint flow without manually pasting outpoints.
Scope: FT-only. Joint NFT+FT V1 deploys (which the mainnet “snk” deploy may turn out to be; see Phase 2a.0) are out of scope and filed as deferred work (“Joint NFT+FT V1 deploy”) to be brainstormed separately if real demand surfaces. Deliberately not numbered as “M2.5” because that framing would imply scheduled work; this is a ticket-stub, not a milestone.
M1 (V1 mint) already ships in PR #65
with public helpers M2 reuses unchanged: find_dmint_funding_utxo,
is_token_bearing_script, build_dmint_v1_state_script,
build_dmint_v1_code_script, build_dmint_v1_contract_script,
build_dmint_v1_mint_preimage, the _V1_ALGO_BYTE_TO_ENUM mapping,
the DmintError hierarchy, and DmintCborPayload (which already
omits the daa key when daa_mode == FIXED — so it produces V1-
correct CBOR as-is, no new class needed).
Problem Statement¶
Three concrete gaps block deploying a usable dMint token via pyrxd:
prepare_dmint_deployonly emits V2 contracts and refuses to run unless the caller passesallow_v2_deploy=True. No live mainnet contracts are V2; no ecosystem miner targets V2; indexer behavior on V2 is empirically unknown. The function is effectively a no-op for users wanting to deploy a usable token.No way to find live contract UTXOs at the chain. The M1 mint demo (
examples/dmint_claim_demo.py) requires the user to look up the contract outpoint manually via a block explorer. Every M1+ caller (mint, deploy verification, future tooling) needs this primitive.The Photonic Wallet V1 deploy bytes are not yet captured locally. Per
docs/dmint-research-mainnet.md§5: “Deploy reveal TX not yet isolated.” pyrxd cannot byte-compare its output against on-chain truth; this is the same anti-pattern that produced four rounds of M1 review findings (seedocs/solutions/logic-errors/dmint-v1-mint-shape-mismatch.md).
Proposed Solution¶
A two-phase milestone:
Phase 2a (research, no code): close all byte-level unknowns about V1 deploy — Photonic source layout, mainnet reveal decode, reconciling the unexplained 35-output “snk” deploy commit.
Phase 2b (implementation): land the V1 deploy code with byte- equal golden vectors against Phase 2a’s findings; the chain-walking helper; and the API flip that makes V1 the default.
Technical Approach¶
Architecture¶
V1 deploy is structurally NOT parallel to V2¶
This is the load-bearing architectural finding from research that the brainstorm did not anticipate:
V1 deploy (Photonic) |
V2 deploy (existing pyrxd) |
|
|---|---|---|
Tx count |
2 (commit + reveal) |
3 (commit + reveal + deploy) |
Reveal output |
N parallel contract UTXOs at vout[0..N-1] + optional FT premine at vout[N] |
Token-ref FT UTXO only |
Contract creation |
In the reveal tx itself |
In a separate deploy tx |
Contract value |
1 photon each (singleton) |
|
Funding |
Reveal tx pays N×1 photons + premine + fee from caller-provided funding |
Deploy tx pays |
This means M2’s prepare_dmint_deploy cannot just swap the contract-
script builder. The whole tx-shape changes when V1 is selected.
Auto-dispatch on params type (corrected 2026-05-08)¶
Single public function prepare_dmint_deploy(params, *, allow_v2_deploy=False)
that dispatches on the type of params — DmintV1DeployParams
takes the V1 path, DmintV2DeployParams takes the V2 path. No
version kwarg.
This matches the established pyrxd dispatch pattern:
build_dmint_mint_txdispatches onstate.is_v1(auto-detection from data, no version kwarg) atsrc/pyrxd/glyph/dmint.py:1721DmintState.from_scripttries V2 then V1 (auto-detection) atsrc/pyrxd/glyph/dmint.py:1170
Sibling params + result dataclasses¶
Split DmintFullDeployParams into two unrelated dataclasses:
Type |
Used by |
V1-only fields |
V2-only fields |
|---|---|---|---|
|
V1 path |
|
— |
|
V2 path |
— |
|
Shared fields (metadata, owner_pkh, max_height, reward_photons,
difficulty, premine_amount, contract_ref_placeholder,
token_ref_placeholder, algo) live on both. No version-conditional
fields on a shared parent — each dataclass is honest about what it
accepts.
Same split for results: rename existing DmintDeployResult to
DmintV2DeployResult; add DmintV1DeployResult as its sibling. Both
are @dataclass(frozen=True) (matches DmintMineResult /
DmintState / DmintContractUtxo precedent at dmint.py:724,1130,1490).
Their deferred-builder methods have distinct names because
their signatures differ — V1’s build_reveal_outputs(commit_txid)
(V1’s reveal directly creates contract outputs) and V2’s existing
build_reveal_scripts(commit_txid, commit_vout, commit_value). Same
method name with different arity would TypeError on polymorphic
call-sites; better to be honest with two names.
Default-flip semantics¶
Before M2: prepare_dmint_deploy(DmintFullDeployParams(...)) raises
DmintError unless allow_v2_deploy=True.
After M2:
prepare_dmint_deploy(DmintV1DeployParams(...))→ V1 path, succeedsprepare_dmint_deploy(DmintV2DeployParams(...))→ V2 path, requiresallow_v2_deploy=True, raisesDmintErrorotherwiseprepare_dmint_deploy(DmintFullDeployParams(...))(legacy alias) → kept as aDeprecationWarning-emitting alias for one release; alias points atDmintV2DeployParams. The alias removes after M2.1.
The “default flip” is therefore implicit in the params type the
caller constructs — there’s no behavior change for an existing caller
who keeps calling with the old DmintFullDeployParams shape; they
get a deprecation warning and unchanged V2 semantics. New callers
construct DmintV1DeployParams to opt into V1.
Wire-format constraints from ecosystem interop¶
Three hard constraints from cross-tool research:
Contracts MUST live at consecutive vouts of the reveal tx (vout[0..N-1]). glyph-miner discovers parallel contracts by incrementing vout from
firstRef; non-consecutive layout silently undercounts (glyph-minersrc/deployments.ts:207-219).CBOR
pmust equal[1, 4](FT + DMINT markers); CBORvfield must be omitted for V1 (emittingv: 2would mis- classify as V2 in glyph-miner). Display fieldsticker,name, optionalicon/mainare required for explorer rendering.Per-contract
contractRef[i] = LE-reversed (commit_txid, 1+i)— the LE-reversed outpoint of the i-th ref-seed P2PKH in the commit tx. All N contracts sharetokenRef = LE-reversed (commit_txid, 0).
Implementation Phases¶
Phase 2a: Research (no code; finishes when exit criteria met)¶
Phase 2a.0 — Reconcile the “snk” deploy discrepancy. Research
flagged that mainnet RBG-class deploy commit a443d9df…878b has 35
outputs with two hashlock commits at vouts 0 and 33, while Photonic
source suggests 1 + N outputs (FT-commit + N ref-seeds). Possibilities:
joint NFT+FT deploy (vout 0 = FT commit, vout 33 = NFT commit?), an
older Photonic version, or misread research. Reconcile before
encoding any commit-tx logic.
Method: walk the commit’s tx outputs in detail; classify each by script shape. Use opcode-aware classification, not byte-substring scans (per the funding-utxo-byte-scan-dos.md lesson — naive matchers misclassify P2PKH addresses whose hash bytes happen to contain marker opcodes). For each output, check opcode sequence:
Hashlock commit:
0x76 0xa9 0x14 <pkh-20> 0x88 0xac 0x6a <gly-magic-push>(FT) or NFT variantP2PKH ref-seed:
0x76 0xa9 0x14 <pkh-20> 0x88 0xacexactlyAnything else: flag for inspection
If two outputs are hashlock commits and 33 are ref-seed P2PKHs, that’s a 33-contract deploy. If one is FT-commit, one is NFT-commit, and 33 are ref-seeds, that’s a joint NFT+FT deploy and our V1-FT-only path is a simpler subset of the mainnet sample.
Phase 2a.1 — Walk the deploy reveal on-chain. From the commit
txid, use client.get_history(commit_txid_scripthash) or directly
look at txs that spend the commit’s vouts to find the reveal. Fetch
its raw bytes. Decode byte-by-byte:
vout count
Each output script: classify (contract, FT premine, P2PKH change, OP_RETURN)
Reveal scriptSigs (especially vout[0] of commit which carries the CBOR payload push)
Decode the CBOR payload — record the exact field set Photonic emits
Save findings in
docs/dmint-research-photonic-deploy.md(new file) with hex fixtures suitable for byte-equal assertions.
Phase 2a.2 — Confirm Photonic source where source is canonical.
Re-clone or reach the Photonic Wallet repo if /tmp/photonic-wallet/
isn’t available; specifically read:
packages/lib/src/mint.ts—createCommitOutputs,createRevealOutputs,revealDirectfor V1 deploy pathspackages/lib/src/script.ts—dMintScriptfor V1 contract output byte constructionpackages/lib/src/types.ts:68-78— the literalDmintPayloadtype to settle the CBOR-shape questionCross-check Photonic source against Phase 2a.1’s mainnet decode. Disagreements get resolved in favor of mainnet (live nodes are the ground truth). Each disagreement must be recorded in a “Photonic Divergence” section of the research doc, naming which Photonic file/line and value differ from mainnet, and the reason pyrxd will prefer mainnet. This prevents future review rounds from re-litigating the same discrepancies — the project convention is to treat Photonic as the default reference but deviate explicitly, with a documented reason, when Photonic isn’t the best answer.
Phase 2a exit criteria (Phase 2a is done when ALL are met):
[x] “snk” 35-output discrepancy reconciled with documented explanation (see
docs/dmint-research-photonic-deploy.md§2: 1 FT-commit + 32 ref-seeds + 1 NFT-commit + 1 change)[x] At least one mainnet V1 deploy reveal saved as a hex fixture (
b965b32d…9dd6reveal raw +a443d9df…878bcommit raw — saved at/tmp/dmint-m2-research/{commit,reveal}_raw.hex; will be moved into the repo as a fixture during Phase 2b)[x] Commit-tx output layout documented byte-for-byte (vout count, ordering, ref-seed P2PKH structure, value of each output) — research doc §2
[x] Reveal-tx output layout documented byte-for-byte (vout count, contract output positions, FT premine if any, OP_RETURN if any) — research doc §3
[x] Photonic’s V1 CBOR
dmint:{...}payload shape confirmed (which fields populated, which omitted) — research doc §4:p:[1,4],ticker,name,desc,by,main. Nodmint:{...}field exists — dMint params live in the contract output scripts, not the CBOR.[x] Per-contract
contractRefderivation rule confirmed against Photonic source AND a mainnet decode —contractRef[i] = LE-reversed(commit_txid, vout=i+1), all 32 contracts in the GLYPH reveal verified[x] “Photonic Divergence” section exists in the research doc — §7 documents 5 divergences (V1 contract output shape, premine, delegate-ref, algo+DAA, V1 vs V2 protocol vector)
Phase 2b: Implementation¶
Phase 2b.1 — Library core
Three coordinated changes in src/pyrxd/glyph/builder.py:
Split params into sibling dataclasses:
Rename existing
DmintFullDeployParams→DmintV2DeployParamsAdd new
DmintV1DeployParamswith V1-only fields:num_contracts: int = 1(range[1, 250], validated at construction time via__post_init__; 250 is the standardness ceiling for tx size at typical V1 contract bytes), optionalop_return_msg: bytes | None = NoneKeep
DmintFullDeployParamsas aDeprecationWarning-emitting subclass ofDmintV2DeployParamswhose__init__callswarnings.warn(...)before delegating tosuper().__init__(...). Bare type aliasing (DmintFullDeployParams = DmintV2DeployParams) would NOT emit any warning at construction time — the alias is just a name binding. The subclass-with-warner pattern is required for the deprecation acceptance test to pass. Removed in M2.1 (target version v0.6, ~1-2 weeks per current cadence).
Split results into sibling dataclasses:
Rename existing
DmintDeployResult→DmintV2DeployResultAdd
DmintDeployResultas aDeprecationWarning-emitting subclass alias for the same one-release deprecation window (parallels the params-side alias; both renames need both sides)Add new
DmintV1DeployResult(@dataclass(frozen=True)) carrying:commit_result(CommitResult),cbor_bytes,owner_pkh,premine_amount,num_contracts,placeholder_contract_scripts: tuple[bytes, ...](immutable; matchesDmintMineResult/DmintState/DmintContractUtxofrozen-dataclass precedent atdmint.py:724,1130,1490), and abuild_reveal_outputs(commit_txid) -> DmintV1RevealScriptsdeferred-builder method.DmintV1RevealScripts(new@dataclass(frozen=True)):contract_scripts: tuple[bytes, ...](length =num_contracts),contract_value: int = 1,premine_script: bytes | None,premine_amount: int | None,op_return_script: bytes | None. MirrorsFtDeployRevealScriptsshape atbuilder.py:80-92.Method-name divergence resolved: V1 uses
build_reveal_outputs(commit_txid)(the V1 reveal directly creates the contract outputs, hence “outputs”); V2 keeps the existingbuild_reveal_scripts(commit_txid, commit_vout, commit_value). Different method names with honest signatures — better than same name with different arity (which would TypeError on polymorphic call).Also rename the existing
DmintV2DeployResult.build_reveal_scriptscallsites in test/example code if any used the polymorphicresult.build_reveal_scripts(...)form expecting V1 semantics.
Auto-dispatch in
prepare_dmint_deploy: function now takesparams: DmintV1DeployParams | DmintV2DeployParamsand uses structural pattern matching withtyping.assert_neveron the default arm:match params: case DmintV1DeployParams(): return _prepare_dmint_v1_deploy(params) case DmintV2DeployParams(): if not allow_v2_deploy: raise DmintError(...) return _prepare_dmint_v2_deploy(params) case _: assert_never(params)
Mypy strict gets exhaustiveness checking for free; “what if someone passes a third type” raises immediately. Legacy
DmintFullDeployParams(subclass ofDmintV2DeployParams) hits the second arm — V2 path with deprecation warning emitted at construction.@overloadstubs for static call-site narrowing — without them, every caller writesif isinstance(result, DmintV1DeployResult):to use V1-specific fields:@overload def prepare_dmint_deploy( params: DmintV1DeployParams, *, allow_v2_deploy: bool = ... ) -> DmintV1DeployResult: ... @overload def prepare_dmint_deploy( params: DmintV2DeployParams, *, allow_v2_deploy: bool = ... ) -> DmintV2DeployResult: ... def prepare_dmint_deploy(params, *, allow_v2_deploy=False): ...
In src/pyrxd/glyph/dmint.py:
find_dmint_contract_utxos(client, *, token_ref, initial_state=None, limit=None, min_confirmations=1) -> list[DmintContractUtxo]— public.Why dual-call-shape: Phase 2a research confirmed public ElectrumX (
electrumx.radiant4people.com:50022) exposes neitherdmint.get_contractsnor anyblockchain.ref.listunspent-style RPC. The plan’s original “compute codescript-hash inline, query directly” approach therefore only works when the caller already knows every state-item value (so the codescript can be reconstructed deterministically). Two distinct use cases need this helper:Just-deployed verification: caller has the deploy params in hand, wants to confirm all N initial contract UTXOs exist on chain. Fast: one
get_utxos(scripthash)per contract.Live-token discovery: caller has only
token_ref(e.g. the M1 mint demo wants to mine GLYPH). Slow path: walk from reveal, enumerate its contract outputs.
The function picks the path based on whether
initial_stateis supplied:Shape A — fast path (
initial_state: DmintV1ContractInitialStatesupplied): for eachi in range(initial_state.num_contracts), computecontractRef[i] = LE-reversed(commit_txid, i+1)fromtoken_ref’s txid component, build the contract codescript via M1’sbuild_dmint_v1_contract_script(...), compute its scripthash inline (hashlib.sha256(codescript).digest()[::-1].hex()), queryclient.get_utxos(scripthash), and apply S2 cross-check.Shape B — fallback (
initial_stateisNone): parsetoken_refto getcommit_txid. Fetch the commit tx; compute scripthash of itsvout[0](the FT-commit hashlock). Callclient.get_history(scripthash)— exactly two entries (commitreveal). Take the second; that’s the reveal txid. Fetch the reveal; for each output try
DmintState.from_script(script); if it parses as V1 ANDstate.token_ref == token_ref, build aDmintContractUtxo. Verify each is currently unspent via a scripthash-levelget_utxoslookup. (Skip mined-from contracts in the first cut — the spend chain walk to find current heads is filed as deferred work; the M1 mint demo only needs fresh contracts, and the fresh state is what’s directly on the reveal.)
Per security S2 (BOTH shapes): for each candidate UTXO, fetch its source tx and verify
tx.txid() == u.tx_hashANDtx.outputs[u.tx_pos].locking_script.serialize() == script. Mirrors M1 round-4’s defense infind_dmint_funding_utxo. This defends against malicious / buggy ElectrumX servers.DmintV1ContractInitialStateis a small frozen dataclass with fieldsnum_contracts: int,reward_sats: int,max_height: int,target: int— exactly the dMint params needed to rebuild a fresh-state contract script. Constructible directly or extractable via.to_initial_state()fromDmintV1DeployParams/DmintV1DeployResult.
V2 self-test inventory step (Security S1): BEFORE merging the
default-flip change, audit tests/test_dmint_end_to_end.py and
related V2 self-tests. Every prepare_dmint_deploy(...) call there
must explicitly use DmintV2DeployParams (or be migrated to
DmintV1DeployParams if that’s the test’s intent). A test passing
the legacy DmintFullDeployParams shape silently triggers the
deprecation warning and runs V2 — fine for one release, but the
inventory makes intent explicit.
Phase 2b.2 — V1 deploy commit-tx output planning
The V1 commit emits vout[0] = FT-shape Glyph commit (hash256(payload)
gly magic) +
vout[1..N]= N P2PKH ref-seeds to owner_pkh, value 1 each. Reusesbuild_commit_locking_scriptfrom existing builder infrastructure. The per-ref-seed P2PKH script:OP_DUP OP_HASH160 <owner_pkh:20> OP_EQUALVERIFY OP_CHECKSIG.
Defensive runtime assertion: _prepare_dmint_v1_deploy asserts
that the resulting reveal vout count equals
num_contracts + (1 if premine else 0) + (1 if change else 0) and
refuses to emit anything ambiguous (S7 mitigation — guards against
future joint NFT+FT support producing bytes the indexer interprets
ambiguously).
NOTE: Phase 2a.0 may reveal that the on-chain shape requires extra hashlock commits (the “snk” 35-output discrepancy). If so, this section gets revised — but the FT-only scope (Overview line) stays.
Phase 2b.3 — V1 deploy reveal-tx output planning
The reveal spends the commit’s N+1 outputs:
Input 0: vout[0] of commit (FT-shape Glyph commit) — scriptSig pushes the raw CBOR payload +
glymagicInputs 1..N: vout[1..N] of commit (P2PKH ref-seeds) — caller signs each with the owner key
Sighash input values: V1’s build_reveal_outputs(commit_txid)
does NOT take commit_value (unlike V2’s build_reveal_scripts)
because V1’s input values are derivable from protocol constants:
Input 0 (FT commit): the value at vout[0] of the commit tx, which the caller assembles deterministically (Phase 2b.2 commit-tx output planning specifies this exactly — it’s the value the caller already paid into the commit). The signing helper either looks it up from the caller’s stored
commit_valueparameter or accepts an explicit arg.Inputs 1..N (ref-seeds): always 1 photon (V1 protocol constant).
The signing helper signature is therefore
_sign_p2pkh_inputs(tx, indices, private_key, *, input_values: list[int]) —
the helper takes per-input values for sighash construction, not just
indices. This keeps the public build_reveal_outputs(commit_txid)
signature clean while making sighash input values explicit at sign-
time.
Reveal outputs:
vout[0..N-1]: N V1 contract UTXOs, value 1 photon each. Each built via
build_dmint_v1_contract_script(height=0, contract_ref=ref_seed_outpoint(i+1), token_ref=ft_commit_outpoint, max_height, reward, target, algo).vout[N] (optional): FT premine output, value
premine_amount, script viabuild_ft_locking_script(owner_pkh, token_ref).vout[N+1] (optional): change to owner_pkh, P2PKH.
Phase 2b.4 — Multi-input signing helper (atomic)
The reveal tx has N+1 inputs to sign. Add a private
_sign_p2pkh_inputs(tx, indices, private_key) helper to
examples/dmint_deploy_demo.py (mirrors M1’s _sign_p2pkh_input).
Promote to pyrxd.transaction.signing only if a third caller
materializes — single-call YAGNI for now.
Per security S4, signing must be atomic — three-pass:
Build all N preimages first
Sign all N preimages
Attach all N unlocking scripts to the tx in one final pass
If any step fails, raise before mutating the tx. A loop that builds- signs-attaches per input would leave a half-signed tx on partial failure (key access failure, OOM, etc.); atomic three-pass avoids it.
Phase 2b.5 — Demo + tests
File |
Change |
|---|---|
|
New manual demo (DRY_RUN=1 default, three-key handshake on broadcast). Modeled on |
|
New test file. Synthetic V1 deploy round-trip; byte-equal golden vectors against Phase 2a mainnet fixtures (NOT synthetic-only — see Quality Gates); multi-contract enumeration via |
|
Add V1 commit + reveal |
Phase 2b.6 — Documentation (minimal in this PR)
The full rewrite of docs/dmint-followup.md ships as a separate
sibling PR after M2 lands. M2’s code PR includes only:
File |
Change |
|---|---|
|
Add a brief “M2 closeout” section noting which deferred items got pulled forward. |
|
Updated for the new dispatch + sibling params behavior. |
|
Banner line update (still stale; full rewrite in sibling PR). |
(The sibling-PR pattern keeps the M2 code review surface focused on code, not 100+ lines of doc rewriting.)
Alternative Approaches Considered¶
Alternative 1: Implement-first (rejected)¶
Skip Phase 2a’s research, build V1 deploy from existing partial research + glyph-miner inference, validate against the chain at the VPS tier.
Why rejected: violates the M1-incident lesson. Round 1 of M1 review caught wrong V1 mint output shape; round 4 caught a fee underestimate. Both were “synthetic tests through pyrxd’s own parser passed; the spec said something different.” Phase 2a is the preventive cost; without it, M2 has the same expected number of review rounds as M1.
Alternative 2: Two separate public functions (rejected)¶
prepare_dmint_v1_deploy + prepare_dmint_v2_deploy as separate
public functions instead of one function dispatching on params type.
Why rejected: would commit pyrxd to ALSO splitting
build_dmint_mint_tx in the same milestone (since M1 reviewers
flagged its polymorphic shape and recommended a split). That bigger
refactor wasn’t in M2 scope. Auto-dispatch on params type is the
cheapest way to honor “honest signatures per version” without forcing
the mint-side split right now. The mint-side split can happen later
(or never) on its own merit.
Alternative 4: Explicit version: int kwarg (rejected during deepen-plan review)¶
Original plan draft had prepare_dmint_deploy(params, *, version=1)
with single-shared-params and a runtime version field.
Why rejected: pattern-recognition review showed pyrxd’s two prior
dispatch precedents (build_dmint_mint_tx, DmintState.from_script)
both use auto-detection on data, not version kwargs. Citing
build_dmint_mint_tx as kwarg-precedent was an outright error in the
original draft. Auto-dispatch on params type matches both prior
precedents and the architect-strategist’s M1 recommendation that V1
and V2 should not share a polymorphic params class.
Alternative 3: Defer find_dmint_contract_utxos (rejected)¶
Ship V1 deploy without the chain helper; manual outpoint lookup continues.
Why rejected: architect-strategist M1 review pulled
find_dmint_funding_utxo forward into M1 for exactly this reason —
helpers with two known consumers belong with the first one. M2’s
deploy verification + M1’s mint demo both need contract discovery.
Punting again would repeat the anti-pattern.
Acceptance Criteria¶
Functional Requirements¶
Phase 2a (must complete before any code in Phase 2b)¶
[x]
docs/dmint-research-photonic-deploy.mdexists and explains the 35-output GLYPH deploy commit shape: 1 FT-commit + 32 ref-seeds + 1 NFT-commit + 1 change (joint NFT+FT-style deploy with mint-fresh auth NFT chosen for pyrxd; forward-prior auth NFT documented as deferred)[x] At least one byte-decoded mainnet V1 deploy reveal saved as a hex fixture (
b965b32d…9dd6raw bytes saved during research; will be moved into the repo astests/fixtures/dmint_v1_deploy_*.hexduring Phase 2b)[x] V1 commit-tx output layout documented byte-for-byte (research doc §2)
[x] V1 reveal-tx output layout documented byte-for-byte (research doc §3)
[x] V1 CBOR payload shape confirmed (research doc §4):
p:[1,4],ticker,name,desc,by,main. Nodmint:{...}field; dMint params live in contract output scripts only.[x] Per-contract
contractRefderivation rule confirmed against both Photonic source AND the mainnet decode (research doc §3.3 + §5)
Phase 2b — V1 deploy library¶
[x]
DmintV1DeployParams(@dataclass(frozen=True)) public; V1-only fields (num_contracts, optionalop_return_msg); validation in__post_init__.[x]
DmintV2DeployParamsrenamed fromDmintFullDeployParams.[x]
DmintFullDeployParamsretained as a subclass-with-warner (NOT a bare alias) so construction emitsDeprecationWarning. Subclass pattern pinned byTestDeprecationAliases.test_subclass_pattern_not_bare_alias.[x]
num_contractsvalidated[1, 250]at construction time via__post_init__; out-of-range raisesValidationError. Plusmax_heightandreward_photonsvalidated against their 3-byte protocol ceilings; non-SHA256d algo rejected.[x]
prepare_dmint_deploy(params)dispatches viaisinstancewithtyping.assert_neveron default arm (mypy exhaustiveness). V1 path succeeds without opt-in; V2 path retains theallow_v2_deploy=Trueguard.[x]
@overloadstubs declare V1→V1result, V2→V2result. Plainmypyconfirmed call-site narrowing.[x]
DmintV1DeployResultandDmintV2DeployResult(renamed fromDmintDeployResult). V1 result isfrozen=Trueper theDmintMineResult/DmintState/DmintContractUtxoprecedent; carriesplaceholder_contract_scripts: tuple[bytes, ...].[x]
DmintDeployResultretained as aDeprecationWarning-emitting subclass ofDmintV2DeployResult. Both warner aliases scheduled for removal in v0.6.[x] Method-name divergence resolved:
DmintV1DeployResult.build_reveal_outputs(commit_txid)exists. Distinct from V2’sbuild_reveal_scripts(commit_txid, commit_vout, commit_value).[x]
DmintV1RevealScripts(@dataclass(frozen=True)) public:contract_scripts: tuple[bytes, ...],contract_value=1,cbor_bytes,scriptsig_suffix, optionalpremine_script,premine_amount,op_return_script.[x] V1 commit-tx FT-commit script byte-equal against GLYPH chain truth (exercised transitively by the golden-vector test below).
[x] V1 reveal contract output byte-equal against GLYPH mainnet reveal vout 0 (the entire 241-byte contract script matches —
TestV1GoldenVectorGlyphPattern::test_v1_contract_script_byte_equals_glyph_vout_0). Caught a Phase 2a research-doc field-label swap (max_height vs reward) that synthetic round-trip tests had missed.[x] CBOR payload shape pinned:
p:[1,4]enforced;vfield forbidden;dmintsub-dict forbidden (V1 stores params in scripts).[x] Pin test:
assert "v" not in cbor2.loads(result.cbor_bytes)inTestV1CborShape::test_no_v_field_in_cbor.[ ] Defensive runtime assertion:
_prepare_dmint_v1_deployrefuses to emit a reveal tx whose vout count differs fromnum_contracts + (1 if premine else 0) + (1 if change else 0)
Phase 2b — Chain helper (parallelizable with Phase 2a)¶
This helper does NOT depend on Phase 2a’s research findings — its
inputs are (token_ref, codescript-hash), its filter logic uses the
already-shipped is_v1=True parser from M1, and its security-S2
cross-check mirrors find_dmint_funding_utxo’s pattern byte-for-byte.
It can be implemented in parallel with Phase 2a research.
[x]
find_dmint_contract_utxos(client, *, token_ref: GlyphRef, initial_state=None, limit=None, min_confirmations=1) -> list[DmintContractUtxo]exists inpyrxd.glyph.dmint, public. Final signature added aninitial_statekwarg for the fast-path overload (see §2b.1 above for the dual-call-shape rationale). Theclientparam is typedAnyto match the M1 wart infind_dmint_funding_utxo— the helper uses lazy imports ofElectrumXClient-shape methods, so aif TYPE_CHECKINGprotocol would add ceremony without value here.[x]
limit is None or limit >= 1validated;limit=0rejected (avoids confusion with “no limit”)[x] Filters out non-V1 contracts in the walk path (parsed state where
is_v1=False); the fast path only emits V1 contracts by construction[x] Empty list returned when no contracts found (not an exception)
[x] Per security S2: for each candidate UTXO, verifies
tx.txid() == u.tx_hashAND verifiestx.outputs[u.tx_pos].locking_script.serialize()byte-equals the expected codescript. Mirrors the M1 round-4 defense infind_dmint_funding_utxo. RaisesCovenantErroron mismatch.[x] Hashlock-reuse disambiguation (surfaced by live-chain smoke test): the walk path filters scripthash-history candidates by “spends
commit_txid:0” because the same FT-commit hashlock can appear in multiple unrelated txs by the same deployer. Seedocs/solutions/logic-errors/dmint-deploy-reveal-hashlock-reuse.md.[x] Covered by 15 tests in
tests/test_dmint_v1_deploy.py: input validation, fast path (count, unconfirmed filter, empty, limit), walk path (success, no reveal, wrong token_ref filter, hashlock-reuse disambiguation), S2 (script mismatch, missing vout, honest server).[x] Verified live against GLYPH on public ElectrumX (returns 0 unspent fresh contracts — correct because all 32 GLYPH contracts have advanced past initial state).
Phase 2b — Multi-input signing¶
[ ] Demo’s
_sign_p2pkh_inputs(tx, indices, private_key)helper signs atomically: builds all preimages first (pass 1), signs all (pass 2), attaches all (pass 3). Raises before mutating the tx if any step fails. No mid-loop attach.
Phase 2b — Default flip + test inventory¶
[ ] V2 self-test inventory complete: every
prepare_dmint_deploycall site intests/test_dmint_end_to_end.pyand adjacent test files audited; each call uses an explicitDmintV1DeployParamsorDmintV2DeployParams. Tests passing the legacyDmintFullDeployParamsaccepted only with a comment documenting why (deprecation-warning regression test).[ ] Hypothesis property test on the dispatcher: arbitrary param- shape inputs are dispatched correctly; invalid params raise
ValidationErrororDmintError, never anything else.
(Non-Functional Requirements and Quality Gates were merged into the PR-merge criteria above to avoid duplication — single source of truth for “what gates the merge.”)
PR-merge criteria (synthetic + VPS)¶
These gate the M2 code PR merge. Must all pass before PR review can approve. Don’t require real RXD or on-chain artifacts.
[ ] All synthetic V1 deploy tests pass under
pytest -m unit[ ] Optional
pytest -m integrationpath (gated byRADIANT_INTEGRATION) pushes V1 commit + reveal via SSH to VPStestmempoolaccept, expectsallowed=truefor commit andallowed=falsefor reveal (positive control)[ ] No regressions in existing dMint test suites (V1 mint, V2 parser, V2 deploy)
[ ] All Phase 2a exit criteria met (research-doc + fixtures exist; reviewed by user)
[ ] All Phase 2b acceptance criteria met
[ ]
task cipasses locally; lint + format clean; mypy strict passes; bandit clean[ ] Code review pass (security-sentinel + red-team) catches no show-stoppers — same pattern as M1
[ ] No private symbols imported by
examples/dmint_deploy_demo.py(architect-strategist M1 pattern)
Operational ship-it criteria (post-merge, separate gate)¶
These are NOT a PR-merge blocker. The M2 code PR can merge cleanly once the PR-merge criteria above pass. The operational gate runs after merge as part of the M2.0 release validation. Wire-format bugs found here are fixed in M2.1.
[ ] One fresh V1 dMint token deployed on mainnet via pyrxd, with a token name agreed in advance and documented in the M2 release note
[ ] glyph-miner discovers the token via its
(firstRef, numContracts)enumeration[ ] glyph-miner successfully mines at least one contract
[ ] RXinDexer indexes the token (
token_type='dmint', ticker/name surfaced)[ ] Photonic Wallet explorer renders the token (deferred if Photonic-not-locally-inspectable)
Success Metrics¶
Primary (PR-merge): M2 PR-merge criteria all green; CI clean.
Primary (operational): one confirmed V1 deploy on mainnet, mineable by glyph-miner (binary outcome). Lands in M2.0 release validation, not the PR.
Secondary: synthetic V1 deploy tests stable on CI for 2+ weeks without flake.
Tertiary: M2 closeout can mark
prepare_dmint_deployas the user-facing “deploy a dMint token” path with no caveats — removing the M1 footgun warning for V1.
Dependencies & Prerequisites¶
A self-hosted Radiant full node (existing, used by M1 deploy integration tests)
ElectrumX mainnet endpoint for chain walking in Phase 2a
Photonic Wallet TS source (clone locally if not already cached)
glyph-miner (already cloned locally from M1)
RXinDexer (already cloned locally)
Risk Analysis & Mitigation¶
High¶
R1: Phase 2a discovers Photonic V1 deploy structure differs significantly from the brainstorm’s assumption. E.g. the “snk” 35-output discrepancy points at a joint NFT+FT deploy pattern pyrxd doesn’t currently support. Could expand M2 scope.
Mitigation: scope M2 explicitly as “FT-only V1 dMint deploy.” Joint NFT+FT V1 deploys filed as deferred work (no milestone number; brainstorm separately if real demand emerges).
R2: glyph-miner refuses to mine pyrxd-deployed contracts for a shape reason we missed. Manual acceptance gate fails.
Mitigation: Phase 2a includes reading glyph-miner’s
parseDmintScript(glyph-minersrc/glyph.ts:277-322) and asserting our contract output script byte-for-byte matchesV1_BYTECODE_PART_B. Plus the consecutive-vouts requirement is a hard test gate.
R3: Mainnet RBG deploy reveal fundamentally different from Photonic source. Implies upstream drift; pyrxd has to choose which to match.
Mitigation: match mainnet over source (live nodes are ground truth). Document the divergence in the research doc.
Medium¶
R4:
num_contracts > 100rejected by chain standardness. Probably no real user wants 100+ contracts but the cap is defensive.Mitigation: explicit
ValidationErrorwith link to docs about standardness limits.
R5: V1 default flip breaks an external pyrxd consumer who relied on the post-M1 V2-default behavior.
Mitigation (downgraded from “alpha caveats” by deepen review):
DmintFullDeployParamsretained as aDeprecationWarning- emitting alias forDmintV2DeployParamsfor one release, then removed in M2.1. Plus M2 PR description with migration guidancealpha caveat. Deprecation cycle is cheap; alpha-only mitigation silently switches contract types for any caller who didn’t read the changelog.
Low¶
R6: Reveal-tx N+1 inputs hit some signing-loop bug that the M1 single-input demo didn’t surface.
Mitigation: synthetic test exercises 5+ contracts so the multi-input loop is real, not vestigial.
Resource Requirements¶
1 developer (eric), self-paced
~1 RXD for the manual mainnet acceptance gate (deploy commit + reveal fees + N×1 photon per contract; conservative budget)
VPS access for testmempoolaccept (existing)
Future Considerations¶
M3 (V2 deploy proof, deferred indefinitely): only revisit when someone wants V2’s DAA features (ASERT/LWMA dynamic difficulty).
prepare_dmint_v2_mint_tx/prepare_dmint_v1_mint_txsplit: M1 reviewers flaggedbuild_dmint_mint_tx’s polymorphic shape. Splitting is a separate refactor; not blocked by M2.Joint NFT+FT V1 deploys: only if R1 surfaces the pattern as ecosystem-canonical AND there’s real demand.
Find dMint deploys (not contracts) on chain: a
find_dmint_tokens(client)helper that enumerates all live tokens. Probably scanner.py work.
Documentation Plan¶
[ ]
docs/dmint-research-photonic-deploy.md(new, Phase 2a output)[ ]
docs/dmint-followup.md(full rewrite, Phase 2b.6)[ ]
examples/dmint_deploy_demo.py(new, modeled ondmint_claim_demo.py)[ ]
prepare_dmint_deploydocstring rewrite to document the V1 default + V2 opt-in behavior[ ]
find_dmint_contract_utxosdocstring with the M1find_dmint_funding_utxoshape
SpecFlow gaps applied¶
Every critical gap from the SpecFlow analysis is addressed:
C1 (premine output placement): vout[N] of reveal, with
owner_pkhas the recipient, controlled bypremine_amountfield onDmintV1DeployParamsC2 (V1 2-tx vs V2 3-tx): explicit Architecture-section Key Decision; sibling params + result dataclasses make the difference type-level visible
C3 (testmempoolaccept on N-output reveal): Phase 2b.5 test spec accepts any input-missing reject reason
C4 (
num_contractsupper bound):1 <= num_contracts <= 250(real standardness ceiling, not a guess), enforced at construction time via__post_init__, raisesValidationErrorI1 (stale-commit recovery): documented in
prepare_dmint_deploydocstring as caller responsibility (verify N confirmations before callingbuild_reveal_scripts); matches M1 contractI2 (
find_dmint_contract_utxosrace + cap):limit=andmin_confirmations=kwargs added;tx.txid()cross-check (S2)I3 (Plan-stage decision:
targetvsdifficulty): V1 deploy public API takesdifficulty: intfor ecosystem parity with Photonic; internal converter via existingdifficulty_to_targetI4 (Plan-stage decision:
find_dmint_contract_utxosinput): takestoken_ref, derives codescript-hash internallyBrainstorm Open Q #4 (banner timing): full rewrite of
dmint-followup.mdships as a sibling PR after M2; M2’s code PR includes only the banner updateN4 (
prepare_dmint_deploypost-flip semantics): auto-dispatch on params type (DmintV1DeployParamsvsDmintV2DeployParams), not aversionkwarg. Default flip is implicit in which params type the caller constructs.
References & Research¶
Internal¶
docs/brainstorms/2026-05-08-dmint-v1-deploy-m2-brainstorm.mddocs/plans/2026-05-07-feat-dmint-v1-mint-and-reference-miner-plan.md— M1 plandocs/dmint-research-mainnet.md§1-§5 (V1 layout, mint trace, deploy gap)docs/dmint-research-photonic.md— Photonic source citations from M1 researchdocs/solutions/logic-errors/dmint-v1-mint-shape-mismatch.md— golden-vector lessondocs/solutions/logic-errors/funding-utxo-byte-scan-dos.md— opcode-aware classification lessonsrc/pyrxd/glyph/builder.py:291— V2prepare_dmint_deploysrc/pyrxd/glyph/dmint.py:352-504— M1 V1 builderssrc/pyrxd/glyph/dmint.py:2156—find_dmint_funding_utxo(pattern to mirror)tests/test_dmint_deploy_integration.py:355-545— VPS testmempoolaccept harnessProject convention — Photonic’s TypeScript source is the default reference for protocol questions, but pyrxd deviates explicitly (with a documented reason) when Photonic is buggy, outdated, or worse-engineered than the alternative.
External¶
glyph-miner (MIT):
src/dmint-api.ts:309-342— RXinDexer-driven discoverysrc/deployments.ts:90-98, 207-219— fallback URL + per-token enumeration via consecutive voutssrc/glyph.ts:103-105, 265, 277-322, 391-441— V1 contract-script parser (the “what bytes glyph-miner actually checks”)
RXinDexer:
indexer/parser.py:540-542— auto-discovery viadetect_token_from_scriptindexer/script_utils.py:262-373— V1+V2 dMint contract parserindexer/script_utils.py:925-1006— CBOR field extraction
Photonic Wallet TS source (re-clone needed):
packages/lib/src/mint.ts:200-217—createCommitOutputspackages/lib/src/mint.ts:398-461—createRevealOutputspackages/lib/src/mint.ts:406-408—contractRefderivationpackages/lib/src/types.ts:68-78—DmintPayloadtypepackages/lib/src/script.ts— V1 covenant bytecode constants
Files to be created¶
docs/dmint-research-photonic-deploy.md(Phase 2a output)examples/dmint_deploy_demo.py(Phase 2b.5)tests/test_dmint_v1_deploy.py(Phase 2b.5)
Files to be modified¶
src/pyrxd/glyph/builder.py— split params intoDmintV1DeployParams+DmintV2DeployParams(rename of existingDmintFullDeployParams); split results intoDmintV1DeployResultDmintV2DeployResult(rename of existingDmintDeployResult); add newDmintV1RevealScripts;prepare_dmint_deploybecomes a match-dispatcher with@overloadstubs;DmintFullDeployParamsandDmintDeployResultretained asDeprecationWarning-emitting subclasses for one release
src/pyrxd/glyph/dmint.py—find_dmint_contract_utxos(the codescript-hash is computed inline; no separate helper)tests/test_dmint_deploy_integration.py— add V1 commit + reveal testmempoolaccept casesdocs/dmint-followup.md— banner update only (full rewrite is a sibling PR)docs/plans/2026-05-07-feat-dmint-v1-mint-and-reference-miner-plan.md— M2 closeout note