Compare commits
	
		
			31 Commits
		
	
	
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | 3a2598edfe | ||
|  | 4191969ab1 | ||
|  | a71c2226d2 | ||
|  | aa37d3afde | ||
|  | d2ed6f745e | ||
|  | 3b313f319c | ||
|  | d9a1ef7328 | ||
|  | 98ab5e27c2 | ||
|  | 68fb5b578c | ||
|  | ee3102d092 | ||
|  | 6ca04b911c | ||
|  | b28956d2e5 | ||
|  | 804a2bb357 | ||
|  | 297516d092 | ||
|  | 3b1a01f2f8 | ||
|  | 77c9d40751 | ||
|  | 9f9df5c4e9 | ||
|  | e687907fdd | ||
|  | 7e33c3a68f | ||
|  | 432575ae41 | ||
|  | 7ffef0294a | ||
|  | 37d5d5fc93 | ||
|  | 709ddfa7a9 | ||
|  | 5311718619 | ||
|  | 85c362d357 | ||
|  | de562799f4 | ||
|  | 17b9ccae83 | ||
|  | 52c4e829da | ||
|  | 97f6bddabc | ||
|  | c1a84e23e0 | ||
|  | 838798a553 | 
|  | @ -52,5 +52,10 @@ service cloud.firestore { | |||
|       allow read; | ||||
|       allow write: if request.auth.uid == userId; | ||||
|     } | ||||
| 
 | ||||
|     match /transactions/{transactionId} { | ||||
|       allow read; | ||||
|       allow write; | ||||
|     } | ||||
|   } | ||||
| } | ||||
							
								
								
									
										84
									
								
								functions/src/buy-leaderboard-slot.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										84
									
								
								functions/src/buy-leaderboard-slot.ts
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,84 @@ | |||
| import * as functions from 'firebase-functions' | ||||
| import * as admin from 'firebase-admin' | ||||
| 
 | ||||
| import { User } from '../../common/user' | ||||
| 
 | ||||
| export const buyLeaderboardSlot = functions | ||||
|   .runWith({ minInstances: 1 }) | ||||
|   .https.onCall( | ||||
|     async ( | ||||
|       data: { | ||||
|         slotId: string | ||||
|         reassessValue: number | ||||
|       }, | ||||
|       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) => { | ||||
|         const userDoc = firestore.doc(`users/${userId}`) | ||||
|         const userSnap = await transaction.get(userDoc) | ||||
|         if (!userSnap.exists) | ||||
|           return { status: 'error', message: 'User not found' } | ||||
|         const user = userSnap.data() as User | ||||
| 
 | ||||
|         const { slotId, reassessValue } = data | ||||
| 
 | ||||
|         // TODO: find most recent purchase of slotId.
 | ||||
|         // Fake data below:
 | ||||
|         const prevSlotPurchase = { | ||||
|           id: slotId, | ||||
|           reassessValue: 100, | ||||
|           userId: '', | ||||
|           timestamp: 0, | ||||
|         } | ||||
| 
 | ||||
|         if (prevSlotPurchase) { | ||||
|           const prevSlotUserDoc = firestore.doc( | ||||
|             `users/${prevSlotPurchase.userId}` | ||||
|           ) | ||||
|           const prevSlotUserSnap = await transaction.get(prevSlotUserDoc) | ||||
|           if (!prevSlotUserSnap.exists) | ||||
|             return { status: 'error', message: 'Previous slot owner not found' } | ||||
|           const prevSlotUser = prevSlotUserSnap.data() as User | ||||
| 
 | ||||
|           const timeSinceLastPurchase = Date.now() - prevSlotPurchase.timestamp | ||||
|           const hoursSinceLastPurchase = | ||||
|             timeSinceLastPurchase / (1000 * 60 * 60) | ||||
| 
 | ||||
|           const harbergerTax = | ||||
|             prevSlotPurchase.reassessValue * 0.1 * hoursSinceLastPurchase | ||||
|           const prevSlotUserBalance = prevSlotUser.balance - harbergerTax | ||||
|           if (!isFinite(prevSlotUserBalance)) { | ||||
|             throw new Error( | ||||
|               'Invalid user balance for previous slot owner ' + | ||||
|                 prevSlotUser.username | ||||
|             ) | ||||
|           } | ||||
|           transaction.update(prevSlotUserDoc, { balance: prevSlotUserBalance }) | ||||
|         } | ||||
| 
 | ||||
|         // TODO: If no prevSlotPurchase, use a default purchase price?
 | ||||
|         const newBalance = user.balance - prevSlotPurchase.reassessValue | ||||
|         if (!isFinite(newBalance)) { | ||||
|           throw new Error('Invalid user balance for ' + user.username) | ||||
|         } | ||||
|         transaction.update(userDoc, { balance: newBalance }) | ||||
| 
 | ||||
|         const newSlotPurchase = { | ||||
|           id: slotId, | ||||
|           reassessValue, | ||||
|           userId, | ||||
|           timestamp: Date.now(), | ||||
|         } | ||||
| 
 | ||||
|         // TODO: save doc newSlotPurchase in some collection.
 | ||||
| 
 | ||||
|         return { status: 'success', slotPurchase: newSlotPurchase } | ||||
|       }) | ||||
|     } | ||||
|   ) | ||||
| 
 | ||||
