Skip to content

Sales and swaps

A swap sells a token for satoshis in a single Transaction, so the token and the payment either move together, or neither does. It is a transfer whose funding coin is the buyer’s, and whose payment Output pays the seller. The seller signs the token Input, and the buyer signs the coin Input.

Position Covenant NFT (LtSwap) Fuelled sale version (FnftSwap) Signed by
Input 0 the seller’s token the seller’s token the seller
Input 1 the buyer’s coin: price plus fee, exactly the same the buyer
Output 0 the price, to the seller the token, to the buyer, its fuel unchanged
Output 1 the token, to the buyer the price, to the seller

The tokens that can be sold this way are:

  • the covenant NFT and the Listed NFT, with LtSwap;
  • the sale versions of the fuelled family, with FnftSwap: FnftBuilder.saleable() (TSL1_LT_FNFT_S), saleableVoucher() (TSL1_LT_VNFT_S) and saleableNote() (TSL1_LT_NOTE_S). A token is either issued as a sale version or it is not, and the plain Fuelled, Voucher and Note NFTs cannot be swapped.

The fungible token has no swap.

LtSwapTerms fix every byte of the swap except the two Signatures: the token’s Transaction, who is paid and how much, the buyer’s coin (outpoint, value, key), and the new owner. The fee is the coin’s value less the price. There is no change, so the buyer first makes a coin of exactly the price plus the fee.

/// The buyer's coin, as the Transaction that paid it. The seller is sent
/// these bytes to check that the bid's coin is real and holds what it claims.
(List<int>, LtCoin) buyerCoin(SVPrivateKey buyer, int sats) {
final tx = Transaction()
..addInput(TransactionInput('cd' * 32, 0, TransactionInput.MAX_SEQ_NUMBER))
..addOutput(TransactionOutput(BigInt.from(sats), SVScript.fromByteArray(p2pkhOf(buyer))));
final txid = hex.decode(tx.id).reversed.toList(); // internal byte order
return (hex.decode(tx.serialize()), LtCoin(outpoint(txid, 0), buyer.lt, sats));
}
final swap = LtSwap(b);
final (coinTx, coin) = buyerCoin(buyer, 50000 + 300); // exactly price + fee
final terms = LtSwapTerms(
tokenTx: sellersToken.raw, // the Transaction whose Output 1 is sold
payTo: pkhOf(seller),
price: 50000,
fundOut: coin.out,
fundSats: coin.sats,
fundPub: coin.pub,
newOwner: pkhOf(buyer),
);
expect(terms.fee, 300);

terms.encode() and LtSwapTerms.decode give us a versioned byte encoding to send the terms in. The envelope around it is your app’s business.

Each side signs the same digest, whatever the other side has done, so either one may sign first. The first Signature makes a half: the swap with one real Signature and a placeholder, which the network refuses. The half is the message to the other side.

Let’s walk through the default case, step by step.

  1. The seller sends the buyer the token’s Transaction, the Issuer’s anchor, any reissue links, and the price.

  2. The buyer checks the token as a payee would, for the seller’s key, and signs its own Input:

    // The buyer checks the token first, as a payee would, for the seller's key.
    await LtReceive.check(b: b, raw: sellersToken.raw, ownerPkh: pkhOf(seller), anchorTx: issuer.anchor);
    // Then it signs its own Input. The half it sends the seller is the bid.
    final bid = swap.bid(terms, buyer.lt);

    The buyer also checks with its chain service that the token’s Output is unspent.

  3. The buyer sends the bid along with the coin’s own Transaction. The seller reads the bid back, checks the coin and the buyer’s Signature, and completes the swap:

    // The seller reads the half back (the coin's value comes from the
    // coin's own Transaction, which the buyer sends with the bid), checks
    // the buyer's coin and Signature, and completes it with its own.
    final fundSats = Transaction.fromHex(hex.encode(coinTx)).outputs[0].satoshis.toInt();
    final half = swap.readHalf(bid.raw, tokenTx: sellersToken.raw, fundSats: fundSats);
    expect(half.terms.price, 50000);
    swap.checkBid(half.terms, half.buyerSig!, coinTx);
    final done = swap.assemble(half.terms, seller: seller.lt, buyer: LtGivenSignature(coin.pub, half.buyerSig!));
    // Either side can check the complete swap: both Inputs run, the price is
    // paid, the buyer owns the token.
    swap.verify(terms, done.raw);
    final mine = await LtReceive.check(b: b, raw: done.raw, ownerPkh: pkhOf(buyer), anchorTx: issuer.anchor);
    expect(mine.tokenId, iss.txid);
  4. The seller broadcasts the swap, and sends it to the buyer.

