Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: enable quiz completion without reward #3421

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions core/api/dev/apollo-federation/supergraph.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,7 @@ type ConsumerAccount implements Account

"""List the quiz questions of the consumer account"""
quiz: [Quiz!]!
quizRewardsEnabled: Boolean!
realtimePrice: RealtimePrice!

"""
Expand Down Expand Up @@ -1371,6 +1372,7 @@ type QuizCompletedPayload
{
errors: [Error!]!
quiz: Quiz
rewardPaid: Boolean
}

type QuizQuestion
Expand Down
24 changes: 19 additions & 5 deletions core/api/src/app/index.types.d.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,22 @@
type PartialResult<T> = {
result: T | null
error?: ApplicationError
partialResult: true
}
type PartialResultType =
(typeof import("./partial-result").PartialResultType)[keyof typeof import("./partial-result").PartialResultType]

type PartialResult<T> =
| {
result: T
error?: undefined
type: typeof import("./partial-result").PartialResultType.Ok
}
| {
result: T
error: ApplicationError
type: typeof import("./partial-result").PartialResultType.Partial
}
| {
result: null
error: ApplicationError
type: typeof import("./partial-result").PartialResultType.Err
}

type ValueOf<T> = T[keyof T]

Expand Down
12 changes: 9 additions & 3 deletions core/api/src/app/partial-result.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,22 @@
export const PartialResult = {
ok: <T>(result: T): PartialResult<T> => ({
result,
partialResult: true,
type: PartialResultType.Ok,
}),
partial: <T>(result: T, error: ApplicationError): PartialResult<T> => ({
result,
error,
partialResult: true,
type: PartialResultType.Partial,
}),
err: <T>(error: ApplicationError): PartialResult<T> => ({
result: null,
error,
partialResult: true,
type: PartialResultType.Err,
}),
}

export const PartialResultType = {
Partial: "Partial",
Ok: "Ok",
Err: "Err",
} as const
149 changes: 128 additions & 21 deletions core/api/src/app/payments/add-earn.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { PartialResult } from "../partial-result"

import { intraledgerPaymentSendWalletIdForBtcWallet } from "./send-intraledger"

import { getRewardsConfig, OnboardingEarn } from "@/config"
Expand Down Expand Up @@ -29,64 +31,125 @@ export const addEarn = async ({
}: {
quizQuestionId: string
accountId: string
}): Promise<QuizQuestion | ApplicationError> => {
}): Promise<
PartialResult<{
quiz: Quiz
rewardPaid: boolean
}>
> => {
const accountId = checkedToAccountId(accountIdRaw)
if (accountId instanceof Error) return accountId
if (accountId instanceof Error) return PartialResult.err(accountId)

const rewardsConfig = getRewardsConfig()

// TODO: quizQuestionId checkedFor
const quizQuestionId = quizQuestionIdString as QuizQuestionId

const amount = OnboardingEarn[quizQuestionId]
if (!amount) return new InvalidQuizQuestionIdError()
if (!amount) return PartialResult.err(new InvalidQuizQuestionIdError())

const funderWalletId = await getFunderWalletId()
const funderWallet = await WalletsRepository().findById(funderWalletId)
if (funderWallet instanceof Error) return funderWallet
if (funderWallet instanceof Error) return PartialResult.err(funderWallet)
const funderAccount = await AccountsRepository().findById(funderWallet.accountId)
if (funderAccount instanceof Error) return funderAccount
if (funderAccount instanceof Error) return PartialResult.err(funderAccount)

const recipientAccount = await AccountsRepository().findById(accountId)
if (recipientAccount instanceof Error) return recipientAccount
if (recipientAccount instanceof Error) return PartialResult.err(recipientAccount)

const user = await UsersRepository().findById(recipientAccount.kratosUserId)
if (user instanceof Error) return user
if (user instanceof Error) return PartialResult.err(user)

const isFirstTimeAnsweringQuestion =
await RewardsRepository(accountId).add(quizQuestionId)
if (isFirstTimeAnsweringQuestion instanceof Error)
return PartialResult.err(isFirstTimeAnsweringQuestion)

const quiz: Quiz = {
id: quizQuestionId,
amount: amount,
completed: true,
}

const validatedPhoneMetadata = PhoneMetadataAuthorizer(
rewardsConfig.phoneMetadataValidationSettings,
).authorize(user.phoneMetadata)

if (validatedPhoneMetadata instanceof Error) {
return new InvalidPhoneForRewardError(validatedPhoneMetadata.name)
return PartialResult.partial(
{
quiz,
rewardPaid: false,
},
new InvalidPhoneForRewardError(validatedPhoneMetadata.name),
)
}

const accountIP = await AccountsIpsRepository().findLastByAccountId(recipientAccount.id)
if (accountIP instanceof Error) return accountIP
if (accountIP instanceof Error)
return PartialResult.partial(
{
quiz,
rewardPaid: false,
},
new InvalidPhoneForRewardError(accountIP),
)

const validatedIPMetadata = IPMetadataAuthorizer(
rewardsConfig.ipMetadataValidationSettings,
).authorize(accountIP.metadata)

if (validatedIPMetadata instanceof Error) {
if (validatedIPMetadata instanceof MissingIPMetadataError)
return new InvalidIpMetadataError(validatedIPMetadata)

if (validatedIPMetadata instanceof UnauthorizedIPError) return validatedIPMetadata

return new UnknownRepositoryError("add earn error")
return PartialResult.partial(
{
quiz,
rewardPaid: false,
},
new InvalidIpMetadataError(validatedIPMetadata),
)

if (validatedIPMetadata instanceof UnauthorizedIPError)
return PartialResult.partial(
{
quiz,
rewardPaid: false,
},
validatedIPMetadata,
)

return PartialResult.partial(
{
quiz,
rewardPaid: false,
},
new UnknownRepositoryError("add earn error"),
)
}

const recipientWallets = await WalletsRepository().listByAccountId(accountId)
if (recipientWallets instanceof Error) return recipientWallets
if (recipientWallets instanceof Error)
return PartialResult.partial(
{
quiz,
rewardPaid: false,
},
recipientWallets,
)

const recipientBtcWallet = recipientWallets.find(
(wallet) => wallet.currency === WalletCurrency.Btc,
)
if (recipientBtcWallet === undefined) return new NoBtcWalletExistsForAccountError()
const recipientWalletId = recipientBtcWallet.id
if (recipientBtcWallet === undefined)
return PartialResult.partial(
{
quiz,
rewardPaid: false,
},
new NoBtcWalletExistsForAccountError(),
)

const shouldGiveReward = await RewardsRepository(accountId).add(quizQuestionId)
if (shouldGiveReward instanceof Error) return shouldGiveReward
const recipientWalletId = recipientBtcWallet.id

const payment = await intraledgerPaymentSendWalletIdForBtcWallet({
senderWalletId: funderWalletId,
Expand All @@ -95,7 +158,51 @@ export const addEarn = async ({
memo: quizQuestionId,
senderAccount: funderAccount,
})
if (payment instanceof Error) return payment

return { id: quizQuestionId, earnAmount: amount }
if (payment instanceof Error)
return PartialResult.partial(
{
quiz,
rewardPaid: false,
},
payment,
)

return PartialResult.ok({
quiz,
rewardPaid: true,
})
}

export const isAccountEligibleForEarnPayment = async ({
accountId,
}: {
accountId: AccountId
}): Promise<boolean | ApplicationError> => {
const recipientAccount = await AccountsRepository().findById(accountId)
if (recipientAccount instanceof Error) return recipientAccount

const user = await UsersRepository().findById(recipientAccount.kratosUserId)
if (user instanceof Error) return user

const rewardsConfig = getRewardsConfig()

const validatedPhoneMetadata = PhoneMetadataAuthorizer(
rewardsConfig.phoneMetadataValidationSettings,
).authorize(user.phoneMetadata)

if (validatedPhoneMetadata instanceof Error) {
return false
}

const accountIP = await AccountsIpsRepository().findLastByAccountId(recipientAccount.id)
if (accountIP instanceof Error) return accountIP

const validatedIPMetadata = IPMetadataAuthorizer(
rewardsConfig.ipMetadataValidationSettings,
).authorize(accountIP.metadata)

if (validatedIPMetadata instanceof Error) return false

return true
}
3 changes: 0 additions & 3 deletions core/api/src/domain/bitcoin/lightning/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,6 @@ export class DestinationMissingDependentFeatureError extends LightningServiceErr
export class LookupPaymentTimedOutError extends LightningServiceError {
level = ErrorLevel.Critical
}
export class InvalidFeeProbeStateError extends LightningServiceError {
level = ErrorLevel.Critical
}

export class UnknownRouteNotFoundError extends LightningServiceError {
level = ErrorLevel.Critical
Expand Down
1 change: 0 additions & 1 deletion core/api/src/domain/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,6 @@ export class CouldNotFindLnPaymentFromHashError extends CouldNotFindError {
export class CouldNotFindAccountFromIdError extends CouldNotFindError {}
export class CouldNotFindAccountFromUsernameError extends CouldNotFindError {}
export class CouldNotFindAccountFromPhoneError extends CouldNotFindError {}
export class CouldNotFindTransactionsForAccountError extends CouldNotFindError {}
export class CouldNotFindAccountFromKratosIdError extends CouldNotFindError {}

export class RewardAlreadyPresentError extends DomainError {}
Expand Down
5 changes: 0 additions & 5 deletions core/api/src/graphql/error-map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,10 +96,6 @@ export const mapError = (error: ApplicationError): CustomGraphQLError => {
message = `The associated lightning invoice could not be found.`
return new NotFoundError({ message, logger: baseLogger })

case "CouldNotFindTransactionsForAccountError":
message = "No transactions found for your account."
return new NotFoundError({ message, logger: baseLogger })

case "CouldNotFindUserFromPhoneError":
message = `User does not exist for phone ${error.message}`
return new NotFoundError({ message, logger: baseLogger })
Expand Down Expand Up @@ -591,7 +587,6 @@ export const mapError = (error: ApplicationError): CustomGraphQLError => {
case "BigIntConversionError":
case "BigIntFloatConversionError":
case "SafeWrapperError":
case "InvalidFeeProbeStateError":
case "LookupPaymentTimedOutError":
case "InvalidPubKeyError":
case "SkipProbeForPubkeyError":
Expand Down
27 changes: 12 additions & 15 deletions core/api/src/graphql/public/root/mutation/ln-invoice-fee-probe.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
import { normalizePaymentAmount } from "../../../shared/root/mutation"

import { InvalidFeeProbeStateError } from "@/domain/bitcoin/lightning"

import { Payments } from "@/app"

import { GT } from "@/graphql/index"
import WalletId from "@/graphql/shared/types/scalar/wallet-id"
import SatAmountPayload from "@/graphql/public/types/payload/sat-amount"
import LnPaymentRequest from "@/graphql/shared/types/scalar/ln-payment-request"
import { mapAndParseErrorForGqlResponse } from "@/graphql/error-map"
import { PartialResultType } from "@/app/partial-result"

const LnInvoiceFeeProbeInput = GT.Input({
name: "LnInvoiceFeeProbeInput",
Expand Down Expand Up @@ -43,31 +42,29 @@ const LnInvoiceFeeProbeMutation = GT.Field<
if (paymentRequest instanceof Error)
return { errors: [{ message: paymentRequest.message }] }

const { result: feeSatAmount, error } =
await Payments.getLightningFeeEstimationForBtcWallet({
walletId,
uncheckedPaymentRequest: paymentRequest,
})
const {
result: feeSatAmount,
error,
type,
} = await Payments.getLightningFeeEstimationForBtcWallet({
walletId,
uncheckedPaymentRequest: paymentRequest,
})

if (feeSatAmount !== null && error instanceof Error) {
if (type === PartialResultType.Partial) {
error
return {
errors: [mapAndParseErrorForGqlResponse(error)],
...normalizePaymentAmount(feeSatAmount),
}
}

if (error instanceof Error) {
if (type === PartialResultType.Err) {
return {
errors: [mapAndParseErrorForGqlResponse(error)],
}
}

if (feeSatAmount === null) {
return {
errors: [mapAndParseErrorForGqlResponse(new InvalidFeeProbeStateError())],
}
}

return {
errors: [],
...normalizePaymentAmount(feeSatAmount),
Expand Down
Loading
Loading