paint-brush
Build a Solana Crowdfunding App using Reactby@anatolii
2,168 reads
2,168 reads

Build a Solana Crowdfunding App using React

by Anatolii KabanovJuly 22nd, 2022
Read on Terminal Reader
Read this story w/o Javascript
tldt arrow

Too Long; Didn't Read

The Solana-crowdfunding app is based on the framework for the anchor-lang anchor. The application will support 3 major functions: create, donate, withdraw and create a campaign. The main executable code is stored in the ‘lib.rs’ file and also ‘Anchor.toml’ replace the program ID that you generated with what is inside these files.

Company Mentioned

Mention Thumbnail

Coin Mentioned

Mention Thumbnail
featured image - Build a Solana Crowdfunding App using React
Anatolii Kabanov HackerNoon profile picture


First, you will require installing all Solana stuff and the framework for the backend anchor. You can find the installation guide here, also there are some basic examples and other useful stuff.


The working repository can be found here.


Open the terminal in the folder you prefer to create the program and simply run the next command. It will create the project structure with all the required things.


anchor init crowdfunding-program
cd crowdfunding-program


The main executable code is stored in (all changes need to be done there):


programs/crowdfunding-program/src/lib.rs


First things first you need some account (it is actually like a wallet) for the program.


solana-keygen new -o id.json


Terminal then print the pubkey: <ACCOUNT_ADDRESS>

Change the location of the wallet to "./id.json" in ‘Anchor.toml’ file.


wallet = "./id.json"


Before deployment, you need some amount of Sol to do that. Request some money to your new account.


 solana airdrop 2 <ACCOUNT_ADDRESS> --url devnet


Now you can deploy the application.


anchor deploy


You will find new folders in your solution after deployment is completed. Basically, we need to obtain the program address and replace it with the automatically generated address by anchor.


solana address -k ./target/deploy/crowdfunding_program-keypair.json


This address will be the program ID. In the ‘lib.rs’ file and also in ‘Anchor.toml’ replace the program ID that you generated with what is inside these files.


The application will support 3 major functions:

  • create - create a campaign with the ‘name’, ‘description’, and ‘target_amount’;
  • donate - donate the particular amount to the campaign;
  • withdraw - withdraw the particular amount of money from the campaign account to the user’s wallet.


In the body of the create function I’m simply assigning all parameters to the campaign account.


   pub fn create(ctx: Context<Create>, name: String, description: String, target_amount: u64) -> Result<()> {
        let campaign = &mut ctx.accounts.campaign;
        campaign.name = name;
        campaign.description = description;
        campaign.amount_donated = 0;
        campaign.target_amount = target_amount;
        // * - means dereferencing
        campaign.owner = *ctx.accounts.user.key;
        Ok(())
    }


The <Create> is a structure of the current function context accounts, that must include a campaign account, and a signer who creates the campaign.


#[derive(Accounts)]
pub struct Create<'info> {
    // init means to create campaign account
    // bump to use unique address for campaign account
    #[account(init, payer=user, space=9000, seeds=[b"campaign_demo".as_ref(), user.key().as_ref()], bump)]
    pub campaign: Account<'info, Campaign>,
    // mut makes it changeble (mutable)
    #[account(mut)]
    pub user: Signer<'info>,
    pub system_program: Program<'info, System>,
}


#[account]
pub struct Campaign {
    pub owner: Pubkey,
    pub name: String,
    pub description: String,
    pub amount_donated: u64,
    pub target_amount: u64,
}


The donate functionality requires transferring a specific amount of money to the campaign account. The instruction describes from what account to what account the Sols’ will be transferred. At the I also increasing the counter of donated Sols’ campaign.amount_donated += amount.


pub fn donate(ctx: Context<Donate>, amount: u64) -> Result<()> {
        let instruction = anchor_lang::solana_program::system_instruction::transfer(
            &ctx.accounts.user.key(),
            &ctx.accounts.campaign.key(),
            amount
        );
        anchor_lang::solana_program::program::invoke(
            &instruction,
            &[
                ctx.accounts.user.to_account_info(),
                ctx.accounts.campaign.to_account_info(),
            ]
        );
        let campaign = &mut ctx.accounts.campaign;
        campaign.amount_donated += amount;
        Ok(())
}


The structure of the <Donate> accounts context is close to the <Create>, but here I don’t need to initialize the campaign itself, I just use the campaign that users selected.


