argus/src/lib/registry-lifecycle.js
Local Dev f9a5eb367f Theseus navigator UI overhaul, BNS-only indexer, site restructure
Theseus Navigator:
- Firefox-style toolbar: SVG back/fwd/reload/home buttons
- security padlock badge in the address bar opens a floating site-info
  overlay VIEW (on top of the page, never pushes content down)
- Edge-style favorites bar (shown only on the new-tab/home page)
- search-engine picker (DuckDuckGo/Google/Bing/Brave/Startpage)
- Theseus logo moved right, opens Settings; native menu removed
- address bar doubles as search; bottom resolver status line

Argus:
- BNS-only indexer: an electrum endpoint over a bare BCHN node
  (watches just the beacon, caches txs so no txindex needed) + test
- registry lifecycle / portfolio / name-index modules

Site restructure (nav/apps/store/hermes/choose) + runbook notes.
2026-07-30 08:16:55 +02:00

231 lines
9.8 KiB
JavaScript
Raw 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.

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