Tokens
Transfer hooks
ERC20, ERC721, ERC1155, and ERC6909 make every balance or ownership change in their update: each transfer, mint, and burn, from their externals, their functions, and the extensions. update calls two hooks the contract implements. beforeTransfer runs first, where OpenZeppelin's _update overrides check before calling super. afterTransfer runs after the module's own transfer log, where they follow the change. A mint comes from the zero address and a burn goes to it.
| Module | beforeTransfer | afterTransfer |
|---|---|---|
ERC20 | (from, to, value) | (from, to, value) |
ERC721 | (to, tokenId, auth), before it reads the owner | (from, to, tokenId), with the previous owner |
ERC1155 | (from, to, ids, values), a batch at once | (from, to, ids, values) |
ERC6909 | (from, to, id, amount) | (from, to, id, amount) |
A token without extensions skips both. That compiles to nothing: the token is the same code as a module without hooks.
hook ERC20.beforeTransfer is skip;
hook ERC20.afterTransfer is skip;An extension is a function the contract calls from a hook. hook M.h is f; names it when it is the only one, and a hook body calls several in the order it writes them. Nothing changes a token unless the contract says so.
| Extension | Hook | Function |
|---|---|---|
Pausable | beforeTransfer | Pausable.requireNotPaused |
ERC20Capped | afterTransfer | ERC20Capped.afterTransfer: a mint can't pass the cap |
ERC20Votes | afterTransfer | ERC20Votes.transferVotingUnits |
ERC721Enumerable | afterTransfer | ERC721Enumerable.afterTransfer |
ERC1155Supply | afterTransfer | ERC1155Supply.afterTransfer |
ERC6909TokenSupply | afterTransfer | ERC6909TokenSupply.afterTransfer |
What a hook raises joins the errors of every transfer, mint, and burn that reaches it, so the contract exposes those externals with the errors listed, after the file's interfaces without them, IERC20Views and the like:
import "std/interfaces/erc20";
import "std/token/erc20";
import "std/security/pausable";
import "std/access/ownable";
import "std/evm";
contract Token is {
use Ownable;
use Pausable exposing IPausable;
use ERC20 exposing IERC20Metadata, IERC20Views, IERC20Approvals, {
function transfer(to : address; value : uint) : bool
raises ERC20InvalidSender | ERC20InvalidReceiver | EnforcedPause | ERC20InsufficientBalance;
function transferFrom(from : address; to : address; value : uint) : bool
raises ERC20InsufficientAllowance | ERC20InvalidApprover | ERC20InvalidSpender | ERC20InvalidSender
| ERC20InvalidReceiver | EnforcedPause | ERC20InsufficientBalance;
}
supports IERC20, IERC20Metadata;
(* A pause stops transfers, mints, and burns, as OpenZeppelin's `ERC20Pausable` does. *)
hook ERC20.beforeTransfer is Pausable.requireNotPaused;
hook ERC20.afterTransfer is skip;
deploy()
raises OwnableInvalidOwner
is {
ERC20.init("Token", "TKN");
Ownable.init(Evm.sender());
}
external function pause()
raises OwnableUnauthorizedAccount | EnforcedPause
is {
Ownable.checkOwner();
Pausable.pause();
}
}When the module is delegated, its facet calls each hook the contract implements back into the contract, and a skipped one costs a bit test.
ERC20
std/token/erc20.lithe. The ERC-20 standard, with OpenZeppelin's errors, events, and order of checks. The module's externals are the standard's, and a token exposes them by naming the standard's interfaces from std/interfaces, and announces them through ERC-165 with supports:
import "std/interfaces/erc20";
import "std/evm";
import "std/token/erc20";
contract Token is {
use ERC20 exposing IERC20, IERC20Metadata;
supports IERC20, IERC20Metadata;
hook ERC20.beforeTransfer is skip;
hook ERC20.afterTransfer is skip;
storage is record {
minter : address;
}
deploy(const supply : uint)
raises ERC20InvalidReceiver | ERC20InsufficientBalance
is {
ERC20.init("Token", "TKN");
ERC20.mint(Evm.sender(), supply);
}
}| Function | |
|---|---|
name, symbol, decimals, totalSupply, balanceOf, allowance, transfer, approve, transferFrom | the standard's externals; decimals is 18 |
init(name, symbol) | |
move(from, to, value) | a checked transfer between two accounts |
mint(account, value), burn(account, value) | |
update(from, to, value) | every balance change, between the hooks; from zero mints, to zero burns |
approveAs(owner, spender, value, announce) | sets an allowance, logging Approval if announce |
spendAllowance(owner, spender, value) | an allowance of Uint.max is never spent down |
A rule for every balance change, such as a pause, goes in a hook. A token that writes an entrypoint of its own exposes the rest of IERC20 as IERC20Views, its views, and IERC20Approvals, its approve, which the file declares. A token with its own decimals, such as a vault, exposes name and symbol as the file's IERC20Name.
ERC20 ships a spec file, std/token/erc20.spec.lithe: its balances add up to its supply, the zero address holds nothing, and it lists exactly when transfer, approve, and transferFrom revert. lithe prove proves them for your contract, whose own code can change the module's storage too:
invariant ERC20.supply: erc20.totalSupply = sum(erc20.balances)
proved deploy
proved transfer
proved approve (unchanged)
proved transferFrom
...
spec for ERC20.transferFrom
proved ensures new.erc20.totalSupply = old.erc20.totalSupply
...
proved raises ERC20InsufficientAllowance when allowed < value
...
proved reverts in no other caseERC20Burnable
std/token/erc20_burnable.lithe. The externals, IERC20Burnable: burn(value) burns the caller's tokens, and burnFrom(account, value) spends an allowance to burn someone else's. Both go through ERC20.burn, so the hooks run.
ERC20Permit
std/token/erc20_permit.lithe. ERC-2612: approvals signed off-chain, so a holder can approve without paying for a transaction. It uses EIP712, ECDSA, and Nonces, and a contract uses all four. Its externals are the whole of ERC-2612, IERC20Permit: permit, nonces, and DOMAIN_SEPARATOR.
import "std/interfaces/erc20";
import "std/utils/nonce";
import "std/crypto/ecdsa";
import "std/crypto/eip712";
import "std/token/erc20";
import "std/token/erc20_permit";
contract Token is {
use EIP712;
use ECDSA;
use Nonces;
use ERC20 exposing {
view function balanceOf(account : address) : uint;
view function allowance(owner : address; spender : address) : uint;
function transferFrom(from : address; to : address; value : uint) : bool
raises ERC20InsufficientAllowance | ERC20InvalidApprover | ERC20InvalidSpender
| ERC20InvalidSender | ERC20InvalidReceiver | ERC20InsufficientBalance;
}
use ERC20Permit exposing IERC20Permit;
hook ERC20.beforeTransfer is skip;
hook ERC20.afterTransfer is skip;
storage is record {
supplyCap : uint;
}
deploy() is {
ERC20.init("Token", "TKN");
EIP712.init("Token", "1");
}
}ERC20Capped
std/token/erc20_capped.lithe. A ceiling on the supply, like OpenZeppelin's ERC20Capped. init(cap) sets it once, from deploy or a migration, as an immutable, and a zero cap raises ERC20InvalidCap. Each mint checks the cap through ERC20's afterTransfer hook: afterTransfer(from, to, value) checks the supply after a mint with check(supply), which raises ERC20ExceededCap. The external is cap(), IERC20Capped.
import "std/interfaces/erc20";
import "std/token/erc20";
import "std/token/erc20_capped";
contract Token is {
use ERC20 exposing IERC20Metadata, IERC20Views, IERC20Approvals, {
function transfer(to : address; value : uint) : bool
raises ERC20InvalidSender | ERC20InvalidReceiver | ERC20InsufficientBalance | ERC20ExceededCap;
function transferFrom(from : address; to : address; value : uint) : bool
raises ERC20InsufficientAllowance | ERC20InvalidApprover | ERC20InvalidSpender | ERC20InvalidSender
| ERC20InvalidReceiver | ERC20InsufficientBalance | ERC20ExceededCap;
}
use ERC20Capped exposing IERC20Capped;
hook ERC20.beforeTransfer is skip;
hook ERC20.afterTransfer is ERC20Capped.afterTransfer;
deploy()
raises ERC20InvalidCap
is {
ERC20.init("Token", "TKN");
ERC20Capped.init(1_000_000);
}
external function mint(const to : address; const value : uint)
raises ERC20InvalidReceiver | ERC20InsufficientBalance | ERC20ExceededCap
is {
ERC20.mint(to, value);
}
}ERC20Votes
std/token/erc20_votes.lithe. Voting power from balances, like OpenZeppelin's ERC20Votes: ERC-5805, with ERC-6372's clock in block numbers. A holder's balance counts as votes for the account it delegates to, itself included, and for no one until it delegates. Each delegate's votes and the total supply are checkpointed by block, so a governor reads them as of a past block, and the supply is capped at what a checkpoint holds, 2^208 - 1. Delegation can be signed off-chain, with EIP712, ECDSA, and Nonces, so a contract uses those, Checkpoints, and Math too. The storage is laid out as OpenZeppelin's Votes, under its own id.
The votes follow every balance change through ERC20's afterTransfer hook, which calls transferVotingUnits. A token with other rules for a balance change, such as a pause or a cap, puts them in its hooks too, in OpenZeppelin's order: the pause before, the cap after, then the votes. What the votes raise joins the transfers' errors:
import "std/interfaces/erc5805";
import "std/math/math";
import "std/utils/nonce";
import "std/crypto/ecdsa";
import "std/utils/checkpoint";
import "std/interfaces/erc20";
import "std/crypto/eip712";
import "std/token/erc20";
import "std/address";
import "std/evm";
import "std/token/erc20_votes";
import "std/security/pausable";
contract Token is {
use EIP712;
use ECDSA;
use Nonces;
use Checkpoints;
use Math;
use Pausable;
use ERC20 exposing IERC20Metadata, {
view function balanceOf(account : address) : uint;
function transfer(to : address; value : uint) : bool
raises ERC20InvalidSender | ERC20InvalidReceiver | EnforcedPause | ERC20InsufficientBalance
| ERC20ExceededSafeSupply | CheckpointUnorderedInsertion;
}
use ERC20Votes exposing IERC6372, IVotes;
hook ERC20.beforeTransfer is Pausable.requireNotPaused;
hook ERC20.afterTransfer is ERC20Votes.transferVotingUnits;
deploy() is {
ERC20.init("Token", "TKN");
EIP712.init("Token", "1");
}
}| Function | |
|---|---|
clock, CLOCK_MODE, getVotes, getPastVotes, getPastTotalSupply, delegates, delegate, delegateBySig | ERC-5805's externals, IERC6372 and IVotes |
numCheckpoints(account), checkpoints(account, pos) | a delegate's history, oldest first, IVotesCheckpoints |
timepoint() | the clock: the block number, as clock answers; a lookup at a block that isn't past yet raises ERC5805FutureLookup |
delegateAs(account, delegatee) | account's votes go to delegatee, as delegate and delegateBySig do for the caller and the signer |
transferVotingUnits(from, to, value) | for ERC20's afterTransfer: the votes and total follow a balance change; a mint past 2^208 - 1 raises ERC20ExceededSafeSupply |
ERC20FlashMint
std/token/erc20_flash_mint.lithe. ERC-3156 flash loans of the token itself, like OpenZeppelin's ERC20FlashMint, with no fee. flashLoan mints the loan to the borrower, calls its onFlashLoan in a deferred call, then, in the continuation, checks its answer, spends its allowance to the token, and burns the loan. A borrower's own failure passes through unchanged. The externals are ERC-3156's lender, IERC3156FlashLender:
import "std/interfaces/erc3156";
import "std/interfaces/erc20";
import "std/token/erc20";
import "std/token/erc20_flash_mint";
contract Token is {
use ERC20 exposing IERC20, IERC20Metadata;
use ERC20FlashMint exposing IERC3156FlashLender;
hook ERC20.beforeTransfer is skip;
hook ERC20.afterTransfer is skip;
deploy() is {
ERC20.init("Token", "TKN");
}
}flashLoan mints and burns with ERC20, so the token's hooks run for the loan: a pause stops it, and the loan counts toward a cap and moves votes. A token whose hooks raise lists those errors for flashLoan, exposing the rest as IERC3156FlashLenderViews. examples/stdlib/governance_token.lithe does, with roles, a pause, a cap, permits, and votes. A token with rules of its own for a loan writes its own flashLoan around checkLoan(token, amount), which returns the fee, and repay(receiver, answer, amount, fee).
SafeERC20
std/token/safe_erc20.lithe. Calls to other ERC-20 tokens, like OpenZeppelin's SafeERC20 and Solady's SafeTransferLib. A call succeeds when the token answers true, or answers nothing and has code, as USDT does: the methods are IERC20OptionalReturn's, whose results are optional. false raises SafeERC20FailedOperation(token), and a token's own revert passes through unchanged, so a function that calls these raises forwarded. Each call is deferred, so it runs after the transition's own effects:
import "std/token/safe_erc20";
import "std/interfaces/erc20";
import "std/evm";
use SafeERC20;
storage is record {
token : IERC20;
deposits : map(address, uint);
}
external nonreentrant function deposit(const value : uint)
raises SafeERC20FailedOperation | forwarded
is {
storage.deposits[Evm.sender()] := storage.deposits[Evm.sender()] + value;
SafeERC20.safeTransferFrom(storage.token, Evm.sender(), Evm.self(), value);
}
external nonreentrant function withdraw(const value : uint)
raises SafeERC20FailedOperation | forwarded
is {
storage.deposits[Evm.sender()] := storage.deposits[Evm.sender()] - value;
SafeERC20.safeTransfer(storage.token, Evm.sender(), value);
}| Function | |
|---|---|
safeTransfer(token, to, value), safeTransferFrom(token, from, to, value) | |
forceApprove(token, spender, value) | approves value; if the token reverts or answers false, as USDT does to change an allowance that isn't zero, approves zero and then value |
safeIncreaseAllowance(token, spender, value) | forceApprove of this contract's allowance plus value |
safeDecreaseAllowance(token, spender, value) | forceApprove of the allowance less value; raises SafeERC20FailedDecreaseAllowance when it is less than value |
false raises SafeERC20FailedOperation, as in OpenZeppelin. A token with no code, or an answer that isn't a clean bool, such as the word 2, reverts with no data, where OpenZeppelin raises SafeERC20FailedOperation: an optional result checks both itself and reverts. OpenZeppelin's error costs gas on every call that succeeds, measured in bench/erc4626.lithe, whose deposits and withdrawals go through these:
| Answer taken as | covers | per successful transfer | erc4626 suite, exec gas | runtime bytes |
|---|---|---|---|---|
option(bool), here | false | 885,470 | 4,627 | |
option(uint), check for 1 | every word but 1 | +131 on withdraw | +1,716 | +119 |
option(uint248), check for 1 | words below 2^248 | +18 to +28 | +158 to +246 | +20 to +26 |
For a token with no code, a check of the token's code before every call costs 120 more gas on each call that succeeds, and a raw call whose answer is decoded here 312 more. None is worth an error that only a broken token triggers, so none is done.
OpenZeppelin's trySafeTransfer has no counterpart, since a deferred call's outcome reaches only its continuation: write the call with Ok and Error arms instead.
ERC4626
std/token/erc4626.lithe. ERC-4626 tokenized vaults, like OpenZeppelin's ERC4626: the shares are the vault's ERC20, and depositing the asset mints them. Errors, events, rounding, and the order of checks are OpenZeppelin's. The asset moves through optional results, so tokens that return nothing work, and an asset's own revert passes through unchanged, as with SafeERC20, which a vault also uses. init(asset, decimals, offset) sets the asset, its decimals, which assetDecimals(asset) reads, or 18 when the asset has none, and the decimals offset, as immutables:
import "std/token/erc4626";
import "std/token/erc20";
import "std/token/safe_erc20";
import "std/interfaces/erc20";
import "std/interfaces/erc4626";
contract Vault is {
use SafeERC20;
use ERC20 exposing IERC20, IERC20Name;
use ERC4626 exposing IERC4626, {
view function decimals() : uint8;
}
supports IERC20, IERC20Metadata, IERC4626;
hook ERC20.beforeTransfer is skip;
hook ERC20.afterTransfer is skip;
deploy(const asset : address) is {
ERC20.init("Vault", "vTKN");
ERC4626.init(asset, ERC4626.assetDecimals(asset), 0);
}
}The vault's decimals is the asset's plus the offset, so it exposes decimals from ERC4626, and name and symbol from ERC20 with the file's IERC20Name.
Inflation. Conversions count 10 ** offset virtual shares and one virtual asset: shares = assets * (supply + 10 ** offset) / (totalAssets + 1). An attacker who deposits first and donates assets to round a later deposit down to nothing loses more than the victim does, and each unit of offset multiplies the attacker's cost by ten. The offset is at most 19.
Order. The asset moves in deferred calls, after the transition's own effects, so a deposit can't take the asset and then mint in one transition. Minting first and pulling the asset after would leave the vault, while the asset's transferFrom runs, with the new shares but not their assets: an asset with transfer hooks, such as an ERC-777, could re-enter at a price the deposit had lowered, and any contract reading convertToAssets then would see it. So a deposit defers the transferFrom and mints in its continuation, and while the asset runs, the vault is exactly as it was before the deposit. That is OpenZeppelin's order, transfer then mint, for the same reason. A withdrawal burns the shares first and sends the assets after, which also leaves nothing half done while the asset runs, as OpenZeppelin's burn-then-transfer does. deposit, mint, withdraw, and redeem log Deposit and Withdraw as OpenZeppelin's do, and within the vault's own logs, in the same order.
| Function | |
|---|---|
asset, totalAssets, convertToShares, convertToAssets, maxDeposit, previewDeposit, maxMint, previewMint, maxWithdraw, previewWithdraw, maxRedeem, previewRedeem, deposit, mint, withdraw, redeem | the standard's externals, IERC4626 |
decimals | the asset's decimals plus the offset |
init(asset, decimals, offset), assetDecimals(asset) | |
heldAssets() | the vault's balance of the asset, which totalAssets answers |
sharesFor(assets, up), assetsFor(shares, up) | the conversions, rounded down, or up if up; the previews round against the caller, and deposits and mints are unlimited, as OpenZeppelin's are |
depositAs(caller, receiver, assets, shares) | takes assets from caller, then mints shares to receiver in the continuation |
withdrawAs(caller, receiver, owner, assets, shares) | spends caller's allowance unless it owns the shares, burns them, then sends assets to receiver |
A vault with a limit or a fee writes the entrypoint itself around these pieces, raising ERC4626ExceededMaxDeposit and the rest from std/interfaces/erc4626.lithe, and exposes the rest of IERC4626 from the module. A cap on the assets held:
import "std/token/erc4626";
import "std/token/erc20";
import "std/interfaces/erc4626";
import "std/token/safe_erc20";
import "std/interfaces/erc20";
import "std/evm";
contract CappedVault is {
use SafeERC20;
use ERC20 exposing IERC20, IERC20Name;
use ERC4626 exposing {
view function decimals() : uint8;
view function asset() : address;
view function totalAssets() : uint;
view function convertToShares(assets : uint) : uint;
view function convertToAssets(shares : uint) : uint;
view function previewDeposit(assets : uint) : uint;
view function maxWithdraw(owner : address) : uint;
view function previewWithdraw(assets : uint) : uint;
view function maxRedeem(owner : address) : uint;
view function previewRedeem(shares : uint) : uint;
function withdraw(assets : uint; receiver : address; owner : address) : uint
raises ERC4626ExceededMaxWithdraw | ERC20InsufficientAllowance | ERC20InvalidApprover
| ERC20InvalidSpender | ERC20InvalidSender | ERC20InsufficientBalance
| SafeERC20FailedOperation | forwarded;
function redeem(shares : uint; receiver : address; owner : address) : uint
raises ERC4626ExceededMaxRedeem | ERC20InsufficientAllowance | ERC20InvalidApprover
| ERC20InvalidSpender | ERC20InvalidSender | ERC20InsufficientBalance
| SafeERC20FailedOperation | forwarded;
}
supports IERC20, IERC20Metadata, IERC4626;
hook ERC20.beforeTransfer is skip;
hook ERC20.afterTransfer is skip;
deploy(const asset : address) is {
ERC20.init("Capped Vault", "cvTKN");
ERC4626.init(asset, ERC4626.assetDecimals(asset), 6);
}
(* What fits under a cap of a million assets. *)
view function room() : uint is {
const held : uint := ERC4626.heldAssets();
var room : uint := 0;
if held < 1_000_000 then {
room := 1_000_000 - held;
}
} with room
external view function maxDeposit(const receiver : address) : uint is
room()
external view function maxMint(const receiver : address) : uint is
ERC4626.sharesFor(room(), false)
external view function previewMint(const shares : uint) : uint is
ERC4626.assetsFor(shares, true)
external function deposit(const assets : uint; const receiver : address) : uint
raises ERC4626ExceededMaxDeposit | SafeERC20FailedOperation | ERC20InvalidReceiver
| ERC20InsufficientBalance | forwarded
is {
const most : uint := room();
assert(assets <= most, ERC4626ExceededMaxDeposit { receiver = receiver; assets = assets; max = most; });
const shares : uint := ERC4626.sharesFor(assets, false);
ERC4626.depositAs(Evm.sender(), receiver, assets, shares);
} with shares
external function mint(const shares : uint; const receiver : address) : uint
raises ERC4626ExceededMaxMint | SafeERC20FailedOperation | ERC20InvalidReceiver
| ERC20InsufficientBalance | forwarded
is {
const most : uint := ERC4626.sharesFor(room(), false);
assert(shares <= most, ERC4626ExceededMaxMint { receiver = receiver; shares = shares; max = most; });
const assets : uint := ERC4626.assetsFor(shares, true);
ERC4626.depositAs(Evm.sender(), receiver, assets, shares);
} with assets
}Solady's vault can turn the virtual shares off, or count its assets some other way; here totalAssets is always the vault's balance, and a vault that counts otherwise writes its own conversions from Uint.mulDiv.
ERC721
std/token/erc721.lithe. The ERC-721 standard and its metadata extension, with OpenZeppelin's errors, events, and order of checks. The safe transfers and safeMint call the receiver in a deferred call, after the transfer's own effects, and pass on a receiver's revert reason unchanged.
import "std/interfaces/erc721";
import "std/evm";
import "std/token/erc721";
contract Collection is {
use ERC721 exposing IERC721, IERC721Metadata;
supports IERC721, IERC721Metadata;
hook ERC721.beforeTransfer is skip;
hook ERC721.afterTransfer is skip;
storage is record {
nextId : uint;
}
deploy() is {
ERC721.init("Collection", "COL");
}
external function mint() : uint
raises ERC721InvalidReceiver | ERC721InvalidSender | ERC721NonexistentToken | ERC721InsufficientApproval | forwarded
is {
const id : uint := storage.nextId;
storage.nextId := id + 1;
ERC721.safeMint(Evm.sender(), id, "");
} with id
}IERC721 has both safeTransferFrom overloads, one for each ABI signature, and supports answers for it and IERC721Metadata with 0x80ac58cd and 0x5b5e139f. tokenURI checks that the token exists and answers the empty string, as OpenZeppelin's does without a base URI; a collection with URIs writes its own tokenURI. A collection whose hooks raise, or that writes its own transfers or tokenURI, exposes the rest with the file's IERC721Views, IERC721Name (name and symbol), and IERC721Approvals, and still lists the standards in supports. update(to, tokenId, auth) makes every ownership change, between the hooks, and returns the previous owner; a nonzero auth must be allowed to move the token.
ERC721 ships a spec file too, std/token/erc721.spec.lithe: each owned token counts once in its owner's balance, the zero address owns and approves nothing, only a token that exists has an approval, and transferFrom moves the token and clears its approval. lithe prove proves them for your collection.
ERC721Enumerable
std/token/erc721_enumerable.lithe. ERC-721's enumeration extension, IERC721Enumerable: every token, and each owner's tokens, by index, like OpenZeppelin's ERC721Enumerable. A token that leaves a list gives its place to the list's last token, so the order changes as tokens move.
The lists follow every ownership change through ERC721's afterTransfer hook, so the collection's transfers, mints, and burns are ERC721's:
import "std/interfaces/erc721";
import "std/token/erc721";
import "std/evm";
import "std/token/erc721_enumerable";
contract Collection is {
use ERC721 exposing IERC721, IERC721Metadata;
use ERC721Enumerable exposing IERC721Enumerable;
hook ERC721.beforeTransfer is skip;
hook ERC721.afterTransfer is ERC721Enumerable.afterTransfer;
storage is record {
nextId : uint;
}
deploy() is {
ERC721.init("Collection", "COL");
}
external function mint() : uint
raises ERC721InvalidReceiver | ERC721InvalidSender | ERC721NonexistentToken | ERC721InsufficientApproval | forwarded
is {
const id : uint := storage.nextId;
storage.nextId := id + 1;
ERC721.safeMint(Evm.sender(), id, "");
} with id
}| Function | |
|---|---|
totalSupply, tokenByIndex, tokenOfOwnerByIndex | the externals; an index past the end raises ERC721OutOfBoundsIndex |
afterTransfer(from, to, tokenId) | for ERC721's afterTransfer: the lists follow the token |
ERC721URIStorage
std/token/erc721_uri_storage.lithe. A URI for each token, like OpenZeppelin's ERC721URIStorage. tokenURI(tokenId, base) is the base with the token's own URI, the base with the token's id when it has none, or the token's own URI when the base is empty, and raises ERC721NonexistentToken for a token that doesn't exist. setTokenURI(tokenId, uri) logs ERC-4906's MetadataUpdate, and announceUpdate(from, to) its BatchMetadataUpdate, for a change to many tokens at once.
ERC-4906's ERC-165 id, 0x49064906, is fixed by the standard, not derived from functions, so a collection answers for it in its own supportsInterface, and for what its supports lists with Abi.supportsInterface:
external view function tokenURI(const tokenId : uint) : string
raises ERC721NonexistentToken
is
ERC721URIStorage.tokenURI(tokenId, "ipfs://collection/")
external pure function supportsInterface(const interfaceId : bytes4) : bool is
ERC721URIStorage.supportsInterface(interfaceId) or Abi.supportsInterface(interfaceId)ERC2981
std/token/erc2981.lithe. NFT royalties, ERC-2981, like OpenZeppelin's ERC2981: a default royalty, and a royalty for a token that takes its place. A fee is a fraction of 10000, and the royalty on a sale is salePrice * fee / 10000, rounded down. Its external is IERC2981's royaltyInfo, from std/interfaces/erc2981.lithe, which also declares the errors for a fee over 10000 or a zero receiver.
| Function | |
|---|---|
royaltyInfo(tokenId, salePrice) | the external: the receiver and the amount |
setDefaultRoyalty(receiver, fee), deleteDefaultRoyalty() | |
setTokenRoyalty(tokenId, receiver, fee), resetTokenRoyalty(tokenId) | a token's own royalty, and back to the default |
examples/stdlib/collectible.lithe puts the three together with Ownable and Pausable: an enumerable collection with per-token URIs under a base, royalties, and an owner who mints, pauses, and sets royalties. Its hooks check the pause before each ownership change and keep the enumerations after.
ERC1155
std/token/erc1155.lithe. The ERC-1155 multi-token standard and its metadata URI extension, with OpenZeppelin's errors, events, and order of checks. One URI serves every id, and clients substitute the id for {id} in it; set it with setURI, from deploy and whenever it changes. Every balance change goes through update, which logs TransferSingle for one id and TransferBatch for any other count. The safe transfers and mints call the receiver's onERC1155Received, or onERC1155BatchReceived for a batch even of one id, in a deferred call after the update's own effects, and pass on a receiver's revert reason unchanged. A burn asks no receiver.
import "std/interfaces/erc1155";
import "std/evm";
import "std/token/erc1155";
import "std/access/ownable";
contract Items is {
use Ownable;
use ERC1155 exposing IERC1155, IERC1155MetadataURI;
supports IERC1155, IERC1155MetadataURI;
hook ERC1155.beforeTransfer is skip;
hook ERC1155.afterTransfer is skip;
deploy()
raises OwnableInvalidOwner
is {
ERC1155.setURI("https://items.example/api/item/{id}.json");
Ownable.init(Evm.sender());
}
external function mint(const to : address; const id : uint; const value : uint; const data : bytes)
raises OwnableUnauthorizedAccount | ERC1155InvalidReceiver | ERC1155InvalidArrayLength
| ERC1155InsufficientBalance | forwarded
is {
Ownable.checkOwner();
ERC1155.mint(to, id, value, data);
}
}supports answers for IERC1155 and IERC1155MetadataURI with 0xd9b67a26 and 0x0e89341c.
| Function | |
|---|---|
balanceOf, balanceOfBatch, isApprovedForAll, setApprovalForAll, safeTransferFrom, safeBatchTransferFrom, uri | the standard's externals |
setURI(uri) | the URI of every id |
checkAuthorized(operator, owner) | operator is owner, or approved for all of its tokens |
update(from, to, ids, values) | every balance change, between the hooks, which see the whole batch; from zero mints, to zero burns |
checkReceived(from, to, id, value, data), checkBatchReceived(from, to, ids, values, data) | ask a contract receiver to accept |
move(from, to, ids, values) | a checked transfer between two accounts, asking no receiver |
mint(to, id, value, data), mintBatch(to, ids, values, data) | mint, then ask the receiver |
burn(from, id, value), burnBatch(from, ids, values) | |
approveAllAs(owner, operator, approved) |
To burn as OpenZeppelin's ERC1155Burnable does, a contract writes the entrypoint around checkAuthorized:
external function burn(const account : address; const id : uint; const value : uint)
raises ERC1155MissingApprovalForAll | ERC1155InvalidSender | ERC1155InvalidArrayLength
| ERC1155InsufficientBalance
is {
ERC1155.checkAuthorized(Evm.sender(), account);
ERC1155.burn(account, id, value);
}ERC1155Supply
std/token/erc1155_supply.lithe. Each id's total supply and the total of every id, like OpenZeppelin's ERC1155Supply. Mints and burns change the supplies through ERC1155's afterTransfer hook, which the contract points at afterTransfer(from, to, ids, values); a transfer leaves them as they are. Its externals are IERC1155Supply: totalSupply(id), totalSupply(), and exists(id); inside the contract, totalSupplyAll() is the total of every id. examples/stdlib/multi_token.lithe is a collection built on both:
use ERC1155 exposing IERC1155, IERC1155MetadataURI;
use ERC1155Supply exposing IERC1155Supply;
hook ERC1155.beforeTransfer is skip;
hook ERC1155.afterTransfer is ERC1155Supply.afterTransfer;ERC6909
std/token/erc6909.lithe. The ERC-6909 minimal multi-token standard, with OpenZeppelin's errors, events, and order of checks. An owner approves spenders two ways: an operator, set with setOperator, moves any amount of any id, and an allowance, set with approve, is per spender and id. transferFrom by the owner or one of its operators spends no allowance; anyone else's spends one, and an allowance of Uint.max is never spent down. Every balance change goes through update, which logs Transfer with the caller, between the hooks. There are no receiver callbacks and no batch methods.
import "std/token/erc6909";
import "std/access/ownable";
import "std/evm";
import "std/interfaces/erc6909";
contract Claims is {
use Ownable;
use ERC6909 exposing IERC6909;
supports IERC6909;
hook ERC6909.beforeTransfer is skip;
hook ERC6909.afterTransfer is skip;
deploy()
raises OwnableInvalidOwner
is {
Ownable.init(Evm.sender());
}
external function mint(const to : address; const id : uint; const amount : uint)
raises OwnableUnauthorizedAccount | ERC6909InvalidReceiver | ERC6909InsufficientBalance
is {
Ownable.checkOwner();
ERC6909.mint(to, id, amount);
}
}supports answers for IERC6909 with 0x0f632fb3.
| Function | |
|---|---|
balanceOf, allowance, isOperator, approve, setOperator, transfer, transferFrom | the standard's externals |
update(from, to, id, amount) | every balance change, between the hooks; from zero mints, to zero burns |
move(sender, receiver, id, amount) | a transfer between two accounts, checking no approval |
mint(to, id, amount), burn(from, id, amount) | |
approveAs(owner, spender, id, amount), setOperatorAs(owner, spender, approved) | |
spendAllowance(owner, spender, id, amount) | spend without logging; Uint.max is never spent |
To burn from another account as transferFrom spends, a contract checks the operator before the allowance:
external function burn(const from : address; const id : uint; const amount : uint)
raises ERC6909InsufficientAllowance | ERC6909InvalidSender | ERC6909InsufficientBalance
is {
if from <> Evm.sender() and not ERC6909.erc6909.operatorApprovals[from][Evm.sender()] then {
ERC6909.spendAllowance(from, Evm.sender(), id, amount);
}
ERC6909.burn(from, id, amount);
}ERC-6909 extensions
Each is a small module that requires ERC6909 and exposes its standard's interface, like OpenZeppelin's extension of the same name. examples/stdlib/claims.lithe puts all three together with Ownable.
| Module | Externals | Internals |
|---|---|---|
ERC6909TokenSupply, std/token/erc6909_token_supply.lithe | IERC6909TokenSupply: totalSupply(id) | afterTransfer(from, to, id, amount), for ERC6909's hook of the same name, which keeps each id's supply |
ERC6909Metadata, std/token/erc6909_metadata.lithe | IERC6909Metadata: name(id), symbol(id), decimals(id) | setName, setSymbol, and setDecimals, which log ERC6909NameUpdated, ERC6909SymbolUpdated, and ERC6909DecimalsUpdated |
ERC6909ContentURI, std/token/erc6909_content_uri.lithe | IERC6909ContentURI: contractURI(), tokenURI(id) | setContractURI(uri), which logs ERC-7572's ContractURIUpdated, and setTokenURI(id, uri), which logs URI |
Mints and burns change a supply, through ERC6909's afterTransfer hook:
import "std/token/erc6909_token_supply";
import "std/interfaces/erc6909";
import "std/evm";
import "std/token/erc6909";
contract Shares is {
use ERC6909 exposing IERC6909;
use ERC6909TokenSupply exposing IERC6909TokenSupply;
supports IERC6909, IERC6909TokenSupply;
hook ERC6909.beforeTransfer is skip;
hook ERC6909.afterTransfer is ERC6909TokenSupply.afterTransfer;
external payable function deposit(const id : uint)
raises ERC6909InvalidReceiver | ERC6909InsufficientBalance
is {
ERC6909.mint(Evm.sender(), id, uint(Evm.value()));
}
}
