Skip to content

Commit

Permalink
feat: introduce time based quizzes (#3743)
Browse files Browse the repository at this point in the history
* feat: introduce time based quizzes

* chore: addressing comments

* chore: addressing more comments

* chore: using the app wrapper

* chore: rename file

---------

Co-authored-by: Nicolas Burtey <[email protected]>
  • Loading branch information
nicolasburtey and Nicolas Burtey authored Dec 26, 2023
1 parent 69872b4 commit 736c51b
Show file tree
Hide file tree
Showing 60 changed files with 876 additions and 258 deletions.
83 changes: 82 additions & 1 deletion bats/core/api/quiz.bats
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ setup_file() {
"10000"
}

@test "quiz: completes a quiz question and gets paid once" {
@test "quiz: completes a quiz question and gets paid once - legacy mutation" {
token_name="alice"
question_id="sat"

Expand Down Expand Up @@ -78,3 +78,84 @@ setup_file() {
')
[[ "$btc_balance_after_retry" == "$btc_balance_after_quiz" ]] || exit 1
}

@test "quiz: completes a quiz question and gets paid once - time based quiz mutation" {
token_name="alice"
question_id="whatIsBitcoin"

# Check initial balance
exec_graphql $token_name 'wallets-for-account'
btc_initial_balance=$(graphql_output '
.data.me.defaultAccount.wallets[]
| select(.walletCurrency == "BTC")
.balance
')

exec_graphql $token_name 'quiz'
completed=$(graphql_output '.data.me.defaultAccount.quiz' | jq '.[] | select(.id == "whatIsBitcoin") | .completed')
[[ "${completed}" == "false" ]] || exit 1

# Do quiz
variables=$(
jq -n \
--arg question_id "$question_id" \
'{input: {id: $question_id}}'
)
exec_graphql "$token_name" 'quiz-claim' "$variables"
quizzes=$(graphql_output '.data.quizClaim.quizzes')
[[ "${quizzes}" != "null" ]] || exit 1

quiz_completed=$(graphql_output '.data.quizClaim.quizzes' | jq '.[] | select(.id == "whatIsBitcoin") | .completed')
[[ "${quiz_completed}" == "true" ]] || exit 1

exec_graphql $token_name 'quiz'
completed=$(graphql_output '.data.me.defaultAccount.quiz' | jq '.[] | select(.id == "whatIsBitcoin") | .completed')
[[ "${completed}" == "true" ]] || exit 1

# Check balance after complete
exec_graphql $token_name 'wallets-for-account'
btc_balance_after_quiz=$(graphql_output '
.data.me.defaultAccount.wallets[]
| select(.walletCurrency == "BTC")
.balance
')
[[ "$btc_balance_after_quiz" -gt "$btc_initial_balance" ]] || exit 1

# Check memo
exec_graphql "$token_name" 'transactions' '{"first": 1}'
txn_memo=$(graphql_output '.data.me.defaultAccount.transactions.edges[0].node.memo')
[[ "${txn_memo}" == "${question_id}" ]] || exit 1

# Retry quiz
exec_graphql "$token_name" 'quiz-claim' "$variables"
errors=$(graphql_output '.data.quizClaim.errors')
[[ "${errors}" != "null" ]] || exit 1
error_msg=$(graphql_output '.data.quizClaim.errors[0].message')
[[ "${error_msg}" =~ "already claimed" ]] || exit 1

# Check balance after retry
exec_graphql $token_name 'wallets-for-account'
btc_balance_after_retry=$(graphql_output '
.data.me.defaultAccount.wallets[]
| select(.walletCurrency == "BTC")
.balance
')
[[ "$btc_balance_after_retry" == "$btc_balance_after_quiz" ]] || exit 1

# Section 1 quiz should not be claimable

# Do quiz
question_id="coincidenceOfWants"

variables=$(
jq -n \
--arg question_id "$question_id" \
'{input: {id: $question_id}}'
)

exec_graphql "$token_name" 'quiz-claim' "$variables"
errors=$(graphql_output '.data.quizClaim.errors')
[[ "${errors}" != "null" ]] || exit 1
error_msg=$(graphql_output '.data.quizClaim.errors[0].code')
[[ "${error_msg}" =~ "QUIZ_CLAIMED_TOO_EARLY" ]] || exit 1
}
15 changes: 15 additions & 0 deletions bats/gql/quiz-claim.gql
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
mutation quizClaim($input: QuizClaimInput!) {
quizClaim(input: $input) {
quizzes {
amount
completed
id
notBefore
}
errors {
code
message
path
}
}
}
3 changes: 2 additions & 1 deletion bats/gql/quiz.gql
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@ query myQuizQuestions {
id
amount
completed
notBefore
}
}
}
}
}
}
49 changes: 48 additions & 1 deletion core/api/dev/apollo-federation/supergraph.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -723,6 +723,19 @@ enum link__Purpose {
EXECUTION
}

input LnAddressPaymentSendInput
@join__type(graph: PUBLIC)
{
"""Amount in satoshis."""
amount: SatAmount!

"""Lightning address to send to."""
lnAddress: String!

"""Wallet ID to send bitcoin from."""
walletId: WalletId!
}

type LnInvoice implements Invoice
@join__implements(graph: PUBLIC, interface: "Invoice")
@join__type(graph: PUBLIC)
Expand Down Expand Up @@ -929,6 +942,19 @@ type LnUpdate
walletId: WalletId! @deprecated(reason: "Deprecated in favor of transaction")
}

