Merge branch 'main' into hanania

This commit is contained in:
James Grugett 2022-07-14 22:21:08 -05:00
commit fbb185d7a0
239 changed files with 10915 additions and 5638 deletions

View File

@ -52,4 +52,4 @@ jobs:
- name: Run Typescript checker on cloud functions
if: ${{ success() || failure() }}
working-directory: functions
run: tsc --pretty --project tsconfig.json --noEmit
run: tsc -b -v --pretty

2
.gitignore vendored
View File

@ -3,3 +3,5 @@
.vercel
node_modules
yarn-error.log
firebase-debug.log

View File

@ -6,7 +6,8 @@
"recommendations": [
"esbenp.prettier-vscode",
"dbaeumer.vscode-eslint",
"toba.vsfire"
"toba.vsfire",
"bradlc.vscode-tailwindcss"
],
// List of extensions recommended by VS Code that should not be recommended for users of this workspace.
"unwantedRecommendations": []

View File

@ -1,6 +1,7 @@
module.exports = {
plugins: ['lodash'],
extends: ['eslint:recommended'],
ignorePatterns: ['lib'],
env: {
browser: true,
node: true,
@ -31,6 +32,7 @@ module.exports = {
rules: {
'no-extra-semi': 'off',
'no-constant-condition': ['error', { checkLoops: false }],
'linebreak-style': ['error', 'unix'],
'lodash/import-scope': [2, 'member'],
},
}

3
common/.gitignore vendored
View File

@ -1,6 +1,5 @@
# Compiled JavaScript files
lib/**/*.js
lib/**/*.js.map
lib/
# TypeScript v1 declaration files
typings/

1
common/.yarnrc Normal file
View File

@ -0,0 +1 @@
save-prefix ""

View File

@ -10,14 +10,12 @@ import {
import { User } from './user'
import { LiquidityProvision } from './liquidity-provision'
import { noFees } from './fees'
import { ENV_CONFIG } from './envs/constants'
export const FIXED_ANTE = 100
// deprecated
export const PHANTOM_ANTE = 0.001
export const MINIMUM_ANTE = 50
export const FIXED_ANTE = ENV_CONFIG.fixedAnte ?? 100
export const HOUSE_LIQUIDITY_PROVIDER_ID = 'IPTOzEqrpkWmEzh6hwvAyY9PqFb2' // @ManifoldMarkets' id
export const DEV_HOUSE_LIQUIDITY_PROVIDER_ID = '94YYTk1AFWfbWMpfYcvnnwI1veP2' // @ManifoldMarkets' id
export function getCpmmInitialLiquidity(
providerId: string,

22
common/api.ts Normal file
View File

@ -0,0 +1,22 @@
import { ENV_CONFIG } from './envs/constants'
export class APIError extends Error {
code: number
details?: unknown
constructor(code: number, message: string, details?: unknown) {
super(message)
this.code = code
this.name = 'APIError'
this.details = details
}
}
export function getFunctionUrl(name: string) {
if (process.env.NEXT_PUBLIC_FIREBASE_EMULATE) {
const { projectId, region } = ENV_CONFIG.firebaseConfig
return `http://localhost:5001/${projectId}/${region}/${name}`
} else {
const { cloudRunId, cloudRunRegion } = ENV_CONFIG
return `https://${name}-${cloudRunId}-${cloudRunRegion}.a.run.app`
}
}

View File

@ -4,6 +4,7 @@ export type Bet = {
id: string
userId: string
contractId: string
createdTime: number
amount: number // bet size; negative if SELL bet
loanAmount?: number
@ -25,9 +26,7 @@ export type Bet = {
isAnte?: boolean
isLiquidityProvision?: boolean
isRedemption?: boolean
createdTime: number
}
} & Partial<LimitProps>
export type NumericBet = Bet & {
value: number
@ -35,4 +34,29 @@ export type NumericBet = Bet & {
allBetAmounts: { [outcome: string]: number }
}
// Binary market limit order.
export type LimitBet = Bet & LimitProps
type LimitProps = {
orderAmount: number // Amount of limit order.
limitProb: number // [0, 1]. Bet to this probability.
isFilled: boolean // Whether all of the bet amount has been filled.
isCancelled: boolean // Whether to prevent any further fills.
// A record of each transaction that partially (or fully) fills the orderAmount.
// I.e. A limit order could be filled by partially matching with several bets.
// Non-limit orders can also be filled by matching with multiple limit orders.
fills: fill[]
}
export type fill = {
// The id the bet matched against, or null if the bet was matched by the pool.
matchedBetId: string | null
amount: number
shares: number
timestamp: number
// If the fill is a sale, it means the matching bet has shares of the same outcome.
// I.e. -fill.shares === matchedBet.shares
isSale?: boolean
}
export const MAX_LOAN_PER_CONTRACT = 20

View File

@ -1,10 +1,17 @@
import { sum, groupBy, mapValues, sumBy, partition } from 'lodash'
import { sum, groupBy, mapValues, sumBy } from 'lodash'
import { LimitBet } from './bet'
import { CPMMContract } from './contract'
import { CREATOR_FEE, Fees, LIQUIDITY_FEE, noFees, PLATFORM_FEE } from './fees'
import { CREATOR_FEE, Fees, LIQUIDITY_FEE, PLATFORM_FEE } from './fees'
import { LiquidityProvision } from './liquidity-provision'
import { computeFills } from './new-bet'
import { binarySearch } from './util/algos'
import { addObjects } from './util/object'
export type CpmmState = {
pool: { [outcome: string]: number }
p: number
}
export function getCpmmProbability(
pool: { [outcome: string]: number },
p: number
@ -14,11 +21,11 @@ export function getCpmmProbability(
}
export function getCpmmProbabilityAfterBetBeforeFees(
contract: CPMMContract,
state: CpmmState,
outcome: string,
bet: number
) {
const { pool, p } = contract
const { pool, p } = state
const shares = calculateCpmmShares(pool, p, bet, outcome)
const { YES: y, NO: n } = pool
@ -31,12 +38,12 @@ export function getCpmmProbabilityAfterBetBeforeFees(
}
export function getCpmmOutcomeProbabilityAfterBet(
contract: CPMMContract,
state: CpmmState,
outcome: string,
bet: number
) {
const { newPool } = calculateCpmmPurchase(contract, bet, outcome)
const p = getCpmmProbability(newPool, contract.p)
const { newPool } = calculateCpmmPurchase(state, bet, outcome)
const p = getCpmmProbability(newPool, state.p)
return outcome === 'NO' ? 1 - p : p
}
@ -58,12 +65,8 @@ function calculateCpmmShares(
: n + bet - (k * (bet + y) ** -p) ** (1 / (1 - p))
}
export function getCpmmLiquidityFee(
contract: CPMMContract,
bet: number,
outcome: string
) {
const prob = getCpmmProbabilityAfterBetBeforeFees(contract, outcome, bet)
export function getCpmmFees(state: CpmmState, bet: number, outcome: string) {
const prob = getCpmmProbabilityAfterBetBeforeFees(state, outcome, bet)
const betP = outcome === 'YES' ? 1 - prob : prob
const liquidityFee = LIQUIDITY_FEE * betP * bet
@ -78,25 +81,23 @@ export function getCpmmLiquidityFee(
}
export function calculateCpmmSharesAfterFee(
contract: CPMMContract,
state: CpmmState,
bet: number,
outcome: string
) {
const { pool, p } = contract
const { remainingBet } = getCpmmLiquidityFee(contract, bet, outcome)
const { pool, p } = state
const { remainingBet } = getCpmmFees(state, bet, outcome)
return calculateCpmmShares(pool, p, remainingBet, outcome)
}
export function calculateCpmmPurchase(
contract: CPMMContract,
state: CpmmState,
bet: number,
outcome: string
) {
const { pool, p } = contract
const { remainingBet, fees } = getCpmmLiquidityFee(contract, bet, outcome)
// const remainingBet = bet
// const fees = noFees
const { pool, p } = state
const { remainingBet, fees } = getCpmmFees(state, bet, outcome)
const shares = calculateCpmmShares(pool, p, remainingBet, outcome)
const { YES: y, NO: n } = pool
@ -115,119 +116,111 @@ export function calculateCpmmPurchase(
return { shares, newPool, newP, fees }
}
function computeK(y: number, n: number, p: number) {
return y ** p * n ** (1 - p)
}
function sellSharesK(
y: number,
n: number,
p: number,
s: number,
outcome: 'YES' | 'NO',
b: number
) {
return outcome === 'YES'
? computeK(y - b + s, n - b, p)
: computeK(y - b, n - b + s, p)
}
function calculateCpmmShareValue(
contract: CPMMContract,
shares: number,
// Note: there might be a closed form solution for this.
// If so, feel free to switch out this implementation.
export function calculateCpmmAmountToProb(
state: CpmmState,
prob: number,
outcome: 'YES' | 'NO'
) {
const { pool, p } = contract
if (outcome === 'NO') prob = 1 - prob
// Find bet amount that preserves k after selling shares.
const k = computeK(pool.YES, pool.NO, p)
const otherPool = outcome === 'YES' ? pool.NO : pool.YES
// First, find an upper bound that leads to a more extreme probability than prob.
let maxGuess = 10
let newProb = 0
do {
maxGuess *= 10
newProb = getCpmmOutcomeProbabilityAfterBet(state, outcome, maxGuess)
} while (newProb < prob)
// Constrain the max sale value to the lessor of 1. shares and 2. the other pool.
// This is because 1. the max value per share is M$ 1,
// and 2. The other pool cannot go negative and the sale value is subtracted from it.
// (Without this, there are multiple solutions for the same k.)
let highAmount = Math.min(shares, otherPool)
let lowAmount = 0
let mid = 0
let kGuess = 0
while (true) {
mid = lowAmount + (highAmount - lowAmount) / 2
// Then, binary search for the amount that gets closest to prob.
const amount = binarySearch(0, maxGuess, (amount) => {
const newProb = getCpmmOutcomeProbabilityAfterBet(state, outcome, amount)
return newProb - prob
})
// Break once we've reached max precision.
if (mid === lowAmount || mid === highAmount) break
return amount
}
kGuess = sellSharesK(pool.YES, pool.NO, p, shares, outcome, mid)
if (kGuess < k) {
highAmount = mid
} else {
lowAmount = mid
}
}
return mid
function calculateAmountToBuyShares(
state: CpmmState,
shares: number,
outcome: 'YES' | 'NO',
unfilledBets: LimitBet[]
) {
// Search for amount between bounds (0, shares).
// Min share price is M$0, and max is M$1 each.
return binarySearch(0, shares, (amount) => {
const { takers } = computeFills(
outcome,
amount,
state,
undefined,
unfilledBets
)
const totalShares = sumBy(takers, (taker) => taker.shares)
return totalShares - shares
})
}
export function calculateCpmmSale(
contract: CPMMContract,
state: CpmmState,
shares: number,
outcome: string
outcome: 'YES' | 'NO',
unfilledBets: LimitBet[]
) {
if (Math.round(shares) < 0) {
throw new Error('Cannot sell non-positive shares')
}
const saleValue = calculateCpmmShareValue(
contract,
const oppositeOutcome = outcome === 'YES' ? 'NO' : 'YES'
const buyAmount = calculateAmountToBuyShares(
state,
shares,
outcome as 'YES' | 'NO'
oppositeOutcome,
unfilledBets
)
const fees = noFees
const { cpmmState, makers, takers, totalFees } = computeFills(
oppositeOutcome,
buyAmount,
state,
undefined,
unfilledBets
)
// const { fees, remainingBet: saleValue } = getCpmmLiquidityFee(
// contract,
// rawSaleValue,
// outcome === 'YES' ? 'NO' : 'YES'
// )
// Transform buys of opposite outcome into sells.
const saleTakers = takers.map((taker) => ({
...taker,
// You bought opposite shares, which combine with existing shares, removing them.
shares: -taker.shares,
// Opposite shares combine with shares you are selling for M$ of shares.
// You paid taker.amount for the opposite shares.
// Take the negative because this is money you gain.
amount: -(taker.shares - taker.amount),
isSale: true,
}))
const { pool } = contract
const { YES: y, NO: n } = pool
const saleValue = -sumBy(saleTakers, (taker) => taker.amount)
const { liquidityFee: fee } = fees
const [newY, newN] =
outcome === 'YES'
? [y + shares - saleValue + fee, n - saleValue + fee]
: [y - saleValue + fee, n + shares - saleValue + fee]
if (newY < 0 || newN < 0) {
console.log('calculateCpmmSale', {
newY,
newN,
y,
n,
shares,
return {
saleValue,
fee,
outcome,
})
throw new Error('Cannot sell more than in pool')
cpmmState,
fees: totalFees,
makers,
takers: saleTakers,
}
const postBetPool = { YES: newY, NO: newN }
const { newPool, newP } = addCpmmLiquidity(postBetPool, contract.p, fee)
return { saleValue, newPool, newP, fees }
}
export function getCpmmProbabilityAfterSale(
contract: CPMMContract,
state: CpmmState,
shares: number,
outcome: 'YES' | 'NO'
outcome: 'YES' | 'NO',
unfilledBets: LimitBet[]
) {
const { newPool } = calculateCpmmSale(contract, shares, outcome)
return getCpmmProbability(newPool, contract.p)
const { cpmmState } = calculateCpmmSale(state, shares, outcome, unfilledBets)
return getCpmmProbability(cpmmState.pool, cpmmState.p)
}
export function getCpmmLiquidity(
@ -271,22 +264,24 @@ const calculateLiquidityDelta = (p: number) => (l: LiquidityProvision) => {
}
export function getCpmmLiquidityPoolWeights(
contract: CPMMContract,
liquidities: LiquidityProvision[]
state: CpmmState,
liquidities: LiquidityProvision[],
excludeAntes: boolean
) {
const [antes, nonAntes] = partition(liquidities, (l) => !!l.isAnte)
const calcLiqudity = calculateLiquidityDelta(state.p)
const liquidityShares = liquidities.map(calcLiqudity)
const shareSum = sum(liquidityShares)
const calcLiqudity = calculateLiquidityDelta(contract.p)
const liquidityShares = nonAntes.map(calcLiqudity)
const shareSum = sum(liquidityShares) + sum(antes.map(calcLiqudity))
const weights = liquidityShares.map((s, i) => ({
weight: s / shareSum,
providerId: nonAntes[i].userId,
const weights = liquidityShares.map((shares, i) => ({
weight: shares / shareSum,
providerId: liquidities[i].userId,
}))
const userWeights = groupBy(weights, (w) => w.providerId)
const includedWeights = excludeAntes
? weights.filter((_, i) => !liquidities[i].isAnte)
: weights
const userWeights = groupBy(includedWeights, (w) => w.providerId)
const totalUserWeights = mapValues(userWeights, (userWeight) =>
sumBy(userWeight, (w) => w.weight)
)
@ -295,11 +290,12 @@ export function getCpmmLiquidityPoolWeights(
export function getUserLiquidityShares(
userId: string,
contract: CPMMContract,
liquidities: LiquidityProvision[]
state: CpmmState,
liquidities: LiquidityProvision[],
excludeAntes: boolean
) {
const weights = getCpmmLiquidityPoolWeights(contract, liquidities)
const weights = getCpmmLiquidityPoolWeights(state, liquidities, excludeAntes)
const userWeight = weights[userId] ?? 0
return mapValues(contract.pool, (shares) => userWeight * shares)
return mapValues(state.pool, (shares) => userWeight * shares)
}

View File

@ -2,7 +2,7 @@ import { cloneDeep, range, sum, sumBy, sortBy, mapValues } from 'lodash'
import { Bet, NumericBet } from './bet'
import { DPMContract, DPMBinaryContract, NumericContract } from './contract'
import { DPM_FEES } from './fees'
import { normpdf } from '../common/util/math'
import { normpdf } from './util/math'
import { addObjects } from './util/object'
export function getDpmProbability(totalShares: { [outcome: string]: number }) {

View File

@ -1,5 +1,5 @@
import { maxBy } from 'lodash'
import { Bet } from './bet'
import { Bet, LimitBet } from './bet'
import {
calculateCpmmSale,
getCpmmProbability,
@ -18,15 +18,25 @@ import {
getDpmProbabilityAfterSale,
} from './calculate-dpm'
import { calculateFixedPayout } from './calculate-fixed-payouts'
import { Contract, BinaryContract, FreeResponseContract } from './contract'
import {
Contract,
BinaryContract,
FreeResponseContract,
PseudoNumericContract,
} from './contract'
import { floatingEqual } from './util/math'
export function getProbability(contract: BinaryContract) {
export function getProbability(
contract: BinaryContract | PseudoNumericContract
) {
return contract.mechanism === 'cpmm-1'
? getCpmmProbability(contract.pool, contract.p)
: getDpmProbability(contract.totalShares)
}
export function getInitialProbability(contract: BinaryContract) {
export function getInitialProbability(
contract: BinaryContract | PseudoNumericContract
) {
if (contract.initialProbability) return contract.initialProbability
if (contract.mechanism === 'dpm-2' || (contract as any).totalShares)
@ -64,9 +74,20 @@ export function calculateShares(
: calculateDpmShares(contract.totalShares, bet, betChoice)
}
export function calculateSaleAmount(contract: Contract, bet: Bet) {
return contract.mechanism === 'cpmm-1' && contract.outcomeType === 'BINARY'
? calculateCpmmSale(contract, Math.abs(bet.shares), bet.outcome).saleValue
export function calculateSaleAmount(
contract: Contract,
bet: Bet,
unfilledBets: LimitBet[]
) {
return contract.mechanism === 'cpmm-1' &&
(contract.outcomeType === 'BINARY' ||
contract.outcomeType === 'PSEUDO_NUMERIC')
? calculateCpmmSale(
contract,
Math.abs(bet.shares),
bet.outcome as 'YES' | 'NO',
unfilledBets
).saleValue
: calculateDpmSaleAmount(contract, bet)
}
@ -79,15 +100,23 @@ export function calculatePayoutAfterCorrectBet(contract: Contract, bet: Bet) {
export function getProbabilityAfterSale(
contract: Contract,
outcome: string,
shares: number
shares: number,
unfilledBets: LimitBet[]
) {
return contract.mechanism === 'cpmm-1'
? getCpmmProbabilityAfterSale(contract, shares, outcome as 'YES' | 'NO')
? getCpmmProbabilityAfterSale(
contract,
shares,
outcome as 'YES' | 'NO',
unfilledBets
)
: getDpmProbabilityAfterSale(contract.totalShares, outcome, shares)
}
export function calculatePayout(contract: Contract, bet: Bet, outcome: string) {
return contract.mechanism === 'cpmm-1' && contract.outcomeType === 'BINARY'
return contract.mechanism === 'cpmm-1' &&
(contract.outcomeType === 'BINARY' ||
contract.outcomeType === 'PSEUDO_NUMERIC')
? calculateFixedPayout(contract, bet, outcome)
: calculateDpmPayout(contract, bet, outcome)
}
@ -96,7 +125,9 @@ export function resolvedPayout(contract: Contract, bet: Bet) {
const outcome = contract.resolution
if (!outcome) throw new Error('Contract not resolved')
return contract.mechanism === 'cpmm-1' && contract.outcomeType === 'BINARY'
return contract.mechanism === 'cpmm-1' &&
(contract.outcomeType === 'BINARY' ||
contract.outcomeType === 'PSEUDO_NUMERIC')
? calculateFixedPayout(contract, bet, outcome)
: calculateDpmPayout(contract, bet, outcome)
}
@ -143,7 +174,7 @@ export function getContractBetMetrics(contract: Contract, yourBets: Bet[]) {
const profitPercent = (profit / totalInvested) * 100
const hasShares = Object.values(totalShares).some(
(shares) => shares > 0
(shares) => !floatingEqual(shares, 0)
)
return {

View File

@ -1,5 +1,6 @@
import { difference } from 'lodash'
export const CATEGORIES_GROUP_SLUG_POSTFIX = '-default'
export const CATEGORIES = {
politics: 'Politics',
technology: 'Technology',
@ -24,9 +25,15 @@ export const TO_CATEGORY = Object.fromEntries(
export const CATEGORY_LIST = Object.keys(CATEGORIES)
export const EXCLUDED_CATEGORIES: category[] = ['fun', 'manifold', 'personal']
export const EXCLUDED_CATEGORIES: category[] = [
'fun',
'manifold',
'personal',
'covid',
'culture',
'gaming',
'crypto',
'world',
]
export const DEFAULT_CATEGORIES = difference(
CATEGORY_LIST,
EXCLUDED_CATEGORIES
)
export const DEFAULT_CATEGORIES = difference(CATEGORY_LIST, EXCLUDED_CATEGORIES)

View File

@ -300,10 +300,29 @@ Future plans: We expect to focus on similar theoretical problems in alignment un
name: 'Wild Animal Initiative',
website: 'https://www.wildanimalinitiative.org/',
ein: '82-2281466',
tags: ['Featured'] as CharityTag[],
photo: 'https://i.imgur.com/bOVUnDm.png',
preview: 'We want to make life better for wild animals.',
description:
'Wild Animal Initiative (WAI) currently operates in the U.S., where they work to strengthen the animal advocacy movement through creating an academic field dedicated to wild animal welfare. They compile literature reviews, write theoretical and opinion articles, and publish research results on their website and/or in peer-reviewed journals. WAI focuses on identifying and sharing possible research avenues and connecting with more established fields. They also work with researchers from various academic and non-academic institutions to identify potential collaborators, and they recently launched a grant assistance program.',
preview:
'Our mission is to understand and improve the lives of wild animals.',
description: `Although the natural world is a source of great beauty and happiness, vast numbers of animals routinely face serious challenges such as disease, hunger, or natural disasters. There is no “one-size-fits-all” solution to these threats. However, even as we recognize that improving the welfare of free-ranging wild animals is difficult, we believe that humans have a responsibility to help whenever we can.
Our staff explores how humans can beneficially coexist with animals through the lens of wild animal welfare.
We respect wild animals as individuals with their own needs and preferences, rather than seeing them as mere parts of ecosystems. But this approach demands a richer understanding of wild animals lives.
We want to take a proactive approach to managing the welfare benefits, threats, and uncertainties that are inherent to complex natural and urban environments. Yet, to take action safely, we must conduct research to understand the impacts of our actions. The transdisciplinary perspective of wild animal welfare draws upon ethics, ecology, and animal welfare science to gather the knowledge we need, facilitating evidence-based improvements to wild animals quality of life.
Without sufficient public interest or research activity, solutions to the problems wild animals face will go undiscovered.
Wild Animal Initiative currently focuses on helping scientists, grantors, and decision-makers investigate important and understudied questions about wild animal welfare. Our work catalyzes research and applied projects that will open the door to a clearer picture of wild animals needs and how to enhance their well-being. Ultimately, we envision a world in which people actively choose to help wild animals and have the knowledge they need to do so responsibly.`,
},
{
name: 'FYXX Foundation',
website: 'https://www.fyxxfoundation.org/',
photo: 'https://i.imgur.com/ROmWO7m.png',
preview:
'FYXX Foundation: wildlife population management, without killing.',
description: `The future of our planet depends on the innovations of today, and the health of our wildlife are the first indication of our successful stewardship, which we believe can be improved by safe population management utilizing fertility control instead of poison and culling.`,
},
{
name: 'New Incentives',
@ -516,6 +535,22 @@ The American Civil Liberties Union is our nation's guardian of liberty, working
The U.S. Constitution and the Bill of Rights trumpet our aspirations for the kind of society that we want to be. But for much of our history, our nation failed to fulfill the promise of liberty for whole groups of people.`,
},
{
name: 'The Center for Election Science',
website: 'https://electionscience.org/',
photo: 'https://i.imgur.com/WvdHHZa.png',
preview:
'The Center for Election Science is a nonpartisan nonprofit dedicated to empowering voters with voting methods that strengthen democracy. We believe you deserve a vote that empowers you to impact the world you live in.',
description: `Founded in 2011, The Center for Election Science is a national, nonpartisan nonprofit focused on voting reform.
Our Mission To empower people with voting methods that strengthen democracy.
Our Vision A world where democracies thrive because voters voices are heard.
With an emphasis on approval voting, we bring better elections to people across the country through both advocacy and research.
The movement for a better way to vote is rapidly gaining momentum as voters grow tired of election results that dont represent the will of the people. In 2018, we worked with locals in Fargo, ND to help them become the first city in the U.S. to adopt approval voting. And in 2020, we helped grassroots activists empower the 300k people of St. Louis, MO with stronger democracy through approval voting.`,
},
].map((charity) => {
const slug = charity.name.toLowerCase().replace(/\s/g, '-')
return {

View File

@ -1,10 +1,12 @@
import { Answer } from './answer'
import { Fees } from './fees'
import { JSONContent } from '@tiptap/core'
export type AnyMechanism = DPM | CPMM
export type AnyOutcomeType = Binary | FreeResponse | Numeric
export type AnyOutcomeType = Binary | PseudoNumeric | FreeResponse | Numeric
export type AnyContractType =
| (CPMM & Binary)
| (CPMM & PseudoNumeric)
| (DPM & Binary)
| (DPM & FreeResponse)
| (DPM & Numeric)
@ -19,7 +21,7 @@ export type Contract<T extends AnyContractType = AnyContractType> = {
creatorAvatarUrl?: string
question: string
description: string // More info about what the contract is about
description: string | JSONContent // More info about what the contract is about
tags: string[]
lowercaseTags: string[]
visibility: 'public' | 'unlisted'
@ -33,7 +35,7 @@ export type Contract<T extends AnyContractType = AnyContractType> = {
isResolved: boolean
resolutionTime?: number // When the contract creator resolved the market
resolution?: string
resolutionProbability?: number,
resolutionProbability?: number
closeEmailsSent?: number
@ -42,9 +44,14 @@ export type Contract<T extends AnyContractType = AnyContractType> = {
volume7Days: number
collectedFees: Fees
groupSlugs?: string[]
uniqueBettorIds?: string[]
uniqueBettorCount?: number
} & T
export type BinaryContract = Contract & Binary
export type PseudoNumericContract = Contract & PseudoNumeric
export type NumericContract = Contract & Numeric
export type FreeResponseContract = Contract & FreeResponse
export type DPMContract = Contract & DPM
@ -75,6 +82,18 @@ export type Binary = {
resolution?: resolution
}
export type PseudoNumeric = {
outcomeType: 'PSEUDO_NUMERIC'
min: number
max: number
isLogScale: boolean
resolutionValue?: number
// same as binary market; map everything to probability
initialProbability: number
resolutionProbability?: number
}
export type FreeResponse = {
outcomeType: 'FREE_RESPONSE'
answers: Answer[] // Used for outcomeType 'FREE_RESPONSE'.
@ -94,7 +113,12 @@ export type Numeric = {
export type outcomeType = AnyOutcomeType['outcomeType']
export type resolution = 'YES' | 'NO' | 'MKT' | 'CANCEL'
export const RESOLUTIONS = ['YES', 'NO', 'MKT', 'CANCEL'] as const
export const OUTCOME_TYPES = ['BINARY', 'FREE_RESPONSE', 'NUMERIC'] as const
export const OUTCOME_TYPES = [
'BINARY',
'FREE_RESPONSE',
'PSEUDO_NUMERIC',
'NUMERIC',
] as const
export const MAX_QUESTION_LENGTH = 480
export const MAX_DESCRIPTION_LENGTH = 10000

View File

@ -36,5 +36,9 @@ export const IS_PRIVATE_MANIFOLD = ENV_CONFIG.visibility === 'PRIVATE'
export const CORS_ORIGIN_MANIFOLD = new RegExp(
'^https?://(?:[a-zA-Z0-9\\-]+\\.)*' + escapeRegExp(ENV_CONFIG.domain) + '$'
)
// Vercel deployments, used for testing.
export const CORS_ORIGIN_VERCEL = new RegExp(
'^https?://[a-zA-Z0-9\\-]+' + escapeRegExp('mantic.vercel.app') + '$'
)
// Any localhost server on any port
export const CORS_ORIGIN_LOCALHOST = /^http:\/\/localhost:\d+$/

View File

@ -12,12 +12,7 @@ export const DEV_CONFIG: EnvConfig = {
appId: '1:134303100058:web:27f9ea8b83347251f80323',
measurementId: 'G-YJC9E37P37',
},
functionEndpoints: {
placebet: 'https://placebet-w3txbmd3ba-uc.a.run.app',
sellshares: 'https://sellshares-w3txbmd3ba-uc.a.run.app',
sellbet: 'https://sellbet-w3txbmd3ba-uc.a.run.app',
createmarket: 'https://createmarket-w3txbmd3ba-uc.a.run.app',
creategroup: 'https://creategroup-w3txbmd3ba-uc.a.run.app',
},
cloudRunId: 'w3txbmd3ba',
cloudRunRegion: 'uc',
amplitudeApiKey: 'fd8cbfd964b9a205b8678a39faae71b3',
}

View File

@ -11,16 +11,11 @@ export const HANANIA_CONFIG: EnvConfig = {
appId: '1:319008991675:web:d2dc5e72b95cdcec96fc9e',
measurementId: 'G-VCXVKYGKTC',
},
// TODO replace
functionEndpoints: {
placebet: 'https://placebet-45jazbrfja-uc.a.run.app',
sellshares: 'https://sellshares-45jazbrfja-uc.a.run.app',
sellbet: 'https://sellbet-45jazbrfja-uc.a.run.app',
createmarket: 'https://createmarket-45jazbrfja-uc.a.run.app',
creategroup: 'https://creategroup-45jazbrfja-uc.a.run.app',
},
cloudRunId: '45jazbrfja', // TODO: fill in real ID for T1
cloudRunRegion: 'uc',
adminEmails: [...PROD_CONFIG.adminEmails],
whitelistEmail: '',
moneyMoniker: 'H$',
visibility: 'PRIVATE',
newQuestionPlaceholders: [],
}

View File

@ -1,16 +1,13 @@
export type V2CloudFunction =
| 'placebet'
| 'sellbet'
| 'sellshares'
| 'createmarket'
| 'creategroup'
export type EnvConfig = {
domain: string
firebaseConfig: FirebaseConfig
functionEndpoints: Record<V2CloudFunction, string>
amplitudeApiKey?: string
// IDs for v2 cloud functions -- find these by deploying a cloud function and
// examining the URL, https://[name]-[cloudRunId]-[cloudRunRegion].a.run.app
cloudRunId: string
cloudRunRegion: string
// Access controls
adminEmails: string[]
whitelistEmail?: string // e.g. '@theoremone.co'. If not provided, all emails are whitelisted
@ -20,14 +17,18 @@ export type EnvConfig = {
moneyMoniker: string // e.g. 'M$'
faviconPath?: string // Should be a file in /public
navbarLogoPath?: string
newQuestionPlaceholders?: string[] // TODO remove
newQuestionPlaceholders: string[]
// Currency controls
fixedAnte?: number
startingBalance?: number
}
type FirebaseConfig = {
apiKey: string
authDomain: string
projectId: string
region?: string // TODO remove
region?: string
storageBucket: string
messagingSenderId: string
appId: string
@ -48,13 +49,8 @@ export const PROD_CONFIG: EnvConfig = {
appId: '1:128925704902:web:f61f86944d8ffa2a642dc7',
measurementId: 'G-SSFK1Q138D',
},
functionEndpoints: {
placebet: 'https://placebet-nggbo3neva-uc.a.run.app',
sellshares: 'https://sellshares-nggbo3neva-uc.a.run.app',
sellbet: 'https://sellbet-nggbo3neva-uc.a.run.app',
createmarket: 'https://createmarket-nggbo3neva-uc.a.run.app',
creategroup: 'https://creategroup-nggbo3neva-uc.a.run.app',
},
cloudRunId: 'nggbo3neva',
cloudRunRegion: 'uc',
adminEmails: [
'akrolsmir@gmail.com', // Austin
'jahooma@gmail.com', // James

View File

@ -12,14 +12,8 @@ export const THEOREMONE_CONFIG: EnvConfig = {
appId: '1:698012149198:web:b342af75662831aa84b79f',
measurementId: 'G-Y3EZ1WNT6E',
},
// TODO: fill in real endpoints for T1
functionEndpoints: {
placebet: 'https://placebet-nggbo3neva-uc.a.run.app',
sellshares: 'https://sellshares-nggbo3neva-uc.a.run.app',
sellbet: 'https://sellbet-nggbo3neva-uc.a.run.app',
createmarket: 'https://createmarket-nggbo3neva-uc.a.run.app',
creategroup: 'https://creategroup-nggbo3neva-uc.a.run.app',
},
cloudRunId: 'nggbo3neva', // TODO: fill in real ID for T1
cloudRunRegion: 'uc',
adminEmails: [...PROD_CONFIG.adminEmails, 'david.glidden@theoremone.co'],
whitelistEmail: '@theoremone.co',
moneyMoniker: 'T$',

View File

@ -9,7 +9,10 @@ export type Group = {
memberIds: string[] // User ids
anyoneCanJoin: boolean
contractIds: string[]
chatDisabled?: boolean
}
export const MAX_GROUP_NAME_LENGTH = 75
export const MAX_ABOUT_LENGTH = 140
export const MAX_ID_LENGTH = 60
export const NEW_USER_GROUP_SLUGS = ['updates', 'bugs', 'welcome']

35
common/manalink.ts Normal file
View File

@ -0,0 +1,35 @@
export type Manalink = {
// The link to send: https://manifold.markets/send/{slug}
// Also functions as the unique id for the link.
slug: string
// Note: we assume both fromId and toId are of SourceType 'USER'
fromId: string
// Displayed to people claiming the link
message: string
// How much to send with the link
amount: number
token: 'M$' // TODO: could send eg YES shares too??
createdTime: number
// If null, the link is valid forever
expiresTime: number | null
// If null, the link can be used infinitely
maxUses: number | null
// Used for simpler caching
claimedUserIds: string[]
// Successful redemptions of the link
claims: Claim[]
}
export type Claim = {
toId: string
// The ID of the successful txn that tracks the money moved
txnId: string
claimedTime: number
}

View File

@ -1,6 +1,6 @@
import { sumBy } from 'lodash'
import { sortBy, sumBy } from 'lodash'
import { Bet, MAX_LOAN_PER_CONTRACT, NumericBet } from './bet'
import { Bet, fill, LimitBet, MAX_LOAN_PER_CONTRACT, NumericBet } from './bet'
import {
calculateDpmShares,
getDpmProbability,
@ -8,20 +8,31 @@ import {
getNumericBets,
calculateNumericDpmShares,
} from './calculate-dpm'
import { calculateCpmmPurchase, getCpmmProbability } from './calculate-cpmm'
import {
calculateCpmmAmountToProb,
calculateCpmmPurchase,
CpmmState,
getCpmmProbability,
} from './calculate-cpmm'
import {
CPMMBinaryContract,
DPMBinaryContract,
FreeResponseContract,
NumericContract,
PseudoNumericContract,
} from './contract'
import { noFees } from './fees'
import { addObjects } from './util/object'
import { addObjects, removeUndefinedProps } from './util/object'
import { NUMERIC_FIXED_VAR } from './numeric-constants'
import {
floatingEqual,
floatingGreaterEqual,
floatingLesserEqual,
} from './util/math'
export type CandidateBet<T extends Bet> = Omit<T, 'id' | 'userId'>
export type CandidateBet<T extends Bet = Bet> = Omit<T, 'id' | 'userId'>
export type BetInfo = {
newBet: CandidateBet<Bet>
newBet: CandidateBet
newPool?: { [outcome: string]: number }
newTotalShares?: { [outcome: string]: number }
newTotalBets?: { [outcome: string]: number }
@ -29,38 +40,203 @@ export type BetInfo = {
newP?: number
}
export const getNewBinaryCpmmBetInfo = (
outcome: 'YES' | 'NO',
const computeFill = (
amount: number,
contract: CPMMBinaryContract,
loanAmount: number
outcome: 'YES' | 'NO',
limitProb: number | undefined,
cpmmState: CpmmState,
matchedBet: LimitBet | undefined
) => {
const { shares, newPool, newP, fees } = calculateCpmmPurchase(
contract,
amount,
outcome
)
const prob = getCpmmProbability(cpmmState.pool, cpmmState.p)
const { pool, p, totalLiquidity } = contract
const probBefore = getCpmmProbability(pool, p)
const probAfter = getCpmmProbability(newPool, newP)
const newBet: CandidateBet<Bet> = {
contractId: contract.id,
amount,
shares,
outcome,
fees,
loanAmount,
probBefore,
probAfter,
createdTime: Date.now(),
if (
limitProb !== undefined &&
(outcome === 'YES'
? floatingGreaterEqual(prob, limitProb) &&
(matchedBet?.limitProb ?? 1) > limitProb
: floatingLesserEqual(prob, limitProb) &&
(matchedBet?.limitProb ?? 0) < limitProb)
) {
// No fill.
return undefined
}
const { liquidityFee } = fees
const newTotalLiquidity = (totalLiquidity ?? 0) + liquidityFee
const timestamp = Date.now()
return { newBet, newPool, newP, newTotalLiquidity }
if (
!matchedBet ||
(outcome === 'YES'
? !floatingGreaterEqual(prob, matchedBet.limitProb)
: !floatingLesserEqual(prob, matchedBet.limitProb))
) {
// Fill from pool.
const limit = !matchedBet
? limitProb
: outcome === 'YES'
? Math.min(matchedBet.limitProb, limitProb ?? 1)
: Math.max(matchedBet.limitProb, limitProb ?? 0)
const buyAmount =
limit === undefined
? amount
: Math.min(amount, calculateCpmmAmountToProb(cpmmState, limit, outcome))
const { shares, newPool, newP, fees } = calculateCpmmPurchase(
cpmmState,
buyAmount,
outcome
)
const newState = { pool: newPool, p: newP }
return {
maker: {
matchedBetId: null,
shares,
amount: buyAmount,
state: newState,
fees,
timestamp,
},
taker: {
matchedBetId: null,
shares,
amount: buyAmount,
timestamp,
},
}
}
// Fill from matchedBet.
const matchRemaining = matchedBet.orderAmount - matchedBet.amount
const shares = Math.min(
amount /
(outcome === 'YES' ? matchedBet.limitProb : 1 - matchedBet.limitProb),
matchRemaining /
(outcome === 'YES' ? 1 - matchedBet.limitProb : matchedBet.limitProb)
)
const maker = {
bet: matchedBet,
matchedBetId: 'taker',
amount:
shares *
(outcome === 'YES' ? 1 - matchedBet.limitProb : matchedBet.limitProb),
shares,
timestamp,
}
const taker = {
matchedBetId: matchedBet.id,
amount:
shares *
(outcome === 'YES' ? matchedBet.limitProb : 1 - matchedBet.limitProb),
shares,
timestamp,
}
return { maker, taker }
}
export const computeFills = (
outcome: 'YES' | 'NO',
betAmount: number,
state: CpmmState,
limitProb: number | undefined,
unfilledBets: LimitBet[]
) => {
const sortedBets = sortBy(
unfilledBets.filter((bet) => bet.outcome !== outcome),
(bet) => (outcome === 'YES' ? bet.limitProb : -bet.limitProb),
(bet) => bet.createdTime
)
const takers: fill[] = []
const makers: {
bet: LimitBet
amount: number
shares: number
timestamp: number
}[] = []
let amount = betAmount
let cpmmState = { pool: state.pool, p: state.p }
let totalFees = noFees
let i = 0
while (true) {
const matchedBet: LimitBet | undefined = sortedBets[i]
const fill = computeFill(amount, outcome, limitProb, cpmmState, matchedBet)
if (!fill) break
const { taker, maker } = fill
if (maker.matchedBetId === null) {
// Matched against pool.
cpmmState = maker.state
totalFees = addObjects(totalFees, maker.fees)
takers.push(taker)
} else {
// Matched against bet.
takers.push(taker)
makers.push(maker)
i++
}
amount -= taker.amount
if (floatingEqual(amount, 0)) break
}
return { takers, makers, totalFees, cpmmState }
}
export const getBinaryCpmmBetInfo = (
outcome: 'YES' | 'NO',
betAmount: number,
contract: CPMMBinaryContract | PseudoNumericContract,
limitProb: number | undefined,
unfilledBets: LimitBet[]
) => {
const { pool, p } = contract
const { takers, makers, cpmmState, totalFees } = computeFills(
outcome,
betAmount,
{ pool, p },
limitProb,
unfilledBets
)
const probBefore = getCpmmProbability(contract.pool, contract.p)
const probAfter = getCpmmProbability(cpmmState.pool, cpmmState.p)
const takerAmount = sumBy(takers, 'amount')
const takerShares = sumBy(takers, 'shares')
const isFilled = floatingEqual(betAmount, takerAmount)
const newBet: CandidateBet = removeUndefinedProps({
orderAmount: betAmount,
amount: takerAmount,
shares: takerShares,
limitProb,
isFilled,
isCancelled: false,
fills: takers,
contractId: contract.id,
outcome,
probBefore,
probAfter,
loanAmount: 0,
createdTime: Date.now(),
fees: totalFees,
})
const { liquidityFee } = totalFees
const newTotalLiquidity = (contract.totalLiquidity ?? 0) + liquidityFee
return {
newBet,
newPool: cpmmState.pool,
newP: cpmmState.p,
newTotalLiquidity,
makers,
}
}
export const getNewBinaryDpmBetInfo = (
@ -95,7 +271,7 @@ export const getNewBinaryDpmBetInfo = (
const probBefore = getDpmProbability(contract.totalShares)
const probAfter = getDpmProbability(newTotalShares)
const newBet: CandidateBet<Bet> = {
const newBet: CandidateBet = {
contractId: contract.id,
amount,
loanAmount,
@ -132,7 +308,7 @@ export const getNewMultiBetInfo = (
const probBefore = getDpmOutcomeProbability(totalShares, outcome)
const probAfter = getDpmOutcomeProbability(newTotalShares, outcome)
const newBet: CandidateBet<Bet> = {
const newBet: CandidateBet = {
contractId: contract.id,
amount,
loanAmount,

View File

@ -7,10 +7,12 @@ import {
FreeResponse,
Numeric,
outcomeType,
PseudoNumeric,
} from './contract'
import { User } from './user'
import { parseTags } from './util/parse'
import { parseTags, richTextToString } from './util/parse'
import { removeUndefinedProps } from './util/object'
import { JSONContent } from '@tiptap/core'
export function getNewContract(
id: string,
@ -18,7 +20,7 @@ export function getNewContract(
creator: User,
question: string,
outcomeType: outcomeType,
description: string,
description: JSONContent,
initialProb: number,
ante: number,
closeTime: number,
@ -27,16 +29,23 @@ export function getNewContract(
// used for numeric markets
bucketCount: number,
min: number,
max: number
max: number,
isLogScale: boolean
) {
const tags = parseTags(
`${question} ${description} ${extraTags.map((tag) => `#${tag}`).join(' ')}`
[
question,
richTextToString(description),
...extraTags.map((tag) => `#${tag}`),
].join(' ')
)
const lowercaseTags = tags.map((tag) => tag.toLowerCase())
const propsByOutcomeType =
outcomeType === 'BINARY'
? getBinaryCpmmProps(initialProb, ante) // getBinaryDpmProps(initialProb, ante)
: outcomeType === 'PSEUDO_NUMERIC'
? getPseudoNumericCpmmProps(initialProb, ante, min, max, isLogScale)
: outcomeType === 'NUMERIC'
? getNumericProps(ante, bucketCount, min, max)
: getFreeAnswerProps(ante)
@ -52,7 +61,7 @@ export function getNewContract(
creatorAvatarUrl: creator.avatarUrl,
question: question.trim(),
description: description.trim(),
description,
tags,
lowercaseTags,
visibility: 'public',
@ -111,6 +120,24 @@ const getBinaryCpmmProps = (initialProb: number, ante: number) => {
return system
}
const getPseudoNumericCpmmProps = (
initialProb: number,
ante: number,
min: number,
max: number,
isLogScale: boolean
) => {
const system: CPMM & PseudoNumeric = {
...getBinaryCpmmProps(initialProb, ante),
outcomeType: 'PSEUDO_NUMERIC',
min,
max,
isLogScale,
}
return system
}
const getFreeAnswerProps = (ante: number) => {
const system: DPM & FreeResponse = {
mechanism: 'dpm-2',

View File

@ -22,6 +22,8 @@ export type Notification = {
sourceSlug?: string
sourceTitle?: string
isSeenOnHref?: string
}
export type notification_source_types =
| 'contract'
@ -33,6 +35,8 @@ export type notification_source_types =
| 'tip'
| 'admin_message'
| 'group'
| 'user'
| 'bonus'
export type notification_source_update_types =
| 'created'
@ -53,3 +57,9 @@ export type notification_reason_types =
| 'on_new_follow'
| 'you_follow_user'
| 'added_you_to_group'
| 'you_referred_user'
| 'user_joined_to_bet_on_your_market'
| 'unique_bettors_on_your_contract'
| 'on_group_you_are_member_of'
| 'tip_received'
| 'bet_fill'

View File

@ -3,3 +3,4 @@ export const NUMERIC_FIXED_VAR = 0.005
export const NUMERIC_GRAPH_COLOR = '#5fa5f9'
export const NUMERIC_TEXT_COLOR = 'text-blue-500'
export const UNIQUE_BETTOR_BONUS_AMOUNT = 5

View File

@ -3,10 +3,14 @@
"version": "1.0.0",
"private": true,
"scripts": {
"verify": "(cd .. && yarn verify)"
"verify": "(cd .. && yarn verify)",
"verify:dir": "npx eslint . --max-warnings 0"
},
"sideEffects": false,
"dependencies": {
"@tiptap/extension-image": "2.0.0-beta.30",
"@tiptap/extension-link": "2.0.0-beta.43",
"@tiptap/starter-kit": "2.0.0-beta.190",
"lodash": "4.17.21"
},
"devDependencies": {

View File

@ -72,7 +72,7 @@ export const getLiquidityPoolPayouts = (
const { pool } = contract
const finalPool = pool[outcome]
const weights = getCpmmLiquidityPoolWeights(contract, liquidities)
const weights = getCpmmLiquidityPoolWeights(contract, liquidities, false)
return Object.entries(weights).map(([providerId, weight]) => ({
userId: providerId,
@ -123,7 +123,7 @@ export const getLiquidityPoolProbPayouts = (
const { pool } = contract
const finalPool = p * pool.YES + (1 - p) * pool.NO
const weights = getCpmmLiquidityPoolWeights(contract, liquidities)
const weights = getCpmmLiquidityPoolWeights(contract, liquidities, false)
return Object.entries(weights).map(([providerId, weight]) => ({
userId: providerId,

View File

@ -1,7 +1,12 @@
import { sumBy, groupBy, mapValues } from 'lodash'
import { Bet, NumericBet } from './bet'
import { Contract, CPMMBinaryContract, DPMContract } from './contract'
import {
Contract,
CPMMBinaryContract,
DPMContract,
PseudoNumericContract,
} from './contract'
import { Fees } from './fees'
import { LiquidityProvision } from './liquidity-provision'
import {
@ -48,15 +53,19 @@ export type PayoutInfo = {
export const getPayouts = (
outcome: string | undefined,
resolutions: {
[outcome: string]: number
},
contract: Contract,
bets: Bet[],
liquidities: LiquidityProvision[],
resolutions?: {
[outcome: string]: number
},
resolutionProbability?: number
): PayoutInfo => {
if (contract.mechanism === 'cpmm-1' && contract.outcomeType === 'BINARY') {
if (
contract.mechanism === 'cpmm-1' &&
(contract.outcomeType === 'BINARY' ||
contract.outcomeType === 'PSEUDO_NUMERIC')
) {
return getFixedPayouts(
outcome,
contract,
@ -67,16 +76,16 @@ export const getPayouts = (
}
return getDpmPayouts(
outcome,
resolutions,
contract,
bets,
resolutions,
resolutionProbability
)
}
export const getFixedPayouts = (
outcome: string | undefined,
contract: CPMMBinaryContract,
contract: CPMMBinaryContract | PseudoNumericContract,
bets: Bet[],
liquidities: LiquidityProvision[],
resolutionProbability?: number
@ -100,11 +109,11 @@ export const getFixedPayouts = (
export const getDpmPayouts = (
outcome: string | undefined,
resolutions: {
[outcome: string]: number
},
contract: DPMContract,
bets: Bet[],
resolutions?: {
[outcome: string]: number
},
resolutionProbability?: number
): PayoutInfo => {
const openBets = bets.filter((b) => !b.isSold && !b.sale)
@ -115,8 +124,8 @@ export const getDpmPayouts = (
return getDpmStandardPayouts(outcome, contract, openBets)
case 'MKT':
return contract.outcomeType === 'FREE_RESPONSE'
? getPayoutsMultiOutcome(resolutions, contract, openBets)
return contract.outcomeType === 'FREE_RESPONSE' // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
? getPayoutsMultiOutcome(resolutions!, contract, openBets)
: getDpmMktPayouts(contract, openBets, resolutionProbability)
case 'CANCEL':
case undefined:

45
common/pseudo-numeric.ts Normal file
View File

@ -0,0 +1,45 @@
import { BinaryContract, PseudoNumericContract } from './contract'
import { formatLargeNumber, formatPercent } from './util/format'
export function formatNumericProbability(
p: number,
contract: PseudoNumericContract
) {
const value = getMappedValue(contract)(p)
return formatLargeNumber(value)
}
export const getMappedValue =
(contract: PseudoNumericContract | BinaryContract) => (p: number) => {
if (contract.outcomeType === 'BINARY') return p
const { min, max, isLogScale } = contract
if (isLogScale) {
const logValue = p * Math.log10(max - min)
return 10 ** logValue + min
}
return p * (max - min) + min
}
export const getFormattedMappedValue =
(contract: PseudoNumericContract | BinaryContract) => (p: number) => {
if (contract.outcomeType === 'BINARY') return formatPercent(p)
const value = getMappedValue(contract)(p)
return formatLargeNumber(value)
}
export const getPseudoProbability = (
value: number,
min: number,
max: number,
isLogScale = false
) => {
if (isLogScale) {
return Math.log10(value - min) / Math.log10(max - min)
}
return (value - min) / (max - min)
}

54
common/redeem.ts Normal file
View File

@ -0,0 +1,54 @@
import { partition, sumBy } from 'lodash'
import { Bet } from './bet'
import { getProbability } from './calculate'
import { CPMMContract } from './contract'
import { noFees } from './fees'
import { CandidateBet } from './new-bet'
type RedeemableBet = Pick<Bet, 'outcome' | 'shares' | 'loanAmount'>
export const getRedeemableAmount = (bets: RedeemableBet[]) => {
const [yesBets, noBets] = partition(bets, (b) => b.outcome === 'YES')
const yesShares = sumBy(yesBets, (b) => b.shares)
const noShares = sumBy(noBets, (b) => b.shares)
const shares = Math.max(Math.min(yesShares, noShares), 0)
const loanAmount = sumBy(bets, (bet) => bet.loanAmount ?? 0)
const loanPayment = Math.min(loanAmount, shares)
const netAmount = shares - loanPayment
return { shares, loanPayment, netAmount }
}
export const getRedemptionBets = (
shares: number,
loanPayment: number,
contract: CPMMContract
) => {
const p = getProbability(contract)
const createdTime = Date.now()
const yesBet: CandidateBet = {
contractId: contract.id,
amount: p * -shares,
shares: -shares,
loanAmount: loanPayment ? -loanPayment / 2 : 0,
outcome: 'YES',
probBefore: p,
probAfter: p,
createdTime,
isRedemption: true,
fees: noFees,
}
const noBet: CandidateBet = {
contractId: contract.id,
amount: (1 - p) * -shares,
shares: -shares,
loanAmount: loanPayment ? -loanPayment / 2 : 0,
outcome: 'NO',
probBefore: p,
probAfter: p,
createdTime,
isRedemption: true,
fees: noFees,
}
return [yesBet, noBet]
}

View File

@ -42,10 +42,10 @@ export function scoreUsersByContract(contract: Contract, bets: Bet[]) {
)
const { payouts: resolvePayouts } = getPayouts(
resolution as string,
{},
contract,
openBets,
[],
{},
resolutionProb
)

View File

@ -1,4 +1,4 @@
import { Bet } from './bet'
import { Bet, LimitBet } from './bet'
import {
calculateDpmShareValue,
deductDpmFees,
@ -7,6 +7,7 @@ import {
import { calculateCpmmSale, getCpmmProbability } from './calculate-cpmm'
import { CPMMContract, DPMContract } from './contract'
import { DPM_CREATOR_FEE, DPM_PLATFORM_FEE, Fees } from './fees'
import { sumBy } from 'lodash'
export type CandidateBet<T extends Bet> = Omit<T, 'id' | 'userId'>
@ -78,19 +79,24 @@ export const getCpmmSellBetInfo = (
shares: number,
outcome: 'YES' | 'NO',
contract: CPMMContract,
prevLoanAmount: number
prevLoanAmount: number,
unfilledBets: LimitBet[]
) => {
const { pool, p } = contract
const { saleValue, newPool, newP, fees } = calculateCpmmSale(
const { saleValue, cpmmState, fees, makers, takers } = calculateCpmmSale(
contract,
shares,
outcome
outcome,
unfilledBets
)
const loanPaid = Math.min(prevLoanAmount, saleValue)
const probBefore = getCpmmProbability(pool, p)
const probAfter = getCpmmProbability(newPool, p)
const probAfter = getCpmmProbability(cpmmState.pool, cpmmState.p)
const takerAmount = sumBy(takers, 'amount')
const takerShares = sumBy(takers, 'shares')
console.log(
'SELL M$',
@ -104,20 +110,26 @@ export const getCpmmSellBetInfo = (
const newBet: CandidateBet<Bet> = {
contractId: contract.id,
amount: -saleValue,
shares: -shares,
amount: takerAmount,
shares: takerShares,
outcome,
probBefore,
probAfter,
createdTime: Date.now(),
loanAmount: -loanPaid,
fees,
fills: takers,
isFilled: true,
isCancelled: false,
orderAmount: takerAmount,
}
return {
newBet,
newPool,
newP,
newPool: cpmmState.pool,
newP: cpmmState.p,
fees,
makers,
takers,
}
}

23
common/stats.ts Normal file
View File

@ -0,0 +1,23 @@
export type Stats = {
startDate: number
dailyActiveUsers: number[]
weeklyActiveUsers: number[]
monthlyActiveUsers: number[]
dailyBetCounts: number[]
dailyContractCounts: number[]
dailyCommentCounts: number[]
dailySignups: number[]
weekOnWeekRetention: number[]
monthlyRetention: number[]
weeklyActivationRate: number[]
topTenthActions: {
daily: number[]
weekly: number[]
monthly: number[]
}
manaBet: {
daily: number[]
weekly: number[]
monthly: number[]
}
}

View File

@ -1,6 +1,8 @@
{
"compilerOptions": {
"baseUrl": "../",
"composite": true,
"module": "commonjs",
"moduleResolution": "node",
"noImplicitReturns": true,
"outDir": "lib",

View File

@ -1,6 +1,6 @@
// A txn (pronounced "texan") respresents a payment between two ids on Manifold
// Shortened from "transaction" to distinguish from Firebase transactions (and save chars)
type AnyTxnType = Donation | Tip
type AnyTxnType = Donation | Tip | Manalink | Referral | Bonus
type SourceType = 'USER' | 'CONTRACT' | 'CHARITY' | 'BANK'
export type Txn<T extends AnyTxnType = AnyTxnType> = {
@ -16,6 +16,8 @@ export type Txn<T extends AnyTxnType = AnyTxnType> = {
amount: number
token: 'M$' // | 'USD' | MarketOutcome
category: 'CHARITY' | 'MANALINK' | 'TIP' | 'REFERRAL' | 'UNIQUE_BETTOR_BONUS'
// Any extra data
data?: { [key: string]: any }
@ -34,10 +36,31 @@ type Tip = {
toType: 'USER'
category: 'TIP'
data: {
contractId: string
commentId: string
contractId?: string
groupId?: string
}
}
type Manalink = {
fromType: 'USER'
toType: 'USER'
category: 'MANALINK'
}
type Referral = {
fromType: 'BANK'
toType: 'USER'
category: 'REFERRAL'
}
type Bonus = {
fromType: 'BANK'
toType: 'USER'
category: 'UNIQUE_BETTOR_BONUS'
}
export type DonationTxn = Txn & Donation
export type TipTxn = Txn & Tip
export type ManalinkTxn = Txn & Manalink
export type ReferralTxn = Txn & Referral

View File

@ -1,3 +1,5 @@
import { ENV_CONFIG } from './envs/constants'
export type User = {
id: string
createdTime: number
@ -33,11 +35,15 @@ export type User = {
followerCountCached: number
followedCategories?: string[]
referredByUserId?: string
referredByContractId?: string
}
export const STARTING_BALANCE = 1000
export const SUS_STARTING_BALANCE = 10 // for sus users, i.e. multiple sign ups for same person
export const STARTING_BALANCE = ENV_CONFIG.startingBalance ?? 1000
// for sus users, i.e. multiple sign ups for same person
export const SUS_STARTING_BALANCE = ENV_CONFIG.startingBalance ?? 10
export const REFERRAL_AMOUNT = 500
export type PrivateUser = {
id: string // same as User.id
username: string // denormalized from User
@ -51,6 +57,7 @@ export type PrivateUser = {
initialIpAddress?: string
apiKey?: string
notificationPreferences?: notification_subscribe_types
lastTimeCheckedBonuses?: number
}
export type notification_subscribe_types = 'all' | 'less' | 'none'

22
common/util/algos.ts Normal file
View File

@ -0,0 +1,22 @@
export function binarySearch(
min: number,
max: number,
comparator: (x: number) => number
) {
let mid = 0
while (true) {
mid = min + (max - min) / 2
// Break once we've reached max precision.
if (mid === min || mid === max) break
const comparison = comparator(mid)
if (comparison === 0) break
else if (comparison > 0) {
max = mid
} else {
min = mid
}
}
return mid
}

View File

@ -34,3 +34,17 @@ export function median(xs: number[]) {
export function average(xs: number[]) {
return sum(xs) / xs.length
}
const EPSILON = 0.00000001
export function floatingEqual(a: number, b: number, epsilon = EPSILON) {
return Math.abs(a - b) < epsilon
}
export function floatingGreaterEqual(a: number, b: number, epsilon = EPSILON) {
return a + epsilon >= b
}
export function floatingLesserEqual(a: number, b: number, epsilon = EPSILON) {
return a - epsilon <= b
}

View File

@ -1,4 +1,25 @@
import { MAX_TAG_LENGTH } from '../contract'
import { generateText, JSONContent } from '@tiptap/core'
// Tiptap starter extensions
import { Blockquote } from '@tiptap/extension-blockquote'
import { Bold } from '@tiptap/extension-bold'
import { BulletList } from '@tiptap/extension-bullet-list'
import { Code } from '@tiptap/extension-code'
import { CodeBlock } from '@tiptap/extension-code-block'
import { Document } from '@tiptap/extension-document'
import { HardBreak } from '@tiptap/extension-hard-break'
import { Heading } from '@tiptap/extension-heading'
import { History } from '@tiptap/extension-history'
import { HorizontalRule } from '@tiptap/extension-horizontal-rule'
import { Italic } from '@tiptap/extension-italic'
import { ListItem } from '@tiptap/extension-list-item'
import { OrderedList } from '@tiptap/extension-ordered-list'
import { Paragraph } from '@tiptap/extension-paragraph'
import { Strike } from '@tiptap/extension-strike'
import { Text } from '@tiptap/extension-text'
// other tiptap extensions
import { Image } from '@tiptap/extension-image'
import { Link } from '@tiptap/extension-link'
export function parseTags(text: string) {
const regex = /(?:^|\s)(?:[#][a-z0-9_]+)/gi
@ -27,3 +48,31 @@ export function parseWordsAsTags(text: string) {
.join(' ')
return parseTags(taggedText)
}
// can't just do [StarterKit, Image...] because it doesn't work with cjs imports
export const exhibitExts = [
Blockquote,
Bold,
BulletList,
Code,
CodeBlock,
Document,
HardBreak,
Heading,
History,
HorizontalRule,
Italic,
ListItem,
OrderedList,
Paragraph,
Strike,
Text,
Image,
Link,
]
// export const exhibitExts = [StarterKit as unknown as Extension, Image]
export function richTextToString(text?: JSONContent) {
return !text ? '' : generateText(text, exhibitExts)
}

View File

@ -115,10 +115,10 @@ Requires no authorization.
outcomeType: string // BINARY, FREE_RESPONSE, or NUMERIC
mechanism: string // dpm-2 or cpmm-1
pool: number // sum of YES and NO shares in liquidity pool for CPMM, null for DPM
probability: number
p?: number // probability constant in y^p * n^(1-p) = k
totalLiquidity?: number
pool: { outcome: number } // For CPMM markets, the number of shares in the liquidity pool. For DPM markets, the amount of mana invested in each answer.
p?: number // CPMM markets only, probability constant in y^p * n^(1-p) = k
totalLiquidity?: number // CPMM markets only, the amount of mana deposited into the liquidity pool
volume: number
volume7Days: number
@ -147,202 +147,204 @@ Requires no authorization.
```json
{
"id":"lEoqtnDgJzft6apSKzYK",
"creatorUsername":"Angela",
"creatorName":"Angela",
"createdTime":1655258914863,
"creatorAvatarUrl":"https://firebasestorage.googleapis.com/v0/b/mantic-markets.appspot.com/o/user-images%2FAngela%2F50463444807_edfd4598d6_o.jpeg?alt=media&token=ef44e13b-2e6c-4498-b9c4-8e38bdaf1476",
"closeTime":1655265001448,
"question":"What is good?",
"description":"Resolves proportionally to the answer(s) which I find most compelling. (Obviously Ill refrain from giving my own answers)\n\n(Please have at it with philosophy, ethics, etc etc)\n\n\nContract resolved automatically.",
"tags":[],
"url":"https://manifold.markets/Angela/what-is-good",
"pool":null,
"outcomeType":"FREE_RESPONSE",
"mechanism":"dpm-2",
"volume":112,
"volume7Days":212,
"volume24Hours":0,
"isResolved":true,
"resolution":"MKT",
"resolutionTime":1655265001448,
"answers":[
"id": "lEoqtnDgJzft6apSKzYK",
"creatorUsername": "Angela",
"creatorName": "Angela",
"createdTime": 1655258914863,
"creatorAvatarUrl": "https://firebasestorage.googleapis.com/v0/b/mantic-markets.appspot.com/o/user-images%2FAngela%2F50463444807_edfd4598d6_o.jpeg?alt=media&token=ef44e13b-2e6c-4498-b9c4-8e38bdaf1476",
"closeTime": 1655265001448,
"question": "What is good?",
"description": "Resolves proportionally to the answer(s) which I find most compelling. (Obviously Ill refrain from giving my own answers)\n\n(Please have at it with philosophy, ethics, etc etc)\n\n\nContract resolved automatically.",
"tags": [],
"url": "https://manifold.markets/Angela/what-is-good",
"pool": null,
"outcomeType": "FREE_RESPONSE",
"mechanism": "dpm-2",
"volume": 112,
"volume7Days": 212,
"volume24Hours": 0,
"isResolved": true,
"resolution": "MKT",
"resolutionTime": 1655265001448,
"answers": [
{
"createdTime":1655258941573,
"avatarUrl":"https://firebasestorage.googleapis.com/v0/b/mantic-markets.appspot.com/o/user-images%2FAngela%2F50463444807_edfd4598d6_o.jpeg?alt=media&token=ef44e13b-2e6c-4498-b9c4-8e38bdaf1476",
"id":"1",
"username":"Angela",
"number":1,
"name":"Angela",
"contractId":"lEoqtnDgJzft6apSKzYK",
"text":"ANTE",
"userId":"qe2QqIlOkeWsbljfeF3MsxpSJ9i2",
"probability":0.66749733001068
"createdTime": 1655258941573,
"avatarUrl": "https://firebasestorage.googleapis.com/v0/b/mantic-markets.appspot.com/o/user-images%2FAngela%2F50463444807_edfd4598d6_o.jpeg?alt=media&token=ef44e13b-2e6c-4498-b9c4-8e38bdaf1476",
"id": "1",
"username": "Angela",
"number": 1,
"name": "Angela",
"contractId": "lEoqtnDgJzft6apSKzYK",
"text": "ANTE",
"userId": "qe2QqIlOkeWsbljfeF3MsxpSJ9i2",
"probability": 0.66749733001068
},
{
"name":"Isaac King",
"username":"IsaacKing",
"text":"This answer",
"userId":"y1hb6k7txdZPV5mgyxPFApZ7nQl2",
"id":"2",
"number":2,
"avatarUrl":"https://lh3.googleusercontent.com/a-/AOh14GhNVriOvxK2VUAmE-jvYZwC-XIymatzVirT0Bqb2g=s96-c",
"contractId":"lEoqtnDgJzft6apSKzYK",
"createdTime":1655261198074,
"probability":0.008922214311142757
"name": "Isaac King",
"username": "IsaacKing",
"text": "This answer",
"userId": "y1hb6k7txdZPV5mgyxPFApZ7nQl2",
"id": "2",
"number": 2,
"avatarUrl": "https://lh3.googleusercontent.com/a-/AOh14GhNVriOvxK2VUAmE-jvYZwC-XIymatzVirT0Bqb2g=s96-c",
"contractId": "lEoqtnDgJzft6apSKzYK",
"createdTime": 1655261198074,
"probability": 0.008922214311142757
},
{
"createdTime":1655263226587,
"userId":"jbgplxty4kUKIa1MmgZk22byJq03",
"id":"3",
"avatarUrl":"https://firebasestorage.googleapis.com/v0/b/mantic-markets.appspot.com/o/user-images%2FMartin%2Fgiphy.gif?alt=media&token=422ef610-553f-47e3-bf6f-c0c5cc16c70a",
"text":"Toyota Camry",
"contractId":"lEoqtnDgJzft6apSKzYK",
"name":"Undox",
"username":"Undox",
"number":3,
"probability":0.008966714133143469
"createdTime": 1655263226587,
"userId": "jbgplxty4kUKIa1MmgZk22byJq03",
"id": "3",
"avatarUrl": "https://firebasestorage.googleapis.com/v0/b/mantic-markets.appspot.com/o/user-images%2FMartin%2Fgiphy.gif?alt=media&token=422ef610-553f-47e3-bf6f-c0c5cc16c70a",
"text": "Toyota Camry",
"contractId": "lEoqtnDgJzft6apSKzYK",
"name": "Undox",
"username": "Undox",
"number": 3,
"probability": 0.008966714133143469
},
{
"number":4,
"name":"James Grugett",
"userId":"5LZ4LgYuySdL1huCWe7bti02ghx2",
"text":"Utility (Defined by your personal utility function.)",
"createdTime":1655264793224,
"contractId":"lEoqtnDgJzft6apSKzYK",
"username":"JamesGrugett",
"id":"4",
"avatarUrl":"https://lh3.googleusercontent.com/a-/AOh14GjC83uMe-fEfzd6QvxiK6ZqZdlMytuHxevgMYIkpAI=s96-c",
"probability":0.09211463154147384
"number": 4,
"name": "James Grugett",
"userId": "5LZ4LgYuySdL1huCWe7bti02ghx2",
"text": "Utility (Defined by your personal utility function.)",
"createdTime": 1655264793224,
"contractId": "lEoqtnDgJzft6apSKzYK",
"username": "JamesGrugett",
"id": "4",
"avatarUrl": "https://lh3.googleusercontent.com/a-/AOh14GjC83uMe-fEfzd6QvxiK6ZqZdlMytuHxevgMYIkpAI=s96-c",
"probability": 0.09211463154147384
}
],
"comments":[
"comments": [
{
"id":"ZdHIyfQazHyl8nI0ENS7",
"userId":"qe2QqIlOkeWsbljfeF3MsxpSJ9i2",
"createdTime":1655265807433,
"text":"ok what\ni did not resolve this intentionally",
"contractId":"lEoqtnDgJzft6apSKzYK",
"userName":"Angela",
"userAvatarUrl":"https://firebasestorage.googleapis.com/v0/b/mantic-markets.appspot.com/o/user-images%2FAngela%2F50463444807_edfd4598d6_o.jpeg?alt=media&token=ef44e13b-2e6c-4498-b9c4-8e38bdaf1476",
"userUsername":"Angela"
"id": "ZdHIyfQazHyl8nI0ENS7",
"userId": "qe2QqIlOkeWsbljfeF3MsxpSJ9i2",
"createdTime": 1655265807433,
"text": "ok what\ni did not resolve this intentionally",
"contractId": "lEoqtnDgJzft6apSKzYK",
"userName": "Angela",
"userAvatarUrl": "https://firebasestorage.googleapis.com/v0/b/mantic-markets.appspot.com/o/user-images%2FAngela%2F50463444807_edfd4598d6_o.jpeg?alt=media&token=ef44e13b-2e6c-4498-b9c4-8e38bdaf1476",
"userUsername": "Angela"
},
{
"userName":"James Grugett",
"userUsername":"JamesGrugett",
"id":"F7fvHGhTiFal8uTsUc9P",
"userAvatarUrl":"https://lh3.googleusercontent.com/a-/AOh14GjC83uMe-fEfzd6QvxiK6ZqZdlMytuHxevgMYIkpAI=s96-c","replyToCommentId":"ZdHIyfQazHyl8nI0ENS7",
"text":"@Angela Sorry! There was an error that automatically resolved several markets that were created in the last few hours.",
"createdTime":1655266286514,
"userId":"5LZ4LgYuySdL1huCWe7bti02ghx2",
"contractId":"lEoqtnDgJzft6apSKzYK"
"userName": "James Grugett",
"userUsername": "JamesGrugett",
"id": "F7fvHGhTiFal8uTsUc9P",
"userAvatarUrl": "https://lh3.googleusercontent.com/a-/AOh14GjC83uMe-fEfzd6QvxiK6ZqZdlMytuHxevgMYIkpAI=s96-c",
"replyToCommentId": "ZdHIyfQazHyl8nI0ENS7",
"text": "@Angela Sorry! There was an error that automatically resolved several markets that were created in the last few hours.",
"createdTime": 1655266286514,
"userId": "5LZ4LgYuySdL1huCWe7bti02ghx2",
"contractId": "lEoqtnDgJzft6apSKzYK"
},
{
"userId":"qe2QqIlOkeWsbljfeF3MsxpSJ9i2",
"contractId":"lEoqtnDgJzft6apSKzYK",
"id":"PIHhXy5hLHSgW8uoUD0Q",
"userName":"Angela",
"text":"lmk if anyone lost manna from this situation and i'll try to fix it",
"userUsername":"Angela",
"createdTime":1655277581308,
"userAvatarUrl":"https://firebasestorage.googleapis.com/v0/b/mantic-markets.appspot.com/o/user-images%2FAngela%2F50463444807_edfd4598d6_o.jpeg?alt=media&token=ef44e13b-2e6c-4498-b9c4-8e38bdaf1476"
},{
"userAvatarUrl":"https://firebasestorage.googleapis.com/v0/b/mantic-markets.appspot.com/o/user-images%2FAngela%2F50463444807_edfd4598d6_o.jpeg?alt=media&token=ef44e13b-2e6c-4498-b9c4-8e38bdaf1476",
"userName":"Angela",
"text":"from my end it looks like no one did",
"replyToCommentId":"PIHhXy5hLHSgW8uoUD0Q",
"createdTime":1655287149528,
"userUsername":"Angela",
"id":"5slnWEQWwm6dHjDi6oiH",
"contractId":"lEoqtnDgJzft6apSKzYK",
"userId":"qe2QqIlOkeWsbljfeF3MsxpSJ9i2"
"userId": "qe2QqIlOkeWsbljfeF3MsxpSJ9i2",
"contractId": "lEoqtnDgJzft6apSKzYK",
"id": "PIHhXy5hLHSgW8uoUD0Q",
"userName": "Angela",
"text": "lmk if anyone lost manna from this situation and i'll try to fix it",
"userUsername": "Angela",
"createdTime": 1655277581308,
"userAvatarUrl": "https://firebasestorage.googleapis.com/v0/b/mantic-markets.appspot.com/o/user-images%2FAngela%2F50463444807_edfd4598d6_o.jpeg?alt=media&token=ef44e13b-2e6c-4498-b9c4-8e38bdaf1476"
},
{
"userAvatarUrl": "https://firebasestorage.googleapis.com/v0/b/mantic-markets.appspot.com/o/user-images%2FAngela%2F50463444807_edfd4598d6_o.jpeg?alt=media&token=ef44e13b-2e6c-4498-b9c4-8e38bdaf1476",
"userName": "Angela",
"text": "from my end it looks like no one did",
"replyToCommentId": "PIHhXy5hLHSgW8uoUD0Q",
"createdTime": 1655287149528,
"userUsername": "Angela",
"id": "5slnWEQWwm6dHjDi6oiH",
"contractId": "lEoqtnDgJzft6apSKzYK",
"userId": "qe2QqIlOkeWsbljfeF3MsxpSJ9i2"
}
],
"bets":[
"bets": [
{
"outcome":"0",
"contractId":"lEoqtnDgJzft6apSKzYK",
"fees":{
"liquidityFee":0,
"creatorFee":0,
"platformFee":0
"outcome": "0",
"contractId": "lEoqtnDgJzft6apSKzYK",
"fees": {
"liquidityFee": 0,
"creatorFee": 0,
"platformFee": 0
},
"isAnte":true,
"shares":100,
"probAfter":1,
"amount":100,
"userId":"IPTOzEqrpkWmEzh6hwvAyY9PqFb2",
"createdTime":1655258914863,
"probBefore":0,
"id":"2jNZqnwoEQL7WDTTAWDP"
"isAnte": true,
"shares": 100,
"probAfter": 1,
"amount": 100,
"userId": "IPTOzEqrpkWmEzh6hwvAyY9PqFb2",
"createdTime": 1655258914863,
"probBefore": 0,
"id": "2jNZqnwoEQL7WDTTAWDP"
},
{
"shares":173.20508075688772,
"fees":{
"platformFee":0,
"liquidityFee":0,
"creatorFee":0
"shares": 173.20508075688772,
"fees": {
"platformFee": 0,
"liquidityFee": 0,
"creatorFee": 0
},
"contractId":"lEoqtnDgJzft6apSKzYK",
"probBefore":0,
"createdTime":1655258941573,
"loanAmount":0,
"userId":"qe2QqIlOkeWsbljfeF3MsxpSJ9i2",
"amount":100,
"outcome":"1",
"probAfter":0.75,
"id":"xuc3JoiNkE8lXPh15mUb"
"contractId": "lEoqtnDgJzft6apSKzYK",
"probBefore": 0,
"createdTime": 1655258941573,
"loanAmount": 0,
"userId": "qe2QqIlOkeWsbljfeF3MsxpSJ9i2",
"amount": 100,
"outcome": "1",
"probAfter": 0.75,
"id": "xuc3JoiNkE8lXPh15mUb"
},
{
"userId":"y1hb6k7txdZPV5mgyxPFApZ7nQl2",
"contractId":"lEoqtnDgJzft6apSKzYK",
"loanAmount":0,
"probAfter":0.009925496893641248,
"id":"8TBlzPtOdO0q5BgSyRbi",
"createdTime":1655261198074,
"shares":20.024984394500787,
"amount":1,
"outcome":"2",
"probBefore":0,
"fees":{
"liquidityFee":0,
"creatorFee":0,
"platformFee":0
"userId": "y1hb6k7txdZPV5mgyxPFApZ7nQl2",
"contractId": "lEoqtnDgJzft6apSKzYK",
"loanAmount": 0,
"probAfter": 0.009925496893641248,
"id": "8TBlzPtOdO0q5BgSyRbi",
"createdTime": 1655261198074,
"shares": 20.024984394500787,
"amount": 1,
"outcome": "2",
"probBefore": 0,
"fees": {
"liquidityFee": 0,
"creatorFee": 0,
"platformFee": 0
}
},
{
"probAfter":0.00987648269777473,
"outcome":"3",
"id":"9vdwes6s9QxbYZUBhHs4",
"createdTime":1655263226587,
"shares":20.074859899884732,
"amount":1,
"loanAmount":0,
"fees":{
"liquidityFee":0,
"platformFee":0,
"creatorFee":0
"probAfter": 0.00987648269777473,
"outcome": "3",
"id": "9vdwes6s9QxbYZUBhHs4",
"createdTime": 1655263226587,
"shares": 20.074859899884732,
"amount": 1,
"loanAmount": 0,
"fees": {
"liquidityFee": 0,
"platformFee": 0,
"creatorFee": 0
},
"userId":"jbgplxty4kUKIa1MmgZk22byJq03",
"contractId":"lEoqtnDgJzft6apSKzYK",
"probBefore":0
"userId": "jbgplxty4kUKIa1MmgZk22byJq03",
"contractId": "lEoqtnDgJzft6apSKzYK",
"probBefore": 0
},
{
"createdTime":1655264793224,
"fees":{
"creatorFee":0,
"liquidityFee":0,
"platformFee":0
"createdTime": 1655264793224,
"fees": {
"creatorFee": 0,
"liquidityFee": 0,
"platformFee": 0
},
"probAfter":0.09211463154147384,
"amount":10,
"id":"BehiSGgk1wAkIWz1a8L4",
"userId":"5LZ4LgYuySdL1huCWe7bti02ghx2",
"contractId":"lEoqtnDgJzft6apSKzYK",
"loanAmount":0,
"probBefore":0,
"outcome":"4",
"shares":64.34283176858165
"probAfter": 0.09211463154147384,
"amount": 10,
"id": "BehiSGgk1wAkIWz1a8L4",
"userId": "5LZ4LgYuySdL1huCWe7bti02ghx2",
"contractId": "lEoqtnDgJzft6apSKzYK",
"loanAmount": 0,
"probBefore": 0,
"outcome": "4",
"shares": 64.34283176858165
}
]
}
@ -396,6 +398,64 @@ Requires no authorization.
```
- Response type: A `FullMarket` ; same as above.
### `GET /v0/users`
Lists all users.
Requires no authorization.
- Example request
```
https://manifold.markets/api/v0/users
```
- Example response
```json
[
{
"id":"igi2zGXsfxYPgB0DJTXVJVmwCOr2",
"createdTime":1639011767273,
"name":"Austin",
"username":"Austin",
"url":"https://manifold.markets/Austin",
"avatarUrl":"https://lh3.googleusercontent.com/a-/AOh14GiZyl1lBehuBMGyJYJhZd-N-mstaUtgE4xdI22lLw=s96-c",
"bio":"I build Manifold! Always happy to chat; reach out on Discord or find a time on https://calendly.com/austinchen/manifold!",
"bannerUrl":"https://images.unsplash.com/photo-1501523460185-2aa5d2a0f981?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1531&q=80",
"website":"https://blog.austn.io",
"twitterHandle":"akrolsmir",
"discordHandle":"akrolsmir#4125",
"balance":9122.607163564959,
"totalDeposits":10339.004780544328,
"totalPnLCached":9376.601262721899,
"creatorVolumeCached":76078.46984199001
}
```
- Response type: Array of `LiteUser`
```tsx
// Basic information about a user
type LiteUser = {
id: string // user's unique id
createdTime: number
name: string // display name, may contain spaces
username: string // username, used in urls
url: string // link to user's profile
avatarUrl?: string
bio?: string
bannerUrl?: string
website?: string
twitterHandle?: string
discordHandle?: string
// Note: the following are here for convenience only and may be removed in the future.
balance: number
totalDeposits: number
totalPnLCached: number
creatorVolumeCached: number
}
```
### `POST /v0/bet`
Places a new bet on behalf of the authorized user.
@ -453,6 +513,118 @@ $ curl https://manifold.markets/api/v0/market -X POST -H 'Content-Type: applicat
"initialProb":25}'
```
### `POST /v0/market/[marketId]/resolve`
Resolves a market on behalf of the authorized user.
Parameters:
For binary markets:
- `outcome`: Required. One of `YES`, `NO`, `MKT`, or `CANCEL`.
- `probabilityInt`: Optional. The probability to use for `MKT` resolution.
For free response markets:
- `outcome`: Required. One of `MKT`, `CANCEL`, or a `number` indicating the answer index.
- `resolutions`: An array of `{ answer, pct }` objects to use as the weights for resolving in favor of multiple free response options. Can only be set with `MKT` outcome.
For numeric markets:
- `outcome`: Required. One of `CANCEL`, or a `number` indicating the selected numeric bucket ID.
- `value`: The value that the market may resolves to.
Example request:
```
# Resolve a binary market
$ curl https://manifold.markets/api/v0/market/{marketId}/resolve -X POST \
-H 'Content-Type: application/json' \
-H 'Authorization: Key {...}' \
--data-raw '{"outcome": "YES"}'
# Resolve a binary market with a specified probability
$ curl https://manifold.markets/api/v0/market/{marketId}/resolve -X POST \
-H 'Content-Type: application/json' \
-H 'Authorization: Key {...}' \
--data-raw '{"outcome": "MKT", \
"probabilityInt": 75}'
# Resolve a free response market with a single answer chosen
$ curl https://manifold.markets/api/v0/market/{marketId}/resolve -X POST \
-H 'Content-Type: application/json' \
-H 'Authorization: Key {...}' \
--data-raw '{"outcome": 2}'
# Resolve a free response market with multiple answers chosen
$ curl https://manifold.markets/api/v0/market/{marketId}/resolve -X POST \
-H 'Content-Type: application/json' \
-H 'Authorization: Key {...}' \
--data-raw '{"outcome": "MKT", \
"resolutions": [ \
{"answer": 0, "pct": 50}, \
{"answer": 2, "pct": 50} \
]}'
```
### `GET /v0/bets`
Gets a list of bets, ordered by creation date descending.
Parameters:
- `username`: Optional. If set, the response will include only bets created by this user.
- `market`: Optional. The slug of a market. If set, the response will only include bets on this market.
- `limit`: Optional. How many bets to return. The maximum and the default is 1000.
- `before`: Optional. The ID of the bet before which the list will start. For
example, if you ask for the most recent 10 bets, and then perform a second
query for 10 more bets with `before=[the id of the 10th bet]`, you will
get bets 11 through 20.
Requires no authorization.
- Example request
```
https://manifold.markets/api/v0/bets?username=ManifoldMarkets&market=will-california-abolish-daylight-sa
```
- Response type: A `Bet[]`.
- <details><summary>Example response</summary><p>
```json
[
{
"probAfter": 0.44418877319153904,
"shares": -645.8346334931828,
"outcome": "YES",
"contractId": "tgB1XmvFXZNhjr3xMNLp",
"sale": {
"betId": "RcOtarI3d1DUUTjiE0rx",
"amount": 474.9999999999998
},
"createdTime": 1644602886293,
"userId": "94YYTk1AFWfbWMpfYcvnnwI1veP2",
"probBefore": 0.7229189477449224,
"id": "x9eNmCaqQeXW8AgJ8Zmp",
"amount": -499.9999999999998
},
{
"probAfter": 0.9901970375647697,
"contractId": "zdeaYVAfHlo9jKzWh57J",
"outcome": "YES",
"amount": 1,
"id": "8PqxKYwXCcLYoXy2m2Nm",
"shares": 1.0049875638533763,
"userId": "94YYTk1AFWfbWMpfYcvnnwI1veP2",
"probBefore": 0.9900000000000001,
"createdTime": 1644705818872
}
]
```
</p>
</details>
## Changelog
- 2022-06-08: Add paging to markets endpoint

View File

@ -19,7 +19,6 @@ for the pool to be sorted into.
- Users can create a market on any question they want.
- When a user creates a market, they must choose a close date, after which trading will halt.
- They must also pay a M$100 market creation fee, which is used as liquidity to subsidize trading on the market.
- The creation fee for the first market created each day is provided by Manifold.
- The market creator will earn a commission on all bets placed in the market.
- The market creator is responsible for resolving each market in a timely manner. All fees earned as a commission will be paid out after resolution.
- Creators can also resolve N/A to cancel all transactions and reverse all transactions made on the market - this includes profits from selling shares.

View File

@ -26,8 +26,7 @@ const config = {
docs: {
routeBasePath: '/',
sidebarPath: require.resolve('./sidebars.js'),
// Please change this to your repo.
editUrl: 'https://github.com/manifoldmarkets/manifold/tree/main/docs/docs',
editUrl: 'https://github.com/manifoldmarkets/manifold/tree/main/docs',
remarkPlugins: [math],
rehypePlugins: [katex],
},
@ -72,7 +71,7 @@ const config = {
label: 'Docs',
},
{
href: 'https://github.com/manifoldmarkets/docs',
href: 'https://github.com/manifoldmarkets/manifold/tree/main/docs/docs',
label: 'GitHub',
position: 'right',
},
@ -116,7 +115,7 @@ const config = {
},
{
label: 'GitHub',
href: 'https://github.com/manifoldmarkets/docs',
href: 'https://github.com/manifoldmarkets/manifold/',
},
],
},

View File

@ -1,8 +1,8 @@
{
"functions": {
"predeploy": "npm --prefix \"$RESOURCE_DIR\" run build",
"runtime": "nodejs12",
"source": "functions"
"predeploy": "cd functions && yarn build",
"runtime": "nodejs16",
"source": "functions/dist"
},
"firestore": {
"rules": "firestore.rules",

View File

@ -306,6 +306,62 @@
}
]
},
{
"collectionGroup": "manalinks",
"queryScope": "COLLECTION",
"fields": [
{
"fieldPath": "fromId",
"order": "ASCENDING"
},
{
"fieldPath": "createdTime",
"order": "DESCENDING"
}
]
},
{
"collectionGroup": "notifications",
"queryScope": "COLLECTION",
"fields": [
{
"fieldPath": "isSeen",
"order": "ASCENDING"
},
{
"fieldPath": "createdTime",
"order": "DESCENDING"
}
]
},
{
"collectionGroup": "portfolioHistory",
"queryScope": "COLLECTION_GROUP",
"fields": [
{
"fieldPath": "userId",
"order": "ASCENDING"
},
{
"fieldPath": "timestamp",
"order": "ASCENDING"
}
]
},
{
"collectionGroup": "portfolioHistory",
"queryScope": "COLLECTION",
"fields": [
{
"fieldPath": "userId",
"order": "ASCENDING"
},
{
"fieldPath": "timestamp",
"order": "ASCENDING"
}
]
},
{
"collectionGroup": "txns",
"queryScope": "COLLECTION",
@ -396,6 +452,28 @@
}
]
},
{
"collectionGroup": "bets",
"fieldPath": "id",
"indexes": [
{
"order": "ASCENDING",
"queryScope": "COLLECTION"
},
{
"order": "DESCENDING",
"queryScope": "COLLECTION"
},
{
"arrayConfig": "CONTAINS",
"queryScope": "COLLECTION"
},
{
"order": "ASCENDING",
"queryScope": "COLLECTION_GROUP"
}
]
},
{
"collectionGroup": "bets",
"fieldPath": "userId",

View File

@ -12,11 +12,24 @@ service cloud.firestore {
|| request.auth.uid == 'IPTOzEqrpkWmEzh6hwvAyY9PqFb2' // Manifold
}
match /stats/stats {
allow read;
}
match /users/{userId} {
allow read;
allow update: if resource.data.id == request.auth.uid
&& request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories']);
.hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories', 'referredByContractId']);
allow update: if resource.data.id == request.auth.uid
&& request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['referredByUserId'])
// only one referral allowed per user
&& !("referredByUserId" in resource.data)
// user can't refer themselves
&& !(resource.data.id == request.resource.data.referredByUserId);
// quid pro quos enabled (only once though so nbd) - bc I can't make this work:
// && (get(/databases/$(database)/documents/users/$(request.resource.data.referredByUserId)).referredByUserId == resource.data.id);
}
match /{somePath=**}/portfolioHistory/{portfolioHistoryId} {
@ -58,7 +71,7 @@ service cloud.firestore {
match /contracts/{contractId} {
allow read;
allow update: if request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['tags', 'lowercaseTags']);
.hasOnly(['tags', 'lowercaseTags', 'groupSlugs']);
allow update: if request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['description', 'closeTime'])
&& resource.data.creatorId == request.auth.uid;
@ -104,6 +117,16 @@ service cloud.firestore {
allow read;
}
// Note: `resource` = existing doc, `request.resource` = incoming doc
match /manalinks/{slug} {
// Anyone can view any manalink
allow get;
// Only you can create a manalink with your fromId
allow create: if request.auth.uid == request.resource.data.fromId;
// Only you can list and change your own manalinks
allow list, update: if request.auth.uid == resource.data.fromId;
}
match /users/{userId}/notifications/{notificationId} {
allow read;
allow update: if resource.data.userId == request.auth.uid

3
functions/.env Normal file
View File

@ -0,0 +1,3 @@
# This sets which EnvConfig is deployed to Firebase Cloud Functions
NEXT_PUBLIC_FIREBASE_ENV=PROD

View File

@ -1,7 +1,7 @@
module.exports = {
plugins: ['lodash'],
extends: ['eslint:recommended'],
ignorePatterns: ['lib'],
ignorePatterns: ['dist', 'lib'],
env: {
node: true,
},
@ -30,6 +30,7 @@ module.exports = {
},
],
rules: {
'linebreak-style': ['error', 'unix'],
'lodash/import-scope': [2, 'member'],
},
}

View File

@ -1,10 +1,11 @@
# Secrets
.env*
.runtimeconfig.json
# GCP deployment artifact
dist/
# Compiled JavaScript files
lib/**/*.js
lib/**/*.js.map
lib/
# TypeScript v1 declaration files
typings/

1
functions/.yarnrc Normal file
View File

@ -0,0 +1 @@
save-prefix ""

View File

@ -23,8 +23,10 @@ Adapted from https://firebase.google.com/docs/functions/get-started
### For local development
0. [Install](https://cloud.google.com/sdk/docs/install) gcloud CLI
1. If you don't have java (or see the error `Error: Process java -version has exited with code 1. Please make sure Java is installed and on your system PATH.`): 0. `$ brew install java`
1. `$ sudo ln -sfn /opt/homebrew/opt/openjdk/libexec/openjdk.jdk /Library/Java/JavaVirtualMachines/openjdk.jdk`
1. If you don't have java (or see the error `Error: Process java -version has exited with code 1. Please make sure Java is installed and on your system PATH.`):
1. `$ brew install java`
2. `$ sudo ln -sfn /opt/homebrew/opt/openjdk/libexec/openjdk.jdk /Library/Java/JavaVirtualMachines/openjdk.jdk`
2. `$ gcloud auth login` to authenticate the CLI tools to Google Cloud
3. `$ gcloud config set project <project-id>` to choose the project (`$ gcloud projects list` to see options)
4. `$ mkdir firestore_export` to create a folder to store the exported database
@ -52,7 +54,7 @@ Adapted from https://firebase.google.com/docs/functions/get-started
## Deploying
0. `$ firebase use prod` to switch to prod
1. `$ yarn deploy` to push your changes live!
1. `$ firebase deploy --only functions` to push your changes live!
(Future TODO: auto-deploy functions on Git push)
## Secrets management

View File

@ -5,23 +5,29 @@
"firestore": "dev-mantic-markets.appspot.com"
},
"scripts": {
"build": "tsc",
"build": "yarn compile && rm -rf dist && mkdir -p dist/functions && cp -R ../common/lib dist/common && cp -R lib/src dist/functions/src && cp ../yarn.lock dist && cp package.json dist && cp .env dist",
"compile": "tsc -b",
"watch": "tsc -w",
"shell": "yarn build && firebase functions:shell",
"start": "yarn shell",
"deploy": "firebase deploy --only functions",
"logs": "firebase functions:log",
"serve": "yarn build && firebase emulators:start --only functions,firestore --import=./firestore_export",
"serve": "firebase use dev && yarn build && firebase emulators:start --only functions,firestore --import=./firestore_export",
"db:update-local-from-remote": "yarn db:backup-remote && gsutil rsync -r gs://$npm_package_config_firestore/firestore_export ./firestore_export",
"db:backup-local": "firebase emulators:export --force ./firestore_export",
"db:rename-remote-backup-folder": "gsutil mv gs://$npm_package_config_firestore/firestore_export gs://$npm_package_config_firestore/firestore_export_$(date +%d-%m-%Y-%H-%M)",
"db:backup-remote": "yarn db:rename-remote-backup-folder && gcloud firestore export gs://$npm_package_config_firestore/firestore_export/",
"verify": "(cd .. && yarn verify)"
"verify": "(cd .. && yarn verify)",
"verify:dir": "npx eslint . --max-warnings 0; tsc -b -v --pretty"
},
"main": "lib/functions/src/index.js",
"main": "functions/src/index.js",
"dependencies": {
"@amplitude/node": "1.10.0",
"fetch": "1.1.0",
"@google-cloud/functions-framework": "3.1.2",
"@tiptap/core": "2.0.0-beta.181",
"@tiptap/extension-image": "2.0.0-beta.30",
"@tiptap/extension-link": "2.0.0-beta.43",
"@tiptap/starter-kit": "2.0.0-beta.190",
"firebase-admin": "10.0.0",
"firebase-functions": "3.21.2",
"lodash": "4.17.21",

View File

@ -1,54 +1,45 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { z } from 'zod'
import { Contract } from '../../common/contract'
import { User } from '../../common/user'
import { removeUndefinedProps } from '../../common/util/object'
import { redeemShares } from './redeem-shares'
import { getNewLiquidityProvision } from '../../common/add-liquidity'
import { APIError, newEndpoint, validate } from './api'
export const addLiquidity = functions.runWith({ minInstances: 1 }).https.onCall(
async (
data: {
amount: number
contractId: string
},
context
) => {
const userId = context?.auth?.uid
if (!userId) return { status: 'error', message: 'Not authorized' }
const bodySchema = z.object({
contractId: z.string(),
amount: z.number().gt(0),
})
const { amount, contractId } = data
export const addliquidity = newEndpoint({}, async (req, auth) => {
const { amount, contractId } = validate(bodySchema, req.body)
if (amount <= 0 || isNaN(amount) || !isFinite(amount))
return { status: 'error', message: 'Invalid amount' }
if (!isFinite(amount)) throw new APIError(400, 'Invalid amount')
// run as transaction to prevent race conditions
return await firestore
.runTransaction(async (transaction) => {
const userDoc = firestore.doc(`users/${userId}`)
return await firestore.runTransaction(async (transaction) => {
const userDoc = firestore.doc(`users/${auth.uid}`)
const userSnap = await transaction.get(userDoc)
if (!userSnap.exists)
return { status: 'error', message: 'User not found' }
if (!userSnap.exists) throw new APIError(400, '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' }
if (!contractSnap.exists) throw new APIError(400, 'Invalid contract')
const contract = contractSnap.data() as Contract
if (
contract.mechanism !== 'cpmm-1' ||
contract.outcomeType !== 'BINARY'
(contract.outcomeType !== 'BINARY' &&
contract.outcomeType !== 'PSEUDO_NUMERIC')
)
return { status: 'error', message: 'Invalid contract' }
throw new APIError(400, 'Invalid contract')
const { closeTime } = contract
if (closeTime && Date.now() > closeTime)
return { status: 'error', message: 'Trading is closed' }
throw new APIError(400, 'Trading is closed')
if (user.balance < amount)
return { status: 'error', message: 'Insufficient balance' }
if (user.balance < amount) throw new APIError(400, 'Insufficient balance')
const newLiquidityProvisionDoc = firestore
.collection(`contracts/${contractId}/liquidity`)
@ -82,7 +73,7 @@ export const addLiquidity = functions.runWith({ minInstances: 1 }).https.onCall(
const newTotalDeposits = user.totalDeposits - amount
if (!isFinite(newBalance)) {
throw new Error('Invalid user balance for ' + user.username)
throw new APIError(500, 'Invalid user balance for ' + user.username)
}
transaction.update(userDoc, {
@ -92,13 +83,8 @@ export const addLiquidity = functions.runWith({ minInstances: 1 }).https.onCall(
transaction.create(newLiquidityProvisionDoc, newLiquidityProvision)
return { status: 'success', newLiquidityProvision }
return newLiquidityProvision
})
.then(async (result) => {
await redeemShares(userId, contractId)
return result
})
}
)
})
const firestore = admin.firestore()

View File

@ -3,12 +3,14 @@ import { logger } from 'firebase-functions/v2'
import { HttpsOptions, onRequest, Request } from 'firebase-functions/v2/https'
import { log } from './utils'
import { z } from 'zod'
import { APIError } from '../../common/api'
import { PrivateUser } from '../../common/user'
import {
CORS_ORIGIN_MANIFOLD,
CORS_ORIGIN_LOCALHOST,
CORS_ORIGIN_VERCEL,
} from '../../common/envs/constants'
export { APIError } from '../../common/api'
type Output = Record<string, unknown>
type AuthedUser = {
@ -20,17 +22,6 @@ type JwtCredentials = { kind: 'jwt'; data: admin.auth.DecodedIdToken }
type KeyCredentials = { kind: 'key'; data: string }
type Credentials = JwtCredentials | KeyCredentials
export class APIError {
code: number
msg: string
details: unknown
constructor(code: number, msg: string, details?: unknown) {
this.code = code
this.msg = msg
this.details = details
}
}
const auth = admin.auth()
const firestore = admin.firestore()
const privateUsers = firestore.collection(
@ -108,17 +99,26 @@ export const validate = <T extends z.ZodTypeAny>(schema: T, val: unknown) => {
}
}
const DEFAULT_OPTS: HttpsOptions = {
minInstances: 1,
cors: [CORS_ORIGIN_MANIFOLD, CORS_ORIGIN_LOCALHOST],
interface EndpointOptions extends HttpsOptions {
methods?: string[]
}
export const newEndpoint = (methods: [string], fn: Handler) =>
onRequest(DEFAULT_OPTS, async (req, res) => {
const DEFAULT_OPTS = {
methods: ['POST'],
minInstances: 1,
concurrency: 100,
memory: '2GiB',
cpu: 1,
cors: [CORS_ORIGIN_MANIFOLD, CORS_ORIGIN_VERCEL, CORS_ORIGIN_LOCALHOST],
}
export const newEndpoint = (endpointOpts: EndpointOptions, fn: Handler) => {
const opts = Object.assign(endpointOpts, DEFAULT_OPTS)
return onRequest(opts, async (req, res) => {
log('Request processing started.')
try {
if (!methods.includes(req.method)) {
const allowed = methods.join(', ')
if (!opts.methods.includes(req.method)) {
const allowed = opts.methods.join(', ')
throw new APIError(405, `This endpoint supports only ${allowed}.`)
}
const authedUser = await lookupUser(await parseCredentials(req))
@ -126,7 +126,7 @@ export const newEndpoint = (methods: [string], fn: Handler) =>
res.status(200).json(await fn(req, authedUser))
} catch (e) {
if (e instanceof APIError) {
const output: { [k: string]: unknown } = { message: e.msg }
const output: { [k: string]: unknown } = { message: e.message }
if (e.details != null) {
output.details = e.details
}
@ -137,3 +137,4 @@ export const newEndpoint = (methods: [string], fn: Handler) =>
}
}
})
}

View File

@ -18,46 +18,63 @@
import * as functions from 'firebase-functions'
import * as firestore from '@google-cloud/firestore'
const client = new firestore.v1.FirestoreAdminClient()
import { FirestoreAdminClient } from '@google-cloud/firestore/types/v1/firestore_admin_client'
const bucket = 'gs://manifold-firestore-backup'
export const backupDb = functions.pubsub
.schedule('every 24 hours')
.onRun((_context) => {
const projectId = process.env.GCP_PROJECT || process.env.GCLOUD_PROJECT
if (projectId == null) {
throw new Error('No project ID environment variable set.')
}
const databaseName = client.databasePath(projectId, '(default)')
return client
.exportDocuments({
name: databaseName,
outputUriPrefix: bucket,
export const backupDbCore = async (
client: FirestoreAdminClient,
project: string,
bucket: string
) => {
const name = client.databasePath(project, '(default)')
const outputUriPrefix = `gs://${bucket}`
// Leave collectionIds empty to export all collections
// or set to a list of collection IDs to export,
// collectionIds: ['users', 'posts']
// NOTE: Subcollections are not backed up by default
collectionIds: [
const collectionIds = [
'contracts',
'groups',
'private-users',
'stripe-transactions',
'transactions',
'users',
'bets',
'comments',
'follows',
'followers',
'answers',
'txns',
],
})
.then((responses) => {
'manalinks',
'liquidity',
'stats',
'cache',
'latency',
'views',
'notifications',
'portfolioHistory',
'folds',
]
return await client.exportDocuments({ name, outputUriPrefix, collectionIds })
}
export const backupDb = functions.pubsub
.schedule('every 24 hours')
.onRun(async (_context) => {
try {
const client = new firestore.v1.FirestoreAdminClient()
const project = process.env.GCP_PROJECT || process.env.GCLOUD_PROJECT
if (project == null) {
throw new Error('No project ID environment variable set.')
}
const responses = await backupDbCore(
client,
project,
'manifold-firestore-backup'
)
const response = responses[0]
console.log(`Operation Name: ${response['name']}`)
})
.catch((err) => {
} catch (err) {
console.error(err)
throw new Error('Export operation failed')
})
}
})

View File

@ -1,17 +0,0 @@
import * as admin from 'firebase-admin'
import fetch from './fetch'
export const callCloudFunction = (functionName: string, data: unknown = {}) => {
const projectId = admin.instanceId().app.options.projectId
const url = `https://us-central1-${projectId}.cloudfunctions.net/${functionName}`
return fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ data }),
}).then((response) => response.json())
}

View File

@ -0,0 +1,35 @@
import * as admin from 'firebase-admin'
import { z } from 'zod'
import { APIError, newEndpoint, validate } from './api'
import { LimitBet } from '../../common/bet'
const bodySchema = z.object({
betId: z.string(),
})
export const cancelbet = newEndpoint({}, async (req, auth) => {
const { betId } = validate(bodySchema, req.body)
const result = await firestore.runTransaction(async (trans) => {
const snap = await trans.get(
firestore.collectionGroup('bets').where('id', '==', betId)
)
const betDoc = snap.docs[0]
if (!betDoc?.exists) throw new APIError(400, 'Bet not found.')
const bet = betDoc.data() as LimitBet
if (bet.userId !== auth.uid)
throw new APIError(400, 'Not authorized to cancel bet.')
if (bet.limitProb === undefined)
throw new APIError(400, 'Not a limit order: Cannot cancel.')
if (bet.isCancelled) throw new APIError(400, 'Bet already cancelled.')
trans.update(betDoc.ref, { isCancelled: true })
return { ...bet, isCancelled: true }
})
return result
})
const firestore = admin.firestore()

View File

@ -1,5 +1,5 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { z } from 'zod'
import { getUser } from './utils'
import { Contract } from '../../common/contract'
@ -11,37 +11,23 @@ import {
} from '../../common/util/clean-username'
import { removeUndefinedProps } from '../../common/util/object'
import { Answer } from '../../common/answer'
import { APIError, newEndpoint, validate } from './api'
export const changeUserInfo = functions
.runWith({ minInstances: 1 })
.https.onCall(
async (
data: {
username?: string
name?: string
avatarUrl?: string
},
context
) => {
const userId = context?.auth?.uid
if (!userId) return { status: 'error', message: 'Not authorized' }
const bodySchema = z.object({
username: z.string().optional(),
name: z.string().optional(),
avatarUrl: z.string().optional(),
})
const user = await getUser(userId)
if (!user) return { status: 'error', message: 'User not found' }
export const changeuserinfo = newEndpoint({}, async (req, auth) => {
const { username, name, avatarUrl } = validate(bodySchema, req.body)
const { username, name, avatarUrl } = data
const user = await getUser(auth.uid)
if (!user) throw new APIError(400, 'User not found')
return await changeUser(user, { username, name, avatarUrl })
.then(() => {
console.log('succesfully changed', user.username, 'to', data)
return { status: 'success' }
})
.catch((e) => {
console.log('Error', e.message)
return { status: 'error', message: e.message }
})
}
)
await changeUser(user, { username, name, avatarUrl })
return { message: 'Successfully changed user info.' }
})
export const changeUser = async (
user: User,
@ -55,14 +41,14 @@ export const changeUser = async (
if (update.username) {
update.username = cleanUsername(update.username)
if (!update.username) {
throw new Error('Invalid username')
throw new APIError(400, 'Invalid username')
}
const sameNameUser = await transaction.get(
firestore.collection('users').where('username', '==', update.username)
)
if (!sameNameUser.empty) {
throw new Error('Username already exists')
throw new APIError(400, 'Username already exists')
}
}
@ -104,17 +90,10 @@ export const changeUser = async (
)
const answerUpdate: Partial<Answer> = removeUndefinedProps(update)
await transaction.update(userRef, userUpdate)
await Promise.all(
commentSnap.docs.map((d) => transaction.update(d.ref, commentUpdate))
)
await Promise.all(
answerSnap.docs.map((d) => transaction.update(d.ref, answerUpdate))
)
await contracts.docs.map((d) => transaction.update(d.ref, contractUpdate))
transaction.update(userRef, userUpdate)
commentSnap.docs.forEach((d) => transaction.update(d.ref, commentUpdate))
answerSnap.docs.forEach((d) => transaction.update(d.ref, answerUpdate))
contracts.docs.forEach((d) => transaction.update(d.ref, contractUpdate))
})
}

View File

@ -0,0 +1,104 @@
import * as admin from 'firebase-admin'
import { z } from 'zod'
import { User } from 'common/user'
import { Manalink } from 'common/manalink'
import { runTxn, TxnData } from './transact'
import { APIError, newEndpoint, validate } from './api'
const bodySchema = z.object({
slug: z.string(),
})
export const claimmanalink = newEndpoint({}, async (req, auth) => {
const { slug } = validate(bodySchema, req.body)
// Run as transaction to prevent race conditions.
return await firestore.runTransaction(async (transaction) => {
// Look up the manalink
const manalinkDoc = firestore.doc(`manalinks/${slug}`)
const manalinkSnap = await transaction.get(manalinkDoc)
if (!manalinkSnap.exists) {
throw new APIError(400, 'Manalink not found')
}
const manalink = manalinkSnap.data() as Manalink
const { amount, fromId, claimedUserIds } = manalink
if (amount <= 0 || isNaN(amount) || !isFinite(amount))
throw new APIError(500, 'Invalid amount')
const fromDoc = firestore.doc(`users/${fromId}`)
const fromSnap = await transaction.get(fromDoc)
if (!fromSnap.exists) {
throw new APIError(500, `User ${fromId} not found`)
}
const fromUser = fromSnap.data() as User
// Only permit one redemption per user per link
if (claimedUserIds.includes(auth.uid)) {
throw new APIError(400, `You already redeemed manalink ${slug}`)
}
// Disallow expired or maxed out links
if (manalink.expiresTime != null && manalink.expiresTime < Date.now()) {
throw new APIError(
400,
`Manalink ${slug} expired on ${new Date(
manalink.expiresTime
).toLocaleString()}`
)
}
if (
manalink.maxUses != null &&
manalink.maxUses <= manalink.claims.length
) {
throw new APIError(
400,
`Manalink ${slug} has reached its max uses of ${manalink.maxUses}`
)
}
if (fromUser.balance < amount) {
throw new APIError(
400,
`Insufficient balance: ${fromUser.name} needed ${amount} for this manalink but only had ${fromUser.balance} `
)
}
// Actually execute the txn
const data: TxnData = {
fromId,
fromType: 'USER',
toId: auth.uid,
toType: 'USER',
amount,
token: 'M$',
category: 'MANALINK',
description: `Manalink ${slug} claimed: ${amount} from ${fromUser.username} to ${auth.uid}`,
}
const result = await runTxn(transaction, data)
const txnId = result.txn?.id
if (!txnId) {
throw new APIError(
500,
result.message ?? 'An error occurred posting the transaction.'
)
}
// Update the manalink object with this info
const claim = {
toId: auth.uid,
txnId,
claimedTime: Date.now(),
}
transaction.update(manalinkDoc, {
claimedUserIds: [...claimedUserIds, auth.uid],
claims: [...manalink.claims, claim],
})
return { message: 'Manalink claimed' }
})
})
const firestore = admin.firestore()

View File

@ -1,5 +1,5 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { z } from 'zod'
import { Contract } from '../../common/contract'
import { User } from '../../common/user'
@ -7,55 +7,41 @@ import { getNewMultiBetInfo } from '../../common/new-bet'
import { Answer, MAX_ANSWER_LENGTH } from '../../common/answer'
import { getContract, getValues } from './utils'
import { sendNewAnswerEmail } from './emails'
import { APIError, newEndpoint, validate } from './api'
export const createAnswer = functions
.runWith({ minInstances: 1, secrets: ['MAILGUN_KEY'] })
.https.onCall(
async (
data: {
contractId: string
amount: number
text: string
},
context
) => {
const userId = context?.auth?.uid
if (!userId) return { status: 'error', message: 'Not authorized' }
const bodySchema = z.object({
contractId: z.string().max(MAX_ANSWER_LENGTH),
amount: z.number().gt(0),
text: z.string(),
})
const { contractId, amount, text } = data
const opts = { secrets: ['MAILGUN_KEY'] }
if (amount <= 0 || isNaN(amount) || !isFinite(amount))
return { status: 'error', message: 'Invalid amount' }
export const createanswer = newEndpoint(opts, async (req, auth) => {
const { contractId, amount, text } = validate(bodySchema, req.body)
if (!text || typeof text !== 'string' || text.length > MAX_ANSWER_LENGTH)
return { status: 'error', message: 'Invalid text' }
if (!isFinite(amount)) throw new APIError(400, 'Invalid amount')
// Run as transaction to prevent race conditions.
const result = await firestore.runTransaction(async (transaction) => {
const userDoc = firestore.doc(`users/${userId}`)
const answer = await firestore.runTransaction(async (transaction) => {
const userDoc = firestore.doc(`users/${auth.uid}`)
const userSnap = await transaction.get(userDoc)
if (!userSnap.exists)
return { status: 'error', message: 'User not found' }
if (!userSnap.exists) throw new APIError(400, 'User not found')
const user = userSnap.data() as User
if (user.balance < amount)
return { status: 'error', message: 'Insufficient balance' }
if (user.balance < amount) throw new APIError(400, 'Insufficient balance')
const contractDoc = firestore.doc(`contracts/${contractId}`)
const contractSnap = await transaction.get(contractDoc)
if (!contractSnap.exists)
return { status: 'error', message: 'Invalid contract' }
if (!contractSnap.exists) throw new APIError(400, 'Invalid contract')
const contract = contractSnap.data() as Contract
if (contract.outcomeType !== 'FREE_RESPONSE')
return {
status: 'error',
message: 'Requires a free response contract',
}
throw new APIError(400, 'Requires a free response contract')
const { closeTime, volume } = contract
if (closeTime && Date.now() > closeTime)
return { status: 'error', message: 'Trading is closed' }
throw new APIError(400, 'Trading is closed')
const [lastAnswer] = await getValues<Answer>(
firestore
@ -64,8 +50,7 @@ export const createAnswer = functions
.limit(1)
)
if (!lastAnswer)
return { status: 'error', message: 'Could not fetch last answer' }
if (!lastAnswer) throw new APIError(500, 'Could not fetch last answer')
const number = lastAnswer.number + 1
const id = `${number}`
@ -96,9 +81,7 @@ export const createAnswer = functions
getNewMultiBetInfo(answerId, amount, contract, loanAmount)
const newBalance = user.balance - amount
const betDoc = firestore
.collection(`contracts/${contractId}/bets`)
.doc()
const betDoc = firestore.collection(`contracts/${contractId}/bets`).doc()
transaction.create(betDoc, {
id: betDoc.id,
userId: user.id,
@ -113,16 +96,14 @@ export const createAnswer = functions
volume: volume + amount,
})
return { status: 'success', answerId, betId: betDoc.id, answer }
return answer
})
const { answer } = result
const contract = await getContract(contractId)
if (answer && contract) await sendNewAnswerEmail(answer, contract)
return result
}
)
return answer
})
const firestore = admin.firestore()

View File

@ -5,7 +5,6 @@ import {
CPMMBinaryContract,
Contract,
FreeResponseContract,
MAX_DESCRIPTION_LENGTH,
MAX_QUESTION_LENGTH,
MAX_TAG_LENGTH,
NumericContract,
@ -22,17 +21,41 @@ import {
getCpmmInitialLiquidity,
getFreeAnswerAnte,
getNumericAnte,
HOUSE_LIQUIDITY_PROVIDER_ID,
} from '../../common/antes'
import { getNoneAnswer } from '../../common/answer'
import { getNewContract } from '../../common/new-contract'
import { NUMERIC_BUCKET_COUNT } from '../../common/numeric-constants'
import { User } from '../../common/user'
import { Group, MAX_ID_LENGTH } from '../../common/group'
import { getPseudoProbability } from '../../common/pseudo-numeric'
import { JSONContent } from '@tiptap/core'
const descScehma: z.ZodType<JSONContent> = z.lazy(() =>
z.intersection(
z.record(z.any()),
z.object({
type: z.string().optional(),
attrs: z.record(z.any()).optional(),
content: z.array(descScehma).optional(),
marks: z
.array(
z.intersection(
z.record(z.any()),
z.object({
type: z.string(),
attrs: z.record(z.any()).optional(),
})
)
)
.optional(),
text: z.string().optional(),
})
)
)
const bodySchema = z.object({
question: z.string().min(1).max(MAX_QUESTION_LENGTH),
description: z.string().max(MAX_DESCRIPTION_LENGTH),
description: descScehma.optional(),
tags: z.array(z.string().min(1).max(MAX_TAG_LENGTH)).optional(),
closeTime: zTimestamp().refine(
(date) => date.getTime() > new Date().getTime(),
@ -46,49 +69,50 @@ const binarySchema = z.object({
initialProb: z.number().min(1).max(99),
})
const finite = () =>
z.number().gte(Number.MIN_SAFE_INTEGER).lte(Number.MAX_SAFE_INTEGER)
const numericSchema = z.object({
min: z.number(),
max: z.number(),
min: finite(),
max: finite(),
initialValue: finite(),
isLogScale: z.boolean().optional(),
})
export const createmarket = newEndpoint(['POST'], async (req, auth) => {
export const createmarket = newEndpoint({}, async (req, auth) => {
const { question, description, tags, closeTime, outcomeType, groupId } =
validate(bodySchema, req.body)
let min, max, initialProb
if (outcomeType === 'NUMERIC') {
;({ min, max } = validate(numericSchema, req.body))
if (max - min <= 0.01) throw new APIError(400, 'Invalid range.')
let min, max, initialProb, isLogScale
if (outcomeType === 'PSEUDO_NUMERIC' || outcomeType === 'NUMERIC') {
let initialValue
;({ min, max, initialValue, isLogScale } = validate(
numericSchema,
req.body
))
if (max - min <= 0.01 || initialValue <= min || initialValue >= max)
throw new APIError(400, 'Invalid range.')
initialProb = getPseudoProbability(initialValue, min, max, isLogScale) * 100
if (initialProb < 1 || initialProb > 99)
throw new APIError(400, 'Invalid initial value.')
}
if (outcomeType === 'BINARY') {
;({ initialProb } = validate(binarySchema, req.body))
}
// Uses utc time on server:
const today = new Date()
let freeMarketResetTime = new Date().setUTCHours(16, 0, 0, 0)
if (today.getTime() < freeMarketResetTime) {
freeMarketResetTime = freeMarketResetTime - 24 * 60 * 60 * 1000
}
const userDoc = await firestore.collection('users').doc(auth.uid).get()
if (!userDoc.exists) {
throw new APIError(400, 'No user exists with the authenticated user ID.')
}
const user = userDoc.data() as User
const userContractsCreatedTodaySnapshot = await firestore
.collection(`contracts`)
.where('creatorId', '==', auth.uid)
.where('createdTime', '>=', freeMarketResetTime)
.get()
console.log('free market reset time: ', freeMarketResetTime)
const isFree = userContractsCreatedTodaySnapshot.size === 0
const ante = FIXED_ANTE
// TODO: this is broken because it's not in a transaction
if (ante > user.balance && !isFree)
if (ante > user.balance)
throw new APIError(400, `Balance must be at least ${ante}.`)
const slug = await getSlug(question)
@ -130,23 +154,24 @@ export const createmarket = newEndpoint(['POST'], async (req, auth) => {
user,
question,
outcomeType,
description,
description ?? {},
initialProb ?? 0,
ante,
closeTime.getTime(),
tags ?? [],
NUMERIC_BUCKET_COUNT,
min ?? 0,
max ?? 0
max ?? 0,
isLogScale ?? false
)
if (!isFree && ante) await chargeUser(user.id, ante, true)
if (ante) await chargeUser(user.id, ante, true)
await contractRef.create(contract)
const providerId = isFree ? HOUSE_LIQUIDITY_PROVIDER_ID : user.id
const providerId = user.id
if (outcomeType === 'BINARY') {
if (outcomeType === 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') {
const liquidityDoc = firestore
.collection(`contracts/${contract.id}/liquidity`)
.doc()

View File

@ -20,7 +20,7 @@ const bodySchema = z.object({
about: z.string().min(1).max(MAX_ABOUT_LENGTH).optional(),
})
export const creategroup = newEndpoint(['POST'], async (req, auth) => {
export const creategroup = newEndpoint({}, async (req, auth) => {
const { name, about, memberIds, anyoneCanJoin } = validate(
bodySchema,
req.body

View File

@ -10,14 +10,16 @@ import { Contract } from '../../common/contract'
import { getUserByUsername, getValues } from './utils'
import { Comment } from '../../common/comment'
import { uniq } from 'lodash'
import { Bet } from '../../common/bet'
import { Bet, LimitBet } from '../../common/bet'
import { Answer } from '../../common/answer'
import { getContractBetMetrics } from '../../common/calculate'
import { removeUndefinedProps } from '../../common/util/object'
import { TipTxn } from '../../common/txn'
import { Group } from '../../common/group'
const firestore = admin.firestore()
type user_to_reason_texts = {
[userId: string]: { reason: notification_reason_types }
[userId: string]: { reason: notification_reason_types; isSeeOnHref?: string }
}
export const createNotification = async (
@ -66,11 +68,11 @@ export const createNotification = async (
sourceUserAvatarUrl: sourceUser.avatarUrl,
sourceText,
sourceContractCreatorUsername: sourceContract?.creatorUsername,
// TODO: move away from sourceContractTitle to sourceTitle
sourceContractTitle: sourceContract?.question,
sourceContractSlug: sourceContract?.slug,
sourceSlug: sourceSlug ? sourceSlug : sourceContract?.slug,
sourceTitle: sourceTitle ? sourceTitle : sourceContract?.question,
isSeenOnHref: userToReasonTexts[userId].isSeeOnHref,
}
await notificationRef.set(removeUndefinedProps(notification))
})
@ -252,10 +254,57 @@ export const createNotification = async (
}
}
const notifyUserReceivedReferralBonus = async (
userToReasonTexts: user_to_reason_texts,
relatedUserId: string
) => {
if (shouldGetNotification(relatedUserId, userToReasonTexts))
userToReasonTexts[relatedUserId] = {
// If the referrer is the market creator, just tell them they joined to bet on their market
reason:
sourceContract?.creatorId === relatedUserId
? 'user_joined_to_bet_on_your_market'
: 'you_referred_user',
}
}
const notifyContractCreatorOfUniqueBettorsBonus = async (
userToReasonTexts: user_to_reason_texts,
userId: string
) => {
userToReasonTexts[userId] = {
reason: 'unique_bettors_on_your_contract',
}
}
const notifyOtherGroupMembersOfComment = async (
userToReasons: user_to_reason_texts,
userId: string
) => {
if (shouldGetNotification(userId, userToReasons))
userToReasons[userId] = {
reason: 'on_group_you_are_member_of',
isSeeOnHref: sourceSlug,
}
}
const getUsersToNotify = async () => {
const userToReasonTexts: user_to_reason_texts = {}
// The following functions modify the userToReasonTexts object in place.
if (sourceContract) {
if (sourceType === 'follow' && relatedUserId) {
await notifyFollowedUser(userToReasonTexts, relatedUserId)
} else if (sourceType === 'group' && relatedUserId) {
if (sourceUpdateType === 'created')
await notifyUserAddedToGroup(userToReasonTexts, relatedUserId)
} else if (sourceType === 'user' && relatedUserId) {
await notifyUserReceivedReferralBonus(userToReasonTexts, relatedUserId)
} else if (sourceType === 'comment' && !sourceContract && relatedUserId) {
await notifyOtherGroupMembersOfComment(userToReasonTexts, relatedUserId)
}
// The following functions need sourceContract to be defined.
if (!sourceContract) return userToReasonTexts
if (
sourceType === 'comment' ||
sourceType === 'answer' ||
@ -284,12 +333,12 @@ export const createNotification = async (
})
} else if (sourceType === 'liquidity' && sourceUpdateType === 'created') {
await notifyContractCreator(userToReasonTexts, sourceContract)
}
} else if (sourceType === 'follow' && relatedUserId) {
await notifyFollowedUser(userToReasonTexts, relatedUserId)
} else if (sourceType === 'group' && relatedUserId) {
if (sourceUpdateType === 'created')
await notifyUserAddedToGroup(userToReasonTexts, relatedUserId)
} else if (sourceType === 'bonus' && sourceUpdateType === 'created') {
// Note: the daily bonus won't have a contract attached to it
await notifyContractCreatorOfUniqueBettorsBonus(
userToReasonTexts,
sourceContract.creatorId
)
}
return userToReasonTexts
}
@ -297,3 +346,74 @@ export const createNotification = async (
const userToReasonTexts = await getUsersToNotify()
await createUsersNotifications(userToReasonTexts)
}
export const createTipNotification = async (
fromUser: User,
toUser: User,
tip: TipTxn,
idempotencyKey: string,
commentId: string,
contract?: Contract,
group?: Group
) => {
const slug = group ? group.slug + `#${commentId}` : commentId
const notificationRef = firestore
.collection(`/users/${toUser.id}/notifications`)
.doc(idempotencyKey)
const notification: Notification = {
id: idempotencyKey,
userId: toUser.id,
reason: 'tip_received',
createdTime: Date.now(),
isSeen: false,
sourceId: tip.id,
sourceType: 'tip',
sourceUpdateType: 'created',
sourceUserName: fromUser.name,
sourceUserUsername: fromUser.username,
sourceUserAvatarUrl: fromUser.avatarUrl,
sourceText: tip.amount.toString(),
sourceContractCreatorUsername: contract?.creatorUsername,
sourceContractTitle: contract?.question,
sourceContractSlug: contract?.slug,
sourceSlug: slug,
sourceTitle: group?.name,
}
return await notificationRef.set(removeUndefinedProps(notification))
}
export const createBetFillNotification = async (
fromUser: User,
toUser: User,
bet: Bet,
userBet: LimitBet,
contract: Contract,
idempotencyKey: string
) => {
const fill = userBet.fills.find((fill) => fill.matchedBetId === bet.id)
const fillAmount = fill?.amount ?? 0
const notificationRef = firestore
.collection(`/users/${toUser.id}/notifications`)
.doc(idempotencyKey)
const notification: Notification = {
id: idempotencyKey,
userId: toUser.id,
reason: 'bet_fill',
createdTime: Date.now(),
isSeen: false,
sourceId: userBet.id,
sourceType: 'bet',
sourceUpdateType: 'updated',
sourceUserName: fromUser.name,
sourceUserUsername: fromUser.username,
sourceUserAvatarUrl: fromUser.avatarUrl,
sourceText: fillAmount.toString(),
sourceContractCreatorUsername: contract.creatorUsername,
sourceContractTitle: contract.question,
sourceContractSlug: contract.slug,
sourceContractId: contract.id,
}
return await notificationRef.set(removeUndefinedProps(notification))
}

View File

@ -1,13 +1,12 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { z } from 'zod'
import {
PrivateUser,
STARTING_BALANCE,
SUS_STARTING_BALANCE,
User,
} from '../../common/user'
import { getUser, getUserByUsername } from './utils'
import { getUser, getUserByUsername, getValues, isProd } from './utils'
import { randomString } from '../../common/util/random'
import {
cleanDisplayName,
@ -15,29 +14,37 @@ import {
} from '../../common/util/clean-username'
import { sendWelcomeEmail } from './emails'
import { isWhitelisted } from '../../common/envs/constants'
import { DEFAULT_CATEGORIES } from '../../common/categories'
import {
CATEGORIES_GROUP_SLUG_POSTFIX,
DEFAULT_CATEGORIES,
} from '../../common/categories'
import { track } from './analytics'
import { APIError, newEndpoint, validate } from './api'
import { Group, NEW_USER_GROUP_SLUGS } from '../../common/group'
import { uniq } from 'lodash'
import {
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
HOUSE_LIQUIDITY_PROVIDER_ID,
} from '../../common/antes'
export const createUser = functions
.runWith({ minInstances: 1, secrets: ['MAILGUN_KEY'] })
.https.onCall(async (data: { deviceToken?: string }, context) => {
const userId = context?.auth?.uid
if (!userId) return { status: 'error', message: 'Not authorized' }
const bodySchema = z.object({
deviceToken: z.string().optional(),
})
const preexistingUser = await getUser(userId)
const opts = { secrets: ['MAILGUN_KEY'] }
export const createuser = newEndpoint(opts, async (req, auth) => {
const { deviceToken } = validate(bodySchema, req.body)
const preexistingUser = await getUser(auth.uid)
if (preexistingUser)
return {
status: 'error',
message: 'User already created',
user: preexistingUser,
}
throw new APIError(400, 'User already exists', { user: preexistingUser })
const fbUser = await admin.auth().getUser(userId)
const fbUser = await admin.auth().getUser(auth.uid)
const email = fbUser.email
if (!isWhitelisted(email)) {
return { status: 'error', message: `${email} is not whitelisted` }
throw new APIError(400, `${email} is not whitelisted`)
}
const emailName = email?.replace(/@.*$/, '')
@ -51,19 +58,16 @@ export const createUser = functions
}
const avatarUrl = fbUser.photoURL
const { deviceToken } = data
const deviceUsedBefore =
!deviceToken || (await isPrivateUserWithDeviceToken(deviceToken))
const ipAddress = context.rawRequest.ip
const ipCount = ipAddress ? await numberUsersWithIp(ipAddress) : 0
const ipCount = req.ip ? await numberUsersWithIp(req.ip) : 0
const balance =
deviceUsedBefore || ipCount > 2 ? SUS_STARTING_BALANCE : STARTING_BALANCE
const user: User = {
id: userId,
id: auth.uid,
name,
username,
avatarUrl,
@ -76,25 +80,25 @@ export const createUser = functions
followedCategories: DEFAULT_CATEGORIES,
}
await firestore.collection('users').doc(userId).create(user)
console.log('created user', username, 'firebase id:', userId)
await firestore.collection('users').doc(auth.uid).create(user)
console.log('created user', username, 'firebase id:', auth.uid)
const privateUser: PrivateUser = {
id: userId,
id: auth.uid,
username,
email,
initialIpAddress: ipAddress,
initialIpAddress: req.ip,
initialDeviceToken: deviceToken,
}
await firestore.collection('private-users').doc(userId).create(privateUser)
await firestore.collection('private-users').doc(auth.uid).create(privateUser)
await sendWelcomeEmail(user, privateUser)
await addUserToDefaultGroups(user)
await track(auth.uid, 'create user', { username }, { ip: req.ip })
await track(userId, 'create user', { username }, { ip: ipAddress })
return { status: 'success', user }
})
return user
})
const firestore = admin.firestore()
@ -115,3 +119,50 @@ const numberUsersWithIp = async (ipAddress: string) => {
return snap.docs.length
}
const addUserToDefaultGroups = async (user: User) => {
for (const category of Object.values(DEFAULT_CATEGORIES)) {
const slug = category.toLowerCase() + CATEGORIES_GROUP_SLUG_POSTFIX
const groups = await getValues<Group>(
firestore.collection('groups').where('slug', '==', slug)
)
await firestore
.collection('groups')
.doc(groups[0].id)
.update({
memberIds: uniq(groups[0].memberIds.concat(user.id)),
})
}
for (const slug of NEW_USER_GROUP_SLUGS) {
const groups = await getValues<Group>(
firestore.collection('groups').where('slug', '==', slug)
)
const group = groups[0]
await firestore
.collection('groups')
.doc(group.id)
.update({
memberIds: uniq(group.memberIds.concat(user.id)),
})
const manifoldAccount = isProd()
? HOUSE_LIQUIDITY_PROVIDER_ID
: DEV_HOUSE_LIQUIDITY_PROVIDER_ID
if (slug === 'welcome') {
const welcomeCommentDoc = firestore
.collection(`groups/${group.id}/comments`)
.doc()
await welcomeCommentDoc.create({
id: welcomeCommentDoc.id,
groupId: group.id,
userId: manifoldAccount,
text: `Welcome, ${user.name} (@${user.username})!`,
createdTime: Date.now(),
userName: 'Manifold Markets',
userUsername: 'ManifoldMarkets',
userAvatarUrl: 'https://manifold.markets/logo-bg-white.png',
})
}
}
}

View File

@ -613,7 +613,7 @@
>our Discord</a
>! Or,
<a
href="https://us-central1-mantic-markets.cloudfunctions.net/unsubscribe?id={{userId}}&type=market-resolve"
href="{{unsubscribeUrl}}"
style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;

View File

@ -635,7 +635,7 @@
>our Discord</a
>! Or,
<a
href="https://us-central1-mantic-markets.cloudfunctions.net/unsubscribe?id={{userId}}&type=market-resolved"
href="{{unsubscribeUrl}}"
style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;

View File

@ -1,4 +1,4 @@
import { DOMAIN, PROJECT_ID } from '../../common/envs/constants'
import { DOMAIN } from '../../common/envs/constants'
import { Answer } from '../../common/answer'
import { Bet } from '../../common/bet'
import { getProbability } from '../../common/calculate'
@ -6,11 +6,19 @@ import { Comment } from '../../common/comment'
import { Contract } from '../../common/contract'
import { DPM_CREATOR_FEE } from '../../common/fees'
import { PrivateUser, User } from '../../common/user'
import { formatMoney, formatPercent } from '../../common/util/format'
import {
formatLargeNumber,
formatMoney,
formatPercent,
} from '../../common/util/format'
import { getValueFromBucket } from '../../common/calculate-dpm'
import { formatNumericProbability } from '../../common/pseudo-numeric'
import { sendTemplateEmail } from './send-email'
import { getPrivateUser, getUser } from './utils'
import { getFunctionUrl } from '../../common/api'
const UNSUBSCRIBE_ENDPOINT = getFunctionUrl('unsubscribe')
export const sendMarketResolutionEmail = async (
userId: string,
@ -48,6 +56,9 @@ export const sendMarketResolutionEmail = async (
? ` (plus ${formatMoney(creatorPayout)} in commissions)`
: ''
const emailType = 'market-resolved'
const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}`
const templateData: market_resolved_template = {
userId: user.id,
name: user.name,
@ -57,6 +68,7 @@ export const sendMarketResolutionEmail = async (
investment: `${Math.floor(investment)}`,
payout: `${Math.floor(payout)}${creatorPayoutText}`,
url: `https://${DOMAIN}/${creator.username}/${contract.slug}`,
unsubscribeUrl,
}
// Modify template here:
@ -80,6 +92,7 @@ type market_resolved_template = {
investment: string
payout: string
url: string
unsubscribeUrl: string
}
const toDisplayResolution = (
@ -101,6 +114,17 @@ const toDisplayResolution = (
return display || resolution
}
if (contract.outcomeType === 'PSEUDO_NUMERIC') {
const { resolutionValue } = contract
return resolutionValue
? formatLargeNumber(resolutionValue)
: formatNumericProbability(
resolutionProbability ?? getProbability(contract),
contract
)
}
if (resolution === 'MKT' && resolutions) return 'MULTI'
if (resolution === 'CANCEL') return 'N/A'
@ -125,7 +149,7 @@ export const sendWelcomeEmail = async (
const firstName = name.split(' ')[0]
const emailType = 'generic'
const unsubscribeLink = `https://us-central1-${PROJECT_ID}.cloudfunctions.net/unsubscribe?id=${userId}&type=${emailType}`
const unsubscribeLink = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}`
await sendTemplateEmail(
privateUser.email,
@ -157,7 +181,7 @@ export const sendOneWeekBonusEmail = async (
const firstName = name.split(' ')[0]
const emailType = 'generic'
const unsubscribeLink = `https://us-central1-${PROJECT_ID}.cloudfunctions.net/unsubscribe?id=${userId}&type=${emailType}`
const unsubscribeLink = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}`
await sendTemplateEmail(
privateUser.email,
@ -189,7 +213,7 @@ export const sendThankYouEmail = async (
const firstName = name.split(' ')[0]
const emailType = 'generic'
const unsubscribeLink = `https://us-central1-${PROJECT_ID}.cloudfunctions.net/unsubscribe?id=${userId}&type=${emailType}`
const unsubscribeLink = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}`
await sendTemplateEmail(
privateUser.email,
@ -223,6 +247,8 @@ export const sendMarketCloseEmail = async (
const { question, slug, volume, mechanism, collectedFees } = contract
const url = `https://${DOMAIN}/${username}/${slug}`
const emailType = 'market-resolve'
const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}`
await sendTemplateEmail(
privateUser.email,
@ -231,6 +257,7 @@ export const sendMarketCloseEmail = async (
{
question,
url,
unsubscribeUrl,
userId,
name: firstName,
volume: formatMoney(volume),
@ -261,8 +288,8 @@ export const sendNewCommentEmail = async (
const { question, creatorUsername, slug } = contract
const marketUrl = `https://${DOMAIN}/${creatorUsername}/${slug}#${comment.id}`
const unsubscribeUrl = `https://us-central1-${PROJECT_ID}.cloudfunctions.net/unsubscribe?id=${userId}&type=market-comment`
const emailType = 'market-comment'
const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}`
const { name: commentorName, avatarUrl: commentorAvatarUrl } = commentCreator
const { text } = comment
@ -343,7 +370,8 @@ export const sendNewAnswerEmail = async (
const { name, avatarUrl, text } = answer
const marketUrl = `https://${DOMAIN}/${creatorUsername}/${slug}`
const unsubscribeUrl = `https://us-central1-${PROJECT_ID}.cloudfunctions.net/unsubscribe?id=${userId}&type=market-answer`
const emailType = 'market-answer'
const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}`
const subject = `New answer on ${question}`
const from = `${name} <info@manifold.markets>`

View File

@ -1,9 +0,0 @@
let fetchRequest: typeof fetch
try {
fetchRequest = fetch
} catch {
fetchRequest = require('node-fetch')
}
export default fetchRequest

View File

@ -1,6 +1,6 @@
import { newEndpoint } from './api'
export const health = newEndpoint(['GET'], async (_req, auth) => {
export const health = newEndpoint({ methods: ['GET'] }, async (_req, auth) => {
return {
message: 'Server is working.',
uid: auth.uid,

View File

@ -3,21 +3,13 @@ import * as admin from 'firebase-admin'
admin.initializeApp()
// v1
// export * from './keep-awake'
export * from './transact'
export * from './resolve-market'
export * from './stripe'
export * from './create-user'
export * from './create-answer'
export * from './on-create-bet'
export * from './on-create-comment'
export * from './on-create-comment-on-contract'
export * from './on-view'
export * from './unsubscribe'
export * from './update-metrics'
export * from './update-stats'
export * from './backup-db'
export * from './change-user-info'
export * from './market-close-notifications'
export * from './add-liquidity'
export * from './on-create-answer'
export * from './on-update-contract'
export * from './on-create-contract'
@ -26,12 +18,26 @@ export * from './on-unfollow-user'
export * from './on-create-liquidity-provision'
export * from './on-update-group'
export * from './on-create-group'
export * from './on-update-user'
export * from './on-create-comment-on-group'
export * from './on-create-txn'
export * from './on-delete-group'
// v2
export * from './health'
export * from './transact'
export * from './change-user-info'
export * from './create-user'
export * from './create-answer'
export * from './place-bet'
export * from './cancel-bet'
export * from './sell-bet'
export * from './sell-shares'
export * from './claim-manalink'
export * from './create-contract'
export * from './add-liquidity'
export * from './withdraw-liquidity'
export * from './create-group'
export * from './resolve-market'
export * from './unsubscribe'
export * from './stripe'

View File

@ -1,25 +0,0 @@
import * as functions from 'firebase-functions'
import { callCloudFunction } from './call-cloud-function'
export const keepAwake = functions.pubsub
.schedule('every 1 minutes')
.onRun(async () => {
await Promise.all([
callCloudFunction('placeBet'),
callCloudFunction('resolveMarket'),
callCloudFunction('sellBet'),
])
await sleep(30)
await Promise.all([
callCloudFunction('placeBet'),
callCloudFunction('resolveMarket'),
callCloudFunction('sellBet'),
])
})
const sleep = (seconds: number) => {
return new Promise((resolve) => setTimeout(resolve, seconds * 1000))
}

View File

@ -1,9 +1,26 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { keyBy, uniq } from 'lodash'
import { Bet } from '../../common/bet'
import { Bet, LimitBet } from '../../common/bet'
import { getContract, getUser, getValues, isProd, log } from './utils'
import {
createBetFillNotification,
createNotification,
} from './create-notification'
import { filterDefined } from '../../common/util/array'
import { Contract } from '../../common/contract'
import { runTxn, TxnData } from './transact'
import { UNIQUE_BETTOR_BONUS_AMOUNT } from '../../common/numeric-constants'
import {
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
HOUSE_LIQUIDITY_PROVIDER_ID,
} from '../../common/antes'
import { APIError } from '../../common/api'
import { User } from '../../common/user'
const firestore = admin.firestore()
const BONUS_START_DATE = new Date('2022-07-13T15:30:00.000Z').getTime()
export const onCreateBet = functions.firestore
.document('contracts/{contractId}/bets/{betId}')
@ -11,6 +28,8 @@ export const onCreateBet = functions.firestore
const { contractId } = context.params as {
contractId: string
}
const { eventId } = context
const bet = change.data() as Bet
const lastBetTime = bet.createdTime
@ -18,4 +37,150 @@ export const onCreateBet = functions.firestore
.collection('contracts')
.doc(contractId)
.update({ lastBetTime, lastUpdatedTime: Date.now() })
await notifyFills(bet, contractId, eventId)
await updateUniqueBettorsAndGiveCreatorBonus(
contractId,
eventId,
bet.userId
)
})
const updateUniqueBettorsAndGiveCreatorBonus = async (
contractId: string,
eventId: string,
bettorId: string
) => {
const userContractSnap = await firestore
.collection(`contracts`)
.doc(contractId)
.get()
const contract = userContractSnap.data() as Contract
if (!contract) {
log(`Could not find contract ${contractId}`)
return
}
let previousUniqueBettorIds = contract.uniqueBettorIds
if (!previousUniqueBettorIds) {
const contractBets = (
await firestore
.collection(`contracts/${contractId}/bets`)
.where('userId', '!=', contract.creatorId)
.get()
).docs.map((doc) => doc.data() as Bet)
if (contractBets.length === 0) {
log(`No bets for contract ${contractId}`)
return
}
previousUniqueBettorIds = uniq(
contractBets
.filter((bet) => bet.createdTime < BONUS_START_DATE)
.map((bet) => bet.userId)
)
}
const isNewUniqueBettor =
!previousUniqueBettorIds.includes(bettorId) &&
bettorId !== contract.creatorId
const newUniqueBettorIds = uniq([...previousUniqueBettorIds, bettorId])
// Update contract unique bettors
if (!contract.uniqueBettorIds || isNewUniqueBettor) {
log(`Got ${previousUniqueBettorIds} unique bettors`)
isNewUniqueBettor && log(`And a new unique bettor ${bettorId}`)
await firestore.collection(`contracts`).doc(contractId).update({
uniqueBettorIds: newUniqueBettorIds,
uniqueBettorCount: newUniqueBettorIds.length,
})
}
if (!isNewUniqueBettor) return
// Create combined txn for all new unique bettors
const bonusTxnDetails = {
contractId: contractId,
uniqueBettorIds: newUniqueBettorIds,
}
const fromUserId = isProd()
? HOUSE_LIQUIDITY_PROVIDER_ID
: DEV_HOUSE_LIQUIDITY_PROVIDER_ID
const fromSnap = await firestore.doc(`users/${fromUserId}`).get()
if (!fromSnap.exists) throw new APIError(400, 'From user not found.')
const fromUser = fromSnap.data() as User
const result = await firestore.runTransaction(async (trans) => {
const bonusTxn: TxnData = {
fromId: fromUser.id,
fromType: 'BANK',
toId: contract.creatorId,
toType: 'USER',
amount: UNIQUE_BETTOR_BONUS_AMOUNT,
token: 'M$',
category: 'UNIQUE_BETTOR_BONUS',
description: JSON.stringify(bonusTxnDetails),
}
return await runTxn(trans, bonusTxn)
})
if (result.status != 'success' || !result.txn) {
log(`No bonus for user: ${contract.creatorId} - reason:`, result.status)
} else {
log(`Bonus txn for user: ${contract.creatorId} completed:`, result.txn?.id)
await createNotification(
result.txn.id,
'bonus',
'created',
fromUser,
eventId + '-bonus',
result.txn.amount + '',
contract,
undefined,
// No need to set the user id, we'll use the contract creator id
undefined,
contract.slug,
contract.question
)
}
}
const notifyFills = async (bet: Bet, contractId: string, eventId: string) => {
if (!bet.fills) return
const user = await getUser(bet.userId)
if (!user) return
const contract = await getContract(contractId)
if (!contract) return
const matchedFills = bet.fills.filter((fill) => fill.matchedBetId !== null)
const matchedBets = (
await Promise.all(
matchedFills.map((fill) =>
getValues<LimitBet>(
firestore.collectionGroup('bets').where('id', '==', fill.matchedBetId)
)
)
)
).flat()
const betUsers = await Promise.all(
matchedBets.map((bet) => getUser(bet.userId))
)
const betUsersById = keyBy(filterDefined(betUsers), 'id')
await Promise.all(
matchedBets.map((matchedBet) => {
const matchedUser = betUsersById[matchedBet.userId]
if (!matchedUser) return
return createBetFillNotification(
user,
matchedUser,
bet,
matchedBet,
contract,
eventId
)
})
)
}

View File

@ -11,7 +11,7 @@ import { createNotification } from './create-notification'
const firestore = admin.firestore()
export const onCreateComment = functions
export const onCreateCommentOnContract = functions
.runWith({ secrets: ['MAILGUN_KEY'] })
.firestore.document('contracts/{contractId}/comments/{commentId}')
.onCreate(async (change, context) => {

View File

@ -0,0 +1,52 @@
import * as functions from 'firebase-functions'
import { Comment } from '../../common/comment'
import * as admin from 'firebase-admin'
import { Group } from '../../common/group'
import { User } from '../../common/user'
import { createNotification } from './create-notification'
const firestore = admin.firestore()
export const onCreateCommentOnGroup = functions.firestore
.document('groups/{groupId}/comments/{commentId}')
.onCreate(async (change, context) => {
const { eventId } = context
const { groupId } = context.params as {
groupId: string
}
const comment = change.data() as Comment
const creatorSnapshot = await firestore
.collection('users')
.doc(comment.userId)
.get()
if (!creatorSnapshot.exists) throw new Error('Could not find user')
const groupSnapshot = await firestore
.collection('groups')
.doc(groupId)
.get()
if (!groupSnapshot.exists) throw new Error('Could not find group')
const group = groupSnapshot.data() as Group
await firestore.collection('groups').doc(groupId).update({
mostRecentActivityTime: comment.createdTime,
})
await Promise.all(
group.memberIds.map(async (memberId) => {
return await createNotification(
comment.id,
'comment',
'created',
creatorSnapshot.data() as User,
eventId,
comment.text,
undefined,
undefined,
memberId,
`/group/${group.slug}`,
`${group.name}`
)
})
)
})

View File

@ -2,6 +2,8 @@ import * as functions from 'firebase-functions'
import { getUser } from './utils'
import { createNotification } from './create-notification'
import { Contract } from '../../common/contract'
import { richTextToString } from '../../common/util/parse'
import { JSONContent } from '@tiptap/core'
export const onCreateContract = functions.firestore
.document('contracts/{contractId}')
@ -18,7 +20,7 @@ export const onCreateContract = functions.firestore
'created',
contractCreator,
eventId,
contract.description,
richTextToString(contract.description as JSONContent),
contract
)
})

View File

@ -0,0 +1,81 @@
import * as functions from 'firebase-functions'
import { TipTxn, Txn } from 'common/txn'
import { getContract, getGroup, getUser, log } from './utils'
import { createTipNotification } from './create-notification'
import * as admin from 'firebase-admin'
import { Comment } from 'common/comment'
const firestore = admin.firestore()
export const onCreateTxn = functions.firestore
.document('txns/{txnId}')
.onCreate(async (change, context) => {
const txn = change.data() as Txn
const { eventId } = context
if (txn.category === 'TIP') {
await handleTipTxn(txn, eventId)
}
})
async function handleTipTxn(txn: TipTxn, eventId: string) {
// get user sending and receiving tip
const [sender, receiver] = await Promise.all([
getUser(txn.fromId),
getUser(txn.toId),
])
if (!sender || !receiver) {
log('Could not find corresponding users')
return
}
if (!txn.data?.commentId) {
log('No comment id in tip txn.data')
return
}
let contract = undefined
let group = undefined
let commentSnapshot = undefined
if (txn.data.contractId) {
contract = await getContract(txn.data.contractId)
if (!contract) {
log('Could not find contract')
return
}
commentSnapshot = await firestore
.collection('contracts')
.doc(contract.id)
.collection('comments')
.doc(txn.data.commentId)
.get()
} else if (txn.data.groupId) {
group = await getGroup(txn.data.groupId)
if (!group) {
log('Could not find group')
return
}
commentSnapshot = await firestore
.collection('groups')
.doc(group.id)
.collection('comments')
.doc(txn.data.commentId)
.get()
}
if (!commentSnapshot || !commentSnapshot.exists) {
log('Could not find comment')
return
}
const comment = commentSnapshot.data() as Comment
await createTipNotification(
sender,
receiver,
txn,
eventId,
comment.id,
contract,
group
)
}

View File

@ -0,0 +1,31 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { Group } from 'common/group'
import { Contract } from 'common/contract'
const firestore = admin.firestore()
export const onDeleteGroup = functions.firestore
.document('groups/{groupId}')
.onDelete(async (change) => {
const group = change.data() as Group
// get all contracts with this group's slug
const contracts = await firestore
.collection('contracts')
.where('groupSlugs', 'array-contains', group.slug)
.get()
for (const doc of contracts.docs) {
const contract = doc.data() as Contract
// remove the group from the contract
await firestore
.collection('contracts')
.doc(contract.id)
.update({
groupSlugs: (contract.groupSlugs ?? []).filter(
(groupSlug) => groupSlug !== group.slug
),
})
}
})

View File

@ -24,6 +24,9 @@ export const onUpdateContract = functions.firestore
if (resolutionText === 'MKT' && contract.resolutionProbability)
resolutionText = `${contract.resolutionProbability}%`
else if (resolutionText === 'MKT') resolutionText = 'PROB'
} else if (contract.outcomeType === 'PSEUDO_NUMERIC') {
if (resolutionText === 'MKT' && contract.resolutionValue)
resolutionText = `${contract.resolutionValue}`
}
await createNotification(

View File

@ -12,6 +12,7 @@ export const onUpdateGroup = functions.firestore
// ignore the update we just made
if (prevGroup.mostRecentActivityTime !== group.mostRecentActivityTime)
return
// TODO: create notification with isSeeOnHref set to the group's /group/questions url
await firestore
.collection('groups')

View File

@ -0,0 +1,129 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { REFERRAL_AMOUNT, User } from '../../common/user'
import { HOUSE_LIQUIDITY_PROVIDER_ID } from '../../common/antes'
import { createNotification } from './create-notification'
import { ReferralTxn } from '../../common/txn'
import { Contract } from '../../common/contract'
import { LimitBet } from 'common/bet'
import { QuerySnapshot } from 'firebase-admin/firestore'
const firestore = admin.firestore()
export const onUpdateUser = functions.firestore
.document('users/{userId}')
.onUpdate(async (change, context) => {
const prevUser = change.before.data() as User
const user = change.after.data() as User
const { eventId } = context
if (prevUser.referredByUserId !== user.referredByUserId) {
await handleUserUpdatedReferral(user, eventId)
}
if (user.balance <= 0) {
await cancelLimitOrders(user.id)
}
})
async function handleUserUpdatedReferral(user: User, eventId: string) {
// Only create a referral txn if the user has a referredByUserId
if (!user.referredByUserId) {
console.log(`Not set: referredByUserId ${user.referredByUserId}`)
return
}
const referredByUserId = user.referredByUserId
await firestore.runTransaction(async (transaction) => {
// get user that referred this user
const referredByUserDoc = firestore.doc(`users/${referredByUserId}`)
const referredByUserSnap = await transaction.get(referredByUserDoc)
if (!referredByUserSnap.exists) {
console.log(`User ${referredByUserId} not found`)
return
}
const referredByUser = referredByUserSnap.data() as User
let referredByContract: Contract | undefined = undefined
if (user.referredByContractId) {
const referredByContractDoc = firestore.doc(
`contracts/${user.referredByContractId}`
)
referredByContract = await transaction
.get(referredByContractDoc)
.then((snap) => snap.data() as Contract)
}
console.log(`referredByContract: ${referredByContract}`)
const txns = (
await firestore
.collection('txns')
.where('toId', '==', referredByUserId)
.where('category', '==', 'REFERRAL')
.get()
).docs.map((txn) => txn.ref)
if (txns.length > 0) {
const referralTxns = await transaction.getAll(...txns).catch((err) => {
console.error('error getting txns:', err)
throw err
})
// If the referring user already has a referral txn due to referring this user, halt
if (
referralTxns.map((txn) => txn.data()?.description).includes(user.id)
) {
console.log('found referral txn with the same details, aborting')
return
}
}
console.log('creating referral txns')
const fromId = HOUSE_LIQUIDITY_PROVIDER_ID
// if they're updating their referredId, create a txn for both
const txn: ReferralTxn = {
id: eventId,
createdTime: Date.now(),
fromId,
fromType: 'BANK',
toId: referredByUserId,
toType: 'USER',
amount: REFERRAL_AMOUNT,
token: 'M$',
category: 'REFERRAL',
description: `Referred new user id: ${user.id} for ${REFERRAL_AMOUNT}`,
}
const txnDoc = await firestore.collection(`txns/`).doc(txn.id)
await transaction.set(txnDoc, txn)
console.log('created referral with txn id:', txn.id)
// We're currently not subtracting M$ from the house, not sure if we want to for accounting purposes.
transaction.update(referredByUserDoc, {
balance: referredByUser.balance + REFERRAL_AMOUNT,
totalDeposits: referredByUser.totalDeposits + REFERRAL_AMOUNT,
})
await createNotification(
user.id,
'user',
'updated',
user,
eventId,
txn.amount.toString(),
referredByContract,
'user',
referredByUser.id,
referredByContract?.slug,
referredByContract?.question
)
})
}
async function cancelLimitOrders(userId: string) {
const snapshot = (await firestore
.collectionGroup('bets')
.where('userId', '==', userId)
.where('isFilled', '==', false)
.get()) as QuerySnapshot<LimitBet>
await Promise.all(
snapshot.docs.map((doc) => doc.ref.update({ isCancelled: true }))
)
}

View File

@ -1,17 +1,25 @@
import * as admin from 'firebase-admin'
import { z } from 'zod'
import {
DocumentReference,
FieldValue,
Query,
Transaction,
} from 'firebase-admin/firestore'
import { groupBy, mapValues, sumBy } from 'lodash'
import { APIError, newEndpoint, validate } from './api'
import { Contract, CPMM_MIN_POOL_QTY } from '../../common/contract'
import { User } from '../../common/user'
import {
BetInfo,
getNewBinaryCpmmBetInfo,
getNewBinaryDpmBetInfo,
getBinaryCpmmBetInfo,
getNewMultiBetInfo,
getNumericBetsInfo,
} from '../../common/new-bet'
import { addObjects, removeUndefinedProps } from '../../common/util/object'
import { LimitBet } from '../../common/bet'
import { floatingEqual } from '../../common/util/math'
import { redeemShares } from './redeem-shares'
import { log } from './utils'
@ -22,6 +30,7 @@ const bodySchema = z.object({
const binarySchema = z.object({
outcome: z.enum(['YES', 'NO']),
limitProb: z.number().gte(0.001).lte(0.999).optional(),
})
const freeResponseSchema = z.object({
@ -33,7 +42,7 @@ const numericSchema = z.object({
value: z.number(),
})
export const placebet = newEndpoint(['POST'], async (req, auth) => {
export const placebet = newEndpoint({}, async (req, auth) => {
log('Inside endpoint handler.')
const { amount, contractId } = validate(bodySchema, req.body)
@ -41,10 +50,7 @@ export const placebet = newEndpoint(['POST'], async (req, auth) => {
log('Inside main transaction.')
const contractDoc = firestore.doc(`contracts/${contractId}`)
const userDoc = firestore.doc(`users/${auth.uid}`)
const [contractSnap, userSnap] = await Promise.all([
trans.get(contractDoc),
trans.get(userDoc),
])
const [contractSnap, userSnap] = await trans.getAll(contractDoc, userDoc)
if (!contractSnap.exists) throw new APIError(400, 'Contract not found.')
if (!userSnap.exists) throw new APIError(400, 'User not found.')
log('Loaded user and contract snapshots.')
@ -66,13 +72,30 @@ export const placebet = newEndpoint(['POST'], async (req, auth) => {
newTotalBets,
newTotalLiquidity,
newP,
} = await (async (): Promise<BetInfo> => {
if (outcomeType == 'BINARY' && mechanism == 'dpm-2') {
const { outcome } = validate(binarySchema, req.body)
return getNewBinaryDpmBetInfo(outcome, amount, contract, loanAmount)
} else if (outcomeType == 'BINARY' && mechanism == 'cpmm-1') {
const { outcome } = validate(binarySchema, req.body)
return getNewBinaryCpmmBetInfo(outcome, amount, contract, loanAmount)
makers,
} = await (async (): Promise<
BetInfo & {
makers?: maker[]
}
> => {
if (
(outcomeType == 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') &&
mechanism == 'cpmm-1'
) {
const { outcome, limitProb } = validate(binarySchema, req.body)
const unfilledBetsSnap = await trans.get(
getUnfilledBetsQuery(contractDoc)
)
const unfilledBets = unfilledBetsSnap.docs.map((doc) => doc.data())
return getBinaryCpmmBetInfo(
outcome,
amount,
contract,
limitProb,
unfilledBets
)
} else if (outcomeType == 'FREE_RESPONSE' && mechanism == 'dpm-2') {
const { outcome } = validate(freeResponseSchema, req.body)
const answerDoc = contractDoc.collection('answers').doc(outcome)
@ -97,12 +120,18 @@ export const placebet = newEndpoint(['POST'], async (req, auth) => {
throw new APIError(400, 'Bet too large for current liquidity pool.')
}
const newBalance = user.balance - amount - loanAmount
const betDoc = contractDoc.collection('bets').doc()
trans.create(betDoc, { id: betDoc.id, userId: user.id, ...newBet })
log('Created new bet document.')
trans.update(userDoc, { balance: newBalance })
if (makers) {
updateMakers(makers, betDoc.id, contractDoc, trans)
}
if (newBet.amount !== 0) {
trans.update(userDoc, { balance: FieldValue.increment(-newBet.amount) })
log('Updated user balance.')
trans.update(
contractDoc,
removeUndefinedProps({
@ -112,18 +141,78 @@ export const placebet = newEndpoint(['POST'], async (req, auth) => {
totalBets: newTotalBets,
totalLiquidity: newTotalLiquidity,
collectedFees: addObjects(newBet.fees, collectedFees),
volume: volume + amount,
volume: volume + newBet.amount,
})
)
log('Updated contract properties.')
}
return { betId: betDoc.id }
return { betId: betDoc.id, makers, newBet }
})
log('Main transaction finished.')
await redeemShares(auth.uid, contractId)
if (result.newBet.amount !== 0) {
const userIds = [
auth.uid,
...(result.makers ?? []).map((maker) => maker.bet.userId),
]
await Promise.all(userIds.map((userId) => redeemShares(userId, contractId)))
log('Share redemption transaction finished.')
return result
}
return { betId: result.betId }
})
const firestore = admin.firestore()
export const getUnfilledBetsQuery = (contractDoc: DocumentReference) => {
return contractDoc
.collection('bets')
.where('isFilled', '==', false)
.where('isCancelled', '==', false) as Query<LimitBet>
}
type maker = {
bet: LimitBet
amount: number
shares: number
timestamp: number
}
export const updateMakers = (
makers: maker[],
takerBetId: string,
contractDoc: DocumentReference,
trans: Transaction
) => {
const makersByBet = groupBy(makers, (maker) => maker.bet.id)
for (const makers of Object.values(makersByBet)) {
const bet = makers[0].bet
const newFills = makers.map((maker) => {
const { amount, shares, timestamp } = maker
return { amount, shares, matchedBetId: takerBetId, timestamp }
})
const fills = [...bet.fills, ...newFills]
const totalShares = sumBy(fills, 'shares')
const totalAmount = sumBy(fills, 'amount')
const isFilled = floatingEqual(totalAmount, bet.orderAmount)
log('Updated a matched limit order.')
trans.update(contractDoc.collection('bets').doc(bet.id), {
fills,
isFilled,
amount: totalAmount,
shares: totalShares,
})
}
// Deduct balance of makers.
const spentByUser = mapValues(
groupBy(makers, (maker) => maker.bet.userId),
(makers) => sumBy(makers, (maker) => maker.amount)
)
for (const [userId, spent] of Object.entries(spentByUser)) {
const userDoc = firestore.collection('users').doc(userId)
trans.update(userDoc, { balance: FieldValue.increment(-spent) })
}
}

View File

@ -1,92 +1,46 @@
import * as admin from 'firebase-admin'
import { partition, sumBy } from 'lodash'
import { Bet } from '../../common/bet'
import { getProbability } from '../../common/calculate'
import { getRedeemableAmount, getRedemptionBets } from '../../common/redeem'
import { Contract } from '../../common/contract'
import { noFees } from '../../common/fees'
import { User } from '../../common/user'
export const redeemShares = async (userId: string, contractId: string) => {
return await firestore.runTransaction(async (transaction) => {
return await firestore.runTransaction(async (trans) => {
const contractDoc = firestore.doc(`contracts/${contractId}`)
const contractSnap = await transaction.get(contractDoc)
const contractSnap = await trans.get(contractDoc)
if (!contractSnap.exists)
return { status: 'error', message: 'Invalid contract' }
const contract = contractSnap.data() as Contract
if (contract.outcomeType !== 'BINARY' || contract.mechanism !== 'cpmm-1')
return { status: 'success' }
const { mechanism } = contract
if (mechanism !== 'cpmm-1') return { status: 'success' }
const betsSnap = await transaction.get(
firestore
.collection(`contracts/${contract.id}/bets`)
.where('userId', '==', userId)
)
const betsColl = firestore.collection(`contracts/${contract.id}/bets`)
const betsSnap = await trans.get(betsColl.where('userId', '==', userId))
const bets = betsSnap.docs.map((doc) => doc.data() as Bet)
const [yesBets, noBets] = partition(bets, (b) => b.outcome === 'YES')
const yesShares = sumBy(yesBets, (b) => b.shares)
const noShares = sumBy(noBets, (b) => b.shares)
const amount = Math.min(yesShares, noShares)
if (amount <= 0) return
const prevLoanAmount = sumBy(bets, (bet) => bet.loanAmount ?? 0)
const loanPaid = Math.min(prevLoanAmount, amount)
const netAmount = amount - loanPaid
const p = getProbability(contract)
const createdTime = Date.now()
const yesDoc = firestore.collection(`contracts/${contract.id}/bets`).doc()
const yesBet: Bet = {
id: yesDoc.id,
userId: userId,
contractId: contract.id,
amount: p * -amount,
shares: -amount,
loanAmount: loanPaid ? -loanPaid / 2 : 0,
outcome: 'YES',
probBefore: p,
probAfter: p,
createdTime,
isRedemption: true,
fees: noFees,
}
const noDoc = firestore.collection(`contracts/${contract.id}/bets`).doc()
const noBet: Bet = {
id: noDoc.id,
userId: userId,
contractId: contract.id,
amount: (1 - p) * -amount,
shares: -amount,
loanAmount: loanPaid ? -loanPaid / 2 : 0,
outcome: 'NO',
probBefore: p,
probAfter: p,
createdTime,
isRedemption: true,
fees: noFees,
const { shares, loanPayment, netAmount } = getRedeemableAmount(bets)
if (netAmount === 0) {
return { status: 'success' }
}
const [yesBet, noBet] = getRedemptionBets(shares, loanPayment, contract)
const userDoc = firestore.doc(`users/${userId}`)
const userSnap = await transaction.get(userDoc)
const userSnap = await trans.get(userDoc)
if (!userSnap.exists) return { status: 'error', message: 'User not found' }
const user = userSnap.data() as User
const newBalance = user.balance + netAmount
if (!isFinite(newBalance)) {
throw new Error('Invalid user balance for ' + user.username)
}
transaction.update(userDoc, { balance: newBalance })
transaction.create(yesDoc, yesBet)
transaction.create(noDoc, noBet)
const yesDoc = betsColl.doc()
const noDoc = betsColl.doc()
trans.update(userDoc, { balance: newBalance })
trans.create(yesDoc, { id: yesDoc.id, userId, ...yesBet })
trans.create(noDoc, { id: noDoc.id, userId, ...noBet })
return { status: 'success' }
})

View File

@ -1,8 +1,12 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { z } from 'zod'
import { difference, uniq, mapValues, groupBy, sumBy } from 'lodash'
import { Contract, resolution, RESOLUTIONS } from '../../common/contract'
import {
Contract,
FreeResponseContract,
RESOLUTIONS,
} from '../../common/contract'
import { User } from '../../common/user'
import { Bet } from '../../common/bet'
import { getUser, isProd, payUser } from './utils'
@ -15,69 +19,76 @@ import {
} from '../../common/payouts'
import { removeUndefinedProps } from '../../common/util/object'
import { LiquidityProvision } from '../../common/liquidity-provision'
import { APIError, newEndpoint, validate } from './api'
export const resolveMarket = functions
.runWith({ minInstances: 1, secrets: ['MAILGUN_KEY'] })
.https.onCall(
async (
data: {
outcome: resolution
value?: number
contractId: string
probabilityInt?: number
resolutions?: { [outcome: string]: number }
},
context
) => {
const userId = context?.auth?.uid
if (!userId) return { status: 'error', message: 'Not authorized' }
const bodySchema = z.object({
contractId: z.string(),
})
const { outcome, contractId, probabilityInt, resolutions, value } = data
const binarySchema = z.object({
outcome: z.enum(RESOLUTIONS),
probabilityInt: z.number().gte(0).lte(100).optional(),
})
const freeResponseSchema = z.union([
z.object({
outcome: z.literal('CANCEL'),
}),
z.object({
outcome: z.literal('MKT'),
resolutions: z.array(
z.object({
answer: z.number().int().nonnegative(),
pct: z.number().gte(0).lte(100),
})
),
}),
z.object({
outcome: z.number().int().nonnegative(),
}),
])
const numericSchema = z.object({
outcome: z.union([z.literal('CANCEL'), z.string()]),
value: z.number().optional(),
})
const pseudoNumericSchema = z.union([
z.object({
outcome: z.literal('CANCEL'),
}),
z.object({
outcome: z.literal('MKT'),
value: z.number(),
probabilityInt: z.number().gte(0).lte(100),
}),
])
const opts = { secrets: ['MAILGUN_KEY'] }
export const resolvemarket = newEndpoint(opts, async (req, auth) => {
const { contractId } = validate(bodySchema, req.body)
const userId = auth.uid
const contractDoc = firestore.doc(`contracts/${contractId}`)
const contractSnap = await contractDoc.get()
if (!contractSnap.exists)
return { status: 'error', message: 'Invalid contract' }
throw new APIError(404, 'No contract exists with the provided ID')
const contract = contractSnap.data() as Contract
const { creatorId, outcomeType, closeTime } = contract
const { creatorId, closeTime } = contract
if (outcomeType === 'BINARY') {
if (!RESOLUTIONS.includes(outcome))
return { status: 'error', message: 'Invalid outcome' }
} else if (outcomeType === 'FREE_RESPONSE') {
if (
isNaN(+outcome) &&
!(outcome === 'MKT' && resolutions) &&
outcome !== 'CANCEL'
const { value, resolutions, probabilityInt, outcome } = getResolutionParams(
contract,
req.body
)
return { status: 'error', message: 'Invalid outcome' }
} else if (outcomeType === 'NUMERIC') {
if (isNaN(+outcome) && outcome !== 'CANCEL')
return { status: 'error', message: 'Invalid outcome' }
} else {
return { status: 'error', message: 'Invalid contract outcomeType' }
}
if (value !== undefined && !isFinite(value))
return { status: 'error', message: 'Invalid value' }
if (
outcomeType === 'BINARY' &&
probabilityInt !== undefined &&
(probabilityInt < 0 ||
probabilityInt > 100 ||
!isFinite(probabilityInt))
)
return { status: 'error', message: 'Invalid probability' }
if (creatorId !== userId)
return { status: 'error', message: 'User not creator of contract' }
throw new APIError(403, 'User is not creator of contract')
if (contract.resolution)
return { status: 'error', message: 'Contract already resolved' }
if (contract.resolution) throw new APIError(400, 'Contract already resolved')
const creator = await getUser(creatorId)
if (!creator) return { status: 'error', message: 'Creator not found' }
if (!creator) throw new APIError(500, 'Creator not found')
const resolutionProbability =
probabilityInt !== undefined ? probabilityInt / 100 : undefined
@ -104,15 +115,16 @@ export const resolveMarket = functions
const { payouts, creatorPayout, liquidityPayouts, collectedFees } =
getPayouts(
outcome,
resolutions ?? {},
contract,
bets,
liquidities,
resolutions,
resolutionProbability
)
await contractDoc.update(
removeUndefinedProps({
const updatedContract = {
...contract,
...removeUndefinedProps({
isResolved: true,
resolution: outcome,
resolutionValue: value,
@ -121,8 +133,10 @@ export const resolveMarket = functions
resolutionProbability,
resolutions,
collectedFees,
})
)
}),
}
await contractDoc.update(updatedContract)
console.log('contract ', contractId, 'resolved to:', outcome)
@ -139,14 +153,11 @@ export const resolveMarket = functions
)
if (creatorPayout)
await processPayouts(
[{ userId: creatorId, payout: creatorPayout }],
true
)
await processPayouts([{ userId: creatorId, payout: creatorPayout }], true)
await processPayouts(liquidityPayouts, true)
const result = await processPayouts([...payouts, ...loanPayouts])
await processPayouts([...payouts, ...loanPayouts])
const userPayoutsWithoutLoans = groupPayoutsByUser(payouts)
@ -161,9 +172,8 @@ export const resolveMarket = functions
resolutions
)
return result
}
)
return updatedContract
})
const processPayouts = async (payouts: Payout[], isDeposit = false) => {
const userPayouts = groupPayoutsByUser(payouts)
@ -221,4 +231,72 @@ const sendResolutionEmails = async (
)
}
function getResolutionParams(contract: Contract, body: string) {
const { outcomeType } = contract
if (outcomeType === 'NUMERIC') {
return {
...validate(numericSchema, body),
resolutions: undefined,
probabilityInt: undefined,
}
} else if (outcomeType === 'PSEUDO_NUMERIC') {
return {
...validate(pseudoNumericSchema, body),
resolutions: undefined,
}
} else if (outcomeType === 'FREE_RESPONSE') {
const freeResponseParams = validate(freeResponseSchema, body)
const { outcome } = freeResponseParams
switch (outcome) {
case 'CANCEL':
return {
outcome: outcome.toString(),
resolutions: undefined,
value: undefined,
probabilityInt: undefined,
}
case 'MKT': {
const { resolutions } = freeResponseParams
resolutions.forEach(({ answer }) => validateAnswer(contract, answer))
const pctSum = sumBy(resolutions, ({ pct }) => pct)
if (Math.abs(pctSum - 100) > 0.1) {
throw new APIError(400, 'Resolution percentages must sum to 100')
}
return {
outcome: outcome.toString(),
resolutions: Object.fromEntries(
resolutions.map((r) => [r.answer, r.pct])
),
value: undefined,
probabilityInt: undefined,
}
}
default: {
validateAnswer(contract, outcome)
return {
outcome: outcome.toString(),
resolutions: undefined,
value: undefined,
probabilityInt: undefined,
}
}
}
} else if (outcomeType === 'BINARY') {
return {
...validate(binarySchema, body),
value: undefined,
resolutions: undefined,
}
}
throw new APIError(500, `Invalid outcome type: ${outcomeType}`)
}
function validateAnswer(contract: FreeResponseContract, answer: number) {
const validIds = contract.answers.map((a) => a.id)
if (!validIds.includes(answer.toString())) {
throw new APIError(400, `${answer} is not a valid answer ID`)
}
}
const firestore = admin.firestore()

View File

@ -0,0 +1,16 @@
import * as firestore from '@google-cloud/firestore'
import { getServiceAccountCredentials } from './script-init'
import { backupDbCore } from '../backup-db'
async function backupDb() {
const credentials = getServiceAccountCredentials()
const projectId = credentials.project_id
const client = new firestore.v1.FirestoreAdminClient({ credentials })
const bucket = 'manifold-firestore-backup'
const resp = await backupDbCore(client, projectId, bucket)
console.log(`Operation: ${resp[0]['name']}`)
}
if (require.main === module) {
backupDb().then(() => process.exit())
}

View File

@ -0,0 +1,110 @@
import * as admin from 'firebase-admin'
import { initAdmin } from './script-init'
initAdmin()
import { getValues, isProd } from '../utils'
import {
CATEGORIES_GROUP_SLUG_POSTFIX,
DEFAULT_CATEGORIES,
} from 'common/categories'
import { Group } from 'common/group'
import { uniq } from 'lodash'
import { Contract } from 'common/contract'
import { User } from 'common/user'
import { filterDefined } from 'common/util/array'
import {
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
HOUSE_LIQUIDITY_PROVIDER_ID,
} from 'common/antes'
const adminFirestore = admin.firestore()
async function convertCategoriesToGroups() {
const groups = await getValues<Group>(adminFirestore.collection('groups'))
const contracts = await getValues<Contract>(
adminFirestore.collection('contracts')
)
for (const group of groups) {
const groupContracts = contracts.filter((contract) =>
group.contractIds.includes(contract.id)
)
for (const contract of groupContracts) {
await adminFirestore
.collection('contracts')
.doc(contract.id)
.update({
groupSlugs: uniq([...(contract.groupSlugs ?? []), group.slug]),
})
}
}
for (const category of Object.values(DEFAULT_CATEGORIES)) {
const markets = await getValues<Contract>(
adminFirestore
.collection('contracts')
.where('lowercaseTags', 'array-contains', category.toLowerCase())
)
const slug = category.toLowerCase() + CATEGORIES_GROUP_SLUG_POSTFIX
const oldGroup = await getValues<Group>(
adminFirestore.collection('groups').where('slug', '==', slug)
)
if (oldGroup.length > 0) {
console.log(`Found old group for ${category}`)
await adminFirestore.collection('groups').doc(oldGroup[0].id).delete()
}
const allUsers = await getValues<User>(adminFirestore.collection('users'))
const groupUsers = filterDefined(
allUsers.map((user: User) => {
if (!user.followedCategories || user.followedCategories.length === 0)
return user.id
if (!user.followedCategories.includes(category.toLowerCase()))
return null
return user.id
})
)
const manifoldAccount = isProd()
? HOUSE_LIQUIDITY_PROVIDER_ID
: DEV_HOUSE_LIQUIDITY_PROVIDER_ID
const newGroupRef = await adminFirestore.collection('groups').doc()
const newGroup: Group = {
id: newGroupRef.id,
name: category,
slug,
creatorId: manifoldAccount,
createdTime: Date.now(),
anyoneCanJoin: true,
memberIds: [manifoldAccount],
about: 'Official group for all things related to ' + category,
mostRecentActivityTime: Date.now(),
contractIds: markets.map((market) => market.id),
chatDisabled: true,
}
await adminFirestore.collection('groups').doc(newGroupRef.id).set(newGroup)
// Update group with new memberIds to avoid notifying everyone
await adminFirestore
.collection('groups')
.doc(newGroupRef.id)
.update({
memberIds: uniq(groupUsers),
})
for (const market of markets) {
await adminFirestore
.collection('contracts')
.doc(market.id)
.update({
groupSlugs: uniq([...(market?.groupSlugs ?? []), newGroup.slug]),
})
}
}
}
if (require.main === module) {
convertCategoriesToGroups()
.then(() => process.exit())
.catch(console.log)
}

View File

@ -27,10 +27,10 @@ async function checkIfPayOutAgain(contractRef: DocRef, contract: Contract) {
const { payouts } = getPayouts(
resolution,
resolutions,
contract,
openBets,
[],
resolutions,
resolutionProbability
)

View File

@ -47,26 +47,29 @@ const getFirebaseActiveProject = (cwd: string) => {
}
}
export const initAdmin = (env?: string) => {
export const getServiceAccountCredentials = (env?: string) => {
env = env || getFirebaseActiveProject(process.cwd())
if (env == null) {
console.error(
throw new Error(
"Couldn't find active Firebase project; did you do `firebase use <alias>?`"
)
return
}
const envVar = `GOOGLE_APPLICATION_CREDENTIALS_${env.toUpperCase()}`
const keyPath = process.env[envVar]
if (keyPath == null) {
console.error(
throw new Error(
`Please set the ${envVar} environment variable to contain the path to your ${env} environment key file.`
)
return
}
console.log(`Initializing connection to ${env} Firebase...`)
/* eslint-disable-next-line @typescript-eslint/no-var-requires */
const serviceAccount = require(keyPath)
admin.initializeApp({
return require(keyPath)
}
export const initAdmin = (env?: string) => {
const serviceAccount = getServiceAccountCredentials(env)
console.log(`Initializing connection to ${serviceAccount.project_id}...`)
return admin.initializeApp({
projectId: serviceAccount.project_id,
credential: admin.credential.cert(serviceAccount),
})
}

View File

@ -1,53 +0,0 @@
import * as admin from 'firebase-admin'
import { initAdmin } from './script-init'
initAdmin()
import { getValues } from '../utils'
import { User } from '../../../common/user'
import { batchedWaitAll } from '../../../common/util/promise'
import { Contract } from '../../../common/contract'
import { updateWordScores } from '../update-recommendations'
import { computeFeed } from '../update-feed'
import { getFeedContracts, getTaggedContracts } from '../get-feed-data'
import { CATEGORY_LIST } from '../../../common/categories'
const firestore = admin.firestore()
async function updateFeed() {
console.log('Updating feed')
const contracts = await getValues<Contract>(firestore.collection('contracts'))
const feedContracts = await getFeedContracts()
const users = await getValues<User>(
firestore.collection('users').where('username', '==', 'JamesGrugett')
)
await batchedWaitAll(
users.map((user) => async () => {
console.log('Updating recs for', user.username)
await updateWordScores(user, contracts)
console.log('Updating feed for', user.username)
await computeFeed(user, feedContracts)
})
)
console.log('Updating feed categories!')
await batchedWaitAll(
users.map((user) => async () => {
for (const category of CATEGORY_LIST) {
const contracts = await getTaggedContracts(category)
const feed = await computeFeed(user, contracts)
await firestore
.collection(`private-users/${user.id}/cache`)
.doc(`feed-${category}`)
.set({ feed })
}
})
)
}
if (require.main === module) {
updateFeed().then(() => process.exit())
}

View File

@ -0,0 +1,15 @@
import { initAdmin } from './script-init'
initAdmin()
import { log, logMemory } from '../utils'
import { updateStatsCore } from '../update-stats'
async function updateStats() {
logMemory()
log('Updating stats...')
await updateStatsCore()
}
if (require.main === module) {
updateStats().then(() => process.exit())
}

View File

@ -13,7 +13,7 @@ const bodySchema = z.object({
betId: z.string(),
})
export const sellbet = newEndpoint(['POST'], async (req, auth) => {
export const sellbet = newEndpoint({}, async (req, auth) => {
const { contractId, betId } = validate(bodySchema, req.body)
// run as transaction to prevent race conditions
@ -21,11 +21,11 @@ export const sellbet = newEndpoint(['POST'], async (req, auth) => {
const contractDoc = firestore.doc(`contracts/${contractId}`)
const userDoc = firestore.doc(`users/${auth.uid}`)
const betDoc = firestore.doc(`contracts/${contractId}/bets/${betId}`)
const [contractSnap, userSnap, betSnap] = await Promise.all([
transaction.get(contractDoc),
transaction.get(userDoc),
transaction.get(betDoc),
])
const [contractSnap, userSnap, betSnap] = await transaction.getAll(
contractDoc,
userDoc,
betDoc
)
if (!contractSnap.exists) throw new APIError(400, 'Contract not found.')
if (!userSnap.exists) throw new APIError(400, 'User not found.')
if (!betSnap.exists) throw new APIError(400, 'Bet not found.')

View File

@ -9,6 +9,9 @@ import { getCpmmSellBetInfo } from '../../common/sell-bet'
import { addObjects, removeUndefinedProps } from '../../common/util/object'
import { getValues } from './utils'
import { Bet } from '../../common/bet'
import { floatingLesserEqual } from '../../common/util/math'
import { getUnfilledBetsQuery, updateMakers } from './place-bet'
import { FieldValue } from 'firebase-admin/firestore'
const bodySchema = z.object({
contractId: z.string(),
@ -16,7 +19,7 @@ const bodySchema = z.object({
outcome: z.enum(['YES', 'NO']),
})
export const sellshares = newEndpoint(['POST'], async (req, auth) => {
export const sellshares = newEndpoint({}, async (req, auth) => {
const { contractId, shares, outcome } = validate(bodySchema, req.body)
// Run as transaction to prevent race conditions.
@ -24,9 +27,8 @@ export const sellshares = newEndpoint(['POST'], async (req, auth) => {
const contractDoc = firestore.doc(`contracts/${contractId}`)
const userDoc = firestore.doc(`users/${auth.uid}`)
const betsQ = contractDoc.collection('bets').where('userId', '==', auth.uid)
const [contractSnap, userSnap, userBets] = await Promise.all([
transaction.get(contractDoc),
transaction.get(userDoc),
const [[contractSnap, userSnap], userBets] = await Promise.all([
transaction.getAll(contractDoc, userDoc),
getValues<Bet>(betsQ), // TODO: why is this not in the transaction??
])
if (!contractSnap.exists) throw new APIError(400, 'Contract not found.')
@ -47,14 +49,22 @@ export const sellshares = newEndpoint(['POST'], async (req, auth) => {
const outcomeBets = userBets.filter((bet) => bet.outcome == outcome)
const maxShares = sumBy(outcomeBets, (bet) => bet.shares)
if (shares > maxShares + 0.000000000001)
if (!floatingLesserEqual(shares, maxShares))
throw new APIError(400, `You can only sell up to ${maxShares} shares.`)
const { newBet, newPool, newP, fees } = getCpmmSellBetInfo(
shares,
const soldShares = Math.min(shares, maxShares)
const unfilledBetsSnap = await transaction.get(
getUnfilledBetsQuery(contractDoc)
)
const unfilledBets = unfilledBetsSnap.docs.map((doc) => doc.data())
const { newBet, newPool, newP, fees, makers } = getCpmmSellBetInfo(
soldShares,
outcome,
contract,
prevLoanAmount
prevLoanAmount,
unfilledBets
)
if (
@ -66,11 +76,17 @@ export const sellshares = newEndpoint(['POST'], async (req, auth) => {
}
const newBetDoc = firestore.collection(`contracts/${contractId}/bets`).doc()
const newBalance = user.balance - newBet.amount + (newBet.loanAmount ?? 0)
const userId = user.id
transaction.update(userDoc, { balance: newBalance })
transaction.create(newBetDoc, { id: newBetDoc.id, userId, ...newBet })
updateMakers(makers, newBetDoc.id, contractDoc, transaction)
transaction.update(userDoc, {
balance: FieldValue.increment(-newBet.amount),
})
transaction.create(newBetDoc, {
id: newBetDoc.id,
userId: user.id,
...newBet,
})
transaction.update(
contractDoc,
removeUndefinedProps({

View File

@ -1,4 +1,4 @@
import * as functions from 'firebase-functions'
import { onRequest } from 'firebase-functions/v2/https'
import * as admin from 'firebase-admin'
import Stripe from 'stripe'
@ -42,9 +42,9 @@ const manticDollarStripePrice = isProd()
10000: 'price_1K8bEiGdoFKoCJW7Us4UkRHE',
}
export const createCheckoutSession = functions
.runWith({ minInstances: 1, secrets: ['STRIPE_APIKEY'] })
.https.onRequest(async (req, res) => {
export const createcheckoutsession = onRequest(
{ minInstances: 1, secrets: ['STRIPE_APIKEY'] },
async (req, res) => {
const userId = req.query.userId?.toString()
const manticDollarQuantity = req.query.manticDollarQuantity?.toString()
@ -86,14 +86,15 @@ export const createCheckoutSession = functions
})
res.redirect(303, session.url || '')
})
}
)
export const stripeWebhook = functions
.runWith({
export const stripewebhook = onRequest(
{
minInstances: 1,
secrets: ['MAILGUN_KEY', 'STRIPE_APIKEY', 'STRIPE_WEBHOOKSECRET'],
})
.https.onRequest(async (req, res) => {
},
async (req, res) => {
const stripe = initStripe()
let event
@ -115,7 +116,8 @@ export const stripeWebhook = functions
}
res.status(200).send('success')
})
}
)
const issueMoneys = async (session: StripeSession) => {
const { id: sessionId } = session

View File

@ -1,108 +1,84 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { User } from '../../common/user'
import { Txn } from '../../common/txn'
import { removeUndefinedProps } from '../../common/util/object'
import { APIError, newEndpoint } from './api'
export const transact = functions
.runWith({ minInstances: 1 })
.https.onCall(async (data: Omit<Txn, 'id' | 'createdTime'>, context) => {
const userId = context?.auth?.uid
if (!userId) return { status: 'error', message: 'Not authorized' }
export type TxnData = Omit<Txn, 'id' | 'createdTime'>
const {
amount,
fromType,
fromId,
toId,
toType,
category,
token,
data: innerData,
description,
} = data
// TODO: We totally fail to validate most of the input to this function,
// so anyone can spam our database with malformed transactions.
export const transact = newEndpoint({}, async (req, auth) => {
const data = req.body
const { amount, fromType, fromId } = data
if (fromType !== 'USER')
return {
status: 'error',
message: "From type is only implemented for type 'user'.",
}
throw new APIError(400, "From type is only implemented for type 'user'.")
if (fromId !== userId)
return {
status: 'error',
message: 'Must be authenticated with userId equal to specified fromId.',
}
if (fromId !== auth.uid)
throw new APIError(
403,
'Must be authenticated with userId equal to specified fromId.'
)
if (isNaN(amount) || !isFinite(amount))
return { status: 'error', message: 'Invalid amount' }
throw new APIError(400, 'Invalid amount')
// Run as transaction to prevent race conditions.
return await firestore.runTransaction(async (transaction) => {
const fromDoc = firestore.doc(`users/${userId}`)
const fromSnap = await transaction.get(fromDoc)
const result = await runTxn(transaction, data)
if (result.status == 'error') {
throw new APIError(500, result.message ?? 'An unknown error occurred.')
}
return result
})
})
export async function runTxn(
fbTransaction: admin.firestore.Transaction,
data: TxnData
) {
const { amount, fromId, toId, toType } = data
const fromDoc = firestore.doc(`users/${fromId}`)
const fromSnap = await fbTransaction.get(fromDoc)
if (!fromSnap.exists) {
return { status: 'error', message: 'User not found' }
}
const fromUser = fromSnap.data() as User
if (amount > 0 && fromUser.balance < amount) {
if (fromUser.balance < amount) {
return {
status: 'error',
message: `Insufficient balance: ${fromUser.username} needed ${amount} but only had ${fromUser.balance} `,
}
}
// TODO: Track payments received by charities, bank, contracts too.
if (toType === 'USER') {
const toDoc = firestore.doc(`users/${toId}`)
const toSnap = await transaction.get(toDoc)
const toSnap = await fbTransaction.get(toDoc)
if (!toSnap.exists) {
return { status: 'error', message: 'User not found' }
}
const toUser = toSnap.data() as User
if (amount < 0 && toUser.balance < -amount) {
return {
status: 'error',
message: `Insufficient balance: ${
toUser.username
} needed ${-amount} but only had ${toUser.balance} `,
}
}
transaction.update(toDoc, {
fbTransaction.update(toDoc, {
balance: toUser.balance + amount,
totalDeposits: toUser.totalDeposits + amount,
})
}
const newTxnDoc = firestore.collection(`txns/`).doc()
const txn = removeUndefinedProps({
id: newTxnDoc.id,
createdTime: Date.now(),
fromId,
fromType,
toId,
toType,
amount,
category,
data: innerData,
token,
description,
})
transaction.create(newTxnDoc, txn)
transaction.update(fromDoc, {
const txn = { id: newTxnDoc.id, createdTime: Date.now(), ...data }
fbTransaction.create(newTxnDoc, removeUndefinedProps(txn))
fbTransaction.update(fromDoc, {
balance: fromUser.balance - amount,
totalDeposits: fromUser.totalDeposits - amount,
})
return { status: 'success', txn }
})
})
}
const firestore = admin.firestore()

View File

@ -1,11 +1,9 @@
import * as functions from 'firebase-functions'
import { onRequest } from 'firebase-functions/v2/https'
import * as admin from 'firebase-admin'
import { getUser } from './utils'
import { PrivateUser } from '../../common/user'
export const unsubscribe = functions
.runWith({ minInstances: 1 })
.https.onRequest(async (req, res) => {
export const unsubscribe = onRequest({ minInstances: 1 }, async (req, res) => {
const id = req.query.id as string
let type = req.query.type as string
if (!id || !type) {
@ -16,12 +14,9 @@ export const unsubscribe = functions
if (type === 'market-resolved') type = 'market-resolve'
if (
![
'market-resolve',
'market-comment',
'market-answer',
'generic',
].includes(type)
!['market-resolve', 'market-comment', 'market-answer', 'generic'].includes(
type
)
) {
res.status(400).send('Invalid type parameter.')
return
@ -66,6 +61,6 @@ export const unsubscribe = functions
`${name}, you have been unsubscribed from market answer emails on Manifold Markets.`
)
else res.send(`${name}, you have been unsubscribed.`)
})
})
const firestore = admin.firestore()

View File

@ -1,220 +0,0 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { flatten, shuffle, sortBy, uniq, zip, zipObject } from 'lodash'
import { getValue, getValues } from './utils'
import { Contract } from '../../common/contract'
import { logInterpolation } from '../../common/util/math'
import { DAY_MS } from '../../common/util/time'
import {
getProbability,
getOutcomeProbability,
getTopAnswer,
} from '../../common/calculate'
import { User } from '../../common/user'
import {
getContractScore,
MAX_FEED_CONTRACTS,
} from '../../common/recommended-contracts'
import { callCloudFunction } from './call-cloud-function'
import {
getFeedContracts,
getRecentBetsAndComments,
getTaggedContracts,
} from './get-feed-data'
import { CATEGORY_LIST } from '../../common/categories'
const firestore = admin.firestore()
const BATCH_SIZE = 30
const MAX_BATCHES = 50
const getUserBatches = async () => {
const users = shuffle(await getValues<User>(firestore.collection('users')))
const userBatches: User[][] = []
for (let i = 0; i < users.length; i += BATCH_SIZE) {
userBatches.push(users.slice(i, i + BATCH_SIZE))
}
console.log('updating feed batches', MAX_BATCHES, 'of', userBatches.length)
return userBatches.slice(0, MAX_BATCHES)
}
export const updateFeed = functions.pubsub
.schedule('every 60 minutes')
.onRun(async () => {
const userBatches = await getUserBatches()
await Promise.all(
userBatches.map((users) =>
callCloudFunction('updateFeedBatch', { users })
)
)
console.log('updating category feed')
await Promise.all(
CATEGORY_LIST.map((category) =>
callCloudFunction('updateCategoryFeed', {
category,
})
)
)
})
export const updateFeedBatch = functions.https.onCall(
async (data: { users: User[] }) => {
const { users } = data
const contracts = await getFeedContracts()
const feeds = await getNewFeeds(users, contracts)
await Promise.all(
zip(users, feeds).map(([user, feed]) =>
/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */
getUserCacheCollection(user!).doc('feed').set({ feed })
)
)
}
)
export const updateCategoryFeed = functions.https.onCall(
async (data: { category: string }) => {
const { category } = data
const userBatches = await getUserBatches()
await Promise.all(
userBatches.map(async (users) => {
await callCloudFunction('updateCategoryFeedBatch', {
users,
category,
})
})
)
}
)
export const updateCategoryFeedBatch = functions.https.onCall(
async (data: { users: User[]; category: string }) => {
const { users, category } = data
const contracts = await getTaggedContracts(category)
const feeds = await getNewFeeds(users, contracts)
await Promise.all(
zip(users, feeds).map(([user, feed]) =>
/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */
getUserCacheCollection(user!).doc(`feed-${category}`).set({ feed })
)
)
}
)
const getNewFeeds = async (users: User[], contracts: Contract[]) => {
const feeds = await Promise.all(users.map((u) => computeFeed(u, contracts)))
const contractIds = uniq(flatten(feeds).map((c) => c.id))
const data = await Promise.all(contractIds.map(getRecentBetsAndComments))
const dataByContractId = zipObject(contractIds, data)
return feeds.map((feed) =>
feed.map((contract) => {
return { contract, ...dataByContractId[contract.id] }
})
)
}
const getUserCacheCollection = (user: User) =>
firestore.collection(`private-users/${user.id}/cache`)
export const computeFeed = async (user: User, contracts: Contract[]) => {
const userCacheCollection = getUserCacheCollection(user)
const [wordScores, lastViewedTime] = await Promise.all([
getValue<{ [word: string]: number }>(userCacheCollection.doc('wordScores')),
getValue<{ [contractId: string]: number }>(
userCacheCollection.doc('lastViewTime')
),
]).then((dicts) => dicts.map((dict) => dict ?? {}))
const scoredContracts = contracts.map((contract) => {
const score = scoreContract(
contract,
wordScores,
lastViewedTime[contract.id]
)
return [contract, score] as [Contract, number]
})
const sortedContracts = sortBy(
scoredContracts,
([_, score]) => score
).reverse()
// console.log(sortedContracts.map(([c, score]) => c.question + ': ' + score))
return sortedContracts.slice(0, MAX_FEED_CONTRACTS).map(([c]) => c)
}
function scoreContract(
contract: Contract,
wordScores: { [word: string]: number },
viewTime: number | undefined
) {
const recommendationScore = getContractScore(contract, wordScores)
const activityScore = getActivityScore(contract, viewTime)
// const lastViewedScore = getLastViewedScore(viewTime)
return recommendationScore * activityScore
}
function getActivityScore(contract: Contract, viewTime: number | undefined) {
const { createdTime, lastBetTime, lastCommentTime, outcomeType } = contract
const hasNewComments =
lastCommentTime && (!viewTime || lastCommentTime > viewTime)
const newCommentScore = hasNewComments ? 1 : 0.5
const timeSinceLastComment = Date.now() - (lastCommentTime ?? createdTime)
const commentDaysAgo = timeSinceLastComment / DAY_MS
const commentTimeScore =
0.25 + 0.75 * (1 - logInterpolation(0, 3, commentDaysAgo))
const timeSinceLastBet = Date.now() - (lastBetTime ?? createdTime)
const betDaysAgo = timeSinceLastBet / DAY_MS
const betTimeScore = 0.5 + 0.5 * (1 - logInterpolation(0, 3, betDaysAgo))
let prob = 0.5
if (outcomeType === 'BINARY') {
prob = getProbability(contract)
} else if (outcomeType === 'FREE_RESPONSE') {
const topAnswer = getTopAnswer(contract)
if (topAnswer)
prob = Math.max(0.5, getOutcomeProbability(contract, topAnswer.id))
}
const frac = 1 - Math.abs(prob - 0.5) ** 2 / 0.25
const probScore = 0.5 + frac * 0.5
const { volume24Hours, volume7Days } = contract
const combinedVolume = Math.log(volume24Hours + 1) + Math.log(volume7Days + 1)
const volumeScore = 0.5 + 0.5 * logInterpolation(4, 20, combinedVolume)
const score =
newCommentScore * commentTimeScore * betTimeScore * probScore * volumeScore
// Map score to [0.5, 1] since no recent activty is not a deal breaker.
const mappedScore = 0.5 + 0.5 * score
const newMappedScore = 0.7 + 0.3 * score
const isNew = Date.now() < contract.createdTime + DAY_MS
return isNew ? newMappedScore : mappedScore
}
// function getLastViewedScore(viewTime: number | undefined) {
// if (viewTime === undefined) {
// return 1
// }
// const daysAgo = (Date.now() - viewTime) / DAY_MS
// if (daysAgo < 0.5) {
// const frac = logInterpolation(0, 0.5, daysAgo)
// return 0.5 + 0.25 * frac
// }
// const frac = logInterpolation(0.5, 14, daysAgo)
// return 0.75 + 0.25 * frac
// }

View File

@ -1,70 +0,0 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { getValue, getValues } from './utils'
import { Contract } from '../../common/contract'
import { Bet } from '../../common/bet'
import { User } from '../../common/user'
import { ClickEvent } from '../../common/tracking'
import { getWordScores } from '../../common/recommended-contracts'
import { batchedWaitAll } from '../../common/util/promise'
import { callCloudFunction } from './call-cloud-function'
const firestore = admin.firestore()
export const updateRecommendations = functions.pubsub
.schedule('every 24 hours')
.onRun(async () => {
const users = await getValues<User>(firestore.collection('users'))
const batchSize = 100
const userBatches: User[][] = []
for (let i = 0; i < users.length; i += batchSize) {
userBatches.push(users.slice(i, i + batchSize))
}
await Promise.all(
userBatches.map((batch) =>
callCloudFunction('updateRecommendationsBatch', { users: batch })
)
)
})
export const updateRecommendationsBatch = functions.https.onCall(
async (data: { users: User[] }) => {
const { users } = data
const contracts = await getValues<Contract>(
firestore.collection('contracts')
)
await batchedWaitAll(
users.map((user) => () => updateWordScores(user, contracts))
)
}
)
export const updateWordScores = async (user: User, contracts: Contract[]) => {
const [bets, viewCounts, clicks] = await Promise.all([
getValues<Bet>(
firestore.collectionGroup('bets').where('userId', '==', user.id)
),
getValue<{ [contractId: string]: number }>(
firestore.doc(`private-users/${user.id}/cache/viewCounts`)
),
getValues<ClickEvent>(
firestore
.collection(`private-users/${user.id}/events`)
.where('type', '==', 'click')
),
])
const wordScores = getWordScores(contracts, viewCounts ?? {}, clicks, bets)
const cachedCollection = firestore.collection(
`private-users/${user.id}/cache`
)
await cachedCollection.doc('wordScores').set(wordScores)
}

View File

@ -0,0 +1,316 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { concat, countBy, sortBy, range, zip, uniq, sum, sumBy } from 'lodash'
import { getValues, log, logMemory } from './utils'
import { Bet } from '../../common/bet'
import { Contract } from '../../common/contract'
import { Comment } from '../../common/comment'
import { User } from '../../common/user'
import { DAY_MS } from '../../common/util/time'
import { average } from '../../common/util/math'
const firestore = admin.firestore()
const numberOfDays = 90
const getBetsQuery = (startTime: number, endTime: number) =>
firestore
.collectionGroup('bets')
.where('createdTime', '>=', startTime)
.where('createdTime', '<', endTime)
.orderBy('createdTime', 'asc')
export async function getDailyBets(startTime: number, numberOfDays: number) {
const query = getBetsQuery(startTime, startTime + DAY_MS * numberOfDays)
const bets = await getValues<Bet>(query)
const betsByDay = range(0, numberOfDays).map(() => [] as Bet[])
for (const bet of bets) {
const dayIndex = Math.floor((bet.createdTime - startTime) / DAY_MS)
betsByDay[dayIndex].push(bet)
}
return betsByDay
}
const getCommentsQuery = (startTime: number, endTime: number) =>
firestore
.collectionGroup('comments')
.where('createdTime', '>=', startTime)
.where('createdTime', '<', endTime)
.orderBy('createdTime', 'asc')
export async function getDailyComments(
startTime: number,
numberOfDays: number
) {
const query = getCommentsQuery(startTime, startTime + DAY_MS * numberOfDays)
const comments = await getValues<Comment>(query)
const commentsByDay = range(0, numberOfDays).map(() => [] as Comment[])
for (const comment of comments) {
const dayIndex = Math.floor((comment.createdTime - startTime) / DAY_MS)
commentsByDay[dayIndex].push(comment)
}
return commentsByDay
}
const getContractsQuery = (startTime: number, endTime: number) =>
firestore
.collection('contracts')
.where('createdTime', '>=', startTime)
.where('createdTime', '<', endTime)
.orderBy('createdTime', 'asc')
export async function getDailyContracts(
startTime: number,
numberOfDays: number
) {
const query = getContractsQuery(startTime, startTime + DAY_MS * numberOfDays)
const contracts = await getValues<Contract>(query)
const contractsByDay = range(0, numberOfDays).map(() => [] as Contract[])
for (const contract of contracts) {
const dayIndex = Math.floor((contract.createdTime - startTime) / DAY_MS)
contractsByDay[dayIndex].push(contract)
}
return contractsByDay
}
const getUsersQuery = (startTime: number, endTime: number) =>
firestore
.collection('users')
.where('createdTime', '>=', startTime)
.where('createdTime', '<', endTime)
.orderBy('createdTime', 'asc')
export async function getDailyNewUsers(
startTime: number,
numberOfDays: number
) {
const query = getUsersQuery(startTime, startTime + DAY_MS * numberOfDays)
const users = await getValues<User>(query)
const usersByDay = range(0, numberOfDays).map(() => [] as User[])
for (const user of users) {
const dayIndex = Math.floor((user.createdTime - startTime) / DAY_MS)
usersByDay[dayIndex].push(user)
}
return usersByDay
}
export const updateStatsCore = async () => {
const today = Date.now()
const startDate = today - numberOfDays * DAY_MS
log('Fetching data for stats update...')
const [dailyBets, dailyContracts, dailyComments, dailyNewUsers] =
await Promise.all([
getDailyBets(startDate.valueOf(), numberOfDays),
getDailyContracts(startDate.valueOf(), numberOfDays),
getDailyComments(startDate.valueOf(), numberOfDays),
getDailyNewUsers(startDate.valueOf(), numberOfDays),
])
logMemory()
const dailyBetCounts = dailyBets.map((bets) => bets.length)
const dailyContractCounts = dailyContracts.map(
(contracts) => contracts.length
)
const dailyCommentCounts = dailyComments.map((comments) => comments.length)
const dailyUserIds = zip(dailyContracts, dailyBets, dailyComments).map(
([contracts, bets, comments]) => {
const creatorIds = (contracts ?? []).map((c) => c.creatorId)
const betUserIds = (bets ?? []).map((bet) => bet.userId)
const commentUserIds = (comments ?? []).map((comment) => comment.userId)
return uniq([...creatorIds, ...betUserIds, ...commentUserIds])
}
)
log(
`Fetched ${sum(dailyBetCounts)} bets, ${sum(
dailyContractCounts
)} contracts, ${sum(dailyComments)} comments, from ${sum(
dailyNewUsers
)} unique users.`
)
const dailyActiveUsers = dailyUserIds.map((userIds) => userIds.length)
const weeklyActiveUsers = dailyUserIds.map((_, i) => {
const start = Math.max(0, i - 6)
const end = i
const uniques = new Set<string>()
for (let j = start; j <= end; j++)
dailyUserIds[j].forEach((userId) => uniques.add(userId))
return uniques.size
})
const monthlyActiveUsers = dailyUserIds.map((_, i) => {
const start = Math.max(0, i - 29)
const end = i
const uniques = new Set<string>()
for (let j = start; j <= end; j++)
dailyUserIds[j].forEach((userId) => uniques.add(userId))
return uniques.size
})
const weekOnWeekRetention = dailyUserIds.map((_userId, i) => {
const twoWeeksAgo = {
start: Math.max(0, i - 13),
end: Math.max(0, i - 7),
}
const lastWeek = {
start: Math.max(0, i - 6),
end: i,
}
const activeTwoWeeksAgo = new Set<string>()
for (let j = twoWeeksAgo.start; j <= twoWeeksAgo.end; j++) {
dailyUserIds[j].forEach((userId) => activeTwoWeeksAgo.add(userId))
}
const activeLastWeek = new Set<string>()
for (let j = lastWeek.start; j <= lastWeek.end; j++) {
dailyUserIds[j].forEach((userId) => activeLastWeek.add(userId))
}
const retainedCount = sumBy(Array.from(activeTwoWeeksAgo), (userId) =>
activeLastWeek.has(userId) ? 1 : 0
)
const retainedFrac = retainedCount / activeTwoWeeksAgo.size
return Math.round(retainedFrac * 100 * 100) / 100
})
const monthlyRetention = dailyUserIds.map((_userId, i) => {
const twoMonthsAgo = {
start: Math.max(0, i - 60),
end: Math.max(0, i - 30),
}
const lastMonth = {
start: Math.max(0, i - 30),
end: i,
}
const activeTwoMonthsAgo = new Set<string>()
for (let j = twoMonthsAgo.start; j <= twoMonthsAgo.end; j++) {
dailyUserIds[j].forEach((userId) => activeTwoMonthsAgo.add(userId))
}
const activeLastMonth = new Set<string>()
for (let j = lastMonth.start; j <= lastMonth.end; j++) {
dailyUserIds[j].forEach((userId) => activeLastMonth.add(userId))
}
const retainedCount = sumBy(Array.from(activeTwoMonthsAgo), (userId) =>
activeLastMonth.has(userId) ? 1 : 0
)
const retainedFrac = retainedCount / activeTwoMonthsAgo.size
return Math.round(retainedFrac * 100 * 100) / 100
})
const firstBetDict: { [userId: string]: number } = {}
for (let i = 0; i < dailyBets.length; i++) {
const bets = dailyBets[i]
for (const bet of bets) {
if (bet.userId in firstBetDict) continue
firstBetDict[bet.userId] = i
}
}
const weeklyActivationRate = dailyNewUsers.map((_, i) => {
const start = Math.max(0, i - 6)
const end = i
let activatedCount = 0
let newUsers = 0
for (let j = start; j <= end; j++) {
const userIds = dailyNewUsers[j].map((user) => user.id)
newUsers += userIds.length
for (const userId of userIds) {
const dayIndex = firstBetDict[userId]
if (dayIndex !== undefined && dayIndex <= end) {
activatedCount++
}
}
}
const frac = activatedCount / (newUsers || 1)
return Math.round(frac * 100 * 100) / 100
})
const dailySignups = dailyNewUsers.map((users) => users.length)
const dailyTopTenthActions = zip(
dailyContracts,
dailyBets,
dailyComments
).map(([contracts, bets, comments]) => {
const userIds = concat(
contracts?.map((c) => c.creatorId) ?? [],
bets?.map((b) => b.userId) ?? [],
comments?.map((c) => c.userId) ?? []
)
const counts = Object.values(countBy(userIds))
const sortedCounts = sortBy(counts, (count) => count).reverse()
if (sortedCounts.length === 0) return 0
const tenthPercentile = sortedCounts[Math.floor(sortedCounts.length * 0.1)]
return tenthPercentile
})
const weeklyTopTenthActions = dailyTopTenthActions.map((_, i) => {
const start = Math.max(0, i - 6)
const end = i
return average(dailyTopTenthActions.slice(start, end))
})
const monthlyTopTenthActions = dailyTopTenthActions.map((_, i) => {
const start = Math.max(0, i - 29)
const end = i
return average(dailyTopTenthActions.slice(start, end))
})
// Total mana divided by 100.
const dailyManaBet = dailyBets.map((bets) => {
return Math.round(sumBy(bets, (bet) => bet.amount) / 100)
})
const weeklyManaBet = dailyManaBet.map((_, i) => {
const start = Math.max(0, i - 6)
const end = i
const total = sum(dailyManaBet.slice(start, end))
if (end - start < 7) return (total * 7) / (end - start)
return total
})
const monthlyManaBet = dailyManaBet.map((_, i) => {
const start = Math.max(0, i - 29)
const end = i
const total = sum(dailyManaBet.slice(start, end))
const range = end - start + 1
if (range < 30) return (total * 30) / range
return total
})
const statsData = {
startDate: startDate.valueOf(),
dailyActiveUsers,
weeklyActiveUsers,
monthlyActiveUsers,
dailyBetCounts,
dailyContractCounts,
dailyCommentCounts,
dailySignups,
weekOnWeekRetention,
weeklyActivationRate,
monthlyRetention,
topTenthActions: {
daily: dailyTopTenthActions,
weekly: weeklyTopTenthActions,
monthly: monthlyTopTenthActions,
},
manaBet: {
daily: dailyManaBet,
weekly: weeklyManaBet,
monthly: monthlyManaBet,
},
}
log('Computed stats: ', statsData)
await firestore.doc('stats/stats').set(statsData)
}
export const updateStats = functions
.runWith({ memory: '1GB', timeoutSeconds: 540 })
.pubsub.schedule('every 60 minutes')
.onRun(updateStatsCore)

View File

@ -3,6 +3,7 @@ import * as admin from 'firebase-admin'
import { chunk } from 'lodash'
import { Contract } from '../../common/contract'
import { PrivateUser, User } from '../../common/user'
import { Group } from '../../common/group'
export const log = (...args: unknown[]) => {
console.log(`[${new Date().toISOString()}]`, ...args)
@ -66,6 +67,10 @@ export const getContract = (contractId: string) => {
return getDoc<Contract>('contracts', contractId)
}
export const getGroup = (groupId: string) => {
return getDoc<Group>('groups', groupId)
}
export const getUser = (userId: string) => {
return getDoc<User>('users', userId)
}

View File

@ -1,5 +1,5 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { z } from 'zod'
import { CPMMContract } from '../../common/contract'
import { User } from '../../common/user'
@ -10,36 +10,26 @@ import { Bet } from '../../common/bet'
import { getProbability } from '../../common/calculate'
import { noFees } from '../../common/fees'
import { APIError } from './api'
import { APIError, newEndpoint, validate } from './api'
import { redeemShares } from './redeem-shares'
export const withdrawLiquidity = functions
.runWith({ minInstances: 1 })
.https.onCall(
async (
data: {
contractId: string
},
context
) => {
const userId = context?.auth?.uid
if (!userId) return { status: 'error', message: 'Not authorized' }
const bodySchema = z.object({
contractId: z.string(),
})
const { contractId } = data
if (!contractId)
return { status: 'error', message: 'Missing contract id' }
export const withdrawliquidity = newEndpoint({}, async (req, auth) => {
const { contractId } = validate(bodySchema, req.body)
return await firestore
.runTransaction(async (trans) => {
const lpDoc = firestore.doc(`users/${userId}`)
const lpDoc = firestore.doc(`users/${auth.uid}`)
const lpSnap = await trans.get(lpDoc)
if (!lpSnap.exists) throw new APIError(400, 'User not found.')
const lp = lpSnap.data() as User
const contractDoc = firestore.doc(`contracts/${contractId}`)
const contractSnap = await trans.get(contractDoc)
if (!contractSnap.exists)
throw new APIError(400, 'Contract not found.')
if (!contractSnap.exists) throw new APIError(400, 'Contract not found.')
const contract = contractSnap.data() as CPMMContract
const liquidityCollection = firestore.collection(
@ -53,17 +43,17 @@ export const withdrawLiquidity = functions
)
const userShares = getUserLiquidityShares(
userId,
auth.uid,
contract,
liquidities
liquidities,
true
)
// zero all added amounts for now
// can add support for partial withdrawals in the future
liquiditiesSnap.docs
.filter(
(_, i) =>
!liquidities[i].isAnte && liquidities[i].userId === userId
(_, i) => !liquidities[i].isAnte && liquidities[i].userId === auth.uid
)
.forEach((doc) => trans.update(doc.ref, { amount: 0 }))
@ -98,7 +88,7 @@ export const withdrawLiquidity = functions
shares - payout < 1 // don't create bet if less than 1 share
? undefined
: ({
userId: userId,
userId: auth.uid,
contractId: contract.id,
amount:
(outcome === 'YES' ? prob : 1 - prob) * (shares - payout),
@ -114,9 +104,7 @@ export const withdrawLiquidity = functions
.filter((x) => x !== undefined)
for (const bet of bets) {
const doc = firestore
.collection(`contracts/${contract.id}/bets`)
.doc()
const doc = firestore.collection(`contracts/${contract.id}/bets`).doc()
trans.create(doc, { id: doc.id, ...bet })
}
@ -124,15 +112,10 @@ export const withdrawLiquidity = functions
})
.then(async (result) => {
// redeem surplus bet with pre-existing bets
await redeemShares(userId, contractId)
console.log('userid', userId, 'withdraws', result)
return { status: 'success', userShares: result }
await redeemShares(auth.uid, contractId)
console.log('userid', auth.uid, 'withdraws', result)
return result
})
.catch((e) => {
return { status: 'error', message: e.message }
})
}
)
})
const firestore = admin.firestore()

View File

@ -1,6 +1,7 @@
{
"compilerOptions": {
"baseUrl": "../",
"composite": true,
"module": "commonjs",
"noImplicitReturns": true,
"outDir": "lib",
@ -8,6 +9,11 @@
"strict": true,
"target": "es2017"
},
"references": [
{
"path": "../common"
}
],
"compileOnSave": true,
"include": ["src", "../common/**/*.ts"]
"include": ["src"]
}

Some files were not shown because too many files have changed in this diff Show More