Author: Figo, IOSG Ventures
Imperative vs. Declarative Programming: A Shift in Paradigm
In the blockchain world, programming paradigms define how developers interact with the technology and how users experience the outcomes. Let’s break down the key differences between the traditional imperative approach and the emerging declarative approach, using a relatable analogy — ordering a custom coffee at your favorite café.
Imperative Programming: Following a Recipe
Imagine walking into a café and giving the barista a detailed list of steps to make your coffee — “Grind 20g of beans, use 200ml of water at 90°C, brew for 4 minutes, pour into a ceramic mug.” You’re micromanaging the process, ensuring that every step is followed precisely to get the coffee you want.
This is how imperative programming works in blockchains. Developers write smart contracts that dictate every step of the process, from fetching data to executing transactions. While this ensures that the process is followed as intended, it can be rigid and inefficient, especially if unexpected conditions arise.
Declarative Programming: Defining the Outcome
Now, imagine you walk into the same café and simply say, “I’d like a medium-strong coffee, no sugar, in a ceramic mug.” The barista knows what you want and can choose the best way to make it, whether that’s using a different brewing method or adjusting the grind size based on the beans available.
This is the essence of declarative programming. Instead of specifying every step, you define the desired outcome, and the system figures out the best way to achieve it. This approach is more flexible, allowing for adjustments based on current conditions and user preferences.
Here’s a table to highlight the key differences:
Why Intents for Blockchain Applications?
Understanding Intents
In a traditional blockchain setup, you would be giving step-by-step instructions to achieve a particular state change. But what if you could just specify the end goal and let the system figure out the best way to get there? That’s the idea behind intents in blockchain — a user declares what they want, and the system handles the rest.
For example, instead of telling the blockchain to “sell 1 ETH for the best available price,” a user might declare an intent to “get at least 2500 USDT in exchange for my 1 ETH.” The system then finds the best way to fulfill this request, potentially across multiple exchanges or liquidity pools.
Challenges with Imperative Blockchains
The rigidity of imperative programming often leads to several issues:
- Uncertain Outcomes: Users often don’t know the result of a transaction until it’s confirmed, which can be stressful and risky.
- Market Inefficiencies: Imperative execution can bottleneck when there’s high demand for specific outcomes, like in DeFi trading.
- Security Risks: More detailed steps mean more potential points of failure, increasing the risk of bugs and exploits.
These problems are particularly prevalent in environments like DeFi, where users face issues like failed transactions, high slippage, and exposure to MEV (Miner Extractable Value).
The Rise of Declarative Blockchains
Declarative blockchains like Essential aim to address these issues by focusing on outcomes rather than processes. By defining what the end state should be and using intents to achieve it, these systems offer several advantages:
- Predictable Outcomes: Users can trust that the blockchain will deliver the desired result.
- Improved Security: Fewer steps mean fewer chances for things to go wrong.
- Scalability: By optimizing for outcomes, declarative blockchains can handle more complex operations without requiring significantly more computational resources.
An Intent-Centric Language
As intent-centric programming gains traction in the blockchain space, languages like Pint (developed by Essential) and Juvix (by Anoma) have emerged to help developers focus on outcomes rather than specific steps. These languages offer a new way to approach blockchain development, prioritizing the desired state of the system over the procedural instructions to get there.
In this section, we’ll delve into Pint, the language behind Essential’s declarative blockchain, to explore how it operates and what sets it apart from what we are used to.
Pint is a specialized constraint modeling language designed specifically for Essential’s intent-centric blockchain. Unlike traditional imperative smart contract languages, Pint emphasizes defining the desired outcomes of state changes without specifying the exact steps to achieve those outcomes. This shift in focus allows for more flexibility, security, and efficiency in how blockchain applications are developed and executed.
What Makes Pint Different?
Pint offers a unique way of handling blockchain logic by concentrating on constraints and predicates instead of traditional execution models. Here’s how it works:
- Contracts: In Pint, contracts define the rules for updating blockchain state. Unlike traditional contracts, these don’t specify how to execute changes; instead, they validate whether a proposed state change meets certain criteria.
- Predicates: Predicates are conditions that need to be met for a state change to be valid. They serve as filters that determine whether a particular state transition can occur.
- Constraints: Constraints are the building blocks of predicates. These Boolean expressions must evaluate to True for a predicate to pass. For instance, a constraint might require that a counter is incremented by exactly one, ensuring consistency in state updates.
- State: State variables represent the values on the blockchain. Unlike traditional imperative languages where developers write some execution that is applied to the pre-state in order to determine the post-state, Pint provides both the pre-state and post-state as inputs. For a given declaration state foo, foo refers to the pre-state, while foo' refers to the post-state.
- Decision Variables: Decision variables allow solvers to provide extra data that may be required to satisfy a predicate’s constraints. For example, a token contract’s transfer predicate may require a valid signature.
A First Look at Pint: Simple Counter Example
To better understand how Pint works, let’s walk through a simple example — a counter contract.
storage {
counter: int,
}
predicate Increment {
state counter: int = storage::counter; // Read the current value of the counter
constraint (counter == nil && counter' == 1) || counter' == counter + 1;
// The constraint ensures that if the counter is not set, it starts at 1.
// Otherwise, it increments the current value by 1.
}
Storage Block:
- The storage block defines a single integer variable counter that will be stored on the blockchain. This is where the current value of the counter is kept.
Predicate Increment:
- The predicate Increment defines the logic for incrementing the counter.
- The line state counter: int = storage::counter; reads the current value of the counter from storage.
- The constraint (counter == nil && counter' == 1) || counter' == counter + 1; ensures that if the counter hasn’t been initialized (i.e., it’s nil), it starts at 1. Otherwise, the counter is incremented by 1.
To put it simply, this contract says, “If the counter doesn’t exist yet, start it at 1. If it does exist, add 1 to whatever the current value is.” This example illustrates how Pint allows developers to define the desired outcome (incrementing the counter) without needing to specify each step (checking the counter, adding to it, etc.).
Building a Simple Cryptocurrency from Scratch
Now how about a more complex example? Let’s take a step-by-step approach to see how we can build a basic cryptocurrency using Pint, starting from first principles. Imagine we’re tasked with creating a digital currency system. What do we need?
Step 1: Define the Core Components
First, we need to decide on the fundamental elements of our cryptocurrency. At the most basic level, we need:
- A record of the total supply of the currency: This keeps track of how many units of the currency exist.
- A way to track balances: We need to know how much currency each account holds.
In Pint, we can define these using a storage block:
storage {
total_supply: int,
balances: (b256 => int),
}
- total_supply: This integer keeps track of the total amount of currency.
- balances: This is a map (or dictionary) that associates each address (represented by b256, a 256-bit hash) with its corresponding balance.
Step 2: Create New Currency
Next, we need a way to create new currency — also known as minting. When minting currency, we want to increase the total supply and update the balance of the recipient.
Let’s think through the steps:
- We need to specify who will receive the newly minted currency and how much they will receive.
- We then need to update the total supply and the recipient’s balance accordingly.
Here’s how this logic looks in Pint:
predicate Mint {
var receiver: b256;
var amount: int;
state receiver_balance = mut storage::balances[receiver];
state total_supply = mut storage::total_supply;
constraint total_supply' == total_supply + amount;
constraint receiver_balance' == receiver_balance + amount;
}
Breaking it Down:
- Decision Variables: receiver and amount represent the recipient’s address and the amount of currency to mint.
- State Variables: We reference the state of total_supply and receiver_balance, declaring them mutable (mut), meaning they can be updated.
- Constraints:
— The first constraint ensures that the total supply increases by the minted amount.
— The second constraint ensures the recipient’s balance is updated by the minted amount.
However, it’s important to note that this example is simplified for understanding. The contract, as written, has no authentication built in. This means anyone could mint new coins or transfer them from any account to another. In a real-world scenario, this lack of security would be a significant issue. In later sections, we’ll explore how to add authentication to ensure that only authorized users can mint or transfer currency, making the system more secure.
Step 3: Transfer Currency Between Users
Now that we can create currency, the next step is to enable users to send currency to each other. For this, we need to:
- Check that the sender has enough currency to send.
- Deduct the amount from the sender’s balance.
- Add the amount to the recipient’s balance.
Here’s the corresponding Pint code:
predicate Send {
var from: b256;
var receiver: b256;
var amount: int;
state from_balance = mut storage::balances[from];
state receiver_balance = mut storage::balances[receiver];
constraint amount < from_balance;
constraint from_balance' == from_balance - amount;
constraint receiver_balance' == receiver_balance + amount;
}
Breaking it Down:
- Decision Variables: from, receiver, and amount specify who is sending the currency, who is receiving it, and how much is being sent.
- State Variables: from_balance and receiver_balance are the current balances of the sender and recipient, which are mutable.
- Constraints:
— The first constraint checks that the sender has enough balance to cover the transfer.
— The second constraint deducts the amount from the sender’s balance.
— The third constraint adds the amount to the recipient’s balance.
Again, the simplicity here omits authentication, which in practice would be essential to prevent unauthorized transfers. Adding such security layers would involve more complex predicates that verify a user’s identity before allowing transactions.
Here’s the full contract, with both minting and transfer functionality combined:
storage {
total_supply: int,
balances: (b256 => int),
}
predicate Mint {
var receiver: b256;
var amount: int;
state receiver_balance = mut storage::balances[receiver];
state total_supply = mut storage::total_supply;
constraint total_supply' == total_supply + amount;
constraint receiver_balance' == receiver_balance + amount;
}
predicate Send {
var from: b256;
var receiver: b256;
var amount: int;
state from_balance = mut storage::balances[from];
state receiver_balance = mut storage::balances[receiver];
constraint amount < from_balance;
constraint from_balance' == from_balance - amount;
constraint receiver_balance' == receiver_balance + amount;
}
This subcurrency contract provides a foundational framework for a basic cryptocurrency, illustrating how intent-centric programming simplifies the process by focusing on the desired outcomes of each operation.
Building an NFT Contract
Now that we’ve built a basic cryptocurrency, let’s explore a more complex and nuanced application: managing NFTs (Non-Fungible Tokens). Unlike fungible tokens, where each unit is identical, NFTs represent unique assets, requiring a more sophisticated contract design.
Here’s how you could structure an NFT system in Pint:
use std::lib::PredicateAddress;
use std::auth::@auth;
use std::lib::@safe_increment;
use std::lib::@mut_keys;
storage {
owners: (int => b256),
nonce: (b256 => int),
}
interface Auth {
predicate Predicate {
// The address that the authorization predicate is outputting.
// This points the authorization predicate to a predicate in this set.
// By setting this address the authorization can't be used with the wrong predicate.
pub var addr: { contract: b256, addr: b256 };
}
}
predicate Mint {
var token: int;
var new_owner: b256;
state owner = mut storage::owners[token];
constraint owner == nil;
constraint owner' == new_owner;
}
predicate Transfer {
// The address that the amount is being sent from.
pub var key: b256;
// The address that the amount is being sent to.
pub var to: b256;
// The token being transferred.
pub var token: int;
state owner = mut storage::owners[token];
state nonce = mut storage::nonce[key];
constraint owner == key;
constraint owner' == to;
constraint @safe_increment(nonce);
// Check the authorization predicate.
var auth_addr: PredicateAddress;
interface AuthI = Auth(auth_addr.contract);
predicate A = AuthI::Predicate(auth_addr.addr);
@auth(key; A::addr; auth_addr; @transfer());
}
predicate Cancel {
// The account that is cancelling a transfer or burn.
pub var key: b256;
state nonce = mut storage::nonce[key];
// Increment the nonce so that any pending transfers or
// burns are invalidated.
constraint @safe_increment(nonce);
// Check the authorization predicate.
var auth_addr: PredicateAddress;
interface AuthI = Auth(auth_addr.contract);
predicate A = AuthI::Predicate(auth_addr.addr);
@auth(key; A::addr; auth_addr; @cancel());
}
macro @transfer() { { contract: signed::ADDRESS, addr: signed::Transfer::ADDRESS } }
macro @cancel() { { contract: signed::ADDRESS, addr: signed::Cancel::ADDRESS } }
Storage Block:
- owners: Maps token IDs to their respective owners.
- nonce: Tracks nonces for each account to ensure that transactions are unique and cannot be replayed.
Predicate Mint:
- Mints a new NFT by assigning a token ID to a new owner.
- The constraint ensures that the token ID has not been assigned before (owner == nil) and then assigns it to the new_owner.
Predicate Transfer:
- Manages the transfer of NFTs from one owner to another.
- It includes authorization checks to ensure that only the current owner can transfer the token.
- The @auth macro integrates with an external authorization contract to verify the transaction.
Predicate Cancel:
- Allows an account to cancel pending transfers or burns by incrementing the nonce, which invalidates any previously signed transactions.
- It also integrates with the authorization system to ensure that only the authorized user can cancel the transaction.
This NFT contract illustrates how Pint can handle more complex, real-world applications while integrating essential security features like authorization and transaction integrity checks. It demonstrates how intent-centric programming can simplify the development of advanced blockchain applications by focusing on what the system should achieve, leaving the underlying process management to the language and its solvers.
Why Developers Should Care
Intent-centric programming, as exemplified by Pint, represents a new way of thinking about blockchain development. By abstracting away the execution details and concentrating on outcomes, developers can create more robust, secure, and flexible applications. This paradigm shift reduces the likelihood of errors, simplifies the coding process, and opens the door to more innovative and efficient solutions.
As blockchain technology continues to evolve, mastering languages like Pint will be essential for developers who want to stay ahead of the curve and build the next generation of decentralized applications.
Closing Thoughts
Intent-centric programming is more than just a new way to write smart contracts — it’s a fundamental rethinking of how blockchain applications can be designed and executed. By focusing on outcomes rather than instructions, developers can create more secure, scalable, and user-friendly applications that are better suited to the dynamic and complex environments of modern blockchains.
As intent-centric programming becomes more prevalent, we might see its principles extend beyond blockchain into other areas of software development, such as IoT, AI, and even traditional web applications. By focusing on outcomes rather than procedures, developers across industries could unlock new levels of efficiency, security, and flexibility. The future of software could very well be defined by this paradigm shift.
To learn more about Pint and dive deeper into intent-centric programming, check out the Pint documentation for detailed guides and examples. For those interested in deploying on Essential, visit Essential’s official documentation to get started on building and deploying your own intent-centric applications.
A First Look at Intent-Centric Programming ft. Pint was originally published in IOSG Ventures on Medium, where people are continuing the conversation by highlighting and responding to this story.