#[derive(Accounts)]
pub struct Donate<'info> {
    #[account(mut)]
    pub campaign: Account<'info, Campaign>,
    // mut makes it changeble (mutable)
    #[account(mut)]
    pub user: Signer<'info>,
    pub system_program: Program<'info, System>,
}



In the withdraw function besides transferring all funds to the user’s wallet I need to check if the amount of money is sufficient for transferring and if the user is the owner of this particular campaign.


 pub fn withdraw(ctx: Context<Withdraw>, amount: u64) -> Result<()> {
        let campaign = &mut ctx.accounts.campaign;
        let user = &mut ctx.accounts.user;
        if campaign.owner != *user.key {
            return Err(ErrorCode::InvalidOwner.into());
        }
        // Rent balance depends on data size
        let rent_balance = Rent::get()?.minimum_balance(campaign.to_account_info().data_len());
        if **campaign.to_account_info().lamports.borrow() - rent_balance < amount {
            return Err(ErrorCode::InvalidWithdrawAmount.into());
        }
        **campaign.to_account_info().try_borrow_mut_lamports()? -= amount;
        **user.to_account_info().try_borrow_mut_lamports()? += amount;
        Ok(())
} 


#[derive(Accounts)]
pub struct Withdraw<'info> {
    #[account(mut)]
    pub campaign: Account<'info, Campaign>,
    // mut makes it changeble (mutable)
    #[account(mut)]
    pub user: Signer<'info>,
}


As you can see in the withdraw method, I throw errors if the User is not the owner of the campaign and if the balance is insufficient. The error messages actually represented in an enum called ErrorCode.


#[error_code]
pub enum ErrorCode {
    #[msg("The user is not the owner of the campaign.")]
    InvalidOwner,
    #[msg("Insufficient amount to withdraw.")]
    InvalidWithdrawAmount,
}


If you are done with all changes in your code, you need to build and deploy your program to the network.


anchor build


And deploy the program to the preferred network that you will work this, for example, ‘devnet’. In the root directory, there is file ‘Anchor.toml’, change the value of cluster to ‘devnet’.


cluster = "devnet"


The number 2 is some kind of constraint on the ‘devnet’. And finally, deploy it.


anchor deploy


After creating the main functionality it will be nice to cover it with tests. You can find tests in the repository, they are more or less similar to what you will have on the UI. Then let’s move forward and create the frontend part.


After you have done the backend part you can move to the frontend part and initialize react application via create-react-app.


npx create-react-app crowdfunding-ui --template typescript


Change directory: cd crowdfunding-ui

And install packages for the UI


 npm i @solana/wallet-adapter-react @solana/wallet-adapter-react-ui @solana/wallet-adapter-wallets @solana/web3.js @project-serum/anchor react-bootstrap


The library react-bootstrap is for styling only.

But it is not enough to properly build and run the UI application. There are some dependencies in the anchor package and some features in Solana libraries if you use react-scripts.


npm i --save-dev react-app-rewired source-map-loader


The problem is in some modules that don’t maintain the webpack 5. So create the file ‘config-overrides.js’ in the root of your UI application and copy the next code.


/* 
* To build solana dependencies properly
*/
const { ProvidePlugin } = require('webpack');

module.exports = function (config, env) {
    return {
        ...config,
        module: {
            ...config.module,
            rules: [
                ...config.module.rules,
                {
                    test: /\.(m?js|ts)$/,
                    enforce: 'pre',
                    use: ['source-map-loader'],
                },
            ],
        },
        plugins: [
            ...config.plugins,
            new ProvidePlugin({
                process: 'process/browser',
            }),
        ],
        resolve: {
            ...config.resolve,
            fallback: {
                assert: require.resolve('assert'),
                buffer: require.resolve('buffer'),
                stream: require.resolve('stream-browserify'),
                crypto: require.resolve('crypto-browserify'),
            },
        },
        ignoreWarnings: [/Failed to parse source map/],
    };
};


As in the example of the Solana library, it is better to create a separate ‘wrapper’ or ‘provider’ to connect and work with the wallet.


const supportedWallets = [ new PhantomWalletAdapter() ];

const WalletWrapper: React.FC<WalletWrapperProps> = ({ children, network }) => {
    return (
        <ConnectionProvider endpoint={network}>
            <WalletProvider wallets={supportedWallets} autoConnect>
                <WalletModalProvider>
                   {children}
                </WalletModalProvider>
            </WalletProvider>
        </ConnectionProvider>
    );
};


