Photo by Markus Spiske on Unsplash
In previous post, we have went through the code of helloworld-example and understand how smart contract in Solana works. Now, let’s begins to understand how to interact with it. In the helloworld-example, it also contains a client-side program written in Typescript. You may find it here. In the link shown, there are 3 files. So, let’s go through it one by one:
- main.ts: The main file is main.ts. It is nothing more than calling several functions from the other helloworld.ts in order;
- helloworld.ts: You can find most of the functions that defined in main.ts in helloworld.ts;
- util.ts: Some repeated reusable code are defined in util.ts.
Program Flow
So, let’s go through main.ts first. Its main() function in main.ts looks like below:
async function main()
{
console.log("Let's say hello to a Solana account...");
// Establish connection to the cluster
await establishConnection();
// Determine who pays for the fees
await establishPayer();
// Check if the program has been deployed
await checkProgram();
// Say hello to an account
await sayHello();
// Find out how many times that account has been greeted
await reportGreetings();
console.log('Success');}
So, it basically outline the flow of the program:
- Establish connection to cluster;
- Get ready to pay lamport;
- Say hello;
- print number of hellos.
You may notice that there is a checkProgram() function too. However, in this passage, I would skip this part as I want to focus more on how a client program interact with a Smart Contract.
Ok. Now, let’s go through each step one by one:
1. Establish connection to cluster
//utils.tsasync function getConfig(): Promise<any>
{
// Path to Solana CLI config file
const CONFIG_FILE_PATH = path.resolve(
os.homedir(),
'.config',
'solana',
'cli',
'config.yml',
);
const configYml = await fs.readFile(CONFIG_FILE_PATH, {encoding: 'utf8'});
return yaml.parse(configYml);
}export async function getRpcUrl(): Promise<string>
{
try
{
const config = await getConfig();
if (!config.json_rpc_url) throw new Error('Missing RPC URL');
return config.json_rpc_url;
} catch (err)
{
console.warn(
'Failed to read RPC url from CLI config file, falling back to
localhost',
);
return 'http://localhost:8899';
}
}//helloworld.tsexport async function establishConnection(): Promise<void>
{
const rpcUrl = await getRpcUrl();
connection = new Connection(rpcUrl, 'confirmed');
const version = await connection.getVersion(); console.log('Connection to cluster established:', rpcUrl, version);
}
So, main logic of establish connection is situated in helloworld.ts. It utilize a function in util.ts called getRpcUrl() to look for current config info. getRpcUrl() calls another function within utils.ts named getconfig() to get the information in ~/.config/solana/cli/config.yml. If you open this file in your local environment, it looks something like this:
$ cat /root/.config/solana/cli/config.yml
---
json_rpc_url: "http://127.0.0.1:8899"
websocket_url: ""
keypair_path: /root/wallet/wallet1.json
address_labels:
"11111111111111111111111111111111": System Program
So, it basically stores all your config info.
Now, getRpcURL() function tries to read the json_rpc_url parameter and return it as a result. This parameter basically indicates which cluster your environment is connected to. In above case, you are connected to a local cluster. If it is empty, it would throw error and it would return local cluster value by default. Finally, establishConnection() function would capture this value and establish connection to the cluster.
2. Get ready to pay lamport
This step is executed in the establishedPayer() function in helloworld.ts. Solana is just like other Blockchain. You need to pay fee for a transaction. In this client program, it will call for airdrop if the account is not sufficient to pay for the transaction. Now, we will go through the related code in 2 parts:
export async function establishPayer(): Promise<void>
{
let fees = 0;
if (!payer) {
const {feeCalculator} = await connection.getRecentBlockhash();
// Calculate the cost to fund the greeter account
fees += await connection.getMinimumBalanceForRentExemption(GREETING_SIZE);
// Calculate the cost of sending transactions
fees += feeCalculator.lamportsPerSignature * 100;
payer = await getPayer();
}
So, the first part just calculate the lamport needed to send the hello transaction. So, we can get an airdrop if fund is insufficient. Getpayer() function is an function imported from util.ts which is used to return the keypair of your account. Code of getpayer() is as per below:
export async function getPayer(): Promise<Keypair> {
try {
const config = await getConfig();
if (!config.keypair_path) throw new Error('Missing keypair path');
return await createKeypairFromFile(config.keypair_path); }
catch (err) {
console.warn(
'Failed to create keypair from CLI config file, falling back to
new random keypair',
);
return Keypair.generate();
}
}
Then, second part of estabilishdPayer() function is simple. Just request an airdrop if its balance is insufficient to fund the transaction and a log message at the end:
let lamports = await connection.getBalance(payer.publicKey);
if (lamports < fees) {
// If current balance is not enough to pay for fees, request an airdrop
const sig = await connection.requestAirdrop(
payer.publicKey,
fees - lamports,
);
await connection.confirmTransaction(sig);
lamports = await connection.getBalance(payer.publicKey);
}console.log(
'Using account',
payer.publicKey.toBase58(),
'containing',
lamports / LAMPORTS_PER_SOL,
'SOL to pay for fees', );
3. Say hello
So, now the we move to the most interesting part. So, let’s take a look:
const GREETING_SEED = 'hello';
greetedPubkey = await PublicKey.createWithSeed(
payer.publicKey,
GREETING_SEED,
programId, );
The above code is placed in helloworld.ts. As mentioned in previous post, user needs to feed an account to the program to store the counter variable. The above code is written exactly to serve this purpose. It uses a function imported from web3.js named createWithSeed(). This is a way to generate a public key using a seed word (i.e. “hello” from above example) and a program ID. The program ID mentioned would also be the owner of this account so the program can amend data in this account at will. You can find some more details of this function in its doc.
So, now, we can use the greetedPubkey to send an instruction to instruct the program to update the account.
export async function sayHello(): Promise<void> {
console.log(
'Saying hello to',
greetedPubkey.toBase58()
);
const instruction = new TransactionInstruction({
keys: [{pubkey: greetedPubkey, isSigner: false, isWritable: true}],
programId,
data: Buffer.alloc(0), // All instructions are hellos
});
await sendAndConfirmTransaction(
connection,
new Transaction().add(instruction),
[payer],
);
}
The code above is used to construct an instruction to interact with the program. After a greeting message, it sends a transaction with keys about account info, programId and data to the cluster. These information is exactly matched with entrypoint of the program as mentioned in previous post. The keys array in it mainly indicates the account parameters. Because if you remember, a program cannot call for account data. It can only relies on client to provide such information. Finally, the instruction is sent out using sendAndConfirmTransaction() function imported from web3.js.
4. print number of hellos
It is all worked in reportGreetings() function in helloworld.ts. It first get account info using connection.getAccountInfo() function provided by web3.js and extract the information from the account, deserialise it and print it out. Major code is as below:
export async function reportGreetings(): Promise<void> {
const accountInfo = await connection.getAccountInfo(greetedPubkey);
if (accountInfo === null) {
throw 'Error: cannot find the greeted account';
}
const greeting = borsh.deserialize(
GreetingSchema,
GreetingAccount,
accountInfo.data,
);
console.log(
greetedPubkey.toBase58(),
'has been greeted',
greeting.counter,
'time(s)', );
}
The most important part in above code is how we use borsh.deserialize() function to deserialise. In borsh deserialisation, you will create an object to store the deserialsed values. In here, the object is called greeting. In order to construct this object, you need a class to define properties of this object (i.e. GreetingAccount in code above). Also, you need to define a Schema to map the deserialised data to the object (i.e. GreetingSchema in above code) and data to be deserialised. If you remember from previous post, the data structure to be deserialized is like this:
GreetingAccount {
pub counter: u32,
}
So, the GreetingSchema code would be as below:
/** * Borsh schema definition for greeting accounts */
const GreetingSchema = new Map([ [GreetingAccount, {kind: 'struct', fields: [['counter', 'u32']]}],]);
Easy. Right? Just map GreetingAccount to a struct type data structure as above. Now, let’s take a look of GreetingAccount class:
/** * The state of a greeting account managed by the hello world program */class GreetingAccount {
counter = 0;constructor(fields: {counter: number} | undefined = undefined) {
if (fields) {
this.counter = fields.counter;
}
}
}
So, borsh.deserialize() function will pass the deserialised data to the class to construct a new object. So, what the class does is just give it a counter property and set to 0 by default. Then, it contains a constructor which would create an object once the data arrives. After that, it read the data and put it back into counter variable.
Conclusion
This is quite a good example for you to start learning Solana Development. Some techniques hightlighted in this article is quite useful in real life such as calculating lamport, create an account with program as owner, how to use borsh to deserialise data etc. I hope you enjoy it. We will move on to some real life development in upcoming articles. So, stay tuned and see you soon!