diff --git a/design/potidaea/src/oracleClient.ts b/design/potidaea/src/oracleClient.ts new file mode 100644 index 0000000..778f806 --- /dev/null +++ b/design/potidaea/src/oracleClient.ts @@ -0,0 +1,120 @@ +// Oracle client — fetches a fresh AnyHedge-format signed BCH/USD message for +// the KatalogosRegistry publish flow on mainnet. Verifies the signature +// client-side against the pinned oracle pubkey before handing it to the +// covenant, so a spoofed relay can't drift our published fees. +// +// Chipnet keeps its hardcoded dev signing key in publish.ts (dev environment, +// no real economic bearing). Mainnet routes through this module and never +// signs its own oracle messages — see wiki/infra/oracle-service.md for +// rationale and rotation policy. + +import { secp256k1, sha256 as laSha256 } from '@bitauth/libauth'; +import { currentNetwork } from './network'; + +/** An AnyHedge-format 16-byte oracle price message + its schnorr signature. */ +export interface OracleAttestation { + /** 16 bytes: timestamp(4) sequence(4) contentSequence(4) satsPerUsd(4), all LE. */ + message: Uint8Array; + /** 64-byte schnorr signature over sha256(message). */ + signature: Uint8Array; + /** Decoded satsPerUsd (bytes 12..16), for the UI to preview the fee. */ + satsPerUsd: bigint; +} + +/** Pinned pubkeys per chain. Chipnet's is the dev key baked into publish.ts + * and matches its signing counterpart. Mainnet's is INTENTIONALLY empty + * until we lock in the exact oracles.cash BCH/USD pubkey at KatalogosRegistry + * deploy time — an empty entry hard-throws below, which is the desired safety + * posture (build should never quietly ship an unverifiable mainnet oracle). */ +const ORACLE_PUBKEY_HEX: Record<'chipnet' | 'mainnet', string> = { + chipnet: '023eafff70c2d019cb430d4f769441320a133f70e6ca01a0fc30d30c8245f459dd', + mainnet: '', +}; + +/** Endpoints to fetch a signed price message from. Mainnet points at + * oracles.cash relays; chipnet returns null (chipnet builds its own message + * in publish.ts). Multiple entries → first-success wins with a 5s timeout. */ +const ORACLE_ENDPOINTS: Record<'chipnet' | 'mainnet', readonly string[]> = { + chipnet: [], + mainnet: [ + // PLACEHOLDER — replace with the actual oracles.cash relay URLs at deploy. + // Documented shape: GET returns JSON { message: '', signature: '' } + // where message is the 16-byte AnyHedge shape. + 'https://oracles.cash/api/v1/oracles/bch-usd/latest', + ], +}; + +const FETCH_TIMEOUT_MS = 5_000; + +function hexToBin(h: string): Uint8Array { + const c = h.startsWith('0x') ? h.slice(2) : h; + const out = new Uint8Array(c.length / 2); + for (let i = 0; i < out.length; i++) out[i] = parseInt(c.slice(i * 2, i * 2 + 2), 16); + return out; +} + +async function fetchWithTimeout(url: string, timeoutMs: number): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + return await fetch(url, { signal: controller.signal }); + } finally { clearTimeout(timer); } +} + +/** Fetch a fresh oracle attestation from the configured relays for the given + * network. Verifies the signature against the pinned pubkey before returning. + * Throws on all-relays-failed or on signature mismatch. */ +export async function fetchOracleAttestation(): Promise { + const net = currentNetwork(); + const endpoints = ORACLE_ENDPOINTS[net]; + if (endpoints.length === 0) { + throw new Error(`no oracle endpoints configured for network=${net} — use the dev signer path`); + } + const pubkeyHex = ORACLE_PUBKEY_HEX[net]; + if (!pubkeyHex || !/^[0-9a-f]{66}$/i.test(pubkeyHex)) { + throw new Error(`ORACLE_PUBKEY_HEX[${net}] is unset or malformed — set the real 33-byte compressed pubkey before publishing`); + } + const pubkey = hexToBin(pubkeyHex); + + const errors: string[] = []; + for (const url of endpoints) { + try { + const res = await fetchWithTimeout(url, FETCH_TIMEOUT_MS); + if (!res.ok) { errors.push(`${url} → http ${res.status}`); continue; } + const body = await res.json() as { message?: string; signature?: string }; + if (!body.message || !body.signature) { + errors.push(`${url} → missing message/signature in response`); continue; + } + const message = hexToBin(body.message); + const signature = hexToBin(body.signature); + if (message.length !== 16) { errors.push(`${url} → message length ${message.length}, want 16`); continue; } + if (signature.length !== 64) { errors.push(`${url} → signature length ${signature.length}, want 64`); continue; } + + const digest = laSha256.hash(message); + const ok = secp256k1.verifySignatureSchnorr(signature, pubkey, digest); + if (!ok) { errors.push(`${url} → signature does not verify under pinned pubkey`); continue; } + + // Decode satsPerUsd for UI preview (bytes 12..16, uint32-LE). + const dv = new DataView(message.buffer, message.byteOffset, message.byteLength); + const satsPerUsd = BigInt(dv.getUint32(12, true)); + + return { message, signature, satsPerUsd }; + } catch (e) { + errors.push(`${url} → ${(e as Error).message ?? e}`); + } + } + throw new Error(`no oracle attestation available: ${errors.join(' | ')}`); +} + +/** Returns the pinned pubkey for the current network — needed at + * KatalogosRegistry constructor time when re-deriving the contract address + * client-side. Throws if the mainnet placeholder is still unset (build gate + * against shipping a non-verifying config). */ +export function pinnedOraclePubkey(): Uint8Array { + const net = currentNetwork(); + const hex = ORACLE_PUBKEY_HEX[net]; + if (!hex || !/^[0-9a-f]{66}$/i.test(hex)) { + throw new Error(`oracle pubkey for network=${net} is unset or malformed`); + } + return hexToBin(hex); +} diff --git a/scripts/mainnet-katalogos-deploy.mjs b/scripts/mainnet-katalogos-deploy.mjs new file mode 100644 index 0000000..c37f65b --- /dev/null +++ b/scripts/mainnet-katalogos-deploy.mjs @@ -0,0 +1,196 @@ +#!/usr/bin/env node +/** + * mainnet-katalogos-deploy.mjs — deploy KatalogosRegistryV1 on BCH MAINNET. + * + * Companion to scripts/chipnet-katalogos-register.mjs, but stripped to a + * single job: broadcast the registry-genesis transaction on mainnet with + * real config. Does NOT then test-register categories — that comes after + * the deploy succeeds and the resulting addresses are pinned into + * shells/app/src/core/katalogos.ts (KATALOGOS_VERSIONS.mainnet) + * design/potidaea/src/publish.ts (MAINNET_REGISTRY, when added) + * + * REFUSES TO RUN unless every mainnet parameter is a real, non-placeholder + * value — this is the [chipnet-sponsor-key-classifier-block] guard applied + * to KatalogosRegistry deployment. + * + * Requires config file .mainnet-katalogos-config.json at repo root: + * { + * "walletFile": ".mainnet-keys-a.json", + * "feeCategoryId": "<64-hex — the mainnet DEA CashToken category>", + * "deaRateSats": , + * "oraclePubKeyHex": "02… (33-byte compressed, e.g. oracles.cash BCH/USD)", + * "treasuryLB": "76a914… (25-byte P2PKH locking bytecode hex)" + * } + * + * All five are load-bearing; a missing or placeholder value aborts BEFORE any + * network call. Nothing gets deployed until the operator confirms interactively. + * + * Run: + * node --import tsx scripts/mainnet-katalogos-deploy.mjs + * + * See wiki/infra/oracle-service.md for the oracle-pubkey rationale. + */ +import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { createInterface } from 'node:readline'; +import { Wallet } from 'mainnet-js'; +import { + Contract, ElectrumNetworkProvider, TransactionBuilder, SignatureTemplate, +} from 'cashscript'; +import { secp256k1, sha256 as laSha256 } from '@bitauth/libauth'; + +const ROOT = join(import.meta.dirname, '..'); +const CONFIG = join(ROOT, '.mainnet-katalogos-config.json'); +const OUT_DIR = join(ROOT, 'MainnetTests'); +const OUT = join(OUT_DIR, '.mainnet-katalogos.json'); +const ARTIFACT = join(ROOT, 'contracts/dist/KatalogosRegistryV1.json'); + +const DUST = 1000n; +const MINER_FEE = 3000n; + +const hexToBin = (h) => { const c = h.startsWith('0x') ? h.slice(2) : h; const o = new Uint8Array(c.length / 2); for (let i = 0; i < o.length; i++) o[i] = parseInt(c.slice(i*2, i*2+2), 16); return o; }; +const binToHex = (b) => Array.from(b, (x) => x.toString(16).padStart(2, '0')).join(''); +const isHex = (s, bytes) => typeof s === 'string' && s.length === bytes * 2 && /^[0-9a-f]+$/i.test(s); + +function loadConfig() { + if (!existsSync(CONFIG)) { + console.error(`missing config: ${CONFIG}`); + console.error('create it with the shape shown at the top of this script.'); + process.exit(2); + } + const cfg = JSON.parse(readFileSync(CONFIG, 'utf8')); + const errs = []; + if (typeof cfg.walletFile !== 'string' || !cfg.walletFile) errs.push('walletFile: missing string'); + if (!isHex(cfg.feeCategoryId, 32)) errs.push('feeCategoryId: must be 64 hex chars'); + if (!isHex(cfg.oraclePubKeyHex, 33)) errs.push('oraclePubKeyHex: must be 66 hex chars (33-byte compressed)'); + if (!isHex(cfg.treasuryLB, 25)) errs.push('treasuryLB: must be 50 hex chars (25-byte P2PKH LB)'); + if (typeof cfg.deaRateSats !== 'number' || cfg.deaRateSats <= 0) errs.push('deaRateSats: must be positive integer'); + if (errs.length) { + console.error('config validation failed:'); + for (const e of errs) console.error(' -', e); + process.exit(2); + } + return { + walletFile: cfg.walletFile, + feeCategoryId: cfg.feeCategoryId.toLowerCase(), + deaRateSats: BigInt(cfg.deaRateSats), + oraclePubKeyHex: cfg.oraclePubKeyHex.toLowerCase(), + treasuryLB: cfg.treasuryLB.toLowerCase(), + }; +} + +async function confirmInteractive(prompt) { + if (process.env.MAINNET_KATALOGOS_YES === '1') { console.log(prompt + '\n MAINNET_KATALOGOS_YES=1 — proceeding non-interactively.'); return true; } + const rl = createInterface({ input: process.stdin, output: process.stdout }); + return new Promise((resolve) => { + rl.question(prompt + '\n Type "MAINNET DEPLOY" to proceed: ', (a) => { rl.close(); resolve(a.trim() === 'MAINNET DEPLOY'); }); + }); +} + +async function main() { + const cfg = loadConfig(); + const walletJson = JSON.parse(readFileSync(join(ROOT, cfg.walletFile), 'utf8')); + const wallet = await Wallet.fromWIF(walletJson.wif); + const provider = new ElectrumNetworkProvider('mainnet'); + const artifact = JSON.parse(readFileSync(ARTIFACT, 'utf8')); + + console.log('══ Mainnet KatalogosRegistryV1 deploy ══════════════════════════'); + console.log(' wallet: ', wallet.cashaddr); + console.log(' feeCategoryId: ', cfg.feeCategoryId); + console.log(' deaRateSats: ', cfg.deaRateSats.toString()); + console.log(' oraclePubKeyHex: ', cfg.oraclePubKeyHex); + console.log(' treasuryLB: ', cfg.treasuryLB); + + const utxos = await provider.getUtxos(wallet.cashaddr); + const plain = utxos.filter((u) => !u.token && BigInt(u.satoshis) >= DUST * 3n + MINER_FEE); + if (plain.length === 0) { + console.error('no plain BCH UTXO with enough sats for genesis — top up the wallet'); + process.exit(3); + } + const funding = plain.sort((a, b) => Number(BigInt(b.satoshis) - BigInt(a.satoshis)))[0]; + // categoryId = the txid of the tx spending this vout=0 output. If funding.vout != 0 + // we split first. + if (funding.vout !== 0) { + console.error('funding UTXO is not vout=0; run a split-to-vout-0 helper first'); + console.error(' (see scripts/chipnet-katalogos-register.mjs eligibleVout0() for the pattern)'); + process.exit(3); + } + + // Predict categoryId (= txid of THIS deploy tx, computed after the tx is + // built but before broadcast). Cashscript's TransactionBuilder handles + // this — we pass a placeholder to Contract() and update after build. + // In practice for a covenant whose constructor doesn't reference its OWN + // category, we can Contract() BEFORE the deploy tx — that's this case. + const contract = new Contract( + artifact, + [BigInt(cfg.deaRateSats), hexToBin(cfg.oraclePubKeyHex), hexToBin(cfg.treasuryLB)], + { provider }, + ); + console.log(' contract address: ', contract.address); + console.log(' contract tokenaddr:', contract.tokenAddress); + console.log(' funding utxo: ', `${funding.txid}:${funding.vout} ${funding.satoshis} sats`); + console.log(''); + console.log('ONCE BROADCAST, THIS TX IS PERMANENT ON MAINNET.'); + console.log('The category id (= this tx\'s txid) becomes the registryCategoryId; the'); + console.log('constructor args baked in above are the covenant\'s identity.'); + + if (!await confirmInteractive(' Proceed with mainnet deploy?')) { + console.log('aborted.'); process.exit(1); + } + + const sig = new SignatureTemplate(walletJson.wif); + const tb = new TransactionBuilder({ provider }); + tb.addInput(funding, sig.unlockP2PKH()); + // Registry minting NFT to the covenant's token address, plus a reservoir dust. + const change = BigInt(funding.satoshis) - DUST - MINER_FEE; + tb.addOutput({ + to: contract.tokenAddress, + amount: DUST, + token: { amount: 0n, category: funding.txid, nft: { capability: 'minting', commitment: '' } }, + }); + if (change >= DUST) tb.addOutput({ to: wallet.cashaddr, amount: change }); + + const rawTxHex = tb.build(); + console.log('\ntx built. broadcasting…'); + const sent = await tb.send(); + const txid = typeof sent === 'string' ? sent : (sent.txid ?? sent.txId); + if (!txid) { console.error('broadcast returned no txid'); process.exit(4); } + + console.log('\n✓ deploy landed:'); + console.log(' txid: ', txid); + console.log(' registryCategoryId:', txid); + console.log(' contractAddress: ', contract.address); + console.log(' contractTokenAddr: ', contract.tokenAddress); + + // Write the reproducibility record. + mkdirSync(OUT_DIR, { recursive: true }); + const record = { + deployedAt: new Date().toISOString(), + label: 'mainnet-v1', + ...cfg, + deaRateSats: Number(cfg.deaRateSats), + txid, + registryCategoryId: txid, + contractAddress: contract.address, + contractTokenAddress: contract.tokenAddress, + rawTxHex, + }; + writeFileSync(OUT, JSON.stringify(record, null, 2) + '\n'); + console.log(`\nwritten: ${OUT}`); + + console.log('\n──────────────────── paste into katalogos.ts ────────────────────'); + console.log('mainnet: [{'); + console.log(` label: 'mainnet-v1',`); + console.log(` contractAddress: '${contract.address}',`); + console.log(` contractTokenAddress: '${contract.tokenAddress}',`); + console.log(` registryCategoryId: '${txid}',`); + console.log(` feeCategoryId: '${cfg.feeCategoryId}',`); + console.log(` writable: true,`); + console.log(` deaRateSats: ${cfg.deaRateSats}n,`); + console.log(` oraclePubKeyHex: '${cfg.oraclePubKeyHex}',`); + console.log(` treasuryLockingBytecodeHex: '${cfg.treasuryLB}',`); + console.log('}],'); + console.log('──────────────────────────────────────────────────────────────────'); +} + +main().catch((e) => { console.error('deploy failed:', e); process.exit(10); }); diff --git a/wiki/infra/oracle-service.md b/wiki/infra/oracle-service.md new file mode 100644 index 0000000..8be1a09 --- /dev/null +++ b/wiki/infra/oracle-service.md @@ -0,0 +1,96 @@ +# Oracle service — mainnet BCH/USD signed feed + +**Status:** proposed for mainnet launch. Chipnet keeps its hardcoded dev key +([publish.ts:63](../../design/potidaea/src/publish.ts:63) `CHIPNET_DEV_ORACLE_PRIV_HEX`). +Mainnet MUST NOT ship with any signing key baked into the bundle — the +[chipnet-sponsor-key-classifier-block](../../CLAUDE.md) memory blocks such builds +under "Credential Leakage" and the block is load-bearing. + +## What the covenant needs + +`KatalogosRegistryV1.register()` reads a signed AnyHedge-format oracle message +via `OP_CHECKDATASIG`: + +- 16-byte message: uint32 LE for timestamp / sequence / contentSequence / **price**. +- The covenant only reads the last 4 bytes (bytes 12..16) — the price in sats-per-USD. +- Signature is Schnorr over `sha256(message)`. +- The `oraclePubKeyHex` is a **constructor arg** baked into the deployed contract's + address, so changing oracles later means redeploying and migrating users to the new + contract address. + +The oracle choice is therefore load-bearing at deploy time. + +## Choice — Path A: consume oracles.cash + +[`oracles.cash`](https://oracles.cash) is General Protocols' production BCH oracle +network — the same one AnyHedge uses on mainnet. Format is exactly what our +covenant already reads. Pubkeys are per-oracle and stable; the operator's fee +policy is documented in their pubkey-signed announcements. + +**Advantages:** + +- Proven adversarial resistance — AnyHedge has ~$M in TVL riding on it. +- Zero infrastructure for us. +- No key management. Our covenant just quotes a pubkey we don't control; oracles.cash + signs, we relay. +- Matches [`oracles-cash-bch-oracle-network`](../../CLAUDE.md) memory — already the + project's default for FIATm/BCHm and AnyHedge integrations. + +**Trade-off:** we don't sign the messages, so we can't unilaterally set the price. +That's the point — an oracle we control is not an oracle, it's a knob. + +## Choice — Path B: self-hosted oracle (future option) + +A dedicated box that reads BCH/USD from a set of exchange feeds, medians them, +and signs the AnyHedge-format message with a key rotated monthly. Full sovereignty; +requires operational discipline and a signing key that MUST live on an isolated +host, never in the dApp bundle, never in the covenant deploy script. + +Only meaningful if oracles.cash goes down or a policy change (fee raise, restriction) +makes us want out. Not in scope for mainnet launch. + +## Deployment steps + +1. **Fetch oracles.cash's BCH/USD pubkey** — the one whose signatures we want to + accept. Query `https://oracles.cash/api/v1/oracles` (or read their published + announcement) and pin the specific 33-byte compressed pubkey we're endorsing. +2. **Deploy `KatalogosRegistryV1` on mainnet** with that pubkey in place of the + dev key. Broadcast script: [`scripts/mainnet-katalogos-deploy.mjs`](../../scripts/mainnet-katalogos-deploy.mjs) + (draft — task #37). +3. **Wire the client** — `design/potidaea/src/oracleClient.ts` fetches a fresh + signed message from oracles.cash's WSS endpoint at publish time, verifies + the signature client-side against the pinned pubkey, and hands the message + + sig to `publishViaLocalSigner()`. +4. **Delete `CHIPNET_DEV_ORACLE_PRIV_HEX`** from mainnet code paths. Chipnet + keeps it (chipnet's a dev environment; dev signing keys are the point). + +## Rotation policy (informational — oracles.cash sets its own) + +We do not rotate; the operator does. What we do: + +- Monitor oracles.cash's pubkey announcement channel for rotation events. +- If the operator rotates their signing key, we redeploy `KatalogosRegistryV1` + with the new pubkey and migrate `KATALOGOS_VERSIONS.mainnet` to include both + the old and the new contract instance — old entries stay readable, new + publishes go to the new address. + +## Fallback: no oracle available at publish time + +`register()` on the covenant fails without a fresh signed message. The client +should: + +- Fetch with a 5s timeout. On failure, retry twice against alternate + oracles.cash relays. +- On persistent failure, surface a "price feed unavailable" toast — the user + can retry. The covenant guarantees no partial publish state. + +## What NOT to do + +- **Do not sign messages locally on mainnet.** The classifier auto-block is + correct — a hex key in build inputs eventually leaks. Every leaked key + turns into a locked-in oracle drift the covenant can't override. +- **Do not use a "test oracle" on mainnet.** Users seeing $1 = 200,000 bits + when the real market says $1 = 210,000 bits will file a bug within an + hour. Precision matters here. +- **Do not accept multi-signer / weighted oracle designs at launch.** The + covenant is single-pubkey. Multi-oracle is a v2 covenant rewrite.