Cash App on Solana

In this guide, you will learn how to create a react-native mobile app that is both Android and iOS compatible. This app will mimic a Cash App experience but run on the Solana blockchain, showcasing that web3 products can have the same user experience as web2 products. To build this, we will need to write an Anchor program, integrate the Solana Name Service SDK, and integrate Solana Pay.

What You Will Learn #

  • Setting up your environment
  • Creating a Solana mobile dApp
  • Anchor program development
  • Anchor PDAs and accounts
  • Deploying a Solana program
  • Testing a Solana program
  • Connecting an onchain program to a mobile React Native UI
  • Solana Pay
  • Solana Name Service

What You Will Build #

You will learn to build a finance application similar to Cash App. This will be a web3 mobile app with a wallet adaptor, devnet deployed anchor program, and custom UI to interact with the anchor program.

Home Screen #

Cash BalanceCash Balance Cash Out ModalCash Out Modal

Payment Screen #

Payment ScreenPayment Screen Request ScreenRequest Screen

QR Screen #

QR ScreenQR Screen QR ModalQR Modal

Activity Screen #

Activity ScreenActivity Screen

Prerequisites #

Setup the following tools on your local development environment:

For an introduction to Solana program development with the Anchor framework, review this guide:

For an introduction to Solana Mobile development, review the Solana Mobile docs:

Project Design Overview #

Let's start by quickly mapping out the entire dApp design. To create a clone of Cash App, we want to have the following features:

  1. Account creation
  2. Deposit and withdraw funds
  3. User-to-user money transfer
  4. QR code generation
  5. Connect with friends
  6. Activity tracking
  7. Send payment requests to friends

To enable these functionalities, we will do the following:

  1. Write a Solana program that allows for users to initialize a new account on-chain and set up a user name (similar to $Cashtag) with Solana Name Service. With the username being set via SNS, you can then get public key information directly from an account's username.
  2. Add instructions to the Solana program for a user to be able to deposit funds from their wallet into their cash account and withdrawal funds from their cash account into their wallet.
  3. Add instructions for a user to be able to directly send funds from their own cash account to another cash account, request funds from a specified cash account, and accept or decline payment requests.
  4. Integrate Solana Pay to enable QR code generation. Solana pay also allows you to specify the amount and memo for the requested transaction directly in the QR code.
  5. Add an instruction for a user to be able to add friends by pushing the user provided public key to a friends vector saved to the user's account state, which can then be displayed on the front end similar to Cash App.
  6. Add an activity tab that queries the cash account state of the connected user to show pending requests and pending payments.
  7. Add in an additional account type for payment requests and write instructions for creating a request, accepting a request and processing the payment transfer, and declining the request and closing the pending request account.

Solana Mobile App Template Set Up #

Since this project will be a mobile app, we can get started with the Solana mobile expo app template:

yarn create expo-app --template @solana-mobile/solana-mobile-expo-template

This is initializing a new project using the Expo framework that is specifically designed for creating mobile applications that interact with the Solana blockchain.

Name the project cash-app-clone then navigate into the directory.

Follow the Running the app guide to launch the template as a custom development build and get it running on your Android emulator. Once you have built the program and are running a dev client with expo, the emulator will automatically update every time you save your code.

Reminder

You must have fake wallet running on the same Android emulator to be able to test out transactions, as explained in the Solana mobile development set up docs or you must have a real wallet app, like Phantom or Solflare, installed and set up on your emulator.

Writing a Solana Program with Cash App Functionalities #

Initialize the Anchor Workspace #

An Anchor workspace needs to be initialized to enable Solana program development, deployment, and testing within this repository.

cd cash-app-clone
 
anchor init cash-app

Once the Anchor workspace has been initialized, navigate to to cash-app/programs/cash-app/src/lib.rs to start writing the program code.

Your Anchor program should already be defined by initializing the Anchor work space and should look as follows:

use anchor_lang::prelude::*;
 
declare_id!("3dQeymKBEWf32Uzyzxm3Qyopt6uyHJdXxtvrpJdk7vCE");
 
#[program]
pub mod cash_app {
    use super::*;
 
    pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
        Ok(())
    }
}
 
#[derive(Accounts)]
pub struct Initialize {}

Define Your Account State #

#[account]
#[derive(InitSpace)]
pub struct CashAccount {
    pub owner: Pubkey,
    #[max_len(100)]
    pub friends: Vec<Pubkey>,
}

Since we are able to directly query the SOL balance of PDA accounts, we don't have to keep track of the user's account balance here.

Write Instructions #

Now that the state is defined, we need to create an instruction to initialize an account when a new user signs up for Cash App. This will initialize a new cash_account and the PDA of this account will be derived from the string "cash-account" and the public key of the user's wallet.

#[program]
pub mod cash_app {
    use super::*;
 
    pub fn initialize_account(ctx: Context<InitializeAccount>) -> Result<()> {
        let cash_account = &mut ctx.accounts.cash_account;
        cash_account.owner = *ctx.accounts.signer.key;
        cash_account.friends = Vec::new();
        Ok(())
    }
}
 
#[derive(Accounts)]
pub struct InitializeAccount<'info> {
    #[account(
        init,
        seeds = [b"cash-account", signer.key().as_ref()],
        bump,
        payer = signer,
        space = 8 + CashAccount::INIT_SPACE
    )]
    pub cash_account: Account<'info, CashAccount>,
    #[account(mut)]
    pub signer: Signer<'info>,
    pub system_program: Program<'info, System>,
}
 

Because the InitSpace macro was used when defining the CashAccount state, it can be called to calculate the space that this program will take up on chain. The space is needed in order to calculate how much rent the payer will need to pay to hold the program on chain.

Next we will need to add an instruction to this program that allows a user to deposit funds into their cash account:

#[program]
pub mod cash_app {
    use super::*;
 
    //...
 
    pub fn deposit_funds(ctx: Context<DepositFunds>, amount: u64) -> Result<()> {
        require!(amount > 0, ErrorCode::InvalidAmount);
 
        let ix = system_instruction::transfer(
            &ctx.accounts.signer.key(),
            ctx.accounts.cash_account.to_account_info().key,
            amount,
        );
 
        invoke(
            &ix,
            &[
                ctx.accounts.signer.clone(),
                ctx.accounts.cash_account.to_account_info(),
            ],
        )?;
 
        Ok(())
    }
}
 
#[derive(Accounts)]
pub struct DepositFunds<'info> {
    #[account(
        mut,
        seeds = [b"cash-account", signer.key().as_ref()],
        bump,
    )]
    pub cash_account: Account<'info, CashAccount>,
    #[account(mut)]
    /// CHECK: This account is only used to transfer SOL, not for data storage.
    pub signer: AccountInfo<'info>,
    pub system_program: Program<'info, System>,
}
 
#[error_code]
pub enum ErrorCode {
    #[msg("The provided amount must be greater than zero.")]
    InvalidAmount,
}
 

The deposit_funds function constructs a system instruction to transfer SOL from the user's wallet to the user's cash account PDA. Solana programs are designed to be isolated for security reasons; they don't have direct access to each other's state or functions. If one program needs to run an instruction handler that is part of another program, it must do so through a cross-program invocation (CPI). Since the funds are coming from the signer's wallet, which is an account owned by the signer not the program, the function has to interact with the System Program to modify the balance of the accounts. The transfer instruction from the System Program is then executed using invoke, which safely performs the CPI by taking in the transfer instruction and a slice of accounts that the instruction will interact with.

invoke ensures that all operations are performed securely and in compliance with the rules set by the Solana network and the specific programs involved. It verifies that:

  • Only authorized modifications to account data are performed.
  • The necessary signatures for operations that require them are present.
  • The operation does not violate the program's constraints or Solana's network rules.

Next, we need to add an instruction that allows a user to withdraw funds from their cash account:

#[program]
pub mod cash_app {
    use super::*;
 
    //...
 
    pub fn withdraw_funds(ctx: Context<WithdrawFunds>, amount: u64) -> Result<()> {
        require!(amount > 0, ErrorCode::InvalidAmount);
 
        let cash_account = &mut ctx.accounts.cash_account.to_account_info();
        let wallet = &mut ctx.accounts.signer.to_account_info();
 
        require!(*cash_account.owner == ctx.accounts.signer.key(), ErrorCode::InvalidSigner);
 
        **cash_account.try_borrow_mut_lamports()? -= amount;
        **wallet.try_borrow_mut_lamports()? += amount;
 
        Ok(())
    }
}
 
