Skip to content

feat(dht): attach the untrusted mirror-coin pointer to the provider announce - #431

Merged
MichaelTaylor3d merged 5 commits into
mainfrom
loop/422-dht-mirror-pointer
Aug 30, 2026
Merged

feat(dht): attach the untrusted mirror-coin pointer to the provider announce#431
MichaelTaylor3d merged 5 commits into
mainfrom
loop/422-dht-mirror-pointer

Conversation

@MichaelTaylor3d

@MichaelTaylor3d MichaelTaylor3d commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

DRAFT — DO NOT MERGE — gate round not yet returned.

Closes #422.

Takes the dig-dht 0.15 tier and attaches the untrusted mirror-coin pointer
ProviderRecord::unverified_mirror_coin_id to this node's DHT provider announce, refreshed on
collateral-epoch rollover.

The tier, measured from the resolved lock

cargo update -p dig-dht -p dig-download -p dig-peer-selector — every crate named explicitly,
because a caret-compatible transitive is invisible to cargo fetch and can leave two lines alive
while cargo prints success. Counted from Cargo.lock, not from the caret:

crate entries version
dig-dht 1 0.15.0
dig-download 1 0.22.0
dig-peer-selector 1 0.11.0
dig-nat 1 0.21.0
dig-tls 1 0.4.0
dig-peer 1 0.13.0

chia-protocol (2 lines) and chia-bls (5) are multi-line and unchanged from origin/main
counted on both locks. Pre-existing, not introduced here.

§2.4b: every other dig-*/chia-* declaration in this crate was checked against index.crates.io
and is already at the latest published version. The one exception is deliberate and left alone:
dig-ipc-protocol = "=0.3.0" is an exact pin on a wire contract (0.3.1 exists; taking it is a
reviewed change, not a sweep).

The pointer

dig-node-core holds no collateral-epoch knowledge of its own, so the pointer arrives through a seam:

  • MirrorCoinPointersepoch() plus a per-content coin_id_for(). Per content because a
    mirror coin bonds one (store, root, owner, epoch) tuple, so a single node-wide pointer would send
    verifiers to a coin that does not advertise the capsule they asked about.
  • Announce publishes through announce_provider_with_collateral. announce_inventory_ids and
    sync_inventory keep their signatures and delegate with None.
  • DhtHandle::reannounce_on_epoch_rollover() compares the source's epoch to the epoch the
    published pointers were drawn for. run_maintenance calls it before republish — after would
    leave last epoch's coin id published for a further whole interval.

The four properties #422 states hold: the claim is untrusted (NC-12, where to look, never what the
coin is); absence is a normal state that neither suppresses an announce nor reads as a fault; a
verifier's judgement is unchanged and rests on the coin's own evidence; and the pointer is refreshed
on rollover.

Blast radius

Checked by grep + direct read (per-worktree gitnexus not analysed; a ~10-minute index would have
competed with the build, and §2.0 permits the fallback). Every callers-of edit is additive —
announce_inventory_ids, sync_inventory and DhtHandle::new keep their signatures:

  • announce_inventory_idstests/dht_integration.rs:406, unchanged.
  • sync_inventory — no production caller outside dht.rs; tests/holdings_wire.rs references it.
  • DhtHandle::newpeer.rs:3004, neighbourhood_probe.rs:736, tests/holdings_wire.rs:455; all
    now delegate to with_mirror_pointers(.., None).
  • run_maintenancepeer.rs:3198.
  • dig_dht::ProviderRecord gained a field, so six struct-literal test fixtures in
    union_locator.rs / module_reshare.rs take unverified_mirror_coin_id: None.

How it was verified

  • cargo test -p dig-node-core --lib --locked1012 passed, 0 failed.
  • cargo clippy --workspace --all-targets --locked -- -D warnings — clean.
  • Each test proven load-bearing by reverting only its fix (from a file copy, on committed work):
mutation reds on
reannounce_on_epoch_rolloverreturn 0 (the "publish once, let republish refresh it" implementation) every announced id is re-announced on a rollover
re-announce without re-reading the pointers the refreshed announce names THIS epoch's coin, read fresh rather than replayed
never attach the pointer at announce the bonded id publishes this node's claimed coin

