diff --git a/contracts/usd-listing.cash b/contracts/usd-listing.cash new file mode 100644 index 0000000..95ba2d0 --- /dev/null +++ b/contracts/usd-listing.cash @@ -0,0 +1,68 @@ +pragma cashscript ^0.13.0; + +// USD-pegged sale of a BCNR name certificate (CashTokens NFT). +// +// The certificate sits in this covenant while it is for sale. Anyone may +// buy it by paying the seller the BCH equivalent of `targetCents` at the +// oracle's current price; the seller may reclaim it at any time. +// +// Oracle message (40 bytes): timestamp as uint32 LE, price in USD cents per +// BCH as uint32 LE, then the 32-byte txid of the listing's own outpoint, +// signed by `oraclePk` (Schnorr data signature over the sha256 of the +// message, as checkDataSig expects). +// +// What the chain can and cannot enforce: script can require a transaction +// to be mined no EARLIER than a time (nLocktime), never no later, so a quote +// cannot expire on-chain. The [floorSats, ceilSats] band the seller chooses +// is therefore the real guarantee: whatever quote a buyer presents, the +// payout stays inside it. Binding the quote to the listing's outpoint means +// cancelling and relisting voids every quote ever issued. The locktime rule +// below still stops a quote from being used before the oracle issued it. +contract UsdListing( + pubkey oraclePk, + bytes20 sellerPkh, + int targetCents, + int floorSats, + int ceilSats, + int maxAgeSecs +) { + // Buyer path. Output 0 pays the seller; output 1 carries the certificate + // to wherever the buyer wants it. Remaining outputs are the buyer's. + function buy(bytes oracleMsg, datasig oracleSig) { + require(oracleMsg.length == 40); + require(checkDataSig(oracleSig, oracleMsg, oraclePk)); + + bytes tsBytes, bytes rest = oracleMsg.split(4); + bytes priceBytes, bytes quotedTxid = rest.split(4); + int ts = int(tsBytes); + int priceCents = int(priceBytes); + require(priceCents > 0); + require(quotedTxid == tx.inputs[this.activeInputIndex].outpointTransactionHash); + + // Not before the quote: nLocktime is checked against median-time-past + // (about an hour behind the clock), so the buyer sets it a couple of + // hours behind the quote and the contract allows that slack. + require(tx.locktime + 10800 >= ts); + require(tx.locktime <= ts + maxAgeSecs); + + int need = targetCents * 100000000 / priceCents; + if (need < floorSats) { need = floorSats; } + if (need > ceilSats) { need = ceilSats; } + + // Seller is paid first, in plain BCH. + require(tx.outputs[0].lockingBytecode == new LockingBytecodeP2PKH(sellerPkh)); + require(tx.outputs[0].value >= need); + require(tx.outputs[0].tokenCategory == 0x); + + // The certificate leaves intact: same category, same commitment. + require(tx.outputs[1].tokenCategory == tx.inputs[this.activeInputIndex].tokenCategory); + require(tx.outputs[1].nftCommitment == tx.inputs[this.activeInputIndex].nftCommitment); + } + + // Seller path: reclaim the certificate (cancel the listing, or re-list + // at a new target). + function cancel(pubkey pk, sig s) { + require(hash160(pk) == sellerPkh); + require(checkSig(s, pk)); + } +} diff --git a/contracts/usd-listing.json b/contracts/usd-listing.json new file mode 100644 index 0000000..b79bee5 --- /dev/null +++ b/contracts/usd-listing.json @@ -0,0 +1,61 @@ +{ + "contractName": "UsdListing", + "constructorInputs": [ + { "name": "oraclePk", "type": "pubkey" }, + { "name": "sellerPkh", "type": "bytes20" }, + { "name": "targetCents", "type": "int" }, + { "name": "floorSats", "type": "int" }, + { "name": "ceilSats", "type": "int" }, + { "name": "maxAgeSecs", "type": "int" } + ], + "abi": [ + { + "name": "buy", + "inputs": [ + { "name": "oracleMsg", "type": "bytes" }, + { "name": "oracleSig", "type": "datasig" } + ] + }, + { + "name": "cancel", + "inputs": [ + { "name": "pk", "type": "pubkey" }, + { "name": "s", "type": "sig" } + ] + } + ], + "bytecode": "OP_6 OP_PICK OP_0 OP_NUMEQUAL OP_IF OP_TXLOCKTIME OP_CHECKLOCKTIMEVERIFY OP_DROP OP_7 OP_PICK OP_SIZE OP_NIP 28 OP_NUMEQUALVERIFY OP_8 OP_ROLL OP_8 OP_PICK OP_ROT OP_CHECKDATASIGVERIFY OP_6 OP_ROLL OP_4 OP_SPLIT OP_4 OP_SPLIT OP_ROT OP_BIN2NUM OP_ROT OP_BIN2NUM OP_DUP OP_0 OP_GREATERTHAN OP_VERIFY OP_ROT OP_INPUTINDEX OP_OUTPOINTTXHASH OP_EQUALVERIFY OP_TXLOCKTIME 302a OP_ADD OP_2 OP_PICK OP_GREATERTHANOREQUAL OP_VERIFY OP_TXLOCKTIME OP_ROT OP_7 OP_ROLL OP_ADD OP_LESSTHANOREQUAL OP_VERIFY OP_ROT 00e1f505 OP_MUL OP_SWAP OP_DIV OP_DUP OP_3 OP_PICK OP_LESSTHAN OP_IF OP_2 OP_PICK OP_NIP OP_ENDIF OP_DUP OP_4 OP_PICK OP_GREATERTHAN OP_IF OP_3 OP_PICK OP_NIP OP_ENDIF OP_0 OP_OUTPUTBYTECODE 76a914 OP_3 OP_ROLL OP_CAT 88ac OP_CAT OP_EQUALVERIFY OP_0 OP_OUTPUTVALUE OP_LESSTHANOREQUAL OP_VERIFY OP_0 OP_OUTPUTTOKENCATEGORY OP_0 OP_EQUALVERIFY OP_1 OP_OUTPUTTOKENCATEGORY OP_INPUTINDEX OP_UTXOTOKENCATEGORY OP_EQUALVERIFY OP_1 OP_OUTPUTTOKENCOMMITMENT OP_INPUTINDEX OP_UTXOTOKENCOMMITMENT OP_EQUAL OP_NIP OP_NIP OP_NIP OP_ELSE OP_6 OP_ROLL OP_1 OP_NUMEQUALVERIFY OP_6 OP_PICK OP_HASH160 OP_ROT OP_EQUALVERIFY OP_6 OP_ROLL OP_6 OP_ROLL OP_CHECKSIGVERIFY OP_2DROP OP_2DROP OP_DROP OP_1 OP_ENDIF", + "source": "pragma cashscript ^0.13.0;\n\n// USD-pegged sale of a BCNR name certificate (CashTokens NFT).\n//\n// The certificate sits in this covenant while it is for sale. Anyone may\n// buy it by paying the seller the BCH equivalent of `targetCents` at the\n// oracle's current price; the seller may reclaim it at any time.\n//\n// Oracle message (40 bytes): timestamp as uint32 LE, price in USD cents per\n// BCH as uint32 LE, then the 32-byte txid of the listing's own outpoint,\n// signed by `oraclePk` (Schnorr data signature over the sha256 of the\n// message, as checkDataSig expects).\n//\n// What the chain can and cannot enforce: script can require a transaction\n// to be mined no EARLIER than a time (nLocktime), never no later, so a quote\n// cannot expire on-chain. The [floorSats, ceilSats] band the seller chooses\n// is therefore the real guarantee: whatever quote a buyer presents, the\n// payout stays inside it. Binding the quote to the listing's outpoint means\n// cancelling and relisting voids every quote ever issued. The locktime rule\n// below still stops a quote from being used before the oracle issued it.\ncontract UsdListing(\n pubkey oraclePk,\n bytes20 sellerPkh,\n int targetCents,\n int floorSats,\n int ceilSats,\n int maxAgeSecs\n) {\n // Buyer path. Output 0 pays the seller; output 1 carries the certificate\n // to wherever the buyer wants it. Remaining outputs are the buyer's.\n function buy(bytes oracleMsg, datasig oracleSig) {\n require(oracleMsg.length == 40);\n require(checkDataSig(oracleSig, oracleMsg, oraclePk));\n\n bytes tsBytes, bytes rest = oracleMsg.split(4);\n bytes priceBytes, bytes quotedTxid = rest.split(4);\n int ts = int(tsBytes);\n int priceCents = int(priceBytes);\n require(priceCents > 0);\n require(quotedTxid == tx.inputs[this.activeInputIndex].outpointTransactionHash);\n\n // Not before the quote: nLocktime is checked against median-time-past\n // (about an hour behind the clock), so the buyer sets it a couple of\n // hours behind the quote and the contract allows that slack.\n require(tx.locktime + 10800 >= ts);\n require(tx.locktime <= ts + maxAgeSecs);\n\n int need = targetCents * 100000000 / priceCents;\n if (need < floorSats) { need = floorSats; }\n if (need > ceilSats) { need = ceilSats; }\n\n // Seller is paid first, in plain BCH.\n require(tx.outputs[0].lockingBytecode == new LockingBytecodeP2PKH(sellerPkh));\n require(tx.outputs[0].value >= need);\n require(tx.outputs[0].tokenCategory == 0x);\n\n // The certificate leaves intact: same category, same commitment.\n require(tx.outputs[1].tokenCategory == tx.inputs[this.activeInputIndex].tokenCategory);\n require(tx.outputs[1].nftCommitment == tx.inputs[this.activeInputIndex].nftCommitment);\n }\n\n // Seller path: reclaim the certificate (cancel the listing, or re-list\n // at a new target).\n function cancel(pubkey pk, sig s) {\n require(hash160(pk) == sellerPkh);\n require(checkSig(s, pk));\n }\n}\n", + "fingerprint": "b4c2176de988b48f6a69c7c93fd3fa5ca03f6fee2e5e6201aa765e9af4c0e01b", + "debug": { + "bytecode": "5679009c63c5b1755779827701289d587a58797bbb567a547f547f7b817b817600a0697bc0c888c502302a935279a269c57b577a93a1697b0400e1f505957c967653799f6352797768765479a0635379776800cd0376a914537a7e0288ac7e8800cca16900d1008851d1c0ce8851d2c0cf8777777767567a519d5679a97b88567a567aad6d6d755168", + "sourceMap": "31:4:60:5;;;;;:53:31:53;::::1;;32:16:32:25:0;;:::32:1;;:36::38:0;:8::40:1;33:29:33:38:0;;:40::49;;:51::59;:8::62:1;35:36:35:45:0;;:52::53;:36::54:1;36:56:36:57:0;:45::58:1;37:21:37:28:0;:17::29:1;38:29:38:39:0;:25::40:1;39:16:39:26:0;:29::30;:16:::1;:8::32;40:16:40:26:0;:40::61;:30::86:1;:8::88;45:16:45:27:0;:30::35;:16:::1;:39::41:0;;:16:::1;:8::43;46:16:46:27:0;:31::33;:36::46;;:31:::1;:16;:8::48;48:19:48:30:0;:33::42;:19:::1;:45::55:0;:19:::1;49:12:49:16:0;:19::28;;:12:::1;:30::51:0;:39::48;;:32::49:1;:30::51;50:12:50:16:0;:19::27;;:12:::1;:29::49:0;:38::46;;:31::47:1;:29::49;53:27:53:28:0;:16::45:1;:49::84:0;:74::83;;:49::84:1;;;:8::86;54:27:54:28:0;:16::35:1;:::43;:8::45;55:27:55:28:0;:16::43:1;:47::49:0;:8::51:1;58:27:58:28:0;:16::43:1;:57::78:0;:47::93:1;:8::95;59:27:59:28:0;:16::43:1;:57::78:0;:47::93:1;:8::95;31:53:60:5;;;:4;64::67::0;;;;65:24:65:26;;:16::27:1;:31::40:0;:8::42:1;66:25:66:26:0;;:28::30;;:8::33:1;64:38:67:5;;;;21:0:68:1", + "logs": [], + "requires": [ + { "ip": 12, "line": 31, "message": "Using tx.locktime requires a non-final sequence number on the spending input" }, + { "ip": 19, "line": 32 }, + { "ip": 25, "line": 33 }, + { "ip": 39, "line": 39 }, + { "ip": 43, "line": 40 }, + { "ip": 50, "line": 45 }, + { "ip": 57, "line": 46 }, + { "ip": 89, "line": 53 }, + { "ip": 93, "line": 54 }, + { "ip": 97, "line": 55 }, + { "ip": 102, "line": 58 }, + { "ip": 108, "line": 59 }, + { "ip": 120, "line": 65 }, + { "ip": 125, "line": 66 } + ], + "sourceTags": "5:7:lg;102:104:sc" + }, + "compiler": { + "name": "cashc", + "version": "0.13.3", + "options": { + "enforceFunctionParameterTypes": true, + "enforceLocktimeGuard": true + } + }, + "updatedAt": "2026-09-20T17:10:46.988Z" +} \ No newline at end of file diff --git a/src/gateway/public-gateway.mjs b/src/gateway/public-gateway.mjs index c497dea..b6c45ff 100644 --- a/src/gateway/public-gateway.mjs +++ b/src/gateway/public-gateway.mjs @@ -32,6 +32,8 @@ import { resolveName, buildIndex, normalizeName, connectAny, CHIPNET_ELECTRUM, T import { pickRecord, isSubdomainOf } from "./record-picker.js"; import { buildTldSnapshot } from "./tld-index.js"; import { verifyListing } from "./market-tx.js"; +import { verifyUsdListing, oracleMessage, signOracleMessage, oraclePubkey, usdListingLock } from "./market-usd.js"; +import { readFileSync as readFileSyncOracle } from "node:fs"; // TLD-beacon snapshot cache. Rebuilds by walking TLD_BEACON via electrum, // verbose-fetching every tx, and passing history+txs to buildTldSnapshot. @@ -190,6 +192,24 @@ async function getPrice() { return { usd: null, sats_per_usd: null, sources: [], median_of: 0, at: new Date().toISOString(), stale: true, error: results.map((r) => r.reason?.message).join("; ") }; } +// ---- price oracle: the gateway signs its median so covenants can read it ---- +// Key: 32-byte hex in BNS_ORACLE_KEY_FILE (default ./oracle.key next to this +// file), created once with `openssl rand -hex 32`; never in the repo. +let oracleKey = null; +function getOracleKey() { + if (oracleKey) return oracleKey; + const file = process.env.BNS_ORACLE_KEY_FILE || "./oracle.key"; + try { + const hex = readFileSyncOracle(file, "utf8").trim(); + if (!/^[0-9a-f]{64}$/i.test(hex)) throw new Error("oracle key file must hold 32 bytes of hex"); + oracleKey = hexToBin(hex); + return oracleKey; + } catch (e) { throw new Error(`oracle not configured: ${e.message}`); } +} +const oraclePkHex = () => binToHex(oraclePubkey(getOracleKey())); + +function usdListingLockHex(l) { return binToHex(usdListingLock(hexToBin(l.redeem_hex))); } + async function activeListings() { if (_marketCheck.list && Date.now() - _marketCheck.at < 30000) return _marketCheck.list; const stored = readMarket(); @@ -199,6 +219,16 @@ async function activeListings() { const byLock = new Map(); for (const l of stored) { const entry = idx.get(l.name); + if (l.kind === "usd") { + // Alive while the certificate still sits in the covenant. + if (!entry || entry.category !== l.category) continue; + const clock = usdListingLockHex(l); + if (!byLock.has(clock)) byLock.set(clock, await listUnspentForLock(clock).catch(() => null)); + const clist = byLock.get(clock); + if (clist && !clist.some((u) => u.tx_hash === l.outpoint.txid && u.tx_pos === l.outpoint.vout)) continue; // bought or cancelled + keep.push({ ...l, records: entry.records ?? {} }); + continue; + } if (!entry || entry.owner !== l.seller || entry.category !== l.category) continue; // sold or moved const lock = lockingHexOf(l.seller); if (!byLock.has(lock)) byLock.set(lock, await listUnspentForLock(lock).catch(() => null)); @@ -414,6 +444,7 @@ function proxyToUrl(upstreamUrl, reqPath) { // apex, current priority stands (h → s3 → ip → u). This does not break // Sia-per-subdomain owners: if they omit `ip` from the parent, the fallback // still tries `s3` first as before. +const escapeHtml = (v) => String(v ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); async function serve(res, name, reqPath, baseHref) { // Public content: let pages on other origins (sirius.x, theseus.x) read a // name's files with fetch(), e.g. Sirius Studio inlining a stylesheet. @@ -469,6 +500,13 @@ async function serve(res, name, reqPath, baseHref) { if (fb.ok) { up = fb; } } } + if (up.status === 404 || up.status === 403) { + // Sia answers a missing key with an S3 XML error; visitors should + // see a page that says what is missing and where the site root is. + if (reqPath === "/favicon.ico") { res.writeHead(204); return res.end(); } + res.writeHead(404, { "content-type": "text/html; charset=utf-8" }); + return res.end(`Not found on ${escapeHtml(name)}

