// Name lifecycle: phases, prices and the payment split. // // WHAT THIS IS FOR // These are the rules the covenant will encode. Until the covenant exists they // are still needed, by three callers that must all agree: // // · the storefront, to quote a renewal or a takeover // · the wallet UI, to show "ACTIVE until block X" and warn before expiry // · the covenant tests, as the reference the contract is checked against // // So they live here as pure functions of block height, deliberately written the // way a covenant computes: integer arithmetic on satoshis and heights, no // floating point, no clock, no oracle. When the CashScript contract is written it // must produce identical numbers, and these functions are the fixture. // // SEE Decentralized.DNS/DESIGN-covenant-registry.md for the reasoning behind // every constant here. The short version of the two least obvious ones: // // Decay, not a step. The takeover price glides from the IDLE premium down to // the OPEN floor. A step would put a starting pistol on one block and hand the // name to whoever paid the biggest miner fee — which is the outcome the pricing // is trying to prevent. A glide is also demand discovery without an oracle: a // name people want gets bought on the way down at what someone will actually // pay, and never reaches the floor. // // No miner share by default. Paying miners is only possible as transaction fee, // and a fee attached to a takeover is a discount for the one party who also // controls block ordering and can read the demand signal out of the mempool. // Commit–reveal neutralises that, but then the design's safety depends on // commit–reveal being right. Folding the share into the fund removes the // problem structurally. Flip `SPLIT.minerPercent` to 15 to restore it. // ---------- time ---------- /** ~10-minute blocks. Whole-year registrations only, so this is exact enough. */ export const BLOCKS_PER_YEAR = 52_560; export const BLOCKS_PER_MONTH = 4_380; /** Grace window after expiry during which the owner may still reclaim. */ export const GRACE_MONTHS = 6; export const GRACE_BLOCKS = GRACE_MONTHS * BLOCKS_PER_MONTH; export const MIN_REGISTRATION_YEARS = 1; // ---------- phases ---------- export const PHASE = { active: "active", // owner holds it, renewal not yet due idle: "idle", // expired; owner may reclaim cheaply, others may take over dearly open: "open", // grace elapsed; anyone may buy at the floor }; /** * Which phase a name is in at a given block height. * `expiryHeight` and `idleEndHeight` are covenant parameters. */ export function phaseAt({ height, expiryHeight, idleEndHeight }) { if (height < expiryHeight) return PHASE.active; if (height < idleEndHeight) return PHASE.idle; return PHASE.open; } export const idleEndFor = (expiryHeight) => expiryHeight + GRACE_BLOCKS; export const expiryFor = (startHeight, years) => startHeight + BLOCKS_PER_YEAR * years; // ---------- tiers ---------- // Length-based, because it is the one metric a covenant can be given as a // parameter and a buyer can predict without consulting us. Dictionary words and // reserved terms are handled at registration time by assigning a higher tier — // the contract only ever sees the tier number. // // Satoshi figures are chipnet placeholders carrying USD *intent* (see the table // in the design note). Mainnet needs repricing against a real BCH price, and // once a name is minted its tier is fixed for that covenant's lifetime — the // migrate path is the only way to reprice. That is a deliberate trade for having // no oracle. export const TIERS = [ { tier: 1, maxLength: 2, renewalSats: 2_000_000n }, // ~$200/yr intent { tier: 2, maxLength: 4, renewalSats: 500_000n }, // ~$50/yr { tier: 3, maxLength: 7, renewalSats: 200_000n }, // ~$20/yr { tier: 4, maxLength: Infinity, renewalSats: 100_000n }, // ~$10/yr ]; /** Multipliers on the annual renewal price. */ export const IDLE_PREMIUM_MULTIPLE = 50n; // tier 3: $20 -> $1000 export const OPEN_FLOOR_MULTIPLE = 5n; // tier 3: $20 -> $100 /** Tier for a name, by label length. `name` may be "alice" or "alice.nav". */ export function tierForName(name) { const label = String(name).split(".")[0]; if (!label.length) throw new Error("empty name has no tier"); return TIERS.find((t) => label.length <= t.maxLength).tier; } export function tierConfig(tier) { const t = TIERS.find((x) => x.tier === tier); if (!t) throw new Error(`unknown tier ${tier}`); return t; } export const renewalPriceSats = (tier, years = 1) => tierConfig(tier).renewalSats * BigInt(years); export const idlePremiumSats = (tier) => tierConfig(tier).renewalSats * IDLE_PREMIUM_MULTIPLE; export const openFloorSats = (tier) => tierConfig(tier).renewalSats * OPEN_FLOOR_MULTIPLE; // ---------- takeover price ---------- /** * What a stranger must pay to take the name at this height. * * ACTIVE — not for sale at any price; the owner holds it. * IDLE — glides linearly from the premium down to the floor. * OPEN — the floor, indefinitely. * * Integer arithmetic throughout, in the order a covenant would do it: multiply * before dividing so the remainder is not lost, and never touch floating point. */ export function takeoverPriceSats({ height, expiryHeight, idleEndHeight, tier }) { const phase = phaseAt({ height, expiryHeight, idleEndHeight }); if (phase === PHASE.active) return null; // not purchasable const floor = openFloorSats(tier); if (phase === PHASE.open) return floor; const premium = idlePremiumSats(tier); const span = BigInt(idleEndHeight - expiryHeight); const remaining = BigInt(idleEndHeight - height); // 1..span within IDLE return floor + ((premium - floor) * remaining) / span; } /** What the owner pays to reclaim or renew — never affected by phase or demand. */ export function reclaimPriceSats({ tier, years = 1 }) { return renewalPriceSats(tier, years); } // ---------- the split ---------- /** * Recommended: 30 / 0 / 70 in IDLE, 0 / 0 / 100 in OPEN. * * The owner's 30% is a buy-out while they are still entitled to reclaim. Once * OPEN, that entitlement has lapsed and so does the justification, so the share * goes to zero rather than paying an absent party in perpetuity. * * `minerPercent` is 0 by design — see the header. Set it to 15 to restore the * miner share; the remainder is taken out of the fund, and the amount is left * unspent so it becomes transaction fee (the only way to pay miners at all). */ export const SPLIT = { ownerPercentIdle: 30n, ownerPercentOpen: 0n, minerPercent: 0n, // 15n restores the miner share }; /** * Split a takeover payment into the outputs a covenant must require. * * `minerFeeSats` is NOT an output — it is the amount left unspent, which the * miner collects as fee. Callers building the transaction must under-spend by * exactly this much. */ export function splitTakeover({ amountSats, phase }) { const amount = BigInt(amountSats); if (amount <= 0n) throw new Error("takeover amount must be positive"); if (phase === PHASE.active) throw new Error("an ACTIVE name is not for sale"); const ownerPercent = phase === PHASE.idle ? SPLIT.ownerPercentIdle : SPLIT.ownerPercentOpen; const ownerSats = (amount * ownerPercent) / 100n; const minerFeeSats = (amount * SPLIT.minerPercent) / 100n; // The fund takes the remainder, so integer division can never lose or // conjure a satoshi — the three parts always sum to the amount exactly. const fundSats = amount - ownerSats - minerFeeSats; return { ownerSats, minerFeeSats, fundSats, totalSats: amount }; } // ---------- commit–reveal ---------- // Applies to first registrations AND takeovers. One confirmation is enough to // make ordering unforgeable: an attacker seeing a reveal cannot produce a commit // older than one already in a block. The upper bound stops speculative // pre-commits from being hoarded indefinitely. export const COMMIT = { minConfirmations: 1, maxConfirmations: 1008, // ~1 week saltBytes: 16, }; export const commitIsMature = (commitHeight, atHeight) => { const age = atHeight - commitHeight; return age >= COMMIT.minConfirmations && age <= COMMIT.maxConfirmations; }; // ---------- fund ---------- export const FUND = { // 2-of-3 multisig, published, with visible spending from day one. Baked into // the covenant at mint and therefore permanent: if this key is lost every // future takeover pays into a hole, and if it is compromised the attacker // collects the fund share forever. The migrate path (design note §4.6) is the // only escape, which is why it must exist from the first version. address: null, // set before any mainnet covenant is deployed multisig: "2-of-3", purpose: "Bitcoin Cash development and infrastructure", // Voluntary and opt-in — it touches only names in this registry, not the // chain and not miners' rewards. That distinction is the answer to the // inevitable comparison with the 2020 IFP, and it only holds if the fund is // transparent from the start rather than after the first accusation. voluntary: true, }; /** Everything a UI needs to describe a name's standing at a glance. */ export function describeLifecycle({ height, expiryHeight, idleEndHeight, tier }) { const phase = phaseAt({ height, expiryHeight, idleEndHeight }); return { phase, tier, blocksUntilExpiry: Math.max(0, expiryHeight - height), blocksUntilOpen: Math.max(0, idleEndHeight - height), reclaimPriceSats: reclaimPriceSats({ tier }), takeoverPriceSats: takeoverPriceSats({ height, expiryHeight, idleEndHeight, tier }), ownerCanReclaim: phase !== PHASE.open, // The number that actually protects a present owner: their option is tens of // times cheaper than a stranger's, so renewing always beats being bought out. ownerAdvantage: phase === PHASE.idle ? Number(idlePremiumSats(tier) / reclaimPriceSats({ tier })) : null, }; }