raresats.net API
Guides View as Markdown

Make, accept and cancel offers

An offer (a bid) is a price you propose for one UTXO, listed or not. You sign your side of the whole trade now; the UTXO's holder completes it later by signing theirs. Nothing leaves your wallet until they do: your funding UTXOs stay yours, and spending any of them cancels the offer for certain.

The transaction #

You sign the complete transaction when you make the offer (version 2, locktime 0, every input sequence 0xfffffffd), the same layout as Magisat's accepted offers:

InputOutput
0the seller's UTXO (they sign it to accept)0the UTXO's sats, all of them, to your ordinals address
1…your funding: plain cardinal UTXOs of your payment address1price to the address that holds the UTXO
2the platform fee: 2.5% of price, at least 546 sats
3your change, when it is above dust
lastthe OP_RETURN raresats.net marker

Ordinal theory's first-in-first-out rule puts input 0's sats exactly in output 0, so no padding is needed. Your signatures (SIGHASH_DEFAULT on Taproot, SIGHASH_ALL on SegWit v0) fix every output: the seller cannot change what they receive or where the sats go. They stay on the server, which no API returns, and are added only once the seller has signed.

Before you start #

  • What you can bid on: any UTXO the site tracks rare sats in (GET /api/utxo/{outpoint} knows it), unspent and confirmed, with no inscription or rune, and not held by your own address. Taproot, native SegWit or nested SegWit holders.
  • Your funds: plain cardinal, confirmed UTXOs of your payment address (no inscription, rune or rare sat). Several offers may use the same funds; when one is accepted, the others that share them become invalid.
  • The price is what the seller receives. You pay it, plus the platform fee, plus the network fee at the rate you choose: summary.total.

Make an offer #

  1. POST /api/market/offers/prepare with the outpoint, the price, your wallet (bidder), a fee rate (see GET /api/market/fees) and expires_in.
  2. Sign the PSBT's inputs listed in sign (yours: input 1 and up) with your wallet's default sighash. Do not finalize.
  3. POST /api/market/offers with {id, psbt} before submit_by (10 minutes). The answer is the Offer, active.

Expiry #

expires_in is 86400 (1 day), 259200 (3 days), 604800 (7 days, the default) or 2592000 (30 days) seconds, or null / "never" for no expiry: unlike Magisat's, such an offer stays acceptable until you cancel it, it is accepted, or it becomes invalid. Either way, the fee rate is fixed when you sign; if the network is busy when the seller accepts, they can speed it up by spending their output 1 (CPFP).

The server enforces the expiry and then deletes your signatures. The signed transaction itself has no expiry: to cancel for certain, spend the funding UTXO(s).

Accept an offer #

Only the address that holds the UTXO can accept, and only while the offer is active:

  1. POST /api/market/offers/{id}/accept/prepare with your address and public key. The answer is the trade's summary and the PSBT, input 0 ready for your wallet.
  2. Sign input 0 with your wallet's default sighash. Do not finalize.
  3. POST /api/market/offers/{id}/accept with {psbt}. The server checks again that your UTXO and the bidder's funds are unspent, adds the bidder's signatures and broadcasts. The answer is {txid}.

The offer is then pending, and accepted once confirmed. If you had listed the UTXO, the listing is reserved meanwhile and sold once confirmed; the other offers on it become invalid. Resubmitting the same accept is idempotent: do it when the answer is lost or says broadcast_unknown.

Cancel offers #

POST /api/market/offers/cancel with the ids and a BIP-322 signature of raresats.net cancel-offer <id,id,…> <unix seconds> by your payment or ordinals address: at most 10 minutes old, accepted once (409 replayed after), as for listings.

Reading offers #

A complete Node example #

One Taproot key that pays, receives and holds (as UniSat has), with bitcoinjs-lib: Node 18+, npm install bitcoinjs-lib tiny-secp256k1 ecpair.

offers.mjs
// node offers.mjs make <outpoint> <price> [<days>|never]
// node offers.mjs accept <offer id>
// node offers.mjs cancel <offer id> [<offer id> …]
// WIF: the private key (mainnet WIF) of your Taproot address.
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 [command, ...args] = process.argv.slice(2);