The rollover test's fake moves its epoch and its coin ids together, as a real rollover does — a
fake that kept one coin across the rollover could not tell a fresh read from a replay. It also calls
service.republish() mid-test and asserts the record unchanged, so the later change can only
have come from the re-announce.

Not done here, stated rather than hidden

The pointer source is not yet injected in production. The mirror coins live in
dig-node-service, and constructing a MirrorCoinPointers for the real node reaches server.rs,
which PRs #423 / #430 own. This PR plumbs the seam end-to-end inside dig-node-core and leaves
DhtHandle::new publishing no pointer — a normal, fully supported state. No user-facing surface
claims a pointer is published, so nothing is made to lie.

Consequently SPEC.md §25.6 stays accurate and is deliberately untouched (another lane owns it):
a reader still MUST NOT infer that a mirror coin id reaches the DHT. One sentence at SPEC.md:7962
gives a now-obsolete reason — that the dig-dht 0.15 bump is blocked — and wants a follow-up edit by
whichever lane owns §25.

Version: workspace 0.176.0, dig-node-core 0.63.0 (minor — new public API, no behaviour change
for an existing caller). Cargo.toml and Cargo.lock agree.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

Sequencing: this PR is HELD behind #430, deliberately — and the hold is not about quality

This PR is green and gate-ready. Five required checks SUCCESS at b6fda8c, zero unresolved
threads, and the lock evidence is exactly what #422 asked for — dig-dht 0.15.0, dig-download 0.22.0, dig-peer-selector 0.11.0, one entry each, counted from the resolved lock after the
rebase rather than before it, with main counted as a control.

It is held because #430 and this PR both bump Cargo.toml/Cargo.lock, and the second to merge is
invalidated rather than merely conflicted.
That is measured, not predicted: #432 merged mid-flight
today and took 0.176.0 — the exact version #430 had claimed — so #430's version-increment gate
went from green to failing without a line of its code changing, and needed a full re-bump plus another
35-minute coverage run.

Order: #430 first (it is the epic's last create blocker and its security gate is running now), then
this one rebases and re-bumps — a rebase alone will not fix it, because the number itself is stale,
not the merge base.

Two findings from this lane worth carrying, neither blocking

1. The pointer is plumbed but NOT injected in production. DhtHandle::new publishes no pointer;
dig-node-core holds no collateral-epoch knowledge, and constructing a real MirrorCoinPointers
reaches dig-node-service/server.rs, which #423 and #430 own. This is a supported state and no
surface claims otherwise
, which is the right shape — but it means #422's acceptance is not met by
this merge alone, and the injection needs a named follow-up rather than being assumed done.

2. SPEC.md:7962 will carry an obsolete REASON, not an obsolete status. It says §25.6 is
unattached because the dig-dht 0.15 bump is blocked. After this merge the status stays true (no
coin id reaches the DHT on a real node, per finding 1) while the reason rots — the bump is no
longer blocked. Left untouched here to keep the diff to one concern; whichever lane owns §25 should
fix that sentence. A stale reason is how a resolved blocker becomes a false blocker for the next
reader.

What the mutation evidence actually proved

Three reds, each from reverting only its own fix on committed work:

mutation red on
reannounce_on_epoch_rolloverreturn 0 (i.e. "publish once, let republish refresh it") every announced id is re-announced on a rollover
re-announce without re-reading the pointers the refreshed announce names THIS epoch's coin, read fresh rather than replayed
never attach the pointer at announce the bonded id publishes this node's claimed coin

The second is the one that matters most for NC-12: the fake pointer source moves its epoch and its
coin ids together, as a real rollover does, so a fake reusing one coin across the rollover cannot make
a replay look like a fresh read. service.republish() is called mid-test with the record asserted
unchanged, so any later change can only have come from the re-announce.

@MichaelTaylor3d
MichaelTaylor3d force-pushed the loop/422-dht-mirror-pointer branch from b6fda8c to 2754385 Compare August 30, 2026 19:56
@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security — IN PROGRESS, not the verdict

Audited head: 2754385e00d8d718abdbada97b753e91d22740da (resolved from gh pr view 431 --json headRefOid, not from the dispatch brief).

Posting as I go so nothing is lost to a watchdog. Nothing CRITICAL or HIGH so far.

1. NC-12 requirement 1 — nothing downstream treats the received pointer as evidence: SATISFIED, structurally