input LnurlPaymentSendInput
@join__type(graph: PUBLIC)
{
"""Amount in satoshis."""
amount: SatAmount!

"""Lnurl string to send to."""
lnurl: String!

"""Wallet ID to send bitcoin from."""
walletId: WalletId!
}

input LnUsdInvoiceBtcDenominatedCreateOnBehalfOfRecipientInput
@join__type(graph: PUBLIC)
{
Expand Down Expand Up @@ -1054,6 +1080,9 @@ type Mutation
"""
intraLedgerUsdPaymentSend(input: IntraLedgerUsdPaymentSendInput!): PaymentSendPayload! @join__field(graph: PUBLIC)

"""Sends a payment to a lightning address."""
lnAddressPaymentSend(input: LnAddressPaymentSendInput!): PaymentSendPayload! @join__field(graph: PUBLIC)

"""
Returns a lightning invoice for an associated wallet.
When invoice is paid the value will be credited to a BTC wallet.
Expand Down Expand Up @@ -1130,13 +1159,17 @@ type Mutation
"""
lnUsdInvoiceCreateOnBehalfOfRecipient(input: LnUsdInvoiceCreateOnBehalfOfRecipientInput!): LnInvoicePayload! @join__field(graph: PUBLIC)
lnUsdInvoiceFeeProbe(input: LnUsdInvoiceFeeProbeInput!): SatAmountPayload! @join__field(graph: PUBLIC)

"""Sends a payment to a lightning address."""
lnurlPaymentSend(input: LnurlPaymentSendInput!): PaymentSendPayload! @join__field(graph: PUBLIC)
onChainAddressCreate(input: OnChainAddressCreateInput!): OnChainAddressPayload! @join__field(graph: PUBLIC)
onChainAddressCurrent(input: OnChainAddressCurrentInput!): OnChainAddressPayload! @join__field(graph: PUBLIC)
onChainPaymentSend(input: OnChainPaymentSendInput!): PaymentSendPayload! @join__field(graph: PUBLIC)
onChainPaymentSendAll(input: OnChainPaymentSendAllInput!): PaymentSendPayload! @join__field(graph: PUBLIC)
onChainUsdPaymentSend(input: OnChainUsdPaymentSendInput!): PaymentSendPayload! @join__field(graph: PUBLIC)
onChainUsdPaymentSendAsBtcDenominated(input: OnChainUsdPaymentSendAsBtcDenominatedInput!): PaymentSendPayload! @join__field(graph: PUBLIC)
quizCompleted(input: QuizCompletedInput!): QuizCompletedPayload! @join__field(graph: PUBLIC)
quizClaim(input: QuizClaimInput!): QuizClaimPayload! @join__field(graph: PUBLIC)
quizCompleted(input: QuizCompletedInput!): QuizCompletedPayload! @join__field(graph: PUBLIC) @deprecated(reason: "Use quizClaim instead")
userContactUpdateAlias(input: UserContactUpdateAliasInput!): UserContactUpdateAliasPayload! @join__field(graph: PUBLIC) @deprecated(reason: "will be moved to AccountContact")
userEmailDelete: UserEmailDeletePayload! @join__field(graph: PUBLIC)
userEmailRegistrationInitiate(input: UserEmailRegistrationInitiateInput!): UserEmailRegistrationInitiatePayload! @join__field(graph: PUBLIC)
Expand Down Expand Up @@ -1490,6 +1523,20 @@ type Quiz
amount: SatAmount!
completed: Boolean!
id: ID!
notBefore: Timestamp
}

input QuizClaimInput
@join__type(graph: PUBLIC)
{
id: ID!
}

type QuizClaimPayload
@join__type(graph: PUBLIC)
{
errors: [Error!]!
quizzes: [Quiz!]!
}

input QuizCompletedInput
Expand Down
130 changes: 121 additions & 9 deletions core/api/src/app/quiz/add.ts → core/api/src/app/quiz/claim.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { intraledgerPaymentSendWalletIdForBtcWallet } from "../payments/send-intraledger"

import { QuizzesValue } from "@/domain/earn"
import { listQuizzesByAccountId } from "./list"

import { QuizzesValue } from "@/domain/quiz"

import { getQuizzesConfig } from "@/config"

Expand All @@ -12,8 +14,7 @@ import {
MissingIPMetadataError,
NoBtcWalletExistsForAccountError,
NotEnoughBalanceForQuizError,
UnauthorizedIPError,
UnknownRepositoryError,
QuizClaimedTooEarlyError,
} from "@/domain/errors"
import { WalletCurrency } from "@/domain/shared"
import { RateLimitConfig } from "@/domain/rate-limit"
Expand All @@ -33,15 +34,22 @@ import { consumeLimiter } from "@/services/rate-limit"
import { getFunderWalletId } from "@/services/ledger/caching"
import { AccountsIpsRepository } from "@/services/mongoose/accounts-ips"

export const completeQuiz = async ({
type ClaimQuizResult = {
id: QuizQuestionId
amount: Satoshis
completed: boolean
notBefore: Date | undefined
}[]

export const claimQuizLegacy = async ({
quizQuestionId: quizQuestionIdString,
accountId: accountIdRaw,
ip,
}: {
quizQuestionId: string
accountId: string
ip: IpAddress | undefined
}): Promise<QuizQuestion | ApplicationError> => {
}): Promise<ClaimQuizResult | ApplicationError> => {
const check = await checkAddQuizAttemptPerIpLimits(ip)
if (check instanceof Error) return check

Expand Down Expand Up @@ -86,11 +94,12 @@ export const completeQuiz = async ({
if (validatedIPMetadata instanceof MissingIPMetadataError)
return new InvalidIpMetadataError(validatedIPMetadata)

if (validatedIPMetadata instanceof UnauthorizedIPError) return validatedIPMetadata

return new UnknownRepositoryError("add quiz error")
return validatedIPMetadata
}

const quizzesBefore = await listQuizzesByAccountId(accountId)
if (quizzesBefore instanceof Error) return quizzesBefore

const recipientWallets = await WalletsRepository().listByAccountId(accountId)
if (recipientWallets instanceof Error) return recipientWallets

Expand Down Expand Up @@ -121,7 +130,10 @@ export const completeQuiz = async ({
})
if (payment instanceof Error) return payment

return { id: quizId, earnAmount: amount }
const quizzesAfter = await listQuizzesByAccountId(accountId)
if (quizzesAfter instanceof Error) return quizzesAfter

return quizzesAfter
}

const checkAddQuizAttemptPerIpLimits = async (
Expand All @@ -135,6 +147,106 @@ const checkAddQuizAttemptPerIpLimits = async (
})
}

export const claimQuiz = async ({
quizQuestionId: quizQuestionIdString,
accountId: accountIdRaw,
ip,
}: {
quizQuestionId: string
accountId: string
ip: IpAddress | undefined
}): Promise<ClaimQuizResult | ApplicationError> => {
const check = await checkAddQuizAttemptPerIpLimits(ip)
if (check instanceof Error) return check

const accountId = checkedToAccountId(accountIdRaw)
if (accountId instanceof Error) return accountId

const quizzesConfig = getQuizzesConfig()

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

const amount = QuizzesValue[quizId]
if (!amount) return new InvalidQuizQuestionIdError()

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

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 validatedPhoneMetadata = PhoneMetadataAuthorizer(
quizzesConfig.phoneMetadataValidationSettings,
).authorize(user.phoneMetadata)

if (validatedPhoneMetadata instanceof Error) {
return new InvalidPhoneForQuizError(validatedPhoneMetadata.name)
}

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

const validatedIPMetadata = IPMetadataAuthorizer(
quizzesConfig.ipMetadataValidationSettings,
).authorize(accountIP.metadata)
if (validatedIPMetadata instanceof Error) {
if (validatedIPMetadata instanceof MissingIPMetadataError)
return new InvalidIpMetadataError(validatedIPMetadata)

return validatedIPMetadata
}

const quizzesBefore = await listQuizzesByAccountId(accountId)
if (quizzesBefore instanceof Error) return quizzesBefore

const quiz = quizzesBefore.find((quiz) => quiz.id === quizId)
if (quiz === undefined) return new InvalidQuizQuestionIdError()

if (quiz.notBefore && quiz.notBefore > new Date()) return new QuizClaimedTooEarlyError()

const recipientWallets = await WalletsRepository().listByAccountId(accountId)
if (recipientWallets instanceof Error) return recipientWallets

const recipientBtcWallet = recipientWallets.find(
(wallet) => wallet.currency === WalletCurrency.Btc,
)
if (recipientBtcWallet === undefined) return new NoBtcWalletExistsForAccountError()
const recipientWalletId = recipientBtcWallet.id

const shouldGiveSats = await QuizRepository().add({ quizId, accountId })
if (shouldGiveSats instanceof Error) return shouldGiveSats

const funderBalance = await getBalanceForWallet({ walletId: funderWalletId })
if (funderBalance instanceof Error) return funderBalance

const sendCheck = FunderBalanceChecker().check({
balance: funderBalance as Satoshis,
amountToSend: amount,
})
if (sendCheck instanceof Error) return sendCheck

const payment = await intraledgerPaymentSendWalletIdForBtcWallet({
senderWalletId: funderWalletId,
recipientWalletId,
amount,
memo: quizId,
senderAccount: funderAccount,
})
if (payment instanceof Error) return payment

const quizzesAfter = await listQuizzesByAccountId(accountId)
if (quizzesAfter instanceof Error) return quizzesAfter

return quizzesAfter
}

const FunderBalanceChecker = () => {
const check = ({
balance,
Expand Down
Loading

0 comments on commit 736c51b

Please sign in to comment.