# 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

```text
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`:

```json title="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`.

```js title="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.`);
}
```
