# MINA zkApp staking pool :::warning A proof of concept implementation of a zkApp staking pool is available [here](https://github.com/garethtdavies/zkAppPool). It is unaudited and should never be used for the custody of actual funds. ::: Currently, when delegating to a block producer (aka validator) on MINA, the distribution of rewards is conducted off-chain, so block producers are responsible for calculating and distributing payouts according to their terms and conditions. Typical terms and conditions would include the fee percentage, the payout frequency, and perhaps the [payout algorithm used](https://github.com/jrwashburn/mina-pool-payout). While the delegator's funds are not at risk (you merely delegate the right to produce a block), a pool operator can steal any rewards in the worst-case scenario. The only recourse for a delegator would be to switch, which due to the latency involved in delegation transactions, would result in at least two epochs of rewards lost. [zkApps](https://minaprotocol.com/zkapps) are Mina's zero-knowledge powered smart contracts, built with [SnarkyJS](https://github.com/o1-labs/snarkyjs), a Typescript library. Using zkApps, we can reimagine a Mina staking pool whereby: * Anyone can force a payout from a pool. * The calculation of the rewards is verified. * Funds can only be moved from the smart contract via a valid zero-knowledge proof, so rewards cannot be stolen. * The payout calculation and fee charged are transparent. ## Design problem Mina's unique design with a [fixed blockchain size](https://minaprotocol.com/lightweight-blockchain) (so no on-chain history), with only the current state available and limited [on-chain state](https://docs.minaprotocol.com/zkapps/how-zkapps-work#on-chain-state) available for an account (8 fields of 32 bytes each) makes implementing a zkApp pool a challenge. As part of the [zkApp builders program](https://www.youtube.com/watch?v=maoA_5MgOA8&list=PLKIvwYrcKk8DdDgx5jBpEDoRyKWVP6EKM&index=12&t=1s), I researched and developed a proof of concept solution to the problem. :::success Since completing the zkApp builders program, there has been significant input to the project from [45930](https://github.com/45930). ::: The first approach to the problem was to develop a claiming-type solution that would be familiar to anyone who has interacted with a DeFi application. In this case, rewards would accrue to the smart contract (via the coinbase receiver) and be claimed by the user, who pays the transaction fee, signing the transaction to prove they own the address at their chosen time. Due to the limited nature of Mina's on-chain state, such rewards would need to be maintained [offline](https://docs.minaprotocol.com/zkapps/tutorials/offchain-storage), likely in a [Merkle tree/map](https://docs.minaprotocol.com/zkapps/tutorials/common-types-and-functions#merkle-trees) with only the Merkle root stored on-chain. When a user claims, after proving their account exists with the Merkle tree, they add a precondition to the transaction committing to the current root of the Merkle tree and update the off-chain storage and on-chain Merkle root to reflect the claim. While this is a standard implementation of many [other zkApps](https://github.com/o1-labs/snarkyjs/blob/main/src/examples/zkapps/voting/demo.ts), it fails to handle concurrent state updates, so if more than one user wants to modify the state in the same block (in this case updating the Merkle root after claiming), they are unable to do so. Only the first transaction will be successful, but the subsequent transactions in the same block will fail as their precondition to the Merkle root will no longer be valid. To overcome issues with multiple state updates in a single block, there are [actions/reducers](https://docs.minaprotocol.com/zkapps/advanced-snarkyjs/actions-and-reducer). Using this approach, pending actions, i.e., user claims in our example, can be rolled-up in a reducer step that processes all pending actions. While this solution is feasible, it introduces a lot of additional complexity, such as running a separate rollup process and is limited by the number of account updates that can fit in a single transaction (as the number of pending actions may exceed the number of account updates). For these reasons, I chose a batch payout approach for the design of the staking pool, albeit the claiming pool design likely merits further research. ## zkApp batch payout staking pool Currently, pool operators calculate and pay rewards at a frequency of their choosing, which range from multiple times a week, to more typically once per epoch. This results in a large number of payout transactions, which dominate the network activity, particularly at the start of the epoch. One advantage of zkApps is that a single transaction can contain multiple account updates (up to 10 if [signing the transaction](https://github.com/garethtdavies/zkAppPool/blob/main/batch-signed-tx.ts)). Using this approach for a zkApp staking pool, we can batch many payouts into a single zkApp transaction, greatly reducing the number of transactions required to pay all delegators. :::info Any zkApp transaction can be batched in this way, and the sending account does not need to be a zkApp. [Existing payout scripts](https://github.com/jrwashburn/mina-pool-payout) can be easily modified to make use of this, continuing to sign transaction payouts but batching them. ::: Assuming we want to replicate a payout frequency of once per epoch, we need a way to get the block data for the prior epoch to determine the payouts, as the consensus nodes only store the last 290 blocks (typically less than 24 hours). We can obtain this historical data from an [archive node](https://docs.minaprotocol.com/node-operators/archive-node). For this example, we use the [MinaExplorer GraphQL archive service](https://docs.minaexplorer.com/minaexplorer/graphql-getting-started), which provides a historical database in GraphQL format for all blocks. However, to verify that the data received is from MinaExplorer and of the correct form within the zkApp, we need to use an [oracle](https://docs.minaprotocol.com/zkapps/tutorials/oracle). ### Oracles [Oracles](https://docs.minaprotocol.com/zkapps/tutorials/oracle) are a way of getting off-chain data into a zkApp. The first version of oracles in MINA relies on the oracle to return signed data that can be verified from within the zkApp. In our case, the oracle is an [AWS lambda function](https://github.com/garethtdavies/zkAppPool/blob/main/lambda-payouts/index.ts) that retrieves historical block data from the MinaExplorer GraphQL API, calculates the payout (using a simplified algorithm for demonstration purposes, ignoring supercharged rewards), and then signs the resulting response. We can verify this signature from within the zkApp, so we know that the data is in the expected format and came from our trusted data source. :::warning We will need to trust that the oracle returns the correct data as the zkApp only checks the validity of the signature. ::: As we rely on the oracle for the payout data, we can also use it to enforce the correct ordering of payout transactions. In this way, we can avoid using an off-chain state solution yet ensure duplicate payouts cannot occur. The oracle returns an array of rewards containing the index, public key, and the calculated rewards in nanomina. For example: ```json { "index": "0", "publicKey": "B62qpJZYLwCjH5Hafi9YiCGGgVhuoq9j6A47MxJG3qzH3nzS3pZZcnn", "rewards": "2963600467869" } ``` The oracle also returns metadata, such as the current epoch evaluated, the rewards to pay the pool operator, the validator public key, and the resulting signature. For example: ```json { "rewards": [ { "index": "0", "publicKey": "B62qpJZYLwCjH5Hafi9YiCGGgVhuoq9j6A47MxJG3qzH3nzS3pZZcnn", "rewards": "2963600467869" } ... ], "epoch": "39", "feePayout": { "numDelegates": "60", "payout": "7920610176572" }, "validatorKey": "B62qjhiEXP45KEk8Fch4FnYJQ7UMMfiR3hq9ZeMUZ8ia3MbfEteSYDg", "publicKey": "B62qphyUJg3TjMKi74T2rF8Yer5rQjBr1UyEG7Wg9XEYAHjaSiSqFv1", "signature": { "r": "17349071890022458028560389267292716436763141143003649200774941774301130202252", "s": "13106682875493843319565706347346402322142429503289401379723236429620466111395" } } ``` In the smart contract, we need to pass a fixed-sized array (8 rewards in this example), so in the case where we do not have 8 remaining payouts, we fill the rewards array with [dummy data](https://github.com/garethtdavies/zkAppPool/blob/main/lambda-payouts/index.ts#L187) and specifically `PublicKey.empty()`. We sign this dummy data as we replicate it within the zkApp, and verify the resulting signature. A sample response from the oracle is [here](https://kodem6bg3gatbplrmoiy2sxnty0wfrhp.lambda-url.us-west-2.on.aws/?publicKey=B62qjhiEXP45KEk8Fch4FnYJQ7UMMfiR3hq9ZeMUZ8ia3MbfEteSYDg&epoch=39). It takes as [inputs](https://github.com/garethtdavies/zkAppPool/blob/main/pool-payout-zkapp/src/main.ts) the epoch, the index as well as the staking pool public key. The oracle will return data for any staking pool and epoch, but currently, data is only returned for mainnet. :::info Any pool operator can write their own oracle that implements a different payout algorithm or uses an archive service they control, so they do not need to trust a third party. ::: ### Smart Contract A working proof of concept implementation of the zkApp smart contract is available [here](https://github.com/garethtdavies/zkAppPool/blob/main/pool-payout-zkapp/src/PoolPayout.ts). The [main.ts](https://github.com/garethtdavies/zkAppPool/blob/main/pool-payout-zkapp/src/main.ts) script provides an example of how to interact with the smart contract. At a high level: * The user [requests data from the oracle](https://github.com/garethtdavies/zkAppPool/blob/main/pool-payout-zkapp/src/main.ts#L74). The oracle returns the signed data, and this data and signature are passed into the zkApp, where the signature is verified using the oracle public key stored on-chain - this ensures that the data matches our expectation and is provided by our trusted source. * The zkApp gets the current index and epoch from the on-chain state. It asserts that the epoch is the same as that on-chain. * The zkApp, assuming the signature verifies and epoch matches, creates account updates for each payout in a sequential order incrementing the index each time. * If the payout run has been completed, i.e., all payouts have been made and the current index = number of delegators in the pool, the block producer fee is calculated and added to the transaction as the final account update. * The transaction contains preconditions to the current state and updates the new state to the final index and epoch after all account updates have been processed. An [account update to pay the pool operator](https://github.com/garethtdavies/zkAppPool/blob/main/pool-payout-zkapp/src/PoolPayout.ts#L179) is always made in every transaction - it will just be 0 if it is not the final payout of the epoch. During this final payout of the epoch, we update the state to the next epoch (i.e., `current epoch + 1`) and reset the index to 0 such that we can then begin processing the next epoch once confirmed. In this way, we can sequentially process the payouts in a fixed order (determined by the oracle) while ensuring it is not possible to make the same payout multiple times. #### zkApp Permissions A key aspect of the functionality of the zkApp pool is that account updates can **only** be made via a valid proof. As we only have a single @method, namely `sendReward`, which generates a proof, this should be the only means to transfer funds from the pool. We make use of an [`init` method](https://github.com/o1-labs/snarkyjs/pull/543) to deploy the contract. Doing so means that the state cannot be reinitialized, even if redeploying the contract. ```typescript const INITIAL_EPOCH = 39 const INITIAL_INDEX = 0 const VALIDATOR_FEE = 5 //5% const ORACLE_PUBLIC_KEY = "B62..." init() { super.init() this.setPermissions({ ...Permissions.default(), editState: Permissions.proof(), send: Permissions.proof(), setVerificationKey: Permissions.proof(), ... }); this.currentEpoch.set(Field(INITIAL_EPOCH)); this.currentIndex.set(Field(INITIAL_INDEX)); this.feePercentage.set(UInt32.from(VALIDATOR_FEE)); this.oraclePublicKey.set(PublicKey.fromBase58(ORACLE_PUBLIC_KEY)); this.validatorPublicKey.set(PublicKey.fromBase58(VALIDATOR_PUBLIC_KEY)); } ``` The above snippet has been shortened for brevity, but all permissions are set to `Permissions.proof()`, preventing even a valid signature from either updating the state, redeploying the contract or sending funds. It should be noted in the case of an issue with the contract or funds mistakenly sent to the contract, there would be no way to recover them. The deployment must contain a starting point for the zkApp staking pool calculations, which are stored as on-chain state variables. #### On-chain state variables As previously noted, we have a very limited amount of on-chain state to work with at just 8 fields of 32 bytes. As a result, we store: * Index - required to enforce order (1 field). * Epoch - required to enforce the correct epoch is evaluated (1 field). * Oracle public key - to verify the oracle signature from within the zkApp (2 fields). * Validator public key - to enforce the oracle data contains data from the correct staking pool (2 fields). * Fee percentage (optional) - can publically store the fee percentage charged by the pool, which is used in the zkApp payout calculation (1 field). * Validator payout address (optional but can't be used with fee percentage) - can specify an address to send the pool rewards to, if different from pool address (2 fields). ### Bulk payouts We have already seen that we can fit 8 account updates in a single transaction, but we can also fit many such transactions in a single block. When generating the transaction, we need to know the final index of the prior transaction (essentially, `current index + 8` until the epoch is completed) so that we can correctly set the index precondition. As a result, we do not have to wait for the on-chain state to be updated after a block has been received. :::info To allow for multiple transactions in a block, we do not assert in the zkApp that the current index matches the on-chain state. Therefore it is possible to create a proof with an incorrect index. However, this transaction will fail when the transaction is included in a block as we set a precondition on the index. ::: With this design, it would be possible, with Mina's current block size, to fit close to 1000 account updates in a single block (assuming the block was full only with these transactions). This approach should greatly ease the time taken to complete payout runs for larger pools and reduce the number of transactions on mainnet associated with payout transactions by approximately an order of magnitude. ### Permissionless payouts One primary advantage of such a system is that **anyone** can complete a payout, as we only need to generate a valid proof. It is not expected this would be the normal course of operation, as there is some overhead in generating the transactions (you need to construct the proofs), plus paying the cost of transaction fees. However, if a pool operator fails to payout within a reasonable timeframe, anyone can force the payouts. In the worst case, if multiple users were to try to complete payouts at the same time or attempt to interfere with existing payouts, only one would complete, and the other(s) transactions would fail based on their outdated preconditions. ## Issues / Further work The solution still relies on the pool operator setting the coinbase receiver to the zkApp (while they could also use the block producer address itself, it would be a bad practice). To that extent, a pool operator could still steal rewards by simply modifying the coinbase receiver to point away from the zkApp. While it would be transparent, as on-chain, likely a method to tie the coinbase receiver address to the block producer address is required. The current solution is heavily reliant on the oracle and, as a result, would be subject to potential [oracle attacks](https://en.wikipedia.org/wiki/Oracle_attack). To add resilancy to the zkApp, multiple oracles could be used, with their signatures verified within the zkApp (ignoring potential issues with the size of the resulting circuit). Multiple oracles would need to agree on a canonical order of payout transactions, but likely this could be easily achieved, e.g., via an alphabetic sorting of public keys. To prevent payouts being made before the epoch has been completed, or before a reasonable number of block confirmations (to prevent against chain reorgs) we can make use of [network preconditions](https://docs.minaprotocol.com/zkapps/advanced-snarkyjs/on-chain-values#network-reference). In the following example we ensure that the transaction is only valid after 1000 slots of the following epoch are completed. For example, if payouts are made for epoch 44, the zkApp transaction is only valid after slot 1000 of epoch 45. While the choice of 1000 slots is arbitrary, it would provide a high probability of finality (290 blocks). This snippet is currently not included in the Berkeley implementation as it is not possible to test a realistic scenario on this network due to frequent restarts of the test network and the latencies involved with making new delegations. ```typescript let minimumSlotNumber = epoch.add(1).mul(7140).add(1000); this.network.globalSlotSinceGenesis.assertBetween(UInt32.from(minimumSlotNumber), UInt32.from(4294967295)); ``` Substantial improvements can be made to how a user interacts with the contract (currently only via [`main.ts`](https://github.com/garethtdavies/zkAppPool/blob/main/pool-payout-zkapp/src/main.ts)) to simplify the process of sending the payouts.