List a UTXO
Listing is three steps: prepare a PSBT, sign it with SIGHASH_SINGLE|ANYONECANPAY (0x83), submit it. Your key never leaves your wallet or script; the server keeps only the signature, and never shows it to anyone.
Before you start #
- The UTXO must be confirmed, hold tracked rare sats of a market row, and carry no inscription or runes. Find yours with
GET /api/address/{address}. - It must belong to a Taproot, native SegWit or nested SegWit address. Legacy (
1…) is refused withlegacy_address. - Choose a
pricein sats: what you receive. It must be above the UTXO's own value and at least 546. Buyers pay 2.5% on top; you pay nothing.
1. Prepare #
POST /api/market/listings/prepare with your address, its public key and the UTXOs (up to 50 in one PSBT, one signature prompt):
{
"seller_address": "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh",
"seller_public_key": "0283dfe85a3151d2517290da461fe2815591ef69f2b18a2ce63f01697a8b313145",
"items": [{ "outpoint": "8b09d6914ae17431940ce8b6cadae378a5d9b3c90c73c3a5b0b8c8e7edc9e86a:0", "price": 94200 }]
}seller_public_key: for Taproot, the internal (untweaked) key, x-only (32 bytes) or compressed (33 bytes); for SegWit, the compressed key. Wallets return it with the address.payout_address(optional) is where the price goes; it defaults toseller_address. Xverse-style wallets usually pay out to their payment address.
The answer is the PSBT, the inputs to sign (sighash: 131 = 0x83) and what each listing will show:
{
"psbt": "cHNidP8BAF4CAAAAAWroye3n…",
"sign": [{ "index": 0, "address": "bc1p4qhj…fqzwh", "sighash": 131 }],
"items": [{ "outpoint": "8b09…e86a:0", "value": 6280, "price": 94200, "unit_price": 14, "row": "block9", "count": 6280 }]
}The PSBT pairs input i (your UTXO) with output i (your price to your payout address). Version 2, locktime 0, sequence 0xfffffffd. Check those outputs before signing.
2. Sign with 0x83 #
Sign every input in sign with sighash 0x83 (SIGHASH_SINGLE|ANYONECANPAY). Your signature then commits only to your UTXO and to your payment output: a buyer can add their inputs and outputs, but cannot change what you receive. Do not finalize or broadcast.
- UniSat:
unisat.signPsbt(hex, {autoFinalized: false, toSignInputs: [{index, address, sighashTypes: [0x83]}]}). - Xverse (Sats Connect):
signPsbtwithsignInputs: {[address]: [indexes]}andbroadcast: false; the sighash comes from the PSBT input'ssighashType, which prepare set to0x83. - Your own key: the Node script below.
3. Submit #
POST /api/market/listings with {"psbt": "<signed base64>"}. The server checks every rule again, verifies each signature against the chain's amount and script, and answers your new listings, active:
{ "listings": [{ "id": "3324d08569806818", "status": "active", "price": 94200, "unit_price": 14, "row": "block9", "count": 6280, "…": "…" }] }To change a price, list the same UTXO again: the new listing replaces the old one (now cancelled, its signature deleted). To take it off sale, cancel it.
A complete Node example #
A script that lists a UTXO of a Taproot key: Node 18+, npm install bitcoinjs-lib tiny-secp256k1 ecpair.
// node list.mjs <txid:vout> <price in sats>
// WIF: the private key (mainnet WIF) of the Taproot address holding the UTXO.
import * as bitcoin from 'bitcoinjs-lib';
import * as ecc from 'tiny-secp256k1';
import { ECPairFactory } from 'ecpair';
bitcoin.initEccLib(ecc);
const ECPair = ECPairFactory(ecc);
const BASE = process.env.RARESATS_URL ?? 'https://raresats.net';
const [outpoint, price] = process.argv.slice(2);
const key = ECPair.fromWIF(process.env.WIF);
const internalKey = key.publicKey.subarray(1, 33);
const { address } = bitcoin.payments.p2tr({ internalPubkey: internalKey });
async function post(path, body) {
const response = await fetch(`${BASE}${path}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body)
});
const json = await response.json();
if (!response.ok) {
throw Object.assign(new Error(`${path}: ${response.status} ${json.code}: ${json.error}`), { code: json.code });
}
return json;
}
// 1. Prepare: the server checks the UTXO and builds the PSBT.
const prepared = await post('/api/market/listings/prepare', {
seller_address: address,
seller_public_key: Buffer.from(key.publicKey).toString('hex'),
items: [{ outpoint, price: Number(price) }]
});
// 2. Check what you sign, then sign every input with 0x83 (SINGLE|ANYONECANPAY).
const psbt = bitcoin.Psbt.fromBase64(prepared.psbt);
psbt.txOutputs.forEach((output, i) => {
if (Number(output.value) !== prepared.items[i].price || bitcoin.address.fromOutputScript(output.script) !== address) {
throw new Error(`output ${i} does not pay the price to ${address}`);
}
});
const signer = key.tweak(bitcoin.crypto.taggedHash('TapTweak', internalKey));
for (const { index, sighash } of prepared.sign) {
psbt.signInput(index, signer, [sighash]);
}
// 3. Submit: the listing is live.
const { listings } = await post('/api/market/listings', { psbt: psbt.toBase64() });
for (const listing of listings) {
console.log(`${listing.id} ${listing.outpoint}: ${listing.price} sats, ${listing.unit_price} per sat of ${listing.row} x${listing.count}`);
}For a native SegWit (P2WPKH) key, use bitcoin.payments.p2wpkh({ pubkey: key.publicKey }) for the address and sign with key itself (no tweak).
Errors you may meet #
| Code | What to do |
|---|---|
not_owner, key_mismatch | The UTXO or the key is not the address's. |
untracked, no_row | It holds no rare sat a market row tracks. |
inscribed, runes | Only plain rare sats are listed. |
unconfirmed, unindexed, indexer_behind, fork_behind | Wait for a confirmation and the indexers; try again in a minute. |
price_too_low | Price above the UTXO's value and at least 546 (and the payout's dust limit). |
spent, changed, utxo_mismatch | The UTXO moved, or the PSBT's amount is not the chain's. Prepare again. |
pending | It is being bought right now. |
bad_signature, bad_sighash, missing_signature | Sign every input in sign, with 0x83. |
Every code: Errors.