Unlimited dApp script complexity
This article explains how to execute complex scripts that are not limited by “gas”, distributing the script’s execution across several blocks.
One of the things blockchain people are good at is explaining why user restrictions and inconveniences are insignificant and are actually the solutions rather than the problems. Seed phrases, instead of being risky and hard to manage, become “staying in control” or even “easy to remember”. Every DeFi hack, essentially a loss of funds, is a “valuable lesson” (which is sometimes as lame as teaching entrepreneurs to write tests for their code). Low throughput “drives competitive fee rates”. As such, gas limits during script execution “ensure the stability of the network”.
Let’s focus on the last one here. Of course, the limits per blockchain transaction have to exist, otherwise the network could be stalled with an infinite-loop script: all network nodes start evaluating the transaction and never stop. The simplest solution is just to cap the amount of operations processed by one script, but what if your business transaction requires more than that?
The old way: Manually splitting a business transaction into blockchain transactions
Surely you can alway manually slice your business transaction into multiple blockchain transactions. Just like the random infamous airdrop payout
function, instead of just expressing your idea via
Table table = …function payout(){
table.foreach {
(key, value) => table.update(key, value * 2)
}
}
you’ll implement partial payout
function
Table table = …function payout(addresses: List[Address]){
addresses.foreach {
address => table.update(address, table.get(address) * 2)
}
}
and then handle all misuses of it, like
- Denying invoking payout for the same address multiple times
- Ensuring no other routines intertwine with this business transaction
- Ensuring every single address is processed
All of this is required to follow the ACID principles(though sometimes not explicitly formulated as requirements).
The proposed way: “Under-the-hood”
The idea of Ride Continuations is to tackle exactly that class of problems: business transactions should be split into blockchain transactions automatically. Meaning, not manually by the developer in code, but by the execution environment of the blockchain.
The idea is simple: if a computation can be frozen/unfrozen, one can invoke a business transaction and observe its execution over multiple transactions and blocks. That’s what Continuations are for.
This means splitting a large execution into several smaller steps. The first step, the InvokeScriptTrasaction
, starts the execution. When the execution limits are exhausted, the computation gets frozen (imagine serialising an application’s memory, stack, and instruction line).
To continue execution, all that is needed is to deserialise the frozen state and continue from that point, until the execution limits of the blockchain transaction are exhausted — exactly what the ContinuationTransaction
transaction does.
What are the contents of ContinuationTransaction?
The ContinuationTransaction
only consists of InvocationTransaction id, for which execution is continued. There’s no sender, signature or any other familiar fields. They are not necessary, and here’s why:
The ContinuationTransaction
is of a special type: they are not broadcast through the UTX pool. Whenever a miner sees an unfinished computation in the blockchain’s state, he puts a continuation transaction in his (micro)block — the user who initiated a large business transaction doesn’t need to handle the blockchain transactions required. Upon execution of each ContinuationTransaction
, the miner charges the invoker for the work done by the network.
Parallelism and concurrency
The blockchain doesn’t need to “freeze” during heavy operation: other dApps can run concurrently just fine:
Example for Waves
Let’s say you want to verify 100 of 150 multi-signatures (2/3 rule on a big vote):
The execution for such an operation obviously costs more than 4,000 complexity (today’s maximum per blockchain transaction). Therefore, you’d have to split this into batches (e.g., 10 sigs each and then wrap-up), perhaps like this:
let pubKeys = [
[...],
[...],
...,
[...] ]@Callable(i)
func verifyBatch(data: ByteVector, batchId: Int, sigs: List[ByteVector]) = {
let correctSignaturesInBatch = ...
WriteSet([DataEntry(
data.toBase58String() + "|" + batchId.toString(),
correctSignaturesInBatch)
])
}@Callable(i)
func verifyAll(data: ByteVector) = {
let correctSignaturesPerBatch = ...
let totalCorrectSignatures = ...
let passed = totalCorrectSignatures > 100
WriteSet([DataEntry(
"vote result for " + data.toBase50String(),
passed)
])
}
Note the steps I had to code here:
- Split the keys into batches, handling batchIds
- Keep track of intermediate results
And the steps that are not shown:
- Actual signature validations
- Verify batchId argument is correct
- Reading batch data
- Validation of the caller
- Off-chain mechanics
On the contrary, how I’d do that with Continuations:
let pubKeys = [...]
@Callable(i)
func vote(data: ByteVector, sigs: List[ByteVector]) = {
let totalCorrectSignatures = FOLD<150>(..., 0, ...)
let passed = totalCorrectSignatures > 100
WriteSet([DataEntry(
"vote result for " + data.toBase50String(),
passed)
])
After that, all I need to do is to broadcast one InvokeScriptTransaction
. The blockchain will understand that the complexity of the vote function extends 4,000 (due to the highlighted line), split the execution into batches, and forge the ContinuationTransaction
s for me until the result is there:
— InvokeScriptTransaction(..., function:vote, args: […])
— ContinuationTransaction(id)
— ContinuationTransaction(id)
— ContinuationTransaction(id)
— ContinuationTransaction(id)
…
— ContinuationTransaction(id) // the final tx will record
// the result to the blockchain
And that’s it. Although there’s still a limit of 4,000 complexity per blockchain transaction, I can write code for more complex business transactions.
What does it mean for (Waves) Blockchain?
Smart contract scripts don’t need to be restricted by a “gas limit” or “complexity limit”. This approach therefore enables more complex scenarios to be implemented in decentralised applications. Building complex applications requires complex building blocks and higher abstractions — the exact thing that Continuations
can enable.
How to try it out?
A PoC is available in the Waves repository. If you’re good with scala code, please refer to the ContinuationSuite
test. Still, there are a lot of things to polish — miner algorithms, Ride syntax, fee mechanics and other tweaks.
TL;DR: Ride Continuations enable scripts that are unlimited by any “gas limit” or complexity. Large computations are spread across multiple transactions and blocks, but still maintain Atomicity, Consistency and Isolation of business transactions.