diff --git a/bats/core/api/referral.bats b/bats/core/api/referral.bats new file mode 100644 index 00000000000..f6ced1d6853 --- /dev/null +++ b/bats/core/api/referral.bats @@ -0,0 +1,9 @@ +#!/usr/bin/env bats +load "../../helpers/user.bash" + +@test "referral: alice is using bob referral" { + local token_name=$1 + local phone=$(random_phone) + + login_user "$token_name" "$phone" "bob" +} diff --git a/bats/helpers/user.bash b/bats/helpers/user.bash index d4800a9d5c9..dacc56af5ae 100644 --- a/bats/helpers/user.bash +++ b/bats/helpers/user.bash @@ -1,10 +1,12 @@ CURRENT_FILE=${BASH_SOURCE:-bats/helpers/.} +echo "Sourcing file: $CURRENT_FILE" source "$(dirname "$CURRENT_FILE")/_common.bash" source "$(dirname "$CURRENT_FILE")/cli.bash" login_user() { local token_name=$1 local phone=$2 + local referral=$3 local code="000000" @@ -13,7 +15,8 @@ login_user() { jq -n \ --arg phone "$phone" \ --arg code "$code" \ - '{input: {phone: $phone, code: $code}}' + --arg referral "$referral" \ + '{input: {phone: $phone, code: $code, referral: $referral}}' ) exec_graphql 'anon' 'user-login' "$variables" auth_token="$(graphql_output '.data.userLogin.authToken')" diff --git a/core/api/src/app/accounts/create-account.ts b/core/api/src/app/accounts/create-account.ts index 8a261d5fc6d..f18318b0faa 100644 --- a/core/api/src/app/accounts/create-account.ts +++ b/core/api/src/app/accounts/create-account.ts @@ -13,10 +13,12 @@ const initializeCreatedAccount = async ({ account, config, phone, + referral, }: { account: Account config: AccountsConfig phone?: PhoneNumber + referral: Referral | undefined }): Promise => { const walletsEnabledConfig = config.initialWallets @@ -50,6 +52,7 @@ const initializeCreatedAccount = async ({ account.statusHistory = [{ status: config.initialStatus, comment: "Initial Status" }] account.level = config.initialLevel + account.referral = referral const updatedAccount = await AccountsRepository().update(account) if (updatedAccount instanceof Error) return updatedAccount @@ -76,6 +79,7 @@ export const createAccountForDeviceAccount = async ({ return initializeCreatedAccount({ account: accountNew, config: levelZeroAccountsConfig, + referral: undefined, }) } @@ -83,10 +87,12 @@ export const createAccountWithPhoneIdentifier = async ({ newAccountInfo: { kratosUserId, phone }, config, phoneMetadata, + referral, }: { newAccountInfo: NewAccountWithPhoneIdentifier config: AccountsConfig phoneMetadata?: PhoneMetadata + referral: Referral | undefined }): Promise => { const user = await UsersRepository().update({ id: kratosUserId, phone, phoneMetadata }) if (user instanceof Error) return user @@ -98,6 +104,7 @@ export const createAccountWithPhoneIdentifier = async ({ account: accountNew, config, phone, + referral, }) if (account instanceof Error) return account diff --git a/core/api/src/app/authentication/login.ts b/core/api/src/app/authentication/login.ts index 5df0be3378d..538f97721ab 100644 --- a/core/api/src/app/authentication/login.ts +++ b/core/api/src/app/authentication/login.ts @@ -64,10 +64,12 @@ export const loginWithPhoneToken = async ({ phone, code, ip, + referral, }: { phone: PhoneNumber code: PhoneCode ip: IpAddress + referral?: Referral }): Promise => { { const limitOk = await checkFailedLoginAttemptPerIpLimits(ip) @@ -103,7 +105,6 @@ export const loginWithPhoneToken = async ({ const kratosResult = await authService.createIdentityWithSession({ phone, - phoneMetadata, }) if (kratosResult instanceof Error) return kratosResult const { kratosUserId } = kratosResult @@ -112,6 +113,7 @@ export const loginWithPhoneToken = async ({ newAccountInfo: { phone, kratosUserId }, config: getDefaultAccountsConfig(), phoneMetadata, + referral, }) if (account instanceof Error) { recordExceptionInCurrentSpan({ diff --git a/core/api/src/app/bootstrap/index.ts b/core/api/src/app/bootstrap/index.ts index 2cbfb713797..39ad02a71a2 100644 --- a/core/api/src/app/bootstrap/index.ts +++ b/core/api/src/app/bootstrap/index.ts @@ -69,6 +69,7 @@ export const bootstrap = async () => { account = await createAccountWithPhoneIdentifier({ newAccountInfo: { phone, kratosUserId }, config: getDefaultAccountsConfig(), + referral: undefined, }) } if (account instanceof Error) return account diff --git a/core/api/src/domain/accounts/index.types.d.ts b/core/api/src/domain/accounts/index.types.d.ts index 1f7e8cc1622..040033c53c4 100644 --- a/core/api/src/domain/accounts/index.types.d.ts +++ b/core/api/src/domain/accounts/index.types.d.ts @@ -107,6 +107,7 @@ type Account = { readonly contacts: AccountContact[] kratosUserId: UserId displayCurrency: DisplayCurrency + referral?: Referral // temp role?: string } diff --git a/core/api/src/domain/authentication/index.types.d.ts b/core/api/src/domain/authentication/index.types.d.ts index 4c553d297bb..1c60a72daa3 100644 --- a/core/api/src/domain/authentication/index.types.d.ts +++ b/core/api/src/domain/authentication/index.types.d.ts @@ -85,7 +85,6 @@ interface IAuthWithPhonePasswordlessService { logoutToken(args: { sessionId: SessionId }): Promise createIdentityWithSession(args: { phone: PhoneNumber - phoneMetadata?: PhoneMetadata }): Promise updateIdentityFromDeviceAccount(args: { phone: PhoneNumber diff --git a/core/api/src/domain/errors.ts b/core/api/src/domain/errors.ts index 72355226439..3d599dc9a84 100644 --- a/core/api/src/domain/errors.ts +++ b/core/api/src/domain/errors.ts @@ -26,7 +26,7 @@ export class CannotConnectToDbError extends RepositoryError { export class DbConnectionClosedError extends RepositoryError { level = ErrorLevel.Critical } -export class MultipleWalletsFoundForAccountIdAndCurrency extends RepositoryError {} +export class MultipleWalletsFoundForAccountIdAndCurrencyError extends RepositoryError {} export class WalletInvoiceMissingLnInvoiceError extends RepositoryError {} export class CouldNotUnsetPhoneFromUserError extends CouldNotUpdateError {} @@ -87,6 +87,7 @@ export class InvalidPubKeyError extends ValidationError {} export class InvalidScanDepthAmount extends ValidationError {} export class SatoshiAmountRequiredError extends ValidationError {} export class InvalidUsername extends ValidationError {} +export class InvalidReferralError extends ValidationError {} export class InvalidDeviceId extends ValidationError {} export class InvalidIdentityPassword extends ValidationError {} export class InvalidIdentityUsername extends ValidationError {} diff --git a/core/api/src/domain/users/index.ts b/core/api/src/domain/users/index.ts index 1c6fb89e899..b2135d5ccc4 100644 --- a/core/api/src/domain/users/index.ts +++ b/core/api/src/domain/users/index.ts @@ -8,6 +8,7 @@ import { InvalidIdentityUsername, InvalidLanguageError, InvalidPhoneNumber, + InvalidReferralError, } from "@/domain/errors" export * from "./phone-metadata-authorizer" @@ -89,4 +90,11 @@ export const checkedToIdentityPassword = ( return password as IdentityPassword } +export const checkedToReferral = (referral: string): Referral | ValidationError => { + if (referral.length > 12) { + return new InvalidReferralError(referral) + } + return referral as Referral +} + export { Languages } diff --git a/core/api/src/domain/users/index.types.d.ts b/core/api/src/domain/users/index.types.d.ts index 83c3dd1de37..1654e1c71d4 100644 --- a/core/api/src/domain/users/index.types.d.ts +++ b/core/api/src/domain/users/index.types.d.ts @@ -1,6 +1,8 @@ type PhoneNumber = string & { readonly brand: unique symbol } type PhoneCode = string & { readonly brand: unique symbol } +type Referral = string & { readonly brand: unique symbol } + type EmailAddress = string & { readonly brand: unique symbol } type EmailCode = string & { readonly brand: unique symbol } diff --git a/core/api/src/graphql/error-map.ts b/core/api/src/graphql/error-map.ts index 8795b0605d5..0376db6f95a 100644 --- a/core/api/src/graphql/error-map.ts +++ b/core/api/src/graphql/error-map.ts @@ -710,7 +710,8 @@ export const mapError = (error: ApplicationError): CustomGraphQLError => { case "InvalidCarrierTypeForPhoneMetadataError": case "InvalidErrorCodeForPhoneMetadataError": case "InvalidCountryCodeForPhoneMetadataError": - case "MultipleWalletsFoundForAccountIdAndCurrency": + case "MultipleWalletsFoundForAccountIdAndCurrencyError": + case "InvalidReferralError": message = `Unexpected error occurred, please try again or contact support if it persists (code: ${ error.name }${error.message ? ": " + error.message : ""})` diff --git a/core/api/src/graphql/public/root/mutation/user-login.ts b/core/api/src/graphql/public/root/mutation/user-login.ts index 9ce119fca80..5dbdd37e68a 100644 --- a/core/api/src/graphql/public/root/mutation/user-login.ts +++ b/core/api/src/graphql/public/root/mutation/user-login.ts @@ -5,6 +5,7 @@ import Phone from "@/graphql/shared/types/scalar/phone" import AuthTokenPayload from "@/graphql/shared/types/payload/auth-token" import { mapAndParseErrorForGqlResponse } from "@/graphql/error-map" import { Authentication } from "@/app" +import Referral from "@/graphql/shared/types/scalar/referral" const UserLoginInput = GT.Input({ name: "UserLoginInput", @@ -15,6 +16,9 @@ const UserLoginInput = GT.Input({ code: { type: GT.NonNull(OneTimeAuthCode), }, + referral: { + type: Referral, + }, }), }) @@ -25,6 +29,7 @@ const UserLoginMutation = GT.Field< input: { phone: PhoneNumber | InputValidationError code: PhoneCode | InputValidationError + referral?: Referral | InputValidationError } } >({ @@ -36,7 +41,7 @@ const UserLoginMutation = GT.Field< input: { type: GT.NonNull(UserLoginInput) }, }, resolve: async (_, args, { ip }) => { - const { phone, code } = args.input + const { phone, code, referral } = args.input if (phone instanceof Error) { return { errors: [{ message: phone.message }] } @@ -46,6 +51,10 @@ const UserLoginMutation = GT.Field< return { errors: [{ message: code.message }] } } + if (referral instanceof Error) { + return { errors: [{ message: referral.message }] } + } + if (ip === undefined) { return { errors: [{ message: "ip is undefined" }] } } @@ -54,6 +63,7 @@ const UserLoginMutation = GT.Field< phone, code, ip, + referral, }) if (res instanceof Error) { diff --git a/core/api/src/graphql/public/schema.graphql b/core/api/src/graphql/public/schema.graphql index 6510474d499..788bd59ad6e 100644 --- a/core/api/src/graphql/public/schema.graphql +++ b/core/api/src/graphql/public/schema.graphql @@ -1262,6 +1262,9 @@ type RealtimePricePayload { realtimePrice: RealtimePrice } +"""Referral code provided by a community""" +scalar Referral + """ Non-fractional signed whole numeric value between -(2^53) + 1 and 2^53 - 1 """ @@ -1583,6 +1586,7 @@ type UserEmailRegistrationValidatePayload { input UserLoginInput { code: OneTimeAuthCode! phone: Phone! + referral: Referral } input UserLoginUpgradeInput { diff --git a/core/api/src/graphql/shared/types/scalar/referral.ts b/core/api/src/graphql/shared/types/scalar/referral.ts new file mode 100644 index 00000000000..fd0b72b80a8 --- /dev/null +++ b/core/api/src/graphql/shared/types/scalar/referral.ts @@ -0,0 +1,31 @@ +import { checkedToReferral } from "@/domain/users" +import { InputValidationError } from "@/graphql/error" +import { GT } from "@/graphql/index" + +const Referral = GT.Scalar({ + name: "Referral", + description: "Referral code provided by a community", + parseValue(value) { + if (typeof value !== "string") { + return new InputValidationError({ message: "Invalid type for Referral" }) + } + return validReferralValue(value) + }, + parseLiteral(ast) { + if (ast.kind === GT.Kind.STRING) { + return validReferralValue(ast.value) + } + return new InputValidationError({ message: "Invalid type for Referral" }) + }, +}) + +function validReferralValue(value: string) { + const ReferralNumberValid = checkedToReferral(value) + if (ReferralNumberValid instanceof Error) + return new InputValidationError({ + message: "Referral is not a valid", + }) + return ReferralNumberValid +} + +export default Referral diff --git a/core/api/src/services/kratos/auth-phone-no-password.ts b/core/api/src/services/kratos/auth-phone-no-password.ts index a3a536f55c4..dd85cfc38c2 100644 --- a/core/api/src/services/kratos/auth-phone-no-password.ts +++ b/core/api/src/services/kratos/auth-phone-no-password.ts @@ -76,10 +76,8 @@ export const AuthWithPhonePasswordlessService = (): IAuthWithPhonePasswordlessSe const createIdentityWithSession = async ({ phone, - phoneMetadata, }: { phone: PhoneNumber - phoneMetadata?: PhoneMetadata }): Promise => { const traits = { phone } const method = "password" @@ -91,7 +89,6 @@ export const AuthWithPhonePasswordlessService = (): IAuthWithPhonePasswordlessSe traits, method, password, - transient_payload: { phoneMetadata }, }, }) const authToken = result.data.session_token as AuthToken diff --git a/core/api/src/services/mongoose/accounts.ts b/core/api/src/services/mongoose/accounts.ts index 34c6b33885a..eac1b0ff550 100644 --- a/core/api/src/services/mongoose/accounts.ts +++ b/core/api/src/services/mongoose/accounts.ts @@ -65,7 +65,7 @@ export const AccountsRepository = (): IAccountsRepository => { withdrawFee, kratosUserId, displayCurrency, - + referral, role, }: Account): Promise => { try { @@ -87,7 +87,7 @@ export const AccountsRepository = (): IAccountsRepository => { withdrawFee, kratosUserId, displayCurrency, - + referral, role, }, { diff --git a/core/api/src/services/mongoose/schema.ts b/core/api/src/services/mongoose/schema.ts index 32086469877..640946194b7 100644 --- a/core/api/src/services/mongoose/schema.ts +++ b/core/api/src/services/mongoose/schema.ts @@ -259,6 +259,8 @@ const AccountSchema = new Schema( index: true, }, + referral: String, + displayCurrency: String, // FIXME: should be an enum }, { id: false }, diff --git a/core/api/src/services/mongoose/schema.types.d.ts b/core/api/src/services/mongoose/schema.types.d.ts index d618dcf9c23..8648ea9937b 100644 --- a/core/api/src/services/mongoose/schema.types.d.ts +++ b/core/api/src/services/mongoose/schema.types.d.ts @@ -89,6 +89,7 @@ interface AccountRecord { onchain: OnChainObjectForUser[] defaultWalletId: WalletId displayCurrency?: string + referral?: Referral // mongoose in-built functions save: () => Promise diff --git a/core/api/src/services/mongoose/wallets.ts b/core/api/src/services/mongoose/wallets.ts index 1cd9a059002..4d471e9992a 100644 --- a/core/api/src/services/mongoose/wallets.ts +++ b/core/api/src/services/mongoose/wallets.ts @@ -13,7 +13,7 @@ import { CouldNotFindWalletFromOnChainAddressesError, CouldNotListWalletsFromAccountIdError, CouldNotListWalletsFromWalletCurrencyError, - MultipleWalletsFoundForAccountIdAndCurrency, + MultipleWalletsFoundForAccountIdAndCurrencyError, } from "@/domain/errors" export const WalletsRepository = (): IWalletsRepository => { @@ -99,7 +99,7 @@ export const WalletsRepository = (): IWalletsRepository => { return new CouldNotFindWalletFromAccountIdAndCurrencyError(WalletCurrency.Btc) } if (btcWallets.length > 1) { - return new MultipleWalletsFoundForAccountIdAndCurrency(WalletCurrency.Btc) + return new MultipleWalletsFoundForAccountIdAndCurrencyError(WalletCurrency.Btc) } const btcWallet = btcWallets[0] @@ -108,7 +108,7 @@ export const WalletsRepository = (): IWalletsRepository => { return new CouldNotFindWalletFromAccountIdAndCurrencyError(WalletCurrency.Usd) } if (usdWallets.length > 1) { - return new MultipleWalletsFoundForAccountIdAndCurrency(WalletCurrency.Usd) + return new MultipleWalletsFoundForAccountIdAndCurrencyError(WalletCurrency.Usd) } const usdWallet = usdWallets[0] diff --git a/core/api/test/helpers/user.ts b/core/api/test/helpers/user.ts index af8847c0a05..6c5a02b268d 100644 --- a/core/api/test/helpers/user.ts +++ b/core/api/test/helpers/user.ts @@ -76,6 +76,7 @@ export const createUserAndWalletFromPhone = async ( account = await createAccountWithPhoneIdentifier({ newAccountInfo: { phone, kratosUserId }, config: getDefaultAccountsConfig(), + referral: undefined, }) if (account instanceof Error) throw account @@ -164,6 +165,7 @@ export const createUserAndWallet = async ( account = await createAccountWithPhoneIdentifier({ newAccountInfo: { phone, kratosUserId }, config: getDefaultAccountsConfig(), + referral: undefined, }) if (account instanceof Error) throw account diff --git a/core/api/test/integration/app/accounts/create-account.spec.ts b/core/api/test/integration/app/accounts/create-account.spec.ts index c2b7e0c46e9..a87c0945590 100644 --- a/core/api/test/integration/app/accounts/create-account.spec.ts +++ b/core/api/test/integration/app/accounts/create-account.spec.ts @@ -19,6 +19,7 @@ describe("createAccountWithPhoneIdentifier", () => { initialStatus: AccountStatus.Active, initialWallets, }, + referral: undefined, }) if (account instanceof Error) throw account @@ -54,6 +55,7 @@ describe("createAccountWithPhoneIdentifier", () => { initialStatus: AccountStatus.Active, initialWallets, }, + referral: undefined, }) if (account instanceof Error) throw account @@ -89,6 +91,7 @@ describe("createAccountWithPhoneIdentifier", () => { initialStatus: AccountStatus.Active, initialWallets, }, + referral: undefined, }) if (account instanceof Error) throw account diff --git a/core/api/test/integration/scenarios/hedge-arbitrage.skip.ts b/core/api/test/integration/scenarios/hedge-arbitrage.skip.ts index 6501597f0ce..36f4445e7e0 100644 --- a/core/api/test/integration/scenarios/hedge-arbitrage.skip.ts +++ b/core/api/test/integration/scenarios/hedge-arbitrage.skip.ts @@ -137,6 +137,7 @@ const newAccountAndWallets = async () => { initialWallets, initialLevel: AccountLevel.One, }, + referral: undefined, }) if (account instanceof Error) throw account diff --git a/core/api/test/integration/services/wallets-repository.spec.ts b/core/api/test/integration/services/wallets-repository.spec.ts index 64e2a35f481..5f7ea7e79b2 100644 --- a/core/api/test/integration/services/wallets-repository.spec.ts +++ b/core/api/test/integration/services/wallets-repository.spec.ts @@ -5,7 +5,7 @@ import mongoose from "mongoose" import { CouldNotFindAccountFromIdError, CouldNotFindWalletFromAccountIdAndCurrencyError, - MultipleWalletsFoundForAccountIdAndCurrency, + MultipleWalletsFoundForAccountIdAndCurrencyError, RepositoryError, } from "@/domain/errors" import { WalletCurrency } from "@/domain/shared" @@ -72,7 +72,9 @@ describe("WalletsRepository", () => { await newWallet(WalletCurrency.Usd) const accountWallets = await wallets.findAccountWalletsByAccountId(accountId) - expect(accountWallets).toBeInstanceOf(MultipleWalletsFoundForAccountIdAndCurrency) + expect(accountWallets).toBeInstanceOf( + MultipleWalletsFoundForAccountIdAndCurrencyError, + ) expect((accountWallets as RepositoryError).message).toBe(WalletCurrency.Btc) }) @@ -82,7 +84,9 @@ describe("WalletsRepository", () => { await newWallet(WalletCurrency.Usd) const accountWallets = await wallets.findAccountWalletsByAccountId(accountId) - expect(accountWallets).toBeInstanceOf(MultipleWalletsFoundForAccountIdAndCurrency) + expect(accountWallets).toBeInstanceOf( + MultipleWalletsFoundForAccountIdAndCurrencyError, + ) expect((accountWallets as RepositoryError).message).toBe(WalletCurrency.Usd) }) }) diff --git a/dev/config/apollo-federation/supergraph.graphql b/dev/config/apollo-federation/supergraph.graphql index 8016b1833e0..5f91d45ca6b 100644 --- a/dev/config/apollo-federation/supergraph.graphql +++ b/dev/config/apollo-federation/supergraph.graphql @@ -1633,6 +1633,10 @@ type RealtimePricePayload realtimePrice: RealtimePrice } +"""Referral code provided by a community""" +scalar Referral + @join__type(graph: PUBLIC) + """ Non-fractional signed whole numeric value between -(2^53) + 1 and 2^53 - 1 """ @@ -2057,6 +2061,7 @@ input UserLoginInput { code: OneTimeAuthCode! phone: Phone! + referral: Referral } input UserLoginUpgradeInput