did-btcr2-js

ADR 041: Cooperative Non-Inclusion Signaling for Aggregate Beacons

Status: Superseded by ADR 042

This ADR’s liveness framing rests on treating n-of-n MuSig2 as mandated by the specification. The specification only RECOMMENDS that example, so a fault-tolerant signing scheme is permitted. ADR 042 adopts a hybrid Taproot output (optimistic MuSig2 key-path, k-of-n script fallback, timelock recovery) that handles liveness directly. The non-inclusion data-commitment design below (a member with no update is absent from the CAS map, or carries a non-inclusion leaf in the SMT) still stands and carries forward under ADR 042, decoupled from liveness.

Date: 2026-06-23

Branch / PR: feat/aggregation-non-inclusion

References: ADR 008, ADR 017, ADR 027, ADR 036, ADR 038, ADR 039, ADR 040

Context

In an aggregate beacon, a cohort of participants jointly sign one Bitcoin transaction whose OP_RETURN commits to the batch of their DID updates (a CAS Announcement Map, or a Sparse Merkle Tree root). Not every member has an update every round: a participant may join a cohort and, when the round comes, have nothing to announce. Today there is no way to express that.

The cohort cannot proceed until every member submits an update. AggregationCohort.hasAllUpdates() returns pendingUpdates.size === participants.length (cohort.ts:192), and the service only advances CollectingUpdates to UpdatesCollected when that is true. Both aggregation builders refuse to run otherwise: buildCASAnnouncement and buildSMTTree throw INCOMPLETE_UPDATES (cohort.ts:202, cohort.ts:223). A member with no update never enters pendingUpdates, so the gate is never satisfied and the round stalls. There is no wire message for “no update this round”, no participant phase for it, and a non-submitting member would in any case drop the distributed aggregated data (the participant requires a prior submitted update before it will validate).

Two very different cases hide behind “a member did not submit an update”, and this ADR addresses only the first:

Why a silent member cannot simply be skipped. The cohort signs with n-of-n MuSig2 (ADR 008): the aggregate public key is derived from all n members’ keys, and the beacon address (the Taproot output the cohort funds and later spends) is that aggregate key. A valid key-path signature requires every member’s partial signature. Dropping a signer yields a different aggregate key, hence a different address, so the funds already sitting at the original address can no longer be spent by the smaller cohort. The beacon spend is key-path-only Taproot (ADR 038; cohort.ts:65-68 notes the key-path-only output has no script tree), so there is no fallback path to recover that UTXO without the full n-of-n. A non-updating member therefore still owes a nonce and a partial signature: non-inclusion saves an update, not a signature. A deadline timer can fail the round or trigger re-formation, but it cannot conjure the missing signature or skip the member and still produce a valid one. Closing the silent-member hole means a fund-recovery design (a Taproot script-path timelock) plus cohort re-formation economics, none of which the specification addresses. That is a separate, larger effort and is deferred.

What the specification mandates (did:btcr2 Aggregate Beacons and Algorithms):

The SMT primitive already supports all of this. The ADR 017 tree accepts an entry with an absent update and emits a non-inclusion leaf and a verifiable non-inclusion proof for it. The entire gap is in the aggregation orchestration layer, which never feeds the tree a slotted-but-empty leaf for a non-updating member.

Decision

Model cooperative non-inclusion as a first-class in-cohort state across the two state machines and the wire format, gating aggregation on “every member has responded” rather than “every member has submitted an update”. Leave the signing rounds untouched (all n members still sign). Record the silent-member liveness hole as explicitly out of scope.

  1. Non-inclusion is a response, not a removal. A member that declines to update stays in the cohort, keeps its slot in the aggregate key, and still contributes a nonce and a partial signature. The aggregate key, the beacon address, and the n-of-n signing flow are unchanged.

  2. New SUBMIT_NONINCLUDED message. A Step-2 message alongside SUBMIT_UPDATE, body { cohortId }, carrying no update. Membership is already proven by the signed transport envelope (ADR 027), so no extra proof field is needed for this version. Add the factory, a guard (asserts no update is present), and route it in the update phase.

  3. Gate on responses, not updates. AggregationCohort gains a nonIncluded set and addNonInclusion(did) (the same membership validation as addUpdate). A new hasAllResponses() returns pendingUpdates.size + nonIncluded.size === participants.length and replaces hasAllUpdates() at the collection gate. This mirrors the existing hasAllValidationResponses() (cohort.ts:267), which already counts approvals plus rejections against the participant count. pendingUpdates stays clean (real updates only), so CAS correctness is preserved by construction.

  4. Slot every participant in the SMT; omit decliners from CAS. buildSMTTree iterates the full participant list, not just pendingUpdates: an inclusion leaf for submitters, a non-inclusion leaf (SHA-256(SHA-256(nonce))) for decliners, with a per-slot nonce generated for every member as today. Each member, included or not, gets a verifiable proof. buildCASAnnouncement continues to iterate only pendingUpdates, so decliners are naturally absent from the map, matching the spec. Both builders’ preconditions relax from hasAllUpdates() to hasAllResponses().

  5. Participant NonIncluded phase and declineUpdate(). ParticipantCohortPhase gains NonIncluded as a sibling of UpdateSubmitted, reachable from CohortReady. A new declineUpdate(cohortId) emits SUBMIT_NONINCLUDED and moves to NonIncluded. The distribute-data handler is relaxed to accept a NonIncluded member, who validates its own slot (verifies its non-inclusion proof for SMT, or asserts its DID is absent for CAS) rather than dropping the message, then proceeds through the normal validation and signing rounds unchanged. ServiceCohortPhase needs no new state; the gating moves into hasAllResponses().

  6. Validation distinguishes a valid decline from a dropped update. The SMT strategy, for a decliner, skips the update-id requirement and recomputes SHA-256(SHA-256(nonce)) before verifying the proof. The CAS strategy treats “absent for a member that declined” as valid and “absent for a member that submitted” as a failure, keyed on the participant’s own recorded intent rather than map presence alone.

  7. Runner ergonomics. OnProvideUpdate widens to return SignedBTCR2Update | null; returning null means “no update this round” and the runner calls declineUpdate instead of submitUpdate. CohortCompleteInfo gains an explicit included: boolean so a resolver consuming the sidecar is never left inferring inclusion from an absent proof.

  8. Scope boundary: the silent member is out of scope. This change fixes the cooperative case. It does not fix a member that sends neither SUBMIT_UPDATE nor SUBMIT_NONINCLUDED: that member still stalls the cohort at hasAllResponses(), and because n-of-n requires their signature, no deadline can skip them and still finalize. The deadline-and-recovery track (a signing deadline, a Taproot script-path timelock so funds are recoverable when a member vanishes, and cohort re-formation) is a separate effort with its own ADR. It pairs naturally with the time-between-announcements cadence modeled but staged in ADR 039.

Rejected alternatives

Consequences

Positive

Negative

Accepted

References