Wire rounds now produce a signed ALP SEND and broadcast it through Chronik.
Details
- Reviewers
Fabien - Group Reviewers
Restricted Project - Commits
- rABCbc004e9a6eb7: [alp-fusion] Sign fused inputs over covert and broadcast via Chronik
cd apps/alp-fusion && pnpm test
Diff Detail
- Repository
- rABC Bitcoin ABC
- Lint
Lint Not Applicable - Unit
Tests Not Applicable
Event Timeline
Build Bitcoin ABC Diffs / Diff Testing (ai-review) passed.
CodeRabbit Review
Diff : committed changes only
Compare : HEAD → master
Directory : work
────────────────────────────────────────
(\(\
(• .•) 🥕 I brought carrots. You brought corner cases. Let's party.
────────────────────────────────────────────────────────────────────────
minor [Functional Correctness]
→ ]8;;vscode://file//work/apps/alp-fusion/test/coordinator/wire.test.ts:69apps/alp-fusion/test/coordinator/wire.test.ts:69-71]8;;
Distinct tags can produce the same keypair.
The seckey holds the tag in one byte, and tag 0 is remapped to 1. Tag
0 and tag 1 therefore derive the same key and the same P2PKH script.
Any tag pair that matches after & 0xff collides in the same way.
keysByTag hides the collision, because it keys the cache by the tag, not
by the derived key.
A collision makes two "different" participants share a script.
signOwnedInputs then matches one key to both inputs, and the test still
passes while it no longer covers the intended case.
Write the tag across more bytes and keep 0 distinct.
🐛 Proposed fix
const sk = Buffer.alloc(32);
- sk[31] = tag === 0 ? 1 : tag & 0xff;
+ sk.writeUInt32BE(tag + 1, 28);
const created = { sk, pk: Buffer.from(ecc.derivePubkey(sk)) };────────────────────────────────────────
Review complete
1 finding ✔
Minor 1
12 files reviewed:
- apps/alp-fusion/DEPLOY.md
- apps/alp-fusion/README.md
- apps/alp-fusion/package.json
- apps/alp-fusion/src/client/chronik.ts
- apps/alp-fusion/src/client/wire.ts
- apps/alp-fusion/src/coordinator/wire.ts
- apps/alp-fusion/src/protocol/components.ts
- apps/alp-fusion/src/tx/sign.ts
- apps/alp-fusion/test/client/chronik.test.ts
- apps/alp-fusion/test/coordinator/wire.test.ts
... and 2 more files
────────────────────────────────────────
Print all AI prompts: coderabbit review --show-prompts
Build Bitcoin ABC Diffs / Diff Testing (ai-review) passed.
CodeRabbit Review
Diff : committed changes only
Compare : HEAD → master
Directory : work
────────────────────────────────────────
(\(\
(• .•) Warning: May contain traces of bugs.
────────────────────────────────────────────────────────────────────────
minor [Stability & Availability] → ]8;;vscode://file//work/apps/alp-fusion/src/client/wire.ts:217apps/alp-fusion/src/client/wire.ts:217]8;; Guard against a repeated sharecovertcomponents message. The while loop can process sharecovertcomponents more than once. The block then assembles and submits the same signatures again. The coordinator rejects the second submission with input already signed (Line 229 of src/coordinator/wire.ts), which fails an otherwise valid round. Add a signaturesSent flag and enter the block only once.
────────────────────────────────────────────────────────────────────────
major [Functional Correctness]
→ ]8;;vscode://file//work/apps/alp-fusion/src/client/wire.ts:218apps/alp-fusion/src/client/wire.ts:218-222]8;;
Assemble with the round token id, not opts.tokenId.
The client accepts a FusionBegin for any pool in joinKeys (Line
119-126), so the round token can be beginToken and not opts.tokenId.
The coordinator assembles with round.tokenId. If the two differ, the
client builds a different ALP SEND, and every covert signature it
submits is over the wrong sighash. The round then fails at
applyCovertSignature.
Pass beginToken so both sides assemble the same transaction.
🐛 Proposed fix
const { assembled } = assembleFromComponents(
- opts.tokenId,
+ beginToken,
shared,
feerate,
);────────────────────────────────────────────────────────────────────────
minor [Data Integrity & Integration] → ]8;;vscode://file//work/apps/alp-fusion/src/coordinator/wire.ts:802apps/alp-fusion/src/coordinator/wire.ts:802-808]8;; Return the signed transaction when the broadcast fails. A broadcast rejection replaces the successful result with ok: false and drops rawTx and txid. Every client then treats the round as failed. Two consequences follow. Clients cannot retry the broadcast themselves, and they may re-fuse UTXOs that a transaction already spends, for example when the node rejects with a "transaction already known" style error. Keep ok: true with the local txid, and report the broadcast error in a separate field, or at least keep rawTx in the failure result.
────────────────────────────────────────────────────────────────────────
major [Data Integrity & Integration]
→ ]8;;vscode://file//work/apps/alp-fusion/src/client/chronik.ts:58apps/alp-fusion/src/client/chronik.ts:58-109]8;;
Confirm the UTXO filter against ALP token types.
The filter only skips mint batons and other token IDs. It keeps any UTXO
whose token.tokenId matches, including SLP token types and UTXOs of a
non-standard ALP type. assembleAlpSend colors these as ALP atoms, so a
matching-id SLP coin would be spent as bare XEC and the tokens burned.
Add a token.tokenType.protocol === 'ALP' guard.
🛡️ Proposed fix
- if (token.isMintBaton || token.tokenId.toLowerCase() !== want) {
+ if (
+ token.isMintBaton ||
+ token.tokenId.toLowerCase() !== want ||
+ token.tokenType.protocol !== 'ALP'
+ ) {
continue;
}────────────────────────────────────────────────────────────────────────
critical [Security & Privacy]
→ ]8;;vscode://file//work/apps/alp-fusion/src/tx/sign.ts:89apps/alp-fusion/src/tx/sign.ts:89-116]8;;
Restrict signing to the inputs the player actually contributed.
signOwnedInputs matches an input by its P2PKH outputScript only. The
shared component list arrives from the coordinator, and the coordinator
controls which components it shares. A malicious coordinator can insert a
component that references any other UTXO of the victim that pays the same
address. The client then signs that input and the coin is spent in the
fused transaction, with the value assigned to outputs the victim did not
choose. Address reuse makes this reachable in practice.
Match on the outpoint set of the local contribution, and use the script
match only as a secondary check.
🛡️ Sketch of the fix
export function signOwnedInputs(
tx: Tx,
keys: FusionInputKey[],
+ ownPrevOuts?: ReadonlySet<string>,
): CovertInputSignature[] {
const unsigned = UnsignedTx.fromTx(tx);
const ecc = new Ecc();
const out: CovertInputSignature[] = [];
for (let i = 0; i < tx.inputs.length; i++) {
+ if (ownPrevOuts !== undefined) {
+ const { txid, outIdx } = tx.inputs[i].prevOut;
+ const key = `${
+ typeof txid === 'string'
+ ? txid.toLowerCase()
+ : Buffer.from(txid).toString('hex')
+ }:${outIdx}`;
+ if (!ownPrevOuts.has(key)) {
+ continue;
+ }
+ }
const outputScript = tx.inputs[i].signData?.outputScript;
Add the outpoints of contribution.tokenInputs and
contribution.fuelInputs at the two call sites in src/client/wire.ts
and test/coordinator/wire.test.ts.────────────────────────────────────────
Review complete
5 findings ✔
Critical 1
Major 2
Minor 2
12 files reviewed:
- apps/alp-fusion/DEPLOY.md
- apps/alp-fusion/README.md
- apps/alp-fusion/package.json
- apps/alp-fusion/src/client/chronik.ts
- apps/alp-fusion/src/client/wire.ts
- apps/alp-fusion/src/coordinator/wire.ts
- apps/alp-fusion/src/protocol/components.ts
- apps/alp-fusion/src/tx/sign.ts
- apps/alp-fusion/test/client/chronik.test.ts
- apps/alp-fusion/test/coordinator/wire.test.ts
... and 2 more files
────────────────────────────────────────
Print all AI prompts: coderabbit review --show-prompts
Address CodeRabbit: sign only contributed outpoints, assemble with FusionBegin tokenId, skip non-ALP UTXOs, send covert sigs once, keep signed FusionResult on broadcast error.
Build Bitcoin ABC Diffs / Diff Testing (ai-review) passed.
CodeRabbit Review
Diff : committed changes only
Compare : HEAD → master
Directory : work
────────────────────────────────────────
(\(\
(• .•) I raised 60 million carrots in my last funding round.
────────────────────────────────────────────────────────────────────────
major [Stability & Availability]
→ ]8;;vscode://file//work/apps/alp-fusion/src/coordinator/wire.ts:205apps/alp-fusion/src/coordinator/wire.ts:205]8;;
Fail the signing phase on post-assembly failures.
fail() returns early when finished is true, and tryAssemble sets
finished = true as soon as assembly succeeds. So every fail() call
during the signing phase is a no-op and never resolves signed. Two
reachable paths hit this:
- A player disconnects after assembly (line 732). No further signature can
arrive, but the round only ends when each session's signWait timer
expires.
- close() calls fail('coordinator closed') (line 395). close()
resolves while activeRound.signed is still pending and a
roundTimeoutMs timer is still armed.
In both cases activeRound stays set until the timeout, so
tryStartReady cannot start the next round. Call failSign before the
finished guard.
🐛 Proposed fix
fail(error: string): void {
+ this.failSign(error);
if (this.finished) {
return;
}
this.finished = true;
this.resolveResult({ ok: false, error, components: [] });
- this.failSign(error);
}────────────────────────────────────────────────────────────────────────
major [Data Integrity & Integration] → ]8;;vscode://file//work/apps/alp-fusion/src/client/chronik.ts:47apps/alp-fusion/src/client/chronik.ts:47]8;; Keep token checks enabled for fused broadcasts. chronik-client 4.3.0 defines the second broadcastTx argument as skipTokenChecks, which bypasses the client's token-burn safety check when set to true. The coordinator can then submit a fused transaction that burns ALP atoms. Remove the argument unless intentional token burns are explicitly supported. 🔒️ Proposed fix - return chronik.broadcastTx(toHex(rawTx), true); + return chronik.broadcastTx(toHex(rawTx));
────────────────────────────────────────
Review complete
2 findings ✔
Major 2
12 files reviewed:
- apps/alp-fusion/DEPLOY.md
- apps/alp-fusion/README.md
- apps/alp-fusion/package.json
- apps/alp-fusion/src/client/chronik.ts
- apps/alp-fusion/src/client/wire.ts
- apps/alp-fusion/src/coordinator/wire.ts
- apps/alp-fusion/src/protocol/components.ts
- apps/alp-fusion/src/tx/sign.ts
- apps/alp-fusion/test/client/chronik.test.ts
- apps/alp-fusion/test/coordinator/wire.test.ts
... and 2 more files
────────────────────────────────────────
Print all AI prompts: coderabbit review --show-prompts
Address CodeRabbit: failSign after assemble (disconnect/close), keep Chronik token-burn checks on broadcast.
Build Bitcoin ABC Diffs / Diff Testing (ai-review) passed.
CodeRabbit Review
Diff : committed changes only
Compare : HEAD → master
Directory : work
────────────────────────────────────────
(\(\
(• .•) Dollars to donuts, you didn't test this.
────────────────────────────────────────────────────────────────────────
major [Security & Privacy]
→ ]8;;vscode://file//work/apps/alp-fusion/src/client/wire.ts:224apps/alp-fusion/src/client/wire.ts:224-233]8;;
Verify your own outputs in the assembled transaction before you sign.
The client assembles from shared, which the coordinator controls, and
then signs every input it owns. The code does not check that the outputs
from opts.contribution.tokenOutputs are present in
assembled.tx.outputs.
A malicious coordinator can drop this player's output components and
insert its own outputs. Atom and sats conservation still holds across the
round, so assembleAlpSend accepts the set. The player then signs its
inputs and loses the contributed atoms and fuel.
Add an output-presence check before signOwnedInputs, and abort the round
when a contributed output is missing.
🔒 Sketch of the check
const { assembled } = assembleFromComponents(
beginToken,
shared,
feerate,
);
+ // A coordinator-supplied component list must still
+ // contain every output this player contributed.
+ assertOwnOutputsPresent(assembled.tx, opts.contribution);
const owned = signOwnedInputs(
assertOwnOutputsPresent should match each tokenOutputs entry by
(script.bytecode, sats, atoms) and account for duplicates by
multiplicity.────────────────────────────────────────
Review complete
1 finding ✔
Major 1
12 files reviewed:
- apps/alp-fusion/DEPLOY.md
- apps/alp-fusion/README.md
- apps/alp-fusion/package.json
- apps/alp-fusion/src/client/chronik.ts
- apps/alp-fusion/src/client/wire.ts
- apps/alp-fusion/src/coordinator/wire.ts
- apps/alp-fusion/src/protocol/components.ts
- apps/alp-fusion/src/tx/sign.ts
- apps/alp-fusion/test/client/chronik.test.ts
- apps/alp-fusion/test/coordinator/wire.test.ts
... and 2 more files
────────────────────────────────────────
Print all AI prompts: coderabbit review --show-prompts
Address CodeRabbit: require contributed token outputs in the assembled SEND before signing.
Build Bitcoin ABC Diffs / Diff Testing (ai-review) passed.
CodeRabbit Review
Diff : committed changes only
Compare : HEAD → master
Directory : work
────────────────────────────────────────
(\(\
(• .•) Your function signature is a cry for help.
────────────────────────────────────────────────────────────────────────
major [Data Integrity & Integration]
→ ]8;;vscode://file//work/apps/alp-fusion/src/tx/sign.ts:87apps/alp-fusion/src/tx/sign.ts:87]8;;
Assert that pubkeys covers every assembled input.
The coordinator sizes sigs from pubkeys and treats "every slot
defined" as fully signed. pubkeysFromComponents derives that array in a
separate pass from componentsToContribution, so the two classifications
must stay in lock step. If the pubkey count is ever lower than the input
count, the coordinator resolves signed as ok and broadcasts a tx with
unsigned inputs. Add the invariant at the point where both values exist.
🛡️ Proposed invariant
- return { assembled, pubkeys: pubkeysFromComponents(componentBytes) };
+ const pubkeys = pubkeysFromComponents(componentBytes);
+ if (pubkeys.length !== assembled.tx.inputs.length) {
+ throw new Error(
+ 'assembleFromComponents: pubkey count does not match inputs',
+ );
+ }
+ return { assembled, pubkeys };────────────────────────────────────────
Review complete
1 finding ✔
Major 1
12 files reviewed:
- apps/alp-fusion/DEPLOY.md
- apps/alp-fusion/README.md
- apps/alp-fusion/package.json
- apps/alp-fusion/src/client/chronik.ts
- apps/alp-fusion/src/client/wire.ts
- apps/alp-fusion/src/coordinator/wire.ts
- apps/alp-fusion/src/protocol/components.ts
- apps/alp-fusion/src/tx/sign.ts
- apps/alp-fusion/test/client/chronik.test.ts
- apps/alp-fusion/test/coordinator/wire.test.ts
... and 2 more files
────────────────────────────────────────
Print all AI prompts: coderabbit review --show-prompts
Build Bitcoin ABC Diffs / Diff Testing (ai-review) passed.
CodeRabbit Review
Diff : committed changes only
Compare : HEAD → master
Directory : work
────────────────────────────────────────
(\(\
(• .•) That's not a fallback, that's a trapdoor.
────────────────────────────────────────
Review complete
No findings ✔
12 files reviewed:
- apps/alp-fusion/DEPLOY.md
- apps/alp-fusion/README.md
- apps/alp-fusion/package.json
- apps/alp-fusion/src/client/chronik.ts
- apps/alp-fusion/src/client/wire.ts
- apps/alp-fusion/src/coordinator/wire.ts
- apps/alp-fusion/src/protocol/components.ts
- apps/alp-fusion/src/tx/sign.ts
- apps/alp-fusion/test/client/chronik.test.ts
- apps/alp-fusion/test/coordinator/wire.test.ts
... and 2 more files
────────────────────────────────────────