How To Make A Cardano CIP-68 Fungible Token With Helios (Part 1 of 2)

Staking Rocks! [PHRCK]
8 min readSep 30, 2023

Here’s a complete guide on creating a CIP-68 compliant fungible token on Cardano. It’s JS dev-friendly.

While this is complete from start to finish, it’s a demo project only, meant for beginners to Cardano smart contracts, to get them started easily; even without first having to learn functional programming. The Helios DSL for Cardano makes this possible. You do need to have a good understanding of the eUTXO model though.

For those not yet familiar of it, CIP-68 is a standard specification for storing and retrieving token metadata on-chain. Prior to its introduction, tokens on Cardano have their metadata (token info) stored in an off-chain registry that is maintained by the Cardano Foundation. CIP-68 introduces a more convenient way of storing token info and making them reliably available through decentralization. These specs even make it possible for the token info to be readable by smart contracts.

Token Features

On top of the inherent upgradability and programmability of a CIP-68 token, what we will be making in this sample project is a token whose policy never expires, but also has a fixed maximum supply.

Having a policy that never expires, means that we can continue to mint and burn amounts of this token indefinitely (time-wise). And having a specified maximum supply, means that we can ensure that only a finite amount of this token can ever exist. Prior to the availability of smart contracts on Cardano, it wasn’t possible to have both these features together in a token.

Smart Contracts

We will first prepare the smart contracts that we will need for this token. By the way, smart contracts on Cardano are actually more appropriately called validator scripts. So, for the rest of this guide, let’s call them that — “validator scripts”.

We will be needing 3 validator scripts. 1 for the minting policy of our main token, 1 for the minting policy of our thread NFT, and another 1 for our state machine that will enforce our maximum supply.

In Cardano, the way we can maintain an on-chain ‘state’ that is readable by validator scripts, is by attaching a piece of data which is called a “datum” into a UTXO.

CIP-68 Reference Token

This ability to store info in datums is the mechanism used by CIP-68 to store token metadata. For every unique token, there’s always an accompanying “reference” token that is contained in a UTXO that has the token info in its datum.

This is also the same mechanism that we will use for storing and updating the current supply of our token in real time.

Thread NFT

The UTXO containing our supply state datum will be locked at our validator script address, to make sure the datum will get updated every time a mint/burn transaction happens. But since anyone can send any UTXO with any datum to our validator script address, it will be difficult to keep track of which UTXO is our official one. That’s why we will be needing the “thread NFT”.

To initialize our state, we will first mint this thread NFT and we will make sure that this NFT is always included in our official UTXO containing the updated supply state of our token. This way, to get our official UTXO, we just need to find the UTXO where this thread NFT is currently locked. Then we read its datum to get the current supply.

threadTokenPolicy.hl

The first validator script we need is the one containing our minting policy for the thread token. This minting script only needs to make sure that it mints exactly 1 token only. One of the simplest and most widely described way to do this is to require a certain input UTXO to be used in the minting tx. Since a UTXO can be spent exactly one time only, we can be sure that there will only ever be one thread token minted under this policy.

Here it is as implemented in Helios language:

minting threadTokenPolicy

