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