feat(network): chipnet|mainnet state module + chain-aware wiring

Introduces design/potidaea/src/network.ts as the single source of truth
for which BCH chain the browser session is pointed at. Everything that
talks to the chain reads from it: endpoint pool, address encoding,
cashscript provider, catalog, WalletConnect chainId, topbar chip.

New module:
  network.ts
    - currentNetwork() / setNetwork() / onNetworkChange()
    - persisted in localStorage so a reload keeps the choice
    - addrPrefix(net), cashscriptNetwork(net), networkLabel(net) helpers

endpoints.ts
  - Split into per-chain CHIPNET_ENDPOINTS / MAINNET_ENDPOINTS plus
    endpointsFor(net) selector (mirror in CHIPNET_BROADCAST_RPC /
    MAINNET_BROADCAST_RPC for the broadcast-RPC pool that the broadcast
    commit will consume).
  - Chipnet pool widens to include the local-dev Fulcrum (slot 0, Vite
    DEV only), silentmode Fulcrum slot, and coinspectrum fallback.
  - Mainnet pool mirrors shells/app/src/core/spv/pool.ts public set.

Chain-aware integration (single-line swaps across many files):
  electrum.ts       — ElectrumClient.connectAny defaults to endpointsFor(currentNetwork())
  orbital.ts        — all four ElectrumNetworkProvider sites use cashscriptNetwork(currentNetwork());
                      KNOWN_MBAS → per-chain SEED_MBAS[Network] with refreshMbasForNetwork(net)
                      wipe-and-reseed, mainnet starts empty
  genesis.ts        — provider swap at both Artemision + Share genesis entry points
  publish.ts        — provider swap + mainnet guard throwing
                      PublishError('wrong-network') (KatalogosRegistryV1 is
                      chipnet-only until deployed; the chipnet dev oracle key
                      must never ship to mainnet)
  registry.ts       — provider swap in discoveredParamsToKnownMba
  signer-store.ts   — signer address encoding follows currentNetwork() so
                      the same WIF produces bchtest: on chipnet and
                      bitcoincash: on mainnet; createUserWallet() uses the
                      matching WIF flavor

shells/app/src/core/spv/pool.ts
  - Owns-slot wiring: dev Fulcrum ws://127.0.0.1:50003 (DEV only) +
    chipnet.silentmode.st:50012 own slot + coinspectrum fallback on
    chipnet; mainnet set unchanged from the existing public pool.

