In this post we are going to create a launch Dapp for a pair of custom tokens on the Solana blockchain, which incorporates charitable giving as a core part of the payment process.
We will be using a 'pay what you want' model similar to something like Humble Bundle, in which participants will be able to pay whatever they want for a block of 1000 tokens above some small minimum price. In our implementation we have set that minimum to 0.0001 SOL which is about 0.35 US cents at time of writing. They will also be able to choose how much of that payment we donate to their choice of charity, and how much stays with us, the app's developers.
Additionally, if a user pays more than the current average they will not only get double the tokens, but they will also receive a special supporters token, which might be used as a governance token for your project, or to unlock special features in your applications.
You can find the complete source code for the on-chain program and a rust based client here, and for a simple javascript front-end interface here. The front-end application can be tested at the bottom of this post, where it displays the current headline stats, and will allow you to join the token launch. Note this is running on the Solana devnet network so there are no real donations being made!
In order to make the charitable components of the launch as transparent and streamlined as possible, we will be using The Giving Block (TGB) to handle all the donations. TGB are partnered with over a thousand non-profit organizations spread across a huge range of different sectors, and from those we have selected a short list of seven that can be chosen by participants of our token launch.
To summarize, by the end of the post we will have completed the following:
To create our tokens on the Solana blockchain we will be making use of the Strata Protocol launchpad. Strata makes creating new tokens extremely straight forward, with the whole process taking only a matter of minutes. They also charge zero additional fees to use their platform, which means creating your token only costs about 0.01 SOL, or 30 US cents at time of writing.
You can specify which network you want to launch your token on from the drop down menu on the top right. For this post we will be creating two tokens on the devnet network. The first will be the primary token for our application, which we will imaginatively call the 'Dao Plays Test Token', with symbol DPTT. We want this to feel like the sort of token you would get in an old fashioned fairground or arcade, so we will set decimals to zero. Our launch Dapp will be selling blocks of 1000 or 2000 tokens so we set a supply of 100 million for an optimistic cap of about fifty to one hundred thousand participants.
The launchpad then provides two final options: i) whether to keep the mint authority, which will allow you to mint more of this sort of token in the future, and ii) whether to keep the freeze authority, which will allow you to freeze token accounts associated with this token. Neither of our tokens will keep their freeze authority, and for this first token type we will also not keep the mint authority, so our supply of 100 million tokens will be all that is ever created. When you click the Create Token button you will be prompted to authorize a couple of transactions related to setting up the accounts that will hold your tokens and the token meta data, and then you are done!
We then repeat this process for our second token, which we call the Dao Plays Supporter Test Token. In this case we keep decimals as zero, but as we will initially only need a much smaller quantity we set the supply to be one hundred thousand. Given we anticipate that this token will be used over many applications going forward, rather than just one as with the primary token type, we also retain mint authority so that we can produce more if that becomes necessary.
You can view your newly created tokens on explorer.solana by entering the mint address that was reported at the end of the creation process. For example, our two tokens can be seen here and here (also pictured below).
Now that we have our tokens it is time to set up our charity wallets. The Giving Block allow you to donate either to individual organizations, or what they refer to as index funds, which makes it easy to contribute to a collection of similarly themed organizations, where the donation is split evenly between all the members of the index.
In our example we will be including one index fund (the Ukraine Emergency Response Fund), and six individual charities, which we list below. These cover a range of different sectors such as the environment, accessibility to water, education, human rights, health & medicine, and effective giving. You can see a short description of each of these charities in their own words by hovering over the logos below, and can find out more by following the links to their TGB page. We also include a description of each in our front end application at the end of the post.
When you visit an organization's page on TGB you can use the widget to select the cryptocurrency you want your wallet to be in, and can then choose to either enter your personal details, or donate anonymously. At this point you are given a unique public key that is tied to that organization, and any donations made to that account will be tied to you (if you entered your details). This widget makes use of dynamic wallet addresses, so that every time you go back to the page and repeat this process you will be presented with a new address. This means that if privacy is a concern you can donate from different personal wallets, to different TGB wallets, in order to obfuscate your donations from third parties.
The only drawback with this process currently is that there is no way to cryptographically verify that one of these dynamic addresses really is related to The Giving Block, or therefore, to verify which cause it is for. This is a shortcoming that they are currently working to resolve, but until then when developing a trustless DApp, we have to rely on a non-cryptographic solution to publicly verify the accounts. In particular, after getting your wallets it is necessary for you to donate a small amount to each one, publicly display the transactions to these (for example by tweeting the transaction ids), and then TGB will verify via their twitter account that these donations were for genuine accounts, and which causes they are linked to.
While this allows potential users of your program to be confident that the wallets they are sending their crypto to are genuine, it unfortunately doesn't solve the reverse problem. It would be great to have the option for a user of our token sale to simply provide us with the account of a charity they would like to support, rather than having to choose one from a list. However currently there is no way for us to verify on chain in an automated way that the account really is a TGB account, hence why we must provide a list of authenticated accounts. We will be sure to post an updated version of this guide when the cryptographic solutions are available.
Now that we have both our tokens and our charity wallets we can start putting together the program that is going to actually handle the token launch. Our program will have 3 main functions:
init_token_launch
: Initialises the token launch, creates the program's data account and transfers tokens over to the programjoin_token_launch
: Allows users to participate in the token launchend_token_launch
: Ends the token launch, and transfers any remaining tokens back from the programThe first of the three functions introduces almost all of the key Solana concepts that will be at play within the program as a whole, namely 'program derived addresses', 'associated token accounts', and 'cross program invocation'. We will go through each of these in turn below as we implement the three main tasks that this function must perform:
The most straight forward way to handle a token launch is to have our program be responsible for sending out tokens to participants. Right now, however, the tokens are sitting in our own accounts, and the program doesn't have any authority to take the tokens from there, and send them on as needed. We therefore need to give the program control over the tokens, and to do that we first create (or more accurately, find) what is referred to as a 'Program derived address' (PDA).
There are many in depth discussions of exactly what PDAs are already available online (for example here or here) so we won't spend much time discussing the technical details of how they differ from a standard wallet address. For our purposes it is enough to say that a PDA is nothing more than the public key for an account that is owned by a program rather than by a person. Having a PDA will allow the program to have it's own token accounts, and critically will allow it to sign transactions that involve sending tokens from those accounts. Only the program that owns the PDA will be able to sign such transactions, and so in this regard the PDA provides the same functionality to the program as a keypair provides to a human.
The Solana API provides the find_program_address
function to find a PDA for your program which can be used on or off chain to easily obtain the right public key, without having to actually store it anywhere. This function takes as arguments an array of bytes that will be used as a seed (in our case just the string "token_account", where the lower case "b" converts that to a byte array), and the program's own public key.
let (expected_pda, bump_seed) = Pubkey::find_program_address(&[b"token_account"],program_id);
create_program_address
function using both the provided seed, and a separate bump seed which starts at a value of 255 and decreases by one until the function finds a valid PDA. Once it has been found it is recommended to directly call create_program_address
on chain and simply pass the correct bump_seed, rather than reusing find_program_address
as the former can be significantly less costly.create_account
function:pub fn create_account(from_pubkey: &Pubkey,to_pubkey: &Pubkey,lamports: u64,space: u64,owner: &Pubkey) -> Instruction
from_pubkey
refers to the wallet address that will fund the creation of the account, and the to_pubkey
is simply the address where we want to create our new account which will be the PDA. The third pubkey that is passed as the last argument, owner
, is the address of our program. In english then, this will create a new account for which our program will be the owner, at the location of our newly found PDA, and we will fund it's creation with our own wallet.$solana rent 0Rent per byte-year: 0.00000348 SOLRent per epoch: 0.000002439 SOLRent-exempt minimum: 0.00089088 SOL
// in state.rspub struct ICOData {pub charity_totals : [u64 ; 7],pub donated_total : u64,pub paid_total : u64,pub n_donations : u64}
try_to_vec
function.pub fn get_state_size() -> usize{let encoded = ICOData{charity_totals: [0; 7],donated_total : 0,paid_total : 0,n_donations : 0}.try_to_vec().unwrap();encoded.len()}
minimum_balance
function provided by Solana then takes this size and returns the current balance in lamports required for us to keep the account rent free. Our function to create the program's data account that combines these elements is shown below:// in processor.rs// create the program's data accountfn create_program_account<'a>(// the wallet that will be paying to create the token accountfunding_account: &AccountInfo<'a>,// the program account that we want to createpda : &AccountInfo<'a>,// the address of the programprogram_id : &Pubkey,bump_seed : u8) -> ProgramResult{let data_size = get_state_size();let space : u64 = data_size.try_into().unwrap();let lamports = rent::Rent::default().minimum_balance(data_size);msg!("Require {} lamports for {} size data", lamports, data_size);let ix = solana_program::system_instruction::create_account(funding_account.key,pda.key,lamports,space,program_id,);...
invoke_signed
function below....// Sign and submit transactioninvoke_signed(&ix,&[funding_account.clone(), pda.clone()],&[&[b"token_account", &[bump_seed]]])?;Ok(())}
get_associated_token_address
function. This takes either the wallet address of a user, or in this case, the address of our program's account, and the mint address of our token:// in spl_associated_token_accountpub fn get_associated_token_address(wallet_address: &Pubkey,spl_token_mint_address: &Pubkey) -> Pubkey
create_associated_token_address
to actually create the account:// in spl_associated_token_accountpub fn create_associated_token_account(funding_address: &Pubkey,wallet_address: &Pubkey,spl_token_mint_address: &Pubkey) -> Instruction
invoke
rather than invoke_signed
because it is the funding wallet that will be signing the transaction, rather than our program.fn create_token_account<'a>(// the wallet that will be paying to create the token accountfunding_account : &AccountInfo<'a>,// the account that will own the new token accountwallet_account : &AccountInfo<'a>,// the mint of the token accounttoken_mint_account : &AccountInfo<'a>,// the address of the token account, found through get_associated_token_accountnew_token_account : &AccountInfo<'a>,// the spl_token program accounttoken_program_account : &AccountInfo<'a>) -> ProgramResult{let create_ATA_idx = create_associated_token_account(&funding_account.key,&wallet_account.key,&token_mint_account.key);invoke(&create_ATA_idx,&[funding_account.clone(),new_token_account.clone(),wallet_account.clone(),token_mint_account.clone(),token_program_account.clone()],)?;Ok(())}
transfer
:// in spl_token::instructionpub fn transfer(token_program_id: &Pubkey,source_pubkey: &Pubkey,destination_pubkey: &Pubkey,authority_pubkey: &Pubkey,signer_pubkeys: &[&Pubkey],amount: u64) -> Result<Instruction, ProgramError>
authority_pubkey
in this case will be our wallet, however when the program is transferring tokens to other users that will be the PDA. In our use cases we will be leaving the signers vector empty. Our complete function to handle transferring the tokens is shown below, and it's structure should by now by quite familiar! We simply create the instruction, and then call invoke_signed
to handle the cross program invocation and actually enact the transaction. Note that in this case we could have used invoke
, as our PDA doesn't need to sign this transaction, however when the program is sending out tokens then we do need to use invoke_signed
and it is simpler to just use it generically for both cases.// in processor.rsfn transfer_tokens<'a>(// the amount in tokens that will be transferredamount : u64,// the token account that will act as the sourcetoken_source_account : &AccountInfo<'a>,// the token account to send totoken_dest_account : &AccountInfo<'a>,// the account that will sign the transactionauthority_account : &AccountInfo<'a>,// the spl_token accounttoken_program_account : &AccountInfo<'a>,// the bump_seed from our PDAbump_seed : u8) -> ProgramResult{let ix = spl_token::instruction::transfer(token_program_account.key,token_source_account.key,token_dest_account.key,authority_account.key,&[],amount,)?;invoke_signed(&ix,&[token_source_account.clone(),token_dest_account.clone(),authority_account.clone(),token_program_account.clone()],&[&[b"token_account", &[bump_seed]]])?;Ok(())}
The second of our main functions will allow users to participate in the token launch, and will be responsible for handling the SOL payments and sending out the different token types to the participants. The overall flow of the code is as follows:
// in state.rspub struct JoinMeta {pub amount_charity : u64,pub amount_dao : u64,pub charity : Charity}
amount_charity
and amount_dao
are the amounts in lamports to be paid to the chosen charity, and to the developers respectively. The Charity
type of the final member of this struct refers to an enum we have defined in state.rs
:// in state.rspub enum Charity {UkraineERF,WaterOrg,OneTreePlanted,EvidenceAction,GirlsWhoCode,OutrightActionInt,TheLifeYouCanSave}
...// check that this transaction is valid:// i) total amount should exceed the minimum// ii) joiner should not already have tokens// iii) program should have enough spare tokensmsg!("Transfer {} {}", meta.amount_charity, meta.amount_dao);msg!("Balance {}", joiner_account_info.try_borrow_lamports()?);// minimum amount is 0.0001 SOL, or 100000 lamportslet min_amount : u64 = 100000;if meta.amount_charity + meta.amount_dao < min_amount {msg!("Amount paid is less than the minimum of 0.0001 SOL");return Err(ProgramError::InvalidArgument);}...
try_borrow_lamports
, which will return a positive value if it has been previously initialised and there are lamports present in it. If it doesn't exist we create it using our create_token_account
function:...// check if we need to create the joiners token accountif **joiner_token_account_info.try_borrow_lamports()? > 0 {msg!("Users token account is already initialised.");}else {msg!("creating user's token account");Self::create_token_account(joiner_account_info,joiner_account_info,token_mint_account_info,joiner_token_account_info,token_program_account_info)?;}...
unpack_unchecked
function:...let program_token_account = spl_token::state::Account::unpack_unchecked(&program_token_account_info.try_borrow_data()?)?;let program_supporters_token_account = spl_token::state::Account::unpack_unchecked(&program_supporters_token_account_info.try_borrow_data()?)?;let joiner_token_account = spl_token::state::Account::unpack_unchecked(&joiner_token_account_info.try_borrow_data()?)?;msg!("token balances: {} {} {}", program_token_account.amount, program_supporters_token_account.amount, joiner_token_account.amount);if joiner_token_account.amount > 0 {msg!("Tokens already present in joiners account, thank you for taking part!");return Err(ProgramError::InvalidAccountData);}...
try_from_slice
function from the borsh
crate. As a last check before proceeding we simply make sure there are enough DPTTs remaining. We don't need to do this for the supporters tokens as we have made sure there is a sufficient supply even if every participant is a supporter....// get the data stored in the program account to access current statelet mut current_state = ICOData::try_from_slice(&program_data_account_info.data.borrow()[..])?;// calculate the current average to see if this individual has paid morelet current_average = current_state.paid_total / current_state.n_donations;let total_paid = meta.amount_charity + meta.amount_dao;let mut ico_token_amount : u64 = 1000;let mut supporter = false;// if they have then they get double!if total_paid > current_average {msg!("Thank you for paying over the average price!");ico_token_amount = 2000;supporter = true;}// check if there are the required number of tokens remainingif program_token_account.amount < ico_token_amount {msg!("Insufficient tokens remaining in token launch");return Err(ProgramError::InvalidArgument);}...
invoke
function, this time executing the transfer
instruction, first to send the charity's amount, and then the developer's:...// if we have made it this far the transaction we can try transferring the SOLinvoke(&system_instruction::transfer(joiner_account_info.key, charity_account_info.key, meta.amount_charity),&[joiner_account_info.clone(), charity_account_info.clone()],)?;invoke(&system_instruction::transfer(joiner_account_info.key, daoplays_account_info.key, meta.amount_dao),&[joiner_account_info.clone(), daoplays_account_info.clone()],)?;...
...// and finally transfer the tokensSelf::transfer_tokens(ico_token_amount,program_token_account_info,joiner_token_account_info,program_data_account_info,token_program_account_info,bump_seed)?;if supporter && program_supporters_token_account.amount >= 1 {// check if we need to create the joiners supporter token accountif **joiner_supporters_token_account_info.try_borrow_lamports()? > 0 {msg!("Users supporter token account is already initialised.");}else {msg!("creating user's supporter token account");Self::create_token_account(joiner_account_info,joiner_account_info,supporters_token_mint_account_info,joiner_supporters_token_account_info,token_program_account_info)?;}Self::transfer_tokens(1,program_supporters_token_account_info,joiner_supporters_token_account_info,program_data_account_info,token_program_account_info,bump_seed)?;}...
borsh
library to send the current state to the program's data account using the serialize
function:...// update the datalet charity_index = charity_index_map[meta.charity];current_state.charity_totals[charity_index] += meta.amount_charity;current_state.donated_total += meta.amount_charity;current_state.paid_total += total_paid;current_state.n_donations += 1;msg!("Updating current state: {} {} {} {}", current_state.charity_totals[charity_index], current_state.donated_total, current_state.paid_total, current_state.n_donations);current_state.serialize(&mut &mut program_data_account_info.data.borrow_mut()[..])?;...
close_account
:pub fn close_account(token_program_id: &Pubkey,account_pubkey: &Pubkey,destination_pubkey: &Pubkey,owner_pubkey: &Pubkey,signer_pubkeys: &[&Pubkey]) -> Result<Instruction, ProgramError>
destination_pubkey
account. The reason that we can't simply do this ourselves with a transfer instruction is that neither we nor the program actually own the token account, the token program does, and so the token program must be the one that sends us the lamports.close_program_token_account
function below, which takes the our program account and it's token account, our wallet and the destination token account and the token program itself:// in processor.rsfn close_program_token_account<'a>(// the program's account infoprogram_account_info : &AccountInfo<'a>,// the account info of the token account we want to closeprogram_token_account_info : &AccountInfo<'a>,// the destination account for the lamports being retrieveddestination_account_info : &AccountInfo<'a>,// the destination account for tokens being retrieveddestination_token_account_info : &AccountInfo<'a>,// the token programtoken_program_account_info : &AccountInfo<'a>,// the bump seed for our program derived addressbump_seed : u8) -> ProgramResult...
...{// Check the destination token account exists, which it should do if we are the ones that set it upif **destination_token_account_info.try_borrow_lamports()? > 0 {msg!("Confirmed destination token account is already initialised.");}else {msg!("destination token account should already exist");return Err(ProgramError::InvalidAccountData);}// And check that we haven't already closed out the program token accountlet program_token_account_lamports = **program_token_account_info.try_borrow_lamports()?;if program_token_account_lamports > 0 {msg!("Confirmed program token account is still initialised.");}else {msg!("program's token account already closed");return Ok(());}...
join_token_launch
function we make use of the unpack_unchecked
spl_token function to grab the token account's data so that we can retrieve the remaining token balance. If there are any tokens left we then make use of our transfer_token
function to send them to the destination_token_account
:...let program_token_account = spl_token::state::Account::unpack_unchecked(&program_token_account_info.try_borrow_data()?)?;msg!("transfer token balance: {}", program_token_account.amount);if program_token_account.amount > 0 {Self::transfer_tokens(program_token_account.amount,program_token_account_info,destination_token_account_info,program_account_info,token_program_account_info,bump_seed)?;}...
close_account
function, and call invoke_signed
to allow our program to sign the transaction:...msg!("close account and transfer SOL balance: {}", program_token_account_lamports);let close_token_account_idx = spl_token::instruction::close_account(token_program_account_info.key,program_token_account_info.key,destination_account_info.key,program_account_info.key,&[])?;invoke_signed(&close_token_account_idx,&[program_token_account_info.clone(), destination_account_info.clone(), program_account_info.clone()],&[&[b"token_account", &[bump_seed]]])?;Ok(())}
end_token_launch
function then simply has to call this function for both the main token account, and the supporters token account after parsing and checking the account details as usual.Overview
Account Info
We hope that you have found this post useful, and might be motivated to try and launch your own charitable token launch in the future. If so feel free to follow us on Twitter to keep up to date with future posts!