#[derive(Accounts)]
pub struct WithdrawFunds<'info> {
    #[account(
        mut,
        seeds = [b"cash-account", signer.key().as_ref()],
        bump,
    )]
    pub cash_account: Account<'info, CashAccount>,
    #[account(mut)]
    pub signer: Signer<'info>,
    pub system_program: Program<'info, System>,
}
 
#[error_code]
pub enum ErrorCode {
    #[msg("The provided amount must be greater than zero.")]
    InvalidAmount,
 
    #[msg("Signer does not have access to call this instruction.")]
    InvalidSigner,
}

Unlike the deposit_funds instruction, the withdraw_funds instruction directly adjusts the Lamports cash_account and the user's wallet by using try_borrow_mut_lamports(). This transfer of funds can be done without the overhead of a CPI because the cash_account is owned by the same program executing the function. This is more efficient but requires careful handling to ensure security.

When Solana Program transfers lamports from an account that it owns, the sender account must be owned by the program but the recipient account does not have to be owned by the program. Since lamports can not be created or destroyed when changing account balances, any decrement performed needs to be balanced with an equal increment somewhere else, otherwise you will get an error. In the above withdraw_funds instruction, the program is transferring the exact same amount of lamports from the cash account into the users wallet.

Since we are directly manipulating the lamports in an account, we want to ensure that the signer of the instruction is the same as the owner of the account so that only the owner can call this instruction. This is why the following validation check was implemented: require!(cash_account.owner == ctx.accounts.signer, ErrorCode::InvalidSigner).

For error handling. the #[error_code] Anchor macro is used, which generates Error and type Result<T> = Result<T, Error> types to be used as return types from Anchor instruction handlers. Importantly, the attribute implements From on the ErrorCode to support converting from the user defined error enum into the generated Error.

Now let's create an instruction for transferring funds from one user to another.

#[program]
pub mod cash_app {
    use super::*;
 
    //...
 
    pub fn transfer_funds(
        ctx: Context<TransferFunds>,
        _recipient: Pubkey,
        amount: u64,
    ) -> Result<()> {
        require!(amount > 0, ErrorCode::InvalidAmount);
 
        let from_cash_account = &mut ctx.accounts.from_cash_account.to_account_info();
        let to_cash_account = &mut ctx.accounts.to_cash_account.to_account_info();
 
        require!(*cash_account.owner == ctx.accounts.signer.key(), ErrorCode::InvalidSigner);
 
        **from_cash_account.try_borrow_mut_lamports()? -= amount;
        **to_cash_account.try_borrow_mut_lamports()? += amount;
 
        Ok(())
    }
}
 
#[derive(Accounts)]
#[instruction(recipient: Pubkey)]
pub struct TransferFunds<'info> {
    #[account(
        mut,
        seeds = [b"cash-account", signer.key().as_ref()],
        bump,
    )]
    pub from_cash_account: Account<'info, CashAccount>,
 
    #[account(
        mut,
        seeds = [b"cash-account", recipient.key().as_ref()],
        bump,
    )]
    pub to_cash_account: Account<'info, CashAccount>,
    pub system_program: Program<'info, System>,
    pub signer: Signer<'info>,
}

In the above instruction, the TransferFunds Context data structure consists of an additional account. The Context is a struct that includes references to all the accounts needed for the operation. Since we need information from both the sender and recipient accounts for this instruction, we need to include both accounts in the Context.

We are once again directly transferring lamports between accounts, since the program owns the cash_account account. The seeds for the cash account PDAs are created from the public key of the cash account owner so the instruction needs to take the recipient's public key as a parameter and pass that to the TransferFunds Context data structure. Then the cash_account PDA can be derived for both the from_cash_account and the to_cash_account.

Since both of the accounts are listed in the #[derive(Accounts)] macro, they are deserialized and validated so you can simply call both of the accounts with the Context ctx to get the account info and update the account balances from there.

To be able to send funds to another user, similar to Cash App, both users must have created an account. We're sending funds to the user's cash_account PDA, not the user's wallet. So each user needs to initialize a cash account by calling the initialize_account instruction to create their unique PDA derived from their wallet public key. We'll need to keep this in mind when designing the UI/UX of the onboarding process for this dApp later on to ensure every user calls the initialize_account instruction when signing up for an account.

Now that the basic payment functionality is enabled, we want to be able to interact with friends. So we need to add instructions for adding friends, requesting payments from friends, and accepting/rejecting payment requests.

Adding a friend is as simple as just pushing a new public key to the friends vector in the CashAccount state.

#[program]
pub mod cash_app {
    use super::*;
 
    //...
    pub fn add_friend(ctx: Context<AddFriend>, pubkey: Pubkey) -> Result<()> {
        let cash_account = &mut ctx.accounts.cash_account;
        cash_account.friends.push(pubkey);
        Ok(())
    }
}
 
#[derive(Accounts)]
pub struct AddFriend<'info> {
    #[account(
        mut,
        seeds = [b"cash-account", signer.key().as_ref()],
        bump,
    )]
    pub cash_account: Account<'info, CashAccount>,
    #[account(mut)]
    /// CHECK: This account is only used to transfer SOL, not for data storage.
    pub signer: AccountInfo<'info>,
    pub system_program: Program<'info, System>,
}

In the add_friend function, there is a design limitation. The vec of friends has a limit to how many friends a user to can add. To enhance this program to allow limitless friends, the way that friends are stored must be changed.

Multiple Accounts Types in One Program #

There are several different ways to approach requesting payments from friends. In this example, we will make each payment request its own PDA account in order to simplify querying active requests, deleting completed requests, and updating both the sender and recipient cash accounts.

Each time a new payment request is created, the instruction will create a new PDA account that holds data for the payment's sender, recipient, and amount.

To have multiple account types within one program, you just need to define the data structure for each account type and have instructions to be able to initialize each account type. We already have the state data structure and init account instruction for the cash account, now we'll just add this for the pending request account.

#[account]
#[derive(InitSpace)]
pub struct PendingRequest {
    pub sender: Pubkey,
    pub recipient: Pubkey,
    pub amount: u64,
}
 
#[derive(Accounts)]
pub struct InitializeRequest<'info> {
    #[account(
        init,
        seeds = [b"pending-request", signer.key().as_ref()],
        bump,
        payer = signer,
        space = 8 + PendingRequest::INIT_SPACE
    )]
    pub pending_request: Account<'info, PendingRequest>,
    #[account(mut)]
    pub signer: Signer<'info>,
    pub system_program: Program<'info, System>,
}
 
#[program]
pub mod cash_app {
    use super::*;
 
    //...
 
    pub fn new_request(ctx: Context<InitializeRequest>, sender: Pubkey, amount: u64) -> Result<()> {
        let pending_request = &mut ctx.accounts.pending_request;
        pending_request.recipient = *ctx.accounts.signer.key;
        pending_request.sender = sender;
        pending_request.amount = amount;
        Ok(())
    }
}

Now that we are able to send payment requests, we need to be able to accept or decline those payments. So let's add in those instructions now.

#[program]
pub mod cash_app {
    use super::*;
 
    //...
 
    pub fn decline_request(_ctx: Context<DeclineRequest>) -> Result<()> {
        Ok(())
    }
}
 
#[derive(Accounts)]
pub struct DeclineRequest<'info> {
    #[account(
        mut,
        seeds = [b"pending-request", signer.key().as_ref()],
        bump,
        close = signer,
    )]
    pub pending_request: Account<'info, PendingRequest>,
    #[account(mut)]
    pub signer: Signer<'info>,
    pub system_program: Program<'info, System>,
}

To decline a request, the pending_request account needs to be closed. By specifying the close constraint in the account macro for the DeclineRequest data structure, the account simply closes when the correct signer signs the decline_request instruction.

For accept_request, we also want the account to close upon completion of the instruction but the requested funds need to be transferred to the correct recipient first.

#[program]
pub mod cash_app {
    use super::*;
 
    //...
 
    pub fn accept_request(ctx: Context<AcceptRequest>) -> Result<()> {
        let amount = ctx.accounts.pending_request.amount;
 
        let from_cash_account = &mut ctx.accounts.from_cash_account.to_account_info();
        let to_cash_account = &mut ctx.accounts.to_cash_account.to_account_info();
 
        **from_cash_account.try_borrow_mut_lamports()? -= amount;
        **to_cash_account.try_borrow_mut_lamports()? += amount;
 
        Ok(())
    }
}
 
