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>
281 lines
12 KiB
TypeScript
281 lines
12 KiB
TypeScript
/**
|
||
* 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);
|
||
}
|