deviant/src/tokenMetadata.ts
walerikus 518a3ce174 chore: initial project baseline — full Deviant codebase under version control
Brings the whole project into git (previously only the on-ramp feature and
a few parallel files were tracked). Includes: wallet core (src/), CashScript
contracts/, the Capacitor shell (shells/), scripts/, tests, and the docs
corpus (wiki/, snapshots/, Parameters/, Archive/, KnowledgeBase text).

Excluded via .gitignore: node_modules/, dist/, .env (secrets), testnet WIF
key files (.chipnet-*.json, devWallets.ts), KnowledgeBase/RAW media (~9.6 GB),
transcripts/ (session logs), and machine-local config (.claude/*.local,
*.lock). Secret-scanned before commit — no credentials in the tree.

Note: src/swapMath.ts, src/swapBuilder.ts, contracts/OpenMintToken.cash are
legacy (OP_RETURN era) but still imported; committed as-is, flagged for a
separate removal once de-referenced.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 20:38:07 +02:00

281 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* tokenMetadata — the genesis parameter set for a Deviant token.
*
* Two tokens with identical metadata share a tokenId by construction.
* This is intentional: tokenId is content-addressed (see ADR-0003).
*
* Both Architecture A (contract) and Architecture B (UI) read from the
* same TokenMetadata structure. The escalation path B → A requires that
* the recomputed tokenId match the original — see tokenId.ts.
*/
/**
* Permitted backing rate tiers in sats per token unit.
* Restricted at the protocol layer per paper §3.2 to avoid rounding
* errors in swap calculations.
*
* Floor raised to 1000 per spec §1.3 and ADR-0006: rates below 1000 sats
* are economically non-viable once the ~546-sat dust minimum and ~220-sat
* miner fee are accounted for. The 1, 10, 100 tiers existed in the
* pre-spec draft and were dropped because no valid UTXO could be
* constructed at those rates.
*
* 1_000_000_000n (10 BCH/unit) added per spec v2.1 §6.1.1 — high-value /
* institutional asset tier. Same `output[1].value == grossSats`
* enforcement applies at every scale.
*/
export const ALLOWED_RATE_TIERS = [
1_000n,
10_000n,
100_000n,
1_000_000n,
10_000_000n,
100_000_000n,
1_000_000_000n,
] as const;
export type AllowedRateTier = (typeof ALLOWED_RATE_TIERS)[number];
export interface TokenMetadata {
/** Display name (UI-only; not part of tokenId hash) */
readonly name: string;
/** Display ticker (UI-only; not part of tokenId hash) */
readonly ticker: string;
/**
* sats per ATOMIC token unit (one on-chain FT) — this is the value the
* contract enforces and the verifier reconstructs. With `decimals = 0`
* (the legacy default) the atomic unit IS the whole displayed unit, so
* `backingRateSats` must itself be an ALLOWED_RATE_TIER. With `decimals > 0`
* the atomic unit is a sub-unit (a "cent"); the WHOLE-UNIT rate that must
* be a tier is `backingRateSats × 10^decimals` (see validateMetadata).
*/
readonly backingRateSats: bigint;
/**
* Display denomination — how many decimal places a whole unit divides into.
* `decimals = d` means 1 whole displayed unit = 10^d atomic FTs (a 2-decimal
* asset shows 100 atomic FTs as "1.00"). Optional; absent ⇒ 0 (whole units,
* backward-compatible with every pre-decimals registry entry). NEW issuance
* defaults to {@link DEFAULT_DECIMALS}. Display-only — NOT part of the
* tokenId hash (like name/ticker). Constraint: `backingRateSats × 10^decimals`
* must be an ALLOWED_RATE_TIER, which bounds `decimals ≤ log10(tier)`.
*/
readonly decimals?: number;
/** Open-mint flag — gates protocol swap eligibility */
readonly openMint: boolean;
/** Stablecoin flag — when true, market price is decoupled from rate */
readonly stablecoin: boolean;
/** Founder fee in basis points, 0..500 (0%..5%) */
readonly founderFeeBps: number;
/** Founder's P2PKH hash (20 bytes) — fee recipient and identity anchor */
readonly founderPkh: Uint8Array;
/** BCH block height at genesis — provides uniqueness across identical params */
readonly genesisHeight: number;
}
/** Default display denomination for newly issued assets: 2 decimals (cents). */
export const DEFAULT_DECIMALS = 2;
/** Hard ceiling on decimals (mirrors the largest tier 10^9 → 9 places). */
export const MAX_DECIMALS = 9;
export class InvalidMetadataError extends Error {
constructor(message: string) {
super(message);
this.name = 'InvalidMetadataError';
}
}
/**
* Throws InvalidMetadataError if metadata violates protocol constraints.
* Pure function — no side effects, fully synchronous.
*/
export function validateMetadata(m: TokenMetadata): void {
const decimals = m.decimals ?? 0;
if (!Number.isInteger(decimals) || decimals < 0 || decimals > MAX_DECIMALS) {
throw new InvalidMetadataError(`decimals must be integer in 0..${MAX_DECIMALS}, got ${decimals}`);
}
if (m.backingRateSats < 1n) {
throw new InvalidMetadataError(`backingRateSats (per atomic unit) must be >= 1, got ${m.backingRateSats}`);
}
// The WHOLE-UNIT rate (atomic rate × 10^decimals) is what must be a tier.
// For decimals = 0 this reduces to "backingRateSats must be a tier" (legacy).
const wholeUnitRate = m.backingRateSats * 10n ** BigInt(decimals);
if (!ALLOWED_RATE_TIERS.includes(wholeUnitRate as AllowedRateTier)) {
throw new InvalidMetadataError(
decimals === 0
? `backingRateSats must be one of ${ALLOWED_RATE_TIERS.join(', ')}, got ${m.backingRateSats}`
: `whole-unit rate (backingRateSats ${m.backingRateSats} × 10^${decimals} = ${wholeUnitRate}) must be one of ${ALLOWED_RATE_TIERS.join(', ')}`,
);
}
if (!Number.isInteger(m.founderFeeBps) || m.founderFeeBps < 0 || m.founderFeeBps > 500) {
throw new InvalidMetadataError(`founderFeeBps must be integer in 0..500, got ${m.founderFeeBps}`);
}
if (m.founderPkh.length !== 20) {
throw new InvalidMetadataError(`founderPkh must be 20 bytes, got ${m.founderPkh.length}`);
}
if (!Number.isInteger(m.genesisHeight) || m.genesisHeight < 0 || m.genesisHeight > 0xffff_ffff) {
throw new InvalidMetadataError(`genesisHeight must be uint32, got ${m.genesisHeight}`);
}
if (m.stablecoin && m.openMint) {
throw new InvalidMetadataError(
'stablecoin and openMint are mutually exclusive (stablecoins use issuer-portal mint)',
);
}
}
/**
* The OP_RETURN protocol prefix that identifies a Deviant token output.
* 4 ASCII bytes 'DVNT'.
*/
export const PROTOCOL_PREFIX = new Uint8Array([0x44, 0x56, 0x4e, 0x54]);
export const PROTOCOL_VERSION = 0x01;
export const TIER_A = 0x41; // 'A' — CashScript-enforced
export const TIER_B = 0x42; // 'B' — UI-only
export type Tier = typeof TIER_A | typeof TIER_B;
// ---------------------------------------------------------------------------
// Denomination helpers (display layer).
//
// `backingRateSats` (a.k.a. a token's `rateSats`) is the per-ATOMIC-FT rate —
// what the contract enforces and what verify/classify/backing consume. The
// human-facing WHOLE unit is a grouping of 10^decimals atomic FTs. These pure
// helpers translate between the two; they touch no consensus path.
// ---------------------------------------------------------------------------
/** Whole-unit rate in sats = atomic rate × 10^decimals. The tier-bearing figure. */
export function wholeUnitRate(atomicRateSats: bigint, decimals: number): bigint {
return atomicRateSats * 10n ** BigInt(decimals);
}
/**
* Inverse: the per-atomic rate to STORE given a founder-declared whole-unit
* rate (a tier) and decimals. Throws if the unit rate doesn't divide evenly
* (i.e. the requested decimals exceed what the tier supports).
*/
export function atomicRateForUnit(wholeUnitRateSats: bigint, decimals: number): bigint {
const denom = 10n ** BigInt(decimals);
if (wholeUnitRateSats % denom !== 0n) {
throw new InvalidMetadataError(
`whole-unit rate ${wholeUnitRateSats} is not divisible by 10^${decimals}; reduce decimals`,
);
}
return wholeUnitRateSats / denom;
}
/**
* Format a raw atomic FT amount as a fixed-decimals display string.
* formatUnits(250n, 2) === "2.50"; formatUnits(7n, 0) === "7".
*/
export function formatUnits(atomicAmount: bigint, decimals: number): string {
if (decimals <= 0) return atomicAmount.toString();
const neg = atomicAmount < 0n;
const abs = neg ? -atomicAmount : atomicAmount;
const denom = 10n ** BigInt(decimals);
const whole = abs / denom;
const frac = (abs % denom).toString().padStart(decimals, '0');
return `${neg ? '-' : ''}${whole}.${frac}`;
}
/**
* Parse a decimal display string back to a raw atomic FT amount.
* parseUnits("2.50", 2) === 250n. Throws if the fractional part has more
* places than `decimals` (would lose precision below the atomic unit).
*/
export function parseUnits(display: string, decimals: number): bigint {
const m = /^(-?)(\d+)(?:\.(\d+))?$/.exec(display.trim());
if (!m) throw new InvalidMetadataError(`not a decimal amount: ${display}`);
const sign = m[1] ?? '';
const whole = m[2] ?? '0';
const frac = m[3] ?? '';
if (frac.length > decimals) {
throw new InvalidMetadataError(
`amount ${display} has ${frac.length} decimal places; max for this asset is ${decimals}`,
);
}
const padded = frac.padEnd(decimals, '0');
const atomic = BigInt(whole) * 10n ** BigInt(decimals) + BigInt(padded || '0');
return sign === '-' ? -atomic : atomic;
}
/**
* The largest number of decimal places at which EVERY rate a curve passes
* through still divides into whole satoshis — i.e. the largest D with
* 10^D | gcd(rates). For a fixed-rate asset pass the single rate; for a DRA
* pass the base rate and increment (the achievable rates are base + n·incr,
* all divisible by 10^D iff 10^D | gcd(base, incr)). An irregular increment
* (e.g. 114) collapses this to 0 — whole units only (see snapshot 0036).
*/
export function maxCleanDecimals(...rates: bigint[]): number {
let g = 0n;
for (const r of rates) g = gcd(g, r < 0n ? -r : r);
if (g === 0n) return 0;
let d = 0;
while (g % 10n ** BigInt(d + 1) === 0n && d < MAX_DECIMALS) d++;
return d;
}
function gcd(a: bigint, b: bigint): bigint {
while (b !== 0n) [a, b] = [b, a % b];
return a;
}
/**
* Dust-safe atomic-rate floor (ADR-0006: ≥1000 sats/FT; comfortably above the
* 675-sat CashTokens output dust limit). An atomic FT backed at this rate is a
* relayable standalone token output, so it is the smallest a divisible MBA's
* atomic unit may carry.
*/
export const ATOMIC_RATE_FLOOR = 1000n;
/**
* The DIVISIBLE-BY-DEFAULT rule (ADR-0039). Two decimal places (cents-style,
* `DEFAULT_DECIMALS`) is the standard — applied uniformly regardless of value,
* so a 1000-sat coin is as fractionable as a 100,000-sat one. The dust floor is
* NOT a creation constraint; sub-floor atomic rates are permitted and instead
* bounded at transaction time by a wallet-side minimum threshold (see
* `minSendableUnits` + the send/claim guards).
*
* Given a founder's whole-unit backing
* rate (sats per whole coin), default to `DEFAULT_DECIMALS` (2) places, clamped
* DOWN only when the rate doesn't divide cleanly into that many (so the atomic
* rate stays a whole number of sats). Returns the per-atomic rate to deploy
* (what the contract enforces) and the display `decimals`.
*
* 100000 → { atomicRateSats: 1000, decimals: 2 } // 0.01 (GLD)
* 10000 → { atomicRateSats: 100, decimals: 2 } // 0.01 (SLV) — sub-floor, threshold-guarded
* 1000 → { atomicRateSats: 10, decimals: 2 } // 0.01 (CUP) — sub-floor, threshold-guarded
* 12345 → { atomicRateSats: 12345, decimals: 0 } // not divisible by 10 → whole units
* 250 → { atomicRateSats: 25, decimals: 1 } // divisible by 10 but not 100 → 1 dp
*
* An atomic rate below `ATOMIC_RATE_FLOOR` is fine here: the smallest *standalone*
* transfer is bounded by the dust floor, surfaced to the user as a minimum
* threshold rather than blocked at genesis.
*/
export function defaultDenomination(wholeRateSats: bigint): {
readonly atomicRateSats: bigint;
readonly decimals: number;
} {
if (wholeRateSats <= 0n) {
throw new InvalidMetadataError(`whole-unit rate must be > 0, got ${wholeRateSats}`);
}
let decimals = 0;
for (let d = 1; d <= DEFAULT_DECIMALS; d++) {
if (wholeRateSats % 10n ** BigInt(d) !== 0n) break; // keep atomic a whole sat count
decimals = d;
}
return { atomicRateSats: wholeRateSats / 10n ** BigInt(decimals), decimals };
}
/**
* Smallest number of WHOLE units that can be sent as a standalone output, given
* the per-atomic rate and decimals — i.e. the dust-floor threshold the wallet
* surfaces. A token FT output must carry ≥ `dustSats` (default CT dust 675), so
* the minimum is `ceil(dustSats / atomicRate)` atomic units expressed in whole
* units. Returns a decimal string (e.g. "0.68").
*/
export function minSendableUnits(atomicRateSats: bigint, decimals: number, dustSats = 675n): string {
const minAtomic = (dustSats + atomicRateSats - 1n) / atomicRateSats; // ceil
return formatUnits(minAtomic, decimals);
}