Introduction
This chapter continues as the third part (3/5) of our code walkthrough of OpenZeppelin’s ERC-721 CVL specification and focuses on token transfer and approval rules, specifically:
transferFrom()approve()setApprovalForAll()zeroAddressBalanceRevert()
These rules verify ownership transfers, per-token approvals, operator approvals for all tokens, and that querying balanceOf(0) reverts.
Formally verify transferFrom()
The transferFrom() function changes the ownership of an NFT to another address. It assumes the NFT has already been minted, and it can only be called by the owner, an address approved for that token ID, or an operator approved for all tokens of the owner.
Here’s the transferFrom rule which we will explain section by section after the code block:
rule transferFrom(env e, address from, address to, uint256 tokenId) {
// preconditions
require nonpayable(e);
require authSanity(e);
address operator = e.msg.sender;
uint256 otherTokenId;
address otherAccount;
requireInvariant ownerHasBalance(tokenId);
require balanceLimited(to);
// pre-call state
uint256 balanceOfFromBefore = balanceOf(from);
uint256 balanceOfToBefore = balanceOf(to);
uint256 balanceOfOtherBefore = balanceOf(otherAccount);
address ownerBefore = unsafeOwnerOf(tokenId);
address otherOwnerBefore = unsafeOwnerOf(otherTokenId);
address approvalBefore = unsafeGetApproved(tokenId);
address otherApprovalBefore = unsafeGetApproved(otherTokenId);
// method call
transferFrom@withrevert(e, from, to, tokenId);
bool success = !lastReverted;
// liveness
assert success <=> (
from == ownerBefore &&
from != 0 &&
to != 0 &&
(operator == from || operator == approvalBefore || isApprovedForAll(ownerBefore, operator))
);
// effect
assert success => (
to_mathint(balanceOf(from)) == balanceOfFromBefore - assert_uint256(from != to ? 1 : 0) &&
to_mathint(balanceOf(to)) == balanceOfToBefore + assert_uint256(from != to ? 1 : 0) &&
unsafeOwnerOf(tokenId) == to &&
unsafeGetApproved(tokenId) == 0
);
// no side effect
assert balanceOf(otherAccount) != balanceOfOtherBefore => (otherAccount == from || otherAccount == to);
assert unsafeOwnerOf(otherTokenId) != otherOwnerBefore => otherTokenId == tokenId;
assert unsafeGetApproved(otherTokenId) != otherApprovalBefore => otherTokenId == tokenId;
}
Preconditions
These preconditions specify what must be true before the Prover executes transferFrom(). We explain each line in the code block afterward:
require nonpayable(e);
require authSanity(e);
...
requireInvariant ownerHasBalance(tokenId);
require balanceLimited(to);
require nonpayable(e)
This requires that transferFrom() is called without sending ETH, since the function is non-payable. It is expressed as a definition:
definition nonpayable(env e) returns bool = e.msg.value == 0;
It defines the condition e.msg.value == 0 as a reusable expression named nonpayable(e), which checks that the call carries no ETH.
Although the ERC-721 standard specifies transferFrom() as payable, OpenZeppelin implements it as non-payable. In practice, ETH is not expected to be sent alongside NFT transfers, so this has become a practical convention. The CVL rule enforcese.msg.value == 0.
require authSanity(e)
This requires that the caller is not the zero address. It is also expressed as a definition:
definition authSanity(env e) returns bool = e.msg.sender != 0
Without this requirement, the Prover treats address(0) as a valid caller, even though no real call can originate from address(0). The contract’s authorization logic never permits address(0) as an owner, approved address, or operator, so any violations from address(0) acting as a caller are false positives.
requireInvariant ownerHasBalance(tokenId)
This precondition requires the ownerHasBalance(tokenId) invariant (which guarantees that if a token exists, its owner has a positive balance) to hold at the start of the rule. It forces the Prover to begin in a state where any existing token has a valid (nonzero) owner:
rule transferFrom(env e, address from, address to, uint256 tokenId) {
// preconditions
require nonpayable(e);
require authSanity(e);
...
requireInvariant ownerHasBalance(tokenId); // invariant as a precondition
require balanceLimited(to);
...
}
// invariant as a precondition
invariant ownerHasBalance(uint256 tokenId)
unsafeOwnerOf(tokenId) != 0 => balanceOf(ownerOf(tokenId)) > 0
...
The ownerHasBalance invariant relies on another invariant balanceOfConsistency through preserved block, which verifies that balanceOf, _ownedByUser, and the ghost _balances are equal:
invariant ownerHasBalance(uint256 tokenId)
unsafeOwnerOf(tokenId) != 0 => balanceOf(ownerOf(tokenId)) > 0
{
preserved { // invariant as a preserved condition
requireInvariant balanceOfConsistency(ownerOf(tokenId));
}
}
// invariant as a preserved condition
invariant balanceOfConsistency(address user)
to_mathint(balanceOf(user)) == _ownedByUser[user] &&
to_mathint(balanceOf(user)) == _balances[user];
By using the invariant ownerHasBalance(tokenId) as a precondition, the transferFrom rule inherits the balanceOfConsistency guarantee:
rule transferFrom(env e, address from, address to, uint256 tokenId) {
// preconditions
require nonpayable(e);
require authSanity(e);
...
requireInvariant ownerHasBalance(tokenId); // invariant as a precondition
require balanceLimited(to);
...
}
If this precondition is removed, the Prover can start the rule in impossible states where ownership and balances do not match — for example, an address could be recorded as the owner of a token while its balance is zero because the ghost values do not align with storage. This leads to false positive violations.
require balanceLimited(to)
The balanceLimited(to) limits the balance of the recipient (to) address to stay below max_uint256 as expressed in the definition:
definition balanceLimited(address account) returns bool = balanceOf(account) < max_uint256;
The contract increments balances inside an unchecked block for gas efficiency. Although overflow is practically impossible, the Prover still explores overflow cases because transferFrom() calls _update(), where the balance increment occurs inside an unchecked block:
// ERC721.sol
function transferFrom(address from, address to, uint256 tokenId) public virtual {
...
address previousOwner = _update(to, tokenId, _msgSender());
..
}
/// ERC721.sol
function _update(address to, uint256 tokenId, address auth) internal virtual returns (address) {
...
if (to != address(0)) {
unchecked {
_balances[to] += 1;
}
}
...
}
Using require balanceLimited(to) rules out practically unreachable overflow states that would cause the rule to fail in unrealistic scenarios.
Pre-call state
Before invoking the tra``nsferFrom() method , we record state variables for comparison against post-call values:
balanceOfFromBeforeis the balance of the sender (from) before the transfer.balanceOfToBeforeis the balance of the recipient (to) before the transfer.balanceOfOtherBeforeis the balance of an uninvolved account (otherAccount) before the transfer.ownerBeforeis the owner of the token to be transferred.otherOwnerBeforeis the owner of an uninvolved token.approvalBeforeis the approval for the token to be transferred.otherApprovalBeforeis the approval of an uninvolved token.
uint256 balanceOfFromBefore = balanceOf(from);
uint256 balanceOfToBefore = balanceOf(to);
uint256 balanceOfOtherBefore = balanceOf(otherAccount);
address ownerBefore = unsafeOwnerOf(tokenId);
address otherOwnerBefore = unsafeOwnerOf(otherTokenId);
address approvalBefore = unsafeGetApproved(tokenId);
address otherApprovalBefore = unsafeGetApproved(otherTokenId);
Notice that we read approvals using unsafeGetApproved(), a harness function, rather than the contract’s getApproved(). The harness version exposes the approval value even when the token is unminted or burned (owner == address(0)) because it returns _getApproved(tokenId) without checking whether the token exists or has an owner. In contrast, getApproved() in the actual contract performs an ownership check and reverts if the token is unminted or burned:
// ERC721Harness.sol -- returns approval without checking token existence or ownership
function unsafeGetApproved(uint256 tokenId) external view returns (address) {
return _getApproved(tokenId);
}
// ERC721.sol -- performs an ownership check and reverts if the token is unminted or burned
function getApproved(uint256 tokenId) public view virtual returns (address) {
_requireOwned(tokenId); // reverts if `owner == address(0)`
return _getApproved(tokenId);
}
This prevents querying the approval state of unminted or burned tokens because getApproved() reverts in those cases. However, the rule needs to compare approval values across state transitions that pass through the zero-address state:
- token creation (unminted to minted) — owner transitions from
address(0)to a nonzero address - token destruction (minted to unminted/burned) — owner transitions from a nonzero address back to
address(0)
Hence, the need for the unsafeGetApproved(), so the rule can read approval values even when the token is in the zero-address owner state.
Now that the pre-call state is recorded, we execute transferFrom() and reason about the post-call values in the assertions that follow.
Transfer call
The transferFrom() call with @withrevert instructs the Prover to explore both reverting and non-reverting paths. The !lastReverted condition captures whether the call did not revert and stores this as success:
transferFrom@withrevert(e, from, to, tokenId);
bool success = !lastReverted;
Assertions — liveness, effect, and no side effect
Liveness:
A transfer does not revert if and only if (<=>) all of the following hold:
-
from == ownerBeforeThe
fromaddress (which the token is being transferred from) must be the previous owner of the token. -
from != 0The
fromaddress must not be the zero address. -
to != 0The
toaddress (receiver) must not be the zero address. -
(operator == from || operator == approvalBefore || isApprovedForAll(ownerBefore, operator))The operator (caller) must have authorization through at least one of these:
operator == from— the owner transfers their own tokenoperator == approvalBefore— the operator has specific approval for this token (viaapprove())isApprovedForAll(ownerBefore, operator)— the operator has blanket approval for all tokens from the owner (viasetApprovalForAll())
// liveness
assert success <=> (
from == ownerBefore &&
from != 0 &&
to != 0 &&
(operator == from || operator == approvalBefore || isApprovedForAll(ownerBefore, operator))
);
Effect:
If the transfer does not revert, the following state changes apply:
-
to_mathint(balanceOf(from)) == balanceOfFromBefore - assert_uint256(from != to ? 1 : 0)The sender’s balance decreases by 1, except in a self-transfer (
from == to) where it remains unchanged. -
to_mathint(balanceOf(to)) == balanceOfToBefore + assert_uint256(from != to ? 1 : 0)The receiver’s balance increases by 1, except in a self-transfer (
from == to) where it remains unchanged. -
unsafeOwnerOf(tokenId) == toThe receiver (
to) address becomes the new owner oftokenId. -
unsafeGetApproved(tokenId) == 0The approval for
tokenIdis cleared.
// effect
assert success => (
to_mathint(balanceOf(from)) == balanceOfFromBefore - assert_uint256(from != to ? 1 : 0) &&
to_mathint(balanceOf(to)) == balanceOfToBefore + assert_uint256(from != to ? 1 : 0) &&
unsafeOwnerOf(tokenId) == to &&
unsafeGetApproved(tokenId) == 0
);
No side effect:
After asserting the intended state changes (effect), we also check that no unintended effects occur on balances, ownership, or approvals:
-
On balance
balanceOf(otherAccount) != balanceOfOtherBefore => (otherAccount == from || otherAccount == to)If the balance of any account changes, it implies (
=>) that the account is either the sender or the receiver. No other uninvolved account had its balance modified. -
On ownership
unsafeOwnerOf(otherTokenId) != otherOwnerBefore => otherTokenId == tokenIdIf the ownership of any token changes, it implies (
=>) that the token was the one transferred. No other uninvolved token had its ownership changed. -
On approval
unsafeGetApproved(otherTokenId) != otherApprovalBefore => otherTokenId == tokenIdIf the approval of any token changes, it implies (
=>) that the token was the one transferred. No other uninvolved token had its approval modified.
// no side effect
assert balanceOf(otherAccount) != balanceOfOtherBefore => (otherAccount == from || otherAccount == to);
assert unsafeOwnerOf(otherTokenId) != otherOwnerBefore => otherTokenId == tokenId;
assert unsafeGetApproved(otherTokenId) != otherApprovalBefore => otherTokenId == tokenId;
Here’s the complete specifications for the rule transferFrom:
methods {
function balanceOf(address) external returns (uint256) envfree;
function ownerOf(uint256) external returns (address) envfree;
function unsafeOwnerOf(uint256) external returns (address) envfree;
function unsafeGetApproved(uint256) external returns (address) envfree;
function isApprovedForAll(address,address) external returns (bool) envfree;
function _.onERC721Received(address,address,uint256,bytes) external => DISPATCHER(true);
}
/*
┌─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│ Definitions │
└─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
*/
definition nonpayable(env e) returns bool = e.msg.value == 0;
definition balanceLimited(address account) returns bool = balanceOf(account) < max_uint256;
definition authSanity(env e) returns bool = e.msg.sender != 0;
/*
┌─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│ Ghost & hooks: ownership count │
└─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
*/
ghost mapping(address => mathint) _ownedByUser {
init_state axiom forall address a. _ownedByUser[a] == 0;
}
hook Sstore _owners[KEY uint256 tokenId] address newOwner (address oldOwner) {
_ownedByUser[newOwner] = _ownedByUser[newOwner] + to_mathint(newOwner != 0 ? 1 : 0);
_ownedByUser[oldOwner] = _ownedByUser[oldOwner] - to_mathint(oldOwner != 0 ? 1 : 0);
}
/*
┌─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│ Ghost & hooks: balances │
└─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
*/
ghost mapping(address => mathint) _balances {
init_state axiom forall address a. _balances[a] == 0;
}
hook Sload uint256 value _balances[KEY address user] {
require _balances[user] == to_mathint(value);
}
/*
┌─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│ Invariant: balanceOf is the number of tokens owned │
└─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
*/
invariant balanceOfConsistency(address user)
to_mathint(balanceOf(user)) == _ownedByUser[user] &&
to_mathint(balanceOf(user)) == _balances[user];
/*
┌─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│ Invariant: owner of a token must have some balance │
└─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
*/
invariant ownerHasBalance(uint256 tokenId)
unsafeOwnerOf(tokenId) != 0 => balanceOf(ownerOf(tokenId)) > 0 // fixed for Prover V8
{
preserved {
requireInvariant balanceOfConsistency(ownerOf(tokenId));
}
}
/*
┌─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│ Rule: transferFrom behavior and side effects │
└─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
*/
rule transferFrom(env e, address from, address to, uint256 tokenId) {
require nonpayable(e);
require authSanity(e);
address operator = e.msg.sender;
uint256 otherTokenId;
address otherAccount;
requireInvariant ownerHasBalance(tokenId);
require balanceLimited(to);
uint256 balanceOfFromBefore = balanceOf(from);
uint256 balanceOfToBefore = balanceOf(to);
uint256 balanceOfOtherBefore = balanceOf(otherAccount);
address ownerBefore = unsafeOwnerOf(tokenId);
address otherOwnerBefore = unsafeOwnerOf(otherTokenId);
address approvalBefore = unsafeGetApproved(tokenId);
address otherApprovalBefore = unsafeGetApproved(otherTokenId);
transferFrom@withrevert(e, from, to, tokenId);
bool success = !lastReverted;
// liveness
assert success <=> (
from == ownerBefore &&
from != 0 &&
to != 0 &&
(operator == from || operator == approvalBefore || isApprovedForAll(ownerBefore, operator))
);
// effect
assert success => (
to_mathint(balanceOf(from)) == balanceOfFromBefore - assert_uint256(from != to ? 1 : 0) &&
to_mathint(balanceOf(to)) == balanceOfToBefore + assert_uint256(from != to ? 1 : 0) &&
unsafeOwnerOf(tokenId) == to &&
unsafeGetApproved(tokenId) == 0
);
// no side effect
assert balanceOf(otherAccount) != balanceOfOtherBefore => (otherAccount == from || otherAccount == to);
assert unsafeOwnerOf(otherTokenId) != otherOwnerBefore => otherTokenId == tokenId;
assert unsafeGetApproved(otherTokenId) != otherApprovalBefore => otherTokenId == tokenId;
}
Here’s the Prover run.
Now that we have verified transferFrom``(), we can move to the approval mechanisms that authorize these transfers.
Recall that transferFrom() succeeds only when the caller has proper authorization: as the token owner, as an address approved for that token, or as an operator approved for all tokens of the owner. The next two rules verify the functions that grant these permissions: approve() for per-token authorization and setApprovalForAll() for operator-level authorization.
Formally verify approve()
The approve() contract function grants an address permission to transfer a specific token. It succeeds only if the token exists and the caller is the owner or an operator (has blanket approval).
Here’s the rule that verifies this behavior:
rule approve(env e, address spender, uint256 tokenId) {
// preconditions
require nonpayable(e);
require authSanity(e);
// pre-call state
address caller = e.msg.sender;
address owner = unsafeOwnerOf(tokenId);
uint256 otherTokenId;
address otherApprovalBefore = unsafeGetApproved(otherTokenId);
// method call
approve@withrevert(e, spender, tokenId);
bool success = !lastReverted;
// liveness
assert success <=> (
owner != 0 &&
(owner == caller || isApprovedForAll(owner, caller))
);
// effect
assert success => unsafeGetApproved(tokenId) == spender;
// no side effect
assert unsafeGetApproved(otherTokenId) != otherApprovalBefore => otherTokenId == tokenId;
}
Preconditions
By now, we already know that nonpayable(e) requires the call to be sent without Ether, and authSanity(e) requires that msg.sender is not the zero address:
require nonpayable(e);
require authSanity(e);
Approve call
As a common pattern in OpenZeppelin’s ERC-721 rule specifications, the approve method is called with @withrevert, and the result of !lastReverted is stored in the success variable to reason about the case where the call succeeds:
approve@withrevert(e, spender, tokenId);
bool success = !lastReverted;
Assertions — liveness, effect, and no side effect
Liveness:
This means that the function call does not revert “if and only if” the following conditions are met:
-
owner != 0The token owner must not be the zero address. This prevents approving a token that doesn’t exist or was burned.
-
(owner == caller || isApprovedForAll(owner, caller))This is an authorization check where the
callermust either:- Be the token
owner, or - Be an operator approved via
isApprovedForAll(owner, caller)(returns true)
- Be the token
// liveness
assert success <=> (
owner != 0 &&
(owner == caller || isApprovedForAll(owner, caller))
);
Effect:
-
assert success => unsafeGetApproved(tokenId) == spenderIf the call succeeded (success == true), then the approved address for
tokenIdmust now be equal tospender.
// effect
assert success => unsafeGetApproved(tokenId) == spender;
No side effect:
-
assert unsafeGetApproved(otherTokenId) != otherApprovalBefore => otherTokenId == tokenIdIf the approved address for
otherTokenIdchanges, thenotherTokenIdmust be the same astokenId. It means onlytokenId(the intended target) has its approval updated and no other token (otherTokenId) is accidentally affected.
// no side effect
assert unsafeGetApproved(otherTokenId) != otherApprovalBefore => otherTokenId == tokenId;
Here’s the complete specification for the approve rule:
methods {
function unsafeOwnerOf(uint256) external returns (address) envfree;
function unsafeGetApproved(uint256) external returns (address) envfree;
function isApprovedForAll(address,address) external returns (bool) envfree;
}
/*
┌─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│ Definitions │
└─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
*/
definition nonpayable(env e) returns bool = e.msg.value == 0;
definition authSanity(env e) returns bool = e.msg.sender != 0;
/*
┌─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│ Rule: approve behavior and side effects │
└─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
*/
rule approve(env e, address spender, uint256 tokenId) {
require nonpayable(e);
require authSanity(e);
address caller = e.msg.sender;
address owner = unsafeOwnerOf(tokenId);
uint256 otherTokenId;
address otherApprovalBefore = unsafeGetApproved(otherTokenId);
approve@withrevert(e, spender, tokenId);
bool success = !lastReverted;
// liveness
assert success <=> (
owner != 0 &&
(owner == caller || isApprovedForAll(owner, caller))
);
// effect
assert success => unsafeGetApproved(tokenId) == spender;
// no side effect
assert unsafeGetApproved(otherTokenId) != otherApprovalBefore => otherTokenId == tokenId;
}
Here’s the Prover run.
Formally verify setApprovalForAll()
The setApprovalForAll() function authorizes an address to manage all tokens owned by the caller. Here is the rule:
rule setApprovalForAll(env e, address operator, bool approved) {
// preconditions
require nonpayable(e);
// pre-call state
address owner = e.msg.sender;
address otherOwner;
address otherOperator;
bool otherIsApprovedForAllBefore = isApprovedForAll(otherOwner, otherOperator);
setApprovalForAll@withrevert(e, operator, approved);
bool success = !lastReverted;
// liveness
assert success <=> operator != 0;
// effect
assert success => isApprovedForAll(owner, operator) == approved;
// no side effect
assert isApprovedForAll(otherOwner, otherOperator) != otherIsApprovedForAllBefore => (
otherOwner == owner &&
otherOperator == operator
);
}
At this point, we’re already familiar with the pattern for preconditions, pre-call and post-call states, and the method call, so we’ll skip those and go straight to the assertions.
Assertions — liveness, effect, and no side effect
Liveness:
-
assert success <=> operator != 0The call succeeds if and only if the operator is not the zero address. ERC-721 prohibits approving the zero address as an operator, so this assertion requires the call to fail when
operator == 0:
// liveness
assert success <=> operator != 0;
Effect:
-
assert success => isApprovedForAll(owner, operator) == approvedIf the call succeeded, the approval status of the
(owner, operator)pair must match theapprovedboolean value. It means that the approval value (true or false) was correctly set.// effect assert success => isApprovedForAll(owner, operator) == approved;
No side effect:
- If the approval status of any
(otherOwner, otherOperator)changes, those addresses must match the caller (owner) and the designated operator (operator). Any change to a different approval entry causes this assertion to fail.
// no side effect
assert isApprovedForAll(otherOwner, otherOperator) != otherIsApprovedForAllBefore => (
otherOwner == owner &&
otherOperator == operator
);
Here’s the full CVL specification for the setApprovalForAll rule:
methods {
function unsafeOwnerOf(uint256) external returns (address) envfree;
function unsafeGetApproved(uint256) external returns (address) envfree;
function isApprovedForAll(address,address) external returns (bool) envfree;
}
/*
┌─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│ Definitions │
└─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
*/
definition nonpayable(env e) returns bool = e.msg.value == 0;
definition authSanity(env e) returns bool = e.msg.sender != 0;
/*
┌─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│ Rule: setApprovalForAll behavior and side effects │
└─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
*/
rule setApprovalForAll(env e, address operator, bool approved) {
require nonpayable(e);
address owner = e.msg.sender;
address otherOwner;
address otherOperator;
bool otherIsApprovedForAllBefore = isApprovedForAll(otherOwner, otherOperator);
setApprovalForAll@withrevert(e, operator, approved);
bool success = !lastReverted;
// liveness
assert success <=> operator != 0;
// effect
assert success => isApprovedForAll(owner, operator) == approved;
// no side effect
assert isApprovedForAll(otherOwner, otherOperator) != otherIsApprovedForAllBefore => (
otherOwner == owner &&
otherOperator == operator
);
}
Here’s the Prover run.
Formally verify that balanceOf(0) reverts
The following rule verifies that the balanceOf function always reverts when the zero address is queried.
Below is the contract implementation of balanceOf. It reverts if owner == address(0):
function balanceOf(address owner) public view virtual returns (uint256) {
if (owner == address(0)) {
revert ERC721InvalidOwner(address(0));
}
return _balances[owner];
}
Here is the rule that verifies this behavior:
methods {
function balanceOf(address) external returns (uint256) envfree;
}
/*
┌─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│ Rule: balance of address(0) is 0 │
└─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
*/
rule zeroAddressBalanceRevert() {
balanceOf@withrevert(0);
assert lastReverted;
}
Here’s the Prover run:
Full specification for transfer and approval
Here’s the complete specification and the Prover run.
methods {
function balanceOf(address) external returns (uint256) envfree;
function ownerOf(uint256) external returns (address) envfree;
function unsafeOwnerOf(uint256) external returns (address) envfree;
function unsafeGetApproved(uint256) external returns (address) envfree;
function isApprovedForAll(address,address) external returns (bool) envfree;
function _.onERC721Received(address,address,uint256,bytes) external => DISPATCHER(true);
}
/*
┌─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│ Definitions │
└─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
*/
definition nonpayable(env e) returns bool = e.msg.value == 0;
definition balanceLimited(address account) returns bool = balanceOf(account) < max_uint256;
definition authSanity(env e) returns bool = e.msg.sender != 0;
/*
┌─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│ Ghost & hooks: ownership count │
└─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
*/
ghost mapping(address => mathint) _ownedByUser {
init_state axiom forall address a. _ownedByUser[a] == 0;
}
hook Sstore _owners[KEY uint256 tokenId] address newOwner (address oldOwner) {
_ownedByUser[newOwner] = _ownedByUser[newOwner] + to_mathint(newOwner != 0 ? 1 : 0);
_ownedByUser[oldOwner] = _ownedByUser[oldOwner] - to_mathint(oldOwner != 0 ? 1 : 0);
}
/*
┌─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│ Ghost & hooks: balances │
└─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
*/
ghost mapping(address => mathint) _balances {
init_state axiom forall address a. _balances[a] == 0;
}
hook Sload uint256 value _balances[KEY address user] {
require _balances[user] == to_mathint(value);
}
/*
┌─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│ Invariant: balanceOf is the number of tokens owned │
└─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
*/
invariant balanceOfConsistency(address user)
to_mathint(balanceOf(user)) == _ownedByUser[user] &&
to_mathint(balanceOf(user)) == _balances[user];
/*
┌─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│ Invariant: owner of a token must have some balance │
└─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
*/
invariant ownerHasBalance(uint256 tokenId)
unsafeOwnerOf(tokenId) != 0 => balanceOf(ownerOf(tokenId)) > 0
{
preserved {
requireInvariant balanceOfConsistency(ownerOf(tokenId));
}
}
/*
┌─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│ Rule: transferFrom behavior and side effects │
└─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
*/
rule transferFrom(env e, address from, address to, uint256 tokenId) {
require nonpayable(e);
require authSanity(e);
address operator = e.msg.sender;
uint256 otherTokenId;
address otherAccount;
requireInvariant ownerHasBalance(tokenId);
require balanceLimited(to);
uint256 balanceOfFromBefore = balanceOf(from);
uint256 balanceOfToBefore = balanceOf(to);
uint256 balanceOfOtherBefore = balanceOf(otherAccount);
address ownerBefore = unsafeOwnerOf(tokenId);
address otherOwnerBefore = unsafeOwnerOf(otherTokenId);
address approvalBefore = unsafeGetApproved(tokenId);
address otherApprovalBefore = unsafeGetApproved(otherTokenId);
transferFrom@withrevert(e, from, to, tokenId);
bool success = !lastReverted;
// liveness
assert success <=> (
from == ownerBefore &&
from != 0 &&
to != 0 &&
(operator == from || operator == approvalBefore || isApprovedForAll(ownerBefore, operator))
);
// effect
assert success => (
to_mathint(balanceOf(from)) == balanceOfFromBefore - assert_uint256(from != to ? 1 : 0) &&
to_mathint(balanceOf(to)) == balanceOfToBefore + assert_uint256(from != to ? 1 : 0) &&
unsafeOwnerOf(tokenId) == to &&
unsafeGetApproved(tokenId) == 0
);
// no side effect
assert balanceOf(otherAccount) != balanceOfOtherBefore => (otherAccount == from || otherAccount == to);
assert unsafeOwnerOf(otherTokenId) != otherOwnerBefore => otherTokenId == tokenId;
assert unsafeGetApproved(otherTokenId) != otherApprovalBefore => otherTokenId == tokenId;
}
/*
┌─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│ Rule: approve behavior and side effects │
└─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
*/
rule approve(env e, address spender, uint256 tokenId) {
require nonpayable(e);
require authSanity(e);
address caller = e.msg.sender;
address owner = unsafeOwnerOf(tokenId);
uint256 otherTokenId;
address otherApprovalBefore = unsafeGetApproved(otherTokenId);
approve@withrevert(e, spender, tokenId);
bool success = !lastReverted;
// liveness
assert success <=> (
owner != 0 &&
(owner == caller || isApprovedForAll(owner, caller))
);
// effect
assert success => unsafeGetApproved(tokenId) == spender;
// no side effect
assert unsafeGetApproved(otherTokenId) != otherApprovalBefore => otherTokenId == tokenId;
}
/*
┌─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│ Rule: setApprovalForAll behavior and side effects │
└─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
*/
rule setApprovalForAll(env e, address operator, bool approved) {
require nonpayable(e);
address owner = e.msg.sender;
address otherOwner;
address otherOperator;
bool otherIsApprovedForAllBefore = isApprovedForAll(otherOwner, otherOperator);
setApprovalForAll@withrevert(e, operator, approved);
bool success = !lastReverted;
// liveness
assert success <=> operator != 0;
// effect
assert success => isApprovedForAll(owner, operator) == approved;
// no side effect
assert isApprovedForAll(otherOwner, otherOperator) != otherIsApprovedForAllBefore => (
otherOwner == owner &&
otherOperator == operator
);
}
/*
┌─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│ Rule: balance of address(0) is 0 │
└─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
*/
rule zeroAddressBalanceRevert() {
balanceOf@withrevert(0);
assert lastReverted;
}