#[derive(Accounts)]
pub struct AcceptRequest<'info> {
    #[account(
        mut,
        seeds = [b"pending-request", signer.key().as_ref()],
        bump,
        close = signer,
    )]
    pub pending_request: Account<'info, PendingRequest>,
    #[account(
        mut,
        seeds = [b"cash-account", pending_request.sender.key().as_ref()],
        bump,
    )]
    pub from_cash_account: Account<'info, CashAccount>,
    #[account(
        mut,
        seeds = [b"cash-account", pending_request.recipient.key().as_ref()],
        bump,
    )]
    pub to_cash_account: Account<'info, CashAccount>,
    #[account(mut)]
    pub signer: Signer<'info>,
    pub system_program: Program<'info, System>,
}

The AcceptRequest struct has references to three accounts since we need all three to complete the request. The recipient and sender public keys are pulled from the pending_request account state and used to derive the two cash_account accounts needed for this transaction.

We're now able to deposit funds, withdraw funds, send funds to another user, request funds from another user, add friends, and accept/decline requests, which covers all of the functionality in Cash App. We'll just add one optimization to this program before testing.

Integrating a Counter for Unique PDAs #

Since a user can have multiple pending requests, we want each request to have a unique PDA. So we can update the above code to include a counter in the PDA. The counter will need to be tracked in the user's cash account state, so now ProcessRequest needs to take in the cash account along with the pending request account. So lets first update both account data structures.

#[account]
#[derive(InitSpace)]
pub struct CashAccount {
    pub signer: Pubkey,
    pub friends: Vec<Pubkey>,
    pub request_counter: u64,
}
 
#[account]
#[derive(InitSpace)]
pub struct PendingRequest {
    pub sender: Pubkey,
    pub recipient: Pubkey,
    pub amount: u64,
    pub id: u64,
}

Now we need to update the InitializeRequest, DeclineRequest, and AcceptRequest structs to include the requester's cash account so that the counter can be queried and incremented and the pending_request account can use the value of the counter in its PDA generation.

#[derive(Accounts)]
pub struct InitializeRequest<'info> {
    #[account(
        init,
        seeds = [b"pending-request", signer.key().as_ref(), cash_account.pending_request_counter.to_le_bytes().as_ref()],
        bump,
        payer = signer,
        space = 8 + PendingRequest::INIT_SPACE
    )]
    pub pending_request: Account<'info, PendingRequest>,
    #[account(
        mut,
        seeds = [b"cash-account", signer.key().as_ref()],
        bump,
        close = signer,
    )]
    pub cash_account: Account<'info, CashAccount>,
    #[account(mut)]
    pub signer: Signer<'info>,
    pub system_program: Program<'info, System>,
}
 
#[derive(Accounts)]
pub struct DeclineRequest<'info> {
    #[account(
        mut,
        seeds = [b"pending-request", signer.key().as_ref(), pending_request.id.to_le_bytes().as_ref()],
        bump,
        close = signer,
    )]
    pub pending_request: Account<'info, PendingRequest>,
    #[account(mut)]
    pub signer: Signer<'info>,
    pub system_program: Program<'info, System>,
}
 
#[derive(Accounts)]
pub struct AcceptRequest<'info> {
    #[account(
        mut,
        seeds = [b"pending-request", signer.key().as_ref(), pending_request.id.to_le_bytes().as_ref()],
        bump,
        close = signer,
    )]
    pub pending_request: Account<'info, PendingRequest>,
    #[account(
        mut,
        seeds = [b"cash-account", pending_request.sender.key().as_ref()],
        bump,
    )]
    pub from_cash_account: Account<'info, CashAccount>,
    #[account(
        mut,
        seeds = [b"cash-account", pending_request.recipient.key().as_ref()],
        bump,
    )]
    pub to_cash_account: Account<'info, CashAccount>,
    #[account(mut)]
    pub signer: Signer<'info>,
    pub system_program: Program<'info, System>,
}

Lastly, we need to update the initialization of each account. The pending_request_counter should start at 0 and increment with each new request sent from that specific cash account.

#[program]
pub mod cash_app {
    use super::*;
 
    //...
 
    pub fn initialize_account(ctx: Context<InitializeAccount>) -> Result<()> {
        let cash_account = &mut ctx.accounts.cash_account;
        cash_account.signer = *ctx.accounts.signer.key;
        cash_account.friends = Vec::new();
        cash_account.request_counter = 0;
        Ok(())
    }
 
    pub fn new_request(ctx: Context<InitializeAccount>, recipient: Pubkey, amount: u64) -> Result<()> {
        let cash_account = &mut ctx.accounts.cash_account;
        let pending_request = &mut ctx.accounts.pending_request;
        pending_request.sender = *ctx.accounts.signer.key;
        pending_request.recipient = recipient;
        pending_request.amount = amount;
        pending_request.id = cash_account.request_counter;
        cash_account.request_counter += 1;
        Ok(())
    }
}

Now your Solana program should match the final version here:

Build and Deploy the Program #

First, we need to deploy the Anchor program. For testing purposes, you can either deploy to your localnet or to devnet.

  • Devnet is a public test network provided by Solana that more closely resembles mainnet. It operates with a broader set of validators and easily enables testing CPIs, oracles, and wallet services.
  • Localnet is a private instance of the Solana blockchain running locally on your machine. It enables more control of the environment but doesn't completely mimic real-world conditions of the blockchain.

In the next section of this guide, you'll need the program deployed to localnet to run the Anchor test suite, so deploy to localnet now.

Navigate to cash-app-clone/cash-app in your terminal.

solana-test-validator

This runs a local test validator to simulate the Solana blockchain environment on your own machine. Note: You cannot deploy to localnet unless your test validator is running.

anchor build

This builds your program's workspace. It targets Solana's BPF runtime and emits each program's IDL in the target/idl folder and the corresponding typescript types in the target/types folder. If your program is doesn't build, then there is an error in your code that needs to be addressed.

anchor deploy --provider.cluster localnet

This command deploys your program to the specified cluster and generates a program ID public key. If you choose to deploy to localnet, you must be running solana-test-validator to be able to deploy.

anchor keys sync

This syncs the program's declare_id! pubkey with the program's actual pubkey. It specifically updates the lib.rs and Anchor.toml files.

Testing an Anchor Program #

Testing Solana Anchor programs involves simulating the behavior of the Solana program and ensuring it operates as expected. For the below test, we'll cover the following:

  • Creates Accounts for User A and User B
  • Deposits funds into User A's account
  • Withdraws funds from User A's account
  • Transfers funds from User A's account to User B's account
  • User A adds User B as a friend
  • User A requests funds from User B
  • User B accepts the request
  • User A requests funds again from User B
  • User B declines the request

When initializing an Anchor workspace, a file for TypeScript tests is generated. Navigate to cash-app-clone/cash-app/tests/cash-app.ts to find the testing template, which will already have the required modules imported.

First we need to set up our environment to interact with the Solana blockchain.

describe("cash-app", () => {
  const provider = anchor.AnchorProvider.env();
  const program = anchor.workspace.CashApp as Program<CashApp>;
});

provider enables you to facilitate interactions between your application (client-side) and the Solana blockchain, which includes a wallet that holds the keypair used to sign transactions.

program now represents your Anchor program and can be used to call functions defined in your on-chain program, pass in required accounts, and handle the program's data. It simplifies interacting with the Solana blockchain by abstracting many of the lower-level details.

Next, we need to define the wallet accounts that will be interacting with the Solana program as well as their cash_account PDAs. myWallet is the provider's wallet, meaning that it is already integrated with the AnchorProvider and is configured when the provider is initialized. Since yourWallet is a new wallet being generated, it will need to be funded with SOL by requesting an airdrop.

it("A to B user flow", async () => {
  const myWallet = provider.wallet as anchor.Wallet;
  const yourWallet = new anchor.web3.Keypair();
 
  const [myAccount] = await anchor.web3.PublicKey.findProgramAddress(
    [Buffer.from("cash-account"), myWallet.publicKey.toBuffer()],
    program.programId,
  );
 
  const [yourAccount] = await anchor.web3.PublicKey.findProgramAddress(
    [Buffer.from("cash-account"), yourWallet.publicKey.toBuffer()],
    program.programId,
  );
 
  console.log("requesting airdrop");
  const airdropTx = await provider.connection.requestAirdrop(
    yourWallet.publicKey,
    5 * anchor.web3.LAMPORTS_PER_SOL,
  );
  await provider.connection.confirmTransaction(airdropTx);
 
  let yourBalance = await program.provider.connection.getBalance(
    yourWallet.publicKey,
  );
  console.log("Your wallet balance:", yourBalance);
});