main.ts
  - window.setPotidaeaNetwork bridge invoked by app.html netMenu
  - onNetworkChange listener: close cached client, refresh catalog,
    re-derive signer, repaint chip + mainnet banner
  - Boot-time badge + banner sync (localStorage might hold "mainnet"
    from a prior session; corrects the HTML's static "chipnet" default)
  - WC import-flow prefix check uses addrPrefix() instead of hardcoded
    "bchtest:"

app.html
  - netMenu mainnet item un-disabled, tag now "read-only" with tooltip
  - netMenu click handler calls window.setPotidaeaNetwork(net)
    (no more "mainnet is coming" toast)

Verified in-browser: flipping to mainnet shows the view-only banner,
badge updates to "● mainnet", localStorage persists; flipping back
restores chipnet reads (live BCH balance via local Fulcrum).

Follow-up commits land: feat(broadcast) — fan-out module + wiring,
feat(bcmr-integration) — genesis/publish emission hooks that depend on
both this + the broadcast commit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Local Dev 2026-10-08 22:42:49 +02:00
parent f7ec956d6e
commit 5f852b945e
11 changed files with 330 additions and 56 deletions

View file

@ -1083,7 +1083,7 @@
<div class="hdrmenu" id="netMenu">
<div class="hm-head">Network</div>
<div class="hm-item on" data-net="chipnet"><span class="hm-dot on"></span>chipnet<span class="hm-tag">live</span></div>
<div class="hm-item disabled" data-net="mainnet"><span class="hm-dot"></span>mainnet<span class="hm-tag soon">upcoming</span></div>
<div class="hm-item" data-net="mainnet"><span class="hm-dot"></span>mainnet<span class="hm-tag" title="No MBAs deployed on mainnet yet — view-only">read-only</span></div>
</div>
</div>
<div class="hdrsel">
@ -2110,7 +2110,7 @@
document.getElementById('netBadge').addEventListener('click', function(e){ toggle(netMenu, e); });
document.getElementById('walletChip').addEventListener('click', function(e){ toggle(walletMenu, e); });
document.addEventListener('click', function(){ closeAll(); });
netMenu.querySelectorAll('.hm-item').forEach(function(it){ it.addEventListener('click', function(e){ e.stopPropagation(); if(it.classList.contains('disabled')){ mockToast('Mainnet is coming — chipnet only for now'); return; } closeAll(); }); });
netMenu.querySelectorAll('.hm-item').forEach(function(it){ it.addEventListener('click', function(e){ e.stopPropagation(); if(it.classList.contains('disabled')){ return; } var net = it.getAttribute('data-net'); if (net && typeof window.setPotidaeaNetwork === 'function') { window.setPotidaeaNetwork(net); } closeAll(); }); });
var ACT = { import:'Import wallet — paste a seed phrase or WIF (mockup)', create:'Create new wallet — generate a fresh seed (mockup)', wizard:'WizardConnect — pair a BCH-native dApp (mockup)' };
walletMenu.querySelectorAll('.hm-item[data-act]').forEach(function(it){ it.addEventListener('click', function(e){ e.stopPropagation(); closeAll(); mockToast(ACT[it.getAttribute('data-act')]); }); });
function mockToast(msg){

View file

@ -10,7 +10,8 @@
// Phase 1). No retries — a stale balance in the UI is bounded by the next
// user action.
import { CHIPNET_ENDPOINTS } from './endpoints';
import { endpointsFor } from './endpoints';
import { currentNetwork } from './network';
type PendingCall = { resolve: (v: unknown) => void; reject: (e: Error) => void; };
@ -34,7 +35,7 @@ export class ElectrumClient {
/** Open a client against the first reachable endpoint from `pool`. Sends the
* mandatory `server.version` handshake so downstream RPCs actually reply. */
static async connectAny(pool: readonly string[] = CHIPNET_ENDPOINTS): Promise<ElectrumClient> {
static async connectAny(pool: readonly string[] = endpointsFor(currentNetwork())): Promise<ElectrumClient> {
let lastErr: Error | null = null;
for (const url of pool) {
try {
@ -132,6 +133,25 @@ export interface AddressUtxo {
};
}
export interface HistoryEntry {
tx_hash: string;
height: number; // 0 for mempool; negative if any input is unconfirmed
fee?: number;
}
/** Fetch tx history for an address (Fulcrum + CashTokens compat). Returns
* chronological entries { tx_hash, height }. Most recent last per electrum
* convention; caller sorts if needed. Empty on failure. */
export async function fetchAddressHistory(client: ElectrumClient, cashaddr: string): Promise<HistoryEntry[]> {
try {
const raw = await client.request<HistoryEntry[]>('blockchain.address.get_history', [cashaddr]);
return Array.isArray(raw) ? raw : [];
} catch (e) {
console.warn('[electrum] address.get_history failed:', e instanceof Error ? e.message : e);
return [];
}
}
/** Fetch a transaction's raw hex by txid. Server-side `verbose` flag is
* false so we get the raw hex — parsing lives in the caller (libauth
* `decodeTransaction` handles it cleanly and gives us typed outputs). */

View file

@ -1,13 +1,69 @@
// Chipnet electrum endpoints — mirrors the wallet's DEFAULT_ENDPOINTS.chipnet
// slice from shells/app/src/core/spv/pool.ts. Two public WSS servers is what
// exists today; the wallet keeps a placeholder for "deviant (own)" when we run
// our own Fulcrum, which we'd add here too.
// Per-chain electrum + broadcast endpoints. `endpointsFor(network)` returns the
// electrum WSS pool the ElectrumClient and the ephemeral-broadcast fan-out draw
// from; `broadcastRpcFor(network)` returns the extra RPC-proxy write path (our
// own BCHN nodes, uncoupled from any third-party Fulcrum).
//
// Update rule: if the shell's pool.ts adds/removes a chipnet endpoint, mirror
// that change here. Kept as a copy (not an import) because the mockup deliberately
// avoids pulling shells/app/ into its bundle — smaller ship, no cross-package
// module resolution.
// Update rule: if the shell's pool.ts adds/removes an endpoint for either
// network, mirror the change here — kept as a copy (not an import) because the
// mockup deliberately avoids pulling shells/app/ into its bundle.
import type { Network } from './network';
// ── Chipnet ──────────────────────────────────────────────────────────────────
// Local desktop Fulcrum lives in slot 0 (dev only) — talks to this machine's
// BCHN chipnet node (127.0.0.1:50003). Silentmode's Fulcrum sits at slot 1
// once installed; public pool below covers the fallback.
export const CHIPNET_ENDPOINTS: readonly string[] = [
...(import.meta.env?.DEV ? ['ws://127.0.0.1:50003'] : []),
'wss://chipnet.silentmode.st:50012',
'wss://chipnet.bch.ninja:50004',
'wss://chipnet.imaginary.cash:50004',
'wss://chipnet.electroncash.de:50004',
'wss://cbch.loping.net:62104',
'wss://coinspectrum.duckdns.org:50012',
];
// Chipnet broadcast-only RPC proxies. Fired IN ADDITION to the electrum pool —
// this is the own-node write path uncoupled from third-party Fulcrum operators.
// The silentmode entry starts responding once we deploy the broadcast-proxy
// bundle to that box (see scratchpad/silentmode-broadcast-proxy/DEPLOY.md).
export const CHIPNET_BROADCAST_RPC: readonly string[] = [
'https://chipnet.silentmode.st/rpc/broadcast',
];
// ── Mainnet ──────────────────────────────────────────────────────────────────
// Mirrors shells/app/src/core/spv/pool.ts::DEFAULT_ENDPOINTS.mainnet, with our
// own Fulcrum wired into slot 0. The `bch.silentmode.st:50014` URL is a
// PLACEHOLDER — pinned to the hostname the mainnet BCH server playbook uses
// (see D:/Dev/SilentMode/VPS/MAINNET-BCH-PLAYBOOK.md §0). Until that box is
// spun up the URL will fail to connect and the pool falls through to the
// public pool below — no user impact. When we deploy, update the hostname
// here to match the box we actually chose and rebuild.
export const MAINNET_ENDPOINTS: readonly string[] = [
'wss://bch.silentmode.st:50014', // own Fulcrum, placeholder (playbook §4)
'wss://bch.imaginary.cash:50004',
'wss://cashnode.bch.ninja:50004',
'wss://electrum.imaginary.cash:50004',
'wss://fulcrum.greyh.at:50004',
'wss://bch.loping.net:50004',
'wss://blackie.c3-soft.com:50004',
'wss://fulcrum.criptolayer.net:50004',
];
// Own-node mainnet write path — HTTPS RPC broadcast proxy in front of our
// mainnet BCHN. The URL is a PLACEHOLDER matching MAINNET_ENDPOINTS[0]; both
// swap together when the box lands. Broadcasts fan out to public Fulcrums
// AND this endpoint in parallel; first success wins.
export const MAINNET_BROADCAST_RPC: readonly string[] = [
'https://bch.silentmode.st/rpc/broadcast',
];
// ── Selectors ────────────────────────────────────────────────────────────────
export function endpointsFor(net: Network): readonly string[] {
return net === 'mainnet' ? MAINNET_ENDPOINTS : CHIPNET_ENDPOINTS;
}
export function broadcastRpcFor(net: Network): readonly string[] {
return net === 'mainnet' ? MAINNET_BROADCAST_RPC : CHIPNET_BROADCAST_RPC;
}

View file

@ -22,6 +22,7 @@ import type { Artifact } from 'cashscript';
import { Contract, ElectrumNetworkProvider, TransactionBuilder, SignatureTemplate } from 'cashscript';
import type { Signer } from './signer-store';
import type { KnownMba } from './orbital';
import { cashscriptNetwork, currentNetwork } from './network';
const ART = artifact as unknown as Artifact;
const SHARE_ART = shareArtifact as unknown as Artifact;
@ -77,7 +78,7 @@ export async function genesisArtemisionMBA(
throw new Error('founderFeeBps out of range 0..500');
}
const reservoirSupply = params.reservoirSupply ?? DEFAULT_RESERVOIR_SUPPLY;
const provider = new ElectrumNetworkProvider('chipnet');
const provider = new ElectrumNetworkProvider(cashscriptNetwork(currentNetwork()));
// Pick an eligible funding UTXO — must be vout=0, plain BCH (no token),
// enough sats for reservoir + miner fee + change.
@ -176,7 +177,7 @@ export async function genesisDeviantShareMBA(
if (params.feeBps < 0n || params.feeBps > 500n) throw new Error('feeBps out of range 0..500');
if (params.companySplitBps < 0n || params.companySplitBps > 10000n) throw new Error('companySplitBps out of range 0..10000');
const reservoirSupply = params.reservoirSupply ?? DEFAULT_RESERVOIR_SUPPLY;
const provider = new ElectrumNetworkProvider('chipnet');
const provider = new ElectrumNetworkProvider(cashscriptNetwork(currentNetwork()));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const own = (await provider.getUtxos(signer.cashaddr)) as any[];

View file

@ -33,6 +33,8 @@ import {
listUserWallets, switchUserWallet, removeUserWallet, renameUserWallet,
pinDevWif, clearDevWif, type Signer,
} from './signer-store';
import { currentNetwork, setNetwork, onNetworkChange, addrPrefix, type Network } from './network';
import { refreshMbasForNetwork } from './orbital';
// ── known chipnet CashToken categories the mockup asks the chain about ──────
// Ticker → hex category id. Only tickers present in the mockup's ASSETS array
@ -78,11 +80,87 @@ export interface PotidaeaWcHandle {
}
const wc = new WcClient({
network: 'chipnet',
network: currentNetwork(),
dappName: 'Potidaea',
dappIcon: undefined, // TODO: 128×128 favicon-alike data-URL
});
// Wire the topbar netMenu (in app.html) to real network switching. app.html
// calls window.setPotidaeaNetwork(net) when a chip row is clicked; we flip
// the network module, then let onNetworkChange fan out reloads.
(window as unknown as { setPotidaeaNetwork?: (net: Network) => void }).setPotidaeaNetwork = (net) => {
if (net !== 'chipnet' && net !== 'mainnet') return;
setNetwork(net);
};
// Re-seed the MBA catalog on boot for the CURRENT network. localStorage might
// hold "mainnet" from a previous session — orbital's flat map starts with
// chipnet seeds, so we align it here before the first paint.
refreshMbasForNetwork(currentNetwork());
// React to network flips: repaint the badge, drop cached electrum client so
// the next read reopens against the new pool, re-seed the catalog, invalidate
// the signer (its cashaddr/tokenaddr are network-flavored), and repaint the
// swap picker + Katalogos.
onNetworkChange((net) => {
const badge = document.querySelector<HTMLElement>('#netBadge .net-lbl');
if (badge) badge.textContent = '● ' + net;
document.querySelectorAll('#netMenu .hm-item').forEach((el) => {
const chip = el as HTMLElement;
const isThis = chip.getAttribute('data-net') === net;
chip.classList.toggle('on', isThis);
const dot = chip.querySelector('.hm-dot'); if (dot) dot.classList.toggle('on', isThis);
});
currentClient?.close(); currentClient = null;
refreshMbasForNetwork(net);
// Locally-created assets are per-user, not per-chain in the current cache
// layout — see [potidaea-mockup-real-dapp] memory. On a chain switch we
// re-load them (they might no-op on mainnet since the wizard is chipnet-
// only for now).
try { mergeKnownMbas(loadLocalKnownMbas()); } catch { /* noop */ }
// Re-derive the active signer with the new address prefix.
if (localSigner) { const reloaded = loadActiveSigner(); if (reloaded) localSigner = reloaded; }
renderChip();
onSignerChanged();
paintMainnetBanner();
});
// Read-only mainnet banner: prepends a soft strip to .content when the user
// is on mainnet, warning that no MBAs are deployed there yet so the swap /
// wizard / publish surfaces are read-only. Removed when they flip back.
function paintMainnetBanner(): void {
const existing = document.getElementById('mainnet-banner');
if (currentNetwork() !== 'mainnet') { existing?.remove(); return; }
if (existing) return;
const el = document.createElement('div');
el.id = 'mainnet-banner';
el.className = 'wc-p1-banner';
el.style.cssText = 'background:linear-gradient(90deg, rgba(255,180,0,.15), rgba(255,180,0,.05)); border-color: rgba(255,180,0,.4); color: var(--ink-bright);';
el.innerHTML = '⚠ <b>Mainnet — view-only.</b> No MBAs are deployed on mainnet yet. Balances + wallets display live; Swap, Wizard, Publish are disabled until the mainnet catalog + KatalogosRegistry ship. Flip back to chipnet in the network chip to trade.';
const content = document.querySelector('.content');
if (content) content.insertBefore(el, content.firstChild);
}
// Boot-time badge + banner sync (the HTML default is "chipnet"; if the user
// reloaded while on mainnet, correct the visible chip label + item selection).
{
const net = currentNetwork();
const badge = document.querySelector<HTMLElement>('#netBadge .net-lbl');
if (badge) badge.textContent = '● ' + net;
document.querySelectorAll('#netMenu .hm-item').forEach((el) => {
const chip = el as HTMLElement;
const isThis = chip.getAttribute('data-net') === net;
chip.classList.toggle('on', isThis);
const dot = chip.querySelector('.hm-dot'); if (dot) dot.classList.toggle('on', isThis);
});
// Defer to DOMContentLoaded so .content exists.
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => paintMainnetBanner());
} else {
paintMainnetBanner();
}
}
// ── UI hookup — the wallet chip in the topbar ───────────────────────────────
// The existing HTML holds a fake chip: <div class="wallet-chip"> with a dot,
// short address, and BCH figure. We replace its contents on every WC state
@ -3466,9 +3544,10 @@ function openImportModal(): void {
if (!wif) { err.textContent = 'WIF required'; err.hidden = false; return; }
const s = importUserWif(wif);
if (!s) { err.textContent = 'Invalid WIF — check for typos'; err.hidden = false; return; }
if (!s.cashaddr.startsWith('bchtest:')) {
const expectedPrefix = addrPrefix() + ':';
if (!s.cashaddr.startsWith(expectedPrefix)) {
clearUserWallet();
err.textContent = 'WIF network does not match the network selected above (address ' + s.cashaddr.slice(0, 10) + '…).';
err.textContent = 'WIF address ' + s.cashaddr.slice(0, 10) + '… does not match the selected network (' + currentNetwork() + ').';
err.hidden = false;
return;
}

View file

@ -0,0 +1,69 @@
// Network selector — single source of truth for "which BCH chain is this
// browser session currently pointed at?". Everything that talks to the chain
// (endpoints, broadcast, cashscript's ElectrumNetworkProvider, address prefix,
// WalletConnect chainId, catalog lookup) reads from here.
//
// Mainnet vs chipnet distinction:
// - chipnet is the default. All existing MBAs (ASM/ASM2), the Katalogos
// registry deployment, the platform-fee dispenser, the DEA yield vault
// — every deployed covenant on this project lives on chipnet.
// - mainnet is READ-ONLY for now: the wallet can display balances, view
// activity, and read Katalogos, but no MBAs are deployed there yet, so
// the swap picker is empty and the wizard is disabled. Broadcast fan-out
// still targets public mainnet Fulcrums so a WC wallet can still push
// any tx it builds itself.
//
// The user can flip networks in the topbar chip. The choice persists in
// localStorage so a reload keeps their pick. Every subscriber refreshes on
// change — balances, catalog, activity, the connected client all rebuild.
export type Network = 'chipnet' | 'mainnet';
const STORAGE_KEY = 'deviant.potidaea.network';
let current: Network = readInitial();
function readInitial(): Network {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw === 'mainnet' || raw === 'chipnet') return raw;
} catch { /* SSR / disabled storage */ }
return 'chipnet';
}
type Listener = (net: Network, previous: Network) => void;
const listeners = new Set<Listener>();
export function currentNetwork(): Network { return current; }
export function setNetwork(net: Network): void {
if (net === current) return;
const previous = current;
current = net;
try { localStorage.setItem(STORAGE_KEY, net); } catch { /* noop */ }
for (const l of listeners) {
try { l(net, previous); } catch (e) { console.error('[network] listener threw:', e); }
}
}
export function onNetworkChange(l: Listener): () => void {
listeners.add(l);
return () => listeners.delete(l);
}
/** Human-readable label for UI chips. Kept here so every surface prints the
* same string. */
export function networkLabel(net: Network = current): string {
return net === 'mainnet' ? 'mainnet' : 'chipnet';
}
/** Cashaddr prefix (no colon). */
export function addrPrefix(net: Network = current): 'bchtest' | 'bitcoincash' {
return net === 'mainnet' ? 'bitcoincash' : 'bchtest';
}
/** cashscript's Network enum values, as strings — passed to
* new ElectrumNetworkProvider(...). */
export function cashscriptNetwork(net: Network = current): 'chipnet' | 'mainnet' {
return net;
}

View file

@ -24,6 +24,7 @@ import {
appendPlatformFeeOutputs, computePlatformFee, PLATFORM_FEE_V1, PLATFORM_ADDRESSES,
type PlatformFeeMath,
} from './platformFee';
import { cashscriptNetwork, currentNetwork, type Network } from './network';
const ART = artifact as unknown as Artifact;
const SHARE_ART = shareArtifact as unknown as Artifact;
@ -52,31 +53,55 @@ export interface KnownMba {
companySplitBps?: bigint;
}
// Static seed — the two long-lived chipnet MBAs the mockup ships with.
// Locally-created assets are merged in at boot via mergeKnownMbas() (see
// genesis.ts loadLocalKnownMbas → main.ts). The map is `let` rather than
// `const Readonly` because that merge mutates it in place.
export const KNOWN_MBAS: Record<string, KnownMba> = {
ASM: {
ticker: 'ASM',
categoryId: '09af8a5b2fc2c9a2449045a8d5a6aa60db493989682eca257a57fa8d75d67231',
rateSats: 100n,
founderFeeBps: 100n,
founderLockingBytecodeHex: '76a9149aa6baabb2bbb1d1940e4ec82b9640a7ab62b8dc88ac',
contractAddress: 'bchtest:pw8ypezzy6gv87tq96ey7p60vx9rgl4v5cq4lseg5luk9ktmn20ysd5k97aey',
contractTokenAddress: 'bchtest:rw8ypezzy6gv87tq96ey7p60vx9rgl4v5cq4lseg5luk9ktmn20ysl82y8uq0',
// Per-chain seed catalogs. Every deployed covenant on this project currently
// lives on chipnet — mainnet is empty until we broadcast the mainnet genesis
// txs with real BCH backing. The exported KNOWN_MBAS is the "current network's
// catalog" for consumer ergonomics; refreshMbasForNetwork() re-seeds it in
// place when the topbar chip flips.
const SEED_MBAS: Record<Network, Record<string, KnownMba>> = {
chipnet: {
ASM: {
ticker: 'ASM',
categoryId: '09af8a5b2fc2c9a2449045a8d5a6aa60db493989682eca257a57fa8d75d67231',
rateSats: 100n,
founderFeeBps: 100n,
founderLockingBytecodeHex: '76a9149aa6baabb2bbb1d1940e4ec82b9640a7ab62b8dc88ac',
contractAddress: 'bchtest:pw8ypezzy6gv87tq96ey7p60vx9rgl4v5cq4lseg5luk9ktmn20ysd5k97aey',
contractTokenAddress: 'bchtest:rw8ypezzy6gv87tq96ey7p60vx9rgl4v5cq4lseg5luk9ktmn20ysl82y8uq0',
},
ASM2: {
ticker: 'ASM2',
categoryId: '8a9f592d4a3bdbc1bc23e57c151434cbebb79bc3a00a07e7f457c9ae8fea2815',
rateSats: 1000n,
founderFeeBps: 100n,
founderLockingBytecodeHex: '76a9149aa6baabb2bbb1d1940e4ec82b9640a7ab62b8dc88ac',
contractAddress: 'bchtest:pdkzq8dq34774s2l7xpw8d8cjjr6sthcfmrknglpe5a76f0vj63ycuqljv9c0',
contractTokenAddress: 'bchtest:rdkzq8dq34774s2l7xpw8d8cjjr6sthcfmrknglpe5a76f0vj63ycwnrn4ypy',
},
},
ASM2: {
ticker: 'ASM2',
categoryId: '8a9f592d4a3bdbc1bc23e57c151434cbebb79bc3a00a07e7f457c9ae8fea2815',
rateSats: 1000n,
founderFeeBps: 100n,
founderLockingBytecodeHex: '76a9149aa6baabb2bbb1d1940e4ec82b9640a7ab62b8dc88ac',
contractAddress: 'bchtest:pdkzq8dq34774s2l7xpw8d8cjjr6sthcfmrknglpe5a76f0vj63ycuqljv9c0',
contractTokenAddress: 'bchtest:rdkzq8dq34774s2l7xpw8d8cjjr6sthcfmrknglpe5a76f0vj63ycwnrn4ypy',
mainnet: {
// No MBAs deployed on mainnet yet. Adding one here means broadcasting a
// real genesis tx with real BCH backing — that's the "founder capital"
// step, not a code change.
},
};
// Static seed for the CURRENT network — the mockup keeps the flat `KNOWN_MBAS`
// map for consumer ergonomics (23 call sites) and re-seeds it in place when
// the topbar chip flips. Locally-created assets are merged in at boot via
// mergeKnownMbas() (see genesis.ts loadLocalKnownMbas → main.ts).
export const KNOWN_MBAS: Record<string, KnownMba> = { ...SEED_MBAS[currentNetwork()] };
/** Re-seed KNOWN_MBAS in place for `net`. Wipes every entry that isn't in the
* new chain's seed — including locally-cached assets from the other chain —
* and re-adds `SEED_MBAS[net]`. Callers should reload the local cache for
* the new network afterwards via loadLocalKnownMbas() to restore user-
* created assets on THIS chain. */
export function refreshMbasForNetwork(net: Network): void {
for (const k of Object.keys(KNOWN_MBAS)) delete KNOWN_MBAS[k];
for (const [k, v] of Object.entries(SEED_MBAS[net])) KNOWN_MBAS[k] = v;
}
/** Merge extra MBAs into KNOWN_MBAS. Later entries win on ticker collision;
* ticker collisions are logged so a locally-created "ASM" doesn't quietly
* replace the chipnet-canonical one. Called at boot with the local cache. */
@ -164,7 +189,7 @@ export async function claimViaWc(
if (!m) throw new Error(`unknown MBA ticker: ${args.ticker}`);
const preview = previewClaim(args.ticker, args.amountUnits);
const provider = new ElectrumNetworkProvider('chipnet');
const provider = new ElectrumNetworkProvider(cashscriptNetwork(currentNetwork()));
const contract = new Contract(
ART,
[txidLE(m.categoryId), m.rateSats, m.founderFeeBps, hexToBin(m.founderLockingBytecodeHex)],
@ -305,7 +330,7 @@ export async function claimViaLocalSigner(
const m = KNOWN_MBAS[args.ticker];
if (!m) throw new Error(`unknown MBA ticker: ${args.ticker}`);
const preview = previewClaim(args.ticker, args.amountUnits);
const provider = new ElectrumNetworkProvider('chipnet');
const provider = new ElectrumNetworkProvider(cashscriptNetwork(currentNetwork()));
const contract = new Contract(
ART,
[txidLE(m.categoryId), m.rateSats, m.founderFeeBps, hexToBin(m.founderLockingBytecodeHex)],
@ -385,7 +410,7 @@ export async function claimViaLocalSignerShare(
const platformFee = computePlatformFee(grossSats, PLATFORM_FEE_V1);
const totalBchNeeded = grossSats + feeSats + platformFee.emittedTotal + MINER_FEE + DUST;
const provider = new ElectrumNetworkProvider('chipnet');
const provider = new ElectrumNetworkProvider(cashscriptNetwork(currentNetwork()));
const companyLB = hexToBin(m.founderLockingBytecodeHex); // Share stashes company LB in the "founder" slot
const distributionLB = hexToBin(m.distributionLockingBytecodeHex);
const contract = new Contract(
@ -471,7 +496,7 @@ export async function redeemViaLocalSigner(
if (!m) throw new Error(`unknown MBA ticker: ${args.ticker}`);
if (args.amountUnits < 1n) throw new Error('amount too small');
const provider = new ElectrumNetworkProvider('chipnet');
const provider = new ElectrumNetworkProvider(cashscriptNetwork(currentNetwork()));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const own = (await provider.getUtxos(signer.cashaddr)) as any[];
const tokens = own.filter((u) => u.token?.category === m.categoryId && !u.token?.nft);

View file

@ -40,6 +40,7 @@ import {
CashAddressNetworkPrefix,
} from '@bitauth/libauth';
import type { Signer } from './signer-store';
import { cashscriptNetwork, currentNetwork } from './network';
const ART = artifact as unknown as Artifact;
@ -110,7 +111,7 @@ export interface PublishResult {
export class PublishError extends Error {
constructor(message: string, readonly code:
| 'no-state-utxo' | 'insufficient-dea' | 'insufficient-bch' | 'invalid-category' | 'other',
| 'no-state-utxo' | 'insufficient-dea' | 'insufficient-bch' | 'invalid-category' | 'wrong-network' | 'other',
) {
super(message);
this.name = 'PublishError';
@ -255,11 +256,14 @@ export async function publishViaLocalSigner(
targetCategoryId: string,
metadata?: PublishMetadata,
): Promise<PublishResult> {
if (currentNetwork() !== 'chipnet') {
throw new PublishError('Katalogos not deployed on mainnet yet — publish is chipnet-only', 'wrong-network');
}
if (!/^[0-9a-f]{64}$/i.test(targetCategoryId)) {
throw new PublishError('categoryId must be 64 hex chars', 'invalid-category');
}
const provider = new ElectrumNetworkProvider('chipnet');
const provider = new ElectrumNetworkProvider(cashscriptNetwork(currentNetwork()));
// Reconstruct the covenant so we can spend its state UTXO. Constructor
// args mirror shells/app/src/core/katalogos.ts.

View file

@ -25,6 +25,7 @@
import { ElectrumClient, fetchAddressUtxos, fetchTransactionHex } from './electrum';
import { decodeTransaction, hexToBin as laHexToBin, binToHex as laBinToHex } from '@bitauth/libauth';
import type { KnownMba } from './orbital';
import { cashscriptNetwork, currentNetwork } from './network';
/** One live chipnet registry version — mirrors shells/app/src/core/katalogos.ts
* KATALOGOS_VERSIONS.chipnet[0]. Update in lockstep with that file (or add
@ -291,7 +292,7 @@ export async function discoveredParamsToKnownMba(
params: DiscoveredParams,
): Promise<KnownMba | null> {
const { Contract, ElectrumNetworkProvider } = await import('cashscript');
const provider = new ElectrumNetworkProvider('chipnet');
const provider = new ElectrumNetworkProvider(cashscriptNetwork(currentNetwork()));
const catLE = categoryToConsensusLE(entry.categoryId);
const displayTicker = params.ticker ?? ('K:' + entry.categoryId.slice(0, 6));

View file

@ -20,12 +20,16 @@
// which mode it came from.
//
// SAFETY POSTURE FOR USER MODE:
// - Chipnet only. Address prefix hard-coded to bchtest.
// - Both chipnet and mainnet are supported. Signer.cashaddr / tokenaddr are
// encoded for whichever network is currently active; the underlying key
// material is chain-agnostic. A network switch re-derives the addresses
// from the same WIF.
// - Every stored WIF stays in this browser's localStorage. No network
// sync, no upload path.
// - No backup / no recovery. The Create modal shows the WIF once at
// generation for copy-out. Losing browser storage = losing all keys.
// - Not for mainnet. The mockup itself lives on chipnet endpoints.
// - Mainnet holds no MBAs today (nothing deployed there yet), so mainnet
// addresses are read-only until the mainnet catalog + registry ships.
//
// The dev-mode single-slot design is preserved so existing automated
// smoke tests keep working with their pinned WIF; the user-mode multi-
@ -43,6 +47,7 @@ import {
CashAddressType,
CashAddressNetworkPrefix,
} from '@bitauth/libauth';
import { currentNetwork, type Network } from './network';
export type SignerMode = 'dev' | 'user';
@ -76,14 +81,18 @@ function isLocalhost(): boolean {
return h === 'localhost' || h === '127.0.0.1' || h === '';
}
function encAddr(pkh: Uint8Array, type: CashAddressType): string {
const res = encodeCashAddress({ prefix: CashAddressNetworkPrefix.testnet, type, payload: pkh });
function encAddr(pkh: Uint8Array, type: CashAddressType, net: Network): string {
const prefix = net === 'mainnet' ? CashAddressNetworkPrefix.mainnet : CashAddressNetworkPrefix.testnet;
const res = encodeCashAddress({ prefix, type, payload: pkh });
if (typeof res === 'string') return res;
// @ts-expect-error runtime shape check
return res.address ?? String(res);
}
/** Build a Signer from a WIF string. Returns null on decode failure. */
/** Build a Signer from a WIF string. Returns null on decode failure. The
* Signer's cashaddr / tokenaddr are encoded for the CURRENT network — a
* network switch invalidates the addresses, so main.ts must re-derive the
* active Signer after a network flip. */
export function signerFromWif(wif: string, mode: SignerMode): Signer | null {
const dec = decodePrivateKeyWif(wif.trim());
if (typeof dec === 'string') { console.warn('[signer] WIF decode failed:', dec); return null; }
@ -91,13 +100,14 @@ export function signerFromWif(wif: string, mode: SignerMode): Signer | null {
const publicKey = secp256k1.derivePublicKeyCompressed(privateKey);
if (typeof publicKey === 'string') { console.warn('[signer] pk derive failed:', publicKey); return null; }
const publicKeyHash = ripemd160.hash(sha256.hash(publicKey));
const net = currentNetwork();
return {
mode,
privateKey,
publicKey,
publicKeyHash,
cashaddr: encAddr(publicKeyHash, CashAddressType.p2pkh),
tokenaddr: encAddr(publicKeyHash, CashAddressType.p2pkhWithTokens),
cashaddr: encAddr(publicKeyHash, CashAddressType.p2pkh, net),
tokenaddr: encAddr(publicKeyHash, CashAddressType.p2pkhWithTokens, net),
};
}
@ -224,11 +234,13 @@ export function renameUserWallet(cashaddr: string, newLabel: string): boolean {
return true;
}
/** Generate a fresh chipnet key + WIF, append + activate, return both. The
* Create modal shows the WIF once for copy-out — the only backup chance. */
/** Generate a fresh key + WIF, append + activate, return both. WIF flavor
* follows the current network (mainnet or testnet); the same private key
* imports fine on either chain because the WIF prefix does not affect the
* key material — it only tells wallets which chain the creator intended. */
export function createUserWallet(label?: string): { signer: Signer; wif: string; label: string } | null {
const privateKey = generatePrivateKey();
const wif = encodePrivateKeyWif(privateKey, 'testnet');
const wif = encodePrivateKeyWif(privateKey, currentNetwork() === 'mainnet' ? 'mainnet' : 'testnet');
const s = signerFromWif(wif, 'user');
if (!s) return null;
const store = readStore();

View file

@ -37,9 +37,16 @@ export interface PoolConfig {
/** Known public WSS Fulcrum endpoints. VERIFY before mainnet use; first slot = our own. */
export const DEFAULT_ENDPOINTS: Record<'chipnet' | 'mainnet', SpvEndpoint[]> = {
chipnet: [
// { url: 'wss://our-endpoint.example:50004', label: 'deviant (own)', own: true },
// Local desktop Fulcrum (dev only) — points at this machine's BCHN chipnet
// node + Fulcrum on 127.0.0.1:50003. Kept behind Vite DEV so production
// bundles do not carry a URL only the developer's browser can reach.
...(import.meta.env?.DEV ? [{ url: 'ws://127.0.0.1:50003', label: 'localhost (dev)', own: true }] : []),
// Silentmode VPS Fulcrum — first "own" slot for production; fronts the
// already-running BCHN chipnet node on that box once Fulcrum is installed.
{ url: 'wss://chipnet.silentmode.st:50012', label: 'deviant (own)', own: true },
{ url: 'wss://chipnet.bch.ninja:50004', label: 'bch.ninja' },
{ url: 'wss://chipnet.imaginary.cash:50004', label: 'imaginary' },
{ url: 'wss://coinspectrum.duckdns.org:50012', label: 'coinspectrum (fallback)' },
],
mainnet: [
// { url: 'wss://our-endpoint.example:50004', label: 'deviant (own)', own: true },