SOURCE CODE

EffortToken.sol

The actual smart contract running on OP Sepolia. Every line explained.Because developers of the future should know the code they interact with.

Interactive Walkthrough
Solidity0.8.20
|
LicenseMIT
InheritsERC20Ownable
Lines208
Verified on Etherscan
EffortToken.sol
1// SPDX-License-Identifier: MIT

This comment tells the compiler and the world what license governs this code. MIT is the most permissive open-source license — anyone can use, modify, and distribute this code freely.

  • Required by the Solidity compiler since v0.6.8 — omitting it triggers a warning
  • Common licenses: MIT (permissive), GPL-3.0 (copyleft), UNLICENSED (proprietary)
  • Etherscan reads this line to display the license badge on the contract page
2pragma solidity ^0.8.20;

The pragma locks this contract to Solidity 0.8.x compilers. The caret (^) means 'compatible with 0.8.20 and above, but below 0.9.0'. This is critical because different compiler versions can produce different bytecode or behave differently.

  • ^0.8.20 means >= 0.8.20 and < 0.9.0 — it won't compile on 0.7.x or 0.9.x
  • Solidity 0.8.x added built-in overflow/underflow protection — a massive security upgrade over 0.7.x
  • The word 'pragma' comes from Greek 'pragmatikos' (relating to practice) — it's a practical instruction to the compiler
3 
4import "@openzeppelin/contracts/token/ERC20/ERC20.sol";

Imports the complete ERC-20 token implementation from OpenZeppelin. This single import gives EffortToken all 6 standard functions (totalSupply, balanceOf, transfer, allowance, approve, transferFrom) plus internal helpers like _mint and _burn.

  • The path @openzeppelin/contracts/token/ERC20/ERC20.sol points to an npm package installed in node_modules
  • ERC20.sol is ~400 lines of battle-tested code — writing it yourself would be error-prone and unaudited
  • This import also brings in IERC20 (the interface) and IERC20Metadata (name, symbol, decimals)
5import "@openzeppelin/contracts/access/Ownable.sol";

Imports the Ownable access control module. This gives our contract an 'owner' address with special privileges, plus functions to transfer or renounce ownership. It's the simplest form of admin control in Solidity.

  • Ownable provides: owner(), transferOwnership(), renounceOwnership(), and the onlyOwner modifier
  • In OpenZeppelin v5, Ownable requires passing the initial owner to the constructor — it's no longer implicit
  • For more complex access control (multiple roles), use OpenZeppelin's AccessControl instead

The Foundation of Every Smart Contract

Every Solidity file starts with a license identifier (SPDX), a compiler version pragma, and import statements. The SPDX license tells developers and auditors how the code can be used. The pragma pins the compiler version to prevent unexpected behavior from version changes. OpenZeppelin imports give us battle-tested, audited base contracts instead of writing everything from scratch.

Why it matters

Using audited OpenZeppelin contracts as a base reduces the attack surface dramatically. Over $50B+ in on-chain value is secured by OpenZeppelin code. The pragma version lock prevents your contract from being compiled with an untested compiler version.

Security Note

Always use a specific compiler version (^0.8.20) rather than a floating version. The ^0.8.x range includes overflow protection by default — a critical safety feature missing in older versions.

Tips & Tricks
  • Run `npm install @openzeppelin/contracts` to add OpenZeppelin to your Hardhat or Foundry project. Always pin to a specific version in package.json.
  • Use the caret (^) in pragma carefully: `^0.8.20` allows 0.8.20 through 0.8.x but NOT 0.9.0. For maximum safety, some auditors prefer exact versions like `pragma solidity 0.8.20;`.
  • Check the OpenZeppelin Contracts Wizard at wizard.openzeppelin.com to generate starter contracts with the exact features you need.
Common Mistakes
  • Using `pragma solidity >=0.4.0 <0.9.0` — this compiles on ancient versions without overflow protection, a massive vulnerability.
  • Forgetting the SPDX license identifier — the compiler will warn, and Etherscan verification may fail or show 'No License'.
  • Importing from a URL instead of a package manager — this makes builds non-reproducible and can be a supply chain attack vector.
Key Terms
SPDX
Software Package Data Exchange — a standard for communicating software license information. Required by the Solidity compiler since v0.6.8.
Pragma
A compiler directive that tells the Solidity compiler which versions can compile this code. Think of it as a compatibility requirement.
OpenZeppelin
The most widely-used library of audited, reusable smart contract components. It's the 'standard library' of Solidity.
💡
Did You Know?

The ERC in ERC-20 stands for 'Ethereum Request for Comments' — inspired by the RFC process that built the internet. ERC-20 was proposed by Fabian Vogelsteller and Vitalik Buterin in November 2015.

EffortToken.sol
7/**
8 * @title EffortToken
9 * @dev ERC-20 token for the Proof of Effort educational dApp
10 *
11 * This token represents learning progress and contribution, not financial value.
12 * Users earn EFFORT tokens by completing educational tasks like quizzes and lessons.
13 *
14 * Educational Concepts Demonstrated:
15 * - ERC-20 standard implementation (balanceOf, transfer, approve, transferFrom)
16 * - Controlled minting through authorized minter
17 * - Token spending/burning for premium content access
18 * - Event emission for on-chain activity tracking
19 */

This multi-line comment uses NatSpec (Ethereum Natural Language Specification) to document the contract. Tools like Etherscan, Remix, and documentation generators parse these @tags automatically. Think of it as a README that lives inside the code.

  • @title — the human-readable name of the contract, displayed prominently on Etherscan
  • @dev — technical notes for developers reading or auditing the code
  • The bullet list documents the educational concepts this contract demonstrates — good practice for any teaching-oriented codebase