| const firestore = admin.firestore() | ||||
|  | @ -21,3 +21,4 @@ export * from './update-user-metrics' | |||
| export * from './backup-db' | ||||
| export * from './change-user-info' | ||||
| export * from './market-close-emails' | ||||
| export * from './buy-leaderboard-slot' | ||||
|  |  | |||
							
								
								
									
										315
									
								
								web/components/manaboard.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										315
									
								
								web/components/manaboard.tsx
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,315 @@ | |||
| import clsx from 'clsx' | ||||
| import React, { useEffect, useState } from 'react' | ||||
| import { ENV_CONFIG } from '../../common/envs/constants' | ||||
| 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 { | ||||
|   SlotData, | ||||
|   Transaction, | ||||
|   writeTransaction, | ||||
| } from '../lib/firebase/transactions' | ||||
| import { loadFakeBalance } from '../pages/leaderboards' | ||||
| import { AddFundsButton } from './add-funds-button' | ||||
| import { AmountInput } from './amount-input' | ||||
| import { Avatar } from './avatar' | ||||
| import { Col } from './layout/col' | ||||
| import { Modal } from './layout/modal' | ||||
| import { Row } from './layout/row' | ||||
| import { SiteLink } from './site-link' | ||||
| import { Title } from './title' | ||||
| 
 | ||||
| export function Manaboard(props: { | ||||
|   title: string | ||||
|   users: User[] | ||||
|   values: number[] | ||||
|   createdTimes: number[] | ||||
|   className?: string | ||||
| }) { | ||||
|   // TODO: Ideally, highlight your own entry on the leaderboard
 | ||||
|   let { title, users, className, values, createdTimes } = props | ||||
| 
 | ||||
|   const [expanded, setExpanded] = useState(false) | ||||
|   if (!expanded) { | ||||
|     users = users.slice(0, 25) | ||||
|     values = values.slice(0, 25) | ||||
|   } | ||||
| 
 | ||||
|   return ( | ||||
|     <div className={clsx('w-full px-1', className)}> | ||||
|       <Title text={title} className="!mt-0" /> | ||||
|       {users.length === 0 ? ( | ||||
|         <div className="ml-2 text-gray-500">None yet</div> | ||||
|       ) : ( | ||||
|         <div className="overflow-x-auto"> | ||||
|           <table className="table-zebra table-compact table w-full text-gray-500"> | ||||
|             <thead> | ||||
|               <tr className="p-2"> | ||||
|                 <th> | ||||
|                   <div className="pl-2">#</div> | ||||
|                 </th> | ||||
|                 <th>Name</th> | ||||
|                 <th>Value</th> | ||||
|                 <th></th> | ||||
|               </tr> | ||||
|             </thead> | ||||
|             <tbody> | ||||
|               {users.map((user, index) => ( | ||||
|                 <tr key={user.id + index}> | ||||
|                   <td> | ||||
|                     <div className="pl-2">{index + 1}</div> | ||||
|                   </td> | ||||
|                   <td className="w-full" style={{ maxWidth: 190 }}> | ||||
|                     <Row className="items-center gap-4"> | ||||
|                       <SiteLink href={`/${user.username}`}> | ||||
|                         <Avatar | ||||
|                           username={user.username} | ||||
|                           avatarUrl={user.avatarUrl} | ||||
|                           size={8} | ||||
|                         /> | ||||
|                       </SiteLink> | ||||
|                       <div | ||||
|                         className={clsx( | ||||
|                           'truncate', | ||||
|                           createdTimes[index] | ||||
|                             ? 'text-gray-600' | ||||
|                             : 'text-gray-300' | ||||
|                         )} | ||||
|                       > | ||||
|                         {user.name} | ||||
|                       </div> | ||||
|                     </Row> | ||||
|                   </td> | ||||
|                   <td> | ||||
|                     <Row className="items-center gap-4"> | ||||
|                       {formatMoney(values[index])} | ||||
|                       <BuySlotModal | ||||
|                         slot={index + 1} | ||||
|                         title={`${title}`} | ||||
|                         holder={user} | ||||
|                         value={values[index]} | ||||
|                         createdTime={createdTimes[index]} | ||||
|                         allSlots={users} | ||||
|                       /> | ||||
|                     </Row> | ||||
|                   </td> | ||||
|                   <td></td> | ||||
|                 </tr> | ||||
|               ))} | ||||
|             </tbody> | ||||
|           </table> | ||||
|           <button | ||||
|             className="btn btn-sm btn-outline m-2" | ||||
|             onClick={() => setExpanded(!expanded)} | ||||
|           > | ||||
|             {expanded ? 'Fewer slots' : 'More slots'} | ||||
|           </button> | ||||
|         </div> | ||||
|       )} | ||||
|     </div> | ||||
|   ) | ||||
| } | ||||
| 
 | ||||