const key = ECPair.fromWIF(process.env.WIF);
const internalKey = key.publicKey.subarray(1, 33);
const { address, output } = bitcoin.payments.p2tr({ internalPubkey: internalKey });
const publicKey = Buffer.from(key.publicKey).toString('hex');
const signer = key.tweak(bitcoin.crypto.taggedHash('TapTweak', internalKey));

async function call(path, body) {
  const response = await fetch(`${BASE}${path}`, body === undefined ? {} : {
    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;
}

// Sign exactly the inputs listed, with the default sighash; never finalize.
function sign({ psbt, sign: inputs }) {
  const parsed = bitcoin.Psbt.fromBase64(psbt);
  for (const { index } of inputs) {
    parsed.signInput(index, signer);
  }
  return parsed.toBase64();
}

// BIP-322 simple: sign the virtual to_sign transaction; the signature is its witness.
function signBip322(message) {
  const tag = bitcoin.crypto.sha256(Buffer.from('BIP0322-signed-message'));
  const hash = bitcoin.crypto.sha256(Buffer.concat([tag, tag, Buffer.from(message, 'utf8')]));
  const toSpend = new bitcoin.Transaction();
  toSpend.version = 0;
  toSpend.addInput(new Uint8Array(32), 0xffffffff, 0, Uint8Array.from([0x00, 0x20, ...hash]));
  toSpend.addOutput(output, 0n);
  const toSign = new bitcoin.Psbt();
  toSign.setVersion(0);
  toSign.addInput({ hash: toSpend.getHash(), index: 0, sequence: 0, witnessUtxo: { script: output, value: 0n }, tapInternalKey: internalKey });
  toSign.addOutput({ script: Uint8Array.from([0x6a]), value: 0n });
  toSign.signInput(0, signer);
  toSign.finalizeAllInputs();
  const witness = toSign.extractTransaction(true).ins[0].witness;
  return Buffer.concat([Buffer.from([witness.length]), ...witness.flatMap(item => [Buffer.from([item.length]), Buffer.from(item)])]).toString('base64');
}

if (command === 'make') {
  const [outpoint, price, days = '7'] = args;
  const fees = await call('/api/market/fees');
  const prepared = await call('/api/market/offers/prepare', {
    outpoint,
    price: Number(price),
    bidder: { payment_address: address, payment_public_key: publicKey, ordinals_address: address, ordinals_public_key: publicKey },
    fee_rate: fees.normal,
    expires_in: days === 'never' ? 'never' : Number(days) * 86400
  });
  const { summary } = prepared;
  console.log(`price ${summary.price} + platform fee ${summary.platform_fee} + network fee ${summary.network_fee} = ${summary.total} sats`);
  const { offer } = await call('/api/market/offers', { id: prepared.id, psbt: sign(prepared) });
  console.log(`${offer.id} ${offer.outpoint}: ${offer.status}, expires ${offer.expires_at === null ? 'never' : new Date(offer.expires_at * 1000).toISOString()}`);
} else if (command === 'accept') {
  const [id] = args;
  const prepared = await call(`/api/market/offers/${id}/accept/prepare`, { seller_address: address, seller_public_key: publicKey });
  console.log(`you receive ${prepared.summary.price} sats for ${prepared.summary.outpoint}`);
  const body = { psbt: sign(prepared) };
  // Resubmitting is idempotent: retry while the outcome is unknown.
  for (let attempt = 1; ; attempt++) {
    try {
      console.log('accepted', (await call(`/api/market/offers/${id}/accept`, body)).txid);
      break;
    } catch (error) {
      if (error.code !== 'broadcast_unknown' || attempt === 5) {
        throw error;
      }
      await new Promise(resolve => setTimeout(resolve, 10_000));
    }
  }
} else if (command === 'cancel') {
  const message = `raresats.net cancel-offer ${args.join(',')} ${Math.floor(Date.now() / 1000)}`;
  const { offers } = await call('/api/market/offers/cancel', { ids: args, address, message, signature: signBip322(message) });
  for (const offer of offers) {
    console.log(`${offer.id}: ${offer.status}`);
  }
} else {
  console.error('usage: node offers.mjs make <outpoint> <price> [<days>|never] | accept <offer id> | cancel <offer id> …');
  process.exit(1);
}