stove-labs / mip-token-standard

Apache License 2.0
2 stars 2 forks source link

Token standard definition #2

Open maht0rz opened 1 year ago

maht0rz commented 1 year ago

This Issue contains a WIP token standard definition for the Mina L1, built with snarkyjs and based on the currently available smart contract features such as custom tokens. As a result of this discussion, we plan to propose a fully featured MIP.

Prerequisites

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.

⚠️ 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

🚨 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)

🚨 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.

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

🚨 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;
}
maht0rz commented 1 year ago

Additional discussion:

mitschabaude commented 1 year ago

Started by reading Transferable. Sounds reasonable! I'd suggest to tweak one aspect:

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

This sounds like the zkApp would issue a separate account update instead of using this.self for decreasing the balance. To enable using this.self, I would propose the following flexible interface:

token.transfer(options: { from: PublicKey | AccountUpdate | SmartContract, amount: UInt64 })

if an account update is passed, then we decrease the balance on it. if a SmartContract is passed, then we call .self on it to get its account update and decrease the balance on that. so, in a zkApp we could use:

token.transfer({ from: this, amount })

EDIT: everything I wrote applies to to and receive permissions as well of course

maht0rz commented 1 year ago

Started by reading Transferable. Sounds reasonable! I'd suggest to tweak one aspect:

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

This sounds like the zkApp would issue a separate account update instead of using this.self for decreasing the balance. To enable using this.self, I would propose the following flexible interface:

token.transfer(options: { from: PublicKey | AccountUpdate | SmartContract, amount: UInt64 })

if an account update is passed, then we decrease the balance on it. if a SmartContract is passed, then we call .self on it to get its account update and decrease the balance on that. so, in a zkApp we could use:

token.transfer({ from: this, amount })

Thanks gregor i had an implementation earlier where i've passed SmartContract as from/to, and its definitely better since there's less AUs in the end. I'll update the interface and implementation accordingly. I think this may help with designing the transfer approval as well - but not much.

mitschabaude commented 1 year ago

After discussing a bit more with @maht0rz, I think the token owner interface for transfers (Transferable) is sufficient and handles all cases in the only way that's feasible.

It will need to be augmented with the right coding patterns in third party contracts that interact with multiple token holders which can either have proof or non-proof permissions for send/receive. Example: https://github.com/stove-labs/mip-token-standard/blob/develop/packages/token/test/ThirdParty.ts#L23

It's unfortunate that this complexity is pushed upwards but I don't think there's a different way that can handle arbitrary token holder accounts.

From my perspective, what this is mainly blocked on at this point is snarkyjs' current lack of general-purpose approval methods (see https://github.com/o1-labs/snarkyjs/issues/706, and more even advanced approval methods using recursion would be ideal)

maht0rz commented 1 year ago

@iam-dev brought up the question of configurability and/or composability of token standard features. I've attempted to implement the individual features in a composable manner, but decided not to pursue it further due to issues with inheritance in SnarkyJS SmartContracts. You can find the attempts and the resulting mixins in an older commit of this repository: https://github.com/stove-labs/mip-token-standard/tree/825d2195264b0156776afb1f1bbd5db585ece044/packages/token/src/mixins

Other than that, you could cherry pick only the features you want from the current monolithic implementation by removing the unwanted methods and their metadata from the Token class, which should be fairly straightforward to do.

ycryptx commented 1 year ago

Did any thinking go into not building the token standard using custom tokens? I can see the benefit of coming up with a standard that composes on top of what's already available natively, but the token account model enforced by custom tokens introduces significant problems:

As mentioned in the proposal, when using custom tokens we have to fund an additional account in order to transfer tokens to a new user. This significantly deviates with how tokens work on other chains. For non-fungiables, custom tokens create a more costly and cumbersome minting experience. For fungiables, custom tokens make the ability to airdrop tokens non-trivial. It seems that the end result is that this token standard will have a harder time scaling to more users than a token standard not based on the native custom tokens.

maht0rz commented 1 year ago

@ycryptx aidroping would incur storage costs if the account ledger is tracked in e.g. a merkle tree anyways, this is handled by the protocol with an account creation fee instead. You can always build your airdrop on a claim-basis, where the end user would have to pay to claim the token (rather than the airdrop issuer).

Anyways the token standard described here is a fungible one, so it does not aim to address the NFT airdrop concerns you've mentioned.

If we wanted to build a token standard with non-built-in tokens, there'd be a whole another plethora of issues to deal with - e.g. concurrency.