CatDex: A token category authorized decentralized exchange

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.

3 Likes

This seems like a really cool concept. Is there any proof-of-concept yet that we can try out? How is this different to JeDEX which Jason has said is ready to roll out as soon as Velma upgrade goes live?

The CatSwap contract design in a community feedback phase.

If anyone has suggestions, or questions, or concerns. Any improvement can be easily made at this phase.

If anyone is interested in getting into BCH DeFi development, this is a good introduction or a place to start.


In regard to CatDex v. Jedex et. al, the biggest difference is CatDex does not use an automated market making algorithm, it’s limit orders first. It is more like TapSwap, where an open order is entered, and the market maker waits for another party to take that order at the predetermined price.

Jedex could add limit orders in Future Work, so it should be possible. Likewise, AMM order types could be supported by CadDex, but it’s not above.

CatDex is just a single contract like Cauldron & TapSwap, with a thread for each order NFT.

CatDex is not using Jedex’s batched ticks to prevent order front running. Cat orders are processed in a first come first serve way that could be replaced by miners for profit. From a market maker perspective, the party that put up tokens or cash for a trade doesn’t care if two parties realized it was a good deal in the same block, as long as the trade was executed.

With fees for order handling on Jedex:

To make denials of service expensive, order submission requires a fixed fee of 10,000 satoshis; fees are accumulated in the primary covenant at each tick and may be withdrawn by the LP at any time.

A 10k flat fee is better but similar to a 3% fee or 0.3% trade fee―in that 10,000 sats is too high for my use case.


The Future Derivative Secondary Market Usecase

When the market price for an asset isn’t clear, automated market maker algorithms are very useful because it’s possible to create a very elastic market over a HUGE range of prices where the user pays for the robustness of the market in slippage and the market maker is rewarded in liquidity provider fees.

However…

If the price an asset is fairly well known and agreed upon, then limit orders will always beat AMMs in terms of overall capital efficiency.

For example, if there’s $30k in a stablecoin AMM, that might result in 3% slippage to trade $500 of BCH, whereas the same market capacity could be created for $1k of liquidity as limit order spreads.

So, out-of-the-box, with a fraction of the liquidity, a narrowly priced asset on limit orders exchanges would probably beat all the AMMs for slippage.

Stepping away from fiat volatility, if a user took a 3,000 sat coupon to lock 0.1 BCH into 0.1 FBCH, that user knows that every block towards maturation their tokens are becoming slightly more valuable, and when their futures mature they’ll be worth almost exactly 0.09_999_700 BCH (the BCH in the vault minus ~300 sats in network fees to redeem).

It’s very easy for everyone to know what FBCH is worth in BCH terms (much tighter than an AMM would allow), so if someone decides they don’t want to hold 0.1 FBCH to maturation and they want to sell early, they should be able list an ASK to sell it all for exactly what the price is on the primary (coupon) market.

So someone that took a 3000 sat coupon to lock 0.1 FBCH, they should probably be able to sell their (now more mature) FBCH for at least 0.09_997_000 BCH on the secondary market. If they held FBCH for long enough to eat the 500 sat fee, they should be able to get out early―scott free.

To have a robust derivatives market, efficiency and liquidity of futures is key. Right now, it’s possible to always make sats taking FBCH coupons, but if market participants can fairly reasonably expect to be able sell their FBCH for more than they got it for in an efficient liquid market, then the incentive to hold straight BCH evaporates a bit more.

2 Likes