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.
231 lines
9.8 KiB
JavaScript
231 lines
9.8 KiB
JavaScript
// 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,
|
||
};
|
||
}
|