(Draft) Fungible token standard (zkApps)

GM,

I’ve been working on a fungible token standard that would leverage the custom token feature of zkapps. Goal of this post is to discuss the proposed features given what is reasonable using custom tokens. I’m looking for feedback in terms of feasibility & features.

Here’s the repo link: GitHub - stove-labs/mip-token-standard

Prerequisites

  • MIP zkApps, especially account updates, permissions and built-in token features

Reference implementation

The reference implementation of the features highlighted below is available as part of this repository, under /packages/token/src (develop branch). Tests can be found under /packages/token/test

Features

The token standard is designed in a modular fashion, also thanks to leveraging snarkyjs contract interoperability. As long as the contracts involved in the token standard ‘contract suite’ follow the predefined interfaces, the interoperability aspects of the token standard will be maintained. This allows for rich integrations with third party smart contracts, without the need to upgrade the token contracts themselves.

Token (owner) and Token Account

Mina L1 offers built-in custom tokens, where every account that holds a balance of a token is essentially a child account of the token contract. If Alice was a MINA account and we wanted to transfer our custom token to Alice’s address, we’d have to deploy/fund an additional account under the token contract itself. This additional token account would be deployed using a tokenId of the parent token (owner) contract.

Keep in mind that deploying/funding a new account is charged with the ‘default account creation fee’ which is 1 MINA at the time of writing. This means that to transfer a custom token to an address that has not received this specific token just yet, you need to pay 1 MINA to create that account.

Transferable

Transfers are an essential feature of every token standard, however in the case of snarkyjs and its account update based smart contracts, a simple transfer() method would not be sufficient. Each Mina L1 account can contain different set of permissions for receive and send, therefore the token contract cannot make any assumptions about the authorization required for both from and to accounts. Due to the aforementioned reasons, the Transferable interface is split into multiple methods, that allow the token contract and anyone interacting with it to issue account updates in a layout that suits their use case.

Interface

interface TransferOptions {
  from?: PublicKey;
  to?: PublicKey;
  amount: UInt64;
  mayUseToken?: MayUseToken;
}

class TransferFromToOptions extends Struct({
  from: PublicKey,
  to: PublicKey,
  amount: UInt64,
}) {}

interface Transferable {
  transferFromTo: (options: TransferFromToOptions) => FromToTransferReturn;

  transfer: (options: TransferOptions) => TransferReturn;
  transferFrom: (
    from: PublicKey,
    amount: UInt64,
    mayUseToken: MayUseToken
  ) => FromTransferReturn;
  transferTo: (
    to: PublicKey,
    amount: UInt64,
    mayUseToken: MayUseToken
  ) => ToTransferReturn;
}

Usage

Deciding which method to call is based on the underlying permissions of both the from and to accounts respectively.

All the available interface methods are wrapped under transfer(...), which decides which underlying implementation to call based
on the parameters provided.

Here’s a breakdown of what parameters should be used, depending on the account permission (send/receive proof/signature/none).

Keep in mind that balance changes in the account updates need to have a cumulative balance change of 0, in order to be approved. This ensures no tokens are minted or burned during transfers, more on approvals in a later section of this document.

:warning: from account permission is send and to account permission is receive.

Transfer between to ‘normal’ accounts:

// from: `signature`, to: `none`

// Mina.transaction
token.transfer({ from, to, amount });

Withdraw from zkApp:

// from: `proof`, to: `none`

// from
// zkApp -> issue a child account update to decrease its own balance, which can be proven
token.transfer({ from, amount });

// to
// Mina.transaction -> issue a top-level account update to increase the balance of the recipient
token.transfer({ to, amount });

Transfer between two zkApps:

// from: `proof`, to: `proof`

// from
// zkApp -> issue a child account update to decrease its own balance, which can be proven
token.transfer({ from, amount });

// to
// zkApp -> issue a child account update to increase its own balance, which can be proven
token.transfer({ to, amount });

Approvable

:rotating_light: This feature is currently blocked and only works by approving an ‘any’ account update layout, which isn’t secure. Further work is required in defining a set of approval methods universal enough to support various account update layouts.

Account updates to Token Accounts always require an approval from the Token (owner). In practice this means that any smart-contract call to a token account will need authorize it’s own account updates by e.g. proof or signature, and in addition these account updates will have to go through an approval from the Token (owner) contract.

A practical example would be a zkApp that holds a balance of a custom token. If you want to withdraw, which means manipulate the state of the token account in any way via an account update - this account update will need additional validation/approval from the Token (owner) contract.

This approval logic is built-in into zkApps out of the box, and custom tokens rely on it completely.

Interface

interface Approvable {
  approveTransfer: (from: AccountUpdate, to: AccountUpdate) => void;
  approveDeploy: (deploy: AccountUpdate) => void;
}

Usage

Transfer between to ‘normal’ accounts:

No approval required, as long as the account updates are issued and proven from the Token (owner) contract itself.

Withdraw from zkApp:

// from: `proof`, to: `none`

// to
// Mina.transaction -> issue a top-level account update to increase the balance of the recipient
const fromAccountUpdate = token.transfer({ to, amount });