Now we can interact with the Solana program. First we need to initialize each user's cash_account.

const initMe = await program.methods
  .initializeAccount()
  .accounts({
    cashAccount: myAccount,
    signer: myWallet.publicKey,
    systemProgram: anchor.web3.SystemProgram.programId,
  })
  .rpc();
console.log(`Use 'solana confirm -v ${initMe}' to see the logs`);
 
await anchor.getProvider().connection.confirmTransaction(initMe);
 
const initYou = await program.methods
  .initializeAccount()
  .accounts({
    cashAccount: yourAccount,
    signer: yourWallet.publicKey,
    systemProgram: anchor.web3.SystemProgram.programId,
  })
  .signers([yourWallet])
  .rpc();
console.log(`Initialized your account : ${initYou}' `);
 
await anchor.getProvider().connection.confirmTransaction(initYou);

By calling the program namespace program.methods, you're able to interact with the instructions handlers of that program. When a transaction is sent using the provider (or methods derived from it, such as program.rpc()), the signing by myWallet is implicitly handled. The provider automatically includes the wallet configured with it (myWallet in this case) as a signer for any transactions it sends. This means you do not need to manually specify myWallet in the .signers() array when constructing a transaction, because it's inherently assumed to be a signer through the provider's configuration. However, yourWallet is a new keypair which is not automatically associated with the provider, so must explicitly tell Anchor to use yourWallet for signing any transaction where it's required.

Since any other instruction call is handled exactly as described above, you can complete this test example independently.

Lastly, run your test suite against your localnet.

anchor test

Connecting a Solana Program to a React-Native Expo App #

Now that we have a working Solana program, we need to integrate this with the UI of the dApp.

Android Emulator #

Lets get the Android emulator running so we can see in real time the UI updates that we will make throughout this guide.

You must have an EAS account and be logged into your account in the EAS CLI, to set this up follow the expo documentation.

Navigate to the cash-app-clone directory in your terminal and run:

eas build --profile development --platform android

Then in a new terminal window run:

npx expo start --dev-client

Install the build on your Android emulator and keep it running in a separate window. Every time you save a file, the emulator will refresh.

Initial Program Connection #

We can create a custom hook that accepts the public key of the user as a parameter that is designed to interact with our deployed Solana program. By providing the program ID, the rpc endpoint that the program was deployed to, the IDL of the program, and the PDA of a specified user, we can create the logic required to manage interactions with the Solana program. Create a new file under utils/useCashAppProgram.tsx, to implement this function.

Since we want this app to be publicly available, deploy your program to devnet and use that public key instead of 11111111111111111111111111111111.

export function UseCashAppProgramAccount(user: PublicKey) {
  const cashAppProgramId = new PublicKey("11111111111111111111111111111111");
 
  const [connection] = useState(
    () => new Connection("https://api.devnet.solana.com"),
  );
 
  const [cashAppPDA] = useMemo(() => {
    const accountSeed = [Buffer.from("cash_account"), user.toBuffer()];
    return PublicKey.findProgramAddressSync(accountSeed, cashAppProgramId);
  }, [cashAppProgramId]);
 
  const cashAppProgram = useMemo(() => {
    return new Program<CashAppProgram>(
      idl as CashAppProgram,
      cashAppProgramId,
      { connection },
    );
  }, [cashAppProgramId]);
 
  const value = useMemo(
    () => ({
      cashAppProgram: cashAppProgram,
      cashAppProgramId: cashAppProgramId,
      cashAppPDA: cashAppPDA,
    }),
    [cashAppProgram, cashAppProgramId, cashAppPDA],
  );
 
  return value;
}

Since there is only one cash_account account per public key, it is easy to calculate the cashAccountPDA by taking in the user's public key as a parameter and using that to calculate what the public key of the Cash App PDA for each individual user is.

Since the IDL is generated as a JSON file when building the program, we can just import it to this file.

This function returns:

  • cashAppPDA - The connect user's Program Derived Address (PDA) for their cash account
  • cashAppProgramID - The public key of the deployed Solana program on devnet
  • cashAppProgram - The Cash App program which provides the IDL deserialized client representation of an Anchor program.

The Program class provides the IDL deserialized client representation of an Anchor program. This API is a one stop shop for all things related to communicating with on-chain programs. It enables sending transactions, deserializing accounts, decoding instruction data, listening to events, etc.

The cashAppProgram object, created from the Program class, provides a set of dynamically generated properties, known as namespaces. Namespaces map one-to-one to program methods and accounts, which we will be using a lot later in this project. The namespace is generally used as follows: program.<namespace>.<program-specific-method>

To get information for specific pending_request accounts associated with a specific public key, we'll need to take in the pending request ID as a parameter.

export function UsePendingRequestAccount(
  user: PublicKey,
  count: number,
  cashAppProgramId: PublicKey,
) {
  const [connection] = useState(
    () => new Connection("https://api.devnet.solana.com"),
  );
 
  bigNumber = new BN(count);
  const [pendingRequestPDA] = useMemo(() => {
    const pendingRequestSeed = [
      Buffer.from("cash_account"),
      user.toBuffer(),
      bigNumber.toBuffer(),
    ];
    return PublicKey.findProgramAddressSync(
      pendingRequestSeed,
      cashAppProgramId,
    );
  }, [cashAppProgramId]);
 
  const value = useMemo(
    () => ({
      pendingRequestPDA: pendingRequestPDA,
    }),
    [pendingRequestPDA],
  );
 
  return value;
}

Styling and Themes #

React Native uses a styling system that is based on the standard CSS properties, but it's specifically tailored for mobile development. Styles are defined using JavaScript objects, which enables dynamic generation of styles by leveraging JavaScript's capabilities. To achieve a design that mimics the look and feel of Cash App, we'll create a StyleSheet Object that will be use throughout this dApp. This style sheet will feature a monochrome grayscale color palette, bold text, and rounded shapes.

import { StyleSheet, Dimensions } from "react-native";
 
const { width } = Dimensions.get("window");
 
const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: "#141414",
    alignItems: "center",
    justifyContent: "center",
  },
  header: {
    backgroundColor: "#1b1b1b",
    width: "100%",
    padding: 20,
    alignItems: "center",
  },
  headerText: {
    color: "#fff",
    fontSize: 24,
    fontWeight: "bold",
  },
  button: {
    width: 80,
    height: 80,
    justifyContent: "center",
    alignItems: "center",
    backgroundColor: "#333",
    borderRadius: 40,
  },
  buttonGroup: {
    flexDirection: "column",
    paddingVertical: 4,
  },
  buttonText: {
    color: "#fff",
    fontSize: 18,
    fontWeight: "600",
    textAlign: "center",
  },
  cardContainer: {
    width: width - 40,
    backgroundColor: "#222",
    borderRadius: 20,
    padding: 20,
    marginVertical: 10,
    shadowColor: "#000",
    shadowOffset: { width: 0, height: 1 },
    shadowOpacity: 0.22,
    shadowRadius: 2.22,
    elevation: 3,
  },
  modalView: {
    backgroundColor: "#444",
    padding: 35,
    alignItems: "center",
    borderTopLeftRadius: 50,
    borderTopRightRadius: 50,
    shadowColor: "#000",
    shadowOffset: {
      width: 0,
      height: -2,
    },
    shadowOpacity: 0.8,
    shadowRadius: 4,
    elevation: 5,
    width: "100%",
    height: "40%",
  },
  cardTitle: {
    fontSize: 20,
    fontWeight: "bold",
    marginBottom: 5,
  },
  cardContent: {
    fontSize: 16,
    color: "#666",
  },
});
 
export default styles;

Along with setting the StyleSheet, we also need to update the theme. A theme creates a more uniform look and feel throughout the entire application. Navigate to App.tsx, and update the code to only use DarkTheme.

To follow the UI/UX of Cash App, we'll need the following screens: Home, Pay, Scan, and Activity.

Navigate to HomeNavigator.tsx and update the <Tab.Navigator> to include the following screens:

