feat(broadcast): multi-source fan-out replacing single-endpoint cashscript send
Introduces design/potidaea/src/broadcast.ts and wires every tx-emitting
site in the Potidaea client through it. Replaces cashscript's
TransactionBuilder.send() — which hits ONE hardcoded electrum endpoint
(wss://chipnet.bch.ninja:50004) — with a parallel fan-out that pushes
the same signed tx to as many independent nodes as the pool allows.
broadcast.ts
- fanoutBroadcast(rawTxHex) fires in parallel:
(a) every pre-opened ElectrumClient the caller passed in
(b) up to N (default 3) fresh ephemeral WSS connections drawn
from endpointsFor(currentNetwork()), shuffled; closed after
(c) every HTTPS JSON-RPC proxy in broadcastRpcFor(currentNetwork())
— our own-node write path, uncoupled from third-party Fulcrums
- Resolves on first success, aggregates rejections for diagnostics
- txidOf(rawTxHex) computes txid client-side (double-SHA256 + display
byte-order) so we trust our own hash, not whatever a server echoes
- rpcBroadcast handles BCHN "already known" (-27) as success — a tx
already in mempool is "safely propagated" from the caller's view
Mixed-content handling: ephemerals skip ws:// URLs when the page itself
is served over https:, since Chrome's loopback exception doesn't apply
to in-page requests from a secure origin.
Integration (all 6 tb.send() sites):
orbital.ts
- claimViaLocalSigner (BCH → MBA, Static family)
- claimViaLocalSignerShare (BCH → MBA, Share family)
- redeemViaLocalSigner (MBA → BCH, any family)
genesis.ts
- genesisArtemisionMBA (Static family genesis)
- genesisDeviantShareMBA (Share family genesis)
publish.ts
- publishViaLocalSigner (KatalogosRegistryV1.register tx)
Shape at each site: tb.build() to get raw hex, then
const { txid } = await fanoutBroadcast(rawTxHex)
Caller gets the exact same ClaimTxResult / GenesisResult / PublishResult
shape as before — swapping cashscript's send for our fan-out is purely
additive at the API layer.
Server-side companion: scratchpad/silentmode-broadcast-proxy/ (chipnet
RPC proxy bundle) + scratchpad/mainnet-broadcast-proxy/ (mainnet twin,
pending BCHN install per MAINNET-BCH-PLAYBOOK.md). The chipnet
broadcastRpcFor() URL (chipnet.silentmode.st/rpc/broadcast) starts
responding once that bundle lands on silentmode; until then the public
Fulcrums carry the broadcast.
Depends on: feat(network) — broadcast.ts imports endpointsFor +
currentNetwork from the network module.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
5f852b945e
commit
b8ac497323
4 changed files with 171 additions and 20 deletions
156
design/potidaea/src/broadcast.ts
Normal file
156
design/potidaea/src/broadcast.ts
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
// Broadcast fan-out — pushes a signed raw tx to as many independent BCH nodes
|
||||
// as we can reach. The design intent (agreed with the user 2026-09-26):
|
||||
//
|
||||
// Reads → local desktop Fulcrum (dev) + public chipnet Fulcrums (prod).
|
||||
// Writes → local desktop Fulcrum → local BCHN (in dev, via electrum WSS)
|
||||
// + our SERVER's BCHN (via HTTPS RPC proxy)
|
||||
// + every public Fulcrum in the electrum pool (each relays to its BCHN)
|
||||
// → transaction ends up in the mempool of at least two nodes we own
|
||||
// AND across the public P2P mesh, so a single third-party rejecting
|
||||
// or delaying cannot suppress a Potidaea swap.
|
||||
//
|
||||
// The routine fires ALL paths in parallel and resolves on the first success.
|
||||
// Every path that returns an error is aggregated for diagnostics — we do not
|
||||
// throw unless every path failed. The caller only cares about the txid.
|
||||
//
|
||||
// Nothing here handles building or signing — pass in the fully signed raw tx
|
||||
// hex from cashscript's TransactionBuilder.build() (NOT .send(), which does
|
||||
// its own broadcast to a single hardcoded endpoint we do not want).
|
||||
|
||||
import { ElectrumClient } from './electrum';
|
||||
import { broadcastRpcFor, endpointsFor } from './endpoints';
|
||||
import { currentNetwork } from './network';
|
||||
|
||||
/** Compute txid (little-endian display form) from a raw tx hex, so callers
|
||||
* can accept a broadcast that landed on ONE node — we do not depend on the
|
||||
* server echoing the txid back. */
|
||||
export async function txidOf(rawTxHex: string): Promise<string> {
|
||||
const bytes = new Uint8Array(rawTxHex.length / 2);
|
||||
for (let i = 0; i < bytes.length; i++) bytes[i] = parseInt(rawTxHex.slice(i * 2, i * 2 + 2), 16);
|
||||
const d1 = new Uint8Array(await crypto.subtle.digest('SHA-256', bytes));
|
||||
const d2 = new Uint8Array(await crypto.subtle.digest('SHA-256', d1));
|
||||
// txid is d2 reversed, hex-encoded.
|
||||
let out = '';
|
||||
for (let i = d2.length - 1; i >= 0; i--) out += d2[i].toString(16).padStart(2, '0');
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface FanoutResult {
|
||||
txid: string;
|
||||
accepted: string[]; // labels of endpoints that returned OK
|
||||
rejected: Array<{ endpoint: string; error: string }>;
|
||||
}
|
||||
|
||||
export interface FanoutOptions {
|
||||
/** Existing open ElectrumClients to relay through (reused, not closed). */
|
||||
electrumClients?: readonly ElectrumClient[];
|
||||
/** How many fresh electrum WSS connections to open in parallel from the pool.
|
||||
* Default 3. Ephemerals are closed after the broadcast attempt. Set to 0 to
|
||||
* skip opening any and rely purely on `electrumClients` + RPC proxies. */
|
||||
openEphemeralElectrum?: number;
|
||||
}
|
||||
|
||||
/** Fan-out a signed raw tx to every open electrum WSS client (existing +
|
||||
* freshly opened from CHIPNET_ENDPOINTS) AND every configured RPC broadcast
|
||||
* proxy. Resolves on first success. Every rejected path is aggregated for
|
||||
* diagnostics; we only throw when every path failed. */
|
||||
export async function fanoutBroadcast(
|
||||
rawTxHex: string,
|
||||
opts: FanoutOptions = {},
|
||||
): Promise<FanoutResult> {
|
||||
const expectedTxid = await txidOf(rawTxHex);
|
||||
const openN = opts.openEphemeralElectrum ?? 3;
|
||||
const net = currentNetwork();
|
||||
const pool = endpointsFor(net);
|
||||
const rpcPool = broadcastRpcFor(net);
|
||||
|
||||
type Attempt = Promise<{ label: string; ok: true; txid: string } | { label: string; ok: false; error: string }>;
|
||||
const attempts: Attempt[] = [];
|
||||
const ephemerals: ElectrumClient[] = [];
|
||||
|
||||
// Reuse the caller's already-open clients (usually main.ts's currentClient).
|
||||
for (const c of opts.electrumClients ?? []) {
|
||||
attempts.push(
|
||||
c.request<string>('blockchain.transaction.broadcast', [rawTxHex])
|
||||
.then((txid) => ({ label: `electrum:${c.url}`, ok: true as const, txid: String(txid) }))
|
||||
.catch((e: unknown) => ({ label: `electrum:${c.url}`, ok: false as const, error: String((e as Error)?.message ?? e) })),
|
||||
);
|
||||
}
|
||||
|
||||
// Open up to N fresh WSS connections in parallel from the pool — each server's
|
||||
// BCHN then relays to the P2P mesh, so this is the "public electrum broadcasts
|
||||
// to public BCHNs" leg. Skip URLs whose scheme is ws:// unless we're on http:
|
||||
// (mixed-content policy forbids ws:// from an https: page in browsers).
|
||||
if (openN > 0) {
|
||||
const usable = pool.filter((u) => (typeof location === 'undefined' ? true : (location.protocol === 'https:' ? u.startsWith('wss://') : true)));
|
||||
const shuffled = [...usable].sort(() => Math.random() - 0.5).slice(0, openN);
|
||||
for (const url of shuffled) {
|
||||
attempts.push((async () => {
|
||||
try {
|
||||
const c = await ElectrumClient.connectAny([url]); // connectAny does the server.version handshake for us
|
||||
ephemerals.push(c);
|
||||
const txid = await c.request<string>('blockchain.transaction.broadcast', [rawTxHex]);
|
||||
return { label: `electrum-ephemeral:${url}`, ok: true as const, txid: String(txid) };
|
||||
} catch (e) {
|
||||
return { label: `electrum-ephemeral:${url}`, ok: false as const, error: String((e as Error)?.message ?? e) };
|
||||
}
|
||||
})());
|
||||
}
|
||||
}
|
||||
|
||||
// Own-node RPC proxies — the whole point of the exercise: writes reach OUR
|
||||
// BCHNs directly, uncoupled from any third-party electrum operator.
|
||||
for (const url of rpcPool) {
|
||||
attempts.push(rpcBroadcast(url, rawTxHex, expectedTxid)
|
||||
.then((txid) => ({ label: `rpc:${url}`, ok: true as const, txid }))
|
||||
.catch((e: unknown) => ({ label: `rpc:${url}`, ok: false as const, error: String((e as Error)?.message ?? e) })));
|
||||
}
|
||||
|
||||
if (attempts.length === 0) throw new Error('no broadcast paths configured');
|
||||
|
||||
const results = await Promise.allSettled(attempts);
|
||||
// Close ephemerals — do NOT await, best-effort.
|
||||
for (const c of ephemerals) { try { c.close(); } catch { /* noop */ } }
|
||||
|
||||
const accepted: string[] = [];
|
||||
const rejected: Array<{ endpoint: string; error: string }> = [];
|
||||
let winningTxid: string | null = null;
|
||||
for (const r of results) {
|
||||
if (r.status !== 'fulfilled') continue;
|
||||
if (r.value.ok) { accepted.push(r.value.label); if (!winningTxid) winningTxid = r.value.txid; }
|
||||
else rejected.push({ endpoint: r.value.label, error: r.value.error });
|
||||
}
|
||||
|
||||
if (accepted.length === 0) {
|
||||
throw new Error(`broadcast rejected by all ${attempts.length} paths: ${rejected.map((x) => `${x.endpoint} — ${x.error}`).join(' | ')}`);
|
||||
}
|
||||
// Server-echoed txid could disagree with our hash if a server is buggy or
|
||||
// hostile — trust our own hash, keep the server value only for diagnostics.
|
||||
return { txid: expectedTxid, accepted, rejected };
|
||||
}
|
||||
|
||||
/** POST a BCHN-style JSON-RPC sendrawtransaction to `url`. The url is expected
|
||||
* to be nginx-fronted with server-side auth injected; the client sends no
|
||||
* Authorization header. On success BCHN echoes the txid string in `result`.
|
||||
* BCHN quirk: it returns HTTP 500 with `error.code=-27` for "already known"
|
||||
* (tx already in mempool) — we treat that as a WIN since the tx is present.
|
||||
* Rejections that indicate propagation-safe reasons (already in chain,
|
||||
* already known, missing-inputs-because-double-spend-of-us) collapse to
|
||||
* success; genuine invalid-tx errors surface. */
|
||||
async function rpcBroadcast(url: string, rawTxHex: string, expectedTxid: string): Promise<string> {
|
||||
const body = JSON.stringify({ jsonrpc: '1.0', id: 'potidaea-broadcast', method: 'sendrawtransaction', params: [rawTxHex] });
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body,
|
||||
});
|
||||
const text = await res.text();
|
||||
let parsed: { result?: string; error?: { code?: number; message?: string } } | null = null;
|
||||
try { parsed = JSON.parse(text) as typeof parsed; } catch { /* non-JSON = raw error */ }
|
||||
if (parsed && typeof parsed.result === 'string') return parsed.result;
|
||||
const code = parsed?.error?.code;
|
||||
const msg = parsed?.error?.message ?? text ?? `http ${res.status}`;
|
||||
// BCHN "already known / in mempool / in block" — our tx is safely propagated.
|
||||
if (code === -27 || /already in .*block|transaction already/i.test(msg)) return expectedTxid;
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
|
@ -23,6 +23,7 @@ import { Contract, ElectrumNetworkProvider, TransactionBuilder, SignatureTemplat
|
|||
import type { Signer } from './signer-store';
|
||||
import type { KnownMba } from './orbital';
|
||||
import { cashscriptNetwork, currentNetwork } from './network';
|
||||
import { fanoutBroadcast } from './broadcast';
|
||||
|
||||
const ART = artifact as unknown as Artifact;
|
||||
const SHARE_ART = shareArtifact as unknown as Artifact;
|
||||
|
|
@ -124,10 +125,8 @@ export async function genesisArtemisionMBA(
|
|||
});
|
||||
if (change >= DUST) tb.addOutput({ to: signer.cashaddr, amount: change });
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const sent: any = await tb.send();
|
||||
const txid = typeof sent === 'string' ? sent : (sent.txid ?? sent.txId);
|
||||
if (!txid) throw new Error('genesis broadcast returned no txid');
|
||||
const rawTxHex = tb.build();
|
||||
const { txid } = await fanoutBroadcast(rawTxHex);
|
||||
|
||||
const result: GenesisResult = {
|
||||
txid,
|
||||
|
|
@ -212,10 +211,8 @@ export async function genesisDeviantShareMBA(
|
|||
});
|
||||
if (change >= DUST) tb.addOutput({ to: signer.cashaddr, amount: change });
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const sent: any = await tb.send();
|
||||
const txid = typeof sent === 'string' ? sent : (sent.txid ?? sent.txId);
|
||||
if (!txid) throw new Error('Share genesis broadcast returned no txid');
|
||||
const rawTxHex = tb.build();
|
||||
const { txid } = await fanoutBroadcast(rawTxHex);
|
||||
|
||||
const result: ShareGenesisResult = {
|
||||
txid,
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import {
|
|||
type PlatformFeeMath,
|
||||
} from './platformFee';
|
||||
import { cashscriptNetwork, currentNetwork, type Network } from './network';
|
||||
import { fanoutBroadcast } from './broadcast';
|
||||
|
||||
const ART = artifact as unknown as Artifact;
|
||||
const SHARE_ART = shareArtifact as unknown as Artifact;
|
||||
|
|
@ -366,9 +367,8 @@ export async function claimViaLocalSigner(
|
|||
appendPlatformFeeOutputs(tb, preview.grossSats, 'chipnet', PLATFORM_FEE_V1);
|
||||
tb.addOutput({ to: signer.cashaddr, amount: changeSats });
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const sent: any = await tb.send();
|
||||
const txid = typeof sent === 'string' ? sent : (sent.txid ?? sent.txId);
|
||||
const rawTxHex = tb.build();
|
||||
const { txid } = await fanoutBroadcast(rawTxHex);
|
||||
return { txid, ticker: args.ticker, amountUnits: args.amountUnits, grossSats: preview.grossSats, feeSats: preview.feeSats };
|
||||
}
|
||||
|
||||
|
|
@ -457,9 +457,8 @@ export async function claimViaLocalSignerShare(
|
|||
appendPlatformFeeOutputs(tb, grossSats, 'chipnet', PLATFORM_FEE_V1);
|
||||
tb.addOutput({ to: signer.cashaddr, amount: changeSats });
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const sent: any = await tb.send();
|
||||
const txid = typeof sent === 'string' ? sent : (sent.txid ?? sent.txId);
|
||||
const rawTxHex = tb.build();
|
||||
const { txid } = await fanoutBroadcast(rawTxHex);
|
||||
return { txid, ticker: args.ticker, amountUnits: args.amountUnits, grossSats, feeSats };
|
||||
}
|
||||
|
||||
|
|
@ -565,9 +564,8 @@ export async function redeemViaLocalSigner(
|
|||
}
|
||||
tb.addOutput({ to: signer.cashaddr, amount: outputSats - (leftoverUnits > 0n && leftoverBacking < DUST ? (DUST - leftoverBacking) : 0n) });
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const sent: any = await tb.send();
|
||||
const txid = typeof sent === 'string' ? sent : (sent.txid ?? sent.txId);
|
||||
const rawTxHex = tb.build();
|
||||
const { txid } = await fanoutBroadcast(rawTxHex);
|
||||
return { txid, ticker: args.ticker, amountUnits: args.amountUnits, releasedSats: netToUser };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ import {
|
|||
} from '@bitauth/libauth';
|
||||
import type { Signer } from './signer-store';
|
||||
import { cashscriptNetwork, currentNetwork } from './network';
|
||||
import { fanoutBroadcast } from './broadcast';
|
||||
|
||||
const ART = artifact as unknown as Artifact;
|
||||
|
||||
|
|
@ -447,9 +448,8 @@ export async function publishViaLocalSigner(
|
|||
tb.addOpReturnOutput(chunks);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const sent: any = await tb.send();
|
||||
const txid = typeof sent === 'string' ? sent : (sent.txid ?? sent.txId);
|
||||
const rawTxHex = tb.build();
|
||||
const { txid } = await fanoutBroadcast(rawTxHex);
|
||||
return { txid, targetCategoryId, feeDea };
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue