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>
196 lines
9.2 KiB
JavaScript
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); });
|