<PaperProvider theme={theme}>
  <Tab.Navigator
    screenOptions={({ route }) => ({
      header: () => <TopBar />,
      tabBarIcon: ({ focused, color, size }) => {
        switch (route.name) {
          case "Home":
            return (
              <MaterialCommunityIcon
                name={focused ? "home" : "home-outline"}
                size={size}
                color={color}
              />
            );
          case "Pay":
            return (
              <MaterialCommunityIcon
                name={focused ? "currency-usd" : "currency-usd"}
                size={size}
                color={color}
              />
            );
          case "Scan":
            return (
              <MaterialCommunityIcon
                name={focused ? "qrcode-scan" : "qrcode-scan"}
                size={size}
                color={color}
              />
            );
          case "Activity":
            return (
              <MaterialCommunityIcon
                name={focused ? "clock-outline" : "clock-outline"}
                size={size}
                color={color}
              />
            );
        }
      },
    })}
  >
    <Tab.Screen name="Home" component={HomeScreen} />
    <Tab.Screen name="Pay" component={PayScreen} />
    <Tab.Screen name="Scan" component={ScanScreen} />
    <Tab.Screen name="Activity" component={ActivityScreen} />
  </Tab.Navigator>
</PaperProvider>

In addition to this, you'll need to create new files for each of these screens. Navigate to src/screens and create a file for PayScreen.tsx, ScanScreen.tsx, and ActivityScreen.tsx.

Each file needs to have a function correlating to the screen name that follows the same format as the HomeScreen in this template.

export function HomeScreen() {
  return <View style={styles.screenContainer}></View>;
}

Creating Components #

Throughout this guide, we'll be using a modular approach to building features, so we can focus on one component at a time.

Account Balance Component #

Let's start with the home screen. To mimic Cash App, all we need is a container that displays your account balance, a button to deposit funds into your account, and a button to withdraw funds from your account.

In the expo template we are using, there is already similar functionality. However, this code is for your connected wallet balance rather than the cash account's balance. So we need to connect this feature to our deployed Solana program and query the balance of the user's cash_account instead.

First simplify the home screen to just:

export function HomeScreen() {
  const { selectedAccount } = useAuthorization();
 
  return (
    <View style={styles.screenContainer}>
      {selectedAccount ? (
        <>
          <AccountDetailFeature />
        </>
      ) : (
        <>
          <Text style={styles.headerTextLarge}>Solana Cash App</Text>
          <Text style={styles.text}>
            {" "}
            Sign in with Solana (SIWS) to link your wallet.
          </Text>
          <SignInFeature />
        </>
      )}
    </View>
  );
}

Then click into AccountDetailFeature and update the styling to use cardContainer, add in a "Cash Balance" label for the card container, and delete the AccountTokens component, as shown below:

export function AccountDetailFeature() {
  const { selectedAccount } = useAuthorization();
 
  if (!selectedAccount) {
    return null;
  }
  const theme = useTheme();
 
  return (
    <>
      <View style={styles.cardContainer}>
        <Text variant="titleMedium" style={styles.headerText}>
          Cash Balance
        </Text>
        <View style={{ alignItems: "center" }}>
          <AccountBalance address={selectedAccount.publicKey} />
          <AccountButtonGroup address={selectedAccount.publicKey} />
        </View>
      </View>
    </>
  );
}

NOTE: The StyleSheet that we created earlier should be imported to every page.

Now click into the AccountBalance function. To update this query, we simply need to change the public key that is being passed through the useGetBalance function. We can grab the cashAppPDA from the UseCashAppProgram function we created earlier.

export function AccountBalance({ address }: { address: PublicKey }) {
  const { cashAppPDA } = UseCashAppProgram(address);
 
  const query = useGetBalance(cashAppPDA);
  const theme = {
    ...MD3DarkTheme,
    ...DarkTheme,
    colors: {
      ...MD3DarkTheme.colors,
      ...DarkTheme.colors,
    },
  };
 
  return (
    <>
      <View style={styles.accountBalance}>
        <Text variant="displayMedium" theme={theme}>
          ${query.data ? lamportsToSol(query.data) : "0.00"}
        </Text>
      </View>
    </>
  );
}

Deposit and Withdraw Components #

Next, we need to update the buttons to deposit and withdraw funds. Go to the AccountButtonGroup function.

To be able to call and execute an instruction from the deployed Solana program, we can use the program namespaces which map one-to-one to program methods and accounts.

const [connection] = useState(
  () => new Connection("https://api.devnet.solana.com"),
);
 
const depositFunds = useCallback(
  async (program: Program<CashApp>) => {
    let signedTransactions = await transact(
      async (wallet: Web3MobileWallet) => {
        const [authorizationResult, latestBlockhash] = await Promise.all([
          authorizeSession(wallet),
          connection.getLatestBlockhash(),
        ]);
 
        const depositInstruction = await program.methods
          .depositFunds(pubkey, newDepositAmount)
          .accounts({
            user: authorizationResult.publicKey,
            fromCashAccount: cashAppPDA,
          })
          .instruction();
 
        const depositTransaction = new Transaction({
          ...latestBlockhash,
          feePayer: authorizationResult.publicKey,
        }).add(depositInstruction);
 
        const signedTransactions = await wallet.signTransactions({
          transactions: [depositTransaction],
        });
 
        return signedTransactions[0];
      },
    );
 
    let txSignature = await connection.sendRawTransaction(
      signedTransactions.serialize(),
      {
        skipPreflight: true,
      },
    );
 
    const confirmationResult = await connection.confirmTransaction(
      txSignature,
      "confirmed",
    );
 
    if (confirmationResult.value.err) {
      throw new Error(JSON.stringify(confirmationResult.value.err));
    } else {
      console.log("Transaction successfully submitted!");
    }
  },
  [authorizeSession, connection, cashAppPDA],
);

This function uses React's useCallback hook to create a memoized callback function that handles the process of depositing funds within the connected Solana program. It accepts a Program parameter which is an Anchor program interface for the CashApp dApp.

Since the namespace is generally used as follows: program.<namespace>.<program-specific-method>, in the above code, we are creating an instruction to depositFunds with the specified accounts.

Then this instruction can be added to a Transaction and signed with the connected wallet.

Lastly, the signed transaction is then sent by using the sendRawTransaction method from connection object.

The connection object is an instance of the Connection class from the solanaweb3.js library, which is a connection to a fullnode JSON RPC endpoint.

Now that we have the function for depositFunds, you'll need to do follow the same formate to create a withdrawFunds function using the program namespace for the withdrawFunds instruction.

const withdrawInstruction = await program.methods
  .withdrawFunds(pubkey, newDepositAmount)
  .accounts({
    user: authorizationResult.publicKey,
    fromCashAccount: cashAppPDA,
  })
  .instruction();

Additional documentation:

Npm packages to be installed and imported:

  • @solana-mobile/mobile-wallet-adapter-protocol-web3js
  • @coral-xyz/anchor
  • @solana/web3.js

Now we can connect these functions to buttons on the UI.

We'll follow a very similar structure to the current AccountButtonGroup function, but we need different functionality. So delete everything within the function.

Since Cash App also uses modals when clicking on the "Add Cash" and "Cash Out" buttons, we'll have a withdraw and deposit modal. We'll also need to take in a user input value for the amount to be deposited or withdrawn. Lastly, we'll need to call the depositFunds and withdrawFunds functions we just created.