20contract EffortToken is ERC20, Ownable {

This single line does three powerful things: (1) declares a new contract named EffortToken, (2) inherits ALL of ERC20's functionality (13+ functions), and (3) inherits Ownable's access control. The 'is' keyword is Solidity's inheritance syntax — similar to 'extends' in Java or TypeScript.

  • 'contract' is like 'class' in other languages — it defines a deployable unit of code with state and functions
  • 'is ERC20, Ownable' — multiple inheritance, resolved via C3 linearization (rightmost parent wins on conflicts)
  • After this line, EffortToken has: balanceOf(), transfer(), approve(), transferFrom(), totalSupply(), allowance(), owner(), transferOwnership(), and more — all inherited
  • The opening brace '{' starts the contract body — everything until the matching '}' on line 208 is part of EffortToken

Inheritance: Standing on Giants' Shoulders

The contract declaration uses Solidity's inheritance model. `EffortToken is ERC20, Ownable` means our contract inherits ALL functionality from both base contracts. ERC20 gives us the complete token standard (balanceOf, transfer, approve, transferFrom, etc.) while Ownable gives us a simple access control mechanism with an owner address. The NatSpec comment block (@title, @dev) provides machine-readable documentation that tools like Etherscan display automatically.

Why it matters

Inheritance is how Solidity achieves code reuse. By inheriting from ERC20, our token automatically works with every wallet, DEX, and DeFi protocol that supports the ERC-20 standard — that's virtually every tool in the Ethereum ecosystem.

Tips & Tricks
  • NatSpec comments (@title, @dev, @notice, @param) are rendered by Etherscan and developer tools. Always write them — they're your contract's public documentation.
  • The order of inheritance matters in Solidity. If two parent contracts define the same function, the rightmost parent takes priority (C3 linearization).
  • You can check what functions your contract inherits by looking at the contract ABI — it lists every callable function including inherited ones.
Common Mistakes
  • Forgetting to call parent constructors — in Solidity 0.8+, this causes a compile error, but in older versions it silently used default values.
  • Inheriting from contracts you don't fully understand — always read the parent contract's code. For example, ERC20 has a `_beforeTokenTransfer` hook that can be overridden.
  • Assuming inheritance is the same as in JavaScript or Python — Solidity uses C3 linearization for multiple inheritance, which resolves differently.
Key Terms
ERC-20
The standard interface for fungible tokens on Ethereum. Defines 6 functions (totalSupply, balanceOf, transfer, allowance, approve, transferFrom) and 2 events (Transfer, Approval).
Ownable
An access control pattern that assigns a single 'owner' address with special permissions. The owner can transfer ownership or renounce it entirely.
NatSpec
Ethereum Natural Language Specification — a documentation format using @tags that tools like Etherscan, Remix, and documentation generators can parse and display.
Inheritance
A mechanism where a contract acquires all functions, state variables, and modifiers from parent contracts. In Solidity, a contract can inherit from multiple parents.
💡
Did You Know?

When you inherit from ERC20, your contract actually gets 13 functions — not just the 6 in the standard. OpenZeppelin adds helpers like `_mint`, `_burn`, `increaseAllowance`, and `decreaseAllowance`.

EffortToken.sol
21 /// @notice Address authorized to mint tokens (TaskRegistry contract)
22 address public taskRegistry;

This state variable stores the address of the TaskRegistry contract — the ONLY address allowed to call the mint() function. It acts as a gatekeeper: when mint() is called, the contract checks if msg.sender matches this address. If not, the transaction reverts.

  • 'address public' — a 20-byte Ethereum address with an auto-generated getter function
  • Defaults to address(0) on deployment — meaning minting is disabled until the owner calls setTaskRegistry()
  • The @notice comment is rendered by Etherscan, helping users understand the variable's purpose
  • This pattern is called 'authorization by address' — a simple but effective access control mechanism
23 
24 /// @notice Address authorized to process content purchases (ContentStore contract)
25 address public contentStore;

Mirrors taskRegistry but for the spending/burn side. Only the ContentStore contract at this address can call spendForContent() to burn a user's tokens. This creates a separation of concerns: the token contract handles the mechanics, while the ContentStore handles the business logic of content purchases.

  • Same pattern as taskRegistry — public address with authorization checks in the spending function
  • Also defaults to address(0), disabling content purchases until configured
  • Having separate authorized addresses for minting and burning means you can upgrade each system independently

On-Chain Storage: The Contract's Memory

State variables are permanently stored on the blockchain. `taskRegistry` holds the address of the contract that's allowed to mint new tokens, and `contentStore` holds the address that can burn tokens for content purchases. Both are `public`, which means Solidity auto-generates getter functions — anyone can read these values, but only the owner can change them.

Why it matters

Every state variable costs gas to store and modify. The pattern of storing authorized contract addresses (rather than hardcoding them) allows the system to be upgraded — if the TaskRegistry contract needs to be replaced, the owner can update the address without redeploying the token.

Security Note

Making these `public` is an intentional transparency choice. On a public blockchain, all storage is readable anyway — the `public` keyword just provides a convenient getter function.

Tips & Tricks
  • Each storage slot on Ethereum is 32 bytes (256 bits). An `address` is only 20 bytes, so Solidity can pack two addresses into adjacent slots if they're declared consecutively.
  • Use `immutable` for variables set once in the constructor and never changed — they're stored in bytecode instead of storage, saving ~20,000 gas on deployment.
  • The `public` keyword auto-generates a getter: `address public taskRegistry` creates a `taskRegistry()` function anyone can call.
Common Mistakes
  • Storing data on-chain that could live off-chain — every 32 bytes of new storage costs 20,000 gas (~$0.50 at current prices). Use events or IPFS for large data.
  • Declaring variables as `private` and thinking they're secret — ALL blockchain storage is publicly readable via `eth_getStorageAt`. The `private` keyword only restricts Solidity-level access.
  • Forgetting that state variable default values are zero — `address` defaults to `address(0)`, `uint256` to `0`, `bool` to `false`. This is why we check for zero address before setting.
Key Terms
Storage Slot
The fundamental unit of persistent storage in the EVM. Each slot holds 32 bytes and is identified by a 256-bit index. Reading costs 2,100 gas (cold) or 100 gas (warm).
address
A 20-byte (160-bit) Ethereum address type. Can represent a user wallet (EOA) or a smart contract. The zero address 0x000...000 is conventionally used to represent 'no address'.
Visibility
Controls who can access a variable or function. `public` = anyone, `external` = only from outside, `internal` = this contract + children, `private` = this contract only.
💡
Did You Know?

Storing 32 bytes on Ethereum mainnet costs 20,000 gas. At $3,000 ETH and 30 gwei gas price, that's about $1.80 — making Ethereum one of the most expensive databases per byte in the world.

EffortToken.sol
27 // ============ Events ============
28 
29 /// @dev Emitted when tokens are minted upon task completion
30 event TokensMinted(
31 address indexed to,
32 uint256 amount,
33 string taskType,
34 uint256 taskId,
35 uint256 timestamp
36 );

Emitted every time a user earns EFFORT tokens by completing a lesson or quiz. This is the primary reward signal — our frontend watches for this event to show 'You earned X EFFORT!' notifications in real-time. Each parameter tells a piece of the story.

  • address indexed to — the wallet that received tokens. 'indexed' makes it searchable: you can query 'show me all mints to 0xABC...' efficiently
  • uint256 amount — how many tokens were minted, in wei (10^18 = 1 EFFORT). Always in raw units, never formatted
  • string taskType — 'QUIZ' or 'LESSON'. Note: strings can't be indexed (they'd be hashed), so this goes in the event data blob
  • uint256 taskId — links this mint back to a specific task, enabling audit trails like 'Quiz #7 rewarded 5 EFFORT to 0xABC'
  • uint256 timestamp — block.timestamp at the moment of minting. Note: this is block time, not wall-clock time — miners have slight control over it