// zkApp

// from
// zkApp's token account -> issue a child account update to decrease its own balance, which can be proven
token.transfer({ from, amount });

// zkApp, use the `self` account update of the zkApp's token account
token.approve(fromAccountUpdate, zkAppTokenAccount.self);

Transfer between two zkApps:

Same principles as in the prior case apply here, but the fromAccountUpdate has to be issued from the respective token account as well, in order to have the appropriate proof authorization.

Adminable (Mintable, Burnable, Pausable, Upgradable)

:rotating_light: Pausable isn’t implemented yet in the reference implementation. For fully pausable transfers, the pausable state must be taken into consideration during approve as well.

Adminable interfaces consist of multiple individual interfaces, such as: Mintable, Burnable, Pausable and Upgradable. Each of these interfaces can be implemented on its own, to allow for cases where certain tokens might not be e.g. mintable at all.

  • Mintable
    • Responsible for dealing with total & circulating supply as well as minting or updating the total supply itself.
  • Burnable
    • Responsible for enabling burning of tokens.
  • Pausable
    • Allows to pause token transfers.
  • Upgradable
    • Allows upgrading of the Token’s (owner) verification key.

Interface

interface Mintable {
  totalSupply: State<UInt64>;
  circulatingSupply: State<UInt64>;
  mint: (to: PublicKey, amount: UInt64) => AccountUpdate;
  setTotalSupply: (amount: UInt64) => void;
}

interface Burnable {
  burn: (from: PublicKey, amount: UInt64) => AccountUpdate;
}

interface Pausable {
  paused: State<Bool>;
  setPaused: (paused: Bool) => void;
}

interface Upgradable {
  setVerificationKey: (verificationKey: VerificationKey) => void;
}

Viewable

:rotating_light: There are no tests yet for viewable functions preconditions in the reference implementation.

The Viewable interface provides a set of non-method functions on the Token (owner) contract, which can be used to inspect state of the Token (owner) contract itself, or its Token accounts. Each viewable function offers an option to enable certain assertions, to support a case where the view function may be used in a third party smart contract.

Interface

interface Viewable {
  getAccountOf: (address: PublicKey) => ReturnType<typeof Account>;
  getBalanceOf: (address: PublicKey, options: ViewableOptions) => UInt64;
  getTotalSupply: (options: ViewableOptions) => UInt64;
  getCirculatingSupply: (options: ViewableOptions) => UInt64;
  getDecimals: () => UInt64;
  getPaused: (options: ViewableOptions) => Bool;
  getHooks: (options: ViewableOptions) => PublicKey;
}

Usage

// zkApp

@method runIfEnoughBalance(address: PublicKey) {
  const token = new Token(tokenAddress);

  // creates a precondition, that the balance of the given address
  // is the same as when this method was proven
  const balance = token.getBalanceOf(address);
  const minimalBalance = UInt64.from(1000);

  balance.assertGreaterThanOrEqual(minimalBalance);
}

Hookable

The Hookable interface provide a non-invasive way to extend the behavior of the Token’s implementation. Hooks can be used to intercept certain features, such as transfers or admin-ish actions. Hooks are implemented as a standalone contract, which is called by the Token (owner) contract during certain points of execution. The hooks contract is referenced by an address in the Token (owner) contract itself.

Interface

// for the Token (owner) contract
interface Hookable {
  hooks: State<PublicKey>;
}

// for the Hooks contract
interface Hooks {
  canAdmin: (action: AdminAction) => Bool;
  canTransfer: ({ from, to, amount }: TransferFromToOptions) => Bool;
}
6 Likes

Brilliant write-up @maht0rz! I’d like to gauge the community’s thoughts on how to communicate token metadata. For example, token standards in other ecosystems have a tokenURI method which returns a URL/string that resolves to a standardised JSON object containing information like a description, image(s), symbol.

2 Likes

That’d be a nice addition, we should also not forget about the zkapp URI which should contain a link to the actual implementation of the token thats being used. This will enable wallets to make transfers and generate concrete proofs for the given token.

2 Likes

@maht0rz really great to know that tokens are getting proper attention they deserve

I am facing trouble with initalizing a token in mina dapp.

For example, if I call the following function

like this

 await Mina.transaction(deployerAddress, () => {
  dexApp.supplyTokenX(UInt64.from(10));
});
  @method supplyTokenX(dx: UInt64) {
    let user = this.sender;
    let tokenX = new TokenContract(this.tokenX.getAndRequireEquals());
    tokenX.transfer(user, this.address, dx);
  }

It cancells transaction.

My current workaround is transfering zero tokens into a zk dApp

await Mina.transaction(deployerAddress, () => {
  AccountUpdate.fundNewAccount(deployerAddress);
  tokenX.transfer(deployerAddress, zkDexAppAddress, UInt64.zero);
});

Very keen on hearing advice or feedback from you

I am not sure if you have run tests here when the address balance is nothing. I am very curios if its just something wrong on my end.

There is a missing getBalanceOf in the token standard as well