Merge branch 'main' into swap3
This commit is contained in:
commit
2cf672fac1
4
.github/workflows/check.yml
vendored
4
.github/workflows/check.yml
vendored
|
@ -48,8 +48,8 @@ jobs:
|
|||
- name: Run Typescript checker on web client
|
||||
if: ${{ success() || failure() }}
|
||||
working-directory: web
|
||||
run: tsc --pretty --project tsconfig.json --noEmit
|
||||
run: tsc -b -v --pretty
|
||||
- name: Run Typescript checker on cloud functions
|
||||
if: ${{ success() || failure() }}
|
||||
working-directory: functions
|
||||
run: tsc --pretty --project tsconfig.json --noEmit
|
||||
run: tsc -b -v --pretty
|
||||
|
|
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -3,3 +3,5 @@
|
|||
.vercel
|
||||
node_modules
|
||||
yarn-error.log
|
||||
|
||||
firebase-debug.log
|
14
.vscode/extensions.json
vendored
Normal file
14
.vscode/extensions.json
vendored
Normal file
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
// See https://go.microsoft.com/fwlink/?LinkId=827846 to learn about workspace recommendations.
|
||||
// Extension identifier format: ${publisher}.${name}. Example: vscode.csharp
|
||||
|
||||
// List of extensions which should be recommended for users of this workspace.
|
||||
"recommendations": [
|
||||
"esbenp.prettier-vscode",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"toba.vsfire",
|
||||
"bradlc.vscode-tailwindcss"
|
||||
],
|
||||
// List of extensions recommended by VS Code that should not be recommended for users of this workspace.
|
||||
"unwantedRecommendations": []
|
||||
}
|
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"javascript.preferences.importModuleSpecifier": "shortest",
|
||||
"typescript.preferences.importModuleSpecifier": "shortest",
|
||||
"files.eol": "\r\n",
|
||||
"files.eol": "\n",
|
||||
"search.exclude": {
|
||||
"**/node_modules": true,
|
||||
"**/package-lock.json": true,
|
||||
|
|
|
@ -17,6 +17,14 @@ module.exports = {
|
|||
},
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'warn',
|
||||
{
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
caughtErrorsIgnorePattern: '^_',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
|
|
3
common/.gitignore
vendored
3
common/.gitignore
vendored
|
@ -1,6 +1,5 @@
|
|||
# Compiled JavaScript files
|
||||
lib/**/*.js
|
||||
lib/**/*.js.map
|
||||
lib/
|
||||
|
||||
# TypeScript v1 declaration files
|
||||
typings/
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { sum, groupBy, mapValues, sumBy } from 'lodash'
|
||||
import { sum, groupBy, mapValues, sumBy, partition } from 'lodash'
|
||||
|
||||
import { CPMMContract } from './contract'
|
||||
import { CREATOR_FEE, Fees, LIQUIDITY_FEE, noFees, PLATFORM_FEE } from './fees'
|
||||
|
@ -260,13 +260,7 @@ export function addCpmmLiquidity(
|
|||
return { newPool, liquidity, newP }
|
||||
}
|
||||
|
||||
export function getCpmmLiquidityPoolWeights(
|
||||
contract: CPMMContract,
|
||||
liquidities: LiquidityProvision[]
|
||||
) {
|
||||
const { p } = contract
|
||||
|
||||
const liquidityShares = liquidities.map((l) => {
|
||||
const calculateLiquidityDelta = (p: number) => (l: LiquidityProvision) => {
|
||||
const oldLiquidity = getCpmmLiquidity(l.pool, p)
|
||||
|
||||
const newPool = addObjects(l.pool, { YES: l.amount, NO: l.amount })
|
||||
|
@ -274,13 +268,22 @@ export function getCpmmLiquidityPoolWeights(
|
|||
|
||||
const liquidity = newLiquidity - oldLiquidity
|
||||
return liquidity
|
||||
})
|
||||
}
|
||||
|
||||
const shareSum = sum(liquidityShares)
|
||||
export function getCpmmLiquidityPoolWeights(
|
||||
contract: CPMMContract,
|
||||
liquidities: LiquidityProvision[]
|
||||
) {
|
||||
const [antes, nonAntes] = partition(liquidities, (l) => !!l.isAnte)
|
||||
|
||||
const calcLiqudity = calculateLiquidityDelta(contract.p)
|
||||
const liquidityShares = nonAntes.map(calcLiqudity)
|
||||
|
||||
const shareSum = sum(liquidityShares) + sum(antes.map(calcLiqudity))
|
||||
|
||||
const weights = liquidityShares.map((s, i) => ({
|
||||
weight: s / shareSum,
|
||||
providerId: liquidities[i].userId,
|
||||
providerId: nonAntes[i].userId,
|
||||
}))
|
||||
|
||||
const userWeights = groupBy(weights, (w) => w.providerId)
|
||||
|
@ -290,22 +293,13 @@ export function getCpmmLiquidityPoolWeights(
|
|||
return totalUserWeights
|
||||
}
|
||||
|
||||
// export function removeCpmmLiquidity(
|
||||
// contract: CPMMContract,
|
||||
// liquidity: number
|
||||
// ) {
|
||||
// const { YES, NO } = contract.pool
|
||||
// const poolLiquidity = getCpmmLiquidity({ YES, NO })
|
||||
// const p = getCpmmProbability({ YES, NO }, contract.p)
|
||||
export function getUserLiquidityShares(
|
||||
userId: string,
|
||||
contract: CPMMContract,
|
||||
liquidities: LiquidityProvision[]
|
||||
) {
|
||||
const weights = getCpmmLiquidityPoolWeights(contract, liquidities)
|
||||
const userWeight = weights[userId] ?? 0
|
||||
|
||||
// const f = liquidity / poolLiquidity
|
||||
// const [payoutYes, payoutNo] = [f * YES, f * NO]
|
||||
|
||||
// const betAmount = Math.abs(payoutYes - payoutNo)
|
||||
// const betOutcome = p >= 0.5 ? 'NO' : 'YES' // opposite side as adding liquidity
|
||||
// const payout = Math.min(payoutYes, payoutNo)
|
||||
|
||||
// const newPool = { YES: YES - payoutYes, NO: NO - payoutNo }
|
||||
|
||||
// return { newPool, payout, betAmount, betOutcome }
|
||||
// }
|
||||
return mapValues(contract.pool, (shares) => userWeight * shares)
|
||||
}
|
||||
|
|
|
@ -346,10 +346,6 @@ function calculateMktDpmPayout(contract: DPMContract, bet: Bet) {
|
|||
probs = mapValues(totalShares, (shares) => shares ** 2 / squareSum)
|
||||
}
|
||||
|
||||
const weightedShareTotal = sumBy(Object.keys(totalShares), (outcome) => {
|
||||
return probs[outcome] * totalShares[outcome]
|
||||
})
|
||||
|
||||
const { outcome, amount, shares } = bet
|
||||
|
||||
const poolFrac =
|
||||
|
@ -359,11 +355,11 @@ function calculateMktDpmPayout(contract: DPMContract, bet: Bet) {
|
|||
(outcome) => {
|
||||
return (
|
||||
(probs[outcome] * (bet as NumericBet).allOutcomeShares[outcome]) /
|
||||
weightedShareTotal
|
||||
totalShares[outcome]
|
||||
)
|
||||
}
|
||||
)
|
||||
: (probs[outcome] * shares) / weightedShareTotal
|
||||
: (probs[outcome] * shares) / totalShares[outcome]
|
||||
|
||||
const totalPool = sum(Object.values(pool))
|
||||
const winnings = poolFrac * totalPool
|
||||
|
|
|
@ -142,6 +142,10 @@ export function getContractBetMetrics(contract: Contract, yourBets: Bet[]) {
|
|||
const profit = payout + saleValue + redeemed - totalInvested
|
||||
const profitPercent = (profit / totalInvested) * 100
|
||||
|
||||
const hasShares = Object.values(totalShares).some(
|
||||
(shares) => shares > 0
|
||||
)
|
||||
|
||||
return {
|
||||
invested: Math.max(0, currentInvested),
|
||||
payout,
|
||||
|
@ -149,6 +153,7 @@ export function getContractBetMetrics(contract: Contract, yourBets: Bet[]) {
|
|||
profit,
|
||||
profitPercent,
|
||||
totalShares,
|
||||
hasShares,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -160,6 +165,7 @@ export function getContractBetNullMetrics() {
|
|||
profit: 0,
|
||||
profitPercent: 0,
|
||||
totalShares: {} as { [outcome: string]: number },
|
||||
hasShares: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import { difference } from 'lodash'
|
||||
|
||||
export const CATEGORIES = {
|
||||
politics: 'Politics',
|
||||
technology: 'Technology',
|
||||
|
@ -12,10 +14,19 @@ export const CATEGORIES = {
|
|||
crypto: 'Crypto',
|
||||
gaming: 'Gaming',
|
||||
fun: 'Fun',
|
||||
} as { [category: string]: string }
|
||||
}
|
||||
|
||||
export type category = keyof typeof CATEGORIES
|
||||
|
||||
export const TO_CATEGORY = Object.fromEntries(
|
||||
Object.entries(CATEGORIES).map(([k, v]) => [v, k])
|
||||
)
|
||||
|
||||
export const CATEGORY_LIST = Object.keys(CATEGORIES)
|
||||
|
||||
export const EXCLUDED_CATEGORIES: category[] = ['fun', 'manifold', 'personal']
|
||||
|
||||
export const DEFAULT_CATEGORIES = difference(
|
||||
CATEGORY_LIST,
|
||||
EXCLUDED_CATEGORIES
|
||||
)
|
||||
|
|
|
@ -58,6 +58,19 @@ export const charities: Charity[] = [
|
|||
- Promoting long-term thinking`,
|
||||
tags: ['Featured'] as CharityTag[],
|
||||
},
|
||||
{
|
||||
name: 'New Science',
|
||||
website: 'https://newscience.org/',
|
||||
photo: 'https://i.imgur.com/C7PoR4q.png',
|
||||
preview:
|
||||
'Facilitating scientific breakthroughs by empowering the next generation of scientists and building the 21st century institutions of basic science.',
|
||||
description: `As its first major project, in the summer of 2022, New Science will run an in-person research fellowship in Boston for young life scientists, during which they will independently explore an ambitious high-risk scientific idea they couldn’t work on otherwise and start building the foundations for a bigger research project, while having much more freedom than they could expect in their normal research environment but also much more support from us. This is inspired by Cold Spring Harbor Laboratory, which started as a place where leading molecular biologists came for the summer to hang out and work on random projects together, and which eventually housed 8 Nobel Prize winners.
|
||||
|
||||
As its second major project, in the fall of 2022, New Science will run an in-person 12-month-long fellowship for young scientists starting to directly attack the biggest structural issues of the established institutions of science. We will double down on things that worked well during the summer fellowship, while extending the fellowship to one year, thus allowing researchers to make much more progress and will strive to provide them as much scientific leverage as possible.
|
||||
|
||||
In several years, New Science will start funding entire labs outside of academia and then will be creating an entire network of scientific organizations, while supporting the broader scientific ecosystem that will constitute the 21st century institutions of basic science.`,
|
||||
tags: ['Featured'] as CharityTag[],
|
||||
},
|
||||
{
|
||||
name: 'Global Health and Development Fund',
|
||||
website: 'https://funds.effectivealtruism.org/funds/global-development',
|
||||
|
@ -472,9 +485,9 @@ Future plans: We expect to focus on similar theoretical problems in alignment un
|
|||
name: 'The Trevor Project',
|
||||
website: 'https://www.thetrevorproject.org/',
|
||||
photo: 'https://i.imgur.com/QN4mVNn.jpeg',
|
||||
preview: 'The Trevor Project is the world’s largest suicide prevention and crisis intervention organization for LGBTQ (lesbian, gay, bisexual, transgender, queer, and questioning) young people.',
|
||||
description:
|
||||
`Two decades ago, we responded to a health crisis. Now we’re building a safer, more-inclusive world. LGBTQ young people are four times more likely to attempt suicide, and suicide remains the second leading cause of death among all young people in the U.S.
|
||||
preview:
|
||||
'The Trevor Project is the world’s largest suicide prevention and crisis intervention organization for LGBTQ (lesbian, gay, bisexual, transgender, queer, and questioning) young people.',
|
||||
description: `Two decades ago, we responded to a health crisis. Now we’re building a safer, more-inclusive world. LGBTQ young people are four times more likely to attempt suicide, and suicide remains the second leading cause of death among all young people in the U.S.
|
||||
|
||||
Our Mission
|
||||
To end suicide among lesbian, gay, bisexual, transgender, queer & questioning young people.
|
||||
|
@ -485,6 +498,24 @@ Future plans: We expect to focus on similar theoretical problems in alignment un
|
|||
Our Goal
|
||||
To serve 1.8 million crisis contacts annually, by the end of our 25th year, while continuing to innovate on our core services.`,
|
||||
},
|
||||
{
|
||||
name: 'ACLU',
|
||||
website: 'https://www.aclu.org/',
|
||||
photo: 'https://i.imgur.com/nbSYuDC.png',
|
||||
preview:
|
||||
'The ACLU works in the courts, legislatures, and communities to defend and preserve the individual rights and liberties guaranteed to all people in this country by the Constitution and laws of the United States.',
|
||||
description: `
|
||||
THREE THINGS TO KNOW ABOUT THE ACLU
|
||||
• We protect American values. In many ways, the ACLU is the nation's most conservative organization. Our job is to conserve America's original civic values - the Constitution and the Bill of Rights - and defend the rights of every man, woman and child in this country.
|
||||
• We're not anti-anything. The only things we fight are attempts to take away or limit your civil liberties, like your right to practice any religion you want (or none at all); or to decide in private whether or not to have a child; or to speak out - for or against - anything at all; or to be treated with equality and fairness, no matter who you are.
|
||||
• We're there for you. Rich or poor, straight or gay, black or white or brown, urban or rural, pious or atheist, American-born or foreign-born, able-bodied or living with a disability. Every person in this country should have the same basic rights. And since our founding in 1920, we've been working hard to make sure no one takes them away.
|
||||
|
||||
The American Civil Liberties Union is our nation's guardian of liberty, working daily in courts, legislatures and communities to defend and preserve the individual rights and liberties that the Constitution and laws of the United States guarantee everyone in this country.
|
||||
|
||||
"So long as we have enough people in this country willing to fight for their rights, we'll be called a democracy," ACLU Founder Roger Baldwin said.
|
||||
|
||||
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.`,
|
||||
},
|
||||
].map((charity) => {
|
||||
const slug = charity.name.toLowerCase().replace(/\s/g, '-')
|
||||
return {
|
||||
|
|
|
@ -2,7 +2,8 @@
|
|||
// They're uniquely identified by the pair contractId/betId.
|
||||
export type Comment = {
|
||||
id: string
|
||||
contractId: string
|
||||
contractId?: string
|
||||
groupId?: string
|
||||
betId?: string
|
||||
answerOutcome?: string
|
||||
replyToCommentId?: string
|
||||
|
|
|
@ -33,6 +33,7 @@ export type Contract<T extends AnyContractType = AnyContractType> = {
|
|||
isResolved: boolean
|
||||
resolutionTime?: number // When the contract creator resolved the market
|
||||
resolution?: string
|
||||
resolutionProbability?: number,
|
||||
|
||||
closeEmailsSent?: number
|
||||
|
||||
|
@ -94,6 +95,9 @@ export type outcomeType = AnyOutcomeType['outcomeType']
|
|||
export type resolution = 'YES' | 'NO' | 'MKT' | 'CANCEL'
|
||||
export const RESOLUTIONS = ['YES', 'NO', 'MKT', 'CANCEL'] as const
|
||||
export const OUTCOME_TYPES = ['BINARY', 'FREE_RESPONSE', 'NUMERIC'] as const
|
||||
|
||||
export const MAX_QUESTION_LENGTH = 480
|
||||
export const MAX_DESCRIPTION_LENGTH = 10000
|
||||
export const MAX_TAG_LENGTH = 60
|
||||
|
||||
export const CPMM_MIN_POOL_QTY = 0.01
|
||||
|
|
|
@ -12,8 +12,7 @@ export const DEV_CONFIG: EnvConfig = {
|
|||
appId: '1:134303100058:web:27f9ea8b83347251f80323',
|
||||
measurementId: 'G-YJC9E37P37',
|
||||
},
|
||||
functionEndpoints: {
|
||||
placebet: 'https://placebet-w3txbmd3ba-uc.a.run.app',
|
||||
createmarket: 'https://createmarket-w3txbmd3ba-uc.a.run.app',
|
||||
},
|
||||
cloudRunId: 'w3txbmd3ba',
|
||||
cloudRunRegion: 'uc',
|
||||
amplitudeApiKey: 'fd8cbfd964b9a205b8678a39faae71b3',
|
||||
}
|
||||
|
|
|
@ -1,9 +1,12 @@
|
|||
export type V2CloudFunction = 'placebet' | 'createmarket'
|
||||
|
||||
export type EnvConfig = {
|
||||
domain: string
|
||||
firebaseConfig: FirebaseConfig
|
||||
functionEndpoints: Record<V2CloudFunction, string>
|
||||
amplitudeApiKey?: string
|
||||
|
||||
// IDs for v2 cloud functions -- find these by deploying a cloud function and
|
||||
// examining the URL, https://[name]-[cloudRunId]-[cloudRunRegion].a.run.app
|
||||
cloudRunId: string
|
||||
cloudRunRegion: string
|
||||
|
||||
// Access controls
|
||||
adminEmails: string[]
|
||||
|
@ -30,6 +33,8 @@ type FirebaseConfig = {
|
|||
|
||||
export const PROD_CONFIG: EnvConfig = {
|
||||
domain: 'manifold.markets',
|
||||
amplitudeApiKey: '2d6509fd4185ebb8be29709842752a15',
|
||||
|
||||
firebaseConfig: {
|
||||
apiKey: 'AIzaSyDp3J57vLeAZCzxLD-vcPaGIkAmBoGOSYw',
|
||||
authDomain: 'mantic-markets.firebaseapp.com',
|
||||
|
@ -40,10 +45,8 @@ export const PROD_CONFIG: EnvConfig = {
|
|||
appId: '1:128925704902:web:f61f86944d8ffa2a642dc7',
|
||||
measurementId: 'G-SSFK1Q138D',
|
||||
},
|
||||
functionEndpoints: {
|
||||
placebet: 'https://placebet-nggbo3neva-uc.a.run.app',
|
||||
createmarket: 'https://createmarket-nggbo3neva-uc.a.run.app',
|
||||
},
|
||||
cloudRunId: 'nggbo3neva',
|
||||
cloudRunRegion: 'uc',
|
||||
adminEmails: [
|
||||
'akrolsmir@gmail.com', // Austin
|
||||
'jahooma@gmail.com', // James
|
||||
|
|
|
@ -12,11 +12,8 @@ export const THEOREMONE_CONFIG: EnvConfig = {
|
|||
appId: '1:698012149198:web:b342af75662831aa84b79f',
|
||||
measurementId: 'G-Y3EZ1WNT6E',
|
||||
},
|
||||
// TODO: fill in real endpoints for T1
|
||||
functionEndpoints: {
|
||||
placebet: 'https://placebet-nggbo3neva-uc.a.run.app',
|
||||
createmarket: 'https://createmarket-nggbo3neva-uc.a.run.app',
|
||||
},
|
||||
cloudRunId: 'nggbo3neva', // TODO: fill in real ID for T1
|
||||
cloudRunRegion: 'uc',
|
||||
adminEmails: [...PROD_CONFIG.adminEmails, 'david.glidden@theoremone.co'],
|
||||
whitelistEmail: '@theoremone.co',
|
||||
moneyMoniker: 'T$',
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
export const PLATFORM_FEE = 0.01
|
||||
export const CREATOR_FEE = 0.06
|
||||
export const LIQUIDITY_FEE = 0.06
|
||||
export const PLATFORM_FEE = 0
|
||||
export const CREATOR_FEE = 0.1
|
||||
export const LIQUIDITY_FEE = 0
|
||||
|
||||
export const DPM_PLATFORM_FEE = 0.01
|
||||
export const DPM_CREATOR_FEE = 0.04
|
||||
|
|
|
@ -1,23 +0,0 @@
|
|||
export type Fold = {
|
||||
id: string
|
||||
slug: string
|
||||
name: string
|
||||
about: string
|
||||
curatorId: string // User id
|
||||
createdTime: number
|
||||
|
||||
tags: string[]
|
||||
lowercaseTags: string[]
|
||||
|
||||
contractIds: string[]
|
||||
excludedContractIds: string[]
|
||||
|
||||
// Invariant: exactly one of the following is defined.
|
||||
// Default: creatorIds: undefined, excludedCreatorIds: []
|
||||
creatorIds?: string[]
|
||||
excludedCreatorIds?: string[]
|
||||
|
||||
followCount: number
|
||||
|
||||
disallowMarketCreation?: boolean
|
||||
}
|
4
common/follow.ts
Normal file
4
common/follow.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
export type Follow = {
|
||||
userId: string
|
||||
timestamp: number
|
||||
}
|
15
common/group.ts
Normal file
15
common/group.ts
Normal file
|
@ -0,0 +1,15 @@
|
|||
export type Group = {
|
||||
id: string
|
||||
slug: string
|
||||
name: string
|
||||
about: string
|
||||
creatorId: string // User id
|
||||
createdTime: number
|
||||
mostRecentActivityTime: number
|
||||
memberIds: string[] // User ids
|
||||
anyoneCanJoin: boolean
|
||||
contractIds: string[]
|
||||
}
|
||||
export const MAX_GROUP_NAME_LENGTH = 75
|
||||
export const MAX_ABOUT_LENGTH = 140
|
||||
export const MAX_ID_LENGTH = 60
|
35
common/manalink.ts
Normal file
35
common/manalink.ts
Normal file
|
@ -0,0 +1,35 @@
|
|||
export type Manalink = {
|
||||
// The link to send: https://manifold.markets/send/{slug}
|
||||
// Also functions as the unique id for the link.
|
||||
slug: string
|
||||
|
||||
// Note: we assume both fromId and toId are of SourceType 'USER'
|
||||
fromId: string
|
||||
|
||||
// Displayed to people claiming the link
|
||||
message: string
|
||||
|
||||
// How much to send with the link
|
||||
amount: number
|
||||
token: 'M$' // TODO: could send eg YES shares too??
|
||||
|
||||
createdTime: number
|
||||
// If null, the link is valid forever
|
||||
expiresTime: number | null
|
||||
// If null, the link can be used infinitely
|
||||
maxUses: number | null
|
||||
|
||||
// Used for simpler caching
|
||||
claimedUserIds: string[]
|
||||
// Successful redemptions of the link
|
||||
claims: Claim[]
|
||||
}
|
||||
|
||||
export type Claim = {
|
||||
toId: string
|
||||
|
||||
// The ID of the successful txn that tracks the money moved
|
||||
txnId: string
|
||||
|
||||
claimedTime: number
|
||||
}
|
|
@ -14,6 +14,14 @@ export type Notification = {
|
|||
sourceUserName?: string
|
||||
sourceUserUsername?: string
|
||||
sourceUserAvatarUrl?: string
|
||||
sourceText?: string
|
||||
|
||||
sourceContractTitle?: string
|
||||
sourceContractCreatorUsername?: string
|
||||
sourceContractSlug?: string
|
||||
|
||||
sourceSlug?: string
|
||||
sourceTitle?: string
|
||||
}
|
||||
export type notification_source_types =
|
||||
| 'contract'
|
||||
|
@ -24,6 +32,7 @@ export type notification_source_types =
|
|||
| 'follow'
|
||||
| 'tip'
|
||||
| 'admin_message'
|
||||
| 'group'
|
||||
|
||||
export type notification_source_update_types =
|
||||
| 'created'
|
||||
|
@ -42,3 +51,5 @@ export type notification_reason_types =
|
|||
| 'reply_to_users_answer'
|
||||
| 'reply_to_users_comment'
|
||||
| 'on_new_follow'
|
||||
| 'you_follow_user'
|
||||
| 'added_you_to_group'
|
||||
|
|
27
common/quadratic-funding.ts
Normal file
27
common/quadratic-funding.ts
Normal file
|
@ -0,0 +1,27 @@
|
|||
import { groupBy, mapValues, sum, sumBy } from 'lodash'
|
||||
import { Txn } from './txn'
|
||||
|
||||
// Returns a map of charity ids to the amount of M$ matched
|
||||
export function quadraticMatches(
|
||||
allCharityTxns: Txn[],
|
||||
matchingPool: number
|
||||
): Record<string, number> {
|
||||
// For each charity, group the donations by each individual donor
|
||||
const donationsByCharity = groupBy(allCharityTxns, 'toId')
|
||||
const donationsByDonors = mapValues(donationsByCharity, (txns) =>
|
||||
groupBy(txns, 'fromId')
|
||||
)
|
||||
|
||||
// Weight for each charity = [sum of sqrt(individual donor)] ^ 2
|
||||
const weights = mapValues(donationsByDonors, (byDonor) => {
|
||||
const sumByDonor = Object.values(byDonor).map((txns) =>
|
||||
sumBy(txns, 'amount')
|
||||
)
|
||||
const sumOfRoots = sumBy(sumByDonor, Math.sqrt)
|
||||
return sumOfRoots ** 2
|
||||
})
|
||||
|
||||
// Then distribute the matching pool based on the individual weights
|
||||
const totalWeight = sum(Object.values(weights))
|
||||
return mapValues(weights, (weight) => matchingPool * (weight / totalWeight))
|
||||
}
|
|
@ -7,7 +7,12 @@ import { getPayouts } from './payouts'
|
|||
export function scoreCreators(contracts: Contract[]) {
|
||||
const creatorScore = mapValues(
|
||||
groupBy(contracts, ({ creatorId }) => creatorId),
|
||||
(contracts) => sumBy(contracts, ({ pool }) => pool.YES + pool.NO)
|
||||
(contracts) =>
|
||||
sumBy(
|
||||
contracts.map((contract) => {
|
||||
return contract.volume
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
return creatorScore
|
||||
|
|
|
@ -1,22 +1,18 @@
|
|||
import { Bet } from './bet'
|
||||
import {
|
||||
getDpmProbability,
|
||||
calculateDpmShareValue,
|
||||
deductDpmFees,
|
||||
getDpmOutcomeProbability,
|
||||
} from './calculate-dpm'
|
||||
import { calculateCpmmSale, getCpmmProbability } from './calculate-cpmm'
|
||||
import { CPMMContract, DPMContract } from './contract'
|
||||
import { DPM_CREATOR_FEE, DPM_PLATFORM_FEE, Fees } from './fees'
|
||||
import { User } from './user'
|
||||
|
||||
export const getSellBetInfo = (
|
||||
user: User,
|
||||
bet: Bet,
|
||||
contract: DPMContract,
|
||||
newBetId: string
|
||||
) => {
|
||||
export type CandidateBet<T extends Bet> = Omit<T, 'id' | 'userId'>
|
||||
|
||||
export const getSellBetInfo = (bet: Bet, contract: DPMContract) => {
|
||||
const { pool, totalShares, totalBets } = contract
|
||||
const { id: betId, amount, shares, outcome, loanAmount } = bet
|
||||
const { id: betId, amount, shares, outcome } = bet
|
||||
|
||||
const adjShareValue = calculateDpmShareValue(contract, bet)
|
||||
|
||||
|
@ -29,8 +25,8 @@ export const getSellBetInfo = (
|
|||
|
||||
const newTotalBets = { ...totalBets, [outcome]: totalBets[outcome] - amount }
|
||||
|
||||
const probBefore = getDpmProbability(totalShares)
|
||||
const probAfter = getDpmProbability(newTotalShares)
|
||||
const probBefore = getDpmOutcomeProbability(totalShares, outcome)
|
||||
const probAfter = getDpmOutcomeProbability(newTotalShares, outcome)
|
||||
|
||||
const profit = adjShareValue - amount
|
||||
|
||||
|
@ -54,9 +50,7 @@ export const getSellBetInfo = (
|
|||
creatorFee
|
||||
)
|
||||
|
||||
const newBet: Bet = {
|
||||
id: newBetId,
|
||||
userId: user.id,
|
||||
const newBet: CandidateBet<Bet> = {
|
||||
contractId: contract.id,
|
||||
amount: -adjShareValue,
|
||||
shares: -shares,
|
||||
|
@ -71,25 +65,20 @@ export const getSellBetInfo = (
|
|||
fees,
|
||||
}
|
||||
|
||||
const newBalance = user.balance + saleAmount - (loanAmount ?? 0)
|
||||
|
||||
return {
|
||||
newBet,
|
||||
newPool,
|
||||
newTotalShares,
|
||||
newTotalBets,
|
||||
newBalance,
|
||||
fees,
|
||||
}
|
||||
}
|
||||
|
||||
export const getCpmmSellBetInfo = (
|
||||
user: User,
|
||||
shares: number,
|
||||
outcome: 'YES' | 'NO',
|
||||
contract: CPMMContract,
|
||||
prevLoanAmount: number,
|
||||
newBetId: string
|
||||
prevLoanAmount: number
|
||||
) => {
|
||||
const { pool, p } = contract
|
||||
|
||||
|
@ -100,8 +89,6 @@ export const getCpmmSellBetInfo = (
|
|||
)
|
||||
|
||||
const loanPaid = Math.min(prevLoanAmount, saleValue)
|
||||
const netAmount = saleValue - loanPaid
|
||||
|
||||
const probBefore = getCpmmProbability(pool, p)
|
||||
const probAfter = getCpmmProbability(newPool, p)
|
||||
|
||||
|
@ -115,9 +102,7 @@ export const getCpmmSellBetInfo = (
|
|||
fees.creatorFee
|
||||
)
|
||||
|
||||
const newBet: Bet = {
|
||||
id: newBetId,
|
||||
userId: user.id,
|
||||
const newBet: CandidateBet<Bet> = {
|
||||
contractId: contract.id,
|
||||
amount: -saleValue,
|
||||
shares: -shares,
|
||||
|
@ -129,13 +114,10 @@ export const getCpmmSellBetInfo = (
|
|||
fees,
|
||||
}
|
||||
|
||||
const newBalance = user.balance + netAmount
|
||||
|
||||
return {
|
||||
newBet,
|
||||
newPool,
|
||||
newP,
|
||||
newBalance,
|
||||
fees,
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": "../",
|
||||
"composite": true,
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node",
|
||||
"noImplicitReturns": true,
|
||||
"outDir": "lib",
|
||||
|
|
|
@ -1,6 +1,9 @@
|
|||
// A txn (pronounced "texan") respresents a payment between two ids on Manifold
|
||||
// Shortened from "transaction" to distinguish from Firebase transactions (and save chars)
|
||||
export type Txn = {
|
||||
type AnyTxnType = Donation | Tip | Manalink
|
||||
type SourceType = 'USER' | 'CONTRACT' | 'CHARITY' | 'BANK'
|
||||
|
||||
export type Txn<T extends AnyTxnType = AnyTxnType> = {
|
||||
id: string
|
||||
createdTime: number
|
||||
|
||||
|
@ -13,9 +16,36 @@ export type Txn = {
|
|||
amount: number
|
||||
token: 'M$' // | 'USD' | MarketOutcome
|
||||
|
||||
category: 'CHARITY' // | 'BET' | 'TIP'
|
||||
category: 'CHARITY' | 'MANALINK' | 'TIP' // | 'BET'
|
||||
// Any extra data
|
||||
data?: { [key: string]: any }
|
||||
|
||||
// Human-readable description
|
||||
description?: string
|
||||
} & T
|
||||
|
||||
type Donation = {
|
||||
fromType: 'USER'
|
||||
toType: 'CHARITY'
|
||||
category: 'CHARITY'
|
||||
}
|
||||
|
||||
export type SourceType = 'USER' | 'CONTRACT' | 'CHARITY' | 'BANK'
|
||||
type Tip = {
|
||||
fromType: 'USER'
|
||||
toType: 'USER'
|
||||
category: 'TIP'
|
||||
data: {
|
||||
contractId: string
|
||||
commentId: string
|
||||
}
|
||||
}
|
||||
|
||||
type Manalink = {
|
||||
fromType: 'USER'
|
||||
toType: 'USER'
|
||||
category: 'MANALINK'
|
||||
}
|
||||
|
||||
export type DonationTxn = Txn & Donation
|
||||
export type TipTxn = Txn & Tip
|
||||
export type ManalinkTxn = Txn & Manalink
|
||||
|
|
|
@ -15,8 +15,22 @@ export type User = {
|
|||
|
||||
balance: number
|
||||
totalDeposits: number
|
||||
totalPnLCached: number
|
||||
creatorVolumeCached: number
|
||||
|
||||
profitCached: {
|
||||
daily: number
|
||||
weekly: number
|
||||
monthly: number
|
||||
allTime: number
|
||||
}
|
||||
|
||||
creatorVolumeCached: {
|
||||
daily: number
|
||||
weekly: number
|
||||
monthly: number
|
||||
allTime: number
|
||||
}
|
||||
|
||||
followerCountCached: number
|
||||
|
||||
followedCategories?: string[]
|
||||
}
|
||||
|
@ -40,3 +54,11 @@ export type PrivateUser = {
|
|||
}
|
||||
|
||||
export type notification_subscribe_types = 'all' | 'less' | 'none'
|
||||
|
||||
export type PortfolioMetrics = {
|
||||
investmentValue: number
|
||||
balance: number
|
||||
totalDeposits: number
|
||||
timestamp: number
|
||||
userId: string
|
||||
}
|
||||
|
|
|
@ -7,6 +7,6 @@ export const cleanUsername = (name: string, maxLength = 25) => {
|
|||
.substring(0, maxLength)
|
||||
}
|
||||
|
||||
export const cleanDisplayName = (displayName: string, maxLength = 25) => {
|
||||
export const cleanDisplayName = (displayName: string, maxLength = 30) => {
|
||||
return displayName.replace(/\s+/g, ' ').substring(0, maxLength).trim()
|
||||
}
|
||||
|
|
|
@ -12,6 +12,10 @@ export function formatMoney(amount: number) {
|
|||
return ENV_CONFIG.moneyMoniker + formatter.format(newAmount).replace('$', '')
|
||||
}
|
||||
|
||||
export function formatMoneyWithDecimals(amount: number) {
|
||||
return ENV_CONFIG.moneyMoniker + amount.toFixed(2)
|
||||
}
|
||||
|
||||
export function formatWithCommas(amount: number) {
|
||||
return formatter.format(Math.floor(amount)).replace('$', '')
|
||||
}
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import { sortBy, sum } from 'lodash'
|
||||
|
||||
export const logInterpolation = (min: number, max: number, value: number) => {
|
||||
if (value <= min) return 0
|
||||
if (value >= max) return 1
|
||||
|
@ -16,4 +18,19 @@ export function normpdf(x: number, mean = 0, variance = 1) {
|
|||
)
|
||||
}
|
||||
|
||||
const TAU = Math.PI * 2
|
||||
export const TAU = Math.PI * 2
|
||||
|
||||
export function median(xs: number[]) {
|
||||
if (xs.length === 0) return NaN
|
||||
|
||||
const sorted = sortBy(xs, (x) => x)
|
||||
const mid = Math.floor(sorted.length / 2)
|
||||
if (sorted.length % 2 === 0) {
|
||||
return (sorted[mid - 1] + sorted[mid]) / 2
|
||||
}
|
||||
return sorted[mid]
|
||||
}
|
||||
|
||||
export function average(xs: number[]) {
|
||||
return sum(xs) / xs.length
|
||||
}
|
||||
|
|
|
@ -23,3 +23,18 @@ export const addObjects = <T extends { [key: string]: number }>(
|
|||
|
||||
return newObj as T
|
||||
}
|
||||
|
||||
export const subtractObjects = <T extends { [key: string]: number }>(
|
||||
obj1: T,
|
||||
obj2: T
|
||||
) => {
|
||||
const keys = union(Object.keys(obj1), Object.keys(obj2))
|
||||
const newObj = {} as any
|
||||
|
||||
for (const key of keys) {
|
||||
newObj[key] = (obj1[key] ?? 0) - (obj2[key] ?? 0)
|
||||
}
|
||||
|
||||
return newObj as T
|
||||
}
|
||||
|
||||
|
|
|
@ -1,3 +1,11 @@
|
|||
# docs
|
||||
|
||||
Manifold Markets Docs
|
||||
|
||||
## Getting started
|
||||
|
||||
0. Make sure you have [Yarn 1.x][yarn]
|
||||
1. `$ cd docs`
|
||||
2. `$ yarn`
|
||||
3. `$ yarn start`
|
||||
4. The docs site will be available on http://localhost:3000
|
||||
|
|
61
docs/docs/$how-to.md
Normal file
61
docs/docs/$how-to.md
Normal file
|
@ -0,0 +1,61 @@
|
|||
# How to Manifold
|
||||
|
||||
Manifold Markets is a novel site where users can bet against each other to predict the outcomes of all types of questions. Engage in intense discussion, or joke with friends, whilst putting play-money where your mouth is.
|
||||
|
||||
## Mana
|
||||
|
||||
Mana (M$) is our virtual play currency that cannot be converted to real money.
|
||||
|
||||
- **Its Value**
|
||||
|
||||
You can redeem your Mana and we will [donate to a charity](https://manifold.markets/charity) on your behalf. Redeeming and purchasing Mana occurs at a rate of M$100 to $1. You will be able to redeem it for merch and other cool items soon too!
|
||||
|
||||
- **It sets us apart**
|
||||
|
||||
Using play-money sets us apart from other similar sites as we don’t want our users to solely focus on monetary gains. Instead we prioritize providing value in the form of an enjoyable experience and facilitating a more informed world through the power of prediction markets.
|
||||
|
||||
## How probabilities work
|
||||
|
||||
The probability of a market represents what the collective bets of users predict the chances of an outcome occurring is. How this is calculated depends on the type of market - see below!
|
||||
|
||||
## Types of markets
|
||||
|
||||
There are currently 3 types of markets: Yes/No (binary), Free response, and Numerical.
|
||||
|
||||
- **Yes/No (Binary)**
|
||||
|
||||
The creator asks a question where traders can bet yes or no.
|
||||
|
||||
Check out [Maniswap](https://www.notion.so/Maniswap-ce406e1e897d417cbd491071ea8a0c39) for more info on its automated market maker.
|
||||
|
||||
- **Free Response**
|
||||
|
||||
The creator asks an open ended question. Both the creator and users can propose answers which can be bet on. Don’t be intimidated to add new answers! The payout system and initial liquidity rewards users who bet on new answers early. The algorithm used to determine the probability and payout is complicated but if you want to learn more check out [DPM](https://www.notion.so/DPM-b9b48a09ea1f45b88d991231171730c5).
|
||||
|
||||
- **Numerical**
|
||||
|
||||
Retracted whilst we make improvements. You still may see some old ones floating around though. Questions which can be answered by a number within a given range. Betting on a value will cause you to buy shares from ‘buckets’ surrounding the number you choose.
|
||||
|
||||
## Compete and build your portfolio
|
||||
|
||||
Generate profits to prove your expertise and shine above your friends.
|
||||
|
||||
To the moon 🚀
|
||||
|
||||
- **Find inaccurate probabilities**
|
||||
|
||||
Use your superior knowledge on topics to identify markets which have inaccurate probabilities. This gives you favorable odds, so bet accordingly to shift the probability to what you think it should be.
|
||||
|
||||
- **React to news**
|
||||
|
||||
Markets are dynamic and ongoing events can drastically affect what the probability should look like. Be the keenest to react and there is a lot of Mana to be made.
|
||||
|
||||
- **Buy low, sell high**
|
||||
|
||||
Similar to a stock market, probabilities can be overvalued and undervalued. If you bet (buy shares) at one end of the spectrum and subsequently other users buy even more shares of that same type, the value of your own shares will increase. Sometimes it will be most profitable to wait for the market to resolve but often it can be wise to sell your shares and take the immediate profits. This can also be a great way to free up Mana if you are lacking funds.
|
||||
|
||||
- **Create innovative answers**
|
||||
|
||||
Certain free response markets provide room for creativity! The answers themselves can often affect the outcome based on how compelling they are.
|
||||
|
||||
More questions? Check out **[this community-driven FAQ](https://docs.manifold.markets/faq)**!
|
|
@ -5,7 +5,7 @@ slug: /
|
|||
|
||||
# About Manifold Markets
|
||||
|
||||
Manifold Markets lets anyone create a prediction market on any topic. Win virtual money betting on what you know, from **[chess tournaments](https://manifold.markets/SG/will-magnus-carlsen-lose-any-regula)** to **[lunar collisions](https://manifold.markets/Duncan/will-the-wayward-falcon-9-booster-h)** to **[newsletter subscriber rates](https://manifold.markets/Nu%C3%B1oSempere/how-many-additional-subscribers-wil)** - or learn about the future by creating your own market!
|
||||
Manifold Markets lets anyone create a prediction market on any topic. Win virtual play money betting on what you know, from **[chess tournaments](https://manifold.markets/SG/will-magnus-carlsen-lose-any-regula)** to **[lunar collisions](https://manifold.markets/Duncan/will-the-wayward-falcon-9-booster-h)** to **[newsletter subscriber rates](https://manifold.markets/Nu%C3%B1oSempere/how-many-additional-subscribers-wil)** - or learn about the future by creating your own market!
|
||||
|
||||
### **What are prediction markets?**
|
||||
|
||||
|
@ -17,27 +17,13 @@ If I think the Democrats are very likely to win, and you disagree, I might offer
|
|||
|
||||
Now, you or I could be mistaken and overshooting the true probability one way or another. If so, there's an incentive for someone else to bet and correct it! Over time, the implied probability will converge to the **[market's best estimate](https://en.wikipedia.org/wiki/Efficient-market_hypothesis)**. Since these probabilities are public, anyone can use them to make better decisions!
|
||||
|
||||
### **How does Manifold Markets work?**
|
||||
|
||||
1. **Anyone can create a market for any yes-or-no question.**
|
||||
|
||||
You can ask questions about the future like "Will Taiwan remove its 14-day COVID quarantine by Jun 01, 2022?" If the market thinks this is very likely, you can plan more activities for your trip.
|
||||
|
||||
You can also ask subjective, personal questions like "Will I enjoy my 2022 Taiwan trip?". Then share the market with your family and friends and get their takes!
|
||||
|
||||
2. **Anyone can bet on a market using Manifold Dollars (M$), our platform currency.**
|
||||
|
||||
You get M$ 1,000 just for signing up, so you can start betting immediately! When a market creator decides an outcome in your favor, you'll win Manifold Dollars from people who bet against you.
|
||||
|
||||
More questions? Check out **[this community-driven FAQ](https://outsidetheasylum.blog/manifold-markets-faq/)**!
|
||||
|
||||
### **Can prediction markets work without real money?**
|
||||
|
||||
Yes! There is substantial evidence that play-money prediction markets provide real predictive power. Examples include **[sports betting](http://www.electronicmarkets.org/fileadmin/user_upload/doc/Issues/Volume_16/Issue_01/V16I1_Statistical_Tests_of_Real-Money_versus_Play-Money_Prediction_Markets.pdf)** and internal prediction markets at firms like **[Google](https://www.networkworld.com/article/2284098/google-bets-on-value-of-prediction-markets.html)**.
|
||||
|
||||
Our overall design also ensures that good forecasting will come out on top in the long term. In the competitive environment of the marketplace, bettors that are correct more often will gain influence, leading to better-calibrated forecasts over time.
|
||||
|
||||
Since our launch, we've seen hundreds of users trade each day, on over a thousand different markets! You can track the popularity of our platform at **[http://manifold.markets/analytics](http://manifold.markets/analytics)**.
|
||||
Since our launch, we've seen hundreds of users trade each day, on over a thousand different markets! You can track the popularity of our platform at **[https://manifold.markets/stats](https://manifold.markets/stats)**.
|
||||
|
||||
### **Why is this important?**
|
||||
|
||||
|
@ -67,7 +53,7 @@ Manifold Markets is currently a team of three:
|
|||
- Stephen Grugett
|
||||
- Austin Chen
|
||||
|
||||
We've previously launched consumer-facing startups (**[Throne](https://throne.live/)**, **[One Word](http://oneword.games/platform)**), and worked at top tech and trading firms (Google, Susquehanna).
|
||||
We've previously launched consumer-facing startups (**[Throne](https://throne.live/)**, **[One Word](https://oneword.games/platform)**), and worked at top tech and trading firms (Google, Susquehanna).
|
||||
|
||||
## **Talk to us!**
|
||||
|
||||
|
|
577
docs/docs/api.md
577
docs/docs/api.md
|
@ -4,42 +4,85 @@
|
|||
|
||||
Our API is still in alpha — things may change or break at any time!
|
||||
|
||||
:::
|
||||
|
||||
Manifold currently supports a basic, read-only API for getting information about our markets.
|
||||
|
||||
If you have questions, come chat with us on [Discord](https://discord.com/invite/eHQBNBqXuh). We’d love to hear about what you build!
|
||||
|
||||
## List out all markets
|
||||
:::
|
||||
|
||||
### `/v0/markets`
|
||||
## General notes
|
||||
|
||||
Some APIs are not associated with any particular user. Other APIs require authentication.
|
||||
|
||||
APIs that require authentication accept an `Authorization` header in one of two formats:
|
||||
|
||||
- `Authorization: Key {key}`. A Manifold API key associated with a user
|
||||
account. Each account may have zero or one API keys. To generate an API key
|
||||
for your account, visit your user profile, click "edit", and click the
|
||||
"refresh" button next to the API key field at the bottom. You can click it
|
||||
again any time to invalidate your existing key and generate a new one.
|
||||
|
||||
- `Authorization: Bearer {jwt}`. A signed JWT from Firebase asserting your
|
||||
identity. This is what our web client uses. It will probably be annoying for
|
||||
you to generate and we will not document it further here.
|
||||
|
||||
API requests that accept parameters should either have the parameters in the
|
||||
query string if they are GET requests, or have a body with a JSON object with
|
||||
one property per parameter if they are POST requests.
|
||||
|
||||
API responses should always either have a body with a JSON result object (if
|
||||
the response was a 200) or with a JSON object representing an error (if the
|
||||
response was a 4xx or 5xx.)
|
||||
|
||||
## Endpoints
|
||||
|
||||
### `GET /v0/markets`
|
||||
|
||||
Lists all markets, ordered by creation date descending.
|
||||
|
||||
Parameters:
|
||||
|
||||
- `limit`: Optional. How many markets to return. The maximum and the default is 1000.
|
||||
- `before`: Optional. The ID of the market before which the list will start. For
|
||||
example, if you ask for the most recent 10 markets, and then perform a second
|
||||
query for 10 more markets with `before=[the id of the 10th market]`, you will
|
||||
get markets 11 through 20.
|
||||
|
||||
Requires no authorization.
|
||||
|
||||
- Example request
|
||||
```
|
||||
http://manifold.markets/api/v0/markets
|
||||
https://manifold.markets/api/v0/markets?limit=1
|
||||
```
|
||||
- Example response
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id":"FKtYX3t8ZfIp5gytJWAI",
|
||||
"creatorUsername":"JamesGrugett",
|
||||
"creatorName":"James Grugett",
|
||||
"createdTime":1645139406452,
|
||||
"closeTime":1647406740000,
|
||||
"question":"What will be the best assessment of the Free response feature on March 15th?",
|
||||
"description":"Hey guys, let's try this out!\nWe will see how people use the new Free response market type over the next month. Then I will pick the answer that I think best describes the consensus view of this feature on March 15th. Cheers.",
|
||||
"id":"EvIhzcJXwhL0HavaszD7",
|
||||
"creatorUsername":"Austin",
|
||||
"creatorName":"Austin",
|
||||
"createdTime":1653850472294,
|
||||
"creatorAvatarUrl":"https://lh3.googleusercontent.com/a-/AOh14GiZyl1lBehuBMGyJYJhZd-N-mstaUtgE4xdI22lLw=s96-c",
|
||||
"closeTime":1653893940000,
|
||||
"question":"Will I write a new blog post today?",
|
||||
"description":"I'm supposed to, or else Beeminder charges me $90.\nTentative topic ideas:\n- \"Manifold funding, a history\"\n- \"Markets and bounties allow trades through time\"\n- \"equity vs money vs time\"\n\nClose date updated to 2022-05-29 11:59 pm",
|
||||
"tags":[
|
||||
"ManifoldMarkets"
|
||||
"personal",
|
||||
"commitments"
|
||||
],
|
||||
"url":"https://manifold.markets/JamesGrugett/what-will-be-the-best-assessment-of",
|
||||
"pool":null,
|
||||
"probability":0,
|
||||
"volume7Days":100,
|
||||
"volume24Hours":100,
|
||||
"isResolved":false,
|
||||
"url":"https://manifold.markets/Austin/will-i-write-a-new-blog-post-today",
|
||||
"pool":146.73022894879944,
|
||||
"probability":0.8958175225896258,
|
||||
"p":0.08281474972181882,
|
||||
"totalLiquidity":102.65696071594805,
|
||||
"outcomeType":"BINARY",
|
||||
"mechanism":"cpmm-1",
|
||||
"volume":241,
|
||||
"volume7Days":0,
|
||||
"volume24Hours":0,
|
||||
"isResolved":true,
|
||||
"resolution":"YES",
|
||||
"resolutionTime":1653924077078
|
||||
},
|
||||
...
|
||||
}
|
||||
```
|
||||
- Response type: Array of `LiteMarket`
|
||||
|
||||
|
@ -52,29 +95,47 @@ If you have questions, come chat with us on [Discord](https://discord.com/invite
|
|||
// Attributes about the creator
|
||||
creatorUsername: string
|
||||
creatorName: string
|
||||
createdTime: number
|
||||
createdTime: number // milliseconds since epoch
|
||||
creatorAvatarUrl?: string
|
||||
|
||||
// Market attributes. All times are in milliseconds since epoch
|
||||
closeTime?: number // Min of creator's chosen date, and resolutionTime
|
||||
question: string
|
||||
description: string
|
||||
|
||||
// A list of tags on each market. Any user can add tags to any market.
|
||||
// This list also includes the predefined categories shown as filters on the home page.
|
||||
tags: string[]
|
||||
|
||||
// Note: This url always points to https://manifold.markets, regardless of what instance the api is running on.
|
||||
// This url includes the creator's username, but this doesn't need to be correct when constructing valid URLs.
|
||||
// i.e. https://manifold.markets/Austin/test-market is the same as https://manifold.markets/foo/test-market
|
||||
url: string
|
||||
|
||||
pool: number
|
||||
outcomeType: string // BINARY, FREE_RESPONSE, or NUMERIC
|
||||
mechanism: string // dpm-2 or cpmm-1
|
||||
|
||||
probability: number
|
||||
pool: { outcome: number } // For CPMM markets, the number of shares in the liquidity pool. For DPM markets, the amount of mana invested in each answer.
|
||||
p?: number // CPMM markets only, probability constant in y^p * n^(1-p) = k
|
||||
totalLiquidity?: number // CPMM markets only, the amount of mana deposited into the liquidity pool
|
||||
|
||||
volume: number
|
||||
volume7Days: number
|
||||
volume24Hours: number
|
||||
|
||||
isResolved: boolean
|
||||
resolutionTime?: number
|
||||
resolution?: string
|
||||
resolutionProbability?: number // Used for BINARY markets resolved to MKT
|
||||
}
|
||||
```
|
||||
|
||||
## Get information about one market
|
||||
### `GET /v0/market/[marketId]`
|
||||
|
||||
### `/v0/market/[marketId]`
|
||||
Gets information about a single market by ID. Includes comments, bets, and answers.
|
||||
|
||||
Requires no authorization.
|
||||
|
||||
- Example request
|
||||
|
||||
|
@ -86,227 +147,204 @@ If you have questions, come chat with us on [Discord](https://discord.com/invite
|
|||
|
||||
```json
|
||||
{
|
||||
"id": "3zspH9sSzMlbFQLn9GKR",
|
||||
"creatorUsername": "Austin",
|
||||
"creatorName": "Austin Chen",
|
||||
"createdTime": 1644103005345,
|
||||
"closeTime": 1667894340000,
|
||||
"question": "Will Carrick Flynn win the general election for Oregon's 6th District?",
|
||||
"description": "The Effective Altruism movement usually stays out of politics, but here is a recent, highly-upvoted endorsement of donating to Carrick Flynn as a high-impact area: https://forum.effectivealtruism.org/posts/Qi9nnrmjwNbBqWbNT/the-best-usd5-800-i-ve-ever-donated-to-pandemic-prevention\nFurther reading: https://ballotpedia.org/Oregon%27s_6th_Congressional_District_election,_2022\n\n#EffectiveAltruism #Politics",
|
||||
"tags": ["EffectiveAltruism", "Politics"],
|
||||
"url": "https://manifold.markets/Austin/will-carrick-flynn-win-the-general",
|
||||
"pool": 400.0916328426886,
|
||||
"probability": 0.34455568984059187,
|
||||
"volume7Days": 326.9083671573114,
|
||||
"id": "lEoqtnDgJzft6apSKzYK",
|
||||
"creatorUsername": "Angela",
|
||||
"creatorName": "Angela",
|
||||
"createdTime": 1655258914863,
|
||||
"creatorAvatarUrl": "https://firebasestorage.googleapis.com/v0/b/mantic-markets.appspot.com/o/user-images%2FAngela%2F50463444807_edfd4598d6_o.jpeg?alt=media&token=ef44e13b-2e6c-4498-b9c4-8e38bdaf1476",
|
||||
"closeTime": 1655265001448,
|
||||
"question": "What is good?",
|
||||
"description": "Resolves proportionally to the answer(s) which I find most compelling. (Obviously I’ll refrain from giving my own answers)\n\n(Please have at it with philosophy, ethics, etc etc)\n\n\nContract resolved automatically.",
|
||||
"tags": [],
|
||||
"url": "https://manifold.markets/Angela/what-is-good",
|
||||
"pool": null,
|
||||
"outcomeType": "FREE_RESPONSE",
|
||||
"mechanism": "dpm-2",
|
||||
"volume": 112,
|
||||
"volume7Days": 212,
|
||||
"volume24Hours": 0,
|
||||
"isResolved": false,
|
||||
"bets": [
|
||||
"isResolved": true,
|
||||
"resolution": "MKT",
|
||||
"resolutionTime": 1655265001448,
|
||||
"answers": [
|
||||
{
|
||||
"createdTime": 1644103005345,
|
||||
"isAnte": true,
|
||||
"shares": 83.66600265340756,
|
||||
"userId": "igi2zGXsfxYPgB0DJTXVJVmwCOr2",
|
||||
"amount": 70,
|
||||
"probAfter": 0.3,
|
||||
"probBefore": 0.3,
|
||||
"id": "E1MjiVYBM0GkqRXhv5cR",
|
||||
"outcome": "NO",
|
||||
"contractId": "3zspH9sSzMlbFQLn9GKR"
|
||||
"createdTime": 1655258941573,
|
||||
"avatarUrl": "https://firebasestorage.googleapis.com/v0/b/mantic-markets.appspot.com/o/user-images%2FAngela%2F50463444807_edfd4598d6_o.jpeg?alt=media&token=ef44e13b-2e6c-4498-b9c4-8e38bdaf1476",
|
||||
"id": "1",
|
||||
"username": "Angela",
|
||||
"number": 1,
|
||||
"name": "Angela",
|
||||
"contractId": "lEoqtnDgJzft6apSKzYK",
|
||||
"text": "ANTE",
|
||||
"userId": "qe2QqIlOkeWsbljfeF3MsxpSJ9i2",
|
||||
"probability": 0.66749733001068
|
||||
},
|
||||
{
|
||||
"contractId": "3zspH9sSzMlbFQLn9GKR",
|
||||
"probAfter": 0.3,
|
||||
"shares": 54.77225575051661,
|
||||
"userId": "igi2zGXsfxYPgB0DJTXVJVmwCOr2",
|
||||
"isAnte": true,
|
||||
"createdTime": 1644103005345,
|
||||
"id": "jn3iIGwD5f0vxOHxo62o",
|
||||
"amount": 30,
|
||||
"probBefore": 0.3,
|
||||
"outcome": "YES"
|
||||
"name": "Isaac King",
|
||||
"username": "IsaacKing",
|
||||
"text": "This answer",
|
||||
"userId": "y1hb6k7txdZPV5mgyxPFApZ7nQl2",
|
||||
"id": "2",
|
||||
"number": 2,
|
||||
"avatarUrl": "https://lh3.googleusercontent.com/a-/AOh14GhNVriOvxK2VUAmE-jvYZwC-XIymatzVirT0Bqb2g=s96-c",
|
||||
"contractId": "lEoqtnDgJzft6apSKzYK",
|
||||
"createdTime": 1655261198074,
|
||||
"probability": 0.008922214311142757
|
||||
},
|
||||
{
|
||||
"shares": 11.832723364874056,
|
||||
"probAfter": 0.272108843537415,
|
||||
"userId": "PkBnU8cAZiOLa0fjxiUzMKsFMYZ2",
|
||||
"contractId": "3zspH9sSzMlbFQLn9GKR",
|
||||
"outcome": "NO",
|
||||
"amount": 10,
|
||||
"id": "f6sHBab6lbGw9PsnVXdc",
|
||||
"probBefore": 0.3,
|
||||
"createdTime": 1644203305863
|
||||
"createdTime": 1655263226587,
|
||||
"userId": "jbgplxty4kUKIa1MmgZk22byJq03",
|
||||
"id": "3",
|
||||
"avatarUrl": "https://firebasestorage.googleapis.com/v0/b/mantic-markets.appspot.com/o/user-images%2FMartin%2Fgiphy.gif?alt=media&token=422ef610-553f-47e3-bf6f-c0c5cc16c70a",
|
||||
"text": "Toyota Camry",
|
||||
"contractId": "lEoqtnDgJzft6apSKzYK",
|
||||
"name": "Undox",
|
||||
"username": "Undox",
|
||||
"number": 3,
|
||||
"probability": 0.008966714133143469
|
||||
},
|
||||
{
|
||||
"userId": "BTksWMdCeHfDitWVaAZdjLSdu3o1",
|
||||
"amount": 10,
|
||||
"id": "Vfui2KOQwy7gkRPP7xc6",
|
||||
"shares": 18.12694184700382,
|
||||
"outcome": "YES",
|
||||
"createdTime": 1644212358699,
|
||||
"contractId": "3zspH9sSzMlbFQLn9GKR",
|
||||
"probBefore": 0.272108843537415,
|
||||
"probAfter": 0.3367768595041322
|
||||
},
|
||||
{
|
||||
"contractId": "3zspH9sSzMlbFQLn9GKR",
|
||||
"probAfter": 0.3659259259259259,
|
||||
"userId": "BTksWMdCeHfDitWVaAZdjLSdu3o1",
|
||||
"probBefore": 0.3367768595041322,
|
||||
"amount": 5,
|
||||
"outcome": "YES",
|
||||
"createdTime": 1644433184238,
|
||||
"id": "eGI1VwAWF822LkcmOUot",
|
||||
"shares": 8.435122540124937
|
||||
},
|
||||
{
|
||||
"userId": "NHA7Gv9nNpb7b60GpLD3oFkBvPa2",
|
||||
"shares": 59.79133423528123,
|
||||
"amount": 50,
|
||||
"probAfter": 0.24495867768595042,
|
||||
"contractId": "3zspH9sSzMlbFQLn9GKR",
|
||||
"createdTime": 1644693685223,
|
||||
"probBefore": 0.3659259259259259,
|
||||
"id": "fbU0DbmDWMnubggpQotw",
|
||||
"outcome": "NO"
|
||||
},
|
||||
{
|
||||
"amount": 25,
|
||||
"userId": "iXw2OSyhs0c4QW2fAfK3yqmaYDv1",
|
||||
"probAfter": 0.20583333333333328,
|
||||
"outcome": "NO",
|
||||
"shares": 28.3920247989266,
|
||||
"contractId": "3zspH9sSzMlbFQLn9GKR",
|
||||
"createdTime": 1644695698202,
|
||||
"id": "k9hyljJD3MMXK2OYxTsR",
|
||||
"probBefore": 0.24495867768595042
|
||||
},
|
||||
{
|
||||
"createdTime": 1644716782308,
|
||||
"shares": 11.17480183821209,
|
||||
"probBefore": 0.20583333333333328,
|
||||
"userId": "clvYFhVDzccYu20OUc5NBKJyDxj2",
|
||||
"probAfter": 0.1927679500520291,
|
||||
"id": "yYkZ4JpLgZHrRQUugpCD",
|
||||
"outcome": "NO",
|
||||
"contractId": "3zspH9sSzMlbFQLn9GKR",
|
||||
"amount": 10
|
||||
},
|
||||
{
|
||||
"contractId": "3zspH9sSzMlbFQLn9GKR",
|
||||
"outcome": "YES",
|
||||
"amount": 30,
|
||||
"id": "IU2Hb1DesgKIN140BkhE",
|
||||
"shares": 58.893424111838016,
|
||||
"createdTime": 1644736846538,
|
||||
"probBefore": 0.1927679500520291,
|
||||
"userId": "BTksWMdCeHfDitWVaAZdjLSdu3o1",
|
||||
"probAfter": 0.3289359861591695
|
||||
},
|
||||
{
|
||||
"isSold": true,
|
||||
"userId": "5zeWhzi9nlNNf5C9TVjshAN7QOd2",
|
||||
"createdTime": 1644751343436,
|
||||
"outcome": "NO",
|
||||
"contractId": "3zspH9sSzMlbFQLn9GKR",
|
||||
"amount": 25,
|
||||
"probBefore": 0.3289359861591695,
|
||||
"id": "fkCxVH7THaDbEhyJjXVk",
|
||||
"probAfter": 0.2854194032651529,
|
||||
"shares": 30.022082866721178
|
||||
},
|
||||
{
|
||||
"probAfter": 0.2838618650900295,
|
||||
"id": "Ao05LRRMXVWw8d7LtwhL",
|
||||
"outcome": "NO",
|
||||
"probBefore": 0.2854194032651529,
|
||||
"shares": 1.1823269994736165,
|
||||
"userId": "pUF3dMs9oLNpgU2LYtFmodaoDow1",
|
||||
"amount": 1,
|
||||
"contractId": "3zspH9sSzMlbFQLn9GKR",
|
||||
"createdTime": 1644768321860
|
||||
},
|
||||
{
|
||||
"id": "LJ8H8DTuK7CH9vN3u0Sd",
|
||||
"createdTime": 1644771352663,
|
||||
"shares": 113.5114039238785,
|
||||
"probAfter": 0.17510453314667793,
|
||||
"outcome": "NO",
|
||||
"amount": 100,
|
||||
"probBefore": 0.2838618650900295,
|
||||
"contractId": "3zspH9sSzMlbFQLn9GKR",
|
||||
"userId": "ebX5nzwrs8V0M5UynWvbtcj7KAI2"
|
||||
},
|
||||
{
|
||||
"outcome": "YES",
|
||||
"amount": 20,
|
||||
"probBefore": 0.17510453314667793,
|
||||
"id": "TECEF9I5FqTqt6uTIsJX",
|
||||
"contractId": "3zspH9sSzMlbFQLn9GKR",
|
||||
"createdTime": 1644805061501,
|
||||
"shares": 43.88281646028875,
|
||||
"userId": "lHxg3179e4amWm5LJhJoJrcWK482",
|
||||
"probAfter": 0.24160019644701852
|
||||
},
|
||||
{
|
||||
"amount": -25.908367157311375,
|
||||
"id": "G3u2EzETWOyrGo15wtiQ",
|
||||
"outcome": "NO",
|
||||
"createdTime": 1644847494264,
|
||||
"sale": {
|
||||
"betId": "fkCxVH7THaDbEhyJjXVk",
|
||||
"amount": 25.862948799445807
|
||||
},
|
||||
"probAfter": 0.26957595409437557,
|
||||
"shares": -30.022082866721178,
|
||||
"probBefore": 0.24160019644701852,
|
||||
"contractId": "3zspH9sSzMlbFQLn9GKR",
|
||||
"userId": "5zeWhzi9nlNNf5C9TVjshAN7QOd2"
|
||||
},
|
||||
{
|
||||
"createdTime": 1644853733891,
|
||||
"userId": "lbTXACtCnIacKDloKfXxYkDn0zM2",
|
||||
"amount": 10,
|
||||
"id": "z443uCkbYRLZW9QdXu1u",
|
||||
"probAfter": 0.25822886066938844,
|
||||
"contractId": "3zspH9sSzMlbFQLn9GKR",
|
||||
"outcome": "NO",
|
||||
"shares": 11.655141043149968,
|
||||
"probBefore": 0.26957595409437557
|
||||
},
|
||||
{
|
||||
"userId": "BTksWMdCeHfDitWVaAZdjLSdu3o1",
|
||||
"amount": 15,
|
||||
"shares": 28.311399392675895,
|
||||
"id": "axoryV664uzHZ0jzWSXR",
|
||||
"outcome": "YES",
|
||||
"probBefore": 0.25822886066938844,
|
||||
"contractId": "3zspH9sSzMlbFQLn9GKR",
|
||||
"createdTime": 1644863335939,
|
||||
"probAfter": 0.3033936853512369
|
||||
},
|
||||
{
|
||||
"createdTime": 1644987330420,
|
||||
"id": "jHAYDdZRkDw3lFoDXdmm",
|
||||
"shares": 26.353902809992064,
|
||||
"userId": "BTksWMdCeHfDitWVaAZdjLSdu3o1",
|
||||
"contractId": "3zspH9sSzMlbFQLn9GKR",
|
||||
"probAfter": 0.34455568984059187,
|
||||
"probBefore": 0.3033936853512369,
|
||||
"amount": 15,
|
||||
"outcome": "YES"
|
||||
"number": 4,
|
||||
"name": "James Grugett",
|
||||
"userId": "5LZ4LgYuySdL1huCWe7bti02ghx2",
|
||||
"text": "Utility (Defined by your personal utility function.)",
|
||||
"createdTime": 1655264793224,
|
||||
"contractId": "lEoqtnDgJzft6apSKzYK",
|
||||
"username": "JamesGrugett",
|
||||
"id": "4",
|
||||
"avatarUrl": "https://lh3.googleusercontent.com/a-/AOh14GjC83uMe-fEfzd6QvxiK6ZqZdlMytuHxevgMYIkpAI=s96-c",
|
||||
"probability": 0.09211463154147384
|
||||
}
|
||||
],
|
||||
"comments": [
|
||||
{
|
||||
"contractId": "3zspH9sSzMlbFQLn9GKR",
|
||||
"userUsername": "Celer",
|
||||
"userAvatarUrl": "https://lh3.googleusercontent.com/a/AATXAJwp0vAolZgOmT7GbzFq7mOf8lr0BFEB_LqWWfZk=s96-c",
|
||||
"userId": "NHA7Gv9nNpb7b60GpLD3oFkBvPa2",
|
||||
"text": "It's a D+3 district, and the person we're pushing is functionally an outsider. I maxed my donation, but 25%, what I bought down to, implying even odds on both the general and the primary, seems if anything optimistic.",
|
||||
"createdTime": 1644693740967,
|
||||
"id": "fbU0DbmDWMnubggpQotw",
|
||||
"betId": "fbU0DbmDWMnubggpQotw",
|
||||
"userName": "Celer"
|
||||
"id": "ZdHIyfQazHyl8nI0ENS7",
|
||||
"userId": "qe2QqIlOkeWsbljfeF3MsxpSJ9i2",
|
||||
"createdTime": 1655265807433,
|
||||
"text": "ok what\ni did not resolve this intentionally",
|
||||
"contractId": "lEoqtnDgJzft6apSKzYK",
|
||||
"userName": "Angela",
|
||||
"userAvatarUrl": "https://firebasestorage.googleapis.com/v0/b/mantic-markets.appspot.com/o/user-images%2FAngela%2F50463444807_edfd4598d6_o.jpeg?alt=media&token=ef44e13b-2e6c-4498-b9c4-8e38bdaf1476",
|
||||
"userUsername": "Angela"
|
||||
},
|
||||
{
|
||||
"userName": "James Grugett",
|
||||
"userUsername": "JamesGrugett",
|
||||
"id": "F7fvHGhTiFal8uTsUc9P",
|
||||
"userAvatarUrl": "https://lh3.googleusercontent.com/a-/AOh14GjC83uMe-fEfzd6QvxiK6ZqZdlMytuHxevgMYIkpAI=s96-c",
|
||||
"replyToCommentId": "ZdHIyfQazHyl8nI0ENS7",
|
||||
"text": "@Angela Sorry! There was an error that automatically resolved several markets that were created in the last few hours.",
|
||||
"createdTime": 1655266286514,
|
||||
"userId": "5LZ4LgYuySdL1huCWe7bti02ghx2",
|
||||
"contractId": "lEoqtnDgJzft6apSKzYK"
|
||||
},
|
||||
{
|
||||
"userId": "qe2QqIlOkeWsbljfeF3MsxpSJ9i2",
|
||||
"contractId": "lEoqtnDgJzft6apSKzYK",
|
||||
"id": "PIHhXy5hLHSgW8uoUD0Q",
|
||||
"userName": "Angela",
|
||||
"text": "lmk if anyone lost manna from this situation and i'll try to fix it",
|
||||
"userUsername": "Angela",
|
||||
"createdTime": 1655277581308,
|
||||
"userAvatarUrl": "https://firebasestorage.googleapis.com/v0/b/mantic-markets.appspot.com/o/user-images%2FAngela%2F50463444807_edfd4598d6_o.jpeg?alt=media&token=ef44e13b-2e6c-4498-b9c4-8e38bdaf1476"
|
||||
},
|
||||
{
|
||||
"userAvatarUrl": "https://firebasestorage.googleapis.com/v0/b/mantic-markets.appspot.com/o/user-images%2FAngela%2F50463444807_edfd4598d6_o.jpeg?alt=media&token=ef44e13b-2e6c-4498-b9c4-8e38bdaf1476",
|
||||
"userName": "Angela",
|
||||
"text": "from my end it looks like no one did",
|
||||
"replyToCommentId": "PIHhXy5hLHSgW8uoUD0Q",
|
||||
"createdTime": 1655287149528,
|
||||
"userUsername": "Angela",
|
||||
"id": "5slnWEQWwm6dHjDi6oiH",
|
||||
"contractId": "lEoqtnDgJzft6apSKzYK",
|
||||
"userId": "qe2QqIlOkeWsbljfeF3MsxpSJ9i2"
|
||||
}
|
||||
],
|
||||
"bets": [
|
||||
{
|
||||
"outcome": "0",
|
||||
"contractId": "lEoqtnDgJzft6apSKzYK",
|
||||
"fees": {
|
||||
"liquidityFee": 0,
|
||||
"creatorFee": 0,
|
||||
"platformFee": 0
|
||||
},
|
||||
"isAnte": true,
|
||||
"shares": 100,
|
||||
"probAfter": 1,
|
||||
"amount": 100,
|
||||
"userId": "IPTOzEqrpkWmEzh6hwvAyY9PqFb2",
|
||||
"createdTime": 1655258914863,
|
||||
"probBefore": 0,
|
||||
"id": "2jNZqnwoEQL7WDTTAWDP"
|
||||
},
|
||||
{
|
||||
"shares": 173.20508075688772,
|
||||
"fees": {
|
||||
"platformFee": 0,
|
||||
"liquidityFee": 0,
|
||||
"creatorFee": 0
|
||||
},
|
||||
"contractId": "lEoqtnDgJzft6apSKzYK",
|
||||
"probBefore": 0,
|
||||
"createdTime": 1655258941573,
|
||||
"loanAmount": 0,
|
||||
"userId": "qe2QqIlOkeWsbljfeF3MsxpSJ9i2",
|
||||
"amount": 100,
|
||||
"outcome": "1",
|
||||
"probAfter": 0.75,
|
||||
"id": "xuc3JoiNkE8lXPh15mUb"
|
||||
},
|
||||
{
|
||||
"userId": "y1hb6k7txdZPV5mgyxPFApZ7nQl2",
|
||||
"contractId": "lEoqtnDgJzft6apSKzYK",
|
||||
"loanAmount": 0,
|
||||
"probAfter": 0.009925496893641248,
|
||||
"id": "8TBlzPtOdO0q5BgSyRbi",
|
||||
"createdTime": 1655261198074,
|
||||
"shares": 20.024984394500787,
|
||||
"amount": 1,
|
||||
"outcome": "2",
|
||||
"probBefore": 0,
|
||||
"fees": {
|
||||
"liquidityFee": 0,
|
||||
"creatorFee": 0,
|
||||
"platformFee": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"probAfter": 0.00987648269777473,
|
||||
"outcome": "3",
|
||||
"id": "9vdwes6s9QxbYZUBhHs4",
|
||||
"createdTime": 1655263226587,
|
||||
"shares": 20.074859899884732,
|
||||
"amount": 1,
|
||||
"loanAmount": 0,
|
||||
"fees": {
|
||||
"liquidityFee": 0,
|
||||
"platformFee": 0,
|
||||
"creatorFee": 0
|
||||
},
|
||||
"userId": "jbgplxty4kUKIa1MmgZk22byJq03",
|
||||
"contractId": "lEoqtnDgJzft6apSKzYK",
|
||||
"probBefore": 0
|
||||
},
|
||||
{
|
||||
"createdTime": 1655264793224,
|
||||
"fees": {
|
||||
"creatorFee": 0,
|
||||
"liquidityFee": 0,
|
||||
"platformFee": 0
|
||||
},
|
||||
"probAfter": 0.09211463154147384,
|
||||
"amount": 10,
|
||||
"id": "BehiSGgk1wAkIWz1a8L4",
|
||||
"userId": "5LZ4LgYuySdL1huCWe7bti02ghx2",
|
||||
"contractId": "lEoqtnDgJzft6apSKzYK",
|
||||
"loanAmount": 0,
|
||||
"probBefore": 0,
|
||||
"outcome": "4",
|
||||
"shares": 64.34283176858165
|
||||
}
|
||||
]
|
||||
}
|
||||
|
@ -318,10 +356,11 @@ If you have questions, come chat with us on [Discord](https://discord.com/invite
|
|||
- Response type: A `FullMarket`
|
||||
|
||||
```tsx
|
||||
// A complete market, along with bets and comments
|
||||
// A complete market, along with bets, comments, and answers (for free response markets)
|
||||
type FullMarket = LiteMarket & {
|
||||
bets: Bet[]
|
||||
comments: Comment[]
|
||||
answers?: Answer[]
|
||||
}
|
||||
|
||||
type Bet = {
|
||||
|
@ -347,9 +386,11 @@ If you have questions, come chat with us on [Discord](https://discord.com/invite
|
|||
}
|
||||
```
|
||||
|
||||
### `/v0/slug/[marketSlug]`
|
||||
### `GET /v0/slug/[marketSlug]`
|
||||
|
||||
This is a convenience endpoint for getting info about a market from it slug (everything after the last slash in a market’s URL).
|
||||
Gets information about a single market by slug (the portion of the URL path after the username).
|
||||
|
||||
Requires no authorization.
|
||||
|
||||
- Example request
|
||||
```
|
||||
|
@ -357,13 +398,67 @@ This is a convenience endpoint for getting info about a market from it slug (eve
|
|||
```
|
||||
- Response type: A `FullMarket` ; same as above.
|
||||
|
||||
## Deprecated
|
||||
### `POST /v0/bet`
|
||||
|
||||
- Our old Markets API was available at [https://us-central1-mantic-markets.cloudfunctions.net/markets](https://us-central1-mantic-markets.cloudfunctions.net/markets)
|
||||
- We don’t plan on continuing to change this, but we’ll support this endpoint until 2022-03-30
|
||||
Places a new bet on behalf of the authorized user.
|
||||
|
||||
Parameters:
|
||||
|
||||
- `amount`: Required. The amount to bet, in M$, before fees.
|
||||
- `contractId`: Required. The ID of the contract (market) to bet on.
|
||||
- `outcome`: Required. The outcome to bet on. For binary markets, this is `YES`
|
||||
or `NO`. For free response markets, this is the ID of the free response
|
||||
answer. For numeric markets, this is a string representing the target bucket,
|
||||
and an additional `value` parameter is required which is a number representing
|
||||
the target value. (Bet on numeric markets at your own peril.)
|
||||
|
||||
Example request:
|
||||
|
||||
```
|
||||
$ curl https://manifold.markets/api/v0/bet -X POST -H 'Content-Type: application/json' \
|
||||
-H 'Authorization: Key {...}' \
|
||||
--data-raw '{"amount":1, \
|
||||
"outcome":"YES", \
|
||||
"contractId":"{...}"}'
|
||||
```
|
||||
|
||||
### `POST /v0/market`
|
||||
|
||||
Creates a new market on behalf of the authorized user.
|
||||
|
||||
Parameters:
|
||||
|
||||
- `outcomeType`: Required. One of `BINARY`, `FREE_RESPONSE`, or `NUMERIC`.
|
||||
- `question`: Required. The headline question for the market.
|
||||
- `description`: Required. A long description describing the rules for the market.
|
||||
- `closeTime`: Required. The time at which the market will close, represented as milliseconds since the epoch.
|
||||
- `tags`: Optional. An array of string tags for the market.
|
||||
|
||||
For binary markets, you must also provide:
|
||||
|
||||
- `initialProb`: An initial probability for the market, between 1 and 99.
|
||||
|
||||
For numeric markets, you must also provide:
|
||||
|
||||
- `min`: The minimum value that the market may resolve to.
|
||||
- `max`: The maximum value that the market may resolve to.
|
||||
|
||||
Example request:
|
||||
|
||||
```
|
||||
$ curl https://manifold.markets/api/v0/market -X POST -H 'Content-Type: application/json' \
|
||||
-H 'Authorization: Key {...}'
|
||||
--data-raw '{"outcomeType":"BINARY", \
|
||||
"question":"Is there life on Mars?", \
|
||||
"description":"I'm not going to type some long ass example description.", \
|
||||
"closeTime":1700000000000, \
|
||||
"initialProb":25}'
|
||||
```
|
||||
|
||||
## Changelog
|
||||
|
||||
- 2022-06-08: Add paging to markets endpoint
|
||||
- 2022-06-05: Add new authorized write endpoints
|
||||
- 2022-02-28: Add `resolutionTime` to markets, change `closeTime` definition
|
||||
- 2022-02-19: Removed user IDs from bets
|
||||
- 2022-02-17: Released our v0 API, with `/markets`, `/market/[marketId]`, and `/slug/[slugId]`
|
||||
|
|
22
docs/docs/awesome-manifold.md
Normal file
22
docs/docs/awesome-manifold.md
Normal file
|
@ -0,0 +1,22 @@
|
|||
# Awesome Manifold 😎
|
||||
|
||||
A list of community-created projects built on, or related to, Manifold Markets.
|
||||
|
||||
## Data
|
||||
|
||||
- [Manifold Market Stats](https://wasabipesto.com/jupyter/manifold/)
|
||||
|
||||
## Sites using Manifold
|
||||
|
||||
- [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
|
||||
|
||||
## API / Dev
|
||||
|
||||
- [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)
|
||||
- [manifeed](https://github.com/joy-void-joy/manifeed) - Tool that creates an RSS feed for new Manifold markets
|
||||
|
||||
## Bots
|
||||
|
||||
- [@manifold@botsin.space](https://botsin.space/@manifold) - Posts new Manifold markets to Mastodon
|
|
@ -1,52 +0,0 @@
|
|||
# Guide to YES/NO markets
|
||||
|
||||
# Overview
|
||||
|
||||
Historically, Manifold used a special type of automated market marker based on a dynamic pari-mutuel (DPM) betting
|
||||
system. Free response and numeric markets still use this system. Binary markets created prior to March 15, 2022 used
|
||||
this system.
|
||||
|
||||
Binary markets created after March 15 use a constant-function market maker which holds constant the weighted geometric
|
||||
mean, with weights equal to the probabilities chosen by the market creator at creation. This design was inspired by
|
||||
Uniswap's CPMM and a suggestion from Manifold user Pepe.
|
||||
|
||||
# Basic facts
|
||||
|
||||
- Markets are structured around a question with a binary outcome.
|
||||
- Traders can place a bet on either YES or NO and receive shares in the outcome in return.
|
||||
- 1 YES share = M$1 if the event happens. 1 NO share = M$1 if the event does not happen.
|
||||
- Notice that 1 YES share + 1 NO share = M$1. If you ever get multiple YES and NO shares, they will cancel out and you will be left with cash.
|
||||
- When the market is resolved, you will be paid out according to your shares. If you own 100 YES shares, if the event resolves YES, you will earn M$100. (If the event resolves NO, you will earn M$0).
|
||||
- The creator of each market is responsible for resolving each market YES or NO.
|
||||
- Creators can also resolve N/A to cancel all transactions and return the money, or resolve to a particular probability (say 50%).
|
||||
|
||||
# Betting
|
||||
|
||||
- Betting on YES will increase the market’s implied probability; betting on NO will decrease the probability.
|
||||
- Manifold's automated market automatically adjusts the market probability after each trade and determines how many shares a user will get for their bet.
|
||||
- You can sell back your shares for cash. If you sell YES shares, the market probability will go down. If you sell NO shares, the probability will go up.
|
||||
- Manifold charges fees on each trade. They are baked into the number of shares you receive.
|
||||
- If you place a M$100 bet on YES when the probability is 50%, you may end up with 150 YES shares. These shares already include our fees. Notice also that when you buy, the probability goes up, so you are not getting in exactly at 200 shares or 50%.
|
||||
- Our fee schedule is currently: 13% _ (1 - post-bet probability) _ bet amount
|
||||
- The post-trade probability is what the market probability would be after your bet if there were no fees.
|
||||
- Example:
|
||||
- If you bet M$100 on NO and the resulting probability without fees would be 10%, then you pay M$100 _ 13% _ 10% = M$1.3.
|
||||
- If you bet M$100 on YES and the resulting probability without fees would be 90%, then you pay `M$100 * 13% * 10% = M$1.3`.
|
||||
- The fees are used to provide a commission to the market creator and to subsidize trading within the market.
|
||||
- The market creator’s commission is paid out only after the market is resolved.
|
||||
- No fees are levied on sales.
|
||||
|
||||
# Market creation
|
||||
|
||||
- Users can create a market on any question they want.
|
||||
- When you create a market, you must choose an initial probability and a close date (after which trading will halt).
|
||||
- You must also pay a M$ 50 market creation fee, which is used to subsidize trading on your market.
|
||||
- You will earn a commission on all bets placed in your market.
|
||||
- You are responsible for resolving your market in a timely manner. All the fees you earned as a commission will be paid out after resolution.
|
||||
|
||||
# Liquidity
|
||||
|
||||
- The liquidity in a market is the amount of capital available for traders to trade against.
|
||||
- The more liquidity, the greater incentive there is for traders to bet, the more accurate the market will be.
|
||||
- You can add liquidity to a market you are interested in to increase the incentives for traders to participate. You can think of added liquidity as a subsidy for getting your question answered.
|
||||
- You can add liquidity to any market by opening up the market info popup window located in the (...) section of the header on the market page.
|
|
@ -15,35 +15,97 @@ Our community is the beating heart of Manifold; your individual contributions ar
|
|||
|
||||
## Awarded bounties
|
||||
|
||||
🥧 *Awarded 2022-03-14*
|
||||
🎈 *Awarded on 2022-06-14*
|
||||
|
||||
**[Kevin Zielnicki](https://manifold.markets/kjz): M$ 10,000**
|
||||
**[Wasabipesto](https://manifold.markets/wasabipesto): M$20,000**
|
||||
|
||||
- For creating an awesome stats page which features and analyses various data sets! This can be found on the second tab of our [analytics page](https://manifold.markets/stats).
|
||||
|
||||
**[Jack](https://manifold.markets/jack): M$10,000**
|
||||
|
||||
- For adding a bunch of charities to [Manifold for Good](https://manifold.markets/charity), working out market math with Austin, and excellent comment activity.
|
||||
|
||||
**[Forrest](https://manifold.markets/Forrest): M$10,000**
|
||||
|
||||
- For a variety of [open source code contributions](https://github.com/manifoldmarkets/manifold/commits?author=ForrestWeiswolf), making our code base easier to use and maintain.
|
||||
|
||||
**[IsaacKing](https://manifold.markets/IsaacKing): M$10,000**
|
||||
|
||||
- For responsible disclosure of an exploit involving liquidity withdrawal, which has [now been fixed](https://github.com/manifoldmarkets/manifold/pull/472)! Removing one infinite money glitch at a time.
|
||||
|
||||
**[Sjlver](https://manifold.markets/Sjlver): M$5,000**
|
||||
|
||||
- For responsible disclosure of a potential exploit. We would say what it is, but it isn’t quite fixed yet! 🤫
|
||||
|
||||
_🌿 Announced on 2022-05-02_
|
||||
|
||||
**[Marshall Polaris](https://manifold.markets/mqp): M$200K**
|
||||
|
||||
- For spearheading the effort to [open-source Manifold](https://github.com/manifoldmarkets/manifold), by documenting our processes, triaging bugs, and improving the new contributor experience.
|
||||
- Marshall contributed over 2 weeks of part-time volunteer work; as such, we are awarding an amount that reflects the extraordinary amount of effort he’s put in.
|
||||
|
||||
**[Vincent Luczkow](https://manifold.markets/VincentLuczkow): M$10,000**
|
||||
|
||||
- For building and releasing https://github.com/vluzko/manifold-markets-python, a super cool Python visualization of the calibration accuracy of all Manifold markets. Turns out we’re doing okay!
|
||||
|
||||
**[Akhil Wable](https://manifold.markets/AkhilWable): M$10,000**
|
||||
|
||||
- For writing up [Akhil’s Product Suggestions](https://www.notion.so/Akhil-s-Product-Suggestions-672e1cba393d4242852ff95ae79528df), an extensive, thoughtful list of improvements we could make to our platform.
|
||||
|
||||
**[Alex K. Chen](https://manifold.markets/AlexKChen): M$6,000**
|
||||
|
||||
- For the creation of a metric ton of innovative, long term questions. At the time of award, Alex was singlehandedly responsible for 20% of all markets posted in April.
|
||||
|
||||
**[ZorbaTHut](https://manifold.markets/ZorbaTHut): M$5,000**
|
||||
|
||||
- For [testing out futarchy](https://manifold.markets/tag/themotte_leaving) on an important problem for the community of The Motte.
|
||||
|
||||
**[Tetraspace](https://manifold.markets/Tetraspace): M$3,500**
|
||||
|
||||
- For the creation of [a focused set of questions on UK politics](https://twitter.com/TetraspaceWest/status/1516824123149848579), with relevant real-world predictions.
|
||||
- For the idea and execution of using FR bounded buckets for mapping out a scalar range ([example market](https://manifold.markets/Tetraspace/if-ron-desantis-is-elected-presiden), [discussion here](https://manifold.markets/StephenMalina/how-many-daily-active-users-will-ma)).
|
||||
|
||||
**[tcheasdfjkl](https://manifold.markets/tcheasdfjkl): M$2,500**
|
||||
|
||||
- For calling out numerous areas of improvement, e.g. around our profit numbers being wonky, and problems with the DPM ⇒ CFMM market conversions.
|
||||
|
||||
**[Jack](https://manifold.markets/JackC): M$500**
|
||||
|
||||
- For recommending we list the Long-Term Future Fund as a supported charity.
|
||||
|
||||
**[N.C. Young](https://manifold.markets/NcyRocks): M$500**
|
||||
|
||||
- For recommending we list the Givewell Maximum Impact Fund as a supported charity.
|
||||
|
||||
\**🥧 *Awarded 2022-03-14\*
|
||||
|
||||
**[Kevin Zielnicki](https://manifold.markets/kjz): M$10,000**
|
||||
|
||||
- For identifying issues with our Dynamic Parimutuel Market Maker in an [excellent blog post](https://kevin.zielnicki.com/2022/02/17/manifold/) (and [associated market](https://manifold.markets/kjz/will-manifolds-developers-agree-wit)), leading us to change to a different mechanism.
|
||||
|
||||
**[Pepe](https://manifold.markets/Pepe): M$ 10,000**
|
||||
**[Pepe](https://manifold.markets/Pepe): M$10,000**
|
||||
|
||||
- For developing the function used in our Constant Function Market Maker and working with us to polish it on Discord, making it easier for us to provision liquidity compared to a CPMM.
|
||||
- For developing the function used in our Constant Function Market Maker, making it easier for us to provision liquidity compared to a CPMM.
|
||||
|
||||
**[Gurkenglas](https://manifold.markets/Gurkenglas): M$ 5,000**
|
||||
**[Gurkenglas](https://manifold.markets/Gurkenglas): M$5,000**
|
||||
|
||||
- For concrete suggestions on Discord around improving our market maker algorithms, and creating useful graphs to make our different market makers more legible.
|
||||
- For concrete suggestions around improving our market maker algorithms, and creating useful graphs to make our different market makers more legible.
|
||||
|
||||
**[Scott Alexander](https://manifold.markets/ScottAlexander): M$ 5,000**
|
||||
**[Scott Alexander](https://manifold.markets/ScottAlexander): M$5,000**
|
||||
|
||||
- For [developing and publicizing the idea of providing interest-free loans on each market](https://astralcodexten.substack.com/p/play-money-and-reputation-systems), helping make long-term markets more accurate.
|
||||
|
||||
**[David Glidden](https://manifold.markets/dglid): M$ 5,000**
|
||||
**[David Glidden](https://manifold.markets/dglid): M$5,000**
|
||||
|
||||
- For taking on the mantle of [@MetaculusBot](https://manifold.markets/MetaculusBot), which allows traders access to a wider spread of topics, and permits head-to-head comparisons between our prediction markets and other forecasting platforms.
|
||||
|
||||
**[Isaac King](https://manifold.markets/IsaacKing): M$ 5,000**
|
||||
**[Isaac King](https://manifold.markets/IsaacKing): M$5,000**
|
||||
|
||||
- For [compiling a comprehensive FAQ](https://outsidetheasylum.blog/manifold-markets-faq/) that answers a variety of questions that new users commonly face, and also inspiring us to move to [this open-source docs platform](http://docs.manifold.markets/).
|
||||
- For [compiling an FAQ](https://outsidetheasylum.blog/manifold-markets-faq/) that answers a variety of questions that new users commonly face, and also inspiring us to move to [this open-source docs platform](https://docs.manifold.markets/).
|
||||
|
||||
**[Blazer](https://manifold.markets/BlazingDarkness): M$ 2,500**
|
||||
**[Blazer](https://manifold.markets/BlazingDarkness/was-it-an-unpleasant-surprise-when): M$2,500**
|
||||
|
||||
- For [calling out our mistake](https://manifold.markets/BlazingDarkness/was-it-an-unpleasant-surprise-when) in retroactively publicizing all market creator’s trades, leading us to revert this feature entirely.
|
||||
- For [calling out our mistake](https://manifold.markets/BlazingDarkness/was-it-an-unpleasant-surprise-when) in retroactively publicizing the market creator’s trades, leading us to revert this feature entirely.
|
||||
|
||||
⛑️ _Awarded 2022-01-09_
|
||||
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
### Do I have to pay real money in order to participate?
|
||||
|
||||
Nope! Each account starts with a free M$ 1000. If you invest it wisely, you can increase your total without ever needing to put any real money into the site.
|
||||
Nope! Each account starts with a free M$1000. If you invest it wisely, you can increase your total without ever needing to put any real money into the site.
|
||||
|
||||
### What is the name for the currency Manifold uses, represented by M$?
|
||||
|
||||
|
@ -14,9 +14,11 @@ Manifold Dollars, or mana for short.
|
|||
|
||||
No. Gambling laws put many restrictions on real-money prediction markets, so Manifold uses play money instead.
|
||||
|
||||
You can instead redeem your Mana and we will [donate to a charity](http://manifold.markets/charity) on your behalf. Redeeming and purchasing Mana occurs at a rate of M$100 to $1.
|
||||
|
||||
### How do the free response markets work?
|
||||
|
||||
Any user can enter a response and bet on it, or they can bet on on other people's responses. The response probabilities are weighted proportionally to how many people have bet on them. The market creator's ante goes into a "none of the above" pseudo-option that can't be bet on and can't be chosen as a correct answer when the market is resolved. (This means that free response markets tend to lose their creator almost their entire ante, whereas normal markets only lose them a small fraction that's proportional to how well they chose their starting odds. It also means that if there are only a finite number of options that could win, traders can make guaranteed money by investing in them all equally.) See [here](https://manifoldmarkets.substack.com/p/above-the-fold-milestones-and-new) for more information.
|
||||
Any user can enter a response and bet on it, or they can bet on other people's responses. The response probabilities are weighted proportionally to how many people have bet on them. The market creator's ante goes into a "none of the above" pseudo-option that can't be bet on and can't be chosen as a correct answer when the market is resolved. (This means that free response markets tend to lose their creator almost their entire ante. It also means that if there are only a finite number of options that could win, traders can make guaranteed money by investing in them all equally.) See [here](https://manifoldmarkets.substack.com/p/above-the-fold-milestones-and-new) for more information.
|
||||
|
||||
### How accurate are the market probabilities?
|
||||
|
||||
|
@ -35,9 +37,9 @@ No. See [here](https://manifold.markets/hamnox/will-manifold-markets-add-nongoo
|
|||
|
||||
## Placing and winning bets
|
||||
|
||||
### The payout probabilities I'm shown sometimes aren't right. For example if a market is at 15% and I bet M$ 1 on "no", it tells me that I'll make a 42% profit if I win, but the listed payout is just M$ 1. What's going on?
|
||||
### The payout probabilities I'm shown sometimes aren't right. For example if a market is at 15% and I bet M$1 on "no", it tells me that I'll make a 42% profit if I win, but the listed payout is just M$1. What's going on?
|
||||
|
||||
Payout amounts are visually rounded to the nearest M$ 1, and only integer amounts can be put into markets. Behind the scenes however, your balance does track fractional amounts, so you're making a M$ 0.42 profit on that bet. Once you win another M$ 0.08, that fractional M$ 0.5 will display as an extra M$ 1 in your account. (There's no way to view your exact balance, you can only see the rounded value.)
|
||||
Payout amounts are visually rounded to the nearest M$1, and only integer amounts can be put into markets. Behind the scenes however, your balance does track fractional amounts, so you're making a M$0.42 profit on that bet. Once you win another M$0.08, that fractional M$0.5 will display as an extra M$1 in your account. (There's no way to view your exact balance, you can only see the rounded value.)
|
||||
|
||||
### What are the rules about insider trading? (Using private information about a market to make a profit.)
|
||||
|
||||
|
@ -45,7 +47,7 @@ It's not only allowed, but encouraged. The whole point of a prediction market is
|
|||
|
||||
### Can I see who is buying/selling in a market?
|
||||
|
||||
Trading is anonymous by default. You'll only see their username if they leave a comment. As an exception, trading from the market's creator has their name attached.
|
||||
All trades before June 1, 2022 are anonymous by default. Trades after that date can be viewed in the Bets tab of any market, and also on that user's profile.
|
||||
|
||||
## Creating and resolving markets
|
||||
|
||||
|
@ -63,12 +65,14 @@ A market being "closed" means that people can no longer place or sell bets, "loc
|
|||
|
||||
### What does "PROB" mean?
|
||||
|
||||
Resolving a market as "PROB" means that it's resolved at a certain probability, chosen by the market creator. PROB 100% is the same as "yes", and PROB 0% is the same as "no". For example, if a market is resolved at PROB 75%, anyone who bought "yes" at less than 75% will (usually) make a profit, and anyone who bought "yes" at greater than 75% will (usually) take a loss. Vice versa for "no".
|
||||
Resolving a market as "PROB" means that it's resolved at a certain probability, chosen by the market creator. PROB 100% is the same as "yes", and PROB 0% is the same as "no". For example, if a market is resolved at PROB 75%, anyone who bought "yes" at less than 75% will (usually) make a profit, and anyone who bought "yes" at greater than 75% will (usually) take a loss. Vice versa for "no". This is also shown as "MKT" in the interface and API.
|
||||
|
||||
### What happens if a market creator resolves a market incorrectly, or doesn't resolve it at all?
|
||||
|
||||
Nothing. The idea is for Manifold Markets to function with similar freedom and versatility to a Twitter poll, but with more accurate results due to the dynamics of prediction markets. Individual market resolution is not enforced by the site, so if you don't trust a certain user to judge their markets fairly, you probably shouldn't participate in their markets.
|
||||
|
||||
That being said, manifold staff may manually send reminder emails to the creators of large markets if they have not been resolved in some time. There are also some projects in the works to enable automated market resolution after some time has passed.
|
||||
|
||||
### How do I tell if a certain market creator is trustworthy?
|
||||
|
||||
Look at their market resolution history on their profile page. If their past markets have all been resolved correctly, their future ones probably will be too. You can also look at the comments on those markets to see if any traders noticed anything suspicious. You can also ask about that person in the [Manifold Markets Discord](https://discord.gg/eHQBNBqXuh). And if their profile links to their website or social media pages, you can take that into account too.
|
||||
|
@ -87,15 +91,15 @@ You'll get an automated email when they close. You can also go to your profile p
|
|||
|
||||
### When do market creators get their commission fees?
|
||||
|
||||
When the creator resolves their market, they get the commission from all the trades that were exectuted in the market.
|
||||
When the creator resolves their market, they get the commission from all the trades that were executed in the market.
|
||||
|
||||
### How do I see markets that are currently open?
|
||||
|
||||
You can see the top 99 markets in various categories [here](https://manifold.markets/markets).
|
||||
You can see the top markets in various categories [here](https://manifold.markets/markets).
|
||||
|
||||
### Can I bet in a market I created?
|
||||
|
||||
Yes. However if you're doing things that the community would perceive as "shady", such as put all your money on the correct resolution immediately before closing the market, people may be more reluctant to participate in your markets in the future. Betting "normally" in your own market is fine though.
|
||||
Yes. However if you're doing things that the community would perceive as "shady", such as putting all your money on the correct resolution immediately before closing the market, people may be more reluctant to participate in your markets in the future. Betting "normally" in your own market is fine though.
|
||||
|
||||
## Miscellaneous
|
||||
|
||||
|
@ -103,6 +107,8 @@ Yes. However if you're doing things that the community would perceive as "shady"
|
|||
|
||||
Contact them via [email](mailto:info@manifold.markets), post in their [Discord](https://discord.gg/eHQBNBqXuh), or create a market about that bug/feature in order to draw more attention to it and get community input.
|
||||
|
||||
If you don't mind putting in a little work, fork the code and open a [pull request](https://github.com/manifoldmarkets/manifold/pulls) on GitHub.
|
||||
|
||||
### How can I get notified of new developments?
|
||||
|
||||
Being a very recent project, Manifold is adding new features and tweaking existing ones quite frequently. You can keep up with changes by subscribing to their [Substack](https://manifoldmarkets.substack.com/), or joining their [Discord server](https://discord.gg/eHQBNBqXuh).
|
||||
|
@ -113,7 +119,7 @@ No, but the website is designed responsively and looks great on mobile.
|
|||
|
||||
### Does Manifold have an API for programmers?
|
||||
|
||||
Yep. Documentation is [here](https://www.notion.so/Manifold-Markets-API-5e7d0aef4dcf452bb04b319e178fabc5).
|
||||
Yep. Documentation is [here](https://docs.manifold.markets/api).
|
||||
|
||||
### If I have a question that isn't answered here, where can I ask it?
|
||||
|
||||
|
|
110
docs/docs/market-details.md
Normal file
110
docs/docs/market-details.md
Normal file
|
@ -0,0 +1,110 @@
|
|||
# Guide to Market Types
|
||||
|
||||
# Market Mechanisms
|
||||
|
||||
Historically, Manifold used a special type of automated market maker based on a dynamic pari-mutuel (DPM) betting
|
||||
system. Free response and numeric markets still use this system. Binary markets created prior to March 15, 2022 used
|
||||
this system, but all of those markets have since closed.
|
||||
|
||||
Binary markets created after March 15 use a constant-function market maker which holds constant the weighted geometric
|
||||
mean, with weights equal to the probabilities chosen by the market creator at creation. This design was inspired by
|
||||
Uniswap's CPMM and a suggestion from Manifold user Pepe. The benefit of this approach is that the payout for any bet
|
||||
is fixed at purchase time - 100 shares of YES will always return M$100 if YES is chosen.
|
||||
|
||||
Free response markets (and the depreciated numeric markets) still use the DPM system, as they have discrete "buckets"
|
||||
for the pool to be sorted into.
|
||||
|
||||
## Market Creation
|
||||
|
||||
- Users can create a market on any question they want.
|
||||
- When a user creates a market, they must choose a close date, after which trading will halt.
|
||||
- They must also pay a M$100 market creation fee, which is used as liquidity to subsidize trading on the market.
|
||||
- The creation fee for the first market created each day is provided by Manifold.
|
||||
- The market creator will earn a commission on all bets placed in the market.
|
||||
- The market creator is responsible for resolving each market in a timely manner. All fees earned as a commission will be paid out after resolution.
|
||||
- Creators can also resolve N/A to cancel all transactions and reverse all transactions made on the market - this includes profits from selling shares.
|
||||
|
||||
# Binary Markets
|
||||
|
||||
## Binary Markets: Overview
|
||||
|
||||
- Binary markets are structured around a question with a binary outcome, such as:
|
||||
- [Will Bitcoin be worth more than $60,000 on Jan 1, 2022 at 12 am ET?](https://manifold.markets/SG/will-bitcoin-be-worth-more-than-600)
|
||||
- [Will Manifold Markets have over $1M in revenue by Jan 1st, 2023?](https://manifold.markets/ManifoldMarkets/will-mantic-markets-have-over-1m)
|
||||
- [Will we discover life on Mars before 2024?](https://manifold.markets/LarsDoucet/will-we-discover-life-on-mars-befor)
|
||||
- Some binary markets are used as quasi-numeric markets, such as:
|
||||
- [How many additional subscribers will my newsletter have by the end of February?](https://manifold.markets/Nu%C3%B1oSempere/how-many-additional-subscribers-wil)
|
||||
- [How many new signups will Manifold have at the end of launch day?](https://manifold.markets/ManifoldMarkets/how-many-new-signups-will-manifold)
|
||||
- [What day will US Covid deaths peak in February?](https://manifold.markets/JamesGrugett/what-day-will-us-covid-deaths-peak)
|
||||
- These markets are made possible by the MKT option described below.
|
||||
|
||||
## Binary Markets: Betting & Payouts
|
||||
|
||||
- Traders can place a bet on either YES or NO and receive shares in the outcome in return.
|
||||
- Betting on YES will increase the market’s implied probability; betting on NO will decrease the probability.
|
||||
- Manifold's automated market automatically adjusts the market probability after each trade and determines how many shares a user will get for their bet.
|
||||
- You can sell back your shares for cash. If you sell YES shares, the market probability will go down. If you sell NO shares, the probability will go up.
|
||||
- 1 YES share = M$1 if the event happens. 1 NO share = M$1 if the event does not happen.
|
||||
- Notice that 1 YES share + 1 NO share = M$1. If you ever get multiple YES and NO shares, they will cancel out and you will be left with cash.
|
||||
- When the market is resolved, you will be paid out according to your shares. If you own 100 YES shares, if the event resolves YES, you will earn M$100. (If the event resolves NO, you will earn M$0).
|
||||
- The creator of each market is responsible for resolving each market. They can resolve to YES, NO, MKT, or N/A.
|
||||
- Resolving to MKT allows the creator to choose a percentage. The payout for any YES share is multiplied by this percentage, and vice versa for NO.
|
||||
- For example, if a market resolves to MKT at 30%, if you have 100 shares of YES you will receive `M$100 * 30% = M$30`.
|
||||
- In the same situation as above, if you have 100 shares of NO you will receive `M$100 * (100% - 30%) = M$70`.
|
||||
- Note that even in this instance, 1 YES share plus 1 NO share still equals M$1.
|
||||
|
||||
## Binary Markets: Liquidity
|
||||
|
||||
- The liquidity in a market is the amount of capital available for traders to trade against. The more liquidity, the greater incentive there is for traders to bet, and the more accurate the market should be.
|
||||
- When a market is created, the creation fee (also called the ante or subsidy) is used to fill the liquiity pool. This happens whether the creation fee is paid by the user or by Manifold for the daily free market.
|
||||
- Behind the scenes, when a bet is placed the CPMM mechanism does [a bunch of math](http://bit.ly/maniswap). The end result is that for each M$1 bet, 1 YES share and 1 NO share is created. Some amount of shares are then given to the user who made the bet, and the rest are stored in the liquidity pool.
|
||||
Due to this mechansim, the number of YES shares in the whole market always equals the number of NO shares.
|
||||
- You can manually add liquidity to any market to increase the incentives for traders to participate. You can think of added liquidity as a subsidy for getting your question answered. You can do this by opening up the market info popup window located in the (...) section of the header on the market page.
|
||||
- Adding liquidity provides you with a number of YES and NO shares, which can be withdrawn from the same interface. These shares resolve to M$ like normal when the market resolves, which will return you some amount of your investment.
|
||||
- If the market moves significantly in either direction, your liquidity will become significantly less valuable. You are currently very unlikely to make money by investing liquidity in a market, it is a way to subsidize a market and encourage more people to bet, to achieve a more accurate answer.
|
||||
- Adding liquidity to a market also makes it require more capital to move the market, so if you want to subsidize a market, first make sure the market price is roughly where you think it should be.
|
||||
|
||||
# Free-Response Markets
|
||||
|
||||
## Free-Response Markets: Overview
|
||||
|
||||
- Free-response markets are structured around a question with a multiple outcomes, such as:
|
||||
- [Which team will win the NBA Finals 2022?](https://manifold.markets/howtodowtle/which-team-will-win-the-nba-finals)
|
||||
- [Who will win "Top Streaming Songs Artist" at the 2022 Billboard Music Awards?](https://manifold.markets/Predictor/who-will-win-top-streaming-songs-ar)
|
||||
- [What life improvement intervention suggested would I found most useful?](https://manifold.markets/vlad/what-life-improvement-intervention)
|
||||
- Some free-response markets are used as quasi-numeric markets, such as:
|
||||
- [What day will Russia invade Ukraine?](https://manifold.markets/Duncan/what-day-will-russia-invade-ukraine)
|
||||
- [What will inflation be in March?](https://manifold.markets/ManifoldMarkets/what-will-inflation-be-in-march)
|
||||
- [How many Manifold team members in the Bahamas will test positive for COVID?](https://manifold.markets/Sinclair/how-many-manifold-team-members-in-t)
|
||||
|
||||
## Free-Response Markets: Betting & Payouts
|
||||
|
||||
- Markets are structured around a list of answers, any of which can be bet on.
|
||||
- When a Free Response market is created, the market creation fee goes into a hidden answer called the Ante and gets paid to the winner(s), to subsidize the market and create an incentive to bet. This happens whether the creation fee is paid by the user or by Manifold for the daily free market.
|
||||
- This hidden answer is why a market's probabilities will not add up to 100%.
|
||||
- If you want to further subsidize a market, it's customary to create an ANTE answer and put money in that.
|
||||
- Anyone can add answers to a market as long as they stake some amount of M$ on it. Traders can place a bet on any answer and receive shares in the outcome in return.
|
||||
- When a user places a bet, their M$ goes into the market's pool and they receive a certain amount of shares of the selected answer.
|
||||
- When the market is resolved, you will be paid out according to your shares. If the creator resolves to answer #1, the entire pool is divided up amongst the users who bet on answer #1 proportional to their shares.
|
||||
- The creator of each market is responsible for resolving each market. They can resolve to any single answer, or even multiple answers.
|
||||
- Resolving to multiple answers allows the creator to choose a percentage for each selected answer (or distribute equally). The payout for any answer is taken from the amount of the total pool allocated to that answer.
|
||||
- For example, let's take a free-response market with many answers. The pool for this market is $500, and you own 100 out of 500 total shares of answer #1.
|
||||
- If the creator resolves to answer #1 only, you will receive `M$500 * (100 / 500) = M$100`.
|
||||
- If the creator resolves 50% to answer #1 and 50% to answer #2, you will receive `(M$500 * 50%) * (100 / 500) = M$50`.
|
||||
- Note that your payout is dependent on the total number of shares, and thus may decrease if more people buy shares in that answer.
|
||||
|
||||
# Fees
|
||||
|
||||
- Manifold charges fees on each trade. They are automatically calculated and baked into the number of shares you receive when you place a bet.
|
||||
- Our CPMM fee schedule is currently: `10% * (1 - post-bet probability) * bet amount`
|
||||
- Note that all current binary markets use this fee schedule.
|
||||
- The post-bet probability is what the market probability would be after your bet if there were no fees.
|
||||
- Example:
|
||||
- If you bet M$100 on NO and the resulting probability without fees would be 10%, then you pay `M$100 * 10% * 10% = M$1.0`.
|
||||
- If you bet M$100 on YES and the resulting probability without fees would be 50%, then you pay `M$100 * 10% * 50% = M$5.0`.
|
||||
- 100% of this fee is used to provide a commission to the market creator, which is paid out after the market is resolved.
|
||||
- Our DPM fee schedule is currently: `5% * (1 - post-bet probability) * bet amount`
|
||||
- Note that all free-response markets use this fee schedule. The calculation for this is the same as above.
|
||||
- 4% is used to provide a commission to the market creator, which is paid out after the market is resolved. 1% is "burnt" to prevent inflation.
|
||||
- No fees are levied on sales. If you have existing shares in a binary market and buy shares on the opposite side, that is equivalent to selling your shares and you do not pay fees.
|
||||
|
|
@ -27,7 +27,7 @@ const config = {
|
|||
routeBasePath: '/',
|
||||
sidebarPath: require.resolve('./sidebars.js'),
|
||||
// Please change this to your repo.
|
||||
editUrl: 'https://github.com/manifoldmarkets/docs/tree/main/',
|
||||
editUrl: 'https://github.com/manifoldmarkets/manifold/tree/main/docs/docs',
|
||||
remarkPlugins: [math],
|
||||
rehypePlugins: [katex],
|
||||
},
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
{
|
||||
"functions": {
|
||||
"predeploy": "npm --prefix \"$RESOURCE_DIR\" run build",
|
||||
"runtime": "nodejs12",
|
||||
"source": "functions"
|
||||
"predeploy": "cd functions && yarn build",
|
||||
"runtime": "nodejs16",
|
||||
"source": "functions/dist"
|
||||
},
|
||||
"firestore": {
|
||||
"rules": "firestore.rules",
|
||||
|
|
|
@ -1,5 +1,69 @@
|
|||
{
|
||||
"indexes": [
|
||||
{
|
||||
"collectionGroup": "bets",
|
||||
"queryScope": "COLLECTION_GROUP",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "isAnte",
|
||||
"order": "ASCENDING"
|
||||
},
|
||||
{
|
||||
"fieldPath": "isRedemption",
|
||||
"order": "ASCENDING"
|
||||
},
|
||||
{
|
||||
"fieldPath": "userId",
|
||||
"order": "ASCENDING"
|
||||
},
|
||||
{
|
||||
"fieldPath": "createdTime",
|
||||
"order": "ASCENDING"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "bets",
|
||||
"queryScope": "COLLECTION_GROUP",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "userId",
|
||||
"order": "ASCENDING"
|
||||
},
|
||||
{
|
||||
"fieldPath": "createdTime",
|
||||
"order": "DESCENDING"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "comments",
|
||||
"queryScope": "COLLECTION_GROUP",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "userId",
|
||||
"order": "ASCENDING"
|
||||
},
|
||||
{
|
||||
"fieldPath": "createdTime",
|
||||
"order": "DESCENDING"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "contracts",
|
||||
"queryScope": "COLLECTION",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "creatorId",
|
||||
"order": "ASCENDING"
|
||||
},
|
||||
{
|
||||
"fieldPath": "createdTime",
|
||||
"order": "ASCENDING"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "contracts",
|
||||
"queryScope": "COLLECTION",
|
||||
|
@ -14,6 +78,20 @@
|
|||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "contracts",
|
||||
"queryScope": "COLLECTION",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "isResolved",
|
||||
"order": "ASCENDING"
|
||||
},
|
||||
{
|
||||
"fieldPath": "autoResolutionTime",
|
||||
"order": "ASCENDING"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "contracts",
|
||||
"queryScope": "COLLECTION",
|
||||
|
@ -104,6 +182,24 @@
|
|||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "contracts",
|
||||
"queryScope": "COLLECTION",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "isResolved",
|
||||
"order": "ASCENDING"
|
||||
},
|
||||
{
|
||||
"fieldPath": "visibility",
|
||||
"order": "ASCENDING"
|
||||
},
|
||||
{
|
||||
"fieldPath": "volume7Days",
|
||||
"order": "ASCENDING"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "contracts",
|
||||
"queryScope": "COLLECTION",
|
||||
|
@ -118,6 +214,84 @@
|
|||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "contracts",
|
||||
"queryScope": "COLLECTION",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "isResolved",
|
||||
"order": "ASCENDING"
|
||||
},
|
||||
{
|
||||
"fieldPath": "volume7Days",
|
||||
"order": "ASCENDING"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "contracts",
|
||||
"queryScope": "COLLECTION",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "isResolved",
|
||||
"order": "ASCENDING"
|
||||
},
|
||||
{
|
||||
"fieldPath": "volume7Days",
|
||||
"order": "ASCENDING"
|
||||
},
|
||||
{
|
||||
"fieldPath": "closeTime",
|
||||
"order": "ASCENDING"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "contracts",
|
||||
"queryScope": "COLLECTION",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "isResolved",
|
||||
"order": "ASCENDING"
|
||||
},
|
||||
{
|
||||
"fieldPath": "volume7Days",
|
||||
"order": "ASCENDING"
|
||||
},
|
||||
{
|
||||
"fieldPath": "createdTime",
|
||||
"order": "ASCENDING"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "contracts",
|
||||
"queryScope": "COLLECTION",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "isResolved",
|
||||
"order": "ASCENDING"
|
||||
},
|
||||
{
|
||||
"fieldPath": "volume7Days",
|
||||
"order": "DESCENDING"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "contracts",
|
||||
"queryScope": "COLLECTION",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "lowercaseTags",
|
||||
"arrayConfig": "CONTAINS"
|
||||
},
|
||||
{
|
||||
"fieldPath": "createdTime",
|
||||
"order": "DESCENDING"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "contracts",
|
||||
"queryScope": "COLLECTION",
|
||||
|
@ -131,6 +305,38 @@
|
|||
"order": "DESCENDING"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "txns",
|
||||
"queryScope": "COLLECTION",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "toId",
|
||||
"order": "ASCENDING"
|
||||
},
|
||||
{
|
||||
"fieldPath": "toType",
|
||||
"order": "ASCENDING"
|
||||
},
|
||||
{
|
||||
"fieldPath": "createdTime",
|
||||
"order": "DESCENDING"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "manalinks",
|
||||
"queryScope": "COLLECTION",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "fromId",
|
||||
"order": "ASCENDING"
|
||||
},
|
||||
{
|
||||
"fieldPath": "createdTime",
|
||||
"order": "DESCENDING"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"fieldOverrides": [
|
||||
|
@ -295,6 +501,50 @@
|
|||
"queryScope": "COLLECTION_GROUP"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "follows",
|
||||
"fieldPath": "userId",
|
||||
"indexes": [
|
||||
{
|
||||
"order": "ASCENDING",
|
||||
"queryScope": "COLLECTION"
|
||||
},
|
||||
{
|
||||
"order": "DESCENDING",
|
||||
"queryScope": "COLLECTION"
|
||||
},
|
||||
{
|
||||
"arrayConfig": "CONTAINS",
|
||||
"queryScope": "COLLECTION"
|
||||
},
|
||||
{
|
||||
"order": "ASCENDING",
|
||||
"queryScope": "COLLECTION_GROUP"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "portfolioHistory",
|
||||
"fieldPath": "timestamp",
|
||||
"indexes": [
|
||||
{
|
||||
"order": "ASCENDING",
|
||||
"queryScope": "COLLECTION"
|
||||
},
|
||||
{
|
||||
"order": "DESCENDING",
|
||||
"queryScope": "COLLECTION"
|
||||
},
|
||||
{
|
||||
"arrayConfig": "CONTAINS",
|
||||
"queryScope": "COLLECTION"
|
||||
},
|
||||
{
|
||||
"order": "ASCENDING",
|
||||
"queryScope": "COLLECTION_GROUP"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
@ -19,9 +19,8 @@ service cloud.firestore {
|
|||
.hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories']);
|
||||
}
|
||||
|
||||
match /users/{userId}/follows/{followUserId} {
|
||||
match /{somePath=**}/portfolioHistory/{portfolioHistoryId} {
|
||||
allow read;
|
||||
allow write: if request.auth.uid == userId;
|
||||
}
|
||||
|
||||
match /users/{userId}/follows/{followUserId} {
|
||||
|
@ -29,6 +28,10 @@ service cloud.firestore {
|
|||
allow write: if request.auth.uid == userId;
|
||||
}
|
||||
|
||||
match /{somePath=**}/follows/{followUserId} {
|
||||
allow read;
|
||||
}
|
||||
|
||||
match /private-users/{userId} {
|
||||
allow read: if resource.data.id == request.auth.uid || isAdmin();
|
||||
allow update: if (resource.data.id == request.auth.uid || isAdmin())
|
||||
|
@ -60,12 +63,19 @@ service cloud.firestore {
|
|||
.hasOnly(['description', 'closeTime'])
|
||||
&& resource.data.creatorId == request.auth.uid;
|
||||
allow update: if isAdmin();
|
||||
match /comments/{commentId} {
|
||||
allow create: if request.auth != null && commentMatchesUser(request.auth.uid, request.resource.data);
|
||||
}
|
||||
}
|
||||
|
||||
match /{somePath=**}/bets/{betId} {
|
||||
allow read;
|
||||
}
|
||||
|
||||
match /{somePath=**}/liquidity/{liquidityId} {
|
||||
allow read;
|
||||
}
|
||||
|
||||
function commentMatchesUser(userId, comment) {
|
||||
// it's a bad look if someone can impersonate other ids/names/avatars so check everything
|
||||
let user = get(/databases/$(database)/documents/users/$(userId));
|
||||
|
@ -77,20 +87,12 @@ service cloud.firestore {
|
|||
|
||||
match /{somePath=**}/comments/{commentId} {
|
||||
allow read;
|
||||
allow create: if request.auth != null && commentMatchesUser(request.auth.uid, request.resource.data);
|
||||
}
|
||||
|
||||
match /{somePath=**}/answers/{answerId} {
|
||||
allow read;
|
||||
}
|
||||
|
||||
match /folds/{foldId} {
|
||||
allow read;
|
||||
allow update: if request.auth.uid == resource.data.curatorId
|
||||
&& request.resource.data.diff(resource.data).affectedKeys()
|
||||
.hasOnly(['name', 'about', 'tags', 'lowercaseTags']);
|
||||
allow delete: if request.auth.uid == resource.data.curatorId;
|
||||
}
|
||||
|
||||
match /{somePath=**}/followers/{userId} {
|
||||
allow read;
|
||||
|
@ -102,11 +104,42 @@ service cloud.firestore {
|
|||
allow read;
|
||||
}
|
||||
|
||||
// Note: `resource` = existing doc, `request.resource` = incoming doc
|
||||
match /manalinks/{slug} {
|
||||
// Anyone can view any manalink
|
||||
allow get;
|
||||
// Only you can create a manalink with your fromId
|
||||
allow create: if request.auth.uid == request.resource.data.fromId;
|
||||
// Only you can list and change your own manalinks
|
||||
allow list, update: if request.auth.uid == resource.data.fromId;
|
||||
}
|
||||
|
||||
match /users/{userId}/notifications/{notificationId} {
|
||||
allow read;
|
||||
allow update: if resource.data.userId == request.auth.uid
|
||||
&& request.resource.data.diff(resource.data).affectedKeys()
|
||||
.hasOnly(['isSeen', 'viewTime']);
|
||||
}
|
||||
|
||||
match /groups/{groupId} {
|
||||
allow read;
|
||||
allow update: if request.auth.uid == resource.data.creatorId
|
||||
&& request.resource.data.diff(resource.data)
|
||||
.affectedKeys()
|
||||
.hasOnly(['name', 'about', 'contractIds', 'memberIds', 'anyoneCanJoin' ]);
|
||||
allow update: if (request.auth.uid in resource.data.memberIds || resource.data.anyoneCanJoin)
|
||||
&& request.resource.data.diff(resource.data)
|
||||
.affectedKeys()
|
||||
.hasOnly([ 'contractIds', 'memberIds' ]);
|
||||
allow delete: if request.auth.uid == resource.data.creatorId;
|
||||
|
||||
function isMember() {
|
||||
return request.auth.uid in get(/databases/$(database)/documents/groups/$(groupId)).data.memberIds;
|
||||
}
|
||||
match /comments/{commentId} {
|
||||
allow read;
|
||||
allow create: if request.auth != null && commentMatchesUser(request.auth.uid, request.resource.data) && isMember();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -19,7 +19,7 @@ module.exports = {
|
|||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-extra-semi': 'off',
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
'warn',
|
||||
{
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
|
|
6
functions/.gitignore
vendored
6
functions/.gitignore
vendored
|
@ -2,9 +2,11 @@
|
|||
.env*
|
||||
.runtimeconfig.json
|
||||
|
||||
# GCP deployment artifact
|
||||
dist/
|
||||
|
||||
# Compiled JavaScript files
|
||||
lib/**/*.js
|
||||
lib/**/*.js.map
|
||||
lib/
|
||||
|
||||
# TypeScript v1 declaration files
|
||||
typings/
|
||||
|
|
|
@ -52,7 +52,7 @@ Adapted from https://firebase.google.com/docs/functions/get-started
|
|||
## Deploying
|
||||
|
||||
0. `$ firebase use prod` to switch to prod
|
||||
1. `$ yarn deploy` to push your changes live!
|
||||
1. `$ firebase deploy --only functions` to push your changes live!
|
||||
(Future TODO: auto-deploy functions on Git push)
|
||||
|
||||
## Secrets management
|
||||
|
|
|
@ -5,7 +5,8 @@
|
|||
"firestore": "dev-mantic-markets.appspot.com"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"build": "yarn compile && rm -rf dist && mkdir -p dist/functions && cp -R ../common/lib dist/common && cp -R lib/src dist/functions/src && cp ../yarn.lock dist && cp package.json dist",
|
||||
"compile": "tsc -b",
|
||||
"watch": "tsc -w",
|
||||
"shell": "yarn build && firebase functions:shell",
|
||||
"start": "yarn shell",
|
||||
|
@ -18,17 +19,15 @@
|
|||
"db:backup-remote": "yarn db:rename-remote-backup-folder && gcloud firestore export gs://$npm_package_config_firestore/firestore_export/",
|
||||
"verify": "(cd .. && yarn verify)"
|
||||
},
|
||||
"main": "lib/functions/src/index.js",
|
||||
"main": "functions/src/index.js",
|
||||
"dependencies": {
|
||||
"@react-query-firebase/firestore": "0.4.2",
|
||||
"cors": "2.8.5",
|
||||
"@amplitude/node": "1.10.0",
|
||||
"fetch": "1.1.0",
|
||||
"firebase-admin": "10.0.0",
|
||||
"firebase-functions": "3.21.2",
|
||||
"lodash": "4.17.21",
|
||||
"mailgun-js": "0.22.0",
|
||||
"module-alias": "2.2.2",
|
||||
"react-query": "3.39.0",
|
||||
"stripe": "8.194.0",
|
||||
"zod": "3.17.2"
|
||||
},
|
||||
|
|
24
functions/src/analytics.ts
Normal file
24
functions/src/analytics.ts
Normal file
|
@ -0,0 +1,24 @@
|
|||
import * as Amplitude from '@amplitude/node'
|
||||
|
||||
import { DEV_CONFIG } from '../../common/envs/dev'
|
||||
import { PROD_CONFIG } from '../../common/envs/prod'
|
||||
|
||||
import { isProd } from './utils'
|
||||
|
||||
const key = isProd() ? PROD_CONFIG.amplitudeApiKey : DEV_CONFIG.amplitudeApiKey
|
||||
|
||||
const amp = Amplitude.init(key ?? '')
|
||||
|
||||
export const track = async (
|
||||
userId: string,
|
||||
eventName: string,
|
||||
eventProperties?: any,
|
||||
amplitudeProperties?: Partial<Amplitude.Event>
|
||||
) => {
|
||||
await amp.logEvent({
|
||||
event_type: eventName,
|
||||
user_id: userId,
|
||||
event_properties: eventProperties,
|
||||
...amplitudeProperties,
|
||||
})
|
||||
}
|
|
@ -1,19 +1,20 @@
|
|||
import * as admin from 'firebase-admin'
|
||||
import { Response } from 'express'
|
||||
import { logger } from 'firebase-functions/v2'
|
||||
import { onRequest, Request } from 'firebase-functions/v2/https'
|
||||
|
||||
import * as Cors from 'cors'
|
||||
import { HttpsOptions, onRequest, Request } from 'firebase-functions/v2/https'
|
||||
import { log } from './utils'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { User, PrivateUser } from '../../common/user'
|
||||
import { PrivateUser } from '../../common/user'
|
||||
import {
|
||||
CORS_ORIGIN_MANIFOLD,
|
||||
CORS_ORIGIN_LOCALHOST,
|
||||
} from '../../common/envs/constants'
|
||||
|
||||
type Output = Record<string, unknown>
|
||||
type AuthedUser = [User, PrivateUser]
|
||||
type AuthedUser = {
|
||||
uid: string
|
||||
creds: JwtCredentials | (KeyCredentials & { privateUser: PrivateUser })
|
||||
}
|
||||
type Handler = (req: Request, user: AuthedUser) => Promise<Output>
|
||||
type JwtCredentials = { kind: 'jwt'; data: admin.auth.DecodedIdToken }
|
||||
type KeyCredentials = { kind: 'key'; data: string }
|
||||
|
@ -30,6 +31,12 @@ export class APIError {
|
|||
}
|
||||
}
|
||||
|
||||
const auth = admin.auth()
|
||||
const firestore = admin.firestore()
|
||||
const privateUsers = firestore.collection(
|
||||
'private-users'
|
||||
) as admin.firestore.CollectionReference<PrivateUser>
|
||||
|
||||
export const parseCredentials = async (req: Request): Promise<Credentials> => {
|
||||
const authHeader = req.get('Authorization')
|
||||
if (!authHeader) {
|
||||
|
@ -44,8 +51,7 @@ export const parseCredentials = async (req: Request): Promise<Credentials> => {
|
|||
switch (scheme) {
|
||||
case 'Bearer':
|
||||
try {
|
||||
const jwt = await admin.auth().verifyIdToken(payload)
|
||||
return { kind: 'jwt', data: jwt }
|
||||
return { kind: 'jwt', data: await auth.verifyIdToken(payload) }
|
||||
} catch (err) {
|
||||
// This is somewhat suspicious, so get it into the firebase console
|
||||
logger.error('Error verifying Firebase JWT: ', err)
|
||||
|
@ -59,25 +65,12 @@ export const parseCredentials = async (req: Request): Promise<Credentials> => {
|
|||
}
|
||||
|
||||
export const lookupUser = async (creds: Credentials): Promise<AuthedUser> => {
|
||||
const firestore = admin.firestore()
|
||||
const users = firestore.collection('users')
|
||||
const privateUsers = firestore.collection('private-users')
|
||||
switch (creds.kind) {
|
||||
case 'jwt': {
|
||||
const { user_id } = creds.data
|
||||
if (typeof user_id !== 'string') {
|
||||
if (typeof creds.data.user_id !== 'string') {
|
||||
throw new APIError(403, 'JWT must contain Manifold user ID.')
|
||||
}
|
||||
const [userSnap, privateUserSnap] = await Promise.all([
|
||||
users.doc(user_id).get(),
|
||||
privateUsers.doc(user_id).get(),
|
||||
])
|
||||
if (!userSnap.exists || !privateUserSnap.exists) {
|
||||
throw new APIError(403, 'No user exists with the provided ID.')
|
||||
}
|
||||
const user = userSnap.data() as User
|
||||
const privateUser = privateUserSnap.data() as PrivateUser
|
||||
return [user, privateUser]
|
||||
return { uid: creds.data.user_id, creds }
|
||||
}
|
||||
case 'key': {
|
||||
const key = creds.data
|
||||
|
@ -85,35 +78,14 @@ export const lookupUser = async (creds: Credentials): Promise<AuthedUser> => {
|
|||
if (privateUserQ.empty) {
|
||||
throw new APIError(403, `No private user exists with API key ${key}.`)
|
||||
}
|
||||
const privateUserSnap = privateUserQ.docs[0]
|
||||
const userSnap = await users.doc(privateUserSnap.id).get()
|
||||
if (!userSnap.exists) {
|
||||
throw new APIError(403, `No user exists with ID ${privateUserSnap.id}.`)
|
||||
}
|
||||
const user = userSnap.data() as User
|
||||
const privateUser = privateUserSnap.data() as PrivateUser
|
||||
return [user, privateUser]
|
||||
const privateUser = privateUserQ.docs[0].data()
|
||||
return { uid: privateUser.id, creds: { privateUser, ...creds } }
|
||||
}
|
||||
default:
|
||||
throw new APIError(500, 'Invalid credential type.')
|
||||
}
|
||||
}
|
||||
|
||||
export const applyCors = (
|
||||
req: Request,
|
||||
res: Response,
|
||||
params: Cors.CorsOptions
|
||||
) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
Cors(params)(req, res, (result) => {
|
||||
if (result instanceof Error) {
|
||||
return reject(result)
|
||||
}
|
||||
return resolve(result)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export const zTimestamp = () => {
|
||||
return z.preprocess((arg) => {
|
||||
return typeof arg == 'number' ? new Date(arg) : undefined
|
||||
|
@ -124,6 +96,7 @@ export const validate = <T extends z.ZodTypeAny>(schema: T, val: unknown) => {
|
|||
const result = schema.safeParse(val)
|
||||
if (!result.success) {
|
||||
const issues = result.error.issues.map((i) => {
|
||||
// TODO: export this type for the front-end to parse
|
||||
return {
|
||||
field: i.path.join('.') || null,
|
||||
error: i.message,
|
||||
|
@ -135,18 +108,24 @@ export const validate = <T extends z.ZodTypeAny>(schema: T, val: unknown) => {
|
|||
}
|
||||
}
|
||||
|
||||
const DEFAULT_OPTS: HttpsOptions = {
|
||||
minInstances: 1,
|
||||
concurrency: 100,
|
||||
memory: '2GiB',
|
||||
cpu: 1,
|
||||
cors: [CORS_ORIGIN_MANIFOLD, CORS_ORIGIN_LOCALHOST],
|
||||
}
|
||||
|
||||
export const newEndpoint = (methods: [string], fn: Handler) =>
|
||||
onRequest({ minInstances: 1 }, async (req, res) => {
|
||||
onRequest(DEFAULT_OPTS, async (req, res) => {
|
||||
log('Request processing started.')
|
||||
try {
|
||||
await applyCors(req, res, {
|
||||
origin: [CORS_ORIGIN_MANIFOLD, CORS_ORIGIN_LOCALHOST],
|
||||
methods: methods,
|
||||
})
|
||||
if (!methods.includes(req.method)) {
|
||||
const allowed = methods.join(', ')
|
||||
throw new APIError(405, `This endpoint supports only ${allowed}.`)
|
||||
}
|
||||
const authedUser = await lookupUser(await parseCredentials(req))
|
||||
log('User credentials processed.')
|
||||
res.status(200).json(await fn(req, authedUser))
|
||||
} catch (e) {
|
||||
if (e instanceof APIError) {
|
||||
|
|
|
@ -41,7 +41,7 @@ export const backupDb = functions.pubsub
|
|||
// NOTE: Subcollections are not backed up by default
|
||||
collectionIds: [
|
||||
'contracts',
|
||||
'folds',
|
||||
'groups',
|
||||
'private-users',
|
||||
'stripe-transactions',
|
||||
'users',
|
||||
|
|
|
@ -5,7 +5,10 @@ import { getUser } from './utils'
|
|||
import { Contract } from '../../common/contract'
|
||||
import { Comment } from '../../common/comment'
|
||||
import { User } from '../../common/user'
|
||||
import { cleanUsername } from '../../common/util/clean-username'
|
||||
import {
|
||||
cleanUsername,
|
||||
cleanDisplayName,
|
||||
} from '../../common/util/clean-username'
|
||||
import { removeUndefinedProps } from '../../common/util/object'
|
||||
import { Answer } from '../../common/answer'
|
||||
|
||||
|
@ -63,6 +66,10 @@ export const changeUser = async (
|
|||
}
|
||||
}
|
||||
|
||||
if (update.name) {
|
||||
update.name = cleanDisplayName(update.name)
|
||||
}
|
||||
|
||||
const userRef = firestore.collection('users').doc(user.id)
|
||||
const userUpdate: Partial<User> = removeUndefinedProps(update)
|
||||
|
||||
|
|
102
functions/src/claim-manalink.ts
Normal file
102
functions/src/claim-manalink.ts
Normal file
|
@ -0,0 +1,102 @@
|
|||
import * as functions from 'firebase-functions'
|
||||
import * as admin from 'firebase-admin'
|
||||
|
||||
import { User } from 'common/user'
|
||||
import { Manalink } from 'common/manalink'
|
||||
import { runTxn, TxnData } from './transact'
|
||||
|
||||
export const claimManalink = functions
|
||||
.runWith({ minInstances: 1 })
|
||||
.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.
|
||||
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) {
|
||||
return { status: 'error', message: 'Manalink not found' }
|
||||
}
|
||||
const manalink = manalinkSnap.data() as Manalink
|
||||
|
||||
const { amount, fromId, claimedUserIds } = manalink
|
||||
|
||||
if (amount <= 0 || isNaN(amount) || !isFinite(amount))
|
||||
return { status: 'error', message: 'Invalid amount' }
|
||||
|
||||
const fromDoc = firestore.doc(`users/${fromId}`)
|
||||
const fromSnap = await transaction.get(fromDoc)
|
||||
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 (claimedUserIds.includes(userId)) {
|
||||
return {
|
||||
status: 'error',
|
||||
message: `${fromUser.name} already redeemed manalink ${slug}`,
|
||||
}
|
||||
}
|
||||
|
||||
// Disallow expired or maxed out links
|
||||
if (manalink.expiresTime != null && manalink.expiresTime < Date.now()) {
|
||||
return {
|
||||
status: 'error',
|
||||
message: `Manalink ${slug} expired on ${new Date(
|
||||
manalink.expiresTime
|
||||
).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) {
|
||||
return {
|
||||
status: 'error',
|
||||
message: `Insufficient balance: ${fromUser.name} needed ${amount} for this manalink but only had ${fromUser.balance} `,
|
||||
}
|
||||
}
|
||||
|
||||
// Actually execute the txn
|
||||
const data: TxnData = {
|
||||
fromId,
|
||||
fromType: 'USER',
|
||||
toId: userId,
|
||||
toType: 'USER',
|
||||
amount,
|
||||
token: 'M$',
|
||||
category: 'MANALINK',
|
||||
description: `Manalink ${slug} claimed: ${amount} from ${fromUser.username} to ${userId}`,
|
||||
}
|
||||
const result = await runTxn(transaction, data)
|
||||
const txnId = result.txn?.id
|
||||
if (!txnId) {
|
||||
return { status: 'error', message: result.message }
|
||||
}
|
||||
|
||||
// Update the manalink object with this info
|
||||
const claim = {
|
||||
toId: userId,
|
||||
txnId,
|
||||
claimedTime: Date.now(),
|
||||
}
|
||||
transaction.update(manalinkDoc, {
|
||||
claimedUserIds: [...claimedUserIds, userId],
|
||||
claims: [...manalink.claims, claim],
|
||||
})
|
||||
|
||||
return { status: 'success', message: 'Manalink claimed' }
|
||||
})
|
||||
})
|
||||
|
||||
const firestore = admin.firestore()
|
|
@ -22,11 +22,12 @@ import {
|
|||
getCpmmInitialLiquidity,
|
||||
getFreeAnswerAnte,
|
||||
getNumericAnte,
|
||||
HOUSE_LIQUIDITY_PROVIDER_ID,
|
||||
} from '../../common/antes'
|
||||
import { getNoneAnswer } from '../../common/answer'
|
||||
import { getNewContract } from '../../common/new-contract'
|
||||
import { NUMERIC_BUCKET_COUNT } from '../../common/numeric-constants'
|
||||
import { User } from '../../common/user'
|
||||
import { Group, MAX_ID_LENGTH } from '../../common/group'
|
||||
|
||||
const bodySchema = z.object({
|
||||
question: z.string().min(1).max(MAX_QUESTION_LENGTH),
|
||||
|
@ -37,6 +38,7 @@ const bodySchema = z.object({
|
|||
'Close time must be in the future.'
|
||||
),
|
||||
outcomeType: z.enum(OUTCOME_TYPES),
|
||||
groupId: z.string().min(1).max(MAX_ID_LENGTH).optional(),
|
||||
})
|
||||
|
||||
const binarySchema = z.object({
|
||||
|
@ -48,11 +50,9 @@ const numericSchema = z.object({
|
|||
max: z.number(),
|
||||
})
|
||||
|
||||
export const createmarket = newEndpoint(['POST'], async (req, [user, _]) => {
|
||||
const { question, description, tags, closeTime, outcomeType } = validate(
|
||||
bodySchema,
|
||||
req.body
|
||||
)
|
||||
export const createmarket = newEndpoint(['POST'], async (req, auth) => {
|
||||
const { question, description, tags, closeTime, outcomeType, groupId } =
|
||||
validate(bodySchema, req.body)
|
||||
|
||||
let min, max, initialProb
|
||||
if (outcomeType === 'NUMERIC') {
|
||||
|
@ -63,23 +63,42 @@ export const createmarket = newEndpoint(['POST'], async (req, [user, _]) => {
|
|||
;({ initialProb } = validate(binarySchema, req.body))
|
||||
}
|
||||
|
||||
// Uses utc time on server:
|
||||
const today = new Date()
|
||||
let freeMarketResetTime = new Date().setUTCHours(16, 0, 0, 0)
|
||||
if (today.getTime() < freeMarketResetTime) {
|
||||
freeMarketResetTime = freeMarketResetTime - 24 * 60 * 60 * 1000
|
||||
const userDoc = await firestore.collection('users').doc(auth.uid).get()
|
||||
if (!userDoc.exists) {
|
||||
throw new APIError(400, 'No user exists with the authenticated user ID.')
|
||||
}
|
||||
|
||||
const userContractsCreatedTodaySnapshot = await firestore
|
||||
.collection(`contracts`)
|
||||
.where('creatorId', '==', user.id)
|
||||
.where('createdTime', '>=', freeMarketResetTime)
|
||||
.get()
|
||||
console.log('free market reset time: ', freeMarketResetTime)
|
||||
const isFree = userContractsCreatedTodaySnapshot.size === 0
|
||||
const user = userDoc.data() as User
|
||||
|
||||
const ante = FIXED_ANTE
|
||||
|
||||
// TODO: this is broken because it's not in a transaction
|
||||
if (ante > user.balance)
|
||||
throw new APIError(400, `Balance must be at least ${ante}.`)
|
||||
|
||||
const slug = await getSlug(question)
|
||||
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(
|
||||
'creating contract for',
|
||||
user.username,
|
||||
|
@ -89,8 +108,6 @@ export const createmarket = newEndpoint(['POST'], async (req, [user, _]) => {
|
|||
ante || 0
|
||||
)
|
||||
|
||||
const slug = await getSlug(question)
|
||||
const contractRef = firestore.collection('contracts').doc()
|
||||
const contract = getNewContract(
|
||||
contractRef.id,
|
||||
slug,
|
||||
|
@ -107,11 +124,11 @@ export const createmarket = newEndpoint(['POST'], async (req, [user, _]) => {
|
|||
max ?? 0
|
||||
)
|
||||
|
||||
if (!isFree && ante) await chargeUser(user.id, ante, true)
|
||||
if (ante) await chargeUser(user.id, ante, true)
|
||||
|
||||
await contractRef.create(contract)
|
||||
|
||||
const providerId = isFree ? HOUSE_LIQUIDITY_PROVIDER_ID : user.id
|
||||
const providerId = user.id
|
||||
|
||||
if (outcomeType === 'BINARY') {
|
||||
const liquidityDoc = firestore
|
||||
|
|
|
@ -1,95 +0,0 @@
|
|||
import * as functions from 'firebase-functions'
|
||||
import * as admin from 'firebase-admin'
|
||||
|
||||
import { getUser } from './utils'
|
||||
import { Contract } from '../../common/contract'
|
||||
import { slugify } from '../../common/util/slugify'
|
||||
import { randomString } from '../../common/util/random'
|
||||
import { Fold } from '../../common/fold'
|
||||
|
||||
export const createFold = functions.runWith({ minInstances: 1 }).https.onCall(
|
||||
async (
|
||||
data: {
|
||||
name: string
|
||||
about: string
|
||||
tags: string[]
|
||||
},
|
||||
context
|
||||
) => {
|
||||
const userId = context?.auth?.uid
|
||||
if (!userId) return { status: 'error', message: 'Not authorized' }
|
||||
|
||||
const creator = await getUser(userId)
|
||||
if (!creator) return { status: 'error', message: 'User not found' }
|
||||
|
||||
let { name, about } = data
|
||||
|
||||
if (!name || typeof name !== 'string')
|
||||
return { status: 'error', message: 'Name must be a non-empty string' }
|
||||
name = name.trim().slice(0, 140)
|
||||
|
||||
if (typeof about !== 'string')
|
||||
return { status: 'error', message: 'About must be a string' }
|
||||
about = about.trim().slice(0, 140)
|
||||
|
||||
const { tags } = data
|
||||
|
||||
if (!Array.isArray(tags))
|
||||
return { status: 'error', message: 'Tags must be an array of strings' }
|
||||
|
||||
console.log(
|
||||
'creating fold for',
|
||||
creator.username,
|
||||
'named',
|
||||
name,
|
||||
'about',
|
||||
about,
|
||||
'tags',
|
||||
tags
|
||||
)
|
||||
|
||||
const slug = await getSlug(name)
|
||||
|
||||
const foldRef = firestore.collection('folds').doc()
|
||||
|
||||
const fold: Fold = {
|
||||
id: foldRef.id,
|
||||
curatorId: userId,
|
||||
slug,
|
||||
name,
|
||||
about,
|
||||
tags,
|
||||
lowercaseTags: tags.map((tag) => tag.toLowerCase()),
|
||||
createdTime: Date.now(),
|
||||
contractIds: [],
|
||||
excludedContractIds: [],
|
||||
excludedCreatorIds: [],
|
||||
followCount: 0,
|
||||
}
|
||||
|
||||
await foldRef.create(fold)
|
||||
|
||||
await foldRef.collection('followers').doc(userId).set({ userId })
|
||||
|
||||
return { status: 'success', fold }
|
||||
}
|
||||
)
|
||||
|
||||
const getSlug = async (name: string) => {
|
||||
const proposedSlug = slugify(name)
|
||||
|
||||
const preexistingFold = await getFoldFromSlug(proposedSlug)
|
||||
|
||||
return preexistingFold ? proposedSlug + '-' + randomString() : proposedSlug
|
||||
}
|
||||
|
||||
const firestore = admin.firestore()
|
||||
|
||||
export async function getFoldFromSlug(slug: string) {
|
||||
const snap = await firestore
|
||||
.collection('folds')
|
||||
.where('slug', '==', slug)
|
||||
.get()
|
||||
|
||||
return snap.empty ? undefined : (snap.docs[0].data() as Contract)
|
||||
}
|
87
functions/src/create-group.ts
Normal file
87
functions/src/create-group.ts
Normal file
|
@ -0,0 +1,87 @@
|
|||
import * as admin from 'firebase-admin'
|
||||
|
||||
import { getUser } from './utils'
|
||||
import { Contract } from '../../common/contract'
|
||||
import { slugify } from '../../common/util/slugify'
|
||||
import { randomString } from '../../common/util/random'
|
||||
import {
|
||||
Group,
|
||||
MAX_ABOUT_LENGTH,
|
||||
MAX_GROUP_NAME_LENGTH,
|
||||
MAX_ID_LENGTH,
|
||||
} from '../../common/group'
|
||||
import { APIError, newEndpoint, validate } from '../../functions/src/api'
|
||||
import { z } from 'zod'
|
||||
|
||||
const bodySchema = z.object({
|
||||
name: z.string().min(1).max(MAX_GROUP_NAME_LENGTH),
|
||||
memberIds: z.array(z.string().min(1).max(MAX_ID_LENGTH)),
|
||||
anyoneCanJoin: z.boolean(),
|
||||
about: z.string().min(1).max(MAX_ABOUT_LENGTH).optional(),
|
||||
})
|
||||
|
||||
export const creategroup = newEndpoint(['POST'], async (req, auth) => {
|
||||
const { name, about, memberIds, anyoneCanJoin } = validate(
|
||||
bodySchema,
|
||||
req.body
|
||||
)
|
||||
|
||||
const creator = await getUser(auth.uid)
|
||||
if (!creator)
|
||||
throw new APIError(400, 'No user exists with the authenticated user ID.')
|
||||
|
||||
// Add creator id to member ids for convenience
|
||||
if (!memberIds.includes(creator.id)) memberIds.push(creator.id)
|
||||
|
||||
console.log(
|
||||
'creating group for',
|
||||
creator.username,
|
||||
'named',
|
||||
name,
|
||||
'about',
|
||||
about,
|
||||
'other member ids',
|
||||
memberIds
|
||||
)
|
||||
|
||||
const slug = await getSlug(name)
|
||||
|
||||
const groupRef = firestore.collection('groups').doc()
|
||||
|
||||
const group: Group = {
|
||||
id: groupRef.id,
|
||||
creatorId: creator.id,
|
||||
slug,
|
||||
name,
|
||||
about: about ?? '',
|
||||
createdTime: Date.now(),
|
||||
mostRecentActivityTime: Date.now(),
|
||||
// TODO: allow users to add contract ids on group creation
|
||||
contractIds: [],
|
||||
anyoneCanJoin,
|
||||
memberIds,
|
||||
}
|
||||
|
||||
await groupRef.create(group)
|
||||
|
||||
return { status: 'success', group: group }
|
||||
})
|
||||
|
||||
const getSlug = async (name: string) => {
|
||||
const proposedSlug = slugify(name)
|
||||
|
||||
const preexistingGroup = await getGroupFromSlug(proposedSlug)
|
||||
|
||||
return preexistingGroup ? proposedSlug + '-' + randomString() : proposedSlug
|
||||
}
|
||||
|
||||
const firestore = admin.firestore()
|
||||
|
||||
export async function getGroupFromSlug(slug: string) {
|
||||
const snap = await firestore
|
||||
.collection('groups')
|
||||
.where('slug', '==', slug)
|
||||
.get()
|
||||
|
||||
return snap.empty ? undefined : (snap.docs[0].data() as Contract)
|
||||
}
|
|
@ -26,10 +26,12 @@ export const createNotification = async (
|
|||
sourceUpdateType: notification_source_update_types,
|
||||
sourceUser: User,
|
||||
idempotencyKey: string,
|
||||
sourceText: string,
|
||||
sourceContract?: Contract,
|
||||
relatedSourceType?: notification_source_types,
|
||||
relatedUserId?: string,
|
||||
sourceText?: string
|
||||
sourceSlug?: string,
|
||||
sourceTitle?: string
|
||||
) => {
|
||||
const shouldGetNotification = (
|
||||
userId: string,
|
||||
|
@ -62,21 +64,64 @@ export const createNotification = async (
|
|||
sourceUserName: sourceUser.name,
|
||||
sourceUserUsername: sourceUser.username,
|
||||
sourceUserAvatarUrl: sourceUser.avatarUrl,
|
||||
sourceText,
|
||||
sourceContractCreatorUsername: sourceContract?.creatorUsername,
|
||||
// TODO: move away from sourceContractTitle to sourceTitle
|
||||
sourceContractTitle: sourceContract?.question,
|
||||
sourceContractSlug: sourceContract?.slug,
|
||||
sourceSlug: sourceSlug ? sourceSlug : sourceContract?.slug,
|
||||
sourceTitle: sourceTitle ? sourceTitle : sourceContract?.question,
|
||||
}
|
||||
await notificationRef.set(removeUndefinedProps(notification))
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const notifyRepliedUsers = async (
|
||||
const notifyLiquidityProviders = async (
|
||||
userToReasonTexts: user_to_reason_texts,
|
||||
contract: Contract
|
||||
) => {
|
||||
const liquidityProviders = await firestore
|
||||
.collection(`contracts/${contract.id}/liquidity`)
|
||||
.get()
|
||||
const liquidityProvidersIds = uniq(
|
||||
liquidityProviders.docs.map((doc) => doc.data().userId)
|
||||
)
|
||||
liquidityProvidersIds.forEach((userId) => {
|
||||
if (!shouldGetNotification(userId, userToReasonTexts)) return
|
||||
userToReasonTexts[userId] = {
|
||||
reason: 'on_contract_with_users_shares_in',
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const notifyUsersFollowers = async (
|
||||
userToReasonTexts: user_to_reason_texts
|
||||
) => {
|
||||
const followers = await firestore
|
||||
.collectionGroup('follows')
|
||||
.where('userId', '==', sourceUser.id)
|
||||
.get()
|
||||
|
||||
followers.docs.forEach((doc) => {
|
||||
const followerUserId = doc.ref.parent.parent?.id
|
||||
if (
|
||||
!relatedSourceType ||
|
||||
!relatedUserId ||
|
||||
!shouldGetNotification(relatedUserId, userToReasonTexts)
|
||||
)
|
||||
return
|
||||
followerUserId &&
|
||||
shouldGetNotification(followerUserId, userToReasonTexts)
|
||||
) {
|
||||
userToReasonTexts[followerUserId] = {
|
||||
reason: 'you_follow_user',
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const notifyRepliedUsers = async (
|
||||
userToReasonTexts: user_to_reason_texts,
|
||||
relatedUserId: string,
|
||||
relatedSourceType: notification_source_types
|
||||
) => {
|
||||
if (!shouldGetNotification(relatedUserId, userToReasonTexts)) return
|
||||
if (relatedSourceType === 'comment') {
|
||||
userToReasonTexts[relatedUserId] = {
|
||||
reason: 'reply_to_users_comment',
|
||||
|
@ -98,8 +143,10 @@ export const createNotification = async (
|
|||
}
|
||||
}
|
||||
|
||||
const notifyTaggedUsers = async (userToReasonTexts: user_to_reason_texts) => {
|
||||
if (!sourceText) return
|
||||
const notifyTaggedUsers = async (
|
||||
userToReasonTexts: user_to_reason_texts,
|
||||
sourceText: string
|
||||
) => {
|
||||
const taggedUsers = sourceText.match(/@\w+/g)
|
||||
if (!taggedUsers) return
|
||||
// await all get tagged users:
|
||||
|
@ -118,9 +165,13 @@ export const createNotification = async (
|
|||
|
||||
const notifyContractCreator = async (
|
||||
userToReasonTexts: user_to_reason_texts,
|
||||
sourceContract: Contract
|
||||
sourceContract: Contract,
|
||||
options?: { force: boolean }
|
||||
) => {
|
||||
if (shouldGetNotification(sourceContract.creatorId, userToReasonTexts))
|
||||
if (
|
||||
options?.force ||
|
||||
shouldGetNotification(sourceContract.creatorId, userToReasonTexts)
|
||||
)
|
||||
userToReasonTexts[sourceContract.creatorId] = {
|
||||
reason: 'on_users_contract',
|
||||
}
|
||||
|
@ -164,7 +215,7 @@ export const createNotification = async (
|
|||
})
|
||||
}
|
||||
|
||||
const notifyOtherBettorsOnContract = async (
|
||||
const notifyBettorsOnContract = async (
|
||||
userToReasonTexts: user_to_reason_texts,
|
||||
sourceContract: Contract
|
||||
) => {
|
||||
|
@ -191,27 +242,54 @@ export const createNotification = async (
|
|||
})
|
||||
}
|
||||
|
||||
// TODO: Update for liquidity.
|
||||
// TODO: Notify users of their own closed but not resolved contracts.
|
||||
const notifyUserAddedToGroup = async (
|
||||
userToReasonTexts: user_to_reason_texts,
|
||||
relatedUserId: string
|
||||
) => {
|
||||
if (shouldGetNotification(relatedUserId, userToReasonTexts))
|
||||
userToReasonTexts[relatedUserId] = {
|
||||
reason: 'added_you_to_group',
|
||||
}
|
||||
}
|
||||
|
||||
const getUsersToNotify = async () => {
|
||||
const userToReasonTexts: user_to_reason_texts = {}
|
||||
// The following functions modify the userToReasonTexts object in place.
|
||||
if (sourceContract) {
|
||||
if (
|
||||
sourceContract &&
|
||||
(sourceType === 'comment' ||
|
||||
sourceType === 'comment' ||
|
||||
sourceType === 'answer' ||
|
||||
sourceType === 'contract')
|
||||
(sourceType === 'contract' &&
|
||||
(sourceUpdateType === 'updated' || sourceUpdateType === 'resolved'))
|
||||
) {
|
||||
if (sourceType === 'comment') {
|
||||
await notifyRepliedUsers(userToReasonTexts)
|
||||
await notifyTaggedUsers(userToReasonTexts)
|
||||
if (relatedUserId && relatedSourceType)
|
||||
await notifyRepliedUsers(
|
||||
userToReasonTexts,
|
||||
relatedUserId,
|
||||
relatedSourceType
|
||||
)
|
||||
if (sourceText) await notifyTaggedUsers(userToReasonTexts, sourceText)
|
||||
}
|
||||
await notifyContractCreator(userToReasonTexts, sourceContract)
|
||||
await notifyOtherAnswerersOnContract(userToReasonTexts, sourceContract)
|
||||
await notifyOtherBettorsOnContract(userToReasonTexts, sourceContract)
|
||||
await notifyLiquidityProviders(userToReasonTexts, sourceContract)
|
||||
await notifyBettorsOnContract(userToReasonTexts, sourceContract)
|
||||
await notifyOtherCommentersOnContract(userToReasonTexts, sourceContract)
|
||||
} else if (sourceType === 'contract' && sourceUpdateType === 'created') {
|
||||
await notifyUsersFollowers(userToReasonTexts)
|
||||
} 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 notifyFollowedUser(userToReasonTexts, relatedUserId)
|
||||
} else if (sourceType === 'group' && relatedUserId) {
|
||||
if (sourceUpdateType === 'created')
|
||||
await notifyUserAddedToGroup(userToReasonTexts, relatedUserId)
|
||||
}
|
||||
return userToReasonTexts
|
||||
}
|
||||
|
|
|
@ -15,6 +15,9 @@ import {
|
|||
} from '../../common/util/clean-username'
|
||||
import { sendWelcomeEmail } from './emails'
|
||||
import { isWhitelisted } from '../../common/envs/constants'
|
||||
import { DEFAULT_CATEGORIES } from '../../common/categories'
|
||||
|
||||
import { track } from './analytics'
|
||||
|
||||
export const createUser = functions
|
||||
.runWith({ minInstances: 1, secrets: ['MAILGUN_KEY'] })
|
||||
|
@ -67,8 +70,10 @@ export const createUser = functions
|
|||
balance,
|
||||
totalDeposits: balance,
|
||||
createdTime: Date.now(),
|
||||
totalPnLCached: 0,
|
||||
creatorVolumeCached: 0,
|
||||
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)
|
||||
|
@ -86,6 +91,8 @@ export const createUser = functions
|
|||
|
||||
await sendWelcomeEmail(user, privateUser)
|
||||
|
||||
await track(userId, 'create user', { username }, { ip: ipAddress })
|
||||
|
||||
return { status: 'success', user }
|
||||
})
|
||||
|
||||
|
|
|
@ -1,10 +1,8 @@
|
|||
<!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>
|
||||
<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>Thank You for Purchasing M$</title>
|
||||
<!--[if !mso]><!-->
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
|
@ -48,7 +46,7 @@
|
|||
<noscript>
|
||||
<xml>
|
||||
<o:OfficeDocumentSettings>
|
||||
<o:AllowPNG />
|
||||
<o:AllowPNG/>
|
||||
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||
</o:OfficeDocumentSettings>
|
||||
</xml>
|
||||
|
@ -56,13 +54,11 @@
|
|||
<![endif]-->
|
||||
<!--[if lte mso 11]>
|
||||
<style type="text/css">
|
||||
.mj-outlook-group-fix {
|
||||
width: 100% !important;
|
||||
}
|
||||
.mj-outlook-group-fix { width:100% !important; }
|
||||
</style>
|
||||
<![endif]-->
|
||||
<style type="text/css">
|
||||
@media only screen and (min-width: 480px) {
|
||||
@media only screen and (min-width:480px) {
|
||||
.mj-column-per-100 {
|
||||
width: 100% !important;
|
||||
max-width: 100%;
|
||||
|
@ -82,7 +78,7 @@
|
|||
}
|
||||
</style>
|
||||
<style type="text/css">
|
||||
@media only screen and (max-width: 480px) {
|
||||
@media only screen and (max-width:480px) {
|
||||
table.mj-full-width-mobile {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
@ -92,104 +88,39 @@
|
|||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
</head>
|
||||
|
||||
<body style="word-spacing: normal; background-color: #f4f4f4">
|
||||
<div style="background-color: #f4f4f4">
|
||||
<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%"
|
||||
>
|
||||
<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;
|
||||
"
|
||||
>
|
||||
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%"
|
||||
>
|
||||
<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: 10px 25px;
|
||||
word-break: break-word;
|
||||
"
|
||||
>
|
||||
<table
|
||||
border="0"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
role="presentation"
|
||||
style="
|
||||
border-collapse: collapse;
|
||||
border-spacing: 0px;
|
||||
"
|
||||
>
|
||||
<td align="center"
|
||||
style="font-size:0px;padding:10px 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="Thank you!"
|
||||
height="auto"
|
||||
<td style="width:550px;">
|
||||
<a href="https://manifold.markets" target="_blank">
|
||||
<img alt="Thank you" height="auto"
|
||||
src="https://03jlj.mjt.lu/img/03jlj/b/z2h/sqw9.gif"
|
||||
style="
|
||||
border: none;
|
||||
display: block;
|
||||
outline: none;
|
||||
text-decoration: none;
|
||||
height: auto;
|
||||
width: 100%;
|
||||
font-size: 13px;
|
||||
"
|
||||
width="550"
|
||||
/>
|
||||
style="border:none;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:13px;"
|
||||
width="550" />
|
||||
</a>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
@ -206,147 +137,47 @@
|
|||
</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%"
|
||||
>
|
||||
<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;
|
||||
"
|
||||
>
|
||||
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%"
|
||||
>
|
||||
<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 0px 25px;
|
||||
padding-top: 0px;
|
||||
padding-right: 25px;
|
||||
padding-bottom: 0px;
|
||||
padding-left: 25px;
|
||||
word-break: break-word;
|
||||
"
|
||||
>
|
||||
<td align="left"
|
||||
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;">
|
||||
<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: 25px;
|
||||
text-align: center;
|
||||
margin: 10px 0;
|
||||
margin-top: 10px;
|
||||
"
|
||||
data-testid="3Q8BP69fq"
|
||||
>
|
||||
<span
|
||||
style="
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 18px;
|
||||
"
|
||||
>Thank you so much for purchasing Manifold
|
||||
Dollars!</span
|
||||
><br /><span
|
||||
style="
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 18px;
|
||||
"
|
||||
>Best of luck with your forecasting!</span
|
||||
>
|
||||
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: 25px; text-align: center; margin: 10px 0; margin-top: 10px;"
|
||||
data-testid="3Q8BP69fq"><span
|
||||
style="font-family:Arial, sans-serif;font-size:18px;">Thank
|
||||
you so much for purchasing Mana!</span><br /><span
|
||||
style="font-family:Arial, sans-serif;font-size:18px;">May
|
||||
the odds ever be in your favor!</span></p>
|
||||
<p class="text-build-content"
|
||||
style="text-align: center; margin: 10px 0;"
|
||||
data-testid="3Q8BP69fq"> </p>
|
||||
<p class="text-build-content"
|
||||
style="text-align: center; margin: 10px 0;"
|
||||
data-testid="3Q8BP69fq"><span
|
||||
style="font-family:Arial, sans-serif;font-size:18px;">Cheers,</span>
|
||||
</p>
|
||||
<p
|
||||
class="text-build-content"
|
||||
style="text-align: center; margin: 10px 0"
|
||||
data-testid="3Q8BP69fq"
|
||||
></p>
|
||||
<br />
|
||||
<p
|
||||
class="text-build-content"
|
||||
style="text-align: center; margin: 10px 0"
|
||||
data-testid="3Q8BP69fq"
|
||||
>
|
||||
<span
|
||||
style="
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 18px;
|
||||
"
|
||||
>Cheers,</span
|
||||
>
|
||||
</p>
|
||||
<p
|
||||
class="text-build-content"
|
||||
style="text-align: center; margin: 10px 0"
|
||||
data-testid="3Q8BP69fq"
|
||||
>
|
||||
<span
|
||||
style="
|
||||
color: #000000;
|
||||
font-family: Arial;
|
||||
font-size: 18px;
|
||||
"
|
||||
>David from Manifold</span
|
||||
>
|
||||
</p>
|
||||
<p
|
||||
class="text-build-content"
|
||||
style="
|
||||
text-align: center;
|
||||
margin: 10px 0;
|
||||
margin-bottom: 10px;
|
||||
"
|
||||
data-testid="3Q8BP69fq"
|
||||
></p>
|
||||
<p class="text-build-content"
|
||||
style="text-align: center; margin: 10px 0;"
|
||||
data-testid="3Q8BP69fq"><span
|
||||
style="color:#000000;font-family:Arial;font-size:18px;">David
|
||||
from Manifold</span></p>
|
||||
<p class="text-build-content"
|
||||
style="text-align: center; margin: 10px 0; margin-bottom: 10px;"
|
||||
data-testid="3Q8BP69fq"> </p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
@ -359,88 +190,34 @@
|
|||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div style="margin: 0px auto; max-width: 600px">
|
||||
<table
|
||||
align="center"
|
||||
border="0"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
role="presentation"
|
||||
style="width: 100%"
|
||||
>
|
||||
<!--[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]-->
|
||||
|
||||
<!--[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;
|
||||
"
|
||||
>
|
||||
<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%"
|
||||
>
|
||||
<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%"
|
||||
>
|
||||
<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;
|
||||
"
|
||||
>
|
||||
<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>
|
||||
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>
|
||||
|
@ -459,5 +236,6 @@
|
|||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</div>
|
||||
</body>
|
||||
</body>
|
||||
|
||||
</html>
|
|
@ -15,21 +15,18 @@
|
|||
#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;
|
||||
|
@ -38,7 +35,6 @@
|
|||
text-decoration: none;
|
||||
-ms-interpolation-mode: bicubic;
|
||||
}
|
||||
|
||||
p {
|
||||
display: block;
|
||||
margin: 13px 0;
|
||||
|
@ -67,7 +63,6 @@
|
|||
width: 100% !important;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.mj-column-per-50 {
|
||||
width: 50% !important;
|
||||
max-width: 50%;
|
||||
|
@ -79,7 +74,6 @@
|
|||
width: 100% !important;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.moz-text-html .mj-column-per-50 {
|
||||
width: 50% !important;
|
||||
max-width: 50%;
|
||||
|
@ -90,7 +84,6 @@
|
|||
width: 100% !important;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
[owa] .mj-column-per-50 {
|
||||
width: 50% !important;
|
||||
max-width: 50%;
|
||||
|
@ -101,14 +94,12 @@
|
|||
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]-->
|
||||
|
@ -317,8 +308,8 @@
|
|||
font-family: Arial, sans-serif;
|
||||
font-size: 18px;
|
||||
"
|
||||
>We can't wait to see what markets you will
|
||||
create!</span
|
||||
>We can't wait to see what questions you
|
||||
will ask!</span
|
||||
>
|
||||
</p>
|
||||
<p
|
||||
|
@ -331,9 +322,9 @@
|
|||
font-family: Arial, sans-serif;
|
||||
font-size: 18px;
|
||||
"
|
||||
>As a gift, M$1,000 has been credited to your
|
||||
account.</span
|
||||
>
|
||||
>As a gift M$1000 has been credited to your
|
||||
account - the equivalent of 10 USD.
|
||||
</span>
|
||||
</p>
|
||||
<p
|
||||
class="text-build-content"
|
||||
|
@ -385,10 +376,10 @@
|
|||
align="center"
|
||||
style="
|
||||
font-size: 0px;
|
||||
padding: 5px 15px 5px 0px;
|
||||
padding-top: 5px;
|
||||
padding: 0px 15px 0px 0px;
|
||||
padding-top: 0px;
|
||||
padding-right: 15px;
|
||||
padding-bottom: 5px;
|
||||
padding-bottom: 0px;
|
||||
padding-left: 0px;
|
||||
word-break: break-word;
|
||||
"
|
||||
|
@ -411,9 +402,9 @@
|
|||
target="_blank"
|
||||
>
|
||||
<img
|
||||
alt="Bet"
|
||||
alt=""
|
||||
height="auto"
|
||||
src="https://03jlj.mjt.lu/img/03jlj/b/urx/sjii.png"
|
||||
src="https://03jlj.mjt.lu/img/03jlj/b/urx/1rsm.png"
|
||||
style="
|
||||
border: none;
|
||||
display: block;
|
||||
|
@ -437,10 +428,10 @@
|
|||
align="center"
|
||||
style="
|
||||
font-size: 0px;
|
||||
padding: 5px 15px 5px 0px;
|
||||
padding-top: 5px;
|
||||
padding: 0px 15px 0px 0px;
|
||||
padding-top: 0px;
|
||||
padding-right: 15px;
|
||||
padding-bottom: 5px;
|
||||
padding-bottom: 0px;
|
||||
padding-left: 0px;
|
||||
word-break: break-word;
|
||||
"
|
||||
|
@ -463,9 +454,9 @@
|
|||
target="_blank"
|
||||
>
|
||||
<img
|
||||
alt="Ask"
|
||||
alt=""
|
||||
height="auto"
|
||||
src="https://03jlj.mjt.lu/img/03jlj/b/urx/sjik.png"
|
||||
src="https://03jlj.mjt.lu/img/03jlj/b/urx/1rs2.png"
|
||||
style="
|
||||
border: none;
|
||||
display: block;
|
||||
|
@ -489,10 +480,10 @@
|
|||
align="center"
|
||||
style="
|
||||
font-size: 0px;
|
||||
padding: 5px 15px 5px 0px;
|
||||
padding-top: 5px;
|
||||
padding: 0px 15px 0px 0px;
|
||||
padding-top: 0px;
|
||||
padding-right: 15px;
|
||||
padding-bottom: 5px;
|
||||
padding-bottom: 0px;
|
||||
padding-left: 0px;
|
||||
word-break: break-word;
|
||||
"
|
||||
|
@ -515,9 +506,9 @@
|
|||
target="_blank"
|
||||
>
|
||||
<img
|
||||
alt="Give"
|
||||
alt=""
|
||||
height="auto"
|
||||
src="https://03jlj.mjt.lu/img/03jlj/b/urx/sji8.png"
|
||||
src="https://03jlj.mjt.lu/img/03jlj/b/urx/1rsp.png"
|
||||
style="
|
||||
border: none;
|
||||
display: block;
|
||||
|
@ -653,7 +644,6 @@
|
|||
></a
|
||||
>
|
||||
</p>
|
||||
<br />
|
||||
<p
|
||||
class="text-build-content"
|
||||
data-testid="3Q8BP69fq"
|
||||
|
@ -687,6 +677,7 @@
|
|||
>David from Manifold</span
|
||||
>
|
||||
</p>
|
||||
<br />
|
||||
<p
|
||||
class="text-build-content"
|
||||
data-testid="3Q8BP69fq"
|
||||
|
@ -705,6 +696,30 @@
|
|||
</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;
|
||||
"
|
||||
></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"
|
||||
|
|
|
@ -275,8 +275,8 @@ export const sendNewCommentEmail = async (
|
|||
)}`
|
||||
}
|
||||
|
||||
const subject = `Comment on ${question}`
|
||||
const from = `${commentorName} <info@manifold.markets>`
|
||||
const subject = `Comment from ${commentorName} on ${question}`
|
||||
const from = `${commentorName} on Manifold <no-reply@manifold.markets>`
|
||||
|
||||
if (contract.outcomeType === 'FREE_RESPONSE' && answerId && answerText) {
|
||||
const answerNumber = `#${answerId}`
|
||||
|
|
|
@ -42,8 +42,8 @@ export async function getTaggedContracts(tag: string) {
|
|||
return taggedContracts.filter((c) => (c.closeTime ?? Infinity) > Date.now())
|
||||
}
|
||||
|
||||
export async function getRecentBetsAndComments(contract: Contract) {
|
||||
const contractDoc = firestore.collection('contracts').doc(contract.id)
|
||||
export async function getRecentBetsAndComments(contractId: string) {
|
||||
const contractDoc = firestore.collection('contracts').doc(contractId)
|
||||
|
||||
const [recentBets, recentComments] = await Promise.all([
|
||||
getValues<Bet>(
|
||||
|
@ -64,7 +64,6 @@ export async function getRecentBetsAndComments(contract: Contract) {
|
|||
])
|
||||
|
||||
return {
|
||||
contract,
|
||||
recentBets,
|
||||
recentComments,
|
||||
}
|
||||
|
|
|
@ -1,11 +1,8 @@
|
|||
import { newEndpoint } from './api'
|
||||
|
||||
export const health = newEndpoint(['GET'], async (_req, [user, _]) => {
|
||||
export const health = newEndpoint(['GET'], async (_req, auth) => {
|
||||
return {
|
||||
message: 'Server is working.',
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
},
|
||||
uid: auth.uid,
|
||||
}
|
||||
})
|
||||
|
|
|
@ -4,33 +4,35 @@ admin.initializeApp()
|
|||
|
||||
// v1
|
||||
// export * from './keep-awake'
|
||||
export * from './claim-manalink'
|
||||
export * from './transact'
|
||||
export * from './resolve-market'
|
||||
export * from './stripe'
|
||||
export * from './sell-bet'
|
||||
export * from './sell-shares'
|
||||
export * from './create-user'
|
||||
export * from './create-fold'
|
||||
export * from './create-answer'
|
||||
export * from './on-create-bet'
|
||||
export * from './on-create-comment'
|
||||
export * from './on-fold-follow'
|
||||
export * from './on-fold-delete'
|
||||
export * from './on-view'
|
||||
export * from './unsubscribe'
|
||||
export * from './update-contract-metrics'
|
||||
export * from './update-user-metrics'
|
||||
export * from './update-recommendations'
|
||||
export * from './update-feed'
|
||||
export * from './update-metrics'
|
||||
export * from './backup-db'
|
||||
export * from './change-user-info'
|
||||
export * from './market-close-emails'
|
||||
export * from './market-close-notifications'
|
||||
export * from './add-liquidity'
|
||||
export * from './on-create-answer'
|
||||
export * from './on-update-contract'
|
||||
export * from './on-create-contract'
|
||||
export * from './on-follow-user'
|
||||
export * from './on-unfollow-user'
|
||||
export * from './on-create-liquidity-provision'
|
||||
export * from './on-update-group'
|
||||
export * from './on-create-group'
|
||||
|
||||
// v2
|
||||
export * from './health'
|
||||
export * from './place-bet'
|
||||
export * from './sell-bet'
|
||||
export * from './sell-shares'
|
||||
export * from './create-contract'
|
||||
export * from './withdraw-liquidity'
|
||||
export * from './create-group'
|
||||
|
|
|
@ -4,9 +4,11 @@ import * as admin from 'firebase-admin'
|
|||
import { Contract } from '../../common/contract'
|
||||
import { getPrivateUser, getUserByUsername } from './utils'
|
||||
import { sendMarketCloseEmail } from './emails'
|
||||
import { createNotification } from './create-notification'
|
||||
|
||||
export const marketCloseEmails = functions.pubsub
|
||||
.schedule('every 1 hours')
|
||||
export const marketCloseNotifications = functions
|
||||
.runWith({ secrets: ['MAILGUN_KEY'] })
|
||||
.pubsub.schedule('every 1 hours')
|
||||
.onRun(async () => {
|
||||
await sendMarketCloseEmails()
|
||||
})
|
||||
|
@ -55,5 +57,14 @@ async function sendMarketCloseEmails() {
|
|||
if (!privateUser) continue
|
||||
|
||||
await sendMarketCloseEmail(user, privateUser, contract)
|
||||
await createNotification(
|
||||
contract.id,
|
||||
'contract',
|
||||
'closed',
|
||||
user,
|
||||
'closed' + contract.id.slice(6, contract.id.length),
|
||||
contract.closeTime?.toString() ?? new Date().toString(),
|
||||
contract
|
||||
)
|
||||
}
|
||||
}
|
|
@ -27,6 +27,7 @@ export const onCreateAnswer = functions.firestore
|
|||
'created',
|
||||
answerCreator,
|
||||
eventId,
|
||||
answer.text,
|
||||
contract
|
||||
)
|
||||
})
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import * as functions from 'firebase-functions'
|
||||
import * as admin from 'firebase-admin'
|
||||
|
||||
import { getContract } from './utils'
|
||||
import { Bet } from '../../common/bet'
|
||||
|
||||
const firestore = admin.firestore()
|
||||
|
@ -12,16 +11,11 @@ export const onCreateBet = functions.firestore
|
|||
const { contractId } = context.params as {
|
||||
contractId: string
|
||||
}
|
||||
|
||||
const contract = await getContract(contractId)
|
||||
if (!contract)
|
||||
throw new Error('Could not find contract corresponding with bet')
|
||||
|
||||
const bet = change.data() as Bet
|
||||
const lastBetTime = bet.createdTime
|
||||
|
||||
await firestore
|
||||
.collection('contracts')
|
||||
.doc(contract.id)
|
||||
.doc(contractId)
|
||||
.update({ lastBetTime, lastUpdatedTime: Date.now() })
|
||||
})
|
||||
|
|
|
@ -78,10 +78,10 @@ export const onCreateComment = functions
|
|||
'created',
|
||||
commentCreator,
|
||||
eventId,
|
||||
comment.text,
|
||||
contract,
|
||||
relatedSourceType,
|
||||
relatedUser,
|
||||
comment.text
|
||||
relatedUser
|
||||
)
|
||||
|
||||
const recipientUserIds = uniq([
|
||||
|
|
24
functions/src/on-create-contract.ts
Normal file
24
functions/src/on-create-contract.ts
Normal file
|
@ -0,0 +1,24 @@
|
|||
import * as functions from 'firebase-functions'
|
||||
import { getUser } from './utils'
|
||||
import { createNotification } from './create-notification'
|
||||
import { Contract } from '../../common/contract'
|
||||
|
||||
export const onCreateContract = functions.firestore
|
||||
.document('contracts/{contractId}')
|
||||
.onCreate(async (snapshot, context) => {
|
||||
const contract = snapshot.data() as Contract
|
||||
const { eventId } = context
|
||||
|
||||
const contractCreator = await getUser(contract.creatorId)
|
||||
if (!contractCreator) throw new Error('Could not find contract creator')
|
||||
|
||||
await createNotification(
|
||||
contract.id,
|
||||
'contract',
|
||||
'created',
|
||||
contractCreator,
|
||||
eventId,
|
||||
contract.description,
|
||||
contract
|
||||
)
|
||||
})
|
30
functions/src/on-create-group.ts
Normal file
30
functions/src/on-create-group.ts
Normal file
|
@ -0,0 +1,30 @@
|
|||
import * as functions from 'firebase-functions'
|
||||
import { getUser } from './utils'
|
||||
import { createNotification } from './create-notification'
|
||||
import { Group } from '../../common/group'
|
||||
|
||||
export const onCreateGroup = functions.firestore
|
||||
.document('groups/{groupId}')
|
||||
.onCreate(async (change, context) => {
|
||||
const group = change.data() as Group
|
||||
const { eventId } = context
|
||||
|
||||
const groupCreator = await getUser(group.creatorId)
|
||||
if (!groupCreator) throw new Error('Could not find group creator')
|
||||
// create notifications for all members of the group
|
||||
for (const memberId of group.memberIds) {
|
||||
await createNotification(
|
||||
group.id,
|
||||
'group',
|
||||
'created',
|
||||
groupCreator,
|
||||
eventId,
|
||||
group.about,
|
||||
undefined,
|
||||
undefined,
|
||||
memberId,
|
||||
group.slug,
|
||||
group.name
|
||||
)
|
||||
}
|
||||
})
|
31
functions/src/on-create-liquidity-provision.ts
Normal file
31
functions/src/on-create-liquidity-provision.ts
Normal file
|
@ -0,0 +1,31 @@
|
|||
import * as functions from 'firebase-functions'
|
||||
import { getContract, getUser } from './utils'
|
||||
import { createNotification } from './create-notification'
|
||||
import { LiquidityProvision } from 'common/liquidity-provision'
|
||||
|
||||
export const onCreateLiquidityProvision = functions.firestore
|
||||
.document('contracts/{contractId}/liquidity/{liquidityId}')
|
||||
.onCreate(async (change, context) => {
|
||||
const liquidity = change.data() as LiquidityProvision
|
||||
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
|
||||
if (liquidity.userId === 'IPTOzEqrpkWmEzh6hwvAyY9PqFb2') return
|
||||
|
||||
const liquidityProvider = await getUser(liquidity.userId)
|
||||
if (!liquidityProvider) throw new Error('Could not find liquidity provider')
|
||||
|
||||
await createNotification(
|
||||
contract.id,
|
||||
'liquidity',
|
||||
'created',
|
||||
liquidityProvider,
|
||||
eventId,
|
||||
liquidity.amount.toString(),
|
||||
contract
|
||||
)
|
||||
})
|
|
@ -1,10 +0,0 @@
|
|||
import * as functions from 'firebase-functions'
|
||||
|
||||
export const onFoldDelete = functions.firestore
|
||||
.document('folds/{foldId}')
|
||||
.onDelete(async (change, _context) => {
|
||||
const snapshot = await change.ref.collection('followers').get()
|
||||
|
||||
// Delete followers sub-collection.
|
||||
await Promise.all(snapshot.docs.map((doc) => doc.ref.delete()))
|
||||
})
|
|
@ -1,17 +0,0 @@
|
|||
import * as functions from 'firebase-functions'
|
||||
import * as admin from 'firebase-admin'
|
||||
|
||||
const firestore = admin.firestore()
|
||||
|
||||
export const onFoldFollow = functions.firestore
|
||||
.document('folds/{foldId}/followers/{userId}')
|
||||
.onWrite(async (change, context) => {
|
||||
const { foldId } = context.params
|
||||
|
||||
const snapshot = await firestore
|
||||
.collection(`folds/${foldId}/followers`)
|
||||
.get()
|
||||
const followCount = snapshot.size
|
||||
|
||||
await firestore.doc(`folds/${foldId}`).update({ followCount })
|
||||
})
|
|
@ -1,12 +1,16 @@
|
|||
import * as functions from 'firebase-functions'
|
||||
import * as admin from 'firebase-admin'
|
||||
|
||||
import { getUser } from './utils'
|
||||
import { createNotification } from './create-notification'
|
||||
import { FieldValue } from 'firebase-admin/firestore'
|
||||
|
||||
export const onFollowUser = functions.firestore
|
||||
.document('users/{userId}/follows/{followedUserId}')
|
||||
.onCreate(async (change, context) => {
|
||||
const { userId } = context.params as {
|
||||
const { userId, followedUserId } = context.params as {
|
||||
userId: string
|
||||
followedUserId: string
|
||||
}
|
||||
const { eventId } = context
|
||||
|
||||
|
@ -15,14 +19,21 @@ export const onFollowUser = functions.firestore
|
|||
const followingUser = await getUser(userId)
|
||||
if (!followingUser) throw new Error('Could not find following user')
|
||||
|
||||
await firestore.doc(`users/${followedUserId}`).update({
|
||||
followerCountCached: FieldValue.increment(1),
|
||||
})
|
||||
|
||||
await createNotification(
|
||||
followingUser.id,
|
||||
'follow',
|
||||
'created',
|
||||
followingUser,
|
||||
eventId,
|
||||
'',
|
||||
undefined,
|
||||
undefined,
|
||||
follow.userId
|
||||
)
|
||||
})
|
||||
|
||||
const firestore = admin.firestore()
|
||||
|
|
18
functions/src/on-unfollow-user.ts
Normal file
18
functions/src/on-unfollow-user.ts
Normal file
|
@ -0,0 +1,18 @@
|
|||
import * as functions from 'firebase-functions'
|
||||
import * as admin from 'firebase-admin'
|
||||
|
||||
import { FieldValue } from 'firebase-admin/firestore'
|
||||
|
||||
export const onUnfollowUser = functions.firestore
|
||||
.document('users/{userId}/follows/{followedUserId}')
|
||||
.onDelete(async (change, context) => {
|
||||
const { followedUserId } = context.params as {
|
||||
followedUserId: string
|
||||
}
|
||||
|
||||
await firestore.doc(`users/${followedUserId}`).update({
|
||||
followerCountCached: FieldValue.increment(-1),
|
||||
})
|
||||
})
|
||||
|
||||
const firestore = admin.firestore()
|
|
@ -14,24 +14,51 @@ export const onUpdateContract = functions.firestore
|
|||
|
||||
const previousValue = change.before.data() as Contract
|
||||
if (previousValue.isResolved !== contract.isResolved) {
|
||||
let resolutionText = contract.resolution ?? contract.question
|
||||
if (contract.outcomeType === 'FREE_RESPONSE') {
|
||||
const answerText = contract.answers.find(
|
||||
(answer) => answer.id === contract.resolution
|
||||
)?.text
|
||||
if (answerText) resolutionText = answerText
|
||||
} else if (contract.outcomeType === 'BINARY') {
|
||||
if (resolutionText === 'MKT' && contract.resolutionProbability)
|
||||
resolutionText = `${contract.resolutionProbability}%`
|
||||
else if (resolutionText === 'MKT') resolutionText = 'PROB'
|
||||
}
|
||||
|
||||
await createNotification(
|
||||
contract.id,
|
||||
'contract',
|
||||
'resolved',
|
||||
contractUpdater,
|
||||
eventId,
|
||||
resolutionText,
|
||||
contract
|
||||
)
|
||||
} else if (
|
||||
previousValue.closeTime !== contract.closeTime ||
|
||||
previousValue.description !== contract.description
|
||||
) {
|
||||
let sourceText = ''
|
||||
if (previousValue.closeTime !== contract.closeTime && contract.closeTime)
|
||||
sourceText = contract.closeTime.toString()
|
||||
else {
|
||||
const oldTrimmedDescription = previousValue.description.trim()
|
||||
const newTrimmedDescription = contract.description.trim()
|
||||
if (oldTrimmedDescription === '') sourceText = newTrimmedDescription
|
||||
else
|
||||
sourceText = newTrimmedDescription
|
||||
.split(oldTrimmedDescription)[1]
|
||||
.trim()
|
||||
}
|
||||
|
||||
await createNotification(
|
||||
contract.id,
|
||||
'contract',
|
||||
'updated',
|
||||
contractUpdater,
|
||||
eventId,
|
||||
sourceText,
|
||||
contract
|
||||
)
|
||||
}
|
||||
|
|
20
functions/src/on-update-group.ts
Normal file
20
functions/src/on-update-group.ts
Normal file
|
@ -0,0 +1,20 @@
|
|||
import * as functions from 'firebase-functions'
|
||||
import * as admin from 'firebase-admin'
|
||||
import { Group } from '../../common/group'
|
||||
const firestore = admin.firestore()
|
||||
|
||||
export const onUpdateGroup = functions.firestore
|
||||
.document('groups/{groupId}')
|
||||
.onUpdate(async (change) => {
|
||||
const prevGroup = change.before.data() as Group
|
||||
const group = change.after.data() as Group
|
||||
|
||||
// ignore the update we just made
|
||||
if (prevGroup.mostRecentActivityTime !== group.mostRecentActivityTime)
|
||||
return
|
||||
|
||||
await firestore
|
||||
.collection('groups')
|
||||
.doc(group.id)
|
||||
.update({ mostRecentActivityTime: Date.now() })
|
||||
})
|
|
@ -2,7 +2,7 @@ import * as admin from 'firebase-admin'
|
|||
import { z } from 'zod'
|
||||
|
||||
import { APIError, newEndpoint, validate } from './api'
|
||||
import { Contract } from '../../common/contract'
|
||||
import { Contract, CPMM_MIN_POOL_QTY } from '../../common/contract'
|
||||
import { User } from '../../common/user'
|
||||
import {
|
||||
BetInfo,
|
||||
|
@ -13,6 +13,7 @@ import {
|
|||
} from '../../common/new-bet'
|
||||
import { addObjects, removeUndefinedProps } from '../../common/util/object'
|
||||
import { redeemShares } from './redeem-shares'
|
||||
import { log } from './utils'
|
||||
|
||||
const bodySchema = z.object({
|
||||
contractId: z.string(),
|
||||
|
@ -32,21 +33,26 @@ const numericSchema = z.object({
|
|||
value: z.number(),
|
||||
})
|
||||
|
||||
export const placebet = newEndpoint(['POST'], async (req, [bettor, _]) => {
|
||||
export const placebet = newEndpoint(['POST'], async (req, auth) => {
|
||||
log('Inside endpoint handler.')
|
||||
const { amount, contractId } = validate(bodySchema, req.body)
|
||||
|
||||
const result = await firestore.runTransaction(async (trans) => {
|
||||
const userDoc = firestore.doc(`users/${bettor.id}`)
|
||||
const userSnap = await trans.get(userDoc)
|
||||
log('Inside main transaction.')
|
||||
const contractDoc = firestore.doc(`contracts/${contractId}`)
|
||||
const userDoc = firestore.doc(`users/${auth.uid}`)
|
||||
const [contractSnap, userSnap] = await Promise.all([
|
||||
trans.get(contractDoc),
|
||||
trans.get(userDoc),
|
||||
])
|
||||
if (!contractSnap.exists) throw new APIError(400, 'Contract not found.')
|
||||
if (!userSnap.exists) throw new APIError(400, 'User not found.')
|
||||
log('Loaded user and contract snapshots.')
|
||||
|
||||
const contract = contractSnap.data() as Contract
|
||||
const user = userSnap.data() as User
|
||||
if (user.balance < amount) throw new APIError(400, 'Insufficient balance.')
|
||||
|
||||
const contractDoc = firestore.doc(`contracts/${contractId}`)
|
||||
const contractSnap = await trans.get(contractDoc)
|
||||
if (!contractSnap.exists) throw new APIError(400, 'Contract not found.')
|
||||
const contract = contractSnap.data() as Contract
|
||||
|
||||
const loanAmount = 0
|
||||
const { closeTime, outcomeType, mechanism, collectedFees, volume } =
|
||||
contract
|
||||
|
@ -80,15 +86,23 @@ export const placebet = newEndpoint(['POST'], async (req, [bettor, _]) => {
|
|||
throw new APIError(500, 'Contract has invalid type/mechanism.')
|
||||
}
|
||||
})()
|
||||
log('Calculated new bet information.')
|
||||
|
||||
if (newP != null && !isFinite(newP)) {
|
||||
throw new APIError(400, 'Trade rejected due to overflow error.')
|
||||
if (
|
||||
mechanism == 'cpmm-1' &&
|
||||
(!newP ||
|
||||
!isFinite(newP) ||
|
||||
Math.min(...Object.values(newPool ?? {})) < CPMM_MIN_POOL_QTY)
|
||||
) {
|
||||
throw new APIError(400, 'Bet too large for current liquidity pool.')
|
||||
}
|
||||
|
||||
const newBalance = user.balance - amount - loanAmount
|
||||
const betDoc = contractDoc.collection('bets').doc()
|
||||
trans.create(betDoc, { id: betDoc.id, userId: user.id, ...newBet })
|
||||
log('Created new bet document.')
|
||||
trans.update(userDoc, { balance: newBalance })
|
||||
log('Updated user balance.')
|
||||
trans.update(
|
||||
contractDoc,
|
||||
removeUndefinedProps({
|
||||
|
@ -101,11 +115,14 @@ export const placebet = newEndpoint(['POST'], async (req, [bettor, _]) => {
|
|||
volume: volume + amount,
|
||||
})
|
||||
)
|
||||
log('Updated contract properties.')
|
||||
|
||||
return { betId: betDoc.id }
|
||||
})
|
||||
|
||||
await redeemShares(bettor.id, contractId)
|
||||
log('Main transaction finished.')
|
||||
await redeemShares(auth.uid, contractId)
|
||||
log('Share redemption transaction finished.')
|
||||
return result
|
||||
})
|
||||
|
||||
|
|
|
@ -129,7 +129,7 @@ export const resolveMarket = functions
|
|||
const openBets = bets.filter((b) => !b.isSold && !b.sale)
|
||||
const loanPayouts = getLoanPayouts(openBets)
|
||||
|
||||
if (!isProd)
|
||||
if (!isProd())
|
||||
console.log(
|
||||
'payouts:',
|
||||
payouts,
|
||||
|
|
46
functions/src/scripts/backfill-followers.ts
Normal file
46
functions/src/scripts/backfill-followers.ts
Normal file
|
@ -0,0 +1,46 @@
|
|||
import * as admin from 'firebase-admin'
|
||||
|
||||
import { initAdmin } from './script-init'
|
||||
initAdmin()
|
||||
|
||||
import { getValues } from '../utils'
|
||||
import { User } from 'common/user'
|
||||
import { Follow } from 'common/follow'
|
||||
|
||||
const firestore = admin.firestore()
|
||||
|
||||
async function backfillFollowers() {
|
||||
console.log('Backfilling user follower counts')
|
||||
const followerCounts: { [userId: string]: number } = {}
|
||||
const users = await getValues<User>(firestore.collection('users'))
|
||||
|
||||
console.log(`Loaded ${users.length} users. Calculating follower counts...`)
|
||||
for (const [idx, user] of users.entries()) {
|
||||
console.log(`Querying user ${user.id} (${idx + 1}/${users.length})`)
|
||||
const follows = await getValues<Follow>(
|
||||
firestore.collection('users').doc(user.id).collection('follows')
|
||||
)
|
||||
|
||||
for (const follow of follows) {
|
||||
followerCounts[follow.userId] = (followerCounts[follow.userId] || 0) + 1
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Finished calculating follower counts. Persisting cached follower counts...`
|
||||
)
|
||||
for (const [idx, user] of users.entries()) {
|
||||
console.log(`Persisting user ${user.id} (${idx + 1}/${users.length})`)
|
||||
const followerCount = followerCounts[user.id] || 0
|
||||
await firestore
|
||||
.collection('users')
|
||||
.doc(user.id)
|
||||
.update({ followerCountCached: followerCount })
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
backfillFollowers()
|
||||
.then(() => process.exit())
|
||||
.catch(console.log)
|
||||
}
|
26
functions/src/scripts/clean-display-names.ts
Normal file
26
functions/src/scripts/clean-display-names.ts
Normal file
|
@ -0,0 +1,26 @@
|
|||
// For a while, we didn't enforce that display names would be clean in the `updateUserInfo`
|
||||
// cloud function, so this script hunts down unclean ones.
|
||||
|
||||
import * as admin from 'firebase-admin'
|
||||
import { initAdmin } from './script-init'
|
||||
import { cleanDisplayName } from '../../../common/util/clean-username'
|
||||
import { log, writeAsync, UpdateSpec } from '../utils'
|
||||
initAdmin()
|
||||
const firestore = admin.firestore()
|
||||
|
||||
if (require.main === module) {
|
||||
const usersColl = firestore.collection('users')
|
||||
usersColl.get().then(async (userSnaps) => {
|
||||
log(`Loaded ${userSnaps.size} users.`)
|
||||
const updates = userSnaps.docs.reduce((acc, u) => {
|
||||
const name = u.data().name
|
||||
if (name != cleanDisplayName(name)) {
|
||||
acc.push({ doc: u.ref, fields: { name: cleanDisplayName(name) } })
|
||||
}
|
||||
return acc
|
||||
}, [] as UpdateSpec[])
|
||||
log(`Found ${updates.length} users to update:`, updates)
|
||||
await writeAsync(firestore, updates)
|
||||
log(`Updated all users.`)
|
||||
})
|
||||
}
|
|
@ -1,34 +0,0 @@
|
|||
import * as admin from 'firebase-admin'
|
||||
import { uniq } from 'lodash'
|
||||
|
||||
import { initAdmin } from './script-init'
|
||||
initAdmin()
|
||||
|
||||
import { getValues } from '../utils'
|
||||
import { Fold } from '../../../common/fold'
|
||||
|
||||
async function lowercaseFoldTags() {
|
||||
const firestore = admin.firestore()
|
||||
console.log('Updating fold tags')
|
||||
|
||||
const folds = await getValues<Fold>(firestore.collection('folds'))
|
||||
|
||||
console.log('Loaded', folds.length, 'folds')
|
||||
|
||||
for (const fold of folds) {
|
||||
const foldRef = firestore.doc(`folds/${fold.id}`)
|
||||
|
||||
const { tags } = fold
|
||||
const lowercaseTags = uniq(tags.map((tag) => tag.toLowerCase()))
|
||||
|
||||
console.log('Adding lowercase tags', fold.slug, lowercaseTags)
|
||||
|
||||
await foldRef.update({
|
||||
lowercaseTags,
|
||||
} as Partial<Fold>)
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
lowercaseFoldTags().then(() => process.exit())
|
||||
}
|
|
@ -11,7 +11,6 @@ import {
|
|||
getDpmProbability,
|
||||
} from '../../../common/calculate-dpm'
|
||||
import { getSellBetInfo } from '../../../common/sell-bet'
|
||||
import { User } from '../../../common/user'
|
||||
|
||||
type DocRef = admin.firestore.DocumentReference
|
||||
|
||||
|
@ -105,8 +104,6 @@ async function recalculateContract(
|
|||
const soldBet = bets.find((b) => b.id === bet.sale?.betId)
|
||||
if (!soldBet) throw new Error('invalid sold bet' + bet.sale.betId)
|
||||
|
||||
const fakeUser = { id: soldBet.userId, balance: 0 } as User
|
||||
|
||||
const fakeContract: Contract = {
|
||||
...contract,
|
||||
totalBets,
|
||||
|
@ -116,11 +113,14 @@ async function recalculateContract(
|
|||
}
|
||||
|
||||
const { newBet, newPool, newTotalShares, newTotalBets } =
|
||||
getSellBetInfo(fakeUser, soldBet, fakeContract, bet.id)
|
||||
getSellBetInfo(soldBet, fakeContract)
|
||||
|
||||
const betDoc = betsRef.doc(bet.id)
|
||||
const userId = soldBet.userId
|
||||
newBet.createdTime = bet.createdTime
|
||||
console.log('sale bet', newBet)
|
||||
if (isCommit) transaction.update(betsRef.doc(bet.id), newBet)
|
||||
if (isCommit)
|
||||
transaction.update(betDoc, { id: bet.id, userId, ...newBet })
|
||||
|
||||
pool = newPool
|
||||
totalShares = newTotalShares
|
||||
|
|
15
functions/src/scripts/update-metrics.ts
Normal file
15
functions/src/scripts/update-metrics.ts
Normal file
|
@ -0,0 +1,15 @@
|
|||
import { initAdmin } from './script-init'
|
||||
initAdmin()
|
||||
|
||||
import { log, logMemory } from '../utils'
|
||||
import { updateMetricsCore } from '../update-metrics'
|
||||
|
||||
async function updateMetrics() {
|
||||
logMemory()
|
||||
log('Updating metrics...')
|
||||
await updateMetricsCore()
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
updateMetrics().then(() => process.exit())
|
||||
}
|
|
@ -1,78 +1,61 @@
|
|||
import * as admin from 'firebase-admin'
|
||||
import * as functions from 'firebase-functions'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { APIError, newEndpoint, validate } from './api'
|
||||
import { Contract } from '../../common/contract'
|
||||
import { User } from '../../common/user'
|
||||
import { Bet } from '../../common/bet'
|
||||
import { getSellBetInfo } from '../../common/sell-bet'
|
||||
import { addObjects, removeUndefinedProps } from '../../common/util/object'
|
||||
|
||||
export const sellBet = functions.runWith({ minInstances: 1 }).https.onCall(
|
||||
async (
|
||||
data: {
|
||||
contractId: string
|
||||
betId: string
|
||||
},
|
||||
context
|
||||
) => {
|
||||
const userId = context?.auth?.uid
|
||||
if (!userId) return { status: 'error', message: 'Not authorized' }
|
||||
const bodySchema = z.object({
|
||||
contractId: z.string(),
|
||||
betId: z.string(),
|
||||
})
|
||||
|
||||
const { contractId, betId } = data
|
||||
export const sellbet = newEndpoint(['POST'], async (req, auth) => {
|
||||
const { contractId, betId } = validate(bodySchema, req.body)
|
||||
|
||||
// run as transaction to prevent race conditions
|
||||
return await firestore.runTransaction(async (transaction) => {
|
||||
const userDoc = firestore.doc(`users/${userId}`)
|
||||
const userSnap = await transaction.get(userDoc)
|
||||
if (!userSnap.exists)
|
||||
return { status: 'error', message: 'User not found' }
|
||||
const user = userSnap.data() as User
|
||||
|
||||
const contractDoc = firestore.doc(`contracts/${contractId}`)
|
||||
const contractSnap = await transaction.get(contractDoc)
|
||||
if (!contractSnap.exists)
|
||||
return { status: 'error', message: 'Invalid contract' }
|
||||
const contract = contractSnap.data() as Contract
|
||||
const { closeTime, mechanism, collectedFees, volume } = contract
|
||||
|
||||
if (mechanism !== 'dpm-2')
|
||||
return {
|
||||
status: 'error',
|
||||
message: 'Sell shares only works with mechanism dpm-2',
|
||||
}
|
||||
|
||||
if (closeTime && Date.now() > closeTime)
|
||||
return { status: 'error', message: 'Trading is closed' }
|
||||
|
||||
const userDoc = firestore.doc(`users/${auth.uid}`)
|
||||
const betDoc = firestore.doc(`contracts/${contractId}/bets/${betId}`)
|
||||
const betSnap = await transaction.get(betDoc)
|
||||
if (!betSnap.exists) return { status: 'error', message: 'Invalid bet' }
|
||||
const [contractSnap, userSnap, betSnap] = await Promise.all([
|
||||
transaction.get(contractDoc),
|
||||
transaction.get(userDoc),
|
||||
transaction.get(betDoc),
|
||||
])
|
||||
if (!contractSnap.exists) throw new APIError(400, 'Contract not found.')
|
||||
if (!userSnap.exists) throw new APIError(400, 'User not found.')
|
||||
if (!betSnap.exists) throw new APIError(400, 'Bet not found.')
|
||||
|
||||
const contract = contractSnap.data() as Contract
|
||||
const user = userSnap.data() as User
|
||||
const bet = betSnap.data() as Bet
|
||||
|
||||
if (userId !== bet.userId)
|
||||
return { status: 'error', message: 'Not authorized' }
|
||||
if (bet.isSold) return { status: 'error', message: 'Bet already sold' }
|
||||
const { closeTime, mechanism, collectedFees, volume } = contract
|
||||
if (mechanism !== 'dpm-2')
|
||||
throw new APIError(400, 'You can only sell bets on DPM-2 contracts.')
|
||||
if (closeTime && Date.now() > closeTime)
|
||||
throw new APIError(400, 'Trading is closed.')
|
||||
|
||||
const newBetDoc = firestore
|
||||
.collection(`contracts/${contractId}/bets`)
|
||||
.doc()
|
||||
if (auth.uid !== bet.userId)
|
||||
throw new APIError(400, 'The specified bet does not belong to you.')
|
||||
if (bet.isSold)
|
||||
throw new APIError(400, 'The specified bet is already sold.')
|
||||
|
||||
const {
|
||||
newBet,
|
||||
newPool,
|
||||
newTotalShares,
|
||||
newTotalBets,
|
||||
newBalance,
|
||||
fees,
|
||||
} = getSellBetInfo(user, bet, contract, newBetDoc.id)
|
||||
const { newBet, newPool, newTotalShares, newTotalBets, fees } =
|
||||
getSellBetInfo(bet, contract)
|
||||
|
||||
/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */
|
||||
const saleAmount = newBet.sale!.amount
|
||||
const newBalance = user.balance + saleAmount - (bet.loanAmount ?? 0)
|
||||
const newBetDoc = firestore.collection(`contracts/${contractId}/bets`).doc()
|
||||
|
||||
if (!isFinite(newBalance)) {
|
||||
throw new Error('Invalid user balance for ' + user.username)
|
||||
}
|
||||
transaction.update(userDoc, { balance: newBalance })
|
||||
|
||||
transaction.update(betDoc, { isSold: true })
|
||||
transaction.create(newBetDoc, newBet)
|
||||
transaction.create(newBetDoc, { id: betDoc.id, userId: user.id, ...newBet })
|
||||
transaction.update(
|
||||
contractDoc,
|
||||
removeUndefinedProps({
|
||||
|
@ -84,9 +67,8 @@ export const sellBet = functions.runWith({ minInstances: 1 }).https.onCall(
|
|||
})
|
||||
)
|
||||
|
||||
return { status: 'success' }
|
||||
return {}
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
const firestore = admin.firestore()
|
||||
|
|
|
@ -1,101 +1,76 @@
|
|||
import { partition, sumBy } from 'lodash'
|
||||
import { sumBy } from 'lodash'
|
||||
import * as admin from 'firebase-admin'
|
||||
import * as functions from 'firebase-functions'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { BinaryContract } from '../../common/contract'
|
||||
import { APIError, newEndpoint, validate } from './api'
|
||||
import { Contract, CPMM_MIN_POOL_QTY } from '../../common/contract'
|
||||
import { User } from '../../common/user'
|
||||
import { getCpmmSellBetInfo } from '../../common/sell-bet'
|
||||
import { addObjects, removeUndefinedProps } from '../../common/util/object'
|
||||
import { getValues } from './utils'
|
||||
import { Bet } from '../../common/bet'
|
||||
|
||||
export const sellShares = functions.runWith({ minInstances: 1 }).https.onCall(
|
||||
async (
|
||||
data: {
|
||||
contractId: string
|
||||
shares: number
|
||||
outcome: 'YES' | 'NO'
|
||||
},
|
||||
context
|
||||
) => {
|
||||
const userId = context?.auth?.uid
|
||||
if (!userId) return { status: 'error', message: 'Not authorized' }
|
||||
const bodySchema = z.object({
|
||||
contractId: z.string(),
|
||||
shares: z.number(),
|
||||
outcome: z.enum(['YES', 'NO']),
|
||||
})
|
||||
|
||||
const { contractId, shares, outcome } = data
|
||||
export const sellshares = newEndpoint(['POST'], async (req, auth) => {
|
||||
const { contractId, shares, outcome } = validate(bodySchema, req.body)
|
||||
|
||||
// Run as transaction to prevent race conditions.
|
||||
return await firestore.runTransaction(async (transaction) => {
|
||||
const userDoc = firestore.doc(`users/${userId}`)
|
||||
const userSnap = await transaction.get(userDoc)
|
||||
if (!userSnap.exists)
|
||||
return { status: 'error', message: 'User not found' }
|
||||
const contractDoc = firestore.doc(`contracts/${contractId}`)
|
||||
const userDoc = firestore.doc(`users/${auth.uid}`)
|
||||
const betsQ = contractDoc.collection('bets').where('userId', '==', auth.uid)
|
||||
const [contractSnap, userSnap, userBets] = await Promise.all([
|
||||
transaction.get(contractDoc),
|
||||
transaction.get(userDoc),
|
||||
getValues<Bet>(betsQ), // TODO: why is this not in the transaction??
|
||||
])
|
||||
if (!contractSnap.exists) throw new APIError(400, 'Contract not found.')
|
||||
if (!userSnap.exists) throw new APIError(400, 'User not found.')
|
||||
|
||||
const contract = contractSnap.data() as Contract
|
||||
const user = userSnap.data() as User
|
||||
|
||||
const contractDoc = firestore.doc(`contracts/${contractId}`)
|
||||
const contractSnap = await transaction.get(contractDoc)
|
||||
if (!contractSnap.exists)
|
||||
return { status: 'error', message: 'Invalid contract' }
|
||||
const contract = contractSnap.data() as BinaryContract
|
||||
const { closeTime, mechanism, collectedFees, volume } = contract
|
||||
|
||||
if (mechanism !== 'cpmm-1')
|
||||
return {
|
||||
status: 'error',
|
||||
message: 'Sell shares only works with mechanism cpmm-1',
|
||||
}
|
||||
|
||||
throw new APIError(400, 'You can only sell shares on CPMM-1 contracts.')
|
||||
if (closeTime && Date.now() > closeTime)
|
||||
return { status: 'error', message: 'Trading is closed' }
|
||||
|
||||
const userBets = await getValues<Bet>(
|
||||
contractDoc.collection('bets').where('userId', '==', userId)
|
||||
)
|
||||
throw new APIError(400, 'Trading is closed.')
|
||||
|
||||
const prevLoanAmount = sumBy(userBets, (bet) => bet.loanAmount ?? 0)
|
||||
|
||||
const [yesBets, noBets] = partition(
|
||||
userBets ?? [],
|
||||
(bet) => bet.outcome === 'YES'
|
||||
)
|
||||
const [yesShares, noShares] = [
|
||||
sumBy(yesBets, (bet) => bet.shares),
|
||||
sumBy(noBets, (bet) => bet.shares),
|
||||
]
|
||||
const outcomeBets = userBets.filter((bet) => bet.outcome == outcome)
|
||||
const maxShares = sumBy(outcomeBets, (bet) => bet.shares)
|
||||
|
||||
const maxShares = outcome === 'YES' ? yesShares : noShares
|
||||
if (shares > maxShares + 0.000000000001) {
|
||||
return {
|
||||
status: 'error',
|
||||
message: `You can only sell ${maxShares} shares`,
|
||||
}
|
||||
}
|
||||
if (shares > maxShares + 0.000000000001)
|
||||
throw new APIError(400, `You can only sell up to ${maxShares} shares.`)
|
||||
|
||||
const newBetDoc = firestore
|
||||
.collection(`contracts/${contractId}/bets`)
|
||||
.doc()
|
||||
|
||||
const { newBet, newPool, newP, newBalance, fees } = getCpmmSellBetInfo(
|
||||
user,
|
||||
const { newBet, newPool, newP, fees } = getCpmmSellBetInfo(
|
||||
shares,
|
||||
outcome,
|
||||
contract,
|
||||
prevLoanAmount,
|
||||
newBetDoc.id
|
||||
prevLoanAmount
|
||||
)
|
||||
|
||||
if (!isFinite(newP)) {
|
||||
return {
|
||||
status: 'error',
|
||||
message: 'Trade rejected due to overflow error.',
|
||||
}
|
||||
if (
|
||||
!newP ||
|
||||
!isFinite(newP) ||
|
||||
Math.min(...Object.values(newPool ?? {})) < CPMM_MIN_POOL_QTY
|
||||
) {
|
||||
throw new APIError(400, 'Sale too large for current liquidity pool.')
|
||||
}
|
||||
|
||||
if (!isFinite(newBalance)) {
|
||||
throw new Error('Invalid user balance for ' + user.username)
|
||||
}
|
||||
const newBetDoc = firestore.collection(`contracts/${contractId}/bets`).doc()
|
||||
const newBalance = user.balance - newBet.amount + (newBet.loanAmount ?? 0)
|
||||
const userId = user.id
|
||||
|
||||
transaction.update(userDoc, { balance: newBalance })
|
||||
transaction.create(newBetDoc, newBet)
|
||||
transaction.create(newBetDoc, { id: newBetDoc.id, userId, ...newBet })
|
||||
transaction.update(
|
||||
contractDoc,
|
||||
removeUndefinedProps({
|
||||
|
@ -108,7 +83,6 @@ export const sellShares = functions.runWith({ minInstances: 1 }).https.onCall(
|
|||
|
||||
return { status: 'success' }
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
const firestore = admin.firestore()
|
||||
|
|
|
@ -4,6 +4,7 @@ import Stripe from 'stripe'
|
|||
|
||||
import { getPrivateUser, getUser, isProd, payUser } from './utils'
|
||||
import { sendThankYouEmail } from './emails'
|
||||
import { track } from './analytics'
|
||||
|
||||
export type StripeSession = Stripe.Event.Data.Object & {
|
||||
id: string
|
||||
|
@ -27,7 +28,7 @@ const initStripe = () => {
|
|||
}
|
||||
|
||||
// manage at https://dashboard.stripe.com/test/products?active=true
|
||||
const manticDollarStripePrice = isProd
|
||||
const manticDollarStripePrice = isProd()
|
||||
? {
|
||||
500: 'price_1KFQXcGdoFKoCJW770gTNBrm',
|
||||
1000: 'price_1KFQp1GdoFKoCJW7Iu0dsF65',
|
||||
|
@ -90,7 +91,7 @@ export const createCheckoutSession = functions
|
|||
export const stripeWebhook = functions
|
||||
.runWith({
|
||||
minInstances: 1,
|
||||
secrets: ['STRIPE_APIKEY', 'STRIPE_WEBHOOKSECRET'],
|
||||
secrets: ['MAILGUN_KEY', 'STRIPE_APIKEY', 'STRIPE_WEBHOOKSECRET'],
|
||||
})
|
||||
.https.onRequest(async (req, res) => {
|
||||
const stripe = initStripe()
|
||||
|
@ -152,6 +153,13 @@ const issueMoneys = async (session: StripeSession) => {
|
|||
if (!privateUser) return
|
||||
|
||||
await sendThankYouEmail(user, privateUser)
|
||||
|
||||
await track(
|
||||
userId,
|
||||
'M$ purchase',
|
||||
{ amount: payout, sessionId },
|
||||
{ revenue: payout / 100 }
|
||||
)
|
||||
}
|
||||
|
||||
const firestore = admin.firestore()
|
||||
|
|
|
@ -5,13 +5,15 @@ import { User } from '../../common/user'
|
|||
import { Txn } from '../../common/txn'
|
||||
import { removeUndefinedProps } from '../../common/util/object'
|
||||
|
||||
export type TxnData = Omit<Txn, 'id' | 'createdTime'>
|
||||
|
||||
export const transact = functions
|
||||
.runWith({ minInstances: 1 })
|
||||
.https.onCall(async (data: Omit<Txn, 'id' | 'createdTime'>, context) => {
|
||||
.https.onCall(async (data: TxnData, context) => {
|
||||
const userId = context?.auth?.uid
|
||||
if (!userId) return { status: 'error', message: 'Not authorized' }
|
||||
|
||||
const { amount, fromType, fromId, toId, toType, description } = data
|
||||
const { amount, fromType, fromId } = data
|
||||
|
||||
if (fromType !== 'USER')
|
||||
return {
|
||||
|
@ -25,13 +27,23 @@ export const transact = functions
|
|||
message: 'Must be authenticated with userId equal to specified fromId.',
|
||||
}
|
||||
|
||||
if (amount <= 0 || isNaN(amount) || !isFinite(amount))
|
||||
if (isNaN(amount) || !isFinite(amount))
|
||||
return { status: 'error', message: 'Invalid amount' }
|
||||
|
||||
// Run as transaction to prevent race conditions.
|
||||
return await firestore.runTransaction(async (transaction) => {
|
||||
const fromDoc = firestore.doc(`users/${userId}`)
|
||||
const fromSnap = await transaction.get(fromDoc)
|
||||
await runTxn(transaction, data)
|
||||
})
|
||||
})
|
||||
|
||||
export async function runTxn(
|
||||
fbTransaction: admin.firestore.Transaction,
|
||||
data: TxnData
|
||||
) {
|
||||
const { amount, fromId, toId, toType } = data
|
||||
|
||||
const fromDoc = firestore.doc(`users/${fromId}`)
|
||||
const fromSnap = await fbTransaction.get(fromDoc)
|
||||
if (!fromSnap.exists) {
|
||||
return { status: 'error', message: 'User not found' }
|
||||
}
|
||||
|
@ -44,45 +56,29 @@ export const transact = functions
|
|||
}
|
||||
}
|
||||
|
||||
// TODO: Track payments received by charities, bank, contracts too.
|
||||
if (toType === 'USER') {
|
||||
const toDoc = firestore.doc(`users/${toId}`)
|
||||
const toSnap = await transaction.get(toDoc)
|
||||
const toSnap = await fbTransaction.get(toDoc)
|
||||
if (!toSnap.exists) {
|
||||
return { status: 'error', message: 'User not found' }
|
||||
}
|
||||
const toUser = toSnap.data() as User
|
||||
transaction.update(toDoc, {
|
||||
fbTransaction.update(toDoc, {
|
||||
balance: toUser.balance + amount,
|
||||
totalDeposits: toUser.totalDeposits + amount,
|
||||
})
|
||||
}
|
||||
|
||||
const newTxnDoc = firestore.collection(`txns/`).doc()
|
||||
|
||||
const txn: Txn = removeUndefinedProps({
|
||||
id: newTxnDoc.id,
|
||||
createdTime: Date.now(),
|
||||
|
||||
fromId,
|
||||
fromType,
|
||||
toId,
|
||||
toType,
|
||||
|
||||
amount,
|
||||
// TODO: Unhardcode once we have non-donation txns
|
||||
token: 'M$',
|
||||
category: 'CHARITY',
|
||||
description,
|
||||
})
|
||||
|
||||
transaction.create(newTxnDoc, txn)
|
||||
transaction.update(fromDoc, {
|
||||
const txn = { id: newTxnDoc.id, createdTime: Date.now(), ...data }
|
||||
fbTransaction.create(newTxnDoc, removeUndefinedProps(txn))
|
||||
fbTransaction.update(fromDoc, {
|
||||
balance: fromUser.balance - amount,
|
||||
totalDeposits: fromUser.totalDeposits - amount,
|
||||
})
|
||||
|
||||
return { status: 'success', txn }
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const firestore = admin.firestore()
|
||||
|
|
|
@ -1,43 +0,0 @@
|
|||
import * as functions from 'firebase-functions'
|
||||
import * as admin from 'firebase-admin'
|
||||
import { sumBy } from 'lodash'
|
||||
|
||||
import { getValues } from './utils'
|
||||
import { Contract } from '../../common/contract'
|
||||
import { Bet } from '../../common/bet'
|
||||
import { batchedWaitAll } from '../../common/util/promise'
|
||||
|
||||
const firestore = admin.firestore()
|
||||
|
||||
const oneDay = 1000 * 60 * 60 * 24
|
||||
|
||||
export const updateContractMetrics = functions.pubsub
|
||||
.schedule('every 15 minutes')
|
||||
.onRun(async () => {
|
||||
const contracts = await getValues<Contract>(
|
||||
firestore.collection('contracts')
|
||||
)
|
||||
|
||||
await batchedWaitAll(
|
||||
contracts.map((contract) => async () => {
|
||||
const volume24Hours = await computeVolumeFrom(contract, oneDay)
|
||||
const volume7Days = await computeVolumeFrom(contract, oneDay * 7)
|
||||
|
||||
const contractRef = firestore.doc(`contracts/${contract.id}`)
|
||||
return contractRef.update({
|
||||
volume24Hours,
|
||||
volume7Days,
|
||||
})
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
const computeVolumeFrom = async (contract: Contract, timeAgoMs: number) => {
|
||||
const bets = await getValues<Bet>(
|
||||
firestore
|
||||
.collection(`contracts/${contract.id}/bets`)
|
||||
.where('createdTime', '>', Date.now() - timeAgoMs)
|
||||
)
|
||||
|
||||
return sumBy(bets, (bet) => (bet.isRedemption ? 0 : Math.abs(bet.amount)))
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
import * as functions from 'firebase-functions'
|
||||
import * as admin from 'firebase-admin'
|
||||
import { shuffle, sortBy } from 'lodash'
|
||||
import { flatten, shuffle, sortBy, uniq, zip, zipObject } from 'lodash'
|
||||
|
||||
import { getValue, getValues } from './utils'
|
||||
import { Contract } from '../../common/contract'
|
||||
|
@ -67,15 +67,16 @@ export const updateFeedBatch = functions.https.onCall(
|
|||
async (data: { users: User[] }) => {
|
||||
const { users } = data
|
||||
const contracts = await getFeedContracts()
|
||||
|
||||
const feeds = await getNewFeeds(users, contracts)
|
||||
await Promise.all(
|
||||
users.map(async (user) => {
|
||||
const feed = await computeFeed(user, contracts)
|
||||
await getUserCacheCollection(user).doc('feed').set({ feed })
|
||||
})
|
||||
zip(users, feeds).map(([user, feed]) =>
|
||||
/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */
|
||||
getUserCacheCollection(user!).doc('feed').set({ feed })
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
export const updateCategoryFeed = functions.https.onCall(
|
||||
async (data: { category: string }) => {
|
||||
const { category } = data
|
||||
|
@ -96,16 +97,28 @@ export const updateCategoryFeedBatch = functions.https.onCall(
|
|||
async (data: { users: User[]; category: string }) => {
|
||||
const { users, category } = data
|
||||
const contracts = await getTaggedContracts(category)
|
||||
|
||||
const feeds = await getNewFeeds(users, contracts)
|
||||
await Promise.all(
|
||||
users.map(async (user) => {
|
||||
const feed = await computeFeed(user, contracts)
|
||||
await getUserCacheCollection(user).doc(`feed-${category}`).set({ feed })
|
||||
})
|
||||
zip(users, feeds).map(([user, feed]) =>
|
||||
/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */
|
||||
getUserCacheCollection(user!).doc(`feed-${category}`).set({ feed })
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
const getNewFeeds = async (users: User[], contracts: Contract[]) => {
|
||||
const feeds = await Promise.all(users.map((u) => computeFeed(u, contracts)))
|
||||
const contractIds = uniq(flatten(feeds).map((c) => c.id))
|
||||
const data = await Promise.all(contractIds.map(getRecentBetsAndComments))
|
||||
const dataByContractId = zipObject(contractIds, data)
|
||||
return feeds.map((feed) =>
|
||||
feed.map((contract) => {
|
||||
return { contract, ...dataByContractId[contract.id] }
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const getUserCacheCollection = (user: User) =>
|
||||
firestore.collection(`private-users/${user.id}/cache`)
|
||||
|
||||
|
@ -135,14 +148,7 @@ export const computeFeed = async (user: User, contracts: Contract[]) => {
|
|||
|
||||
// console.log(sortedContracts.map(([c, score]) => c.question + ': ' + score))
|
||||
|
||||
const feedContracts = sortedContracts
|
||||
.slice(0, MAX_FEED_CONTRACTS)
|
||||
.map(([c]) => c)
|
||||
|
||||
const feed = await Promise.all(
|
||||
feedContracts.map((contract) => getRecentBetsAndComments(contract))
|
||||
)
|
||||
return feed
|
||||
return sortedContracts.slice(0, MAX_FEED_CONTRACTS).map(([c]) => c)
|
||||
}
|
||||
|
||||
function scoreContract(
|
||||
|
|
241
functions/src/update-metrics.ts
Normal file
241
functions/src/update-metrics.ts
Normal file
|
@ -0,0 +1,241 @@
|
|||
import * as functions from 'firebase-functions'
|
||||
import * as admin from 'firebase-admin'
|
||||
import { groupBy, isEmpty, sum, sumBy } from 'lodash'
|
||||
import { getValues, log, logMemory, writeAsync } from './utils'
|
||||
import { Bet } from '../../common/bet'
|
||||
import { Contract } from '../../common/contract'
|
||||
import { PortfolioMetrics, User } from '../../common/user'
|
||||
import { calculatePayout } from '../../common/calculate'
|
||||
import { DAY_MS } from '../../common/util/time'
|
||||
import { last } from 'lodash'
|
||||
|
||||
const firestore = admin.firestore()
|
||||
|
||||
const oneDay = 1000 * 60 * 60 * 24
|
||||
|
||||
const computeInvestmentValue = (
|
||||
bets: Bet[],
|
||||
contractsDict: { [k: string]: Contract }
|
||||
) => {
|
||||
return sumBy(bets, (bet) => {
|
||||
const contract = contractsDict[bet.contractId]
|
||||
if (!contract || contract.isResolved) return 0
|
||||
if (bet.sale || bet.isSold) return 0
|
||||
|
||||
const payout = calculatePayout(contract, bet, 'MKT')
|
||||
return payout - (bet.loanAmount ?? 0)
|
||||
})
|
||||
}
|
||||
|
||||
const computeTotalPool = (userContracts: Contract[], startTime = 0) => {
|
||||
const periodFilteredContracts = userContracts.filter(
|
||||
(contract) => contract.createdTime >= startTime
|
||||
)
|
||||
return sum(
|
||||
periodFilteredContracts.map((contract) => sum(Object.values(contract.pool)))
|
||||
)
|
||||
}
|
||||
|
||||
export const updateMetricsCore = async () => {
|
||||
const [users, contracts, bets, allPortfolioHistories] = await Promise.all([
|
||||
getValues<User>(firestore.collection('users')),
|
||||
getValues<Contract>(firestore.collection('contracts')),
|
||||
getValues<Bet>(firestore.collectionGroup('bets')),
|
||||
getValues<PortfolioMetrics>(
|
||||
firestore
|
||||
.collectionGroup('portfolioHistory')
|
||||
.where('timestamp', '>', Date.now() - 31 * DAY_MS) // so it includes just over a month ago
|
||||
),
|
||||
])
|
||||
log(
|
||||
`Loaded ${users.length} users, ${contracts.length} contracts, and ${bets.length} bets.`
|
||||
)
|
||||
logMemory()
|
||||
|
||||
const now = Date.now()
|
||||
const betsByContract = groupBy(bets, (bet) => bet.contractId)
|
||||
const contractUpdates = contracts.map((contract) => {
|
||||
const contractBets = betsByContract[contract.id] ?? []
|
||||
return {
|
||||
doc: firestore.collection('contracts').doc(contract.id),
|
||||
fields: {
|
||||
volume24Hours: computeVolume(contractBets, now - oneDay),
|
||||
volume7Days: computeVolume(contractBets, now - oneDay * 7),
|
||||
},
|
||||
}
|
||||
})
|
||||
await writeAsync(firestore, contractUpdates)
|
||||
log(`Updated metrics for ${contracts.length} contracts.`)
|
||||
|
||||
const contractsById = Object.fromEntries(
|
||||
contracts.map((contract) => [contract.id, contract])
|
||||
)
|
||||
const contractsByUser = groupBy(contracts, (contract) => contract.creatorId)
|
||||
const betsByUser = groupBy(bets, (bet) => bet.userId)
|
||||
const portfolioHistoryByUser = groupBy(allPortfolioHistories, (p) => p.userId)
|
||||
const userUpdates = users.map((user) => {
|
||||
const currentBets = betsByUser[user.id] ?? []
|
||||
const portfolioHistory = portfolioHistoryByUser[user.id] ?? []
|
||||
const userContracts = contractsByUser[user.id] ?? []
|
||||
const newCreatorVolume = calculateCreatorVolume(userContracts)
|
||||
const newPortfolio = calculateNewPortfolioMetrics(
|
||||
user,
|
||||
contractsById,
|
||||
currentBets
|
||||
)
|
||||
const lastPortfolio = last(portfolioHistory)
|
||||
const didProfitChange =
|
||||
lastPortfolio === undefined ||
|
||||
lastPortfolio.balance !== newPortfolio.balance ||
|
||||
lastPortfolio.totalDeposits !== newPortfolio.totalDeposits ||
|
||||
lastPortfolio.investmentValue !== newPortfolio.investmentValue
|
||||
|
||||
const newProfit = calculateNewProfit(
|
||||
portfolioHistory,
|
||||
newPortfolio,
|
||||
didProfitChange
|
||||
)
|
||||
|
||||
return {
|
||||
fieldUpdates: {
|
||||
doc: firestore.collection('users').doc(user.id),
|
||||
fields: {
|
||||
creatorVolumeCached: newCreatorVolume,
|
||||
...(didProfitChange && {
|
||||
profitCached: newProfit,
|
||||
}),
|
||||
},
|
||||
},
|
||||
|
||||
subcollectionUpdates: {
|
||||
doc: firestore
|
||||
.collection('users')
|
||||
.doc(user.id)
|
||||
.collection('portfolioHistory')
|
||||
.doc(),
|
||||
fields: {
|
||||
...(didProfitChange && {
|
||||
...newPortfolio,
|
||||
}),
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
await writeAsync(
|
||||
firestore,
|
||||
userUpdates.map((u) => u.fieldUpdates)
|
||||
)
|
||||
await writeAsync(
|
||||
firestore,
|
||||
userUpdates
|
||||
.filter((u) => !isEmpty(u.subcollectionUpdates.fields))
|
||||
.map((u) => u.subcollectionUpdates),
|
||||
'set'
|
||||
)
|
||||
log(`Updated metrics for ${users.length} users.`)
|
||||
}
|
||||
|
||||
const computeVolume = (contractBets: Bet[], since: number) => {
|
||||
return sumBy(contractBets, (b) =>
|
||||
b.createdTime > since && !b.isRedemption ? Math.abs(b.amount) : 0
|
||||
)
|
||||
}
|
||||
|
||||
const calculateProfitForPeriod = (
|
||||
startTime: number,
|
||||
portfolioHistory: PortfolioMetrics[],
|
||||
currentProfit: number
|
||||
) => {
|
||||
const startingPortfolio = [...portfolioHistory]
|
||||
.reverse() // so we search in descending order (most recent first), for efficiency
|
||||
.find((p) => p.timestamp < startTime)
|
||||
|
||||
if (startingPortfolio === undefined) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const startingProfit = calculateTotalProfit(startingPortfolio)
|
||||
|
||||
return currentProfit - startingProfit
|
||||
}
|
||||
|
||||
const calculateTotalProfit = (portfolio: PortfolioMetrics) => {
|
||||
return portfolio.investmentValue + portfolio.balance - portfolio.totalDeposits
|
||||
}
|
||||
|
||||
const calculateCreatorVolume = (userContracts: Contract[]) => {
|
||||
const allTimeCreatorVolume = computeTotalPool(userContracts, 0)
|
||||
const monthlyCreatorVolume = computeTotalPool(
|
||||
userContracts,
|
||||
Date.now() - 30 * DAY_MS
|
||||
)
|
||||
const weeklyCreatorVolume = computeTotalPool(
|
||||
userContracts,
|
||||
Date.now() - 7 * DAY_MS
|
||||
)
|
||||
|
||||
const dailyCreatorVolume = computeTotalPool(
|
||||
userContracts,
|
||||
Date.now() - 1 * DAY_MS
|
||||
)
|
||||
|
||||
return {
|
||||
daily: dailyCreatorVolume,
|
||||
weekly: weeklyCreatorVolume,
|
||||
monthly: monthlyCreatorVolume,
|
||||
allTime: allTimeCreatorVolume,
|
||||
}
|
||||
}
|
||||
|
||||
const calculateNewPortfolioMetrics = (
|
||||
user: User,
|
||||
contractsById: { [k: string]: Contract },
|
||||
currentBets: Bet[]
|
||||
) => {
|
||||
const investmentValue = computeInvestmentValue(currentBets, contractsById)
|
||||
const newPortfolio = {
|
||||
investmentValue: investmentValue,
|
||||
balance: user.balance,
|
||||
totalDeposits: user.totalDeposits,
|
||||
timestamp: Date.now(),
|
||||
userId: user.id,
|
||||
}
|
||||
return newPortfolio
|
||||
}
|
||||
|
||||
const calculateNewProfit = (
|
||||
portfolioHistory: PortfolioMetrics[],
|
||||
newPortfolio: PortfolioMetrics,
|
||||
didProfitChange: boolean
|
||||
) => {
|
||||
if (!didProfitChange) {
|
||||
return {} // early return for performance
|
||||
}
|
||||
|
||||
const allTimeProfit = calculateTotalProfit(newPortfolio)
|
||||
const newProfit = {
|
||||
daily: calculateProfitForPeriod(
|
||||
Date.now() - 1 * DAY_MS,
|
||||
portfolioHistory,
|
||||
allTimeProfit
|
||||
),
|
||||
weekly: calculateProfitForPeriod(
|
||||
Date.now() - 7 * DAY_MS,
|
||||
portfolioHistory,
|
||||
allTimeProfit
|
||||
),
|
||||
monthly: calculateProfitForPeriod(
|
||||
Date.now() - 30 * DAY_MS,
|
||||
portfolioHistory,
|
||||
allTimeProfit
|
||||
),
|
||||
allTime: allTimeProfit,
|
||||
}
|
||||
|
||||
return newProfit
|
||||
}
|
||||
|
||||
export const updateMetrics = functions
|
||||
.runWith({ memory: '1GB', timeoutSeconds: 540 })
|
||||
.pubsub.schedule('every 15 minutes')
|
||||
.onRun(updateMetricsCore)
|
|
@ -1,79 +0,0 @@
|
|||
import * as functions from 'firebase-functions'
|
||||
import * as admin from 'firebase-admin'
|
||||
import { sum, sumBy } from 'lodash'
|
||||
|
||||
import { getValues } from './utils'
|
||||
import { Contract } from '../../common/contract'
|
||||
import { Bet } from '../../common/bet'
|
||||
import { User } from '../../common/user'
|
||||
import { batchedWaitAll } from '../../common/util/promise'
|
||||
import { calculatePayout } from '../../common/calculate'
|
||||
|
||||
const firestore = admin.firestore()
|
||||
|
||||
export const updateUserMetrics = functions.pubsub
|
||||
.schedule('every 15 minutes')
|
||||
.onRun(async () => {
|
||||
const [users, contracts] = await Promise.all([
|
||||
getValues<User>(firestore.collection('users')),
|
||||
getValues<Contract>(firestore.collection('contracts')),
|
||||
])
|
||||
|
||||
const contractsDict = Object.fromEntries(
|
||||
contracts.map((contract) => [contract.id, contract])
|
||||
)
|
||||
|
||||
await batchedWaitAll(
|
||||
users.map((user) => async () => {
|
||||
const [investmentValue, creatorVolume] = await Promise.all([
|
||||
computeInvestmentValue(user, contractsDict),
|
||||
computeTotalPool(user, contractsDict),
|
||||
])
|
||||
|
||||
const totalValue = user.balance + investmentValue
|
||||
const totalPnL = totalValue - user.totalDeposits
|
||||
|
||||
await firestore.collection('users').doc(user.id).update({
|
||||
totalPnLCached: totalPnL,
|
||||
creatorVolumeCached: creatorVolume,
|
||||
})
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
const computeInvestmentValue = async (
|
||||
user: User,
|
||||
contractsDict: { [k: string]: Contract }
|
||||
) => {
|
||||
const query = firestore.collectionGroup('bets').where('userId', '==', user.id)
|
||||
const bets = await getValues<Bet>(query)
|
||||
|
||||
return sumBy(bets, (bet) => {
|
||||
const contract = contractsDict[bet.contractId]
|
||||
if (!contract || contract.isResolved) return 0
|
||||
if (bet.sale || bet.isSold) return 0
|
||||
|
||||
const payout = calculatePayout(contract, bet, 'MKT')
|
||||
return payout - (bet.loanAmount ?? 0)
|
||||
})
|
||||
}
|
||||
|
||||
const computeTotalPool = async (
|
||||
user: User,
|
||||
contractsDict: { [k: string]: Contract }
|
||||
) => {
|
||||
const creatorContracts = Object.values(contractsDict).filter(
|
||||
(contract) => contract.creatorId === user.id
|
||||
)
|
||||
const pools = creatorContracts.map((contract) =>
|
||||
sum(Object.values(contract.pool))
|
||||
)
|
||||
return sum(pools)
|
||||
}
|
||||
|
||||
// const computeVolume = async (contract: Contract) => {
|
||||
// const bets = await getValues<Bet>(
|
||||
// firestore.collection(`contracts/${contract.id}/bets`)
|
||||
// )
|
||||
// return sumBy(bets, (bet) => Math.abs(bet.amount))
|
||||
// }
|
|
@ -1,10 +1,49 @@
|
|||
import * as admin from 'firebase-admin'
|
||||
|
||||
import { chunk } from 'lodash'
|
||||
import { Contract } from '../../common/contract'
|
||||
import { PrivateUser, User } from '../../common/user'
|
||||
|
||||
export const isProd =
|
||||
admin.instanceId().app.options.projectId === 'mantic-markets'
|
||||
export const log = (...args: unknown[]) => {
|
||||
console.log(`[${new Date().toISOString()}]`, ...args)
|
||||
}
|
||||
|
||||
export const logMemory = () => {
|
||||
const used = process.memoryUsage()
|
||||
for (const [k, v] of Object.entries(used)) {
|
||||
log(`${k} ${Math.round((v / 1024 / 1024) * 100) / 100} MB`)
|
||||
}
|
||||
}
|
||||
|
||||
export type UpdateSpec = {
|
||||
doc: admin.firestore.DocumentReference
|
||||
fields: { [k: string]: unknown }
|
||||
}
|
||||
|
||||
export const writeAsync = async (
|
||||
db: admin.firestore.Firestore,
|
||||
updates: UpdateSpec[],
|
||||
operationType: 'update' | 'set' = 'update',
|
||||
batchSize = 500 // 500 = Firestore batch limit
|
||||
) => {
|
||||
const chunks = chunk(updates, batchSize)
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
log(`${i * batchSize}/${updates.length} updates written...`)
|
||||
const batch = db.batch()
|
||||
for (const { doc, fields } of chunks[i]) {
|
||||
if (operationType === 'update') {
|
||||
batch.update(doc, fields)
|
||||
} else {
|
||||
batch.set(doc, fields)
|
||||
}
|
||||
}
|
||||
await batch.commit()
|
||||
}
|
||||
}
|
||||
|
||||
export const isProd = () => {
|
||||
return admin.instanceId().app.options.projectId === 'mantic-markets'
|
||||
}
|
||||
|
||||
export const getDoc = async <T>(collection: string, doc: string) => {
|
||||
const snap = await admin.firestore().collection(collection).doc(doc).get()
|
||||
|
@ -36,6 +75,7 @@ export const getPrivateUser = (userId: string) => {
|
|||
}
|
||||
|
||||
export const getUserByUsername = async (username: string) => {
|
||||
const firestore = admin.firestore()
|
||||
const snap = await firestore
|
||||
.collection('users')
|
||||
.where('username', '==', username)
|
||||
|
@ -44,13 +84,12 @@ export const getUserByUsername = async (username: string) => {
|
|||
return snap.empty ? undefined : (snap.docs[0].data() as User)
|
||||
}
|
||||
|
||||
const firestore = admin.firestore()
|
||||
|
||||
const updateUserBalance = (
|
||||
userId: string,
|
||||
delta: number,
|
||||
isDeposit = false
|
||||
) => {
|
||||
const firestore = admin.firestore()
|
||||
return firestore.runTransaction(async (transaction) => {
|
||||
const userDoc = firestore.doc(`users/${userId}`)
|
||||
const userSnap = await transaction.get(userDoc)
|
||||
|
|
138
functions/src/withdraw-liquidity.ts
Normal file
138
functions/src/withdraw-liquidity.ts
Normal file
|
@ -0,0 +1,138 @@
|
|||
import * as functions from 'firebase-functions'
|
||||
import * as admin from 'firebase-admin'
|
||||
|
||||
import { CPMMContract } from '../../common/contract'
|
||||
import { User } from '../../common/user'
|
||||
import { subtractObjects } from '../../common/util/object'
|
||||
import { LiquidityProvision } from '../../common/liquidity-provision'
|
||||
import { getUserLiquidityShares } from '../../common/calculate-cpmm'
|
||||
import { Bet } from '../../common/bet'
|
||||
import { getProbability } from '../../common/calculate'
|
||||
import { noFees } from '../../common/fees'
|
||||
|
||||
import { APIError } from './api'
|
||||
import { redeemShares } from './redeem-shares'
|
||||
|
||||
export const withdrawLiquidity = functions
|
||||
.runWith({ minInstances: 1 })
|
||||
.https.onCall(
|
||||
async (
|
||||
data: {
|
||||
contractId: string
|
||||
},
|
||||
context
|
||||
) => {
|
||||
const userId = context?.auth?.uid
|
||||
if (!userId) return { status: 'error', message: 'Not authorized' }
|
||||
|
||||
const { contractId } = data
|
||||
if (!contractId)
|
||||
return { status: 'error', message: 'Missing contract id' }
|
||||
|
||||
return await firestore
|
||||
.runTransaction(async (trans) => {
|
||||
const lpDoc = firestore.doc(`users/${userId}`)
|
||||
const lpSnap = await trans.get(lpDoc)
|
||||
if (!lpSnap.exists) throw new APIError(400, 'User not found.')
|
||||
const lp = lpSnap.data() as User
|
||||
|
||||
const contractDoc = firestore.doc(`contracts/${contractId}`)
|
||||
const contractSnap = await trans.get(contractDoc)
|
||||
if (!contractSnap.exists)
|
||||
throw new APIError(400, 'Contract not found.')
|
||||
const contract = contractSnap.data() as CPMMContract
|
||||
|
||||
const liquidityCollection = firestore.collection(
|
||||
`contracts/${contractId}/liquidity`
|
||||
)
|
||||
|
||||
const liquiditiesSnap = await trans.get(liquidityCollection)
|
||||
|
||||
const liquidities = liquiditiesSnap.docs.map(
|
||||
(doc) => doc.data() as LiquidityProvision
|
||||
)
|
||||
|
||||
const userShares = getUserLiquidityShares(
|
||||
userId,
|
||||
contract,
|
||||
liquidities
|
||||
)
|
||||
|
||||
// zero all added amounts for now
|
||||
// can add support for partial withdrawals in the future
|
||||
liquiditiesSnap.docs
|
||||
.filter(
|
||||
(_, i) =>
|
||||
!liquidities[i].isAnte && liquidities[i].userId === userId
|
||||
)
|
||||
.forEach((doc) => trans.update(doc.ref, { amount: 0 }))
|
||||
|
||||
const payout = Math.min(...Object.values(userShares))
|
||||
if (payout <= 0) return {}
|
||||
|
||||
const newBalance = lp.balance + payout
|
||||
const newTotalDeposits = lp.totalDeposits + payout
|
||||
trans.update(lpDoc, {
|
||||
balance: newBalance,
|
||||
totalDeposits: newTotalDeposits,
|
||||
} as Partial<User>)
|
||||
|
||||
const newPool = subtractObjects(contract.pool, userShares)
|
||||
|
||||
const minPoolShares = Math.min(...Object.values(newPool))
|
||||
const adjustedTotal = contract.totalLiquidity - payout
|
||||
|
||||
// total liquidity is a bogus number; use minPoolShares to prevent from going negative
|
||||
const newTotalLiquidity = Math.max(adjustedTotal, minPoolShares)
|
||||
|
||||
trans.update(contractDoc, {
|
||||
pool: newPool,
|
||||
totalLiquidity: newTotalLiquidity,
|
||||
})
|
||||
|
||||
const prob = getProbability(contract)
|
||||
|
||||
// surplus shares become user's bets
|
||||
const bets = Object.entries(userShares)
|
||||
.map(([outcome, shares]) =>
|
||||
shares - payout < 1 // don't create bet if less than 1 share
|
||||
? undefined
|
||||
: ({
|
||||
userId: userId,
|
||||
contractId: contract.id,
|
||||
amount:
|
||||
(outcome === 'YES' ? prob : 1 - prob) * (shares - payout),
|
||||
shares: shares - payout,
|
||||
outcome,
|
||||
probBefore: prob,
|
||||
probAfter: prob,
|
||||
createdTime: Date.now(),
|
||||
isLiquidityProvision: true,
|
||||
fees: noFees,
|
||||
} as Omit<Bet, 'id'>)
|
||||
)
|
||||
.filter((x) => x !== undefined)
|
||||
|
||||
for (const bet of bets) {
|
||||
const doc = firestore
|
||||
.collection(`contracts/${contract.id}/bets`)
|
||||
.doc()
|
||||
trans.create(doc, { id: doc.id, ...bet })
|
||||
}
|
||||
|
||||
return userShares
|
||||
})
|
||||
.then(async (result) => {
|
||||
// redeem surplus bet with pre-existing bets
|
||||
await redeemShares(userId, contractId)
|
||||
|
||||
console.log('userid', userId, 'withdraws', result)
|
||||
return { status: 'success', userShares: result }
|
||||
})
|
||||
.catch((e) => {
|
||||
return { status: 'error', message: e.message }
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
const firestore = admin.firestore()
|
|
@ -1,6 +1,7 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": "../",
|
||||
"composite": true,
|
||||
"module": "commonjs",
|
||||
"noImplicitReturns": true,
|
||||
"outDir": "lib",
|
||||
|
@ -8,6 +9,11 @@
|
|||
"strict": true,
|
||||
"target": "es2017"
|
||||
},
|
||||
"references": [
|
||||
{
|
||||
"path": "../common"
|
||||
}
|
||||
],
|
||||
"compileOnSave": true,
|
||||
"include": ["src", "../common/**/*.ts"]
|
||||
"include": ["src"]
|
||||
}
|
||||
|
|
|
@ -8,8 +8,15 @@ module.exports = {
|
|||
],
|
||||
rules: {
|
||||
'@typescript-eslint/no-empty-function': 'off',
|
||||
'@typescript-eslint/no-unused-vars': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'warn',
|
||||
{
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
caughtErrorsIgnorePattern: '^_',
|
||||
},
|
||||
],
|
||||
'@next/next/no-img-element': 'off',
|
||||
'@next/next/no-typos': 'off',
|
||||
'lodash/import-scope': [2, 'member'],
|
||||
|
|
|
@ -24,7 +24,7 @@ export function AddFundsButton(props: { className?: string }) {
|
|||
className
|
||||
)}
|
||||
>
|
||||
Add funds
|
||||
Get M$
|
||||
</label>
|
||||
<input type="checkbox" id="add-funds" className="modal-toggle" />
|
||||
|
||||
|
@ -61,7 +61,7 @@ export function AddFundsButton(props: { className?: string }) {
|
|||
>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary bg-gradient-to-r from-teal-500 to-green-500 px-10 font-medium hover:from-teal-600 hover:to-green-600"
|
||||
className="btn btn-primary bg-gradient-to-r from-indigo-500 to-blue-500 px-10 font-medium hover:from-indigo-600 hover:to-blue-600"
|
||||
>
|
||||
Checkout
|
||||
</button>
|
||||
|
|
|
@ -1,87 +0,0 @@
|
|||
import clsx from 'clsx'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { Contract } from 'common/contract'
|
||||
import { formatMoney } from 'common/util/format'
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
import { addLiquidity } from 'web/lib/firebase/fn-call'
|
||||
import { AmountInput } from './amount-input'
|
||||
import { Row } from './layout/row'
|
||||
|
||||
export function AddLiquidityPanel(props: { contract: Contract }) {
|
||||
const { contract } = props
|
||||
const { id: contractId } = contract
|
||||
|
||||
const user = useUser()
|
||||
|
||||
const [amount, setAmount] = useState<number | undefined>(undefined)
|
||||
const [error, setError] = useState<string | undefined>(undefined)
|
||||
const [isSuccess, setIsSuccess] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const onAmountChange = (amount: number | undefined) => {
|
||||
setIsSuccess(false)
|
||||
setAmount(amount)
|
||||
|
||||
// Check for errors.
|
||||
if (amount !== undefined) {
|
||||
if (user && user.balance < amount) {
|
||||
setError('Insufficient balance')
|
||||
} else if (amount < 1) {
|
||||
setError('Minimum amount: ' + formatMoney(1))
|
||||
} else {
|
||||
setError(undefined)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const submit = () => {
|
||||
if (!amount) return
|
||||
|
||||
setIsLoading(true)
|
||||
setIsSuccess(false)
|
||||
|
||||
addLiquidity({ amount, contractId })
|
||||
.then((r) => {
|
||||
if (r.status === 'success') {
|
||||
setIsSuccess(true)
|
||||
setError(undefined)
|
||||
setIsLoading(false)
|
||||
} else {
|
||||
setError('Server error')
|
||||
}
|
||||
})
|
||||
.catch((e) => setError('Server error'))
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="text-gray-500">
|
||||
Subsidize this market by adding liquidity for traders.
|
||||
</div>
|
||||
|
||||
<Row>
|
||||
<AmountInput
|
||||
amount={amount}
|
||||
onChange={onAmountChange}
|
||||
label="M$"
|
||||
error={error}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<button
|
||||
className={clsx('btn btn-primary ml-2', isLoading && 'btn-disabled')}
|
||||
onClick={submit}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</Row>
|
||||
|
||||
{isSuccess && amount && (
|
||||
<div>Success! Added {formatMoney(amount)} in liquidity.</div>
|
||||
)}
|
||||
|
||||
{isLoading && <div>Processing...</div>}
|
||||
</>
|
||||
)
|
||||
}
|
24
web/components/alert-box.tsx
Normal file
24
web/components/alert-box.tsx
Normal file
|
@ -0,0 +1,24 @@
|
|||
import { ExclamationIcon } from '@heroicons/react/solid'
|
||||
import { Linkify } from './linkify'
|
||||
|
||||
export function AlertBox(props: { title: string; text: string }) {
|
||||
const { title, text } = props
|
||||
return (
|
||||
<div className="rounded-md bg-yellow-50 p-4">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<ExclamationIcon
|
||||
className="h-5 w-5 text-yellow-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-yellow-800">{title}</h3>
|
||||
<div className="mt-2 text-sm text-yellow-700">
|
||||
<Linkify text={text} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user