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:
parent
2346320bad
commit
2d80e68ef8
5 changed files with 134 additions and 22 deletions
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ export {
|
|||
registerWithExternalWallet,
|
||||
registerTldWithBuiltInWallet,
|
||||
registerTldWithExternalWallet,
|
||||
findTldOwnerAddress,
|
||||
splitServiceFee,
|
||||
setRecordsWithBuiltInWallet,
|
||||
setRecordsWithExternalWallet,
|
||||
findCertificateUtxo,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue