Merge branch 'main' into stripe

This commit is contained in:
jahooma 2022-01-07 13:36:56 -06:00
commit b4a22ba4a1
75 changed files with 3534 additions and 729 deletions

View File

@ -1,5 +1,7 @@
{
"projects": {
"default": "mantic-markets"
"default": "mantic-markets",
"prod": "mantic-markets",
"dev": "dev-mantic-markets"
}
}
}

View File

@ -1,2 +1,3 @@
# mantic
Mantic Markets
Manifold Markets

34
firestore.rules Normal file
View File

@ -0,0 +1,34 @@
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /users/{userId} {
allow read;
allow create: if request.auth != null;
}
match /contracts/{contractId} {
allow read;
allow delete: if resource.data.creatorId == request.auth.uid;
}
match /contracts/{contractId}/bets/{betId} {
allow read;
}
match /{somePath=**}/bets/{betId} {
allow read;
}
match /contracts/{contractId}/comments/{commentId} {
allow read;
allow create: if request.auth != null;
}
match /{somePath=**}/comments/{commentId} {
allow read;
}
}
}

View File

@ -2,6 +2,7 @@
"name": "functions",
"scripts": {
"build": "tsc",
"watch": "tsc -w",
"serve": "yarn build && firebase emulators:start --only functions",
"shell": "yarn build && firebase functions:shell",
"start": "yarn shell",
@ -17,9 +18,11 @@
"firebase-admin": "10.0.0",
"firebase-functions": "3.16.0",
"lodash": "4.17.21",
"mailgun-js": "0.22.0",
"stripe": "8.194.0"
},
"devDependencies": {
"@types/mailgun-js": "0.22.12",
"firebase-functions-test": "0.3.3",
"typescript": "4.5.3"
},

View File

@ -0,0 +1,152 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { randomString } from './util/random-string'
import { slugify } from './util/slugify'
import { Contract } from './types/contract'
import { getUser } from './utils'
import { payUser } from '.'
import { User } from './types/user'
export const createContract = functions
.runWith({ minInstances: 1 })
.https.onCall(
async (
data: {
question: string
description: string
initialProb: number
ante?: number
closeTime?: number
},
context
) => {
const userId = context?.auth?.uid
if (!userId) return { status: 'error', message: 'Not authorized' }
const creator = await getUser(userId)
if (!creator) return { status: 'error', message: 'User not found' }
const { question, description, initialProb, ante, closeTime } = data
if (!question || !initialProb)
return { status: 'error', message: 'Missing contract attributes' }
if (ante !== undefined && (ante < 0 || ante > creator.balance))
return { status: 'error', message: 'Invalid ante' }
console.log(
'creating contract for',
creator.username,
'on',
question,
'ante:',
ante || 0
)
const slug = await getSlug(question)
const contractRef = firestore.collection('contracts').doc()
const contract = getNewContract(
contractRef.id,
slug,
creator,
question,
description,
initialProb,
ante,
closeTime
)
if (ante) await payUser([creator.id, -ante])
await contractRef.create(contract)
return { status: 'success', contract }
}
)
const getSlug = async (question: string) => {
const proposedSlug = slugify(question).substring(0, 35)
const preexistingContract = await getContractFromSlug(proposedSlug)
return preexistingContract
? proposedSlug + '-' + randomString()
: proposedSlug
}
function getNewContract(
id: string,
slug: string,
creator: User,
question: string,
description: string,
initialProb: number,
ante?: number,
closeTime?: number
) {
const { startYes, startNo, poolYes, poolNo } = calcStartPool(
initialProb,
ante
)
const contract: Contract = {
id,
slug,
outcomeType: 'BINARY',
creatorId: creator.id,
creatorName: creator.name,
creatorUsername: creator.username,
question: question.trim(),
description: description.trim(),
startPool: { YES: startYes, NO: startNo },
pool: { YES: poolYes, NO: poolNo },
totalShares: { YES: 0, NO: 0 },
totalBets: { YES: 0, NO: 0 },
isResolved: false,
createdTime: Date.now(),
lastUpdatedTime: Date.now(),
}
if (closeTime) contract.closeTime = closeTime
return contract
}
const calcStartPool = (
initialProbInt: number,
ante?: number,
phantomAnte = 200
) => {
const p = initialProbInt / 100.0
const totalAnte = phantomAnte + (ante || 0)
const poolYes =
p === 0.5
? p * totalAnte
: -(totalAnte * (-p + Math.sqrt((-1 + p) * -p))) / (-1 + 2 * p)
const poolNo = totalAnte - poolYes
const f = phantomAnte / totalAnte
const startYes = f * poolYes
const startNo = f * poolNo
return { startYes, startNo, poolYes, poolNo }
}
const firestore = admin.firestore()
export async function getContractFromSlug(slug: string) {
const snap = await firestore
.collection('contracts')
.where('slug', '==', slug)
.get()
return snap.empty ? undefined : (snap.docs[0].data() as Contract)
}

34
functions/src/emails.ts Normal file
View File

@ -0,0 +1,34 @@
import { sendEmail } from './send-email'
import { Contract } from './types/contract'
import { User } from './types/user'
import { getUser } from './utils'
export const sendMarketResolutionEmail = async (
userId: string,
payout: number,
creator: User,
contract: Contract,
resolution: 'YES' | 'NO' | 'CANCEL' | 'MKT'
) => {
const user = await getUser(userId)
if (!user) return
const subject = `Resolved ${toDisplayResolution[resolution]}: ${contract.question}`
const body = `Dear ${user.name},
A market you bet in has been resolved!
Creator: ${contract.creatorName}
Question: ${contract.question}
Resolution: ${toDisplayResolution[resolution]}
Your payout is M$ ${Math.round(payout)}
View the market here:
https://manifold.markets/${creator.username}/${contract.slug}
`
await sendEmail(user.email, subject, body)
}
const toDisplayResolution = { YES: 'YES', NO: 'NO', CANCEL: 'N/A', MKT: 'MKT' }

View File

@ -2,7 +2,9 @@ import * as admin from 'firebase-admin'
admin.initializeApp()
export * from './keep-awake'
// export * from './keep-awake'
export * from './place-bet'
export * from './resolve-market'
export * from './stripe'
export * from './sell-bet'
export * from './create-contract'

View File

@ -8,6 +8,7 @@ export const keepAwake = functions.pubsub
await Promise.all([
callCloudFunction('placeBet'),
callCloudFunction('resolveMarket'),
callCloudFunction('sellBet'),
])
await sleep(30)
@ -15,6 +16,7 @@ export const keepAwake = functions.pubsub
await Promise.all([
callCloudFunction('placeBet'),
callCloudFunction('resolveMarket'),
callCloudFunction('sellBet'),
])
})

View File

