Project: deploy and call a trivial smart contract#

← Back to Week 4: Track exposure

This is the Days 3-4 deliverable: write a trivial Vyper contract, deploy it to a public testnet with the Ape framework, then read and change its state from a plain Python script using web3.py. There's no separate solutions file. This walkthrough is the full path from nothing to a working, deployed contract, and every file it describes is already sitting in this folder, complete and ready to run once you fill in your own address, RPC URL, and key.

Read the notes in ../notes/ first if you haven't (private keys, transactions and gas, testnets, and smart contracts). This walkthrough assumes that vocabulary and only re-explains things in the context of the actual commands you're running.

If you haven't already, work through ../exercises/00-guided-write-greeter.md before Step 2 below. It has you type the contract yourself, one piece at a time, compiling after each one, before you've read any explanation of the finished version. Step 2 below is then a second pass over the same contract, useful for extra depth, not your first exposure to it.

Everything here targets Sepolia, the standard public Ethereum testnet, using the RPC endpoint and node provider account (Infura, Alchemy, or similar) you set up in Week 0. If your provider gives you a different testnet, the same steps apply; just swap the network name.

Step 0: generate a testnet keypair#

You need an address and a private key to work with before anything else. Rather than pulling one out of thin air, generate a real one, so you see exactly where these numbers come from. In a Python shell:

from eth_account import Account
acct = Account.create()
print("Address:", acct.address)
print("Private key:", acct.key.hex())

