Swap contract Demo

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, the Charli3 developer team faced the challenge of transitioning from the previous Haskell-based wallet architecture to a new architecture that fully supports Vasil's features. One important aspect of this transition was the creation of an oracle data feed as a reference input for various transactions. To achieve this, the team decided to rewrite a Haskell contract that utilizes reference inputs, leveraging the Pycardano, Python library. This approach enabled them to explore the capabilities of the library and develop a comprehensive tutorial on reading information from Charli3's oracles using contracts.

In this guide, we will demonstrate how to access the oracle feed through reference UTXOs and illustrate the process of creating transactions using Pycardano.

Python and Haskell

Before we proceed with the code, it's worth noting that while the off-chain portion of smart contracts can be rewritten in different programming languages, the on-chain code can only be written in Haskell (although there are rising alternative available). Keeping that in mind, this guide will focus on explaining the main functions of the smart contract, emphasizing the distinctions between the Haskell and Python implementations of the code.

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 includes a minted NFT. This NFT serves as an identifier for the UTXO that will hold two assets.

  • The Add liquidity transaction allows the user to add specific amounts of tokens to the swap's UTXO. These token quantities must be available in the user's wallet.

  • The Swap A transaction facilitates the exchange of asset A from the customers' wallet to the swap's UTXO in return for asset B.

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

The on-chain component of the swap contract validates that when multiplied by the oracle feed, the input tokens from the customers' wallet result in the deterministic addition or removal of assets from the swap contract's UTXO. It also adds or removes tokens from the user's wallet accordingly.

Furthermore, the contract guarantees that the "add liquidity" transaction is always an incremental operation.

Swap's validator
{- The validator argument contains information about the assets involved in the trade, the datum represents the unit type, the redeemer specifies the quantity of assets to be exchanged by the user, and the context provides transaction-related information.
-}
{-# 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 initial transaction, Run swap, establishes the contract's address and generates a UTXO that includes the minted SWAP NFT, serving as the pool liquidity. The Pycardano library simplifies the setup of a Cardano development environment by integrating Blockfrost's services. To utilize these services, you need a Blockfrost account and a token ID to interact 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 creates a unique SWAP NFT at a contract address. Once the SWAP UTXO is generated, it needs to be filled with assets to serve as a liquidity pool. The contract operates with a single UTXO for all transactions.

See below for the token name variable and 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 showcase how to mint assets using Pycardano. In the actual implementation, this function is executed automatically when a new swap address is created through 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 SWAP UTXO created earlier. This allows the contract to enable trades of assets from the UTXO with any user possessing any of the predetermined assets.

Note: To enhance the code:

  • Use separate wallets for adding assets and trading with the swap contract, improving security and separation of concerns.

  • Implement additional security measures like multi-signature or threshold signature schemes to ensure authorized participation in adding assets or trading.

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, you need to provide the following information:

  1. Oracle contract address and its UTXO feed NFT identifier.

  2. Swap contract address and its UTXO NFT identifier.

  3. User's wallet address.

  4. Details of the assets being traded.

Note that in this example, tADA information is not required as it doesn't contain a policy ID or asset name.

It is assumed that a valid oracle contract already exists on the blockchain, and you can use it for testing purposes. If using a private test-net, you have the option to deploy your own oracle contract.

swap contract's arguments
# 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 follows a standard format created with a Haskell library. Using the library's generic data type, we generate the inline oracle data. Accessing the information is as simple as reading the UTXO that holds this data as a reference UTXO.

The following on-chain Haskell code snippet demonstrates how to read the datum and extract the integer value representing the exchange price. Additionally, in this example we ensure that the oracle feed UTXO is the only 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 Pycardano library offers convenient built-in functions for searching and retrieving the oracle feed. By providing the oracle's address, we can search for available UTXOs and locate the one that contains the oracle fee NFT (OracleFeed). From there, the get_price() function can extract 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()
    

Let's take a closer look to the code:

Oracle feed inline datum
oracle_inline_datum: GenericData = GenericData.from_cbor(
         oracle_feed_utxo.output.datum.cbor
)

Here's a breakdown of what each part does:

  • oracle_feed_utxo: This is an object that represents a UTXO of the transaction that contains the oracle feed.

  • oracle_feed_utxo.output: This is a property of the oracle_feed_utxo object that represents the output of the UTXO. We can access diferente pieces of information like address, amount, datum_hash, datum, script, etc. More information in pycardano's documentation.

  • oracle_feed_utxo.output.datum: This is a property of the output that represents the CBOR-encoded datum associated with the output.

  • oracle_feed_utxo.output.datum.cbor: This is the binary CBOR-encoded datum that is being parsed.

  • GenericData.from_cbor: This is a static method of the GenericData class that takes in a CBOR-encoded byte string and returns a GenericData object. The GenericData object is a custom data type that is defined in the datum.py file, see the code below.

  • oracle_inline_datum: GenericData: This is a variable declaration that creates a new variable called oracle_inline_datum of type GenericData.This process is known as downcasting.

  • = GenericData.from_cbor(oracle_feed_utxo.output.datum.cbor): This sets the value of oracle_inline_datum to the result of calling the from_cbor method of the GenericData class with the CBOR-encoded datum as the argument.

In summary, this code extracts the CBOR-encoded datum from the oracle feed UTXO, parses it into a GenericData object using a static method, and assigns the result to the variable oracle_inline_datum.

Swap contract's datum file
@dataclass
class GenericData(PlutusData):
    CONSTR_ID = 0
    price_data: PriceData
@dataclass
class PriceData(PlutusData):
    """represents cip oracle datum PriceMap(Tag +2)"""

    CONSTR_ID = 2
    price_map: dict

    def get_price(self) -> int:
        """get price from price map"""
        return self.price_map[0]

    def get_timestamp(self) -> int:
        """get timestamp of the feed"""
        return self.price_map[1]

    def get_expiry(self) -> int:
        """get expiry of the feed"""
        return self.price_map[2]

    @classmethod
    def set_price_map(cls, price: int, timestamp: int, expiry: int):
        """set price_map"""
        price_map = {0: price, 1: timestamp, 2: expiry}
        return cls(price_map)

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, such as 1,000,000. This enables accurate evaluation of decimal precision when working with integers. For example, if we have 2,400,000 with a coin_precision of 1,000,000, it would be 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 SwapA transaction is exactly the opposite operation of SwapB

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.

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

Copyrigth: (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