Swap contract

The swap contract is an interactive example, written in Python, that integrates data feed from a Charli3's oracle. The contract support trade operations, liquidity and minting operations.

Introduction

After the Vasil upgrade, Charli3's developer team faced the challenge of transitioning from the previous wallet architecture, written in Haskell, to a new architecture that fully supports Vasil's features. A key aspect of this process was creating an oracle data feed as a reference input for various transactions. To accomplish this, the team chose to rewrite a Haskell contract that uses reference inputs with the help of Pycardano, a Python library. This allowed them to test the library's capabilities and create a comprehensive tutorial on how contracts can read information from Charli3's oracles. Through this guide, we will read the oracle feed by utilizing reference UTXO and demonstrate how to create transactions using Pycardano.

Python and Haskell

Before delving into the code, it's important to note that while it is possible to rewrite the off-chain portion of smart contracts in various programming languages, it is only possible to write the on-chain code in Haskell. With that in mind, in this guide, we will describe the smart contract's primary functions, highlighting the differences between the code written in Haskell and Python.

Swap-contract code

The swap contract supports four primary operations:

  • The "Run swap" transaction initiates the creation of a UTXO at the contract address, which contains a minted NFT. This serves as an identifier for the UTXO that will hold two assets.

  • "Add liquidity" transaction enables the addition of specific amounts of tokens to the swap's UTXO. These quantities must be present in the wallet of the swap's creator.

  • "Swap A" transaction allows the exchange of asset A from the user's wallet to the swap's UTXO in exchange for asset B.

  • "Swap B" transaction enables the exchange of asset B from the user's wallet to the swap's UTXO in exchange for asset A.

The on-chain component of the swap contract verifies that the input tokens from the user's wallet, when multiplied by the oracle feed, result in the deterministic addition or removal of assets from the swap contract's UTXO and give or remove tokens from the user's wallet. Additionally, the contract ensures that the adding liquidity transaction is always an incremental operation.

