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 read;
|
||||||
allow write: if request.auth.uid == userId;
|
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 './backup-db'
|
||||||
export * from './change-user-info'
|
export * from './change-user-info'
|
||||||
export * from './market-close-emails'
|
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 { Avatar } from '../avatar'
|
||||||
import { IS_PRIVATE_MANIFOLD } from '../../../common/envs/constants'
|
import { IS_PRIVATE_MANIFOLD } from '../../../common/envs/constants'
|
||||||
import { Row } from '../layout/row'
|
import { Row } from '../layout/row'
|
||||||
|
import { loadFakeBalance } from '../../pages/leaderboards'
|
||||||
|
|
||||||
export function getNavigationOptions(user?: User | null) {
|
export function getNavigationOptions(user?: User | null) {
|
||||||
if (IS_PRIVATE_MANIFOLD) {
|
if (IS_PRIVATE_MANIFOLD) {
|
||||||
|
@ -34,7 +35,9 @@ export function ProfileSummary(props: { user: User | undefined }) {
|
||||||
<div className="truncate text-left">
|
<div className="truncate text-left">
|
||||||
<div>{user?.name}</div>
|
<div>{user?.name}</div>
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
{user ? formatMoney(Math.floor(user.balance)) : ' '}
|
{user
|
||||||
|
? formatMoney(Math.floor(loadFakeBalance() || user.balance))
|
||||||
|
: ' '}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
|
@ -4,6 +4,7 @@ import {
|
||||||
SearchIcon,
|
SearchIcon,
|
||||||
BookOpenIcon,
|
BookOpenIcon,
|
||||||
DotsHorizontalIcon,
|
DotsHorizontalIcon,
|
||||||
|
ChartSquareBarIcon,
|
||||||
} from '@heroicons/react/outline'
|
} from '@heroicons/react/outline'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import _ from 'lodash'
|
import _ from 'lodash'
|
||||||
|
@ -19,6 +20,7 @@ import { getNavigationOptions, ProfileSummary } from './profile-menu'
|
||||||
const navigation = [
|
const navigation = [
|
||||||
{ name: 'Home', href: '/home', icon: HomeIcon },
|
{ name: 'Home', href: '/home', icon: HomeIcon },
|
||||||
{ name: 'Markets', href: '/markets', icon: SearchIcon },
|
{ name: 'Markets', href: '/markets', icon: SearchIcon },
|
||||||
|
{ name: 'Leaderboards', href: '/leaderboards', icon: ChartSquareBarIcon },
|
||||||
{ name: 'About', href: 'https://docs.manifold.markets', icon: BookOpenIcon },
|
{ 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 })
|
.then((r) => r.data as { status: string; message?: string })
|
||||||
.catch((e) => ({ status: 'error', message: e.message }))
|
.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(
|
const topTradersQuery = query(
|
||||||
collection(db, 'users'),
|
collection(db, 'users'),
|
||||||
orderBy('totalPnLCached', 'desc'),
|
orderBy('totalPnLCached', 'desc'),
|
||||||
limit(21)
|
limit(51)
|
||||||
)
|
)
|
||||||
|
|
||||||
export async function getTopTraders() {
|
export async function getTopTraders() {
|
||||||
const users = await getValues<User>(topTradersQuery)
|
const users = await getValues<User>(topTradersQuery)
|
||||||
return users.slice(0, 20)
|
return users.slice(0, 50)
|
||||||
}
|
}
|
||||||
|
|
||||||
const topCreatorsQuery = query(
|
const topCreatorsQuery = query(
|
||||||
|
|
|
@ -20,6 +20,7 @@
|
||||||
"@heroicons/react": "1.0.5",
|
"@heroicons/react": "1.0.5",
|
||||||
"@nivo/core": "0.74.0",
|
"@nivo/core": "0.74.0",
|
||||||
"@nivo/line": "0.74.0",
|
"@nivo/line": "0.74.0",
|
||||||
|
"@widgetbot/react-embed": "^1.4.0",
|
||||||
"clsx": "1.1.1",
|
"clsx": "1.1.1",
|
||||||
"daisyui": "1.16.4",
|
"daisyui": "1.16.4",
|
||||||
"dayjs": "1.10.7",
|
"dayjs": "1.10.7",
|
||||||
|
|
|
@ -6,6 +6,18 @@ import { Page } from '../components/page'
|
||||||
import { getTopCreators, getTopTraders, User } from '../lib/firebase/users'
|
import { getTopCreators, getTopTraders, User } from '../lib/firebase/users'
|
||||||
import { formatMoney } from '../../common/util/format'
|
import { formatMoney } from '../../common/util/format'
|
||||||
import { fromPropz, usePropz } from '../hooks/use-propz'
|
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 const getStaticProps = fromPropz(getStaticPropz)
|
||||||
export async function getStaticPropz() {
|
export async function getStaticPropz() {
|
||||||
|
@ -24,10 +36,7 @@ export async function getStaticPropz() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Leaderboards(props: {
|
function Leaderboards(props: { topTraders: User[]; topCreators: User[] }) {
|
||||||
topTraders: User[]
|
|
||||||
topCreators: User[]
|
|
||||||
}) {
|
|
||||||
props = usePropz(props, getStaticPropz) ?? {
|
props = usePropz(props, getStaticPropz) ?? {
|
||||||
topTraders: [],
|
topTraders: [],
|
||||||
topCreators: [],
|
topCreators: [],
|
||||||
|
@ -61,3 +70,270 @@ export default function Leaderboards(props: {
|
||||||
</Page>
|
</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"
|
"@typescript-eslint/types" "4.33.0"
|
||||||
eslint-visitor-keys "^2.0.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:
|
abort-controller@^3.0.0:
|
||||||
version "3.0.0"
|
version "3.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/abort-controller/-/abort-controller-3.0.0.tgz#eaf54d53b62bae4138e809ca225c8439a6efb392"
|
resolved "https://registry.yarnpkg.com/abort-controller/-/abort-controller-3.0.0.tgz#eaf54d53b62bae4138e809ca225c8439a6efb392"
|
||||||
|
@ -4303,7 +4316,7 @@ promisify-call@^2.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
with-callback "^1.0.2"
|
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"
|
version "15.8.1"
|
||||||
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5"
|
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5"
|
||||||
integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==
|
integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==
|
||||||
|
@ -4493,6 +4506,15 @@ react@17.0.2:
|
||||||
loose-envify "^1.1.0"
|
loose-envify "^1.1.0"
|
||||||
object-assign "^4.1.1"
|
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:
|
readable-stream@1.1.x:
|
||||||
version "1.1.14"
|
version "1.1.14"
|
||||||
resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.1.14.tgz#7cf4c54ef648e3813084c636dd2079e166c081d9"
|
resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.1.14.tgz#7cf4c54ef648e3813084c636dd2079e166c081d9"
|
||||||
|
|
Loading…
Reference in New Issue
Block a user