Exploring Invariant-Based Smart Contract Development: The Case of VotingSwap
by Simon, Co-Founder / CTO
Smart contracts on blockchain platforms like Ethereum have revolutionized how we think about executing and enforcing agreements without intermediaries. Among various approaches to smart contract design, invariant-based development is gaining traction due to its robustness and flexibility. This design paradigm allows developers to define state conditions that must always hold true, regardless of the contract’s intermediate states. The central idea is simple: any actions can proceed as long as they uphold the contract's predefined invariants.
Understanding Invariants
An invariant in the context of smart contracts is a condition or a set of conditions that must always be true every time the contract's state is modified. These conditions can relate to the balance of tokens, permissions for function calls, or any logical condition set by the developers.
Understanding Invariants with Uniswap V2
Introduction to Uniswap V2
Uniswap V2 is a decentralized exchange protocol built on the Ethereum blockchain. It facilitates the automated trading of decentralized finance (DeFi) tokens, offering an improvement over its predecessor by introducing features like price oracles and flash swaps. One of the core components of Uniswap V2 is the constant product market maker model, a formula that ensures liquidity providers (LPs) can always buy or sell tokens in a pool without requiring a counterparty on the other side of the trade.
Invariant in Uniswap V2
The key invariant in Uniswap V2 is the constant product formula, defined as x*y=k, where x and y are the reserves of two tokens in a liquidity pool, and k is a constant. This formula ensures that the product of the quantities of the two reserves cannot change, which maintains liquidity and determines the price of one token relative to the other.
pragma solidity ^0.8.19;
interface IUniswapV2Pair {
function getReserves() external view returns (uint reserveA, uint reserveB);
function swap(uint amountAOut, uint amountBOut, address to, bytes calldata data) external;
}
contract SimpleSwap {
IUniswapV2Pair pair;
constructor(address _pair) {
pair = IUniswapV2Pair(_pair);
}
// Perform a token swap from Token A to Token B
function swapTokens(uint amountAIn, address to) external {
(uint reserveA, uint reserveB) = pair.getReserves();
require(amountAIn > 0, "Invalid amount");
// Calculate amount of Token B to output using the constant product formula
uint amountBOut = reserveB - (reserveA * reserveB) / (reserveA + amountAIn);
// Call the swap function of the Uniswap V2 Pair contract
pair.swap(0, amountBOut, to, "");
}
}
Explanation of the Code
Contract Setup: The SimpleSwap contract initializes by storing the address of a Uniswap V2 pair. Swap Logic:
- It first fetches the current reserves (reserveA and reserveB) of the two tokens in the pair.
- The amount of the output token (Token B) is calculated using the constant product formula, adjusted for the input amount of Token A.
- The swap function is then called on the Uniswap V2 pair contract, specifying the amounts of Token A and Token B to be swapped and the address to receive Token B.
How Invariants Hold in Uniswap V2
In practical terms, whenever a trade is executed, the amount of one token in the pool decreases (as it is sold for the other token), and the amount of the other token increases. The constant product formula ensures that the pool’s price adjusts automatically to changes in supply and demand. If a large trade is executed, it has a significant impact on the price, providing an arbitrage opportunity that typically brings the price back in line with the broader market.
Here’s a simplified code snippet illustrating how a swap might be implemented in Uniswap V2, adhering to the invariant:
Analysis Of UniswapV2
If you notice, UniswapV2 does not force token transfers. Instead, it relies on the invariant to ensure that the pool's reserves are always balanced. This approach allows for more flexibility and efficiency in trading, as users can interact with the pool without needing to match buyers and sellers directly. The invariant acts as a self-enforcing mechanism that maintains the integrity of the pool's liquidity and pricing mechanism.
A largely underrated aspect of UniswapV2 is that it allows for composability. Since UniswapV2 does force transfers, this means that other smart contracts can interact with UniswapV2 pools without worrying about the pool's internal state. This composability is a key feature of DeFi protocols, enabling developers to build complex financial applications by combining different protocols and services. UniswapV2 leverages it's UniswapV2Router
contract to interact with the pools, allowing for seamless integration with other DeFi protocols.
VotingSwap: A Practical Example
To illustrate this concept, let's dissect a real-world example: the VotingSwap contract. This Ethereum-based smart contract utilizes invariant-based principles to manage voting tokens and their exchange within a secure framework.
Key Features of the VotingSwap Contract
Contract Overview: The VotingSwap contract is designed to facilitate voting with tokens by allowing token holders to cast votes through token swaps. This process is governed by stringent rules that ensure the integrity and transparency of the voting outcome.
Core Invariants:
- Token Balances: The contract ensures that after all operations, the number of tokens at the receiver's address should not be less than a predefined minimum. This safeguards against the loss of token value during transactions.
- Signature Validity: Any action performed through the contract must be accompanied by a valid signature from a designated signer. This prevents unauthorized actions.
- Timeliness of Transactions: All operations must occur within a specified timeframe, ensuring that outdated commands don't execute.
Functional Flow:
The contract starts with the castVotes function where multiple transaction calls can be executed. However, these are only deemed valid if they respect the set invariants: the minimum number of output tokens and a valid signature that matches the precomputed digest. If the token output is insufficient or the signature is invalid, the contract reverts the transaction, upholding the invariance. Upon successful execution of all calls, the contract burns the involved tokens, thus reducing the total supply and affecting the token's scarcity and value.
Security Considerations:
By using a combination of ECDSA for signatures and the EIP-712 standard for structured data, VotingSwap provides an additional layer of security that aligns with Ethereum's best practices. The use of invariants like checking the post-transaction token balance and signature validation acts as a safeguard against a range of attacks, including reentrancy and unauthorized access.
Benefits of Invariant-Based Contracts
The use of invariants offers multiple advantages:
- Security: By enforcing strict conditions that must always be true, invariant-based contracts prevent many common vulnerabilities in smart contracts.
- Predictability: Such contracts provide a clear understanding of possible states, making them easier to audit and verify.
- Flexibility: Developers can encode complex logical requirements into the contract, allowing for sophisticated, self-enforcing agreements.
Challenges and Considerations
Despite its advantages, invariant-based development can be complex:
- Design Complexity: Identifying and defining appropriate invariants requires deep understanding and thorough planning.
- Gas Costs: Ethereum transactions cost gas, and the more complex the invariants, the higher the gas costs could be. Optimization is crucial to keeping these costs manageable.
- Code Complexity: The integration / off-chain code could be complex, especially when dealing with multiple invariants and external dependencies.
- Fit: Not all smart contracts need to be invariant-based. Simple contracts with straightforward logic may not benefit from this approach.
Conclusion
Invariant-based smart contract development, as demonstrated by the VotingSwap contract, represents a powerful paradigm for creating secure, reliable, and flexible decentralized applications. As this approach continues to mature, it could become a standard for developing more sophisticated and secure contracts on Ethereum and beyond. By leveraging this model, developers can ensure that despite the complex and mutable nature of blockchain applications, the integrity and predetermined conditions of their contracts remain intact.
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.19;
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol";
import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol";
import {SignatureChecker} from "@openzeppelin/contracts/utils/cryptography/SignatureChecker.sol";
struct Call {
address target;
bytes data;
uint256 value;
}
contract VotingSwap is Ownable, EIP712("VotingSwap", "1,0") {
error UnsuccesfulCall();
error InvalidSignature();
error InsufficientBalanceOut();
error InvalidProjectId();
address constant BURN_ADDRESS = address(0xdead);
IERC20 public immutable HELLO_TOKEN;
address public signer;
bytes32 constant VOTE_PERMISSION_TYPEHASH = keccak256(
"VotePermission(string projectId,address tokenOut,uint256 minTokenOut,uint256 minHelloTokensOut,uint256 deadline,address targetReceiver,uint256 numVotes,bytes32 callHash)"
);
mapping(string => uint256) private _projectIdToNumVotes;
event VoteCast(address indexed voter, string indexed projectId, uint248 numVotes);
event SignerChanged(address indexed newSigner);
constructor(address _helloToken, address _signer) {
HELLO_TOKEN = IERC20(_helloToken);
signer = _signer;
}
function castVotes(
address tokenOut, //(Should be WETH | WBNB Depending on the chain)
uint256 minTokenOut, // (WETH UniV2 | WBNB PancakeSwapV2) $3000, minTokensOut
uint256 minHelloTokensOut, //
Call[] memory calls,
uint256 numVotes,
string memory projectId,
uint256 deadline,
address targetReceiver, // (Should be the Pair for Wrapped Native Token and Hello Token)
bytes memory signature
) external payable {
{
_checkVotePermissionSignature(
projectId,
tokenOut,
minTokenOut,
minHelloTokensOut,
deadline,
targetReceiver,
numVotes,
keccak256(abi.encode(calls)),
signature
);
uint256 receiverBalanceBefore = IERC20(tokenOut).balanceOf(targetReceiver);
_executeCalls(calls);
uint256 receiverBalanceAfter = IERC20(tokenOut).balanceOf(targetReceiver);
if (receiverBalanceAfter - receiverBalanceBefore < minTokenOut) {
revert InsufficientBalanceOut();
}
}
uint256 balanceHello = HELLO_TOKEN.balanceOf(address(this));
// Prevent malicious calls that aren't signed by the signer by enforcing a minimum amount of tokens to be sent to the contract
// A proper amount can be calculated off-chain and included in the signature
if (balanceHello < minHelloTokensOut) {
revert InsufficientBalanceOut();
}
// Send the whole balance to burn
HELLO_TOKEN.transfer(BURN_ADDRESS, balanceHello);
_addVotesToProject(msg.sender, projectId, numVotes);
}
function changeSigner(address newSigner) external onlyOwner {
signer = newSigner;
emit SignerChanged(newSigner);
}
function deriveVotePermissionDigest(
string memory projectId,
address tokenOut,
uint256 minTokenOut,
uint256 minHelloTokensOut,
uint256 deadline,
address targetReceiver,
uint256 numVotes,
bytes32 callHash
) public view returns (bytes32) {
return _hashTypedDataV4(
keccak256(
abi.encode(
VOTE_PERMISSION_TYPEHASH,
keccak256(bytes(projectId)),
tokenOut,
minTokenOut,
minHelloTokensOut,
deadline,
targetReceiver,
numVotes,
callHash
)
)
);
}
function rescueTokens(address token, uint256 amount) external onlyOwner {
IERC20(token).transfer(owner(), amount);
}
function rescueETH(uint256 amount) external onlyOwner {
(bool success,) = owner().call{value: amount}("");
require(success, "ETH_TRANSFER_FAILED");
}
function _executeCalls(Call[] memory calls) internal {
unchecked {
for (uint256 i; i < calls.length; ++i) {
_executeCall(calls[i]);
}
}
}
function _checkVotePermissionSignature(
string memory projectId,
address tokenOut,
uint256 minTokenOut,
uint256 minHelloTokensOut,
uint256 deadline,
address targetReceiver,
uint256 numVotes,
bytes32 callHash,
bytes memory signature
) internal view {
if (block.timestamp > deadline) {
revert InvalidSignature();
}
bytes32 digest = deriveVotePermissionDigest(
projectId, tokenOut, minTokenOut, minHelloTokensOut, deadline, targetReceiver, numVotes, callHash
);
if (!SignatureChecker.isValidSignatureNow(signer, digest, signature)) {
revert InvalidSignature();
}
}
function _addVotesToProject(address spender, string memory projectId, uint256 numVotes) private {
_projectIdToNumVotes[projectId] += numVotes;
emit VoteCast(spender, projectId, SafeCast.toUint248(numVotes));
}
function _executeCall(Call memory call) internal {
(bool success,) = call.target.call{value: call.value}(call.data);
if (!success) {
revert UnsuccesfulCall();
}
}
}