CatDex is a decentralized exchange using CashToken NFT commitments to track and store available open orders for fungible tokens at fixed prices.
Anyone may fulfill the open orders by swapping funds in a prescribed manner at the prices specified.
Each sub exchange for a token is designed to be discoverable without a specalized indexing service.
While each order may only be used once in a transaction, many orders can be used at once including across exchanges controlled by different owners by grouping the order and trade in pairs
Goal
The goal is to support selling 1 FBCH for 99,999,000 sats, or more generally to support a robust and low fee secondary market to match the granular pricing available on primary futures markets.
Features:
- Zero commission trades, although order takers pay standard transaction fees to miners.
- Allow for partial order fulfillment at a fixed price,
- Both Open Asks and Open Bids are supported.
- Opening multiple simultaneous orders on both sides are supported.
- Allow contracts with zero (0) of a specific token to acquire a position in that token
- Allow contracts with some number of tokens to liquidate that position completely
- Given a known token category, orders are discoverable with the exchange token category.
- Given an exchange category, orders are indexed and retrieved by getting the NFT balance of the exchange.
- Allow a minting Baton holder to withdraw, or cancel any open order, by spending/burning utxos.
Usage:
The exchange “owner” creates an NFT category with a minting baton to open trade orders on their own behalf.
An order to Bid, to buy an asset, is created by:
- creating a mutable NFT with the order in the commitment
- where the order quantity to be bought is positive, and
- the output contains sufficient value to fulfill the entire order,
- then sending that output to the dex contract.
An Ask order, to sell, is created as above:
- creating a mutable NFT identical to a buy, where,
- the quantity available for sale is negative,
- and the order utxo will accumulate any funds from the sale of the token,
- after the owner sends both: the order and the tokens to the contract as two utxos.
Order Commitments: mutable NFT commitment to trade fungible token
// The contents of the Order commitment:
byte16(LE)<quantity> number of tokens remaining in order
byte16(LE)<price> price per token in sats
TODO: multiplier pre-multiply the token price by some fixed constant (i.e. decimals)
===
32 length
For crafting signed integers as VM numbers, the following javascript wrapper for libauth was helpful:
const toVmNum = (n) => padMinimallyEncodedVmNumber(bigIntToVmNumber(n),16);
Transaction Building Modes:
Order Mode:
In “Order mode” outputs are submitted in pairs, with both the record for the order
and the cash value moving on the even “foot” and the tokens trading on the odd “foot”.
orderAuth input[even] -> output[even] - An order NFT of category AuthCat (mutable)
assetVault input[odd] -> output[odd] - An input or output of category AssetCat.
Withdraw Mode:
Transactions with a minting NFT may withdraw all outputs from the contract.
Auth input[0] -> output[0] - Authenticating NFT of category AuthCat (minting)
input[i] -> output[*] - Unrestricted spending
Note:
The exchange owner(s) holds the minting NFT baton(s), which authorizes spending funds from the exchange. Sending the minting baton is equivalent to transfering ownership of the exchange.
Given that the order NFTs are agnostic to the token being traded outside the context of the contract, it’s probably not a good practice to reuse one Authentication NFT for many exchanges.
Unauthorized Mode:
Without NFT authorization, evaluating unlocking script fails.
Contract
CatDex is a single contract with two parameters. It’s a 152 byte unlocking scrip. All the data for the swap function is derived from introspection.
Below is the commented CashScript with OP_codes and error messages.
//
// CatDex - WIP 20250115, Mock tested
//
// Parameters:
//
// authCat - the token category authorizing trades or transfers (owner's NFT)
// assetCat - the category of the fungible token being traded
//
contract CatDex(bytes32 authCat, bytes32 assetCat){
function swap(){
// Set the index of the order baton related to this trade
// OP_INPUTINDEX OP_INPUTINDEX OP_2 OP_MOD OP_SUB
int orderIndex = this.activeInputIndex - (this.activeInputIndex % 2);
// If the order input (even input) is a mutable auth Baton ...
// OP_DUP OP_UTXOTOKENCATEGORY OP_2 OP_PICK OP_1 OP_CAT OP_EQUAL OP_IF
if(tx.inputs[orderIndex].tokenCategory == authCat + 0x01){
// Require the baton is passed back in an output of the same index with mutable capability
// OP_DUP OP_OUTPUTTOKENCATEGORY OP_2 OP_PICK OP_1 OP_CAT OP_EQUALVERIFY
require(tx.outputs[orderIndex].tokenCategory == authCat + 0x01,
"order baton must be returned intact");
// Require the order baton be passed back to the contract
// OP_DUP OP_OUTPUTBYTECODE OP_OVER OP_UTXOBYTECODE OP_EQUALVERIFY
require(tx.outputs[orderIndex].lockingBytecode ==
tx.inputs[orderIndex].lockingBytecode,
"order baton must be returned to the dex");
// Get the next index of the asset thread
// OP_DUP OP_1ADD
int assetIndex = orderIndex + 1;
// Require the asset thread be sent at the current contract
// OP_DUP OP_OUTPUTBYTECODE OP_2 OP_PICK OP_OUTPUTBYTECODE OP_EQUALVERIFY
require(tx.outputs[assetIndex].lockingBytecode ==
tx.outputs[orderIndex].lockingBytecode,
"token output be returned to the dex");
// if the amount of tokens is greater than zero,
// OP_DUP OP_OUTPUTTOKENAMOUNT OP_0 OP_GREATERTHAN OP_IF
if(tx.outputs[assetIndex].tokenAmount > 0){
// require the asset output contain token category specified by the order
// OP_DUP OP_OUTPUTTOKENCATEGORY OP_4 OP_PICK OP_EQUALVERIFY
require(tx.outputs[assetIndex].tokenCategory == assetCat,
"wrong token passed as the return asset");
} // OP_ENDIF
// Parse the order data from the NFT commitment
// OP_OVER OP_UTXOTOKENCOMMITMENT OP_16 OP_SPLIT
bytes quantityBin, bytes priceBin =
tx.inputs[orderIndex].nftCommitment.split(16);
// OP_OVER OP_BIN2NUM
int orderQuantity = int(quantityBin);
// OP_OVER OP_BIN2NUM
int price = int(priceBin);
// Get the amount of the token traded
// OP_4 OP_PICK OP_OUTPUTTOKENAMOUNT
// OP_5 OP_PICK OP_UTXOTOKENAMOUNT OP_SUB
int tradeQuantity = tx.outputs[assetIndex].tokenAmount -
tx.inputs[assetIndex].tokenAmount;
// Verify new authCat order baton NFT commitment
// OP_2 OP_PICK OP_OVER OP_SUB OP_16 OP_NUM2BIN
// OP_2 OP_PICK OP_16 OP_NUM2BIN OP_CAT
bytes32 nextCommitment = bytes16(orderQuantity-tradeQuantity) + bytes16(price);
// OP_7 OP_PICK OP_OUTPUTTOKENCOMMITMENT OP_OVER OP_EQUALVERIFY
require(tx.outputs[orderIndex].nftCommitment == nextCommitment,
"order baton data was not updated to reflect trade");
// require the sign of the quantity traded is matches the order sign
// OP_OVER OP_0 OP_GREATERTHAN OP_4 OP_PICK OP_0 OP_GREATERTHAN OP_EQUALVERIFY
require(tradeQuantity > 0 == orderQuantity > 0,
"conflicting trade & order direction");
// require the amount traded be less than (or equal to) the quantity available
// OP_OVER OP_ABS OP_4 OP_PICK OP_ABS OP_LESSTHANOREQUAL OP_VERIFY
require(
abs(tradeQuantity) <= abs(orderQuantity),
"trade must be less than or equal to order quantity available"
);
// Verify the value returned with the order exceeds the quantity traded times the price.
// OP_7 OP_PICK OP_OUTPUTVALUE OP_8 OP_PICK OP_UTXOVALUE OP_SUB
// OP_2OVER OP_SWAP OP_MUL OP_NEGATE OP_GREATERTHANOREQUAL OP_VERIFYY
require(
(tx.outputs[orderIndex].value - tx.inputs[orderIndex].value) >= -(tradeQuantity*price),
"Payment for order too low"
);
//
} // OP_2DROP OP_2DROP OP_2DROP OP_DROP
//
// otherwise, if the zeroth input contains the minting baton
// OP_ELSE
else{
// Authentication failed, script fails.
// OP_0 OP_UTXOTOKENCATEGORY OP_2 OP_PICK OP_2 OP_CAT OP_EQUALVERIFY
require(tx.inputs[0].tokenCategory == authCat + 0x02,
"Authorizing order baton NFT must be passed with each trade on an even input.");
} // OP_ENDIF
} // OP_ENDIF
} // OP_2DROP OP_2DROP OP_1
Updated with simplification suggested by @Jonas for unauthorized case.