# 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:

| Input | | Output | |
| --- | --- | --- | --- |
| 0 | the seller's UTXO (they sign it to accept) | 0 | the UTXO's sats, all of them, to your ordinals address |
| 1… | your funding: plain cardinal UTXOs of your payment address | 1 | `price` to the address that holds the UTXO |
| | | 2 | the platform fee: 2.5% of `price`, at least 546 sats |
| | | 3 | your change, when it is above dust |
| | | last | the `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`](https://raresats.net/docs/prepare-offer.md) with the outpoint, the price, your wallet (`bidder`), a fee rate (see [`GET /api/market/fees`](https://raresats.net/docs/get-fees.md)) 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`](https://raresats.net/docs/submit-offer.md) with `{id, psbt}` before `submit_by` (10 minutes). The answer is the [Offer](https://raresats.net/docs/offer-object.md), `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`](https://raresats.net/docs/prepare-accept-offer.md) 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`](https://raresats.net/docs/accept-offer.md) 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`](https://raresats.net/docs/cancel-offers.md) 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](https://raresats.net/docs/guide-cancel.md).

## Reading offers

- A UTXO's offers, best first: [`GET /api/market/offers?outpoint=`](https://raresats.net/docs/get-offers.md); the best of many: [`GET /api/market/offers/best?outpoints=`](https://raresats.net/docs/get-best-offers.md). Listings carry theirs as `best_offer`.
- A wallet's offers made and received: `?bidder=<address>&status=all` and `?seller=<address>&status=all`.
- The activity: [`GET /api/market/activity?types=offer_made,offer_cancelled,offer_accepted`](https://raresats.net/docs/get-activity.md).

## 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`.

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