@ -43,22 +43,18 @@ export const placeBet = functions.runWith({ minInstances: 1 }).https.onCall(
.collection(`contracts/${contractId}/bets`)
.doc()
const { newBet, newPool, newDpmWeights, newBalance } = getNewBetInfo(
user,
outcome,
amount,
contract,
newBetDoc.id
)
const { newBet, newPool, newTotalShares, newTotalBets, newBalance } =
getNewBetInfo(user, outcome, amount, contract, newBetDoc.id)
transaction.create(newBetDoc, newBet)
transaction.update(contractDoc, {
pool: newPool,
dpmWeights: newDpmWeights,
totalShares: newTotalShares,
totalBets: newTotalBets,
})
transaction.update(userDoc, { balance: newBalance })
return { status: 'success' }
return { status: 'success', betId: newBetDoc.id }
})
}
)
@ -79,29 +75,26 @@ const getNewBetInfo = (
? { YES: yesPool + amount, NO: noPool }
: { YES: yesPool, NO: noPool + amount }
const dpmWeight =
const shares =
outcome === 'YES'
? (amount * noPool ** 2) / (yesPool ** 2 + amount * yesPool)
: (amount * yesPool ** 2) / (noPool ** 2 + amount * noPool)
? amount + (amount * noPool ** 2) / (yesPool ** 2 + amount * yesPool)
: amount + (amount * yesPool ** 2) / (noPool ** 2 + amount * noPool)
const { YES: yesWeight, NO: noWeight } = contract.dpmWeights || {
YES: 0,
NO: 0,
} // only nesc for old contracts
const { YES: yesShares, NO: noShares } = contract.totalShares
const newDpmWeights =
const newTotalShares =
outcome === 'YES'
? { YES: yesWeight + dpmWeight, NO: noWeight }
: { YES: yesWeight, NO: noWeight + dpmWeight }
? { YES: yesShares + shares, NO: noShares }
: { YES: yesShares, NO: noShares + shares }
const { YES: yesBets, NO: noBets } = contract.totalBets
const newTotalBets =
outcome === 'YES'
? { YES: yesBets + amount, NO: noBets }
: { YES: yesBets, NO: noBets + amount }
const probBefore = yesPool ** 2 / (yesPool ** 2 + noPool ** 2)
const probAverage =
(amount +
noPool * Math.atan(yesPool / noPool) -
noPool * Math.atan((amount + yesPool) / noPool)) /
amount
const probAfter = newPool.YES ** 2 / (newPool.YES ** 2 + newPool.NO ** 2)
const newBet: Bet = {
@ -109,15 +102,14 @@ const getNewBetInfo = (
userId: user.id,
contractId: contract.id,
amount,
dpmWeight,
shares,
outcome,
probBefore,
probAverage,
probAfter,
createdTime: Date.now(),
}
const newBalance = user.balance - amount
return { newBet, newPool, newDpmWeights, newBalance }
return { newBet, newPool, newTotalShares, newTotalBets, newBalance }
}

View File

@ -5,6 +5,8 @@ import * as _ from 'lodash'
import { Contract } from './types/contract'
import { User } from './types/user'
import { Bet } from './types/bet'
import { getUser } from './utils'
import { sendMarketResolutionEmail } from './emails'
export const PLATFORM_FEE = 0.01 // 1%
export const CREATOR_FEE = 0.01 // 1%
@ -14,7 +16,7 @@ export const resolveMarket = functions
.https.onCall(
async (
data: {
outcome: string
outcome: 'YES' | 'NO' | 'CANCEL' | 'MKT'
contractId: string
},
context
@ -24,7 +26,7 @@ export const resolveMarket = functions
const { outcome, contractId } = data
if (!['YES', 'NO', 'CANCEL'].includes(outcome))
if (!['YES', 'NO', 'MKT', 'CANCEL'].includes(outcome))
return { status: 'error', message: 'Invalid outcome' }
const contractDoc = firestore.doc(`contracts/${contractId}`)
@ -39,6 +41,9 @@ export const resolveMarket = functions
if (contract.resolution)
return { status: 'error', message: 'Contract already resolved' }
const creator = await getUser(contract.creatorId)
if (!creator) return { status: 'error', message: 'Creator not found' }
await contractDoc.update({
isResolved: true,
resolution: outcome,
@ -50,15 +55,19 @@ export const resolveMarket = functions
const betsSnap = await firestore
.collection(`contracts/${contractId}/bets`)
.get()
const bets = betsSnap.docs.map((doc) => doc.data() as Bet)
const openBets = bets.filter((b) => !b.isSold && !b.sale)
const startPool = contract.startPool.YES + contract.startPool.NO
const truePool = contract.pool.YES + contract.pool.NO - startPool
const payouts =
outcome === 'CANCEL'
? bets.map((bet) => ({
userId: bet.userId,
payout: bet.amount,
}))
: getPayouts(outcome, contract, bets)
? getCancelPayouts(truePool, openBets)
: outcome === 'MKT'
? getMktPayouts(truePool, contract, openBets)
: getStandardPayouts(outcome, truePool, contract, openBets)
console.log('payouts:', payouts)
@ -69,31 +78,90 @@ export const resolveMarket = functions
const payoutPromises = Object.entries(userPayouts).map(payUser)
return await Promise.all(payoutPromises)
const result = await Promise.all(payoutPromises)
.catch((e) => ({ status: 'error', message: e }))
.then(() => ({ status: 'success' }))
await sendResolutionEmails(
openBets,
userPayouts,
creator,
contract,
outcome
)
return result
}
)
const sendResolutionEmails = async (
openBets: Bet[],
userPayouts: { [userId: string]: number },
creator: User,
contract: Contract,
outcome: 'YES' | 'NO' | 'CANCEL' | 'MKT'
) => {
const nonWinners = _.difference(
_.uniq(openBets.map(({ userId }) => userId)),
Object.keys(userPayouts)
)
const emailPayouts = [
...Object.entries(userPayouts),
...nonWinners.map((userId) => [userId, 0] as const),
]
await Promise.all(
emailPayouts.map(([userId, payout]) =>
sendMarketResolutionEmail(userId, payout, creator, contract, outcome)
)
)
}
const firestore = admin.firestore()
const getPayouts = (outcome: string, contract: Contract, bets: Bet[]) => {
const getCancelPayouts = (truePool: number, bets: Bet[]) => {
console.log('resolved N/A, pool M$', truePool)
const betSum = _.sumBy(bets, (b) => b.amount)
return bets.map((bet) => ({
userId: bet.userId,
payout: (bet.amount / betSum) * truePool,
}))
}
const getStandardPayouts = (
outcome: string,
truePool: number,
contract: Contract,
bets: Bet[]
) => {
const [yesBets, noBets] = _.partition(bets, (bet) => bet.outcome === 'YES')
const winningBets = outcome === 'YES' ? yesBets : noBets
const [pool, winningBets] =
outcome === 'YES'
? [contract.pool.NO - contract.startPool.NO, yesBets]
: [contract.pool.YES - contract.startPool.YES, noBets]
const betSum = _.sumBy(winningBets, (b) => b.amount)
const finalPool = (1 - PLATFORM_FEE - CREATOR_FEE) * pool
const creatorPayout = CREATOR_FEE * pool
console.log('final pool:', finalPool, 'creator fee:', creatorPayout)
if (betSum >= truePool) return getCancelPayouts(truePool, winningBets)
const sumWeights = _.sumBy(winningBets, (bet) => bet.dpmWeight)
const creatorPayout = CREATOR_FEE * truePool
console.log(
'resolved',
outcome,
'pool: M$',
truePool,
'creator fee: M$',
creatorPayout
)
const shareDifferenceSum = _.sumBy(winningBets, (b) => b.shares - b.amount)
const winningsPool = truePool - betSum
const winnerPayouts = winningBets.map((bet) => ({
userId: bet.userId,
payout: bet.amount + (bet.dpmWeight / sumWeights) * finalPool,
payout:
(1 - fees) *
(bet.amount +
((bet.shares - bet.amount) / shareDifferenceSum) * winningsPool),
}))
return winnerPayouts.concat([
@ -101,6 +169,59 @@ const getPayouts = (outcome: string, contract: Contract, bets: Bet[]) => {
]) // add creator fee
}
const getMktPayouts = (truePool: number, contract: Contract, bets: Bet[]) => {
const p =
contract.pool.YES ** 2 / (contract.pool.YES ** 2 + contract.pool.NO ** 2)
console.log('Resolved MKT at p=', p, 'pool: $M', truePool)
const [yesBets, noBets] = _.partition(bets, (bet) => bet.outcome === 'YES')
const weightedBetTotal =
p * _.sumBy(yesBets, (b) => b.amount) +
(1 - p) * _.sumBy(noBets, (b) => b.amount)
if (weightedBetTotal >= truePool) {
return bets.map((bet) => ({
userId: bet.userId,
payout:
(((bet.outcome === 'YES' ? p : 1 - p) * bet.amount) /
weightedBetTotal) *
truePool,
}))
}
const winningsPool = truePool - weightedBetTotal
const weightedShareTotal =
p * _.sumBy(yesBets, (b) => b.shares - b.amount) +
(1 - p) * _.sumBy(noBets, (b) => b.shares - b.amount)
const yesPayouts = yesBets.map((bet) => ({
userId: bet.userId,
payout:
(1 - fees) *
(p * bet.amount +
((p * (bet.shares - bet.amount)) / weightedShareTotal) * winningsPool),
}))
const noPayouts = noBets.map((bet) => ({
userId: bet.userId,
payout:
(1 - fees) *
((1 - p) * bet.amount +
(((1 - p) * (bet.shares - bet.amount)) / weightedShareTotal) *
winningsPool),
}))
const creatorPayout = CREATOR_FEE * truePool
return [
...yesPayouts,
...noPayouts,
{ userId: contract.creatorId, payout: creatorPayout },
]
}
export const payUser = ([userId, payout]: [string, number]) => {
return firestore.runTransaction(async (transaction) => {
const userDoc = firestore.doc(`users/${userId}`)
@ -112,3 +233,5 @@ export const payUser = ([userId, payout]: [string, number]) => {
transaction.update(userDoc, { balance: newUserBalance })
})
}
const fees = PLATFORM_FEE + CREATOR_FEE

View File

@ -0,0 +1,58 @@
import * as admin from 'firebase-admin'
import * as _ from 'lodash'
import { Bet } from '../types/bet'
import { Contract } from '../types/contract'
type DocRef = admin.firestore.DocumentReference
// Generate your own private key, and set the path below:
// https://console.firebase.google.com/u/0/project/mantic-markets/settings/serviceaccounts/adminsdk
const serviceAccount = require('../../../../Downloads/mantic-markets-firebase-adminsdk-1ep46-820891bb87.json')
admin.initializeApp({
credential: admin.credential.cert(serviceAccount),
})
const firestore = admin.firestore()
async function migrateBet(contractRef: DocRef, bet: Bet) {
const { dpmWeight, amount, id } = bet as Bet & { dpmWeight: number }
const shares = dpmWeight + amount
await contractRef.collection('bets').doc(id).update({ shares })
}
async function migrateContract(contractRef: DocRef, contract: Contract) {
const bets = await contractRef
.collection('bets')
.get()
.then((snap) => snap.docs.map((bet) => bet.data() as Bet))
const totalShares = {
YES: _.sumBy(bets, (bet) => (bet.outcome === 'YES' ? bet.shares : 0)),
NO: _.sumBy(bets, (bet) => (bet.outcome === 'NO' ? bet.shares : 0)),
}
await contractRef.update({ totalShares })
}
async function migrateContracts() {
console.log('Migrating contracts')
const snapshot = await firestore.collection('contracts').get()
const contracts = snapshot.docs.map((doc) => doc.data() as Contract)
console.log('Loaded contracts', contracts.length)
for (const contract of contracts) {
const contractRef = firestore.doc(`contracts/${contract.id}`)
const betsSnapshot = await contractRef.collection('bets').get()
const bets = betsSnapshot.docs.map((bet) => bet.data() as Bet)
console.log('contract', contract.question, 'bets', bets.length)
for (const bet of bets) await migrateBet(contractRef, bet)
await migrateContract(contractRef, contract)
}
}
if (require.main === module) migrateContracts().then(() => process.exit())

View File

@ -0,0 +1,62 @@
import * as admin from 'firebase-admin'
import * as _ from 'lodash'
import { Bet } from '../types/bet'
import { Contract } from '../types/contract'
type DocRef = admin.firestore.DocumentReference
// Generate your own private key, and set the path below:
// https://console.firebase.google.com/u/0/project/mantic-markets/settings/serviceaccounts/adminsdk
const serviceAccount = require('../../../../Downloads/dev-mantic-markets-firebase-adminsdk-sir5m-b2d27f8970.json')
admin.initializeApp({
credential: admin.credential.cert(serviceAccount),
})
const firestore = admin.firestore()
async function recalculateContract(contractRef: DocRef, contract: Contract) {
const bets = await contractRef
.collection('bets')
.get()
.then((snap) => snap.docs.map((bet) => bet.data() as Bet))
const openBets = bets.filter((b) => !b.isSold && !b.sale)
const totalShares = {
YES: _.sumBy(openBets, (bet) => (bet.outcome === 'YES' ? bet.shares : 0)),
NO: _.sumBy(openBets, (bet) => (bet.outcome === 'NO' ? bet.shares : 0)),
}
const totalBets = {
YES: _.sumBy(openBets, (bet) => (bet.outcome === 'YES' ? bet.amount : 0)),
NO: _.sumBy(openBets, (bet) => (bet.outcome === 'NO' ? bet.amount : 0)),
}
await contractRef.update({ totalShares, totalBets })
console.log(
'calculating totals for "',
contract.question,
'" total bets:',
totalBets
)
console.log()
}
async function recalculateContractTotals() {
console.log('Recalculating contract info')
const snapshot = await firestore.collection('contracts').get()
const contracts = snapshot.docs.map((doc) => doc.data() as Contract)
console.log('Loaded', contracts.length, 'contracts')
for (const contract of contracts) {
const contractRef = firestore.doc(`contracts/${contract.id}`)
await recalculateContract(contractRef, contract)
}
}
if (require.main === module)
recalculateContractTotals().then(() => process.exit())

View File

@ -0,0 +1,46 @@
import * as admin from 'firebase-admin'
import * as _ from 'lodash'
import { Contract } from '../types/contract'
import { getValues } from '../utils'
// Generate your own private key, and set the path below:
// https://console.firebase.google.com/u/0/project/mantic-markets/settings/serviceaccounts/adminsdk
// James:
const serviceAccount = require('../../../../Downloads/mantic-markets-firebase-adminsdk-1ep46-820891bb87.json')
// Stephen:
// const serviceAccount = require('../../../../Downloads/dev-mantic-markets-firebase-adminsdk-sir5m-b2d27f8970.json')
admin.initializeApp({
credential: admin.credential.cert(serviceAccount),
})
const firestore = admin.firestore()
async function renameUserContracts(
username: string,
newNames: { name: string; username: string }
) {
console.log(`Renaming contracts of ${username} to`, newNames)
const contracts = await getValues<Contract>(
firestore.collection('contracts').where('creatorUsername', '==', username)
)
console.log('Loaded', contracts.length, 'contracts by', username)
for (const contract of contracts) {
const contractRef = firestore.doc(`contracts/${contract.id}`)
console.log('Renaming', contract.slug)
await contractRef.update({
creatorUsername: newNames.username,
creatorName: newNames.name,
} as Partial<Contract>)
}
}
if (require.main === module)
renameUserContracts('ManticMarkets', {
username: 'ManifoldMarkets',
name: 'Manifold Markets',
}).then(() => process.exit())

182
functions/src/sell-bet.ts Normal file
View File

@ -0,0 +1,182 @@
import * as admin from 'firebase-admin'
import * as functions from 'firebase-functions'
import { CREATOR_FEE, PLATFORM_FEE } from './resolve-market'
import { Bet } from './types/bet'
import { Contract } from './types/contract'
import { User } from './types/user'
export const sellBet = functions.runWith({ minInstances: 1 }).https.onCall(
async (
data: {
contractId: string
betId: string
},
context
) => {
const userId = context?.auth?.uid
if (!userId) return { status: 'error', message: 'Not authorized' }
const { contractId, betId } = data
// 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 contractDoc = firestore.doc(`contracts/${contractId}`)
const contractSnap = await transaction.get(contractDoc)
if (!contractSnap.exists)
return { status: 'error', message: 'Invalid contract' }
const contract = contractSnap.data() as Contract
const betDoc = firestore.doc(`contracts/${contractId}/bets/${betId}`)
const betSnap = await transaction.get(betDoc)
if (!betSnap.exists) return { status: 'error', message: 'Invalid bet' }
const bet = betSnap.data() as Bet
if (bet.isSold) return { status: 'error', message: 'Bet already sold' }
const newBetDoc = firestore
.collection(`contracts/${contractId}/bets`)
.doc()
const {
newBet,
newPool,
newTotalShares,
newTotalBets,
newBalance,
creatorFee,
} = getSellBetInfo(user, bet, contract, newBetDoc.id)
const creatorDoc = firestore.doc(`users/${contract.creatorId}`)
const creatorSnap = await transaction.get(creatorDoc)
if (creatorSnap.exists) {
const creator = creatorSnap.data() as User
const creatorNewBalance = creator.balance + creatorFee
transaction.update(creatorDoc, { balance: creatorNewBalance })
}
transaction.update(betDoc, { isSold: true })
transaction.create(newBetDoc, newBet)
transaction.update(contractDoc, {
pool: newPool,
totalShares: newTotalShares,
totalBets: newTotalBets,
})
transaction.update(userDoc, { balance: newBalance })
return { status: 'success' }
})
}
)
const firestore = admin.firestore()
const getSellBetInfo = (
user: User,
bet: Bet,
contract: Contract,
newBetId: string
) => {
const { id: betId, amount, shares, outcome } = bet
const { YES: yesPool, NO: noPool } = contract.pool
const { YES: yesStart, NO: noStart } = contract.startPool
const { YES: yesShares, NO: noShares } = contract.totalShares
const { YES: yesBets, NO: noBets } = contract.totalBets
const [y, n, s] = [yesPool, noPool, shares]
const shareValue =
outcome === 'YES'
? // https://www.wolframalpha.com/input/?i=b+%2B+%28b+n%5E2%29%2F%28y+%28-b+%2B+y%29%29+%3D+c+solve+b
(n ** 2 +
s * y +
y ** 2 -
Math.sqrt(
n ** 4 + (s - y) ** 2 * y ** 2 + 2 * n ** 2 * y * (s + y)
)) /
(2 * y)
: (y ** 2 +
s * n +
n ** 2 -
Math.sqrt(
y ** 4 + (s - n) ** 2 * n ** 2 + 2 * y ** 2 * n * (s + n)
)) /
(2 * n)
const startPool = yesStart + noStart
const pool = yesPool + noPool - startPool
const probBefore = yesPool ** 2 / (yesPool ** 2 + noPool ** 2)
const f = pool / (probBefore * yesShares + (1 - probBefore) * noShares)
const myPool = outcome === 'YES' ? yesPool - yesStart : noPool - noStart
const adjShareValue = Math.min(Math.min(1, f) * shareValue, myPool)
const newPool =
outcome === 'YES'
? { YES: yesPool - adjShareValue, NO: noPool }
: { YES: yesPool, NO: noPool - adjShareValue }
const newTotalShares =
outcome === 'YES'
? { YES: yesShares - shares, NO: noShares }
: { YES: yesShares, NO: noShares - shares }
const newTotalBets =
outcome === 'YES'
? { YES: yesBets - amount, NO: noBets }
: { YES: yesBets, NO: noBets - amount }
const probAfter = newPool.YES ** 2 / (newPool.YES ** 2 + newPool.NO ** 2)
const creatorFee = CREATOR_FEE * adjShareValue
const saleAmount = (1 - CREATOR_FEE - PLATFORM_FEE) * adjShareValue
console.log(
'SELL M$',
amount,
outcome,
'for M$',
saleAmount,
'M$/share:',
f,
'creator fee: M$',
creatorFee
)
const newBet: Bet = {
id: newBetId,
userId: user.id,
contractId: contract.id,
amount: -adjShareValue,
shares: -shares,
outcome,
probBefore,
probAfter,
createdTime: Date.now(),
sale: {
amount: saleAmount,
betId,
},
}
const newBalance = user.balance + saleAmount
return {
newBet,
newPool,
newTotalShares,
newTotalBets,
newBalance,
creatorFee,
}
}

View File

@ -0,0 +1,18 @@
import * as mailgun from 'mailgun-js'
import * as functions from 'firebase-functions'
const DOMAIN = 'mg.manifold.markets'
const mg = mailgun({ apiKey: functions.config().mailgun.key, domain: DOMAIN })
export const sendEmail = (to: string, subject: string, text: string) => {
const data = {
from: 'Manifold Markets <no-reply@manifold.markets>',
to,
subject,
text,
}
return mg.messages().send(data, (error, body) => {
console.log('Sent email', error, body)
})
}

View File

@ -2,11 +2,20 @@ export type Bet = {
id: string
userId: string
contractId: string
amount: number // Amount of bet
outcome: 'YES' | 'NO' // Chosen outcome
createdTime: number
amount: number // bet size; negative if SELL bet
outcome: 'YES' | 'NO'
shares: number // dynamic parimutuel pool weight; negative if SELL bet
probBefore: number
probAverage: number
probAfter: number
dpmWeight: number // Dynamic Parimutuel weight
}
sale?: {
amount: number // amount user makes from sale
betId: string // id of bet being sold
}
isSold?: boolean // true if this BUY bet has been sold
createdTime: number
}

View File

@ -4,6 +4,7 @@ export type Contract = {
creatorId: string
creatorName: string
creatorUsername: string
question: string
description: string // More info about what the contract is about
@ -12,7 +13,8 @@ export type Contract = {
startPool: { YES: number; NO: number }
pool: { YES: number; NO: number }
dpmWeights: { YES: number; NO: number }
totalShares: { YES: number; NO: number }
totalBets: { YES: number; NO: number }
createdTime: number // Milliseconds since epoch
lastUpdatedTime: number // If the question or description was changed

View File

@ -4,14 +4,14 @@ import { Contract } from './types/contract'
import { User } from './types/user'
export const getValue = async <T>(collection: string, doc: string) => {
const snap = await admin.firestore()
.collection(collection)
.doc(doc)
.get()
const snap = await admin.firestore().collection(collection).doc(doc).get()
return snap.exists
? snap.data() as T
: undefined
return snap.exists ? (snap.data() as T) : undefined
}
export const getValues = async <T>(query: admin.firestore.Query) => {
const snap = await query.get()
return snap.docs.map((doc) => doc.data() as T)
}
export const getContract = (contractId: string) => {
@ -20,4 +20,4 @@ export const getContract = (contractId: string) => {
export const getUser = (userId: string) => {
return getValue<User>('users', userId)
}
}

View File

@ -301,6 +301,14 @@
resolved "https://registry.yarnpkg.com/@types/long/-/long-4.0.1.tgz#459c65fa1867dafe6a8f322c4c51695663cc55e9"
integrity sha512-5tXH6Bx/kNGd3MgffdmP4dy2Z+G4eaXw0SE81Tq3BNadtnMR5/ySMzX4SLEzHJzSmPNn4HIdpQsBvXMUykr58w==
"@types/mailgun-js@0.22.12":
version "0.22.12"
resolved "https://registry.yarnpkg.com/@types/mailgun-js/-/mailgun-js-0.22.12.tgz#b0dcb590b56ef3e599ab1f262882493d318e5510"
integrity sha512-fTjuh2mOPoJF2BN0QAhE5iPOBls333KIJrmrrJHObMDuBCgEfaENLX2LH1FjKqhfuWfGotKOfPFZMuSok5Ow7g==
dependencies:
"@types/node" "*"
form-data "^2.5.0"
"@types/mime@^1":
version "1.3.2"
resolved "https://registry.yarnpkg.com/@types/mime/-/mime-1.3.2.tgz#93e25bf9ee75fe0fd80b594bc4feb0e862111b5a"
@ -344,6 +352,13 @@ accepts@~1.3.7:
mime-types "~2.1.24"
negotiator "0.6.2"
agent-base@4, agent-base@^4.2.0, agent-base@^4.3.0:
version "4.3.0"
resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-4.3.0.tgz#8165f01c436009bccad0b1d122f05ed770efc6ee"
integrity sha512-salcGninV0nPrwpGNn4VTXBb1SOuXQBiqbrNXoeizJsHrsL6ERFM2Ne3JUSBWRE6aeNJI2ROP/WEEIDUiDe3cg==
dependencies:
es6-promisify "^5.0.0"
agent-base@6:
version "6.0.2"
resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77"
@ -351,6 +366,13 @@ agent-base@6:
dependencies:
debug "4"
agent-base@~4.2.1:
version "4.2.1"
resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-4.2.1.tgz#d89e5999f797875674c07d87f260fc41e83e8ca9"
integrity sha512-JVwXMr9nHYTUXsBFKUqhJwvlcYU/blreOEUkhNR2eXZIvwd+c+o5V4MgDPKWnMS/56awN3TRzIP+KoPn+roQtg==
dependencies:
es6-promisify "^5.0.0"
ansi-regex@^5.0.1:
version "5.0.1"
resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304"
@ -373,6 +395,13 @@ arrify@^2.0.0, arrify@^2.0.1:
resolved "https://registry.yarnpkg.com/arrify/-/arrify-2.0.1.tgz#c9655e9331e0abcd588d2a7cad7e9956f66701fa"
integrity sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==
ast-types@0.x.x:
version "0.14.2"
resolved "https://registry.yarnpkg.com/ast-types/-/ast-types-0.14.2.tgz#600b882df8583e3cd4f2df5fa20fa83759d4bdfd"
integrity sha512-O0yuUDnZeQDL+ncNGlJ78BiO4jnYI3bvMsD5prT0/nsgijG/LpNBIr63gTjVTNsiGkgQhiyCShTgxt8oXOrklA==
dependencies:
tslib "^2.0.1"
async-retry@^1.3.1, async-retry@^1.3.3:
version "1.3.3"
resolved "https://registry.yarnpkg.com/async-retry/-/async-retry-1.3.3.tgz#0e7f36c04d8478e7a58bdbed80cedf977785f280"
@ -380,6 +409,18 @@ async-retry@^1.3.1, async-retry@^1.3.3:
dependencies:
retry "0.13.1"
async@^2.6.1:
version "2.6.3"
resolved "https://registry.yarnpkg.com/async/-/async-2.6.3.tgz#d72625e2344a3656e3a3ad4fa749fa83299d82ff"
integrity sha512-zflvls11DCy+dQWzTW2dzuilv8Z5X/pjfmZOWba6TNIVDm+2UDaJmXSOXlasHKfNBs8oo3M0aT50fDEWfKZjXg==
dependencies:
lodash "^4.17.14"
asynckit@^0.4.0:
version "0.4.0"
resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79"
integrity sha1-x57Zf380y48robyXkLzDZkdLS3k=
base64-js@^1.3.0:
version "1.5.1"
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
@ -440,6 +481,11 @@ cliui@^7.0.2:
strip-ansi "^6.0.0"
wrap-ansi "^7.0.0"
co@^4.6.0:
version "4.6.0"
resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184"
integrity sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ=
color-convert@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3"
@ -452,6 +498,13 @@ color-name@~1.1.4:
resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2"
integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==
combined-stream@^1.0.6:
version "1.0.8"
resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f"
integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==
dependencies:
delayed-stream "~1.0.0"
compressible@^2.0.12:
version "2.0.18"
resolved "https://registry.yarnpkg.com/compressible/-/compressible-2.0.18.tgz#af53cca6b070d4c3c0750fbd77286a6d7cc46fba"
@ -493,6 +546,11 @@ cookie@0.4.1:
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.1.tgz#afd713fe26ebd21ba95ceb61f9a8116e50a537d1"
integrity sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==
core-util-is@~1.0.0:
version "1.0.3"
resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85"
integrity sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==
cors@^2.8.5:
version "2.8.5"
resolved "https://registry.yarnpkg.com/cors/-/cors-2.8.5.tgz#eac11da51592dd86b9f06f6e7ac293b3df875d29"
@ -506,25 +564,63 @@ crypto-random-string@^2.0.0:
resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-2.0.0.tgz#ef2a7a966ec11083388369baa02ebead229b30d5"
integrity sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==
data-uri-to-buffer@1:
version "1.2.0"
resolved "https://registry.yarnpkg.com/data-uri-to-buffer/-/data-uri-to-buffer-1.2.0.tgz#77163ea9c20d8641b4707e8f18abdf9a78f34835"
integrity sha512-vKQ9DTQPN1FLYiiEEOQ6IBGFqvjCa5rSK3cWMy/Nespm5d/x3dGFT9UBZnkLxCwua/IXBi2TYnwTEpsOvhC4UQ==
date-and-time@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/date-and-time/-/date-and-time-2.0.1.tgz#bc8b72704980e8a0979bb186118d30d02059ef04"
integrity sha512-O7Xe5dLaqvY/aF/MFWArsAM1J4j7w1CSZlPCX9uHgmb+6SbkPd8Q4YOvfvH/cZGvFlJFfHOZKxQtmMUOoZhc/w==
debug@2.6.9:
debug@2, debug@2.6.9:
version "2.6.9"
resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"
integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==
dependencies:
ms "2.0.0"
debug@4, debug@^4.1.1, debug@^4.3.2:
debug@3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/debug/-/debug-3.1.0.tgz#5bb5a0672628b64149566ba16819e61518c67261"
integrity sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==
dependencies:
ms "2.0.0"
debug@4, debug@^4.1.0, debug@^4.1.1, debug@^4.3.2:
version "4.3.3"
resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.3.tgz#04266e0b70a98d4462e6e288e38259213332b664"
integrity sha512-/zxw5+vh1Tfv+4Qn7a5nsbcJKPaSvCDhojn6FEl9vupwK2VCSDtEiEtqr8DFtzYFOdz63LBkxec7DYuc2jon6Q==
dependencies:
ms "2.1.2"
debug@^3.1.0:
version "3.2.7"
resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.7.tgz#72580b7e9145fb39b6676f9c5e5fb100b934179a"
integrity sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==
dependencies:
ms "^2.1.1"
deep-is@~0.1.3:
version "0.1.4"
resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831"
integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==
degenerator@^1.0.4:
version "1.0.4"
resolved "https://registry.yarnpkg.com/degenerator/-/degenerator-1.0.4.tgz#fcf490a37ece266464d9cc431ab98c5819ced095"
integrity sha1-/PSQo37OJmRk2cxDGrmMWBnO0JU=
dependencies:
ast-types "0.x.x"
escodegen "1.x.x"
esprima "3.x.x"
delayed-stream@~1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619"
integrity sha1-3zrhmayt+31ECqrgsp4icrJOxhk=
depd@~1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9"
@ -600,6 +696,18 @@ ent@^2.2.0:
resolved "https://registry.yarnpkg.com/ent/-/ent-2.2.0.tgz#e964219325a21d05f44466a2f686ed6ce5f5dd1d"
integrity sha1-6WQhkyWiHQX0RGai9obtbOX13R0=
es6-promise@^4.0.3:
version "4.2.8"
resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-4.2.8.tgz#4eb21594c972bc40553d276e510539143db53e0a"
integrity sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==
es6-promisify@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/es6-promisify/-/es6-promisify-5.0.0.tgz#5109d62f3e56ea967c4b63505aef08291c8a5203"
integrity sha1-UQnWLz5W6pZ8S2NQWu8IKRyKUgM=
dependencies:
es6-promise "^4.0.3"
escalade@^3.1.1:
version "3.1.1"
resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40"
@ -610,6 +718,38 @@ escape-html@~1.0.3:
resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988"
integrity sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=
escodegen@1.x.x:
version "1.14.3"
resolved "https://registry.yarnpkg.com/escodegen/-/escodegen-1.14.3.tgz#4e7b81fba61581dc97582ed78cab7f0e8d63f503"
integrity sha512-qFcX0XJkdg+PB3xjZZG/wKSuT1PnQWx57+TVSjIMmILd2yC/6ByYElPwJnslDsuWuSAp4AwJGumarAAmJch5Kw==
dependencies:
esprima "^4.0.1"
estraverse "^4.2.0"
esutils "^2.0.2"
optionator "^0.8.1"
optionalDependencies:
source-map "~0.6.1"
esprima@3.x.x:
version "3.1.3"
resolved "https://registry.yarnpkg.com/esprima/-/esprima-3.1.3.tgz#fdca51cee6133895e3c88d535ce49dbff62a4633"
integrity sha1-/cpRzuYTOJXjyI1TXOSdv/YqRjM=
esprima@^4.0.1:
version "4.0.1"
resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71"
integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==
estraverse@^4.2.0:
version "4.3.0"
resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-4.3.0.tgz#398ad3f3c5a24948be7725e83d11a7de28cdbd1d"
integrity sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==
esutils@^2.0.2:
version "2.0.3"
resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64"
integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==
etag@~1.8.1:
version "1.8.1"
resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887"
@ -656,7 +796,7 @@ express@^4.17.1:
utils-merge "1.0.1"
vary "~1.1.2"
extend@^3.0.2:
extend@^3.0.2, extend@~3.0.2:
version "3.0.2"
resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa"
integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==
@ -666,6 +806,11 @@ fast-deep-equal@^3.1.1:
resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525"
integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==
fast-levenshtein@~2.0.6:
version "2.0.6"
resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917"
integrity sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=
fast-text-encoding@^1.0.0, fast-text-encoding@^1.0.3:
version "1.0.3"
resolved "https://registry.yarnpkg.com/fast-text-encoding/-/fast-text-encoding-1.0.3.tgz#ec02ac8e01ab8a319af182dae2681213cfe9ce53"
@ -686,6 +831,11 @@ fetch@1.1.0:
biskviit "1.0.1"
encoding "0.1.12"
file-uri-to-path@1:
version "1.0.0"
resolved "https://registry.yarnpkg.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz#553a7b8446ff6f684359c445f1e37a05dacc33dd"
integrity sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==
finalhandler@~1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.1.2.tgz#b7e7d000ffd11938d0fdb053506f6ebabe9f587d"
@ -734,6 +884,15 @@ firebase-functions@3.16.0:
express "^4.17.1"
lodash "^4.17.14"
form-data@^2.3.3, form-data@^2.5.0:
version "2.5.1"
resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.5.1.tgz#f2cbec57b5e59e23716e128fe44d4e5dd23895f4"
integrity sha512-m21N3WOmEEURgk6B9GLOE4RuWOFf28Lhh9qGYeNlGq4VDXUlJy2th2slBNU8Gp8EzloYZOibZJ7t5ecIrFSjVA==
dependencies:
asynckit "^0.4.0"
combined-stream "^1.0.6"
mime-types "^2.1.12"
forwarded@0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811"
@ -748,6 +907,14 @@ function-bind@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d"
integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==
ftp@~0.3.10:
version "0.3.10"
resolved "https://registry.yarnpkg.com/ftp/-/ftp-0.3.10.tgz#9197d861ad8142f3e63d5a83bfe4c59f7330885d"
integrity sha1-kZfYYa2BQvPmPVqDv+TFn3MwiF0=
dependencies:
readable-stream "1.1.x"
xregexp "2.0.0"
functional-red-black-tree@^1.0.1:
version "1.0.1"
@ -806,6 +973,18 @@ get-stream@^6.0.0:
resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-6.0.1.tgz#a262d8eef67aced57c2852ad6167526a43cbf7b7"
integrity sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==
get-uri@^2.0.0:
version "2.0.4"
resolved "https://registry.yarnpkg.com/get-uri/-/get-uri-2.0.4.tgz#d4937ab819e218d4cb5ae18e4f5962bef169cc6a"
integrity sha512-v7LT/s8kVjs+Tx0ykk1I+H/rbpzkHvuIq87LmeXptcf5sNWm9uQiwjNAt94SJPA1zOlCntmnOlJvVWKmzsxG8Q==
dependencies:
data-uri-to-buffer "1"
debug "2"
extend "~3.0.2"
file-uri-to-path "1"
ftp "~0.3.10"
readable-stream "2"
google-auth-library@^7.0.0, google-auth-library@^7.6.1, google-auth-library@^7.9.2:
version "7.11.0"
resolved "https://registry.yarnpkg.com/google-auth-library/-/google-auth-library-7.11.0.tgz#b63699c65037310a424128a854ba7e736704cbdb"
@ -894,6 +1073,14 @@ http-parser-js@>=0.5.1:
resolved "https://registry.yarnpkg.com/http-parser-js/-/http-parser-js-0.5.5.tgz#d7c30d5d3c90d865b4a2e870181f9d6f22ac7ac5"
integrity sha512-x+JVEkO2PoM8qqpbPbOL3cqHPwerep7OwzK7Ay+sMQjKzaKCqWvjoXm5tqMP9tXWWTnTzAjIhXg+J99XYuPhPA==
http-proxy-agent@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/http-proxy-agent/-/http-proxy-agent-2.1.0.tgz#e4821beef5b2142a2026bd73926fe537631c5405"
integrity sha512-qwHbBLV7WviBl0rQsOzH6o5lwyOIvwp/BdFnvVxXORldu5TmjFfjzBcWUWS5kWAZhmv+JtiDhSuQCp4sBfbIgg==
dependencies:
agent-base "4"
debug "3.1.0"
http-proxy-agent@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz#5129800203520d434f142bc78ff3c170800f2b43"
@ -903,6 +1090,14 @@ http-proxy-agent@^5.0.0:
agent-base "6"
debug "4"
https-proxy-agent@^3.0.0:
version "3.0.1"
resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-3.0.1.tgz#b8c286433e87602311b01c8ea34413d856a4af81"
integrity sha512-+ML2Rbh6DAuee7d07tYGEKOEi2voWPUGan+ExdPbPW6Z3svq+JCqr0v8WmKPOkz1vOVykPCBSuobe7G8GJUtVg==
dependencies:
agent-base "^4.3.0"
debug "^3.1.0"
https-proxy-agent@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.0.tgz#e2a90542abb68a762e0a0850f6c9edadfd8506b2"
@ -923,11 +1118,26 @@ imurmurhash@^0.1.4:
resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea"
integrity sha1-khi5srkoojixPcT7a21XbyMUU+o=
inherits@2.0.4, inherits@^2.0.3:
inflection@~1.12.0:
version "1.12.0"
resolved "https://registry.yarnpkg.com/inflection/-/inflection-1.12.0.tgz#a200935656d6f5f6bc4dc7502e1aecb703228416"
integrity sha1-ogCTVlbW9fa8TcdQLhrstwMihBY=
inflection@~1.3.0:
version "1.3.8"
resolved "https://registry.yarnpkg.com/inflection/-/inflection-1.3.8.tgz#cbd160da9f75b14c3cc63578d4f396784bf3014e"
integrity sha1-y9Fg2p91sUw8xjV41POWeEvzAU4=
inherits@2.0.4, inherits@^2.0.3, inherits@~2.0.1, inherits@~2.0.3:
version "2.0.4"
resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
ip@1.1.5, ip@^1.1.5:
version "1.1.5"
resolved "https://registry.yarnpkg.com/ip/-/ip-1.1.5.tgz#bdded70114290828c0a039e72ef25f5aaec4354a"
integrity sha1-vd7XARQpCCjAoDnnLvJfWq7ENUo=
ipaddr.js@1.9.1:
version "1.9.1"
resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3"
@ -948,6 +1158,11 @@ is-stream-ended@^0.1.4:
resolved "https://registry.yarnpkg.com/is-stream-ended/-/is-stream-ended-0.1.4.tgz#f50224e95e06bce0e356d440a4827cd35b267eda"
integrity sha512-xj0XPvmr7bQFTvirqnFr50o0hQIh6ZItDqloxt5aJrR4NQsYeSsyFQERYGCAzfindAcnKjINnwEEgLx4IqVzQw==
is-stream@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44"
integrity sha1-EtSj3U5o4Lec6428hBc66A2RykQ=
is-stream@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-2.0.1.tgz#fac1e3d53b97ad5a9d0ae9cef2389f5810a5c077"
@ -958,6 +1173,16 @@ is-typedarray@^1.0.0:
resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a"
integrity sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=
isarray@0.0.1:
version "0.0.1"
resolved "https://registry.yarnpkg.com/isarray/-/isarray-0.0.1.tgz#8a18acfca9a8f4177e09abfc6038939b05d1eedf"
integrity sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=
isarray@~1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11"
integrity sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=
jose@^2.0.5:
version "2.0.5"
resolved "https://registry.yarnpkg.com/jose/-/jose-2.0.5.tgz#29746a18d9fff7dcf9d5d2a6f62cb0c7cd27abd3"
@ -1033,6 +1258,14 @@ jws@^4.0.0:
jwa "^2.0.0"
safe-buffer "^5.0.1"
levn@~0.3.0:
version "0.3.0"
resolved "https://registry.yarnpkg.com/levn/-/levn-0.3.0.tgz#3b09924edf9f083c0490fdd4c0bc4421e04764ee"
integrity sha1-OwmSTt+fCDwEkP3UwLxEIeBHZO4=
dependencies:
prelude-ls "~1.1.2"
type-check "~0.3.2"
limiter@^1.1.5:
version "1.1.5"
resolved "https://registry.yarnpkg.com/limiter/-/limiter-1.1.5.tgz#8f92a25b3b16c6131293a0cc834b4a838a2aa7c2"
@ -1093,6 +1326,13 @@ long@^4.0.0:
resolved "https://registry.yarnpkg.com/long/-/long-4.0.0.tgz#9a7b71cfb7d361a194ea555241c92f7468d5bf28"
integrity sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==
lru-cache@^5.1.1:
version "5.1.1"
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-5.1.1.tgz#1da27e6710271947695daf6848e847f01d84b920"
integrity sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==
dependencies:
yallist "^3.0.2"
lru-cache@^6.0.0:
version "6.0.0"
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-6.0.0.tgz#6d6fe6570ebd96aaf90fcad1dafa3b2566db3a94"
@ -1116,6 +1356,21 @@ lru-memoizer@^2.1.4:
lodash.clonedeep "^4.5.0"
lru-cache "~4.0.0"
mailgun-js@0.22.0:
version "0.22.0"
resolved "https://registry.yarnpkg.com/mailgun-js/-/mailgun-js-0.22.0.tgz#128942b5e47a364a470791608852bf68c96b3a05"
integrity sha512-a2alg5nuTZA9Psa1pSEIEsbxr1Zrmqx4VkgGCQ30xVh0kIH7Bu57AYILo+0v8QLSdXtCyLaS+KVmdCrQo0uWFA==
dependencies:
async "^2.6.1"
debug "^4.1.0"
form-data "^2.3.3"
inflection "~1.12.0"
is-stream "^1.1.0"
path-proxy "~1.0.0"
promisify-call "^2.0.2"
proxy-agent "^3.0.3"
tsscmp "^1.0.6"
make-dir@^3.0.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-3.1.0.tgz#415e967046b3a7f1d185277d84aa58203726a13f"
@ -1143,7 +1398,7 @@ mime-db@1.51.0, "mime-db@>= 1.43.0 < 2":
resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.51.0.tgz#d9ff62451859b18342d960850dc3cfb77e63fb0c"
integrity sha512-5y8A56jg7XVQx2mbv1lu49NR4dokRnhZYTtL+KGfaa27uq4pSTXkwQkFJl4pkRMyNFz/EtYDSkiiEHx3F7UN6g==
mime-types@^2.0.8, mime-types@~2.1.24:
mime-types@^2.0.8, mime-types@^2.1.12, mime-types@~2.1.24:
version "2.1.34"
resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.34.tgz#5a712f9ec1503511a945803640fafe09d3793c24"
integrity sha512-6cP692WwGIs9XXdOO4++N+7qjqv0rqxxVvJ3VHPh/Sc9mVZcQP+ZGhkKiTvWMQRr2tbHkJP/Yn7Y0npb3ZBs4A==
@ -1180,6 +1435,11 @@ negotiator@0.6.2:
resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.2.tgz#feacf7ccf525a77ae9634436a64883ffeca346fb"
integrity sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw==
netmask@^1.0.6:
version "1.0.6"
resolved "https://registry.yarnpkg.com/netmask/-/netmask-1.0.6.tgz#20297e89d86f6f6400f250d9f4f6b4c1945fcd35"
integrity sha1-ICl+idhvb2QA8lDZ9Pa0wZRfzTU=
node-fetch@^2.6.1:
version "2.6.6"
resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.6.tgz#1751a7c01834e8e1697758732e9efb6eeadfaf89"
@ -1221,6 +1481,18 @@ once@^1.3.1, once@^1.4.0:
dependencies:
wrappy "1"
optionator@^0.8.1:
version "0.8.3"
resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.8.3.tgz#84fa1d036fe9d3c7e21d99884b601167ec8fb495"
integrity sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==
dependencies:
deep-is "~0.1.3"
fast-levenshtein "~2.0.6"
levn "~0.3.0"
prelude-ls "~1.1.2"
type-check "~0.3.2"
word-wrap "~1.2.3"
p-limit@^3.0.1:
version "3.1.0"
resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b"
@ -1228,16 +1500,65 @@ p-limit@^3.0.1:
dependencies:
yocto-queue "^0.1.0"
pac-proxy-agent@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/pac-proxy-agent/-/pac-proxy-agent-3.0.1.tgz#115b1e58f92576cac2eba718593ca7b0e37de2ad"
integrity sha512-44DUg21G/liUZ48dJpUSjZnFfZro/0K5JTyFYLBcmh9+T6Ooi4/i4efwUiEy0+4oQusCBqWdhv16XohIj1GqnQ==
dependencies:
agent-base "^4.2.0"
debug "^4.1.1"
get-uri "^2.0.0"
http-proxy-agent "^2.1.0"
https-proxy-agent "^3.0.0"
pac-resolver "^3.0.0"
raw-body "^2.2.0"
socks-proxy-agent "^4.0.1"
pac-resolver@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/pac-resolver/-/pac-resolver-3.0.0.tgz#6aea30787db0a891704deb7800a722a7615a6f26"
integrity sha512-tcc38bsjuE3XZ5+4vP96OfhOugrX+JcnpUbhfuc4LuXBLQhoTthOstZeoQJBDnQUDYzYmdImKsbz0xSl1/9qeA==
dependencies:
co "^4.6.0"
degenerator "^1.0.4"
ip "^1.1.5"
netmask "^1.0.6"
thunkify "^2.1.2"
parseurl@~1.3.3:
version "1.3.3"
resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4"
integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==
path-proxy@~1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/path-proxy/-/path-proxy-1.0.0.tgz#18e8a36859fc9d2f1a53b48dee138543c020de5e"
integrity sha1-GOijaFn8nS8aU7SN7hOFQ8Ag3l4=
dependencies:
inflection "~1.3.0"
path-to-regexp@0.1.7:
version "0.1.7"
resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c"
integrity sha1-32BBeABfUi8V60SQ5yR6G/qmf4w=
prelude-ls@~1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54"
integrity sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ=
process-nextick-args@~2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2"
integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==
promisify-call@^2.0.2:
version "2.0.4"
resolved "https://registry.yarnpkg.com/promisify-call/-/promisify-call-2.0.4.tgz#d48c2d45652ccccd52801ddecbd533a6d4bd5fba"
integrity sha1-1IwtRWUszM1SgB3ey9UzptS9X7o=
dependencies:
with-callback "^1.0.2"
proto3-json-serializer@^0.1.5:
version "0.1.6"
resolved "https://registry.yarnpkg.com/proto3-json-serializer/-/proto3-json-serializer-0.1.6.tgz#67cf3b8d5f4c8bebfc410698ad3b1ed64da39c7b"
@ -1272,6 +1593,25 @@ proxy-addr@~2.0.7:
forwarded "0.2.0"
ipaddr.js "1.9.1"
proxy-agent@^3.0.3:
version "3.1.1"
resolved "https://registry.yarnpkg.com/proxy-agent/-/proxy-agent-3.1.1.tgz#7e04e06bf36afa624a1540be247b47c970bd3014"
integrity sha512-WudaR0eTsDx33O3EJE16PjBRZWcX8GqCEeERw1W3hZJgH/F2a46g7jty6UGty6NeJ4CKQy8ds2CJPMiyeqaTvw==
dependencies:
agent-base "^4.2.0"
debug "4"
http-proxy-agent "^2.1.0"
https-proxy-agent "^3.0.0"
lru-cache "^5.1.1"
pac-proxy-agent "^3.0.1"
proxy-from-env "^1.0.0"
socks-proxy-agent "^4.0.1"
proxy-from-env@^1.0.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2"
integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==
pseudomap@^1.0.1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/pseudomap/-/pseudomap-1.0.2.tgz#f052a28da70e618917ef0a8ac34c1ae5a68286b3"
@ -1316,7 +1656,7 @@ range-parser@~1.2.1:
resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031"
integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==
raw-body@2.4.2:
raw-body@2.4.2, raw-body@^2.2.0:
version "2.4.2"
resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.4.2.tgz#baf3e9c21eebced59dd6533ac872b71f7b61cb32"
integrity sha512-RPMAFUJP19WIet/99ngh6Iv8fzAbqum4Li7AD6DtGaW2RpMB/11xDoalPiJMTbu6I3hkbMVkATvZrqb9EEqeeQ==
@ -1326,6 +1666,29 @@ raw-body@2.4.2:
iconv-lite "0.4.24"
unpipe "1.0.0"
readable-stream@1.1.x:
version "1.1.14"
resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.1.14.tgz#7cf4c54ef648e3813084c636dd2079e166c081d9"
integrity sha1-fPTFTvZI44EwhMY23SB54WbAgdk=
dependencies:
core-util-is "~1.0.0"
inherits "~2.0.1"
isarray "0.0.1"
string_decoder "~0.10.x"
readable-stream@2:
version "2.3.7"
resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.7.tgz#1eca1cf711aef814c04f62252a36a62f6cb23b57"
integrity sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==
dependencies:
core-util-is "~1.0.0"
inherits "~2.0.3"
isarray "~1.0.0"
process-nextick-args "~2.0.0"
safe-buffer "~5.1.1"
string_decoder "~1.1.1"
util-deprecate "~1.0.1"
readable-stream@^3.1.1:
version "3.6.0"
resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.0.tgz#337bbda3adc0706bd3e024426a286d4b4b2c9198"
@ -1358,6 +1721,11 @@ safe-buffer@5.2.1, safe-buffer@>=5.1.0, safe-buffer@^5.0.1, safe-buffer@~5.2.0:
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6"
integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==
safe-buffer@~5.1.0, safe-buffer@~5.1.1:
version "5.1.2"
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d"
integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==
"safer-buffer@>= 2.1.2 < 3":
version "2.1.2"
resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a"
@ -1421,11 +1789,37 @@ signal-exit@^3.0.2:
resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.6.tgz#24e630c4b0f03fea446a2bd299e62b4a6ca8d0af"
integrity sha512-sDl4qMFpijcGw22U5w63KmD3cZJfBuFlVNbVMKje2keoKML7X2UzWbc4XrmEbDwg0NXJc3yv4/ox7b+JWb57kQ==
smart-buffer@^4.1.0:
version "4.2.0"
resolved "https://registry.yarnpkg.com/smart-buffer/-/smart-buffer-4.2.0.tgz#6e1d71fa4f18c05f7d0ff216dd16a481d0e8d9ae"
integrity sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==
snakeize@^0.1.0:
version "0.1.0"
resolved "https://registry.yarnpkg.com/snakeize/-/snakeize-0.1.0.tgz#10c088d8b58eb076b3229bb5a04e232ce126422d"
integrity sha1-EMCI2LWOsHazIpu1oE4jLOEmQi0=
socks-proxy-agent@^4.0.1:
version "4.0.2"
resolved "https://registry.yarnpkg.com/socks-proxy-agent/-/socks-proxy-agent-4.0.2.tgz#3c8991f3145b2799e70e11bd5fbc8b1963116386"
integrity sha512-NT6syHhI9LmuEMSK6Kd2V7gNv5KFZoLE7V5udWmn0de+3Mkj3UMA/AJPLyeNUVmElCurSHtUdM3ETpR3z770Wg==
dependencies:
agent-base "~4.2.1"
socks "~2.3.2"
socks@~2.3.2:
version "2.3.3"
resolved "https://registry.yarnpkg.com/socks/-/socks-2.3.3.tgz#01129f0a5d534d2b897712ed8aceab7ee65d78e3"
integrity sha512-o5t52PCNtVdiOvzMry7wU4aOqYWL0PeCXRWBEiJow4/i/wr+wpsJQ9awEu1EonLIqsfGd5qSgDdxEOvCdmBEpA==
dependencies:
ip "1.1.5"
smart-buffer "^4.1.0"
source-map@~0.6.1:
version "0.6.1"
resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263"
integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==
"statuses@>= 1.5.0 < 2", statuses@~1.5.0:
version "1.5.0"
resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c"
@ -1464,6 +1858,18 @@ string_decoder@^1.1.1:
dependencies:
safe-buffer "~5.2.0"
string_decoder@~0.10.x:
version "0.10.31"
resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-0.10.31.tgz#62e203bc41766c6c28c9fc84301dab1c5310fa94"
integrity sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ=
string_decoder@~1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8"
integrity sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==
dependencies:
safe-buffer "~5.1.0"
strip-ansi@^6.0.0, strip-ansi@^6.0.1:
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
@ -1495,6 +1901,11 @@ teeny-request@^7.0.0:
stream-events "^1.0.5"
uuid "^8.0.0"
thunkify@^2.1.2:
version "2.1.2"
resolved "https://registry.yarnpkg.com/thunkify/-/thunkify-2.1.2.tgz#faa0e9d230c51acc95ca13a361ac05ca7e04553d"
integrity sha1-+qDp0jDFGsyVyhOjYawFyn4EVT0=
toidentifier@1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35"
@ -1505,11 +1916,23 @@ tr46@~0.0.3:
resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a"
integrity sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o=
tslib@^2.1.0:
tslib@^2.0.1, tslib@^2.1.0:
version "2.3.1"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.3.1.tgz#e8a335add5ceae51aa261d32a490158ef042ef01"
integrity sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==
tsscmp@^1.0.6:
version "1.0.6"
resolved "https://registry.yarnpkg.com/tsscmp/-/tsscmp-1.0.6.tgz#85b99583ac3589ec4bfef825b5000aa911d605eb"
integrity sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==
type-check@~0.3.2:
version "0.3.2"
resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.3.2.tgz#5884cab512cf1d355e3fb784f30804b2b520db72"
integrity sha1-WITKtRLPHTVeP7eE8wgEsrUg23I=
dependencies:
prelude-ls "~1.1.2"
type-is@~1.6.18:
version "1.6.18"
resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131"
@ -1542,7 +1965,7 @@ unpipe@1.0.0, unpipe@~1.0.0:
resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec"
integrity sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw=
util-deprecate@^1.0.1:
util-deprecate@^1.0.1, util-deprecate@~1.0.1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=
@ -1589,6 +2012,16 @@ whatwg-url@^5.0.0:
tr46 "~0.0.3"
webidl-conversions "^3.0.0"
with-callback@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/with-callback/-/with-callback-1.0.2.tgz#a09629b9a920028d721404fb435bdcff5c91bc21"
integrity sha1-oJYpuakgAo1yFAT7Q1vc/1yRvCE=
word-wrap@~1.2.3:
version "1.2.3"
resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c"
integrity sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==
wrap-ansi@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
@ -1618,6 +2051,11 @@ xdg-basedir@^4.0.0:
resolved "https://registry.yarnpkg.com/xdg-basedir/-/xdg-basedir-4.0.0.tgz#4bc8d9984403696225ef83a1573cbbcb4e79db13"
integrity sha512-PSNhEJDejZYV7h50BohL09Er9VaIefr2LMAf3OEmpCkjOi34eYyQYAXUTjEQtZJTKcF0E2UKTh+osDLsgNim9Q==
xregexp@2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/xregexp/-/xregexp-2.0.0.tgz#52a63e56ca0b84a7f3a5f3d61872f126ad7a5943"
integrity sha1-UqY+VsoLhKfzpfPWGHLxJq16WUM=
y18n@^5.0.5:
version "5.0.8"
resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55"
@ -1628,6 +2066,11 @@ yallist@^2.0.0:
resolved "https://registry.yarnpkg.com/yallist/-/yallist-2.1.2.tgz#1c11f9218f076089a47dd512f93c6699a6a81d52"
integrity sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI=
yallist@^3.0.2:
version "3.1.1"
resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd"
integrity sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==
yallist@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72"

View File

@ -1,4 +1,4 @@
# Mantic Markets web
# Manifold Markets web
## Getting Started

View File

@ -10,7 +10,7 @@ export function SEO(props: {
return (
<Head>
<title>{title} | Mantic Markets</title>
<title>{title} | Manifold Markets</title>
<meta
property="og:title"
@ -29,7 +29,7 @@ export function SEO(props: {
{url && (
<meta
property="og:url"
content={'https://mantic.markets' + url}
content={'https://manifold.markets' + url}
key="url"
/>
)}

View File

@ -0,0 +1,38 @@
import clsx from 'clsx'
import { useState } from 'react'
export function AdvancedPanel(props: { children: any }) {
const { children } = props
const [collapsed, setCollapsed] = useState(true)
return (
<div
tabIndex={0}
className={clsx(
'relative collapse collapse-arrow',
collapsed ? 'collapse-close' : 'collapse-open'
)}
>
<div
onClick={() => setCollapsed((collapsed) => !collapsed)}
className="cursor-pointer"
>
<div className="mt-4 mr-6 text-sm text-gray-400 text-right">
Advanced
</div>
<div
className="collapse-title p-0 absolute w-0 h-0 min-h-0"
style={{
top: -2,
right: -15,
color: '#9ca3af' /* gray-400 */,
}}
/>
</div>
<div className="collapse-content !p-0 m-0 !bg-transparent">
{children}
</div>
</div>
)
}

View File

@ -1,6 +1,6 @@
import { getFunctions, httpsCallable } from 'firebase/functions'
import clsx from 'clsx'
import React, { useState } from 'react'
import React, { useEffect, useState } from 'react'
import { useUser } from '../hooks/use-user'
import { Contract } from '../lib/firebase/contracts'
@ -8,17 +8,31 @@ import { Col } from './layout/col'
import { Row } from './layout/row'
import { Spacer } from './layout/spacer'
import { YesNoSelector } from './yes-no-selector'
import { formatMoney, formatPercent } from '../lib/util/format'
import {
formatMoney,
formatPercent,
formatWithCommas,
} from '../lib/util/format'
import { Title } from './title'
import {
getProbability,
getDpmWeight,
calculateShares,
getProbabilityAfterBet,
} from '../lib/calculation/contract'
calculatePayoutAfterCorrectBet,
} from '../lib/calculate'
import { firebaseLogin } from '../lib/firebase/users'
import { AddFundsButton } from './add-funds-button'
import { OutcomeLabel } from './outcome-label'
import { AdvancedPanel } from './advanced-panel'
import { Bet } from '../lib/firebase/bets'
import { placeBet } from '../lib/firebase/api-call'
export function BetPanel(props: { contract: Contract; className?: string }) {
useEffect(() => {
// warm up cloud function
placeBet({}).catch()
}, [])
const { contract, className } = props
const user = useUser()
@ -85,9 +99,9 @@ export function BetPanel(props: { contract: Contract; className?: string }) {
betChoice,
betAmount ?? 0
)
const dpmWeight = getDpmWeight(contract.pool, betAmount ?? 0, betChoice)
const shares = calculateShares(contract.pool, betAmount ?? 0, betChoice)
const estimatedWinnings = Math.floor((betAmount ?? 0) + dpmWeight)
const estimatedWinnings = Math.floor(shares)
const estimatedReturn = betAmount
? (estimatedWinnings - betAmount) / betAmount
: 0
@ -97,9 +111,9 @@ export function BetPanel(props: { contract: Contract; className?: string }) {
return (
<Col
className={clsx('bg-gray-100 shadow-xl px-8 py-6 rounded-md', className)}
className={clsx('bg-gray-100 shadow-md px-8 py-6 rounded-md', className)}
>
<Title className="!mt-0 whitespace-nowrap" text="Place a bet" />
<Title className="!mt-0 whitespace-nowrap" text={`Buy ${betChoice}`} />
<div className="mt-2 mb-1 text-sm text-gray-400">Outcome</div>
<YesNoSelector
@ -108,7 +122,14 @@ export function BetPanel(props: { contract: Contract; className?: string }) {
onSelect={(choice) => onBetChoice(choice)}
/>
<div className="mt-3 mb-1 text-sm text-gray-400">Bet amount</div>
<div className="mt-3 mb-1 text-sm text-gray-400">
Amount{' '}
{user && (
<span className="float-right">
{formatMoney(remainingBalance > 0 ? remainingBalance : 0)} left
</span>
)}
</div>
<Col className="my-2">
<label className="input-group">
<span className="text-sm bg-gray-200">M$</span>
@ -153,12 +174,38 @@ export function BetPanel(props: { contract: Contract; className?: string }) {
</Row>
<div className="mt-2 mb-1 text-sm text-gray-400">
Max payout (estimated)
Estimated max payout
</div>
<div>
{formatMoney(estimatedWinnings)} &nbsp; (+{estimatedReturnPercent})
{formatMoney(estimatedWinnings)} &nbsp;{' '}
{estimatedWinnings ? <span>(+{estimatedReturnPercent})</span> : null}
</div>
<AdvancedPanel>
<div className="mt-2 mb-1 text-sm text-gray-400">
<OutcomeLabel outcome={betChoice} /> shares
</div>
<div>
{formatWithCommas(shares)} of{' '}
{formatWithCommas(shares + contract.totalShares[betChoice])}
</div>
<div className="mt-2 mb-1 text-sm text-gray-400">
Current payout if <OutcomeLabel outcome={betChoice} />
</div>
<div>
{formatMoney(
betAmount
? calculatePayoutAfterCorrectBet(contract, {
outcome: betChoice,
amount: betAmount,
shares,
} as Bet)
: 0
)}
</div>
</AdvancedPanel>
<Spacer h={6} />
{user ? (
@ -174,21 +221,18 @@ export function BetPanel(props: { contract: Contract; className?: string }) {
)}
onClick={betDisabled ? undefined : submitBet}
>
{isSubmitting ? 'Submitting...' : 'Place bet'}
{isSubmitting ? 'Submitting...' : 'Submit trade'}
</button>
) : (
<button
className="btn mt-4 border-none normal-case text-lg font-medium px-10 bg-gradient-to-r from-teal-500 to-green-500 hover:from-teal-600 hover:to-green-600"
onClick={firebaseLogin}
>
Sign in to bet!
Sign in to trade!
</button>
)}
{wasSubmitted && <div className="mt-4">Bet submitted!</div>}
{wasSubmitted && <div className="mt-4">Trade submitted!</div>}
</Col>
)
}
const functions = getFunctions()
export const placeBet = httpsCallable(functions, 'placeBet')

View File

@ -2,10 +2,16 @@ import Link from 'next/link'
import _ from 'lodash'
import dayjs from 'dayjs'
import { useEffect, useState } from 'react'
import clsx from 'clsx'
import { useUserBets } from '../hooks/use-user-bets'
import { Bet } from '../lib/firebase/bets'
import { User } from '../lib/firebase/users'
import { formatMoney, formatPercent } from '../lib/util/format'
import {
formatMoney,
formatPercent,
formatWithCommas,
} from '../lib/util/format'
import { Col } from './layout/col'
import { Spacer } from './layout/spacer'
import { Contract, getContractFromId, path } from '../lib/firebase/contracts'
@ -13,10 +19,12 @@ import { Row } from './layout/row'
import { UserLink } from './user-page'
import {
calculatePayout,
currentValue,
calculateSaleAmount,
resolvedPayout,
} from '../lib/calculation/contract'
import clsx from 'clsx'
} from '../lib/calculate'
import { sellBet } from '../lib/firebase/api-call'
import { ConfirmationButton } from './confirmation-button'
import { OutcomeLabel, YesLabel, NoLabel, MarketLabel } from './outcome-label'
export function BetsList(props: { user: User }) {
const { user } = props
@ -65,19 +73,26 @@ export function BetsList(props: { user: User }) {
contracts,
(contract) => contract.isResolved
)
const currentBets = _.sumBy(unresolved, (contract) =>
_.sumBy(contractBets[contract.id], (bet) => bet.amount)
_.sumBy(contractBets[contract.id], (bet) => {
if (bet.isSold || bet.sale) return 0
return bet.amount
})
)
const currentBetsValue = _.sumBy(unresolved, (contract) =>
_.sumBy(contractBets[contract.id], (bet) => currentValue(contract, bet))
_.sumBy(contractBets[contract.id], (bet) => {
if (bet.isSold || bet.sale) return 0
return calculatePayout(contract, bet, 'MKT')
})
)
return (
<Col className="mt-6 gap-6">
<Row className="gap-8">
<Col>
<div className="text-sm text-gray-500">Active bets</div>
<div className="text-sm text-gray-500">Currently invested</div>
<div>{formatMoney(currentBets)}</div>
</Col>
<Col>
@ -168,41 +183,49 @@ function MyContractBets(props: { contract: Contract; bets: Bet[] }) {
export function MyBetsSummary(props: {
contract: Contract
bets: Bet[]
showMKT?: boolean
className?: string
}) {
const { bets, contract, className } = props
const { bets, contract, showMKT, className } = props
const { resolution } = contract
const betsTotal = _.sumBy(bets, (bet) => bet.amount)
const excludeSales = bets.filter((b) => !b.isSold && !b.sale)
const betsTotal = _.sumBy(excludeSales, (bet) => bet.amount)
const betsPayout = resolution
? _.sumBy(bets, (bet) => resolvedPayout(contract, bet))
? _.sumBy(excludeSales, (bet) => resolvedPayout(contract, bet))
: 0
const yesWinnings = _.sumBy(bets, (bet) =>
const yesWinnings = _.sumBy(excludeSales, (bet) =>
calculatePayout(contract, bet, 'YES')
)
const noWinnings = _.sumBy(bets, (bet) =>
const noWinnings = _.sumBy(excludeSales, (bet) =>
calculatePayout(contract, bet, 'NO')
)
const marketWinnings = _.sumBy(excludeSales, (bet) =>
calculatePayout(contract, bet, 'MKT')
)
return (
<Row className={clsx('gap-4 sm:gap-6', className)}>
<Row
className={clsx(
'gap-4 sm:gap-6',
showMKT && 'flex-wrap sm:flex-nowrap',
className
)}
>
<Col>
<div className="text-sm text-gray-500 whitespace-nowrap">
Total bets
</div>
<div className="text-sm text-gray-500 whitespace-nowrap">Invested</div>
<div className="whitespace-nowrap">{formatMoney(betsTotal)}</div>
</Col>
{resolution ? (
<>
<Col>
<div className="text-sm text-gray-500">Payout</div>
<div className="whitespace-nowrap">{formatMoney(betsPayout)}</div>
</Col>
</>
<Col>
<div className="text-sm text-gray-500">Payout</div>
<div className="whitespace-nowrap">{formatMoney(betsPayout)}</div>
</Col>
) : (
<>
<Row className="gap-4 sm:gap-6">
<Col>
<div className="text-sm text-gray-500 whitespace-nowrap">
Payout if <YesLabel />
@ -215,7 +238,17 @@ export function MyBetsSummary(props: {
</div>
<div className="whitespace-nowrap">{formatMoney(noWinnings)}</div>
</Col>
</>
{showMKT && (
<Col>
<div className="text-sm text-gray-500 whitespace-nowrap">
Payout if <MarketLabel />
</div>
<div className="whitespace-nowrap">
{formatMoney(marketWinnings)}
</div>
</Col>
)}
</Row>
)}
</Row>
)
@ -228,6 +261,11 @@ export function ContractBetsTable(props: {
}) {
const { contract, bets, className } = props
const [sales, buys] = _.partition(bets, (bet) => bet.sale)
const salesDict = _.fromPairs(
sales.map((sale) => [sale.sale?.betId ?? '', sale])
)
const { isResolved } = contract
return (
@ -237,15 +275,21 @@ export function ContractBetsTable(props: {
<tr className="p-2">
<th>Date</th>
<th>Outcome</th>
<th>Bet</th>
<th>Amount</th>
<th>Probability</th>
{!isResolved && <th>Est. max payout</th>}
<th>{isResolved ? <>Payout</> : <>Current value</>}</th>
<th>Shares</th>
<th>{isResolved ? <>Payout</> : <>Sale price</>}</th>
<th></th>
</tr>
</thead>
<tbody>
{bets.map((bet) => (
<BetRow key={bet.id} bet={bet} contract={contract} />
{buys.map((bet) => (
<BetRow
key={bet.id}
bet={bet}
sale={salesDict[bet.id]}
contract={contract}
/>
))}
</tbody>
</table>
@ -253,14 +297,22 @@ export function ContractBetsTable(props: {
)
}
function BetRow(props: { bet: Bet; contract: Contract }) {
const { bet, contract } = props
const { amount, outcome, createdTime, probBefore, probAfter, dpmWeight } = bet
function BetRow(props: { bet: Bet; contract: Contract; sale?: Bet }) {
const { bet, sale, contract } = props
const {
amount,
outcome,
createdTime,
probBefore,
probAfter,
shares,
isSold,
} = bet
const { isResolved } = contract
return (
<tr>
<td>{dayjs(createdTime).format('MMM D, H:mma')}</td>
<td>{dayjs(createdTime).format('MMM D, h:mma')}</td>
<td>
<OutcomeLabel outcome={outcome} />
</td>
@ -268,34 +320,59 @@ function BetRow(props: { bet: Bet; contract: Contract }) {
<td>
{formatPercent(probBefore)} {formatPercent(probAfter)}
</td>
{!isResolved && <td>{formatMoney(amount + dpmWeight)}</td>}
<td>{formatWithCommas(shares)}</td>
<td>
{formatMoney(
isResolved
? resolvedPayout(contract, bet)
: currentValue(contract, bet)
{sale ? (
<>{formatMoney(Math.abs(sale.amount))} (sold)</>
) : (
formatMoney(
isResolved
? resolvedPayout(contract, bet)
: calculateSaleAmount(contract, bet)
)
)}
</td>
{!isResolved && !isSold && (
<td className="text-neutral">
<SellButton contract={contract} bet={bet} />
</td>
)}
</tr>
)
}
function OutcomeLabel(props: { outcome: 'YES' | 'NO' | 'CANCEL' }) {
const { outcome } = props
function SellButton(props: { contract: Contract; bet: Bet }) {
useEffect(() => {
// warm up cloud function
sellBet({}).catch()
}, [])
if (outcome === 'YES') return <YesLabel />
if (outcome === 'NO') return <NoLabel />
return <CancelLabel />
}
const { contract, bet } = props
const [isSubmitting, setIsSubmitting] = useState(false)
function YesLabel() {
return <span className="text-primary">YES</span>
}
function NoLabel() {
return <span className="text-red-400">NO</span>
}
function CancelLabel() {
return <span className="text-yellow-400">N/A</span>
return (
<ConfirmationButton
id={`sell-${bet.id}`}
openModelBtn={{
className: clsx('btn-sm', isSubmitting && 'btn-disabled loading'),
label: 'Sell',
}}
submitBtn={{ className: 'btn-primary' }}
onSubmit={async () => {
setIsSubmitting(true)
await sellBet({ contractId: contract.id, betId: bet.id })
setIsSubmitting(false)
}}
>
<div className="text-2xl mb-4">
Sell <OutcomeLabel outcome={bet.outcome} />
</div>
<div>
Do you want to sell {formatWithCommas(bet.shares)} shares of{' '}
<OutcomeLabel outcome={bet.outcome} /> for{' '}
{formatMoney(calculateSaleAmount(contract, bet))}?
</div>
</ConfirmationButton>
)
}

View File

@ -0,0 +1,151 @@
import clsx from 'clsx'
import Link from 'next/link'
import { Row } from '../components/layout/row'
import { formatMoney } from '../lib/util/format'
import { UserLink } from './user-page'
import { Linkify } from './linkify'
import { Contract, compute, path } from '../lib/firebase/contracts'
import { Col } from './layout/col'
import { parseTags } from '../lib/util/parse'
import dayjs from 'dayjs'
export function ContractCard(props: { contract: Contract }) {
const { contract } = props
const { question, resolution } = contract
const { probPercent } = compute(contract)
return (
<Link href={path(contract)}>
<a>
<li className="col-span-1 bg-white hover:bg-gray-100 shadow-md rounded-lg divide-y divide-gray-200">
<div className="card">
<div className="card-body p-6">
<Row className="justify-between gap-4 mb-2">
<p className="font-medium text-indigo-700">{question}</p>
<ResolutionOrChance
className="items-center"
resolution={resolution}
probPercent={probPercent}
/>
</Row>
<AbbrContractDetails contract={contract} />
</div>
</div>
</li>
</a>
</Link>
)
}
export function ResolutionOrChance(props: {
resolution?: 'YES' | 'NO' | 'MKT' | 'CANCEL'
probPercent: string
large?: boolean
className?: string
}) {
const { resolution, probPercent, large, className } = props
const resolutionColor = {
YES: 'text-primary',
NO: 'text-red-400',
MKT: 'text-blue-400',
CANCEL: 'text-yellow-400',
'': '', // Empty if unresolved
}[resolution || '']
const resolutionText = {
YES: 'YES',
NO: 'NO',
MKT: 'MKT',
CANCEL: 'N/A',
'': '',
}[resolution || '']
return (
<Col className={clsx(large ? 'text-4xl' : 'text-3xl', className)}>
{resolution ? (
<>
<div
className={clsx('text-gray-500', large ? 'text-xl' : 'text-base')}
>
Resolved
</div>
<div className={resolutionColor}>{resolutionText}</div>
</>
) : (
<>
<div className="text-primary">{probPercent}</div>
<div
className={clsx('text-primary', large ? 'text-xl' : 'text-base')}
>
chance
</div>
</>
)}
</Col>
)
}
export function AbbrContractDetails(props: { contract: Contract }) {
const { contract } = props
const { truePool } = compute(contract)
return (
<Col className={clsx('text-sm text-gray-500 gap-2')}>
<Row className="gap-2 flex-wrap">
<div className="whitespace-nowrap">
<UserLink username={contract.creatorUsername} />
</div>
<div className=""></div>
<div className="whitespace-nowrap">{formatMoney(truePool)} pool</div>
</Row>
</Col>
)
}
export function ContractDetails(props: { contract: Contract }) {
const { contract } = props
const { question, description, closeTime } = contract
const { truePool, createdDate, resolvedDate } = compute(contract)
const tags = parseTags(`${question} ${description}`).map((tag) => `#${tag}`)
return (
<Col className="text-sm text-gray-500 gap-2 sm:flex-row sm:flex-wrap">
<Row className="gap-2 flex-wrap">
<div className="whitespace-nowrap">
<UserLink username={contract.creatorUsername} />
</div>
<div className=""></div>
<div className="whitespace-nowrap">
{resolvedDate ? `${createdDate} - ${resolvedDate}` : createdDate}
</div>
{!resolvedDate && closeTime && (
<>
<div className=""></div>
<div className="whitespace-nowrap">
{closeTime > Date.now() ? 'Closes' : 'Closed'}{' '}
{dayjs(closeTime).format('MMM D, h:mma')}
</div>
</>
)}
<div className=""></div>
<div className="whitespace-nowrap">{formatMoney(truePool)} pool</div>
</Row>
{tags.length > 0 && (
<>
<div className="hidden sm:block"></div>
<Row className="gap-2 flex-wrap">
{tags.map((tag) => (
<div key={tag} className="bg-gray-100 px-1">
<Linkify text={tag} gray />
</div>
))}
</Row>
</>
)}
</Col>
)
}

View File

@ -0,0 +1,471 @@
// From https://tailwindui.com/components/application-ui/lists/feeds
import { useState } from 'react'
import {
BanIcon,
ChatAltIcon,
CheckIcon,
LockClosedIcon,
StarIcon,
UserIcon,
UsersIcon,
XIcon,
} from '@heroicons/react/solid'
import { useBets } from '../hooks/use-bets'
import { Bet } from '../lib/firebase/bets'
import { Comment, mapCommentsByBetId } from '../lib/firebase/comments'
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import { OutcomeLabel } from './outcome-label'
import { Contract, setContract } from '../lib/firebase/contracts'
import { useUser } from '../hooks/use-user'
import { Linkify } from './linkify'
import { Row } from './layout/row'
import { createComment } from '../lib/firebase/comments'
import { useComments } from '../hooks/use-comments'
import { formatMoney } from '../lib/util/format'
dayjs.extend(relativeTime)
function FeedComment(props: { activityItem: any }) {
const { activityItem } = props
const { person, text, amount, outcome, createdTime } = activityItem
return (
<>
<div className="relative">
<img
className="h-10 w-10 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-gray-50"
src={person.avatarUrl}
alt=""
/>
<span className="absolute -bottom-3 -right-2 bg-gray-50 rounded-tl px-0.5 py-px">
<ChatAltIcon className="h-5 w-5 text-gray-400" aria-hidden="true" />
</span>
</div>
<div className="min-w-0 flex-1">
<div>
<p className="mt-0.5 text-sm text-gray-500">
<a href={person.href} className="font-medium text-gray-900">
{person.name}
</a>{' '}
placed M$ {amount} on <OutcomeLabel outcome={outcome} />{' '}
<Timestamp time={createdTime} />
</p>
</div>
<div className="mt-2 text-gray-700">
<p className="whitespace-pre-wrap">
<Linkify text={text} />
</p>
</div>
</div>
</>
)
}
function Timestamp(props: { time: number }) {
const { time } = props
return (
<span
className="whitespace-nowrap text-gray-300 ml-1"
title={dayjs(time).format('MMM D, h:mma')}
>
{dayjs(time).fromNow()}
</span>
)
}
function FeedBet(props: { activityItem: any }) {
const { activityItem } = props
const { id, contractId, amount, outcome, createdTime } = activityItem
const user = useUser()
const isCreator = user?.id == activityItem.userId
const [comment, setComment] = useState('')
async function submitComment() {
if (!user || !comment) return
await createComment(contractId, id, comment, user)
}
return (
<>
<div>
<div className="relative px-1">
<div className="h-8 w-8 bg-gray-200 rounded-full ring-8 ring-gray-50 flex items-center justify-center">
<UserIcon className="h-5 w-5 text-gray-500" aria-hidden="true" />
</div>
</div>
</div>
<div className="min-w-0 flex-1 py-1.5">
<div className="text-sm text-gray-500">
<span className="text-gray-900">
{isCreator ? 'You' : 'A trader'}
</span>{' '}
placed {formatMoney(amount)} on <OutcomeLabel outcome={outcome} />{' '}
<Timestamp time={createdTime} />
{isCreator && (
// Allow user to comment in an textarea if they are the creator
<div className="mt-2">
<textarea
value={comment}
onChange={(e) => setComment(e.target.value)}
className="textarea textarea-bordered w-full"
placeholder="Add a comment..."
/>
<button
className="btn btn-outline btn-sm mt-1"
onClick={submitComment}
>
Comment
</button>
</div>
)}
</div>
</div>
</>
)
}
export function ContractDescription(props: {
contract: Contract
isCreator: boolean
}) {
const { contract, isCreator } = props
const [editing, setEditing] = useState(false)
const editStatement = () => `${dayjs().format('MMM D, h:mma')}: `
const [description, setDescription] = useState(editStatement())
// Append the new description (after a newline)
async function saveDescription(e: any) {
e.preventDefault()
setEditing(false)
contract.description = `${contract.description}\n${description}`.trim()
await setContract(contract)
setDescription(editStatement())
}
return (
<div className="whitespace-pre-line break-words mt-2 text-gray-700">
<Linkify text={contract.description} />
<br />
{isCreator &&
!contract.resolution &&
(editing ? (
<form className="mt-4">
<textarea
className="textarea h-24 textarea-bordered w-full mb-1"
value={description}
onChange={(e) => setDescription(e.target.value || '')}
autoFocus
onFocus={(e) =>
// Focus starts at end of description.
e.target.setSelectionRange(
description.length,
description.length
)
}
/>
<Row className="gap-2">
<button
className="btn btn-neutral btn-outline btn-sm"
onClick={saveDescription}
>
Save
</button>
<button
className="btn btn-error btn-outline btn-sm"
onClick={() => setEditing(false)}
>
Cancel
</button>
</Row>
</form>
) : (
<Row>
<button
className="btn btn-neutral btn-outline btn-sm mt-4"
onClick={() => setEditing(true)}
>
Add to description
</button>
</Row>
))}
</div>
)
}
function FeedStart(props: { contract: Contract }) {
const { contract } = props
const user = useUser()
const isCreator = user?.id === contract.creatorId
return (
<>
<div>
<div className="relative px-1">
<div className="h-8 w-8 bg-gray-200 rounded-full ring-8 ring-gray-50 flex items-center justify-center">
<StarIcon className="h-5 w-5 text-gray-500" aria-hidden="true" />
</div>
</div>
</div>
<div className="min-w-0 flex-1 py-1.5">
<div className="text-sm text-gray-500">
<span className="text-gray-900">{contract.creatorName}</span> created
this market <Timestamp time={contract.createdTime} />
</div>
<ContractDescription contract={contract} isCreator={isCreator} />
</div>
</>
)
}
function OutcomeIcon(props: { outcome?: 'YES' | 'NO' | 'CANCEL' }) {
const { outcome } = props
switch (outcome) {
case 'YES':
return <CheckIcon className="h-5 w-5 text-gray-500" aria-hidden="true" />
case 'NO':
return <XIcon className="h-5 w-5 text-gray-500" aria-hidden="true" />
case 'CANCEL':
default:
return <BanIcon className="h-5 w-5 text-gray-500" aria-hidden="true" />
}
}
function FeedResolve(props: { contract: Contract }) {
const { contract } = props
const resolution = contract.resolution || 'CANCEL'
return (
<>
<div>
<div className="relative px-1">
<div className="h-8 w-8 bg-gray-200 rounded-full ring-8 ring-gray-50 flex items-center justify-center">
<OutcomeIcon outcome={resolution} />
</div>
</div>
</div>
<div className="min-w-0 flex-1 py-1.5">
<div className="text-sm text-gray-500">
<span className="text-gray-900">{contract.creatorName}</span> resolved
this market to <OutcomeLabel outcome={resolution} />{' '}
<Timestamp time={contract.resolutionTime || 0} />
</div>
</div>
</>
)
}
function FeedClose(props: { contract: Contract }) {
const { contract } = props
return (
<>
<div>
<div className="relative px-1">
<div className="h-8 w-8 bg-gray-200 rounded-full ring-8 ring-gray-50 flex items-center justify-center">
<LockClosedIcon
className="h-5 w-5 text-gray-500"
aria-hidden="true"
/>
</div>
</div>
</div>
<div className="min-w-0 flex-1 py-1.5">
<div className="text-sm text-gray-500">
Trading closed in this market{' '}
<Timestamp time={contract.closeTime || 0} />
</div>
</div>
</>
)
}
function toFeedBet(bet: Bet) {
return {
id: bet.id,
contractId: bet.contractId,
userId: bet.userId,
type: 'bet',
amount: bet.amount,
outcome: bet.outcome,
createdTime: bet.createdTime,
date: dayjs(bet.createdTime).fromNow(),
}
}
function toFeedComment(bet: Bet, comment: Comment) {
return {
id: bet.id,
contractId: bet.contractId,
userId: bet.userId,
type: 'comment',
amount: bet.amount,
outcome: bet.outcome,
createdTime: bet.createdTime,
date: dayjs(bet.createdTime).fromNow(),
// Invariant: bet.comment exists
text: comment.text,
person: {
href: `/${comment.userUsername}`,
name: comment.userName,
avatarUrl: comment.userAvatarUrl,
},
}
}
// Group together bets that are:
// - Within 24h of the first in the group
// - Do not have a comment
// - Were not created by this user
// Return a list of ActivityItems
function group(bets: Bet[], comments: Comment[], userId?: string) {
const commentsMap = mapCommentsByBetId(comments)
const items: any[] = []
let group: Bet[] = []
// Turn the current group into an ActivityItem
function pushGroup() {
if (group.length == 1) {
items.push(toActivityItem(group[0]))
} else if (group.length > 1) {
items.push({ type: 'betgroup', bets: [...group], id: group[0].id })
}
group = []
}
function toActivityItem(bet: Bet) {
const comment = commentsMap[bet.id]
return comment ? toFeedComment(bet, comment) : toFeedBet(bet)
}
for (const bet of bets) {
const isCreator = userId === bet.userId
if (commentsMap[bet.id] || isCreator) {
pushGroup()
// Create a single item for this
items.push(toActivityItem(bet))
} else {
if (
group.length > 0 &&
dayjs(bet.createdTime).diff(dayjs(group[0].createdTime), 'hour') > 24
) {
// More than 24h has passed; start a new group
pushGroup()
}
group.push(bet)
}
}
if (group.length > 0) {
pushGroup()
}
return items as ActivityItem[]
}
// TODO: Make this expandable to show all grouped bets?
function FeedBetGroup(props: { activityItem: any }) {
const { activityItem } = props
const bets: Bet[] = activityItem.bets
const yesAmount = bets
.filter((b) => b.outcome == 'YES')
.reduce((acc, bet) => acc + bet.amount, 0)
const yesSpan = yesAmount ? (
<span>
{formatMoney(yesAmount)} on <OutcomeLabel outcome={'YES'} />
</span>
) : null
const noAmount = bets
.filter((b) => b.outcome == 'NO')
.reduce((acc, bet) => acc + bet.amount, 0)
const noSpan = noAmount ? (
<span>
{formatMoney(noAmount)} on <OutcomeLabel outcome={'NO'} />
</span>
) : null
const traderCount = bets.length
const createdTime = bets[0].createdTime
return (
<>
<div>
<div className="relative px-1">
<div className="h-8 w-8 bg-gray-200 rounded-full ring-8 ring-gray-50 flex items-center justify-center">
<UsersIcon className="h-5 w-5 text-gray-500" aria-hidden="true" />
</div>
</div>
</div>
<div className="min-w-0 flex-1 py-1.5">
<div className="text-sm text-gray-500">
<span className="text-gray-900">{traderCount} traders</span> placed{' '}
{yesSpan}
{yesAmount && noAmount ? ' and ' : ''}
{noSpan} <Timestamp time={createdTime} />
</div>
</div>
</>
)
}
// Missing feed items:
// - Bet sold?
type ActivityItem = {
id: string
type: 'bet' | 'comment' | 'start' | 'betgroup' | 'close' | 'resolve'
}
export function ContractFeed(props: { contract: Contract }) {
const { contract } = props
const { id } = contract
const user = useUser()
let bets = useBets(id)
if (bets === 'loading') bets = []
let comments = useComments(id)
if (comments === 'loading') comments = []
const allItems = [
{ type: 'start', id: 0 },
...group(bets, comments, user?.id),
]
if (contract.closeTime && contract.closeTime <= Date.now()) {
allItems.push({ type: 'close', id: `${contract.closeTime}` })
}
if (contract.resolution) {
allItems.push({ type: 'resolve', id: `${contract.resolutionTime}` })
}
return (
<div className="flow-root">
<ul role="list" className="-mb-8">
{allItems.map((activityItem, activityItemIdx) => (
<li key={activityItem.id}>
<div className="relative pb-8">
{activityItemIdx !== allItems.length - 1 ? (
<span
className="absolute top-5 left-5 -ml-px h-full w-0.5 bg-gray-200"
aria-hidden="true"
/>
) : null}
<div className="relative flex items-start space-x-3">
{activityItem.type === 'start' ? (
<FeedStart contract={contract} />
) : activityItem.type === 'comment' ? (
<FeedComment activityItem={activityItem} />
) : activityItem.type === 'bet' ? (
<FeedBet activityItem={activityItem} />
) : activityItem.type === 'betgroup' ? (
<FeedBetGroup activityItem={activityItem} />
) : activityItem.type === 'close' ? (
<FeedClose contract={contract} />
) : activityItem.type === 'resolve' ? (
<FeedResolve contract={contract} />
) : null}
</div>
</div>
</li>
))}
</ul>
</div>
)
}

View File

@ -1,132 +1,72 @@
import { useState } from 'react'
import {
compute,
Contract,
deleteContract,
setContract,
path,
} from '../lib/firebase/contracts'
import { Col } from './layout/col'
import { Spacer } from './layout/spacer'
import { ContractProbGraph } from './contract-prob-graph'
import { ContractDetails } from './contracts-list'
import router from 'next/router'
import { useUser } from '../hooks/use-user'
import { Row } from './layout/row'
import dayjs from 'dayjs'
import { Linkify } from './linkify'
import clsx from 'clsx'
function ContractDescription(props: {
contract: Contract
isCreator: boolean
}) {
const { contract, isCreator } = props
const [editing, setEditing] = useState(false)
const editStatement = () => `${dayjs().format('MMM D, h:mma')}: `
const [description, setDescription] = useState(editStatement())
// Append the new description (after a newline)
async function saveDescription(e: any) {
e.preventDefault()
setEditing(false)
contract.description = `${contract.description}\n${description}`.trim()
await setContract(contract)
setDescription(editStatement())
}
return (
<div className="whitespace-pre-line">
<Linkify text={contract.description} />
<br />
{isCreator &&
!contract.resolution &&
(editing ? (
<form className="mt-4">
<textarea
className="textarea h-24 textarea-bordered w-full mb-2"
value={description}
onChange={(e) => setDescription(e.target.value || '')}
autoFocus
onFocus={(e) =>
// Focus starts at end of description.
e.target.setSelectionRange(
description.length,
description.length
)
}
/>
<Row className="gap-4 justify-end">
<button
className="btn btn-error btn-outline btn-sm mt-2"
onClick={() => setEditing(false)}
>
Cancel
</button>
<button
className="btn btn-neutral btn-outline btn-sm mt-2"
onClick={saveDescription}
>
Save
</button>
</Row>
</form>
) : (
<Row className="justify-end">
<button
className="btn btn-neutral btn-outline btn-sm mt-4"
onClick={() => setEditing(true)}
>
Add to description
</button>
</Row>
))}
</div>
)
}
import { ContractDetails, ResolutionOrChance } from './contract-card'
import { ContractFeed } from './contract-feed'
import { TweetButton } from './tweet-button'
export const ContractOverview = (props: {
contract: Contract
className?: string
}) => {
const { contract, className } = props
const { resolution, creatorId } = contract
const { probPercent, volume } = compute(contract)
const { resolution, creatorId, creatorName } = contract
const { probPercent, truePool } = compute(contract)
const user = useUser()
const isCreator = user?.id === creatorId
const resolutionColor = {
YES: 'text-primary',
NO: 'text-red-400',
CANCEL: 'text-yellow-400',
'': '', // Empty if unresolved
}[contract.resolution || '']
const tweetQuestion = isCreator
? contract.question
: `${creatorName}: ${contract.question}`
const tweetDescription = resolution
? isCreator
? `Resolved ${resolution}!`
: `Resolved ${resolution} by ${creatorName}:`
: `Currently ${probPercent} chance, place your bets here:`
const url = `https://manifold.markets${path(contract)}`
const tweetText = `${tweetQuestion}\n\n${tweetDescription}\n\n${url}`
return (
<Col className={clsx('mb-6', className)}>
<Col className="justify-between md:flex-row">
<Col>
<div className="text-3xl text-indigo-700 mb-4">
<Row className="justify-between gap-4">
<Col className="gap-4">
<div className="text-2xl md:text-3xl text-indigo-700">
<Linkify text={contract.question} />
</div>
<ResolutionOrChance
className="md:hidden"
resolution={resolution}
probPercent={probPercent}
large
/>
<ContractDetails contract={contract} />
<TweetButton className="self-end md:hidden" tweetText={tweetText} />
</Col>
{resolution ? (
<Col className="text-4xl mt-8 md:mt-0 md:ml-4 md:mr-6 items-end self-center md:self-start">
<div className="text-xl text-gray-500">Resolved</div>
<div className={resolutionColor}>
{resolution === 'CANCEL' ? 'N/A' : resolution}
</div>
</Col>
) : (
<Col className="text-4xl mt-8 md:mt-0 md:ml-4 md:mr-6 text-primary items-end self-center md:self-start">
{probPercent}
<div className="text-xl">chance</div>
</Col>
)}
</Col>
<Col className="hidden md:flex justify-between items-end">
<ResolutionOrChance
className="items-end"
resolution={resolution}
probPercent={probPercent}
large
/>
<TweetButton className="mt-6" tweetText={tweetText} />
</Col>
</Row>
<Spacer h={4} />
@ -134,13 +74,8 @@ export const ContractOverview = (props: {
<Spacer h={12} />
{((isCreator && !contract.resolution) || contract.description) && (
<label className="text-gray-500 mb-2 text-sm">Description</label>
)}
<ContractDescription contract={contract} isCreator={isCreator} />
{/* Show a delete button for contracts without any trading */}
{isCreator && volume === 0 && (
{isCreator && truePool === 0 && (
<>
<Spacer h={8} />
<button
@ -155,6 +90,8 @@ export const ContractOverview = (props: {
</button>
</>
)}
<ContractFeed contract={contract} />
</Col>
)
}

View File

@ -3,84 +3,15 @@ import Link from 'next/link'
import clsx from 'clsx'
import { useEffect, useState } from 'react'
import { Row } from '../components/layout/row'
import {
compute,
Contract,
listContracts,
path,
} from '../lib/firebase/contracts'
import { formatMoney } from '../lib/util/format'
import { compute, Contract, listContracts } from '../lib/firebase/contracts'
import { User } from '../lib/firebase/users'
import { UserLink } from './user-page'
import { Linkify } from './linkify'
import { Col } from './layout/col'
import { SiteLink } from './site-link'
import { parseTags } from '../lib/util/parse'
import { ContractCard } from './contract-card'
import { Sort, useQueryAndSortParams } from '../hooks/use-sort-and-query-params'
export function ContractDetails(props: { contract: Contract }) {
const { contract } = props
const { volume, createdDate, resolvedDate } = compute(contract)
return (
<Row className="flex-wrap text-sm text-gray-500">
<div className="whitespace-nowrap">
<UserLink username={contract.creatorUsername} />
</div>
<div className="mx-2"></div>
<div className="whitespace-nowrap">
{resolvedDate ? `${createdDate} - ${resolvedDate}` : createdDate}
</div>
<div className="mx-2"></div>
<div className="whitespace-nowrap">{formatMoney(volume)} volume</div>
</Row>
)
}
function ContractCard(props: { contract: Contract }) {
const { contract } = props
const { probPercent } = compute(contract)
const resolutionColor = {
YES: 'text-primary',
NO: 'text-red-400',
CANCEL: 'text-yellow-400',
'': '', // Empty if unresolved
}[contract.resolution || '']
const resolutionText = {
YES: 'YES',
NO: 'NO',
CANCEL: 'N/A',
'': '',
}[contract.resolution || '']
return (
<Link href={path(contract)}>
<a>
<li className="col-span-1 bg-white hover:bg-gray-100 shadow-xl rounded-lg divide-y divide-gray-200">
<div className="card">
<div className="card-body p-6">
<Row className="justify-between gap-4 mb-2">
<p className="font-medium text-indigo-700">
<Linkify text={contract.question} />
</p>
<div className={clsx('text-4xl', resolutionColor)}>
{resolutionText || (
<div className="text-primary">
{probPercent}
<div className="text-lg">chance</div>
</div>
)}
</div>
</Row>
<ContractDetails contract={contract} />
</div>
</div>
</li>
</a>
</Link>
)
}
function ContractsGrid(props: { contracts: Contract[] }) {
export function ContractsGrid(props: { contracts: Contract[] }) {
const [resolvedContracts, activeContracts] = _.partition(
props.contracts,
(c) => c.isResolved
@ -110,14 +41,122 @@ function ContractsGrid(props: { contracts: Contract[] }) {
)
}
type Sort = 'createdTime' | 'volume' | 'resolved' | 'all'
const MAX_GROUPED_CONTRACTS_DISPLAYED = 6
function CreatorContractsGrid(props: { contracts: Contract[] }) {
const { contracts } = props
const byCreator = _.groupBy(contracts, (contract) => contract.creatorId)
const creatorIds = _.sortBy(Object.keys(byCreator), (creatorId) =>
_.sumBy(byCreator[creatorId], (contract) => -1 * compute(contract).truePool)
)
return (
<Col className="gap-6">
{creatorIds.map((creatorId) => {
const { creatorUsername, creatorName } = byCreator[creatorId][0]
return (
<Col className="gap-4" key={creatorUsername}>
<SiteLink className="text-lg" href={`/${creatorUsername}`}>
{creatorName}
</SiteLink>
<ul role="list" className="grid grid-cols-1 gap-4 lg:grid-cols-2">
{byCreator[creatorId]
.slice(0, MAX_GROUPED_CONTRACTS_DISPLAYED)
.map((contract) => (
<ContractCard contract={contract} key={contract.id} />
))}
</ul>
{byCreator[creatorId].length > MAX_GROUPED_CONTRACTS_DISPLAYED ? (
<Link href={`/${creatorUsername}`}>
<a
className={clsx(
'self-end hover:underline hover:decoration-indigo-400 hover:decoration-2'
)}
onClick={(e) => e.stopPropagation()}
>
See all
</a>
</Link>
) : (
<div />
)}
</Col>
)
})}
</Col>
)
}
function TagContractsGrid(props: { contracts: Contract[] }) {
const { contracts } = props
const contractTags = _.flatMap(contracts, (contract) =>
parseTags(contract.question + ' ' + contract.description).map((tag) => ({
tag,
contract,
}))
)
const groupedByTag = _.groupBy(contractTags, ({ tag }) => tag)
const byTag = _.mapValues(groupedByTag, (contractTags) =>
contractTags.map(({ contract }) => contract)
)
const tags = _.sortBy(Object.keys(byTag), (tag) =>
_.sumBy(byTag[tag], (contract) => -1 * compute(contract).truePool)
)
return (
<Col className="gap-6">
{tags.map((tag) => {
return (
<Col className="gap-4" key={tag}>
<SiteLink className="text-lg" href={`/tag/${tag}`}>
#{tag}
</SiteLink>
<ul role="list" className="grid grid-cols-1 gap-4 lg:grid-cols-2">
{byTag[tag]
.slice(0, MAX_GROUPED_CONTRACTS_DISPLAYED)
.map((contract) => (
<ContractCard contract={contract} key={contract.id} />
))}
</ul>
{byTag[tag].length > MAX_GROUPED_CONTRACTS_DISPLAYED ? (
<Link href={`/tag/${tag}`}>
<a
className={clsx(
'self-end hover:underline hover:decoration-indigo-400 hover:decoration-2'
)}
onClick={(e) => e.stopPropagation()}
>
See all
</a>
</Link>
) : (
<div />
)}
</Col>
)
})}
</Col>
)
}
const MAX_CONTRACTS_DISPLAYED = 99
export function SearchableGrid(props: {
contracts: Contract[]
defaultSort?: Sort
query: string
setQuery: (query: string) => void
sort: Sort
setSort: (sort: Sort) => void
byOneCreator?: boolean
}) {
const { contracts, defaultSort } = props
const [query, setQuery] = useState('')
const [sort, setSort] = useState(defaultSort || 'volume')
const { contracts, query, setQuery, sort, setSort, byOneCreator } = props
function check(corpus: String) {
return corpus.toLowerCase().includes(query.toLowerCase())
@ -130,10 +169,10 @@ export function SearchableGrid(props: {
check(c.creatorUsername)
)
if (sort === 'createdTime' || sort === 'resolved' || sort === 'all') {
if (sort === 'newest' || sort === 'resolved' || sort === 'all') {
matches.sort((a, b) => b.createdTime - a.createdTime)
} else if (sort === 'volume') {
matches.sort((a, b) => compute(b).volume - compute(a).volume)
} else if (sort === 'most-traded' || sort === 'creator' || sort === 'tag') {
matches.sort((a, b) => compute(b).truePool - compute(a).truePool)
}
if (sort !== 'all') {
@ -143,6 +182,9 @@ export function SearchableGrid(props: {
)
}
if (matches.length > MAX_CONTRACTS_DISPLAYED)
matches = _.slice(matches, 0, MAX_CONTRACTS_DISPLAYED)
return (
<div>
{/* Show a search input next to a sort dropdown */}
@ -159,22 +201,37 @@ export function SearchableGrid(props: {
value={sort}
onChange={(e) => setSort(e.target.value as Sort)}
>
<option value="volume">Most traded</option>
<option value="createdTime">Newest first</option>
{byOneCreator ? (
<option value="all">All markets</option>
) : (
<option value="creator">By creator</option>
)}
<option value="tag">By tag</option>
<option value="most-traded">Most traded</option>
<option value="newest">Newest</option>
<option value="resolved">Resolved</option>
<option value="all">All markets</option>
</select>
</div>
<ContractsGrid contracts={matches} />
{sort === 'tag' ? (
<TagContractsGrid contracts={matches} />
) : !byOneCreator && (sort === 'creator' || sort === 'resolved') ? (
<CreatorContractsGrid contracts={matches} />
) : (
<ContractsGrid contracts={matches} />
)}
</div>
)
}
export function ContractsList(props: { creator: User }) {
export function CreatorContractsList(props: { creator: User }) {
const { creator } = props
const [contracts, setContracts] = useState<Contract[] | 'loading'>('loading')
const { query, setQuery, sort, setSort } = useQueryAndSortParams({
defaultSort: 'all',
})
useEffect(() => {
if (creator?.id) {
// TODO: stream changes from firestore
@ -184,5 +241,14 @@ export function ContractsList(props: { creator: User }) {
if (contracts === 'loading') return <></>
return <SearchableGrid contracts={contracts} defaultSort="all" />
return (
<SearchableGrid
contracts={contracts}
byOneCreator
query={query}
setQuery={setQuery}
sort={sort}
setSort={setSort}
/>
)
}

View File

@ -1,9 +1,9 @@
import Link from 'next/link'
import { Fragment } from 'react'
import { SiteLink } from './site-link'
// Return a JSX span, linkifying @username, #hashtags, and https://...
export function Linkify(props: { text: string }) {
const { text } = props
export function Linkify(props: { text: string; gray?: boolean }) {
const { text, gray } = props
const regex = /(?:^|\s)(?:[@#][a-z0-9_]+|https?:\/\/\S+)/gi
const matches = text.match(regex) || []
const links = matches.map((match) => {
@ -15,17 +15,18 @@ export function Linkify(props: { text: string }) {
{
'@': `/${tag}`,
'#': `/tag/${tag}`,
}[symbol] ?? match
}[symbol] ?? match.trim()
return (
<>
{whitespace}
<Link href={href}>
<a className="text-indigo-700 hover:underline hover:decoration-2">
{symbol}
{tag}
</a>
</Link>
<SiteLink
className={gray ? 'text-gray-500' : 'text-indigo-700'}
href={href}
>
{symbol}
{tag}
</SiteLink>
</>
)
})

View File

@ -14,12 +14,11 @@ export function ManticLogo(props: { darkBackground?: boolean }) {
/>
<div
className={clsx(
'font-major-mono lowercase mt-1 sm:text-2xl md:whitespace-nowrap',
'hidden sm:flex font-major-mono lowercase mt-1 sm:text-2xl md:whitespace-nowrap',
darkBackground && 'text-white'
)}
style={{ fontFamily: 'Major Mono Display,monospace' }}
>
Mantic Markets
Manifold Markets
</div>
</a>
</Link>

View File

@ -34,6 +34,7 @@ export function MenuButton(props: {
{({ active }) => (
<a
href={item.href}
target={item.href.startsWith('http') ? '_blank' : undefined}
onClick={item.onClick}
className={clsx(
active ? 'bg-gray-100' : '',

View File

@ -22,14 +22,7 @@ export function NavBar(props: {
const themeClasses = clsx(darkBackground && 'text-white', hoverClasses)
return (
<nav
className={clsx(
'w-full p-4 mb-4 shadow-sm',
!darkBackground && 'bg-white',
className
)}
aria-label="Global"
>
<nav className={clsx('w-full p-4 mb-4', className)} aria-label="Global">
<Row
className={clsx(
'justify-between items-center mx-auto sm:px-4',

View File

@ -0,0 +1,26 @@
export function OutcomeLabel(props: {
outcome: 'YES' | 'NO' | 'CANCEL' | 'MKT'
}) {
const { outcome } = props
if (outcome === 'YES') return <YesLabel />
if (outcome === 'NO') return <NoLabel />
if (outcome === 'MKT') return <MarketLabel />
return <CancelLabel />
}
export function YesLabel() {
return <span className="text-primary">YES</span>
}
export function NoLabel() {
return <span className="text-red-400">NO</span>
}
export function CancelLabel() {
return <span className="text-yellow-400">N/A</span>
}
export function MarketLabel() {
return <span className="text-blue-400">MKT</span>
}

View File

@ -7,6 +7,7 @@ export function Page(props: { wide?: boolean; children?: any }) {
return (
<div>
<NavBar wide={wide} />
<div
className={clsx(
'w-full px-4 pb-8 mx-auto',

View File

@ -41,8 +41,8 @@ function getNavigationOptions(user: User, options: { mobile: boolean }) {
]
: []),
{
name: 'Your bets',
href: '/bets',
name: 'Your trades',
href: '/trades',
},
{
name: 'Your markets',
@ -57,6 +57,10 @@ function getNavigationOptions(user: User, options: { mobile: boolean }) {
href: '#',
onClick: () => firebaseLogout(),
},
{
name: 'Discord',
href: 'https://discord.gg/eHQBNBqXuh',
},
]
}
@ -67,7 +71,7 @@ function ProfileSummary(props: { user: User }) {
<div className="rounded-full w-10 h-10 mr-4">
<Image src={user.avatarUrl} width={40} height={40} />
</div>
<div className="truncate" style={{ maxWidth: 175 }}>
<div className="truncate text-left" style={{ maxWidth: 170 }}>
{user.name}
<div className="text-gray-700 text-sm">{formatMoney(user.balance)}</div>
</div>

View File

@ -1,6 +1,5 @@
import clsx from 'clsx'
import React, { useState } from 'react'
import { getFunctions, httpsCallable } from 'firebase/functions'
import React, { useEffect, useState } from 'react'
import { Contract } from '../lib/firebase/contracts'
import { Col } from './layout/col'
@ -9,18 +8,23 @@ import { User } from '../lib/firebase/users'
import { YesNoCancelSelector } from './yes-no-selector'
import { Spacer } from './layout/spacer'
import { ConfirmationButton as ConfirmationButton } from './confirmation-button'
const functions = getFunctions()
export const resolveMarket = httpsCallable(functions, 'resolveMarket')
import { resolveMarket } from '../lib/firebase/api-call'
export function ResolutionPanel(props: {
creator: User
contract: Contract
className?: string
}) {
useEffect(() => {
// warm up cloud function
resolveMarket({}).catch()
}, [])
const { contract, className } = props
const [outcome, setOutcome] = useState<'YES' | 'NO' | 'CANCEL' | undefined>()
const [outcome, setOutcome] = useState<
'YES' | 'NO' | 'MKT' | 'CANCEL' | undefined
>()
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState<string | undefined>(undefined)
@ -48,11 +52,13 @@ export function ResolutionPanel(props: {
? 'bg-red-400 hover:bg-red-500'
: outcome === 'CANCEL'
? 'bg-yellow-400 hover:bg-yellow-500'
: outcome === 'MKT'
? 'bg-blue-400 hover:bg-blue-500'
: 'btn-disabled'
return (
<Col
className={clsx('bg-gray-100 shadow-xl px-8 py-6 rounded-md', className)}
className={clsx('bg-gray-100 shadow-md px-8 py-6 rounded-md', className)}
>
<Title className="mt-0" text="Your market" />
@ -70,18 +76,19 @@ export function ResolutionPanel(props: {
<div>
{outcome === 'YES' ? (
<>
Winnings will be paid out to YES bettors. You earn 1% of the NO
bets.
Winnings will be paid out to YES bettors. You earn 1% of the pool.
</>
) : outcome === 'NO' ? (
<>
Winnings will be paid out to NO bettors. You earn 1% of the YES
bets.
</>
<>Winnings will be paid out to NO bettors. You earn 1% of the pool.</>
) : outcome === 'CANCEL' ? (
<>All bets will be returned with no fees.</>
<>The pool will be returned to traders with no fees.</>
) : outcome === 'MKT' ? (
<>
Traders will be paid out at the current implied probability. You
earn 1% of the pool.
</>
) : (
<>Resolving this market will immediately pay out bettors.</>
<>Resolving this market will immediately pay out traders.</>
)}
</div>

View File

@ -0,0 +1,35 @@
import clsx from 'clsx'
import Link from 'next/link'
export const SiteLink = (props: {
href: string
children: any
className?: string
}) => {
const { href, children, className } = props
return href.startsWith('http') ? (
<a
href={href}
className={clsx(
'hover:underline hover:decoration-indigo-400 hover:decoration-2',
className
)}
onClick={(e) => e.stopPropagation()}
>
{children}
</a>
) : (
<Link href={href}>
<a
className={clsx(
'hover:underline hover:decoration-indigo-400 hover:decoration-2',
className
)}
onClick={(e) => e.stopPropagation()}
>
{children}
</a>
</Link>
)
}

View File

@ -4,10 +4,7 @@ export function Title(props: { text: string; className?: string }) {
const { text, className } = props
return (
<h1
className={clsx(
'text-3xl font-major-mono text-indigo-700 inline-block my-6',
className
)}
className={clsx('text-3xl text-indigo-700 inline-block my-6', className)}
>
{text}
</h1>

View File

@ -0,0 +1,24 @@
import clsx from 'clsx'
export function TweetButton(props: { className?: string; tweetText?: string }) {
const { tweetText, className } = props
return (
<a
className={clsx('btn btn-xs normal-case border-none', className)}
style={{ backgroundColor: '#1da1f2', width: 75 }}
href={`https://twitter.com/intent/tweet?text=${encodeURIComponent(
tweetText ?? ''
)}`}
target="_blank"
>
<img
className="mr-2"
src={'/twitter-icon-white.svg'}
width={15}
height={15}
/>
Tweet
</a>
)
}

View File

@ -1,28 +1,19 @@
import { firebaseLogout, User } from '../lib/firebase/users'
import { ContractsList } from './contracts-list'
import { CreatorContractsList } from './contracts-list'
import { Title } from './title'
import { Row } from './layout/row'
import { formatMoney } from '../lib/util/format'
import Link from 'next/link'
import clsx from 'clsx'
import { SEO } from './SEO'
import { Page } from './page'
import { SiteLink } from './site-link'
export function UserLink(props: { username: string; className?: string }) {
const { username, className } = props
return (
<Link href={`/${username}`}>
<a
className={clsx(
'hover:underline hover:decoration-indigo-400 hover:decoration-2',
className
)}
onClick={(e) => e.stopPropagation()}
>
@{username}
</a>
</Link>
<SiteLink href={`/${username}`} className={className}>
@{username}
</SiteLink>
)
}
@ -67,7 +58,7 @@ export function UserPage(props: { user: User; currentUser?: User }) {
const isCurrentUser = user.id === currentUser?.id
const possesive = isCurrentUser ? 'Your ' : `${user.username}'s `
const possesive = isCurrentUser ? 'Your ' : `${user.name}'s `
return (
<Page>
@ -81,7 +72,7 @@ export function UserPage(props: { user: User; currentUser?: User }) {
<Title text={possesive + 'markets'} />
<ContractsList creator={user} />
<CreatorContractsList creator={user} />
</Page>
)
}

View File

@ -1,6 +1,7 @@
import clsx from 'clsx'
import React from 'react'
import { formatMoney } from '../lib/util/format'
import { Col } from './layout/col'
import { Row } from './layout/row'
export function YesNoSelector(props: {
@ -30,41 +31,53 @@ export function YesNoSelector(props: {
}
export function YesNoCancelSelector(props: {
selected: 'YES' | 'NO' | 'CANCEL' | undefined
onSelect: (selected: 'YES' | 'NO' | 'CANCEL') => void
selected: 'YES' | 'NO' | 'MKT' | 'CANCEL' | undefined
onSelect: (selected: 'YES' | 'NO' | 'MKT' | 'CANCEL') => void
className?: string
btnClassName?: string
}) {
const { selected, onSelect, className } = props
const btnClassName = clsx('px-6', props.btnClassName)
const btnClassName = clsx('px-6 flex-1', props.btnClassName)
return (
<Row className={clsx('space-x-3', className)}>
<Button
color={selected === 'YES' ? 'green' : 'gray'}
onClick={() => onSelect('YES')}
className={btnClassName}
>
YES
</Button>
<Col>
<Row className={clsx('space-x-3 w-full', className)}>
<Button
color={selected === 'YES' ? 'green' : 'gray'}
onClick={() => onSelect('YES')}
className={btnClassName}
>
YES
</Button>
<Button
color={selected === 'NO' ? 'red' : 'gray'}
onClick={() => onSelect('NO')}
className={btnClassName}
>
NO
</Button>
<Button
color={selected === 'NO' ? 'red' : 'gray'}
onClick={() => onSelect('NO')}
className={btnClassName}
>
NO
</Button>
</Row>
<Button
color={selected === 'CANCEL' ? 'yellow' : 'gray'}
onClick={() => onSelect('CANCEL')}
className={btnClassName}
>
N/A
</Button>
</Row>
<Row className={clsx('space-x-3 w-full', className)}>
<Button
color={selected === 'MKT' ? 'blue' : 'gray'}
onClick={() => onSelect('MKT')}
className={clsx(btnClassName, 'btn-sm')}
>
MKT
</Button>
<Button
color={selected === 'CANCEL' ? 'yellow' : 'gray'}
onClick={() => onSelect('CANCEL')}
className={clsx(btnClassName, 'btn-sm')}
>
N/A
</Button>
</Row>
</Col>
)
}
@ -98,7 +111,7 @@ export function FundsSelector(props: {
function Button(props: {
className?: string
onClick?: () => void
color: 'green' | 'purple' | 'red' | 'yellow' | 'gray'
color: 'green' | 'red' | 'blue' | 'yellow' | 'gray'
children?: any
}) {
const { className, onClick, children, color } = props
@ -109,9 +122,9 @@ function Button(props: {
className={clsx(
'flex-1 inline-flex justify-center items-center px-8 py-3 border border-transparent rounded-md shadow-sm text-sm font-medium text-white',
color === 'green' && 'btn-primary',
color === 'purple' && 'btn-secondary',
color === 'red' && 'bg-red-400 hover:bg-red-500',
color === 'yellow' && 'bg-yellow-400 hover:bg-yellow-500',
color === 'blue' && 'bg-blue-400 hover:bg-blue-500',
color === 'gray' && 'text-gray-700 bg-gray-300 hover:bg-gray-400',
className
)}

12
web/hooks/use-comments.ts Normal file
View File

@ -0,0 +1,12 @@
import { useEffect, useState } from 'react'
import { Comment, listenForComments } from '../lib/firebase/comments'
export const useComments = (contractId: string) => {
const [comments, setComments] = useState<Comment[] | 'loading'>('loading')
useEffect(() => {
if (contractId) return listenForComments(contractId, setComments)
}, [contractId])
return comments
}

View File

@ -1,5 +1,11 @@
import _ from 'lodash'
import { useEffect, useState } from 'react'
import { Contract, listenForContracts } from '../lib/firebase/contracts'
import { Bet, listenForRecentBets } from '../lib/firebase/bets'
import {
computeHotContracts,
Contract,
listenForContracts,
} from '../lib/firebase/contracts'
export const useContracts = () => {
const [contracts, setContracts] = useState<Contract[] | 'loading'>('loading')
@ -10,3 +16,16 @@ export const useContracts = () => {
return contracts
}
export const useHotContracts = () => {
const [recentBets, setRecentBets] = useState<Bet[] | 'loading'>('loading')
useEffect(() => {
const oneDay = 1000 * 60 * 60 * 24
return listenForRecentBets(oneDay, setRecentBets)
}, [])
if (recentBets === 'loading') return 'loading'
return computeHotContracts(recentBets)
}

View File

@ -0,0 +1,40 @@
import { useRouter } from 'next/router'
export type Sort =
| 'creator'
| 'tag'
| 'newest'
| 'most-traded'
| 'resolved'
| 'all'
export function useQueryAndSortParams(options?: { defaultSort: Sort }) {
const router = useRouter()
const { s: sort, q: query } = router.query as {
q?: string
s?: Sort
}
const setSort = (sort: Sort | undefined) => {
router.query.s = sort
router.push(router, undefined, { shallow: true })
}
const setQuery = (query: string | undefined) => {
if (query) {
router.query.q = query
} else {
delete router.query.q
}
router.push(router, undefined, { shallow: true })
}
return {
sort: sort ?? options?.defaultSort ?? 'creator',
query: query ?? '',
setSort,
setQuery,
}
}

165
web/lib/calculate.ts Normal file
View File

@ -0,0 +1,165 @@
import { Bet } from './firebase/bets'
import { Contract } from './firebase/contracts'
const fees = 0.02
export function getProbability(pool: { YES: number; NO: number }) {
const [yesPool, noPool] = [pool.YES, pool.NO]
const numerator = Math.pow(yesPool, 2)
const denominator = Math.pow(yesPool, 2) + Math.pow(noPool, 2)
return numerator / denominator
}
export function getProbabilityAfterBet(
pool: { YES: number; NO: number },
outcome: 'YES' | 'NO',
bet: number
) {
const [YES, NO] = [
pool.YES + (outcome === 'YES' ? bet : 0),
pool.NO + (outcome === 'NO' ? bet : 0),
]
return getProbability({ YES, NO })
}
export function calculateShares(
pool: { YES: number; NO: number },
bet: number,
betChoice: 'YES' | 'NO'
) {
const [yesPool, noPool] = [pool.YES, pool.NO]
return betChoice === 'YES'
? bet + (bet * noPool ** 2) / (yesPool ** 2 + bet * yesPool)
: bet + (bet * yesPool ** 2) / (noPool ** 2 + bet * noPool)
}
export function calculatePayout(
contract: Contract,
bet: Bet,
outcome: 'YES' | 'NO' | 'CANCEL' | 'MKT'
) {
const { amount, outcome: betOutcome, shares } = bet
if (outcome === 'CANCEL') return amount
if (outcome === 'MKT') return calculateMktPayout(contract, bet)
if (betOutcome !== outcome) return 0
const { totalShares, totalBets } = contract
if (totalShares[outcome] === 0) return 0
const startPool = contract.startPool.YES + contract.startPool.NO
const truePool = contract.pool.YES + contract.pool.NO - startPool
if (totalBets[outcome] >= truePool)
return (amount / totalBets[outcome]) * truePool
const total = totalShares[outcome] - totalBets[outcome]
const winningsPool = truePool - totalBets[outcome]
return (1 - fees) * (amount + ((shares - amount) / total) * winningsPool)
}
export function calculatePayoutAfterCorrectBet(contract: Contract, bet: Bet) {
const { amount, outcome, shares } = bet
const { totalShares, totalBets } = contract
const startPool = contract.startPool.YES + contract.startPool.NO
const truePool = amount + contract.pool.YES + contract.pool.NO - startPool
const totalBetsOutcome = totalBets[outcome] + amount
const totalSharesOutcome = totalShares[outcome] + shares
if (totalBetsOutcome >= truePool)
return (amount / totalBetsOutcome) * truePool
const total = totalSharesOutcome - totalBetsOutcome
const winningsPool = truePool - totalBetsOutcome
return (1 - fees) * (amount + ((shares - amount) / total) * winningsPool)
}
function calculateMktPayout(contract: Contract, bet: Bet) {
const p =
contract.pool.YES ** 2 / (contract.pool.YES ** 2 + contract.pool.NO ** 2)
const weightedTotal =
p * contract.totalBets.YES + (1 - p) * contract.totalBets.NO
const startPool = contract.startPool.YES + contract.startPool.NO
const truePool = contract.pool.YES + contract.pool.NO - startPool
const betP = bet.outcome === 'YES' ? p : 1 - p
if (weightedTotal >= truePool) {
return ((betP * bet.amount) / weightedTotal) * truePool
}
const winningsPool = truePool - weightedTotal
const weightedShareTotal =
p * (contract.totalShares.YES - contract.totalBets.YES) +
(1 - p) * (contract.totalShares.NO - contract.totalBets.NO)
return (
(1 - fees) *
(betP * bet.amount +
((betP * (bet.shares - bet.amount)) / weightedShareTotal) * winningsPool)
)
}
export function resolvedPayout(contract: Contract, bet: Bet) {
if (contract.resolution)
return calculatePayout(contract, bet, contract.resolution)
throw new Error('Contract was not resolved')
}
export function currentValue(contract: Contract, bet: Bet) {
const prob = getProbability(contract.pool)
const yesPayout = calculatePayout(contract, bet, 'YES')
const noPayout = calculatePayout(contract, bet, 'NO')
return prob * yesPayout + (1 - prob) * noPayout
}
export function calculateSaleAmount(contract: Contract, bet: Bet) {
const { shares, outcome } = bet
const { YES: yesPool, NO: noPool } = contract.pool
const { YES: yesStart, NO: noStart } = contract.startPool
const { YES: yesShares, NO: noShares } = contract.totalShares
const [y, n, s] = [yesPool, noPool, shares]
const shareValue =
outcome === 'YES'
? // https://www.wolframalpha.com/input/?i=b+%2B+%28b+n%5E2%29%2F%28y+%28-b+%2B+y%29%29+%3D+c+solve+b
(n ** 2 +
s * y +
y ** 2 -
Math.sqrt(
n ** 4 + (s - y) ** 2 * y ** 2 + 2 * n ** 2 * y * (s + y)
)) /
(2 * y)
: (y ** 2 +
s * n +
n ** 2 -
Math.sqrt(
y ** 4 + (s - n) ** 2 * n ** 2 + 2 * y ** 2 * n * (s + n)
)) /
(2 * n)
const startPool = yesStart + noStart
const pool = yesPool + noPool - startPool
const probBefore = yesPool ** 2 / (yesPool ** 2 + noPool ** 2)
const f = pool / (probBefore * yesShares + (1 - probBefore) * noShares)
const myPool = outcome === 'YES' ? yesPool - yesStart : noPool - noStart
const adjShareValue = Math.min(Math.min(1, f) * shareValue, myPool)
const saleAmount = (1 - fees) * adjShareValue
return saleAmount
}

View File

@ -1,72 +0,0 @@
import { Bet } from '../firebase/bets'
import { Contract } from '../firebase/contracts'
const fees = 0.02
export function getProbability(pool: { YES: number; NO: number }) {
const [yesPool, noPool] = [pool.YES, pool.NO]
const numerator = Math.pow(yesPool, 2)
const denominator = Math.pow(yesPool, 2) + Math.pow(noPool, 2)
return numerator / denominator
}
export function getProbabilityAfterBet(
pool: { YES: number; NO: number },
outcome: 'YES' | 'NO',
bet: number
) {
const [YES, NO] = [
pool.YES + (outcome === 'YES' ? bet : 0),
pool.NO + (outcome === 'NO' ? bet : 0),
]
return getProbability({ YES, NO })
}
export function getDpmWeight(
pool: { YES: number; NO: number },
bet: number,
betChoice: 'YES' | 'NO'
) {
const [yesPool, noPool] = [pool.YES, pool.NO]
return betChoice === 'YES'
? (bet * Math.pow(noPool, 2)) / (Math.pow(yesPool, 2) + bet * yesPool)
: (bet * Math.pow(yesPool, 2)) / (Math.pow(noPool, 2) + bet * noPool)
}
export function calculatePayout(
contract: Contract,
bet: Bet,
outcome: 'YES' | 'NO' | 'CANCEL'
) {
const { amount, outcome: betOutcome, dpmWeight } = bet
if (outcome === 'CANCEL') return amount
if (betOutcome !== outcome) return 0
let { dpmWeights, pool, startPool } = contract
// Fake data if not set.
if (!dpmWeights) dpmWeights = { YES: 100, NO: 100 }
// Fake data if not set.
if (!pool) pool = { YES: 100, NO: 100 }
const otherOutcome = outcome === 'YES' ? 'NO' : 'YES'
const poolSize = pool[otherOutcome] - startPool[otherOutcome]
return (1 - fees) * (dpmWeight / dpmWeights[outcome]) * poolSize + amount
}
export function resolvedPayout(contract: Contract, bet: Bet) {
if (contract.resolution)
return calculatePayout(contract, bet, contract.resolution)
throw new Error('Contract was not resolved')
}
export function currentValue(contract: Contract, bet: Bet) {
const prob = getProbability(contract.pool)
const yesPayout = calculatePayout(contract, bet, 'YES')
const noPayout = calculatePayout(contract, bet, 'NO')
return prob * yesPayout + (1 - prob) * noPayout
}

View File

@ -0,0 +1,13 @@
import { getFunctions, httpsCallable } from 'firebase/functions'
const functions = getFunctions()
export const cloudFunction = (name: string) => httpsCallable(functions, name)
export const createContract = cloudFunction('createContract')
export const placeBet = cloudFunction('placeBet')
export const resolveMarket = cloudFunction('resolveMarket')
export const sellBet = cloudFunction('sellBet')

View File

@ -4,19 +4,31 @@ import {
query,
onSnapshot,
where,
getDocs,
} from 'firebase/firestore'
import _ from 'lodash'
import { db } from './init'
export type Bet = {
id: string
userId: string
contractId: string
amount: number // Amount of bet
outcome: 'YES' | 'NO' // Chosen outcome
dpmWeight: number // Dynamic Parimutuel weight
amount: number // bet size; negative if SELL bet
outcome: 'YES' | 'NO'
shares: number // dynamic parimutuel pool weight; negative if SELL bet
probBefore: number
probAverage: number
probAfter: number
sale?: {
amount: number // amount user makes from sale
betId: string // id of bet being sold
// TODO: add sale time?
}
isSold?: boolean // true if this BUY bet has been sold
createdTime: number
}
@ -52,3 +64,34 @@ export function listenForUserBets(
setBets(bets)
})
}
export function listenForRecentBets(
timePeriodMs: number,
setBets: (bets: Bet[]) => void
) {
const recentQuery = query(
collectionGroup(db, 'bets'),
where('createdTime', '>', Date.now() - timePeriodMs)
)
return onSnapshot(recentQuery, (snap) => {
const bets = snap.docs.map((doc) => doc.data() as Bet)
bets.sort((bet1, bet2) => bet1.createdTime - bet2.createdTime)
setBets(bets)
})
}
export async function getRecentBets(timePeriodMs: number) {
const recentQuery = query(
collectionGroup(db, 'bets'),
where('createdTime', '>', Date.now() - timePeriodMs)
)
const snapshot = await getDocs(recentQuery)
const bets = snapshot.docs.map((doc) => doc.data() as Bet)
bets.sort((bet1, bet2) => bet1.createdTime - bet2.createdTime)
return bets
}

View File

@ -0,0 +1,60 @@
import { doc, collection, onSnapshot, setDoc } from 'firebase/firestore'
import { db } from './init'
import { User } from './users'
// Currently, comments are created after the bet, not atomically with the bet.
// They're uniquely identified by the pair contractId/betId.
export type Comment = {
contractId: string
betId: string
text: string
createdTime: number
// Denormalized, for rendering comments
userName?: string
userUsername?: string
userAvatarUrl?: string
}
export async function createComment(
contractId: string,
betId: string,
text: string,
commenter: User
) {
const ref = doc(getCommentsCollection(contractId), betId)
return await setDoc(ref, {
contractId,
betId,
text,
createdTime: Date.now(),
userName: commenter.name,
userUsername: commenter.username,
userAvatarUrl: commenter.avatarUrl,
})
}
function getCommentsCollection(contractId: string) {
return collection(db, 'contracts', contractId, 'comments')
}
export function listenForComments(
contractId: string,
setComments: (comments: Comment[]) => void
) {
return onSnapshot(getCommentsCollection(contractId), (snap) => {
const comments = snap.docs.map((doc) => doc.data() as Comment)
comments.sort((c1, c2) => c1.createdTime - c2.createdTime)
setComments(comments)
})
}
// Return a map of betId -> comment
export function mapCommentsByBetId(comments: Comment[]) {
const map: Record<string, Comment> = {}
for (const comment of comments) {
map[comment.betId] = comment
}
return map
}

View File

@ -11,9 +11,10 @@ import {
onSnapshot,
orderBy,
getDoc,
limit,
} from 'firebase/firestore'
import dayjs from 'dayjs'
import { Bet, getRecentBets } from './bets'
import _ from 'lodash'
export type Contract = {
id: string
@ -30,7 +31,8 @@ export type Contract = {
startPool: { YES: number; NO: number }
pool: { YES: number; NO: number }
dpmWeights: { YES: number; NO: number }
totalShares: { YES: number; NO: number }
totalBets: { YES: number; NO: number }
createdTime: number // Milliseconds since epoch
lastUpdatedTime: number // If the question or description was changed
@ -48,14 +50,16 @@ export function path(contract: Contract) {
export function compute(contract: Contract) {
const { pool, startPool, createdTime, resolutionTime, isResolved } = contract
const volume = pool.YES + pool.NO - startPool.YES - startPool.NO
const truePool = pool.YES + pool.NO - startPool.YES - startPool.NO
const prob = pool.YES ** 2 / (pool.YES ** 2 + pool.NO ** 2)
const probPercent = Math.round(prob * 100) + '%'
const startProb =
startPool.YES ** 2 / (startPool.YES ** 2 + startPool.NO ** 2)
const createdDate = dayjs(createdTime).format('MMM D')
const resolvedDate = isResolved
? dayjs(resolutionTime).format('MMM D')
: undefined
return { volume, probPercent, createdDate, resolvedDate }
return { truePool, probPercent, startProb, createdDate, resolvedDate }
}
const db = getFirestore(app)
@ -105,7 +109,7 @@ export async function listContracts(creatorId: string): Promise<Contract[]> {
}
export async function listAllContracts(): Promise<Contract[]> {
const q = query(contractCollection, orderBy('createdTime', 'desc'), limit(25))
const q = query(contractCollection, orderBy('createdTime', 'desc'))
const snapshot = await getDocs(q)
return snapshot.docs.map((doc) => doc.data() as Contract)
}
@ -113,7 +117,7 @@ export async function listAllContracts(): Promise<Contract[]> {
export function listenForContracts(
setContracts: (contracts: Contract[]) => void
) {
const q = query(contractCollection, orderBy('createdTime', 'desc'), limit(25))
const q = query(contractCollection, orderBy('createdTime', 'desc'))
return onSnapshot(q, (snap) => {
setContracts(snap.docs.map((doc) => doc.data() as Contract))
})
@ -128,3 +132,17 @@ export function listenForContract(
setContract((contractSnap.data() ?? null) as Contract | null)
})
}
export function computeHotContracts(recentBets: Bet[]) {
const contractBets = _.groupBy(recentBets, (bet) => bet.contractId)
const hotContractIds = _.sortBy(Object.keys(contractBets), (contractId) =>
_.sumBy(contractBets[contractId], (bet) => -1 * bet.amount)
).slice(0, 4)
return hotContractIds
}
export async function getHotContracts() {
const oneDay = 1000 * 60 * 60 * 24
const recentBets = await getRecentBets(oneDay)
return computeHotContracts(recentBets)
}

View File

@ -1,24 +1,30 @@
import { getFirestore } from '@firebase/firestore'
import { initializeApp } from 'firebase/app'
const firebaseConfig = {
apiKey: 'AIzaSyDp3J57vLeAZCzxLD-vcPaGIkAmBoGOSYw',
authDomain: 'mantic-markets.firebaseapp.com',
projectId: 'mantic-markets',
storageBucket: 'mantic-markets.appspot.com',
messagingSenderId: '128925704902',
appId: '1:128925704902:web:f61f86944d8ffa2a642dc7',
measurementId: 'G-SSFK1Q138D',
}
// TODO: Reenable this when we have a way to set the Firebase db in dev
// export const isProd = process.env.NODE_ENV === 'production'
export const isProd = true
const firebaseConfig = isProd
? {
apiKey: 'AIzaSyDp3J57vLeAZCzxLD-vcPaGIkAmBoGOSYw',
authDomain: 'mantic-markets.firebaseapp.com',
projectId: 'mantic-markets',
storageBucket: 'mantic-markets.appspot.com',
messagingSenderId: '128925704902',
appId: '1:128925704902:web:f61f86944d8ffa2a642dc7',
measurementId: 'G-SSFK1Q138D',
}
: {
apiKey: 'AIzaSyBoq3rzUa8Ekyo3ZaTnlycQYPRCA26VpOw',
authDomain: 'dev-mantic-markets.firebaseapp.com',
projectId: 'dev-mantic-markets',
storageBucket: 'dev-mantic-markets.appspot.com',
messagingSenderId: '134303100058',
appId: '1:134303100058:web:27f9ea8b83347251f80323',
measurementId: 'G-YJC9E37P37',
}
// Initialize Firebase
export const app = initializeApp(firebaseConfig)
export const db = getFirestore(app)
// try {
// // Note: this is still throwing a console error atm...
// import('firebase/analytics').then((analytics) => {
// analytics.getAnalytics(app)
// })
// } catch (e) {
// console.warn('Analytics were blocked')
// }

View File

@ -19,7 +19,7 @@ import {
signInWithPopup,
} from 'firebase/auth'
export const STARTING_BALANCE = 100
export const STARTING_BALANCE = 1000
export type User = {
id: string

View File

@ -1,63 +0,0 @@
import {
Contract,
getContractFromSlug,
pushNewContract,
setContract,
} from '../firebase/contracts'
import { User } from '../firebase/users'
import { randomString } from '../util/random-string'
import { slugify } from '../util/slugify'
// consider moving to cloud function for security
export async function createContract(
question: string,
description: string,
initialProb: number,
creator: User
) {
const proposedSlug = slugify(question).substring(0, 35)
const preexistingContract = await getContractFromSlug(proposedSlug)
const slug = preexistingContract
? proposedSlug + '-' + randomString()
: proposedSlug
const { startYes, startNo } = calcStartPool(initialProb)
const contract: Omit<Contract, 'id'> = {
slug,
outcomeType: 'BINARY',
creatorId: creator.id,
creatorName: creator.name,
creatorUsername: creator.username,
question: question.trim(),
description: description.trim(),
startPool: { YES: startYes, NO: startNo },
pool: { YES: startYes, NO: startNo },
dpmWeights: { YES: 0, NO: 0 },
isResolved: false,
// TODO: Set create time to Firestore timestamp
createdTime: Date.now(),
lastUpdatedTime: Date.now(),
}
return await pushNewContract(contract)
}
export function calcStartPool(initialProb: number, initialCapital = 200) {
const p = initialProb / 100.0
const startYes =
p === 0.5
? p * initialCapital
: -(initialCapital * (-p + Math.sqrt((-1 + p) * -p))) / (-1 + 2 * p)
const startNo = initialCapital - startYes
return { startYes, startNo }
}

View File

@ -21,10 +21,13 @@ function makeWeights(bids: Bid[]) {
// First pass: calculate all the weights
for (const { yesBid, noBid } of bids) {
const yesWeight =
(yesBid * Math.pow(noPot, 2)) / (Math.pow(yesPot, 2) + yesBid * yesPot) ||
0
yesBid +
(yesBid * Math.pow(noPot, 2)) /
(Math.pow(yesPot, 2) + yesBid * yesPot) || 0
const noWeight =
(noBid * Math.pow(yesPot, 2)) / (Math.pow(noPot, 2) + noBid * noPot) || 0
noBid +
(noBid * Math.pow(yesPot, 2)) / (Math.pow(noPot, 2) + noBid * noPot) ||
0
// Note: Need to calculate weights BEFORE updating pot
yesPot += yesBid
@ -53,15 +56,15 @@ export function makeEntries(bids: Bid[]): Entry[] {
const yesWeightsSum = weights.reduce((sum, entry) => sum + entry.yesWeight, 0)
const noWeightsSum = weights.reduce((sum, entry) => sum + entry.noWeight, 0)
const potSize = yesPot + noPot - YES_SEED - NO_SEED
// Second pass: calculate all the payouts
const entries: Entry[] = []
for (const weight of weights) {
const { yesBid, noBid, yesWeight, noWeight } = weight
// Payout: You get your initial bid back, as well as your share of the
// (noPot - seed) according to your yesWeight
const yesPayout = yesBid + (yesWeight / yesWeightsSum) * (noPot - NO_SEED)
const noPayout = noBid + (noWeight / noWeightsSum) * (yesPot - YES_SEED)
const yesPayout = (yesWeight / yesWeightsSum) * potSize
const noPayout = (noWeight / noWeightsSum) * potSize
const yesReturn = (yesPayout - yesBid) / yesBid
const noReturn = (noPayout - noBid) / noBid
entries.push({ ...weight, yesPayout, noPayout, yesReturn, noReturn })

View File

@ -6,11 +6,11 @@ const formatter = new Intl.NumberFormat('en-US', {
})
export function formatMoney(amount: number) {
return 'M$ ' + formatter.format(amount).substring(1)
return 'M$ ' + formatter.format(amount).replace('$', '')
}
export function formatWithCommas(amount: number) {
return formatter.format(amount).substring(1)
return formatter.format(amount).replace('$', '')
}
export function formatPercent(zeroToOne: number) {

9
web/lib/util/parse.ts Normal file
View File

@ -0,0 +1,9 @@
import _ from 'lodash'
export function parseTags(text: string) {
const regex = /(?:^|\s)(?:[#][a-z0-9_]+)/gi
const matches = (text.match(regex) || []).map((match) =>
match.trim().substring(1)
)
return _.uniqBy(matches, (tag) => tag.toLowerCase())
}

View File

@ -22,7 +22,8 @@
"lodash": "4.17.21",
"next": "12.0.7",
"react": "17.0.2",
"react-dom": "17.0.2"
"react-dom": "17.0.2",
"react-expanding-textarea": "^2.3.4"
},
"devDependencies": {
"@tailwindcss/forms": "0.4.0",

View File

@ -11,7 +11,11 @@ import { useBets } from '../../hooks/use-bets'
import { Title } from '../../components/title'
import { Spacer } from '../../components/layout/spacer'
import { User } from '../../lib/firebase/users'
import { Contract, getContractFromSlug } from '../../lib/firebase/contracts'
import {
compute,
Contract,
getContractFromSlug,
} from '../../lib/firebase/contracts'
import { SEO } from '../../components/SEO'
import { Page } from '../../components/page'
@ -47,14 +51,23 @@ export default function ContractPage(props: {
return <div>Contract not found...</div>
}
const { creatorId, isResolved } = contract
const { creatorId, isResolved, resolution, question } = contract
const isCreator = user?.id === creatorId
const allowTrade =
!isResolved && (!contract.closeTime || contract.closeTime > Date.now())
const allowResolve = !isResolved && isCreator && user
const { probPercent } = compute(contract)
const description = resolution
? `Resolved ${resolution}. ${contract.description}`
: `${probPercent} chance. ${contract.description}`
return (
<Page wide={!isResolved}>
<Page wide={allowTrade}>
<SEO
title={contract.question}
description={contract.description}
title={question}
description={description}
url={`/${props.username}/${props.slug}`}
/>
@ -64,14 +77,13 @@ export default function ContractPage(props: {
<BetsSection contract={contract} user={user ?? null} />
</div>
{!isResolved && (
{(allowTrade || allowResolve) && (
<>
<div className="md:ml-8" />
<Col className="flex-1">
<BetPanel contract={contract} />
{isCreator && user && (
{allowTrade && <BetPanel contract={contract} />}
{allowResolve && (
<ResolutionPanel creator={user} contract={contract} />
)}
</Col>
@ -97,8 +109,8 @@ function BetsSection(props: { contract: Contract; user: User | null }) {
return (
<div>
<Title text="Your bets" />
<MyBetsSummary contract={contract} bets={userBets} />
<Title text="Your trades" />
<MyBetsSummary contract={contract} bets={userBets} showMKT />
<Spacer h={6} />
<ContractBetsTable contract={contract} bets={userBets} />
<Spacer h={12} />

View File

@ -6,32 +6,35 @@ function MyApp({ Component, pageProps }: AppProps) {
return (
<>
<Head>
<title>Mantic Markets</title>
<title>Manifold Markets</title>
<meta
property="og:title"
name="twitter:title"
content="Mantic Markets"
content="Manifold Markets"
key="title"
/>
<meta
name="description"
content="Mantic Markets is creating better forecasting through user-created prediction markets."
content="Manifold Markets is creating better forecasting through user-created prediction markets."
key="description1"
/>
<meta
property="og:description"
name="twitter:description"
content="Mantic Markets is creating better forecasting through user-created prediction markets."
content="Manifold Markets is creating better forecasting through user-created prediction markets."
key="description2"
/>
<meta property="og:url" content="https://mantic.markets" key="url" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:site" content="@manticmarkets" />
<meta property="og:url" content="https://manifold.markets" key="url" />
<meta name="twitter:card" content="summary" />
<meta name="twitter:site" content="@manifoldmarkets" />
<meta
property="og:image"
content="https://manifold.markets/logo-cover.png"
/>
<meta
name="twitter:image"
content="https://mantic.markets/logo-cover.png"
content="https://manifold.markets/logo-bg.png"
/>
</Head>

View File

@ -42,7 +42,7 @@ function Contents() {
<h1 id="about">About</h1>
<hr />
<p>
Mantic Markets is creating better forecasting through user-created
Manifold Markets is creating better forecasting through user-created
prediction markets.
</p>
<p>
@ -77,49 +77,51 @@ function Contents() {
</a>
. This is the power of prediction markets!
</p>
<h3 id="how-does-mantic-markets-work-">How does Mantic Markets work?</h3>
<h3 id="how-does-manifold-markets-work-">
How does Manifold Markets work?
</h3>
<ol>
<li>
<strong>
Anyone can create a market for any yes-or-no question.
</strong>
</li>
</ol>
<p>
You can ask questions about the future like &quot;Will Taiwan remove its
14-day COVID quarantine by Jun 01, 2022?&quot; Then use the information
to plan your trip.
</p>
<p>
You can also ask subjective, personal questions like &quot;Will I enjoy
my 2022 Taiwan trip?&quot;. Then share the market with your family and
friends.
</p>
<ol>
<p>
You can ask questions about the future like &quot;Will Taiwan remove
its 14-day COVID quarantine by Jun 01, 2022?&quot; Then use the
information to plan your trip.
</p>
<p>
You can also ask subjective, personal questions like &quot;Will I
enjoy my 2022 Taiwan trip?&quot;. Then share the market with your
family and friends.
</p>
<li>
<strong>
Anyone can bet on a market using Mantic Dollars (M$), our platform
Anyone can bet on a market using Manifold Dollars (M$), our platform
currency.
</strong>
</li>
</ol>
<p>
You get M$ 100 just for signing up, so you can start betting
You get M$ 1,000 just for signing up, so you can start betting
immediately! When a market creator decides an outcome in your favor,
you&#39;ll win money from people who bet against you.
you&#39;ll win Manifold Dollars from people who bet against you.
</p>
<p>
{/* <p>
If you run out of money, you can purchase more at a rate of $1 USD to M$
100. (Note that Mantic Dollars are not convertible to cash and can only
100. (Note that Manifold Dollars are not convertible to cash and can only
be used within our platform.)
</p>
</p> */}
<aside>
💡 We&#39;re still in Open Beta; we&#39;ll tweak this model and
periodically reset balances before our official launch. If you purchase
any M$ during the beta, we promise to honor that when we launch!
💡 We&#39;re still in Open Beta; we&#39;ll tweak the amounts of Manifold
Dollars given out and periodically reset balances before our official
launch.
{/* If you purchase
any M$ during the beta, we promise to honor that when we launch! */}
</aside>
<h3 id="why-do-i-want-to-bet-with-play-money-">
{/* <h3 id="why-do-i-want-to-bet-with-play-money-">
Why do I want to bet with play-money?
</h3>
<p>
@ -130,7 +132,7 @@ function Contents() {
</p>
<p>By buying M$, you support:</p>
<ul>
<li>The continued development of Mantic Markets</li>
<li>The continued development of Manifold Markets</li>
<li>Cash payouts to market creators (TBD)</li>
<li>Forecasting tournaments for bettors (TBD)</li>
</ul>
@ -138,7 +140,7 @@ function Contents() {
We also have some thoughts on how to reward bettors: physical swag,
exclusive conversations with market creators, NFTs...? If you have
ideas, let us know!
</p>
</p> */}
<h3 id="can-prediction-markets-work-without-real-money-">
Can prediction markets work without real money?
</h3>
@ -162,8 +164,8 @@ function Contents() {
</p>
<h3 id="how-are-markets-resolved-">How are markets resolved?</h3>
<p>
The creator of the prediction market decides the outcome and earns 0.5%
of the trade volume for their effort.
The creator of the prediction market decides the outcome and earns 1% of
the betting pool for their effort.
</p>
<p>
This simple resolution mechanism has surprising benefits in allowing a
@ -200,34 +202,52 @@ function Contents() {
<h3 id="how-is-this-different-from-metaculus-or-hypermind-">
How is this different from Metaculus or Hypermind?
</h3>
<p>
{/* <p>
We believe that in order to get the best results, you have to have skin
in the game. We require that people use real money to buy the currency
they use on our platform.
</p>
<p>
With Mantic Dollars being a scarce resource, people will bet more
With Manifold Dollars being a scarce resource, people will bet more
carefully and can&#39;t rig the outcome by creating multiple accounts.
The result is more accurate predictions.
</p>
</p> */}
<p>
Mantic Markets is also focused on accessibility and allowing anyone to
Manifold Markets is focused on accessibility and allowing anyone to
quickly create and judge a prediction market. When we all have the power
to create and share prediction markets in seconds and apply our own
judgment on the outcome, it leads to a qualitative shift in the number,
variety, and usefulness of prediction markets.
</p>
<h3 id="how-does-betting-in-a-market-work-on-a-technical-level-">
How does betting in a market work on a technical level?
</h3>
<h3 id="how-does-betting-work">How does betting work?</h3>
<ul>
<li>
Markets are structured around a question with a binary outcome (either
YES or NO)
</li>
<li>
Traders can place a bet on either YES or NO. The bet amount is added
to the corresponding bet pool for the outcome. The trader receives
some shares of the final pool. The number of shares depends on the
current implied probability.
</li>
<li>
When the market is resolved, the traders who bet on the correct
outcome are paid out of the total pool (YES pool + NO pool) in
proportion to the amount of shares they own, minus any fees.
</li>
</ul>
<h3 id="type-of-market-maker">What kind of betting system do you use?</h3>
<p>
Mantic Markets uses a special type of automated market marker based on a
dynamic pari-mutuel (DPM) betting system.
Manifold Markets uses a special type of automated market marker based on
a dynamic pari-mutuel (DPM) betting system.
</p>
<p>
Like traditional pari-mutuel systems, your payoff is not known at the
time you place your bet (it&#39;s dependent on the size of the pot when
the event ends).
time you place your bet (it&#39;s dependent on the size of the pool when
the event is resolved).
</p>
<p>
Unlike traditional pari-mutuel systems, the price or probability that
@ -238,8 +258,9 @@ function Contents() {
The result is a market that can function well when trading volume is low
without any risk to the market creator.
</p>
<h3 id="who-are-we-">Who are we?</h3>
<p>Mantic Markets is currently a team of three:</p>
<p>Manifold Markets is currently a team of three:</p>
<ul>
<li>James Grugett</li>
<li>Stephen Grugett</li>
@ -259,7 +280,7 @@ function Contents() {
</p>
<ul>
<li>
Email: <code>info@mantic.markets</code>
Email: <code>info@manifold.markets</code>
</li>
<li>
Office hours:{' '}
@ -269,12 +290,19 @@ function Contents() {
</ul>
<p>
<a href="https://discord.gg/eHQBNBqXuh">
Join the Mantic Markets Discord Server!
Join the Manifold Markets Discord Server!
</a>
</p>
<h1 id="further-reading">Further Reading</h1>
<hr />
<ul>
<li>
<a href="https://manifoldmarkets.notion.site/Technical-Overview-b9b48a09ea1f45b88d991231171730c5">
Technical Overview of Manifold Markets
</a>
</li>
<li>
<a href="https://en.wikipedia.org/wiki/Prediction_market">
Wikipedia: Prediction markets
@ -296,7 +324,7 @@ function Contents() {
</a>
</li>
<li>
<a href="https://mantic.markets/simulator">
<a href="https://manifold.markets/simulator">
Dynamic parimutuel market simulator
</a>
</li>

View File

@ -1,13 +1,19 @@
import router from 'next/router'
import { useEffect, useState } from 'react'
import clsx from 'clsx'
import dayjs from 'dayjs'
import Textarea from 'react-expanding-textarea'
import { ContractsList } from '../components/contracts-list'
import { CreatorContractsList } from '../components/contracts-list'
import { Spacer } from '../components/layout/spacer'
import { Title } from '../components/title'
import { useUser } from '../hooks/use-user'
import { path } from '../lib/firebase/contracts'
import { createContract } from '../lib/service/create-contract'
import { Contract, path } from '../lib/firebase/contracts'
import { Page } from '../components/page'
import { formatMoney } from '../lib/util/format'
import { AdvancedPanel } from '../components/advanced-panel'
import { createContract } from '../lib/firebase/api-call'
import { Row } from '../components/layout/row'
// Allow user to create a new contract
export default function NewContract() {
@ -15,26 +21,69 @@ export default function NewContract() {
useEffect(() => {
if (creator === null) router.push('/')
})
}, [creator])
useEffect(() => {
// warm up function
createContract({}).catch()
}, [])
const [initialProb, setInitialProb] = useState(50)
const [question, setQuestion] = useState('')
const [description, setDescription] = useState('')
const [ante, setAnte] = useState<number | undefined>(0)
const [anteError, setAnteError] = useState('')
const [closeDate, setCloseDate] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const closeTime = dateToMillis(closeDate) || undefined
// We'd like this to look like "Apr 2, 2022, 23:59:59 PM PT" but timezones are hard with dayjs
const formattedCloseTime = closeTime ? new Date(closeTime).toString() : ''
const user = useUser()
const remainingBalance = (user?.balance || 0) - (ante || 0)
const isValid =
initialProb > 0 &&
initialProb < 100 &&
question.length > 0 &&
(ante === undefined || (ante >= 0 && ante <= remainingBalance)) &&
// If set, closeTime must be in the future
(!closeTime || closeTime > Date.now())
async function submit() {
// TODO: add more rigorous error handling for question
if (!creator || !question) return
// TODO: Tell users why their contract is invalid
if (!creator || !isValid) return
setIsSubmitting(true)
const contract = await createContract(
const result: any = await createContract({
question,
description,
initialProb,
creator
)
await router.push(path(contract))
ante,
closeTime: closeTime || undefined,
}).then((r) => r.data || {})
if (result.status !== 'success') {
console.log('error creating contract', result)
return
}
await router.push(path(result.contract as Contract))
}
function onAnteChange(str: string) {
const amount = parseInt(str)
if (str && isNaN(amount)) return
setAnte(str ? amount : undefined)
if (user && user.balance < amount) setAnteError('Insufficient balance')
else setAnteError('')
}
const descriptionPlaceholder = `e.g. This market will resolve to “Yes” if, by June 2, 2021, 11:59:59 PM ET, Paxlovid (also known under PF-07321332)...`
@ -45,19 +94,19 @@ export default function NewContract() {
<Page>
<Title text="Create a new prediction market" />
<div className="w-full bg-gray-100 rounded-lg shadow-xl px-6 py-4">
<div className="w-full bg-gray-100 rounded-lg shadow-md px-6 py-4">
{/* Create a Tailwind form that takes in all the fields needed for a new contract */}
{/* When the form is submitted, create a new contract in the database */}
<form>
<div className="form-control">
<div className="form-control w-full">
<label className="label">
<span className="label-text">Question</span>
<span className="mb-1">Question</span>
</label>
<input
type="text"
placeholder="e.g. Will the FDA approve Paxlovid before Jun 2nd, 2022?"
className="input"
<Textarea
placeholder="e.g. Will the FDA will approve Paxlovid before Jun 2nd, 2022?"
className="input input-bordered resize-none"
disabled={isSubmitting}
value={question}
onChange={(e) => setQuestion(e.target.value || '')}
/>
@ -67,49 +116,120 @@ export default function NewContract() {
<div className="form-control">
<label className="label">
<span className="label-text">Description (optional)</span>
<span className="mb-1">Initial probability</span>
</label>
<textarea
className="textarea h-24 textarea-bordered"
placeholder={descriptionPlaceholder}
value={description}
onChange={(e) => setDescription(e.target.value || '')}
></textarea>
<Row className="items-center gap-2">
<label className="input-group input-group-lg w-fit text-xl">
<input
type="number"
value={initialProb}
className="input input-bordered input-md text-primary text-3xl w-24"
disabled={isSubmitting}
min={1}
max={99}
onChange={(e) =>
setInitialProb(parseInt(e.target.value.substring(0, 2)))
}
/>
<span>%</span>
</label>
<input
type="range"
className="range range-primary"
min={1}
max={99}
value={initialProb}
onChange={(e) => setInitialProb(parseInt(e.target.value))}
/>
</Row>
</div>
<Spacer h={4} />
<div className="form-control">
<label className="label">
<span className="label-text">
Initial probability: {initialProb}%
</span>
<span className="mb-1">Description</span>
</label>
<input
type="range"
className="range range-lg range-primary"
min="1"
max={99}
value={initialProb}
onChange={(e) => setInitialProb(parseInt(e.target.value))}
<Textarea
className="textarea w-full textarea-bordered"
rows={3}
placeholder={descriptionPlaceholder}
value={description}
disabled={isSubmitting}
onClick={(e) => e.stopPropagation()}
onChange={(e) => setDescription(e.target.value || '')}
/>
</div>
<AdvancedPanel>
<div className="form-control mb-1">
<label className="label">
<span className="mb-1">Subsidize your market</span>
</label>
<label className="input-group">
<span className="text-sm ">M$</span>
<input
className={clsx(
'input input-bordered',
anteError && 'input-error'
)}
type="text"
placeholder="0"
maxLength={9}
value={ante ?? ''}
disabled={isSubmitting}
onChange={(e) => onAnteChange(e.target.value)}
/>
</label>
<label>
<span className="label-text text-gray-400 ml-1">
Remaining balance:{' '}
{formatMoney(remainingBalance > 0 ? remainingBalance : 0)}
</span>
</label>
</div>
<Spacer h={4} />
<div className="form-control">
<label className="label">
<span className="mb-1">Close date</span>
</label>
<input
type="date"
className="input input-bordered"
onClick={(e) => e.stopPropagation()}
onChange={(e) => setCloseDate(e.target.value || '')}
min={new Date().toISOString().split('T')[0]}
disabled={isSubmitting}
value={closeDate}
/>
</div>
<label>
<span className="label-text text-gray-400 ml-1">
No new trades will be allowed after{' '}
{closeDate ? formattedCloseTime : 'this time'}
</span>
</label>
</AdvancedPanel>
<Spacer h={4} />
<div className="flex justify-end my-4">
<button
type="submit"
className="btn btn-primary"
disabled={isSubmitting || !question}
className={clsx(
'btn btn-primary',
isSubmitting && 'loading disabled'
)}
disabled={isSubmitting || !isValid}
onClick={(e) => {
e.preventDefault()
submit()
}}
>
Create market
{isSubmitting ? 'Creating...' : 'Create market'}
</button>
</div>
</form>
@ -119,7 +239,17 @@ export default function NewContract() {
<Title text="Your markets" />
{creator && <ContractsList creator={creator} />}
{creator && <CreatorContractsList creator={creator} />}
</Page>
)
}
// Given a date string like '2022-04-02',
// return the time just before midnight on that date (in the user's local time), as millis since epoch
function dateToMillis(date: string) {
return dayjs(date)
.set('hour', 23)
.set('minute', 59)
.set('second', 59)
.valueOf()
}

View File

@ -3,26 +3,42 @@ import React from 'react'
import { useUser } from '../hooks/use-user'
import Markets from './markets'
import LandingPage from './landing-page'
import { Contract, listAllContracts } from '../lib/firebase/contracts'
import {
Contract,
getHotContracts,
listAllContracts,
} from '../lib/firebase/contracts'
import _ from 'lodash'
export async function getStaticProps() {
const contracts = await listAllContracts().catch((_) => [])
const [contracts, hotContractIds] = await Promise.all([
listAllContracts().catch((_) => []),
getHotContracts().catch(() => []),
])
return {
props: {
contracts,
hotContractIds,
},
revalidate: 60, // regenerate after a minute
}
}
const Home = (props: { contracts: Contract[] }) => {
const Home = (props: { contracts: Contract[]; hotContractIds: string[] }) => {
const user = useUser()
if (user === undefined) return <></>
return user ? <Markets contracts={props.contracts} /> : <LandingPage />
return user ? (
<Markets
contracts={props.contracts}
hotContractIds={props.hotContractIds}
/>
) : (
<LandingPage />
)
}
export default Home

View File

@ -13,6 +13,7 @@ import { SearchableGrid } from '../components/contracts-list'
import { Col } from '../components/layout/col'
import { NavBar } from '../components/nav-bar'
import Link from 'next/link'
import { useQueryAndSortParams } from '../hooks/use-sort-and-query-params'
export default function LandingPage() {
return (
@ -93,7 +94,7 @@ function FeaturesSection() {
{
name: 'Play money, real results',
description:
'Get accurate predictions by betting with Mantic Dollars, our virtual currency.',
'Get accurate predictions by betting with Manifold Dollars, our virtual currency.',
icon: LightningBoltIcon,
},
{
@ -116,7 +117,7 @@ function FeaturesSection() {
<div className="max-w-7xl mx-auto px-6 lg:px-8">
<div className="lg:text-center">
<h2 className="text-base text-teal-600 font-semibold tracking-wide uppercase">
Mantic Markets
Manifold Markets
</h2>
<p className="mt-2 text-3xl leading-8 font-extrabold tracking-tight text-gray-900 sm:text-4xl">
Better forecasting for everyone
@ -159,13 +160,20 @@ function FeaturesSection() {
function ExploreMarketsSection() {
const contracts = useContracts()
const { query, setQuery, sort, setSort } = useQueryAndSortParams()
return (
<div className="max-w-4xl px-4 py-8 mx-auto">
<p className="my-12 text-3xl leading-8 font-extrabold tracking-tight text-indigo-700 sm:text-4xl">
Explore our markets
</p>
<SearchableGrid contracts={contracts === 'loading' ? [] : contracts} />
<SearchableGrid
contracts={contracts === 'loading' ? [] : contracts}
query={query}
setQuery={setQuery}
sort={sort}
setSort={setSort}
/>
</div>
)
}

View File

@ -0,0 +1,224 @@
import clsx from 'clsx'
import Link from 'next/link'
import { useState } from 'react'
import { Col } from '../components/layout/col'
import { Row } from '../components/layout/row'
import { Spacer } from '../components/layout/spacer'
import { Linkify } from '../components/linkify'
import { Page } from '../components/page'
import { Title } from '../components/title'
import { useUser } from '../hooks/use-user'
import { createContract } from '../lib/firebase/api-call'
import { compute, Contract, path } from '../lib/firebase/contracts'
type Prediction = {
question: string
description: string
initialProb: number
createdUrl?: string
}
function toPrediction(contract: Contract): Prediction {
const { startProb } = compute(contract)
return {
question: contract.question,
description: contract.description,
initialProb: startProb * 100,
createdUrl: path(contract),
}
}
function PredictionRow(props: { prediction: Prediction }) {
const { prediction } = props
return (
<Row className="gap-4 justify-between hover:bg-gray-300 p-4">
<Col className="justify-between">
<div className="font-medium text-indigo-700 mb-2">
<Linkify text={prediction.question} />
</div>
<div className="text-gray-500 text-sm">{prediction.description}</div>
</Col>
{/* Initial probability */}
<div className="ml-auto">
<div className="text-3xl">
<div className="text-primary">
{prediction.initialProb.toFixed(0)}%
<div className="text-lg">chance</div>
</div>
</div>
</div>
{/* Current probability; hidden for now */}
{/* <div>
<div className="text-3xl">
<div className="text-primary">
{prediction.initialProb}%<div className="text-lg">chance</div>
</div>
</div>
</div> */}
</Row>
)
}
function PredictionList(props: { predictions: Prediction[] }) {
const { predictions } = props
return (
<Col className="divide-gray-300 divide-y border-gray-300 border rounded-md">
{predictions.map((prediction) =>
prediction.createdUrl ? (
<Link href={prediction.createdUrl}>
<a>
<PredictionRow
key={prediction.question}
prediction={prediction}
/>
</a>
</Link>
) : (
<PredictionRow key={prediction.question} prediction={prediction} />
)
)}
</Col>
)
}
const TEST_VALUE = `1. Biden approval rating (as per 538) is greater than 50%: 80%
2. Court packing is clearly going to happen (new justices don't have to be appointed by end of year): 5%
3. Yang is New York mayor: 80%
4. Newsom recalled as CA governor: 5%
5. At least $250 million in damage from BLM protests this year: 30%
6. Significant capital gains tax hike (above 30% for highest bracket): 20%`
export default function MakePredictions() {
const user = useUser()
const [predictionsString, setPredictionsString] = useState('')
const [description, setDescription] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const [createdContracts, setCreatedContracts] = useState<Contract[]>([])
const bulkPlaceholder = `e.g.
${TEST_VALUE}
...
`
const predictions: Prediction[] = []
// Parse bulkContracts, then run createContract for each
const lines = predictionsString ? predictionsString.split('\n') : []
for (const line of lines) {
// Parse line with regex
const matches = line.match(/^(.*):\s*(\d+)%\s*$/) || ['', '', '']
const [_, question, prob] = matches
if (!question || !prob) {
console.error('Invalid prediction: ', line)
continue
}
predictions.push({
question,
description,
initialProb: parseInt(prob),
})
}
async function createContracts() {
if (!user) {
// TODO: Convey error with snackbar/toast
console.error('You need to be signed in!')
return
}
setIsSubmitting(true)
for (const prediction of predictions) {
const contract = await createContract({
question: prediction.question,
description: prediction.description,
initialProb: prediction.initialProb,
}).then((r) => (r.data as any).contract)
setCreatedContracts((prev) => [...prev, contract])
}
setPredictionsString('')
setIsSubmitting(false)
}
return (
<Page>
<Title text="Make Predictions" />
<div className="w-full bg-gray-100 rounded-lg shadow-xl px-6 py-4">
<form>
<div className="form-control">
<label className="label">
<span className="label-text">Prediction</span>
<div className="text-sm text-gray-500 ml-1">
One prediction per line, each formatted like "The sun will rise
tomorrow: 99%"
</div>
</label>
<textarea
className="textarea h-60 textarea-bordered"
placeholder={bulkPlaceholder}
value={predictionsString}
onChange={(e) => setPredictionsString(e.target.value || '')}
></textarea>
</div>
</form>
<Spacer h={4} />
<div className="form-control w-full">
<label className="label">
<span className="label-text">Tags</span>
</label>
<input
type="text"
placeholder="e.g. #ACX2021 #World"
className="input"
value={description}
onChange={(e) => setDescription(e.target.value || '')}
/>
</div>
{predictions.length > 0 && (
<div>
<Spacer h={4} />
<label className="label">
<span className="label-text">Preview</span>
</label>
<PredictionList predictions={predictions} />
</div>
)}
<Spacer h={4} />
<div className="flex justify-end my-4">
<button
type="submit"
className={clsx('btn btn-primary', {
loading: isSubmitting,
})}
disabled={predictions.length === 0 || isSubmitting}
onClick={(e) => {
e.preventDefault()
createContracts()
}}
>
Create all
</button>
</div>
</div>
{createdContracts.length > 0 && (
<>
<Spacer h={16} />
<Title text="Created Predictions" />
<div className="w-full bg-gray-100 rounded-lg shadow-xl px-6 py-4">
<PredictionList predictions={createdContracts.map(toPrediction)} />
</div>
</>
)}
</Page>
)
}

View File

@ -1,28 +1,65 @@
import { SearchableGrid } from '../components/contracts-list'
import _ from 'lodash'
import { ContractsGrid, SearchableGrid } from '../components/contracts-list'
import { Spacer } from '../components/layout/spacer'
import { Page } from '../components/page'
import { useContracts } from '../hooks/use-contracts'
import { Contract, listAllContracts } from '../lib/firebase/contracts'
import { Title } from '../components/title'
import { useContracts, useHotContracts } from '../hooks/use-contracts'
import { useQueryAndSortParams } from '../hooks/use-sort-and-query-params'
import {
Contract,
getHotContracts,
listAllContracts,
} from '../lib/firebase/contracts'
export async function getStaticProps() {
const contracts = await listAllContracts().catch((_) => [])
const [contracts, hotContractIds] = await Promise.all([
listAllContracts().catch((_) => []),
getHotContracts().catch(() => []),
])
return {
props: {
contracts,
hotContractIds,
},
revalidate: 60, // regenerate after a minute
}
}
export default function Markets(props: { contracts: Contract[] }) {
export default function Markets(props: {
contracts: Contract[]
hotContractIds: string[]
}) {
const contracts = useContracts()
const { query, setQuery, sort, setSort } = useQueryAndSortParams()
const hotContractIds = useHotContracts()
const readyHotContractIds =
hotContractIds === 'loading' ? props.hotContractIds : hotContractIds
const readyContracts = contracts === 'loading' ? props.contracts : contracts
const hotContracts = readyHotContractIds.map(
(hotId) =>
_.find(readyContracts, (contract) => contract.id === hotId) as Contract
)
return (
<Page>
<div className="w-full bg-indigo-50 border-2 border-indigo-100 p-6 rounded-lg shadow-md">
<Title className="mt-0" text="🔥 Markets" />
<ContractsGrid contracts={hotContracts} />
</div>
<Spacer h={10} />
{(props.contracts || contracts !== 'loading') && (
<SearchableGrid
contracts={contracts === 'loading' ? props.contracts : contracts}
contracts={readyContracts}
query={query}
setQuery={setQuery}
sort={sort}
setSort={setSort}
/>
)}
</Page>

View File

@ -86,7 +86,7 @@ function TableRowEnd(props: { entry: Entry | null; isNew?: boolean }) {
return (
<>
<td>{(entry.prob * 100).toFixed(1)}%</td>
<td>${(entry.yesBid + entry.yesWeight).toFixed(0)}</td>
<td>${entry.yesWeight.toFixed(0)}</td>
{!props.isNew && (
<>
<td>${entry.yesPayout.toFixed(0)}</td>
@ -99,7 +99,7 @@ function TableRowEnd(props: { entry: Entry | null; isNew?: boolean }) {
return (
<>
<td>{(entry.prob * 100).toFixed(1)}%</td>
<td>${(entry.noBid + entry.noWeight).toFixed(0)}</td>
<td>${entry.noWeight.toFixed(0)}</td>
{!props.isNew && (
<>
<td>${entry.noPayout.toFixed(0)}</td>
@ -149,9 +149,9 @@ function NewBidTable(props: {
function randomBid() {
const bidType = Math.random() < 0.5 ? 'YES' : 'NO'
const p = bidType === 'YES' ? nextEntry.prob : 1 - nextEntry.prob
// const p = bidType === 'YES' ? nextEntry.prob : 1 - nextEntry.prob
const amount = Math.round(p * Math.random() * 300) + 1
const amount = Math.floor(Math.random() * 300) + 1
const bid = makeBid(bidType, amount)
bids.splice(steps, 0, bid)
@ -238,7 +238,7 @@ function NewBidTable(props: {
// Show a hello world React page
export default function Simulator() {
const [steps, setSteps] = useState(1)
const [bids, setBids] = useState([{ yesBid: 550, noBid: 450 }])
const [bids, setBids] = useState([{ yesBid: 100, noBid: 100 }])
const entries = useMemo(
() => makeEntries(bids.slice(0, steps)),

View File

@ -3,6 +3,7 @@ import { SearchableGrid } from '../../components/contracts-list'
import { Page } from '../../components/page'
import { Title } from '../../components/title'
import { useContracts } from '../../hooks/use-contracts'
import { useQueryAndSortParams } from '../../hooks/use-sort-and-query-params'
export default function TagPage() {
const router = useRouter()
@ -18,10 +19,24 @@ export default function TagPage() {
)
}
const { query, setQuery, sort, setSort } = useQueryAndSortParams({
defaultSort: 'most-traded',
})
return (
<Page>
<Title text={`#${tag}`} />
<SearchableGrid contracts={contracts === 'loading' ? [] : contracts} />
{contracts === 'loading' ? (
<></>
) : (
<SearchableGrid
contracts={contracts}
query={query}
setQuery={setQuery}
sort={sort}
setSort={setSort}
/>
)}
</Page>
)
}

View File

@ -4,13 +4,13 @@ import { SEO } from '../components/SEO'
import { Title } from '../components/title'
import { useUser } from '../hooks/use-user'
export default function BetsPage() {
export default function TradesPage() {
const user = useUser()
return (
<Page>
<SEO title="Your bets" description="Your bets" url="/bets" />
<Title text="Your bets" />
<SEO title="Your trades" description="Your trades" url="/trades" />
<Title text="Your trades" />
{user && <BetsList user={user} />}
</Page>
)

BIN
web/public/logo-banner.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

BIN
web/public/logo-bg.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

View File

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 24.2.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Logo" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 248 204" style="enable-background:new 0 0 248 204;" xml:space="preserve">
<style type="text/css">
.st0{fill:#FFFFFF;}
</style>
<g id="Logo_1_">
<path id="white_background" class="st0" d="M221.95,51.29c0.15,2.17,0.15,4.34,0.15,6.53c0,66.73-50.8,143.69-143.69,143.69v-0.04
C50.97,201.51,24.1,193.65,1,178.83c3.99,0.48,8,0.72,12.02,0.73c22.74,0.02,44.83-7.61,62.72-21.66
c-21.61-0.41-40.56-14.5-47.18-35.07c7.57,1.46,15.37,1.16,22.8-0.87C27.8,117.2,10.85,96.5,10.85,72.46c0-0.22,0-0.43,0-0.64
c7.02,3.91,14.88,6.08,22.92,6.32C11.58,63.31,4.74,33.79,18.14,10.71c25.64,31.55,63.47,50.73,104.08,52.76
c-4.07-17.54,1.49-35.92,14.61-48.25c20.34-19.12,52.33-18.14,71.45,2.19c11.31-2.23,22.15-6.38,32.07-12.26
c-3.77,11.69-11.66,21.62-22.2,27.93c10.01-1.18,19.79-3.86,29-7.95C240.37,35.29,231.83,44.14,221.95,51.29z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -8,7 +8,7 @@ module.exports = {
fontFamily: Object.assign(
{ ...defaultTheme.fontFamily },
{
'major-mono': ['Courier', 'monospace'],
'major-mono': ['Major Mono Display', 'monospace'],
'readex-pro': ['Readex Pro', 'sans-serif'],
}
),

View File

@ -2289,6 +2289,11 @@ fast-levenshtein@^2.0.6:
resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917"
integrity sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=
fast-shallow-equal@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/fast-shallow-equal/-/fast-shallow-equal-1.0.0.tgz#d4dcaf6472440dcefa6f88b98e3251e27f25628b"
integrity sha512-HPtaa38cPgWvaCFmRNhlc6NG7pv6NUHqjPgVAkWGoB9mQMwYB27/K0CvOM5Czy+qpT3e8XJ6Q4aPAnzpNpzNaw==
fastq@^1.6.0:
version "1.13.0"
resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.13.0.tgz#616760f88a7526bdfc596b7cab8c18938c36b98c"
@ -3837,6 +3842,15 @@ react-dom@17.0.2:
object-assign "^4.1.1"
scheduler "^0.20.2"
react-expanding-textarea@^2.3.4:
version "2.3.4"
resolved "https://registry.yarnpkg.com/react-expanding-textarea/-/react-expanding-textarea-2.3.4.tgz#3eee788cf3b36798d0f9aed5a50f752278d44a49"
integrity sha512-zg/14CyPrIbjPgjfQGxcCmSv9nCrSNpmYpnqyOnNwaOJb8zxWD/GXN8Dgnp5jx8CPU6uIfZVhxs7h2hiOXiSHQ==
dependencies:
fast-shallow-equal "^1.0.0"
react-with-forwarded-ref "^0.3.3"
tslib "^2.0.3"
react-is@17.0.2:
version "17.0.2"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0"
@ -3866,6 +3880,13 @@ react-refresh@0.8.3:
resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.8.3.tgz#721d4657672d400c5e3c75d063c4a85fb2d5d68f"
integrity sha512-X8jZHc7nCMjaCqoU+V2I0cOhNW+QMBwSUkeXnTi8IPe6zaRWfn60ZzvFDZqWPfmSJfjub7dDW1SP0jaHWLu/hg==
react-with-forwarded-ref@^0.3.3:
version "0.3.4"
resolved "https://registry.yarnpkg.com/react-with-forwarded-ref/-/react-with-forwarded-ref-0.3.4.tgz#b1e884ea081ec3c5dd578f37889159797454c0a5"
integrity sha512-SRq/uTdTh+02JDwYzEEhY2aNNWl/CP2EKP2nQtXzhJw06w6PgYnJt2ObrebvFJu6+wGzX3vDHU3H/ux9hxyZUQ==
dependencies:
tslib "^2.0.3"
react@17.0.2:
version "17.0.2"
resolved "https://registry.yarnpkg.com/react/-/react-17.0.2.tgz#d0b5cc516d29eb3eee383f75b62864cfb6800037"
@ -4508,7 +4529,7 @@ tslib@^1.8.1, tslib@^1.9.0:
resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"
integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==
tslib@^2.1.0:
tslib@^2.0.3, tslib@^2.1.0:
version "2.3.1"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.3.1.tgz#e8a335add5ceae51aa261d32a490158ef042ef01"
integrity sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==