Published on

SPL Token Faucet Tutorial, Your Eighth Seahorse Solana Program

Authors

Welcome back. Let's write some more Seahorse! This tutorial follows on from the previous lottery example. Start here if you're an absolute beginner. Today we're going to build a token faucet. If you're unfamiliar with token faucets try this one or this one to get some dev-net SOL. Faucets typically issue tokens with either zero or very low value. They have a long history in crypto, going back to the early years of Bitcoin.

Our faucet will also be linked to Bitcoin, as the amount of tokens our faucet dispenses will depend on the price of Bitcoin. Today will be our first program using Pyth price oracles. An oracle is simply a way for us to reference off-chain data, which in this case is the price of Bitcoin. Seahorse provides built-in integration with Pyth, so that is the oracle service we will be using. To take advantage of this integration we will need to add an extra import at the top of our Seahorse program from seahorse.pyth import *.

Faucet.py
from seahorse.prelude import *
from seahorse.pyth import *

declare_id('')

Our faucet will be represented as an Account class named Bitcorn Faucet which will store 4 attributes, bump which the bump used to generate the PDA, owner, the public key of the account creator, mint the public key of the token mint account for the token that the faucet will dispense and last_withdraw a timestamp of the last faucet withdrawal time which we will use as a crude way to rate limit access to the faucet.

Faucet.pyw
class BitcornFaucet(Account):
  bump: u8
  owner: Pubkey
  mint: Pubkey
  last_withdraw: i64 # timestamp

Rather than having instructions to set up mint token accounts and mint tokens as we did previously in the lottery tutorial, this time we will do our token creation using the Solana CLI. So in our program we will assume that the 'Bitcorn' token already exists. This means when we come to testing the program we will have to take some extra steps to create the token which we'll come to later.

So to initialize our faucet we need to provide 4 arguments to our instruction, the Signer, the TokenMint account which we've created with the CLI, an empty instance of the BitcornFaucet account and an empty instance of a TokenAccount. The faucet is initialized with the seeds of ['mint', mint] and we also store the bump used to generate the PDA in a variable.

For the most part Seahorse abstracts away our need to deal with 'bumps' or 'bump seeds'. If all you want to do is build simple programs with Seahorse then all you need to know is a bump is a u8 unsigned integer (0-255) that is added into the mix when creating PDAs. Seahorse deals with this process so you don't have to. But if you want to know more about bumps, you can read more here. It's definitely a topic you will need to understand at some point in your Solana developer journey.

Then we initialize our faucet's token account so it can store Bitcorn tokens. Finally we set the bump, mint and owner fields of our faucet account.

Faucet.py
@instruction
def init_faucet(
    signer: Signer, 
    mint: TokenMint, 
    faucet: Empty[BitcornFaucet],
    faucet_account: Empty[TokenAccount]):
  
  bump = faucet.bump()
  
  faucet = faucet.init(
    payer = signer,
    seeds = ['mint', mint]
  )

  faucet_account.init(
    payer = signer,
    seeds = ["token-seed", mint],
    mint = mint,
    authority = faucet,
  )
  
  faucet.bump = bump
  faucet.mint = mint.key()
  faucet.owner = signer.key()

Now we need to perform a task manually with the CLI to add our tokens into the faucet after which users can call the drip instruction to receive tokens. To call the instruction we must pass 7 arguments, the first 5 are straightforward: signer, the token mint account, the faucet, the faucet token account and the user's token account. The last 2 are new for us. PriceAccount is a special account type in Seahorse used for Pyth oracle price feeds. Clock references Solana's Clock sysvar, which we are going to use to generate a timestamp. Read more on Clock here.

We generate the timestamp with the method clock.unix_timestamp() and check it against the field on our Faucet storing the last withdrawal timestamp. If the difference is less than 30 (seconds) the program will panic and return a message. This is our simple system of rate limiting usage through timestamps, a true production application would likely use something more sophisticated.

Faucet.py
# drips tokens based on the oracle price of BTC
@instruction
def drip_bitcorn_tokens(
    signer: Signer, 
    mint: TokenMint, 
    faucet: BitcornFaucet, 
    faucet_account: TokenAccount, 
    user_account: TokenAccount, 
    bitcoin_price_account: PriceAccount, 
    clock: Clock):
  
  timestamp: i64 = clock.unix_timestamp() 

  assert mint.key() == faucet.mint, 'Faucet token does not match the token provided'
  assert timestamp - 30 > faucet.last_withdraw, 'Please try again in 30 seconds'