37 
38 /// @dev Emitted when tokens are spent on premium content
39 event TokensSpentForContent(
40 address indexed user,
41 uint256 amount,
42 uint256 contentId,
43 uint256 timestamp
44 );

The mirror of TokensMinted — emitted when tokens are burned for premium content access. This event creates an on-chain receipt of every purchase. Analytics dashboards can track spending patterns, popular content, and the overall token velocity.

  • address indexed user — who spent the tokens. Indexed so you can query a user's full purchase history
  • uint256 amount — tokens burned (permanently removed from circulation)
  • uint256 contentId — which piece of content was purchased. The ContentStore contract maps IDs to actual content
  • uint256 timestamp — when the purchase happened. Useful for time-series analytics of spending patterns
45 
46 /// @dev Emitted when the TaskRegistry address is updated
47 event TaskRegistryUpdated(
48 address indexed oldTaskRegistry,
49 address indexed newTaskRegistry
50 );

Emitted when the owner changes which contract can mint tokens. This is a critical admin action — if the taskRegistry is changed, a completely different contract controls token creation. The event records BOTH the old and new addresses so anyone can trace the history.

  • address indexed oldTaskRegistry — the previous minter address. 'indexed' allows filtering by either old or new address
  • address indexed newTaskRegistry — the new minter address. Both being indexed uses 2 of the 3 available topic slots
  • This pattern (old, new) is standard for admin events — it answers 'what changed FROM and TO?'
51 
52 /// @dev Emitted when the ContentStore address is updated
53 event ContentStoreUpdated(
54 address indexed oldContentStore,
55 address indexed newContentStore
56 );

Same pattern as TaskRegistryUpdated but for the content store address. Emitted when the owner changes which contract can burn tokens for content purchases. Together, these two admin events create a complete audit trail of all configuration changes.

  • Follows the exact same (old, new) pattern — consistency in event design makes frontend code simpler
  • Both parameters are indexed for efficient querying of admin action history
  • Monitoring services can alert on these events to detect unauthorized admin activity

Broadcasting to the World: Event Logs

Events are the contract's way of communicating with the outside world. When emitted, they create log entries stored in transaction receipts — not in contract storage, making them ~10x cheaper than storage. The `indexed` keyword on parameters lets frontends and indexers efficiently filter events by those values. We have four events: TokensMinted (tracks rewards), TokensSpentForContent (tracks spending), and two admin events for tracking configuration changes.

Why it matters

Events are how dApps know what happened on-chain. Our frontend listens for TokensMinted events to show real-time notifications. Block explorers like Etherscan display events in the transaction log. Indexing services like The Graph use events to build queryable databases of on-chain activity.

Tips & Tricks
  • You can have up to 3 `indexed` parameters per event (plus the event signature as topic[0]). These are stored as 'topics' and are searchable — non-indexed parameters go into the data blob.
  • In your frontend, use wagmi's `useWatchContractEvent` or viem's `watchContractEvent` to listen for events in real-time. This is how you build reactive UIs that respond to on-chain changes.
  • Always emit events for state changes — they're your contract's API for the outside world. If you don't emit an event, frontends have no efficient way to detect what changed.
  • The standard ERC-20 already emits `Transfer` and `Approval` events automatically when you call `_mint`, `_burn`, or `_transfer`. Our custom events add extra context on top.
Common Mistakes
  • Indexing too many parameters with `string` or `bytes` type — indexed dynamic types are stored as keccak256 hashes, making the original value unrecoverable from the event.
  • Forgetting that events are NOT readable from within a smart contract — they can only be read by off-chain code. If a contract needs data, it must be stored in state variables.
  • Not including a timestamp in events — while you can get the block timestamp from the receipt, including it explicitly makes off-chain indexing simpler and faster.
Key Terms
Event
A Solidity mechanism for logging data to the transaction receipt. Events are ~10x cheaper than storage because they live in a separate log structure that contracts can't read.
indexed
A keyword on event parameters that stores them as searchable 'topics'. You can efficiently filter events by indexed values using eth_getLogs.
Topic
An indexed piece of event data (up to 32 bytes). topic[0] is always the keccak256 hash of the event signature. Topics 1-3 are your indexed parameters.
emit
The keyword used to fire an event. It's required since Solidity 0.4.21 — previously events could be called like functions, which was confusing.
💡
Did You Know?

The standard ERC-20 Transfer event uses `address(0)` as the `from` address for mints and as the `to` address for burns. That's how block explorers detect mints and burns — they watch for transfers involving the zero address.

EffortToken.sol
58 // ============ Errors ============
59 
60 /// @dev Thrown when caller is not authorized to mint
61 error UnauthorizedMinter(address caller);

Thrown when someone other than the TaskRegistry tries to call mint(). The error includes the caller's address as a parameter — this is incredibly useful for debugging because the frontend can decode the error and show exactly WHO tried to mint and was rejected.

  • The parameter (address caller) captures msg.sender at the point of failure
  • Used in: mint() function, line 136
  • In the frontend, viem's decodeErrorResult can parse this into a human-readable message
  • The 4-byte selector for this error is the first 4 bytes of keccak256('UnauthorizedMinter(address)')
62 
63 /// @dev Thrown when caller is not authorized to process content purchases
64 error UnauthorizedContentStore(address caller);

The spending equivalent of UnauthorizedMinter. Thrown when any address other than the ContentStore tries to burn tokens via spendForContent(). This prevents malicious actors from destroying other users' tokens.

  • Same pattern as UnauthorizedMinter — includes the unauthorized caller's address
  • Used in: spendForContent() function, line 162
  • Without this check, anyone could call spendForContent and burn your tokens
