From 6ebdb98936ef1b39d9d18fb27cc1bcf702648d59 Mon Sep 17 00:00:00 2001 From: Local Dev Date: Sun, 20 Sep 2026 19:20:46 +0200 Subject: [PATCH] =?UTF-8?q?feat(market):=20names=20can=20be=20priced=20in?= =?UTF-8?q?=20US=20dollars=20=E2=80=94=20a=20covenant=20holds=20the=20cert?= =?UTF-8?q?ificate,=20a=20gateway-signed=20oracle=20sets=20the=20BCH=20amo?= =?UTF-8?q?unt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A BCH-priced offer is a signed transaction with a fixed amount, so a seller who wants "$500" had to relist whenever the rate moved. The new USD mode moves the certificate into a small CashScript covenant (contracts/usd-listing.cash). Anyone may buy it by paying the seller target_cents / price, where the price comes from a message signed by the gateway's oracle key — the median of five exchanges the site already uses. The payout is clamped to a floor/ceiling band the seller sets, and the seller can reclaim the certificate at any time. What the chain can and cannot enforce, stated plainly in the contract and the UI: script can require a transaction to be mined no earlier than a time, never no later, so a quote cannot expire on-chain. The band is the guarantee, and quotes are bound to the listing's outpoint so cancelling and relisting voids every quote ever issued. An offline libauth VM test covers list, buy, underpayment, foreign-listing quotes, forged quotes, band clamping, cancel and a non-seller cancel; on chipnet aloevera.test was listed at $0.30, bought by a second wallet for 119,090 sat at $251.91, listed again by the new owner and cancelled. Gateway: GET /api/price/oracle[?txid=] serves the oracle public key and per-listing signed quotes; the key lives in oracle.key beside the gateway, never in the repo. POST /api/market accepts kind "usd" after reproducing the redeem script from the stated terms and finding the certificate in that covenant; such listings are pruned once the covenant output is spent; DELETE accepts the listing's seller while the index shows the covenant as holder. Dashboard: the Sell tab has "Price is fixed in: US dollars / BCH", a band control with the resulting BCH range, and cancel through the covenant. Market: USD listings show the dollar price, today's BCH equivalent and the band; buying fetches a quote and completes the covenant spend. Studio no longer sends a needless UPD when the registry view is stale, and missing files on a name now get a real 404 page. --- contracts/usd-listing.cash | 68 +++++++++ contracts/usd-listing.json | 61 ++++++++ src/gateway/public-gateway.mjs | 87 +++++++++++- src/lib/market-tx.js | 6 +- src/lib/market-usd.js | 244 ++++++++++++++++++++++++++++++++ src/lib/usd-listing-artifact.js | 68 +++++++++ src/web/libauth-entry.js | 9 ++ src/web/register-entry.js | 1 + 8 files changed, 539 insertions(+), 5 deletions(-) create mode 100644 contracts/usd-listing.cash create mode 100644 contracts/usd-listing.json create mode 100644 src/lib/market-usd.js create mode 100644 src/lib/usd-listing-artifact.js 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 {