diff --git a/firestore.rules b/firestore.rules index 8c419b01..355fca1f 100644 --- a/firestore.rules +++ b/firestore.rules @@ -52,5 +52,10 @@ service cloud.firestore { allow read; allow write: if request.auth.uid == userId; } + + match /transactions/{transactionId} { + allow read; + allow write; + } } } \ No newline at end of file diff --git a/web/components/manaboard.tsx b/web/components/manaboard.tsx index a61da238..a4bfeccc 100644 --- a/web/components/manaboard.tsx +++ b/web/components/manaboard.tsx @@ -5,6 +5,7 @@ import { User } from '../../common/user' import { formatMoney } from '../../common/util/format' import { useUser } from '../hooks/use-user' import { buyLeaderboardSlot } from '../lib/firebase/api-call' +import { Transaction, writeTransaction } from '../lib/firebase/transactions' import { AmountInput } from './amount-input' import { Avatar } from './avatar' import { Col } from './layout/col' @@ -104,10 +105,7 @@ export function BuySlotModal(props: { <> - + <Title text={`Buy slot #${slot}`} className="!mt-0" /> <Label>Current value: {formatMoney(value)}</Label> {user && ( @@ -136,7 +134,15 @@ export function BuySlotModal(props: { label={ENV_CONFIG.moneyMoniker} /> - <button className="btn btn-primary"> + <button + className="btn btn-primary" + onClick={() => { + if (user) { + buySlot({ holder, buyer: user, amount: value, slot, message }) + setOpen(false) + } + }} + > Buy Slot ({formatMoney(value)}) </button> <div className="-mt-2 text-sm"> @@ -153,3 +159,65 @@ export function BuySlotModal(props: { </> ) } + +async function buySlot(options: { + holder: User + buyer: User + amount: number + slot: number + message: string +}) { + const { holder, buyer, amount, slot, message } = options + const createdTime = Date.now() + const buyTransaction: Transaction = { + id: '', + createdTime, + + fromId: buyer.id, + fromName: buyer.name, + fromUsername: buyer.username, + fromAvatarUrl: buyer.avatarUrl, + + toId: holder.id, + toName: holder.name, + toUsername: holder.username, + toAvatarUrl: holder.avatarUrl, + + amount: amount, + + category: 'BUY_LEADERBOARD_SLOT', + description: `${buyer.name} bought a slot from ${holder.name}`, + data: { + slot, + message, + }, + } + + const feeTransaction: Transaction = { + id: '', + createdTime, + + fromId: holder.id, + fromName: holder.name, + fromUsername: holder.username, + fromAvatarUrl: holder.avatarUrl, + + // Send fee to Manifold Markets official account + toId: 'IPTOzEqrpkWmEzh6hwvAyY9PqFb2', + toName: 'Manifold Markets', + toUsername: 'ManifoldMarkets', + toAvatarUrl: 'https://manifold.markets/logo-bg-white.png', + + amount: 10, // TODO: Calculate fee + category: 'LEADERBOARD_TAX', + description: `${holder.name} paid M$ 10 in fees`, + data: { + slot, + }, + } + + await Promise.all([ + writeTransaction(buyTransaction), + writeTransaction(feeTransaction), + ]) +} diff --git a/web/hooks/use-transactions.ts b/web/hooks/use-transactions.ts new file mode 100644 index 00000000..aee51394 --- /dev/null +++ b/web/hooks/use-transactions.ts @@ -0,0 +1,11 @@ +import { useState, useEffect } from 'react' +import { + listenForTransactions, + Transaction, +} from '../lib/firebase/transactions' + +export const useTransactions = () => { + const [transactions, setTransactions] = useState<Transaction[] | undefined>() + useEffect(() => listenForTransactions(setTransactions), []) + return transactions +} diff --git a/web/lib/firebase/transactions.ts b/web/lib/firebase/transactions.ts new file mode 100644 index 00000000..a0db8514 --- /dev/null +++ b/web/lib/firebase/transactions.ts @@ -0,0 +1,56 @@ +import { collection, doc, query, setDoc } from 'firebase/firestore' +import { db } from './init' +import { getValues, listenForValues } from './utils' + +export type Transaction = { + id: string + createdTime: number + + fromId: string + fromName: string + fromUsername: string + fromAvatarUrl?: string + + toId: string + toName: string + toUsername: string + toAvatarUrl?: string + + amount: number + + category: 'BUY_LEADERBOARD_SLOT' | 'LEADERBOARD_TAX' + // Human-readable description + description?: string + // Structured metadata for different kinds of transactions + data?: SlotData | TaxData +} + +type SlotData = { + slot: number + message: string +} + +type TaxData = { + slot: number +} + +export async function listAllTransactions() { + const col = collection(db, 'transactions') + const transactions = await getValues<Transaction>(col) + transactions.sort((t1, t2) => t1.createdTime - t2.createdTime) + return transactions +} + +export function listenForTransactions(setTxns: (txns: Transaction[]) => void) { + const col = collection(db, 'transactions') + const queryAll = query(col) + return listenForValues<Transaction>(queryAll, setTxns) +} + +export async function writeTransaction(transaction: Transaction) { + const col = collection(db, 'transactions') + const newRef = doc(col) + transaction.id = newRef.id + + await setDoc(newRef, transaction) +} diff --git a/web/pages/leaderboards.tsx b/web/pages/leaderboards.tsx index 5cda33e6..fce0deab 100644 --- a/web/pages/leaderboards.tsx +++ b/web/pages/leaderboards.tsx @@ -8,6 +8,8 @@ import { formatMoney } from '../../common/util/format' import { fromPropz, usePropz } from '../hooks/use-propz' import { Manaboard } from '../components/manaboard' import { Title } from '../components/title' +import { useTransactions } from '../hooks/use-transactions' +import { Transaction } from '../lib/firebase/transactions' export const getStaticProps = fromPropz(getStaticPropz) export async function getStaticPropz() { @@ -92,7 +94,12 @@ function Explanation() { </div> ) } - +// TODOs +// [ ] Correctly calculate tax +// [ ] List history of purchases at the bottom +// [ ] Restrict to at most buying one slot per user? +// [ ] Set to 50 top traders +// [ ] Deduct amount from user's balance, either in UX or for real export default function Manaboards(props: { topTraders: User[] topCreators: User[] @@ -103,9 +110,46 @@ export default function Manaboards(props: { } const { topTraders, topCreators } = props + // Find the most recent purchases of each slot, and replace the entries in topTraders + const transactions = useTransactions() ?? [] + // Iterate from oldest to newest transactions, so recent purchases overwrite older ones + const sortedTxns = _.sortBy(transactions, 'createdTime') + for (const txn of sortedTxns) { + if (txn.category === 'BUY_LEADERBOARD_SLOT') { + const buyer = userFromBuy(txn) + const slot = txn.data?.slot ?? 0 + topTraders[slot - 1] = buyer + } + } + + console.log('sorted txn', sortedTxns) + + function userFromBuy(txn: Transaction): User { + return { + id: txn.fromId, + // @ts-ignore + name: txn.data?.message ?? txn.fromName, + username: txn.fromUsername, + avatarUrl: txn.fromAvatarUrl, + + // Dummy data which shouldn't be relied on + createdTime: 0, + creatorVolumeCached: 0, + totalPnLCached: 0, + balance: 0, + totalDeposits: 0, + } + } + return ( <Page margin rightSidebar={<Explanation />}> - <Title text={'Leaderboards (FOR SALE!)'} /> + <Title text={'🏅 Leaderboards'} /> + {/* <div className="absolute right-[700px] top-8"> + <img + className="h-18 mx-auto w-24 object-cover transition hover:rotate-12" + src="https://i.etsystatic.com/8800089/r/il/b79fe6/1591362635/il_fullxfull.1591362635_4523.jpg" + /> + </div> */} <div className="prose mb-8 text-gray-600"> <p> Manafold Markets is running low on mana, so we're selling our @@ -115,8 +159,8 @@ export default function Manaboards(props: { </div> <Col className="mt-6 items-center gap-10"> - <Manaboard title="🏅 Top traders" users={topTraders} /> - <Manaboard title="🏅 Top creators" users={topCreators} /> + <Manaboard title="" users={topTraders} /> + {/* <Manaboard title="🏅 Top creators" users={topCreators} /> */} </Col> </Page> )