Skip to content

Commit

Permalink
feat: introduce time based quizzes
Browse files Browse the repository at this point in the history
  • Loading branch information
Nicolas Burtey committed Dec 26, 2023
1 parent 9082872 commit e1fba3a
Show file tree
Hide file tree
Showing 37 changed files with 762 additions and 226 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: new 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
37 changes: 32 additions & 5 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,6 +14,7 @@ import {
MissingIPMetadataError,
NoBtcWalletExistsForAccountError,
NotEnoughBalanceForQuizError,
QuizClaimedTooEarlyError,
UnauthorizedIPError,
UnknownRepositoryError,
} from "@/domain/errors"
Expand All @@ -32,16 +35,26 @@ import {
import { consumeLimiter } from "@/services/rate-limit"
import { getFunderWalletId } from "@/services/ledger/caching"
import { AccountsIpsRepository } from "@/services/mongoose/accounts-ips"
import { QuizQuestionId } from "@/domain/quiz/index.types"

type ClaimQuizResult = {
id: QuizQuestionId
amount: Satoshis
completed: boolean
notBefore: Date | undefined
}[]

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

Expand Down Expand Up @@ -88,7 +101,18 @@ export const completeQuiz = async ({

if (validatedIPMetadata instanceof UnauthorizedIPError) return validatedIPMetadata

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

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

if (!legacy) {
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)
Expand Down Expand Up @@ -121,7 +145,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 Down
17 changes: 0 additions & 17 deletions core/api/src/app/quiz/get.ts

This file was deleted.

4 changes: 2 additions & 2 deletions core/api/src/app/quiz/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
export * from "./add"
export * from "./get"
export * from "./claim"
export * from "./list"
9 changes: 9 additions & 0 deletions core/api/src/app/quiz/list.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { fillQuizInformation } from "@/domain/quiz"
import { QuizRepository } from "@/services/mongoose"

export const listQuizzesByAccountId = async (accountId: AccountId) => {
const quizzes = await QuizRepository().fetchAll(accountId)
if (quizzes instanceof Error) return quizzes

return fillQuizInformation(quizzes).quizzes
}
2 changes: 1 addition & 1 deletion core/api/src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export * from "./schema"
import { ConfigError } from "./error"

import { toDays } from "@/domain/primitives"
import { QuizzesValue } from "@/domain/earn"
import { QuizzesValue } from "@/domain/quiz"

export const MS_PER_SEC = 1000 as MilliSeconds
export const MS_PER_5_MINS = (60 * 5 * MS_PER_SEC) as MilliSeconds
Expand Down
Loading

0 comments on commit e1fba3a

Please sign in to comment.