Merge branch 'main' into loans2

This commit is contained in:
James Grugett 2022-08-15 15:34:01 -05:00
commit cf85a8cc61
361 changed files with 20423 additions and 8319 deletions

View File

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

43
.github/workflows/format.yml vendored Normal file
View File

@ -0,0 +1,43 @@
name: Reformat main
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
push:
branches: [main]
env:
FORCE_COLOR: 3
NEXT_TELEMETRY_DISABLED: 1
# mqp - i generated a personal token to use for these writes -- it's unclear
# why, but the default token didn't work, even when i gave it max permissions
jobs:
prettify:
name: Auto-prettify
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v3
with:
token: ${{ secrets.FORMATTER_ACCESS_TOKEN }}
- name: Restore cached node_modules
uses: actions/cache@v2
with:
path: '**/node_modules'
key: ${{ runner.os }}-${{ matrix.node-version }}-nodemodules-${{ hashFiles('**/yarn.lock') }}
- name: Install missing dependencies
run: yarn install --prefer-offline --frozen-lockfile
- name: Run Prettier on web client
working-directory: web
run: yarn format
- name: Commit any Prettier changes
uses: stefanzweifel/git-auto-commit-action@v4
with:
commit_message: Auto-prettification
branch: ${{ github.head_ref }}

View File

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

5
common/.gitignore vendored
View File

@ -1,6 +1,5 @@
# Compiled JavaScript files # Compiled JavaScript files
lib/**/*.js lib/
lib/**/*.js.map
# TypeScript v1 declaration files # TypeScript v1 declaration files
typings/ typings/
@ -10,4 +9,4 @@ node_modules/
package-lock.json package-lock.json
ui-debug.log ui-debug.log
firebase-debug.log firebase-debug.log

1
common/.yarnrc Normal file
View File

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

View File

