proto-kit / framework

Apache License 2.0
28 stars 10 forks source link

Add support for custom token bridging #202

Open rpanic opened 1 month ago

rpanic commented 1 month ago

This spec outlines the interactions and protocol that we use for bridging custom tokens (tokens that are not $Mina) to and from protokit appchains.

This work is based on the Mina-settlement. The spec for this, although very slightly outdated can be found here https://github.com/proto-kit/framework/issues/83 Also related work: https://github.com/MinaProtocol/MIPs/blob/main/MIPS/mip-0004-zkapps.md#token-mechanics

For custom token bridging we have the following components:

  1. A Custom token contract, that is the token owner of a custom token. It is deployed on the default token (Token Manager). It manages the bridged token bridged token with tokenId tokenId
  2. Our bridging contract(s), but we'll treat as one in this spec (Bridging contract)
  3. Custom token holders, on the same address as the bridging contract, that live on each custom token that's been bridged (Bridge Token Holder). It manages the withdrawal token with id withdrawTokenId.

General approach

The high level design of custom token bridging keeps the same rough flow as the native token bridging. But since token managers because part of the equation and we want to make the least amount of assumptions about the tokens themselves, the protocol still requires some adaptations.

Custom tokens are controlled entirely by the token owner, which will be a zkapp in most cases. However, even though a token standard exists, we don't want to lock in the exact circuit that these token owners operate on, instead we push the complexity of that to the users. For that, this protocol essentially decouples the token operations from the main appchains operation so that we can permit all forms of token owners to interact with protokit appchains without risking potential deadlocks or liveness attacks.

The already established protocol fortunately has properties that nicely go hand in hand with these requirements. Mainly, deposits are push-based and have to be processed eagerly, while withdrawals, since they can be processed permissionlessly, only have to be processed in-order in the first stage, and are pull-based in the second stage (more about that below at Withdrawals).

Bridge token holder

For every bridged token, the settlement contract has to deploy another contract to the respective custom token. This deployment happens to the same address and happens on every deposit. Authorization of bridge token holders is covered in the "Authorization" section

The responsibility of the bridge token holder is two fold:

  1. Mint withdrawal tokens in case of withdrawals on a custom tokens specific to each bridged custom token. As part of that, it also has to check that the mints have been authorized by the main settlement contract (since that is where settlement happens and therefore the withdrawal actions are dispatched)
  2. Be able to redeem those withdrawal tokens for the bridged tokens on behalf of the user

Deposits

For deposits, the user creates a AU on the bridging contract calling deposit(tokenid, amount). This AU will then be given to the token manager to approve it, therefore authorizing it's token operations.