We continue the instruction by extracting the price of Bitcoin through our Pyth PriceAccount price feed with the validate_price_feed method, into which we pass the string 'devnet-BTC/USD'. This returns an object from which we can grab the price of Bitcoin in US dollars. The amount of tokens the faucet will transfer to users decreases as the price of Bitcoin rises and increases as the price of Bitcoin falls.

Faucet.py
  btc_price_feed = bitcoin_price_account.validate_price_feed('devnet-BTC/USD')
  btc_price = u64(btc_price_feed.get_price().price)
  btc_price = btc_price // 100_000_000  # converts to dollar units i.e. 4313965499999 -> 43,139
  
  print("The Bitcorn price is ", btc_price)
  
  thousand_bucks = 1000 * 1_000_000_000
  drip_amount = u64(thousand_bucks // btc_price)

The transfer of tokens to the user's wallet is handled by the TokenAccount transfer method.

Faucet.py
  bump = faucet.bump

  faucet_account.transfer(
      authority = faucet,
      to = user_account,
      amount = u64(drip_amount),
      signer = ['mint', mint, bump]
  )

An option to return unused tokens is a feature found on many faucet interfaces. So lastly lets add an instruction to facilitate replenishing the faucet. This instruction fulfills a role identical to that of a token transfer through a wallet interface or the command line using the Solana CLI.

Faucet.py
# send tokens back to replenish the faucet
@instruction
def replenish_bitcorn_tokens(
    signer: Signer, 
    mint: TokenMint, 
    user_account: TokenAccount, 
    faucet_account: TokenAccount, 
    amount: u64):
  
  user_account.transfer(
    authority = signer,
    to = faucet_account,
    amount = u64(amount)
  )

Final code

Our final program should look something like this.

Faucet.py

from seahorse.prelude import *
from seahorse.pyth import *

declare_id('fMX8bmgPgmcanxQJPJYgqoXAk4HExMAQ4uGexpLQZVN')

class BitcornFaucet(Account):
  bump: u8
  owner: Pubkey
  mint: Pubkey
  last_withdraw: i64 # timestamp
  

@instruction
def init_faucet(
    signer: Signer, 
    mint: TokenMint, 
    faucet: Empty[BitcornFaucet], 
    faucet_account: Empty[TokenAccount]):
  
  bump = faucet.bump()
  
  faucet = faucet.init(
    payer = signer,
    seeds = ['mint', mint]
  )

  faucet_account.init(
    payer = signer,
    seeds = ["token-seed", mint],
    mint = mint,
    authority = faucet,
  )
  
  faucet.bump = bump
  faucet.mint = mint.key()
  faucet.owner = signer.key()


# drips tokens based on the oracle price of BTC
@instruction
def drip_bitcorn_tokens(
    signer: Signer, 
    mint: TokenMint, 
    faucet: BitcornFaucet, 
    faucet_account: TokenAccount, 
    user_account: TokenAccount, 
    bitcoin_price_account: PriceAccount, 
    clock: Clock):
  
  timestamp: i64 = clock.unix_timestamp() 

  assert mint.key() == faucet.mint, 'Faucet token does not match the token provided'
  assert timestamp - 30 > faucet.last_withdraw, 'Please try again in 30 seconds'
  
  btc_price_feed = bitcoin_price_account.validate_price_feed('devnet-BTC/USD')
  btc_price = u64(btc_price_feed.get_price().price)
  btc_price = btc_price // 100_000_000  # converts to dollar units i.e. 4313965499999 -> 43,139
  
  print("The Bitcorn price is ", btc_price)
  
  thousand_bucks = 1000 * 1_000_000_000
  drip_amount = u64(thousand_bucks // btc_price)
  
  bump = faucet.bump

  faucet_account.transfer(
    authority = faucet,
    to = user_account,
    amount = u64(drip_amount),
    signer = ['mint', mint, bump]
  )


# send tokens back to replenish the faucet
@instruction
def replenish_bitcorn_tokens(
    signer: Signer, 
    mint: TokenMint, 
    user_account: TokenAccount, 
    faucet_account: 
    TokenAccount, 
    amount: u64):
  
  user_account.transfer(
    authority = signer,
    to = faucet_account,
    amount = u64(amount)
  )

Testing

Before we begin testing we will need to create a token for our faucet from the command line. If you can't remember how to do this refer back to this tutorial. Create the token, store the token mint account address in a text file or notepad for easy reference.

Now we can build and deploy our program. I recommend using Solana Playground, but you can also do so locally if you prefer. Navigate to the testing interface and fill in the fields based on the requirements of each instruction. When inputting the Bitcoin price account select 'Pyth' from the drop down menu and scroll to the 'BTC/USD' option.

faucet 1

Remember also to mint tokens to the faucets token account using the command line. Again if you can't remember how to do that, refer to this tutorial.