swap.readHalf rebuilds the terms from the half’s bytes, and refuses a half that is not the swap its own terms build. Always compare the price it reads with the price you agreed.

// An issuance is not sold. Its first spend must carry the Attestation,
// so the Issuer distributes it first.
final issuanceTerms = LtSwapTerms(
tokenTx: iss.raw, payTo: pkhOf(issuerKey), price: 50000, fundOut: coin.out, fundSats: coin.sats,
fundPub: coin.pub, newOwner: pkhOf(buyer));
expect(() => swap.bid(issuanceTerms, buyer.lt), throwsArgumentError);

This is the same exchange, but with FnftSwap. Here the seller signs first (an ask), and the buyer completes it. verify also checks that the token keeps its fuel:

// Only the sale versions can be sold. Here we sell the Voucher NFT's.
final b = FnftBuilder.saleableVoucher();
final vt = VoucherTerms(maxHops: 5, expiry: 900000, redeemTo: pkhOf(issuerKey), feeCap: 1000);
final iss = b.issue(first: coinOf(issuerKey, 30300), issuerPkh: issuer.lt.pkh, owner: pkhOf(issuerKey), terms: vt, fee: 300);
iss.x = issuer.lt.attest(iss.txid);
final sellersToken = b.transfer(from: iss, ownerKey: issuerKey.lt, to: pkhOf(seller), fee: 100);
final sale = FnftSwap(b);
final (_, coin) = buyerCoin(buyer, 25000 + 60);
final terms = LtSwapTerms(
tokenTx: sellersToken.raw, payTo: pkhOf(seller), price: 25000, fundOut: coin.out, fundSats: coin.sats,
fundPub: coin.pub, newOwner: pkhOf(buyer));
final ask = sale.ask(terms, seller.lt); // the seller signs first
final half = sale.readHalf(ask.raw, tokenTx: sellersToken.raw, fundSats: coin.sats);
final done = sale.assemble(half.terms, seller: LtGivenSignature(half.sellerPub!, half.sellerSig!), buyer: buyer.lt);
final bought = sale.verify(terms, done.raw);
expect(bought.value, sellersToken.value); // the fuel goes with the token
expect(bought.hopsLeft, sellersToken.hopsLeft - 1); // a sale is a hop
expect(() => FnftSwap(FnftBuilder.voucher()), throwsArgumentError);

A sale of a Voucher or Note NFT is a transfer. It counts against the hop limit (a voucher at its limit cannot be sold), and the buyer gets the same terms. A sale has two Inputs, so an Issuer’s early forced redemption or call, held at a node, blocks it there. The seller clears it with a one-Input transfer to itself, and then sells from the new Output.

The plugins that can sell have four actions:

  • swapBid, where the buyer signs first;
  • swapAccept, where the seller completes a bid;
  • swapAsk, where the seller signs first;
  • swapTake, where the buyer completes an ask.

libspiffy records a half as a deferred payment, which holds the signer’s Input. CompleteDeferredPaymentCommand replaces it with the swap once the other side completes it. ReclaimDeferredPaymentCommand spends the held coin back, and that is the only way to take a bid back. CancelDeferredPaymentCommand releases the hold, but does not withdraw the Signature. See Plugin reference.

A swap is visible on chain, so anyone can see each sale’s price.