flowchart TB
    AU1["`AU (token: 0)
    Token Manager .approveBase()`"]

    AU2["`AU (token: 0)
    Bridging contract .deposit(tokenId, 10)`"]

    AU3["`AU (token: tokenId)
    User account 
    balanceChange: -10`"]

    AU4["`AU (token: tokenId)
    Bridge token holder 
    balanceChange: +10`"]

    AU1 --> |"MayUseToken: Parent_owns_token"| AU2
    AU2 --> |"MayUseToken: Inherit_from_parent"| AU3
    AU2 --> |"MayUseToken: Inherit_from_parent"| AU4

Withdrawals

For withdrawals, let's look back at the original custom-token polling-based withdrawal mechanism we already implemented for $Mina. In a nutshell, the withdrawal has two phases, after settlement that includes a new batch of outgoing messages, we iterate through these messages and for all messages of type withdrawal, we mint the amount to the user in a custom token where the bridging contract is the token owner. In the second step, the user goes back to the bridging contract, burns the custom token and receives the burned amount in mina from the bridging contract.

This mechanism stays for custom tokens. Now however, we spin up a separate instance of that mechanism per token, where every instance only takes care of their own mints and redeems.

Account / contract structure

For a given instance with token id tokenId, the desired account structure looks like this

flowchart TB
    subgraph "tokenId: 0"
        Settlement["`Settlement Contract
        address: settlementPubKey`"]

        TokenOwner["Token owner for bridged token"]

        UserMinaBalances["User Mina balances"]
    end

    subgraph token1["tokenId: bridgedTokenId"]
        TokenSettlement["`Bridge Token Holder
        address: settlementPubKey`"]

        UserTokenBalances["User token balances"]
    end

    subgraph token2["tokenId: BridgeTokenHolder.tokenId"]
        UserWithdrawals["`Withdrawal token balances`"]
    end

    Settlement --> |"Deploys"| TokenSettlement

    TokenOwner --> |"Owns"| token1

    TokenSettlement --> |"Owns"| token2

Custom token withdrawals flow

Modified withdrawal map on the L2

Withdrawals are initiated on the L2 by putting the withdrawal's data into a StateMap, which means it will be part of the L2's state and therefore state root. However, with the introduction of custom tokens, we now track the counters and withdrawals separately for every token.

export class WithdrawalKey extends Struct({  
  index: Field,  
  tokenId: Field,  
}) {}

@runtimeModule()  
export class Withdrawals extends RuntimeModule {  
  @state() withdrawalCounters = StateMap.from(TokenId, Field);  

  @state() withdrawals = StateMap.from<WithdrawalKey, Withdrawal>(  
    WithdrawalKey,
    Withdrawal  
  );
}

A withdrawal is a struct of type

{  
  tokenId: Field,  
  address: PublicKey,  
  amount: UInt64,  
}

Step 1: Rollup of withdrawal actions + mint of withdrawal tokens

Push new state root to the bridge token holder After any settlement, anyone can invoke a function on the token bridge contract to pull the newest settled state root from the settlement contract on the default token.

Authorizing the new state root

Since the settlement happens on the settlement contract and not on the custom token, the contract on the token ledger has to somehow know that the state root is valid and indeed came from the settlement contract and not some other caller. Unfortunately we cannot make statements about the parent account update in the zkapps protocol, therefore we have to establish this backwards communication channel by using a child account update that we carefully craft to guarantee us the value of that state root.

The account update layout of this transaction looks like this

flowchart TB
    TokenManager["`TokenManager
    tokenId: 0`"]

    TokenContract["`Bridge Token Holder
    tokenId: customTokenId
    call: updateStateRoot(newRoot)`"]

    SettlementContract["`Settlement Contract
    tokenId: 0`"]

    TokenManager --> |"MayUseToken: Parent_owns_token"| TokenContract

    TokenContract --> |"precondition: state[0] == stateRoot"| SettlementContract

Iteratively mint withdrawal tokens

This happens pretty much the same as the native version of minting, with the difference that the two account updates (call of Bridge Token Manager + minting transaction) need to be approved by the Token Manager

flowchart TB
    TokenManager["`TokenManager
    tokenId: 0`"]

    TokenContract["`Bridge Token Holder
    tokenId: customTokenId
    call: mint()`"]

    Mint1["`address: publicKey1
    balanceChange +x`"]
    Mint2["`address: publicKey2
    balanceChange +y`"]
    MintX["..."]

    TokenManager --> |"MayUseToken: Parent_owns_token"| TokenContract

    TokenContract --> |"MayUseToken: Parent_owns_token"| Mint1
    TokenContract --> |"MayUseToken: Parent_owns_token"| Mint2
    TokenContract --> |"MayUseToken: Parent_owns_token"| MintX

The number of mints per transaction is determined by the limit that the zkapps protocol enforces, currently 9.

Step 2: Redeem of custom tokens

For the redeeming, two things have to happen atomically

  1. Burn of respective withdrawal tokens in the right amount
  2. Transfer of the custom tokens from the token holder contract to the user

The AU layout we are aiming for looks like this:

flowchart TB
    AU1["`AU (token: 0)
    Token Manager .approveBase()`"]

    AU2["`AU (token: tokenId)
    Bridging contract .redeem(tokenId, 10)`"]

    AU3["`AU (token: tokenId)
    User account balance +10`"]

    AU4["`AU (token: tokenId)
    Bridge token holder balance -10`"]

    AU5["`AU (token: bridging-tokenId)
    User account balance -10`"]

    AU1 --> |"MayUseToken: Parent_owns_token"| AU2
    AU2 --> |"MayUseToken: Inherit_from_parent"| AU3
    AU2 --> |"MayUseToken: Inherit_from_parent"| AU4
    AU2 --> |"MayUseToken: Parent_owns_tokens"| AU5

Thanks to @mrmr1993 for his help and input

rpanic commented 1 month ago

Authorization

In this design, there exist multiple communication channel from a parent contract to some child contract. In some instances, integrity of those calls has to be ensured since some action executed on the child contract might rely on some validation to happen on the parent contract.

This is the case in the following instances:

Unfortunately, the account update protocol doesn't give us tools to make statements about the parent account update in a given tree, therefore we have to use a workaround for that

Authorization protocol

To still accomplish this, the following protocol is implemented:

  1. The parent contract sets a "authorization" state field by updating it's account state
  2. The child account receives the data to be authorizated as calldata or witness, and creates and sets the account update in (3) as its child
  3. Another account update on the parent's account that has a precondition on the previously set "authorization" state field

Through that protocol, the child can be sure that a certain call has indeed originated at the parent with the exact data that the child receives.

Authorization data

The authorization data should be a hash of

This allows us to not reset the "authorization" state field in update (3), since the authorization can only be used once, saving us one proof. But: This assumes that the child zkapp implements a state transition $S1 \rightarrow{ST} S_2$ that has no valid transition $S2 \rightarrow{ST} S_3$, i.e. applying the same transition twice is impossible.