deviant/scripts/mainnet-katalogos-deploy.mjs
Local Dev 5465029321 feat(mainnet-infra): oracle client + Katalogos deploy script + design doc
Three artifacts the mainnet launch needs, scoped tightly:

design/potidaea/src/oracleClient.ts
  Replaces the baked chipnet dev signing key for mainnet. fetchOracleAttestation()
  pulls a fresh AnyHedge-format signed BCH/USD message from oracles.cash
  (General Protocols' production BCH oracle network — the same signer AnyHedge
  uses), verifies the Schnorr signature client-side against the pinned pubkey
  before handing it to the covenant. Chipnet keeps its publish.ts baked dev
  key (dev environment, no economic bearing). Mainnet pubkey slot is
  INTENTIONALLY empty — any call hard-throws until deploy, which kills the
  'chipnet-sponsor-key-classifier-block' concern for mainnet builds.

scripts/mainnet-katalogos-deploy.mjs
  One-shot broadcast of KatalogosRegistryV1 on BCH mainnet. Refuses to run
  unless .mainnet-katalogos-config.json supplies real (non-placeholder)
  values for feeCategoryId / oraclePubKeyHex / treasuryLB / deaRateSats.
  Interactive 'MAINNET DEPLOY' confirmation gate (or MAINNET_KATALOGOS_YES=1
  for CI). Writes MainnetTests/.mainnet-katalogos.json with the resulting
  (txid, registryCategoryId, contractAddress) and prints paste-ready config
  for KATALOGOS_VERSIONS.mainnet in shells/app/src/core/katalogos.ts.

wiki/infra/oracle-service.md
  Design doc explaining the Path-A choice (consume oracles.cash) vs
  Path-B (self-hosted oracle, future option), the rotation policy
  (operator-driven; we redeploy on their pubkey change), and the
  "do NOT sign messages locally on mainnet" guardrails. Links from
  publish.ts's dev-key constant and the Katalogos deploy script.

Depends on: feat(network) [5f852b9] — oracleClient.ts imports currentNetwork
from the network module.

Follow-up (requires real BCH + external coordination):
  - set the oracles.cash BCH/USD pubkey in oracleClient.ts
  - mint a mainnet DEA category (feeCategoryId prereq)
  - run scripts/mainnet-katalogos-deploy.mjs with the real config
  - paste the result into KATALOGOS_VERSIONS.mainnet
  - refactor publish.ts to consume fetchOracleAttestation() on mainnet

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-10-09 00:21:13 +02:00

196 lines
9.2 KiB
JavaScript

#!/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": <integer sats-per-atomic DEA>,
* "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); });