feat(wallet): adjustable-fee end-to-end — Artemision + AssetDetail + updateFee

Wires the chipnet-validated DeviantAdjustableFeeCapped covenant (snapshot
0259) into the wallet end-to-end, following Parameters/Artemision/
SESSION_PROMPT_adjustable-fee-wallet-integration.md.

**Type + planner (core)**
- `TokenTierA.adjustableFee?: { maxFeeBps, founderPubKeyHex }` — immutable
  identity only; the live feeBps lives on chain in the reservoir NFT's
  commitment (cached values would go stale on updateFee).
- `GenesisIntent.adjustableFee?: {...}` — when present, prepareGenesis()
  uses ADJUSTABLE_FEE_CAPPED_ARTIFACT with the 5-arg constructor
  (category, rateSats, maxFeeBps, founderPubKey(33), founderLB(25))
  instead of DeviantOpenMintFT's 4-arg constructor. Validates 0<=start<=
  cap<=500 and pubkey format.
- Genesis executor grows an optional `nftOverride: { capability, commitment }`.
  Adjustable-fee assets pass { mutable, bytes(startingFeeBps) } so the
  reservoir NFT can be rotated by updateFee later; fixed-fee assets stay
  { minting, '' } as before.
- Embedded artifact: contracts/dist/DeviantAdjustableFeeCapped.json copied
  to src/verify/artifacts/ + exported as ADJUSTABLE_FEE_CAPPED_ARTIFACT.

**ScriptNum codec**
- src/token/scriptNum.ts — pure encode/decode for the ScriptNum used by
  CashScript's bytes(int); hex wrappers (feeBpsToCommitmentHex /
  commitmentHexToFeeBps) match the reservoir NFT's commitment field
  byte-for-byte. 10/10 unit tests cross-check against the exact values
  the chipnet test produced on-chain (feeBps 100→"64", 300→"2c01", 500→"f401").

**updateFee flow (shell)**
- shells/app/src/core/updateFee.ts — executeUpdateFee() reconstructs the
  covenant from the stored descriptor, fetches the mutable reservoir
  UTXO, builds an updateFee(newFeeBps, sig) tx, and broadcasts. Errors
  bucketed via UpdateFeeError (not-adjustable / fee-out-of-range /
  not-founder / no-reservoir / no-bch / broadcast-failed).

**Artemision wizard**
- New "Fee mode" segmented control on step 4 (MBA path): Fixed 🔒 vs
  Adjustable ✎. Adjustable shows both sliders as the honest two-knob
  (starting feeRate + immutable maxFeeRate cap); Fixed shows one slider
  and the copy states the fee is baked into bytecode forever.
- Session's compressed pubkey derived via secp256k1 for the constructor's
  founderPubKey arg. Review-wall lists fee-mode + which covenant deploys.

**AssetDetail founder actions**
- Modify founder-fee row is now live for adjustable-fee assets: click →
  modal with current-fee readback, cap display, decimal input (validated
  0..cap), Broadcast. On success rotates the on-chain commitment,
  refreshes the local registry's cached founderFeeBps, and shows the
  txid + confirmation banner. Fixed-fee assets keep the disabled row +
  explainer pointing at the Artemision Adjustable option.

**Not in scope (deferred)**
- claim.ts / swap.ts LIVE fee read from the reservoir commitment — for
  now claims still use the cached founderFeeBps (stale after an update).
  The next slice adds a live-fee lookup pre-quote.
- Portable descriptor v1 emit/parse for adjustableFee (Route B pattern).
- Live end-to-end chipnet drive of the new wallet flow (unit tests +
  chipnet-adjustable-fee-test.mjs cover the covenant + codec).

10/10 scriptNum tests + 607/607 full suite pass. Core tsc + shell tsc
clean. Deployed to deviant.x.
This commit is contained in:
Local Dev 2026-09-02 08:15:40 +02:00
parent d11a5f068c
commit 9e5f7a3e8d
11 changed files with 833 additions and 64 deletions

View file