And basically, wrap the whole App in it.


const App: React.FC<AppProps> = () => {
    return (
        <WalletWrapper network={network}>
            <CompaingsView network={network}/>
        </WalletWrapper>
    );
}


From the Anchor program you created, we need one file → crowdfunding_program.json. Copy it to the UI src folder. This file contains a description of how to communicate with your program, with all types that you defined.


cp ./target/idl/crowdfunding_program.json ../crowdfunding-ui/src/idl.json


To be able to send and receive data, you require to create the Program class, where you put copied Idl, programId and provider. It will be in the CampaignsView component.


const getProgram = () => {
        /* create the provider and return it to the caller */ 
        const connection = new Connection(network, opts.preflightCommitment);
        const provider = new AnchorProvider(connection, wallet as any, opts);
        /* create the program interface combining the idl, program ID, and provider */
        const program = new Program(idl as Idl, programId, provider);
        return program;
    };

const program = getProgram();


This Program class will be responsible to communicate with the crowdfunding program. And to create the first campaign you need some amount of Sols on your wallet and of course wallet itself.


I used a Phantom wallet, so install the extension to the browser if you haven’t done it yet. Change the network to ‘devnet’ in the settings of the wallet, after airdrop to your wallet.


The create functionality is covered by the createCampaign method. It basically uses values from inputs like: name, description, targetAmount. And signer here is a User’s wallet. At the end CampaignsView will look like this:


import React, { ChangeEvent, useState } from 'react';
import {
    AnchorProvider,
    BN,
    Idl,
    Program,
    utils,
    web3,
} from '@project-serum/anchor';
import { useWallet } from '@solana/wallet-adapter-react';
import { WalletMultiButton } from '@solana/wallet-adapter-react-ui';
import { Commitment, Connection, PublicKey } from '@solana/web3.js';
import idl from '../idl.json';
import { Button, FloatingLabel, Form } from 'react-bootstrap';
import CampaignsTable from '../components/campaigns-table';

const opts: { preflightCommitment: Commitment } = {
    preflightCommitment: 'processed',
};

const programId = new PublicKey(idl.metadata.address);

interface CampaignsViewProps {
    network: string;
}

export const CampaignsView: React.FC<CampaignsViewProps> = ({ network }) => {
    const wallet = useWallet();
    const [name, setName] = useState('');
    const [description, setDescription] = useState('');
    const [targetAmount, setTargetAmount] = useState<number>(1);

    const getProgram = () => {
        /* create the provider and return it to the caller */ 
        const connection = new Connection(network, opts.preflightCommitment);
        const provider = new AnchorProvider(connection, wallet as any, opts);
        /* create the program interface combining the idl, program ID, and provider */
        const program = new Program(idl as Idl, programId, provider);
        return program;
    };

    const program = getProgram();

    const onNameChange = (e: ChangeEvent<any>) => {
        setName(e.target.value);
    };
    const onDescriptionChange = (e: ChangeEvent<any>) => {
        setDescription(e.target.value);
    };
    const onTargetAmountChange = (e: ChangeEvent<any>) => {
        setTargetAmount(e.target.value);
    };

    const createCampaign = async () => {
        const [campaign] = await PublicKey.findProgramAddress(
            [
                utils.bytes.utf8.encode('campaign_demo'),
                wallet.publicKey!.toBuffer(),
            ],
            program.programId,
        );
        await program.methods
            .create(name, description, new BN(targetAmount))
            .accounts({
                campaign: campaign,
                user: wallet.publicKey!,
                systemProgram: web3.SystemProgram.programId,
            })
            .rpc();
    };

    return (
        <div className='campaigns-view p-5'>
            {!wallet.connected && <WalletMultiButton />}
            <Form>
                <Form.Group className='mb-3'>
                    <FloatingLabel controlId='name' label='Name'>
                        <Form.Control
                            type='text'
                            placeholder='Name of the campaign'
                            value={name}
                            onChange={onNameChange}
                        />
                    </FloatingLabel>
                </Form.Group>
                <Form.Group className='mb-3'>
                    <FloatingLabel controlId='description' label='Description'>
                        <Form.Control
                            as='textarea'
                            placeholder='Description of the campaign'
                            style={{ height: '150px' }}
                            value={description}
                            onChange={onDescriptionChange}
                        />
                    </FloatingLabel>
                </Form.Group>
                <Form.Group className='mb-3'>
                    <FloatingLabel
                        controlId='targetAmount'
                        label='Target Amount'
                        className='mb-3'
                    >
                        <Form.Control
                            as='input'
                            type='number'
                            placeholder='Targemt amount that need to be reached'
                            value={targetAmount}
                            onChange={onTargetAmountChange}
                        />
                    </FloatingLabel>
                </Form.Group>
                <Form.Group className='mb-3'>
                    <Button variant='primary' onClick={createCampaign}>
                        Create Campaign
                    </Button>
                </Form.Group>
            </Form>
            {wallet.connected && <CampaignsTable  walletKey={wallet.publicKey!} program={program}/>}
        </div>
    );
};