export function AccountButtonGroup({ address }: { address: PublicKey }) {
  const [showWithdrawModal, setShowWithdrawModal] = useState(false);
  const [showDepositModal, setShowDepositModal] = useState(false);
  const [genInProgress, setGenInProgress] = useState(false);
  const [depositAmount, setDepositAmount] = useState(new anchor.BN(0));
  const newDepositAmount = new anchor.BN(depositAmount * 1000000000);
  const [withdrawAmount, setWithdrawAmount] = useState(new anchor.BN(0));
  const newWithdrawAmount = new anchor.BN(withdrawAmount * 1000000000);
  const { authorizeSession, selectedAccount } = useAuthorization();
  const { cashAppProgram } = UseCashAppProgram(address);
 
  const [connection] = useState(
    () => new Connection("https://api.devnet.solana.com"),
  );
 
  const DepositModal = () => (
    <Modal
      animationType="slide"
      transparent={true}
      visible={showDepositModal}
      onRequestClose={() => {
        setShowDepositModal(!showDepositModal);
      }}
    >
      <View style={styles.bottomView}>
        <View style={styles.modalView}>
          <Text style={styles.buttonText}>Add Cash</Text>
          <TextInput
            label="Amount"
            value={depositAmount}
            onChangeText={setDepositAmount}
            keyboardType="numeric"
            mode="outlined"
            style={{
              marginBottom: 10,
              backgroundColor: "#ccc",
              width: "80%",
              marginTop: 10,
            }}
          />
          <Button
            mode="contained"
            style={styles.modalButton}
            onPress={async () => {
              setDepositModalVisible(!showDepositModal);
              if (genInProgress) {
                return;
              }
              setGenInProgress(true);
              try {
                if (!cashAppProgram || !selectedAccount) {
                  console.warn(
                    "Program/wallet is not initialized yet. Try connecting a wallet first.",
                  );
                  return;
                }
                const deposit = await depositFunds(cashAppProgram);
 
                alertAndLog(
                  "Funds deposited into cash account ",
                  "See console for logged transaction.",
                );
                console.log(deposit);
              } finally {
                setGenInProgress(false);
              }
            }}
          >
            Add
          </Button>
          <TouchableOpacity
            style={{ position: "absolute", bottom: 25 }}
            onPress={() => setDepositModalVisible(false)}
          >
            <Button>Close</Button>
          </TouchableOpacity>
        </View>
      </View>
    </Modal>
  );
 
  const WithdrawModal = () => (
    <Modal
      animationType="slide"
      transparent={true}
      visible={showWithdrawModal}
      onRequestClose={() => {
        setShowWithdrawModal(!showWithdrawModal);
      }}
    >
      <View style={styles.bottomView}>
        <View style={styles.modalView}>
          <Text style={styles.buttonText}>Cash Out</Text>
          <TextInput
            label="Amount"
            value={withdrawAmount}
            onChangeText={setWithdrawAmount}
            keyboardType="numeric"
            mode="outlined"
            style={{
              marginBottom: 20,
              backgroundColor: "#ccc",
              width: "80%",
              marginTop: 50,
            }}
          />
          <Button
            mode="contained"
            style={styles.modalButton}
            onPress={async () => {
              setShowWithdrawModal(!withdrawModalVisible);
              if (genInProgress) {
                return;
              }
              setGenInProgress(true);
              try {
                if (!cashAppProgram || !selectedAccount) {
                  console.warn(
                    "Program/wallet is not initialized yet. Try connecting a wallet first.",
                  );
                  return;
                }
                const deposit = await withdrawFunds(cashAppProgram);
 
                alertAndLog(
                  "Funds withdrawn from cash account ",
                  "See console for logged transaction.",
                );
                console.log(deposit);
              } finally {
                setGenInProgress(false);
              }
            }}
          >
            Withdraw
          </Button>
          <TouchableOpacity
            style={{ position: "absolute", bottom: 25 }}
            onPress={() => setShowWithdrawModal(false)}
          >
            <Button>Close</Button>
          </TouchableOpacity>
        </View>
      </View>
    </Modal>
  );
  return (
    <>
      <View style={styles.buttonRow}>
        <DepositModal />
        <WithdrawModal />
      </View>
    </>
  );
}

That wraps up all the functionality we need on the home screen for a Cash App clone. Now we can move onto the pay screen, which involves transferring funds from one user to another.

Pay Component #

For the pay page, we'll need to call the transferFunds function from the cash app Solana program. To do this, we'll be using the same process that was described for depositFunds. However, the TransferFunds struct described in the CashApp Solana Program requires 2 cash_account accounts rather than the one account that is required for depositFunds. So what needs to change is simply to add calculations of the PDAs of both the sender account and the recipient's account, as shown below:

const [recipientPDA] = useMemo(() => {
  const recipientSeed = [Buffer.from("cash-account"), recipient.toBuffer()];
  return PublicKey.findProgramAddressSync([recipientSeed], cashAppProgramId);
}, [cashAppProgramId]);
 
const transferInstruction = await program.methods
  .transferFunds(pubkey, newTransferAmount)
  .accounts({
    user: authorizationResult.publicKey,
    fromCashAccount: cashAppPDA,
    toCashAccount: recipientPDA,
  })
  .instruction();

In order to calculate the recipient's PDA, the public key of the recipient must be passed through as a parameter of the transferFunds function, along with the amount to transfer and the public key of the signer.

Request Component #

For the request page, we'll need to call the newRequest function from the cash app Solana program. This function also requires multiple accounts. Here you'll need the pending_request account and the cash_account of the signer.

const [pendingRequestPDA] = useMemo(() => {
  const pendingRequestSeed = [
    Buffer.from("pending-request"),
    requester.toBuffer(),
  ];
  return PublicKey.findProgramAddressSync(
    [pendingRequestSeed],
    cashAppProgramId,
  );
}, [cashAppProgramId]);
 
const requestInstruction = await program.methods
  .newPendingRequest(pubkey, requestAmount)
  .accounts({
    user: authorizationResult.publicKey,
    pendingRequest: pendingRequestPDA,
    cashAccount: cashAppPDA,
  })
  .instruction();

Accept and Decline Request Components #

A user will interact with their pending payment requests on the activity page.

const acceptInstruction = await program.methods
  .acceptRequest()
  .accounts({
    user: authorizationResult.publicKey,
    pendingRequest: pendingRequestPDA,
    toCashAccount: recipientPDA,
    fromCashAccount: cashAppPDA,
  })
  .instruction();
 
const declineInstruction = await program.methods
  .declineRequest()
  .accounts({
    user: authorizationResult.publicKey,
    pendingRequest: pendingRequestPDA,
  })
  .instruction();

Creating Screens #

Payment Screen #

In Cash App, the payment screen is simply a key pad with request and pay buttons that take the user input value and redirects you to another screen.

So the pay screen is mainly some UI work. We need to be able to type in a numerical value via a keyboard, handle the input value, select currency via a small modal, and navigate to the request and pay pages via buttons. Here is the code below:

type HomeScreenNavigationProp = NativeStackNavigationProp<
  RootStackParamList,
  "Home"
>;
 
type Props = {
  navigation: HomeScreenNavigationProp;
};
 
const App: React.FC<Props> = ({ navigation }) => {
  const [inputValue, setInputValue] = useState("");
  const [modalVisible, setModalVisible] = useState(false);
 
  const handleInput = (value: string) => {
    setInputValue(inputValue + value);
  };
 
  const handleBackspace = () => {
    setInputValue(inputValue.slice(0, -1));
  };
 
  type NumberButtonProps = {
    number: string;
  };
 
  const NumberButton: React.FC<NumberButtonProps> = ({ number }) => (
    <TouchableOpacity style={styles.button} onPress={() => handleInput(number)}>
      <Text style={styles.buttonText}>{number}</Text>
    </TouchableOpacity>
  );
 
  const CurrencySelectorModal = () => (
    <Modal
      animationType="slide"
      transparent={true}
      visible={modalVisible}
      onRequestClose={() => {
        setModalVisible(!modalVisible);
      }}
    >
      <View style={styles.bottomView}>
        <View style={styles.modalView}>
          <Text style={styles.buttonText}>Select Currency</Text>
          <View style={styles.centeredView}>
            <TouchableOpacity
              style={styles.fullWidthButton}
              onPress={() => setModalVisible(false)}
            >
              <Text style={styles.currencyText}>
                {" "}
                <MaterialCommunityIcon
                  name="currency-usd"
                  size={30}
                  color="white"
                />
                US Dollars
              </Text>
            </TouchableOpacity>
            <TouchableOpacity
              style={styles.fullWidthButton}
              onPress={() => setModalVisible(false)}
            >
              <Text style={styles.currencyText}>
                {" "}
                <MaterialCommunityIcon name="bitcoin" size={30} color="white" />
                Bitcoin
              </Text>
            </TouchableOpacity>
          </View>
          <TouchableOpacity
            style={{ position: "absolute", bottom: 25 }}
            onPress={() => setModalVisible(false)}
          >
            <Text style={styles.mediumButtonText}>Close</Text>
          </TouchableOpacity>
        </View>
      </View>
    </Modal>
  );
 
  return (
    <View style={styles.container}>
      <CurrencySelectorModal />
      <View style={styles.displayContainer}>
        <Text style={styles.displayText}>${inputValue || "0"}</Text>
        <TouchableOpacity
          style={{ position: "relative", marginTop: 15 }}
          onPress={() => setModalVisible(true)}
        >
          <Text style={styles.smallButtonText}>
            USD{" "}
            <MaterialCommunityIcon
              name="chevron-down"
              size={15}
              color="white"
            />
          </Text>
        </TouchableOpacity>
      </View>
      <View style={styles.keypad}>
        <View style={styles.row}>
          {[1, 2, 3].map(number => (
            <NumberButton key={number} number={number.toString()} />
          ))}
        </View>
        <View style={styles.row}>
          {[4, 5, 6].map(number => (
            <NumberButton key={number} number={number.toString()} />
          ))}
        </View>
        <View style={styles.row}>
          {[7, 8, 9].map(number => (
            <NumberButton key={number} number={number.toString()} />
          ))}
        </View>
        <View style={styles.row}>
          <NumberButton number="." />
          <NumberButton number="0" />
          <TouchableOpacity style={styles.button} onPress={handleBackspace}>
            <Text style={styles.buttonText}></Text>
          </TouchableOpacity>
        </View>
      </View>
      <View style={styles.buttonRow}>
        <Button
          mode="contained"
          style={styles.sideButton}
          onPress={() => navigation.navigate("Receive", { inputValue })}
        >
          Request
        </Button>
        <Button
          mode="contained"
          style={styles.sideButton}
          onPress={() => navigation.navigate("Send", { inputValue })}
        >
          Pay
        </Button>
      </View>
    </View>
  );
};