(eth_account is already installed; it's a dependency of web3, which you installed in Week 0.)

Write both values down somewhere temporarily safe, like a password manager or a local, git-ignored text file; you'll need the private key twice in this walkthrough, once for Ape and once directly in a Python script. This is also the moment to actually feel what the private keys note was describing: that private key is now the one and only thing standing between "you control this address" and "you don't." Nothing you did just now registered this address with anyone. It's pure math, and it exists whether or not you ever use it.

Step 1: fund the address from a faucet#

Go to a Sepolia faucet (your node provider's dashboard usually links to one; searching "Sepolia faucet" also works) and paste in the address from Step 0, never the private key, to receive some free test ETH. This test ETH is what pays for gas when you deploy and interact with the contract below. Give it a minute or two to arrive; testnets aren't always instant.

Step 2: set up the Ape project#

Ape expects a contracts/ folder for Vyper source and a scripts/ folder for runnable Python scripts. This folder already has both, along with a minimal ape-config.yaml. Ape picks its compiler plugin per file extension: a .vy file here is compiled by ape-vyper, the same way a .sol file in the Solidity track is compiled by ape-solidity. (Ape also has an ape init command that scaffolds this structure interactively if you're ever starting from a blank folder.)

Look at contracts/Greeter.vy. It's short. Line by line:

# SPDX-License-Identifier: MIT
#pragma version ^0.4.0

The first line is a license marker Vyper tooling expects to see; MIT is a common permissive open-source license and a safe default for learning code. The second line pins which version of the Vyper compiler this file is written for; #pragma version ^0.4.0 means "0.4.0 or any later compatible 0.4.x release," which protects the file from silently breaking if the compiler changes behavior in a future major version. Unlike Solidity, where pragma is its own keyword, Vyper's version pin is written as a specially recognized comment (notice the # prefix), but it serves exactly the same purpose and Ape checks it the same way.

greeting: String[280]

Vyper has no contract keyword at all, unlike Solidity's contract Greeter { ... } wrapper; every .vy file is implicitly the one contract it defines, and Ape names the deployed contract after the filename itself (Greeter, from Greeter.vy). greeting: String[280] declares a state variable: a piece of data that lives permanently in this contract's own storage on the blockchain once deployed, not just for the duration of one function call. Leaving it as plain greeting: String[280], without wrapping it in public(...), is Vyper's equivalent of Solidity's private: nothing outside this contract's own code can read or write it directly, only through the functions this contract chooses to expose, like get_greeting below. String[280] is also new: Vyper requires every string to declare a maximum length in bytes up front, unlike Solidity's unbounded string. Try to store something longer than that bound and Vyper reverts automatically, with no opt-out, the same uncompromising philosophy behind Vyper always reverting on arithmetic overflow (Week 5 covers that in more depth). 280 is simply a generous ceiling for a short greeting; nothing about this project depends on that exact number.

event GreetingChanged:
    changed_by: indexed(address)
    old_greeting: String[280]
    new_greeting: String[280]

An event is a log entry a transaction can emit. It costs a little gas to emit one, but far less than storage, and it's specifically designed to be efficiently watchable from the outside. Off-chain code (a Python script using web3.py) can watch for GreetingChanged events instead of having to constantly poll "has the greeting changed?" This week's project doesn't listen for it yet; Week 6 builds directly on this exact pattern (reacting to on-chain events as they happen), so the contract emits one now to set that up. Declaring an event in Vyper is its own indented block, one field per line, instead of Solidity's single-line parameter list; emitting one later needs the log keyword plus keyword arguments matching these field names exactly, neither of which Solidity requires.

@deploy
def __init__(initial_greeting: String[280]):
    self.greeting = initial_greeting

The constructor runs exactly once, at the moment of deployment, and never again. @deploy is Vyper's equivalent of Solidity's constructor(...) keyword: a decorator marking this one function as the constructor, rather than a special name or keyword Vyper looks for. initial_greeting: String[280] needs no memory keyword the way Solidity's string memory initialGreeting does; Vyper doesn't expose a separate memory/storage location system at the language surface for parameters and local variables like this one. A parameter is just a value. The only way it ever becomes part of permanent contract storage is the explicit assignment on the next line, self.greeting = initial_greeting, writing into state, the same idea as Solidity's greeting = initialGreeting;, just always spelled with self. in Vyper, with no exceptions, anywhere.

@external
@view
def get_greeting() -> String[280]:
    return self.greeting

@view marks this function as read-only: it looks at stored data but never changes it. That has a real, practical consequence: calling a view function costs no gas and needs no signed transaction, because no node has to permanently update anything. It's answered directly by whichever node you ask. @external marks it as callable from outside the contract at all; Vyper requires an explicit decorator like this on every function to say so, where Solidity's public keyword does the same job as part of the function signature itself. (Vyper also has @internal, for functions only callable from other functions in this same contract. Greeter doesn't need one, but you'll meet the idea again in an access-control exercise later this week.)

@external
def set_greeting(new_greeting: String[280]):
    old_greeting: String[280] = self.greeting
    self.greeting = new_greeting
    log GreetingChanged(old_greeting=old_greeting, new_greeting=new_greeting, changed_by=msg.sender)

This function has no @view, because it changes stored data (self.greeting = new_greeting), so calling it for real requires a signed, gas-paying transaction. old_greeting: String[280] = self.greeting copies the current value out of storage into a local variable; Vyper needs no separate keyword for that copy the way Solidity's string memory oldGreeting = greeting; needs memory, and there's no way to accidentally get a live, mutable reference back into storage instead, the way Solidity's storage references allow. msg.sender is filled in automatically by the network as whichever address actually signed and sent that transaction, exactly like Solidity; there's no way for a caller to lie about who they are here, because the network itself checked the signature before running this code at all. log GreetingChanged(...) is how Vyper actually emits an event: the log keyword is required, and every field is passed by keyword, matching the event's declared field names exactly, unlike Solidity's positional emit GreetingChanged(oldGreeting, newGreeting, msg.sender);.

Step 3: compile#

From inside this project/ folder, in a terminal:

ape compile

This runs the Vyper compiler (via the ape-vyper plugin) against everything in contracts/, turning Greeter.vy into bytecode the network can actually run, plus an ABI describing its functions (more on the ABI in Step 6). Ape caches the result; you only need to re-run this after changing the contract's source.

Step 4: import your test account into Ape#

ape accounts import testnet-key

Ape will prompt you to paste the private key from Step 0, then to set a local passphrase. This encrypts the key and stores it on your own machine (inside Ape's own config folder), not inside this project, and never inside anything git tracks. From now on, whenever a script asks Ape to use the account named testnet-key, Ape will prompt you for that passphrase before it signs anything, so a private key never needs to sit in plain text in any file you write.

Step 5: deploy#

Open scripts/deploy.py. It loads the account you just imported, then calls account.deploy(project.Greeter, "Hello from Jenesys!"), which builds the deployment transaction, signs it with your test account, sends it, waits for it to be confirmed, and hands back an object you can immediately call read functions on.

Run it:

ape run deploy --network ethereum:sepolia:<your-rpc-url>

Substitute <your-rpc-url> with the actual HTTPS endpoint from your node provider account (Week 0). Ape's --network flag accepts a raw URL directly in the provider slot like this, which avoids needing to install and configure a separate provider plugin just for this trivial example. (If your installed version of Ape doesn't accept a URL this way, install a node provider plugin instead, for example ape plugins install infura or ape plugins install alchemy, and follow that plugin's own setup instructions for where it expects your provider credentials.)

Ape will ask for the passphrase you set in Step 4, then print the deployed contract's address. Write that address down; you'll need it in Step 7.

Step 6: export the ABI#

An ABI (Application Binary Interface) is a plain description of what functions a contract has, what arguments each one expects, and what it returns. This matters because a contract's compiled bytecode, sitting on the blockchain, doesn't carry readable function names or type information with it. Without an ABI, a tool like web3.py would have no way to know that this contract even has a function called get_greeting, let alone how to correctly format a call to it. Ape already knows the ABI internally, from compiling in Step 3; scripts/get_abi.py just writes it out to a plain JSON file so a different library, web3.py, can read it too.

ape run get_abi --network ethereum:sepolia:<your-rpc-url>

This writes greeter_abi.json into this project/ folder, right next to interact.py.

Step 7: fill in and run the web3.py script#

Open interact.py. Replace CONTRACT_ADDRESS with the address Step 5 printed. Set the two environment variables it needs (same idea as Week 0's API key setup):

macOS/Linux:

export WEB3_PROVIDER_URI="your-rpc-url"
export TESTNET_PRIVATE_KEY="your-private-key-from-step-0"

Windows (PowerShell):

$env:WEB3_PROVIDER_URI = "your-rpc-url"
$env:TESTNET_PRIVATE_KEY = "your-private-key-from-step-0"

Then run it:

python interact.py

Walk through what it's doing:

  • Connects directly to the same RPC endpoint you used to deploy, via Web3.HTTPProvider, and checks that the connection actually worked.
  • Loads the contract using w3.eth.contract(address=..., abi=...). From this point on, greeter.functions.get_greeting() and greeter.functions.set_greeting(...) exist as real, callable Python methods, entirely because the ABI told web3.py they exist and what shape to expect.
  • Calls get_greeting() with .call(), a free, read-only request, and prints the current greeting.
  • Loads your account from the raw private key (w3.eth.account.from_key). This is the same account Ape has been using, but here you're handling the raw key text directly yourself, at a lower level than Ape's encrypted account management, which is exactly why it only ever comes from an environment variable and never appears written in this file.
  • Builds a set_greeting transaction, including chainId, which ties a signed transaction to one specific network so it can't be replayed somewhere else, signs it locally with your key, and broadcasts the raw signed bytes with send_raw_transaction.
  • Waits for the transaction to be mined with wait_for_transaction_receipt, then calls get_greeting() again to prove the change actually happened.

You should see the greeting print once as "Hello from Jenesys!" (whatever Step 5's deploy script set), then, after the transaction confirms, print again as "Hello from web3.py!".

What to notice#

  • You just used two different tools to control the exact same account: Ape, which kept the private key encrypted on disk and asked for a passphrase, and web3.py, which required you to hand it the raw key text yourself. Both are legitimate; Ape's approach is safer for real use, and web3.py's lower-level approach is exactly what a backend service needs when it's the one holding and using a key on its own, with no human around to type a passphrase, which is precisely what Week 6's managed-key service builds on.
  • The deploy transaction and the set_greeting transaction both cost testnet gas, paid from the balance the faucet gave you in Step 1. Run get_greeting() a few more times on its own and notice it costs nothing to call repeatedly; only the state-changing call did.
  • If interact.py fails on the raw_transaction line with an AttributeError, that's the exact version-mismatch case flagged in a comment in that file: try signed_transaction.rawTransaction instead. This is real API drift between versions of web3.py, not a mistake in your setup, and reasoning through a mismatch like this, docs versus what's actually installed, is the debugging skill Foundations was training you for.