argus/src/lib/registry-lifecycle.js

232 lines
9.8 KiB
JavaScript
Raw Normal View History

// 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,
};
}