diff --git a/src/lib/register-tx.js b/src/lib/register-tx.js index d3e146a..dbece97 100644 --- a/src/lib/register-tx.js +++ b/src/lib/register-tx.js @@ -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, }, }; } diff --git a/src/lib/registrar.js b/src/lib/registrar.js index 738bdd8..ac43c56 100644 --- a/src/lib/registrar.js +++ b/src/lib/registrar.js @@ -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, diff --git a/src/lib/resolver-web.js b/src/lib/resolver-web.js index 041134a..6935015 100644 --- a/src/lib/resolver-web.js +++ b/src/lib/resolver-web.js @@ -268,9 +268,13 @@ function tldAdmits(tld, regHeight, tldMap) { return true; } -// Walk the TLD beacon and return Map. +// Walk the TLD beacon and return Map. +// 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. diff --git a/src/registrar-config.js b/src/registrar-config.js index 1e91b24..a56e131 100644 --- a/src/registrar-config.js +++ b/src/registrar-config.js @@ -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; } diff --git a/src/web/register-entry.js b/src/web/register-entry.js index ebdbd7b..4c142c1 100644 --- a/src/web/register-entry.js +++ b/src/web/register-entry.js @@ -13,6 +13,8 @@ export { registerWithExternalWallet, registerTldWithBuiltInWallet, registerTldWithExternalWallet, + findTldOwnerAddress, + splitServiceFee, setRecordsWithBuiltInWallet, setRecordsWithExternalWallet, findCertificateUtxo,