Calculator: Your first Seahorse program
Dip your toes in the water by writing a simple program in Seahorse.
Full code here.
Initializing the project
From your terminal:
This should initialize a new project directory called calculator. Open it up and you should see something like this:
Your code will go under the programs_py folder in calculator.py. The other directories are generated when an Anchor project is initialized. Your Seahorse/Python code lives inside the Anchor project (at programs_py) and generates intermediate Rust artifacts in the programs directory, which Anchor then compiles to bytecode for Solana.
Anchor lets us easily use on-chain programs by providing auto-generated TypeScript bindings for your program's instructions and accounts. More on that later.
Now, cd
into the calculator directory and try building the project. It might take a few minutes the first time, since none of the Rust code has been compiled yet - don't worry, subsequent compilations will take much less time.
Congrats, you've built your first Seahorse program! Unfortunately it doesn't do anything... yet.
You can inspect the generated code if you want to (at programs/calculator/src/lib.rs), but it's not that interesting. We'll fix that soon - but first, let's describe what we're trying to build.
The Calculator
The calculator is a simple four-function calculator that operates on-chain. Users will be able to:
Create a calculator associated with their wallet,
Perform operations on the number stored in the calculator,
And reset the calculator back to 0.
It's a very simple idea, but should get us used to the basics of Seahorse - creating on-chain accounts, manipulating their data with instructions, and performing simple safety checks.
Adding accounts
We need a single account type to represent a user's calculator. In Seahorse, accounts are just classes that derive from the Account
base class. Add the following to your calculator.py:
The fields of Calculator
define the data that will be stored in it. Note the type annotations: these are optional in regular Python, but in Seahorse, they're mandatory for class fields. This lets us know that each Calculator
account has an owner
Pubkey, and a display
i64 (signed integer).
Adding instructions
Now that we have an account defined, we want to be able to create instances of it and interact with them. To do this, we need to write some instructions for our program. In Seahorse, these are just normal Python functions with the @instruction
decorator attached. Add the following function def to your calculator.py:
This instruction just takes two accounts and does some stuff with them. We're introducing two new account types that are native to Seahorse - Signer
and Empty
.
Signer
accounts are special accounts belonging to whoever signed the instruction - when a user makes a transaction that calls init_calculator
, they'll sign the transaction with their Solana keypair and their account will show up as owner
in the instruction.
The Empty[Calculator]
indicates an account that is empty (i.e. has not yet been initialized) of type Calculator
.
Now let's make our accounts do something - add the following to the init_calculator
function:
Calling .init()
on an Empty
account will initialize it. Two arguments are required:
payer
, the account that will pay for the new account's rentseeds
, the program-derived address (PDA) seeds.
PDAs
PDAs are outside the scope of this tutorial, but all you need to know for now is that whenever you create an account via your program, you need to give it a list of things that will uniquely identify it among all accounts that your program has made.
In Seahorse, these can be string literals or other accounts.
.init()
passes back the new account, which we can now use as a Calculator
. All we want to do now is set the owner
field - to get the pubkey of the instruction signer, we call .key()
on it.
And there you have it, the init_calculator
instruction is done!
Simple instructions with prints and asserts
Now that we can make calculators, let's give them some life. Add a new instruction:
This instruction has two new things - a print()
statement and an assert
statement.
Assertions are the primary way to provide safety in Seahorse programs. If the assertion fails, then just like in regular Python, the instruction will exit early and log the message you give it.
Seahorse print
statements are pretty much the same as Python print
statements, and you can use them to print debug your programs. Under the hood, they get translated to an equivalant call to the Solana logger.
Here, we use an assert
to make sure that the wallet that signed the transaction is the owner of the calculator. If we don't perform this check, then anybody could reset anybody else's calculator!
Instructions with parameters and Enums
With this out of the way, we can finish implementing the calculator. We'll just add another instruction that modifies it based on two parameters that get passed in, an operation and a number.
The operation will be an enum (enumerated type), which is a type that can simply have one of any value. This allows you to keep magic numbers out of your code. Add the new class to your calculator.py
:
For parsing purposes, each variant in the enum needs a unique number associated with it. These have no bearing on the generated code.
Instruction parameters are just like normal function parameters - data you pass to the function. In a normal Solana instruction call, accounts and parameters are treated separately. Seahorse simplifies this by letting them both live together in the instruction params.
Here's the code for the new instruction:
And that's it for our calculator program! Make sure it builds before we move on:
You can see the full code here.
Testing with Anchor
Once your program builds, you can start using all the tools that Anchor provides. To Anchor, there's no difference between your Seahorse program and a hand-written Rust program. There's enough documentation on using Anchor elsewhere, but we'll still show you some basics on how to write tests and use your program.
Anchor should have generated a default test file in tests/calculator.ts. Open that and replace the code with this:
Besides some simple things from Anchor/Node, we have one interesting import - import { Calculator } from "../target/types/calculator"
. This is the power that Anchor gives us - when the Seahorse CLI builds your program using Anchor, it auto-generates TypeScript types that let you easily interact with your program through Anchor's TypeScript package.
Now, to start filling in the tests:
Some of this is Anchor boilerplate, and some is us setting up accounts for later. owner
is the address of the Anchor provider's wallet, which signs every transaction that we send to our Solana localnet. calculator
is the address of the Calculator that belongs to owner
. Again, PDAs (Program-Derived Addresses) are outside the scope of this tutorial, but take note of how this address is generated - we pass in (essentially) the same seeds that we did during our call to calculator.init()
during the init_calculator
instruction a while ago.
program
is an interface to the Calculator program, made from Anchor's auto-generated definitions.
And here's everything working together to initialize the calculator
. program.methods
gives us access to all of our program's instructions. We build the initCalculator
(init_calculator
in our Seahorse code) call with the necessary accounts, then call .rpc()
to sign + send the instruction as a transaction.
This will initialize the calculator!
Now let's do something with it - we create 3 instructions (+ 2, * 3, and - 1) to run some operations on our calculator, then package them as a single transaction and send it. Afterward, we can use Anchor to grab the calculator's data from the chain and see what its display
says. This should give us the correct result from our 3 operations.
Also note that we're passing new BN
objects to our instruction, not regular JavaScript numbers. The numeric type we used, i64
, covers a larger range of integers than JavaScript numbers can, so the conversion is needed for safety. When we grab the on-chain data, we convert back to a number as well.
Finally, let's make sure our security guarantees are as airtight as we thought. We create a new keypair called hackerman
to sign a fraudulent transaction. We'll try to get this new wallet, who does not own our calculator
, to sign a transaction resetting our display and erasing all of our hard work.
This transaction will fail due to our assertions in reset_calculator
- hackerman
does not have the same key as the calculator's owner, so an error is thrown and the transaction fails. Safe!
An error message something like this should be printed to the console - note that it contains the log from our print()
earlier:
Last updated