65 
66 /// @dev Thrown when address is zero
67 error ZeroAddress();

A guard against accidentally using the zero address (0x0000...0000). Sending tokens to the zero address effectively burns them with no record, and setting the taskRegistry to zero would permanently disable minting. This single error is reused across multiple functions.

  • Used in: setTaskRegistry(), setContentStore(), mint(), spendForContent() — reused 4 times
  • address(0) is the 'null' of Ethereum — it's a real address but no one controls the private key
  • Reusing errors across functions reduces bytecode size compared to having separate error types
68 
69 /// @dev Thrown when amount is zero
70 error ZeroAmount();

Prevents minting or burning zero tokens. While a zero-amount operation wouldn't break anything technically, it would waste gas and pollute the event log with meaningless entries. This check keeps the contract's on-chain history clean and intentional.

  • Used in: mint() and spendForContent() — reused 2 times
  • A zero-amount mint would still emit events and cost gas — this check saves the user from wasting money
  • Some protocols allow zero-amount transfers for UX reasons (e.g., to trigger side effects), but for minting/burning it's always a mistake
71 

Gas-Efficient Error Handling

Custom errors (introduced in Solidity 0.8.4) replace the older `require(condition, "message")` pattern. Instead of storing error message strings on-chain, custom errors use a 4-byte selector — just like function calls. `UnauthorizedMinter(address caller)` even includes the offending address in the error data, making debugging easier without the gas cost of string storage.

Why it matters

Using custom errors instead of require strings saves gas on deployment AND on reverts. The error parameters (like `address caller`) make it possible for frontends to show meaningful error messages: instead of a generic 'transaction failed', the dApp can say 'Unauthorized: 0xABC... is not the task registry'.

Security Note

These errors enforce the contract's security boundaries. ZeroAddress prevents accidentally burning tokens to the zero address. UnauthorizedMinter ensures only the TaskRegistry can create new tokens.

Tips & Tricks
  • Custom errors save about 50 gas per revert vs `require(false, "message")` and significantly reduce deployment size when you have many error messages.
  • In your frontend, use viem's `decodeErrorResult` to parse custom error data from failed transactions. This turns cryptic hex into human-readable messages.
  • Define errors at the contract level (not inside functions) so they can be reused across multiple functions and are visible in the ABI.
  • Use the pattern `if (condition) revert CustomError()` instead of `require(!condition, ...)` — it's the modern Solidity style.
Common Mistakes
  • Using `require` with long string messages in production — each character costs extra gas on deployment. A 50-character error string costs ~5,000 gas more than a custom error.
  • Not including relevant parameters in custom errors — `error Unauthorized()` is less useful than `error Unauthorized(address caller)` because you can't tell WHO failed.
  • Using `assert` when you mean `require` — assert is for invariant checks (things that should NEVER happen). It consumes all remaining gas, while revert/require refunds unused gas.
Key Terms
Custom Error
A gas-efficient way to revert transactions with structured error data. Uses a 4-byte function selector, just like a function call, instead of encoding a full string.
revert
Aborts the current transaction, undoes all state changes, and returns remaining gas to the caller. Custom errors are passed as the revert data.
Selector
The first 4 bytes of the keccak256 hash of the error (or function) signature. For example, `ZeroAddress()` has selector `0xd92e233d`.
💡
Did You Know?

Before Solidity 0.8.4, the only way to handle errors was `require(condition, "message")` or `revert("message")`. The require string was stored in the contract bytecode — even unused error messages inflated deployment costs.

EffortToken.sol
73 
74 /**
75 * @dev Initializes the Effort Token
76 * Token Name: "Effort Token"
77 * Token Symbol: "EFFORT"
78 * Decimals: 18 (standard)
79 */

Documents the constructor's behavior. Notice it explicitly states the token name, symbol, and decimals — these are the three pieces of metadata every wallet and DEX reads to display your token correctly.

  • Token Name: 'Effort Token' — displayed in wallet UIs and on Etherscan
  • Token Symbol: ' EFFORT' — the ticker shown in trading interfaces and balances
  • Decimals: 18 — the standard. 1 EFFORT in the UI = 1,000,000,000,000,000,000 in the contract
80 constructor() ERC20("Effort Token", "EFFORT") Ownable(msg.sender) {}

This one line does everything: creates the token, names it, and sets the owner. The empty body {} means no additional initialization is needed — all the work is done by the parent constructors. This is a deliberately minimal constructor.

  • ERC20("Effort Token", " EFFORT") — calls the ERC20 parent constructor, which stores the name and symbol internally
  • Ownable(msg.sender) — calls the Ownable parent constructor, setting the deployer as the contract owner
  • The empty {} body means no initial token supply — totalSupply starts at 0
  • msg.sender here is the deployment wallet — this address becomes the permanent owner (until transferred)
  • constructor() has no name and no return type — it's a special function that only runs once during deployment
81 

Birth of a Token: One-Time Initialization

The constructor runs exactly once — when the contract is deployed. It calls the parent constructors: `ERC20("Effort Token", " EFFORT")` sets the token name and symbol (visible in wallets and on Etherscan), and `Ownable(msg.sender)` makes the deployer the contract owner. Notice there's no initial token minting — the supply starts at zero and grows only through the mint function.

Why it matters

The constructor pattern is critical to understand: it can never be called again after deployment. This means the token name, symbol, and initial owner are permanently set at deploy time. The zero initial supply is a design choice — EFFORT tokens are only earned, never pre-minted.

Tips & Tricks
  • In Solidity 0.8+, parent constructors must be called explicitly. The syntax `ERC20("Name", "SYM") Ownable(msg.sender)` passes arguments to each parent in the contract declaration.
  • Constructor code is NOT stored on-chain — it runs once during deployment and is discarded. This means you can have expensive initialization logic without ongoing gas costs.
  • Test your constructor by deploying to a local Hardhat network first. Use `npx hardhat node` and `npx hardhat run scripts/deploy.ts --network localhost`.
  • The `msg.sender` in the constructor is the address that deployed the contract. For proxy patterns, this is the factory contract, NOT the admin — a common gotcha.