Swap's validator
{- The validator argument holds details regarding the assets to be traded, the datum is the unit type, the redeemer denotes the number of assets to be exchanged by the user, and the context provides information about the transaction.
-}
{-# INLINABLE mkSwapValidator #-}
mkSwapValidator
    :: Swap
    -> ()
    -> SwapRedeemer
    -> ScriptContext
    -> Bool
mkSwapValidator Swap{..} _ (SwapA amountA) ctx =
    mkSwapXValidator checkExchangeA coinB oracle amountA ctx
mkSwapValidator Swap{..} _ (SwapB amountB) ctx =
    mkSwapXValidator checkExchangeB coinA oracle amountB ctx
mkSwapValidator swap _ AddLiquidity ctx =
    mkAddLiqValidator swap ctx

The swap contract validator generates a unique address by using an oracle's information and the time of the initial transaction. The initial transaction, Run-swap, determines the contract's address and creates a UTXO with a minted SWAP NFT, used as pool liquidity.

The Pycardano library simplifies the setup of a Cardano development environment by incorporating Blockfrost's services. To use these services, a Blockfrost account and token ID is required for interacting with the Cardano blockchain.

Environment's settings
BLOCKFROST_PROJECT_ID = "BLOCKFROST_API_PROJECT_ID"
BLOCKFROST_BASE_URL = "https://cardano-preprod.blockfrost.io/api"

Start swap

The "start operation" created a unique digital asset, called SWAP (NFT), at a specific contract address on the blockchain. We have established a pre-determined set of rules for the minting policy and use a custom variable as the token name for each new NFT we create. After the UTXO that holds the NFT is generated, it must be filled with assets to serve as a liquidity pool. It's important to note that the contract mechanism only utilizes one UTXO for all transactions.

Below the token name variable with the quantity of tokens to mint.

Token Name
asset_name = "SWAP"                           #Token Name
nft_swap = MultiAsset.from_primitive(
       {
           policy_id.payload: {
               bytes(asset_name, "utf-8"): 1, #Amount to mint
           }
        }
)

We proceed to invoke the mint function to automatically generate the UTXO containing the custom NFT.

Mint class and function
swap_utxo_nft = Mint(...)            #Mint class (Hidden arguments)
swap_utxo_nft.mint_nft_with_script() #Creation of swap UTXO with NFT

Note: The example provided does not require the execution of the start swap transaction. Its purpose is to demonstrate to the reader how to mint assets using Pycardano. This function is executed automatically when a new swap address is created via on-chain code.

Add liquidity

The "add liquidity" operation transfers a specified amount of the defined assets, USDT and TADA, from the user's wallets to the UTXO SWAP created earlier. This allows the contract to enable trades of assets from the pool UTXO with any user possessing any of the predetermined assets.

Note: To improve the code, you can create a separate wallet for the purpose of adding assets to the UTXO swap, and another separate wallet for trading with the swap contract. This way, the wallet that holds the assets being added to the swap is separate from the wallet that is executing the trade, providing a better separation of concerns and increasing security. Additionally, you can consider implementing other security measures such as multi-sig or threshold signature schemes to ensure that assets can only be added to the swap or traded by multiple parties with the proper authorization.

Add liquidity class and function
swapInstance = SwapContract(...)              #Swap contract class (Hidden args)
swapInstance.add_liquidity(amountA, amountB, ...) #The user's wallet associated must cover the asset amount expected by the liquidity function.

Swap Use

Now, we will delve into the implementation of the "swap A" and "swap B" transactions within the Python code. It is assumed that the creation and filling of liquidity for the swap UTXO have already been accomplished.

  • Contract's arguments

To begin, we need to specify the address of the oracle contract and its UTXO feed NFT identifier, the address of the swap contract and its UTXO NFT identifier, the address of the user's wallet, and the details of the assets being traded. As in this example, we don't need to provide tADA information as it does not contain a policy ID or asset name. It is assumed that a valid oracle contract already exists on the blockchain and you are able to use that one, or that you can deploy your own for testing purposes if using a private testnet.

# Charli3's oracle contract address
oracle_address = Address.from_primitive(
    "addr_test1wz58xs5ygmjf9a3p6y3qzmwxp7cyj09zk90rweazvj8vwds4d703u"
)

# Custom contract address (swap contract)
swap_address = Address.from_primitive(
    "addr_test1wqhsrhfqs6xv9g39mraau2jwnaqd7utt9x50d5sfmlz972spwd66j"
)

# Oracle feed nft identity
oracle_nft = MultiAsset.from_primitive(
    {
        "8fe2ef24b3cc8882f01d9246479ef6c6fc24a6950b222c206907a8be": {
            b"InlineOracleFeed": 1
        }
    }
)

# Swap nft identity
swap_nft = MultiAsset.from_primitive(
    {"ce9d1f8f464e1e930f19ae89ccab3de93d11ee5518eed15d641f6693": {b"SWAP": 1}}
)

# Swap asset information
tUSDT = MultiAsset.from_primitive(
    {"c6f192a236596e2bbaac5900d67e9700dec7c77d9da626c98e0ab2ac": {b"USDT": 1}}
)

#User's wallet addressython
user_address = w.user_address()

The Pycardano library supports mnemonic wallets. In this example, we utilize a 24-word Shelley-compatible wallet to sign transactions by restoring an existing wallet via the wallet recovery phase.

  • Reading oracle feed

The oracle feed data is specified in a standard format (CIP), created using a dedicated Haskell library. We use the library's generic data type to create the inline oracle data. Anyone can access the information by reading the UTXO containing this data as a reference UTXO.

The Haskell code snippet reads the datum and retrieves the integer value which serves as the exchange price. It is worth noting that the on-chain code verifies that the oracle feed UTXO is the sole input reference UTXO.

On-chain: Oracle feed reading
mOracleFeed :: Maybe OracleFeed
mOracleFeed =
 case txInfoReferenceInputs $ scriptContextTxInfo ctx of
   [txIn] -> case txOutDatum $ txInInfoResolved txIn of
               NoOutputDatum       -> Nothing
               OutputDatumHash odh ->
                  case findDatum odh (scriptContextTxInfo ctx) of
                    Just dv -> PlutusTx.fromBuiltinData . getDatum $ dv
                    Nothing -> Nothing
               OutputDatum dv      ->
                 PlutusTx.fromBuiltinData . getDatum $ dv
   []     -> traceError
                 "mkSwapXValidator: Empty reference input list"
   _      -> traceError
                 "mkSwapXValidator: Only one reference input is allowed"

The python library has built-in functions that simplify the search and retrieval of the data feed. We only need to provide the oracle's address to search for available UTXOs, then search for the UTXO that holds the oracle fee NFT. Finally, the get_price() function will retrieve the integer value from the datum list structure.

Off-chain: Oracle feed reading
def get_oracle_utxo(self) -> pyc.UTxO:
    """Get oracle's feed UTXO using NFT idenfier"""
    oracle_utxos = self.context.utxos(str(self.oracle_addr))
    oracle_utxo_nft = next((x for x in oracle_utxos if x.output.amount.multi_asset >= self.oracle_nft), None)
    if oracle_utxo_nft is None:
        raise ValueError("Oracle UTXO not found with NFT identifier")
    return oracle_utxo_nft
    
def get_oracle_exchange_rate(self) -> int:
    """Get the oracle's feed exchange rate"""
    oracle_feed_utxo = self.get_oracle_utxo()
    oracle_inline_datum: GenericData = GenericData.from_cbor(
         oracle_feed_utxo.output.datum.cbor
    )
    return oracle_inline_datum.price_data.get_price()
    
  • SwapB transaction (tADA for tUSDT)

The swapB transaction exchanges a specified amount of tADA for tUSD at the exchange rate provided by an oracle. To handle decimal precision, a variable called coin precision is used, which is set as a multiple of 1, for example 1,000,000. This allows for evaluating the exact decimal precision when working with integers, for example 2400000 with a coin precision of 1000000 is evaluated as 2.4.

SwapB
"""Exchange of asset B  with A"""
amountA = self.swap_b_with_a(amountB)

def swap_b_with_a(self, amount_b: int) -> int:
    """Operation for swaping coin B with A"""
    exchange_rate_price = self.get_oracle_exchange_rate()
    return (amount_b * self.coin_precision) // exchange_rate_price
  • SwapA transaction (tUSDT for tADA)

The SwapB transaction is exactly the opposite operation of SwapA

SwapA
"""Exchange of asset A  with B"""
amountB = self.swap_a_with_b(amountA)

def swap_a_with_b(self, amount_a: int) -> int:
    """Operation for swaping coin A with B"""
    exchange_rate_price = self.get_oracle_exchange_rate()
    return (amount_a * exchange_rate_price) // self.coin_precision
  • Transaction submission of a swap operation

We will provide detailed instructions on how to submit a swapB transaction, which is similar to the process for submitting a swapA transaction. Before continuing, we recommend reviewing the Pycardano documentation on transactions.

First, we need to define a class SwapContract that receives the contract's arguments

Swap Contract Class
class SwapContract:
    """SwapContact to interact with the swap smart contract

    Attributes:
        context: Blockfrost class
        oracle_nft: The NFT identifier of the oracle feed utxo
        oracle_addr: Address of the oracle contract
        swap_addr: Address of the swap contract
    """

    def __init__(
        self,
        context: ChainQuery,
        oracle_nft: pyc.MultiAsset,
        oracle_addr: pyc.Address,
        swap_addr: pyc.Address,
        swap: Swap,
    ) -> None:
        self.context = context
        self.oracle_addr = oracle_addr
        self.swap_addr = swap_addr
        self.coin_precision = 1000000
        self.swap = swap
        self.oracle_nft = oracle_nft

The swap class stores information about assets. We do not need to add information for the tADA asset as its fields contain empty values.

Swap Class
class Swap:
    """Class Swap for interact with the assets in the swap operation
    and identify the Swap's NFTs

    Attribures:
        swap_nft: The NFT identifier of the swap utxo
        coinA: Asset
    """

    def __init__(
        self,
        swap_nft: pyc.MultiAsset,
        coinA: pyc.MultiAsset,
    ) -> None:
        self.swap_nft = swap_nft
        self.coinA = coinA

We then query and process the necessary information to construct the transaction.

Data processing
"""Exchange of asset B  with A"""
oracle_feed_utxo = self.get_oracle_utxo()  #Oracle's feed UTXO
swap_utxo = self.get_swap_utxo()           #Swap's UTXO
amountA = self.swap_b_with_a(amountB)      #Assets exchange

We create two UTXOs using the processed information. The first UTXO goes to the swap address, it contains the amount of tADAspecified by the user, and a decreased quantity of tUSDT

Swap UTXO
#Query the available amount of assetB (tADA) at the swap address UTXO
amountB_at_swap_utxo = swap_utxo.output.amount.coin

#We multiply the user's asset by the lovelace rate and add it to the UTXO for the swap
updated_amountB_for_swap_utxo = amountB_at_swap_utxo + (amountB * 1000000)

#Additionally, we need to decrease the amount of tUSDT sent to the user's wallet.
updated_massetA_for_swap_utxo = self.decrease_asset_swap(amountA)

#We create a new value type with the updated asset values
amount_swap = pyc.transaction.Value(
    coin=updated_amountB_for_swap_utxo,
    multi_asset=updated_massetA_for_swap_utxo,
)

#Finally, we add the updated value, unit type and the swap's address to the output UTXO for the swap"
new_output_swap = pyc.TransactionOutput(
    address=swap_address, amount=amount_swap, datum=pyc.PlutusData()
)

Fourth, the second UTXO goes to the user's wallet address. This UTXO pays the user the tUSDT earned from the swap operation.

User UTXO
#The user asset quantity to be added to the wallet (tUSDT received).
multi_asset_for_the_user = self.take_multi_asset_user(amountA)

#We create a value type with the user asset quantity (without tADA).
min_lovelace_amount_for_the_user = pyc.transaction.Value(
    multi_asset=multi_asset_for_the_user
)

#Next, we creates an output UTXO using the previous value at the user's wallet.
min_lovelace_output_utxo_user = pyc.TransactionOutput(
    address=user_address, amount=min_lovelace_amount_for_the_user
)

#The pycardano utils function min_lovelace_post_alonzo calculate minimum Lovelace to attach to the previous created UTXO.
min_lovelace = pyc.utils.min_lovelace_post_alonzo(
    min_lovelace_output_utxo_user, self.context
)

#Now, we create a value type with the minumum tADA amount.
amount_for_the_user = pyc.transaction.Value(
    coin=min_lovelace, multi_asset=multi_asset_for_the_user
)

#Finally, we add the calculated minimum tADA amount to the previously generated output UTXO on line 4.
new_output_utxo_user = pyc.TransactionOutput(
    address=user_address, amount=amount_for_the_user
)

Finally, we gather the information from the previous steps in the Pycardano builder, in this way we construct the swapB transaction. Finally, we sign and send it to the blockchain.

Sign and sumbmission
builder = pyc.TransactionBuilder(self.context) #Pyacardano builder
(
    builder.add_script_input(
        utxo=swap_utxo, script=script, redeemer=swap_redeemer
    )
    .add_input_address(user_address)           #User's wallet address
    .add_output(new_output_utxo_user)          #UTXO paying to the user
    .add_output(new_output_swap)               #UTXO paying to the swap contract
    .reference_inputs.add(oracle_feed_utxo.input) #Oracle's reference UTXO 
)

self.submit_tx_builder(builder, sk, user_address)  #Sign and submission

Command line interface

The python swap contract has a command line interface to easily submit transactions. To access it, first navigate to the src directory. Then, run the command python main.py -h to display helpful information on how to use the command line options. This command line interface is built using the Argparse python library.

python src/main.py -h
usage: python main.py [-h] {trade,user,swap-contract,oracle-contract} ...

The swap python script is a demonstrative smart contract (Plutus v2) featuring the interaction with a charli3's
oracle. This script uses the inline oracle feed as reference input simulating the exchange rate between tADA and tUSDT
to sell or buy assets from a swap contract in the test environment of preproduction.

positional arguments:
  {trade,user,swap-contract,oracle-contract}
    trade               Call the trade transaction to exchange a user asset with another asset at the swap contract.
                        Supported assets tADA and tUSDT.
    user                Obtain information about the wallet of the user who participate in the trade transaction.
    swap-contract       Obtain information about the SWAP smart contract.
    oracle-contract     Obtain information about the ORACLE smart contract.

options:
  -h, --help            show this help message and exit

Copyright: (c) 2020 - 2023 Charli3

The command line interface supports four different commands, each with its own options.

For example, you can change tADA asset for tUSDT using the command: python main.py trade tADA --amount N.

Another useful command is python main.py swap-contract --liquidity which allows to verify the swap contract liquidity. And for query the contract's address python main.py swap-contract --address.

Additionally, you can also retrieve information from the oracle feed by using the command python main.py oracle-contract --feed.

python main.py oracle-contract --feed
Oracle feed:
* Exchange rate tADA/tUSDt 2.4
* Generated data at: 2022-12-01 14:06:58
* Expiration data at: 2023-12-01 15:06:04

When executing the start swap function, it's important to remember to replace the token variable 'asset_name' in the 'mint.py' file. Keep in mind that each NFT must be unique for each new UTXO, and the policy id cannot be changed via python code. After the execution, update the values at the 'swap_nft' variable in the 'main.py' file.

mint.py
def mint_nft_with_script(self):
    """mint tokens with plutus v2 script"""
    policy_id = plutus_script_hash(self.minting_script_plutus_v2)
    asset_name = "SWAP"                           #Token Name 
    nft_swap = MultiAsset.from_primitive(
        {
            policy_id.payload: {
                bytes(asset_name, "utf-8"): 1,
            }
        }
    )
    metadata = {
        0: {
            policy_id.payload.hex(): {
                "Swap": {
                    "description": "This is a test token",
                    "name": asset_name,
                }
            }
        }
    }
Start swap transaction
python main.py swap-contract --start-swap

Swap's NFT information:                         #New NFT information
Currency Symbol (Policy ID): c6f192a236596e2bbaac5900d67e9700dec7c77d9da626c98e0ab2ac
Token Name: SWAP
main.py
swap_nft = MultiAsset.from_primitive(
    {"c6f192a236596e2bbaac5900d67e9700dec7c77d9da626c98e0ab2ac": {b"SWAP": 1}}
) #Variable to update with the new NFT information

Last updated