Support backend for claiming manalinks

This commit is contained in:
Austin Chen 2022-05-09 10:46:51 -04:00
parent 69bd2b62db
commit 7fff5a6f75
6 changed files with 146 additions and 72 deletions

View File

@ -17,11 +17,9 @@ export type Manalink = {
maxUses: number maxUses: number
// Used for simpler caching // Used for simpler caching
successUserIds: string[] claimedUserIds: string[]
// Successful redemptions of the link // Successful redemptions of the link
successes: Claim[] claims: Claim[]
// Failed redemptions of the link
failures: Claim[]
} }
type Claim = { type Claim = {

View File

@ -13,7 +13,7 @@ export type Txn = {
amount: number amount: number
token: 'M$' // | 'USD' | MarketOutcome token: 'M$' // | 'USD' | MarketOutcome
category: 'CHARITY' // | 'BET' | 'TIP' category: 'CHARITY' | 'MANALINK' // | 'BET' | 'TIP'
// Human-readable description // Human-readable description
description?: string description?: string
} }

View File

@ -0,0 +1,81 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { User } from 'common/user'
import { Manalink } from 'common/manalink'
import { runTxn, TxnData } from './transact'
export const claimManalink = functions
.runWith({ minInstances: 1 })
.https.onCall(async (slug: string, context) => {
const userId = context?.auth?.uid
if (!userId) return { status: 'error', message: 'Not authorized' }
// Run as transaction to prevent race conditions.
return await firestore.runTransaction(async (transaction) => {
// Look up the manalink
const manalinkDoc = firestore.doc(`manalinks/${slug}`)
const manalinkSnap = await transaction.get(manalinkDoc)
if (!manalinkSnap.exists) {
return { status: 'error', message: 'Link not found' }
}
const manalink = manalinkSnap.data() as Manalink
const { amount, fromId, claimedUserIds } = manalink
if (amount <= 0 || isNaN(amount) || !isFinite(amount))
return { status: 'error', message: 'Invalid amount' }
// Only permit one redemption per user per link
if (claimedUserIds.includes(userId)) {
return {
status: 'error',
message: `${userId} already redeemed link ${slug}`,
}
}
const fromDoc = firestore.doc(`users/${fromId}`)
const fromSnap = await transaction.get(fromDoc)
if (!fromSnap.exists) {
return { status: 'error', message: `User ${fromId} not found` }
}
const fromUser = fromSnap.data() as User
if (fromUser.balance < amount) {
return {
status: 'error',
message: `Insufficient balance: ${fromUser.username} needed ${amount} for this manalink but only had ${fromUser.balance} `,
}
}
// Actually execute the txn
const data: TxnData = {
fromId,
fromType: 'USER',
toId: userId,
toType: 'USER',
amount,
token: 'M$',
category: 'MANALINK',
description: `Manalink ${slug} claimed: ${amount} from ${fromUser.username} to ${userId}`,
}
const result = await runTxn(transaction, data)
const txnId = result.txn?.id
if (!txnId) {
return { status: 'error', message: result.message }
}
// Update the manalink object with this info
const claim = {
toId: userId,
txnId,
claimedTime: Date.now(),
}
transaction.update(manalinkDoc, {
claimedUserIds: [...claimedUserIds, userId],
claims: [...manalink.claims, claim],
})
})
})
const firestore = admin.firestore()

View File

@ -5,84 +5,79 @@ import { User } from 'common/user'
import { Txn } from 'common/txn' import { Txn } from 'common/txn'
import { removeUndefinedProps } from 'common/util/object' import { removeUndefinedProps } from 'common/util/object'
export type TxnData = Omit<Txn, 'id' | 'createdTime'>
export const transact = functions export const transact = functions
.runWith({ minInstances: 1 }) .runWith({ minInstances: 1 })
.https.onCall(async (data: Omit<Txn, 'id' | 'createdTime'>, context) => { .https.onCall(async (data: TxnData, context) => {
const userId = context?.auth?.uid const userId = context?.auth?.uid
if (!userId) return { status: 'error', message: 'Not authorized' } if (!userId) return { status: 'error', message: 'Not authorized' }
const { amount, fromType, fromId, toId, toType, description } = data if (userId !== data.fromId) {
if (fromType !== 'USER')
return {
status: 'error',
message: "From type is only implemented for type 'user'.",
}
if (fromId !== userId)
return { return {
status: 'error', status: 'error',
message: 'Must be authenticated with userId equal to specified fromId.', message: 'Must be authenticated with userId equal to specified fromId.',
} }
}
if (amount <= 0 || isNaN(amount) || !isFinite(amount))
return { status: 'error', message: 'Invalid amount' }
// Run as transaction to prevent race conditions. // Run as transaction to prevent race conditions.
return await firestore.runTransaction(async (transaction) => { return await firestore.runTransaction(async (transaction) => {
const fromDoc = firestore.doc(`users/${userId}`) await runTxn(transaction, data)
const fromSnap = await transaction.get(fromDoc)
if (!fromSnap.exists) {
return { status: 'error', message: 'User not found' }
}
const fromUser = fromSnap.data() as User
if (fromUser.balance < amount) {
return {
status: 'error',
message: `Insufficient balance: ${fromUser.username} needed ${amount} but only had ${fromUser.balance} `,
}
}
if (toType === 'USER') {
const toDoc = firestore.doc(`users/${toId}`)
const toSnap = await transaction.get(toDoc)
if (!toSnap.exists) {
return { status: 'error', message: 'User not found' }
}
const toUser = toSnap.data() as User
transaction.update(toDoc, {
balance: toUser.balance + amount,
totalDeposits: toUser.totalDeposits + amount,
})
}
const newTxnDoc = firestore.collection(`txns/`).doc()
const txn: Txn = removeUndefinedProps({
id: newTxnDoc.id,
createdTime: Date.now(),
fromId,
fromType,
toId,
toType,
amount,
// TODO: Unhardcode once we have non-donation txns
token: 'M$',
category: 'CHARITY',
description,
})
transaction.create(newTxnDoc, txn)
transaction.update(fromDoc, {
balance: fromUser.balance - amount,
totalDeposits: fromUser.totalDeposits - amount,
})
return { status: 'success', txn }
}) })
}) })
export async function runTxn(
fbTransaction: admin.firestore.Transaction,
data: TxnData
) {
const { amount, fromType, fromId, toId, toType, description } = data
if (fromType !== 'USER')
return {
status: 'error',
message: "From type is only implemented for type 'user'.",
}
if (amount <= 0 || isNaN(amount) || !isFinite(amount))
return { status: 'error', message: 'Invalid amount' }
const fromDoc = firestore.doc(`users/${fromId}`)
const fromSnap = await fbTransaction.get(fromDoc)
if (!fromSnap.exists) {
return { status: 'error', message: 'User not found' }
}
const fromUser = fromSnap.data() as User
if (fromUser.balance < amount) {
return {
status: 'error',
message: `Insufficient balance: ${fromUser.username} needed ${amount} but only had ${fromUser.balance} `,
}
}
// TODO: Track payments received by charities, bank, contracts too.
if (toType === 'USER') {
const toDoc = firestore.doc(`users/${toId}`)
const toSnap = await fbTransaction.get(toDoc)
if (!toSnap.exists) {
return { status: 'error', message: 'User not found' }
}
const toUser = toSnap.data() as User
fbTransaction.update(toDoc, {
balance: toUser.balance + amount,
totalDeposits: toUser.totalDeposits + amount,
})
}
const newTxnDoc = firestore.collection(`txns/`).doc()
const txn: Txn = { id: newTxnDoc.id, createdTime: Date.now(), ...data }
fbTransaction.create(newTxnDoc, removeUndefinedProps(txn))
fbTransaction.update(fromDoc, {
balance: fromUser.balance - amount,
totalDeposits: fromUser.totalDeposits - amount,
})
return { status: 'success', txn }
}
const firestore = admin.firestore() const firestore = admin.firestore()

View File

@ -34,7 +34,7 @@ export async function createManalink(data: {
createdTime: Date.now(), createdTime: Date.now(),
expiresTime, expiresTime,
maxUses, maxUses,
successUserIds: [], claimedUserIds: [],
successes: [], successes: [],
failures: [], failures: [],
} }

View File

@ -138,7 +138,7 @@ function LinksTable(props: { links: Manalink[] }) {
{`http://manifold.markets/send/${manalink.slug}`} {`http://manifold.markets/send/${manalink.slug}`}
</td> </td>
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500"> <td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
{manalink.successUserIds.length} {manalink.claimedUserIds.length}
</td> </td>
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500"> <td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
{manalink.maxUses === Infinity ? '∞' : manalink.maxUses} {manalink.maxUses === Infinity ? '∞' : manalink.maxUses}