The system has two sides: announce (write path) and resolve (read path). They’re connected by what goes on-chain and what goes in sidecar data.
Each beacon type differs in what it commits to the blockchain and how many DIDs it can serve per transaction:
| Singleton | CAS | SMT | |
|---|---|---|---|
| Scope | 1 DID per TX | N DIDs per TX | N DIDs per TX |
| OP_RETURN | hash(canonicalize(signedUpdate)) |
hash(canonicalize(announcement)) |
tree.rootHash |
| Sidecar | SignedBTCR2Update |
CAS Announcement + updates | SMT Proof + updates |
| Verification | Hash match | Hash match | Merkle inclusion proof |
| Signing | Single-party PSBT | Single-party PSBT | MuSig2 multi-party |
The on-chain footprint is always the same: one OP_RETURN output with 32 bytes. The difference is in how many DID updates those 32 bytes commit to.
Both SingletonBeacon.broadcastSignal() and CASBeacon.broadcastSignal() follow the same PSBT pattern:
SMTBeacon.broadcastSignal() does the same thing but builds a single-entry BTCR2MerkleTree first to get the root hash as signal bytes. This is the single-party path for SMT – when a DID owner operates their own SMT beacon without aggregation.
This is where the coordinator/participant protocol comes in. The aggregation flow has four phases:
Coordinator Participant
| |
|--- BEACON_COHORT_ADVERT --------->| (cohortId, minParticipants, network, beaconType)
| | participant derives HD key for cohort
|<-- BEACON_COHORT_OPT_IN ---------| (participantPk)
| |
| (when minParticipants reached) |
| cohort.finalize() |
| -> sorts keys, computes Taproot |
| multisig address via p2tr() |
| |
|--- BEACON_COHORT_READY ---------->| (cohortKeys, beaconAddress)
| | participant validates keys match
Each participant derives a fresh BIP-32 child key per cohort. The beacon address is a Taproot key-path spend address derived from the MuSig2 aggregate of all cohort public keys.
Coordinator Participant
| |
|<-- SUBMIT_UPDATE ----------------| (cohortId, signedUpdate)
| |
| cohort.addUpdate(from, update) |
| (repeats for each participant) |
| |
| When all collected: |
| cohort.buildCASAnnouncement() |
| or cohort.buildSMTTree() |
For CAS: buildCASAnnouncement() creates a { did -> canonicalHash(signedUpdate) } map, then sets signalBytes = hash(canonicalize(announcement)).
For SMT: buildSMTTree() creates a BTCR2MerkleTree with one entry per participant { did, nonce, signedUpdate }, calls tree.finalize(), generates per-participant serialized proofs, and sets signalBytes = tree.rootHash.
Coordinator Participant
| |
|--- DISTRIBUTE_DATA -------------->| (beaconType, signalBytesHex, casAnnouncement/smtProof)
| |
| | CAS: verify announcement[myDid] == hash(myUpdate)
| | SMT: verify Merkle proof includes myUpdate
| |
|<-- VALIDATION_ACK ----------------| (approved: true/false)
| |
| cohort.addValidation(from, ok) |
| When all approved -> VALIDATED |
This is the trust gate. Each participant independently verifies that the aggregated data correctly includes their update before signing the transaction.
Coordinator Participant
| |
| buildBeaconTransaction(cohortId) |
| -> fetches beacon UTXO |
| -> builds TX: input + change + |
| OP_RETURN(signalBytes) |
| |
|--- AUTHORIZATION_REQUEST -------->| (pendingTx, sessionId)
| | creates signing session
|<-- NONCE_CONTRIBUTION ------------| (musig2 nonce)
| |
| session.generateAggregatedNonce() |
| |
|--- AGGREGATED_NONCE ------------->|
| | session.generatePartialSignature(sk)
|<-- SIGNATURE_AUTHORIZATION -------| (partial sig)
| |
| session.generateFinalSignature() |
| -> partialSigAgg([...sigs]) |
| |
| broadcastSignedTransaction() |
| -> setWitness(0, [signature]) |
| -> broadcast to Bitcoin network |
The final transaction has a Taproot key-path witness: a single 64-byte Schnorr signature that is the MuSig2 aggregate of all participants’ partial signatures. On-chain, this looks identical to a single-signer Taproot spend.
The resolver is a sans-I/O state machine. It never touches the network – the caller drives it:
const resolver = DidBtcr2.resolve(did, { sidecar });
let state = resolver.resolve();
while (state.status === 'action-required') {
for (const need of state.needs) {
switch (need.kind) {
case 'NeedBeaconSignals':
// Query Bitcoin for OP_RETURN signals at beacon addresses
const signals = await BeaconSignalDiscovery.indexer(need.services, bitcoin);
resolver.provide(need, signals);
break;
case 'NeedCASAnnouncement':
// Fetch from IPFS or sidecar
resolver.provide(need, announcement);
break;
case 'NeedSMTProof':
// Fetch from sidecar or proof server
resolver.provide(need, smtProof);
break;
case 'NeedSignedUpdate':
// Fetch from sidecar or CAS
resolver.provide(need, signedUpdate);
break;
}
}
state = resolver.resolve();
}
// state.status === 'resolved' -> state.result is the DID document
Each beacon’s processSignals() follows the same contract – returns { updates, needs } – but the internal logic differs:
Singleton: Signal bytes (hex) -> decode to base64url -> direct updateMap lookup. One indirection.
CAS: Signal bytes (hex) -> decode to base64url -> casMap lookup -> extract announcement[did] -> updateMap lookup. Two indirections.
SMT: Signal bytes (hex) -> smtMap lookup by root hash -> verify Merkle proof (verifySerializedProof(proof, didToIndex(did), candidateHash)) -> decode proof.updateId to base64url -> updateMap lookup. Two indirections plus cryptographic verification.
The SMT proof verification (spec section SMT Proof Verification) computes the candidate leaf hash as hash(hash(nonce) || updateId) and walks the sparse Merkle tree path to confirm it produces the on-chain root. This is the only beacon type that requires cryptographic verification at resolve time – Singleton and CAS rely on the hash commitment being unforgeable.
Resolution (spec section 7.2.e.1): Each beacon type implements the spec’s “Process [Type] Beacon” algorithm:
Announcement (spec section 8.2): The broadcastSignal() methods construct transactions per spec: spend beacon UTXO, OP_RETURN with signal bytes, change output back to beacon address. The beacon address chain acts as the append-only signal log.
Aggregation: The coordinator/participant protocol implements the spec’s multi-party beacon coordination:
Key property: The resolve path is completely agnostic to whether the beacon signal was produced by a single party or by aggregation. A Taproot key-path spend looks the same either way. The resolver only cares about what’s in the OP_RETURN and the sidecar – it never needs to know how many parties signed the transaction.
The runner layer handles message routing, callback orchestration, and event emission.
See docs/aggregation.md for the full API reference.
import { SchnorrKeyPair } from '@did-btcr2/keypair';
import {
AggregationServiceRunner,
AggregationParticipantRunner,
DidBtcr2,
NostrTransport,
} from '@did-btcr2/method';
// --- Service Setup ---
const serviceKeys = SchnorrKeyPair.generate();
const serviceDid = DidBtcr2.create(serviceKeys.publicKey.compressed, { idType: 'KEY', network: 'mutinynet' });
const transport = new NostrTransport({ relays: ['wss://relay.example'] });
const service = new AggregationServiceRunner({
transport,
did : serviceDid,
keys : serviceKeys,
config : { minParticipants: 2, network: 'mutinynet', beaconType: 'CASBeacon' },
onProvideTxData: async ({ beaconAddress, signalBytes }) => {
// Build the Bitcoin transaction for MuSig2 signing
// (fetch UTXO, construct PSBT with OP_RETURN, return tx + prevout data)
return { tx, prevOutScripts: [...], prevOutValues: [...] };
},
});
service.on('keygen-complete', ({ beaconAddress }) => console.log('Beacon:', beaconAddress));
service.on('signing-complete', ({ signature }) => console.log('Signed:', signature.length, 'bytes'));
const result = await service.run();
// result.signature = 64-byte Schnorr signature
// result.signedTx = Bitcoin Transaction ready for broadcast
// --- Participant Setup ---
const aliceKeys = SchnorrKeyPair.generate();
const aliceDid = DidBtcr2.create(aliceKeys.publicKey.compressed, { idType: 'KEY', network: 'mutinynet' });
const aliceTransport = new NostrTransport({ relays: ['wss://relay.example'] });
const alice = new AggregationParticipantRunner({
transport : aliceTransport,
did : aliceDid,
keys : aliceKeys,
shouldJoin : async (advert) => advert.beaconType === 'CASBeacon',
onProvideUpdate : async ({ beaconAddress }) => {
// Build a signed update that adds the beacon address to the DID document
return mySignedUpdate;
},
});
alice.on('cohort-ready', ({ beaconAddress }) => console.log('Beacon:', beaconAddress));
alice.on('cohort-complete', () => console.log('Done'));
await alice.start(); // Long-running - listens for cohort adverts
The runners drive the full 4-step protocol automatically. The caller only needs to supply callbacks for decisions (should I join this cohort? what update should I submit? what transaction data should the signing session use?).