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:
parent
7b5952b594
commit
1d00d2d115
4 changed files with 525 additions and 2 deletions
|
|
@ -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.
|
||||
//
|
||||
|
|
|
|||
130
design/potidaea/src/artifacts/KatalogosRegistryV1.json
Normal file
130
design/potidaea/src/artifacts/KatalogosRegistryV1.json
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -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;
|
||||
|
|
|
|||
305
design/potidaea/src/publish.ts
Normal file
305
design/potidaea/src/publish.ts
Normal 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);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue