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) andsaleableNote()(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.
The terms
Section titled “The terms”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 + feefinal 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.
Who signs first
Section titled “Who signs first”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.
The buyer bids, the seller accepts
Section titled “The buyer bids, the seller accepts”Let’s walk through the default case, step by step.
-
The seller sends the buyer the token’s Transaction, the Issuer’s anchor, any reissue links, and the price.
-
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.
-
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); -
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
Section titled “An issuance is not sold”// 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);A fuelled sale version
Section titled “A fuelled sale version”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 firstfinal 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 tokenexpect(bought.hopsLeft, sellersToken.hopsLeft - 1); // a sale is a hopexpect(() => 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.
In a wallet
Section titled “In a wallet”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.