| function Label(props: { children: React.ReactNode }) { | ||||
|   return <label className="-mb-3 text-sm">{props.children}</label> | ||||
| } | ||||
| 
 | ||||
| export function BuySlotModal(props: { | ||||
|   title: string | ||||
|   holder: User | ||||
|   slot: number | ||||
|   value: number | ||||
|   createdTime: number | ||||
|   allSlots: User[] | ||||
| }) { | ||||
|   const { slot, allSlots, holder, value, createdTime } = props | ||||
|   const user = useUser() | ||||
| 
 | ||||
|   const [open, setOpen] = useState(false) | ||||
|   const [newValue, setNewValue] = useState(value) | ||||
|   const [message, setMessage] = useState('') | ||||
|   useEffect(() => { | ||||
|     if (user?.name) { | ||||
|       setMessage(user.name) | ||||
|     } | ||||
|   }, [user]) | ||||
| 
 | ||||
|   // const onBuy = async () => {
 | ||||
|   //   // Feel free to change this. - James
 | ||||
|   //   const slotId = `${title}-${slot}`
 | ||||
|   //   await buyLeaderboardSlot({ slotId, reassessValue: newValue })
 | ||||
|   // }
 | ||||
| 
 | ||||
|   // Find all slots the user currently owns
 | ||||
|   const existingSlots = [] | ||||
|   for (let i = 0; i < allSlots.length; i++) { | ||||
|     if (allSlots[i].id === user?.id) { | ||||
|       existingSlots.push(i + 1) | ||||
|     } | ||||
|   } | ||||
|   // Prevent them from holding more than three slots at once
 | ||||
|   let errorMsg = '' | ||||
|   if (existingSlots.length >= 3 && !existingSlots.includes(slot)) { | ||||
|     errorMsg = 'Sell another slot first (by re-valuing it to M$ 0)' | ||||
|   } | ||||
| 
 | ||||
|   async function onBuy() { | ||||
|     if (user) { | ||||
|       // Start transactions, but don't block
 | ||||
|       const buyData = { slot, newValue, message } | ||||
|       const buyTxn = buyTransaction({ | ||||
|         buyer: user, | ||||
|         holder, | ||||
|         amount: value, | ||||
|         buyData, | ||||
|       }) | ||||
|       await Promise.all([ | ||||
|         writeTransaction(buyTxn), | ||||
|         writeTransaction(taxTransaction({ holder, slot, value, createdTime })), | ||||
|       ]) | ||||
| 
 | ||||
|       setOpen(false) | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   const fakeBalance = loadFakeBalance() | ||||
|   const noFundsMsg = | ||||
|     value > fakeBalance && holder.id !== user?.id | ||||
|       ? `You only have ${formatMoney(fakeBalance)}!` | ||||
|       : '' | ||||
| 
 | ||||
|   return ( | ||||
|     <> | ||||
|       <Modal open={open} setOpen={setOpen}> | ||||
|         <Col className="gap-5 rounded-md bg-white p-6 text-gray-500"> | ||||
|           <Title text={`Buy slot #${slot}`} className="!mt-0" /> | ||||
| 
 | ||||
|           <Label>Current value: {formatMoney(value)}</Label> | ||||
|           {user && ( | ||||
|             <Row className="items-center gap-4 rounded-md bg-gray-100 p-2 text-sm"> | ||||
|               <div className="pl-2">{slot}</div> | ||||
|               <Avatar avatarUrl={user.avatarUrl} size={8} /> | ||||
|               <div className="truncate">{message}</div> | ||||
|             </Row> | ||||
|           )} | ||||
| 
 | ||||
|           <Label>(Optional) set message</Label> | ||||
|           <input | ||||
|             type="text" | ||||
|             className="input input-bordered w-full max-w-xs" | ||||
|             onChange={(e) => { | ||||
|               setMessage(e.target.value) | ||||
|             }} | ||||
|             value={message} | ||||
|           /> | ||||
| 
 | ||||
|           <Label>Reassess value</Label> | ||||
|           <AmountInput | ||||
|             amount={newValue} | ||||
|             onChange={(amount) => | ||||
|               setNewValue(amount && amount >= 1 ? amount : 0) | ||||
|             } | ||||
|             error={errorMsg} | ||||
|             label={ENV_CONFIG.moneyMoniker} | ||||
|           /> | ||||
| 
 | ||||
|           {noFundsMsg ? ( | ||||
|             <div className="alert alert-error"> | ||||
|               {noFundsMsg}{' '} | ||||
|               <span className="!text-gray-600"> | ||||
|                 <AddFundsButton /> | ||||
|               </span> | ||||
|             </div> | ||||
|           ) : ( | ||||
|             <Col> | ||||
|               <button | ||||
|                 className="btn btn-primary" | ||||
|                 onClick={onBuy} | ||||
|                 disabled={!!errorMsg} | ||||
|               > | ||||
|                 Buy Slot ({formatMoney(value)}) | ||||
|               </button> | ||||
|               <div className="mt-2 text-sm"> | ||||
|                 Additional fees: {formatMoney(newValue * 0.25)} per hour | ||||
|               </div> | ||||
|             </Col> | ||||
|           )} | ||||
|         </Col> | ||||
|       </Modal> | ||||
|       <button | ||||
|         className="btn btn-outline btn-sm normal-case" | ||||
|         onClick={() => setOpen(true)} | ||||
|       > | ||||
|         Buy | ||||
|       </button> | ||||
|     </> | ||||
|   ) | ||||
| } | ||||
| 
 | ||||
| function buyTransaction(options: { | ||||
|   buyer: User | ||||
|   holder: User | ||||
|   buyData: SlotData | ||||
|   amount: number | ||||
| }): Transaction { | ||||
|   const { buyer, holder, buyData, amount } = options | ||||
|   return { | ||||
|     id: '', | ||||
|     createdTime: Date.now(), | ||||
| 
 | ||||
|     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, | ||||
| 
 | ||||
|     category: 'BUY_LEADERBOARD_SLOT', | ||||
|     description: `${buyer.name} bought a slot from ${holder.name}`, | ||||
|     data: buyData, | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| function taxTransaction(options: { | ||||
|   holder: User | ||||
|   slot: number | ||||
|   value: number | ||||
|   createdTime: number | ||||
| }): Transaction { | ||||
|   const { holder, slot, value, createdTime } = options | ||||
| 
 | ||||
|   const APRIL_FOOLS_9AM_PT = 1648828800000 | ||||
|   const elapsedMs = Date.now() - (createdTime || APRIL_FOOLS_9AM_PT) | ||||
|   const elapsedHours = elapsedMs / 1000 / 60 / 60 | ||||
|   const tax = elapsedHours * value * 0.25 | ||||
| 
 | ||||
|   return { | ||||
|     id: '', | ||||
|     createdTime: Date.now(), | ||||
| 
 | ||||
|     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: tax, | ||||
| 
 | ||||
|     category: 'LEADERBOARD_TAX', | ||||
|     description: `${holder.name} paid M$ 10 in fees`, | ||||
|     data: { | ||||
|       slot, | ||||
|     }, | ||||
|   } | ||||
| } | ||||
|  | @ -3,6 +3,7 @@ import { formatMoney } from '../../../common/util/format' | |||
| import { Avatar } from '../avatar' | ||||
| import { IS_PRIVATE_MANIFOLD } from '../../../common/envs/constants' | ||||
| import { Row } from '../layout/row' | ||||
| import { loadFakeBalance } from '../../pages/leaderboards' | ||||
| 
 | ||||
| export function getNavigationOptions(user?: User | null) { | ||||
|   if (IS_PRIVATE_MANIFOLD) { | ||||
|  | @ -34,7 +35,9 @@ export function ProfileSummary(props: { user: User | undefined }) { | |||
|       <div className="truncate text-left"> | ||||
|         <div>{user?.name}</div> | ||||
|         <div className="text-sm"> | ||||
|           {user ? formatMoney(Math.floor(user.balance)) : ' '} | ||||
|           {user | ||||
|             ? formatMoney(Math.floor(loadFakeBalance() || user.balance)) | ||||
|             : ' '} | ||||
|         </div> | ||||
|       </div> | ||||
|     </Row> | ||||
|  |  | |||
|  | @ -4,6 +4,7 @@ import { | |||
|   SearchIcon, | ||||
|   BookOpenIcon, | ||||
|   DotsHorizontalIcon, | ||||
|   ChartSquareBarIcon, | ||||
| } from '@heroicons/react/outline' | ||||
| import clsx from 'clsx' | ||||
| import _ from 'lodash' | ||||
|  | @ -19,6 +20,7 @@ import { getNavigationOptions, ProfileSummary } from './profile-menu' | |||
| const navigation = [ | ||||
|   { name: 'Home', href: '/home', icon: HomeIcon }, | ||||
|   { name: 'Markets', href: '/markets', icon: SearchIcon }, | ||||
|   { name: 'Leaderboards', href: '/leaderboards', icon: ChartSquareBarIcon }, | ||||
|   { name: 'About', href: 'https://docs.manifold.markets', icon: BookOpenIcon }, | ||||
| ] | ||||
| 
 | ||||
|  |  | |||
							
								
								
									
										11
									
								
								web/hooks/use-transactions.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								web/hooks/use-transactions.ts
									
									
									
									
									
										Normal file
									
								
							|  | @ -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 | ||||
| } | ||||
|  | @ -66,3 +66,8 @@ export const changeUserInfo = (data: { | |||
|     .then((r) => r.data as { status: string; message?: string }) | ||||
|     .catch((e) => ({ status: 'error', message: e.message })) | ||||
| } | ||||
| 
 | ||||
| export const buyLeaderboardSlot = cloudFunction< | ||||
|   { slotId: string; reassessValue: number }, | ||||
|   { status: 'success' | 'error'; message?: string } | ||||
| >('buyLeaderboardSlot') | ||||
|  |  | |||
							
								
								
									
										57
									
								
								web/lib/firebase/transactions.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										57
									
								
								web/lib/firebase/transactions.ts
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,57 @@ | |||
| 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 | ||||
| } | ||||
| 
 | ||||
| export type SlotData = { | ||||
|   slot: number | ||||
|   newValue: number | ||||
|   message: string | ||||
| } | ||||
| 
 | ||||
| export 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) | ||||
| } | ||||
|  | @ -160,12 +160,12 @@ export function listenForPrivateUsers( | |||
| const topTradersQuery = query( | ||||
|   collection(db, 'users'), | ||||
|   orderBy('totalPnLCached', 'desc'), | ||||
|   limit(21) | ||||
|   limit(51) | ||||
| ) | ||||
| 
 | ||||
| export async function getTopTraders() { | ||||
|   const users = await getValues<User>(topTradersQuery) | ||||
|   return users.slice(0, 20) | ||||
|   return users.slice(0, 50) | ||||
| } | ||||
| 
 | ||||
| const topCreatorsQuery = query( | ||||
|  |  | |||
|  | @ -20,6 +20,7 @@ | |||
|     "@heroicons/react": "1.0.5", | ||||
|     "@nivo/core": "0.74.0", | ||||
|     "@nivo/line": "0.74.0", | ||||
|     "@widgetbot/react-embed": "^1.4.0", | ||||
|     "clsx": "1.1.1", | ||||
|     "daisyui": "1.16.4", | ||||
|     "dayjs": "1.10.7", | ||||
|  |  | |||
|  | @ -6,6 +6,18 @@ import { Page } from '../components/page' | |||
| import { getTopCreators, getTopTraders, User } from '../lib/firebase/users' | ||||
| 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 { SlotData, Transaction } from '../lib/firebase/transactions' | ||||
| 
 | ||||
| import { Grid, _ as r } from 'gridjs-react' | ||||
| import 'gridjs/dist/theme/mermaid.css' | ||||
| import { html } from 'gridjs' | ||||
| import dayjs from 'dayjs' | ||||
| import { useUser } from '../hooks/use-user' | ||||
| import { useState } from 'react' | ||||
| import WidgetBot from '@widgetbot/react-embed' | ||||
| 
 | ||||
| export const getStaticProps = fromPropz(getStaticPropz) | ||||
| export async function getStaticPropz() { | ||||
|  | @ -24,10 +36,7 @@ export async function getStaticPropz() { | |||
|   } | ||||
| } | ||||
| 
 | ||||
| export default function Leaderboards(props: { | ||||
|   topTraders: User[] | ||||
|   topCreators: User[] | ||||
| }) { | ||||
| function Leaderboards(props: { topTraders: User[]; topCreators: User[] }) { | ||||
|   props = usePropz(props, getStaticPropz) ?? { | ||||
|     topTraders: [], | ||||
|     topCreators: [], | ||||
|  | @ -61,3 +70,270 @@ export default function Leaderboards(props: { | |||
|     </Page> | ||||
|   ) | ||||
| } | ||||
| 
 | ||||
| function Explanation() { | ||||
|   return ( | ||||
|     <div className="prose mt-8 text-gray-600"> | ||||
|       <h3 id="how-this-works">How this works</h3> | ||||
|       <ol> | ||||
|         <li> | ||||
|           Every slot has an "assessed value": what the current holder | ||||
|           thinks their slot is worth. | ||||
|         </li> | ||||
|         <li>Slot holders pay a continuous fee of 25% per hour to Manafold.</li> | ||||
|         <li> | ||||
|           At any time, you can pay the assessed value of a slot to buy it from | ||||
|           the current holder. | ||||
|         </li> | ||||
|         <li> | ||||
|           The slot is now yours! You can customize the message, or reassess it | ||||
|           to a new value. | ||||
|         </li> | ||||
|       </ol> | ||||
|       <p> | ||||
|         <em> | ||||
|           Note: this mechanism is known as a{' '} | ||||
|           <a href="https://medium.com/@simondlr/what-is-harberger-tax-where-does-the-blockchain-fit-in-1329046922c6"> | ||||
|             Harberger Tax | ||||
|           </a> | ||||
|           ! | ||||
|         </em> | ||||
|       </p> | ||||
| 
 | ||||
|       <h3 id="where-did-manafold-s-mana-go-"> | ||||
|         Where did Manafold's mana go? | ||||
|       </h3> | ||||
|       <p> | ||||
|         Honestly, we're as puzzled as you are. Leading theories include: | ||||
|       </p> | ||||
|       <ul> | ||||
|         <li>Leaky abstractions in our manabase</li> | ||||
|         <li>One too many floating-point rounding errors</li> | ||||
|         <li> | ||||
|           Our newest user <code>Robert');DROP TABLE Balances;--</code> | ||||
|         </li> | ||||
|       </ul> | ||||
|       <p> | ||||
|         We'd be happy to pay a bounty to anyone who can help us solve this | ||||
|         riddle! Oh wait... | ||||
|       </p> | ||||
|     </div> | ||||
|   ) | ||||
| } | ||||
| 
 | ||||
| // TODOs
 | ||||
| // [ ] Expandable text for explainer
 | ||||
| // [ ] Draw attention to leaderboard
 | ||||
| // [ ] Restrict to at most buying one slot per user?
 | ||||
| export default function Manaboards(props: { | ||||
|   topTraders: User[] | ||||
|   topCreators: User[] | ||||
| }) { | ||||
|   props = usePropz(props, getStaticPropz) ?? { | ||||
|     topTraders: [], | ||||
|     topCreators: [], | ||||
|   } | ||||
|   const { topTraders, topCreators } = props | ||||
|   const slots = _.clone(topTraders) | ||||
|   const user = useUser() | ||||
| 
 | ||||
|   const values = Array.from(Array(slots.length).keys()) | ||||
|     .map((i) => i + 1) | ||||
|     .reverse() | ||||
|   const createdTimes = new Array(slots.length).fill(0) | ||||
| 
 | ||||
|   // Find the most recent purchases of each slot, and replace the entries in slots
 | ||||
|   const txns = useTransactions() ?? [] | ||||
|   // Iterate from oldest to newest transactions, so recent purchases overwrite older ones
 | ||||
|   const sortedTxns = _.sortBy(txns, 'createdTime') | ||||
|   for (const txn of sortedTxns) { | ||||
|     if (txn.category === 'BUY_LEADERBOARD_SLOT') { | ||||
|       const buyer = userFromBuy(txn) | ||||
|       const data = txn.data as SlotData | ||||
|       const slot = data.slot | ||||
|       slots[slot - 1] = buyer | ||||
|       values[slot - 1] = data.newValue | ||||
|       createdTimes[slot - 1] = txn.createdTime | ||||
| 
 | ||||
|       // If new value is 0, that's a sell; reset to topTrader
 | ||||
|       if (data.newValue === 0) { | ||||
|         slots[slot - 1] = topTraders[slot - 1] | ||||
|         values[slot - 1] = 50 - slot | ||||
|         createdTimes[slot - 1] = 0 | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   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, | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   const MANIFOLD_ID = 'IPTOzEqrpkWmEzh6hwvAyY9PqFb2' | ||||
|   if (user?.balance) { | ||||
|     saveFakeBalance(userProfits(user.id, txns) + user.balance) | ||||
|   } | ||||
| 
 | ||||
|   const [expandInfo, setExpandInfo] = useState(false) | ||||
| 
 | ||||
|   return ( | ||||
|     <Page margin rightSidebar={<DiscordWidget />}> | ||||
|       <Title text={'🏅 Leaderboard slots, for sale!'} /> | ||||
|       {/* <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 out of mana... so we're selling our | ||||
|           leaderboard slots to recoup our losses. Buy one now to earn fleeting | ||||
|           glory and keep Manafold afloat! | ||||
|         </p> | ||||
|         <div className="alert alert-success"> | ||||
|           Mana replenished: {formatMoney(userProfits(MANIFOLD_ID, txns))} | ||||
|           <button | ||||
|             className="btn btn-outline btn-sm normal-case" | ||||
|             onClick={() => setExpandInfo(!expandInfo)} | ||||
|           > | ||||
|             More info | ||||
|           </button> | ||||
|         </div> | ||||
|         {expandInfo && <Explanation />} | ||||
|       </div> | ||||
| 
 | ||||
|       <Col className="mt-6 gap-10"> | ||||
|         <Manaboard | ||||
|           title="" | ||||
|           users={slots} | ||||
|           values={values} | ||||
|           createdTimes={createdTimes} | ||||
|         /> | ||||
|         {/* <Manaboard title="🏅 Top creators" users={topCreators} /> */} | ||||
| 
 | ||||
|         <div className="text-sm"> | ||||
|           <Title text={'Transaction history'} /> | ||||
|           {user && ( | ||||
|             <p>Your earnings: {formatMoney(userProfits(user.id, txns))}</p> | ||||
|           )} | ||||
|           <TransactionsTable txns={_.reverse(sortedTxns)} /> | ||||
|         </div> | ||||
|       </Col> | ||||
|     </Page> | ||||
|   ) | ||||
| } | ||||
| 
 | ||||
| function userProfits(userId: string, txns: Transaction[]) { | ||||
|   const losses = txns.filter((txn) => txn.fromId === userId) | ||||
|   const loss = _.sumBy(losses, (txn) => txn.amount) | ||||
|   const profits = txns.filter((txn) => txn.toId === userId) | ||||
|   const profit = _.sumBy(profits, (txn) => txn.amount) | ||||
|   return profit - loss | ||||
| } | ||||
| 
 | ||||
| // Cache user's transaction profits to localStorage
 | ||||
| const FAKE_BALANCE_KEY = 'fake-balance' | ||||
| export function saveFakeBalance(profit: number) { | ||||
|   localStorage.setItem(FAKE_BALANCE_KEY, JSON.stringify(profit)) | ||||
| } | ||||
| 
 | ||||
| export function loadFakeBalance() { | ||||
|   if (typeof window !== 'undefined') { | ||||
|     const profit = localStorage.getItem(FAKE_BALANCE_KEY) | ||||
|     return profit ? JSON.parse(profit) : 0 | ||||
|   } | ||||
|   return 0 | ||||
| } | ||||
| 
 | ||||
| function TransactionsTable(props: { txns: Transaction[] }) { | ||||
|   const { txns } = props | ||||
|   return ( | ||||
|     <Grid | ||||
|       data={txns} | ||||
|       search | ||||
|       // sort={true}
 | ||||
|       pagination={{ | ||||
|         enabled: true, | ||||
|         limit: 25, | ||||
|       }} | ||||
|       columns={[ | ||||
|         { | ||||
|           id: 'data', | ||||
|           name: 'Slot', | ||||
|           formatter: (cell) => (cell as SlotData).slot, | ||||
|         }, | ||||
|         { | ||||
|           id: 'category', | ||||
|           name: 'Type', | ||||
|           formatter: (cell, row) => { | ||||
|             if (cell === 'LEADERBOARD_TAX') { | ||||
|               return 'Tax' | ||||
|             } | ||||
| 
 | ||||
|             // If newValue === 0
 | ||||
|             // @ts-ignore
 | ||||
|             if (row.cells[6].data?.newValue === 0) { | ||||
|               return 'Sell' | ||||
|             } | ||||
| 
 | ||||
|             // If fromUser === toUser
 | ||||
|             if (row.cells[3].data === row.cells[4].data) { | ||||
|               return 'Edit' | ||||
|             } | ||||
| 
 | ||||
|             return 'Buy' | ||||
|           }, | ||||
|         }, | ||||
|         { | ||||
|           id: 'amount', | ||||
|           name: 'Transfer', | ||||
|           formatter: (cell) => formatMoney(cell as number), | ||||
|         }, | ||||
|         { | ||||
|           id: 'fromUsername', | ||||
|           name: 'From', | ||||
|         }, | ||||
|         { id: 'toUsername', name: 'To' }, | ||||
|         { | ||||
|           id: 'createdTime', | ||||
|           name: 'Time', | ||||
|           formatter: (cell) => | ||||
|             html( | ||||
|               `<span class="whitespace-nowrap">${dayjs(cell as number).format( | ||||
|                 'h:mma' | ||||
|               )}</span>` | ||||
|             ), | ||||
|         }, | ||||
|         { | ||||
|           hidden: true, | ||||
|           id: 'data', | ||||
|           name: 'New Value', | ||||
|           formatter: (cell) => (cell as SlotData).newValue ?? '', | ||||
|         }, | ||||
|       ]} | ||||
|     /> | ||||
|   ) | ||||
| } | ||||
| 
 | ||||
| function DiscordWidget() { | ||||
|   return typeof window === 'undefined' ? null : ( | ||||
|     <WidgetBot | ||||
|       className="mt-4 h-[80vh]" | ||||
|       server="915138780216823849" | ||||
|       channel="959499868089507930" | ||||
|     /> | ||||
|   ) | ||||
| } | ||||
|  |  | |||
							
								
								
									
										24
									
								
								yarn.lock
									
									
									
									
									
								
							
							
						
						
									
										24
									
								
								yarn.lock
									
									
									
									
									
								
							|  | @ -1112,6 +1112,19 @@ | |||
|     "@typescript-eslint/types" "4.33.0" | ||||
|     eslint-visitor-keys "^2.0.0" | ||||
| 
 | ||||
| "@widgetbot/embed-api@^1.1.3": | ||||
|   version "1.1.3" | ||||
|   resolved "https://registry.yarnpkg.com/@widgetbot/embed-api/-/embed-api-1.1.3.tgz#c7fd8069d7ce2ec7740d8bf4140c786c636fb3d6" | ||||
|   integrity sha1-x/2AadfOLsd0DYv0FAx4bGNvs9Y= | ||||
| 
 | ||||
| "@widgetbot/react-embed@^1.4.0": | ||||
|   version "1.4.0" | ||||
|   resolved "https://registry.yarnpkg.com/@widgetbot/react-embed/-/react-embed-1.4.0.tgz#b0b617629e0e2cd6ff7a4770db34e0c52e056a43" | ||||
|   integrity sha512-rN/zyv8ndn+I3g1fCMql2NN+2Yn04XVhwL1GHQlSKEvFWNXsqEDyXO1MaDxcvJFcG7cSQLRTcvgVWzAVe+3Fag== | ||||
|   dependencies: | ||||
|     "@widgetbot/embed-api" "^1.1.3" | ||||
|     react "^16.13.1" | ||||
| 
 | ||||
| abort-controller@^3.0.0: | ||||
|   version "3.0.0" | ||||
|   resolved "https://registry.yarnpkg.com/abort-controller/-/abort-controller-3.0.0.tgz#eaf54d53b62bae4138e809ca225c8439a6efb392" | ||||
|  | @ -4303,7 +4316,7 @@ promisify-call@^2.0.2: | |||
|   dependencies: | ||||
|     with-callback "^1.0.2" | ||||
| 
 | ||||
| prop-types@^15.5.8, prop-types@^15.7.2: | ||||
| prop-types@^15.5.8, prop-types@^15.6.2, prop-types@^15.7.2: | ||||
|   version "15.8.1" | ||||
|   resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5" | ||||
|   integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg== | ||||
|  | @ -4493,6 +4506,15 @@ react@17.0.2: | |||
|     loose-envify "^1.1.0" | ||||
|     object-assign "^4.1.1" | ||||
| 
 | ||||
| react@^16.13.1: | ||||
|   version "16.14.0" | ||||
|   resolved "https://registry.yarnpkg.com/react/-/react-16.14.0.tgz#94d776ddd0aaa37da3eda8fc5b6b18a4c9a3114d" | ||||
|   integrity sha512-0X2CImDkJGApiAlcf0ODKIneSwBPhqJawOa5wCtKbu7ZECrmS26NvtSILynQ66cgkT/RJ4LidJOc3bUESwmU8g== | ||||
|   dependencies: | ||||
|     loose-envify "^1.1.0" | ||||
|     object-assign "^4.1.1" | ||||
|     prop-types "^15.6.2" | ||||
| 
 | ||||
| readable-stream@1.1.x: | ||||
|   version "1.1.14" | ||||
|   resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.1.14.tgz#7cf4c54ef648e3813084c636dd2079e166c081d9" | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue
	
	Block a user