In the above code, the Request and Pay buttons redirect you to new pages to complete your transaction, similar to the Cash App UX.

Request and Pay Screens #

The Request and Pay Screens need to take in your input value from the previous Payment screen and use it to execute the transferFunds and newPaymentRequest instructions.

const PayScreen: React.FC<Props> = ({ route, navigation }) => {
  const [reason, setReason] = useState("");
  const { inputValue } = route.params;
  const [genInProgress, setGenInProgress] = useState(false);
  const [userName, setUserName] = useState("");
  const newAmount = new anchor.BN(inputValue);
 
  const [connection] = useState(
    () => new Connection("https://api.devnet.solana.com"),
  );
  const { authorizeSession, selectedAccount } = useAuthorization();
  const user = selectedAccount.publicKey;
  const { cashAppProgram, cashAppPDA } = UseCashAppProgram(user);
 
  const transferFunds = useCallback(
    async (program: Program<CashApp>) => {
      let signedTransactions = await transact(
        async (wallet: Web3MobileWallet) => {
          const [authorizationResult, latestBlockhash] = await Promise.all([
            authorizeSession(wallet),
            connection.getLatestBlockhash(),
          ]);
 
          const { pubkey } = getDomainKeySync(userName);
          console.log(pubkey);
          console.log(newAmount);
 
          const transferInstruction = await program.methods
            .transferFunds(pubkey, newAmount)
            .accounts({
              user: authorizationResult.publicKey,
              fromCashAccount: cashAppPDA,
            })
            .instruction();
 
          const transferTransaction = new Transaction({
            ...latestBlockhash,
            feePayer: authorizationResult.publicKey,
          }).add(transferInstruction);
 
          const signedTransactions = await wallet.signTransactions({
            transactions: [transferTransaction],
          });
 
          return signedTransactions[0];
        },
      );
 
      let txSignature = await connection.sendRawTransaction(
        signedTransactions.serialize(),
        {
          skipPreflight: true,
        },
      );
 
      const confirmationResult = await connection.confirmTransaction(
        txSignature,
        "confirmed",
      );
 
      if (confirmationResult.value.err) {
        throw new Error(JSON.stringify(confirmationResult.value.err));
      } else {
        console.log("Transaction successfully submitted!");
      }
    },
    [authorizeSession, connection, cashAppPDA],
  );
 
  return (
    <View style={styles.container}>
      <View style={styles.topBar}>
        <TouchableOpacity onPress={() => navigation.goBack()}>
          <Ionicons name="close" size={30} color="white" />
        </TouchableOpacity>
        <Text style={styles.title}>${inputValue}</Text>
        <TouchableOpacity
          style={styles.button}
          onPress={async () => {
            if (genInProgress) {
              return;
            }
            setGenInProgress(true);
            try {
              if (!cashAppProgram || !selectedAccount) {
                console.warn(
                  "Program/wallet is not initialized yet. Try connecting a wallet first.",
                );
                return;
              }
              const deposit = await transferFunds(cashAppProgram);
 
              alertAndLog(
                "Funds deposited into cash account ",
                "See console for logged transaction.",
              );
              console.log(deposit);
            } finally {
              setGenInProgress(false);
            }
          }}
        >
          <Text style={styles.buttonText}>Pay</Text>
        </TouchableOpacity>
      </View>
      <View style={styles.row}>
        <Text style={styles.label}>To:</Text>
        <TextInput
          style={styles.input}
          onChangeText={setUserName}
          value={userName}
          placeholder="User"
          placeholderTextColor="#999"
        />
      </View>
      <View style={styles.row}>
        <Text style={styles.label}>For:</Text>
        <TextInput
          style={styles.input}
          onChangeText={setReason}
          value={reason}
          placeholder="Memo"
          placeholderTextColor="#999"
        />
      </View>
      <View style={styles.rowRight}>
        <Text style={styles.regular}>Enable purchase protection:</Text>
        <Switch
          value={purchaseProtection}
          onValueChange={setPurchaseProtection}
          trackColor={{ false: "#767577", true: "#7F5AF0" }}
          thumbColor={purchaseProtection ? "#7F5AF0" : "#f4f3f4"}
        />
      </View>
    </View>
  );
};
 
export default PayScreen;

For the RequestScreen, you'll follow the same process except you will use the newPaymentRequest instruction instead of the transferFunds instruction.

Try this out, then check your work here:

Activity Screen #

The Activity Screen will allow you to add friends, see pending payment requests, accept requests, and decline requests.

For the Add Friend Feature, you'll want a text box for a user to input the pubkey of the friend they want to add and a button that calls the add friend instruction.

export function AddFriend({ address }: { address: PublicKey }) {
  const [pubkey, setPubkey] = useState("");
  const [signingInProgress, setSigningInProgress] = useState(false);
  const [connection] = useState(
    () => new Connection("https://api.devnet.solana.com"),
  );
  const { authorizeSession, selectedAccount } = useAuthorization();
  const { cashAppProgram, cashAppPDA, friends } = UseCashAppProgram(address);
  const user = friends.data?.balance;
 
  const addFriend = useCallback(
    async (program: Program<CashApp>) => {
      let signedTransactions = await transact(
        async (wallet: Web3MobileWallet) => {
          const [authorizationResult, latestBlockhash] = await Promise.all([
            authorizeSession(wallet),
            connection.getLatestBlockhash(),
          ]);
 
          const addFriendIX = await program.methods
            .addFriend(pubkey)
            .accounts({
              user: authorizationResult.publicKey,
              cashAccount: cashAppPDA,
            })
            .instruction();
 
          const addFriendTX = new Transaction({
            ...latestBlockhash,
            feePayer: authorizationResult.publicKey,
          }).add(addFriendIX);
 
          const signedTransactions = await wallet.signTransactions({
            transactions: [addFriendTX],
          });
 
          return signedTransactions[0];
        },
      );
 
      let txSignature = await connection.sendRawTransaction(
        signedTransactions.serialize(),
        {
          skipPreflight: true,
        },
      );
 
      const confirmationResult = await connection.confirmTransaction(
        txSignature,
        "confirmed",
      );
 
      if (confirmationResult.value.err) {
        throw new Error(JSON.stringify(confirmationResult.value.err));
      } else {
        console.log("Transaction successfully submitted!");
      }
    },
    [authorizeSession, connection, cashAppPDA],
  );
 
  return (
    <View
      style={{
        padding: 5,
        justifyContent: "center",
        alignItems: "flex-start",
      }}
    >
      <Text
        variant="titleMedium"
        style={{
          color: "white",
          marginBottom: 10,
        }}
      >
        {" "}
        Add New Friend:
      </Text>
      <View
        style={{
          flexDirection: "row",
          justifyContent: "space-between",
          alignItems: "center",
        }}
      >
        <TextInput
          value={pubkey}
          onChangeText={setPubkey}
          style={{
            marginBottom: 10,
            marginTop: 10,
            backgroundColor: "#f0f0f0",
            height: 40,
            padding: 10,
            fontSize: 18,
            width: "60%",
            marginLeft: 20,
            marginRight: 20,
          }}
        />
        <Button
          mode="contained"
          disabled={signingInProgress}
          onPress={async () => {
            if (signingInProgress) {
              return;
            }
            setSigningInProgress(true);
            try {
              const signedTransaction = await addFriend(cashAppProgram);
              alertAndLog(
                "Transaction signed",
                "View recent transactions for more information.",
              );
              console.log(signedTransaction);
            } catch (err: any) {
              alertAndLog(
                "Error during signing",
                err instanceof Error ? err.message : err,
              );
            } finally {
              setSigningInProgress(false);
            }
          }}
        >
          Add
        </Button>
      </View>
    </View>
  );
}