Common Mistakes
  • Pre-minting a large supply to the deployer — this is often a red flag in token audits. Fair-launch tokens with no pre-mine (like EFFORT) are considered more transparent.
  • Setting the wrong number of decimals — ERC-20 defaults to 18 decimals. If you want a different value, you need to override the `decimals()` function. Most tokens keep 18.
  • Forgetting that the constructor can't be `payable` without explicit declaration — if you want to accept ETH during deployment, you must mark the constructor as `payable`.
  • Using `tx.origin` instead of `msg.sender` — `tx.origin` is the original external account, which can be exploited in phishing attacks.
Key Terms
Constructor
A special function that runs exactly once during contract deployment. It initializes state and cannot be called again. It's NOT part of the deployed bytecode.
msg.sender
The address that called the current function. In the constructor, this is the deployer. In regular calls, it's the immediate caller (which could be another contract).
Decimals
ERC-20 tokens use integer math. 'Decimals' (default 18) tells UIs where to place the decimal point. 1 EFFORT = 1 × 10^18 in raw contract units (wei).
💡
Did You Know?

The name 'constructor' was introduced in Solidity 0.4.22. Before that, the constructor was just a function with the same name as the contract — a pattern that caused several security incidents when contracts were renamed but the constructor function wasn't!

EffortToken.sol
83 
84 /**
85 * @notice Sets the TaskRegistry contract address
86 * @dev Only the owner can call this function
87 * @param _taskRegistry Address of the TaskRegistry contract
88 *
89 * Educational Note: This demonstrates access control patterns in smart contracts.
90 * Only the TaskRegistry can mint new tokens, preventing unauthorized token creation.
91 */
92 function setTaskRegistry(address _taskRegistry) external onlyOwner {
93 if (_taskRegistry == address(0)) revert ZeroAddress();
94 
95 address oldTaskRegistry = taskRegistry;
96 taskRegistry = _taskRegistry;
97 
98 emit TaskRegistryUpdated(oldTaskRegistry, _taskRegistry);
99 }

