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 {