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:
parent
e66a8d6c8a
commit
5465029321
3 changed files with 412 additions and 0 deletions
120
design/potidaea/src/oracleClient.ts
Normal file
120
design/potidaea/src/oracleClient.ts
Normal 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);
|
||||
}
|
||||
196
scripts/mainnet-katalogos-deploy.mjs
Normal file
196
scripts/mainnet-katalogos-deploy.mjs
Normal 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); });
|
||||
96
wiki/infra/oracle-service.md
Normal file
96
wiki/infra/oracle-service.md
Normal 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.
|
||||
Loading…
Add table
Reference in a new issue