Access control and ownership¶
The tested TipJar example combines the three common models.
src/TipJar.sol
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
import {Ownable2Step} from "@openzeppelin/contracts/access/Ownable2Step.sol";
import {Math} from "@openzeppelin/contracts/utils/math/Math.sol";
/// Internal library: inlined at compile time, no separate deployment or linking needed.
library Fees {
/// Basis-point fee, rounded down, without intermediate overflow.
function bps(uint256 amount, uint16 rate) internal pure returns (uint256) {
return Math.mulDiv(amount, rate, 10_000);
}
}
/// Payable tip jar: role-based pause, owner-set fee, two-step ownership, events, custom errors and pull
/// withdrawals (checks-effects-interactions). Unaudited tutorial code.
contract TipJar is AccessControl, Ownable2Step {
using Fees for uint256;
bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE");
uint16 public feeBps = 100; // 1 %
bool public paused;
mapping(address => uint256) public balances;
event Tipped(address indexed from, address indexed to, uint256 amount, uint256 fee);
event Withdrawn(address indexed account, uint256 amount);
event PauseChanged(bool paused);
error IsPaused();
error NothingToWithdraw();
error FeeTooHigh(uint16 requested);
constructor(address admin) Ownable(admin) {
_grantRole(DEFAULT_ADMIN_ROLE, admin);
_grantRole(PAUSER_ROLE, admin);
}
function tip(address to) external payable {
if (paused) revert IsPaused();
uint256 fee = msg.value.bps(feeBps);
balances[to] += msg.value - fee;
balances[owner()] += fee;
emit Tipped(msg.sender, to, msg.value, fee);
}
function withdraw() external {
uint256 amount = balances[msg.sender];
if (amount == 0) revert NothingToWithdraw();
balances[msg.sender] = 0; // effects (state and event) before the external call
emit Withdrawn(msg.sender, amount);
(bool ok,) = msg.sender.call{value: amount}("");
require(ok, "transfer failed");
}
function setPaused(bool value) external onlyRole(PAUSER_ROLE) {
paused = value;
emit PauseChanged(value);
}
function setFee(uint16 newBps) external onlyOwner {
if (newBps > 1_000) revert FeeTooHigh(newBps);
feeBps = newBps;
}
}
| Model | Use | In the example |
|---|---|---|
Ownable |
One administrator | setFee is onlyOwner |
Ownable2Step |
Ownership transfer that the new owner must accept | transferOwnership(BOB) sets pendingOwner; owner unchanged until acceptOwnership() |
AccessControl |
Several roles, each grantable/revocable | PAUSER_ROLE guards setPaused |
patterns/patterns.sh
ME=$(cast wallet address --keystore "$KEYSTORE" --password-file "$PASSWORD_FILE")
BOB=0x000000000000000000000000000000000000bEEF
W="--keystore $KEYSTORE --password-file $PASSWORD_FILE --rpc-url $RPC"
forge build
forge create src/TipJar.sol:TipJar --broadcast $W --constructor-args "$ME" | tee deploy.log
JAR=$(sed -n 's/^Deployed to: //p' deploy.log)
# Payable function + library: tip 1 PQAI to BOB, 1 % fee to the owner.
TX=$(cast send "$JAR" "tip(address)" "$BOB" --value 1ether --async $W)
cast receipt "$TX" status --rpc-url "$RPC"
cast call "$JAR" "balances(address)(uint256)" "$BOB" --rpc-url "$RPC"
cast call "$JAR" "balances(address)(uint256)" "$ME" --rpc-url "$RPC"
# Events: decode Tipped from the receipt's block.
B=$(cast receipt "$TX" blockNumber --rpc-url "$RPC")
cast logs --from-block "$B" --to-block "$B" --address "$JAR" "Tipped(address indexed,address indexed,uint256,uint256)" --rpc-url "$RPC" | grep -c topics
# Access control: an account without PAUSER_ROLE is refused (custom error selector).
SEL=$(cast sig "AccessControlUnauthorizedAccount(address,bytes32)")
if cast call "$JAR" "setPaused(bool)" true --from "$BOB" --rpc-url "$RPC" 2> err.txt; then echo "UNEXPECTED: call succeeded"; exit 1; fi
grep -q "${SEL#0x}" err.txt && echo "refused with AccessControlUnauthorizedAccount ($SEL)"
# Pause with the role, observe IsPaused, unpause.
cast receipt "$(cast send "$JAR" "setPaused(bool)" true --async $W)" status --rpc-url "$RPC"
if cast call "$JAR" "tip(address)" "$BOB" --value 1 --from "$ME" --rpc-url "$RPC" 2> err.txt; then echo "UNEXPECTED: tip while paused"; exit 1; fi
grep -q "$(cast sig 'IsPaused()' | sed 's/^0x//')" err.txt && echo "refused with IsPaused()"
cast receipt "$(cast send "$JAR" "setPaused(bool)" false --async $W)" status --rpc-url "$RPC"
# Pull withdrawal of the owner's fee.
cast receipt "$(cast send "$JAR" "withdraw()" --async $W)" status --rpc-url "$RPC"
cast call "$JAR" "balances(address)(uint256)" "$ME" --rpc-url "$RPC"
# Two-step ownership: the new owner must accept; until then the old owner stays in control.
cast receipt "$(cast send "$JAR" "transferOwnership(address)" "$BOB" --async $W)" status --rpc-url "$RPC"
cast call "$JAR" "pendingOwner()(address)" --rpc-url "$RPC"
cast call "$JAR" "owner()(address)" --rpc-url "$RPC"
Recorded output:
true
990000000000000000 [9.9e17] # BOB credited 0.99 PQAI
10000000000000000 [1e16] # owner fee 1 %
1 # one Tipped event
refused with AccessControlUnauthorizedAccount (0xe2517d3f)
true
refused with IsPaused()
true
true
0 # owner withdrew the fee
true
0x000000000000000000000000000000000000bEEF # pendingOwner
0xaAcAcdD40Bcb3Ce31cDd67f8677C05a7d8A8B81b # owner still the old one
Key custody: keep owner and admin keys out of servers; for anything valuable use a multisig contract as the owner.
Renouncing ownership (renounceOwnership) is irreversible.
Sources at 72057fe3ec: developers/examples/patterns/src/TipJar.sol · developers/examples/patterns/patterns.sh
Found a problem? Report a documentation issue · Build testnet-rc1+72057fe3ec