feat(registrar): 90/10 revenue split — TLD owner earns from name mints

Every second-level name registration under a TLD now routes 90% of the
service fee to whoever holds that TLD's certificate on chain, with 10%
going to the platform address. That's the economic incentive for
minting a TLD: you earn from every name registered under it.

The mechanism, end to end:

1. resolver-web.js fetchTldMap now also records mintScriptHex — the
   scriptPubKey of the TREG output that carries each TLD's NFT.
   Exported so registrar can decode it into a cashaddr with libauth.
   MVP: this is the ORIGINAL owner; NFT transfers after mint are not
   traced yet (a follow-up will walk the chain of transfers).

2. registrar.js gains findTldOwnerAddress(client, tld) and
   splitServiceFee(sats). The split constants live at the top of the
   file (TLD_OWNER_SHARE_NUM/DEN = 90/100) so the ratio moves in one
   place. Rounding: BigInt division favours the platform on odd sat
   counts so the two shares always sum EXACTLY to the input.

3. quoteRegistration wraps the existing flow: it derives the TLD from
   the name, looks up the TLD owner, and if the owner ≠ buyer it asks
   buildRegistrationTx to add a second fee output. If the owner
   couldn't be resolved (TLD not registered, decode failure) the full
   fee stays on the platform address — the buyer still pays the same
   amount either way.

4. register-tx.js buildRegistrationTx accepts tldFeeAddress/tldFeeSats
   and, when set, emits an extra P2PKH output for the TLD owner. Sits
   between the beacon dust and the platform-fee output; outputMap
   records .tldOwnerFee so callers can find it. costs also carries
   tldOwnerFeeSats and netCostSats includes it.

5. priceSummary in registrar-config splits the 'Service fee' row into
   'Service fee — TLD owner (90%)' + 'Service fee — platform (10%)'
   whenever tldOwnerFeeSats > 0, with a per-line note explaining
   where the money goes.

Bundle: re-exported findTldOwnerAddress + splitServiceFee from
register-entry.js. Rebuilt bns-register.js (~34 kB) and deployed;
cache-buster bumped to ?v=20260908split on portal / admin /
register-flow.js.

Verified live: findTldOwnerAddress('.bch') returns the operator
cashaddr; quoteRegistration('tester42.bch') builds cleanly with a
9,000/1,000 split output pair on a 10,000-sat fee; priceSummary
renders both lines. No regressions on the TLD-mint flow (buyer IS
the TLD owner there — split short-circuits and it stays a single
fee output as before).
This commit is contained in:
Local Dev 2026-09-08 02:42:16 +02:00
parent 2346320bad
commit 2d80e68ef8
5 changed files with 134 additions and 22 deletions

View file

