Exploring Program Derive Addresses (PDA's) with Solana, Anchor and React
Fernando Mendez
Posted on December 13, 2021
Note: As of the time of this writing (Monday, December 13, 2021), Solana's testnet
environment (faucet/airdrop) seems to be having issues. Please select the devnet
on the selector (or just don't change it, since is the default value). Remember to change your wallet to point to the devnet
network.
Note: all the code for this post can be found here. There's a demo here showcasing the concepts in this post.
Use cases
Let's image the following scenarios. You built an dApp that uses tokens you created / minted. For testing purposes, you want to allow users to self-airdrop some amount of those tokens (on testings environments). The problem is -since you minted the tokens- the one with the authority to both mint more tokens or transfer them is you. That means you have to sign every transaction dealing with those mints.
Another scenario is a user wanting to trade some items with other users. For safety, the items to trade should be put in some sort of temporary account (escrow account) and only be released to a 3rd party they accept the offer. The difficulty is, if the escrow account belongs to the user, they need to approve / sign the transaction for the tokens to be released. We don't want the user to be involved in the release of the items directly.
In both scenarios, we need to have a sort of "proxy" that can sign a transaction on behalf of the owner of the program. For that, we'll need Program Derive Addresses (PDA's).
In the scenarios that I described above, we would need to use Cross-Program Invocations. In both scenarios, we would interact with the Token Program. For the case of airdropping, we will mint
more of the existing tokens to a user and in the second case we will transfer
tokens.
In both of these scenarios, it is the PDA that would have the authority to sign these transactions in our behalf.
PDA's defined
These are accounts that are owned by a program and not controlled by a private key like other accounts. Pubkey::create_program_address
generates these addresses. This method will hash the seeds with program ID to create a new 32-byte address. There's a change (50%) that this may be a point on the ed25519 curve. That means there is a private key associated with this address. In such cases, the safety of the Solana programming model would be compromised. The Pubkey::create_program_address
will fail in case the generated address lie on the ed25519 curve.
To simplify things, the method Pubkey::find_program_address
will internally call the create_program_address
as many times as necessary until it finds a valid one for us.
PDAs in action
To explore PDA's further, I decided to build a farm animal trading app. The animals that you trade are tokens. In this app, PDA are used in 2 different ways. The first way is an escrow account. Users put away (escrow) the tokens they are offering. These tokens will be release if either some other user accept the offer or the user initiating the offer decides to cancel it. In both cases, it is the escrow account itself that has the authority to sign the transferring of tokens to the destination.
Note: For the code snippets, I'll only be showing the relevant sections, and I'll be linking the line number on the repo. All the code can be found here.
Escrow accounts
First, we need to derive an address. This will be our escrow account(code).
const offer = anchor.web3.Keypair.generate();
const [escrowedTokensOfOfferMaker, escrowedTokensOfOfferMakerBump] = await anchor.web3.PublicKey.findProgramAddress(
[offer.publicKey.toBuffer()],
program.programId
)
We store the bump so that we don't have to keep recalculating it by call the findProgrammAddress
method and having to pass it down from the frontend.
In the contract / program
this is how we use it (here you'll find the entire file). Here, we're creating an offer:
anchor_spl::token::transfer(
CpiContext::new(
ctx.accounts.token_program.to_account_info(),
anchor_spl::token::Transfer {
from: ctx
.accounts
.token_account_from_who_made_the_offer
.to_account_info(),
to: ctx
.accounts
.escrowed_tokens_of_offer_maker
.to_account_info(),
authority: ctx.accounts.who_made_the_offer.to_account_info(),
},
),
im_offering_this_much,
)
We're transferring the tokens from the account initiating the offer to the escrow account. We're also specifying how much we're transferring.
At this point, we can either accept or cancel an offer. For the cancelling part:
// Transfer what's on the escrowed account to the offer reciever.
anchor_spl::token::transfer(
CpiContext::new_with_signer(
ctx.accounts.token_program.to_account_info(),
anchor_spl::token::Transfer {
from: ctx
.accounts
.escrowed_tokens_of_offer_maker
.to_account_info(),
to: ctx
.accounts
.where_the_escrowed_account_was_funded_from
.to_account_info(),
authority: ctx
.accounts
.escrowed_tokens_of_offer_maker
.to_account_info(),
},
&[&[
ctx.accounts.offer.key().as_ref(),
&[ctx.accounts.offer.escrowed_tokens_of_offer_maker_bump],
]],
),
ctx.accounts.escrowed_tokens_of_offer_maker.amount,
)?;
// Close the escrow account
anchor_spl::token::close_account(CpiContext::new_with_signer(
ctx.accounts.token_program.to_account_info(),
anchor_spl::token::CloseAccount {
account: ctx
.accounts
.escrowed_tokens_of_offer_maker
.to_account_info(),
destination: ctx.accounts.who_made_the_offer.to_account_info(),
authority: ctx
.accounts
.escrowed_tokens_of_offer_maker
.to_account_info(),
},
&[&[
ctx.accounts.offer.key().as_ref(),
&[ctx.accounts.offer.escrowed_tokens_of_offer_maker_bump],
]],
))
We're sending the tokens back to the account that initiated the offer. Notice that the authority that's signing-off the transaction is the PDA, since it "owns" the tokens. We're also closing the escrow account since it no longer needed.
The last relevant part is the "swapping" of tokens after accepting an offer:
// Transfer token to who started the offer
anchor_spl::token::transfer(
CpiContext::new(
ctx.accounts.token_program.to_account_info(),
anchor_spl::token::Transfer {
from: ctx
.accounts
.account_holding_what_receiver_will_give
.to_account_info(),
to: ctx
.accounts
.account_holding_what_maker_will_get
.to_account_info(),
authority: ctx.accounts.who_is_taking_the_offer.to_account_info(),
},
),
ctx.accounts.offer.amount_received_if_offer_accepted,
)?;
// Transfer what's on the escrowed account to the offer reciever.
anchor_spl::token::transfer(
CpiContext::new_with_signer(
ctx.accounts.token_program.to_account_info(),
anchor_spl::token::Transfer {
from: ctx
.accounts
.escrowed_tokens_of_offer_maker
.to_account_info(),
to: ctx
.accounts
.account_holding_what_receiver_will_get
.to_account_info(),
authority: ctx
.accounts
.escrowed_tokens_of_offer_maker
.to_account_info(),
},
&[&[
ctx.accounts.offer.key().as_ref(),
&[ctx.accounts.offer.escrowed_tokens_of_offer_maker_bump],
]],
),
ctx.accounts.escrowed_tokens_of_offer_maker.amount,
)?;
// Close the escrow account
anchor_spl::token::close_account(CpiContext::new_with_signer(
ctx.accounts.token_program.to_account_info(),
anchor_spl::token::CloseAccount {
account: ctx
.accounts
.escrowed_tokens_of_offer_maker
.to_account_info(),
destination: ctx.accounts.who_made_the_offer.to_account_info(),
authority: ctx
.accounts
.escrowed_tokens_of_offer_maker
.to_account_info(),
},
&[&[
ctx.accounts.offer.key().as_ref(),
&[ctx.accounts.offer.escrowed_tokens_of_offer_maker_bump],
]],
))
We do this is 3 steps. First, we send the tokens wanted to the user that initiated the offer. We then transfer the escrowed tokens to the user accepting the offer. Then, as with the last snipped, we're closing the escrow account since it no longer required.
Airdropping
The other way the application uses PDA is with airdropping. In this case, we want to allow users to self-mint (airdrop) a limited amount of something we own (the tokens). In those cases, the PDA has the authority to sign the minting of new tokens on our behalf.
Same as before, we're using the findProgramAddress
to get a PDA:
const cowSeed = Buffer.from(anchor.utils.bytes.utf8.encode("cow-mint-faucet"));
const pigSeed = Buffer.from(anchor.utils.bytes.utf8.encode("pig-mint-faucet"));
const [cowMintPda, cowMintPdaBump] = await anchor.web3.PublicKey.findProgramAddress(
[cowSeed],
program.programId);
const [pigMintPda, pigMintPdaBump] = await anchor.web3.PublicKey.findProgramAddress(
[pigSeed],
program.programId);
The airdrop code simplifies to this:
anchor_spl::token::mint_to(
CpiContext::new_with_signer(
ctx.accounts.token_program.to_account_info(),
anchor_spl::token::MintTo {
mint: ctx.accounts.mint.to_account_info(),
to: ctx.accounts.destination.to_account_info(),
authority: ctx.accounts.mint.to_account_info(),
},
&[&[&mint_seed, &[mint_bump]]],
),
amount,
)?;
Same as before, the most important thing to notice here is that the PDA itself has the authority to sign off transactions.
Putting all together.
There's a demo app deployed here. Both devnet
and testnet
have the app deployed. You can use the selector on the page to change between the two (if you do, remember to change what network you're pointing in your walled).
You can airdrop some SOL if you don't have any. Furthermore, you can airdrop some farm animals to start trading.
Note: Every 20 seconds, I'm pulling to an off-chain db to display the full list of offers available to all users.
Final thoughts.
This was another fun experiment with Solana. I wanted to keep everything on chain but ended up having an off-chain DB with all offers created to make them available to all users. I'll explore putting all the offers on-chain.
Overall, I'm enjoying my time playing with Solana. I'll keep experimenting and reporting back. Until the next time.
Resources
- The animal icons came from this absolutely amazing creator's site: https://kenney.nl/
- Background image sourced from: https://www.pixilart.com/art/pixel-farm-bb3c119b728eafd
- Learned more about PDA implementations from (https://github.com/cqfd/quidproquo) and (https://github.com/project-serum/anchor/tree/master/tests/escrow)
- https://spl.solana.com/
Posted on December 13, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.