The NatSpec documents what this function does, who can call it, and what the parameter means. The function signature reveals key design choices: 'external' (can't be called internally), 'onlyOwner' (restricted to the contract owner).

  • external — saves gas vs public because arguments are read directly from calldata, not copied to memory
  • onlyOwner — a modifier from Ownable that adds require(msg.sender == owner()) before the function body runs
  • @param _taskRegistry — the underscore prefix is a Solidity naming convention for function parameters to distinguish them from state variables
100 
101 /**
102 * @notice Sets the ContentStore contract address
103 * @dev Only the owner can call this function
104 * @param _contentStore Address of the ContentStore contract
105 */
106 function setContentStore(address _contentStore) external onlyOwner {
107 if (_contentStore == address(0)) revert ZeroAddress();

The function body follows the Check-Effects-Interactions pattern, the golden rule of secure Solidity. Step 1: Check (validate input). Step 2: Effects (update state). Step 3: Interactions (emit event). Each step has a specific security purpose.

  • Line 93: CHECK — revert if zero address. This prevents accidentally bricking the mint system
  • Line 95: Save old value BEFORE updating — if you saved it after, you'd lose the original for the event
  • Line 96: EFFECT — update the state variable. After this line, mint() will accept calls from the new address
  • Line 98: INTERACTION — emit the event recording the change. This creates a permanent, immutable audit trail
  • If the zero-address check reverts, the state change on line 96 is rolled back — this is atomic transaction behavior
108 
109 address oldContentStore = contentStore;
110 contentStore = _contentStore;
111 
112 emit ContentStoreUpdated(oldContentStore, _contentStore);
113 }

An exact mirror of setTaskRegistry but for the ContentStore address. Identical structure demonstrates code consistency — a hallmark of well-written contracts. Both admin functions use the same validate → update → emit pattern.

  • Same zero-address check, same old-value capture, same state update, same event emission
  • Code duplication here is acceptable because each function manages a different state variable with its own event
  • In more complex contracts, you might abstract this into a single internal function — but here, clarity wins over DRY

Access Control: The Owner's Toolkit

These two functions let the contract owner configure which other contracts can mint and burn tokens. The `onlyOwner` modifier (inherited from Ownable) restricts access. Both functions follow the same pattern: validate input (no zero address), save the old value, update state, then emit an event recording the change. This pattern makes configuration changes fully auditable on-chain.

Why it matters

This is the 'admin key' pattern common in upgradeable DeFi. The owner can point the token at a new TaskRegistry if it needs to be upgraded. The event emission creates a permanent, tamper-proof audit trail. Anyone can verify on Etherscan exactly when and how these critical addresses were changed.

Security Note

The owner has significant power here — they can change who can mint tokens. In production DeFi, this would often be behind a multisig or timelock. For an educational dApp, single-owner simplicity is appropriate.

Tips & Tricks
  • The Check-Effects-Interactions pattern: (1) validate inputs, (2) update state, (3) emit events / call external contracts. This order prevents reentrancy attacks.
  • Always save the old value BEFORE updating state. This pattern (`address old = current; current = new; emit Event(old, new)`) makes events accurately reflect the change.
  • For production contracts, consider using OpenZeppelin's AccessControl instead of Ownable — it supports multiple roles with fine-grained permissions.
  • The `external` keyword means this function can only be called from outside the contract (not internally). It's slightly cheaper than `public` for functions that don't need internal access.
Common Mistakes
  • Not checking for the zero address — setting taskRegistry to address(0) would permanently break minting, since no address could match the check `msg.sender == taskRegistry`.
  • Updating state before validation (putting `taskRegistry = _new` before the zero-address check) — if the check reverts, the state change is undone, but it's still bad practice.
  • Not emitting an event for admin changes — without events, there's no way for users or monitoring tools to detect when critical configuration changed.
  • Using `onlyOwner` for functions that should be time-locked — in high-value protocols, instant admin changes are a governance risk.
Key Terms
Modifier
A reusable piece of code that wraps a function. `onlyOwner` checks `msg.sender == owner()` before executing the function body. The `_` placeholder marks where the function body runs.
Check-Effects-Interactions
A security pattern: first check conditions, then update state, then interact with external contracts. Prevents reentrancy by ensuring state is consistent before external calls.
external
A visibility modifier meaning the function can only be called from outside the contract. Saves gas compared to `public` because it reads arguments directly from calldata.
💡
Did You Know?

The Ownable pattern was one of the first access control mechanisms in Solidity. It's so common that many people call the owner address the 'admin key' — and tools like Etherscan label it automatically.

EffortToken.sol
115 // ============ Minting Functions ============
116 
117 /**
118 * @notice Mints tokens to a user upon task completion
119 * @dev Can only be called by the TaskRegistry contract
120 * @param to Address to receive the minted tokens
121 * @param amount Amount of tokens to mint (in wei, 10^18 = 1 EFFORT)
122 * @param taskType Type of task completed ("QUIZ" or "LESSON")
123 * @param taskId Unique identifier of the completed task
124 *
125 * Educational Note: This function demonstrates:
126 * - Access control (only authorized contract can mint)
127 * - The minting process that increases totalSupply
128 * - Event emission for transparency
129 */

The NatSpec fully documents every parameter with its type, purpose, and format. Notice 'string calldata taskType' — the calldata keyword is a gas optimization specific to external functions. The function takes 4 parameters but only 2 (to, amount) affect the actual mint; the other 2 are purely for event logging.

  • @param to — the recipient. Could be any address including contracts (tokens sent to a contract without a receive function are stuck forever)
  • @param amount — in wei (10^18 = 1 EFFORT). The frontend uses ethers.parseEther() to convert human-readable amounts
  • @param taskType — a string like 'QUIZ' or 'LESSON'. Using calldata instead of memory saves gas because the data isn't copied
  • @param taskId — links this mint to a specific task for auditability. The TaskRegistry assigns these IDs
  • The 'external' keyword means this function CANNOT be called internally — only from other contracts or EOAs
130 function mint(
131 address to,
132 uint256 amount,

Three consecutive validation checks form a security gauntlet. Each check uses the modern 'if/revert' pattern instead of require() strings. If ANY check fails, the entire transaction reverts — no state changes, no events, minimal gas wasted.

  • Line 136: ACCESS CHECK — 'Is the caller the TaskRegistry?' This is THE critical security line. Without it, anyone could mint unlimited tokens
  • Line 137: ZERO ADDRESS CHECK — Prevents minting to address(0), which would burn the tokens immediately with no way to recover them
  • Line 138: ZERO AMOUNT CHECK — Prevents meaningless mints that waste gas and pollute the event log
  • The order matters: check authorization first (cheapest to verify), then validate inputs
  • If the access check fails, the custom error UnauthorizedMinter(msg.sender) is returned — the frontend can decode who tried to mint
133 string calldata taskType,
134 uint256 taskId

This single line is where tokens are born. OpenZeppelin's internal _mint function increases totalSupply by 'amount', credits 'to' with the new tokens, and emits a standard Transfer(address(0), to, amount) event. The address(0) as 'from' is how the ERC-20 standard represents creation from nothing.

  • _mint is an INTERNAL function — only callable from within this contract (or children), never directly from outside
  • It updates two storage slots: totalSupply (global counter) and balances[to] (recipient's balance)
  • It automatically emits Transfer(address(0), to, amount) — this is the standard ERC-20 mint event that all block explorers recognize
  • If 'to' is a contract, _mint does NOT check if it can handle tokens (unlike ERC-721's safeMint). ERC-20 doesn't have a 'safe' mint pattern
135 ) external {
136 if (msg.sender != taskRegistry) revert UnauthorizedMinter(msg.sender);

After the mint succeeds, this event broadcasts the full context to the world. While _mint already emitted a standard Transfer event, our custom event adds the taskType and taskId — information that helps frontends show rich notifications and enables analytics on learning activity.

  • block.timestamp is the timestamp of the block containing this transaction — set by the validator/miner
  • This event is what our frontend's useWatchContractEvent listens for to show 'You earned EFFORT!' notifications
  • The event is emitted AFTER _mint() — if _mint reverted (e.g., overflow), this event would never fire
  • Indexing services like The Graph subscribe to this event to build queryable databases of all minting activity
137 if (to == address(0)) revert ZeroAddress();
138 if (amount == 0) revert ZeroAmount();
139 
140 _mint(to, amount);
141 
142 emit TokensMinted(to, amount, taskType, taskId, block.timestamp);
143 }

Creating New Tokens: The Mint Process

The mint function creates new tokens out of thin air — increasing totalSupply and crediting the recipient's balance. It's gated by a manual access check: `msg.sender != taskRegistry` ensures ONLY the TaskRegistry contract can mint. The function accepts metadata (taskType, taskId) purely for event logging — these don't affect the mint itself but create a rich audit trail. The internal `_mint()` from OpenZeppelin handles all the ERC-20 bookkeeping.

Why it matters

Minting is the most powerful operation in a token contract — it creates value. Understanding who can mint and under what conditions is essential for evaluating any token's economics. In EFFORT's case, tokens are only minted when users complete educational tasks, tying token issuance directly to learning activity.

Security Note

Three validation checks protect this function: (1) only the TaskRegistry can call it, (2) can't mint to the zero address, (3) can't mint zero tokens. If any check fails, the entire transaction reverts with zero gas cost beyond the base transaction fee.

Tips & Tricks
  • The `string calldata taskType` uses `calldata` instead of `memory` — this saves gas because the string data stays in the transaction calldata instead of being copied to memory.
  • OpenZeppelin's `_mint` already emits a standard `Transfer(address(0), to, amount)` event. Our custom `TokensMinted` event adds extra context (taskType, taskId) on top.
  • To calculate the mint amount in your deploy scripts: 10 EFFORT = `ethers.parseEther("10")` = 10 × 10^18 in raw units.
  • Consider adding a mint cap to prevent infinite token creation. You could add `require(totalSupply() + amount <= MAX_SUPPLY)` for a hard cap.
Common Mistakes
  • Using `memory` instead of `calldata` for external function parameters — `calldata` is read-only and cheaper. Only use `memory` when you need to modify the parameter inside the function.
  • Not gating the mint function — a public mint allows anyone to create unlimited tokens, making the token worthless. Always restrict who can mint.
  • Confusing `_mint` (internal, creates tokens) with `mint` (external, your public-facing function). The underscore prefix convention means 'internal use only'.
  • Minting tokens as full units (e.g., `_mint(to, 10)`) instead of with decimals (`_mint(to, 10 * 10**18)`). The `ether` keyword is a shorthand for `* 10**18`.
Key Terms
_mint()
An internal OpenZeppelin function that creates new tokens. It increases totalSupply, credits the recipient's balance, and emits a Transfer event from address(0).
calldata
A read-only data location for external function parameters. Cheaper than `memory` because it doesn't require copying. Only available for `external` functions.
msg.sender
The address of the immediate caller. When the TaskRegistry calls mint(), msg.sender is the TaskRegistry's contract address, NOT the original user.
totalSupply
The total number of tokens in existence. Increases on mint, decreases on burn. Inherited from ERC-20 and auto-tracked by OpenZeppelin.
💡
Did You Know?

The pattern of having a separate 'minter' contract (TaskRegistry) is called the 'separation of concerns' principle. The token contract only knows HOW to mint — the TaskRegistry decides WHEN and WHY. This makes each contract simpler and easier to audit.

EffortToken.sol
145 // ============ Spending Functions ============
146 
147 /**
148 * @notice Burns tokens when a user purchases premium content
149 * @dev Can only be called by the ContentStore contract
150 * @param user Address of the user spending tokens
151 * @param amount Amount of tokens to spend
152 * @param contentId ID of the content being purchased
153 *
154 * Educational Note: This demonstrates the burn pattern where tokens
155 * are permanently removed from circulation when spent.

Similar structure to mint() but with different parameters and purpose. This function destroys tokens instead of creating them. Notice 'user' instead of 'to' — because the ContentStore is calling this ON BEHALF of the user, we need to explicitly pass whose tokens to burn.

  • @param user — whose tokens to burn. The ContentStore passes this — it's NOT msg.sender (which would be the ContentStore's address)
  • @param amount — tokens to destroy. The ContentStore must verify the user agreed to this spending before calling
  • @param contentId — identifies what was purchased. The ContentStore maps these IDs to actual premium content
  • The NatSpec calls out the burn pattern explicitly — educational clarity is part of this contract's design philosophy
156 */
157 function spendForContent(
158 address user,

Identical triple-guard pattern as mint(). The ContentStore authorization check is critical — without it, any contract or wallet could burn arbitrary users' tokens. The zero checks prevent accidental burns that would confuse the event log.

  • Line 162: Only the ContentStore can call this — prevents malicious token burning
  • Line 163: Can't burn from the zero address — _burn would fail anyway, but explicit is better than implicit
  • Line 164: Can't burn zero tokens — prevents meaningless transactions
  • Think of these three lines as a security firewall — each blocks a different class of invalid operation
159 uint256 amount,
160 uint256 contentId

The opposite of _mint(). OpenZeppelin's internal _burn decreases the user's balance and totalSupply, then emits Transfer(user, address(0), amount). The address(0) as 'to' is how ERC-20 represents permanent destruction.

  • _burn checks that user has sufficient balance — if they don't, the entire transaction reverts with an ERC-20 InsufficientBalance error
  • After burning, those tokens can NEVER be recovered — they're permanently removed from the total supply
  • The automatic Transfer(user, address(0), amount) event is how Etherscan tracks and displays burn transactions
161 ) external {
162 if (msg.sender != contentStore) revert UnauthorizedContentStore(msg.sender);

Creates a permanent on-chain receipt of the purchase. While _burn already logged the token destruction, this custom event adds the contentId — telling analytics exactly WHAT was bought, not just that tokens were burned.

  • This event + TokensMinted together form the complete economic history of every token
  • The contentId parameter enables purchase analytics: which content is most popular? What's the average spending?
  • Frontend can listen for this event to show 'Content unlocked!' confirmations
163 if (user == address(0)) revert ZeroAddress();
164 if (amount == 0) revert ZeroAmount();
165 
166 _burn(user, amount);
167 
168 emit TokensSpentForContent(user, amount, contentId, block.timestamp);
169 }

Burning Tokens: Deflationary Mechanics

The spendForContent function is the mirror of mint — it destroys tokens permanently. When a user 'spends' EFFORT on premium content, the tokens are burned (removed from circulation), reducing totalSupply. The ContentStore contract calls this function, and the `_burn()` internal function from OpenZeppelin handles the balance reduction. Like mint, it logs metadata (contentId) for auditability.

Why it matters

This creates a deflationary pressure mechanism: tokens enter circulation through learning (mint) and leave through spending (burn). This is a common tokenomics pattern — understanding mint/burn dynamics helps you evaluate any token's supply mechanics. The burn pattern also means tokens aren't just transferred to a treasury; they're genuinely destroyed.

Security Note

The burn operation will revert if the user doesn't have enough tokens — OpenZeppelin's _burn checks the balance internally. The ContentStore authorization prevents anyone from maliciously burning another user's tokens.

Tips & Tricks
  • Burn vs Transfer-to-treasury: burning reduces totalSupply, while transferring to a treasury keeps supply constant but removes tokens from circulation. Each has different tokenomics implications.
  • OpenZeppelin's `_burn` emits a standard `Transfer(user, address(0), amount)` event — that's how block explorers detect and display burn transactions.
  • If you want users to burn their own tokens voluntarily, you could add a public `burn(uint256 amount)` function. This contract restricts burning to the ContentStore for controlled economics.
  • Watch your token's burn rate vs mint rate. If burns exceed mints, the token becomes increasingly scarce — a dynamic you can track via event indexing.
Common Mistakes
  • Burning from the wrong address — `_burn(msg.sender, amount)` burns the ContentStore's tokens, not the user's. Always use the `user` parameter passed by the caller.
  • Not checking if the user approved the burn — in this pattern, the ContentStore is trusted to handle authorization. In other designs, you'd use the approve/transferFrom pattern before burning.
  • Burning tokens without emitting a custom event — while _burn emits a Transfer event, the custom TokensSpentForContent event tells your frontend WHAT was purchased, not just that tokens moved.
  • Creating a burn function without access control — if anyone can call burn on other users' tokens, it's a critical vulnerability.
Key Terms
_burn()
An internal OpenZeppelin function that destroys tokens. It decreases the user's balance and totalSupply, then emits a Transfer event to address(0).
Deflationary
A token whose supply decreases over time through burning. The opposite of inflationary (unbounded minting). EFFORT has both mint and burn, creating dynamic supply.
Burn Address
In ERC-20, burns are represented as transfers TO address(0). Some protocols use a dead address (0xdead...) instead, which keeps totalSupply unchanged.
💡
Did You Know?

Ethereum itself became deflationary after EIP-1559 (August 2021) — base fees are burned instead of going to miners. On high-activity days, more ETH is burned than created, making it a 'ultra sound money'. EFFORT's burn mechanism works on the same principle at the token level.

EffortToken.sol
171 // ============ View Functions ============
172 
173 /**
174 * @notice Returns the learning tier based on token balance
175 * @param account Address to check
176 * @return tier The user's current learning tier (0-4)
177 *
178 * Tiers:
179 * 0 - Newcomer (0-49 EFFORT)
180 * 1 - Learner (50-199 EFFORT)
181 * 2 - Scholar (200-499 EFFORT)
182 * 3 - Expert (500-999 EFFORT)
183 * 4 - Master (1000+ EFFORT)
184 */
185 function getLearningTier(address account) external view returns (uint256 tier) {

A view function that computes a user's learning tier from their token balance. 'external view' means it can only be called from outside and doesn't modify state — making it completely free to call. The named return 'returns (uint256 tier)' declares the return variable in the signature.

  • 'view' — reads blockchain state (balanceOf) but doesn't write. Free when called off-chain via eth_call
  • 'returns (uint256 tier)' — named return. The variable 'tier' is automatically declared in the function scope
  • The NatSpec documents every tier level with its range — this documentation shows up on Etherscan
  • No access control needed — anyone can check anyone's tier. This is intentional transparency
186 uint256 balance = balanceOf(account);
187 
188 if (balance >= 1000 ether) return 4; // Master
189 if (balance >= 500 ether) return 3; // Expert
190 if (balance >= 200 ether) return 2; // Scholar
191 if (balance >= 50 ether) return 1; // Learner
192 return 0; // Newcomer
193 }

Simple descending if-chain that maps token balances to tier levels 0-4. The checks go from highest to lowest — this is actually the optimal order because most users will be low-tier, meaning they'll fall through all checks to the default (fast path for common case, but clear intent for readers).

  • balanceOf(account) — inherited from ERC20, returns the user's current token balance in wei
  • 1000 ether = 1000 × 10^18 — the 'ether' keyword is just a multiplier, not related to actual ETH
  • Tier 4 (Master): ≥ 1000 EFFORT — the highest achievement. Requires significant learning commitment
  • Tier 3 (Expert): ≥ 500 EFFORT — advanced learner
  • Tier 2 (Scholar): ≥ 200 EFFORT — intermediate level
  • Tier 1 (Learner): ≥ 50 EFFORT — just getting started
  • Tier 0 (Newcomer): < 50 EFFORT — the default, returned by the final 'return 0'
  • Tiers are computed live from the balance — no separate storage needed. If a user spends tokens and drops below a threshold, their tier automatically decreases
194 
195 /**
196 * @notice Returns the tier name for a given tier level
197 * @param tier The tier level (0-4)
198 * @return name The human-readable tier name
199 */
200 function getTierName(uint256 tier) external pure returns (string memory name) {

Converts a numeric tier (0-4) to a human-readable string. The 'pure' keyword means this function doesn't read ANY blockchain state — it's a pure computation from input to output. This makes it even cheaper than 'view' and theoretically executable entirely client-side.

  • 'pure' — stricter than 'view'. No state reads, no balance checks, no storage access. Just input → output
  • 'string memory name' — returns a string stored in memory (temporary). Strings are dynamically-sized in Solidity
  • Returns 'Unknown' for invalid tier values (> 4) — defensive coding against unexpected inputs
  • In practice, the frontend could replicate this logic in JavaScript — but having it on-chain ensures consistency and provides a canonical source of truth
  • This is the last function before the closing brace — the contract is complete
201 if (tier == 0) return "Newcomer";
202 if (tier == 1) return "Learner";
203 if (tier == 2) return "Scholar";
204 if (tier == 3) return "Expert";
205 if (tier == 4) return "Master";
206 return "Unknown";
207 }
208}

Reading State: Free and Fast

View functions read blockchain state without modifying it — they cost ZERO gas when called externally (not from another contract). `getLearningTier` maps a user's balance to a tier level (0-4) using simple threshold checks. `getTierName` converts a numeric tier to a human-readable string. The `pure` keyword on getTierName means it doesn't even read storage — it's a pure computation. The `ether` keyword in Solidity is a unit that multiplies by 10^18 (the standard ERC-20 decimal places).

Why it matters

These gamification functions turn a simple token balance into a progression system. The tier system gives users visible milestones to work toward. Importantly, tiers are computed on-the-fly from the balance rather than stored separately — this means they automatically update when the balance changes, with no extra transactions needed.

Tips & Tricks
  • The `view` and `pure` keywords aren't just documentation — they're enforced by the compiler. A `view` function that tries to modify state will fail to compile.
  • Call view functions from your frontend with wagmi's `useReadContract` hook — they resolve instantly from the node's local state with no transaction needed.
  • The `ether` keyword (e.g., `1000 ether`) is just shorthand for `1000 * 10**18`. It works with any token, not just ETH — because ERC-20 tokens conventionally use 18 decimal places.
  • Named return variables (e.g., `returns (uint256 tier)`) let you skip the explicit `return` statement and just assign to the variable. In this contract, we use explicit returns for clarity.
Common Mistakes
  • Calling a view function from within a transaction (another contract's non-view function) — in that context, it DOES cost gas, because the node must execute it as part of the transaction.
  • Using `ether` keyword thinking it represents actual ETH — in the context of `balanceOf`, it's just the number 10^18 applied to token balances. It's a Solidity unit, not a currency reference.
  • Ordering tier checks from lowest to highest — this wastes gas because most users are low-tier, and the function checks the highest tiers first. In this contract, it checks 1000 first, which is actually optimal if most users are low-tier (fails fast for common cases).
  • Returning strings from smart contracts for UI display — strings are expensive on-chain. A better pattern (used here) is to return a numeric tier and map it to a string in the frontend.
Key Terms
view
A function modifier meaning 'reads state but doesn't modify it'. When called externally (not part of a tx), it's free — the node computes it locally without creating a transaction.
pure
Stricter than `view` — the function doesn't read OR write state. It's a pure computation based only on its inputs. Useful for utility functions like `getTierName`.
ether (keyword)
A Solidity unit that multiplies the preceding number by 10^18. So `50 ether` = 50000000000000000000. It's a convenience unit for working with 18-decimal tokens.
Named Return
In `returns (uint256 tier)`, the variable `tier` is pre-declared in the function scope. You can assign to it directly, or use `return value` explicitly.
💡
Did You Know?

View/pure functions were free since the early days of Ethereum, but they weren't formally specified until EIP-214 introduced the STATICCALL opcode in the Byzantium hard fork (October 2017). Before that, nodes could technically charge gas for read calls.

EffortToken.sol — Deployed & verified on OP Sepolia

View on Etherscan