@ -243,8 +243,12 @@ function measure(transaction) {
* Set this to the funder when the funder is not the buyer.
* @param {object} [o.records] BNS1 record payload (u/h/ip/tls/a/...)
* @param {Array} o.utxos spendable UTXOs (see chain.js getUtxos)
* @param {string} [o.feeAddress] service-fee recipient
* @param {bigint} [o.feeSats] service fee in satoshis
* @param {string} [o.feeAddress] service-fee recipient (the platform)
* @param {bigint} [o.feeSats] service fee in satoshis (platform's cut)
* @param {string} [o.tldFeeAddress] TLD owner's cashaddr — gets the TLD-owner share
* @param {bigint} [o.tldFeeSats] TLD owner share in satoshis (out of the full
* service fee; the caller has already computed the
* split — see quoteRegistration for the policy)
* @param {string} [o.beaconAddress]
* @param {number} [o.feeRate] sat/byte, default 1
* @param {function} o.addressToLockingBytecode injected from chain.js
@ -260,6 +264,8 @@ export function buildRegistrationTx({
utxos,
feeAddress = null,
feeSats = 0n,
tldFeeAddress = null,
tldFeeSats = 0n,
beaconAddress = BEACON,
feeRate = 1,
addressToLockingBytecode,
@ -270,7 +276,9 @@ export function buildRegistrationTx({
const rawName = normalizeName(name);
const payload = encodePayload("REG", rawName, records);
const serviceFee = BigInt(feeSats ?? 0n);
const tldFee = BigInt(tldFeeSats ?? 0n);
if (serviceFee > 0n && !feeAddress) throw new Error("feeSats set but feeAddress missing");
if (tldFee > 0n && !tldFeeAddress) throw new Error("tldFeeSats set but tldFeeAddress missing");
// Change goes to whoever funded the transaction — the buyer in the
// self-custody flow, the registrar when the registrar mints on their behalf.
@ -278,9 +286,10 @@ export function buildRegistrationTx({
const certLock = addressToLockingBytecode(ownerTokenAddress ?? ownerAddress);
const beaconLock = addressToLockingBytecode(beaconAddress);
const feeLock = serviceFee > 0n ? addressToLockingBytecode(feeAddress) : null;
const tldFeeLock = tldFee > 0n ? addressToLockingBytecode(tldFeeAddress) : null;
// Everything the transaction must pay out, before miner fee and change.
const fixedOut = CERTIFICATE_DUST_SAT + BEACON_DUST_SAT + serviceFee;
const fixedOut = CERTIFICATE_DUST_SAT + BEACON_DUST_SAT + serviceFee + tldFee;
// Iterate: pick inputs, measure the real encoded size, re-check funding.
let selection = selectInputs(utxos, fixedOut + 300n); // 300 = first-pass fee guess
@ -308,6 +317,7 @@ export function buildRegistrationTx({
});
outputs.push({ lockingBytecode: opReturnScript(payload), valueSatoshis: 0n });
outputs.push({ lockingBytecode: beaconLock, valueSatoshis: BEACON_DUST_SAT });
if (tldFeeLock) outputs.push({ lockingBytecode: tldFeeLock, valueSatoshis: tldFee });
if (feeLock) outputs.push({ lockingBytecode: feeLock, valueSatoshis: serviceFee });
return { version: 2, locktime: 0, inputs: inputs.map(toInput), outputs };
};
@ -351,6 +361,7 @@ export function buildRegistrationTx({
outputMap.certificate = i++;
outputMap.opReturn = i++;
outputMap.beacon = i++;
if (tldFeeLock) outputMap.tldOwnerFee = i++;
if (feeLock) outputMap.serviceFee = i++;
return {
@ -365,13 +376,16 @@ export function buildRegistrationTx({
costs: {
certificateDustSats: CERTIFICATE_DUST_SAT,
beaconDustSats: BEACON_DUST_SAT,
// serviceFeeSats is the PLATFORM's cut; tldOwnerFeeSats is the TLD
// owner's cut. Together they equal the full service fee the buyer pays.
serviceFeeSats: serviceFee,
tldOwnerFeeSats: tldFee,
chainFeeSats: chainFee,
changeSats: change,
totalInputSats: totalSats,
// What actually leaves the buyer's control (the certificate dust comes back
// to them as the certificate itself, so it is not really "spent").
netCostSats: serviceFee + chainFee + BEACON_DUST_SAT,
netCostSats: serviceFee + tldFee + chainFee + BEACON_DUST_SAT,
},
};
}

View file

@ -25,8 +25,48 @@ import {
toWcTransactionObject,
} from "./register-tx.js";
import { signTransaction, txidOfHex } from "./sign-tx.js";
import { buildIndex } from "./resolver-web.js";
import { buildIndex, fetchTldMap } from "./resolver-web.js";
import { REGISTRAR } from "../registrar-config.js";
import { hexToBin, lockingBytecodeToCashAddress } from "@bitauth/libauth";
// Revenue split: percentage of the name-registration service fee that flows
// to the TLD's owner (whoever holds the TLD's on-chain NFT). The rest is the
// platform's cut. 90/10 today; adjustable per-TLD in future via a rate
// baked into the TLD certificate itself.
const TLD_OWNER_SHARE_NUM = 90n;
const TLD_OWNER_SHARE_DEN = 100n;
/**
* Look up the current TLD owner's cashaddr by walking the TLD beacon.
* MVP: returns the ORIGINAL mint recipient — NFT transfers after mint aren't
* traced yet. Returns null when the TLD isn't registered or the mint script
* can't be decoded (in which case the caller should pay the full service
* fee to the platform address as a fallback).
*/
export async function findTldOwnerAddress(client, tld, prefix = REGISTRAR.addressPrefix ?? "bchtest") {
const map = await fetchTldMap(client);
const entry = map.get(String(tld).toLowerCase());
if (!entry?.mintScriptHex) return null;
try {
const bytecode = hexToBin(entry.mintScriptHex);
const r = lockingBytecodeToCashAddress({ bytecode, prefix, tokenSupport: false });
return typeof r === "string" ? null : r.address;
} catch { return null; }
}
/**
* Split a service fee between the TLD owner (90%) and the platform (10%).
* Returns { tldOwnerFeeSats, platformFeeSats } — both bigints summing to
* exactly the input serviceFeeSats. Rounding favours the platform (the
* remainder from BigInt division stays on the platform side) so the sum is
* always exact even for odd sat counts.
*/
export function splitServiceFee(serviceFeeSats) {
const total = BigInt(serviceFeeSats ?? 0n);
const tldOwner = (total * TLD_OWNER_SHARE_NUM) / TLD_OWNER_SHARE_DEN;
const platform = total - tldOwner;
return { tldOwnerFeeSats: tldOwner, platformFeeSats: platform };
}
// URLs of "beacon-only" indexers (our own bns-indexer.js). They serve
// get_history + transaction.get for the name and TLD beacons but NOT
@ -80,14 +120,39 @@ export async function quoteRegistration(client, {
feeRate = REGISTRAR.feeRate,
}) {
const coins = utxos ?? (await getUtxosForAddresses(client, [ownerAddress]));
// Revenue split: 90% of the service fee flows to whoever owns the TLD
// certificate for this name. If we can't identify the TLD owner (TLD not
// yet registered, gateway hiccup, decode failure), fall back to routing
// the entire fee to the platform — the buyer still pays the same amount.
const raw = normalizeName(name);
const dot = raw.indexOf(".");
const tld = dot < 0 ? "bch" : raw.slice(dot + 1);
const totalFee = BigInt(serviceFee.sats ?? 0n);
let tldOwnerAddress = null;
let tldOwnerFee = 0n;
let platformFee = totalFee;
if (totalFee > 0n) {
try { tldOwnerAddress = await findTldOwnerAddress(client, tld); }
catch { tldOwnerAddress = null; }
// Do not pay the TLD owner if THEY are the buyer (same address holds the
// TLD cert AND is signing this registration) — the "split" would be a
// no-op that just costs an extra output.
if (tldOwnerAddress && tldOwnerAddress !== ownerAddress) {
const split = splitServiceFee(totalFee);
tldOwnerFee = split.tldOwnerFeeSats;
platformFee = split.platformFeeSats;
}
}
return buildRegistrationTx({
name,
ownerAddress,
ownerTokenAddress: toTokenAddress(ownerAddress),
records,
utxos: coins,
feeAddress: serviceFee.sats > 0n ? serviceFee.address : null,
feeSats: serviceFee.sats,
feeAddress: platformFee > 0n ? serviceFee.address : null,
feeSats: platformFee,
tldFeeAddress: tldOwnerFee > 0n ? tldOwnerAddress : null,
tldFeeSats: tldOwnerFee,
beaconAddress: REGISTRAR.beacon,
feeRate,
addressToLockingBytecode,

View file

@ -268,9 +268,13 @@ function tldAdmits(tld, regHeight, tldMap) {
return true;
}
// Walk the TLD beacon and return Map<tld, {category, regHeight, regTxid}>.
// Walk the TLD beacon and return Map<tld, {category, regHeight, regTxid, mintScriptHex}>.
// mintScriptHex is the scriptPubKey.hex of the TREG output that carries the
// TLD NFT — callers that want the owner address decode it with libauth
// (see registrar.js findTldOwnerAddress). MVP: this is the ORIGINAL owner;
// later transfers of the NFT aren't traced yet.
// KEEP IN LOCKSTEP with bns.js buildTldMap — divergence is a consensus split.
async function fetchTldMap(el) {
export async function fetchTldMap(el) {
const history = typeof el.unionHistory === "function"
? (await el.unionHistory(TLD_BEACON_SCRIPTHASH)).history
: await el.call("blockchain.scripthash.get_history", [TLD_BEACON_SCRIPTHASH]);
@ -294,13 +298,23 @@ async function fetchTldMap(el) {
const commitHex = strToHex(tld);
const mint = vouts.find((v) => v.tokenData?.nft && v.tokenData.nft.commitment === commitHex);
if (!mint) continue;
tlds.set(tld, { category: mint.tokenData.category, regHeight: height, regTxid: tx_hash });
// Record the mint-recipient script + vout so callers can compute the
// TLD owner's address (needed for the revenue-split fee output).
// MVP limitation: this is the ORIGINAL owner. If the NFT gets moved
// later, we don't trace it yet — the payout still lands there.
tlds.set(tld, {
category: mint.tokenData.category,
regHeight: height,
regTxid: tx_hash,
mintScriptHex: mint.scriptPubKey?.hex ?? null,
});
}
// TUPDs are irrelevant to the enforcement map — we only need existence + regHeight.
}
return tlds;
}
// ---- the resolver ----
// Pass `client` to reuse an already-open Electrum connection (the registrar
// does this); in that case the caller owns it and we leave it open.

View file

@ -74,16 +74,33 @@ export const REGISTRAR = {
/** Human-readable price breakdown for the confirmation screen. */
export function priceSummary(costs) {
const bch = (sats) => (Number(sats) / 1e8).toFixed(8).replace(/0+$/, "").replace(/\.$/, "");
return [
{ label: "Service fee", sats: costs.serviceFeeSats, bch: bch(costs.serviceFeeSats) },
{ label: "Network fee", sats: costs.chainFeeSats, bch: bch(costs.chainFeeSats) },
{ label: "Discovery beacon", sats: costs.beaconDustSats, bch: bch(costs.beaconDustSats) },
{
label: "Held in the certificate",
sats: costs.certificateDustSats,
bch: bch(costs.certificateDustSats),
note: "stays yours — it lives in the certificate you now own",
},
{ label: "Total", sats: costs.netCostSats, bch: bch(costs.netCostSats), total: true },
];
const rows = [];
// Split fee: show TLD-owner + platform lines when the TLD-owner cut is
// non-zero (i.e. buyer is registering under a TLD they don't own).
const tldOwner = BigInt(costs.tldOwnerFeeSats ?? 0n);
const platform = BigInt(costs.serviceFeeSats ?? 0n);
if (tldOwner > 0n) {
rows.push({
label: "Service fee — TLD owner (90%)",
sats: tldOwner, bch: bch(tldOwner),
note: "goes to whoever holds this TLD's certificate on chain",
});
rows.push({
label: "Service fee — platform (10%)",
sats: platform, bch: bch(platform),
note: "covers the resolver + gateway + indexer",
});
} else {
rows.push({ label: "Service fee", sats: platform, bch: bch(platform) });
}
rows.push({ label: "Network fee", sats: costs.chainFeeSats, bch: bch(costs.chainFeeSats) });
rows.push({ label: "Discovery beacon", sats: costs.beaconDustSats, bch: bch(costs.beaconDustSats) });
rows.push({
label: "Held in the certificate",
sats: costs.certificateDustSats,
bch: bch(costs.certificateDustSats),
note: "stays yours — it lives in the certificate you now own",
});
rows.push({ label: "Total", sats: costs.netCostSats, bch: bch(costs.netCostSats), total: true });
return rows;
}

View file

@ -13,6 +13,8 @@ export {
registerWithExternalWallet,
registerTldWithBuiltInWallet,
registerTldWithExternalWallet,
findTldOwnerAddress,
splitServiceFee,
setRecordsWithBuiltInWallet,
setRecordsWithExternalWallet,
findCertificateUtxo,