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:
parent
d11a5f068c
commit
9e5f7a3e8d
11 changed files with 833 additions and 64 deletions
|
|
@ -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;
|
||||
|
|
|
|||
172
shells/app/src/core/updateFee.ts
Normal file
172
shells/app/src/core/updateFee.ts
Normal 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) };
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'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's commitment (signed).
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Current: {typeof token.founderFeeBps === 'number'
|
||||
? `${(token.founderFeeBps / 100).toFixed(1)}%`
|
||||
: '—'} · rotates the reservoir NFT'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'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
75
src/token/scriptNum.ts
Normal 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));
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
163
src/verify/artifacts/DeviantAdjustableFeeCapped.json
Normal file
163
src/verify/artifacts/DeviantAdjustableFeeCapped.json
Normal 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"
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
66
test/unit/token/scriptNum.test.ts
Normal file
66
test/unit/token/scriptNum.test.ts
Normal 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);
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue