feat(potidaea): real Katalogos Publish broadcast · TST431 registered on chipnet

Stage C of the create→list arc. The wizard's "Publish for \$1 in DEA"
button (and every other openPublishOverlay entry) now broadcasts a real
KatalogosRegistryV1.register() transaction via the local signer.

src/publish.ts (new)
  publishViaLocalSigner(signer, categoryId) builds the four-output
  register() tx:
    inputs   [0] state UTXO (minting NFT) at registry contract,
                 unlocked via covenant register(targetCategory,
                 oracleSig, oracleMessage)
             [1+] DEA UTXOs from signer (≥ feeDea)
             [N+] plain BCH from signer for miner fee + dust
    outputs  [0] state UTXO re-created (minting NFT preserved)
             [1] entry NFT (immutable, commitment = target category)
             [2] fee to treasury (≥ 231 atomic DEA at chipnet rate)
             [3+] DEA change (if any) + BCH change
  Oracle: chipnet dev key (baked, matches shells/app/src/core/
  katalogos-oracle-dev.ts). Signs a 16-byte AnyHedge-shape message
  with satsPerUsd=200,000. feeDea = ceil(satsPerUsd / deaRateSats)
  = 231 (matches the covenant's ceil-div on-chain).
  Address helper encodes the treasury LB as p2pkhWithTokens
  (bchtest:z…) because cashscript refuses a token output to a
  non-token-aware address even when the underlying locking bytecode
  is identical.

  PublishError typed codes (insufficient-dea/insufficient-bch/
  no-state-utxo/invalid-category/other) so main.ts can render
  actionable messages.

src/main.ts
  wirePublishInterceptor monkey-patches window.openPublishOverlay so
  EVERY entry (post-genesis button, Katalogos row, Stoa link) routes
  through the real broadcast when a local signer is attached;
  falls back to the mockup's illustrative overlay otherwise.
  runRealPublish renders a small scrim, calls publishViaLocalSigner,
  refreshes the registry read on success so the count updates
  immediately. On PublishError('insufficient-dea') it prints the
  signer address so the user knows where to send DEA.

contracts/ArtemisionMintFT.cash
  Family-doc header refreshed with the three MBA Shares siblings
  chipnet-proven this session: DeviantShareMBA + DistributionVault +
  TermCouponVault. Enumerates the three Artemision presets that
  compose them (rewards / shares+dividends / t-bond).

Bug fixes along the way (verified before publish landed):
  • DEA change output uses signer.tokenaddr (was cashaddr → cashscript
    "no token support" error).
  • register()'s targetCategory arg passed as display-order bytes to
    match the commitment cashscript writes into the NFT — NOT the
    consensus-LE reversal used for constructor args like
    registryCategory. Byte-order mismatch surfaced as
    "require(tx.outputs[1].nftCommitment == targetCategory)" failing.

SMOKE-TESTED ON CHIPNET (Wallet D, TST431 from prior genesis smoke)
  Attach Wallet D → openPublishOverlay('TST431')
  Publish tx: 27017650fa58c4f5635a2293041d82f0c7e184b46b4b1abd0586cfe95e89c134
  Category:   2dd5af3fb0660c23d8aa96a44edf8d33eb5ab838f46573e6df543fab94997a49
  Fee:        231 atomic DEA (\$1 at chipnet dev-oracle rate)
  Registry count after reload: 8 → 9 · window.potidaeaRegistry now
  includes TST431's category id.

WHAT WORKS  ·  WHAT REMAINS
  ✓  Create MBA → swap MBA (same-user, from genesis smoke ed73…)
  ✓  Publish MBA → Katalogos v1 (cross-user visibility of the id)
  •  Cross-user metadata for swap: v2 registry with params on-chain
     per ADR-0055 Amendment 2026-09-02 (readers see the id today,
     but need rate/fee/founderLB to build a claim tx).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Local Dev 2026-09-06 18:35:41 +02:00
parent 7b5952b594
commit 1d00d2d115
4 changed files with 525 additions and 2 deletions

View file

@ -27,11 +27,32 @@ pragma cashscript ^0.12.0;
// DeviantOracleMBA.cash — oracle-priced backing. Used by the
// BCHm family (USDm/EURm/…). Chipnet-
// validated in Parameters/ASM_Native/FIATm.
// MBAShares… — share/dividend model (Parameters spec,
// no covenant deployed yet).
// DeviantShareMBA.cash — fee-split-at-consensus fork: each
// claim's fee splits into a company
// stream (companyLockingBytecode) +
// a shareholder stream
// (distributionLockingBytecode).
// Chipnet-proven (Parameters/ASM_Native/
// MBA_Shares/SESSION-RECORD_shares-
// products-chipnet.md).
// DistributionVault.cash — companion payout side for
// DeviantShareMBA. Oracle-attested
// (recipient+amount), replay-bound to
// the vault's own outpoint. Chipnet-
// proven.
// TermCouponVault.cash — DistributionVault + CLTV maturity
// sweep: t-bond variant. Chipnet-
// proven.
// DART / DEA / Elevation — dynamic-rate variants (designed,
// not deployed).
//
// Artemision presets that compose these:
// ① Rewards/loyalty coin — DeviantShareMBA · companySplitBps=10000
// (all fee to company; no shareholder
// stream; DistributionVault unused).
// ② Shares + dividends — DeviantShareMBA + DistributionVault.
// ③ T-bond — DeviantShareMBA + TermCouponVault.
//
// Artemision the WIZARD chooses among these templates based on the asset
// class the user picks; this file is what "static MBA" resolves to.
//

File diff suppressed because one or more lines are too long

View file

@ -17,6 +17,7 @@ import { ElectrumClient, fetchAddressUtxos, summariseBalance } from './electrum'
import { WcClient } from './wc';
import { claimViaWc, claimViaLocalSigner, redeemViaLocalSigner, KNOWN_MBAS, mergeKnownMbas, previewClaim } from './orbital';
import { genesisArtemisionMBA, loadLocalKnownMbas, type GenesisResult } from './genesis';
import { publishViaLocalSigner, PublishError } from './publish';
import { bchUsd, refreshBchUsd } from './pricing';
import { BCH_ICON_DATA_URI } from './icons';
import { readKatalogos, type RegisteredEntry } from './registry';
@ -426,6 +427,7 @@ function boot(): void {
injectPhase1Banner();
wireSwapConfirmInterceptor();
wireArtemisionBroadcast();
wirePublishInterceptor();
installAmountPlaceholder();
installBalancesUsdPatch();
// Balances page + swap card still show the mockup's hard-coded numbers
@ -574,6 +576,71 @@ async function runArtemisionGenesis(): Promise<void> {
}
}
// Publish button — replaces the mockup's illustrative openPublishOverlay
// with a real KatalogosRegistryV1.register() broadcast when a local signer
// is attached. Structured as a monkey-patch on window.openPublishOverlay so
// EVERY entry point (post-genesis "Publish for $1 in DEA" button, Katalogos
// tab's Publish links, Stoa row buttons) routes through the real path.
// Falls back to the mockup's illustrative flow when no signer is present.
function wirePublishInterceptor(): void {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const w = window as any;
const originalOverlay = w.openPublishOverlay;
if (typeof originalOverlay !== 'function' || w.__potidaeaPublishWrapped) return;
w.openPublishOverlay = function wrappedPublish(sym: string) {
if (!localSigner) return originalOverlay.apply(this, arguments as unknown as unknown[]);
void runRealPublish(sym);
};
w.__potidaeaPublishWrapped = true;
}
async function runRealPublish(sym: string): Promise<void> {
if (!localSigner) return;
const mba = KNOWN_MBAS[sym];
if (!mba) { alert('Cannot publish ' + sym + ': no local metadata for this ticker'); return; }
// Show a lightweight scrim so the user sees what's happening (the mockup's
// rich publish overlay has ~500 lines of preflight UI — we shortcut to a
// single-modal broadcast until we port that surface. Errors surface here.)
const scrim = document.createElement('div');
scrim.className = 'wc-scrim'; scrim.id = 'wc-publish-scrim';
scrim.innerHTML =
'<div class="wc-modal">' +
'<div class="wc-modal-h">Publish ' + escHtml(sym) + ' to Katalogos</div>' +
'<p class="wc-modal-sub">Inscribes the category on-chain in the shared registry (chipnet v1). Fee: <b>~$1 in DEA</b> (231 atomic units at chipnet oracle rate).</p>' +
'<div id="wc-pub-body"><div style="font-family:var(--font-mono);font-size:11px;color:var(--muted)">Building + broadcasting…</div></div>' +
'<div class="wc-actions" style="margin-top:14px">' +
'<button class="wc-cancel" id="wc-pub-close">Close</button>' +
'</div>' +
'</div>';
document.body.appendChild(scrim);
const body = scrim.querySelector('#wc-pub-body') as HTMLElement;
const closeBtn = scrim.querySelector('#wc-pub-close') as HTMLButtonElement;
closeBtn.addEventListener('click', () => scrim.remove());
try {
const res = await publishViaLocalSigner(localSigner, mba.categoryId);
body.innerHTML =
'<div style="color: var(--acid); font-family: var(--font-mono); font-size: 12px; line-height: 1.7">' +
'✓ Published on chipnet<br>' +
'<span style="color: var(--muted); font-size: 10px">tx</span> <span style="color: var(--ink-bright); word-break: break-all">' + escHtml(res.txid) + '</span><br>' +
'<span style="color: var(--muted); font-size: 10px">category</span> <span style="word-break: break-all">' + escHtml(res.targetCategoryId) + '</span><br>' +
'<span style="color: var(--muted); font-size: 10px">fee</span> ' + String(res.feeDea) + ' atomic DEA' +
'</div>' +
'<p class="wc-modal-sub" style="margin-top:12px">Every wallet reading Katalogos will see this listing from the next block. Refresh Potidaea to pick it up in the on-chain read.</p>';
// Refresh the on-chain read so the banner count updates
void refreshRegistry();
} catch (e) {
const code = (e instanceof PublishError) ? e.code : 'other';
const msg = e instanceof Error ? e.message : String(e);
body.innerHTML =
'<div class="wc-err">✗ ' + escHtml(msg) + '</div>' +
(code === 'insufficient-dea'
? '<p class="wc-modal-sub" style="margin-top:10px">Chipnet DEA is the fee token. To publish, this wallet needs at least the required atomic units of DEA — send some to <code style="font-family:var(--font-mono);font-size:10px;color:var(--acid);word-break:break-all">' + escHtml(localSigner!.cashaddr) + '</code> from a wallet that has DEA, or ask for a chipnet DEA faucet drop.</p>'
: '');
}
}
function attachWellKnownIcons(): void {
const assets = window.ASSETS;
if (!assets) return;

View file

@ -0,0 +1,305 @@
// Katalogos publish — inscribe a category id into the shared registry.
//
// Two-input, four-output register() transaction:
//
// inputs
// [0] state UTXO at the registry contract (minting NFT + registry
// category). Consumed and immediately re-created at output[0].
// [1] user's DEA UTXO holding at least `feeDea` atomic units
// [2+] user's plain BCH UTXOs for miner fee + change dust
//
// outputs
// [0] state UTXO back to registry (minting NFT preserved, same
// commitment) — this is the covenant's own re-creation invariant
// [1] entry NFT to the registry: IMMUTABLE, commitment = the target
// category being registered (bare 32 bytes)
// [2] fee to treasury: DEA of at least feeDea atomic units
// [3+] BCH change + DEA change if we picked more than we spent
//
// Signing: user signs inputs 1..N with their local key (SignatureTemplate);
// input 0 unlocks via the covenant's register(targetCategory, oracleSig,
// oracleMessage) function.
//
// Fee derivation matches the covenant on-chain:
// feeDea = ceil(satsPerUsd / deaRateSats)
// On chipnet dev the oracle is stubbed to $500 / BCH → satsPerUsd = 200,000;
// DEA rate is 867 sats/atomic → feeDea = 231.
//
// The chipnet dev oracle is baked in (see CHIPNET_DEV_ORACLE_PRIV_HEX) so
// the Publish button can sign a valid $1 quote without a running oracle
// service. Mainnet uses a real oracle service — not scoped here.
import artifact from './artifacts/KatalogosRegistryV1.json';
import type { Artifact } from 'cashscript';
import { Contract, ElectrumNetworkProvider, TransactionBuilder, SignatureTemplate } from 'cashscript';
import {
secp256k1,
sha256 as laSha256,
encodeCashAddress,
CashAddressType,
CashAddressNetworkPrefix,
} from '@bitauth/libauth';
import type { Signer } from './signer-store';
const ART = artifact as unknown as Artifact;
// Chipnet KatalogosRegistryV1 — mirrors shells/app/src/core/katalogos.ts
// KATALOGOS_VERSIONS.chipnet[0]. Update in lockstep with that file.
const CHIPNET_REGISTRY = {
contractAddress: 'bchtest:pwm0e4rnq0htx97g3sllrgld652w7ztxlnqlx7820kd3mhklk6v8vfchkntlm',
contractTokenAddress: 'bchtest:rwm0e4rnq0htx97g3sllrgld652w7ztxlnqlx7820kd3mhklk6v8vmtth22xs',
registryCategoryId: '04841b26ef63a7954e26227a37d104c83a3a75c94f1f88e9822d47896f021eea',
feeCategoryId: '3c2337f504ad543518040c2d96477e473efaf8803aa010ce63e93c07996fd0c0',
deaRateSats: 867n,
oraclePubKeyHex: '023eafff70c2d019cb430d4f769441320a133f70e6ca01a0fc30d30c8245f459dd',
treasuryLockingBytecodeHex: '76a914a4098912179c55b53f08b5c499e398e69e56f00c88ac',
};
// Chipnet dev oracle key — matches KATALOGOS_ORACLE_DEV.ts in shells/app.
// Rotated only when the covenant is redeployed. Chipnet-only; mainnet uses
// a real oracle service.
const CHIPNET_DEV_ORACLE_PRIV_HEX = '3b3f2287ba3ac5e0ad6e87b6763672f423869c5322c85672e8317f449aba8198';
const CHIPNET_ORACLE_SATS_PER_USD = 200_000n;
const DUST = 1000n;
const MINER_FEE = 2500n;
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;
}
function txidLE(hex: string): Uint8Array {
const d = hexToBin(hex);
const o = new Uint8Array(d.length);
for (let i = 0; i < d.length; i++) o[i] = d[d.length - 1 - i];
return o;
}
function encodeOracleMessage(satsPerUsd: bigint): Uint8Array {
// 16-byte AnyHedge / ADR-0052 shape: timestamp(4) sequence(4)
// contentSequence(4) price(4), all uint32-LE. Chipnet dev pins the
// first three fields to 1; the covenant only reads bytes 12..16.
const m = new Uint8Array(16);
const dv = new DataView(m.buffer);
dv.setUint32(0, 1, true);
dv.setUint32(4, 1, true);
dv.setUint32(8, 1, true);
dv.setUint32(12, Number(satsPerUsd), true);
return m;
}
function signOracleMessage(message: Uint8Array): Uint8Array {
const priv = hexToBin(CHIPNET_DEV_ORACLE_PRIV_HEX);
const sig = secp256k1.signMessageHashSchnorr(priv, laSha256.hash(message));
if (typeof sig === 'string') throw new Error('oracle sign failed: ' + sig);
return sig;
}
/** feeDea = ceil(satsPerUsd / deaRateSats). Matches the covenant's ceil-div. */
function feeDeaFor(satsPerUsd: bigint, deaRateSats: bigint): bigint {
return (satsPerUsd + deaRateSats - 1n) / deaRateSats;
}
export interface PublishResult {
txid: string;
targetCategoryId: string;
feeDea: bigint;
}
export class PublishError extends Error {
constructor(message: string, readonly code:
| 'no-state-utxo' | 'insufficient-dea' | 'insufficient-bch' | 'invalid-category' | 'other',
) {
super(message);
this.name = 'PublishError';
}
}
export async function publishViaLocalSigner(
signer: Signer,
targetCategoryId: string,
): Promise<PublishResult> {
if (!/^[0-9a-f]{64}$/i.test(targetCategoryId)) {
throw new PublishError('categoryId must be 64 hex chars', 'invalid-category');
}
const provider = new ElectrumNetworkProvider('chipnet');
// Reconstruct the covenant so we can spend its state UTXO. Constructor
// args mirror shells/app/src/core/katalogos.ts.
const contract = new Contract(
ART,
[
txidLE(CHIPNET_REGISTRY.registryCategoryId),
txidLE(CHIPNET_REGISTRY.feeCategoryId),
CHIPNET_REGISTRY.deaRateSats,
hexToBin(CHIPNET_REGISTRY.oraclePubKeyHex),
hexToBin(CHIPNET_REGISTRY.treasuryLockingBytecodeHex),
],
{ provider },
);
// Sanity-check that the derived address matches the pinned one — a drift
// in constructor args or artifact would silently spend to the wrong
// contract and lose funds; better to fail loudly.
if (contract.tokenAddress !== CHIPNET_REGISTRY.contractTokenAddress) {
throw new PublishError(
'contract token address drift: derived ' + contract.tokenAddress +
' vs pinned ' + CHIPNET_REGISTRY.contractTokenAddress,
'other',
);
}
// Find the state UTXO — the minting NFT of the registry category parked
// at the contract's own token address.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const registryUtxos = (await provider.getUtxos(contract.tokenAddress)) as any[];
const stateUtxo = registryUtxos.find((u) =>
u.token?.category === CHIPNET_REGISTRY.registryCategoryId &&
u.token?.nft?.capability === 'minting'
);
if (!stateUtxo) throw new PublishError('no state UTXO at registry — covenant may be draining', 'no-state-utxo');
const satsPerUsd = CHIPNET_ORACLE_SATS_PER_USD;
const feeDea = feeDeaFor(satsPerUsd, CHIPNET_REGISTRY.deaRateSats);
// Pick DEA and BCH from the signer's UTXOs.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const own = (await provider.getUtxos(signer.cashaddr)) as any[];
const deaUtxos = own.filter((u) => u.token?.category === CHIPNET_REGISTRY.feeCategoryId && !u.token?.nft);
deaUtxos.sort((a, b) => Number(BigInt(b.token.amount) - BigInt(a.token.amount)));
const pickedDea: typeof deaUtxos = []; let haveDea = 0n;
for (const u of deaUtxos) {
pickedDea.push(u); haveDea += BigInt(u.token.amount);
if (haveDea >= feeDea) break;
}
if (haveDea < feeDea) {
throw new PublishError(
'insufficient DEA: need ' + feeDea + ' atomic units ($1 in DEA at chipnet dev-oracle rate), have ' + haveDea + '. ' +
'The wallet holding this key needs a small amount of the chipnet DEA token — category ' +
CHIPNET_REGISTRY.feeCategoryId.slice(0, 16) + '…',
'insufficient-dea',
);
}
const plainBch = own.filter((u) => !u.token).sort((a, b) => Number(BigInt(b.satoshis) - BigInt(a.satoshis)));
const pickedBch: typeof plainBch = []; let haveBch = 0n;
// Cover: three output dust (state + entry + fee), miner fee, DEA-change dust if any
const bchNeed = 3n * DUST + MINER_FEE + DUST;
for (const u of plainBch) {
pickedBch.push(u); haveBch += BigInt(u.satoshis);
if (haveBch >= bchNeed) break;
}
if (haveBch < bchNeed) {
throw new PublishError('insufficient BCH: need ' + bchNeed + ' sats for miner fee + dust, have ' + haveBch, 'insufficient-bch');
}
// Sig template for the user's DEA + BCH inputs.
const sig = new SignatureTemplate(signer.privateKey);
// Oracle quote (chipnet dev — signed here in the browser with the baked key).
const oracleMessage = encodeOracleMessage(satsPerUsd);
const oracleSig = signOracleMessage(oracleMessage);
const stateInputSats = BigInt(stateUtxo.satoshis);
const deaChange = haveDea - feeDea;
const tb = new TransactionBuilder({ provider });
// Input 0 — state UTXO, unlocked via register().
// The targetCategory arg must match what cashscript actually writes into
// the NFT commitment field on output[1] (they're compared byte-for-byte
// inside the covenant). cashscript writes the commitment hex STRING as-is
// (display order), so we hand register() the same display-order bytes —
// NOT the reversed consensus form (that's only correct for constructor
// args like `registryCategory`, which reference the token category via the
// OP_*TOKENCATEGORY opcodes that return consensus order).
tb.addInput(stateUtxo, contract.unlock.register(hexToBin(targetCategoryId), oracleSig, oracleMessage));
// Input 1+ — DEA UTXOs
for (const u of pickedDea) tb.addInput(u, sig.unlockP2PKH());
// Input N+ — plain BCH UTXOs
for (const u of pickedBch) tb.addInput(u, sig.unlockP2PKH());
// Output 0 — state re-created (minting NFT preserved)
tb.addOutput({
to: contract.tokenAddress,
amount: stateInputSats >= DUST ? stateInputSats : DUST,
token: {
amount: BigInt(stateUtxo.token.amount ?? 0),
category: CHIPNET_REGISTRY.registryCategoryId,
nft: {
capability: 'minting',
commitment: stateUtxo.token.nft?.commitment ?? '',
},
},
});
// Output 1 — entry NFT: immutable, commitment = target category
tb.addOutput({
to: contract.tokenAddress,
amount: DUST,
token: {
amount: 0n,
category: CHIPNET_REGISTRY.registryCategoryId,
nft: { capability: 'none', commitment: targetCategoryId },
},
});
// Output 2 — fee to treasury: DEA at exactly feeDea (or more).
tb.addOutput({
to: bchtestP2pkhFromLB(CHIPNET_REGISTRY.treasuryLockingBytecodeHex),
amount: DUST,
token: { amount: feeDea, category: CHIPNET_REGISTRY.feeCategoryId },
});
// Output 3+ — DEA change (if any) back to signer
if (deaChange > 0n) {
tb.addOutput({
to: signer.tokenaddr,
amount: DUST,
token: { amount: deaChange, category: CHIPNET_REGISTRY.feeCategoryId },
});
}
// Output N — plain BCH change
const totalInputBch = stateInputSats + pickedDea.reduce((s, u) => s + BigInt(u.satoshis), 0n) + haveBch;
const totalOutputBch =
(stateInputSats >= DUST ? stateInputSats : DUST) + // out[0]
DUST + // out[1]
DUST + // out[2]
(deaChange > 0n ? DUST : 0n); // out[3] if present
const bchChange = totalInputBch - totalOutputBch - MINER_FEE;
if (bchChange < DUST) {
throw new PublishError('BCH change below dust: ' + bchChange, 'insufficient-bch');
}
tb.addOutput({ to: signer.cashaddr, amount: bchChange });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const sent: any = await tb.send();
const txid = typeof sent === 'string' ? sent : (sent.txid ?? sent.txId);
return { txid, targetCategoryId, feeDea };
}
// Local helper — build a bchtest: P2PKH cash address from a 25-byte
// locking bytecode hex (treasury). Keeps the module self-contained.
function bchtestP2pkhFromLB(lbHex: string): string {
// 25-byte P2PKH: 76 a9 14 <pkh 20> 88 ac
const lb = hexToBin(lbHex);
if (lb.length !== 25 || lb[0] !== 0x76 || lb[1] !== 0xa9 || lb[2] !== 0x14 || lb[23] !== 0x88 || lb[24] !== 0xac) {
throw new Error('unexpected treasury locking bytecode shape');
}
const pkh = lb.slice(3, 23);
// p2pkhWithTokens (bchtest:z…) — same P2PKH locking bytecode but the
// token-aware address form, which cashscript's addOutput requires when a
// token payload rides along. The covenant only checks lockingBytecode so
// this is bytecode-equivalent to the plain P2PKH form the treasury uses.
const res = encodeCashAddress({
prefix: CashAddressNetworkPrefix.testnet,
type: CashAddressType.p2pkhWithTokens,
payload: pkh,
});
if (typeof res === 'string') return res;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (res as any).address ?? String(res);
}