Skip to content
Available on private testnetTestnet: yesMainnet: Not availableSoftware: Core bad4a06ea · protocol pqwerai-v8-testnet/1Updated

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.


Found a problem? Report a documentation issue · Build testnet-rc1+72057fe3ec