To accept and decline requests, you'll follow a very similar method. Try this out yourself and then check the code here to review your work here:

Enabling QR Code functionality with Solana Pay #

To mimic the QR code functionality in Cash App, you can simply use the @solana/pay JavaScript SDK. For more information, refer to the Solana Pay API Reference.

The encodeURL function takes in an amount and a memo to encode a Solana Pay URL for a specific transaction.

Typically, this function is paired with createQR to generate a QR code with the Solana Pay URL. As of today, Solana Pay's current version of the createQR function is not compatible with react-native, so we will need to use a different QR code generator that is react-native compatible. In this guide, we'll input the url into QRCode from react-native-qrcode-svg. It does not have the same QR code styling as the Solana Pay createQR, but it still correctly generates the needed QR code.

For simplicity, this functionality will live on its own screen, which we already defined earlier as the Scan Screen. Similarly to the home screen, navigate to ScanScreen.tsx and set up the following function:

export function ScanScreen() {
  const { selectedAccount } = useAuthorization();
 
  return (
    <View style={styles.container}>
      {selectedAccount ? (
        <View style={styles.container}>
          <SolanaPayButton address={selectedAccount.publicKey} />
        </View>
      ) : (
        <>
          <Text style={styles.headerTextLarge}>Solana Cash App</Text>
          <Section description="Sign in with Solana (SIWS) to link your wallet." />
          <SignInFeature />
        </>
      )}
    </View>
  );
}

Now we need to create the SolanaPayButton component. Create a file under src/components/solana-pay/solana-pay-ui.tsx. In Cash App, the QR code is just a link to the users Cash App profile and is a static image in the app. However, the solana pay QR code is actually uniquely generated for each requested transaction, so the QR displayed includes the amount, memo, and the recipient's public key information. So our UI/UX will function slightly different than cash app in this section.

To still follow the look and feel of Cash App, we'll allow most of the screen to display the QR code and have a button at the bottom for a modal that has amount and memo input fields and a generate QR code button. On clicking the "Create QR" button, we'll want to generate a new Solana Pay URL and send that value outside of the modal to the Scan Screen so that the screen will render and display the new QR code.

We can do this with the Solana Pay API, state handling, conditional rendering, and data submission between the two components, as shown below:

export function SolanaPayButton({ address }: { address: PublicKey }) {
  const [showPayModal, setShowPayModal] = useState(false);
 
  const [url, setUrl] = useState("");
 
  return (
    <>
      <View>
        <View
          style={{
            height: 200,
            width: 200,
            justifyContent: "center",
            alignItems: "center",
            alignSelf: "center",
            marginBottom: 200,
            marginTop: 200,
          }}
        >
          {url ? (
            <>
              <View
                style={{
                  height: 350,
                  width: 350,
                  justifyContent: "center",
                  alignItems: "center",
                  alignSelf: "center",
                  backgroundColor: "#333",
                  borderRadius: 25,
                }}
              >
                <QRCode
                  value={url}
                  size={300}
                  color="black"
                  backgroundColor="white"
                />
              </View>
            </>
          ) : (
            <View
              style={{
                height: 350,
                width: 350,
                justifyContent: "center",
                alignItems: "center",
                borderWidth: 1,
                borderColor: "#ccc",
                backgroundColor: "#333",
                borderRadius: 25,
              }}
            >
              <Text style={styles.text2}> Generate a QR Code to display. </Text>
            </View>
          )}
          <Text style={styles.text}> Scan to Pay </Text>
        </View>
        <SolanaPayModal
          hide={() => setShowPayModal(false)}
          show={showPayModal}
          address={address}
          setParentUrl={setUrl}
        />
        <Button
          mode="contained"
          onPress={() => setShowPayModal(true)}
          style={styles.button}
        >
          Create New QR Code
        </Button>
      </View>
    </>
  );
}
 
export function SolPayModal({
  hide,
  show,
  address,
  setParentUrl,
}: {
  hide: () => void;
  show: boolean;
  address: PublicKey;
  setParentUrl: (url: string) => void;
}) {
  const [memo, setMemo] = useState("");
  const [amount, setAmount] = useState("");
 
  const handleSubmit = () => {
    const number = BigNumber(amount);
    const newUrl = encodeURL({
      recipient: address,
      amount: number,
      memo,
    }).toString();
    setParentUrl(newUrl);
    hide();
  };
 
  return (
    <AppModal
      title="Pay"
      hide={hide}
      show={show}
      submit={handleSubmit}
      submitLabel="Create QR"
      submitDisabled={!memo || !amount}
    >
      <View style={{ padding: 20 }}>
        <TextInput
          label="Amount"
          value={amount}
          onChangeText={setAmount}
          keyboardType="numeric"
          mode="outlined"
          style={{ marginBottom: 20, backgroundColor: "#f0f0f0" }}
        />
        <TextInput
          label="Memo"
          value={memo}
          onChangeText={setMemo}
          mode="outlined"
          style={{ marginBottom: 5, backgroundColor: "#f0f0f0" }}
        />
      </View>
    </AppModal>
  );
}

Connecting User Names with Public Keys via Solana Name Service #

Solana Name Service (SNS) enables a human-readable name to be mapped to a Solana address. By implementing SNS, we can easily prompt a user to create a user name (which will become their SNS name behind the scenes) and that name will directly map to the users wallet address.

Solana Name Service has two functions that we can implement throughout this dapp to simplify a lot of the front end:

  • getDomainKeySync - a function that returns the public key associated with the provided domain name. This can be implemented anywhere there is a user input for a public key. Now the user only needs to type in a username when searching for an account, exactly as you do with Cash App. This is what SNS calls a direct lookup.

  • reverseLookup - an asynchronous function that returns the domain name of the provided public key.This can be implemented anywhere in the UI where you want to display the username. This is what SNS calls a reverse lookup

To showcase this, lets update the transfers funds function to now accept a user name as a parameter rather than a public key and integrate the SNS API.

const transferFunds = useCallback(
  async (program: Program<CashApp>) => {
    let signedTransactions = await transact(
      async (wallet: Web3MobileWallet) => {
        const [authorizationResult, latestBlockhash] = await Promise.all([
          authorizeSession(wallet),
          connection.getLatestBlockhash(),
        ]);
 
        const { pubkey } = getDomainKeySync(userName);
 
        const [recipientPDA] = useMemo(() => {
          const recipientSeed = pubkey.toBuffer();
          return PublicKey.findProgramAddressSync(
            [recipientSeed],
            cashAppProgramId,
          );
        }, [cashAppProgramId]);
 
        const transferInstruction = await program.methods
          .transferFunds(pubkey, newTransferAmount)
          .accounts({
            user: authorizationResult.publicKey,
            fromCashAccount: cashAppPDA,
            toCashAccount: recipientPDA,
          })
          .instruction();
 
        const transferTransaction = new Transaction({
          ...latestBlockhash,
          feePayer: authorizationResult.publicKey,
        }).add(transferInstruction);
 
        const signedTransactions = await wallet.signTransactions({
          transactions: [transferTransaction],
        });
 
        return signedTransactions[0];
      },
    );
 
    let txSignature = await connection.sendRawTransaction(
      signedTransactions.serialize(),
      {
        skipPreflight: true,
      },
    );
 
    const confirmationResult = await connection.confirmTransaction(
      txSignature,
      "confirmed",
    );
 
    if (confirmationResult.value.err) {
      throw new Error(JSON.stringify(confirmationResult.value.err));
    } else {
      console.log("Transaction successfully submitted!");
    }
  },
  [authorizeSession, connection, cashAppPDA],
);

This implementation can be integrated everywhere in the application where an input requires a public key, enabling the user experience to be identical to that of a web2 application.

Final thoughts #

Congrats on completing a a web3 mobile application! By completing this tutorial you have learned how to build an expo mobile app with a wallet adapter, write and deploy an anchor solana program, and connect a mobile UI to a deployed solana program.

To build on this knowledge here are a few more resources to look into: