Introduction
This article presents design and implementation of TheWheel dApp on the Solana network.
Code can be access HERE for off-chain and HERE for on-chain
Link to application is HERE
TheWheel is a no-loss lottery game. In a no-loss lottery the winner takes all the money engaged in the game with no subtraction due to taxes or organizer. Moreover, if in the current version lamports only sleep on a PDA Account waiting for the wheel to turn, I will try in the future to stack it for the winner to gain more than the sum of all deposits.
First of all I’ve made my best to design this game for it to be Web3 compliant. What I mean by Web3 compliant is an equal level of privileges for every users including its creator. In other term, nobody should have a kind of admin mode once the game deployed on-chain. For this reason, even if TheWheel is delivered with a Webapp you can build your own application to access the on-chain program and get same level of privilege. For this reason also I have decided to write Program ID and TheWheel PDA on main page for players being able to access the game in case of Webapp suddenly disappear. My opinion is players should always easily access these two information and save it. Player should also checks on Solana Explorer that program fits the code declared during deployment by reading status of the Verifiable Build Status field. This is very important because of transactions player needs to sign when using the program. If player accidentally signs a transaction that executes a malicious code consequences can be catastrophic.
Before we start even if I am quite satisfied about the Web3 implementation, I must admit I am little bit frustrated about choices I had to make. Every Solana tutorials I have read always present same patterns and it seems quite impossible for designers to adopt different ones. For this reason the well-known Anchor framework has raised because it facilitates implementation of patterns every developers have to adopt when programming on Solana. When I see the dynamic on Anchor project, it is clearly expected in a near future for programmers to dispose a kind of IDE or super framework that helps to build all possible on-chain programs easily. When that day will come, question is will Solana be powerful enough to support programs that required skilled programmers or will it be limited to features this IDE will cover.
TheWheel
To read this article you need some basic knowledge of Solana smart-contract programing. My main sources for training were:
To understand design, let us have a quick review of TheWheel game – for a full description of rules follow this LINK.
TheWheel is a multi-session lottery each defined with a launching date and a maximum of players. Everyone can create a new session as long as MAX-AUTHORIZED-SESSION is not reached. From creation to to launching date, anyone can participate as long as MAX-AUTHORIZED-PLAYERS is not reached. When a player choose to participate, the on-chain program creates a new Player Account where player has to transfer quantity of lamports he wish to engage in the game. Program also adds player to the pending list in TheWheel Account. Once player has transfered the money, he must use CONFIRM_DEPOSIT() instruction for the TheWheel program to transfer lamports to the right Game Account. As a reminder, transferring all lamports from an Account to another automatically close debtor Account in Solana protocol. Once lamports has been transfer to Game Account, TheWheel program write participation of the player into the ledger. The higher his participation is, the better is the chance for the player to win the game. Consequently, it is easy to represent a game with a pie chart (a wheel) where pieces represent participation of players. When the launching date comes, anyone can spin the wheel by calling PLAY() instruction.
Now we have a good understanding of main tenets, let us have a look on architecture.
Architecture
Data architecture
In this diagram you can see:
TheWheel Account:
-Arraysession: array where every living sessions are declared.
-Winners: every session with a declared winner
-Pendingmap : players who request participation to a game. Once transfer is confirmed, player is delete.
Game Account:
-is_lock : once game has a winner the game is locked
-winner: publickey of the winner
-sessionnumber : the number of the session
-Ledger : deposit of all players
Player Account:
No data. Player Account is only use for deposit. If you wonder why player does not directly transfer money to Game Account, the reason is simple: there is no way for program to know source of funds. If a same Account is used for deposits, any player could pretend having transfer lamports even if deposit belongs to someone else.
The classic process on TheWheel is:
It can looks strange to first transfer lamports and then confirming deposit as off-chain app may know transaction has been confirmed. Reason is Confirm_deposit() instruction automatically close Player PDA Account although player may have first performed a simple transfer to ensure his lamports correctly arrived to destination before sending more. Therefore, I have chosen to add this confirm_deposit step to avoid player to request new participation in case his will is to increase his deposit. Secondly, if all is fine and quick in testnet cluster, I still don’t have enough experience to predict behavior on Mainnet. As transaction will take a long time to be confirmed and sometime failed, I was afraid player though TheWheel Webapp tries to fool him. For this reason, my choice is for the player to control every steps of the process.
Main instructions with main operations are:
There are choices that can look weird if you are not familiar to Solana. Typically, why player needs to decide the session number to create a new game ? In a “normal” design, session number is decided at backend and client should only request for a new game. Reason is in Solana protocol clients always need to send Accounts that are read or modified inside the instruction. Consequently if you ask the on-chain program to initialize a new Game PDA Account whatever the session number is program will just be enable to instantiate it. To solve this, TheWheel’s player requests a game with a random number and cross fingers for someone else not sending the same request at same time. Of course some more deterministic implementation are possible, for example managing a pending list for new sessions in a PDA Account waiting for player’s confirmation but as there are only nine sessions allowed with a random number chosen between 1 and 255, risk of collision is very low.
The Anchor framework
Now let us have a focus on the Anchor framework.
I can hardly having a definitive opinion about a framework that is constantly evolving. When I am writing this article, I have just been notified about the 0.22.0 Anchor release that includes process to justify unchecked Account during initialization and new features to facilitate the catching of on-chain error messages.
The Account checking is quite a big deal in Solana. It is written in official documentation that find_program_address()
is an expensive function and it is asking to process it mostly off-chain to save user's compute budget.
Problem is it seems impossible not to check AccountInfo given in instructions especially in the case of lottery games where errors can have for consequences a loss of lamports. If I first though errors only occur with malicious users, after having played with the first versions of my Webapp I realized my errors could process wrong transfers and in the future send someone else lamports in a lost Account. Consequently, I have decided to check every Accounts even those that imply no damage for the program.
I do not have enough knowledge in Solana to determine the cost of a find_program_address()
call in an on-chain program. As it is not constant, I would like to see first on devnet what the average cost for a call is before having a final strategy. If a call to find_program_address()
requires too many compute budget, I will have to find a way to protect deposits from spammers and avoid seeing all the money evaporated at the morning if a robot proceed the same instruction all night long.
Anchor framework delivers features to perform Account checking. Problem is it is not always clear what is checked. Documentation and examples are few and if you need to be confident about the code generated, the best way is to run the cargo expand
command to read the Rust generated code from Anchor.
It is very nice to have Anchor framework when starting on Solana. Auto generating code that Serialize && Deserialize data for programmers only having to focus on IDL is a precious relief. Nevertheless, my opinion is once you have enough experience the time saved when using Anchor framework is not so big. Moreover, if your initialization of some Accounts depends on complex parameters you can definitively not use Anchor for that because those parameters cannot be passed to the script. For example in my case before creating new Game PDA Account I must be sure the MAX_SESSION is not already reached.
Consequently my personal choices with Anchor are:
- Not asking Anchor to initialize Account. So here is the code I use to define my Accounts. I only give some AccountInto<'info>.
#[derive(Accounts)]
pub struct InitGame<'info> {
pub creatorgame: Signer<'info>,
#[account(mut)]
pub thewheelaccount: AccountInfo<'info>,
#[account(mut)]
pub gameaccount: AccountInfo<'info>,
pub system_program: Program<'info, System>
}
Another reason not to use Anchor for Account initialization are logs. As reasons why an Account can be rejected are numerous, if programmer wants to get a good understanding of what is happening he needs to define error messages in instructions after every checks. Those messages cannot be defined in Anchor.
- Using borsh directly for Serialize && Deserialize in my WebApp. Doing so is no more easy nor faster than performing with Anchor. I just personally prefer working with borsh structures than with a single
.idl
file.
Play function
Random crate is not available for Solana programs. Information here. Consequently, I have made my best to get a random number by other means to decide who the winner is in the PLAY() instruction. If I first though I could get some randomness using pub fn new_unique() -> Self
in solana_program::pubkey::Pubkey structure, this function is unfortunately not available in Solana runtime because it uses a global variable. After that, I thought to proceed some data from outside runtime ecosystem using solana-client
crate but I get some compilation errors when including Solana-client=”1.9.8”
in Cargo.toml
and honestly I was not fully convinced about this path because whatever the information I can get from the outside world, a malicious user can also get the same and so anticipate who the winner is if the algorithm is knew.
Well, after many headaches, I think the best solution is to use those two information that are slot
and unix_timestamp
program can access in solana_program::clock::Clock structure.
The first reason is that my sub-system does not need to be stronger than the system itself. What I mean is if a malicious user succeed to control Solana enough to decide values of both slot
and unix_timestamp
then it means the all system is corrupted and consequently what that user could win from TheWheel does not worth a kopeck.
Secondly, after having spent time on explorer.solana.com watching “Slot time” field on Mainnet Beta and Devnet clusters, I get conviction there is no way to predict what the slot number will be after a sufficient period of time as it entirely depends of the activity on the network. To have an idea of what we are talking about, the Slot time is between 500 ms and 750 ms on the Mainnet Beta cluster when everything is fine but this value sometime goes higher when number of transactions raises. Conversely, if you make this test locally using your solana-test-validator, you will find a correlation =1 between slot and time because you simply have no activity on your local cluster.
So, what I thought for TheWheel is this:
T is defined as the time required to ensure the slot(T+t) to be unpredictable. If at t=0 you can have an idea of the slot interval you can expect at t=T ex: [slot(T,id=y),…..,slot(T,id=y+x] the more T is high, the more x is. Consequently, when player press “spin the wheel” the first time he has no idea what will be the computed value at T time after first click. Any slot belonging to [slot(T,id=y),…..,slot(T,id=y+x] can be given to hash function during second call and as the hash function works with a butterfly effect, player has absolutely no idea at first click what will be the [0,1] float used to define the winner.
After that step it is quite easy to define winner. The [0, 1] float is simply multiplied with the sum_of_deposits of the Game for the result to fall necessarily in one player interval.
Lamport is a small unit enough for side effects not to affect this process.
Last thing is to deal with second call. If a malicious player knows at T time the slot(T) , he will be able to know who the winner is and consequently he could just wait and try again later if he knows he cannot win this time. So now I need a stick! There are several ways to force player to perform the second call. If he does not, TheWheel can just erase his Publickey from the game ledger or dividing by two his deposit. Nevertheless, there are problems to deal with to ensure this strategy is correct. First it is not necessary player’s fault if transaction of the second call arrives too late or too soon. It can be because of network latency. That is why you need a kind of tolerance period for the second call.
In addition I need more time to have a better idea of transaction delays on different clusters. Documentation says the unixTimestamp
in Clock struct
that it is an approximate measure of real-world time. So I need to check the implementation of Clock struct.
For these two reasons, I will implement the second call in a second version of my program. For the moment only the first call defines the winner.
I know this solution is not perfect but if somebody else has a better idea to get not random number but an unpredictable one in a Solana program I will be very glad to hear his proposition. To improve the randomness of my [0, 1] float I’ve tried to get an idea of the activity on the cluster. First, I thought forcing program to execute during a sufficient period to get different (time,slot) values to ensure a minimum standard deviation is respected because, obviously, with no activity it is easier to anticipate what the slot(T) will be. Unfortunately, this is not possible. If you perform a loop in program, you will consume all your compute credit very quickly. So you cannot count to 1 000 000 and then watch what the new slot is. Moreover, as Solana programs are single-thread you cannot sleep to take different measurements.
Webapp
Code of TheWheel has been organized around the Solana–wallet-adapter project. I first ran a git clone
command on the project before adding my files one by one in same repository.
Not to break architecture of this initial project, I have defined my React context inside existing ones:
return (
<ConnectionProvider endpoint={endpoint}>
<WalletProvider wallets={wallets} onError={onError} autoConnect>
<WalletDialogProvider>
<MyContext>
{children}
</MyContext>
</WalletDialogProvider>
</WalletProvider>
</ConnectionProvider>
);
In Mycontext you can find the main getAccountinfo()
call to get data from the TheWheel PDA Account. Data are pushed in Mycontext for all components being able to use it. A new type has been defined to contain publickey + data:
type PublicKeyAndBuffer = [PublicKey, anchor.web3.AccountInfo<Buffer>];
const PDAProgram : Promise<PublicKeyAndBuffer >= useMemo( async () => {
let [voteAccount, ] = await anchor.web3.PublicKey.findProgramAddress(
[Buffer.from("thewheel"),PUBLICKEY_PROGRAM.toBuffer()],PUBLICKEY_PROGRAM );
const PDATheWheelAccountInfo = await connection!.getAccountInfo(voteAccount);
if (voteAccount!= null && PDATheWheelAccountInfo != null){
const myPublicKeyAndBuffer: PublicKeyAndBuffer = [voteAccount,PDATheWheelAccountInfo]
console.log("PDA TheWheel Account in MyContext =", voteAccount.toString());
return myPublicKeyAndBuffer;
}else{
exit();
}
},[update,PUBLICKEY_PROGRAM]);
As the implementation of React hooks in a Solana is not officially documented programmers have to make their own choices. What follows is a proposition I will be very glad to discuss as there are many chances for problems I had to solve are schematically the same in many other projects.
Here are some issues I had to deal with:
- choice of React hooks: the getAccountInfo() command is executed only one time to get TheWheel PDA Account in MyContext. Concerning the Game PDA Account as it depends of data fetched in TheWheel PDA Account, it is executed in components:
const PDAGAME: Promise<PublicKey> = useMemo( async () => {
console.log("PDAGAME in MyLittleWheelComponent=",props.sessionnumber)
let [game_account_inner, ] = await anchor.web3.PublicKey
.findProgramAddress([Buffer.from("thewheel"),PUBLICKEY_PROGRAM!.toBuffer(),Buffer.from(uint8)],PUBLICKEY_PROGRAM! );
console.log("PDAGAME in MyLittleWheelComponent=",props.sessionnumber, game_account_inner.toString())
return game_account_inner;
},[props,update]);
updating : problem is to deal with new data on Accounts when using the Webapp. I have seen in official documentation you can subscribe your webapp when modifications occurs on an Account. May be I should have given a chance to subscription but as I was first working on my testnet, I have focused on a local way to deal with updatings. Surely in a second version I will look deeper on subscriptions. For the moment I have just defined a
useState
in Mycontext that increments anumber
. when an action is performed in the Webapp. As thisupdate : number
is given to Function Components in the Webapp by thekey
parameter and to useMemo, useEffect and useCallback in entries to force hooks to recalculate the return value, my all Webapp is updated.the control of the data : to ensure my Webapp is a simple client of the on-chain program all controls are performed twice. So you can mirroring all checks in the Webapp and in the on-chain program. For more rigor I will identified pairs of tests in the next version.
Top comments (0)