Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for custom token bridging #202

Open
rpanic opened this issue Sep 25, 2024 · 1 comment · May be fixed by #206
Open

Add support for custom token bridging #202

rpanic opened this issue Sep 25, 2024 · 1 comment · May be fixed by #206
Assignees

Comments

@rpanic
Copy link
Member

rpanic commented Sep 25, 2024

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 #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
Loading

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

Loading

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
Loading

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
Loading

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
Loading

Thanks to @mrmr1993 for his help and input

@rpanic rpanic self-assigned this Oct 2, 2024
@rpanic
Copy link
Member Author

rpanic commented Oct 11, 2024

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:

  • Whitelisting of token bridge deployments on the dispatch contract:
    Token deployments are initiated on the parent contract, but the dispatch contract needs to maintain a map of all deployed bridging contracts to control the validity of deposit recipients for custom tokens.
  • Message hash updates on the dispatch contract
    Since the incoming messages are tracked on the dispatch contract, after settlement, the settled and promised messages hash have to be updated on the dispatch contract. Since that relies on a valid settlement, a authorization has to occur

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

  • All data that is "trusted" by the child, and therefore has to be validated
  • Additionally, the target of the authorization, so that it can't be misused by any other contract

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 $S_1 \rightarrow_{ST} S_2$ that has no valid transition $S_2 \rightarrow_{ST} S_3$, i.e. applying the same transition twice is impossible.

@rpanic rpanic linked a pull request Oct 11, 2024 that will close this issue
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
Status: In Review
Development

Successfully merging a pull request may close this issue.

1 participant