// Contract parameters
const seedTxId: TxId = TxId::new(#)
const seedTxIx: Int = 69
const outputId: TxOutputId = TxOutputId::new(seedTxId, seedTxIx)

func main(_, ctx: ScriptContext) -> Bool {
tx: Tx = ctx.tx;
nftAssetclass: AssetClass = AssetClass::new(
ctx.get_current_minting_policy_hash(),
"tokenSupplyState".encode_utf8()
);
valueMinted: Value = tx.minted;

(valueMinted == Value::new(nftAssetclass, 1)).trace("Only 1 thread NFT minted: ") &&

tx.inputs.any((input: TxInput) -> Bool {input.output_id == outputId})
.trace("Seed UTXO consumed in this tx: ")
}

Validator scripts in Cardano basically just returns either true or false when called. If it returns true, a transaction gets executed. Otherwise, it gets rejected. In Helios, the business logic is contained in the function called main. For a more thorough introduction to Helios, their documentation is a great place to get started.

In the above example, our minting script only requires 2 things:

  1. The first being that there should only be a quantity of 1 token minted under this policy.
  2. The second being that the specified UTXO should be among the input UTXOs consumed in the tx.

In Helios, this type of script is called a parameterized contract and we will be specifying as parameters the details of the required input UTXO when we run this script. We will do this later in the next part of this guide. For now, we just need to prepare these scripts.

supplyStateValidator.hl

The next validator script we need is that one that will hold/lock our thread token, along with the datum containing our supply state. Let’s call this validator script supplyStateValidator. And here is our example implementation in Helios:

spending supplyStateValidator

struct Datum {
refTokenAssetClass: AssetClass
userTokenAssetClass: AssetClass
refTokenCurrentSupply: Int
userTokenCurrentSupply: Int
func update(self, updatedRefTokenSupply: Int, updatedUserTokenSupply: Int) -> Datum {
self.copy(
refTokenCurrentSupply: updatedRefTokenSupply,
userTokenCurrentSupply: updatedUserTokenSupply
)
}
}

enum Redeemer {
Mint
Close
}

// Contract parameters
const adminPkh: PubKeyHash = PubKeyHash::new(#)
const threadTokenMPH: MintingPolicyHash = MintingPolicyHash::new(#)
const stateThreadToken: AssetClass = AssetClass::new(
threadTokenMPH, "tokenSupplyState".encode_utf8()
)
const maxSupply: Int = 21_000_000_000_000

func main(datum: Datum, redeemer: Redeemer, ctx: ScriptContext) -> Bool {
tx: Tx = ctx.tx;
validatorHash: ValidatorHash = ctx.get_current_validator_hash();
mintedRefToken: Int = tx.minted.get_safe(datum.refTokenAssetClass);
mintedUserToken: Int = tx.minted.get_safe(datum.userTokenAssetClass);
updatedRefTokenSupply: Int = datum.refTokenCurrentSupply + mintedRefToken;
updatedUserTokenSupply: Int = datum.userTokenCurrentSupply + mintedUserToken;
expectedNewDatum: Datum = datum.update(updatedRefTokenSupply, updatedUserTokenSupply);
isInline: Bool = true;

// check that the tx is signed by admin
tx.is_signed_by(adminPkh).trace("admin signed: ") &&

redeemer.switch {
Mint => {
// make sure don't exceed max supply rule:
(updatedRefTokenSupply <= 1).trace("CIP68 ref token limited to 1: ") &&
(updatedUserTokenSupply <= maxSupply).trace("max supply preserved: ") &&

// check that the new state UTxO contains the updated datum
// and contains the thread NFT in the value locked
(tx
.value_locked_by_datum(validatorHash, expectedNewDatum, isInline)
.contains( Value::new(stateThreadToken, 1) )
).trace("datum updated as expected: ")
},
Close => {
// closing allows the UTXO and value locked at this script to be spent away.
// only allow closing if supply goes down to zero and the state utxo is no longer needed.
(updatedUserTokenSupply == 0).trace("supply is now zero: ")
}
}
}

In this example, we have set the maxSupply to 21 million. And for every mint and burn transaction, an admin account needs to sign. By the way, a ‘burn’ transaction in Cardano is just a mint transaction with a negative amount. That’s why you don’t see a “Burn” case in there.

In case of a mint/burn, after making sure that an admin has signed the tx, the script proceeds to make sure that the max supply is not exceeded after the minting of new tokens. It also makes sure that there is only 1 CIP-68 reference token.

Then finally, it makes sure that the total supply is updated in the datum of the new UTXO created in each transaction, and that the thread token is contained in that new UTXO.

To let this validator script know the thread token to check, we will supply to it as a contract parameter, the minting policy hash of our official thread token. The way we specify an admin signer is also by supplying as parameter the public key hash of the admin’s wallet.

Since at this point, we don’t yet have the minting policy hash of our main token, we cannot specify that as a contract parameter for this script. But this script needs to be able to access that info so that it can check the amount minted/burned in each tx.

What we can do is store that information in the datum, together with the current supply amount that will continuously be updated. This way, the script will still be able to get the info of our main token so that it can check how much is being minted/burned each time.

mainTokenPolicy.hl

Lastly, we need the minting policy for our main tokens. That’s actually “tokens” with an ‘s’ even though we are really talking about only one token project here. That’s because as mentioned above, in the CIP-68 spec, each token needs to have a “reference” token which should be contained in a UTXO with a datum attached to it containing the token’s metadata. These two — the reference and the “user” tokens — need to be under the same policy.

Here’s our sample implementation:

minting mainTokenPolicy

// policy parameters
const supplyStateValidatorHash: ValidatorHash = ValidatorHash::new(#)
const adminPkh: PubKeyHash = PubKeyHash::new(#)
const refTokenName: ByteArray = #
const userTokenName: ByteArray = #
const threadTokenMPH: MintingPolicyHash = MintingPolicyHash::new(#)
const stateThreadToken: AssetClass = AssetClass::new(
threadTokenMPH, "tokenSupplyState".encode_utf8()
)

func main(_, ctx: ScriptContext) -> Bool {
tx: Tx = ctx.tx;
thisMPH: MintingPolicyHash = ctx.get_current_minting_policy_hash();
minted: Map[ByteArray]Int = tx.minted.get_policy(thisMPH);

// check that the tx is signed by admin
tx.is_signed_by(adminPkh).trace("admin signed: ") &&

// check that only our ref and user tokens are minted under this policy:
minted.all((tokenName: ByteArray, _) -> Bool {
tokenName == refTokenName || tokenName == userTokenName
}).trace("allowed tokenName/s: ") &&

// check that the stateThreadToken is included in the outputs
tx.value_locked_by(supplyStateValidatorHash)
.contains( Value::new(stateThreadToken, 1)).trace("stateThreadToken in outputs: ")
}

To make sure that this policy will only ever allow minting of our reference and user tokens, we will specify those token names as contract parameters.

To make sure that this policy will only ever work in tandem with our supplyStateValidator above, we need to supply it with the validator hash of supplyStateValidator as a parameter. It will use that info when doing its checks in the main function. Likewise with the MintingPolicyHash of our thread token.

We also specify among the parameters the PubKeyHash of our admin signer for good measure.

With those parameters available to this script, it can enforce the following requirements:

  1. That an admin signed the mint/burn tx;
  2. That only our CIP-68 reference and user tokens are minted under this policy;
  3. Lastly, that our official thread token is included in the tx output locked at our supplyStateValidator address every time.

When we initialize our state machine, our thread token will be locked at our supplyStateValidator address. Because of this, we can be sure that it can only be spent or moved to a new UTXO, if the conditions specified in our supplyStateValidator are met.

Up Next

With the above 3 validator scripts prepared, we are now ready to deploy them on-chain. In the next part of this guide, we will prepare the TypeScript files that we can run with NodeJs to compile these Helios validator scripts and deploy them to the Cardano chain.

--

--

Staking Rocks! [PHRCK]

Highly reliable stake pool and validator node operator for Cardano, Umbrella Oracle and World Mobile. https://staking.rocks | https://twitter.com/PHRCKpool