Overcoming Mina L1’s Concurrency Limitations Using Token Accounts
Acknowledgment: Special thanks to DFST for first demonstrating this token account pattern. His approach and willingness to share knowledge is of great benefit to the Mina community.
Developers often arrive at Mina’s doorstep bright-eyed and eager, ready to shape a decentralised, zero-knowledge future. They bring with them the mental models of traditional blockchains — like Ethereum — where concepts such as concurrency and state management follow familiar patterns. In Mina, however, the approach is different and requires a shift in mindset. Instead of thinking in terms of constraints, we can view Mina’s unique architecture as an opportunity to explore new design patterns that unlock powerful possibilities, including off-chain computation and recursion for complex logic that would be prohibitively expensive elsewhere.
Success in this environment is more than achievable; it just requires learning to speak a new “language” of blockchain design. With perseverance, creative thinking, and plenty of coffee, you can embrace Mina’s distinct style and find fresh ways to achieve your goals. In this article, I’ll discuss the challenges developers often face when adapting to Mina’s paradigm and demonstrate how employing token accounts can elegantly address issues that initially seem daunting. Before diving into the solution, let’s first establish some fundamental concepts and reframe the way we think about building on Mina.
Accounts
To understand Mina’s limitations, it helps to grasp how accounts function. Let’s keep it simple and conceptual here.
A Mina account is primarily defined by a public key and a token ID. Most accounts use Mina’s native token ID, which is 1. If you send Mina to this account, it increases its balance. However, if you introduce a new token, say “WonkyDonkeyCoin” (WONK), the user needs a separate account linked to the same public key but with the token ID for WONK. In other words, each token held by a user requires its own account under the same public key.
Accounts store more than just balances. They also hold permissions, and a space reserved for smart contracts (zkApps). Here’s a simplified look under the hood at what an account contains:
When we deploy a zkApp (a Mina smart contract), the zkapp
field is populated. The process of deploying a zkApp involves generating verification and proving keys, with the verification key being stored on the account. This transforms a standard account where its state can be changed with proofs. Although the logic is executed off-chain, it produces a proof that, once verified on-chain, allows the account’s state to be safely updated.
A zkApp-enabled account zkapp field looks roughly like this:
The appState
here represents the on-chain state that your zkApp can read and modify.
Account Updates
When you send a transaction in Mina, you’re essentially broadcasting a set of “account updates” that request certain state changes. These updates might involve transferring tokens, updating Mina balances, or modifying the state of a zkApp.
To change a zkApp’s state, you must generate a proof consistent with the verification key associated with that account. The block producer checks if you have the correct permissions; if so, the state update is applied.
However, there’s a catch: account updates are computationally expensive for the network. As a result, there’s a limit on how many can be included in a single transaction. This restriction can quickly become a bottleneck when building complex applications that require multiple state updates.
Now lets talk about concurrency!
Concurrency
One of the most challenging limitations in developing zkApps is handling concurrency. To illustrate this, let’s look at a simple example from the docs, Add.ts
:
At first glance, this seems straightforward: each call to update
increments the stored number by two. The issue arises from the line:
This method sets a precondition on the transaction, ensuring that the on-chain state matches the expected state at the time the transaction was constructed. If it doesn’t then the block producer will reject this new transaction.
In practice, if two users try to call update
within the same block, the first transaction to be processed will succeed and change the state. However, this invalidates the second transaction’s precondition, causing it to fail. This concurrency problem can be a significant hindrance for applications that need simultaneous, multi-user interaction.
Considering Layer-1 vs. a Layer-2 App Chain
Before we dive into practical workarounds on Mina’s L1, it’s worth noting that there are other options. If you don’t need to build directly on L1, solutions like rollups can simplify your development experience. Projects like Protokit offer frameworks for creating zk-powered applications without dealing as directly with L1’s inherent limitations.
If L1 development isn’t a strict requirement, exploring these Layer-2 App-chains could save you a lot of effort. On the other hand, if building on L1 is essential for your use case, read on to see how we can leverage token accounts to tackle these issues.
Managing user state on L1
A common requirement for applications is to associate data with a particular user or public key. On EVM-based chains, this is straightforward: you define a struct, then create a mapping from an address to that struct, allowing direct and flexible access within your smart contract.
In Mina, it’s not so simple. There are no native mappings for state storage. The official Mina documentation suggests a pattern where you store mappings off-chain in a Merkle tree. You then keep only the tree’s root on-chain and enforce the interaction rules within your zkApp. While this approach works, it’s quite complex and not very friendly from a developer experience standpoint.
Moreover, Merkle trees don’t fully solve concurrency issues. You can only update the root once per block, so only one user can modify the map at a time. This severely limits throughput.
To address this, o1js offers an “action/reducer” model, where you can dispatch state updates as actions and then “reduce” them into a single state transition within one account update. However, this model has its own limitations. Please refer to the official docs for more details.
What happens, however, if you need to tie in an account update with a state transition?
As mentioned earlier, Mina enforces strict limits on the number of account updates you can include in a single transaction. Exceed these, and you’ll see an error like:
Each transaction requires proof work by the network’s snark workers. Complex layouts of account updates demand more proving time, making them “too expensive” to process. Even if you use the reducer pattern, each action that results in an account update (e.g., token transfers or mints) will quickly hit these throughput limits.
Token Accounts To The Rescue
To sidestep these issues, we can leverage Mina’s token account architecture. Instead of maintaining a complex shared state structure, we give each user their own token account, effectively making each user’s data its own mini-zkApp.
This approach helps in two ways:
Concurrency: Each user’s updates are confined to their own token account, so multiple users can update their states concurrently without invalidating each other’s transactions.
Account Update Limits: Because you’re not bunching all state changes into a single account, or transaction, each user interaction is atomic.
Example: A Counting App
Let’s consider a simple app that tracks a per-user count. Our requirements:
Each user has a unique count associated with their public key.
Users should be able to increment counts concurrently.
If a user’s count is divisible by 10, they receive a token payout from our “WONK” token.
The CountTracker Contract
To start with we can create a CountTracker contract. This is going to govern the state that we store from this pseudo-mapping on the users token account. In our case, it’s going to be simple count.
Count State: The
count
variable tracks the user’s current count.Incrementing the Count: Each call to
incrementCount()
retrieves the current count, adds the requested amount, and updates the state.Divisible by 10? It returns a Boolean indicating whether the new count is divisible by 10.
Notice there’s no deploy
method. Instead of deploying CountTracker
as a standalone zkApp, we’ll install it directly onto the user’s token account.
The CountOrchestrator Contract
Next, we create the main contract that orchestrates everything. It manages the token accounts for users, installs the CountTracker
into those accounts, and handles any further interaction with them including minting tokens when counts are divisible by 10.
Inheriting from
TokenContract
: By extendingTokenContract
, we gain the ability to create and manage token accounts linked to this orchestrator.Implementing
FungibleTokenAdminBase
: By implementing the admin functionality for the token contract, the orchestrator can manage the minting of our “WONK” token.State Fields: We store the
countTrackerVerificationKeyHash
(to ensure we’re installing the correctCountTracker
code) and aninteractionFlag
used later to govern token minting.
Blocking Unauthorised Updates
We want to ensure that all changes to user token accounts (like installing CountTracker
or updating their counts) go through the orchestrator’s authorised logic.
Initialising a User’s CountTracker
What we are doing here:
Verification Key Installation: We confirm that the
CountTracker
verification key matches our stored hash, ensuring we’re installing the correct zkApp code.Permissions and App State Setup: We give the token account just enough permissions to run the
CountTracker
logic and update its count field.State Initialisation: We manually initialise the first state field of the zkApp.
User Balance Setup: We call
getBalanceOf()
to create an Account for the users address and the token id of our payment token. This saves an account update later on.
Incrementing the Count
Again, any interaction with the CountTracker will happen through our orchestrator contract
Fetching the Tracker: We load the user’s specific
CountTracker
zkApp from their token account.Increment and Check: If the new count is divisible by 10, we mint tokens to reward the user.
Interaction Flag: By setting this flag, we ensure that the orchestrator’s logic governs when token mints are allowed. This is an implementation of this design pattern.
Finally we implement the token admin logi
Restricting Minting:
canMint()
checks theinteractionFlag
. Minting can only occur from theincrement()
method.Standard Admin Interface: The other functions simply implement the FungibleTokenAdminBase interface that enables the orchestrator contract to be the admin of our “WONK” token
The full code for this example can be found here.
Personally, I found all the token accounts involved a little confusing at first so here is a diagram to hopefully clarify it a little.
zkApps accounts: Although the
CountOrchestrator
andWONK
contracts are themselvesTokenContracts
they are actually accounts with a token id of 1. The difference here is that they have aderivedTokenId
that can be used for their own tokenUser accounts: Each user has their own accounts within our system, the
CountTracker
for holding the state of their count and theWONK
for holding any token they might have received from a payout
Drawbacks
The main drawback from this method is that is can be quite “expensive”, depending on your application. Each account that is created to store data or a token costs 1 mina to open. You will need to evaluate whether this is an acceptable cost for your users before using this model.
Wrap up
Building zkApps on Mina L1 can be a maze of complexity — especially when it comes to concurrency and state limitations. Yet, as we’ve seen, there are practical patterns to help you navigate these challenges. By leveraging token accounts to house individual user data and installing zkApps directly onto those accounts, we can enable concurrent interactions and streamline account update limits. With a bit of creativity and persistence we can figure out ways around the seemingly restrictive L1 constraints.