raresats.net API
Guides View as Markdown

Cancel listings

A cancel proves you are the seller with a BIP-322 signature of a short message by the seller address. The server then deletes the listings' signatures and marks them cancelled.

The message #

raresats.net cancel <id,id,…> <unix seconds>
  • raresats.net cancel (cancel_prefix in GET /api/market/config), a space, the listing ids comma-separated with no spaces in the same order as ids, a space, and the current time in unix seconds.
  • Example: raresats.net cancel 3324d08569806818,1031931af1592e1c 1790949400.
  • Up to 100 listings at once, all listed by the signing address.

Signing #

Sign the message with the seller address (the listing's seller_address) using BIP-322 ("simple"). Wallets do this:

  • UniSat: unisat.signMessage(message, 'bip322-simple').
  • Xverse (Sats Connect): signMessage with {address, message, protocol: 'BIP322'}.
  • Your own key: any BIP-322 library, or the Node script below. Taproot, native SegWit and nested SegWit addresses are verified.

The signature is base64 (the encoded witness of BIP-322's to_sign transaction).

The request #

POST /api/market/listings/cancel:

Request
{
  "ids": ["3324d08569806818"],
  "address": "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh",
  "message": "raresats.net cancel 3324d08569806818 1790949400",
  "signature": "AUBGQvdc2H+7HTo1…"
}

The answer is {"listings": [...]}, each now cancelled.

Freshness and replays #

  • The message must be at most 10 minutes old (stale_message otherwise) and at most 2 minutes ahead of the server's clock (future_message). Sign a fresh one each time.
  • A message and signature are accepted once: sending the same pair again answers 409 replayed.
  • Only active listings can be cancelled. A pending one (a purchase in the mempool) answers 409 not_active: it is being bought. A listing already sold, cancelled or moved answers the same.
  • not_seller (422): a listing was listed by another address. bad_signature (422): the signature does not verify for that address and message.

Exposed listings: signature_public #

A seller's SIGHASH_SINGLE|ANYONECANPAY signature is private until a purchase is broadcast. From then on it is in that transaction, on the network, for anyone to read. If that purchase is then dropped (never confirmed), the listing returns to active with signature_public: true: anyone holding the signature can still buy the UTXO at the listed price, here or in their own transaction, as long as it pays you the listed price.

Cancelling such a listing takes it off sale here and deletes the server's copy, but cannot recall the copies on the network. The answer then carries warning and exposed (the ids concerned). To be certain, move the UTXO (spend it to yourself): that invalidates every signature over it. The same holds when a new listing replaces an exposed one.

This is the nature of every SIGHASH_SINGLE|ANYONECANPAY marketplace, not a raresats.net choice: you are always paid in full, but at the old price.

A complete Node example #

BIP-322 "simple" signing for a Taproot key with bitcoinjs-lib, and the cancel request: Node 18+, npm install bitcoinjs-lib tiny-secp256k1 ecpair.

cancel.mjs
// node cancel.mjs <listing id> [<listing id> …]
// WIF: the private key (mainnet WIF) of the Taproot address that listed them.
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 ids = 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 });

// 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, key.tweak(bitcoin.crypto.taggedHash('TapTweak', internalKey)));
  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');
}

const message = `raresats.net cancel ${ids.join(',')} ${Math.floor(Date.now() / 1000)}`;
const response = await fetch(`${BASE}/api/market/listings/cancel`, {
  method: 'POST',
  headers: { 'content-type': 'application/json' },
  body: JSON.stringify({ ids, address, message, signature: signBip322(message) })
});
const json = await response.json();
if (!response.ok) {
  throw new Error(`${response.status} ${json.code}: ${json.error}`);
}
for (const listing of json.listings) {
  console.log(`${listing.id}: ${listing.status}`);
}
if (json.exposed) {
  console.warn(`${json.warning}: ${json.exposed.join(', ')}. Move the UTXO to be certain.`);
}