@ -59,7 +59,7 @@ interface RawUtxo {
*/
export function makeShellGenesisExecutor(ctx: ShellGenesisContext): GenesisExecutor {
return {
async execute({ contractTokenAddress, reservoirSupply, eligibleUtxo, mintingBaton = true }) {
async execute({ contractTokenAddress, reservoirSupply, eligibleUtxo, mintingBaton = true, nftOverride }) {
const category = eligibleUtxo.txid;
const all = (await ctx.provider.getUtxos(ctx.cashaddr)) as unknown as RawUtxo[];
@ -103,17 +103,23 @@ export function makeShellGenesisExecutor(ctx: ShellGenesisContext): GenesisExecu
tb.addInput(funding as never, template.unlockP2PKH());
for (const u of extras) tb.addInput(u as never, template.unlockP2PKH());
// NFT shape on the reservoir output — three cases:
// 1. nftOverride supplied → use it verbatim (adjustable-fee mode:
// { mutable, bytes(startingFeeBps) })
// 2. mintingBaton true → the OpenMintFT default (minting, '')
// 3. mintingBaton false → no NFT (capped NBA cap-as-consensus, ADR-0054)
const nftForOutput = nftOverride
? { capability: nftOverride.capability, commitment: nftOverride.commitment }
: mintingBaton
? { capability: 'minting' as const, commitment: '' }
: undefined;
tb.addOutput({
to: contractTokenAddress,
amount: RESERVOIR_OUTPUT_SATS,
token: {
amount: reservoirSupply,
category,
// No baton ⇒ the category can never gain another token of any kind.
// That is what makes a capped NBA's cap a consensus fact (ADR-0054).
...(mintingBaton
? { nft: { capability: 'minting' as const, commitment: '' } }
: {}),
...(nftForOutput ? { nft: nftForOutput } : {}),
},
});
const change = total - RESERVOIR_OUTPUT_SATS - GENESIS_MINER_FEE_SATS;

View file

@ -0,0 +1,172 @@
// updateFee — shell-side flow for `DeviantAdjustableFeeCapped.updateFee()`.
//
// Fetches the current reservoir UTXO (identified by the token category +
// mutable NFT), verifies the founder key matches the covenant's pubkey, and
// broadcasts `updateFee(newFeeBps, founderSig)`. Reservoir output preserves
// the lock, category, FT amount, and capability; only the commitment rotates
// to `bytes(newFeeBps)` (byte-identical to the covenant's `bytes(int)`).
//
// Chipnet-validated at scripts/chipnet-adjustable-fee-test.mjs. This shell
// path mirrors that script's `doUpdate()` helper.
import {
Contract,
TransactionBuilder,
SignatureTemplate,
type ElectrumNetworkProvider,
type Artifact,
} from 'cashscript';
import { ADJUSTABLE_FEE_CAPPED_ARTIFACT } from '@core/verify/canonical.js';
import { feeBpsToCommitmentHex, commitmentHexToFeeBps } from '@core/token/scriptNum.js';
import { txidToConsensusBytes } from '@core/verify/contract.js';
import { hexToBin } from '@core/utils/hex.js';
import type { TokenTierA } from '@core/token/types.js';
const MINER_FEE_SATS = 1500n;
const CHANGE_DUST_SATS = 546n;
export class UpdateFeeError extends Error {
constructor(
message: string,
readonly code:
| 'not-adjustable'
| 'fee-out-of-range'
| 'not-founder'
| 'no-reservoir'
| 'no-bch'
| 'broadcast-failed',
) {
super(message);
this.name = 'UpdateFeeError';
}
}
export interface UpdateFeeInput {
readonly token: TokenTierA;
readonly newFeeBps: number;
readonly provider: ElectrumNetworkProvider;
readonly privateKey: Uint8Array;
readonly compressedPubKeyHex: string;
readonly cashaddr: string;
}
export interface UpdateFeeResult {
readonly txid: string;
readonly oldFeeBps: bigint;
readonly newFeeBps: bigint;
}
interface RawUtxo {
txid: string;
vout: number;
satoshis: bigint;
token?: {
category: string;
amount: bigint;
nft?: { capability: 'minting' | 'mutable' | 'none'; commitment: string };
};
}
export async function executeUpdateFee(input: UpdateFeeInput): Promise<UpdateFeeResult> {
const { token, newFeeBps, provider, privateKey, compressedPubKeyHex, cashaddr } = input;
if (!token.adjustableFee) {
throw new UpdateFeeError(
`Asset ${token.ticker} was not minted with the adjustable-fee covenant`,
'not-adjustable',
);
}
const { maxFeeBps, founderPubKeyHex } = token.adjustableFee;
if (newFeeBps < 0 || newFeeBps > maxFeeBps) {
throw new UpdateFeeError(
`newFeeBps ${newFeeBps} out of range (must be 0..${maxFeeBps}, the immutable cap)`,
'fee-out-of-range',
);
}
if (compressedPubKeyHex.toLowerCase() !== founderPubKeyHex.toLowerCase()) {
throw new UpdateFeeError(
`Only the founder key can update the fee (your pubkey doesn't match ${founderPubKeyHex.slice(0, 12)}…)`,
'not-founder',
);
}
// Reconstruct the contract at the same address the plan produced. Constructor
// args order MUST match the .cash source (address-defining).
const contract = new Contract(
ADJUSTABLE_FEE_CAPPED_ARTIFACT as unknown as Artifact,
[
txidToConsensusBytes(token.categoryId),
token.rateSats,
BigInt(maxFeeBps),
hexToBin(founderPubKeyHex),
hexToBin(token.founderLockingBytecodeHex),
],
{ provider },
);
// Fetch the reservoir — the covenant's token-address UTXO with the mutable NFT.
const covenantUtxos = (await provider.getUtxos(contract.tokenAddress)) as unknown as RawUtxo[];
const reservoir = covenantUtxos.find(
(u) => u.token?.category === token.categoryId && u.token?.nft?.capability === 'mutable',
);
if (!reservoir) {
throw new UpdateFeeError(
`No reservoir UTXO found at ${contract.tokenAddress} — is the asset minted?`,
'no-reservoir',
);
}
const oldFeeBps = commitmentHexToFeeBps(reservoir.token!.nft!.commitment);
// Founder needs a plain BCH input for the miner fee.
const cashUtxos = (await provider.getUtxos(cashaddr)) as unknown as RawUtxo[];
const bchUtxo = cashUtxos
.filter((u) => !u.token && u.satoshis >= MINER_FEE_SATS + CHANGE_DUST_SATS)
.sort((a, b) => Number(BigInt(b.satoshis) - BigInt(a.satoshis)))[0];
if (!bchUtxo) {
throw new UpdateFeeError(
`No BCH UTXO ≥ ${MINER_FEE_SATS + CHANGE_DUST_SATS} bits for the miner fee`,
'no-bch',
);
}
const founderSig = new SignatureTemplate(privateKey);
const tb = new TransactionBuilder({ provider });
tb.addInput(reservoir as never, contract.unlock.updateFee(BigInt(newFeeBps), founderSig));
tb.addInput(bchUtxo as never, founderSig.unlockP2PKH());
const newCommitment = feeBpsToCommitmentHex(BigInt(newFeeBps));
// Reservoir back: same lock, same category (+capability), same FT amount,
// NEW commitment. The covenant requires an EXACT match on all four; any
// drift fails at `require(...)` on chain.
tb.addOutput({
to: contract.tokenAddress,
amount: BigInt(reservoir.satoshis),
token: {
amount: reservoir.token!.amount,
category: token.categoryId,
nft: { capability: 'mutable', commitment: newCommitment },
},
});
const change = BigInt(bchUtxo.satoshis) - MINER_FEE_SATS;
if (change >= CHANGE_DUST_SATS) {
tb.addOutput({ to: cashaddr, amount: change });
}
let txid: string;
try {
const sent = (await tb.send()) as unknown as string | { txid?: string; txId?: string };
const raw = typeof sent === 'string' ? sent : (sent.txid ?? sent.txId);
if (!raw) throw new Error('broadcast returned no txid');
txid = raw;
} catch (e) {
throw new UpdateFeeError(
`Broadcast failed: ${(e as Error).message}`,
'broadcast-failed',
);
}
return { txid, oldFeeBps, newFeeBps: BigInt(newFeeBps) };
}

View file

@ -1,6 +1,8 @@
import React, { useEffect, useState } from 'react';
import type { CoreSession, GenesisPlan, NbaGenesisPlan } from '../core/wallet';
import { planGenesis, executeGenesis, planNbaGenesis, executeNbaGenesis, selfSend } from '../core/wallet';
import { secp256k1 } from '@core/crypto/secp256k1.js';
import { binToHex } from '@core/utils/hex.js';
import type { AssetDetailToken } from './AssetDetail';
import type { DisplayBalance } from '@core/wallet/display.js';
import { defaultDenomination, formatUnits } from '@core/tokenMetadata.js';
@ -246,6 +248,11 @@ function Wizard({ session, onExit }: { session: CoreSession | null; onExit: () =
const [rate, setRate] = useState('10,000');
const [feeRate, setFeeRate] = useState(0.5);
const [maxFee, setMaxFee] = useState(2);
// Fee-mode toggle (ADR-0011 realised in snapshot 0259). `fixed` uses the
// original DeviantOpenMintFT — feeRate is baked in forever. `adjustable`
// uses DeviantAdjustableFeeCapped — feeRate is the STARTING fee (mutable
// via updateFee) and maxFee becomes the immutable cap.
const [feeMode, setFeeMode] = useState<'fixed' | 'adjustable'>('fixed');
// Confirmation friction: only DELIST needs a typed-phrase gate (destructive
// + reversible-looking); Create is already gated by the 5-step wizard + the
// one-way irreversible banner. Keeping the state so a future delist flow can
@ -314,6 +321,18 @@ function Wizard({ session, onExit }: { session: CoreSession | null; onExit: () =
setNbaPlan(null);
setPlanErr((e as Error).message);
};
// Adjustable-fee mode adds two constructor args (maxFeeBps + founder
// pubkey). Compressed pubkey is derived from the session's privkey (same
// key the founder signs updateFee with — mainnet-js's publicKeyCompressed
// equivalent). The starting fee stays in founderFeeBps.
const adjustableFee = feeMode === 'adjustable' ? (() => {
const raw = secp256k1.derivePublicKeyCompressed(session.privateKey);
if (typeof raw === 'string') return null; // derivation failed
return {
maxFeeBps: Math.round(maxFee * 100),
founderPubKeyHex: binToHex(raw),
};
})() : null;
const promise = isMba
? planGenesis(session, {
name: name.trim(),
@ -321,6 +340,7 @@ function Wizard({ session, onExit }: { session: CoreSession | null; onExit: () =
rateSats: denom.atomicRateSats,
founderFeeBps,
decimals: denom.decimals,
...(adjustableFee ? { adjustableFee } : {}),
}).then((p) => { if (!cancelled) { setNbaPlan(null); setPlan(p); } })
: planNbaGenesis(session, {
name: name.trim(),
@ -343,7 +363,7 @@ function Wizard({ session, onExit }: { session: CoreSession | null; onExit: () =
return () => { cancelled = true; };
}, [
idx, lastIdx, isMba, session, name, ticker,
denom.atomicRateSats, denom.decimals, founderFeeBps,
denom.atomicRateSats, denom.decimals, founderFeeBps, feeMode, maxFee,
mint, supply, capped, permissionless, atomicCap, nbaDecimals, drawPrice, maxPerDraw, cooldown,
retryTick,
]);
@ -609,25 +629,49 @@ function Wizard({ session, onExit }: { session: CoreSession | null; onExit: () =
{idx === 3 && isMba && (
<>
<div className="q">Founder fees</div>
<div className="slider-wrap">
<div className="slider-top"><span>Current feeRate <span className="mark edit">✎</span></span><span className="val">{feeRate.toFixed(1)}%</span></div>
<input type="range" min={0} max={maxFee} step={0.1} value={feeRate} onChange={(e) => setFeeRate(+e.target.value)} />
{/* Fee mode — Fixed (immutable feeRate) vs Adjustable (feeRate
mutable up to an immutable cap, ADR-0011 realised via the
chipnet-validated DeviantAdjustableFeeCapped covenant, 0259). */}
<div className="segmented" style={{ marginBottom: 10 }}>
<button className={`seg${feeMode === 'fixed' ? ' active' : ''}`} onClick={() => setFeeMode('fixed')}>
Fixed 🔒
</button>
<button className={`seg${feeMode === 'adjustable' ? ' active' : ''}`} onClick={() => setFeeMode('adjustable')}>
Adjustable ✎
</button>
</div>
<div className="slider-wrap">
<div className="slider-top"><span>maxFeeRate <span className="mark lock">🔒</span> cap</span><span className="val">{maxFee.toFixed(1)}%</span></div>
<input type="range" min={0} max={5} step={0.1} value={maxFee} onChange={(e) => { const v = +e.target.value; setMaxFee(v); if (feeRate > v) setFeeRate(v); }} />
<div className="slider-top">
<span>
{feeMode === 'adjustable' ? 'Starting feeRate' : 'feeRate'}{' '}
<span className={`mark ${feeMode === 'adjustable' ? 'edit' : 'lock'}`}>
{feeMode === 'adjustable' ? '✎' : '🔒'}
</span>
</span>
<span className="val">{feeRate.toFixed(1)}%</span>
</div>
<input type="range" min={0} max={feeMode === 'adjustable' ? maxFee : 5} step={0.1} value={feeRate} onChange={(e) => setFeeRate(+e.target.value)} />
</div>
<div className="perm-note">
Two-knob model (ADR-0011): ✎ feeRate adjustable later but never above the 🔒 immutable
maxFeeRate cap. The redeem side carries <b>no fee</b> — redemption is a P2PKH spend
(V2.7 §4.4).
</div>
{isMba && (
<div className="perm-note" style={{ opacity: 0.85 }}>
Note for this template: the canonical <code>DeviantOpenMintFT</code> takes a{' '}
<b>single</b> immutable <code>founderFeeBps</code>. Deploying here bakes in{' '}
<b>{feeRate.toFixed(1)}%</b> forever — the maxFeeRate cap applies to the adjustable-fee
templates (Esc/Desc fee schedules, ADR-0029), not to this one.
{feeMode === 'adjustable' && (
<div className="slider-wrap">
<div className="slider-top"><span>maxFeeRate <span className="mark lock">🔒</span> cap (immutable)</span><span className="val">{maxFee.toFixed(1)}%</span></div>
<input type="range" min={0} max={5} step={0.1} value={maxFee} onChange={(e) => { const v = +e.target.value; setMaxFee(v); if (feeRate > v) setFeeRate(v); }} />
</div>
)}
{feeMode === 'fixed' ? (
<div className="perm-note">
<b>Fixed</b> uses <code>DeviantOpenMintFT</code> — the fee is baked into
the contract's bytecode forever. Deploying here locks in{' '}
<b>{feeRate.toFixed(1)}%</b>. The redeem side carries <b>no fee</b> — redemption
is a P2PKH spend (V2.7 §4.4).
</div>
) : (
<div className="perm-note">
<b>Adjustable</b> uses <code>DeviantAdjustableFeeCapped</code> (chipnet-
validated, 0259). The starting fee is <b>{feeRate.toFixed(1)}%</b>; you can
raise or lower it later via <b>Modify fee</b> on the asset detail page, but
<b> never above {maxFee.toFixed(1)}%</b> — the cap is immutable, part of the
contract's address. The redeem side carries no fee either way (V2.7 §4.4).
</div>
)}
</>
@ -644,8 +688,14 @@ function Wizard({ session, onExit }: { session: CoreSession | null; onExit: () =
<Rev k="🔒 Mint × supply" v={`${mint} · ${supply}`} />
{isMba ? (
<>
<Rev k="🔒 maxFeeRate cap" v={`${maxFee.toFixed(1)}%`} />
<Rev k="🔒 feeRate (deployed)" v={`${feeRate.toFixed(1)}%`} />
<Rev
k={feeMode === 'adjustable' ? '🔒 maxFeeRate cap' : '🔒 feeRate (deployed, immutable)'}
v={feeMode === 'adjustable' ? `${maxFee.toFixed(1)}%` : `${feeRate.toFixed(1)}%`}
/>
{feeMode === 'adjustable' && (
<Rev k="✎ Starting feeRate (mutable)" v={`${feeRate.toFixed(1)}%`} />
)}
<Rev k="Fee mode" v={feeMode === 'adjustable' ? 'Adjustable · DeviantAdjustableFeeCapped' : 'Fixed · DeviantOpenMintFT'} />
</>
) : (
<Rev

View file

@ -5,6 +5,9 @@ import { TokenSendFlow } from '../components/TokenSendFlow';
import { TokenImportModal } from '../components/TokenImportModal';
import { fetchRegisteredCategories, KATALOGOS_VERSIONS } from '../core/katalogos';
import { useKatalogosRev } from '../core/katalogos-events';
import { executeUpdateFee, UpdateFeeError } from '../core/updateFee';
import { secp256k1 } from '@core/crypto/secp256k1.js';
import { binToHex } from '@core/utils/hex.js';
// Asset detail (Katalogos pane). Single-tier (Tier B retired): no [A]/[B] badge, no
// Elevation, no separate Redeem (redeem = Swap → BCH). The class chip GLOWS ACID
@ -58,6 +61,14 @@ export function AssetDetail({
const [publishOpen, setPublishOpen] = useState(false);
const [showDelistNote, setShowDelistNote] = useState(false);
const [showModifyFeeNote, setShowModifyFeeNote] = useState(false);
// Modify-fee modal (adjustable-fee assets only). `liveFeeBps` is fetched on
// demand from the reservoir NFT's commitment — cached founderFeeBps would
// go stale the moment the founder updates it.
const [modifyOpen, setModifyOpen] = useState(false);
const [modifyBusy, setModifyBusy] = useState(false);
const [modifyErr, setModifyErr] = useState<string | null>(null);
const [modifyResult, setModifyResult] = useState<{ txid: string; newFeeBps: bigint } | null>(null);
const [newFeeInput, setNewFeeInput] = useState<string>('');
// Is this asset already published (metadata inscribed on the shared
// Katalogos)? Fetched on mount; refreshed when the katalogos-events rev
// fires, so a Publish anywhere in the app flips the panel.
@ -245,34 +256,62 @@ export function AssetDetail({
</button>
</div>
{/* Modify fee — requires the DeviantAdjustableFeeCapped covenant
(updateFee tx rotates the reservoir NFT's commitment; chipnet-
validated). This asset was minted with the fixed-fee covenant,
so the button is disabled with a plain-text explanation. */}
<div className="brow" style={{ alignItems: 'center', gap: 12 }}>
<div style={{ flex: 1 }}>
<div className="k" style={{ marginBottom: 2 }}>Modify founder fee</div>
<div style={{ fontSize: 11, opacity: 0.75, lineHeight: 1.4 }}>
Current: {typeof token.founderFeeBps === 'number'
? `${(token.founderFeeBps / 100).toFixed(1)}%`
: '—'} · rotates the reservoir NFT&apos;s commitment (signed).
{showModifyFeeNote && (
<><br />Available only for assets minted with the adjustable-fee
covenant (DeviantAdjustableFeeCapped). This asset was deployed with
the fixed-fee covenant, so the rate is baked in — Artemision will
offer the adjustable variant in a future genesis flow.</>
)}
{/* Modify fee — enabled when the asset was minted with the
DeviantAdjustableFeeCapped covenant (chipnet-validated 0259).
Rotates the reservoir NFT's commitment via a founder-signed
updateFee(newFeeBps, sig). Fixed-fee assets still show this
row but disabled with a plain-text explanation. */}
{(() => {
const srcToken = session?.registry.getByCategoryId(token.categoryId ?? '');
const isAdjustable = !!srcToken?.adjustableFee;
return (
<div className="brow" style={{ alignItems: 'center', gap: 12 }}>
<div style={{ flex: 1 }}>
<div className="k" style={{ marginBottom: 2 }}>Modify founder fee</div>
<div style={{ fontSize: 11, opacity: 0.75, lineHeight: 1.4 }}>
{isAdjustable ? (
<>
Current: {typeof token.founderFeeBps === 'number'
? `${(token.founderFeeBps / 100).toFixed(1)}%`
: '—'}
{' '}· cap {(srcToken!.adjustableFee!.maxFeeBps / 100).toFixed(1)}% 🔒 ·
rotates the reservoir NFT&apos;s commitment (signed).
</>
) : (
<>
Current: {typeof token.founderFeeBps === 'number'
? `${(token.founderFeeBps / 100).toFixed(1)}%`
: '—'} · rotates the reservoir NFT&apos;s commitment (signed).
{showModifyFeeNote && (
<><br />Available only for assets minted with the adjustable-fee
covenant (DeviantAdjustableFeeCapped). This asset was deployed with
the fixed-fee covenant, so the rate is baked in — pick <b>Adjustable</b>
{' '}on the fee step when minting your next asset in Artemision.</>
)}
</>
)}
</div>
</div>
<button
className="act-btn"
onClick={() => {
if (isAdjustable) {
setNewFeeInput(((token.founderFeeBps ?? 0) / 100).toFixed(1));
setModifyErr(null);
setModifyResult(null);
setModifyOpen(true);
} else {
setShowModifyFeeNote((v) => !v);
}
}}
title={isAdjustable ? 'Open the Modify fee form' : 'Not available for fixed-fee covenants'}
style={{ minWidth: 92, ...(isAdjustable ? {} : { opacity: 0.55 }) }}
>
Modify
</button>
</div>
</div>
<button
className="act-btn"
onClick={() => setShowModifyFeeNote((v) => !v)}
title="Not available for fixed-fee covenants"
style={{ minWidth: 92, opacity: 0.55 }}
>
Modify
</button>
</div>
);
})()}
{/* Delist — Katalogos v1 entries are IMMUTABLE by design (0248).
No delist path exists on the current covenant; this surfaces
@ -349,6 +388,99 @@ export function AssetDetail({
/>
);
})()}
{/* Modify fee modal — adjustable-fee assets only. Builds and broadcasts
an updateFee(newFeeBps, sig) tx via executeUpdateFee (shell-side flow,
shells/app/src/core/updateFee.ts). The reservoir NFT's commitment
rotates on-chain; the wallet also updates its cached founderFeeBps on
success so the value in the row above is fresh without a reload. */}
{modifyOpen && session && token.categoryId && (() => {
const srcToken = session.registry.getByCategoryId(token.categoryId);
if (!srcToken?.adjustableFee) { setModifyOpen(false); return null; }
const capPct = srcToken.adjustableFee.maxFeeBps / 100;
const currentPct = (srcToken.founderFeeBps ?? 0) / 100;
const parsedPct = Number(newFeeInput);
const validNumber = Number.isFinite(parsedPct) && parsedPct >= 0 && parsedPct <= capPct;
const newBps = validNumber ? Math.round(parsedPct * 100) : NaN;
const submit = async () => {
if (!validNumber) return;
setModifyBusy(true); setModifyErr(null);
try {
const pk = secp256k1.derivePublicKeyCompressed(session.privateKey);
if (typeof pk === 'string') throw new Error(`pubkey derivation failed: ${pk}`);
const res = await executeUpdateFee({
token: srcToken,
newFeeBps: newBps,
provider: session.provider,
privateKey: session.privateKey,
compressedPubKeyHex: binToHex(pk),
cashaddr: session.cashaddr,
});
// Cache-refresh the founderFeeBps on the local registry entry so
// this screen (and Katalogos rows that read from it) reflect the
// new rate without a reload. The on-chain state is what actually
// gates future claims; this is just a display sync.
const updated = { ...srcToken, founderFeeBps: newBps };
session.registry.add(updated as never, 'replace');
setModifyResult({ txid: res.txid, newFeeBps: res.newFeeBps });
onBroadcast?.();
} catch (e) {
setModifyErr(e instanceof UpdateFeeError ? e.message : (e as Error).message);
} finally { setModifyBusy(false); }
};
return (
<div className="modal">
<div className="flow-head">
<button className="back" onClick={() => setModifyOpen(false)}>←</button>
<div className="flow-title">Modify founder fee</div>
<div className="flow-step">{token.ticker}</div>
</div>
{modifyResult ? (
<div style={{ padding: '12px 6px' }}>
<div className="warn-banner info">
✓ Fee updated to <b>{(Number(modifyResult.newFeeBps) / 100).toFixed(1)}%</b>. Live
from this block on — future claims read the new commitment.
</div>
<div className="rev-note" style={{ marginTop: 10, fontFamily: 'var(--font-mono)', fontSize: 11, wordBreak: 'break-all' }}>
tx: {modifyResult.txid}
</div>
<div className="wiz-foot"><button className="btn-acid" onClick={() => setModifyOpen(false)}>Done</button></div>
</div>
) : (
<>
<div className="rev-note" style={{ marginBottom: 12 }}>
Rotates the reservoir NFT&apos;s commitment via <code>updateFee(newFeeBps, sig)</code>.
The immutable cap is <b>{capPct.toFixed(1)}%</b> — anything above rejects on chain.
Redemption stays fee-free either way.
</div>
<div className="brow"><span className="k">Current</span><span className="v">{currentPct.toFixed(1)}%</span></div>
<div className="brow"><span className="k">Cap 🔒</span><span className="v">{capPct.toFixed(1)}%</span></div>
<div className="wfield" style={{ marginTop: 12 }}>
<span className="flabel">New fee — %</span>
<input
className="input"
inputMode="decimal"
value={newFeeInput}
onChange={(e) => setNewFeeInput(e.target.value)}
placeholder={`0.0 – ${capPct.toFixed(1)}`}
/>
</div>
{modifyErr && <div className="warn-banner" style={{ marginTop: 10 }}>⚠ {modifyErr}</div>}
<div className="wiz-foot" style={{ marginTop: 14 }}>
<button className="btn-ghost" onClick={() => setModifyOpen(false)} disabled={modifyBusy}>Cancel</button>
<button
className={`btn-acid${(!validNumber || modifyBusy) ? ' disabled' : ''}`}
disabled={!validNumber || modifyBusy}
onClick={submit}
>
{modifyBusy ? 'Broadcasting…' : `Update to ${validNumber ? parsedPct.toFixed(1) : '—'}%`}
</button>
</div>
</>
)}
</div>
);
})()}
</>
);
}

75
src/token/scriptNum.ts Normal file
View file

@ -0,0 +1,75 @@
// Minimal ScriptNum codec — Bitcoin Script's variable-length little-endian
// signed integer encoding (aka CScriptNum). CashScript's `bytes(int)` produces
// this encoding, and covenants that compare `nftCommitment == bytes(int)` need
// wallet-side encoding that matches byte-for-byte.
//
// Rules (BIP-62 style):
// • 0 → empty byte string
// • positive n → LE bytes; if the top byte's high bit is set,
// append 0x00 to keep it unsigned
// • negative n → LE bytes of |n|; ensure top byte high bit is
// set (append 0x80 if the current top is < 0x80,
// otherwise flip the top bit in place)
// • decoding is the mirror: strip the sign bit off the top byte, LE→bigint,
// apply the sign
//
// Reference: DeviantAdjustableFeeCapped.cash uses `bytes(newFeeBps)` for the
// commitment, and scripts/chipnet-adjustable-fee-test.mjs has the same algorithm
// inline. Extracted here so wallet code shares the codec with the chipnet test.
import { hexToBin, binToHex } from '../utils/hex.js';
/**
* Encode a bigint as a Script number byte-string.
* `0n` → `Uint8Array(0)`. Small positive integers fit in 1–4 bytes.
*/
export function encodeScriptNum(n: bigint): Uint8Array {
if (n === 0n) return new Uint8Array(0);
const neg = n < 0n;
let v = neg ? -n : n;
const out: number[] = [];
while (v > 0n) {
out.push(Number(v & 0xffn));
v >>= 8n;
}
const topIdx = out.length - 1;
const topByte = out[topIdx]!;
if ((topByte & 0x80) !== 0) {
// Top byte's high bit already means "negative"; extend with a leading
// 0x00 (positive) or 0x80 (negative) to disambiguate.
out.push(neg ? 0x80 : 0x00);
} else if (neg) {
// Room in the top byte's sign bit — flip it in place.
out[topIdx] = topByte | 0x80;
}
return Uint8Array.from(out);
}
/** Decode a Script number byte-string into a bigint. Empty → 0n. */
export function decodeScriptNum(bytes: Uint8Array): bigint {
if (bytes.length === 0) return 0n;
const topIdx = bytes.length - 1;
const top = bytes[topIdx]!;
const neg = (top & 0x80) !== 0;
const clean = new Uint8Array(bytes);
clean[topIdx] = top & 0x7f;
let v = 0n;
for (let i = clean.length - 1; i >= 0; i--) v = (v << 8n) | BigInt(clean[i]!);
return neg ? -v : v;
}
// ── Hex-string wrappers for the wallet's fee-bps encoding path ───────────────
// The covenant carries `feeBps` in the reservoir NFT's commitment as a Script
// number. These wrappers give the wallet the exact hex string mainnet-js /
// cashscript expect for `nft.commitment` and inverses for reading it back.
/** `feeBpsToCommitmentHex(0n) === ''` (empty commitment). */
export function feeBpsToCommitmentHex(feeBps: bigint): string {
return binToHex(encodeScriptNum(feeBps));
}
/** `commitmentHexToFeeBps('') === 0n`. */
export function commitmentHexToFeeBps(hex: string): bigint {
if (!hex) return 0n;
return decodeScriptNum(hexToBin(hex));
}

View file

@ -188,6 +188,23 @@ export interface TokenTierA extends TokenBase {
readonly boundFeeBps: number;
};
/**
* Adjustable-founder-fee-with-cap descriptor (ADR-0011 realised; covenant
* `DeviantAdjustableFeeCapped`, snapshot 0259). Present ONLY for assets
* minted with the adjustable template. The live `feeBps` is NOT in this
* descriptor — it is on-chain state in the reservoir NFT's commitment,
* mutated by the founder via `updateFee(newFeeBps, sig)`. Cached values
* would go stale the instant the founder updates; readers MUST fetch the
* reservoir UTXO and decode its `nft.commitment` as ScriptNum for the
* current rate. `founderFeeBps` on the outer token is the STARTING fee
* (immutable in the descriptor, not on chain); `maxFeeBps` is the
* immutable ceiling baked into the contract's bytecode.
*/
readonly adjustableFee?: {
readonly maxFeeBps: number; // IMMUTABLE cap (0..500)
readonly founderPubKeyHex: string; // 33-byte compressed pubkey — signs updateFee()
};
/** TXID of the genesis transaction. */
readonly genesisTxId: string;
}

View file

@ -0,0 +1,163 @@
{
"contractName": "DeviantAdjustableFeeCapped",
"constructorInputs": [
{
"name": "tokenCategory",
"type": "bytes"
},
{
"name": "backingRateSats",
"type": "int"
},
{
"name": "maxFeeBps",
"type": "int"
},
{
"name": "founderPubKey",
"type": "pubkey"
},
{
"name": "founderLockingBytecode",
"type": "bytes"
}
],
"abi": [
{
"name": "claim",
"inputs": [
{
"name": "tokenAmount",
"type": "int"
},
{
"name": "recipientLockingBytecode",
"type": "bytes"
}
]
},
{
"name": "updateFee",
"inputs": [
{
"name": "newFeeBps",
"type": "int"
},
{
"name": "founderSig",
"type": "sig"
}
]
}
],
"bytecode": "OP_5 OP_PICK OP_0 OP_NUMEQUAL OP_IF OP_6 OP_PICK OP_0 OP_GREATERTHAN OP_VERIFY OP_INPUTINDEX OP_UTXOTOKENCOMMITMENT OP_BIN2NUM OP_DUP OP_0 OP_GREATERTHANOREQUAL OP_VERIFY OP_DUP OP_4 OP_ROLL OP_LESSTHANOREQUAL OP_VERIFY OP_6 OP_PICK OP_3 OP_ROLL OP_MUL OP_DUP OP_ROT OP_MUL 1027 OP_DIV OP_0 OP_OUTPUTBYTECODE OP_INPUTINDEX OP_UTXOBYTECODE OP_EQUALVERIFY OP_0 OP_OUTPUTTOKENCATEGORY OP_INPUTINDEX OP_UTXOTOKENCATEGORY OP_EQUALVERIFY OP_0 OP_OUTPUTTOKENCOMMITMENT OP_INPUTINDEX OP_UTXOTOKENCOMMITMENT OP_EQUALVERIFY OP_0 OP_OUTPUTTOKENAMOUNT OP_INPUTINDEX OP_UTXOTOKENAMOUNT OP_8 OP_PICK OP_SUB OP_NUMEQUALVERIFY OP_0 OP_OUTPUTVALUE 2202 OP_GREATERTHANOREQUAL OP_VERIFY OP_1 OP_OUTPUTBYTECODE OP_8 OP_ROLL OP_EQUALVERIFY OP_1 OP_OUTPUTTOKENCATEGORY OP_3 OP_ROLL OP_EQUALVERIFY OP_1 OP_OUTPUTTOKENAMOUNT OP_6 OP_ROLL OP_NUMEQUALVERIFY OP_1 OP_OUTPUTVALUE OP_ROT OP_NUMEQUALVERIFY OP_DUP OP_0 OP_GREATERTHAN OP_IF OP_DUP 2202 OP_GREATERTHANOREQUAL OP_VERIFY OP_2 OP_OUTPUTBYTECODE OP_3 OP_PICK OP_EQUALVERIFY OP_2 OP_OUTPUTVALUE OP_OVER OP_NUMEQUALVERIFY OP_2 OP_OUTPUTTOKENCATEGORY OP_0 OP_EQUALVERIFY OP_ENDIF OP_2DROP OP_2DROP OP_1 OP_ELSE OP_5 OP_ROLL OP_1 OP_NUMEQUALVERIFY OP_5 OP_PICK OP_0 OP_GREATERTHANOREQUAL OP_VERIFY OP_5 OP_PICK OP_3 OP_ROLL OP_LESSTHANOREQUAL OP_VERIFY OP_5 OP_ROLL OP_3 OP_ROLL OP_CHECKSIGVERIFY OP_0 OP_OUTPUTBYTECODE OP_INPUTINDEX OP_UTXOBYTECODE OP_EQUALVERIFY OP_0 OP_OUTPUTTOKENCATEGORY OP_INPUTINDEX OP_UTXOTOKENCATEGORY OP_EQUALVERIFY OP_0 OP_OUTPUTTOKENAMOUNT OP_INPUTINDEX OP_UTXOTOKENAMOUNT OP_NUMEQUALVERIFY OP_0 OP_OUTPUTTOKENCOMMITMENT OP_4 OP_ROLL OP_EQUALVERIFY OP_0 OP_OUTPUTVALUE 2202 OP_GREATERTHANOREQUAL OP_NIP OP_NIP OP_NIP OP_ENDIF",
"source": "pragma cashscript ^0.12.0;\n\n// DeviantAdjustableFeeCapped — adjustable founder fee with an IMMUTABLE cap.\n//\n// Realises ADR-0011's two-knob intent honestly on a UTXO chain:\n// • feeBps is MUTABLE — carried in the reservoir NFT's commitment,\n// updated by a founder-signed updateFee() tx.\n// • maxFeeBps is IMMUTABLE — a constructor param baked into the contract\n// bytecode; the ONLY thing that guarantees the founder can't\n// raise the fee arbitrarily.\n//\n// The reservoir NFT MUST be minted with mutable (0x01) or minting (0x02)\n// capability at genesis so its commitment can change. `tokenCategory` (which\n// includes the capability byte) is propagated from input to output, so the\n// capability is preserved forever.\n//\n// Same self-backed FT + P2PKH redeem model as DeviantOpenMintFT — only the\n// fee source differs (commitment-carried vs immutable constructor arg).\n// No pow/loops, one signature check on the update path, zero on claim().\n\ncontract DeviantAdjustableFeeCapped(\n bytes tokenCategory, // bare 32-byte category id == genesis TXID\n int backingRateSats, // sats per FT unit, >= 1000\n int maxFeeBps, // IMMUTABLE cap (e.g. 500 = 5%)\n pubkey founderPubKey, // 33-byte compressed pubkey — signs updateFee()\n bytes founderLockingBytecode // 25-byte P2PKH bytecode of fee recipient\n) {\n // claim() — permissionless. Reads current feeBps from the reservoir's NFT\n // commitment; requires payer to fund grossSats + feeSats. Commitment is\n // PRESERVED across claim() (fee doesn't change on a claim — only on updateFee).\n function claim(int tokenAmount, bytes recipientLockingBytecode) {\n require(tokenAmount > 0);\n\n // Current fee — carried in the reservoir NFT's commitment (0..maxFeeBps).\n int feeBps = int(tx.inputs[this.activeInputIndex].nftCommitment);\n require(feeBps >= 0);\n require(feeBps <= maxFeeBps); // defence-in-depth (updateFee also enforces)\n\n int grossSats = tokenAmount * backingRateSats;\n int feeSats = grossSats * feeBps / 10000;\n\n // [0] reservoir back — same lock, same category (+capability), same commitment,\n // FT reduced by tokenAmount.\n require(tx.outputs[0].lockingBytecode == tx.inputs[this.activeInputIndex].lockingBytecode);\n require(tx.outputs[0].tokenCategory == tx.inputs[this.activeInputIndex].tokenCategory);\n require(tx.outputs[0].nftCommitment == tx.inputs[this.activeInputIndex].nftCommitment);\n require(tx.outputs[0].tokenAmount == tx.inputs[this.activeInputIndex].tokenAmount - tokenAmount);\n require(tx.outputs[0].value >= 546);\n\n // [1] FT to recipient, self-backed at grossSats.\n require(tx.outputs[1].lockingBytecode == recipientLockingBytecode);\n require(tx.outputs[1].tokenCategory == tokenCategory);\n require(tx.outputs[1].tokenAmount == tokenAmount);\n require(tx.outputs[1].value == grossSats);\n\n // [2] founder fee — only constrained when non-zero.\n if (feeSats > 0) {\n require(feeSats >= 546);\n require(tx.outputs[2].lockingBytecode == founderLockingBytecode);\n require(tx.outputs[2].value == feeSats);\n require(tx.outputs[2].tokenCategory == 0x);\n }\n }\n\n // updateFee() — founder-signed. Rotates the commitment to newFeeBps.\n // • MUST be 0 <= newFeeBps <= maxFeeBps (immutable cap enforces the ceiling)\n // • MUST be signed by the founder key baked into the contract\n // • Preserves the reservoir's FT amount, capability, address — only mutates\n // the commitment.\n function updateFee(int newFeeBps, sig founderSig) {\n require(newFeeBps >= 0);\n require(newFeeBps <= maxFeeBps);\n require(checkSig(founderSig, founderPubKey));\n\n // [0] reservoir back — same lock, same category (+capability), SAME FT amount,\n // new commitment = newFeeBps (ScriptNum-encoded via bytes(int)).\n require(tx.outputs[0].lockingBytecode == tx.inputs[this.activeInputIndex].lockingBytecode);\n require(tx.outputs[0].tokenCategory == tx.inputs[this.activeInputIndex].tokenCategory);\n require(tx.outputs[0].tokenAmount == tx.inputs[this.activeInputIndex].tokenAmount);\n require(tx.outputs[0].nftCommitment == bytes(newFeeBps));\n require(tx.outputs[0].value >= 546);\n }\n}\n",
"debug": {
"bytecode": "5579009c63567900a069c0cf817600a26976547aa1695679537a95767b950210279600cdc0c78800d1c0ce8800d2c0cf8800d3c0d05879949d00cc022202a26951cd587a8851d1537a8851d3567a9d51cc7b9d7600a06376022202a26952cd53798852cc789d52d10088686d6d5167557a519d557900a2695579537aa169557a537aad00cdc0c78800d1c0ce8800d3c0d09d00d2547a8800cc022202a277777768",
"sourceMap": "31:4:63:5;;;;;32:16:32:27;;:30::31;:16:::1;:8::33;35:35:35:56:0;:25::71:1;:21::72;36:16:36:22:0;:26::27;:16:::1;:8::29;37:16:37:22:0;:26::35;;:16:::1;:8::37;39:24:39:35:0;;:38::53;;:24:::1;40::40:33:0;:36::42;:24:::1;:45::50:0;:24:::1;44:27:44:28:0;:16::45:1;:59::80:0;:49::97:1;:8::99;45:27:45:28:0;:16::43:1;:59::80:0;:49::95:1;:8::97;46:27:46:28:0;:16::43:1;:59::80:0;:49::95:1;:8::97;47:27:47:28:0;:16::41:1;:59::80:0;:49::93:1;:96::107:0;;:49:::1;:8::109;48:27:48:28:0;:16::35:1;:49::52:0;:16:::1;:8::54;51:27:51:28:0;:16::45:1;:49::73:0;;:8::75:1;52:27:52:28:0;:16::43:1;:49::62:0;;:8::64:1;53:27:53:28:0;:16::41:1;:49::60:0;;:8::62:1;54:27:54:28:0;:16::35:1;:49::58:0;:8::60:1;57:12:57:19:0;:22::23;:12:::1;:25:62:9:0;58:20:58:27;:31::34;:20:::1;:12::36;59:31:59:32:0;:20::49:1;:53::75:0;;:12::77:1;60:31:60:32:0;:20::39:1;:53::60:0;:12::62:1;61:31:61:32:0;:20::47:1;:53::55:0;:12::57:1;57:25:62:9;31:4:63:5;;;;70::82::0;;;;71:16:71:25;;:29::30;:16:::1;:8::32;72:16:72:25:0;;:29::38;;:16:::1;:8::40;73:25:73:35:0;;:37::50;;:8::53:1;77:27:77:28:0;:16::45:1;:59::80:0;:49::97:1;:8::99;78:27:78:28:0;:16::43:1;:59::80:0;:49::95:1;:8::97;79:27:79:28:0;:16::41:1;:59::80:0;:49::93:1;:8::95;80:27:80:28:0;:16::43:1;:55::64:0;;:8::67:1;81:27:81:28:0;:16::35:1;:49::52:0;:8::54:1;70:4:82:5;;;21:0:83:1",
"logs": [],
"requires": [
{
"ip": 14,
"line": 32
},
{
"ip": 21,
"line": 36
},
{
"ip": 26,
"line": 37
},
{
"ip": 41,
"line": 44
},
{
"ip": 46,
"line": 45
},
{
"ip": 51,
"line": 46
},
{
"ip": 59,
"line": 47
},
{
"ip": 64,
"line": 48
},
{
"ip": 69,
"line": 51
},
{
"ip": 74,
"line": 52
},
{
"ip": 79,
"line": 53
},
{
"ip": 83,
"line": 54
},
{
"ip": 91,
"line": 58
},
{
"ip": 96,
"line": 59
},
{
"ip": 100,
"line": 60
},
{
"ip": 104,
"line": 61
},
{
"ip": 118,
"line": 71
},
{
"ip": 124,
"line": 72
},
{
"ip": 129,
"line": 73
},
{
"ip": 134,
"line": 77
},
{
"ip": 139,
"line": 78
},
{
"ip": 144,
"line": 79
},
{
"ip": 149,
"line": 80
},
{
"ip": 154,
"line": 81
}
]
},
"compiler": {
"name": "cashc",
"version": "0.12.1"
},
"updatedAt": "2026-09-01T22:07:41.022Z"
}

View file

@ -43,6 +43,7 @@ import KatalogosRegistryV1 from './artifacts/KatalogosRegistryV1.json';
import DeviantBondingCurveFT from './artifacts/DeviantBondingCurveFT.json';
import DeviantEscFeeSupply from './artifacts/DeviantEscFeeSupply.json';
import DeviantDescFeeSupply from './artifacts/DeviantDescFeeSupply.json';
import DeviantAdjustableFeeCapped from './artifacts/DeviantAdjustableFeeCapped.json';
import OpenTote from './artifacts/OpenTote.json';
import ParimutuelToteOpen from './artifacts/ParimutuelToteOpen.json';
import ParimutuelToteOpenMBA from './artifacts/ParimutuelToteOpenMBA.json';
@ -67,6 +68,15 @@ function freezeArtifact(artifact: unknown): Readonly<Record<string, unknown>> {
export const CANONICAL_ARTIFACT: Readonly<Record<string, unknown>> =
freezeArtifact(DeviantOpenMintFT);
/** Adjustable-founder-fee-with-cap template (ADR-0011, snapshot 0259).
* Same DEA family as CANONICAL_ARTIFACT but with a mutable-commitment
* reservoir NFT carrying the current `feeBps`; founder-signed
* `updateFee(newFeeBps, sig)` rotates it within an immutable `maxFeeBps` cap.
* Constructor args order (bytecode is address-defining): category(reversed),
* rateSats, maxFeeBps, founderPubKey(33-byte compressed), founderLockingBytecode(25-byte P2PKH). */
export const ADJUSTABLE_FEE_CAPPED_ARTIFACT: Readonly<Record<string, unknown>> =
freezeArtifact(DeviantAdjustableFeeCapped);
/**
* The compiled permissionless-draw template for NON-BACKED assets (PTU / PTL,
* ADR-0054). Same reservoir shape as `DeviantOpenMintFT`, but the recipient FT

View file

@ -25,7 +25,7 @@
import { Contract, MockNetworkProvider, type NetworkProvider } from 'cashscript';
import type { TokenTierA } from '../token/types.js';
import type { Utxo } from '../token/classify.js';
import { CANONICAL_ARTIFACT, CANONICAL_BYTECODE_HASH } from '../verify/canonical.js';
import { CANONICAL_ARTIFACT, CANONICAL_BYTECODE_HASH, ADJUSTABLE_FEE_CAPPED_ARTIFACT } from '../verify/canonical.js';
import { txidToConsensusBytes } from '../verify/contract.js';
import { hexToBin } from '../utils/hex.js';
import { ALLOWED_RATE_TIERS, wholeUnitRate, formatUnits } from '../tokenMetadata.js';
@ -52,6 +52,19 @@ export interface GenesisIntent {
* a contract param, not part of the category id. Absent ⇒ 0 (whole units).
*/
readonly decimals?: number;
/**
* Adjustable-fee-with-cap mode (ADR-0011 / snapshot 0259). When present,
* genesis uses `DeviantAdjustableFeeCapped` instead of `DeviantOpenMintFT`
* and the reservoir NFT is minted with `capability: 'mutable'` and
* `commitment: bytes(founderFeeBps)`. `founderFeeBps` becomes the STARTING
* fee (rotatable on chain); `maxFeeBps` is the immutable cap.
* `founderPubKeyHex` is the compressed 33-byte pubkey that will sign
* `updateFee(newFeeBps, sig)`.
*/
readonly adjustableFee?: {
readonly maxFeeBps: number;
readonly founderPubKeyHex: string;
};
}
export interface PrepareGenesisOptions {
@ -144,22 +157,53 @@ export function prepareGenesis(intent: GenesisIntent, options: PrepareGenesisOpt
const ctDust = options.ctDustMinSats ?? CT_DUST_MIN_SATS;
const feeDust = options.feeDustMinSats ?? FEE_DUST_MIN_SATS;
// Adjustable-fee validation (extra params make the address change; the
// starting-fee doubles as founderFeeBps but MAY differ from maxFeeBps).
if (intent.adjustableFee) {
const { maxFeeBps, founderPubKeyHex } = intent.adjustableFee;
if (maxFeeBps < 0 || maxFeeBps > 500) {
throw new GenesisPlanError(`adjustableFee.maxFeeBps must be 0..500, got ${maxFeeBps}`, 'invalid-fee');
}
if (intent.founderFeeBps > maxFeeBps) {
throw new GenesisPlanError(
`starting fee ${intent.founderFeeBps} bps exceeds cap ${maxFeeBps} bps`,
'invalid-fee',
);
}
if (!/^[0-9a-f]{66}$/i.test(founderPubKeyHex)) {
throw new GenesisPlanError(
`adjustableFee.founderPubKeyHex must be a 33-byte compressed pubkey (66 hex chars), got ${founderPubKeyHex.length}`,
'invalid-fee',
);
}
}
// Derive the contract addresses (pure — MockNetworkProvider, no chain).
let contractAddress: string;
let contractTokenAddress: string;
const isAdjustable = !!intent.adjustableFee;
try {
const provider: NetworkProvider = new MockNetworkProvider();
const artifact = (options.artifact ?? CANONICAL_ARTIFACT) as ConstructorParameters<typeof Contract>[0];
const contract = new Contract(
artifact,
[
txidToConsensusBytes(categoryId),
intent.rateSats,
BigInt(intent.founderFeeBps),
hexToBin(options.founderLockingBytecodeHex),
],
{ provider },
);
const artifact = (options.artifact
?? (isAdjustable ? ADJUSTABLE_FEE_CAPPED_ARTIFACT : CANONICAL_ARTIFACT)
) as ConstructorParameters<typeof Contract>[0];
// Constructor args differ per template — the artifact defines the order
// and it MUST match the .cash source verbatim (the address derives from it).
const args = isAdjustable
? [
txidToConsensusBytes(categoryId),
intent.rateSats,
BigInt(intent.adjustableFee!.maxFeeBps),
hexToBin(intent.adjustableFee!.founderPubKeyHex),
hexToBin(options.founderLockingBytecodeHex),
]
: [
txidToConsensusBytes(categoryId),
intent.rateSats,
BigInt(intent.founderFeeBps),
hexToBin(options.founderLockingBytecodeHex),
];
const contract = new Contract(artifact, args, { provider });
contractAddress = contract.address;
contractTokenAddress = contract.tokenAddress;
} catch (err) {
@ -220,6 +264,14 @@ export function prepareGenesis(intent: GenesisIntent, options: PrepareGenesisOpt
contractBytecodeHash: CANONICAL_BYTECODE_HASH,
initialReservoirSupply: reservoirSupply.toString(),
genesisTxId: '', // filled after broadcast
...(intent.adjustableFee
? {
adjustableFee: {
maxFeeBps: intent.adjustableFee.maxFeeBps,
founderPubKeyHex: intent.adjustableFee.founderPubKeyHex,
},
}
: {}),
};
const decimals = intent.decimals ?? 0;

View file

@ -67,6 +67,7 @@ import {
} from './swap-all.js';
import { isTierA, isMbaBacked } from '../token/types.js';
import { prepareGenesis, type GenesisIntent, type GenesisPlan } from './genesis.js';
import { feeBpsToCommitmentHex } from '../token/scriptNum.js';
import {
prepareNbaGenesis,
type NbaGenesisIntent,
@ -110,6 +111,18 @@ export interface GenesisExecutor {
* consensus fact rather than a promise (ADR-0054).
*/
readonly mintingBaton?: boolean;
/**
* Override the reservoir NFT shape. Default = `{ capability: 'minting',
* commitment: '' }`. Adjustable-fee covenants
* (`DeviantAdjustableFeeCapped`, snapshot 0259) mint the reservoir with
* `{ capability: 'mutable', commitment: bytes(startingFeeBps) }` so
* `updateFee()` can rotate the commitment later. Both fields must be
* present when overriding.
*/
readonly nftOverride?: {
readonly capability: 'minting' | 'mutable' | 'none';
readonly commitment: string;
};
}): Promise<{ readonly txid: string }>;
}
@ -536,11 +549,24 @@ export class DeviantWallet {
'no-genesis-executor',
);
}
// Adjustable-fee assets need the reservoir NFT parked as mutable with the
// starting-feeBps commitment; everything else uses the OpenMintFT default
// (minting, ''). The planner already picked the right covenant address —
// the override just makes the on-chain shape match. See scriptNum.ts for
// the commitment codec (byte-identical to CashScript's `bytes(int)`).
const adjustable = plan.token.adjustableFee;
const nftOverride = adjustable
? {
capability: 'mutable' as const,
commitment: feeBpsToCommitmentHex(BigInt(plan.token.founderFeeBps)),
}
: undefined;
const { txid } = await this.cfg.genesisExecutor.execute({
contractTokenAddress: plan.contractTokenAddress,
reservoirSupply: plan.reservoirSupply,
eligibleUtxo: plan.eligibleUtxo,
mintingBaton: true,
...(nftOverride ? { nftOverride } : {}),
});
const token = { ...plan.token, genesisTxId: txid };
// Register the new asset so it appears in balances immediately.

View file

@ -0,0 +1,66 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
encodeScriptNum,
decodeScriptNum,
feeBpsToCommitmentHex,
commitmentHexToFeeBps,
} from '../../../src/token/scriptNum.js';
// Cross-check against the canonical algorithm used by scripts/chipnet-adjustable-
// fee-test.mjs (which the covenant validated on-chain). Byte-identical encoding
// is load-bearing — the covenant compares `outputs[0].nftCommitment == bytes(int)`.
test('encodeScriptNum: 0 → empty', () => {
assert.deepEqual([...encodeScriptNum(0n)], []);
});
test('encodeScriptNum: 100 → 0x64 (single byte, high bit clear)', () => {
assert.deepEqual([...encodeScriptNum(100n)], [0x64]);
});
test('encodeScriptNum: 300 → 0x2c 0x01 (LE, no sign pad needed)', () => {
// 300 = 0x012c LE → 2c 01; top byte 0x01 has high bit clear, no pad.
assert.deepEqual([...encodeScriptNum(300n)], [0x2c, 0x01]);
});
test('encodeScriptNum: 128 → 0x80 0x00 (sign-pad needed)', () => {
// 128 = 0x80 LE → 80; but 0x80 has the sign bit set, so pad with 00 to
// disambiguate from -0. Result: [0x80, 0x00].
assert.deepEqual([...encodeScriptNum(128n)], [0x80, 0x00]);
});
test('encodeScriptNum: 500 (cap value used by the chipnet test) → 0xf4 0x01', () => {
// 500 = 0x01f4 LE → f4 01. Top byte 0x01 has high bit clear, no pad.
assert.deepEqual([...encodeScriptNum(500n)], [0xf4, 0x01]);
});
test('encodeScriptNum: -1 → 0x81 (in-place sign)', () => {
assert.deepEqual([...encodeScriptNum(-1n)], [0x81]);
});
test('encodeScriptNum: -128 → 0x80 0x80 (sign-pad + neg flag)', () => {
assert.deepEqual([...encodeScriptNum(-128n)], [0x80, 0x80]);
});
test('decodeScriptNum: inverse of encode for a spread of values', () => {
for (const n of [0n, 1n, 100n, 128n, 300n, 500n, 32767n, 65535n, -1n, -128n, -300n]) {
assert.equal(decodeScriptNum(encodeScriptNum(n)), n, `roundtrip failed for ${n}`);
}
});
test('feeBpsToCommitmentHex: matches the exact values the covenant sees', () => {
// From scripts/chipnet-adjustable-fee-test.mjs, real on-chain commitments:
// feeBps=100 → "64" · feeBps=300 → "2c01"
assert.equal(feeBpsToCommitmentHex(0n), '');
assert.equal(feeBpsToCommitmentHex(100n), '64');
assert.equal(feeBpsToCommitmentHex(300n), '2c01');
assert.equal(feeBpsToCommitmentHex(500n), 'f401');
});
test('commitmentHexToFeeBps: reads the reservoir NFT commitment back', () => {
assert.equal(commitmentHexToFeeBps(''), 0n);
assert.equal(commitmentHexToFeeBps('64'), 100n);
assert.equal(commitmentHexToFeeBps('2c01'), 300n);
assert.equal(commitmentHexToFeeBps('f401'), 500n);
});