46
Building a simple on-chain point of sale with Solana, Anchor and React
Note: All the code for this post can be found in this github repo.
A few days ago, I started playing with the Solana blockchain. I was initially interested because it was built on rust (I freaking love rust!). To explore it, I decided to build a basic point of sales (POS) for event tickets.
I initially started reading the code on the Solana Program Library and experimenting but decided to go with Anchor just get started building something more quickly.
I'm not going to describe how to install Solana or Anchor. There is already a fantastic guide written here
The first thing I really love about Anchor is that I was able to start with a test-driven development approach. I started with the first test:
describe("ticketing-system", () => {
const anchor = require("@project-serum/anchor");
const assert = require("assert");
const { SystemProgram } = anchor.web3;
// Configure the client to use the local cluster.
const provider = anchor.Provider.env();
anchor.setProvider(provider);
const program = anchor.workspace.TicketingSystem;
const _ticketingSystem = anchor.web3.Keypair.generate();
const tickets = [1111, 2222, 3333];
it("Is initializes the ticketing system", async () => {
const ticketingSystem = _ticketingSystem;
await program.rpc.initialize(tickets, {
accounts: {
ticketingSystem: ticketingSystem.publicKey,
user: provider.wallet.publicKey,
systemProgram: SystemProgram.programId,
},
signers: [ticketingSystem],
});
const account = await program.account.ticketingSystem.fetch(
ticketingSystem.publicKey
);
assert.ok(account.tickets.length === 3);
assert.ok(
account.tickets[0].owner.toBase58() ==
ticketingSystem.publicKey.toBase58()
);
});
});
With this, I'm testing the ability to create 3 tickets, store it on-chain and ensure that all of them are owned by the program account.
To make the test pass, we have to work on the program account (e.g., lib.rs
). First, let's create the structs that represent both our Ticket and the TicketingSystem
#[account]
#[derive(Default)]
pub struct TicketingSystem {
pub tickets: [Ticket; 3],
}
#[derive(AnchorSerialize, AnchorDeserialize, Default, Clone, Copy)]
pub struct Ticket {
pub owner: Pubkey,
pub id: u32,
pub available: bool,
pub idx: u32,
}
The #[account]
on the TicketingSystem
automatically prepend the first 8 bytes of the SHA256 of the account’s Rust ident (e.g., what's inside the declare_id
). This is a security check that ensures that a malicious actor could not just inject a different type and pretend to be that program account.
We are creating an array of Ticket
, so we have to make it serializable. The other thing to note is that I'm specifying the owner to be of type Pubkey
. The idea is that upon creation, the ticket will be initially owned by the program and when I make a purchase the ownership will be transferred.
The remaining structures:
#[derive(Accounts)]
pub struct Initialize<'info> {
#[account(init, payer = user)]
pub ticketing_system: Account<'info, TicketingSystem>,
#[account(mut)]
pub user: Signer<'info>,
pub system_program: Program<'info, System>,
}
#[derive(Accounts)]
pub struct PurchaseTicket<'info> {
#[account(mut)]
pub ticketing_system: Account<'info, TicketingSystem>,
pub user: Signer<'info>,
}
The #[derive(Accounts)]
implements an Accounts
deserializer. This applies any constraints specified by the #[account(...)]
attributes. For instance, on the Initialize
struct we have had the payer = user
constrains specifying who's paying for the initialization cost (e.g., when the program is deploying).
The following code handles the actual initialization:
pub fn initialize(ctx: Context<Initialize>, tickets: Vec<u32>) -> ProgramResult {
let ticketingSystem = &mut ctx.accounts.ticketing_system;
let owner = ticketingSystem.to_account_info().key;
for (idx, ticket) in tickets.iter().enumerate() {
ticketingSystem.tickets[idx] = Ticket {
owner: *owner,
id: *ticket,
available: true,
idx: idx as u32,
};
}
Ok(())
}
After some fiddling and debugging, I finally get a passing test with anchor test
:
ticketing-system
✔ Is initializes the ticketing system (422ms)
1 passing (426ms)
✨ Done in 8.37s.
Now that I have a list of on-chain Tickets I can retrieve, I want to see them. I decide to create a React app for this. Anchor already created an /app
folder, let's use it.
The overall setup is very much like the one here, with the difference that I'm using Typescript.
The next React code will be shown without the imports. You can find the full code here:
The App.tsx
contains code to detect if we're connected to a wallet or not:
...
function App() {
const wallet = useWallet();
if (!wallet.connected) {
return (
<div className="main-container p-4">
<div className="flex flex-col lg:w-1/4 sm:w-full md:w-1/2">
<WalletMultiButton />
</div>
</div>
);
} else {
return (
<div className="main-container">
<div className="border-b-4 border-brand-border self-stretch">
<h1 className="font-bold text-4xl text-center p-4 text-brand-border">Ticket Sales</h1>
</div>
<Tickets />
</div>
);
}
}
export default App;
I created a few components for Ticket
and Tickets
. I also used tailwindcss
to style them.
This is what Tickets
look like:
function Tickets() {
const wallet = useWallet();
const [tickets, setTickets] = useState<TicketInfo[]>([]);
const initializeTicketingSystem = async () => {
const provider = await getProvider((wallet as any) as NodeWallet);
const program = new Program((idl as any) as Idl, programID, provider);
try {
await program.rpc.initialize(generateTickets(3), {
accounts: {
ticketingSystem: ticketingSystem.publicKey,
user: provider.wallet.publicKey,
systemProgram: SystemProgram.programId,
},
signers: [ticketingSystem],
});
const account = await program.account.ticketingSystem.fetch(
ticketingSystem.publicKey
);
setTickets(account.tickets);
} catch (err) {
console.log("Transaction error: ", err);
}
};
return (
<div>
{tickets.length === 0 && (
<button className="bg-brand-btn rounded-xl font-bold text-xl m-4 p-2 hover:bg-brand-btn-active" onClick={initializeTicketingSystem}>
Generate Tickets
</button>
)}
{tickets.map((ticket) => (
<Ticket
key={ticket.id}
ticket={ticket}
ticketingSystem={ticketingSystem}
setTickets={setTickets}
/>
))}
</div>
);
}
export default Tickets;
Here, we provide a Generate Tickets
button that will initialize the tickets on-chain. These RPC calls could be moved to an API file, but I'll keep there since it is the only place that needs it. The code for the Ticket
is similar in structure. Here will call the purchase
RPC call:
....
const purchase = async (ticket: TicketInfo) => {
const provider = await getProvider((wallet as any) as NodeWallet);
const program = new Program((idl as any) as Idl, programID, provider);
try {
await program.rpc.purchase(ticket.id, ticket.idx, {
accounts: {
ticketingSystem: ticketingSystem.publicKey,
user: provider.wallet.publicKey,
},
});
const account = await program.account.ticketingSystem.fetch(
ticketingSystem.publicKey
);
setTickets(account.tickets);
} catch (err) {
console.log("Transaction error: ", err);
}
};
....
All the styled components look like this:
A gif showing it in action:
You can try a live version ( pointing to the testnet.api ) here
For fun, I added a QR code that's based on the ticket number and the account that made the purchase.
Overall, this was a fun experiment. Based on my initial experimentation using the Solana SDK directly, there's a lot that Anchor
is abstracting away. There's also good practices built into it (e.g., the 8 bytes discriminator for the program's account, lack of order when accessing accounts, etc.). I'll be spending more time with both Anchor and the Solana SDK itself to make sure I understand what's being abstracted away.
Finally, there are a few troubleshooting tips that might help you when using Anchor.
- Remember to
anchor build
andanchor deploy
before runninganchor test
. That ensures that you have the latest bytecode on the runtime. You will encounter a serialization error if you don't. - When you encounter custom errors such as this:
"Transaction simulation failed: Error processing Instruction 0: custom program error: 0x66"
. Convert the number from hex -> integer, if the number is >=300 it's an error from your program, look into the errors section of the idl that gets generated when building your anchor project. If it is < 300, then search the matching error number here - When you get this type of error:
"error: Error: 163: Failed to deserialize the account"
. Very often it's because you haven't allocated enough space (anchor tried to write the account back out to storage and failed). This is solved by allocating more space during the initialization.
For example, had to bump this to 64 to solve the issue. Was initially at 8:
...
#[account(init, payer = user, space = 64 + 64)]
pub ticketing_system: Account<'info, TicketingSystem>,
...
Alternatively (and the recommended option from what I've gathered) is to leave the space out for Anchor to calculate it for you. The exception is if you're dealing with a complex of Custom types that Anchor can't calculate for some reason.
- If you for whatever reason you need to generate a new program ID (e.g., a fail deployment to
devent
ortestdeve
made that account address in use and is not upgradeable). You can simply delete the/deploy
folder under target (e.g/root-of-your-anchor-project/target/deploy
) and runanchor build
again. That will regenerate the/deploy
folder. After that, you just need to run this from your root project directorysolana address -k target/deploy/name-of-your-file-keypair.json
. You can take that output and upgrade both thedeclare_id()
in yourlib.rs
andAnchor.toml
with the new program ID. Finally, you have to runanchor build
again to rebuild with the new program ID.
I still have a lot to explore, I find both Anchor
and the current Solana ecosystem very exciting. Will continue to post my progress. Until the next time.
46