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>
This commit is contained in:
Local Dev 2026-10-09 00:21:13 +02:00
parent e66a8d6c8a
commit 5465029321
3 changed files with 412 additions and 0 deletions

View file

@ -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: '<hex>', signature: '<hex>' }
// 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<Response> {
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<OracleAttestation> {
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);
}

View file

@ -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": <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); });

View file

@ -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.