export default CampaignsView;


At least the user must have the ability to see all campaigns, then add a list with all of the created campaign accounts. In the program, there is no specific functionality for that. It’s because such functionality is inside the Anchor framework. For each account, there is the extension method called ‘all’, you need to call it like program.account.campaign.all().


    const [campaigns, setCampaigns] = useState<ProgramAccount[]>([]);

    const getAllCampaigns = async () => {
        const campaigns = await program.account.campaign.all();
        setCampaigns(campaigns);
    };


The value for donation and withdrawal are hardcoded, but they can be input like for campaign creation. Otherwise donate and withdraw functions are using program to invoke specific methods. For each program method invocation Phantom wallet will ask you to approve or decline the transaction. But the call of the particular function is pretty easy.


import React, { useEffect, useState } from 'react';
import { BN, Program, ProgramAccount, web3 } from '@project-serum/anchor';
import { PublicKey } from '@solana/web3.js';
import { Button, Table } from 'react-bootstrap';

interface CampaignsTableProps {
    program: Program;
    walletKey: PublicKey;
}

export const CampaignsTable: React.FC<CampaignsTableProps> = ({
    program,
    walletKey,
}) => {
    const [campaigns, setCampaigns] = useState<ProgramAccount[]>([]);

    const getAllCampaigns = async () => {
        const campaigns = await program.account.campaign.all();
        setCampaigns(campaigns);
    };

    useEffect(() => {
        getAllCampaigns();
    }, [walletKey]);

    const donate = async (campaignKey: PublicKey) => {
        try {
            await program.methods
                .donate(new BN(0.2 * web3.LAMPORTS_PER_SOL))
                .accounts({
                    campaign: campaignKey,
                    user: walletKey,
                    systemProgram: web3.SystemProgram.programId,
                })
                .rpc();
            await getAllCampaigns();
        } catch (err) {
            console.error('Donate transaction error: ', err);
        }
    };

    const withdraw = async (campaignKey: PublicKey) => {
        try {
            await program.methods
                .withdraw(new BN(0.2 * web3.LAMPORTS_PER_SOL))
                .accounts({
                    campaign: campaignKey,
                    user: walletKey,
                })
                .rpc();
        } catch (err) {
            console.error('Withdraw transaction error: ', err);
        }
    };

    const allCampaigns: () => JSX.Element[] = () => {
        return campaigns.map((c, i) => {
            const key = c.publicKey.toBase58();

            return (
                <tr key={key}>
                    <td>{i + 1}</td>
                    <td>{c.account.name}</td>
                    <td>{c.account.description}</td>
                    <td>{c.account.targetAmount.toString()}</td>
                    <td>{(c.account.amountDonated / web3.LAMPORTS_PER_SOL).toString()}</td>
                    <td>
                        <Button
                            className='m-1'
                            variant='primary'
                            onClick={() => donate(c.publicKey)}
                        >
                            Donate
                        </Button>
                        <Button
                            disabled={c.account.owner.toBase58() !== walletKey.toBase58()}
                            className='m-1'
                            variant='danger'
                            onClick={() => withdraw(c.publicKey)}
                        >
                            Withdraw
                        </Button>
                    </td>
                </tr>
            );
        });
    };

    return (
        <>
            <div>Campaigns</div>
            <Table>
                <thead>
                    <tr>
                        <th>#</th>
                        <th>Name</th>
                        <th>Description</th>
                        <th>Target Amount</th>
                        <th>Donated</th>
                        <th></th>
                    </tr>
                </thead>
                <tbody>{allCampaigns()}</tbody>
            </Table>
        </>
    );
};

export default CampaignsTable;


Now you can run your program and enjoy it.


npm start


All data is stored in the blockchain, brilliant.