@ -5,19 +5,19 @@ import {
CPMMBinaryContract, CPMMBinaryContract,
DPMBinaryContract, DPMBinaryContract,
FreeResponseContract, FreeResponseContract,
MultipleChoiceContract,
NumericContract, NumericContract,
} from './contract' } from './contract'
import { User } from './user' import { User } from './user'
import { LiquidityProvision } from './liquidity-provision' import { LiquidityProvision } from './liquidity-provision'
import { noFees } from './fees' import { noFees } from './fees'
import { ENV_CONFIG } from './envs/constants'
import { Answer } from './answer'
export const FIXED_ANTE = 100 export const FIXED_ANTE = ENV_CONFIG.fixedAnte ?? 100
// deprecated
export const PHANTOM_ANTE = 0.001
export const MINIMUM_ANTE = 50
export const HOUSE_LIQUIDITY_PROVIDER_ID = 'IPTOzEqrpkWmEzh6hwvAyY9PqFb2' // @ManifoldMarkets' id export const HOUSE_LIQUIDITY_PROVIDER_ID = 'IPTOzEqrpkWmEzh6hwvAyY9PqFb2' // @ManifoldMarkets' id
export const DEV_HOUSE_LIQUIDITY_PROVIDER_ID = '94YYTk1AFWfbWMpfYcvnnwI1veP2' // @ManifoldMarkets' id
export function getCpmmInitialLiquidity( export function getCpmmInitialLiquidity(
providerId: string, providerId: string,
@ -113,6 +113,50 @@ export function getFreeAnswerAnte(
return anteBet return anteBet
} }
export function getMultipleChoiceAntes(
creator: User,
contract: MultipleChoiceContract,
answers: string[],
betDocIds: string[]
) {
const { totalBets, totalShares } = contract
const amount = totalBets['0']
const shares = totalShares['0']
const p = 1 / answers.length
const { createdTime } = contract
const bets: Bet[] = answers.map((answer, i) => ({
id: betDocIds[i],
userId: creator.id,
contractId: contract.id,
amount,
shares,
outcome: i.toString(),
probBefore: p,
probAfter: p,
createdTime,
isAnte: true,
fees: noFees,
}))
const { username, name, avatarUrl } = creator
const answerObjects: Answer[] = answers.map((answer, i) => ({
id: i.toString(),
number: i,
contractId: contract.id,
createdTime,
userId: creator.id,
username,
name,
avatarUrl,
text: answer,
}))
return { bets, answerObjects }
}
export function getNumericAnte( export function getNumericAnte(
anteBettorId: string, anteBettorId: string,
contract: NumericContract, contract: NumericContract,

24
common/api.ts Normal file
View File

@ -0,0 +1,24 @@
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_FUNCTIONS_URL) {
return `${process.env.NEXT_PUBLIC_FUNCTIONS_URL}/${name}`
} else 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 id: string
userId: string userId: string
contractId: string contractId: string
createdTime: number
amount: number // bet size; negative if SELL bet amount: number // bet size; negative if SELL bet
loanAmount?: number loanAmount?: number
@ -25,12 +26,36 @@ export type Bet = {
isAnte?: boolean isAnte?: boolean
isLiquidityProvision?: boolean isLiquidityProvision?: boolean
isRedemption?: boolean isRedemption?: boolean
challengeSlug?: string
createdTime: number } & Partial<LimitProps>
}
export type NumericBet = Bet & { export type NumericBet = Bet & {
value: number value: number
allOutcomeShares: { [outcome: string]: number } allOutcomeShares: { [outcome: string]: number }
allBetAmounts: { [outcome: string]: number } 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
}

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

View File

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

View File

@ -1,5 +1,7 @@
import { difference } from 'lodash' import { difference } from 'lodash'
export const CATEGORIES_GROUP_SLUG_POSTFIX = '-default'
export const CATEGORIES = { export const CATEGORIES = {
politics: 'Politics', politics: 'Politics',
technology: 'Technology', technology: 'Technology',
@ -24,9 +26,18 @@ export const TO_CATEGORY = Object.fromEntries(
export const CATEGORY_LIST = Object.keys(CATEGORIES) export const CATEGORY_LIST = Object.keys(CATEGORIES)
export const EXCLUDED_CATEGORIES: category[] = ['fun', 'manifold', 'personal'] export const EXCLUDED_CATEGORIES: category[] = [
'fun',
'manifold',
'personal',
'covid',
'gaming',
'crypto',
]
export const DEFAULT_CATEGORIES = difference( export const DEFAULT_CATEGORIES = difference(CATEGORY_LIST, EXCLUDED_CATEGORIES)
CATEGORY_LIST,
EXCLUDED_CATEGORIES export const DEFAULT_CATEGORY_GROUPS = DEFAULT_CATEGORIES.map((c) => ({
) slug: c.toLowerCase() + CATEGORIES_GROUP_SLUG_POSTFIX,
name: CATEGORIES[c as category],
}))

65
common/challenge.ts Normal file
View File

@ -0,0 +1,65 @@
import { IS_PRIVATE_MANIFOLD } from './envs/constants'
export type Challenge = {
// The link to send: https://manifold.markets/challenges/username/market-slug/{slug}
// Also functions as the unique id for the link.
slug: string
// The user that created the challenge.
creatorId: string
creatorUsername: string
creatorName: string
creatorAvatarUrl?: string
// Displayed to people claiming the challenge
message: string
// How much to put up
creatorAmount: number
// YES or NO for now
creatorOutcome: string
// Different than the creator
acceptorOutcome: string
acceptorAmount: number
// The probability the challenger thinks
creatorOutcomeProb: number
contractId: string
contractSlug: string
contractQuestion: string
contractCreatorUsername: string
createdTime: number
// If null, the link is valid forever
expiresTime: number | null
// How many times the challenge can be used
maxUses: number
// Used for simpler caching
acceptedByUserIds: string[]
// Successful redemptions of the link
acceptances: Acceptance[]
// TODO: will have to fill this on resolve contract
isResolved: boolean
resolutionOutcome?: string
}
export type Acceptance = {
// User that accepted the challenge
userId: string
userUsername: string
userName: string
userAvatarUrl: string
// The ID of the successful bet that tracks the money moved
betId: string
createdTime: number
}
export const CHALLENGES_ENABLED = !IS_PRIVATE_MANIFOLD

View File

@ -169,7 +169,7 @@ export const charities: Charity[] = [
{ {
name: "Founder's Pledge Climate Change Fund", name: "Founder's Pledge Climate Change Fund",
website: 'https://founderspledge.com/funds/climate-change-fund', website: 'https://founderspledge.com/funds/climate-change-fund',
photo: 'https://i.imgur.com/ZAhzHu4.png', photo: 'https://i.imgur.com/9turaJW.png',
preview: preview:
'The Climate Change Fund aims to sustainably reach net-zero emissions globally, while still allowing growth to free millions from energy poverty.', 'The Climate Change Fund aims to sustainably reach net-zero emissions globally, while still allowing growth to free millions from energy poverty.',
description: `The Climate Change Fund aims to sustainably reach net-zero emissions globally. description: `The Climate Change Fund aims to sustainably reach net-zero emissions globally.
@ -183,7 +183,7 @@ export const charities: Charity[] = [
{ {
name: "Founder's Pledge Patient Philanthropy Fund", name: "Founder's Pledge Patient Philanthropy Fund",
website: 'https://founderspledge.com/funds/patient-philanthropy-fund', website: 'https://founderspledge.com/funds/patient-philanthropy-fund',
photo: 'https://i.imgur.com/ZAhzHu4.png', photo: 'https://i.imgur.com/LLR6CI6.png',
preview: preview:
'The Patient Philanthropy Project aims to safeguard and benefit the long-term future of humanity', 'The Patient Philanthropy Project aims to safeguard and benefit the long-term future of humanity',
description: `The Patient Philanthropy Project focuses on how we can collectively grow our resources to support the long-term flourishing of humanity. It addresses a crucial gap: as a society, we spend much too little on safeguarding and benefiting future generations. In fact, we spend more money on ice cream each year than we do on preventing our own extinction. However, people in the future - who do not have a voice in their future survival or environment - matter. Lots of them may yet come into existence and we have the ability to positively affect their lives now, if only by making sure we avoid major catastrophes that could destroy our common future. description: `The Patient Philanthropy Project focuses on how we can collectively grow our resources to support the long-term flourishing of humanity. It addresses a crucial gap: as a society, we spend much too little on safeguarding and benefiting future generations. In fact, we spend more money on ice cream each year than we do on preventing our own extinction. However, people in the future - who do not have a voice in their future survival or environment - matter. Lots of them may yet come into existence and we have the ability to positively affect their lives now, if only by making sure we avoid major catastrophes that could destroy our common future.
@ -300,10 +300,29 @@ Future plans: We expect to focus on similar theoretical problems in alignment un
name: 'Wild Animal Initiative', name: 'Wild Animal Initiative',
website: 'https://www.wildanimalinitiative.org/', website: 'https://www.wildanimalinitiative.org/',
ein: '82-2281466', ein: '82-2281466',
tags: ['Featured'] as CharityTag[],
photo: 'https://i.imgur.com/bOVUnDm.png', photo: 'https://i.imgur.com/bOVUnDm.png',
preview: 'We want to make life better for wild animals.', preview:
description: 'Our mission is to understand and improve the lives of wild animals.',
'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.', 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', name: 'New Incentives',
@ -516,6 +535,36 @@ 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.`, 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.`,
},
{
name: 'Founders Pledge Global Health and Development Fund',
website: 'https://founderspledge.com/funds/global-health-and-development',
photo: 'https://i.imgur.com/EXbxH7T.png',
preview:
'Tackling the vast global inequalities in health, wealth and opportunity',
description: `Nearly half the world lives on less than $2.50 a day, yet giving by the worlds richest often overlooks the worlds poorest and most vulnerable. Despite the average American household being richer than 90% of the rest of the world, only 6% of US charitable giving goes to charities which work internationally.
This Fund is focused on helping those who need it most, wherever that help can make the biggest difference. By building a mixed portfolio of direct and indirect interventions, such as policy work, we aim to:
Improve the lives of the world's most vulnerable people.
Reduce the number of easily preventable deaths worldwide.
Work towards sustainable, systemic change.`,
},
].map((charity) => { ].map((charity) => {
const slug = charity.name.toLowerCase().replace(/\s/g, '-') const slug = charity.name.toLowerCase().replace(/\s/g, '-')
return { return {

View File

@ -1,3 +1,5 @@
import type { JSONContent } from '@tiptap/core'
// Currently, comments are created after the bet, not atomically with the bet. // Currently, comments are created after the bet, not atomically with the bet.
// They're uniquely identified by the pair contractId/betId. // They're uniquely identified by the pair contractId/betId.
export type Comment = { export type Comment = {
@ -9,7 +11,9 @@ export type Comment = {
replyToCommentId?: string replyToCommentId?: string
userId: string userId: string
text: string /** @deprecated - content now stored as JSON in content*/
text?: string
content: JSONContent
createdTime: number createdTime: number
// Denormalized, for rendering comments // Denormalized, for rendering comments

View File

@ -1,13 +1,22 @@
import { Answer } from './answer' import { Answer } from './answer'
import { Fees } from './fees' import { Fees } from './fees'
import { JSONContent } from '@tiptap/core'
import { GroupLink } from 'common/group'
export type AnyMechanism = DPM | CPMM export type AnyMechanism = DPM | CPMM
export type AnyOutcomeType = Binary | FreeResponse | Numeric export type AnyOutcomeType =
| Binary
| MultipleChoice
| PseudoNumeric
| FreeResponse
| Numeric
export type AnyContractType = export type AnyContractType =
| (CPMM & Binary) | (CPMM & Binary)
| (CPMM & PseudoNumeric)
| (DPM & Binary) | (DPM & Binary)
| (DPM & FreeResponse) | (DPM & FreeResponse)
| (DPM & Numeric) | (DPM & Numeric)
| (DPM & MultipleChoice)
export type Contract<T extends AnyContractType = AnyContractType> = { export type Contract<T extends AnyContractType = AnyContractType> = {
id: string id: string
@ -19,7 +28,7 @@ export type Contract<T extends AnyContractType = AnyContractType> = {
creatorAvatarUrl?: string creatorAvatarUrl?: string
question: 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[] tags: string[]
lowercaseTags: string[] lowercaseTags: string[]
visibility: 'public' | 'unlisted' visibility: 'public' | 'unlisted'
@ -33,7 +42,7 @@ export type Contract<T extends AnyContractType = AnyContractType> = {
isResolved: boolean isResolved: boolean
resolutionTime?: number // When the contract creator resolved the market resolutionTime?: number // When the contract creator resolved the market
resolution?: string resolution?: string
resolutionProbability?: number, resolutionProbability?: number
closeEmailsSent?: number closeEmailsSent?: number
@ -42,11 +51,19 @@ export type Contract<T extends AnyContractType = AnyContractType> = {
volume7Days: number volume7Days: number
collectedFees: Fees collectedFees: Fees
groupSlugs?: string[]
groupLinks?: GroupLink[]
uniqueBettorIds?: string[]
uniqueBettorCount?: number
popularityScore?: number
} & T } & T
export type BinaryContract = Contract & Binary export type BinaryContract = Contract & Binary
export type PseudoNumericContract = Contract & PseudoNumeric
export type NumericContract = Contract & Numeric export type NumericContract = Contract & Numeric
export type FreeResponseContract = Contract & FreeResponse export type FreeResponseContract = Contract & FreeResponse
export type MultipleChoiceContract = Contract & MultipleChoice
export type DPMContract = Contract & DPM export type DPMContract = Contract & DPM
export type CPMMContract = Contract & CPMM export type CPMMContract = Contract & CPMM
export type DPMBinaryContract = BinaryContract & DPM export type DPMBinaryContract = BinaryContract & DPM
@ -75,6 +92,18 @@ export type Binary = {
resolution?: resolution 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 = { export type FreeResponse = {
outcomeType: 'FREE_RESPONSE' outcomeType: 'FREE_RESPONSE'
answers: Answer[] // Used for outcomeType 'FREE_RESPONSE'. answers: Answer[] // Used for outcomeType 'FREE_RESPONSE'.
@ -82,6 +111,13 @@ export type FreeResponse = {
resolutions?: { [outcome: string]: number } // Used for MKT resolution. resolutions?: { [outcome: string]: number } // Used for MKT resolution.
} }
export type MultipleChoice = {
outcomeType: 'MULTIPLE_CHOICE'
answers: Answer[]
resolution?: string | 'MKT' | 'CANCEL'
resolutions?: { [outcome: string]: number } // Used for MKT resolution.
}
export type Numeric = { export type Numeric = {
outcomeType: 'NUMERIC' outcomeType: 'NUMERIC'
bucketCount: number bucketCount: number
@ -94,10 +130,16 @@ export type Numeric = {
export type outcomeType = AnyOutcomeType['outcomeType'] export type outcomeType = AnyOutcomeType['outcomeType']
export type resolution = 'YES' | 'NO' | 'MKT' | 'CANCEL' export type resolution = 'YES' | 'NO' | 'MKT' | 'CANCEL'
export const RESOLUTIONS = ['YES', 'NO', 'MKT', 'CANCEL'] as const export const RESOLUTIONS = ['YES', 'NO', 'MKT', 'CANCEL'] as const
export const OUTCOME_TYPES = ['BINARY', 'FREE_RESPONSE', 'NUMERIC'] as const export const OUTCOME_TYPES = [
'BINARY',
'MULTIPLE_CHOICE',
'FREE_RESPONSE',
'PSEUDO_NUMERIC',
'NUMERIC',
] as const
export const MAX_QUESTION_LENGTH = 480 export const MAX_QUESTION_LENGTH = 480
export const MAX_DESCRIPTION_LENGTH = 10000 export const MAX_DESCRIPTION_LENGTH = 16000
export const MAX_TAG_LENGTH = 60 export const MAX_TAG_LENGTH = 60
export const CPMM_MIN_POOL_QTY = 0.01 export const CPMM_MIN_POOL_QTY = 0.01

View File

@ -25,6 +25,10 @@ export function isAdmin(email: string) {
return ENV_CONFIG.adminEmails.includes(email) return ENV_CONFIG.adminEmails.includes(email)
} }
export function isManifoldId(userId: string) {
return userId === 'IPTOzEqrpkWmEzh6hwvAyY9PqFb2'
}
export const DOMAIN = ENV_CONFIG.domain export const DOMAIN = ENV_CONFIG.domain
export const FIREBASE_CONFIG = ENV_CONFIG.firebaseConfig export const FIREBASE_CONFIG = ENV_CONFIG.firebaseConfig
export const PROJECT_ID = ENV_CONFIG.firebaseConfig.projectId export const PROJECT_ID = ENV_CONFIG.firebaseConfig.projectId
@ -34,5 +38,9 @@ export const IS_PRIVATE_MANIFOLD = ENV_CONFIG.visibility === 'PRIVATE'
export const CORS_ORIGIN_MANIFOLD = new RegExp( export const CORS_ORIGIN_MANIFOLD = new RegExp(
'^https?://(?:[a-zA-Z0-9\\-]+\\.)*' + escapeRegExp(ENV_CONFIG.domain) + '$' '^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 // Any localhost server on any port
export const CORS_ORIGIN_LOCALHOST = /^http:\/\/localhost:\d+$/ export const CORS_ORIGIN_LOCALHOST = /^http:\/\/localhost:\d+$/

View File

@ -2,6 +2,7 @@ import { EnvConfig, PROD_CONFIG } from './prod'
export const DEV_CONFIG: EnvConfig = { export const DEV_CONFIG: EnvConfig = {
...PROD_CONFIG, ...PROD_CONFIG,
domain: 'dev.manifold.markets',
firebaseConfig: { firebaseConfig: {
apiKey: 'AIzaSyBoq3rzUa8Ekyo3ZaTnlycQYPRCA26VpOw', apiKey: 'AIzaSyBoq3rzUa8Ekyo3ZaTnlycQYPRCA26VpOw',
authDomain: 'dev-mantic-markets.firebaseapp.com', authDomain: 'dev-mantic-markets.firebaseapp.com',

View File

@ -18,13 +18,18 @@ export type EnvConfig = {
faviconPath?: string // Should be a file in /public faviconPath?: string // Should be a file in /public
navbarLogoPath?: string navbarLogoPath?: string
newQuestionPlaceholders: string[] newQuestionPlaceholders: string[]
// Currency controls
fixedAnte?: number
startingBalance?: number
referralBonus?: number
} }
type FirebaseConfig = { type FirebaseConfig = {
apiKey: string apiKey: string
authDomain: string authDomain: string
projectId: string projectId: string
region: string region?: string
storageBucket: string storageBucket: string
messagingSenderId: string messagingSenderId: string
appId: string appId: string

View File

@ -9,7 +9,21 @@ export type Group = {
memberIds: string[] // User ids memberIds: string[] // User ids
anyoneCanJoin: boolean anyoneCanJoin: boolean
contractIds: string[] contractIds: string[]
chatDisabled?: boolean
mostRecentChatActivityTime?: number
mostRecentContractAddedTime?: number
} }
export const MAX_GROUP_NAME_LENGTH = 75 export const MAX_GROUP_NAME_LENGTH = 75
export const MAX_ABOUT_LENGTH = 140 export const MAX_ABOUT_LENGTH = 140
export const MAX_ID_LENGTH = 60 export const MAX_ID_LENGTH = 60
export const NEW_USER_GROUP_SLUGS = ['updates', 'bugs', 'welcome']
export const GROUP_CHAT_SLUG = 'chat'
export type GroupLink = {
slug: string
name: string
groupId: string
createdTime: number
userId?: string
}

View File

@ -1,4 +1,6 @@
import { Bet, NumericBet } from './bet' import { sortBy, sum, sumBy } from 'lodash'
import { Bet, fill, LimitBet, NumericBet } from './bet'
import { import {
calculateDpmShares, calculateDpmShares,
getDpmProbability, getDpmProbability,
@ -6,20 +8,32 @@ import {
getNumericBets, getNumericBets,
calculateNumericDpmShares, calculateNumericDpmShares,
} from './calculate-dpm' } from './calculate-dpm'
import { calculateCpmmPurchase, getCpmmProbability } from './calculate-cpmm' import {
calculateCpmmAmountToProb,
calculateCpmmPurchase,
CpmmState,
getCpmmProbability,
} from './calculate-cpmm'
import { import {
CPMMBinaryContract, CPMMBinaryContract,
DPMBinaryContract, DPMBinaryContract,
FreeResponseContract, FreeResponseContract,
MultipleChoiceContract,
NumericContract, NumericContract,
PseudoNumericContract,
} from './contract' } from './contract'
import { noFees } from './fees' import { noFees } from './fees'
import { addObjects } from './util/object' import { addObjects, removeUndefinedProps } from './util/object'
import { NUMERIC_FIXED_VAR } from './numeric-constants' 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 = { export type BetInfo = {
newBet: CandidateBet<Bet> newBet: CandidateBet
newPool?: { [outcome: string]: number } newPool?: { [outcome: string]: number }
newTotalShares?: { [outcome: string]: number } newTotalShares?: { [outcome: string]: number }
newTotalBets?: { [outcome: string]: number } newTotalBets?: { [outcome: string]: number }
@ -27,37 +41,236 @@ export type BetInfo = {
newP?: number newP?: number
} }
export const getNewBinaryCpmmBetInfo = ( const computeFill = (
outcome: 'YES' | 'NO',
amount: number, amount: number,
contract: CPMMBinaryContract outcome: 'YES' | 'NO',
limitProb: number | undefined,
cpmmState: CpmmState,
matchedBet: LimitBet | undefined
) => { ) => {
const { shares, newPool, newP, fees } = calculateCpmmPurchase( const prob = getCpmmProbability(cpmmState.pool, cpmmState.p)
contract,
amount,
outcome
)
const { pool, p, totalLiquidity } = contract if (
const probBefore = getCpmmProbability(pool, p) limitProb !== undefined &&
const probAfter = getCpmmProbability(newPool, newP) (outcome === 'YES'
? floatingGreaterEqual(prob, limitProb) &&
const newBet: CandidateBet<Bet> = { (matchedBet?.limitProb ?? 1) > limitProb
contractId: contract.id, : floatingLesserEqual(prob, limitProb) &&
amount, (matchedBet?.limitProb ?? 0) < limitProb)
shares, ) {
outcome, // No fill.
fees, return undefined
loanAmount: 0,
probBefore,
probAfter,
createdTime: Date.now(),
} }
const { liquidityFee } = fees const timestamp = Date.now()
const newTotalLiquidity = (totalLiquidity ?? 0) + liquidityFee
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[]
) => {
if (isNaN(betAmount)) {
throw new Error('Invalid bet amount: ${betAmount}')
}
if (isNaN(limitProb ?? 0)) {
throw new Error('Invalid limitProb: ${limitProb}')
}
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 getBinaryBetStats = (
outcome: 'YES' | 'NO',
betAmount: number,
contract: CPMMBinaryContract | PseudoNumericContract,
limitProb: number,
unfilledBets: LimitBet[]
) => {
const { newBet } = getBinaryCpmmBetInfo(
outcome,
betAmount ?? 0,
contract,
limitProb,
unfilledBets as LimitBet[]
)
const remainingMatched =
((newBet.orderAmount ?? 0) - newBet.amount) /
(outcome === 'YES' ? limitProb : 1 - limitProb)
const currentPayout = newBet.shares + remainingMatched
const currentReturn = betAmount ? (currentPayout - betAmount) / betAmount : 0
const totalFees = sum(Object.values(newBet.fees))
return { currentPayout, currentReturn, totalFees, newBet }
} }
export const getNewBinaryDpmBetInfo = ( export const getNewBinaryDpmBetInfo = (
@ -91,7 +304,7 @@ export const getNewBinaryDpmBetInfo = (
const probBefore = getDpmProbability(contract.totalShares) const probBefore = getDpmProbability(contract.totalShares)
const probAfter = getDpmProbability(newTotalShares) const probAfter = getDpmProbability(newTotalShares)
const newBet: CandidateBet<Bet> = { const newBet: CandidateBet = {
contractId: contract.id, contractId: contract.id,
amount, amount,
loanAmount: 0, loanAmount: 0,
@ -109,7 +322,7 @@ export const getNewBinaryDpmBetInfo = (
export const getNewMultiBetInfo = ( export const getNewMultiBetInfo = (
outcome: string, outcome: string,
amount: number, amount: number,
contract: FreeResponseContract contract: FreeResponseContract | MultipleChoiceContract,
) => { ) => {
const { pool, totalShares, totalBets } = contract const { pool, totalShares, totalBets } = contract
@ -127,7 +340,7 @@ export const getNewMultiBetInfo = (
const probBefore = getDpmOutcomeProbability(totalShares, outcome) const probBefore = getDpmOutcomeProbability(totalShares, outcome)
const probAfter = getDpmOutcomeProbability(newTotalShares, outcome) const probAfter = getDpmOutcomeProbability(newTotalShares, outcome)
const newBet: CandidateBet<Bet> = { const newBet: CandidateBet = {
contractId: contract.id, contractId: contract.id,
amount, amount,
loanAmount: 0, loanAmount: 0,

View File

@ -5,12 +5,15 @@ import {
CPMM, CPMM,
DPM, DPM,
FreeResponse, FreeResponse,
MultipleChoice,
Numeric, Numeric,
outcomeType, outcomeType,
PseudoNumeric,
} from './contract' } from './contract'
import { User } from './user' import { User } from './user'
import { parseTags } from './util/parse' import { parseTags, richTextToString } from './util/parse'
import { removeUndefinedProps } from './util/object' import { removeUndefinedProps } from './util/object'
import { JSONContent } from '@tiptap/core'
export function getNewContract( export function getNewContract(
id: string, id: string,
@ -18,7 +21,7 @@ export function getNewContract(
creator: User, creator: User,
question: string, question: string,
outcomeType: outcomeType, outcomeType: outcomeType,
description: string, description: JSONContent,
initialProb: number, initialProb: number,
ante: number, ante: number,
closeTime: number, closeTime: number,
@ -27,18 +30,30 @@ export function getNewContract(
// used for numeric markets // used for numeric markets
bucketCount: number, bucketCount: number,
min: number, min: number,
max: number max: number,
isLogScale: boolean,
// for multiple choice
answers: string[]
) { ) {
const tags = parseTags( 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 lowercaseTags = tags.map((tag) => tag.toLowerCase())
const propsByOutcomeType = const propsByOutcomeType =
outcomeType === 'BINARY' outcomeType === 'BINARY'
? getBinaryCpmmProps(initialProb, ante) // getBinaryDpmProps(initialProb, ante) ? getBinaryCpmmProps(initialProb, ante) // getBinaryDpmProps(initialProb, ante)
: outcomeType === 'PSEUDO_NUMERIC'
? getPseudoNumericCpmmProps(initialProb, ante, min, max, isLogScale)
: outcomeType === 'NUMERIC' : outcomeType === 'NUMERIC'
? getNumericProps(ante, bucketCount, min, max) ? getNumericProps(ante, bucketCount, min, max)
: outcomeType === 'MULTIPLE_CHOICE'
? getMultipleChoiceProps(ante, answers)
: getFreeAnswerProps(ante) : getFreeAnswerProps(ante)
const contract: Contract = removeUndefinedProps({ const contract: Contract = removeUndefinedProps({
@ -52,7 +67,7 @@ export function getNewContract(
creatorAvatarUrl: creator.avatarUrl, creatorAvatarUrl: creator.avatarUrl,
question: question.trim(), question: question.trim(),
description: description.trim(), description,
tags, tags,
lowercaseTags, lowercaseTags,
visibility: 'public', visibility: 'public',
@ -111,6 +126,24 @@ const getBinaryCpmmProps = (initialProb: number, ante: number) => {
return system 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 getFreeAnswerProps = (ante: number) => {
const system: DPM & FreeResponse = { const system: DPM & FreeResponse = {
mechanism: 'dpm-2', mechanism: 'dpm-2',
@ -124,6 +157,26 @@ const getFreeAnswerProps = (ante: number) => {
return system return system
} }
const getMultipleChoiceProps = (ante: number, answers: string[]) => {
const numAnswers = answers.length
const betAnte = ante / numAnswers
const betShares = Math.sqrt(ante ** 2 / numAnswers)
const defaultValues = (x: any) =>
Object.fromEntries(range(0, numAnswers).map((k) => [k, x]))
const system: DPM & MultipleChoice = {
mechanism: 'dpm-2',
outcomeType: 'MULTIPLE_CHOICE',
pool: defaultValues(betAnte),
totalShares: defaultValues(betShares),
totalBets: defaultValues(betAnte),
answers: [],
}
return system
}
const getNumericProps = ( const getNumericProps = (
ante: number, ante: number,
bucketCount: number, bucketCount: number,

View File

@ -22,6 +22,8 @@ export type Notification = {
sourceSlug?: string sourceSlug?: string
sourceTitle?: string sourceTitle?: string
isSeenOnHref?: string
} }
export type notification_source_types = export type notification_source_types =
| 'contract' | 'contract'
@ -33,6 +35,9 @@ export type notification_source_types =
| 'tip' | 'tip'
| 'admin_message' | 'admin_message'
| 'group' | 'group'
| 'user'
| 'bonus'
| 'challenge'
export type notification_source_update_types = export type notification_source_update_types =
| 'created' | 'created'
@ -53,3 +58,11 @@ export type notification_reason_types =
| 'on_new_follow' | 'on_new_follow'
| 'you_follow_user' | 'you_follow_user'
| 'added_you_to_group' | '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'
| 'user_joined_from_your_group_invite'
| 'challenge_accepted'

View File

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

View File

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

View File

@ -2,7 +2,11 @@ import { sum, groupBy, sumBy, mapValues } from 'lodash'
import { Bet, NumericBet } from './bet' import { Bet, NumericBet } from './bet'
import { deductDpmFees, getDpmProbability } from './calculate-dpm' import { deductDpmFees, getDpmProbability } from './calculate-dpm'
import { DPMContract, FreeResponseContract } from './contract' import {
DPMContract,
FreeResponseContract,
MultipleChoiceContract,
} from './contract'
import { DPM_CREATOR_FEE, DPM_FEES, DPM_PLATFORM_FEE } from './fees' import { DPM_CREATOR_FEE, DPM_FEES, DPM_PLATFORM_FEE } from './fees'
import { addObjects } from './util/object' import { addObjects } from './util/object'
@ -180,7 +184,7 @@ export const getDpmMktPayouts = (
export const getPayoutsMultiOutcome = ( export const getPayoutsMultiOutcome = (
resolutions: { [outcome: string]: number }, resolutions: { [outcome: string]: number },
contract: FreeResponseContract, contract: FreeResponseContract | MultipleChoiceContract,
bets: Bet[] bets: Bet[]
) => { ) => {
const poolTotal = sum(Object.values(contract.pool)) const poolTotal = sum(Object.values(contract.pool))

View File

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

View File

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

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

@ -0,0 +1,48 @@
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 + 1)
return 10 ** logValue + min - 1
}
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 (value < min) return 0
if (value > max) return 1
if (isLogScale) {
return Math.log10(value - min + 1) / Math.log10(max - min + 1)
}
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( const { payouts: resolvePayouts } = getPayouts(
resolution as string, resolution as string,
{},
contract, contract,
openBets, openBets,
[], [],
{},
resolutionProb resolutionProb
) )

View File

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

View File

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

View File

@ -1,6 +1,6 @@
// A txn (pronounced "texan") respresents a payment between two ids on Manifold // A txn (pronounced "texan") respresents a payment between two ids on Manifold
// Shortened from "transaction" to distinguish from Firebase transactions (and save chars) // Shortened from "transaction" to distinguish from Firebase transactions (and save chars)
type AnyTxnType = Donation | Tip | Manalink type AnyTxnType = Donation | Tip | Manalink | Referral | Bonus
type SourceType = 'USER' | 'CONTRACT' | 'CHARITY' | 'BANK' type SourceType = 'USER' | 'CONTRACT' | 'CHARITY' | 'BANK'
export type Txn<T extends AnyTxnType = AnyTxnType> = { export type Txn<T extends AnyTxnType = AnyTxnType> = {
@ -16,7 +16,8 @@ export type Txn<T extends AnyTxnType = AnyTxnType> = {
amount: number amount: number
token: 'M$' // | 'USD' | MarketOutcome token: 'M$' // | 'USD' | MarketOutcome
category: 'CHARITY' | 'MANALINK' | 'TIP' // | 'BET' category: 'CHARITY' | 'MANALINK' | 'TIP' | 'REFERRAL' | 'UNIQUE_BETTOR_BONUS'
// Any extra data // Any extra data
data?: { [key: string]: any } data?: { [key: string]: any }
@ -35,8 +36,9 @@ type Tip = {
toType: 'USER' toType: 'USER'
category: 'TIP' category: 'TIP'
data: { data: {
contractId: string
commentId: string commentId: string
contractId?: string
groupId?: string
} }
} }
@ -46,6 +48,19 @@ type Manalink = {
category: 'MANALINK' 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 DonationTxn = Txn & Donation
export type TipTxn = Txn & Tip export type TipTxn = Txn & Tip
export type ManalinkTxn = Txn & Manalink 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 = { export type User = {
id: string id: string
createdTime: number createdTime: number
@ -33,10 +35,18 @@ export type User = {
followerCountCached: number followerCountCached: number
followedCategories?: string[] followedCategories?: string[]
referredByUserId?: string
referredByContractId?: string
referredByGroupId?: string
lastPingTime?: number
shouldShowWelcome?: boolean
} }
export const STARTING_BALANCE = 1000 export const STARTING_BALANCE = ENV_CONFIG.startingBalance ?? 1000
export const SUS_STARTING_BALANCE = 10 // for sus users, i.e. multiple sign ups for same person // for sus users, i.e. multiple sign ups for same person
export const SUS_STARTING_BALANCE = ENV_CONFIG.startingBalance ?? 10
export const REFERRAL_AMOUNT = ENV_CONFIG.referralBonus ?? 500
export type PrivateUser = { export type PrivateUser = {
id: string // same as User.id id: string // same as User.id
@ -47,6 +57,7 @@ export type PrivateUser = {
unsubscribedFromCommentEmails?: boolean unsubscribedFromCommentEmails?: boolean
unsubscribedFromAnswerEmails?: boolean unsubscribedFromAnswerEmails?: boolean
unsubscribedFromGenericEmails?: boolean unsubscribedFromGenericEmails?: boolean
manaBonusEmailSent?: boolean
initialDeviceToken?: string initialDeviceToken?: string
initialIpAddress?: string initialIpAddress?: string
apiKey?: string apiKey?: string
@ -62,3 +73,6 @@ export type PortfolioMetrics = {
timestamp: number timestamp: number
userId: string userId: string
} }
export const MANIFOLD_USERNAME = 'ManifoldMarkets'
export const MANIFOLD_AVATAR_URL = 'https://manifold.markets/logo-bg-white.png'

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

@ -1,3 +1,38 @@
export function filterDefined<T>(array: (T | null | undefined)[]) { export function filterDefined<T>(array: (T | null | undefined)[]) {
return array.filter((item) => item !== null && item !== undefined) as T[] return array.filter((item) => item !== null && item !== undefined) as T[]
} }
export function buildArray<T>(
...params: (T | T[] | false | undefined | null)[]
) {
const array: T[] = []
for (const el of params) {
if (Array.isArray(el)) {
array.push(...el)
} else if (el) {
array.push(el)
}
}
return array
}
export function groupConsecutive<T, U>(xs: T[], key: (x: T) => U) {
if (!xs.length) {
return []
}
const result = []
let curr = { key: key(xs[0]), items: [xs[0]] }
for (const x of xs.slice(1)) {
const k = key(x)
if (k !== curr.key) {
result.push(curr)
curr = { key: k, items: [x] }
} else {
curr.items.push(x)
}
}
result.push(curr)
return result
}

View File

@ -33,18 +33,24 @@ export function formatPercent(zeroToOne: number) {
return (zeroToOne * 100).toFixed(decimalPlaces) + '%' return (zeroToOne * 100).toFixed(decimalPlaces) + '%'
} }
const showPrecision = (x: number, sigfigs: number) =>
// convert back to number for weird formatting reason
`${Number(x.toPrecision(sigfigs))}`
// Eg 1234567.89 => 1.23M; 5678 => 5.68K // Eg 1234567.89 => 1.23M; 5678 => 5.68K
export function formatLargeNumber(num: number, sigfigs = 2): string { export function formatLargeNumber(num: number, sigfigs = 2): string {
const absNum = Math.abs(num) const absNum = Math.abs(num)
if (absNum < 1000) { if (absNum < 1) return showPrecision(num, sigfigs)
return '' + Number(num.toPrecision(sigfigs))
} if (absNum < 100) return showPrecision(num, 2)
if (absNum < 1000) return showPrecision(num, 3)
if (absNum < 10000) return showPrecision(num, 4)
const suffix = ['', 'K', 'M', 'B', 'T', 'Q'] const suffix = ['', 'K', 'M', 'B', 'T', 'Q']
const suffixIdx = Math.floor(Math.log10(absNum) / 3) const i = Math.floor(Math.log10(absNum) / 3)
const suffixStr = suffix[suffixIdx]
const numStr = (num / Math.pow(10, 3 * suffixIdx)).toPrecision(sigfigs) const numStr = showPrecision(num / Math.pow(10, 3 * i), sigfigs)
return `${Number(numStr)}${suffixStr}` return `${numStr}${suffix[i] ?? ''}`
} }
export function toCamelCase(words: string) { export function toCamelCase(words: string) {

View File

@ -34,3 +34,17 @@ export function median(xs: number[]) {
export function average(xs: number[]) { export function average(xs: number[]) {
return sum(xs) / xs.length 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,29 @@
import { MAX_TAG_LENGTH } from '../contract' 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'
import { Mention } from '@tiptap/extension-mention'
import Iframe from './tiptap-iframe'
import TiptapTweet from './tiptap-tweet-type'
import { uniq } from 'lodash'
export function parseTags(text: string) { export function parseTags(text: string) {
const regex = /(?:^|\s)(?:[#][a-z0-9_]+)/gi const regex = /(?:^|\s)(?:[#][a-z0-9_]+)/gi
@ -27,3 +52,52 @@ export function parseWordsAsTags(text: string) {
.join(' ') .join(' ')
return parseTags(taggedText) return parseTags(taggedText)
} }
// TODO: fuzzy matching
export const wordIn = (word: string, corpus: string) =>
corpus.toLocaleLowerCase().includes(word.toLocaleLowerCase())
const checkAgainstQuery = (query: string, corpus: string) =>
query.split(' ').every((word) => wordIn(word, corpus))
export const searchInAny = (query: string, ...fields: string[]) =>
fields.some((field) => checkAgainstQuery(query, field))
/** @return user ids of all \@mentions */
export function parseMentions(data: JSONContent): string[] {
const mentions = data.content?.flatMap(parseMentions) ?? [] //dfs
if (data.type === 'mention' && data.attrs) {
mentions.push(data.attrs.id as string)
}
return uniq(mentions)
}
// 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,
Mention,
Iframe,
TiptapTweet,
]
export function richTextToString(text?: JSONContent) {
return !text ? '' : generateText(text, exhibitExts)
}

View File

@ -0,0 +1,92 @@
// Adopted from https://github.com/ueberdosis/tiptap/blob/main/demos/src/Experiments/Embeds/Vue/iframe.ts
import { Node } from '@tiptap/core'
export interface IframeOptions {
allowFullscreen: boolean
HTMLAttributes: {
[key: string]: any
}
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
iframe: {
setIframe: (options: { src: string }) => ReturnType
}
}
}
// These classes style the outer wrapper and the inner iframe;
// Adopted from css in https://github.com/ueberdosis/tiptap/blob/main/demos/src/Experiments/Embeds/Vue/index.vue
const wrapperClasses = 'relative h-auto w-full overflow-hidden'
const iframeClasses = 'absolute top-0 left-0 h-full w-full'
export default Node.create<IframeOptions>({
name: 'iframe',
group: 'block',
atom: true,
addOptions() {
return {
allowFullscreen: true,
HTMLAttributes: {
class: 'iframe-wrapper' + ' ' + wrapperClasses,
// Tailwind JIT doesn't seem to pick up `pb-[20rem]`, so we hack this in:
style: 'padding-bottom: 20rem;',
},
}
},
addAttributes() {
return {
src: {
default: null,
},
frameborder: {
default: 0,
},
allowfullscreen: {
default: this.options.allowFullscreen,
parseHTML: () => this.options.allowFullscreen,
},
}
},
parseHTML() {
return [{ tag: 'iframe' }]
},
renderHTML({ HTMLAttributes }) {
return [
'div',
this.options.HTMLAttributes,
[
'iframe',
{
...HTMLAttributes,
class: HTMLAttributes.class + ' ' + iframeClasses,
},
],
]
},
addCommands() {
return {
setIframe:
(options: { src: string }) =>
({ tr, dispatch }) => {
const { selection } = tr
const node = this.type.create(options)
if (dispatch) {
tr.replaceRangeWith(selection.from, selection.to, node)
}
return true
},
}
},
})

View File

@ -0,0 +1,37 @@
import { Node, mergeAttributes } from '@tiptap/core'
export interface TweetOptions {
tweetId: string
}
// This is a version of the Tiptap Node config without addNodeView,
// since that would require bundling in tsx
export const TiptapTweetNode = {
name: 'tiptapTweet',
group: 'block',
atom: true,
addAttributes() {
return {
tweetId: {
default: null,
},
}
},
parseHTML() {
return [
{
tag: 'tiptap-tweet',
},
]
},
renderHTML(props: { HTMLAttributes: Record<string, any> }) {
return ['tiptap-tweet', mergeAttributes(props.HTMLAttributes)]
},
}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
export default Node.create<TweetOptions>(TiptapTweetNode)

43
dev.sh Executable file
View File

@ -0,0 +1,43 @@
#!/bin/bash
ENV=${1:-dev}
case $ENV in
dev)
FIREBASE_PROJECT=dev
NEXT_ENV=DEV ;;
prod)
FIREBASE_PROJECT=prod
NEXT_ENV=PROD ;;
localdb)
FIREBASE_PROJECT=dev
NEXT_ENV=DEV
EMULATOR=true ;;
*)
echo "Invalid environment; must be dev, prod, or localdb."
exit 1
esac
firebase use $FIREBASE_PROJECT
if [ ! -z $EMULATOR ]
then
npx concurrently \
-n FIRESTORE,FUNCTIONS,NEXT,TS \
-c green,white,magenta,cyan \
"yarn --cwd=functions firestore" \
"cross-env FIRESTORE_EMULATOR_HOST=localhost:8080 yarn --cwd=functions dev" \
"cross-env NEXT_PUBLIC_FUNCTIONS_URL=http://localhost:8088 \
NEXT_PUBLIC_FIREBASE_EMULATE=TRUE \
NEXT_PUBLIC_FIREBASE_ENV=${NEXT_ENV} \
yarn --cwd=web serve" \
"cross-env yarn --cwd=web ts-watch"
else
npx concurrently \
-n FUNCTIONS,NEXT,TS \
-c white,magenta,cyan \
"yarn --cwd=functions dev" \
"cross-env NEXT_PUBLIC_FUNCTIONS_URL=http://localhost:8088 \
NEXT_PUBLIC_FIREBASE_ENV=${NEXT_ENV} \
yarn --cwd=web serve" \
"cross-env yarn --cwd=web ts-watch"
fi

View File

@ -34,6 +34,40 @@ response was a 4xx or 5xx.)
## Endpoints ## Endpoints
### `GET /v0/user/[username]`
Gets a user by their username. Remember that usernames may change.
Requires no authorization.
### `GET /v0/user/by-id/[id]`
Gets a user by their unique ID. Many other API endpoints return this as the `userId`.
Requires no authorization.
### GET /v0/me
Returns the authenticated user.
### `GET /v0/groups`
Gets all groups, in no particular order.
Requires no authorization.
### `GET /v0/groups/[slug]`
Gets a group by its slug.
Requires no authorization.
### `GET /v0/groups/by-id/[id]`
Gets a group by its unique ID.
Requires no authorization.
### `GET /v0/markets` ### `GET /v0/markets`
Lists all markets, ordered by creation date descending. Lists all markets, ordered by creation date descending.
@ -456,7 +490,6 @@ Requires no authorization.
} }
``` ```
### `POST /v0/bet` ### `POST /v0/bet`
Places a new bet on behalf of the authorized user. Places a new bet on behalf of the authorized user.
@ -470,6 +503,20 @@ Parameters:
answer. For numeric markets, this is a string representing the target bucket, answer. For numeric markets, this is a string representing the target bucket,
and an additional `value` parameter is required which is a number representing and an additional `value` parameter is required which is a number representing
the target value. (Bet on numeric markets at your own peril.) the target value. (Bet on numeric markets at your own peril.)
- `limitProb`: Optional. A number between `0.001` and `0.999` inclusive representing
the limit probability for your bet (i.e. 0.1% to 99.9% — multiply by 100 for the
probability percentage).
The bet will execute immediately in the direction of `outcome`, but not beyond this
specified limit. If not all the bet is filled, the bet will remain as an open offer
that can later be matched against an opposite direction bet.
- For example, if the current market probability is `50%`:
- A `M$10` bet on `YES` with `limitProb=0.4` would not be filled until the market
probability moves down to `40%` and someone bets `M$15` of `NO` to match your
bet odds.
- A `M$100` bet on `YES` with `limitProb=0.6` would fill partially or completely
depending on current unfilled limit bets and the AMM's liquidity. Any remaining
portion of the bet not filled would remain to be matched against in the future.
- An unfilled limit order bet can be cancelled using the cancel API.
Example request: Example request:
@ -481,6 +528,10 @@ $ curl https://manifold.markets/api/v0/bet -X POST -H 'Content-Type: application
"contractId":"{...}"}' "contractId":"{...}"}'
``` ```
### `POST /v0/bet/cancel/[id]`
Cancel the limit order of a bet with the specified id. If the bet was unfilled, it will be cancelled so that no other bets will match with it. This is action irreversable.
### `POST /v0/market` ### `POST /v0/market`
Creates a new market on behalf of the authorized user. Creates a new market on behalf of the authorized user.
@ -514,8 +565,170 @@ $ curl https://manifold.markets/api/v0/market -X POST -H 'Content-Type: applicat
"initialProb":25}' "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} \
]}'
```
### `POST /v0/market/[marketId]/sell`
Sells some quantity of shares in a binary market on behalf of the authorized user.
Parameters:
- `outcome`: Optional. One of `YES`, or `NO`. If you leave it off, and you only
own one kind of shares, you will sell that kind of shares.
- `shares`: Optional. The amount of shares to sell of the outcome given
above. If not provided, all the shares you own will be sold.
Example request:
```
$ curl https://manifold.markets/api/v0/market/{marketId}/sell -X POST \
-H 'Content-Type: application/json' \
-H 'Authorization: Key {...}' \
--data-raw '{"outcome": "YES", "shares": 10}'
```
### `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-i-be-able-to-place-a-limit-ord
```
- Response type: A `Bet[]`.
- <details><summary>Example response</summary><p>
```json
[
// Limit bet, partially filled.
{
"isFilled": false,
"amount": 15.596681605353808,
"userId": "IPTOzEqrpkWmEzh6hwvAyY9PqFb2",
"contractId": "Tz5dA01GkK5QKiQfZeDL",
"probBefore": 0.5730753474948571,
"isCancelled": false,
"outcome": "YES",
"fees": { "creatorFee": 0, "liquidityFee": 0, "platformFee": 0 },
"shares": 31.193363210707616,
"limitProb": 0.5,
"id": "yXB8lVbs86TKkhWA1FVi",
"loanAmount": 0,
"orderAmount": 100,
"probAfter": 0.5730753474948571,
"createdTime": 1659482775970,
"fills": [
{
"timestamp": 1659483249648,
"matchedBetId": "MfrMd5HTiGASDXzqibr7",
"amount": 15.596681605353808,
"shares": 31.193363210707616
}
]
},
// Normal bet (no limitProb specified).
{
"shares": 17.350459904608414,
"probBefore": 0.5304358279113885,
"isFilled": true,
"probAfter": 0.5730753474948571,
"userId": "IPTOzEqrpkWmEzh6hwvAyY9PqFb2",
"amount": 10,
"contractId": "Tz5dA01GkK5QKiQfZeDL",
"id": "1LPJHNz5oAX4K6YtJlP1",
"fees": {
"platformFee": 0,
"liquidityFee": 0,
"creatorFee": 0.4251333951457593
},
"isCancelled": false,
"loanAmount": 0,
"orderAmount": 10,
"fills": [
{
"amount": 10,
"matchedBetId": null,
"shares": 17.350459904608414,
"timestamp": 1659482757271
}
],
"createdTime": 1659482757271,
"outcome": "YES"
}
]
```
</p>
</details>
## Changelog ## Changelog
- 2022-07-15: Add user by username and user by ID APIs
- 2022-06-08: Add paging to markets endpoint - 2022-06-08: Add paging to markets endpoint
- 2022-06-05: Add new authorized write endpoints - 2022-06-05: Add new authorized write endpoints
- 2022-02-28: Add `resolutionTime` to markets, change `closeTime` definition - 2022-02-28: Add `resolutionTime` to markets, change `closeTime` definition

View File

@ -10,13 +10,16 @@ A list of community-created projects built on, or related to, Manifold Markets.
- [CivicDashboard](https://civicdash.org/dashboard) - Uses Manifold to for tracked solutions for the SF city government - [CivicDashboard](https://civicdash.org/dashboard) - Uses Manifold to for tracked solutions for the SF city government
- [Research.Bet](https://research.bet/) - Prediction market for scientific papers, using Manifold - [Research.Bet](https://research.bet/) - Prediction market for scientific papers, using Manifold
- [WagerWith.me](https://www.wagerwith.me/) — Bet with your friends, with full Manifold integration to bet with M$.
## API / Dev ## API / Dev
- [PyManifold](https://github.com/bcongdon/PyManifold) - Python client for the Manifold API - [PyManifold](https://github.com/bcongdon/PyManifold) - Python client for the Manifold API
- [manifold-markets-python](https://github.com/vluzko/manifold-markets-python) - Python tools for working with Manifold Markets (including various accuracy metrics) - [manifold-markets-python](https://github.com/vluzko/manifold-markets-python) - Python tools for working with Manifold Markets (including various accuracy metrics)
- [ManifoldMarketManager](https://github.com/gappleto97/ManifoldMarketManager) - Python script and library to automatically manage markets
- [manifeed](https://github.com/joy-void-joy/manifeed) - Tool that creates an RSS feed for new Manifold markets - [manifeed](https://github.com/joy-void-joy/manifeed) - Tool that creates an RSS feed for new Manifold markets
## Bots ## Bots
- [@manifold@botsin.space](https://botsin.space/@manifold) - Posts new Manifold markets to Mastodon - [@manifold@botsin.space](https://botsin.space/@manifold) - Posts new Manifold markets to Mastodon
- [James' Bot](https://github.com/manifoldmarkets/market-maker) — Simple trading bot that makes markets

View File

@ -19,7 +19,6 @@ for the pool to be sorted into.
- Users can create a market on any question they want. - 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. - 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. - 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 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. - 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. - 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: { docs: {
routeBasePath: '/', routeBasePath: '/',
sidebarPath: require.resolve('./sidebars.js'), sidebarPath: require.resolve('./sidebars.js'),
// Please change this to your repo. editUrl: 'https://github.com/manifoldmarkets/manifold/tree/main/docs',
editUrl: 'https://github.com/manifoldmarkets/manifold/tree/main/docs/docs',
remarkPlugins: [math], remarkPlugins: [math],
rehypePlugins: [katex], rehypePlugins: [katex],
}, },
@ -72,7 +71,7 @@ const config = {
label: 'Docs', label: 'Docs',
}, },
{ {
href: 'https://github.com/manifoldmarkets/docs', href: 'https://github.com/manifoldmarkets/manifold/tree/main/docs/docs',
label: 'GitHub', label: 'GitHub',
position: 'right', position: 'right',
}, },
@ -116,7 +115,7 @@ const config = {
}, },
{ {
label: 'GitHub', label: 'GitHub',
href: 'https://github.com/manifoldmarkets/docs', href: 'https://github.com/manifoldmarkets/manifold/',
}, },
], ],
}, },

View File

@ -30,7 +30,8 @@
}, },
"devDependencies": { "devDependencies": {
"@docusaurus/module-type-aliases": "2.0.0-beta.17", "@docusaurus/module-type-aliases": "2.0.0-beta.17",
"@tsconfig/docusaurus": "^1.0.4" "@tsconfig/docusaurus": "^1.0.4",
"@types/react": "^17.0.2"
}, },
"browserslist": { "browserslist": {
"production": [ "production": [

View File

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

View File

@ -307,15 +307,11 @@
] ]
}, },
{ {
"collectionGroup": "txns", "collectionGroup": "manalinks",
"queryScope": "COLLECTION", "queryScope": "COLLECTION",
"fields": [ "fields": [
{ {
"fieldPath": "toId", "fieldPath": "fromId",
"order": "ASCENDING"
},
{
"fieldPath": "toType",
"order": "ASCENDING" "order": "ASCENDING"
}, },
{ {
@ -325,11 +321,57 @@
] ]
}, },
{ {
"collectionGroup": "manalinks", "collectionGroup": "notifications",
"queryScope": "COLLECTION", "queryScope": "COLLECTION",
"fields": [ "fields": [
{ {
"fieldPath": "fromId", "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",
"fields": [
{
"fieldPath": "toId",
"order": "ASCENDING"
},
{
"fieldPath": "toType",
"order": "ASCENDING" "order": "ASCENDING"
}, },
{ {
@ -410,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", "collectionGroup": "bets",
"fieldPath": "userId", "fieldPath": "userId",

View File

@ -6,10 +6,12 @@ service cloud.firestore {
match /databases/{database}/documents { match /databases/{database}/documents {
function isAdmin() { function isAdmin() {
return request.auth.uid == 'igi2zGXsfxYPgB0DJTXVJVmwCOr2' // Austin return request.auth.token.email in [
|| request.auth.uid == '5LZ4LgYuySdL1huCWe7bti02ghx2' // James 'akrolsmir@gmail.com',
|| request.auth.uid == 'tlmGNz9kjXc2EteizMORes4qvWl2' // Stephen 'jahooma@gmail.com',
|| request.auth.uid == 'IPTOzEqrpkWmEzh6hwvAyY9PqFb2' // Manifold 'taowell@gmail.com',
'manticmarkets@gmail.com'
]
} }
match /stats/stats { match /stats/stats {
@ -18,15 +20,36 @@ service cloud.firestore {
match /users/{userId} { match /users/{userId} {
allow read; allow read;
allow update: if resource.data.id == request.auth.uid allow update: if userId == request.auth.uid
&& request.resource.data.diff(resource.data).affectedKeys() && request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories']); .hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories', 'lastPingTime','shouldShowWelcome']);
// User referral rules
allow update: if userId == request.auth.uid
&& request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['referredByUserId', 'referredByContractId', 'referredByGroupId'])
// only one referral allowed per user
&& !("referredByUserId" in resource.data)
// user can't refer themselves
&& !(userId == 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} { match /{somePath=**}/portfolioHistory/{portfolioHistoryId} {
allow read; allow read;
} }
match /{somePath=**}/challenges/{challengeId}{
allow read;
}
match /contracts/{contractId}/challenges/{challengeId}{
allow read;
allow create: if request.auth.uid == request.resource.data.creatorId;
// allow update if there have been no claims yet and if the challenge is still open
allow update: if request.auth.uid == resource.data.creatorId;
}
match /users/{userId}/follows/{followUserId} { match /users/{userId}/follows/{followUserId} {
allow read; allow read;
allow write: if request.auth.uid == userId; allow write: if request.auth.uid == userId;
@ -37,8 +60,8 @@ service cloud.firestore {
} }
match /private-users/{userId} { match /private-users/{userId} {
allow read: if resource.data.id == request.auth.uid || isAdmin(); allow read: if userId == request.auth.uid || isAdmin();
allow update: if (resource.data.id == request.auth.uid || isAdmin()) allow update: if (userId == request.auth.uid || isAdmin())
&& request.resource.data.diff(resource.data).affectedKeys() && request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['apiKey', 'unsubscribedFromResolutionEmails', 'unsubscribedFromCommentEmails', 'unsubscribedFromAnswerEmails', 'notificationPreferences' ]); .hasOnly(['apiKey', 'unsubscribedFromResolutionEmails', 'unsubscribedFromCommentEmails', 'unsubscribedFromAnswerEmails', 'notificationPreferences' ]);
} }
@ -62,9 +85,9 @@ service cloud.firestore {
match /contracts/{contractId} { match /contracts/{contractId} {
allow read; allow read;
allow update: if request.resource.data.diff(resource.data).affectedKeys() allow update: if request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['tags', 'lowercaseTags']); .hasOnly(['tags', 'lowercaseTags', 'groupSlugs', 'groupLinks']);
allow update: if request.resource.data.diff(resource.data).affectedKeys() allow update: if request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['description', 'closeTime']) .hasOnly(['description', 'closeTime', 'question'])
&& resource.data.creatorId == request.auth.uid; && resource.data.creatorId == request.auth.uid;
allow update: if isAdmin(); allow update: if isAdmin();
match /comments/{commentId} { match /comments/{commentId} {

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

View File

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

1
functions/.yarnrc Normal file
View File

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

View File

@ -23,8 +23,11 @@ Adapted from https://firebase.google.com/docs/functions/get-started
### For local development ### For local development
0. [Install](https://cloud.google.com/sdk/docs/install) gcloud CLI 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. 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. `$ sudo ln -sfn /opt/homebrew/opt/openjdk/libexec/openjdk.jdk /Library/Java/JavaVirtualMachines/openjdk.jdk`
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 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) 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 4. `$ mkdir firestore_export` to create a folder to store the exported database
@ -51,7 +54,10 @@ Adapted from https://firebase.google.com/docs/functions/get-started
## Deploying ## Deploying
0. `$ firebase use prod` to switch to prod 0. After merging, you need to manually deploy to backend:
1. `git checkout main`
1. `git pull origin main`
1. `$ firebase use prod` to switch to prod
1. `$ firebase deploy --only functions` to push your changes live! 1. `$ firebase deploy --only functions` to push your changes live!
(Future TODO: auto-deploy functions on Git push) (Future TODO: auto-deploy functions on Git push)

View File

@ -5,23 +5,35 @@
"firestore": "dev-mantic-markets.appspot.com" "firestore": "dev-mantic-markets.appspot.com"
}, },
"scripts": { "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", "watch": "tsc -w",
"shell": "yarn build && firebase functions:shell", "shell": "yarn build && firebase functions:shell",
"start": "yarn shell", "start": "yarn shell",
"deploy": "firebase deploy --only functions", "deploy": "firebase deploy --only functions",
"logs": "firebase functions:log", "logs": "firebase functions:log",
"serve": "yarn build && firebase emulators:start --only functions,firestore --import=./firestore_export", "dev": "nodemon src/serve.ts",
"firestore": "firebase emulators:start --only 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: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: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: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/", "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": { "dependencies": {
"@amplitude/node": "1.10.0", "@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/extension-mention": "2.0.0-beta.102",
"@tiptap/starter-kit": "2.0.0-beta.190",
"cors": "2.8.5",
"dayjs": "1.11.4",
"express": "4.18.1",
"firebase-admin": "10.0.0", "firebase-admin": "10.0.0",
"firebase-functions": "3.21.2", "firebase-functions": "3.21.2",
"lodash": "4.17.21", "lodash": "4.17.21",

View File

@ -0,0 +1,167 @@
import { z } from 'zod'
import { APIError, newEndpoint, validate } from './api'
import { log } from './utils'
import { Contract, CPMMBinaryContract } from '../../common/contract'
import { User } from '../../common/user'
import * as admin from 'firebase-admin'
import { FieldValue } from 'firebase-admin/firestore'
import { removeUndefinedProps } from '../../common/util/object'
import { Acceptance, Challenge } from '../../common/challenge'
import { CandidateBet } from '../../common/new-bet'
import { createChallengeAcceptedNotification } from './create-notification'
import { noFees } from '../../common/fees'
import { formatMoney, formatPercent } from '../../common/util/format'
const bodySchema = z.object({
contractId: z.string(),
challengeSlug: z.string(),
outcomeType: z.literal('BINARY'),
closeTime: z.number().gte(Date.now()),
})
const firestore = admin.firestore()
export const acceptchallenge = newEndpoint({}, async (req, auth) => {
const { challengeSlug, contractId } = validate(bodySchema, req.body)
const result = await firestore.runTransaction(async (trans) => {
const contractDoc = firestore.doc(`contracts/${contractId}`)
const userDoc = firestore.doc(`users/${auth.uid}`)
const challengeDoc = firestore.doc(
`contracts/${contractId}/challenges/${challengeSlug}`
)
const [contractSnap, userSnap, challengeSnap] = await trans.getAll(
contractDoc,
userDoc,
challengeDoc
)
if (!contractSnap.exists) throw new APIError(400, 'Contract not found.')
if (!userSnap.exists) throw new APIError(400, 'User not found.')
if (!challengeSnap.exists) throw new APIError(400, 'Challenge not found.')
const anyContract = contractSnap.data() as Contract
const user = userSnap.data() as User
const challenge = challengeSnap.data() as Challenge
if (challenge.acceptances.length > 0)
throw new APIError(400, 'Challenge already accepted.')
const creatorDoc = firestore.doc(`users/${challenge.creatorId}`)
const creatorSnap = await trans.get(creatorDoc)
if (!creatorSnap.exists) throw new APIError(400, 'Creator not found.')
const creator = creatorSnap.data() as User
const {
creatorAmount,
acceptorOutcome,
creatorOutcome,
creatorOutcomeProb,
acceptorAmount,
} = challenge
if (user.balance < acceptorAmount)
throw new APIError(400, 'Insufficient balance.')
if (creator.balance < creatorAmount)
throw new APIError(400, 'Creator has insufficient balance.')
const contract = anyContract as CPMMBinaryContract
const shares = (1 / creatorOutcomeProb) * creatorAmount
const createdTime = Date.now()
const probOfYes =
creatorOutcome === 'YES' ? creatorOutcomeProb : 1 - creatorOutcomeProb
log(
'Creating challenge bet for',
user.username,
shares,
acceptorOutcome,
'shares',
'at',
formatPercent(creatorOutcomeProb),
'for',
formatMoney(acceptorAmount)
)
const yourNewBet: CandidateBet = removeUndefinedProps({
orderAmount: acceptorAmount,
amount: acceptorAmount,
shares,
isCancelled: false,
contractId: contract.id,
outcome: acceptorOutcome,
probBefore: probOfYes,
probAfter: probOfYes,
loanAmount: 0,
createdTime,
fees: noFees,
challengeSlug: challenge.slug,
})
const yourNewBetDoc = contractDoc.collection('bets').doc()
trans.create(yourNewBetDoc, {
id: yourNewBetDoc.id,
userId: user.id,
...yourNewBet,
})
trans.update(userDoc, { balance: FieldValue.increment(-yourNewBet.amount) })
const creatorNewBet: CandidateBet = removeUndefinedProps({
orderAmount: creatorAmount,
amount: creatorAmount,
shares,
isCancelled: false,
contractId: contract.id,
outcome: creatorOutcome,
probBefore: probOfYes,
probAfter: probOfYes,
loanAmount: 0,
createdTime,
fees: noFees,
challengeSlug: challenge.slug,
})
const creatorBetDoc = contractDoc.collection('bets').doc()
trans.create(creatorBetDoc, {
id: creatorBetDoc.id,
userId: creator.id,
...creatorNewBet,
})
trans.update(creatorDoc, {
balance: FieldValue.increment(-creatorNewBet.amount),
})
const volume = contract.volume + yourNewBet.amount + creatorNewBet.amount
trans.update(contractDoc, { volume })
trans.update(
challengeDoc,
removeUndefinedProps({
acceptedByUserIds: [user.id],
acceptances: [
{
userId: user.id,
betId: yourNewBetDoc.id,
createdTime,
amount: acceptorAmount,
userUsername: user.username,
userName: user.name,
userAvatarUrl: user.avatarUrl,
} as Acceptance,
],
})
)
await createChallengeAcceptedNotification(
user,
creator,
challenge,
acceptorAmount,
contract
)
log('Done, sent notification.')
return yourNewBetDoc
})
return { betId: result.id }
})

View File

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

View File

@ -1,14 +1,17 @@
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import { logger } from 'firebase-functions/v2' import { Request, RequestHandler, Response } from 'express'
import { HttpsOptions, onRequest, Request } from 'firebase-functions/v2/https' import { error } from 'firebase-functions/logger'
import { HttpsOptions } from 'firebase-functions/v2/https'
import { log } from './utils' import { log } from './utils'
import { z } from 'zod' import { z } from 'zod'
import { APIError } from '../../common/api'
import { PrivateUser } from '../../common/user' import { PrivateUser } from '../../common/user'
import { import {
CORS_ORIGIN_MANIFOLD, CORS_ORIGIN_MANIFOLD,
CORS_ORIGIN_LOCALHOST, CORS_ORIGIN_LOCALHOST,
CORS_ORIGIN_VERCEL,
} from '../../common/envs/constants' } from '../../common/envs/constants'
export { APIError } from '../../common/api'
type Output = Record<string, unknown> type Output = Record<string, unknown>
type AuthedUser = { type AuthedUser = {
@ -20,17 +23,6 @@ type JwtCredentials = { kind: 'jwt'; data: admin.auth.DecodedIdToken }
type KeyCredentials = { kind: 'key'; data: string } type KeyCredentials = { kind: 'key'; data: string }
type Credentials = JwtCredentials | KeyCredentials 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 auth = admin.auth()
const firestore = admin.firestore() const firestore = admin.firestore()
const privateUsers = firestore.collection( const privateUsers = firestore.collection(
@ -54,7 +46,7 @@ export const parseCredentials = async (req: Request): Promise<Credentials> => {
return { kind: 'jwt', data: await auth.verifyIdToken(payload) } return { kind: 'jwt', data: await auth.verifyIdToken(payload) }
} catch (err) { } catch (err) {
// This is somewhat suspicious, so get it into the firebase console // This is somewhat suspicious, so get it into the firebase console
logger.error('Error verifying Firebase JWT: ', err) error('Error verifying Firebase JWT: ', err)
throw new APIError(403, 'Error validating token.') throw new APIError(403, 'Error validating token.')
} }
case 'Key': case 'Key':
@ -86,12 +78,30 @@ export const lookupUser = async (creds: Credentials): Promise<AuthedUser> => {
} }
} }
export const writeResponseError = (e: unknown, res: Response) => {
if (e instanceof APIError) {
const output: { [k: string]: unknown } = { message: e.message }
if (e.details != null) {
output.details = e.details
}
res.status(e.code).json(output)
} else {
error(e)
res.status(500).json({ message: 'An unknown error occurred.' })
}
}
export const zTimestamp = () => { export const zTimestamp = () => {
return z.preprocess((arg) => { return z.preprocess((arg) => {
return typeof arg == 'number' ? new Date(arg) : undefined return typeof arg == 'number' ? new Date(arg) : undefined
}, z.date()) }, z.date())
} }
export type EndpointDefinition = {
opts: EndpointOptions & { method: string }
handler: RequestHandler
}
export const validate = <T extends z.ZodTypeAny>(schema: T, val: unknown) => { export const validate = <T extends z.ZodTypeAny>(schema: T, val: unknown) => {
const result = schema.safeParse(val) const result = schema.safeParse(val)
if (!result.success) { if (!result.success) {
@ -108,35 +118,34 @@ export const validate = <T extends z.ZodTypeAny>(schema: T, val: unknown) => {
} }
} }
const DEFAULT_OPTS: HttpsOptions = { export interface EndpointOptions extends HttpsOptions {
method?: string
}
const DEFAULT_OPTS = {
method: 'POST',
minInstances: 1, minInstances: 1,
concurrency: 100, concurrency: 100,
memory: '2GiB', memory: '2GiB',
cpu: 1, cpu: 1,
cors: [CORS_ORIGIN_MANIFOLD, CORS_ORIGIN_LOCALHOST], cors: [CORS_ORIGIN_MANIFOLD, CORS_ORIGIN_VERCEL, CORS_ORIGIN_LOCALHOST],
} }
export const newEndpoint = (methods: [string], fn: Handler) => export const newEndpoint = (endpointOpts: EndpointOptions, fn: Handler) => {
onRequest(DEFAULT_OPTS, async (req, res) => { const opts = Object.assign({}, DEFAULT_OPTS, endpointOpts)
log('Request processing started.') return {
try { opts,
if (!methods.includes(req.method)) { handler: async (req: Request, res: Response) => {
const allowed = methods.join(', ') log(`${req.method} ${req.url} ${JSON.stringify(req.body)}`)
throw new APIError(405, `This endpoint supports only ${allowed}.`) try {
} if (opts.method !== req.method) {
const authedUser = await lookupUser(await parseCredentials(req)) throw new APIError(405, `This endpoint supports only ${opts.method}.`)
log('User credentials processed.')
res.status(200).json(await fn(req, authedUser))
} catch (e) {
if (e instanceof APIError) {
const output: { [k: string]: unknown } = { message: e.msg }
if (e.details != null) {
output.details = e.details
} }
res.status(e.code).json(output) const authedUser = await lookupUser(await parseCredentials(req))
} else { res.status(200).json(await fn(req, authedUser))
logger.error(e) } catch (e) {
res.status(500).json({ message: 'An unknown error occurred.' }) writeResponseError(e, res)
} }
} },
}) } as EndpointDefinition
}

View File

@ -18,46 +18,63 @@
import * as functions from 'firebase-functions' import * as functions from 'firebase-functions'
import * as firestore from '@google-cloud/firestore' 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 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
const collectionIds = [
'contracts',
'groups',
'private-users',
'stripe-transactions',
'transactions',
'users',
'bets',
'comments',
'follows',
'followers',
'answers',
'txns',
'manalinks',
'liquidity',
'stats',
'cache',
'latency',
'views',
'notifications',
'portfolioHistory',
'folds',
]
return await client.exportDocuments({ name, outputUriPrefix, collectionIds })
}
export const backupDb = functions.pubsub export const backupDb = functions.pubsub
.schedule('every 24 hours') .schedule('every 24 hours')
.onRun((_context) => { .onRun(async (_context) => {
const projectId = process.env.GCP_PROJECT || process.env.GCLOUD_PROJECT try {
if (projectId == null) { const client = new firestore.v1.FirestoreAdminClient()
throw new Error('No project ID environment variable set.') 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) {
console.error(err)
throw new Error('Export operation failed')
} }
const databaseName = client.databasePath(projectId, '(default)')
return client
.exportDocuments({
name: databaseName,
outputUriPrefix: 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: [
'contracts',
'groups',
'private-users',
'stripe-transactions',
'users',
'bets',
'comments',
'followers',
'answers',
'txns',
],
})
.then((responses) => {
const response = responses[0]
console.log(`Operation Name: ${response['name']}`)
})
.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,33 @@
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)
return 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 }
})
})
const firestore = admin.firestore()

View File

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

View File

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

View File

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

View File

@ -2,36 +2,64 @@ import * as admin from 'firebase-admin'
import { z } from 'zod' import { z } from 'zod'
import { import {
CPMMBinaryContract,
Contract, Contract,
CPMMBinaryContract,
FreeResponseContract, FreeResponseContract,
MAX_DESCRIPTION_LENGTH,
MAX_QUESTION_LENGTH, MAX_QUESTION_LENGTH,
MAX_TAG_LENGTH, MAX_TAG_LENGTH,
MultipleChoiceContract,
NumericContract, NumericContract,
OUTCOME_TYPES, OUTCOME_TYPES,
} from '../../common/contract' } from '../../common/contract'
import { slugify } from '../../common/util/slugify' import { slugify } from '../../common/util/slugify'
import { randomString } from '../../common/util/random' import { randomString } from '../../common/util/random'
import { chargeUser } from './utils' import { chargeUser, getContract } from './utils'
import { APIError, newEndpoint, validate, zTimestamp } from './api' import { APIError, newEndpoint, validate, zTimestamp } from './api'
import { import {
FIXED_ANTE, FIXED_ANTE,
getCpmmInitialLiquidity, getCpmmInitialLiquidity,
getFreeAnswerAnte, getFreeAnswerAnte,
getMultipleChoiceAntes,
getNumericAnte, getNumericAnte,
} from '../../common/antes' } from '../../common/antes'
import { getNoneAnswer } from '../../common/answer' import { Answer, getNoneAnswer } from '../../common/answer'
import { getNewContract } from '../../common/new-contract' import { getNewContract } from '../../common/new-contract'
import { NUMERIC_BUCKET_COUNT } from '../../common/numeric-constants' import { NUMERIC_BUCKET_COUNT } from '../../common/numeric-constants'
import { User } from '../../common/user' import { User } from '../../common/user'
import { Group, MAX_ID_LENGTH } from '../../common/group' import { Group, GroupLink, MAX_ID_LENGTH } from '../../common/group'
import { getPseudoProbability } from '../../common/pseudo-numeric'
import { JSONContent } from '@tiptap/core'
import { uniq, zip } from 'lodash'
import { Bet } from '../../common/bet'
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({ const bodySchema = z.object({
question: z.string().min(1).max(MAX_QUESTION_LENGTH), 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(), tags: z.array(z.string().min(1).max(MAX_TAG_LENGTH)).optional(),
closeTime: zTimestamp().refine( closeTime: zTimestamp().refine(
(date) => date.getTime() > new Date().getTime(), (date) => date.getTime() > new Date().getTime(),
@ -45,24 +73,54 @@ const binarySchema = z.object({
initialProb: z.number().min(1).max(99), 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({ const numericSchema = z.object({
min: z.number(), min: finite(),
max: z.number(), max: finite(),
initialValue: finite(),
isLogScale: z.boolean().optional(),
}) })
export const createmarket = newEndpoint(['POST'], async (req, auth) => { const multipleChoiceSchema = z.object({
answers: z.string().trim().min(1).array().min(2),
})
export const createmarket = newEndpoint({}, async (req, auth) => {
const { question, description, tags, closeTime, outcomeType, groupId } = const { question, description, tags, closeTime, outcomeType, groupId } =
validate(bodySchema, req.body) validate(bodySchema, req.body)
let min, max, initialProb let min, max, initialProb, isLogScale, answers
if (outcomeType === 'NUMERIC') {
;({ min, max } = validate(numericSchema, req.body)) if (outcomeType === 'PSEUDO_NUMERIC' || outcomeType === 'NUMERIC') {
if (max - min <= 0.01) throw new APIError(400, 'Invalid range.') 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)
if (outcomeType === 'PSEUDO_NUMERIC')
throw new APIError(
400,
`Initial value is too ${initialProb < 1 ? 'low' : 'high'}`
)
else throw new APIError(400, 'Invalid initial probability.')
} }
if (outcomeType === 'BINARY') { if (outcomeType === 'BINARY') {
;({ initialProb } = validate(binarySchema, req.body)) ;({ initialProb } = validate(binarySchema, req.body))
} }
if (outcomeType === 'MULTIPLE_CHOICE') {
;({ answers } = validate(multipleChoiceSchema, req.body))
}
const userDoc = await firestore.collection('users').doc(auth.uid).get() const userDoc = await firestore.collection('users').doc(auth.uid).get()
if (!userDoc.exists) { if (!userDoc.exists) {
throw new APIError(400, 'No user exists with the authenticated user ID.') throw new APIError(400, 'No user exists with the authenticated user ID.')
@ -78,27 +136,6 @@ export const createmarket = newEndpoint(['POST'], async (req, auth) => {
const slug = await getSlug(question) const slug = await getSlug(question)
const contractRef = firestore.collection('contracts').doc() const contractRef = firestore.collection('contracts').doc()
let group = null
if (groupId) {
const groupDocRef = await firestore.collection('groups').doc(groupId)
const groupDoc = await groupDocRef.get()
if (!groupDoc.exists) {
throw new APIError(400, 'No group exists with the given group ID.')
}
group = groupDoc.data() as Group
if (!group.memberIds.includes(user.id)) {
throw new APIError(
400,
'User must be a member of the group to add markets to it.'
)
}
if (!group.contractIds.includes(contractRef.id))
await groupDocRef.update({
contractIds: [...group.contractIds, contractRef.id],
})
}
console.log( console.log(
'creating contract for', 'creating contract for',
user.username, user.username,
@ -114,23 +151,52 @@ export const createmarket = newEndpoint(['POST'], async (req, auth) => {
user, user,
question, question,
outcomeType, outcomeType,
description, description ?? {},
initialProb ?? 0, initialProb ?? 0,
ante, ante,
closeTime.getTime(), closeTime.getTime(),
tags ?? [], tags ?? [],
NUMERIC_BUCKET_COUNT, NUMERIC_BUCKET_COUNT,
min ?? 0, min ?? 0,
max ?? 0 max ?? 0,
isLogScale ?? false,
answers ?? []
) )
if (ante) await chargeUser(user.id, ante, true) if (ante) await chargeUser(user.id, ante, true)
await contractRef.create(contract) await contractRef.create(contract)
let group = null
if (groupId) {
const groupDocRef = firestore.collection('groups').doc(groupId)
const groupDoc = await groupDocRef.get()
if (!groupDoc.exists) {
throw new APIError(400, 'No group exists with the given group ID.')
}
group = groupDoc.data() as Group
if (
!group.memberIds.includes(user.id) &&
!group.anyoneCanJoin &&
group.creatorId !== user.id
) {
throw new APIError(
400,
'User must be a member/creator of the group or group must be open to add markets to it.'
)
}
if (!group.contractIds.includes(contractRef.id)) {
await createGroupLinks(group, [contractRef.id], auth.uid)
await groupDocRef.update({
contractIds: uniq([...group.contractIds, contractRef.id]),
})
}
}
const providerId = user.id const providerId = user.id
if (outcomeType === 'BINARY') { if (outcomeType === 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') {
const liquidityDoc = firestore const liquidityDoc = firestore
.collection(`contracts/${contract.id}/liquidity`) .collection(`contracts/${contract.id}/liquidity`)
.doc() .doc()
@ -143,6 +209,31 @@ export const createmarket = newEndpoint(['POST'], async (req, auth) => {
) )
await liquidityDoc.set(lp) await liquidityDoc.set(lp)
} else if (outcomeType === 'MULTIPLE_CHOICE') {
const betCol = firestore.collection(`contracts/${contract.id}/bets`)
const betDocs = (answers ?? []).map(() => betCol.doc())
const answerCol = firestore.collection(`contracts/${contract.id}/answers`)
const answerDocs = (answers ?? []).map((_, i) =>
answerCol.doc(i.toString())
)
const { bets, answerObjects } = getMultipleChoiceAntes(
user,
contract as MultipleChoiceContract,
answers ?? [],
betDocs.map((bd) => bd.id)
)
await Promise.all(
zip(bets, betDocs).map(([bet, doc]) => doc?.create(bet as Bet))
)
await Promise.all(
zip(answerObjects, answerDocs).map(([answer, doc]) =>
doc?.create(answer as Answer)
)
)
await contractRef.update({ answers: answerObjects })
} else if (outcomeType === 'FREE_RESPONSE') { } else if (outcomeType === 'FREE_RESPONSE') {
const noneAnswerDoc = firestore const noneAnswerDoc = firestore
.collection(`contracts/${contract.id}/answers`) .collection(`contracts/${contract.id}/answers`)
@ -199,3 +290,38 @@ export async function getContractFromSlug(slug: string) {
return snap.empty ? undefined : (snap.docs[0].data() as Contract) return snap.empty ? undefined : (snap.docs[0].data() as Contract)
} }
async function createGroupLinks(
group: Group,
contractIds: string[],
userId: string
) {
for (const contractId of contractIds) {
const contract = await getContract(contractId)
if (!contract?.groupSlugs?.includes(group.slug)) {
await firestore
.collection('contracts')
.doc(contractId)
.update({
groupSlugs: uniq([group.slug, ...(contract?.groupSlugs ?? [])]),
})
}
if (!contract?.groupLinks?.map((gl) => gl.groupId).includes(group.id)) {
await firestore
.collection('contracts')
.doc(contractId)
.update({
groupLinks: [
{
groupId: group.id,
name: group.name,
slug: group.slug,
userId,
createdTime: Date.now(),
} as GroupLink,
...(contract?.groupLinks ?? []),
],
})
}
}
}

View File

@ -20,7 +20,7 @@ const bodySchema = z.object({
about: z.string().min(1).max(MAX_ABOUT_LENGTH).optional(), 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( const { name, about, memberIds, anyoneCanJoin } = validate(
bodySchema, bodySchema,
req.body req.body

View File

@ -7,13 +7,17 @@ import {
} from '../../common/notification' } from '../../common/notification'
import { User } from '../../common/user' import { User } from '../../common/user'
import { Contract } from '../../common/contract' import { Contract } from '../../common/contract'
import { getUserByUsername, getValues } from './utils' import { getValues } from './utils'
import { Comment } from '../../common/comment' import { Comment } from '../../common/comment'
import { uniq } from 'lodash' import { uniq } from 'lodash'
import { Bet } from '../../common/bet' import { Bet, LimitBet } from '../../common/bet'
import { Answer } from '../../common/answer' import { Answer } from '../../common/answer'
import { getContractBetMetrics } from '../../common/calculate' import { getContractBetMetrics } from '../../common/calculate'
import { removeUndefinedProps } from '../../common/util/object' import { removeUndefinedProps } from '../../common/util/object'
import { TipTxn } from '../../common/txn'
import { Group, GROUP_CHAT_SLUG } from '../../common/group'
import { Challenge } from '../../common/challenge'
import { richTextToString } from '../../common/util/parse'
const firestore = admin.firestore() const firestore = admin.firestore()
type user_to_reason_texts = { type user_to_reason_texts = {
@ -27,12 +31,22 @@ export const createNotification = async (
sourceUser: User, sourceUser: User,
idempotencyKey: string, idempotencyKey: string,
sourceText: string, sourceText: string,
sourceContract?: Contract, miscData?: {
relatedSourceType?: notification_source_types, contract?: Contract
relatedUserId?: string, relatedSourceType?: notification_source_types
sourceSlug?: string, recipients?: string[]
sourceTitle?: string slug?: string
title?: string
}
) => { ) => {
const {
contract: sourceContract,
relatedSourceType,
recipients,
slug,
title,
} = miscData ?? {}
const shouldGetNotification = ( const shouldGetNotification = (
userId: string, userId: string,
userToReasonTexts: user_to_reason_texts userToReasonTexts: user_to_reason_texts
@ -66,11 +80,10 @@ export const createNotification = async (
sourceUserAvatarUrl: sourceUser.avatarUrl, sourceUserAvatarUrl: sourceUser.avatarUrl,
sourceText, sourceText,
sourceContractCreatorUsername: sourceContract?.creatorUsername, sourceContractCreatorUsername: sourceContract?.creatorUsername,
// TODO: move away from sourceContractTitle to sourceTitle
sourceContractTitle: sourceContract?.question, sourceContractTitle: sourceContract?.question,
sourceContractSlug: sourceContract?.slug, sourceContractSlug: sourceContract?.slug,
sourceSlug: sourceSlug ? sourceSlug : sourceContract?.slug, sourceSlug: slug ? slug : sourceContract?.slug,
sourceTitle: sourceTitle ? sourceTitle : sourceContract?.question, sourceTitle: title ? title : sourceContract?.question,
} }
await notificationRef.set(removeUndefinedProps(notification)) await notificationRef.set(removeUndefinedProps(notification))
}) })
@ -116,7 +129,7 @@ export const createNotification = async (
}) })
} }
const notifyRepliedUsers = async ( const notifyRepliedUser = (
userToReasonTexts: user_to_reason_texts, userToReasonTexts: user_to_reason_texts,
relatedUserId: string, relatedUserId: string,
relatedSourceType: notification_source_types relatedSourceType: notification_source_types
@ -133,7 +146,7 @@ export const createNotification = async (
} }
} }
const notifyFollowedUser = async ( const notifyFollowedUser = (
userToReasonTexts: user_to_reason_texts, userToReasonTexts: user_to_reason_texts,
followedUserId: string followedUserId: string
) => { ) => {
@ -143,21 +156,13 @@ export const createNotification = async (
} }
} }
const notifyTaggedUsers = async ( const notifyTaggedUsers = (
userToReasonTexts: user_to_reason_texts, userToReasonTexts: user_to_reason_texts,
sourceText: string userIds: (string | undefined)[]
) => { ) => {
const taggedUsers = sourceText.match(/@\w+/g) userIds.forEach((id) => {
if (!taggedUsers) return if (id && shouldGetNotification(id, userToReasonTexts))
// await all get tagged users: userToReasonTexts[id] = {
const users = await Promise.all(
taggedUsers.map(async (username) => {
return await getUserByUsername(username.slice(1))
})
)
users.forEach((taggedUser) => {
if (taggedUser && shouldGetNotification(taggedUser.id, userToReasonTexts))
userToReasonTexts[taggedUser.id] = {
reason: 'tagged_user', reason: 'tagged_user',
} }
}) })
@ -242,7 +247,7 @@ export const createNotification = async (
}) })
} }
const notifyUserAddedToGroup = async ( const notifyUserAddedToGroup = (
userToReasonTexts: user_to_reason_texts, userToReasonTexts: user_to_reason_texts,
relatedUserId: string relatedUserId: string
) => { ) => {
@ -252,44 +257,62 @@ export const createNotification = async (
} }
} }
const notifyContractCreatorOfUniqueBettorsBonus = async (
userToReasonTexts: user_to_reason_texts,
userId: string
) => {
userToReasonTexts[userId] = {
reason: 'unique_bettors_on_your_contract',
}
}
const getUsersToNotify = async () => { const getUsersToNotify = async () => {
const userToReasonTexts: user_to_reason_texts = {} const userToReasonTexts: user_to_reason_texts = {}
// The following functions modify the userToReasonTexts object in place. // The following functions modify the userToReasonTexts object in place.
if (sourceContract) { if (sourceType === 'follow' && recipients?.[0]) {
if ( notifyFollowedUser(userToReasonTexts, recipients[0])
sourceType === 'comment' || } else if (
sourceType === 'answer' || sourceType === 'group' &&
(sourceType === 'contract' && sourceUpdateType === 'created' &&
(sourceUpdateType === 'updated' || sourceUpdateType === 'resolved')) recipients
) { ) {
if (sourceType === 'comment') { recipients.forEach((r) => notifyUserAddedToGroup(userToReasonTexts, r))
if (relatedUserId && relatedSourceType) }
await notifyRepliedUsers(
userToReasonTexts, // The following functions need sourceContract to be defined.
relatedUserId, if (!sourceContract) return userToReasonTexts
relatedSourceType
) if (
if (sourceText) await notifyTaggedUsers(userToReasonTexts, sourceText) sourceType === 'comment' ||
} sourceType === 'answer' ||
await notifyContractCreator(userToReasonTexts, sourceContract) (sourceType === 'contract' &&
await notifyOtherAnswerersOnContract(userToReasonTexts, sourceContract) (sourceUpdateType === 'updated' || sourceUpdateType === 'resolved'))
await notifyLiquidityProviders(userToReasonTexts, sourceContract) ) {
await notifyBettorsOnContract(userToReasonTexts, sourceContract) if (sourceType === 'comment') {
await notifyOtherCommentersOnContract(userToReasonTexts, sourceContract) if (recipients?.[0] && relatedSourceType)
} else if (sourceType === 'contract' && sourceUpdateType === 'created') { notifyRepliedUser(userToReasonTexts, recipients[0], relatedSourceType)
await notifyUsersFollowers(userToReasonTexts) if (sourceText) notifyTaggedUsers(userToReasonTexts, recipients ?? [])
} else if (sourceType === 'contract' && sourceUpdateType === 'closed') {
await notifyContractCreator(userToReasonTexts, sourceContract, {
force: true,
})
} else if (sourceType === 'liquidity' && sourceUpdateType === 'created') {
await notifyContractCreator(userToReasonTexts, sourceContract)
} }
} else if (sourceType === 'follow' && relatedUserId) { await notifyContractCreator(userToReasonTexts, sourceContract)
await notifyFollowedUser(userToReasonTexts, relatedUserId) await notifyOtherAnswerersOnContract(userToReasonTexts, sourceContract)
} else if (sourceType === 'group' && relatedUserId) { await notifyLiquidityProviders(userToReasonTexts, sourceContract)
if (sourceUpdateType === 'created') await notifyBettorsOnContract(userToReasonTexts, sourceContract)
await notifyUserAddedToGroup(userToReasonTexts, relatedUserId) await notifyOtherCommentersOnContract(userToReasonTexts, sourceContract)
} else if (sourceType === 'contract' && sourceUpdateType === 'created') {
await notifyUsersFollowers(userToReasonTexts)
notifyTaggedUsers(userToReasonTexts, recipients ?? [])
} else if (sourceType === 'contract' && sourceUpdateType === 'closed') {
await notifyContractCreator(userToReasonTexts, sourceContract, {
force: true,
})
} else if (sourceType === 'liquidity' && sourceUpdateType === 'created') {
await notifyContractCreator(userToReasonTexts, sourceContract)
} 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 return userToReasonTexts
} }
@ -297,3 +320,187 @@ export const createNotification = async (
const userToReasonTexts = await getUsersToNotify() const userToReasonTexts = await getUsersToNotify()
await createUsersNotifications(userToReasonTexts) 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))
}
export const createGroupCommentNotification = async (
fromUser: User,
toUserId: string,
comment: Comment,
group: Group,
idempotencyKey: string
) => {
if (toUserId === fromUser.id) return
const notificationRef = firestore
.collection(`/users/${toUserId}/notifications`)
.doc(idempotencyKey)
const sourceSlug = `/group/${group.slug}/${GROUP_CHAT_SLUG}`
const notification: Notification = {
id: idempotencyKey,
userId: toUserId,
reason: 'on_group_you_are_member_of',
createdTime: Date.now(),
isSeen: false,
sourceId: comment.id,
sourceType: 'comment',
sourceUpdateType: 'created',
sourceUserName: fromUser.name,
sourceUserUsername: fromUser.username,
sourceUserAvatarUrl: fromUser.avatarUrl,
sourceText: richTextToString(comment.content),
sourceSlug,
sourceTitle: `${group.name}`,
isSeenOnHref: sourceSlug,
}
await notificationRef.set(removeUndefinedProps(notification))
}
export const createReferralNotification = async (
toUser: User,
referredUser: User,
idempotencyKey: string,
bonusAmount: string,
referredByContract?: Contract,
referredByGroup?: Group
) => {
const notificationRef = firestore
.collection(`/users/${toUser.id}/notifications`)
.doc(idempotencyKey)
const notification: Notification = {
id: idempotencyKey,
userId: toUser.id,
reason: referredByGroup
? 'user_joined_from_your_group_invite'
: referredByContract?.creatorId === toUser.id
? 'user_joined_to_bet_on_your_market'
: 'you_referred_user',
createdTime: Date.now(),
isSeen: false,
sourceId: referredUser.id,
sourceType: 'user',
sourceUpdateType: 'updated',
sourceContractId: referredByContract?.id,
sourceUserName: referredUser.name,
sourceUserUsername: referredUser.username,
sourceUserAvatarUrl: referredUser.avatarUrl,
sourceText: bonusAmount,
// Only pass the contract referral details if they weren't referred to a group
sourceContractCreatorUsername: !referredByGroup
? referredByContract?.creatorUsername
: undefined,
sourceContractTitle: !referredByGroup
? referredByContract?.question
: undefined,
sourceContractSlug: !referredByGroup ? referredByContract?.slug : undefined,
sourceSlug: referredByGroup
? groupPath(referredByGroup.slug)
: referredByContract?.slug,
sourceTitle: referredByGroup
? referredByGroup.name
: referredByContract?.question,
}
await notificationRef.set(removeUndefinedProps(notification))
}
const groupPath = (groupSlug: string) => `/group/${groupSlug}`
export const createChallengeAcceptedNotification = async (
challenger: User,
challengeCreator: User,
challenge: Challenge,
acceptedAmount: number,
contract: Contract
) => {
const notificationRef = firestore
.collection(`/users/${challengeCreator.id}/notifications`)
.doc()
const notification: Notification = {
id: notificationRef.id,
userId: challengeCreator.id,
reason: 'challenge_accepted',
createdTime: Date.now(),
isSeen: false,
sourceId: challenge.slug,
sourceType: 'challenge',
sourceUpdateType: 'updated',
sourceUserName: challenger.name,
sourceUserUsername: challenger.username,
sourceUserAvatarUrl: challenger.avatarUrl,
sourceText: acceptedAmount.toString(),
sourceContractCreatorUsername: contract.creatorUsername,
sourceContractTitle: contract.question,
sourceContractSlug: contract.slug,
sourceContractId: contract.id,
sourceSlug: `/challenges/${challengeCreator.username}/${challenge.contractSlug}/${challenge.slug}`,
}
return await notificationRef.set(removeUndefinedProps(notification))
}

View File

@ -1,13 +1,16 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import { z } from 'zod'
import { uniq } from 'lodash'
import { import {
MANIFOLD_AVATAR_URL,
MANIFOLD_USERNAME,
PrivateUser, PrivateUser,
STARTING_BALANCE, STARTING_BALANCE,
SUS_STARTING_BALANCE, SUS_STARTING_BALANCE,
User, User,
} from '../../common/user' } from '../../common/user'
import { getUser, getUserByUsername } from './utils' import { getUser, getUserByUsername, getValues, isProd } from './utils'
import { randomString } from '../../common/util/random' import { randomString } from '../../common/util/random'
import { import {
cleanDisplayName, cleanDisplayName,
@ -15,86 +18,88 @@ import {
} from '../../common/util/clean-username' } from '../../common/util/clean-username'
import { sendWelcomeEmail } from './emails' import { sendWelcomeEmail } from './emails'
import { isWhitelisted } from '../../common/envs/constants' 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 { track } from './analytics'
import { APIError, newEndpoint, validate } from './api'
import { Group, NEW_USER_GROUP_SLUGS } from '../../common/group'
import {
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
HOUSE_LIQUIDITY_PROVIDER_ID,
} from '../../common/antes'
export const createUser = functions const bodySchema = z.object({
.runWith({ minInstances: 1, secrets: ['MAILGUN_KEY'] }) deviceToken: z.string().optional(),
.https.onCall(async (data: { deviceToken?: string }, context) => { })
const userId = context?.auth?.uid
if (!userId) return { status: 'error', message: 'Not authorized' }
const preexistingUser = await getUser(userId) const opts = { secrets: ['MAILGUN_KEY'] }
if (preexistingUser)
return {
status: 'error',
message: 'User already created',
user: preexistingUser,
}
const fbUser = await admin.auth().getUser(userId) export const createuser = newEndpoint(opts, async (req, auth) => {
const { deviceToken } = validate(bodySchema, req.body)
const preexistingUser = await getUser(auth.uid)
if (preexistingUser)
throw new APIError(400, 'User already exists', { user: preexistingUser })
const email = fbUser.email const fbUser = await admin.auth().getUser(auth.uid)
if (!isWhitelisted(email)) {
return { status: 'error', message: `${email} is not whitelisted` }
}
const emailName = email?.replace(/@.*$/, '')
const rawName = fbUser.displayName || emailName || 'User' + randomString(4) const email = fbUser.email
const name = cleanDisplayName(rawName) if (!isWhitelisted(email)) {
let username = cleanUsername(name) throw new APIError(400, `${email} is not whitelisted`)
}
const emailName = email?.replace(/@.*$/, '')
const sameNameUser = await getUserByUsername(username) const rawName = fbUser.displayName || emailName || 'User' + randomString(4)
if (sameNameUser) { const name = cleanDisplayName(rawName)
username += randomString(4) let username = cleanUsername(name)
}
const avatarUrl = fbUser.photoURL const sameNameUser = await getUserByUsername(username)
if (sameNameUser) {
username += randomString(4)
}
const { deviceToken } = data const avatarUrl = fbUser.photoURL
const deviceUsedBefore = const deviceUsedBefore =
!deviceToken || (await isPrivateUserWithDeviceToken(deviceToken)) !deviceToken || (await isPrivateUserWithDeviceToken(deviceToken))
const ipAddress = context.rawRequest.ip const balance = deviceUsedBefore ? SUS_STARTING_BALANCE : STARTING_BALANCE
const ipCount = ipAddress ? await numberUsersWithIp(ipAddress) : 0
const balance = const user: User = {
deviceUsedBefore || ipCount > 2 ? SUS_STARTING_BALANCE : STARTING_BALANCE id: auth.uid,
name,
username,
avatarUrl,
balance,
totalDeposits: balance,
createdTime: Date.now(),
profitCached: { daily: 0, weekly: 0, monthly: 0, allTime: 0 },
creatorVolumeCached: { daily: 0, weekly: 0, monthly: 0, allTime: 0 },
followerCountCached: 0,
followedCategories: DEFAULT_CATEGORIES,
shouldShowWelcome: true,
}
const user: User = { await firestore.collection('users').doc(auth.uid).create(user)
id: userId, console.log('created user', username, 'firebase id:', auth.uid)
name,
username,
avatarUrl,
balance,
totalDeposits: balance,
createdTime: Date.now(),
profitCached: { daily: 0, weekly: 0, monthly: 0, allTime: 0 },
creatorVolumeCached: { daily: 0, weekly: 0, monthly: 0, allTime: 0 },
followerCountCached: 0,
followedCategories: DEFAULT_CATEGORIES,
}
await firestore.collection('users').doc(userId).create(user) const privateUser: PrivateUser = {
console.log('created user', username, 'firebase id:', userId) id: auth.uid,
username,
email,
initialIpAddress: req.ip,
initialDeviceToken: deviceToken,
}
const privateUser: PrivateUser = { await firestore.collection('private-users').doc(auth.uid).create(privateUser)
id: userId,
username,
email,
initialIpAddress: ipAddress,
initialDeviceToken: deviceToken,
}
await firestore.collection('private-users').doc(userId).create(privateUser) await addUserToDefaultGroups(user)
await sendWelcomeEmail(user, privateUser)
await track(auth.uid, 'create user', { username }, { ip: req.ip })
await sendWelcomeEmail(user, privateUser) return { user, privateUser }
})
await track(userId, 'create user', { username }, { ip: ipAddress })
return { status: 'success', user }
})
const firestore = admin.firestore() const firestore = admin.firestore()
@ -107,7 +112,7 @@ const isPrivateUserWithDeviceToken = async (deviceToken: string) => {
return !snap.empty return !snap.empty
} }
const numberUsersWithIp = async (ipAddress: string) => { export const numberUsersWithIp = async (ipAddress: string) => {
const snap = await firestore const snap = await firestore
.collection('private-users') .collection('private-users')
.where('initialIpAddress', '==', ipAddress) .where('initialIpAddress', '==', ipAddress)
@ -115,3 +120,50 @@ const numberUsersWithIp = async (ipAddress: string) => {
return snap.docs.length 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.username} aka ${user.name}!`,
createdTime: Date.now(),
userName: 'Manifold Markets',
userUsername: MANIFOLD_USERNAME,
userAvatarUrl: MANIFOLD_AVATAR_URL,
})
}
}
}

View File

@ -0,0 +1,260 @@
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml"
xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<title>Manifold Markets 7th Day Anniversary Gift!</title>
<!--[if !mso]><!-->
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<!--<![endif]-->
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<style type="text/css">
#outlook a {
padding: 0;
}
body {
margin: 0;
padding: 0;
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
}
table,
td {
border-collapse: collapse;
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
}
img {
border: 0;
height: auto;
line-height: 100%;
outline: none;
text-decoration: none;
-ms-interpolation-mode: bicubic;
}
p {
display: block;
margin: 13px 0;
}
</style>
<!--[if mso]>
<noscript>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG/>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
</noscript>
<![endif]-->
<!--[if lte mso 11]>
<style type="text/css">
.mj-outlook-group-fix { width:100% !important; }
</style>
<![endif]-->
<style type="text/css">
@media only screen and (min-width:480px) {
.mj-column-per-100 {
width: 100% !important;
max-width: 100%;
}
}
</style>
<style media="screen and (min-width:480px)">
.moz-text-html .mj-column-per-100 {
width: 100% !important;
max-width: 100%;
}
</style>
<style type="text/css">
[owa] .mj-column-per-100 {
width: 100% !important;
max-width: 100%;
}
</style>
<style type="text/css">
@media only screen and (max-width:480px) {
table.mj-full-width-mobile {
width: 100% !important;
}
td.mj-full-width-mobile {
width: auto !important;
}
}
</style>
</head>
<body style="word-spacing:normal;background-color:#F4F4F4;">
<div style="background-color:#F4F4F4;">
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
style="background:#ffffff;background-color:#ffffff;width:100%;">
<tbody>
<tr>
<td
style="direction:ltr;font-size:0px;padding:0px 0px 0px 0px;padding-bottom:0px;padding-left:0px;padding-right:0px;padding-top:0px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix"
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;"
width="100%">
<tbody>
<tr>
<td align="center"
style="font-size:0px;padding:0px 25px 0px 25px;padding-top:0px;padding-right:25px;padding-bottom:0px;padding-left:25px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
style="border-collapse:collapse;border-spacing:0px;">
<tbody>
<tr>
<td style="width:550px;"><a href="https://manifold.markets/home" target="_blank"><img
alt="" height="auto" src="https://i.imgur.com/8EP8Y8q.gif"
style="border:none;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:13px;"
width="550"></a></td>
</tr>
</tbody>
</table>
</td>
</tr>
<tr>
<td align="left"
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
<div
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
<p class="text-build-content"
style="line-height: 24px; text-align: center; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"
data-testid="4XoHRGw1Y"><span
style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">Thanks for
using Manifold Markets. Running low
on mana (M$)? Click the link below to receive a one time gift of M$500!</span></p>
</div>
</td>
</tr>
<tr>
<td>
<p></p>
</td>
</tr>
<tr>
<td align="center">
<table cellspacing="0" cellpadding="0">
<tr>
<td>
<table cellspacing="0" cellpadding="0">
<tr>
<td style="border-radius: 2px;" bgcolor="#4337c9">
<a href="{{manalink}}" target="_blank"
style="padding: 12px 16px; border: 1px solid #4337c9;border-radius: 16px;font-family: Helvetica, Arial, sans-serif;font-size: 24px; color: #ffffff;text-decoration: none;font-weight:bold;display: inline-block;">
Claim M$500
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td align="left"
style="font-size:0px;padding:15px 25px 0px 25px;padding-top:15px;padding-right:25px;padding-bottom:0px;padding-left:25px;word-break:break-word;">
<div
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
<p class="text-build-content" style="line-height: 23px; margin: 10px 0; margin-top: 10px;"
data-testid="3Q8BP69fq"></a></li>
</ul>
<p class="text-build-content" data-testid="3Q8BP69fq" style="margin: 10px 0;">&nbsp;</p>
<p class="text-build-content" data-testid="3Q8BP69fq" style="margin: 10px 0;"><span
style="color:#000000;font-family:Arial;font-size:18px;">Cheers,</span></p>
<p class="text-build-content" data-testid="3Q8BP69fq" style="margin: 10px 0;"><span
style="color:#000000;font-family:Arial;font-size:18px;">David from Manifold</span></p>
<p class="text-build-content" data-testid="3Q8BP69fq"
style="margin: 10px 0; margin-bottom: 10px;">&nbsp;</p>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:0 0 20px 0;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix"
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;"
width="100%">
<tbody>
<tr>
<td align="center" style="font-size:0px;padding:0px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
style="border-collapse:collapse;border-spacing:0px;">
</div>
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:20px 0px 20px 0px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix"
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" width="100%">
<tbody>
<tr>
<td style="vertical-align:top;padding:0;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" width="100%">
<tbody>
<tr>
<td align="center"
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
<div
style="font-family:Arial, sans-serif;font-size:11px;letter-spacing:normal;line-height:22px;text-align:center;color:#000000;">
<p style="margin: 10px 0;">This e-mail has been sent to {{name}}, <a
href="{{unsubscribeLink}}” style=" color:inherit;text-decoration:none;"
target="_blank">click here to unsubscribe</a>.</p>
</div>
</td>
</tr>
<tr>
<td align="center"
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
<div
style="font-family:Arial, sans-serif;font-size:11px;letter-spacing:normal;line-height:22px;text-align:center;color:#000000;">
</div>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</div>
</body>
</html>

View File

@ -0,0 +1,738 @@
<!DOCTYPE html>
<html
xmlns="http://www.w3.org/1999/xhtml"
xmlns:v="urn:schemas-microsoft-com:vml"
xmlns:o="urn:schemas-microsoft-com:office:office"
>
<head>
<title>(no subject)</title>
<!--[if !mso]><!-->
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<!--<![endif]-->
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<style type="text/css">
#outlook a {
padding: 0;
}
body {
margin: 0;
padding: 0;
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
}
table,
td {
border-collapse: collapse;
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
}
img {
border: 0;
height: auto;
line-height: 100%;
outline: none;
text-decoration: none;
-ms-interpolation-mode: bicubic;
}
p {
display: block;
margin: 13px 0;
}
</style>
<!--[if mso]>
<noscript>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG />
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
</noscript>
<![endif]-->
<!--[if lte mso 11]>
<style type="text/css">
.mj-outlook-group-fix {
width: 100% !important;
}
</style>
<![endif]-->
<!--[if !mso]><!-->
<link
href="https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700"
rel="stylesheet"
type="text/css"
/>
<link
href="https://fonts.googleapis.com/css?family=Readex+Pro"
rel="stylesheet"
type="text/css"
/>
<link
href="https://fonts.googleapis.com/css?family=Readex+Pro"
rel="stylesheet"
type="text/css"
/>
<style type="text/css">
@import url(https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700);
@import url(https://fonts.googleapis.com/css?family=Readex+Pro);
@import url(https://fonts.googleapis.com/css?family=Readex+Pro);
</style>
<!--<![endif]-->
<style type="text/css">
@media only screen and (min-width: 480px) {
.mj-column-per-100 {
width: 100% !important;
max-width: 100%;
}
}
</style>
<style media="screen and (min-width:480px)">
.moz-text-html .mj-column-per-100 {
width: 100% !important;
max-width: 100%;
}
</style>
<style type="text/css">
[owa] .mj-column-per-100 {
width: 100% !important;
max-width: 100%;
}
</style>
<style type="text/css">
@media only screen and (max-width: 480px) {
table.mj-full-width-mobile {
width: 100% !important;
}
td.mj-full-width-mobile {
width: auto !important;
}
}
</style>
</head>
<body style="word-spacing: normal; background-color: #f4f4f4">
<div style="background-color: #f4f4f4">
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div
style="
background: #ffffff;
background-color: #ffffff;
margin: 0px auto;
max-width: 600px;
"
>
<table
align="center"
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
style="background: #ffffff; background-color: #ffffff; width: 100%"
>
<tbody>
<tr>
<td
style="
direction: ltr;
font-size: 0px;
padding: 0px 0px 0px 0px;
padding-bottom: 0px;
padding-left: 0px;
padding-right: 0px;
padding-top: 0px;
text-align: center;
"
>
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
<div
class="mj-column-per-100 mj-outlook-group-fix"
style="
font-size: 0px;
text-align: left;
direction: ltr;
display: inline-block;
vertical-align: top;
width: 100%;
"
>
<table
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
style="vertical-align: top"
width="100%"
>
<tbody>
<tr>
<td
align="center"
style="
font-size: 0px;
padding: 0px 25px 0px 25px;
padding-top: 0px;
padding-right: 25px;
padding-bottom: 0px;
padding-left: 25px;
word-break: break-word;
"
>
<table
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
style="
border-collapse: collapse;
border-spacing: 0px;
"
>
<tbody>
<tr>
<td style="width: 550px">
<a
href="https://manifold.markets/home"
target="_blank"
><img
alt=""
height="auto"
src="https://03jlj.mjt.lu/img/03jlj/b/96u/omk8.gif"
style="
border: none;
display: block;
outline: none;
text-decoration: none;
height: auto;
width: 100%;
font-size: 13px;
"
width="550"
/></a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div
style="
background: #ffffff;
background-color: #ffffff;
margin: 0px auto;
max-width: 600px;
"
>
<table
align="center"
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
style="background: #ffffff; background-color: #ffffff; width: 100%"
>
<tbody>
<tr>
<td
style="
direction: ltr;
font-size: 0px;
padding: 20px 0px 0px 0px;
padding-bottom: 0px;
padding-left: 0px;
padding-right: 0px;
padding-top: 20px;
text-align: center;
"
>
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
<div
class="mj-column-per-100 mj-outlook-group-fix"
style="
font-size: 0px;
text-align: left;
direction: ltr;
display: inline-block;
vertical-align: top;
width: 100%;
"
>
<table
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
style="vertical-align: top"
width="100%"
>
<tbody>
<tr>
<td
align="left"
style="
font-size: 0px;
padding: 0px 25px 20px 25px;
padding-top: 0px;
padding-right: 25px;
padding-bottom: 20px;
padding-left: 25px;
word-break: break-word;
"
>
<div
style="
font-family: Arial, sans-serif;
font-size: 17px;
letter-spacing: normal;
line-height: 1;
text-align: left;
color: #000000;
"
>
<p
class="text-build-content"
style="
line-height: 23px;
margin: 10px 0;
margin-top: 10px;
"
data-testid="3Q8BP69fq"
>
<span
style="
font-family: Readex Pro, Arial, Helvetica,
sans-serif;
font-size: 17px;
"
>On Manifold Markets, several important factors
go into making a good question. These lead to
more people betting on them and allowing a more
accurate prediction to be formed!</span
>
</p>
<p
class="text-build-content"
style="line-height: 23px; margin: 10px 0"
data-testid="3Q8BP69fq"
>
&nbsp;
</p>
<p
class="text-build-content"
style="line-height: 23px; margin: 10px 0"
data-testid="3Q8BP69fq"
>
<span
style="
font-family: Readex Pro, Arial, Helvetica,
sans-serif;
font-size: 17px;
"
>Manifold also gives its creators 10 Mana for
each unique trader that bets on your
market!</span
>
</p>
<p
class="text-build-content"
style="line-height: 23px; margin: 10px 0"
data-testid="3Q8BP69fq"
>
&nbsp;
</p>
<p
class="text-build-content"
style="line-height: 23px; margin: 10px 0"
data-testid="3Q8BP69fq"
>
<span
style="
color: #292fd7;
font-family: Readex Pro, Arial, Helvetica,
sans-serif;
font-size: 20px;
"
><b>What makes a good question?</b></span
>
</p>
<ul>
<li style="line-height: 23px">
<span
style="
font-family: Readex Pro, Arial, Helvetica,
sans-serif;
font-size: 17px;
"
><b>Clear resolution criteria. </b>This is
needed so users know how you are going to
decide on what the correct answer is.</span
>
</li>
<li style="line-height: 23px">
<span
style="
font-family: Readex Pro, Arial, Helvetica,
sans-serif;
font-size: 17px;
"
><b>Clear resolution date</b>. This is
sometimes slightly different from the closing
date. We recommend leaving the market open up
until you resolve it, but if it is different
make sure you say what day you intend to
resolve it in the description!</span
>
</li>
<li style="line-height: 23px">
<span
style="
font-family: Readex Pro, Arial, Helvetica,
sans-serif;
font-size: 17px;
"
><b>Detailed description. </b>Use the rich
text editor to create an easy to read
description. Include any context or background
information that could be useful to people who
are interested in learning more that are
uneducated on the subject.</span
>
</li>
<li style="line-height: 23px">
<span
style="
font-family: Readex Pro, Arial, Helvetica,
sans-serif;
font-size: 17px;
"
><b>Add it to a group. </b>Groups are the
primary way users filter for relevant markets.
Also, consider making your own groups and
inviting friends/interested communities to
them from other sites!</span
>
</li>
<li style="line-height: 23px">
<span
style="
font-family: Readex Pro, Arial, Helvetica,
sans-serif;
font-size: 17px;
"
><b>Bonus: </b>Add a comment on your
prediction and explain (with links and
sources) supporting it.</span
>
</li>
</ul>
<p
class="text-build-content"
style="line-height: 23px; margin: 10px 0"
data-testid="3Q8BP69fq"
>
&nbsp;
</p>
<p
class="text-build-content"
style="line-height: 23px; margin: 10px 0"
data-testid="3Q8BP69fq"
>
<span
style="
color: #292fd7;
font-family: Readex Pro, Arial, Helvetica,
sans-serif;
font-size: 20px;
"
><b
>Examples of markets you should
emulate!&nbsp;</b
></span
>
</p>
<ul>
<li style="line-height: 23px">
<a
class="link-build-content"
style="color: inherit; text-decoration: none"
target="_blank"
href="https://manifold.markets/DavidChee/will-our-upcoming-twitch-bot-be-a-s"
><span
style="
color: #55575d;
font-family: Readex Pro, Arial, Helvetica,
sans-serif;
font-size: 17px;
"
><u>This complex market</u></span
></a
><span
style="
font-family: Readex Pro, Arial, Helvetica,
sans-serif;
font-size: 17px;
"
>
about the project I am working on.</span
>
</li>
<li style="line-height: 23px">
<a
class="link-build-content"
style="color: inherit; text-decoration: none"
target="_blank"
href="https://manifold.markets/SneakySly/will-manifold-reach-1000-weekly-act"
><span
style="
color: #55575d;
font-family: Readex Pro, Arial, Helvetica,
sans-serif;
font-size: 17px;
"
><u>This simple market</u></span
></a
><span
style="
font-family: Readex Pro, Arial, Helvetica,
sans-serif;
font-size: 17px;
"
>
about Manifold&apos;s weekly active
users.</span
>
</li>
</ul>
<p
class="text-build-content"
style="line-height: 23px; margin: 10px 0"
data-testid="3Q8BP69fq"
>
&nbsp;
</p>
<p
class="text-build-content"
style="line-height: 23px; margin: 10px 0"
data-testid="3Q8BP69fq"
>
<span
style="
color: #000000;
font-family: Readex Pro, Arial, Helvetica,
sans-serif;
font-size: 17px;
"
>Why not </span>
<a
class="link-build-content"
style="color: inherit; text-decoration: none"
target="_blank"
href="https://manifold.markets/create"
><span
style="
color: #55575d;
font-family: Readex Pro, Arial, Helvetica,
sans-serif;
font-size: 17px;
"
><u>create a market</u></span
></a
><span
style="
font-family: Readex Pro, Arial, Helvetica,
sans-serif;
font-size: 17px;
"
>
while it is still fresh on your mind?
</p>
<p
class="text-build-content"
style="line-height: 23px; margin: 10px 0"
data-testid="3Q8BP69fq"
>
<span
style="
color: #000000;
font-family: Readex Pro, Arial, Helvetica,
sans-serif;
font-size: 17px;
"
>Thanks for reading!</span
>
</p>
<p
class="text-build-content"
style="
line-height: 23px;
margin: 10px 0;
margin-bottom: 10px;
"
data-testid="3Q8BP69fq"
>
<span
style="
color: #000000;
font-family: Readex Pro, Arial, Helvetica,
sans-serif;
font-size: 17px;
"
>David from Manifold</span
>
</p>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin: 0px auto; max-width: 600px">
<table
align="center"
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
style="width: 100%"
>
<tbody>
<tr>
<td
style="
direction: ltr;
font-size: 0px;
padding: 0 0 20px 0;
text-align: center;
"
>
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin: 0px auto; max-width: 600px">
<table
align="center"
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
style="width: 100%"
>
<tbody>
<tr>
<td
style="
direction: ltr;
font-size: 0px;
padding: 20px 0px 20px 0px;
text-align: center;
"
>
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
<div
class="mj-column-per-100 mj-outlook-group-fix"
style="
font-size: 0px;
text-align: left;
direction: ltr;
display: inline-block;
vertical-align: top;
width: 100%;
"
>
<table
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
width="100%"
>
<tbody>
<tr>
<td style="vertical-align: top; padding: 0">
<table
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
width="100%"
>
<tbody>
<tr>
<td
align="center"
style="
font-size: 0px;
padding: 10px 25px;
word-break: break-word;
"
>
<div
style="
font-family: Ubuntu, Helvetica, Arial,
sans-serif;
font-size: 11px;
line-height: 22px;
text-align: center;
color: #000000;
"
>
<p style="margin: 10px 0">
This e-mail has been sent to {{name}},
<a
href="{{unsubscribeLink}}"
style="
color: inherit;
text-decoration: none;
"
target="_blank"
>click here to unsubscribe</a
>.
</p>
</div>
</td>
</tr>
<tr>
<td
align="center"
style="
font-size: 0px;
padding: 10px 25px;
word-break: break-word;
"
></td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</div>
</body>
</html>

View File

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

View File

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

File diff suppressed because it is too large Load Diff

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 { Answer } from '../../common/answer'
import { Bet } from '../../common/bet' import { Bet } from '../../common/bet'
import { getProbability } from '../../common/calculate' import { getProbability } from '../../common/calculate'
@ -6,11 +6,20 @@ import { Comment } from '../../common/comment'
import { Contract } from '../../common/contract' import { Contract } from '../../common/contract'
import { DPM_CREATOR_FEE } from '../../common/fees' import { DPM_CREATOR_FEE } from '../../common/fees'
import { PrivateUser, User } from '../../common/user' 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 { getValueFromBucket } from '../../common/calculate-dpm'
import { formatNumericProbability } from '../../common/pseudo-numeric'
import { sendTemplateEmail } from './send-email' import { sendTemplateEmail } from './send-email'
import { getPrivateUser, getUser } from './utils' import { getPrivateUser, getUser } from './utils'
import { getFunctionUrl } from '../../common/api'
import { richTextToString } from '../../common/util/parse'
const UNSUBSCRIBE_ENDPOINT = getFunctionUrl('unsubscribe')
export const sendMarketResolutionEmail = async ( export const sendMarketResolutionEmail = async (
userId: string, userId: string,
@ -48,6 +57,9 @@ export const sendMarketResolutionEmail = async (
? ` (plus ${formatMoney(creatorPayout)} in commissions)` ? ` (plus ${formatMoney(creatorPayout)} in commissions)`
: '' : ''
const emailType = 'market-resolved'
const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}`
const templateData: market_resolved_template = { const templateData: market_resolved_template = {
userId: user.id, userId: user.id,
name: user.name, name: user.name,
@ -57,6 +69,7 @@ export const sendMarketResolutionEmail = async (
investment: `${Math.floor(investment)}`, investment: `${Math.floor(investment)}`,
payout: `${Math.floor(payout)}${creatorPayoutText}`, payout: `${Math.floor(payout)}${creatorPayoutText}`,
url: `https://${DOMAIN}/${creator.username}/${contract.slug}`, url: `https://${DOMAIN}/${creator.username}/${contract.slug}`,
unsubscribeUrl,
} }
// Modify template here: // Modify template here:
@ -80,6 +93,7 @@ type market_resolved_template = {
investment: string investment: string
payout: string payout: string
url: string url: string
unsubscribeUrl: string
} }
const toDisplayResolution = ( const toDisplayResolution = (
@ -101,6 +115,17 @@ const toDisplayResolution = (
return display || resolution 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 === 'MKT' && resolutions) return 'MULTI'
if (resolution === 'CANCEL') return 'N/A' if (resolution === 'CANCEL') return 'N/A'
@ -125,7 +150,7 @@ export const sendWelcomeEmail = async (
const firstName = name.split(' ')[0] const firstName = name.split(' ')[0]
const emailType = 'generic' 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( await sendTemplateEmail(
privateUser.email, privateUser.email,
@ -141,7 +166,6 @@ export const sendWelcomeEmail = async (
) )
} }
// TODO: use manalinks to give out M$500
export const sendOneWeekBonusEmail = async ( export const sendOneWeekBonusEmail = async (
user: User, user: User,
privateUser: PrivateUser privateUser: PrivateUser
@ -157,16 +181,16 @@ export const sendOneWeekBonusEmail = async (
const firstName = name.split(' ')[0] const firstName = name.split(' ')[0]
const emailType = 'generic' 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( await sendTemplateEmail(
privateUser.email, privateUser.email,
'Manifold one week anniversary gift', 'Manifold Markets one week anniversary gift',
'one-week', 'one-week',
{ {
name: firstName, name: firstName,
unsubscribeLink, unsubscribeLink,
manalink: '', // TODO manalink: 'https://manifold.markets/link/lj4JbBvE',
}, },
{ {
from: 'David from Manifold <david@manifold.markets>', from: 'David from Manifold <david@manifold.markets>',
@ -189,7 +213,7 @@ export const sendThankYouEmail = async (
const firstName = name.split(' ')[0] const firstName = name.split(' ')[0]
const emailType = 'generic' 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( await sendTemplateEmail(
privateUser.email, privateUser.email,
@ -223,6 +247,8 @@ export const sendMarketCloseEmail = async (
const { question, slug, volume, mechanism, collectedFees } = contract const { question, slug, volume, mechanism, collectedFees } = contract
const url = `https://${DOMAIN}/${username}/${slug}` const url = `https://${DOMAIN}/${username}/${slug}`
const emailType = 'market-resolve'
const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}`
await sendTemplateEmail( await sendTemplateEmail(
privateUser.email, privateUser.email,
@ -231,6 +257,7 @@ export const sendMarketCloseEmail = async (
{ {
question, question,
url, url,
unsubscribeUrl,
userId, userId,
name: firstName, name: firstName,
volume: formatMoney(volume), volume: formatMoney(volume),
@ -261,11 +288,12 @@ export const sendNewCommentEmail = async (
const { question, creatorUsername, slug } = contract const { question, creatorUsername, slug } = contract
const marketUrl = `https://${DOMAIN}/${creatorUsername}/${slug}#${comment.id}` const marketUrl = `https://${DOMAIN}/${creatorUsername}/${slug}#${comment.id}`
const emailType = 'market-comment'
const unsubscribeUrl = `https://us-central1-${PROJECT_ID}.cloudfunctions.net/unsubscribe?id=${userId}&type=market-comment` const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}`
const { name: commentorName, avatarUrl: commentorAvatarUrl } = commentCreator const { name: commentorName, avatarUrl: commentorAvatarUrl } = commentCreator
const { text } = comment const { content } = comment
const text = richTextToString(content)
let betDescription = '' let betDescription = ''
if (bet) { if (bet) {
@ -275,7 +303,7 @@ export const sendNewCommentEmail = async (
)}` )}`
} }
const subject = `Comment from ${commentorName} on ${question}` const subject = `Comment on ${question}`
const from = `${commentorName} on Manifold <no-reply@manifold.markets>` const from = `${commentorName} on Manifold <no-reply@manifold.markets>`
if (contract.outcomeType === 'FREE_RESPONSE' && answerId && answerText) { if (contract.outcomeType === 'FREE_RESPONSE' && answerId && answerText) {
@ -343,7 +371,8 @@ export const sendNewAnswerEmail = async (
const { name, avatarUrl, text } = answer const { name, avatarUrl, text } = answer
const marketUrl = `https://${DOMAIN}/${creatorUsername}/${slug}` 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 subject = `New answer on ${question}`
const from = `${name} <info@manifold.markets>` 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

@ -0,0 +1,18 @@
import { User } from 'common/user'
import * as admin from 'firebase-admin'
import { newEndpoint, APIError } from './api'
export const getcurrentuser = newEndpoint(
{ method: 'GET' },
async (_req, auth) => {
const userDoc = firestore.doc(`users/${auth.uid}`)
const [userSnap] = await firestore.getAll(userDoc)
if (!userSnap.exists) throw new APIError(400, 'User not found.')
const user = userSnap.data() as User
return user
}
)
const firestore = admin.firestore()

View File

@ -0,0 +1,33 @@
import * as admin from 'firebase-admin'
import {
APIError,
EndpointDefinition,
lookupUser,
parseCredentials,
writeResponseError,
} from './api'
const opts = {
method: 'GET',
minInstances: 1,
concurrency: 100,
memory: '2GiB',
cpu: 1,
} as const
export const getcustomtoken: EndpointDefinition = {
opts,
handler: async (req, res) => {
try {
const credentials = await parseCredentials(req)
if (credentials.kind != 'jwt') {
throw new APIError(403, 'API keys cannot mint custom tokens.')
}
const user = await lookupUser(credentials)
const token = await admin.auth().createCustomToken(user.uid)
res.status(200).json({ token: token })
} catch (e) {
writeResponseError(e, res)
}
},
}

View File

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

View File

@ -1,26 +1,18 @@
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import { onRequest } from 'firebase-functions/v2/https'
import { EndpointDefinition } from './api'
admin.initializeApp() admin.initializeApp()
// v1 // v1
// export * from './keep-awake'
export * from './claim-manalink'
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-bet'
export * from './on-create-comment' export * from './on-create-comment-on-contract'
export * from './on-view' export * from './on-view'
export * from './unsubscribe'
export * from './update-metrics' export * from './update-metrics'
export * from './update-stats' export * from './update-stats'
export * from './update-loans' export * from './update-loans'
export * from './backup-db' export * from './backup-db'
export * from './change-user-info'
export * from './market-close-notifications' export * from './market-close-notifications'
export * from './add-liquidity'
export * from './on-create-answer' export * from './on-create-answer'
export * from './on-update-contract' export * from './on-update-contract'
export * from './on-create-contract' export * from './on-create-contract'
@ -29,12 +21,98 @@ export * from './on-unfollow-user'
export * from './on-create-liquidity-provision' export * from './on-create-liquidity-provision'
export * from './on-update-group' export * from './on-update-group'
export * from './on-create-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'
export * from './score-contracts'
// v2 // v2
export * from './health' 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 './place-bet'
export * from './cancel-bet'
export * from './sell-bet' export * from './sell-bet'
export * from './sell-shares' export * from './sell-shares'
export * from './claim-manalink'
export * from './create-contract' export * from './create-contract'
export * from './add-liquidity'
export * from './withdraw-liquidity' export * from './withdraw-liquidity'
export * from './create-group' export * from './create-group'
export * from './resolve-market'
export * from './unsubscribe'
export * from './stripe'
export * from './mana-bonus-email'
import { health } from './health'
import { transact } from './transact'
import { changeuserinfo } from './change-user-info'
import { createuser } from './create-user'
import { createanswer } from './create-answer'
import { placebet } from './place-bet'
import { cancelbet } from './cancel-bet'
import { sellbet } from './sell-bet'
import { sellshares } from './sell-shares'
import { claimmanalink } from './claim-manalink'
import { createmarket } from './create-contract'
import { addliquidity } from './add-liquidity'
import { withdrawliquidity } from './withdraw-liquidity'
import { creategroup } from './create-group'
import { resolvemarket } from './resolve-market'
import { unsubscribe } from './unsubscribe'
import { stripewebhook, createcheckoutsession } from './stripe'
import { getcurrentuser } from './get-current-user'
import { acceptchallenge } from './accept-challenge'
import { getcustomtoken } from './get-custom-token'
const toCloudFunction = ({ opts, handler }: EndpointDefinition) => {
return onRequest(opts, handler as any)
}
const healthFunction = toCloudFunction(health)
const transactFunction = toCloudFunction(transact)
const changeUserInfoFunction = toCloudFunction(changeuserinfo)
const createUserFunction = toCloudFunction(createuser)
const createAnswerFunction = toCloudFunction(createanswer)
const placeBetFunction = toCloudFunction(placebet)
const cancelBetFunction = toCloudFunction(cancelbet)
const sellBetFunction = toCloudFunction(sellbet)
const sellSharesFunction = toCloudFunction(sellshares)
const claimManalinkFunction = toCloudFunction(claimmanalink)
const createMarketFunction = toCloudFunction(createmarket)
const addLiquidityFunction = toCloudFunction(addliquidity)
const withdrawLiquidityFunction = toCloudFunction(withdrawliquidity)
const createGroupFunction = toCloudFunction(creategroup)
const resolveMarketFunction = toCloudFunction(resolvemarket)
const unsubscribeFunction = toCloudFunction(unsubscribe)
const stripeWebhookFunction = toCloudFunction(stripewebhook)
const createCheckoutSessionFunction = toCloudFunction(createcheckoutsession)
const getCurrentUserFunction = toCloudFunction(getcurrentuser)
const acceptChallenge = toCloudFunction(acceptchallenge)
const getCustomTokenFunction = toCloudFunction(getcustomtoken)
export {
healthFunction as health,
transactFunction as transact,
changeUserInfoFunction as changeuserinfo,
createUserFunction as createuser,
createAnswerFunction as createanswer,
placeBetFunction as placebet,
cancelBetFunction as cancelbet,
sellBetFunction as sellbet,
sellSharesFunction as sellshares,
claimManalinkFunction as claimmanalink,
createMarketFunction as createmarket,
addLiquidityFunction as addliquidity,
withdrawLiquidityFunction as withdrawliquidity,
createGroupFunction as creategroup,
resolveMarketFunction as resolvemarket,
unsubscribeFunction as unsubscribe,
stripeWebhookFunction as stripewebhook,
createCheckoutSessionFunction as createcheckoutsession,
getCurrentUserFunction as getcurrentuser,
acceptChallenge as acceptchallenge,
getCustomTokenFunction as getcustomtoken,
}

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

@ -0,0 +1,42 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import * as dayjs from 'dayjs'
import { getPrivateUser } from './utils'
import { sendOneWeekBonusEmail } from './emails'
import { User } from 'common/user'
export const manabonusemail = functions
.runWith({ secrets: ['MAILGUN_KEY'] })
.pubsub.schedule('0 9 * * 1-7')
.onRun(async () => {
await sendOneWeekEmails()
})
const firestore = admin.firestore()
async function sendOneWeekEmails() {
const oneWeekAgo = dayjs().subtract(1, 'week').valueOf()
const twoWeekAgo = dayjs().subtract(2, 'weeks').valueOf()
const userDocs = await firestore
.collection('users')
.where('createdTime', '<=', oneWeekAgo)
.get()
for (const user of userDocs.docs.map((d) => d.data() as User)) {
if (user.createdTime < twoWeekAgo) continue
const privateUser = await getPrivateUser(user.id)
if (!privateUser || privateUser.manaBonusEmailSent) continue
await firestore
.collection('private-users')
.doc(user.id)
.update({ manaBonusEmailSent: true })
console.log('sending m$ bonus email to', user.username)
await sendOneWeekBonusEmail(user, privateUser)
return
}
}

View File

@ -64,7 +64,7 @@ async function sendMarketCloseEmails() {
user, user,
'closed' + contract.id.slice(6, contract.id.length), 'closed' + contract.id.slice(6, contract.id.length),
contract.closeTime?.toString() ?? new Date().toString(), contract.closeTime?.toString() ?? new Date().toString(),
contract { contract }
) )
} }
} }

View File

@ -10,14 +10,14 @@ export const onCreateAnswer = functions.firestore
contractId: string contractId: string
} }
const { eventId } = context const { eventId } = context
const contract = await getContract(contractId)
if (!contract)
throw new Error('Could not find contract corresponding with answer')
const answer = change.data() as Answer const answer = change.data() as Answer
// Ignore ante answer. // Ignore ante answer.
if (answer.number === 0) return if (answer.number === 0) return
const contract = await getContract(contractId)
if (!contract)
throw new Error('Could not find contract corresponding with answer')
const answerCreator = await getUser(answer.userId) const answerCreator = await getUser(answer.userId)
if (!answerCreator) throw new Error('Could not find answer creator') if (!answerCreator) throw new Error('Could not find answer creator')
@ -28,6 +28,6 @@ export const onCreateAnswer = functions.firestore
answerCreator, answerCreator,
eventId, eventId,
answer.text, answer.text,
contract { contract }
) )
}) })

View File

@ -1,9 +1,26 @@
import * as functions from 'firebase-functions' import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin' 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 firestore = admin.firestore()
const BONUS_START_DATE = new Date('2022-07-13T15:30:00.000Z').getTime()
export const onCreateBet = functions.firestore export const onCreateBet = functions.firestore
.document('contracts/{contractId}/bets/{betId}') .document('contracts/{contractId}/bets/{betId}')
@ -11,6 +28,8 @@ export const onCreateBet = functions.firestore
const { contractId } = context.params as { const { contractId } = context.params as {
contractId: string contractId: string
} }
const { eventId } = context
const bet = change.data() as Bet const bet = change.data() as Bet
const lastBetTime = bet.createdTime const lastBetTime = bet.createdTime
@ -18,4 +37,146 @@ export const onCreateBet = functions.firestore
.collection('contracts') .collection('contracts')
.doc(contractId) .doc(contractId)
.update({ lastBetTime, lastUpdatedTime: Date.now() }) .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`).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)
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,
})
}
// No need to give a bonus for the creator's bet
if (!isNewUniqueBettor || bettorId == contract.creatorId) 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,
slug: contract.slug,
title: 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

@ -1,17 +1,17 @@
import * as functions from 'firebase-functions' import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import { uniq } from 'lodash' import { compact, uniq } from 'lodash'
import { getContract, getUser, getValues } from './utils' import { getContract, getUser, getValues } from './utils'
import { Comment } from '../../common/comment' import { Comment } from '../../common/comment'
import { sendNewCommentEmail } from './emails' import { sendNewCommentEmail } from './emails'
import { Bet } from '../../common/bet' import { Bet } from '../../common/bet'
import { Answer } from '../../common/answer' import { Answer } from '../../common/answer'
import { createNotification } from './create-notification' import { createNotification } from './create-notification'
import { parseMentions, richTextToString } from '../../common/util/parse'
const firestore = admin.firestore() const firestore = admin.firestore()
export const onCreateComment = functions export const onCreateCommentOnContract = functions
.runWith({ secrets: ['MAILGUN_KEY'] }) .runWith({ secrets: ['MAILGUN_KEY'] })
.firestore.document('contracts/{contractId}/comments/{commentId}') .firestore.document('contracts/{contractId}/comments/{commentId}')
.onCreate(async (change, context) => { .onCreate(async (change, context) => {
@ -68,20 +68,22 @@ export const onCreateComment = functions
? 'answer' ? 'answer'
: undefined : undefined
const relatedUser = comment.replyToCommentId const repliedUserId = comment.replyToCommentId
? comments.find((c) => c.id === comment.replyToCommentId)?.userId ? comments.find((c) => c.id === comment.replyToCommentId)?.userId
: answer?.userId : answer?.userId
const recipients = uniq(
compact([...parseMentions(comment.content), repliedUserId])
)
await createNotification( await createNotification(
comment.id, comment.id,
'comment', 'comment',
'created', 'created',
commentCreator, commentCreator,
eventId, eventId,
comment.text, richTextToString(comment.content),
contract, { contract, relatedSourceType, recipients }
relatedSourceType,
relatedUser
) )
const recipientUserIds = uniq([ const recipientUserIds = uniq([

View File

@ -0,0 +1,46 @@
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 { createGroupCommentNotification } 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({
mostRecentChatActivityTime: comment.createdTime,
})
await Promise.all(
group.memberIds.map(async (memberId) => {
return await createGroupCommentNotification(
creatorSnapshot.data() as User,
memberId,
comment,
group,
eventId
)
})
)
})

View File

@ -2,6 +2,8 @@ import * as functions from 'firebase-functions'
import { getUser } from './utils' import { getUser } from './utils'
import { createNotification } from './create-notification' import { createNotification } from './create-notification'
import { Contract } from '../../common/contract' import { Contract } from '../../common/contract'
import { parseMentions, richTextToString } from '../../common/util/parse'
import { JSONContent } from '@tiptap/core'
export const onCreateContract = functions.firestore export const onCreateContract = functions.firestore
.document('contracts/{contractId}') .document('contracts/{contractId}')
@ -12,13 +14,16 @@ export const onCreateContract = functions.firestore
const contractCreator = await getUser(contract.creatorId) const contractCreator = await getUser(contract.creatorId)
if (!contractCreator) throw new Error('Could not find contract creator') if (!contractCreator) throw new Error('Could not find contract creator')
const desc = contract.description as JSONContent
const mentioned = parseMentions(desc)
await createNotification( await createNotification(
contract.id, contract.id,
'contract', 'contract',
'created', 'created',
contractCreator, contractCreator,
eventId, eventId,
contract.description, richTextToString(desc),
contract { contract, recipients: mentioned }
) )
}) })

View File

@ -12,19 +12,17 @@ export const onCreateGroup = functions.firestore
const groupCreator = await getUser(group.creatorId) const groupCreator = await getUser(group.creatorId)
if (!groupCreator) throw new Error('Could not find group creator') if (!groupCreator) throw new Error('Could not find group creator')
// create notifications for all members of the group // create notifications for all members of the group
for (const memberId of group.memberIds) { await createNotification(
await createNotification( group.id,
group.id, 'group',
'group', 'created',
'created', groupCreator,
groupCreator, eventId,
eventId, group.about,
group.about, {
undefined, recipients: group.memberIds,
undefined, slug: group.slug,
memberId, title: group.name,
group.slug, }
group.name )
)
}
}) })

View File

@ -8,14 +8,14 @@ export const onCreateLiquidityProvision = functions.firestore
.onCreate(async (change, context) => { .onCreate(async (change, context) => {
const liquidity = change.data() as LiquidityProvision const liquidity = change.data() as LiquidityProvision
const { eventId } = context const { eventId } = context
const contract = await getContract(liquidity.contractId)
if (!contract)
throw new Error('Could not find contract corresponding with liquidity')
// Ignore Manifold Markets liquidity for now - users see a notification for free market liquidity provision // Ignore Manifold Markets liquidity for now - users see a notification for free market liquidity provision
if (liquidity.userId === 'IPTOzEqrpkWmEzh6hwvAyY9PqFb2') return if (liquidity.userId === 'IPTOzEqrpkWmEzh6hwvAyY9PqFb2') return
const contract = await getContract(liquidity.contractId)
if (!contract)
throw new Error('Could not find contract corresponding with liquidity')
const liquidityProvider = await getUser(liquidity.userId) const liquidityProvider = await getUser(liquidity.userId)
if (!liquidityProvider) throw new Error('Could not find liquidity provider') if (!liquidityProvider) throw new Error('Could not find liquidity provider')
@ -26,6 +26,6 @@ export const onCreateLiquidityProvision = functions.firestore
liquidityProvider, liquidityProvider,
eventId, eventId,
liquidity.amount.toString(), liquidity.amount.toString(),
contract { 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,36 @@
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()
console.log("contracts with group's slug:", contracts)
for (const doc of contracts.docs) {
const contract = doc.data() as Contract
const newGroupLinks = contract.groupLinks?.filter(
(link) => link.slug !== group.slug
)
// remove the group from the contract
await firestore
.collection('contracts')
.doc(contract.id)
.update({
groupSlugs: contract.groupSlugs?.filter((s) => s !== group.slug),
groupLinks: newGroupLinks ?? [],
})
}
})

View File

@ -30,9 +30,7 @@ export const onFollowUser = functions.firestore
followingUser, followingUser,
eventId, eventId,
'', '',
undefined, { recipients: [follow.userId] }
undefined,
follow.userId
) )
}) })

View File

@ -24,6 +24,9 @@ export const onUpdateContract = functions.firestore
if (resolutionText === 'MKT' && contract.resolutionProbability) if (resolutionText === 'MKT' && contract.resolutionProbability)
resolutionText = `${contract.resolutionProbability}%` resolutionText = `${contract.resolutionProbability}%`
else if (resolutionText === 'MKT') resolutionText = 'PROB' else if (resolutionText === 'MKT') resolutionText = 'PROB'
} else if (contract.outcomeType === 'PSEUDO_NUMERIC') {
if (resolutionText === 'MKT' && contract.resolutionValue)
resolutionText = `${contract.resolutionValue}`
} }
await createNotification( await createNotification(
@ -33,7 +36,7 @@ export const onUpdateContract = functions.firestore
contractUpdater, contractUpdater,
eventId, eventId,
resolutionText, resolutionText,
contract { contract }
) )
} else if ( } else if (
previousValue.closeTime !== contract.closeTime || previousValue.closeTime !== contract.closeTime ||
@ -59,7 +62,7 @@ export const onUpdateContract = functions.firestore
contractUpdater, contractUpdater,
eventId, eventId,
sourceText, sourceText,
contract { contract }
) )
} }
}) })

View File

@ -1,6 +1,8 @@
import * as functions from 'firebase-functions' import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import { Group } from '../../common/group' import { Group } from '../../common/group'
import { getContract } from './utils'
import { uniq } from 'lodash'
const firestore = admin.firestore() const firestore = admin.firestore()
export const onUpdateGroup = functions.firestore export const onUpdateGroup = functions.firestore
@ -9,12 +11,41 @@ export const onUpdateGroup = functions.firestore
const prevGroup = change.before.data() as Group const prevGroup = change.before.data() as Group
const group = change.after.data() as Group const group = change.after.data() as Group
// ignore the update we just made // Ignore the activity update we just made
if (prevGroup.mostRecentActivityTime !== group.mostRecentActivityTime) if (prevGroup.mostRecentActivityTime !== group.mostRecentActivityTime)
return return
if (prevGroup.contractIds.length < group.contractIds.length) {
await firestore
.collection('groups')
.doc(group.id)
.update({ mostRecentContractAddedTime: Date.now() })
//TODO: create notification with isSeeOnHref set to the group's /group/slug/questions url
// but first, let the new /group/slug/chat notification permeate so that we can differentiate between the two
}
await firestore await firestore
.collection('groups') .collection('groups')
.doc(group.id) .doc(group.id)
.update({ mostRecentActivityTime: Date.now() }) .update({ mostRecentActivityTime: Date.now() })
}) })
export async function removeGroupLinks(group: Group, contractIds: string[]) {
for (const contractId of contractIds) {
const contract = await getContract(contractId)
await firestore
.collection('contracts')
.doc(contractId)
.update({
groupSlugs: uniq([
...(contract?.groupSlugs?.filter((slug) => slug !== group.slug) ??
[]),
]),
groupLinks: [
...(contract?.groupLinks?.filter(
(link) => link.groupId !== group.id
) ?? []),
],
})
}
}

View File

@ -0,0 +1,136 @@
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 { createReferralNotification } from './create-notification'
import { ReferralTxn } from '../../common/txn'
import { Contract } from '../../common/contract'
import { LimitBet } from 'common/bet'
import { QuerySnapshot } from 'firebase-admin/firestore'
import { Group } from 'common/group'
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}`)
let referredByGroup: Group | undefined = undefined
if (user.referredByGroupId) {
const referredByGroupDoc = firestore.doc(
`groups/${user.referredByGroupId}`
)
referredByGroup = await transaction
.get(referredByGroupDoc)
.then((snap) => snap.data() as Group)
}
console.log(`referredByGroup: ${referredByGroup}`)
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 = firestore.collection(`txns/`).doc(txn.id)
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 createReferralNotification(
referredByUser,
user,
eventId,
txn.amount.toString(),
referredByContract,
referredByGroup
)
})
}
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 * as admin from 'firebase-admin'
import { z } from 'zod' import { z } from 'zod'
import {
DocumentReference,
FieldValue,
Query,
Transaction,
} from 'firebase-admin/firestore'
import { groupBy, mapValues, sumBy, uniq } from 'lodash'
import { APIError, newEndpoint, validate } from './api' import { APIError, newEndpoint, validate } from './api'
import { Contract, CPMM_MIN_POOL_QTY } from '../../common/contract' import { Contract, CPMM_MIN_POOL_QTY } from '../../common/contract'
import { User } from '../../common/user' import { User } from '../../common/user'
import { import {
BetInfo, BetInfo,
getNewBinaryCpmmBetInfo, getBinaryCpmmBetInfo,
getNewBinaryDpmBetInfo,
getNewMultiBetInfo, getNewMultiBetInfo,
getNumericBetsInfo, getNumericBetsInfo,
} from '../../common/new-bet' } from '../../common/new-bet'
import { addObjects, removeUndefinedProps } from '../../common/util/object' import { addObjects, removeUndefinedProps } from '../../common/util/object'
import { LimitBet } from '../../common/bet'
import { floatingEqual } from '../../common/util/math'
import { redeemShares } from './redeem-shares' import { redeemShares } from './redeem-shares'
import { log } from './utils' import { log } from './utils'
@ -22,6 +30,15 @@ const bodySchema = z.object({
const binarySchema = z.object({ const binarySchema = z.object({
outcome: z.enum(['YES', 'NO']), outcome: z.enum(['YES', 'NO']),
limitProb: z
.number()
.gte(0.001)
.lte(0.999)
.refine(
(p) => Math.round(p * 100) === p * 100,
'limitProb must be in increments of 0.01 (i.e. whole percentage points)'
)
.optional(),
}) })
const freeResponseSchema = z.object({ const freeResponseSchema = z.object({
@ -33,7 +50,7 @@ const numericSchema = z.object({
value: z.number(), value: z.number(),
}) })
export const placebet = newEndpoint(['POST'], async (req, auth) => { export const placebet = newEndpoint({}, async (req, auth) => {
log('Inside endpoint handler.') log('Inside endpoint handler.')
const { amount, contractId } = validate(bodySchema, req.body) const { amount, contractId } = validate(bodySchema, req.body)
@ -41,10 +58,7 @@ export const placebet = newEndpoint(['POST'], async (req, auth) => {
log('Inside main transaction.') log('Inside main transaction.')
const contractDoc = firestore.doc(`contracts/${contractId}`) const contractDoc = firestore.doc(`contracts/${contractId}`)
const userDoc = firestore.doc(`users/${auth.uid}`) const userDoc = firestore.doc(`users/${auth.uid}`)
const [contractSnap, userSnap] = await Promise.all([ const [contractSnap, userSnap] = await trans.getAll(contractDoc, userDoc)
trans.get(contractDoc),
trans.get(userDoc),
])
if (!contractSnap.exists) throw new APIError(400, 'Contract not found.') if (!contractSnap.exists) throw new APIError(400, 'Contract not found.')
if (!userSnap.exists) throw new APIError(400, 'User not found.') if (!userSnap.exists) throw new APIError(400, 'User not found.')
log('Loaded user and contract snapshots.') log('Loaded user and contract snapshots.')
@ -65,14 +79,34 @@ export const placebet = newEndpoint(['POST'], async (req, auth) => {
newTotalBets, newTotalBets,
newTotalLiquidity, newTotalLiquidity,
newP, newP,
} = await (async (): Promise<BetInfo> => { makers,
if (outcomeType == 'BINARY' && mechanism == 'dpm-2') { } = await (async (): Promise<
const { outcome } = validate(binarySchema, req.body) BetInfo & {
return getNewBinaryDpmBetInfo(outcome, amount, contract) makers?: maker[]
} else if (outcomeType == 'BINARY' && mechanism == 'cpmm-1') { }
const { outcome } = validate(binarySchema, req.body) > => {
return getNewBinaryCpmmBetInfo(outcome, amount, contract) if (
} else if (outcomeType == 'FREE_RESPONSE' && mechanism == 'dpm-2') { (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' || outcomeType === 'MULTIPLE_CHOICE') &&
mechanism == 'dpm-2'
) {
const { outcome } = validate(freeResponseSchema, req.body) const { outcome } = validate(freeResponseSchema, req.body)
const answerDoc = contractDoc.collection('answers').doc(outcome) const answerDoc = contractDoc.collection('answers').doc(outcome)
const answerSnap = await trans.get(answerDoc) const answerSnap = await trans.get(answerDoc)
@ -96,33 +130,99 @@ export const placebet = newEndpoint(['POST'], async (req, auth) => {
throw new APIError(400, 'Bet too large for current liquidity pool.') throw new APIError(400, 'Bet too large for current liquidity pool.')
} }
const newBalance = user.balance - amount
const betDoc = contractDoc.collection('bets').doc() const betDoc = contractDoc.collection('bets').doc()
trans.create(betDoc, { id: betDoc.id, userId: user.id, ...newBet }) trans.create(betDoc, { id: betDoc.id, userId: user.id, ...newBet })
log('Created new bet document.') log('Created new bet document.')
trans.update(userDoc, { balance: newBalance })
log('Updated user balance.')
trans.update(
contractDoc,
removeUndefinedProps({
pool: newPool,
p: newP,
totalShares: newTotalShares,
totalBets: newTotalBets,
totalLiquidity: newTotalLiquidity,
collectedFees: addObjects(newBet.fees, collectedFees),
volume: volume + amount,
})
)
log('Updated contract properties.')
return { betId: betDoc.id } 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({
pool: newPool,
p: newP,
totalShares: newTotalShares,
totalBets: newTotalBets,
totalLiquidity: newTotalLiquidity,
collectedFees: addObjects(newBet.fees, collectedFees),
volume: volume + newBet.amount,
})
)
log('Updated contract properties.')
}
return { betId: betDoc.id, makers, newBet }
}) })
log('Main transaction finished.') log('Main transaction finished.')
await redeemShares(auth.uid, contractId)
log('Share redemption transaction finished.') if (result.newBet.amount !== 0) {
return result const userIds = uniq([
auth.uid,
...(result.makers ?? []).map((maker) => maker.bet.userId),
])
await Promise.all(userIds.map((userId) => redeemShares(userId, contractId)))
log('Share redemption transaction finished.')
}
return { betId: result.betId }
}) })
const firestore = admin.firestore() 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 * as admin from 'firebase-admin'
import { partition, sumBy } from 'lodash'
import { Bet } from '../../common/bet' import { Bet } from '../../common/bet'
import { getProbability } from '../../common/calculate' import { getRedeemableAmount, getRedemptionBets } from '../../common/redeem'
import { Contract } from '../../common/contract' import { Contract } from '../../common/contract'
import { noFees } from '../../common/fees'
import { User } from '../../common/user' import { User } from '../../common/user'
export const redeemShares = async (userId: string, contractId: string) => { 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 contractDoc = firestore.doc(`contracts/${contractId}`)
const contractSnap = await transaction.get(contractDoc) const contractSnap = await trans.get(contractDoc)
if (!contractSnap.exists) if (!contractSnap.exists)
return { status: 'error', message: 'Invalid contract' } return { status: 'error', message: 'Invalid contract' }
const contract = contractSnap.data() as Contract const contract = contractSnap.data() as Contract
if (contract.outcomeType !== 'BINARY' || contract.mechanism !== 'cpmm-1') const { mechanism } = contract
return { status: 'success' } if (mechanism !== 'cpmm-1') return { status: 'success' }
const betsSnap = await transaction.get( const betsColl = firestore.collection(`contracts/${contract.id}/bets`)
firestore const betsSnap = await trans.get(betsColl.where('userId', '==', userId))
.collection(`contracts/${contract.id}/bets`)
.where('userId', '==', userId)
)
const bets = betsSnap.docs.map((doc) => doc.data() as Bet) const bets = betsSnap.docs.map((doc) => doc.data() as Bet)
const [yesBets, noBets] = partition(bets, (b) => b.outcome === 'YES') const { shares, loanPayment, netAmount } = getRedeemableAmount(bets)
const yesShares = sumBy(yesBets, (b) => b.shares) if (netAmount === 0) {
const noShares = sumBy(noBets, (b) => b.shares) return { status: 'success' }
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 [yesBet, noBet] = getRedemptionBets(shares, loanPayment, contract)
const userDoc = firestore.doc(`users/${userId}`) 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' } if (!userSnap.exists) return { status: 'error', message: 'User not found' }
const user = userSnap.data() as User const user = userSnap.data() as User
const newBalance = user.balance + netAmount const newBalance = user.balance + netAmount
if (!isFinite(newBalance)) { if (!isFinite(newBalance)) {
throw new Error('Invalid user balance for ' + user.username) throw new Error('Invalid user balance for ' + user.username)
} }
transaction.update(userDoc, { balance: newBalance }) const yesDoc = betsColl.doc()
const noDoc = betsColl.doc()
transaction.create(yesDoc, yesBet) trans.update(userDoc, { balance: newBalance })
transaction.create(noDoc, noBet) trans.create(yesDoc, { id: yesDoc.id, userId, ...yesBet })
trans.create(noDoc, { id: noDoc.id, userId, ...noBet })
return { status: 'success' } return { status: 'success' }
}) })

View File

@ -1,8 +1,13 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import { z } from 'zod'
import { difference, uniq, mapValues, groupBy, sumBy } from 'lodash' import { difference, uniq, mapValues, groupBy, sumBy } from 'lodash'
import { Contract, resolution, RESOLUTIONS } from '../../common/contract' import {
Contract,
FreeResponseContract,
MultipleChoiceContract,
RESOLUTIONS,
} from '../../common/contract'
import { User } from '../../common/user' import { User } from '../../common/user'
import { Bet } from '../../common/bet' import { Bet } from '../../common/bet'
import { getUser, isProd, payUser } from './utils' import { getUser, isProd, payUser } from './utils'
@ -13,158 +18,163 @@ import {
groupPayoutsByUser, groupPayoutsByUser,
Payout, Payout,
} from '../../common/payouts' } from '../../common/payouts'
import { isManifoldId } from '../../common/envs/constants'
import { removeUndefinedProps } from '../../common/util/object' import { removeUndefinedProps } from '../../common/util/object'
import { LiquidityProvision } from '../../common/liquidity-provision' import { LiquidityProvision } from '../../common/liquidity-provision'
import { APIError, newEndpoint, validate } from './api'
export const resolveMarket = functions const bodySchema = z.object({
.runWith({ minInstances: 1, secrets: ['MAILGUN_KEY'] }) contractId: z.string(),
.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 { outcome, contractId, probabilityInt, resolutions, value } = data const binarySchema = z.object({
outcome: z.enum(RESOLUTIONS),
probabilityInt: z.number().gte(0).lte(100).optional(),
})
const contractDoc = firestore.doc(`contracts/${contractId}`) const freeResponseSchema = z.union([
const contractSnap = await contractDoc.get() z.object({
if (!contractSnap.exists) outcome: z.literal('CANCEL'),
return { status: 'error', message: 'Invalid contract' } }),
const contract = contractSnap.data() as Contract z.object({
const { creatorId, outcomeType, closeTime } = contract 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(),
}),
])
if (outcomeType === 'BINARY') { const numericSchema = z.object({
if (!RESOLUTIONS.includes(outcome)) outcome: z.union([z.literal('CANCEL'), z.string()]),
return { status: 'error', message: 'Invalid outcome' } value: z.number().optional(),
} else if (outcomeType === 'FREE_RESPONSE') { })
if (
isNaN(+outcome) &&
!(outcome === 'MKT' && resolutions) &&
outcome !== 'CANCEL'
)
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)) const pseudoNumericSchema = z.union([
return { status: 'error', message: 'Invalid value' } z.object({
outcome: z.literal('CANCEL'),
}),
z.object({
outcome: z.literal('MKT'),
value: z.number(),
probabilityInt: z.number().gte(0).lte(100),
}),
])
if ( const opts = { secrets: ['MAILGUN_KEY'] }
outcomeType === 'BINARY' &&
probabilityInt !== undefined &&
(probabilityInt < 0 ||
probabilityInt > 100 ||
!isFinite(probabilityInt))
)
return { status: 'error', message: 'Invalid probability' }
if (creatorId !== userId) export const resolvemarket = newEndpoint(opts, async (req, auth) => {
return { status: 'error', message: 'User not creator of contract' } const { contractId } = validate(bodySchema, req.body)
const contractDoc = firestore.doc(`contracts/${contractId}`)
const contractSnap = await contractDoc.get()
if (!contractSnap.exists)
throw new APIError(404, 'No contract exists with the provided ID')
const contract = contractSnap.data() as Contract
const { creatorId, closeTime } = contract
if (contract.resolution) const { value, resolutions, probabilityInt, outcome } = getResolutionParams(
return { status: 'error', message: 'Contract already resolved' } contract,
req.body
const creator = await getUser(creatorId)
if (!creator) return { status: 'error', message: 'Creator not found' }
const resolutionProbability =
probabilityInt !== undefined ? probabilityInt / 100 : undefined
const resolutionTime = Date.now()
const newCloseTime = closeTime
? Math.min(closeTime, resolutionTime)
: closeTime
const betsSnap = await firestore
.collection(`contracts/${contractId}/bets`)
.get()
const bets = betsSnap.docs.map((doc) => doc.data() as Bet)
const liquiditiesSnap = await firestore
.collection(`contracts/${contractId}/liquidity`)
.get()
const liquidities = liquiditiesSnap.docs.map(
(doc) => doc.data() as LiquidityProvision
)
const { payouts, creatorPayout, liquidityPayouts, collectedFees } =
getPayouts(
outcome,
resolutions ?? {},
contract,
bets,
liquidities,
resolutionProbability
)
await contractDoc.update(
removeUndefinedProps({
isResolved: true,
resolution: outcome,
resolutionValue: value,
resolutionTime,
closeTime: newCloseTime,
resolutionProbability,
resolutions,
collectedFees,
})
)
console.log('contract ', contractId, 'resolved to:', outcome)
const openBets = bets.filter((b) => !b.isSold && !b.sale)
const loanPayouts = getLoanPayouts(openBets)
if (!isProd())
console.log(
'payouts:',
payouts,
'creator payout:',
creatorPayout,
'liquidity payout:'
)
if (creatorPayout)
await processPayouts(
[{ userId: creatorId, payout: creatorPayout }],
true
)
await processPayouts(liquidityPayouts, true)
const result = await processPayouts([...payouts, ...loanPayouts])
const userPayoutsWithoutLoans = groupPayoutsByUser(payouts)
await sendResolutionEmails(
openBets,
userPayoutsWithoutLoans,
creator,
creatorPayout,
contract,
outcome,
resolutionProbability,
resolutions
)
return result
}
) )
if (creatorId !== auth.uid && !isManifoldId(auth.uid))
throw new APIError(403, 'User is not creator of contract')
if (contract.resolution) throw new APIError(400, 'Contract already resolved')
const creator = await getUser(creatorId)
if (!creator) throw new APIError(500, 'Creator not found')
const resolutionProbability =
probabilityInt !== undefined ? probabilityInt / 100 : undefined
const resolutionTime = Date.now()
const newCloseTime = closeTime
? Math.min(closeTime, resolutionTime)
: closeTime
const betsSnap = await firestore
.collection(`contracts/${contractId}/bets`)
.get()
const bets = betsSnap.docs.map((doc) => doc.data() as Bet)
const liquiditiesSnap = await firestore
.collection(`contracts/${contractId}/liquidity`)
.get()
const liquidities = liquiditiesSnap.docs.map(
(doc) => doc.data() as LiquidityProvision
)
const { payouts, creatorPayout, liquidityPayouts, collectedFees } =
getPayouts(
outcome,
contract,
bets,
liquidities,
resolutions,
resolutionProbability
)
const updatedContract = {
...contract,
...removeUndefinedProps({
isResolved: true,
resolution: outcome,
resolutionValue: value,
resolutionTime,
closeTime: newCloseTime,
resolutionProbability,
resolutions,
collectedFees,
}),
}
await contractDoc.update(updatedContract)
console.log('contract ', contractId, 'resolved to:', outcome)
const openBets = bets.filter((b) => !b.isSold && !b.sale)
const loanPayouts = getLoanPayouts(openBets)
if (!isProd())
console.log(
'payouts:',
payouts,
'creator payout:',
creatorPayout,
'liquidity payout:'
)
if (creatorPayout)
await processPayouts([{ userId: creatorId, payout: creatorPayout }], true)
await processPayouts(liquidityPayouts, true)
await processPayouts([...payouts, ...loanPayouts])
const userPayoutsWithoutLoans = groupPayoutsByUser(payouts)
await sendResolutionEmails(
openBets,
userPayoutsWithoutLoans,
creator,
creatorPayout,
contract,
outcome,
resolutionProbability,
resolutions
)
return updatedContract
})
const processPayouts = async (payouts: Payout[], isDeposit = false) => { const processPayouts = async (payouts: Payout[], isDeposit = false) => {
const userPayouts = groupPayoutsByUser(payouts) const userPayouts = groupPayoutsByUser(payouts)
@ -221,4 +231,78 @@ 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' ||
outcomeType === 'MULTIPLE_CHOICE'
) {
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 | MultipleChoiceContract,
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() const firestore = admin.firestore()

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