Grepped the whole head tree. unverified_mirror_coin_id is read in exactly one place in dig-node, and it is a test helper:

  • crates/dig-node-core/src/seams/dig_peer/dht.rs:1763record.unverified_mirror_coin_id_bytes(), inside mod tests, used only to read back what this node published about itself.

There is no production reader at all. The field is written only as None in six test fixtures. So the property is not merely "the code is careful with it" — there is no code path in this repo that could treat a remote peer's claim as evidence, because nothing consumes it. Requirement 1 cannot be violated by this diff.

2. The documented literal-construction bypass is NOT reachable here

dig-dht 0.15.0 record.rs:423-427 states the guarantee explicitly: an ADMITTED or STORED record carries canonical lowercase 64-hex or nothing, but the field is pub, so a record built by struct literal — "how a consumer folds a verified holdings-announce in" — can carry arbitrary attacker-shaped bytes of arbitrary length, and such a consumer must normalize it itself.

Checked whether dig-node is that consumer. It is not:

  • git grep "ProviderRecord *{" returns 9 hits; 4 are -> ProviderRecord { function signatures, and all 5 genuine struct literals are test fixtures (module_reshare.rs:1334, union_locator.rs:343,351,403,448,457 — all inside mod tests).
  • Every production record originates from ProviderRecord::new (which sets the field None, record.rs:451) or from dig-dht's own ingress, which normalizes at the wire (deserialize_with = "deserialize_mirror_coin_id", record.rs:428-433) and again at admission (service.rs:238,654,724).

So the unnormalized path exists upstream but has no caller here.

3. Peer-supplied bytes cannot be echoed unescaped on the RPC surface

crates/dig-node-core/src/download.rs:3019:

fn provider_json(p: &ProviderRecord) -> Value {
    json!({ "peer_id": p.provider_peer_id, "addresses": p.addresses })
}

The redirect / dig.getAvailability provider array projects only peer_id and addresses. The mirror-coin pointer is not echoed to an RPC caller, so a hostile pointer cannot reach that surface even if one were somehow held unnormalized.

4. Dependency tier — recounted independently, matches the lane's claim

Counted from the resolved Cargo.lock at 2754385e, with origin/main as the control:

crate head main
dig-dht 1 1
dig-download 1 1
dig-peer-selector 1 1
dig-nat / dig-peer / dig-tls 1 / 1 / 1 1 / 1 / 1
chia-protocol 2 2
chia-bls 5 5

One line each for the three bumped crates — so there is one ProviderRecord type across the locate boundary, which is the defect the cascade existed to remove. chia-protocol and chia-bls are split identically on main; pre-existing, not introduced here (dig_ecosystem#3152).

Still to check: the announced_epoch write ordering vs. a failing announce, lock ordering between announced and announced_epoch, and the re-announce cost asymmetry. Verdict to follow.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security: PASS — nothing CRITICAL, nothing HIGH, no LIVE vulnerability

Audited head: 2754385e00d8d718abdbada97b753e91d22740da — resolved from gh pr view 431 --json headRefOid, not from the dispatch brief. Read entirely from git objects (git show <sha>:<path>); no shared checkout was mutated (the dig-node primary checkout sits on branch pr409 and was left untouched; only git fetch was run).

Scoped as briefed: an additive, behaviour-preserving diff in crates/dig-node-core/src/seams/dig_peer/ with a dormant producer, plus a three-crate dependency uplift. Security leg, not the full custody treatment.


NC-12, the four properties that actually matter

1. The pointer tells a verifier WHERE TO LOOK, never WHAT THE COIN IS — SATISFIED STRUCTURALLY

unverified_mirror_coin_id is read in exactly one place in dig-node, and it is a test helper: crates/dig-node-core/src/seams/dig_peer/dht.rs:1763, inside mod tests, reading back what this node published about itself. There is no production reader. Nothing can treat a remote peer's claim as evidence, because nothing consumes it.

The bypass that would matter is documented upstream. dig-dht-0.15.0/src/record.rs:423-427 states that an admitted-or-stored record is canonical 64-hex or absent, but the field is pub, so a record built by struct literal — "how a consumer folds a verified holdings-announce in" — can hold arbitrary attacker-shaped bytes of arbitrary length, and such a consumer must normalize it itself.

dig-node is not that consumer. git grep "ProviderRecord *{" at head returns 9 hits; 4 are -> ProviderRecord { function signatures, and all 5 genuine struct literals are test fixtures (module_reshare.rs:1334; union_locator.rs:343,351,403,448,457). Every production record comes from ProviderRecord::new (sets the field None, record.rs:451) or from serde.

And serde is the load-bearing detail: the normalization is attached to the type, not to one call site — record.rs:428-433 puts deserialize_with = "deserialize_mirror_coin_id" on the field itself, alongside default and skip_serializing_if = "Option::is_none". So every deserialization path normalizes — the dig-dht wire, the dig-sex forwarded-ask answer merged at download.rs:1730-1732, dig-download's locate — not merely the one the author had in mind. Re-normalized again at admission (service.rs:238, 654, 724). Absent field goes through default to None; a JSON null, number, array or object fails as_str() and becomes None; an oversize string is dropped. Both serde paths land safe.

2. Absence must not degrade discovery — SATISFIED, and by identity rather than by resemblance

pointers.and_then(|p| p.coin_id_for(id)) yielding None calls announce_provider_with_collateral(id, None), and dig-dht-0.15.0/src/service.rs:291-293 shows announce_provider literally delegates to that same call with None. The no-pointer path is byte-identical to the pre-existing one, not just similar. Nothing filters, skips or errors on None; dht.rs:1790-1817 announces a bonded and an unbonded id together and asserts both are announced.

The one-off-variant probe applies here as the mixed case rather than the empty one — "one id has a coin, the rest do not" — and that is exactly what the test covers, with the unbonded id as the truthful control.

3. A hostile value cannot hurt the receiver — SATISFIED

  • Bounded. Normalized to canonical lowercase 64-hex or None at the wire and again at admission. The transient pre-normalization allocation is capped by wire::MAX_FRAMED_BODY (256 KiB), and that ceiling is tested, not merely asserted in prose: record.rs:603-607 builds a MAX_FRAMED_BODY - 512 pointer, and service.rs:1064-1124 asserts the served frame stays under the limit.
  • Never echoed. crates/dig-node-core/src/download.rs:3019 projects only provider_peer_id and addresses into the redirect / dig.getAvailability provider array. A peer-supplied pointer cannot reach an RPC caller, escaped or otherwise.
  • Never logged. The only new log field is repointed, a usize count (dht.rs:867, emitted from the tick at dht.rs:862).
  • Keys nothing attacker-controlled. provider_store.rs:372-379 inserts into self.announced, keyed on this node's own content keys from its own inventory — reachable only from announce_provider_with_collateral, which this node calls about content it holds. Bounded by local inventory; not a rate-limiter-keyed-on-caller-input DoS primitive.
  • Merge cannot cause source confusion. union_locator.rs:102-108: when two sources name the same authenticated provider_peer_id, the first-seen record is kept and only addresses are unioned — the later record's pointer is discarded. A later pool/PEX source therefore cannot graft its pointer onto a DHT-authoritative record. Safe direction. Same shape at download.rs:1730-1732, where first-hand records precede forwarded ones.

4. The epoch re-announce — the property holds in the CODE, and the fixture is not vacuous

dht.rs:728-746. The epoch is compared and the same-epoch case returns 0 before announced_ids() and before any announce, so the cheap check gates the expensive step rather than pacing work already done. On a rollover, announce_inventory_ids_with_pointers calls p.coin_id_for(id) at announce time (dht.rs:493), which is what makes it a fresh read rather than a replay of what republish holds.

I checked the lane's claim about its own fixture rather than accepting it. It holds: EpochPointers::roll_to moves the epoch and the coin id together, so an implementation that replayed the cached pointer would still publish COIN_THIS_EPOCH and fail the COIN_NEXT_EPOCH assertion. The mid-test service.republish() with the record asserted unchanged (dht.rs:1852-1857) pins that the later change can only have come from the re-announce — and dig-dht's republish (service.rs:463-473) does re-attach the recorded pointer verbatim, confirming the assertion tests the real mechanism. A fixture holding one coin id across the rollover would indeed have been blind; this one is not.

Restart is fail-safe, not fail-stale: announced_epoch is in-memory and None at construction, so the first tick after any restart re-announces.


Everything else checked

area verdict
Secrets / credentials none introduced, logged or committed; test coin ids are [0xA1; 32] constants
Custody / privilege untouched — no signing, key handling, elevation, service/registry write, FFI or file-permission change; the §908 boundary is not approached
Crypto / protocol no downgrade, replay or nonce surface. skip_serializing_if plus default keeps the record wire-compatible both directions: an old reader sees no field, a new reader sees None
AuthZ / exposure — who can invoke this announce_all and reannounce_on_epoch_rollover are pub but reachable only from spawn_initial_inventory_announce (bring-up) and run_maintenance (the local timer, dht.rs:862). No remote entry point reaches either, no new RPC method, no new handler, and nothing hung off a pre-existing peer-reachable handler. A peer cannot trigger the re-announce
Amplification / cost asymmetry bytes-in from a peer on this path: zero — there is no peer request in it. The full-inventory re-announce fires only on a change in pointers.epoch(), a local chain-derived value with no remote input
Concurrency / locking no lock is held across an await in the new paths: announced_ids() clones under a temporary guard (dht.rs:761); announce_all and reannounce_on_epoch_rollover take announced and announced_epoch disjointly, in opposite orders, with neither held. No deadlock
Persisted state at rest none added; announced_epoch is in-memory only
Dependencies dig-dht 0.15.0 / dig-download 0.22.0 / dig-peer-selector 0.11.0 — one lock entry each, recounted from the resolved lock at 2754385e with origin/main as control. dig-nat / dig-peer / dig-tls also 1 each. chia-protocol 2 and chia-bls 5 are identical on main — pre-existing (dig_ecosystem#3152), not introduced here

Merge preconditions at this SHA, asserted by name: 14 checks SUCCESS (Test + coverage, Clippy, Rustfmt, Check version increment, CodeQL, the three Analyze jobs, Lint commit messages, the four package builds, Release-script tests); Attach packages to the release SKIPPED; 0 review threads.


What would have made this fail

Named so the PASS is checkable rather than decorative:

  1. A production reader of unverified_mirror_coin_id that short-circuited verification, skipped the hint scan, or ranked/filtered providers on it — a live NC-12 violation, since a hostile peer supplies the value for free.
  2. A production ProviderRecord struct literal built from peer-supplied data, bypassing both normalizations and able to carry a 256 KiB pointer that is stored and re-served — upstream names this exact hazard.
  3. The pointer reaching provider_json, echoing peer bytes onto the RPC surface.
  4. The epoch check running after announced_ids() or after the announce, making the cheap gate pace work already done.
  5. A fixture holding one coin id across roll_to, which would have made the whole rollover proof vacuous.
  6. A second dig-dht line in the lock, giving two distinct ProviderRecord types across the locate boundary.

None of the six is present.


Defense-in-depth — NOT gating, for the injection follow-up child

Both belong to whoever builds the real MirrorCoinPointers in dig-node-service; neither is actionable in this diff, and neither should hold this merge.

(a) reannounce_on_epoch_rollover commits announced_epoch before announcing (dht.rs:733-737, announce at 738-745), so a wholly-failed announce would still record the epoch as done. I chased this and it does not bite: announce_provider_with_collateral writes the local record and calls mark_announced_with_collateral before any fallible network step (service.rs:321-328) and returns Ok(0) early when there are no peers (service.rs:332-335); republish re-attaches from that same local map (service.rs:467-472) and runs on the same maintenance tick immediately after. The stale window is one republish, not one epoch. Worth one sentence in the child, no more.

(b) epoch() should be documented as monotonic and cheap. A source whose epoch oscillated would re-announce the entire inventory on every maintenance tick. Nothing in this diff can do that — there is no producer — but the trait doc currently constrains the meaning of epoch() and not its behaviour, and the constraint becomes real the moment a producer exists.

Also unchanged and correctly out of scope, per the brief: the pointer not being injected in production, the obsolete reason at SPEC.md:7962, and the deliberate dig-ipc-protocol = "=0.3.0" pin.

Verdict: PASS. No LIVE vulnerability. I do not merge, undraft or edit — this verdict returns to the orchestrator.

@MichaelTaylor3d
MichaelTaylor3d marked this pull request as ready for review August 30, 2026 20:28
@MichaelTaylor3d
MichaelTaylor3d merged commit 3d15118 into main Aug 30, 2026
15 checks passed
@MichaelTaylor3d
MichaelTaylor3d deleted the loop/422-dht-mirror-pointer branch August 30, 2026 20:28
MichaelTaylor3d added a commit that referenced this pull request Aug 30, 2026
PR #431 took 0.178.0 on main, so this branch's original bump to 0.178.0
was dropped by the rebase as 'already upstream' -- leaving no effective
version increase. 0.179.0 is the first number past what main holds.

Cargo.lock regenerated; CI runs --locked.
MichaelTaylor3d added a commit that referenced this pull request Aug 30, 2026
…424) (#434)

* chore(mirror): open lane for the production broadcaster (#424)

* feat(mirror): wire a production broadcaster for the mirror lifecycle

`production_broadcaster()` was a literal `None`, so `sign_and_broadcast` refused
before signing and both the spend-record writer (#411) and the mirror signer (#410)
were inert.

`ChainTransport::broadcaster()` is the narrow counterpart to the existing public
`chain_source()`: it returns a broadcaster built on the ONE shared client, so
`shared_client` stays `pub(crate)` and no caller gains the client itself.

The seam keeps its single-derivation role and gains a three-way answer, so an
unreachable chain is no longer reported as a switched-off flag. On a default
install no broadcaster is constructed and no chain is dialed.

Refs #424

* chore(mirror): fmt, fix the remaining open_signer test call site, bump to 0.178.0

* chore: drop the WIP lane marker from CHANGELOG

* chore: bump to 0.179.0 after rebasing onto 3d15118

PR #431 took 0.178.0 on main, so this branch's original bump to 0.178.0
was dropped by the rebase as 'already upstream' -- leaving no effective
version increase. 0.179.0 is the first number past what main holds.

Cargo.lock regenerated; CI runs --locked.

* fix(mirror): a PENDING mempool ack is not an accepted broadcast

Four findings from the adversarial gate on #434, all downstream of an
operator having already enabled live broadcast.

MONEY. `ChiaQueryBroadcaster::broadcast` read `TxStatus::success`, which
`chia_query`'s `ack_to_tx_status` sets true for BOTH status 1 (SUCCESS)
and status 2 (PENDING). PENDING means the full node did not admit the
bundle to its mempool — held for an unknown parent, or a fee below the
floor — so the broadcaster returned `Ok(())` for a submission that never
happened, contradicting the `Broadcaster` contract in the same file. The
journal then recorded it and the intra-pass reservation stranded the
funding coin against a spend the network was not holding.

Fixed locally by reading the status NAME the crate already produces,
through a pure `accepted_by_mempool`. That consumes a distinction
`chia-query` makes rather than re-deriving one, so it is not a rival
implementation. The crate's own conflation is filed as
DIG-Network/chia-query#48 and is a release-first cascade, not this PR.

HONESTY. `ChainUnreachable` promised a reachability claim nothing makes:
the client build succeeds with zero peers because the default config's
coinset fallback tier makes the peer requirement optional. Renamed to
`ChainClientUnavailable` and documented for what it observes — a failed
CONSTRUCTION. `Available` now says plainly that it means a broadcaster
was built, not that a chain answers; unreachability is discovered at the
broadcast, by name. The three-way shape and the per-pass re-read are kept
unchanged, so a node that starts offline still recovers.

TESTS. The default-install test claimed a control no test exercised. Its
fixture is now actually driven through `production_broadcaster(.., true)`,
which both makes that test non-vacuous and pins what `Wired` means.

MESSAGES. The refusal named a closed ticket instead of the flag an
operator can reach; it now names both reasons it can fire. Four string
literals had lost their line continuations.

Co-Authored-By: Claude <noreply@anthropic.com>

* docs(mirror): name the PENDING harm at the reservation, and correct two stale comments

The reservation comment's 'extended only on a broadcast that REACHED the
mempool' was false at the previous head and is what the PENDING fix makes
true; it now says so and names the durable consequence. chain.rs no longer
claims the transport is reachable only as a SignedBundlePusher, which
stopped being true when broadcaster() was added.

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
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.

Attach unverified_mirror_coin_id at the DHT announce (blocked on the dig-dht 0.15 cascade)

1 participant