Not found

There is no file at ${escapeHtml(reqPath)} on ${escapeHtml(name)}.

If you just published, the link in the page points at a file that was not uploaded. Back to the site root

`); + } const buf = Buffer.from(await up.arrayBuffer()); let ct = up.headers.get("content-type"); if (!ct || ct === "application/octet-stream") ct = guess(key); if (ct.includes("text/html")) { res.writeHead(up.status, { "content-type": ct }); return res.end(transformHtml(buf.toString("utf8"), baseHref)); } @@ -1164,6 +1202,26 @@ const server = http.createServer(async (req, res) => { const name = normalizeName(String(listing?.name || "")); const entry = (await getIndex()).get(name); if (!entry) { res.writeHead(404); res.end(JSON.stringify({ error: `"${name}" not registered` })); return; } + if (listing?.kind === "usd") { + // USD-pegged: the certificate must already sit in the covenant + // this listing describes, and the terms must reproduce that script. + const check = verifyUsdListing(listing, { oraclePk: oraclePkHex() }); + if (!check.ok) { res.writeHead(400); res.end(JSON.stringify({ error: "listing rejected: " + check.error })); return; } + if (listing.category !== entry.category) { res.writeHead(400); res.end(JSON.stringify({ error: "category does not match the registry" })); return; } + const covUtxo = await findTokenUtxo(check.covenant_lock, entry.category, listing.token.nft.commitment).catch(() => null); + if (!covUtxo || covUtxo.txid !== listing.outpoint.txid || covUtxo.vout !== listing.outpoint.vout) { res.writeHead(409); res.end(JSON.stringify({ error: "the certificate is not (yet) in this covenant — wait for the listing transaction to relay and retry" })); return; } + const stored = { + v: 2, kind: "usd", name, tld: name.includes(".") ? name.split(".").pop() : "bch", + seller: String(listing.seller), seller_pkh: listing.seller_pkh, category: entry.category, + target_cents: Number(listing.target_cents), floor_sats: String(listing.floor_sats), ceil_sats: String(listing.ceil_sats), max_age_secs: Number(listing.max_age_secs), + oracle_pk: listing.oracle_pk, redeem_hex: listing.redeem_hex, covenant_address: listing.covenant_address, + outpoint: { txid: covUtxo.txid, vout: covUtxo.vout, satoshis: String(covUtxo.satoshis) }, token: covUtxo.token, + records: entry.records ?? {}, created_at: new Date().toISOString(), + }; + const all = readMarket().filter((x) => x.name !== name); + all.push(stored); writeMarket(all); _marketCheck = { at: 0, list: null }; + res.writeHead(200); res.end(JSON.stringify({ ok: true, listing: stored })); return; + } if (!entry.owner) { res.writeHead(503); res.end(JSON.stringify({ error: "index has no owner for this name yet — retry in 30s" })); return; } const ownerLock = lockingHexOf(entry.owner); const check = verifyListing(listing, { category: entry.category, ownerLockingBytecode: ownerLock }); @@ -1185,8 +1243,11 @@ const server = http.createServer(async (req, res) => { if (!entry?.owner) { res.writeHead(404); res.end(JSON.stringify({ error: "unknown name" })); return; } const ts = Number(req.headers["x-bns-ts"] || 0); if (!Number.isFinite(ts) || Math.abs(Date.now() - ts) > 10 * 60 * 1000) { res.writeHead(401); res.end(JSON.stringify({ error: "x-bns-ts missing or stale" })); return; } - const who = recoverSigner(String(req.headers["x-bns-sig"] || ""), `BNS-MARKET1\n${name}\n${ts}`, entry.owner); - if (who !== entry.owner) { res.writeHead(403); res.end(JSON.stringify({ error: "signature does not match current on-chain NFT owner" })); return; } + const existing = readMarket().find((x) => x.name === name); + const allowed = [entry.owner, existing?.kind === "usd" ? existing.seller : null].filter(Boolean); + const sig = String(req.headers["x-bns-sig"] || ""); + const ok = allowed.some((addr) => recoverSigner(sig, `BNS-MARKET1\n${name}\n${ts}`, addr) === addr); + if (!ok) { res.writeHead(403); res.end(JSON.stringify({ error: "signature does not match the name's owner or the listing's seller" })); return; } writeMarket(readMarket().filter((x) => x.name !== name)); _marketCheck = { at: 0, list: null }; res.writeHead(200); res.end(JSON.stringify({ ok: true, name, removed: true })); return; } @@ -1201,6 +1262,28 @@ const server = http.createServer(async (req, res) => { // whoever answers, cached 60 s; the last good answer is served (marked // stale) if every source fails. Clients also query the same sources // themselves when this route is unreachable. + // GET /api/price/oracle?txid=+ // -> { pubkey, message, signature, ts, price_cents, usd, txid } + // A signed quote for ONE listing (bound to its covenant outpoint). The + // covenant clamps the payout to the seller's band whatever the quote says. + if (req.url.startsWith("/api/price/oracle")) { + res.setHeader("access-control-allow-origin", "*"); + res.setHeader("content-type", "application/json"); + res.setHeader("cache-control", "no-store"); + try { + const txid = String(new URL(req.url, "http://x").searchParams.get("txid") || "").toLowerCase(); + if (!txid) { res.writeHead(200); res.end(JSON.stringify({ pubkey: oraclePkHex() })); return; } + if (!/^[0-9a-f]{64}$/.test(txid)) { res.writeHead(400); res.end(JSON.stringify({ error: "txid must be 64 hex chars" })); return; } + const q = await getPrice(); + if (!q.usd) { res.writeHead(503); res.end(JSON.stringify({ error: "no price available", detail: q.error })); return; } + const ts = Math.floor(Date.now() / 1000), priceCents = Math.round(q.usd * 100); + const msg = oracleMessage(ts, priceCents, txid); + const sig = signOracleMessage(getOracleKey(), msg); + res.writeHead(200); res.end(JSON.stringify({ pubkey: oraclePkHex(), message: binToHex(msg), signature: binToHex(sig), ts, price_cents: priceCents, usd: q.usd, median_of: q.median_of, txid }, null, 2)); + } catch (e) { res.writeHead(503); res.end(JSON.stringify({ error: e.message })); } + return; + } + if (req.url === "/api/price" || req.url.startsWith("/api/price?")) { res.setHeader("access-control-allow-origin", "*"); res.setHeader("content-type", "application/json"); diff --git a/src/lib/market-tx.js b/src/lib/market-tx.js index 1ab02ee..bdee9b1 100644 --- a/src/lib/market-tx.js +++ b/src/lib/market-tx.js @@ -44,13 +44,13 @@ import { export const SIGHASH_ALL_FORKID = 0x41; export const SIGHASH_SINGLE_ANYONECANPAY_FORKID = 0xc3; -const toInput = (utxo, unlocking) => ({ +export const toInput = (utxo, unlocking) => ({ outpointTransactionHash: hexToBin(utxo.txid), outpointIndex: utxo.vout, sequenceNumber: 0xfffffffe, unlockingBytecode: unlocking ?? placeholderUnlocker(), }); -const tokenOf = (t) => (t ? { +export const tokenOf = (t) => (t ? { amount: BigInt(t.amount ?? 0), category: hexToBin(t.category), ...(t.nft ? { nft: { capability: t.nft.capability, commitment: hexToBin(t.nft.commitment || "") } } : {}), @@ -72,7 +72,7 @@ export const sourceOutputFromJson = (j) => ({ valueSatoshis: BigInt(j.valueSatoshis ?? j.satoshis), ...(j.token ? { token: tokenOf(j.token) } : {}), }); -const toSource = (utxo, unlocking) => ({ +export const toSource = (utxo, unlocking) => ({ ...toInput(utxo, unlocking), lockingBytecode: utxo.lockingBytecode instanceof Uint8Array ? utxo.lockingBytecode : hexToBin(utxo.lockingBytecode), valueSatoshis: BigInt(utxo.satoshis), diff --git a/src/lib/market-usd.js b/src/lib/market-usd.js new file mode 100644 index 0000000..f20b4a5 --- /dev/null +++ b/src/lib/market-usd.js @@ -0,0 +1,244 @@ +// USD-pegged name listings: the certificate sits in the UsdListing covenant +// (contracts/usd-listing.cash) while it is for sale. Anyone can buy it by +// paying the seller the BCH equivalent of the USD target at the oracle's +// current price, clamped to the seller's [floor, ceil] band; the seller can +// reclaim it at any time. +// +// Oracle message: 8 bytes, uint32 LE timestamp + uint32 LE price in USD +// cents per BCH, Schnorr data-signed by the oracle key (the gateway signs +// its multi-exchange median at GET /api/price/oracle). +// +// Shared by the browser bundle and the gateway (verification only there). +import { + hexToBin, binToHex, encodeDataPush, bigIntToVmNumber, hash256, hash160, + encodeLockingBytecodeP2sh32, encodeLockingBytecodeP2pkh, lockingBytecodeToCashAddress, + OpcodesBCH, numberToBinUint32LE, binToNumberUint32LE, secp256k1, sha256, + generateSigningSerializationBCH, encodeTransaction, flattenBinArray, +} from "@bitauth/libauth"; +import { ARTIFACT } from "./usd-listing-artifact.js"; +import { normalizeName, encodePayload, opReturnScript, selectInputs, BEACON, BEACON_DUST_SAT, CERTIFICATE_DUST_SAT, P2PKH_DUST_SAT } from "./register-tx.js"; +import { toInput, toSource, tokenOf, signInputs, SIGHASH_ALL_FORKID } from "./market-tx.js"; + +export const USD_LISTING_VERSION = 2; +export const DEFAULT_MAX_AGE_SECS = 1200; // a quote may be used for 20 min past its timestamp (plus MTP slack in the contract) +export const LOCKTIME_BACKOFF_SECS = 7200; // buyer sets nLocktime this far behind the quote so MTP does not delay mining + +// ---- contract bytecode ---- +const opcodeOf = (tok) => { const v = OpcodesBCH[tok]; if (v === undefined) throw new Error("unknown opcode " + tok); return Uint8Array.of(v); }; +export function asmToBytecode(asm) { + const parts = asm.trim().split(/\s+/).filter(Boolean).map((tok) => (tok.startsWith("OP_") ? opcodeOf(tok) : encodeDataPush(hexToBin(tok)))); + return flattenBinArray(parts); +} +const CONTRACT_BYTECODE = asmToBytecode(ARTIFACT.bytecode); +const pushInt = (n) => encodeDataPush(bigIntToVmNumber(BigInt(n))); +const FN_BUY = 0, FN_CANCEL = 1; // ABI order in the artifact + +export function sellerPkhFromLock(lockHex) { + const lock = hexToBin(lockHex); + if (lock.length !== 25 || lock[0] !== 0x76 || lock[1] !== 0xa9 || lock[2] !== 0x14) throw new Error("seller must hold the certificate on a plain P2PKH address"); + return binToHex(lock.slice(3, 23)); +} + +/** Redeem script for the given listing terms (constructor args pushed last-first, then the compiled body). */ +export function usdListingRedeem({ oraclePk, sellerPkh, targetCents, floorSats, ceilSats, maxAgeSecs = DEFAULT_MAX_AGE_SECS }) { + if (!(BigInt(targetCents) > 0n) || !(BigInt(floorSats) > 0n) || BigInt(ceilSats) < BigInt(floorSats)) throw new Error("bad listing terms"); + return flattenBinArray([ + pushInt(maxAgeSecs), pushInt(ceilSats), pushInt(floorSats), pushInt(targetCents), + encodeDataPush(hexToBin(sellerPkh)), encodeDataPush(hexToBin(oraclePk)), + CONTRACT_BYTECODE, + ]); +} +export const usdListingLock = (redeem) => encodeLockingBytecodeP2sh32(hash256(redeem)); +export function usdListingAddress(redeem, prefix = "bchtest") { + const r = lockingBytecodeToCashAddress({ bytecode: usdListingLock(redeem), prefix }); + if (typeof r === "string") throw new Error(r); + return r.address; +} + +/** What the buyer must pay, in satoshis, for a given quote — same arithmetic as the contract. */ +export function payoutSats({ targetCents, priceCents, floorSats, ceilSats }) { + let need = (BigInt(targetCents) * 100000000n) / BigInt(priceCents); + if (need < BigInt(floorSats)) need = BigInt(floorSats); + if (need > BigInt(ceilSats)) need = BigInt(ceilSats); + return need; +} + +// ---- oracle ---- +// 40 bytes: ts (uint32 LE) · price cents (uint32 LE) · listing outpoint txid +// (32 bytes, as it appears in the transaction input, i.e. hash byte order). +export function oracleMessage(ts, priceCents, outpointTxidHex) { + if (ts >= 2 ** 31 || priceCents >= 2 ** 31) throw new Error("oracle fields must fit a positive int32"); + const txid = hexToBin(outpointTxidHex); + if (txid.length !== 32) throw new Error("outpoint txid must be 32 bytes"); + return flattenBinArray([numberToBinUint32LE(ts), numberToBinUint32LE(priceCents), txid.slice().reverse()]); +} +export const parseOracleMessage = (msg) => ({ ts: binToNumberUint32LE(msg.slice(0, 4)), priceCents: binToNumberUint32LE(msg.slice(4, 8)), outpointTxid: binToHex(msg.slice(8, 40).slice().reverse()) }); +export function signOracleMessage(privateKey, msg) { + const sig = secp256k1.signMessageHashSchnorr(privateKey, sha256.hash(msg)); + if (typeof sig === "string") throw new Error(sig); + return sig; +} +export function verifyOracleMessage(pubkey, msg, sig) { + return secp256k1.verifySignatureSchnorr(sig, pubkey, sha256.hash(msg)) === true; +} +export const oraclePubkey = (privateKey) => secp256k1.derivePublicKeyCompressed(privateKey); + +// ---- transactions ---- +const feeLoop = (utxos, fixedOut, feeRate, build, { requireGenesis = false } = {}) => { + let selection = selectInputs(utxos, fixedOut + 600n, { requireGenesis }); + for (let round = 0; round < 6; round++) { + const { inputs, totalSats } = selection; + const probe = build(inputs, true, 1_000_000n); + const fee = BigInt(Math.ceil(encodeTransaction(probe.transaction).length * feeRate)); + const need = fixedOut + fee; + if (totalSats < need) { + const grown = selectInputs(utxos, need + 200n, { requireGenesis }); + if (grown.inputs.length > inputs.length) { selection = grown; continue; } + const err = new Error(`not enough funds: need ${need} sat, have ${totalSats} sat`); err.needSats = need; err.haveSats = totalSats; throw err; + } + let change = totalSats - need; const withChange = change >= P2PKH_DUST_SAT; if (!withChange) change = 0n; + return { ...build(inputs, withChange, change), fee: withChange ? fee : totalSats - fixedOut, change }; + } + throw new Error("could not converge on a fee — please retry"); +}; + +/** + * Seller: move the certificate into the covenant. Also publishes a UPD so + * indexers see the covenant as the holder (the name is frozen while listed). + * Returns { transaction, sourceOutputs, redeem, address } — sign every input + * with signInputs (all P2PKH). + */ +export function buildUsdListingTx({ name, certificateUtxo, utxos, sellerAddress, oraclePk, targetCents, floorSats, ceilSats, maxAgeSecs = DEFAULT_MAX_AGE_SECS, records = {}, beaconAddress = BEACON, feeRate = 1, addressToLockingBytecode }) { + const rawName = normalizeName(name); + if (!certificateUtxo?.token?.nft) throw new Error("certificateUtxo does not carry a certificate"); + const sellerLock = addressToLockingBytecode(sellerAddress); + const sellerPkh = sellerPkhFromLock(binToHex(addressToLockingBytecode(certificateUtxo.address || sellerAddress))); + const redeem = usdListingRedeem({ oraclePk, sellerPkh, targetCents, floorSats, ceilSats, maxAgeSecs }); + const covLock = usdListingLock(redeem); + const payload = encodePayload("UPD", rawName, records); + const coins = utxos.filter((u) => !u.token); + const fixedOut = CERTIFICATE_DUST_SAT + BEACON_DUST_SAT - BigInt(certificateUtxo.satoshis); + const built = feeLoop(coins, fixedOut > 0n ? fixedOut : 0n, feeRate, (inputs, withChange, change) => ({ + transaction: { + version: 2, locktime: 0, + inputs: [toInput(certificateUtxo), ...inputs.map((u) => toInput(u))], + outputs: [ + { lockingBytecode: covLock, valueSatoshis: CERTIFICATE_DUST_SAT, token: tokenOf(certificateUtxo.token) }, + { lockingBytecode: opReturnScript(payload), valueSatoshis: 0n }, + { lockingBytecode: addressToLockingBytecode(beaconAddress), valueSatoshis: BEACON_DUST_SAT }, + ...(withChange ? [{ lockingBytecode: sellerLock, valueSatoshis: change }] : []), + ], + }, + sourceOutputs: [toSource(certificateUtxo), ...inputs.map((u) => toSource(u))], + })); + return { ...built, name: rawName, redeem, redeemHex: binToHex(redeem), address: usdListingAddress(redeem, sellerAddress.split(":")[0]), sellerPkh, covenantVout: 0 }; +} + +function covenantSource(listing) { + const redeem = hexToBin(listing.redeem_hex); + return { + redeem, + utxo: { txid: listing.outpoint.txid, vout: listing.outpoint.vout, satoshis: BigInt(listing.outpoint.satoshis ?? CERTIFICATE_DUST_SAT), lockingBytecode: usdListingLock(redeem), token: listing.token }, + }; +} + +/** + * Buyer: pay the seller the quoted amount and take the certificate. + * `quote` = the gateway's /api/price/oracle answer. Returns the transaction + * with the covenant input already unlocked; sign the buyer's inputs with + * signInputs(..., { skip: [0] }). + */ +export function buildUsdBuyTx({ listing, quote, buyerAddress, buyerTokenAddress, utxos, records = {}, beaconAddress = BEACON, feeRate = 1, addressToLockingBytecode }) { + const rawName = normalizeName(listing.name); + const msg = hexToBin(quote.message); + const sig = hexToBin(quote.signature); + if (!verifyOracleMessage(hexToBin(listing.oracle_pk), msg, sig)) throw new Error("oracle signature does not verify against the listing's oracle key"); + const { ts, priceCents, outpointTxid } = parseOracleMessage(msg); + if (outpointTxid !== String(listing.outpoint.txid).toLowerCase()) throw new Error("quote was issued for a different listing"); + const need = payoutSats({ targetCents: listing.target_cents, priceCents, floorSats: listing.floor_sats, ceilSats: listing.ceil_sats }); + const { redeem, utxo: cov } = covenantSource(listing); + const sellerLock = encodeLockingBytecodeP2pkh(hexToBin(listing.seller_pkh)); + const unlocking = flattenBinArray([encodeDataPush(sig), encodeDataPush(msg), encodeDataPush(bigIntToVmNumber(BigInt(FN_BUY))), encodeDataPush(redeem)]); + const buyerLock = addressToLockingBytecode(buyerAddress); + const certLock = addressToLockingBytecode(buyerTokenAddress ?? buyerAddress); + const payload = encodePayload("UPD", rawName, records); + const coins = utxos.filter((u) => !u.token); + const fixedOut = need + CERTIFICATE_DUST_SAT + BEACON_DUST_SAT - cov.satoshis; + const built = feeLoop(coins, fixedOut, feeRate, (inputs, withChange, change) => ({ + transaction: { + version: 2, locktime: Math.max(0, ts - LOCKTIME_BACKOFF_SECS), + inputs: [toInput(cov, unlocking), ...inputs.map((u) => toInput(u))], + outputs: [ + { lockingBytecode: sellerLock, valueSatoshis: need }, + { lockingBytecode: certLock, valueSatoshis: CERTIFICATE_DUST_SAT, token: tokenOf(cov.token) }, + { lockingBytecode: opReturnScript(payload), valueSatoshis: 0n }, + { lockingBytecode: addressToLockingBytecode(beaconAddress), valueSatoshis: BEACON_DUST_SAT }, + ...(withChange ? [{ lockingBytecode: buyerLock, valueSatoshis: change }] : []), + ], + }, + sourceOutputs: [toSource(cov), ...inputs.map((u) => toSource(u))], + })); + return { ...built, name: rawName, covenantInputIndex: 0, priceSats: need, priceCents, quoteTs: ts }; +} + +/** + * Seller: take the certificate back (cancel, or relist afterwards). + * Signs everything itself (needs the seller's keys via keyFor). + */ +export function buildUsdCancelTx({ listing, sellerAddress, sellerTokenAddress, utxos, records = {}, beaconAddress = BEACON, feeRate = 1, addressToLockingBytecode, keyFor }) { + const rawName = normalizeName(listing.name); + const { redeem, utxo: cov } = covenantSource(listing); + const sellerLock = encodeLockingBytecodeP2pkh(hexToBin(listing.seller_pkh)); + const k = keyFor(binToHex(sellerLock)); + if (!k) throw new Error("this wallet does not hold the seller key for this listing"); + const priv = k.privateKey, pub = k.publicKey; + const certLock = addressToLockingBytecode(sellerTokenAddress ?? sellerAddress); + const payload = encodePayload("UPD", rawName, records); + const coins = utxos.filter((u) => !u.token); + const fixedOut = CERTIFICATE_DUST_SAT + BEACON_DUST_SAT - cov.satoshis; + const dummyUnlock = flattenBinArray([encodeDataPush(new Uint8Array(65)), encodeDataPush(pub), encodeDataPush(bigIntToVmNumber(BigInt(FN_CANCEL))), encodeDataPush(redeem)]); + const built = feeLoop(coins, fixedOut > 0n ? fixedOut : 0n, feeRate, (inputs, withChange, change) => ({ + transaction: { + version: 2, locktime: 0, + inputs: [toInput(cov, dummyUnlock), ...inputs.map((u) => toInput(u))], + outputs: [ + { lockingBytecode: certLock, valueSatoshis: CERTIFICATE_DUST_SAT, token: tokenOf(cov.token) }, + { lockingBytecode: opReturnScript(payload), valueSatoshis: 0n }, + { lockingBytecode: addressToLockingBytecode(beaconAddress), valueSatoshis: BEACON_DUST_SAT }, + ...(withChange ? [{ lockingBytecode: addressToLockingBytecode(sellerAddress), valueSatoshis: change }] : []), + ], + }, + sourceOutputs: [toSource(cov), ...inputs.map((u) => toSource(u))], + })); + // Sign the buyer-style inputs first (P2PKH), then the covenant input over the redeem script. + const signed = signInputs(built.transaction, built.sourceOutputs, keyFor, { skip: [0] }); + const serialization = generateSigningSerializationBCH( + { inputIndex: 0, sourceOutputs: built.sourceOutputs, transaction: signed.transaction }, + { coveredBytecode: redeem, signingSerializationType: Uint8Array.of(SIGHASH_ALL_FORKID) }, + ); + const s = secp256k1.signMessageHashSchnorr(priv, hash256(serialization)); + if (typeof s === "string") throw new Error(s); + const sig = flattenBinArray([s, Uint8Array.of(SIGHASH_ALL_FORKID)]); + const unlocking = flattenBinArray([encodeDataPush(sig), encodeDataPush(pub), encodeDataPush(bigIntToVmNumber(BigInt(FN_CANCEL))), encodeDataPush(redeem)]); + const transaction = { ...signed.transaction, inputs: signed.transaction.inputs.map((inp, i) => (i === 0 ? { ...inp, unlockingBytecode: unlocking } : inp)) }; + const encoded = encodeTransaction(transaction); + return { name: rawName, transaction, sourceOutputs: built.sourceOutputs, hex: binToHex(encoded), txid: binToHex(hash256(encoded).slice().reverse()) }; +} + +/** Gateway/buyer-side sanity check of a USD listing object (no chain access). */ +export function verifyUsdListing(listing, { oraclePk } = {}) { + const fail = (error) => ({ ok: false, error }); + try { + if (listing?.kind !== "usd" || listing.v !== USD_LISTING_VERSION) return fail("not a v2 usd listing"); + for (const k of ["name", "seller", "seller_pkh", "category", "target_cents", "floor_sats", "ceil_sats", "max_age_secs", "oracle_pk", "redeem_hex", "covenant_address", "outpoint", "token"]) if (listing[k] == null) return fail("missing " + k); + if (oraclePk && listing.oracle_pk !== oraclePk) return fail("listing was made for a different oracle key"); + const redeem = usdListingRedeem({ oraclePk: listing.oracle_pk, sellerPkh: listing.seller_pkh, targetCents: listing.target_cents, floorSats: listing.floor_sats, ceilSats: listing.ceil_sats, maxAgeSecs: listing.max_age_secs }); + if (binToHex(redeem) !== listing.redeem_hex) return fail("redeem script does not match the stated terms"); + const prefix = String(listing.seller).split(":")[0] || "bchtest"; + if (usdListingAddress(redeem, prefix) !== listing.covenant_address) return fail("covenant address does not match"); + if (binToHex(hash160(hexToBin(listing.seller_pkh))).length !== 40) return fail("bad seller pkh"); + if (listing.token?.category !== listing.category || !listing.token?.nft) return fail("token does not match category"); + if (hexToBin(listing.token.nft.commitment || "").length === 0 || binToHex(new TextEncoder().encode(normalizeName(listing.name))) !== listing.token.nft.commitment) return fail("commitment does not match the name"); + return { ok: true, name: normalizeName(listing.name), covenant_lock: binToHex(usdListingLock(redeem)) }; + } catch (e) { return fail(String(e?.message || e)); } +} diff --git a/src/lib/usd-listing-artifact.js b/src/lib/usd-listing-artifact.js new file mode 100644 index 0000000..da54d5c --- /dev/null +++ b/src/lib/usd-listing-artifact.js @@ -0,0 +1,68 @@ +// Generated from contracts/usd-listing.json by scratchpad/gen-usd-artifact.mjs — do not edit. +// UsdListing compiled with cashc 0.13.3. +export const ARTIFACT = { + "contractName": "UsdListing", + "constructorInputs": [ + { + "name": "oraclePk", + "type": "pubkey" + }, + { + "name": "sellerPkh", + "type": "bytes20" + }, + { + "name": "targetCents", + "type": "int" + }, + { + "name": "floorSats", + "type": "int" + }, + { + "name": "ceilSats", + "type": "int" + }, + { + "name": "maxAgeSecs", + "type": "int" + } + ], + "abi": [ + { + "name": "buy", + "inputs": [ + { + "name": "oracleMsg", + "type": "bytes" + }, + { + "name": "oracleSig", + "type": "datasig" + } + ] + }, + { + "name": "cancel", + "inputs": [ + { + "name": "pk", + "type": "pubkey" + }, + { + "name": "s", + "type": "sig" + } + ] + } + ], + "bytecode": "OP_6 OP_PICK OP_0 OP_NUMEQUAL OP_IF OP_TXLOCKTIME OP_CHECKLOCKTIMEVERIFY OP_DROP OP_7 OP_PICK OP_SIZE OP_NIP 28 OP_NUMEQUALVERIFY OP_8 OP_ROLL OP_8 OP_PICK OP_ROT OP_CHECKDATASIGVERIFY OP_6 OP_ROLL OP_4 OP_SPLIT OP_4 OP_SPLIT OP_ROT OP_BIN2NUM OP_ROT OP_BIN2NUM OP_DUP OP_0 OP_GREATERTHAN OP_VERIFY OP_ROT OP_INPUTINDEX OP_OUTPOINTTXHASH OP_EQUALVERIFY OP_TXLOCKTIME 302a OP_ADD OP_2 OP_PICK OP_GREATERTHANOREQUAL OP_VERIFY OP_TXLOCKTIME OP_ROT OP_7 OP_ROLL OP_ADD OP_LESSTHANOREQUAL OP_VERIFY OP_ROT 00e1f505 OP_MUL OP_SWAP OP_DIV OP_DUP OP_3 OP_PICK OP_LESSTHAN OP_IF OP_2 OP_PICK OP_NIP OP_ENDIF OP_DUP OP_4 OP_PICK OP_GREATERTHAN OP_IF OP_3 OP_PICK OP_NIP OP_ENDIF OP_0 OP_OUTPUTBYTECODE 76a914 OP_3 OP_ROLL OP_CAT 88ac OP_CAT OP_EQUALVERIFY OP_0 OP_OUTPUTVALUE OP_LESSTHANOREQUAL OP_VERIFY OP_0 OP_OUTPUTTOKENCATEGORY OP_0 OP_EQUALVERIFY OP_1 OP_OUTPUTTOKENCATEGORY OP_INPUTINDEX OP_UTXOTOKENCATEGORY OP_EQUALVERIFY OP_1 OP_OUTPUTTOKENCOMMITMENT OP_INPUTINDEX OP_UTXOTOKENCOMMITMENT OP_EQUAL OP_NIP OP_NIP OP_NIP OP_ELSE OP_6 OP_ROLL OP_1 OP_NUMEQUALVERIFY OP_6 OP_PICK OP_HASH160 OP_ROT OP_EQUALVERIFY OP_6 OP_ROLL OP_6 OP_ROLL OP_CHECKSIGVERIFY OP_2DROP OP_2DROP OP_DROP OP_1 OP_ENDIF", + "compiler": { + "name": "cashc", + "version": "0.13.3", + "options": { + "enforceFunctionParameterTypes": true, + "enforceLocktimeGuard": true + } + } +}; diff --git a/src/web/libauth-entry.js b/src/web/libauth-entry.js index 952810d..32db0a2 100644 --- a/src/web/libauth-entry.js +++ b/src/web/libauth-entry.js @@ -62,6 +62,15 @@ export { sha256, hash160, ripemd160, + // ---- covenants (market-usd.js) ---- + encodeDataPush, + bigIntToVmNumber, + encodeLockingBytecodeP2sh32, + encodeLockingBytecodeP2pkh, + OpcodesBCH, + numberToBinUint32LE, + binToNumberUint32LE, + flattenBinArray, // ---- transactions ---- encodeTransaction, diff --git a/src/web/register-entry.js b/src/web/register-entry.js index bb8ceed..a11b13d 100644 --- a/src/web/register-entry.js +++ b/src/web/register-entry.js @@ -41,6 +41,7 @@ export { export { signTransaction, txidOf, txidOfHex } from "../lib/sign-tx.js"; export { buildListing, completeSale, verifyListing, signInputs, sourceOutputToJson, sourceOutputFromJson } from "../lib/market-tx.js"; export { findTldCertificateUtxo, buildCosignRequest, signCosignRequest } from "../lib/registrar.js"; +export { usdListingRedeem, usdListingAddress, payoutSats, parseOracleMessage, verifyOracleMessage, buildUsdListingTx, buildUsdBuyTx, buildUsdCancelTx, verifyUsdListing, DEFAULT_MAX_AGE_SECS } from "../lib/market-usd.js"; export { toTokenAddress, addressToLockingBytecode, addressToScripthash, getUtxos, getUtxosForAddresses, broadcast } from "../lib/chain.js"; export { resolveName, buildIndex } from "../lib/resolver-web.js"; export {