9 | BANK_USDC_WALLET: rand4XuRxdtPS9gDYy6KDeGkEpi69xmkCy5oEmDYfoC
10 | SOLANA_CLUSTER: local #mainnet, devnet, local
11 | RPC_URL: http://localhost:8899/
12 | USE_DUMMY_CARD: true
13 | USDC_MINT_ADDRESS: 4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU
14 | CIRCLE_CLIENT_TYPE: mock
15 | CIRCLE_MASTER_WALLET: 1111111111
16 |
--------------------------------------------------------------------------------
/android/app/src/release/java/com/tapcash/ReactNativeFlipper.java:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) Meta Platforms, Inc. and affiliates.
3 | *
4 | * This source code is licensed under the MIT license found in the LICENSE file in the root
5 | * directory of this source tree.
6 | */
7 | package com.tapcash;
8 |
9 | import android.content.Context;
10 | import com.facebook.react.ReactInstanceManager;
11 |
12 | /**
13 | * Class responsible of loading Flipper inside your React Native application. This is the release
14 | * flavor of it so it's empty as we don't want to load Flipper.
15 | */
16 | public class ReactNativeFlipper {
17 | public static void initializeFlipper(Context context, ReactInstanceManager reactInstanceManager) {
18 | // Do nothing as we don't want to initialize Flipper on Release.
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/src/images/images.ts:
--------------------------------------------------------------------------------
1 | export const IMAGES = {
2 | activity: {
3 | deposit: require("./activity/deposit.png"),
4 | receive: require("./activity/recieve.png"),
5 | send: require("./activity/send.png"),
6 | withdraw: require("./activity/withdraw.png")
7 | },
8 | payments: {
9 | visaMini: require("./payments/visa-mini.png"),
10 | visaSquare: require("./payments/visa-square.png"),
11 | unknownMini: require("./payments/unknown-square.png"),
12 | unknownSquare: require("./payments/unknown-square.png")
13 | },
14 | loaders: {
15 | loaderSmall: require("./loaders/loader-sm.gif"),
16 | loaderLarge: require("./loaders/loader-lg.gif")
17 | },
18 | icons: {
19 | wallet: require("./icons/wallet.png"),
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/backend/src/dev/testing/utils.ts:
--------------------------------------------------------------------------------
1 | import * as ff from "@google-cloud/functions-framework";
2 |
3 | export function buildPostRequest(body: T): ff.Request {
4 | // Our handlers use very little of the request object, so
5 | // we can get away with just spoofing it. If we tried to use
6 | // this on a live local server, it would break.
7 | return {
8 | body: body,
9 | } as unknown as ff.Request;
10 | }
11 |
12 |
13 | export function buildGetRequest(params: T): ff.Request {
14 | // Our handlers use very little of the request object, so
15 | // we can get away with just spoofing it. If we tried to use
16 | // this on a live local server, it would break.
17 | return {
18 | query: params,
19 | } as unknown as ff.Request;
20 | }
21 |
--------------------------------------------------------------------------------
/src/components/AppLogo.tsx:
--------------------------------------------------------------------------------
1 | import { useMemo } from "react";
2 | import { TextStyleProps } from "../common/styles";
3 | import { Text } from "./Text";
4 |
5 | interface Props {
6 | primary?: boolean;
7 | secondary?: boolean;
8 | fontSize?: number;
9 | }
10 |
11 | const defaultProps: Props = {
12 | fontSize: 84
13 | };
14 |
15 | export function AppLogo(props: Props): JSX.Element {
16 | const finalProps: Props = { ...defaultProps, ...props };
17 | const extraProps = useMemo(() => {
18 | const textProps: TextStyleProps = {};
19 | if (props.primary) textProps["primary-medium"] = true;
20 | if (props.secondary) textProps["whiteish"] = true;
21 | return textProps;
22 | }, [props]);
23 |
24 | return tap
25 | }
26 |
--------------------------------------------------------------------------------
/ios/TapCashTests/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleDevelopmentRegion
6 | en
7 | CFBundleExecutable
8 | $(EXECUTABLE_NAME)
9 | CFBundleIdentifier
10 | $(PRODUCT_BUNDLE_IDENTIFIER)
11 | CFBundleInfoDictionaryVersion
12 | 6.0
13 | CFBundleName
14 | $(PRODUCT_NAME)
15 | CFBundlePackageType
16 | BNDL
17 | CFBundleShortVersionString
18 | 1.0
19 | CFBundleSignature
20 | ????
21 | CFBundleVersion
22 | 1
23 |
24 |
25 |
--------------------------------------------------------------------------------
/src/modules/SplashScreen.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect } from "react";
2 | import { TopNavScreen, TopRouteParams } from "../common/navigation";
3 | import { Screen } from "../components/Screen";
4 | import { View } from "../components/View";
5 | import { AppLogo } from "../components/AppLogo";
6 | import { NativeStackScreenProps } from "@react-navigation/native-stack";
7 |
8 | type Props = NativeStackScreenProps;
9 |
10 | export function SplashScreen(props: Props): JSX.Element {
11 | useEffect(() => {
12 | setTimeout(() => props.navigation.navigate(TopNavScreen.AUTHENTICATE), 1500);
13 | }, []);
14 |
15 | return (
16 |
17 |
18 |
19 |
20 |
21 | )
22 | }
23 |
24 |
25 |
26 |
--------------------------------------------------------------------------------
/program/tap_cash/programs/tap_cash/src/state/bank.rs:
--------------------------------------------------------------------------------
1 | use anchor_lang::prelude::*;
2 |
3 | #[account]
4 | pub struct Bank {
5 | /// Version of the Bank Account
6 | pub version: u8,
7 | /// The authority key for the bank
8 | pub authority: Pubkey,
9 | /// The fee payer key for the bank
10 | pub fee_payer: Pubkey,
11 | /// Bump seed for the bank
12 | pub bump: u8
13 | }
14 |
15 | impl Bank {
16 | /// Returns the expected size of the account in bytes.
17 | pub fn get_space() -> usize {
18 | 8 + // account discriminator
19 | 1 + // version
20 | 32 + // authority
21 | 32 + // fee_payer
22 | 1 // bump
23 | }
24 | /// Logs a message indicating that a new bank has been initialized.
25 | pub fn log_init(&self){
26 | msg!("Init new bank v.{}", self.version);
27 | }
28 | }
--------------------------------------------------------------------------------
/program/tap_cash/programs/tap_cash/src/lib.rs:
--------------------------------------------------------------------------------
1 | pub mod state;
2 | pub mod constants;
3 | pub mod instructions;
4 | pub mod model;
5 | pub mod id;
6 |
7 | use anchor_lang::prelude::*;
8 | use instructions::*;
9 |
10 | pub use id::ID;
11 |
12 | #[program]
13 | pub mod tap_cash {
14 | use super::*;
15 |
16 | pub fn initialize_bank(ctx: Context) -> Result<()> {
17 | instructions::initialize_bank(ctx)
18 | }
19 |
20 | pub fn initialize_member(ctx: Context) -> Result<()> {
21 | instructions::initialize_member(ctx)
22 | }
23 |
24 | pub fn initialize_account(ctx: Context) -> Result<()>{
25 | instructions::init_account(ctx)
26 | }
27 |
28 | pub fn send_spl(ctx: Context, withdraw_amount: u64) -> Result<()>{
29 | instructions::send_spl(ctx, withdraw_amount)
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/program/tap_cash/programs/tap_cash/src/state/member.rs:
--------------------------------------------------------------------------------
1 | use anchor_lang::prelude::*;
2 |
3 | #[account]
4 | pub struct Member {
5 | /// Version of the Member Account
6 | pub version: u8,
7 | /// The bank pda
8 | pub bank: Pubkey,
9 | /// The member PubKey received on enrollment (currently via Web3 auth)
10 | pub user_id: Pubkey,
11 | /// Bump seed for the member
12 | pub bump: u8,
13 | /// Number of accounts
14 | pub num_accounts: u8
15 | }
16 |
17 | impl Member {
18 | /// Returns the expected size of the account in bytes.
19 | pub fn get_space() -> usize {
20 | 8 + // account discriminator
21 | 1 + // version
22 | 32 + // bank pda
23 | 32 + // user id
24 | 1 + // num_accounts
25 | 1 // bump
26 | }
27 | /// Logs a message indicating that a new member has been initialized.
28 | pub fn log_init(&self){
29 | msg!("Init new member:{}", self.user_id);
30 | }
31 | }
--------------------------------------------------------------------------------
/backend/src/circle/client.ts:
--------------------------------------------------------------------------------
1 | import { Card } from "@circle-fin/circle-sdk";
2 | import { EmailAddress } from "../shared/member";
3 | import { CircleCardId } from "../types/types";
4 |
5 | /**
6 | * Tap-specific Circle client for interacting with the Circle API.
7 | */
8 | export interface CircleClient {
9 | depositUsdc(args: CircleDepositArgs): Promise;
10 | fetchCard(id: string): Promise;
11 | }
12 |
13 | /**
14 | * This is the set of arguments that are needed to deposit funds into a user's account
15 | */
16 | export interface CircleDepositArgs {
17 | /* The destination USDC associated token account to deposit the funds into */
18 | destinationAtaString: string;
19 | /* The amount of USDC to deposit */
20 | amount: number;
21 | /* The email address of the user */
22 | member: EmailAddress;
23 | /* The id of the card to use */
24 | cardId: CircleCardId;
25 | /* The cvv of the card to use */
26 | cardCvv: string;
27 | }
28 |
--------------------------------------------------------------------------------
/src/common/debounce.ts:
--------------------------------------------------------------------------------
1 | import { useCallback, useEffect, useState } from "react";
2 |
3 |
4 | export function useStateWithDebounce(
5 | callback: (value: T) => void,
6 | intervalMs: number
7 | ): [T | undefined, (value: T) => void] {
8 | const [currentValue, setCurrentValue] = useState();
9 |
10 | const setValue: (value: T) => void = useCallback(value => {
11 | if (value !== currentValue) {
12 | setCurrentValue(value);
13 | }
14 | }, [currentValue]);
15 |
16 |
17 | // call the callback `intervalMs` after the value stops changing
18 | useEffect(() => {
19 | const timeoutId: NodeJS.Timeout = setTimeout(() => {
20 | if (currentValue != null) {
21 | callback(currentValue);
22 | }
23 | }, intervalMs);
24 |
25 | return () => {
26 | clearTimeout(timeoutId);
27 | }
28 | }, [currentValue]);
29 |
30 |
31 | return [currentValue, setValue];
32 | }
33 |
--------------------------------------------------------------------------------
/backend/src/dev/testing/generate.ts:
--------------------------------------------------------------------------------
1 | import { Card, CvvResults } from "@circle-fin/circle-sdk";
2 | import { v4 as uuid } from "uuid";
3 |
4 | /**
5 | *
6 | * @returns a Circle Card with randomly generated attributes
7 | */
8 | export function generateCircleCard(): Card {
9 | return {
10 | id: uuid(),
11 | status: "pending",
12 | billingDetails: {
13 | name: "Baron Bilano",
14 | city: "Baronville",
15 | country: "Baronia",
16 | line1: "123 Baron Street",
17 | postalCode: "12345"
18 | },
19 | expMonth: 1,
20 | expYear: 2025,
21 | network: "VISA",
22 | last4: "4321",
23 | fingerprint: "alskjdflajksdflj",
24 | verification: {
25 | avs: "laksjlkasjdf",
26 | cvv: CvvResults.Pass
27 | },
28 | metadata: {
29 | email: "baron.bilano@gmail.com"
30 | },
31 | createDate: "21:30:38Z",
32 | updateDate: "21:30:38Z"
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/backend/src/program/constants.ts:
--------------------------------------------------------------------------------
1 | import { parseKeypair, parsePublicKey } from "../constants";
2 | import { Keypair, PublicKey } from "../helpers/solana";
3 |
4 | // Program Seed's Ref: program/tap_cash/programs/tap_cash/src/constants/
5 |
6 | /* Seed used for BANK PDA */
7 | export const BANK_SEED = "tap-bank";
8 | /* Seed used for MEMBER PDA */
9 | export const MEMBER_SEED = "member";
10 | /* Seed used for Account PDA (checking) */
11 | export const CHECKING_SEED = "checking";
12 |
13 | /* Authority of Program's BANK (will be used to sign transactions and pay fees) */
14 | export const BANK_AUTH: Keypair = parseKeypair("BANK_AUTH", process.env.BANK_KEY);
15 | /* ATA of USDC Wallet of the BANK */
16 | export const BANK_USDC_WALLET: PublicKey = parsePublicKey("BANK_USDC_WALLET", process.env.BANK_USDC_WALLET);
17 | /* Environment of the Program */
18 | export const PROGRAM_ENV = process.env.SOLANA_ENVIRONMENT;
19 | /* Program ID of the Program (ref: program/tap_cash/programs/tap_cash/src/id.rs) */
20 | export const TAPCASH_PROGRAM_ID: PublicKey = new PublicKey("TAPAPp2YoguQQDkicGyzTzkA3t4AgECvR1eL1hbx9qz");
21 |
--------------------------------------------------------------------------------
/ios/TapCash/AppDelegate.mm:
--------------------------------------------------------------------------------
1 | #import "AppDelegate.h"
2 |
3 | #import
4 |
5 | @implementation AppDelegate
6 |
7 | - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
8 | {
9 | self.moduleName = @"TapCash";
10 | return [super application:application didFinishLaunchingWithOptions:launchOptions];
11 | }
12 |
13 | - (NSURL *)sourceURLForBridge:(RCTBridge *)bridge
14 | {
15 | #if DEBUG
16 | return [[RCTBundleURLProvider sharedSettings] jsBundleURLForBundleRoot:@"index"];
17 | #else
18 | return [[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"];
19 | #endif
20 | }
21 |
22 | /// This method controls whether the `concurrentRoot`feature of React18 is turned on or off.
23 | ///
24 | /// @see: https://reactjs.org/blog/2022/03/29/react-v18.html
25 | /// @note: This requires to be rendering on Fabric (i.e. on the New Architecture).
26 | /// @return: `true` if the `concurrentRoot` feature is enabled. Otherwise, it returns `false`.
27 | - (BOOL)concurrentRootEnabled
28 | {
29 | return true;
30 | }
31 |
32 | @end
33 |
--------------------------------------------------------------------------------
/src/components/RecipientProfile.tsx:
--------------------------------------------------------------------------------
1 | import { Avatar, ViewProps } from "react-native-ui-lib";
2 | import { View } from "./View";
3 | import { MemberPublicProfile } from "../shared/member";
4 | import { StyleSheet } from "react-native";
5 | import { Text } from "./Text";
6 | import { ViewStyleProps } from "../common/styles";
7 |
8 |
9 | export function RecipientProfile(props: MemberPublicProfile & ViewProps & ViewStyleProps): JSX.Element {
10 | return (
11 |
12 |
16 |
17 | {props.name}
18 | {props.email}
19 |
20 |
21 | );
22 | }
23 |
24 |
25 | const STYLES = StyleSheet.create({
26 | container: {
27 | paddingVertical: 12,
28 | gap: 16,
29 | },
30 |
31 | email: {
32 | fontSize: 14
33 | }
34 | })
35 |
--------------------------------------------------------------------------------
/ios/TapCash/Images.xcassets/AppIcon.appiconset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "idiom" : "iphone",
5 | "scale" : "2x",
6 | "size" : "20x20"
7 | },
8 | {
9 | "idiom" : "iphone",
10 | "scale" : "3x",
11 | "size" : "20x20"
12 | },
13 | {
14 | "idiom" : "iphone",
15 | "scale" : "2x",
16 | "size" : "29x29"
17 | },
18 | {
19 | "idiom" : "iphone",
20 | "scale" : "3x",
21 | "size" : "29x29"
22 | },
23 | {
24 | "idiom" : "iphone",
25 | "scale" : "2x",
26 | "size" : "40x40"
27 | },
28 | {
29 | "idiom" : "iphone",
30 | "scale" : "3x",
31 | "size" : "40x40"
32 | },
33 | {
34 | "idiom" : "iphone",
35 | "scale" : "2x",
36 | "size" : "60x60"
37 | },
38 | {
39 | "idiom" : "iphone",
40 | "scale" : "3x",
41 | "size" : "60x60"
42 | },
43 | {
44 | "idiom" : "ios-marketing",
45 | "scale" : "1x",
46 | "size" : "1024x1024"
47 | }
48 | ],
49 | "info" : {
50 | "author" : "xcode",
51 | "version" : 1
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/program/tap_cash/programs/tap_cash/src/state/member_account.rs:
--------------------------------------------------------------------------------
1 | use anchor_lang::prelude::*;
2 |
3 | #[account]
4 | pub struct MemberAccount {
5 | /// Version of the Member Account
6 | pub version: u8,
7 | /// The member pda
8 | pub member: Pubkey,
9 | /// The token mint
10 | pub token_mint: Pubkey,
11 | /// The associated token account
12 | pub ata: Pubkey,
13 | /// Bump seed for the member account
14 | pub bump: u8,
15 | /// Account number
16 | pub acct_no: u8,
17 | /// Account type (0=checking, 1=savings)
18 | pub acct_type: u8
19 | }
20 |
21 | impl MemberAccount {
22 | /// Returns the size of the `MemberAccount` account in bytes
23 | pub fn get_space() -> usize {
24 | 8 + // account discriminator
25 | 1 + // version
26 | 32 + // member pda
27 | 32 + // token_mint
28 | 32 + // ata
29 | 1 + // bump
30 | 1 + // account no
31 | 1 // account type
32 | }
33 | /// Prints a log message with the member's PDA address
34 | pub fn log_init(&self){
35 | msg!("Init new acct for:{}", self.member);
36 | }
37 | }
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | root: true,
3 | extends: [
4 | // By extending from a plugin config, we can get recommended rules without having to add them manually.
5 | 'eslint:recommended',
6 | "plugin:react/recommended",
7 | 'plugin:import/recommended',
8 | 'plugin:jsx-a11y/recommended',
9 | 'plugin:@typescript-eslint/recommended',
10 | 'plugin:react/jsx-runtime',
11 | ],
12 | settings: {
13 | react: {
14 | // Tells eslint-plugin-react to automatically detect the version of React to use.
15 | version: "detect",
16 | },
17 | // Tells eslint how to resolve imports
18 | 'import/resolver': {
19 | node: {
20 | paths: ["src"],
21 | extensions: [".js", ".jsx", ".ts", ".tsx"],
22 | },
23 | },
24 | },
25 | rules: {
26 | camelcase: "warn",
27 | '@typescript-eslint/no-inferrable-types': "off",
28 | quotes: ["error", "double"],
29 | semi: ["error", "always"],
30 | indent: ["error", 4],
31 | 'no-multi-spaces': ["error"],
32 | 'max-len': ["warn", 120],
33 | },
34 | };
35 |
--------------------------------------------------------------------------------
/src/components/TextInput.tsx:
--------------------------------------------------------------------------------
1 | import { Incubator, TextFieldProps } from "react-native-ui-lib";
2 | import { TextStyleProps, ViewStyleProps, useTextStyle, useViewStyle } from "../common/styles";
3 | import { TextProps } from "./Text";
4 | import { StyleSheet } from "react-native";
5 |
6 | type Props = TextFieldProps & TextProps & TextStyleProps & {
7 | inputFieldStyle?: ViewStyleProps
8 | }
9 |
10 | export function TextInput(props: Props): JSX.Element {
11 | const textStyle = useTextStyle({
12 | "gray-dark": true,
13 | "text-md": true,
14 | ...props
15 | });
16 | const fieldStyle = useViewStyle({
17 | ...props.inputFieldStyle
18 | });
19 |
20 | return (
21 |
26 | {props.children}
27 |
28 | )
29 | }
30 |
31 | const STYLE = StyleSheet.create({
32 | fieldBase: {
33 | paddingHorizontal: 16,
34 | paddingVertical: 10,
35 | },
36 | textBase: {
37 | textDecorationLine: "none"
38 | }
39 | });
40 |
--------------------------------------------------------------------------------
/src/shared/activity.ts:
--------------------------------------------------------------------------------
1 | import { Currency } from "./currency";
2 | import { AccountId, MemberPublicProfile } from "./member";
3 |
4 | export interface MemberActivity {
5 | type: MemberActivityType;
6 | deposit?: DepositActivity;
7 | send?: SendActivity;
8 | receive?: ReceiveActivity;
9 | withdraw?: WithdrawActivity;
10 |
11 | /**
12 | * Timestamp in seconds since Unix epoch. Expect to receive value from Solana,
13 | * but optional in case none is returned.
14 | */
15 | unixTimestamp?: number;
16 | }
17 |
18 | export enum MemberActivityType {
19 | DEPOSIT,
20 | SEND,
21 | RECEIVE,
22 | WITHDRAW,
23 | }
24 |
25 | export interface DepositActivity {
26 | account: AccountId;
27 | currency: Currency;
28 | amount: number;
29 | }
30 |
31 |
32 | export interface SendActivity {
33 | recipient: MemberPublicProfile;
34 | currency: Currency;
35 | amount: number;
36 | }
37 |
38 | export interface ReceiveActivity {
39 | sender: MemberPublicProfile;
40 | currency: Currency;
41 | amount: number;
42 | }
43 |
44 |
45 | export interface WithdrawActivity {
46 | source: AccountId;
47 | currency: Currency;
48 | amount: number;
49 | }
50 |
--------------------------------------------------------------------------------
/backend/src/shared/activity.ts:
--------------------------------------------------------------------------------
1 | import { Currency } from "./currency";
2 | import { AccountId, MemberPublicProfile } from "./member";
3 |
4 | export interface MemberActivity {
5 | type: MemberActivityType;
6 | deposit?: DepositActivity;
7 | send?: SendActivity;
8 | receive?: ReceiveActivity;
9 | withdraw?: WithdrawActivity;
10 |
11 | /**
12 | * Timestamp in seconds since Unix epoch. Expect to receive value from Solana,
13 | * but optional in case none is returned.
14 | */
15 | unixTimestamp?: number;
16 | }
17 |
18 | export enum MemberActivityType {
19 | DEPOSIT,
20 | SEND,
21 | RECEIVE,
22 | WITHDRAW,
23 | }
24 |
25 | export interface DepositActivity {
26 | account: AccountId;
27 | currency: Currency;
28 | amount: number;
29 | }
30 |
31 |
32 | export interface SendActivity {
33 | recipient: MemberPublicProfile;
34 | currency: Currency;
35 | amount: number;
36 | }
37 |
38 | export interface ReceiveActivity {
39 | sender: MemberPublicProfile;
40 | currency: Currency;
41 | amount: number;
42 | }
43 |
44 |
45 | export interface WithdrawActivity {
46 | source: AccountId;
47 | currency: Currency;
48 | amount: number;
49 | }
50 |
--------------------------------------------------------------------------------
/backend/src/handlers/test-curls/send.sh:
--------------------------------------------------------------------------------
1 | curl -X POST -H "Content-Type: application/json" -d '{
2 | "emailAddress": "amilz@123.com",
3 | "profilePictureUrl": "https://test.net/1.png",
4 | "name": "tino",
5 | "signerAddressBase58": "Cxcfw2GC1tfEPEuNABNwTujwr6nEtsV6Enzjxz2pDqoE"
6 | }' http://localhost:8080/save-member
7 |
8 |
9 | [113,154,196,249,251,248,198,116,19,104,102,173,218,135,205,227,227,179,179,184,25,7,242,128,8,209,160,39,38,99,218,152,178,89,12,233,233,89,37,201,228,156,171,238,113,143,72,205,158,98,122,60,11,92,186,127,63,190,14,17,104,107,128,238]
10 |
11 | curl -X POST -H "Content-Type: application/json" -d '{
12 | "senderEmailAddress": "5eiqhs@example.com",
13 | "recipientEmailAddress": "51xgcg@test.com",
14 | "amount": "69",
15 | "privateKey": [113,154,196,249,251,248,198,116,19,104,102,173,218,135,205,227,227,179,179,184,25,7,242,128,8,209,160,39,38,99,218,152,178,89,12,233,233,89,37,201,228,156,171,238,113,143,72,205,158,98,122,60,11,92,186,127,63,190,14,17,104,107,128,238]
16 | }' http://localhost:8080/send
17 |
18 | export interface ApiSendRequest {
19 | senderEmailAddress: string;
20 | recipientEmailAddress: string;
21 | amount: number;
22 | privateKey: number[];
23 | }
24 |
25 | 51xgcg@test.com
26 |
--------------------------------------------------------------------------------
/src/modules/deposit/DepositStack.tsx:
--------------------------------------------------------------------------------
1 | import { NativeStackScreenProps, createNativeStackNavigator } from "@react-navigation/native-stack";
2 | import { ProfileStackRouteParams, ProfileNavScreen, DepositStackRouteParams, DepositNavScreen, STACK_DEFAULTS } from "../../common/navigation";
3 | import { AmountInputScreen } from "./AmountInputScreen";
4 | import { DepositingScreen } from "./DepositingScreen";
5 |
6 | const Stack = createNativeStackNavigator();
7 |
8 | type Props = NativeStackScreenProps;
9 |
10 | export function DepositStack(props: Props): JSX.Element {
11 | return (
12 |
16 |
21 |
26 |
27 | )
28 | }
29 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # OSX
2 | #
3 | .DS_Store
4 |
5 | # Xcode
6 | #
7 | build/
8 | *.pbxuser
9 | !default.pbxuser
10 | *.mode1v3
11 | !default.mode1v3
12 | *.mode2v3
13 | !default.mode2v3
14 | *.perspectivev3
15 | !default.perspectivev3
16 | xcuserdata
17 | *.xccheckout
18 | *.moved-aside
19 | DerivedData
20 | *.hmap
21 | *.ipa
22 | *.xcuserstate
23 | ios/.xcode.env.local
24 |
25 | # Android/IntelliJ
26 | #
27 | build/
28 | .idea
29 | .gradle
30 | local.properties
31 | *.iml
32 | *.hprof
33 | .cxx/
34 | *.keystore
35 | !debug.keystore
36 |
37 | # node.js
38 | #
39 | node_modules/
40 | npm-debug.log
41 | yarn-error.log
42 |
43 | # fastlane
44 | #
45 | # It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the
46 | # screenshots whenever they are needed.
47 | # For more information about the recommended setup visit:
48 | # https://docs.fastlane.tools/best-practices/source-control/
49 |
50 | **/fastlane/report.xml
51 | **/fastlane/Preview.html
52 | **/fastlane/screenshots
53 | **/fastlane/test_output
54 |
55 | # Bundle artifact
56 | *.jsbundle
57 |
58 | # Ruby / CocoaPods
59 | /ios/Pods/
60 | /vendor/bundle/
61 |
62 | # Temporary files created by Metro to check the health of the file watcher
63 | .metro-health-check*
64 |
65 | .env*
66 | !.env.example
67 |
68 | secrets/
69 |
--------------------------------------------------------------------------------
/src/components/Button.tsx:
--------------------------------------------------------------------------------
1 | import { ButtonProps, Button as RNUIButton } from "react-native-ui-lib"
2 | import { COLORS, useTextStyle } from "../common/styles";
3 | import { useMemo } from "react";
4 |
5 | interface Props {
6 | label: string;
7 | onPress: () => void;
8 | primary?: boolean;
9 | secondary?: boolean;
10 | tertiary?: boolean;
11 | disabled?: boolean;
12 | }
13 |
14 | export function Button(props: Props & ButtonProps): JSX.Element {
15 | const [fontColor, bgColor, borderColor]: [string, string, string | undefined] = useMemo(() => {
16 | if (props.secondary) return [COLORS.whiteish, COLORS.secondaryMedium, undefined];
17 | if (props.tertiary) return [COLORS.grayDark, COLORS.whiteish, COLORS.grayLight];
18 | else return [COLORS.whiteish, COLORS.primaryMedium, undefined];
19 | }, [props.secondary, props.tertiary, props.primary]);
20 |
21 | const labelStyle = {
22 | ...useTextStyle({
23 | "text-md": true,
24 | }),
25 | color: fontColor
26 | };
27 |
28 | return (
29 |
38 | )
39 | }
40 |
--------------------------------------------------------------------------------
/src/components/ShimmerBars.tsx:
--------------------------------------------------------------------------------
1 | import LinearGradient from 'react-native-linear-gradient';
2 | import ShimmerPlaceHolder from 'react-native-shimmer-placeholder';
3 | import { View } from './View';
4 | import { useWindowDimensions } from 'react-native';
5 |
6 | interface Props {
7 | loading: boolean;
8 | numBars: number;
9 | }
10 |
11 | export function ShimmerBars({ loading, numBars }: Props): JSX.Element {
12 | const windowWidth = useWindowDimensions().width;
13 | const width = Math.round(windowWidth * 0.8);
14 |
15 | const ShimmerBar = (
16 |
27 | );
28 |
29 | const bars = Array.from({ length: numBars }, () => ShimmerBar);
30 |
31 | return (
32 |
37 | {bars.map((bar, index) => (
38 |
39 | {bar}
40 |
41 | ))}
42 |
43 | );
44 | }
45 |
--------------------------------------------------------------------------------
/backend/src/handlers/withdraw.ts:
--------------------------------------------------------------------------------
1 |
2 | //TODO tests
3 |
4 | import { ApiWithdrawRequest, ApiWithdrawResult } from "../shared/api";
5 | import { EmailAddress, AccountId } from "../shared/member";
6 | import { getRequiredParam, makePostHandler } from "./model";
7 |
8 | interface WithdrawArgs {
9 | emailAddress: EmailAddress;
10 | sourceAccount: AccountId;
11 | amount: number;
12 | // TODO something about the destination bank account
13 | //TODO probably the user's private key
14 | }
15 |
16 |
17 | interface WithdrawResult {
18 | //TODO something about the result of the withdraw attempt
19 | }
20 |
21 |
22 | export const handleWithdraw = makePostHandler(withdraw, transformRequest, transformResult);
23 |
24 |
25 | async function withdraw(request: WithdrawArgs): Promise {
26 | return {
27 | //TODO
28 | }
29 | }
30 |
31 |
32 | function transformRequest(body: ApiWithdrawRequest): WithdrawArgs {
33 | return {
34 | emailAddress: getRequiredParam(body, "emailAddress"),
35 | sourceAccount: getRequiredParam(body, "sourceAccount"),
36 | amount: getRequiredParam(body, "amount", Number.parseFloat)
37 | };
38 | }
39 |
40 |
41 | function transformResult(result: WithdrawResult): ApiWithdrawResult {
42 | // nothing to return
43 | return {};
44 | }
45 |
--------------------------------------------------------------------------------
/android/app/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
12 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
--------------------------------------------------------------------------------
/backend/src/circle/open-pgp.ts:
--------------------------------------------------------------------------------
1 | // Ref Implementation: https://github.com/circlefin/payments-sample-app/blob/78e3d1b5b3b548775e755f1b619720bcbe5a8789/lib/openpgp.ts
2 |
3 | import { createMessage, encrypt, readKey } from 'openpgp';
4 |
5 | interface PublicKey {
6 | keyId: string
7 | /* uuid Public Key (Not a web3 PublicKey) */
8 | publicKey: string
9 | }
10 |
11 | interface Result {
12 | /* Encrypted message */
13 | encryptedMessage: string;
14 | keyId: string;
15 | }
16 |
17 | /**
18 | * Encrypt dataToEncrypt
19 | *
20 | * @param {Object} dataToEncrypt
21 | * @param {PublicKey} Object containing keyId and publicKey properties
22 | *
23 | * @return {Object} Object containing encryptedMessage and keyId
24 | */
25 | export async function pgpEncrypt(dataToEncrypt: object, { keyId, publicKey }: PublicKey): Promise {
26 | if (!publicKey || !keyId) {
27 | throw new Error('Unable to encrypt data');
28 | }
29 |
30 | const decodedPublicKey = await readKey({ armoredKey: atob(publicKey) });
31 | const message = await createMessage({ text: JSON.stringify(dataToEncrypt) });
32 | const ciphertext = await encrypt({
33 | message,
34 | encryptionKeys: decodedPublicKey,
35 | });
36 |
37 | return {
38 | // TODO replace btoa
39 | // @ts-ignore copied from online example...
40 | encryptedMessage: btoa(ciphertext),
41 | keyId,
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/backend/src/handlers/account.ts:
--------------------------------------------------------------------------------
1 |
2 | import { ApiAccountRequest, ApiAccountResult } from "../shared/api";
3 | import { getRequiredParam, makeGetHandler } from "./model";
4 | import { getDatabaseClient } from "../helpers/singletons";
5 | import { EmailAddress, MemberPrivateProfile } from "../shared/member";
6 |
7 | interface AccountArgs {
8 | member: EmailAddress;
9 | }
10 |
11 |
12 | //TODO this needs to have authentication even though we're not (yet) sharing
13 | // anything too sensitive
14 | export const handleAccount = makeGetHandler(getAccount, transformRequest, transformResult);
15 |
16 | /**
17 | *
18 | * Fetch a member's private profile from the Database Client
19 | *
20 | * @param request AccountArgs - the request arguments
21 | * @returns a Member's private profile
22 | */
23 | async function getAccount(request: AccountArgs): Promise {
24 | return await getDatabaseClient().getMemberPrivateProfile(request.member);
25 | }
26 |
27 | function transformRequest(params: ApiAccountRequest): AccountArgs {
28 | return {
29 | member: getRequiredParam(params, "memberEmail")
30 | };
31 | }
32 |
33 | function transformResult(result: MemberPrivateProfile): ApiAccountResult {
34 | return {
35 | email: result.email,
36 | name: result.name,
37 | profile: result.profile,
38 | signerAddress: result.signerAddress.toBase58(),
39 | usdcAddress: result.usdcAddress.toBase58()
40 | };
41 | }
42 |
--------------------------------------------------------------------------------
/backend/src/handlers/test-curls/genCurl.ts:
--------------------------------------------------------------------------------
1 | import { Keypair } from "@solana/web3.js";
2 |
3 | interface ApiInitializeMemberRequest {
4 | emailAddress: string;
5 | profilePictureUrl: string;
6 | name: string;
7 | signerAddressBase58: string;
8 | }
9 |
10 | function generateRandomRequest(): ApiInitializeMemberRequest {
11 | const emailSuffixes = ['@example.com', '@test.com', '@random.com'];
12 | const profilePictureUrls = [
13 | 'https://picsum.photos/200/300',
14 | 'https://picsum.photos/300/200',
15 | 'https://picsum.photos/250/250',
16 | ];
17 | const names = ['Alice', 'Bob', 'Charlie', 'Dave', 'Eve'];
18 |
19 | const email = `${Math.random()
20 | .toString(36)
21 | .substring(7)}${emailSuffixes[Math.floor(Math.random() * emailSuffixes.length)]}`;
22 | const profilePictureUrl =
23 | profilePictureUrls[Math.floor(Math.random() * profilePictureUrls.length)];
24 | const name = names[Math.floor(Math.random() * names.length)];
25 | const signerAddressBase58 =
26 | Keypair.generate().publicKey.toBase58(); // Need to write this to do send function later
27 |
28 | return { emailAddress: email, profilePictureUrl, name, signerAddressBase58 };
29 | }
30 |
31 | const requestData = generateRandomRequest();
32 |
33 | const curlCommand = `curl -X POST -H "Content-Type: application/json" -d '${JSON.stringify(
34 | requestData,
35 | )}' http://localhost:8080/save-member`;
36 |
37 | console.log(curlCommand);
38 |
--------------------------------------------------------------------------------
/backend/src/handlers/query-recipients.ts:
--------------------------------------------------------------------------------
1 |
2 | //TODO tests
3 |
4 | import { MemberPublicProfile } from "../shared/member";
5 | import { DatabaseClient } from "../db/client";
6 | import { FirestoreClient } from "../db/firestore";
7 | import { ApiQueryRecipientsRequest, ApiQueryRecipientsResult } from "../shared/api";
8 | import { getRequiredParam, getRequiredIntegerParam, makeGetHandler } from "./model";
9 | import { getDatabaseClient } from "../helpers/singletons";
10 |
11 | interface QueryRecipientsArgs {
12 | emailQuery: string;
13 | limit: number;
14 | }
15 |
16 |
17 | type QueryRecipientsResult = MemberPublicProfile[];
18 |
19 |
20 | export const handleQueryRecipients = makeGetHandler(queryRecipients, transformRequest, transformResult);
21 |
22 | async function queryRecipients(request: QueryRecipientsArgs): Promise {
23 | return await getDatabaseClient().queryMembersByEmail(request.emailQuery, request.limit);
24 | }
25 |
26 |
27 | function transformRequest(params: ApiQueryRecipientsRequest): QueryRecipientsArgs {
28 | return {
29 | emailQuery: getRequiredParam(params, "emailQuery"),
30 | limit: getRequiredIntegerParam(params, "limit"),
31 | };
32 | }
33 |
34 |
35 | function transformResult(result: QueryRecipientsResult): ApiQueryRecipientsResult {
36 | return result.map(r => ({
37 | emailAddress: r.email,
38 | name: r.name,
39 | profilePicture: r.profile
40 | }));
41 | }
42 |
--------------------------------------------------------------------------------
/src/modules/profile/ProfileStack.tsx:
--------------------------------------------------------------------------------
1 | import { NativeStackScreenProps, createNativeStackNavigator } from "@react-navigation/native-stack";
2 | import { ProfileStackRouteParams, ProfileNavScreen, TopRouteParams, TopNavScreen, STACK_DEFAULTS } from "../../common/navigation";
3 | import { ProfileOverviewScreen } from "./ProfileSummaryScreen";
4 | import { PaymentMethodsScreen } from "./PaymentMethodsScreen";
5 | import { DepositStack } from "../deposit/DepositStack";
6 |
7 | const Stack = createNativeStackNavigator();
8 |
9 | type Props = NativeStackScreenProps;
10 |
11 | export function ProfileStack(props: Props): JSX.Element {
12 | return (
13 |
17 |
22 |
27 |
32 |
33 | )
34 | }
35 |
--------------------------------------------------------------------------------
/android/app/src/main/java/com/tapcash/MainActivity.java:
--------------------------------------------------------------------------------
1 | package com.tapcash;
2 |
3 | import android.os.Bundle;
4 | import com.facebook.react.ReactActivity;
5 | import com.facebook.react.ReactActivityDelegate;
6 | import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint;
7 | import com.facebook.react.defaults.DefaultReactActivityDelegate;
8 |
9 | public class MainActivity extends ReactActivity {
10 |
11 | @Override
12 | protected void onCreate(Bundle savedInstanceState) {
13 | super.onCreate(null);
14 | }
15 |
16 | /**
17 | * Returns the name of the main component registered from JavaScript. This is used to schedule
18 | * rendering of the component.
19 | */
20 | @Override
21 | protected String getMainComponentName() {
22 | return "TapCash";
23 | }
24 |
25 | /**
26 | * Returns the instance of the {@link ReactActivityDelegate}. Here we use a util class {@link
27 | * DefaultReactActivityDelegate} which allows you to easily enable Fabric and Concurrent React
28 | * (aka React 18) with two boolean flags.
29 | */
30 | @Override
31 | protected ReactActivityDelegate createReactActivityDelegate() {
32 | return new DefaultReactActivityDelegate(
33 | this,
34 | getMainComponentName(),
35 | // If you opted-in for the New Architecture, we enable the Fabric Renderer.
36 | DefaultNewArchitectureEntryPoint.getFabricEnabled(), // fabricEnabled
37 | // If you opted-in for the New Architecture, we enable Concurrent React (i.e. React 18).
38 | DefaultNewArchitectureEntryPoint.getConcurrentReactEnabled() // concurrentRootEnabled
39 | );
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/src/common/number.ts:
--------------------------------------------------------------------------------
1 | const USDC_FORMATTER = Intl.NumberFormat(undefined, {
2 | minimumFractionDigits: 2,
3 | minimumIntegerDigits: 1,
4 | maximumFractionDigits: 2,
5 | });
6 |
7 | const USDC_FORMATTER_SHORT = Intl.NumberFormat(undefined, {
8 | notation: "compact",
9 | compactDisplay: "short",
10 | minimumFractionDigits: 1,
11 | maximumFractionDigits: 1
12 | });
13 |
14 | export function formatUsd(amount: number, options?: {
15 | leadingSymbol?: boolean
16 | short?: boolean;
17 | stripZeroCents?: boolean;
18 | }): string {
19 | const leadingSymbol: boolean = options?.leadingSymbol ?? true;
20 | const short: boolean = options?.short ?? false;
21 | const stripZeroCents: boolean = options?.stripZeroCents ?? true;
22 |
23 | const useShortFormatter: boolean = short && (amount >= 10000);
24 |
25 | let result: string;
26 | if (useShortFormatter) {
27 | result = USDC_FORMATTER_SHORT.format(amount);
28 |
29 | } else {
30 | result = USDC_FORMATTER.format(amount);
31 | }
32 |
33 | if (leadingSymbol) {
34 | result = "$" + result;
35 | }
36 |
37 | if (stripZeroCents && !useShortFormatter && Number.isInteger(amount)) {
38 | result = result.slice(0, -3);
39 | }
40 |
41 | return result;
42 | }
43 |
44 | const DATE_FORMATTER = new Intl.DateTimeFormat('en-US', {
45 | year: 'numeric',
46 | month: 'short',
47 | day: 'numeric'
48 | });
49 |
50 | export function formatDate(unixTimestamp: number): string {
51 | const date = new Date(unixTimestamp * 1000); // Convert to milliseconds
52 | return DATE_FORMATTER.format(date);
53 | }
54 |
--------------------------------------------------------------------------------
/program/tap_cash/tests/helpers/airdrop.ts:
--------------------------------------------------------------------------------
1 | import * as anchor from "@project-serum/anchor";
2 | import { web3 } from '@project-serum/anchor';
3 |
4 | const { LAMPORTS_PER_SOL } = web3;
5 |
6 | /**
7 | *
8 | * Airdrops 100 SOL to each address in the array (for use in local environments)
9 | *
10 | * @param connection Solana connection
11 | * @param addresses array of addresses to request airdrops for
12 | * @returns array of results
13 | * @throw error if airdrop fails
14 | */
15 | export async function requestAirdrops(connection, addresses: anchor.web3.PublicKey[]) {
16 | const promises = addresses.map(async (address) => {
17 | try {
18 | const airdropBlock = await connection.getLatestBlockhash('finalized');
19 | const airdrop = await connection.requestAirdrop(address, 100 * LAMPORTS_PER_SOL);
20 | await connection.confirmTransaction({
21 | signature: airdrop,
22 | blockhash: airdropBlock.blockhash,
23 | lastValidBlockHeight: airdropBlock.lastValidBlockHeight
24 | });
25 | return { publicKey: address.toString(), status: 'success', txSignature: airdrop };
26 | } catch (error) {
27 | console.error(`Error while requesting airdrop for ${address.toString()}: ${error}`);
28 | return { publicKey: address.toString(), status: 'error', error: error };
29 | }
30 | });
31 | const results = await Promise.allSettled(promises);
32 | return results.map((result, index) => {
33 | const address = addresses[index];
34 | if (result.status === 'fulfilled') {
35 | return result.value;
36 | } else {
37 | return { publicKey: address.toString(), status: 'error', error: result.reason };
38 | }
39 | });
40 | }
--------------------------------------------------------------------------------
/src/modules/deposit/AmountInputScreen.tsx:
--------------------------------------------------------------------------------
1 | import { NativeStackScreenProps } from "@react-navigation/native-stack";
2 | import { DepositNavScreen, DepositStackRouteParams } from "../../common/navigation";
3 | import { Screen } from "../../components/Screen";
4 | import { Button } from "../../components/Button";
5 | import { useCallback, useState } from "react";
6 | import { StyleSheet } from "react-native";
7 | import { COLORS } from "../../common/styles";
8 | import { View } from "../../components/View";
9 | import { Text } from "../../components/Text";
10 | import { MAX_TX_AMOUNT } from "../../common/constants";
11 | import { DollarInput } from "../../components/DollarInput";
12 |
13 | type Props = NativeStackScreenProps;
14 |
15 | export function AmountInputScreen(props: Props): JSX.Element {
16 | const [amount, setAmount] = useState();
17 |
18 | const onSubmit = useCallback(() => {
19 | props.navigation.navigate(DepositNavScreen.DEPOSITING, { amount: (amount ?? 0) });
20 | }, [props.navigation.navigate, amount]);
21 |
22 | return (
23 |
24 |
25 |
30 |
31 | deposit to your Tap account
32 |
33 | {/* TODO Replace dummy CC */}
34 | {`from: •••• •••• •••• 4567`}
35 |
36 |
37 |
38 | )
39 | }
40 |
--------------------------------------------------------------------------------
/src/modules/send/SendStack.tsx:
--------------------------------------------------------------------------------
1 | import { NativeStackScreenProps, createNativeStackNavigator } from "@react-navigation/native-stack";
2 | import { STACK_DEFAULTS, SendNavScreen, SendStackRouteParams, TopNavScreen, TopRouteParams } from "../../common/navigation";
3 | import { RecipientInputScreen } from "./RecipientInputScreen";
4 | import { AmountInputScreen } from "./AmountInputScreen";
5 | import { ConfirmScreen } from "./ConfirmScreen";
6 | import { SendingScreen } from "./SendingScreen";
7 |
8 | const Stack = createNativeStackNavigator();
9 |
10 | type Props = NativeStackScreenProps;
11 |
12 | // more on styling/formatting the stack header at
13 | // https://reactnavigation.org/docs/screen-options
14 |
15 | export function SendStack(props: Props): JSX.Element {
16 | return (
17 |
21 |
26 |
31 |
36 |
41 |
42 | )
43 | }
44 |
--------------------------------------------------------------------------------
/backend/src/handlers/deposit.ts:
--------------------------------------------------------------------------------
1 | import { ApiDepositRequest, ApiDepositResult, ApiResponseStatus } from "../shared/api";
2 | import { EmailAddress } from "../shared/member";
3 | import { getRequiredParam, makePostHandler } from "./model";
4 | import { getCircleClient, getDatabaseClient } from "../helpers/singletons";
5 |
6 | export interface DepositArgs {
7 | /* Member email address */
8 | emailAddress: EmailAddress;
9 | /* Amount to deposit */
10 | amount: number;
11 | }
12 |
13 | /**
14 | * Result of the deposit handler. (void)
15 | */
16 | export interface DepositResult {
17 | }
18 |
19 | export const handleDeposit = makePostHandler(deposit, transformRequest, transformResult);
20 |
21 | /**
22 | *
23 | * Deposit funds into a member's account
24 | *
25 | * @param request DepositArgs - the request arguments
26 | * @returns DepositResult - the result of the deposit handler (void)
27 | * @throws SERVER_ERROR - if the deposit fails
28 | */
29 | async function deposit(request: DepositArgs): Promise {
30 | const { usdcAddress } = await getDatabaseClient().getMemberPrivateProfile(request.emailAddress);
31 |
32 | await getCircleClient().depositUsdc({
33 | destinationAtaString: usdcAddress.toString(),
34 | amount: request.amount,
35 | member: request.emailAddress,
36 | cardId: "1", //TODO ignored atm
37 | cardCvv: "123", //TODO ignored atm
38 | });
39 | return {};
40 | }
41 |
42 | function transformRequest(body: ApiDepositRequest): DepositArgs {
43 | return {
44 | emailAddress: getRequiredParam(body, "emailAddress"),
45 | amount: getRequiredParam(body, "amount", Number.parseFloat)
46 | };
47 | }
48 |
49 | function transformResult(result: DepositResult): ApiDepositResult {
50 | // nothing to return
51 | return {};
52 | }
53 |
--------------------------------------------------------------------------------
/backend/src/program/workspace.ts:
--------------------------------------------------------------------------------
1 | import * as anchor from "@project-serum/anchor";
2 | import { IDL, TapCash } from "../types/tap-cash";
3 | import { TAPCASH_PROGRAM_ID } from "./constants";
4 |
5 | /**
6 | * Workspace is a collection of objects that are used to interact with the program.
7 | */
8 | export interface WorkSpace {
9 | /* Connection to the Solana network */
10 | connection: anchor.web3.Connection;
11 | /* AnchorProvider of the program (Connection, Wallet, options) */
12 | provider: anchor.AnchorProvider;
13 | /* Program object of the program */
14 | program: anchor.Program;
15 | /* Payer of the program (Keypair) */
16 | payer: anchor.web3.Keypair;
17 | }
18 |
19 | /**
20 | *
21 | * Creates a new workspace for the an instance of the TapCashClient
22 | * Includes fallbacks if wallet is not connected or provider is not available
23 | *
24 | * @param endpoint - Solana RPC endpoint
25 | * @param bankAuth - Keypair of the Bank Authority
26 | * @returns Workspace
27 | */
28 | export function createWorkspace(
29 | endpoint: string,
30 | bankAuth: anchor.web3.Keypair
31 | ): WorkSpace {
32 | const anchorWallet = new anchor.Wallet(bankAuth);
33 | const connection = new anchor.web3.Connection(endpoint);
34 | const provider: anchor.AnchorProvider = new anchor.AnchorProvider(
35 | connection,
36 | // fallback value allows querying the program without having a wallet connected
37 | anchorWallet ?? ({} as anchor.Wallet),
38 | { commitment: 'confirmed', preflightCommitment: 'confirmed' }
39 | );
40 | const program: anchor.Program = new anchor.Program(
41 | IDL as unknown as TapCash,
42 | TAPCASH_PROGRAM_ID,
43 | provider ?? ({} as anchor.AnchorProvider)
44 | );
45 | const payer = bankAuth;
46 | return { connection, provider, program, payer };
47 | }
48 |
--------------------------------------------------------------------------------
/program/tap_cash/programs/tap_cash/src/instructions/bank.rs:
--------------------------------------------------------------------------------
1 | use anchor_lang::prelude::*;
2 |
3 | use crate::{
4 | constants::{BANK_SEED, BANK_VERSION},
5 | state::{Bank},
6 | model::error::{BankError}
7 | };
8 |
9 | /// Accounts and constraints for initializing a new bank.
10 | #[derive(Accounts)]
11 | pub struct InitializeBank<'info> {
12 | /// The Bank's creator -- should be secure (e.g., ledger)
13 | #[account(
14 | mut,
15 | owner = system_program.key() @ BankError::InvalidAuthority
16 | )]
17 | pub bank_authority: Signer<'info>,
18 |
19 | /// The Bank (for now, there will only be 1 bank) - only allowing 1 per authority key
20 | #[account(
21 | init,
22 | payer = bank_authority,
23 | space = Bank::get_space(),
24 | seeds = [BANK_SEED.as_ref(), bank_authority.key().as_ref()],
25 | bump
26 | )]
27 | pub bank: Account<'info,Bank>,
28 |
29 | /// Standard system program, for creating accounts
30 | pub system_program: Program<'info, System>,
31 |
32 | /// Standard rent sysvar, for determining rent for created accounts
33 | pub rent: Sysvar<'info, Rent>
34 | }
35 |
36 | /// Initializes a new Bank.
37 | ///
38 | /// This function creates a new account of type Bank and sets its initial state to include the bank version,
39 | /// authority, fee payer, and bump value. This function should only be called once per bank authority key.
40 | pub fn initialize_bank(
41 | ctx: Context
42 | ) -> Result<()> {
43 | let bank = &mut ctx.accounts.bank;
44 |
45 | // Create bank state account
46 | bank.set_inner(Bank {
47 | version: BANK_VERSION,
48 | authority: ctx.accounts.bank_authority.key(),
49 | fee_payer: ctx.accounts.bank_authority.key(),
50 | bump: *ctx.bumps.get("bank").unwrap()
51 | });
52 | bank.log_init();
53 | Ok(())
54 | }
--------------------------------------------------------------------------------
/ios/Podfile:
--------------------------------------------------------------------------------
1 | require_relative '../node_modules/react-native/scripts/react_native_pods'
2 | require_relative '../node_modules/@react-native-community/cli-platform-ios/native_modules'
3 |
4 | platform :ios, min_ios_version_supported
5 | prepare_react_native_project!
6 |
7 | flipper_config = ENV['NO_FLIPPER'] == "1" ? FlipperConfiguration.disabled : FlipperConfiguration.enabled
8 |
9 | linkage = ENV['USE_FRAMEWORKS']
10 | if linkage != nil
11 | Pod::UI.puts "Configuring Pod with #{linkage}ally linked Frameworks".green
12 | use_frameworks! :linkage => linkage.to_sym
13 | end
14 |
15 | target 'TapCash' do
16 | config = use_native_modules!
17 |
18 | # Flags change depending on the env values.
19 | flags = get_default_flags()
20 |
21 | use_react_native!(
22 | :path => config[:reactNativePath],
23 | # Hermes is now enabled by default. Disable by setting this flag to false.
24 | # Upcoming versions of React Native may rely on get_default_flags(), but
25 | # we make it explicit here to aid in the React Native upgrade process.
26 | :hermes_enabled => flags[:hermes_enabled],
27 | :fabric_enabled => flags[:fabric_enabled],
28 | # Enables Flipper.
29 | #
30 | # Note that if you have use_frameworks! enabled, Flipper will not work and
31 | # you should disable the next line.
32 | :flipper_configuration => flipper_config,
33 | # An absolute path to your application root.
34 | :app_path => "#{Pod::Config.instance.installation_root}/.."
35 | )
36 |
37 | target 'TapCashTests' do
38 | inherit! :complete
39 | # Pods for testing
40 | end
41 |
42 | post_install do |installer|
43 | react_native_post_install(
44 | installer,
45 | # Set `mac_catalyst_enabled` to `true` in order to apply patches
46 | # necessary for Mac Catalyst builds
47 | :mac_catalyst_enabled => false
48 | )
49 | __apply_Xcode_12_5_M1_post_install_workaround(installer)
50 | end
51 | end
52 |
--------------------------------------------------------------------------------
/backend/src/dev/testing/MockCircleClient.ts:
--------------------------------------------------------------------------------
1 | import { Card } from "@circle-fin/circle-sdk";
2 | import { CircleClient, CircleDepositArgs } from "../../circle/client";
3 | import { ApiError } from "../../shared/error";
4 | import { MockTapCashClient } from "./MockTapCashClient";
5 | import { PublicKey } from "../../helpers/solana";
6 |
7 | export class MockCircleClient implements CircleClient {
8 | private readonly tapClient: MockTapCashClient;
9 | private readonly cards: Map = new Map();
10 | private readonly balances: Map = new Map();
11 |
12 | private constructor(tapClient: MockTapCashClient) {
13 | this.tapClient = tapClient;
14 | }
15 |
16 | public static make(tapClient: MockTapCashClient): MockCircleClient {
17 | return new MockCircleClient(tapClient);
18 | }
19 |
20 | public async depositUsdc(args: CircleDepositArgs): Promise {
21 | this.balances.set(
22 | args.destinationAtaString,
23 | this.balances.get(args.destinationAtaString) ?? 0 + args.amount
24 | );
25 | const userId: PublicKey | undefined = this.tapClient.getMemberIdFromAta(new PublicKey(args.destinationAtaString));
26 | if (userId === undefined) throw ApiError.noSuchMember(args.destinationAtaString);
27 | const balance: number | undefined = this.tapClient.getMemberAccount(userId)?.balance;
28 | if (balance === undefined) throw ApiError.noSuchMember(args.destinationAtaString);
29 | this.tapClient.setMemberBalance(userId, balance + args.amount);
30 | }
31 |
32 |
33 | public async fetchCard(id: string): Promise {
34 | const result: Card | undefined = this.cards.get(id);
35 | if (result === undefined) {
36 | throw ApiError.noCardFound();
37 | }
38 | return result;
39 | }
40 |
41 |
42 | public addCard(card: Card): void {
43 | this.cards.set(card.id, card);
44 | }
45 |
46 | }
47 |
--------------------------------------------------------------------------------
/backend/src/handlers/deposit.test.ts:
--------------------------------------------------------------------------------
1 | import { MockHttpResponse } from "../dev/testing/MockHttpResponse";
2 | import { ApiDepositRequest, ApiSaveMemberRequest } from "../shared/api";
3 | import { DatabaseClient } from "../db/client";
4 | import { InMemoryDatabaseClient } from "../dev/testing/InMemoryDatabaseClient";
5 | import { setDatabaseClient, setTapCashClient } from "../helpers/singletons";
6 | import { handleSaveMember } from "./save-member";
7 | import { buildPostRequest } from "../dev/testing/utils";
8 | import { MockTapCashClient } from "../dev/testing/MockTapCashClient";
9 | import { Keypair } from "../helpers/solana";
10 | import { handleDeposit } from "./deposit";
11 |
12 |
13 | describe('deposit handler', () => {
14 | it('deposit - all good - account is credited', async () => {
15 | const mockResponse: MockHttpResponse = new MockHttpResponse();
16 | const dbClient: DatabaseClient = InMemoryDatabaseClient.make();
17 | setDatabaseClient(dbClient);
18 |
19 | const tapClient: MockTapCashClient = MockTapCashClient.make();
20 | setTapCashClient(tapClient);
21 |
22 | // initialize member
23 | const userWallet: Keypair = Keypair.generate();
24 | await handleSaveMember(
25 | buildPostRequest({
26 | emailAddress: "mary.jane@gmail.com",
27 | profilePictureUrl: "https://www.google.com",
28 | name: "Mary Jane",
29 | signerAddressBase58: userWallet.publicKey.toBase58()
30 | }),
31 | new MockHttpResponse()
32 | );
33 |
34 | await handleDeposit(
35 | buildPostRequest({
36 | emailAddress: "mary.jane@gmail.com",
37 | amount: 100
38 | }),
39 | mockResponse
40 | )
41 |
42 | expect(mockResponse.mockedNextUse()?.status?.code).toStrictEqual(200);
43 | expect(tapClient.getMemberAccount(userWallet.publicKey)?.balance).toStrictEqual(100);
44 | });
45 | });
46 |
--------------------------------------------------------------------------------
/backend/src/handlers/send.ts:
--------------------------------------------------------------------------------
1 | import * as anchor from "@project-serum/anchor";
2 | import { ApiError, SolanaTxType } from "../shared/error";
3 | import { EmailAddress } from "../shared/member";
4 | import { ApiSendRequest, ApiSendResult } from "../shared/api";
5 | import { getRequiredParam, getPrivateKeyParam, makePostHandler } from "./model";
6 | import { getDatabaseClient, getTapCashClient } from "../helpers/singletons";
7 |
8 | //TODO tests
9 |
10 | interface SendArgs {
11 | senderEmailAddress: EmailAddress;
12 | recipientEmailAddress: EmailAddress;
13 | amount: number;
14 | privateKey: anchor.web3.Keypair;
15 | }
16 |
17 |
18 | interface SendResult {
19 | //TODO something about the result of the send attempt
20 | solanaTransactionId: string
21 | }
22 |
23 |
24 | export const handleSend = makePostHandler(send, transformRequest, transformResult);
25 |
26 | async function send(request: SendArgs): Promise {
27 | const { usdcAddress } = await getDatabaseClient().getMemberPrivateProfile(request.recipientEmailAddress);
28 | const solanaTransactionId = await getTapCashClient().sendTokens({
29 | fromMember: request.privateKey,
30 | destinationAta: usdcAddress,
31 | amount: request.amount
32 | })
33 |
34 | if (!solanaTransactionId) throw ApiError.solanaTxError(SolanaTxType.TRANSFER_TOKEN);
35 |
36 | return {
37 | solanaTransactionId
38 | }
39 | }
40 |
41 |
42 | function transformRequest(body: ApiSendRequest): SendArgs {
43 | return {
44 | senderEmailAddress: getRequiredParam(body, "senderEmailAddress"),
45 | recipientEmailAddress: getRequiredParam(body, "recipientEmailAddress"),
46 | amount: getRequiredParam(body, "amount", Number.parseFloat),
47 | privateKey: getPrivateKeyParam(body, "privateKey")
48 | };
49 | }
50 |
51 |
52 | function transformResult(result: SendResult): ApiSendResult {
53 | // nothing to return
54 | return {};
55 | }
56 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "mo-cash",
3 | "version": "0.0.1",
4 | "private": true,
5 | "scripts": {
6 | "android": "react-native run-android",
7 | "ios": "react-native run-ios",
8 | "lint": "eslint .",
9 | "build-apk-windows": "SET ENVFILE=.env.dev && react-native run-android --mode=release",
10 | "build-apk": "ENVFILE=.env.dev react-native run-android --mode=release",
11 | "start-windows": "SET ENVFILE=.env.dev && react-native start",
12 | "start": "ENVFILE=.env.local react-native start",
13 | "test": "jest"
14 | },
15 | "dependencies": {
16 | "@project-serum/anchor": "^0.26.0",
17 | "@react-navigation/native": "^6.1.2",
18 | "@react-navigation/native-stack": "^6.9.8",
19 | "@toruslabs/react-native-web-browser": "^1.1.0",
20 | "@web3auth/react-native-sdk": "^3.5.0",
21 | "assert": "^2.0.0",
22 | "buffer": "^6.0.3",
23 | "react": "18.2.0",
24 | "react-native": "0.71.0",
25 | "react-native-config": "^1.5.0",
26 | "react-native-gesture-handler": "^2.9.0",
27 | "react-native-get-random-values": "^1.8.0",
28 | "react-native-linear-gradient": "^2.6.2",
29 | "react-native-reanimated": "^2.14.4",
30 | "react-native-safe-area-context": "^4.4.1",
31 | "react-native-screens": "^3.18.2",
32 | "react-native-shimmer-placeholder": "^2.0.9",
33 | "react-native-ui-lib": "^6.29.1"
34 | },
35 | "devDependencies": {
36 | "@babel/core": "^7.12.9",
37 | "@babel/preset-env": "^7.14.0",
38 | "@babel/runtime": "^7.12.5",
39 | "@react-native-community/eslint-config": "^3.0.0",
40 | "@tsconfig/react-native": "^2.0.2",
41 | "@types/jest": "^29.2.1",
42 | "@types/react": "^18.0.24",
43 | "@types/react-test-renderer": "^18.0.0",
44 | "babel-jest": "^29.2.1",
45 | "eslint": "^8.19.0",
46 | "jest": "^29.2.1",
47 | "metro-react-native-babel-preset": "^0.73.8",
48 | "react-native-dotenv": "^3.4.8",
49 | "react-test-renderer": "18.2.0",
50 | "typescript": "4.8.4"
51 | },
52 | "jest": {
53 | "preset": "react-native"
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/android/gradle.properties:
--------------------------------------------------------------------------------
1 | # Project-wide Gradle settings.
2 |
3 | # IDE (e.g. Android Studio) users:
4 | # Gradle settings configured through the IDE *will override*
5 | # any settings specified in this file.
6 |
7 | # For more details on how to configure your build environment visit
8 | # http://www.gradle.org/docs/current/userguide/build_environment.html
9 |
10 | # Specifies the JVM arguments used for the daemon process.
11 | # The setting is particularly useful for tweaking memory settings.
12 | # Default value: -Xmx512m -XX:MaxMetaspaceSize=256m
13 | org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m
14 |
15 | # When configured, Gradle will run in incubating parallel mode.
16 | # This option should only be used with decoupled projects. More details, visit
17 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
18 | # org.gradle.parallel=true
19 |
20 | # AndroidX package structure to make it clearer which packages are bundled with the
21 | # Android operating system, and which are packaged with your app's APK
22 | # https://developer.android.com/topic/libraries/support-library/androidx-rn
23 | android.useAndroidX=true
24 | # Automatically convert third-party libraries to use AndroidX
25 | android.enableJetifier=true
26 |
27 | # Version of flipper SDK to use with React Native
28 | FLIPPER_VERSION=0.125.0
29 |
30 | # Use this property to specify which architecture you want to build.
31 | # You can also override it from the CLI using
32 | # ./gradlew -PreactNativeArchitectures=x86_64
33 | reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64
34 |
35 | # Use this property to enable support to the new architecture.
36 | # This will allow you to use TurboModules and the Fabric render in
37 | # your application. You should enable this flag either if you want
38 | # to write custom TurboModules/Fabric components OR use libraries that
39 | # are providing them.
40 | newArchEnabled=false
41 |
42 | # Use this property to enable or disable the Hermes JS engine.
43 | # If set to false, you will be using JSC instead.
44 | hermesEnabled=true
45 |
--------------------------------------------------------------------------------
/android/app/src/main/res/drawable/rn_edit_text_material.xml:
--------------------------------------------------------------------------------
1 |
2 |
16 |
21 |
22 |
23 |
32 |
33 |
34 |
35 |
36 |
37 |
--------------------------------------------------------------------------------
/backend/src/index.ts:
--------------------------------------------------------------------------------
1 | // Google Cloud Functions Framework main entrypoint
2 |
3 | import * as ff from '@google-cloud/functions-framework';
4 | import { handleSaveMember } from './handlers/save-member';
5 | import { handleDeposit } from './handlers/deposit';
6 | import { handleSend } from './handlers/send';
7 | import { handleWithdraw } from './handlers/withdraw';
8 | import { handleQueryRecipients } from './handlers/query-recipients';
9 | import { handleRecentActivity } from './handlers/recent-activity';
10 | import { handlePaymentMethods } from './handlers/payment-methods';
11 | import { SERVER_ENV } from './constants';
12 | import { ServerEnv } from './types/types';
13 | import { handleAccount } from './handlers/account';
14 |
15 | // local endpoints
16 | ff.http('index', (req: ff.Request, res: ff.Response) => {
17 | if (SERVER_ENV !== ServerEnv.LOCAL) {
18 | throw new Error(
19 | "Only permitted in local development environment. " +
20 | "Individual functions must be deployed to Google Cloud."
21 | );
22 | }
23 | if (req.path.startsWith("/query-recipients")) handleQueryRecipients(req, res);
24 | else if (req.path.startsWith("/deposit")) handleDeposit(req, res);
25 | else if (req.path.startsWith("/save-member")) handleSaveMember(req, res);
26 | else if (req.path.startsWith("/send")) handleSend(req, res);
27 | else if (req.path.startsWith("/withdraw")) handleWithdraw(req, res);
28 | else if (req.path.startsWith("/payment-methods")) handlePaymentMethods(req, res);
29 | else if (req.path.startsWith("/recent-activity")) handleRecentActivity(req, res);
30 | else if (req.path.startsWith("/account")) handleAccount(req, res);
31 | else res.sendStatus(400);
32 | });
33 |
34 |
35 | // deployed endpoints
36 | ff.http('deposit', handleDeposit);
37 | ff.http('save-member', handleSaveMember);
38 | ff.http('send', handleSend);
39 | ff.http('withdraw', handleWithdraw);
40 | ff.http('payment-methods', handlePaymentMethods);
41 | ff.http('query-recipients', handleQueryRecipients);
42 | ff.http('recent-activity', handleRecentActivity);
43 | ff.http('account', handleAccount);
44 |
--------------------------------------------------------------------------------
/App.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { NavigationContainer } from '@react-navigation/native';
3 | import { HomeScreen } from './src/modules/HomeScreen';
4 | import { createNativeStackNavigator } from '@react-navigation/native-stack';
5 | import { AuthenticateScreen } from './src/modules/AuthenticateScreen';
6 | import { TopNavScreen, TopRouteParams } from './src/common/navigation';
7 | import { SplashScreen } from './src/modules/SplashScreen';
8 | import { UserProfileProvider } from './src/components/profile-provider';
9 | import { SendStack } from './src/modules/send/SendStack';
10 | import { ProfileStack } from './src/modules/profile/ProfileStack';
11 | import { StatusBar } from 'react-native';
12 | // for using solana utils in the app
13 | // see https://github.com/uuidjs/uuid#getrandomvalues-not-supported
14 | import 'react-native-get-random-values';
15 |
16 | const Stack = createNativeStackNavigator();
17 |
18 | export default function App(): JSX.Element {
19 | return (
20 | <>
21 |
22 |
23 |
24 |
28 |
32 |
36 |
41 |
45 |
49 |
50 |
51 |
52 | >
53 | );
54 | }
--------------------------------------------------------------------------------
/backend/src/helpers/sampleData.ts:
--------------------------------------------------------------------------------
1 | import { BusinessRecipientAddressCreationRequest, CardCreationRequest, PaymentCreationRequest, TransferCreationRequest } from "@circle-fin/circle-sdk";
2 |
3 | /**
4 | * This is a sample card creation request. It is used to make mock requests to the Circle SDK
5 | */
6 | export const samplePayment:PaymentCreationRequest = {
7 | idempotencyKey: 'ba943ff1-ca16-49b2-ba55-1057e70ca5c7',
8 | keyId: 'test',
9 | metadata: {
10 | email: 'satoshi@circle.com',
11 | phoneNumber: '+14155555555',
12 | sessionId: 'DE6FA86F60BB47B379307F851E238617',
13 | ipAddress: '244.28.239.130'
14 | },
15 | amount: {currency: 'USD', amount: '1.00'},
16 | verification: "none",
17 | // verificationSuccessUrl
18 | // verificationFailureUrl
19 | source: {/*?? id, type - both optional */ },
20 | description: 'sample deposit into tap',
21 | // encryptedData
22 | // channel
23 | }
24 |
25 | export const sampleTransfer: TransferCreationRequest = {
26 | idempotencyKey: 'ba943ff1-ca16-49b2-ba55-1057e70ca5c7',
27 | source: {type: 'wallet', id: '1011922176'},
28 | destination: {type: 'wallet', id: '1011922762'},
29 | amount: {amount: '5.00', currency: 'USD'},
30 | }
31 |
32 | export const sampleCard: CardCreationRequest = {
33 | idempotencyKey: 'ba943ff1-ca16-49b2-ba55-1057e70ca5c7',
34 | encryptedData: '45560123789401460146565',
35 | billingDetails:{
36 | name: 'satoshi',
37 | city: 'portland',
38 | country: 'usa',
39 | line1: '123 anywhere street',
40 | postalCode: '97215'
41 | },
42 | expMonth: 12,
43 | expYear: 2025,
44 | metadata: {
45 | email: 'satoshi@circle.com',
46 | phoneNumber: '+14155555555',
47 | sessionId: 'DE6FA86F60BB47B379307F851E238617',
48 | ipAddress: '244.28.239.130'
49 | }
50 | }
51 |
52 | const sampleAddressInput: BusinessRecipientAddressCreationRequest = {
53 | idempotencyKey: 'ba943ff1-ca16-49b2-ba55-1057e70ca5c7',
54 | address: 'Cxcfw2GC1tfEPEuNABNwTujwr6nEtsV6Enzjxz2pDqoE',
55 | chain: 'SOL',
56 | currency: 'USD',
57 | description: 'user x PDA'
58 | }
--------------------------------------------------------------------------------
/android/app/src/main/java/com/tapcash/MainApplication.java:
--------------------------------------------------------------------------------
1 | package com.tapcash;
2 |
3 | import android.app.Application;
4 | import com.facebook.react.PackageList;
5 | import com.facebook.react.ReactApplication;
6 | import com.facebook.react.ReactNativeHost;
7 | import com.facebook.react.ReactPackage;
8 | import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint;
9 | import com.facebook.react.defaults.DefaultReactNativeHost;
10 | import com.facebook.soloader.SoLoader;
11 | import java.util.List;
12 |
13 | public class MainApplication extends Application implements ReactApplication {
14 |
15 | private final ReactNativeHost mReactNativeHost =
16 | new DefaultReactNativeHost(this) {
17 | @Override
18 | public boolean getUseDeveloperSupport() {
19 | return BuildConfig.DEBUG;
20 | }
21 |
22 | @Override
23 | protected List getPackages() {
24 | @SuppressWarnings("UnnecessaryLocalVariable")
25 | List packages = new PackageList(this).getPackages();
26 | // Packages that cannot be autolinked yet can be added manually here, for example:
27 | // packages.add(new MyReactNativePackage());
28 | return packages;
29 | }
30 |
31 | @Override
32 | protected String getJSMainModuleName() {
33 | return "index";
34 | }
35 |
36 | @Override
37 | protected boolean isNewArchEnabled() {
38 | return BuildConfig.IS_NEW_ARCHITECTURE_ENABLED;
39 | }
40 |
41 | @Override
42 | protected Boolean isHermesEnabled() {
43 | return BuildConfig.IS_HERMES_ENABLED;
44 | }
45 | };
46 |
47 | @Override
48 | public ReactNativeHost getReactNativeHost() {
49 | return mReactNativeHost;
50 | }
51 |
52 | @Override
53 | public void onCreate() {
54 | super.onCreate();
55 | SoLoader.init(this, /* native exopackage */ false);
56 | if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
57 | // If you opted-in for the New Architecture, we load the native entry point for this app.
58 | DefaultNewArchitectureEntryPoint.load();
59 | }
60 | ReactNativeFlipper.initializeFlipper(this, getReactNativeHost().getReactInstanceManager());
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/ios/TapCashTests/TapCashTests.m:
--------------------------------------------------------------------------------
1 | #import
2 | #import
3 |
4 | #import
5 | #import
6 |
7 | #define TIMEOUT_SECONDS 600
8 | #define TEXT_TO_LOOK_FOR @"Welcome to React"
9 |
10 | @interface TapCashTests : XCTestCase
11 |
12 | @end
13 |
14 | @implementation TapCashTests
15 |
16 | - (BOOL)findSubviewInView:(UIView *)view matching:(BOOL (^)(UIView *view))test
17 | {
18 | if (test(view)) {
19 | return YES;
20 | }
21 | for (UIView *subview in [view subviews]) {
22 | if ([self findSubviewInView:subview matching:test]) {
23 | return YES;
24 | }
25 | }
26 | return NO;
27 | }
28 |
29 | - (void)testRendersWelcomeScreen
30 | {
31 | UIViewController *vc = [[[RCTSharedApplication() delegate] window] rootViewController];
32 | NSDate *date = [NSDate dateWithTimeIntervalSinceNow:TIMEOUT_SECONDS];
33 | BOOL foundElement = NO;
34 |
35 | __block NSString *redboxError = nil;
36 | #ifdef DEBUG
37 | RCTSetLogFunction(
38 | ^(RCTLogLevel level, RCTLogSource source, NSString *fileName, NSNumber *lineNumber, NSString *message) {
39 | if (level >= RCTLogLevelError) {
40 | redboxError = message;
41 | }
42 | });
43 | #endif
44 |
45 | while ([date timeIntervalSinceNow] > 0 && !foundElement && !redboxError) {
46 | [[NSRunLoop mainRunLoop] runMode:NSDefaultRunLoopMode beforeDate:[NSDate dateWithTimeIntervalSinceNow:0.1]];
47 | [[NSRunLoop mainRunLoop] runMode:NSRunLoopCommonModes beforeDate:[NSDate dateWithTimeIntervalSinceNow:0.1]];
48 |
49 | foundElement = [self findSubviewInView:vc.view
50 | matching:^BOOL(UIView *view) {
51 | if ([view.accessibilityLabel isEqualToString:TEXT_TO_LOOK_FOR]) {
52 | return YES;
53 | }
54 | return NO;
55 | }];
56 | }
57 |
58 | #ifdef DEBUG
59 | RCTSetLogFunction(RCTDefaultLogFunction);
60 | #endif
61 |
62 | XCTAssertNil(redboxError, @"RedBox error: %@", redboxError);
63 | XCTAssertTrue(foundElement, @"Couldn't find element with text '%@' in %d seconds", TEXT_TO_LOOK_FOR, TIMEOUT_SECONDS);
64 | }
65 |
66 | @end
67 |
--------------------------------------------------------------------------------
/src/solana/solana.ts:
--------------------------------------------------------------------------------
1 | import {
2 | Connection,
3 | Keypair,
4 | PublicKey,
5 | } from "@solana/web3.js";
6 | import * as anchor from "@project-serum/anchor";
7 |
8 | export { Connection, PublicKey, Keypair };
9 |
10 | /**
11 | * SolanaWallet is a wrapper around the Solana Keypair and Connection classes.
12 | */
13 | export class SolanaWallet {
14 | private readonly keypair: Keypair;
15 | private readonly connection: Connection;
16 |
17 | /**
18 | * Constructor for SolanaWallet
19 | * @param keypair Keypair of the wallet
20 | * @param connection Connection to the Solana network
21 | */
22 | private constructor(keypair: Keypair, connection: Connection) {
23 | this.keypair = keypair;
24 | this.connection = connection;
25 | }
26 |
27 |
28 | public static of(ed25519PrivKey: string, solanRpcUrl: string): SolanaWallet {
29 | const keypair: Keypair = Keypair.fromSecretKey(Buffer.from(ed25519PrivKey, 'hex'));
30 | const connection: Connection = new Connection(solanRpcUrl);
31 | return new SolanaWallet(keypair, connection);
32 | }
33 |
34 | /**
35 | *
36 | * @returns Publickey of the wallet
37 | */
38 | public getPublicKey(): PublicKey {
39 | return this.keypair.publicKey;
40 | }
41 |
42 | /**
43 | *
44 | * @returns Balance of the wallet in lamports
45 | */
46 | public async getSolBalance(): Promise {
47 | return await this.connection.getBalance(this.keypair.publicKey);
48 | }
49 |
50 | /**
51 | *
52 | * @param ataAddress ATA address of the USDC account
53 | * @returns balance of the USDC account in USDC
54 | */
55 | public async getUsdcBalance(ataAddress: PublicKey): Promise {
56 | const result: anchor.web3.RpcResponseAndContext = (
57 | await this.connection.getTokenAccountBalance(ataAddress, "confirmed")
58 | );
59 | if (result.value.uiAmount == null) {
60 | console.warn("User may not have a USDC account.");
61 | }
62 | return result.value.uiAmount;
63 | }
64 |
65 | /**
66 | *
67 | * @returns Keypair of the wallet
68 | */
69 | public getKeypair(): Keypair {
70 | return this.keypair;
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/program/tap_cash/programs/tap_cash/src/instructions/member.rs:
--------------------------------------------------------------------------------
1 | use anchor_lang::prelude::*;
2 |
3 | use crate::{
4 | constants::{MEMBER_SEED, MEMBER_VERSION},
5 | state::{Member, Bank},
6 | };
7 |
8 | /// Accounts and constraints for initializing a new member account.
9 | #[derive(Accounts)]
10 | pub struct InitializeMember<'info> {
11 |
12 | /// Fee payer is the Bank's defined payer (not the user)
13 | #[account(
14 | mut,
15 | constraint = payer.to_account_info().key() == bank.fee_payer.key()
16 | )]
17 | pub payer: Signer<'info>,
18 |
19 | /// Member PDA - 1 member per person per bank
20 | #[account(
21 | init,
22 | payer = payer,
23 | space = Member::get_space(),
24 | seeds = [
25 | MEMBER_SEED.as_ref(),
26 | bank.to_account_info().key().as_ref(),
27 | user_id.key().as_ref()
28 | ],
29 | bump
30 | )]
31 | pub member_pda: Account<'info, Member>,
32 |
33 | /// User ID is the Public Key the user recieved when enrolling via Web3 auth (local device wallet for signing)
34 | pub user_id: SystemAccount<'info>,
35 |
36 | /// The bank the member is enrolling to (for now, just 1 Bank)
37 | #[account(owner = crate::ID)]
38 | pub bank: Account<'info, Bank>,
39 |
40 | /// Standard system program, for creating accounts
41 | pub system_program: Program<'info, System>,
42 |
43 | /// Standard rent sysvar, for determining rent for created accounts
44 | pub rent: Sysvar<'info, Rent>
45 | }
46 |
47 | /// Initializes a new member of a Bank.
48 | ///
49 | /// This function creates a new member state account and sets its initial state to include the member version, bank key, user ID, bump value, and number of accounts.
50 | /// This function should only be called once per member.
51 | pub fn initialize_member(
52 | ctx: Context
53 | ) -> Result<()> {
54 | let new_member = &mut ctx.accounts.member_pda;
55 |
56 | // Create a new member state account
57 | new_member.set_inner(Member {
58 | version: MEMBER_VERSION,
59 | bank: ctx.accounts.bank.key(),
60 | user_id: ctx.accounts.user_id.key(),
61 | bump: *ctx.bumps.get("member_pda").unwrap(),
62 | num_accounts: 0
63 | });
64 | new_member.log_init();
65 | Ok(())
66 | }
--------------------------------------------------------------------------------
/src/common/constants.ts:
--------------------------------------------------------------------------------
1 | import Config from "react-native-config";
2 | import { PublicKey } from "../solana/solana";
3 | import { OPENLOGIN_NETWORK } from "@web3auth/react-native-sdk";
4 |
5 | // use http://10.0.2.2:8080/ when testing/deploying with android emulator locally
6 |
7 | export const SAVE_MEMBER_URI: string = parseEnv("SAVE_MEMBER_URI", Config.SAVE_MEMBER_URI);
8 | export const DEPOSIT_URI: string = parseEnv("DEPOSIT_URI", Config.DEPOSIT_URI);
9 | export const SEND_URI: string = parseEnv("SEND_URI", Config.SEND_URI);
10 | export const WITHDRAW_URI: string = parseEnv("WITHDRAW_URI", Config.WITHDRAW_URI);
11 | export const QUERY_RECIPIENTS_URI: string = parseEnv("QUERY_RECIPIENTS_URI", Config.QUERY_RECIPIENTS_URI);
12 | export const MEMBER_ACCOUNT_URI: string = parseEnv("MEMBER_ACCOUNT_URI", Config.MEMBER_ACCOUNT_URI);
13 | export const RECENT_ACTIVITY_URI: string = parseEnv("RECENT_ACTIVITY_URI", Config.RECENT_ACTIVITY_URI);
14 | export const SAVED_PAYMENT_METHODS_URI: string = parseEnv("SAVED_PAYMENT_METHODS_URI", Config.SAVED_PAYMENT_METHODS_URI);
15 | export const WEB3_AUTH_NETWORK: string = parseEnv(
16 | "WEB3_AUTH_NETWORK",
17 | Config.WEB3_AUTH_NETWORK,
18 | undefined,
19 | v => {
20 | if (v === "mainnet") return OPENLOGIN_NETWORK.MAINNET;
21 | else if (v === "testnet") return OPENLOGIN_NETWORK.TESTNET;
22 | else throw new Error("Invalid network " + v);
23 | }
24 | )
25 | export const WEB3_AUTH_CLIENT_ID: string = parseEnv(
26 | "WEB3_AUTH_CLIENT_ID",
27 | Config.WEB3_AUTH_CLIENT_ID
28 | );
29 | export const SOLANA_RPC_URL: string = parseEnv(
30 | "SOLANA_RPC_URL",
31 | Config.SOLANA_RPC_URL
32 | );
33 |
34 | function parseEnv(
35 | name: string,
36 | value: string | undefined,
37 | defaultValue?: T | undefined,
38 | transform: (v: string) => T = castString
39 | ): T {
40 | let result: T;
41 | if (value === undefined) {
42 | if (defaultValue === undefined) {
43 | throw new Error(`Missing required env variable ${name}.`);
44 |
45 | } else {
46 | result = defaultValue;
47 | }
48 | } else {
49 | result = transform(value);
50 | }
51 | return result;
52 | }
53 |
54 |
55 | function castString(value: string): T {
56 | return value as unknown as T;
57 | }
58 |
59 | export const MAX_TX_AMOUNT = 9999;
60 |
--------------------------------------------------------------------------------
/backend/src/handlers/account.test.ts:
--------------------------------------------------------------------------------
1 | import { MockHttpResponse } from "../dev/testing/MockHttpResponse";
2 | import { ApiAccountRequest, ApiAccountResult, ApiSaveMemberRequest } from "../shared/api";
3 | import { DatabaseClient } from "../db/client";
4 | import { InMemoryDatabaseClient } from "../dev/testing/InMemoryDatabaseClient";
5 | import { setDatabaseClient, setTapCashClient } from "../helpers/singletons";
6 | import { handleSaveMember } from "./save-member";
7 | import { buildGetRequest, buildPostRequest } from "../dev/testing/utils";
8 | import { MockTapCashClient } from "../dev/testing/MockTapCashClient";
9 | import { Keypair } from "../helpers/solana";
10 | import { handleAccount } from "./account";
11 |
12 |
13 | describe('account handler', () => {
14 | it('account - all good - member account is returned', async () => {
15 | const mockResponse: MockHttpResponse = new MockHttpResponse();
16 | const dbClient: DatabaseClient = InMemoryDatabaseClient.make();
17 | setDatabaseClient(dbClient);
18 |
19 | const tapClient: MockTapCashClient = MockTapCashClient.make();
20 | setTapCashClient(tapClient);
21 |
22 | // initialize member
23 | const userWallet: Keypair = Keypair.generate();
24 | const profile = {
25 | emailAddress: "mary.jane@gmail.com",
26 | profilePictureUrl: "https://www.google.com",
27 | name: "Mary Jane",
28 | signerAddressBase58: userWallet.publicKey.toBase58()
29 | }
30 | await handleSaveMember(
31 | buildPostRequest(profile),
32 | new MockHttpResponse()
33 | );
34 |
35 | await handleAccount(
36 | buildGetRequest({
37 | memberEmail: "mary.jane@gmail.com"
38 | }),
39 | mockResponse
40 | )
41 |
42 | expect(mockResponse.mockedNextUse()?.status?.code).toStrictEqual(200);
43 | const result: ApiAccountResult = mockResponse.mockedLastUse()?.json?.body.result;
44 | expect(result.email).toStrictEqual(profile.emailAddress);
45 | expect(result.name).toStrictEqual(profile.name);
46 | expect(result.profile).toStrictEqual(profile.profilePictureUrl);
47 | expect(result.signerAddress).toStrictEqual(profile.signerAddressBase58);
48 | expect(result.usdcAddress).not.toBeNull();
49 | });
50 | });
51 |
--------------------------------------------------------------------------------
/android/link-assets-manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "migIndex": 1,
3 | "data": [
4 | {
5 | "path": "assets/fonts/Jost-Black.ttf",
6 | "sha1": "89d1e3d1c18465f17ff6c1f3c9e97661543c4177"
7 | },
8 | {
9 | "path": "assets/fonts/Jost-BlackItalic.ttf",
10 | "sha1": "801dd03d7dd8b42321aa6f92e122a3022ec2ce2f"
11 | },
12 | {
13 | "path": "assets/fonts/Jost-Bold.ttf",
14 | "sha1": "15c306309ab5e093c973fab0ba1ef88a7012cc2d"
15 | },
16 | {
17 | "path": "assets/fonts/Jost-BoldItalic.ttf",
18 | "sha1": "3b22423c1fbd857f1cf564db276c49611460e6c8"
19 | },
20 | {
21 | "path": "assets/fonts/Jost-ExtraBold.ttf",
22 | "sha1": "87f508a395ce233b60598404f9cc3a744453ed09"
23 | },
24 | {
25 | "path": "assets/fonts/Jost-ExtraBoldItalic.ttf",
26 | "sha1": "5b7489c4ed75a1a5ff6c3e19484b19b2041c6159"
27 | },
28 | {
29 | "path": "assets/fonts/Jost-ExtraLight.ttf",
30 | "sha1": "54be3cc77c6549782292202b0027781060fe24ee"
31 | },
32 | {
33 | "path": "assets/fonts/Jost-ExtraLightItalic.ttf",
34 | "sha1": "ecd0983252f93e9a9db2ea911af854c5c1841af1"
35 | },
36 | {
37 | "path": "assets/fonts/Jost-Italic.ttf",
38 | "sha1": "cca49a78a93aba6b1afc4cb35ce33662b49caa26"
39 | },
40 | {
41 | "path": "assets/fonts/Jost-Light.ttf",
42 | "sha1": "0a74ba8a7ed541d221586110aab21dfd5a3abd94"
43 | },
44 | {
45 | "path": "assets/fonts/Jost-LightItalic.ttf",
46 | "sha1": "b5ccd05c1cda7d190ddb3db10eaa275147410e59"
47 | },
48 | {
49 | "path": "assets/fonts/Jost-Medium.ttf",
50 | "sha1": "7e9d55841807a5eeb3ba6a1dfb0607b8a72742c7"
51 | },
52 | {
53 | "path": "assets/fonts/Jost-MediumItalic.ttf",
54 | "sha1": "26b46a785f4f6a373368472782520fd2619e6df6"
55 | },
56 | {
57 | "path": "assets/fonts/Jost-Regular.ttf",
58 | "sha1": "b22e9ed8eb939a7d7f4c4044f94e981ff57818e6"
59 | },
60 | {
61 | "path": "assets/fonts/Jost-SemiBold.ttf",
62 | "sha1": "04165a9fe78bef66820e3c57dd19cb9b40bcbd88"
63 | },
64 | {
65 | "path": "assets/fonts/Jost-SemiBoldItalic.ttf",
66 | "sha1": "89d14ac0abccb4dd68ef6bc0ef1bc8fa2634ff28"
67 | },
68 | {
69 | "path": "assets/fonts/Jost-Thin.ttf",
70 | "sha1": "906c920f0fd99d4032bffa25fab8f261471c47a5"
71 | },
72 | {
73 | "path": "assets/fonts/Jost-ThinItalic.ttf",
74 | "sha1": "d359d267189c12a0fb24baf1abb4d6c6f25580d6"
75 | }
76 | ]
77 | }
78 |
--------------------------------------------------------------------------------
/ios/link-assets-manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "migIndex": 1,
3 | "data": [
4 | {
5 | "path": "assets/fonts/Jost-Black.ttf",
6 | "sha1": "89d1e3d1c18465f17ff6c1f3c9e97661543c4177"
7 | },
8 | {
9 | "path": "assets/fonts/Jost-BlackItalic.ttf",
10 | "sha1": "801dd03d7dd8b42321aa6f92e122a3022ec2ce2f"
11 | },
12 | {
13 | "path": "assets/fonts/Jost-Bold.ttf",
14 | "sha1": "15c306309ab5e093c973fab0ba1ef88a7012cc2d"
15 | },
16 | {
17 | "path": "assets/fonts/Jost-BoldItalic.ttf",
18 | "sha1": "3b22423c1fbd857f1cf564db276c49611460e6c8"
19 | },
20 | {
21 | "path": "assets/fonts/Jost-ExtraBold.ttf",
22 | "sha1": "87f508a395ce233b60598404f9cc3a744453ed09"
23 | },
24 | {
25 | "path": "assets/fonts/Jost-ExtraBoldItalic.ttf",
26 | "sha1": "5b7489c4ed75a1a5ff6c3e19484b19b2041c6159"
27 | },
28 | {
29 | "path": "assets/fonts/Jost-ExtraLight.ttf",
30 | "sha1": "54be3cc77c6549782292202b0027781060fe24ee"
31 | },
32 | {
33 | "path": "assets/fonts/Jost-ExtraLightItalic.ttf",
34 | "sha1": "ecd0983252f93e9a9db2ea911af854c5c1841af1"
35 | },
36 | {
37 | "path": "assets/fonts/Jost-Italic.ttf",
38 | "sha1": "cca49a78a93aba6b1afc4cb35ce33662b49caa26"
39 | },
40 | {
41 | "path": "assets/fonts/Jost-Light.ttf",
42 | "sha1": "0a74ba8a7ed541d221586110aab21dfd5a3abd94"
43 | },
44 | {
45 | "path": "assets/fonts/Jost-LightItalic.ttf",
46 | "sha1": "b5ccd05c1cda7d190ddb3db10eaa275147410e59"
47 | },
48 | {
49 | "path": "assets/fonts/Jost-Medium.ttf",
50 | "sha1": "7e9d55841807a5eeb3ba6a1dfb0607b8a72742c7"
51 | },
52 | {
53 | "path": "assets/fonts/Jost-MediumItalic.ttf",
54 | "sha1": "26b46a785f4f6a373368472782520fd2619e6df6"
55 | },
56 | {
57 | "path": "assets/fonts/Jost-Regular.ttf",
58 | "sha1": "b22e9ed8eb939a7d7f4c4044f94e981ff57818e6"
59 | },
60 | {
61 | "path": "assets/fonts/Jost-SemiBold.ttf",
62 | "sha1": "04165a9fe78bef66820e3c57dd19cb9b40bcbd88"
63 | },
64 | {
65 | "path": "assets/fonts/Jost-SemiBoldItalic.ttf",
66 | "sha1": "89d14ac0abccb4dd68ef6bc0ef1bc8fa2634ff28"
67 | },
68 | {
69 | "path": "assets/fonts/Jost-Thin.ttf",
70 | "sha1": "906c920f0fd99d4032bffa25fab8f261471c47a5"
71 | },
72 | {
73 | "path": "assets/fonts/Jost-ThinItalic.ttf",
74 | "sha1": "d359d267189c12a0fb24baf1abb4d6c6f25580d6"
75 | }
76 | ]
77 | }
78 |
--------------------------------------------------------------------------------
/src/common/navigation.ts:
--------------------------------------------------------------------------------
1 | import { NativeStackNavigationOptions } from "@react-navigation/native-stack";
2 | import { MemberPublicProfile } from "../shared/member";
3 | import { COLORS } from "./styles";
4 |
5 | export enum TopNavScreen {
6 | SPLASH = "Splash",
7 | AUTHENTICATE = "Authenticate",
8 | HOME = "Home",
9 | PROFILE = "Profile",
10 | SEND = "Send"
11 | }
12 |
13 |
14 | export type TopRouteParams = {
15 | [TopNavScreen.HOME]: undefined;
16 | [TopNavScreen.AUTHENTICATE]: undefined;
17 | [TopNavScreen.PROFILE]: { screen?: ProfileNavScreen } | undefined;
18 | [TopNavScreen.SEND]: undefined;
19 | [TopNavScreen.SPLASH]: undefined;
20 | }
21 |
22 |
23 | export enum SendNavScreen {
24 | RECIPIENT_INPUT = "RecipientInput",
25 | AMOUNT_INPUT = "AmountInput",
26 | CONFIRM = "Confirm",
27 | SENDING = "Sending"
28 | }
29 |
30 |
31 | export type SendStackRouteParams = {
32 | [SendNavScreen.RECIPIENT_INPUT]: undefined;
33 | [SendNavScreen.AMOUNT_INPUT]: { recipient: MemberPublicProfile };
34 | [SendNavScreen.CONFIRM]: { recipient: MemberPublicProfile, amount: number, depositAmount: number };
35 | [SendNavScreen.SENDING]: { recipient: MemberPublicProfile, amount: number, depositAmount: number };
36 | }
37 |
38 |
39 | export enum ProfileNavScreen {
40 | OVERVIEW = "Overview",
41 | PAYMENT_METHODS = "PaymentMethods",
42 | CONNECTED_ACCOUNTS = "ConnectedAccounts",
43 | ADD_FUNDS = "AddFunds",
44 | WITHDRAW = "Withdraw",
45 | LOG_OUT = "LogOut"
46 | }
47 |
48 |
49 | export type ProfileStackRouteParams = {
50 | [ProfileNavScreen.OVERVIEW]: undefined;
51 | [ProfileNavScreen.PAYMENT_METHODS]: undefined;
52 | [ProfileNavScreen.CONNECTED_ACCOUNTS]: undefined;
53 | [ProfileNavScreen.ADD_FUNDS]: undefined;
54 | [ProfileNavScreen.WITHDRAW]: undefined;
55 | [ProfileNavScreen.LOG_OUT]: undefined;
56 | }
57 |
58 |
59 | export enum DepositNavScreen {
60 | AMOUNT_INPUT = "AmountInput",
61 | DEPOSITING = "Depositing"
62 | }
63 |
64 |
65 | export type DepositStackRouteParams = {
66 | [DepositNavScreen.AMOUNT_INPUT]: undefined;
67 | [DepositNavScreen.DEPOSITING]: { amount: number };
68 | }
69 |
70 |
71 | export const STACK_DEFAULTS: NativeStackNavigationOptions = {
72 | headerStyle: {
73 | backgroundColor: COLORS.whiteish,
74 | },
75 | headerTitleStyle: {
76 | fontFamily: "Jost-Medium",
77 | fontSize: 20
78 | },
79 | headerShadowVisible: false,
80 | headerTitleAlign: "center"
81 | }
82 |
--------------------------------------------------------------------------------
/backend/src/helpers/solana.ts:
--------------------------------------------------------------------------------
1 | import * as anchor from "@project-serum/anchor";
2 | import { createMint } from "@solana/spl-token";
3 | import { FAKE_USDC } from "../constants";
4 | import { WorkSpace } from "../program/workspace";
5 |
6 | export class PublicKey extends anchor.web3.PublicKey { };
7 | export class Keypair extends anchor.web3.Keypair { };
8 | export class Connection extends anchor.web3.Connection { };
9 |
10 |
11 | /**
12 | *
13 | * Gets USDC Public Key (or creates USDC Mint if one does not exist)
14 | * (for use in local or devnet environments)
15 | *
16 | * @param connection - Solana Connection
17 | * @param auth - Keypair of the Mint Authority of USDC
18 | * @returns USDC Public Key or undefined if USDC Mint could not be created or found
19 | */
20 | export async function getOrCreateUsdc(connection: Connection, auth: Keypair): Promise {
21 | const usdc = FAKE_USDC;
22 |
23 | // If we already have a USDC account, use it (otherwise make it -- e.g., LocalHost session)
24 | const accountInfo = await connection.getAccountInfo(usdc.publicKey);
25 | if (accountInfo) return usdc.publicKey;
26 |
27 | try {
28 | let mintKey = await createMint(
29 | connection,
30 | auth,
31 | auth.publicKey,
32 | null,
33 | 6,
34 | usdc
35 | );
36 | return mintKey;
37 | }
38 | catch {
39 | return;
40 | }
41 | }
42 |
43 |
44 |
45 |
46 |
47 | /**
48 | *
49 | * Airdrops 2 SOL if Balance is below 1 SOL
50 | *
51 | * @param workspace - Workspace to use for the emulator
52 | * @param lamports - Amount of lamports to airdrop (default: 2 SOL)
53 | * @returns void
54 | */
55 | export async function airdropIfNeeded(workspace: WorkSpace, lamports = (anchor.web3.LAMPORTS_PER_SOL * 2)): Promise {
56 | const { connection, program, provider } = workspace;
57 | try {
58 | let balance = await connection.getBalance(provider.wallet.publicKey);
59 | if (balance < 1) throw new Error ("Balance is below 1 SOL");
60 | }
61 | catch {
62 | const { blockhash, lastValidBlockHeight } = await connection.getLatestBlockhash('finalized');
63 | const airdrop = await connection.requestAirdrop(provider.wallet.publicKey, lamports);
64 | await connection.confirmTransaction({
65 | signature: airdrop,
66 | blockhash: blockhash,
67 | lastValidBlockHeight: lastValidBlockHeight
68 | });
69 |
70 | return;
71 | }
72 |
73 |
74 | }
75 |
--------------------------------------------------------------------------------
/ios/TapCash/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleDevelopmentRegion
6 | en
7 | CFBundleDisplayName
8 | Tap
9 | CFBundleExecutable
10 | $(EXECUTABLE_NAME)
11 | CFBundleIdentifier
12 | $(PRODUCT_BUNDLE_IDENTIFIER)
13 | CFBundleInfoDictionaryVersion
14 | 6.0
15 | CFBundleName
16 | $(PRODUCT_NAME)
17 | CFBundlePackageType
18 | APPL
19 | CFBundleShortVersionString
20 | $(MARKETING_VERSION)
21 | CFBundleSignature
22 | ????
23 | CFBundleVersion
24 | $(CURRENT_PROJECT_VERSION)
25 | LSRequiresIPhoneOS
26 |
27 | NSAppTransportSecurity
28 |
29 | NSExceptionDomains
30 |
31 | localhost
32 |
33 | NSExceptionAllowsInsecureHTTPLoads
34 |
35 |
36 |
37 |
38 | NSLocationWhenInUseUsageDescription
39 |
40 | UILaunchStoryboardName
41 | LaunchScreen
42 | UIRequiredDeviceCapabilities
43 |
44 | armv7
45 |
46 | UISupportedInterfaceOrientations
47 |
48 | UIInterfaceOrientationPortrait
49 | UIInterfaceOrientationLandscapeLeft
50 | UIInterfaceOrientationLandscapeRight
51 |
52 | UIViewControllerBasedStatusBarAppearance
53 |
54 | UIAppFonts
55 |
56 | Jost-Black.ttf
57 | Jost-BlackItalic.ttf
58 | Jost-Bold.ttf
59 | Jost-BoldItalic.ttf
60 | Jost-ExtraBold.ttf
61 | Jost-ExtraBoldItalic.ttf
62 | Jost-ExtraLight.ttf
63 | Jost-ExtraLightItalic.ttf
64 | Jost-Italic.ttf
65 | Jost-Light.ttf
66 | Jost-LightItalic.ttf
67 | Jost-Medium.ttf
68 | Jost-MediumItalic.ttf
69 | Jost-Regular.ttf
70 | Jost-SemiBold.ttf
71 | Jost-SemiBoldItalic.ttf
72 | Jost-Thin.ttf
73 | Jost-ThinItalic.ttf
74 |
75 |
76 |
77 |
--------------------------------------------------------------------------------
/backend/src/handlers/query-recipients.test.ts:
--------------------------------------------------------------------------------
1 | import { handleQueryRecipients } from "./query-recipients";
2 | import { MockHttpResponse } from "../dev/testing/MockHttpResponse";
3 | import { ApiQueryRecipientsRequest, ApiQueryRecipientsResult } from "../shared/api";
4 | import { DatabaseClient } from "../db/client";
5 | import { InMemoryDatabaseClient } from "../dev/testing/InMemoryDatabaseClient";
6 | import { setDatabaseClient } from "../helpers/singletons";
7 | import { Keypair } from "../helpers/solana";
8 | import { buildGetRequest } from "../dev/testing/utils";
9 |
10 |
11 | describe('query-recipients handler', () => {
12 | it('query-recipients - no match - returns empty result', async () => {
13 | const mockResponse: MockHttpResponse = new MockHttpResponse();
14 | const dbClient: DatabaseClient = InMemoryDatabaseClient.make();
15 | setDatabaseClient(dbClient);
16 | await handleQueryRecipients(
17 | buildGetRequest({
18 | emailQuery: "ma",
19 | limit: "10"
20 | }),
21 | mockResponse
22 | );
23 |
24 | expect(mockResponse.mockedNextUse()?.status?.code).toStrictEqual(200);
25 | expect(mockResponse.mockedNextUse()?.json?.body.result).toStrictEqual([]);
26 | });
27 |
28 |
29 | it('query-recipients - single match - returns result', async () => {
30 | const mockResponse: MockHttpResponse = new MockHttpResponse();
31 | //TODO would be better to mock the firebase code in FirestoreClient
32 | // so you can e2e test the client as well.
33 | const dbClient: DatabaseClient = InMemoryDatabaseClient.make();
34 | setDatabaseClient(dbClient);
35 |
36 | await dbClient.addMember(
37 | {
38 | email: "mary.jane@gmail.com",
39 | name: "Mary Jane",
40 | profile: "https://google.com"
41 | },
42 | Keypair.generate().publicKey,
43 | Keypair.generate().publicKey
44 | );
45 |
46 | await handleQueryRecipients(
47 | buildGetRequest({
48 | emailQuery: "ma",
49 | limit: "10"
50 | }),
51 | mockResponse
52 | );
53 |
54 | expect(mockResponse.mockedNextUse()?.status?.code).toStrictEqual(200);
55 | const result: ApiQueryRecipientsResult = mockResponse.mockedNextUse()?.json?.body.result;
56 | expect(result.length).toStrictEqual(1);
57 | expect(result[0].emailAddress).toStrictEqual("mary.jane@gmail.com");
58 | });
59 | });
60 |
--------------------------------------------------------------------------------
/src/components/TransactionStatus.tsx:
--------------------------------------------------------------------------------
1 | import { Badge } from "react-native-ui-lib";
2 | import { View } from "./View";
3 | import { useMemo } from "react";
4 | import { COLORS } from "../common/styles";
5 | import { IMAGES } from "../images/images";
6 | import { Image } from "react-native-ui-lib";
7 |
8 | interface TransactionStatusProps {
9 | loading: boolean;
10 | success: boolean | undefined;
11 | error: any | undefined;
12 | defaultContent: JSX.Element;
13 | loadingContent: JSX.Element;
14 | successContent: JSX.Element;
15 | errorContent: JSX.Element;
16 | }
17 |
18 |
19 | export function TransactionStatus(props: TransactionStatusProps): JSX.Element {
20 | const badgeColor: string | undefined = useMemo(() => {
21 | if (props.loading) return COLORS.grayLight;
22 | if (props.success) return COLORS.secondaryMedium;
23 | return "transparent";
24 | }, [props.loading, props.success, props.error]);
25 |
26 | const badgeLabel: string | undefined = useMemo(() => {
27 | if (props.success) return "✓";
28 | if (props.loading) return "✓";
29 | return undefined;
30 | }, [props.loading, props.success, props.error]);
31 |
32 | const badgeLabelColor: string | undefined = useMemo(() => {
33 | if (props.error) return COLORS.error;
34 | if (props.loading) return COLORS.whiteish;
35 | if (props.success) return COLORS.whiteish
36 | return COLORS.grayMedium;
37 | }, [props.loading, props.success, props.error]);
38 |
39 | const renderBadge: boolean = (badgeLabel != null) || (badgeColor != null);
40 |
41 | const content: JSX.Element = useMemo(() => {
42 | if (props.error) return props.errorContent;
43 | if (props.loading) return props.loadingContent;
44 | if (props.success) return props.successContent;
45 | return props.defaultContent;
46 | }, [
47 | props.loadingContent,
48 | props.successContent,
49 | props.errorContent,
50 | props.defaultContent
51 | ]);
52 |
53 | return (
54 |
55 | {(renderBadge && props.loading) && ()}
59 | {(renderBadge && !props.loading) && (
60 |
67 | )}
68 | {content}
69 |
70 | );
71 | }
72 |
--------------------------------------------------------------------------------
/backend/src/handlers/save-member.ts:
--------------------------------------------------------------------------------
1 | import * as anchor from "@project-serum/anchor";
2 | import { EmailAddress, ProfilePicture, MemberId } from "../shared/member";
3 | import { ApiError, SolanaTxType } from "../shared/error";
4 | import { getPublicKeyParam, getRequiredParam, makePostHandler } from "./model";
5 | import { ApiSaveMemberRequest, ApiSaveMemberResult } from "../shared/api";
6 | import { getDatabaseClient, getTapCashClient } from "../helpers/singletons";
7 | import { DatabaseClient } from "../db/client";
8 |
9 |
10 | interface SaveMemberArgs {
11 | email: EmailAddress;
12 | profile: ProfilePicture;
13 | name: string;
14 | signerAddress: anchor.web3.PublicKey;
15 | }
16 |
17 |
18 | interface SaveMemberResult {
19 | memberId: MemberId;
20 | }
21 |
22 |
23 | export const handleSaveMember = makePostHandler(saveMember, transformRequest, transformResult);
24 |
25 | async function saveMember(request: SaveMemberArgs): Promise {
26 | const dbClient: DatabaseClient = getDatabaseClient();
27 |
28 | if (await dbClient.isMember(request.email)) {
29 | return { memberId: await dbClient.updateMember(request) };
30 | }
31 |
32 | // TODO optional add check that account/ata initialized in addition to memberPda
33 | // Right now we don't have a seperate process to init just the account for existing members so no reason. Should add both.
34 | let userAta: anchor.web3.PublicKey | undefined = await getTapCashClient().fetchAtaIfInitialized(request.signerAddress);
35 | if (!userAta) {
36 | userAta = await getTapCashClient().initializeNewMember(request.signerAddress);
37 | if (!userAta) throw ApiError.solanaTxError(SolanaTxType.INITIALIZE_ACCOUNT);
38 | }
39 |
40 | const memberId: string = await dbClient.addMember(
41 | {
42 | email: request.email,
43 | profile: request.profile,
44 | name: request.name
45 | },
46 | request.signerAddress,
47 | userAta
48 | );
49 |
50 | return { memberId: memberId };
51 | }
52 |
53 |
54 |
55 | function transformRequest(body: ApiSaveMemberRequest): SaveMemberArgs {
56 | return {
57 | email: getRequiredParam(body, "emailAddress"),
58 | profile: getRequiredParam(body, "profilePictureUrl"),
59 | name: getRequiredParam(body, "name"),
60 | signerAddress: getPublicKeyParam(body, "signerAddressBase58")
61 | };
62 | }
63 |
64 |
65 | function transformResult(result: SaveMemberResult): ApiSaveMemberResult {
66 | // nothing to return
67 | return {};
68 | }
69 |
--------------------------------------------------------------------------------
/backend/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "backend",
3 | "version": "1.0.0",
4 | "description": "",
5 | "main": "dist/index.js",
6 | "scripts": {
7 | "build": "tsc",
8 | "start": "functions-framework --target=index",
9 | "prestart": "npm run build",
10 | "test-windows": "SET SERVER_ENV=test&& jest",
11 | "test": "SERVER_ENV=test jest",
12 | "gcp-build": "npm run build",
13 | "deploy-query-recipients": "gcloud functions deploy query-recipients --env-vars-file .env.prod.yml --gen2 --runtime=nodejs16 --region=us-central1 --trigger-http --allow-unauthenticated",
14 | "deploy-save-member": "gcloud functions deploy save-member --env-vars-file .env.prod.yml --gen2 --runtime=nodejs16 --region=us-central1 --trigger-http --allow-unauthenticated",
15 | "deploy-deposit": "gcloud functions deploy deposit --env-vars-file .env.prod.yml --gen2 --runtime=nodejs16 --region=us-central1 --trigger-http --allow-unauthenticated",
16 | "deploy-recent-activity": "gcloud functions deploy recent-activity --env-vars-file .env.prod.yml --gen2 --runtime=nodejs16 --region=us-central1 --trigger-http --allow-unauthenticated",
17 | "deploy-send": "gcloud functions deploy send --env-vars-file .env.prod.yml --gen2 --runtime=nodejs16 --region=us-central1 --trigger-http --allow-unauthenticated",
18 | "deploy-withdraw": "gcloud functions deploy withdraw --env-vars-file .env.prod.yml --gen2 --runtime=nodejs16 --region=us-central1 --trigger-http --allow-unauthenticated",
19 | "deploy-account": "gcloud functions deploy account --env-vars-file .env.prod.yml --gen2 --runtime=nodejs16 --region=us-central1 --trigger-http --allow-unauthenticated",
20 | "deploy-payment-methods": "gcloud functions deploy payment-methods --env-vars-file .env.prod.yml --gen2 --runtime=nodejs16 --region=us-central1 --trigger-http --allow-unauthenticated",
21 | "deploy-all": "npm run deploy-query-recipients && npm run deploy-save-member && npm run deploy-deposit && npm run deploy-recent-activity && npm run deploy-send && npm run deploy-withdraw && npm run deploy-account && npm run deploy-payment-methods"
22 | },
23 | "keywords": [],
24 | "author": "austin.w.milt@gmail.com",
25 | "dependencies": {
26 | "@circle-fin/circle-sdk": "^1.3.0",
27 | "@google-cloud/functions-framework": "^3.1.3",
28 | "@openpgp/web-stream-tools": "^0.0.13",
29 | "@project-serum/anchor": "^0.26.0",
30 | "@solana/spl-token": "^0.3.7",
31 | "firebase-admin": "^11.5.0",
32 | "openpgp": "^5.7.0",
33 | "uuid": "^9.0.0",
34 | "yamlenv": "^1.0.0"
35 | },
36 | "devDependencies": {
37 | "@types/bn.js": "^5.1.1",
38 | "@types/jest": "^29.4.0",
39 | "@types/node-fetch": "^2.6.2",
40 | "@types/supertest": "^2.0.12",
41 | "@types/uuid": "^9.0.1",
42 | "babel-jest": "^29.4.3",
43 | "jest": "^29.4.3",
44 | "ts-jest": "^29.0.5",
45 | "typescript": "^4.9.4"
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/backend/src/db/client.ts:
--------------------------------------------------------------------------------
1 | import { PublicKey } from "../helpers/solana";
2 | import { EmailAddress, MemberId, MemberPrivateProfile, MemberPublicProfile } from "../shared/member";
3 | import { CircleCardId } from "../types/types";
4 |
5 | export interface DatabaseClient {
6 |
7 | /**
8 | * Adds a new member the db.
9 | *
10 | * @param profile user profile info
11 | * @param signerAddress public address of the signer wallet belonging to the member
12 | * @param usdcAddress public address of the member's USDC account
13 | * @throws if a member with the same email already exists
14 | */
15 | addMember(
16 | profile: MemberPublicProfile,
17 | signerAddress: PublicKey,
18 | usdcAddress: PublicKey
19 | ): Promise;
20 |
21 |
22 | /**
23 | * Updates the member's profile info if they exist.
24 | *
25 | * @param profile profile data to update
26 | * @throws if the member has not been added to the db
27 | */
28 | updateMember(profile: Partial): Promise;
29 |
30 | /**
31 | * @param emailAddress email address to check for membership
32 | * @returns `true` if the email address is associated with a member, `false` otherwise
33 | */
34 | isMember(emailAddress: EmailAddress): Promise;
35 |
36 | /**
37 | *
38 | * Saves a user's Circle credit card id
39 | *
40 | * @param member User's email address
41 | * @param circleCreditCardId User's Circle credit card id
42 | * @throws if the card is not able to be saved
43 | */
44 | saveCircleCreditCard(member: EmailAddress, circleCreditCardId: string): Promise;
45 |
46 | /**
47 | * @param emailQuery email addresses to search for
48 | * @param limit number of results to return
49 | * @returns member public profiles where the members' email starts with `emailQuery`
50 | */
51 | queryMembersByEmail(emailQuery: string, limit: number): Promise;
52 |
53 | /**
54 | *
55 | * @param accounts public keys of user's USDC accounts to search for
56 | * @returns a map of base58-encoded public key to member profile
57 | * @throws if any of the accounts are not associated with a member
58 | */
59 | getMembersByUsdcAddress(accounts: PublicKey[]): Promise