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:
parent
f7ec956d6e
commit
5f852b945e
11 changed files with 330 additions and 56 deletions
|
|
@ -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){
|
||||
|
|
|
|||
|
|
@ -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). */
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
69
design/potidaea/src/network.ts
Normal file
69
design/potidaea/src/network.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue