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:

seahorse init calculator

This should initialize a new project directory called calculator. Open it up and you should see something like this:

calculator
|\_ app
|\_ migrations
|\_ node_modules
|\_ programs
|\_ programs_py
    |\_ seahorse
    |\_ calculator.py
    :
|\_ target
|\_ tests
:

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.

cd calculator
seahorse build

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:

class Calculator(Account):
  owner: Pubkey
  display: i64

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:

@instruction
def init_calculator(owner: Signer, calculator: Empty[Calculator]):
  # Initialize the calculator and set the owner
  # ...

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:

  # ...
  # Initialize the calculator and set the owner
  calculator = calculator.init(
    payer = owner,
    seeds = ['Calculator', owner]
  )
  calculator.owner = owner.key()

Calling .init() on an Empty account will initialize it. Two arguments are required:

  • payer, the account that will pay for the new account's rent

  • seeds, 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:

@instruction
def reset_calculator(owner: Signer, calculator: Calculator):
  print(owner.key(), 'is resetting', calculator.key())


  # Verify owner
  assert owner.key() == calculator.owner, 'This is not your calculator!'


  calculator.display = 0

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:

class Operation(Enum):
  ADD = 0
  SUB = 1
  MUL = 2
  DIV = 3

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:

@instruction
def do_operation(owner: Signer, calculator: Calculator, op: Operation, num: i64):
  # Verify owner, like before
  assert owner.key() == calculator.owner, 'This is not your calculator!'


  if op == Operation.ADD:
    calculator.display += num
  elif op == Operation.SUB:
    calculator.display -= num
  elif op == Operation.MUL:
    calculator.display *= num
  elif op == Operation.DIV:
    calculator.display //= num

And that's it for our calculator program! Make sure it builds before we move on:

seahorse build

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:

import * as anchor from '@project-serum/anchor'
import { BN, Program, web3 } from '@project-serum/anchor'
const assert = require('assert')


import { Calculator } from '../target/types/calculator'


describe('calculator', () => {
  // Run some tests on our calculator program
  // ...
})

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:

// Run some tests on our calculator program
const provider = anchor.AnchorProvider.env()
anchor.setProvider(provider)


const program = anchor.workspace.Calculator as Program<Calculator>


// Set up some common accounts we'll be using later
const owner = provider.wallet.publicKey
const calculator = web3.PublicKey.findProgramAddressSync(
  [Buffer.from('Calculator'), owner.toBuffer()],
  program.programId
)[0]


// Try initializing the calculator
// ...


// Do some operations on the calculator
// ...


// Make sure our calculator is secure
// ...

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.

// Try initializing the calculator
it('Inits a calculator', async () => {
  await program.methods.initCalculator().accounts({ owner, calculator }).rpc()
})

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!

// Do some operations on the calculator
it('Does some operations', async () => {
  const add2 = await program.methods
    .doOperation({ add: true }, new BN(2))
    .accounts({ owner, calculator })
    .instruction()


  const mul3 = await program.methods
    .doOperation({ mul: true }, new BN(3))
    .accounts({ owner, calculator })
    .instruction()


  const sub1 = await program.methods
    .doOperation({ sub: true }, new BN(1))
    .accounts({ owner, calculator })
    .instruction()


  const tx = new web3.Transaction()
  tx.add(add2, mul3, sub1)
  await provider.sendAndConfirm(tx)


  // Get the calculator's on-chain data
  const calculatorAccount = await program.account.calculator.fetch(calculator)


  assert.ok(calculatorAccount.display.toNumber() === 5)
})

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.

// Make sure our calculator is secure
it('Prevents fraudulent transactions', async () => {
  let hackerman = new web3.Keypair()


  let shouldFail = await program.methods
    .resetCalculator()
    .accounts({
      owner: hackerman.publicKey,
      calculator,
    })
    .instruction()


  let tx = new web3.Transaction()
  tx.add(shouldFail)
  await provider
    .sendAndConfirm(tx, [hackerman])
    .then(() => assert.ok(false)) // Error on success, we want a failure
    .catch(console.log)
})

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:

SendTransactionError: failed to send transaction: Transaction simulation failed: Error processing Instruction 0: custom program error: 0x1770
    at ... {
  logs: [
    'Program Fg6PaFpoGXkYsidMpWTK6W2BeZ7FEfcYkg476zPFsLnS invoke [1]',
    'Program log: Instruction: ResetCalculator',
    'Program log: BxooiAL7QrzeWYjiQ2veesVcimnCQaMBKmh3mMoVzLHn "is resetting" BwjpFHQjRYUG6Kp5dZpkqTYRdhUFRH6fv36MAQ9z1hAe',
    'Program log: AnchorError thrown in programs/calculator/src/lib.rs:69. Error Code: E000. Error Number: 6000. Error Message: This is not your calculator!.',
    'Program Fg6PaFpoGXkYsidMpWTK6W2BeZ7FEfcYkg476zPFsLnS consumed 36107 of 200000 compute units',
    'Program Fg6PaFpoGXkYsidMpWTK6W2BeZ7FEfcYkg476zPFsLnS failed: custom program error: 0x1770'
  ]
}

Last updated