5/5
## Vyper Fundamentals Recap: Building the "Buy Me A Coffee" Contract This lesson serves as a review of the fundamental Vyper concepts we've explored by examining the "Buy Me A Coffee" smart contract project. We built this contract within the Remix IDE, focusing on creating a minimal yet professional-looking example. Understanding these core building blocks is crucial before moving on to more advanced topics and development environments. ## Vyper Contract Structure and Documentation A professional smart contract starts with clear structure and documentation. We utilized NatSpec (Natural Specification) comments for this purpose. * **NatSpec:** Placed at the top of the file using triple-quoted strings (`"""..."""`), NatSpec provides standardized documentation. We used tags like `@title`, `@author`, `@notice`, and `@license` to describe the contract's purpose, creator, notes for users, and licensing. This is essential for others (and your future self) to understand the contract's intent. Function-level documentation using docstrings (`"""..."""` within the function) was also used, for example, in the `withdraw` function. ## State Management: Constants, Immutables, and Storage Managing state efficiently and securely is paramount in smart contracts. Vyper provides different variable types with distinct characteristics impacting mutability and gas costs: * **Constants:** Declared using `constant(type) = value`. These values are hardcoded at compile time and cannot be changed. They offer significant gas savings as their values are directly embedded in the bytecode. Example: `MINIMUM_USD: public(constant(uint256)) = as_wei_value(5, "ether")` and `PRECISION: constant(uint256) = 1 * (10 ** 18)`. * **Immutables:** Declared using `immutable(type)`. These variables are set *once* during contract deployment (typically within the `__init__` constructor) and cannot be changed afterward. They are stored in the contract's code rather than storage, providing substantial gas savings compared to storage variables. Examples: `PRICE_FEED: public(immutable(AggregatorV3Interface))` and `OWNER: public(immutable(address))`. * **Storage Variables:** These are standard state variables whose values are stored on the blockchain's state trie. They can be modified throughout the contract's lifecycle (unless explicitly protected). They consume more gas for reads and writes compared to constants and immutables. Examples: `funders: public(DynArray[address, 1000])` and `funder_to_amount_funded: public(HashMap[address, uint256])`. Using constants and immutables wherever possible is a key gas optimization technique. ## Essential Functions: Initialization, Funding, and Withdrawal Our contract performs three primary actions: initialization, receiving funds, and allowing the owner to withdraw funds. * **Constructor (`__init__`)**: This special function executes only *once* when the contract is deployed. We used it to initialize our immutable variables: `PRICE_FEED` (using the address passed during deployment) and `OWNER` (set to `msg.sender`, the address deploying the contract). * **Funding (`_fund`, `__default__`)**: To receive Ether, functions must be marked with the `@payable` decorator. We created an internal `_fund` function containing the core funding logic: 1. Get the USD value of the incoming Ether using a helper function (`_get_eth_to_usd_rate`) that interacts with the price feed. 2. Check if the value meets the minimum requirement (`assert usd_value_of_eth >= MINIMUM_USD`). 3. Record the funder's address (`msg.sender`) in the `funders` array (`self.funders.append(msg.sender)`). 4. Update the total amount funded by that address in the `funder_to_amount_funded` mapping (`self.funder_to_amount_funded[msg.sender] += msg.value`). `msg.value` holds the amount of Ether sent with the transaction. We also implemented the `__default__` function. This special, `@payable` function is executed if Ether is sent to the contract address without specifying a function call. We simply made it call `self._fund()`, providing a convenient way for users to send Ether directly to the contract. * **Withdrawal (`withdraw`)**: This function allows the owner to retrieve all Ether held by the contract. 1. **Access Control:** The first crucial step is ensuring only the owner can withdraw. We implemented this using `assert msg.sender == OWNER, "Not the contract owner!"`. If this condition is false, the transaction reverts. 2. **Sending Ether:** The `send()` built-in function transfers Ether. We used `send(OWNER, self.balance)` to send the contract's entire Ether balance (`self.balance`) to the `OWNER` address. 3. **Resetting State:** To prepare for future funding rounds (optional but good practice in this example), we reset the funding records. This involved iterating through the `funders` array using a `for` loop and setting the corresponding value in the `funder_to_amount_funded` mapping back to zero. Finally, we cleared the `funders` array itself (`self.funders = []`). ## Interacting with the Outside World: Interfaces and Oracles Smart contracts often need data from or interaction with other contracts. * **Interfaces:** To call functions on another contract, we first need to define its structure using an `interface`. An interface declares the function signatures (name, arguments, return types, mutability) without providing the implementation. This gives our contract the Application Binary Interface (ABI) information needed to interact. We defined `AggregatorV3Interface` to match the functions we needed from the Chainlink Price Feed contract (`latestAnswer`, `decimals`, etc.). * **Oracles (Chainlink Price Feeds):** Oracles provide real-world data to the blockchain. We used a Chainlink ETH/USD Price Feed contract (address found on Sepolia Etherscan) as our oracle. The address was passed into the constructor and stored in the immutable `PRICE_FEED` variable, typed as our defined interface: `PRICE_FEED: public(immutable(AggregatorV3Interface))`. * **External Calls (`staticcall`)**: To *read* data from the price feed contract without potentially changing its state, we used `staticcall`. `price: int256 = staticcall PRICE_FEED.latestAnswer()` safely calls the `latestAnswer` function on the contract at the `PRICE_FEED` address. This contrasts with `extcall`, which would be used for calls that *might* modify the external contract's state. * **Type Conversion (`convert`)**: External calls might return data in a type different from what's needed internally. The Chainlink feed's `latestAnswer` returns `int256`. We used the `convert()` built-in function to cast this value to `uint256` for our calculations: `eth_price: uint256 = convert(price, uint256)`. ## Working with Collections: Arrays vs. Mappings We used two primary data structures to track funders and their contributions, demonstrating their different characteristics: * **Dynamic Arrays (`DynArray`)**: `funders: public(DynArray[address, 1000])`. Dynamic arrays can grow in size (up to the declared maximum, 1000 here). Elements are added using `.append()`. Their current size can be checked using `len()`. They are suitable when the number of elements isn't known beforehand but iteration is required. We used it to keep an ordered list of funders, which was necessary for the state reset logic in `withdraw`. Contrast this with fixed-size arrays (`uint256[100]`), which have a fixed size at declaration, cannot use `.append()` or `len()`, and are fully initialized. * **Mappings (`HashMap`)**: `funder_to_amount_funded: public(HashMap[address, uint256])`. Mappings provide efficient key-value storage and lookup (`O(1)` complexity). You access values using `mapping[key]`. However, they don't have a built-in length, and iterating over all keys/values is not directly supported (often requiring a separate structure, like our `funders` array, to track keys). They are ideal for quickly checking or updating the value associated with a specific key (like a funder's total donation). The choice between arrays and mappings depends on whether you need iteration/ordering capabilities (favoring arrays) or fast key-based lookups (favoring mappings). Resetting mappings typically involves iterating over known keys (often stored in an array) as we did in the `withdraw` function. ## Handling Value: Precision in Calculations Performing calculations involving monetary values or decimals requires care due to integer-only arithmetic in the EVM. * **Integer Division:** Vyper, like Solidity, performs integer division, discarding any remainder. * **Precision:** To handle decimals, especially when dealing with price feeds that have their own decimal precision, we multiply *before* dividing. We retrieved the Chainlink price (which has 8 decimals) and the amount sent (`msg.value` in wei, 18 decimals). To calculate the USD value accurately, we incorporated a `PRECISION` constant (`1 * 10**18`) into our calculations in `_get_eth_to_usd_rate`, effectively working with scaled-up integer values to avoid losing precision during intermediate steps. Using named constants like `PRECISION` is crucial for readability and maintainability, avoiding "magic numbers". ## Recap and Next Steps Congratulations! By building and reviewing the "Buy Me A Coffee" contract, you've solidified your understanding of Vyper's fundamental concepts: contract structure, documentation (NatSpec), state variables (constants, immutables, storage) and gas implications, core functions (`__init__`, `@payable`, `__default__`), access control (`assert`), sending Ether (`send`), inter-contract communication (interfaces, `staticcall`), data structures (`DynArray`, `HashMap`), loops (`for`), and precision handling. These concepts are the bedrock of smart contract development, not just in Vyper but across different languages like Solidity as well. Remember to leverage resources like AI assistants and discussion forums if you encounter challenges. This marks the conclusion of our work within the Remix IDE. Take a well-deserved break. When you return, we will transition to more advanced tools (Python, Mocassin, Titanoboa) to explore topics like Events, Hashing, Modules, and robust Testing methodologies.
This lesson serves as a review of the fundamental Vyper concepts we've explored by examining the "Buy Me A Coffee" smart contract project. We built this contract within the Remix IDE, focusing on creating a minimal yet professional-looking example. Understanding these core building blocks is crucial before moving on to more advanced topics and development environments.
A professional smart contract starts with clear structure and documentation. We utilized NatSpec (Natural Specification) comments for this purpose.
NatSpec: Placed at the top of the file using triple-quoted strings ("""..."""
), NatSpec provides standardized documentation. We used tags like @title
, @author
, @notice
, and @license
to describe the contract's purpose, creator, notes for users, and licensing. This is essential for others (and your future self) to understand the contract's intent. Function-level documentation using docstrings ("""..."""
within the function) was also used, for example, in the withdraw
function.
Managing state efficiently and securely is paramount in smart contracts. Vyper provides different variable types with distinct characteristics impacting mutability and gas costs:
Constants: Declared using constant(type) = value
. These values are hardcoded at compile time and cannot be changed. They offer significant gas savings as their values are directly embedded in the bytecode. Example: MINIMUM_USD: public(constant(uint256)) = as_wei_value(5, "ether")
and PRECISION: constant(uint256) = 1 * (10 ** 18)
.
Immutables: Declared using immutable(type)
. These variables are set once during contract deployment (typically within the __init__
constructor) and cannot be changed afterward. They are stored in the contract's code rather than storage, providing substantial gas savings compared to storage variables. Examples: PRICE_FEED: public(immutable(AggregatorV3Interface))
and OWNER: public(immutable(address))
.
Storage Variables: These are standard state variables whose values are stored on the blockchain's state trie. They can be modified throughout the contract's lifecycle (unless explicitly protected). They consume more gas for reads and writes compared to constants and immutables. Examples: funders: public(DynArray[address, 1000])
and funder_to_amount_funded: public(HashMap[address, uint256])
.
Using constants and immutables wherever possible is a key gas optimization technique.
Our contract performs three primary actions: initialization, receiving funds, and allowing the owner to withdraw funds.
Constructor (__init__
): This special function executes only once when the contract is deployed. We used it to initialize our immutable variables: PRICE_FEED
(using the address passed during deployment) and OWNER
(set to msg.sender
, the address deploying the contract).
Funding (_fund
, __default__
): To receive Ether, functions must be marked with the @payable
decorator. We created an internal _fund
function containing the core funding logic:
Get the USD value of the incoming Ether using a helper function (_get_eth_to_usd_rate
) that interacts with the price feed.
Check if the value meets the minimum requirement (assert usd_value_of_eth >= MINIMUM_USD
).
Record the funder's address (msg.sender
) in the funders
array (self.funders.append(msg.sender)
).
Update the total amount funded by that address in the funder_to_amount_funded
mapping (self.funder_to_amount_funded[msg.sender] += msg.value
). msg.value
holds the amount of Ether sent with the transaction.
We also implemented the __default__
function. This special, @payable
function is executed if Ether is sent to the contract address without specifying a function call. We simply made it call self._fund()
, providing a convenient way for users to send Ether directly to the contract.
Withdrawal (withdraw
): This function allows the owner to retrieve all Ether held by the contract.
Access Control: The first crucial step is ensuring only the owner can withdraw. We implemented this using assert msg.sender == OWNER, "Not the contract owner!"
. If this condition is false, the transaction reverts.
Sending Ether: The send()
built-in function transfers Ether. We used send(OWNER, self.balance)
to send the contract's entire Ether balance (self.balance
) to the OWNER
address.
Resetting State: To prepare for future funding rounds (optional but good practice in this example), we reset the funding records. This involved iterating through the funders
array using a for
loop and setting the corresponding value in the funder_to_amount_funded
mapping back to zero. Finally, we cleared the funders
array itself (self.funders = []
).
Smart contracts often need data from or interaction with other contracts.
Interfaces: To call functions on another contract, we first need to define its structure using an interface
. An interface declares the function signatures (name, arguments, return types, mutability) without providing the implementation. This gives our contract the Application Binary Interface (ABI) information needed to interact. We defined AggregatorV3Interface
to match the functions we needed from the Chainlink Price Feed contract (latestAnswer
, decimals
, etc.).
Oracles (Chainlink Price Feeds): Oracles provide real-world data to the blockchain. We used a Chainlink ETH/USD Price Feed contract (address found on Sepolia Etherscan) as our oracle. The address was passed into the constructor and stored in the immutable PRICE_FEED
variable, typed as our defined interface: PRICE_FEED: public(immutable(AggregatorV3Interface))
.
External Calls (staticcall
): To read data from the price feed contract without potentially changing its state, we used staticcall
. price: int256 = staticcall PRICE_FEED.latestAnswer()
safely calls the latestAnswer
function on the contract at the PRICE_FEED
address. This contrasts with extcall
, which would be used for calls that might modify the external contract's state.
Type Conversion (convert
): External calls might return data in a type different from what's needed internally. The Chainlink feed's latestAnswer
returns int256
. We used the convert()
built-in function to cast this value to uint256
for our calculations: eth_price: uint256 = convert(price, uint256)
.
We used two primary data structures to track funders and their contributions, demonstrating their different characteristics:
Dynamic Arrays (DynArray
): funders: public(DynArray[address, 1000])
. Dynamic arrays can grow in size (up to the declared maximum, 1000 here). Elements are added using .append()
. Their current size can be checked using len()
. They are suitable when the number of elements isn't known beforehand but iteration is required. We used it to keep an ordered list of funders, which was necessary for the state reset logic in withdraw
. Contrast this with fixed-size arrays (uint256[100]
), which have a fixed size at declaration, cannot use .append()
or len()
, and are fully initialized.
Mappings (HashMap
): funder_to_amount_funded: public(HashMap[address, uint256])
. Mappings provide efficient key-value storage and lookup (O(1)
complexity). You access values using mapping[key]
. However, they don't have a built-in length, and iterating over all keys/values is not directly supported (often requiring a separate structure, like our funders
array, to track keys). They are ideal for quickly checking or updating the value associated with a specific key (like a funder's total donation).
The choice between arrays and mappings depends on whether you need iteration/ordering capabilities (favoring arrays) or fast key-based lookups (favoring mappings). Resetting mappings typically involves iterating over known keys (often stored in an array) as we did in the withdraw
function.
Performing calculations involving monetary values or decimals requires care due to integer-only arithmetic in the EVM.
Integer Division: Vyper, like Solidity, performs integer division, discarding any remainder.
Precision: To handle decimals, especially when dealing with price feeds that have their own decimal precision, we multiply before dividing. We retrieved the Chainlink price (which has 8 decimals) and the amount sent (msg.value
in wei, 18 decimals). To calculate the USD value accurately, we incorporated a PRECISION
constant (1 * 10**18
) into our calculations in _get_eth_to_usd_rate
, effectively working with scaled-up integer values to avoid losing precision during intermediate steps. Using named constants like PRECISION
is crucial for readability and maintainability, avoiding "magic numbers".
Congratulations! By building and reviewing the "Buy Me A Coffee" contract, you've solidified your understanding of Vyper's fundamental concepts: contract structure, documentation (NatSpec), state variables (constants, immutables, storage) and gas implications, core functions (__init__
, @payable
, __default__
), access control (assert
), sending Ether (send
), inter-contract communication (interfaces, staticcall
), data structures (DynArray
, HashMap
), loops (for
), and precision handling.
These concepts are the bedrock of smart contract development, not just in Vyper but across different languages like Solidity as well. Remember to leverage resources like AI assistants and discussion forums if you encounter challenges.
This marks the conclusion of our work within the Remix IDE. Take a well-deserved break. When you return, we will transition to more advanced tools (Python, Mocassin, Titanoboa) to explore topics like Events, Hashing, Modules, and robust Testing methodologies.
A practical review to Vyper Fundamentals Recap: Building the "Buy Me A Coffee" Contract - Reinforce core Vyper concepts like state management, function types, interfaces, and data structures through the "Buy Me A Coffee" contract. Understand NatSpec documentation, external calls, oracle interaction, and precision handling before moving beyond Remix.
Previous lesson
Previous
Next lesson
Next
Give us feedback
Course Overview
About the course
The basics of blockchain transactions, how to send and receive money on a blockchain network.
How to write Python based smart contracts using Vyper.
How to read and understand Vyper smart contracts.
Vyper data structures, arrays, structs, hash maps.
How to build a smart contract application and deploy on ZKsync with Moccasin.
Smart Contract Auditor
$100,000 - $200,000 (avg. salary)
On-chain Data Analyst
$59,000 - $139,000 (avg. salary)
DeFi Developer
$75,000 - $200,000 (avg. salary)
Smart Contract Engineer
$100,000 - $150,000 (avg. salary)
Web3 developer
$60,000 - $150,000 (avg. salary)
Web3 Developer Relations
$85,000 - $125,000 (avg. salary)
Last updated on June 11, 2025
Duration: 2h 08min
Duration: 2h 32min
Duration: 24min
Course Overview
About the course
The basics of blockchain transactions, how to send and receive money on a blockchain network.
How to write Python based smart contracts using Vyper.
How to read and understand Vyper smart contracts.
Vyper data structures, arrays, structs, hash maps.
How to build a smart contract application and deploy on ZKsync with Moccasin.
Smart Contract Auditor
$100,000 - $200,000 (avg. salary)
On-chain Data Analyst
$59,000 - $139,000 (avg. salary)
DeFi Developer
$75,000 - $200,000 (avg. salary)
Smart Contract Engineer
$100,000 - $150,000 (avg. salary)
Web3 developer
$60,000 - $150,000 (avg. salary)
Web3 Developer Relations
$85,000 - $125,000 (avg. salary)
Last updated on June 11, 2025