Compare commits
504 Commits
comment-po
...
main
Author | SHA1 | Date | |
---|---|---|---|
|
c762869b83 | ||
|
aaa09f49c0 | ||
|
4d214c01b4 | ||
|
0a70652667 | ||
|
b4162a0896 | ||
|
2ecece02c3 | ||
|
4359ad0530 | ||
|
3f8988bf27 | ||
|
41a46aad9b | ||
|
4097082c75 | ||
|
58cd0e57bd | ||
|
3a63503161 | ||
|
abd06f272b | ||
|
ab1c3020da | ||
|
c044460a91 | ||
|
7a6725ee77 | ||
|
823d1ddd4c | ||
|
7d490e0de1 | ||
|
96d2255cb1 | ||
|
4a139c5cc2 | ||
|
47eb8abed0 | ||
|
903fcc83b3 | ||
|
0615bb2d4b | ||
|
3508c94634 | ||
|
29c0dfe3fe | ||
|
18a3b66164 | ||
|
9e4f41253f | ||
|
546b0231e7 | ||
|
8bb44593f3 | ||
|
2c2bc61788 | ||
|
34c9dbb3e7 | ||
|
d6525bae9f | ||
|
da32a756a8 | ||
|
fa476c78dd | ||
|
e7ba7e715f | ||
|
9bf82c6082 | ||
|
3e1876f0dc | ||
|
5ba4a9dce7 | ||
|
4e5b78f4ee | ||
|
bc6fab399e | ||
|
c2d112e516 | ||
|
3cbe8ad8bb | ||
|
6226291e02 | ||
|
fa4dba4da3 | ||
|
9eff69be75 | ||
|
789bec2a4f | ||
|
18042cd4d1 | ||
|
04a126707b | ||
|
7a412fdb0d | ||
|
e2dc4c6b8f | ||
|
204d302d87 | ||
|
ae39c1175b | ||
|
c44f223064 | ||
|
aa717a767d | ||
|
d9f57b7daa | ||
|
93ceaa52c4 | ||
|
de76557326 | ||
|
da1fcb646f | ||
|
1d618ba337 | ||
|
2cda3a4d4f | ||
|
e44fc8ae13 | ||
|
e6a90e18e4 | ||
|
cee8caa3e8 | ||
|
b49264ddfa | ||
|
12ed569ff6 | ||
|
00acc262a0 | ||
|
fd7d4eb5e2 | ||
|
8ae1166c49 | ||
|
84e2b63c49 | ||
|
f19ef83ac2 | ||
|
0c11f3b450 | ||
|
de9ffa2b52 | ||
|
decb3213f6 | ||
|
ff6278b147 | ||
|
3fc53112b9 | ||
|
59cdc9f776 | ||
|
f587e0256d | ||
|
1c209f68f6 | ||
|
b4e7d88ed8 | ||
|
b2cd6bbe03 | ||
|
a6d5d5ad15 | ||
|
beeca57d4e | ||
|
fb8bd1acfb | ||
|
4215821f35 | ||
|
a71c3d6a4a | ||
|
cdcce421a8 | ||
|
8beff6eb1f | ||
|
f714918b88 | ||
|
946d74489f | ||
|
220d0841bd | ||
|
9d44190b9a | ||
|
3cdd790ae9 | ||
|
6c1ac89cbe | ||
|
0d8a84ef06 | ||
|
d528566ffa | ||
|
b0f8369d9c | ||
|
721c18cf6c | ||
|
43b06ae6fa | ||
|
bfdb5ae595 | ||
|
274f7fa849 | ||
|
d507c4092e | ||
|
e970a908c6 | ||
|
4fd0e5caad | ||
|
70b2b14f80 | ||
|
0ec15ff2f8 | ||
|
8bb9885aee | ||
|
c46c384d1d | ||
|
4f5c93be96 | ||
|
f03e5d7af0 | ||
|
fb0a09664e | ||
|
17d0fb7da6 | ||
|
867cdf2496 | ||
|
f26ba1c4a2 | ||
|
cdc64c6475 | ||
|
5d561acdf8 | ||
|
84f79ffe7c | ||
|
f6fd703005 | ||
|
b8ef272784 | ||
|
a4699b79ed | ||
|
66071e16fa | ||
|
b3136ebcac | ||
|
a143a96919 | ||
|
dea65a4ba0 | ||
|
a310963952 | ||
|
8d06e4b4d2 | ||
|
dc51e2cf46 | ||
|
4831c25ce0 | ||
|
60f2552139 | ||
|
4b8d381da5 | ||
|
565177b76f | ||
|
8bd21c6693 | ||
|
310a41d63e | ||
|
e1636d0f13 | ||
|
d00ea65279 | ||
|
60bb5379cb | ||
|
f3dedfb27a | ||
|
efa2e44937 | ||
|
84bc490ed3 | ||
|
443397b7dc | ||
|
b57ff68654 | ||
|
f0b35993c9 | ||
|
8f56ccad22 | ||
|
9e289146af | ||
|
4285198f09 | ||
|
f533d9bfcb | ||
|
71b0c71729 | ||
|
25333317b0 | ||
|
42a7d04b4d | ||
|
b1d386ca5a | ||
|
0dc8753a92 | ||
|
454f2d1417 | ||
|
d846b9fb30 | ||
|
77e0631ea4 | ||
|
badd67c278 | ||
|
80622dc7ee | ||
|
9d12fa3af0 | ||
|
d9c8925ea0 | ||
|
adb809f973 | ||
|
a63405ca7c | ||
|
7ca0fb72fc | ||
|
ac37f94cf7 | ||
|
bc5af50b0c | ||
|
4162cca3ff | ||
|
91da39370f | ||
|
2f2c586d5d | ||
|
853e3e4896 | ||
|
edbd0feb37 | ||
|
59de979949 | ||
|
b8d65acc3f | ||
|
26f04fb04a | ||
|
e127f9646a | ||
|
25ef17498a | ||
|
68075db3da | ||
|
e1f24f24a9 | ||
|
cd8245fbee | ||
|
f1e400765a | ||
|
94624c5387 | ||
|
7ce09ae39d | ||
|
935bdd12a7 | ||
|
5d7721e041 | ||
|
a149777c0e | ||
|
81fb2456bd | ||
|
f8ec306ee9 | ||
|
a53fb49ec3 | ||
|
7863a4232d | ||
|
a3b841423f | ||
|
b8911cafe8 | ||
|
60aa294131 | ||
|
0818a94307 | ||
|
a3acd3fa3c | ||
|
1ef1af8234 | ||
|
189da4a0cf | ||
|
10f0bbc63d | ||
|
2d56525d65 | ||
|
f1f8082600 | ||
|
ec006f25c4 | ||
|
b40a114168 | ||
|
4bbadeb27c | ||
|
2596d54831 | ||
|
0df5497ffb | ||
|
27dabc193c | ||
|
6ec1b38a21 | ||
|
f35eb42d7b | ||
|
18f8ad433d | ||
|
37e8f2ff5a | ||
|
328aa1457d | ||
|
b9ba3e75fa | ||
|
70bfec2742 | ||
|
26281556f7 | ||
|
730abf584a | ||
|
34d09316e0 | ||
|
6f41ab8efd | ||
|
f1207e87ec | ||
|
4e22b8e332 | ||
|
07de8cc86a | ||
|
f07a022d63 | ||
|
d42ec42b0e | ||
|
6fa4e17a58 | ||
|
af3a3a3934 | ||
|
9e3477970d | ||
|
3390c34d0a | ||
|
419219c703 | ||
|
8aaca848b2 | ||
|
e4d7d0a232 | ||
|
e9050973e1 | ||
|
83d9a1f3e2 | ||
|
49e97ddac1 | ||
|
a9d5dd7fc8 | ||
|
ddb186dd98 | ||
|
d2273087cf | ||
|
6a0b577aeb | ||
|
ca6197c7bb | ||
|
ed6ea011c2 | ||
|
83d33792aa | ||
|
583c5b225e | ||
|
9f256aa7a8 | ||
|
7a271fce29 | ||
|
d8ef363f06 | ||
|
8043fa515a | ||
|
f551e6c469 | ||
|
3f0b665753 | ||
|
40b07329bd | ||
|
7b9aeea0bd | ||
|
935ff7b97a | ||
|
c115b5cca7 | ||
|
d6bb27f97c | ||
|
bbce3e873e | ||
|
26f5e506b7 | ||
|
5adaa7253f | ||
|
a55d85d4b6 | ||
|
f085df96e3 | ||
|
1d2af2900b | ||
|
a48cec63fc | ||
|
e6374c4994 | ||
|
6ac467764d | ||
|
79af4b2be0 | ||
|
094bcaea17 | ||
|
c6e5e04e65 | ||
|
ee4d3947b8 | ||
|
45b281fac5 | ||
|
31c6cb7739 | ||
|
23ca3ff56a | ||
|
c3ffac34a1 | ||
|
375a4e089f | ||
|
efd83eaad4 | ||
|
8d70dc4800 | ||
|
a1dcf8d168 | ||
|
84aaeece9f | ||
|
27d765a4a1 | ||
|
5214f27be3 | ||
|
d0d223f7ad | ||
|
0c9226de41 | ||
|
ce48016f80 | ||
|
1515d8cab2 | ||
|
28cad9caf8 | ||
|
9a950dc080 | ||
|
42cc07e4a6 | ||
|
a5490c903f | ||
|
71975f307c | ||
|
ae4136348d | ||
|
67de983aac | ||
|
59b128dbe7 | ||
|
074a1fdde2 | ||
|
7c34805eeb | ||
|
77a5f8b9dd | ||
|
5ae9049295 | ||
|
d5d1284306 | ||
|
adb8bc476f | ||
|
f92f098f82 | ||
|
370edec890 | ||
|
f5a3abf0bc | ||
|
27e6534d94 | ||
|
1caf75d3b5 | ||
|
051c2905e1 | ||
|
1f7b9174b3 | ||
|
06571a3657 | ||
|
3fb43c16c4 | ||
|
603201a00f | ||
|
b517817ee3 | ||
|
80693620f0 | ||
|
f1ae54355d | ||
|
503038d2a2 | ||
|
bf8dca25b2 | ||
|
a82f447965 | ||
|
1f8c72b4c9 | ||
|
40c51c3d59 | ||
|
86ceea831b | ||
|
efb9ef7602 | ||
|
8c1131ebab | ||
|
2c223160ed | ||
|
11bd658c68 | ||
|
39638a3888 | ||
|
234820ecd4 | ||
|
4d996c2476 | ||
|
9ecf10496c | ||
|
42b27fcedd | ||
|
7bf59bcdd0 | ||
|
043b18da0e | ||
|
64951e691e | ||
|
9a90cc3835 | ||
|
10e361bcac | ||
|
a7f6cb7cfa | ||
|
359a768e14 | ||
|
42aea03415 | ||
|
0fb263efa4 | ||
|
747977556b | ||
|
37e8cfbbed | ||
|
701d0a06cd | ||
|
0ffd6c129a | ||
|
758dbfe398 | ||
|
33dfce3e16 | ||
|
af66d94c84 | ||
|
290a34bc64 | ||
|
4c2f9011d0 | ||
|
57b592b5aa | ||
|
fd31b7eaca | ||
|
1d645e5ff8 | ||
|
0b0b84a6ad | ||
|
2baae33a77 | ||
|
fac87f8e0c | ||
|
670c6faea8 | ||
|
09e4864b32 | ||
|
a445d9b7fa | ||
|
cb613705e9 | ||
|
aeeb47bdbe | ||
|
0844e5620a | ||
|
2d6fe308b8 | ||
|
759685258a | ||
|
b53e4acea6 | ||
|
2f1221f094 | ||
|
2f3ae5192e | ||
|
b0b1d72ba6 | ||
|
dc0b6dc6a6 | ||
|
89e26d077e | ||
|
38b7c898f6 | ||
|
1fc2f15dae | ||
|
3d146dd57d | ||
|
a219680701 | ||
|
1e2df99054 | ||
|
37beb584ef | ||
|
9815e7301f | ||
|
ac97e62f2e | ||
|
17d1b8575c | ||
|
a25acbe1db | ||
|
b2f81c1149 | ||
|
9d81e3b6d1 | ||
|
ab883ea777 | ||
|
3677de58c3 | ||
|
31de3636fd | ||
|
a90b765670 | ||
|
55f854115c | ||
|
138f34fc66 | ||
|
c16e5189f7 | ||
|
1bc1debbe8 | ||
|
608ee7b865 | ||
|
95c47aba1a | ||
|
f892c92e26 | ||
|
7e91133229 | ||
|
523689b525 | ||
|
b83e5db563 | ||
|
13b3613460 | ||
|
715bae57e0 | ||
|
5b5a919ed7 | ||
|
2625ab1549 | ||
|
262183e0e6 | ||
|
b7df1a7043 | ||
|
8929b2e6ba | ||
|
9fc1e855ff | ||
|
1755fb15d4 | ||
|
1e6b72059e | ||
|
2d1fd07834 | ||
|
ec1a9fab77 | ||
|
2cc08ba9e7 | ||
|
35aa6c0429 | ||
|
cd7ddae133 | ||
|
46fab105d9 | ||
|
4cc985634a | ||
|
15cd8b1f94 | ||
|
8862425120 | ||
|
be010da9f5 | ||
|
7f7e7acd61 | ||
|
1f2c7271b7 | ||
|
83de206e9e | ||
|
d55cedb36c | ||
|
eb762d9b9e | ||
|
dba938032f | ||
|
7c8e977d60 | ||
|
e0e6838711 | ||
|
513cf7b290 | ||
|
89c3ea559c | ||
|
9238b20242 | ||
|
925a9e850f | ||
|
8f88af4e2a | ||
|
5b54e7d468 | ||
|
f52127237e | ||
|
95f2604479 | ||
|
a5b943965c | ||
|
c16adb9ec9 | ||
|
e0d9b4d335 | ||
|
9dc0d1696e | ||
|
a7abdbb1db | ||
|
13dad9a10c | ||
|
14c008234a | ||
|
b87e29d7c0 | ||
|
3ed29877ce | ||
|
80d4bffc95 | ||
|
b21daa1248 | ||
|
419c7ab636 | ||
|
e2047210b7 | ||
|
5e34b5a911 | ||
|
723d9dbece | ||
|
7ba19c274b | ||
|
a12ed78813 | ||
|
aa93ec060d | ||
|
fd90bc353b | ||
|
e17a59ae23 | ||
|
2fe9fe593d | ||
|
d612192109 | ||
|
13cffcdaf1 | ||
|
1b9811ce28 | ||
|
3ed3b6fb42 | ||
|
f7bf42d2e0 | ||
|
df316fc4da | ||
|
2ef025a151 | ||
|
90eaf83775 | ||
|
94ffac287e | ||
|
a10e4c115e | ||
|
cc3b44891b | ||
|
d9292f7a95 | ||
|
bf92c4fb06 | ||
|
68120ec2b2 | ||
|
be2c60d3f3 | ||
|
c1c3a360fd | ||
|
ae4d49d960 | ||
|
21c7130d3b | ||
|
d990bc2f07 | ||
|
e2a8df6c3a | ||
|
96dc060a0a | ||
|
d04304bdac | ||
|
2891a47d8c | ||
|
490734db00 | ||
|
77ddc456a2 | ||
|
1a5dcdedcc | ||
|
0ab82a7bd4 | ||
|
deb8397ee9 | ||
|
57190e7876 | ||
|
5a10132e2b | ||
|
ebcecd4fe9 | ||
|
61a9224a7d | ||
|
47c97c36db | ||
|
5483955590 | ||
|
91f89ccb3d | ||
|
08202c3ede | ||
|
70bc5b2c4a | ||
|
c6d034545a | ||
|
eaaa46294a | ||
|
2240db9baa | ||
|
a1c3d0a2dd | ||
|
7704de6904 | ||
|
721448f408 | ||
|
6ee8d90bdb | ||
|
6fe0a22a48 | ||
|
b9fffcfa30 | ||
|
0c0e7b5582 | ||
|
06db5515f6 | ||
|
a5e293c010 | ||
|
4412d0195c | ||
|
c15285aa64 | ||
|
9ff2b62740 | ||
|
e9ab234d61 | ||
|
7988fdde60 | ||
|
b875ac563d | ||
|
b4a59cfb21 | ||
|
d922900bda | ||
|
24766740c5 | ||
|
73fad2e34b | ||
|
a10605e74c | ||
|
c7f29af2ee | ||
|
ea1579975c | ||
|
6e2aa622ab | ||
|
54778ec1b1 | ||
|
8870f0d356 | ||
|
be4def49a2 | ||
|
589bf9651d |
17
.github/workflows/merge-main-into-main2.yml
vendored
Normal file
17
.github/workflows/merge-main-into-main2.yml
vendored
Normal file
|
@ -0,0 +1,17 @@
|
|||
name: Merge main into main2 on every commit
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'main'
|
||||
jobs:
|
||||
merge-branch:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@master
|
||||
|
||||
- name: Merge main -> main2
|
||||
uses: devmasx/merge-branch@master
|
||||
with:
|
||||
type: now
|
||||
target_branch: main2
|
||||
github_token: ${{ github.token }}
|
|
@ -26,7 +26,7 @@ module.exports = {
|
|||
caughtErrorsIgnorePattern: '^_',
|
||||
},
|
||||
],
|
||||
'unused-imports/no-unused-imports': 'error',
|
||||
'unused-imports/no-unused-imports': 'warn',
|
||||
},
|
||||
},
|
||||
],
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { addCpmmLiquidity, getCpmmLiquidity } from './calculate-cpmm'
|
||||
import { getCpmmLiquidity } from './calculate-cpmm'
|
||||
import { CPMMContract } from './contract'
|
||||
import { LiquidityProvision } from './liquidity-provision'
|
||||
|
||||
|
@ -8,25 +8,23 @@ export const getNewLiquidityProvision = (
|
|||
contract: CPMMContract,
|
||||
newLiquidityProvisionId: string
|
||||
) => {
|
||||
const { pool, p, totalLiquidity } = contract
|
||||
const { pool, p, totalLiquidity, subsidyPool } = contract
|
||||
|
||||
const { newPool, newP } = addCpmmLiquidity(pool, p, amount)
|
||||
|
||||
const liquidity =
|
||||
getCpmmLiquidity(newPool, newP) - getCpmmLiquidity(pool, newP)
|
||||
const liquidity = getCpmmLiquidity(pool, p)
|
||||
|
||||
const newLiquidityProvision: LiquidityProvision = {
|
||||
id: newLiquidityProvisionId,
|
||||
userId: userId,
|
||||
contractId: contract.id,
|
||||
amount,
|
||||
pool: newPool,
|
||||
p: newP,
|
||||
pool,
|
||||
p,
|
||||
liquidity,
|
||||
createdTime: Date.now(),
|
||||
}
|
||||
|
||||
const newTotalLiquidity = (totalLiquidity ?? 0) + amount
|
||||
const newSubsidyPool = (subsidyPool ?? 0) + amount
|
||||
|
||||
return { newLiquidityProvision, newPool, newP, newTotalLiquidity }
|
||||
return { newLiquidityProvision, newTotalLiquidity, newSubsidyPool }
|
||||
}
|
||||
|
|
123
common/badge.ts
Normal file
123
common/badge.ts
Normal file
|
@ -0,0 +1,123 @@
|
|||
import { User } from './user'
|
||||
|
||||
export type Badge = {
|
||||
type: BadgeTypes
|
||||
createdTime: number
|
||||
data: { [key: string]: any }
|
||||
name: 'Proven Correct' | 'Streaker' | 'Market Creator'
|
||||
}
|
||||
|
||||
export type BadgeTypes = 'PROVEN_CORRECT' | 'STREAKER' | 'MARKET_CREATOR'
|
||||
|
||||
export type ProvenCorrectBadgeData = {
|
||||
type: 'PROVEN_CORRECT'
|
||||
data: {
|
||||
contractSlug: string
|
||||
contractCreatorUsername: string
|
||||
contractTitle: string
|
||||
commentId: string
|
||||
betAmount: number
|
||||
}
|
||||
}
|
||||
|
||||
export type MarketCreatorBadgeData = {
|
||||
type: 'MARKET_CREATOR'
|
||||
data: {
|
||||
totalContractsCreated: number
|
||||
}
|
||||
}
|
||||
|
||||
export type StreakerBadgeData = {
|
||||
type: 'STREAKER'
|
||||
data: {
|
||||
totalBettingStreak: number
|
||||
}
|
||||
}
|
||||
|
||||
export type ProvenCorrectBadge = Badge & ProvenCorrectBadgeData
|
||||
export type StreakerBadge = Badge & StreakerBadgeData
|
||||
export type MarketCreatorBadge = Badge & MarketCreatorBadgeData
|
||||
|
||||
export const MINIMUM_UNIQUE_BETTORS_FOR_PROVEN_CORRECT_BADGE = 5
|
||||
export const provenCorrectRarityThresholds = [1, 1000, 10000]
|
||||
const calculateProvenCorrectBadgeRarity = (badge: ProvenCorrectBadge) => {
|
||||
const { betAmount } = badge.data
|
||||
const thresholdArray = provenCorrectRarityThresholds
|
||||
let i = thresholdArray.length - 1
|
||||
while (i >= 0) {
|
||||
if (betAmount >= thresholdArray[i]) {
|
||||
return i + 1
|
||||
}
|
||||
i--
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
export const streakerBadgeRarityThresholds = [1, 50, 250]
|
||||
const calculateStreakerBadgeRarity = (badge: StreakerBadge) => {
|
||||
const { totalBettingStreak } = badge.data
|
||||
const thresholdArray = streakerBadgeRarityThresholds
|
||||
let i = thresholdArray.length - 1
|
||||
while (i >= 0) {
|
||||
if (totalBettingStreak == thresholdArray[i]) {
|
||||
return i + 1
|
||||
}
|
||||
i--
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
export const marketCreatorBadgeRarityThresholds = [1, 75, 300]
|
||||
const calculateMarketCreatorBadgeRarity = (badge: MarketCreatorBadge) => {
|
||||
const { totalContractsCreated } = badge.data
|
||||
const thresholdArray = marketCreatorBadgeRarityThresholds
|
||||
let i = thresholdArray.length - 1
|
||||
while (i >= 0) {
|
||||
if (totalContractsCreated == thresholdArray[i]) {
|
||||
return i + 1
|
||||
}
|
||||
i--
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
export type rarities = 'bronze' | 'silver' | 'gold'
|
||||
|
||||
const rarityRanks: { [key: number]: rarities } = {
|
||||
1: 'bronze',
|
||||
2: 'silver',
|
||||
3: 'gold',
|
||||
}
|
||||
|
||||
export const calculateBadgeRarity = (badge: Badge) => {
|
||||
switch (badge.type) {
|
||||
case 'PROVEN_CORRECT':
|
||||
return rarityRanks[
|
||||
calculateProvenCorrectBadgeRarity(badge as ProvenCorrectBadge)
|
||||
]
|
||||
case 'MARKET_CREATOR':
|
||||
return rarityRanks[
|
||||
calculateMarketCreatorBadgeRarity(badge as MarketCreatorBadge)
|
||||
]
|
||||
case 'STREAKER':
|
||||
return rarityRanks[calculateStreakerBadgeRarity(badge as StreakerBadge)]
|
||||
default:
|
||||
return rarityRanks[0]
|
||||
}
|
||||
}
|
||||
|
||||
export const getBadgesByRarity = (user: User | null | undefined) => {
|
||||
const rarities: { [key in rarities]: number } = {
|
||||
bronze: 0,
|
||||
silver: 0,
|
||||
gold: 0,
|
||||
}
|
||||
if (!user) return rarities
|
||||
Object.values(user.achievements).map((value) => {
|
||||
value.badges.map((badge) => {
|
||||
rarities[calculateBadgeRarity(badge)] =
|
||||
(rarities[calculateBadgeRarity(badge)] ?? 0) + 1
|
||||
})
|
||||
})
|
||||
return rarities
|
||||
}
|
|
@ -1,11 +1,10 @@
|
|||
import { sum, groupBy, mapValues, sumBy } from 'lodash'
|
||||
import { groupBy, mapValues, sumBy } from 'lodash'
|
||||
import { LimitBet } from './bet'
|
||||
|
||||
import { CREATOR_FEE, Fees, LIQUIDITY_FEE, PLATFORM_FEE } from './fees'
|
||||
import { LiquidityProvision } from './liquidity-provision'
|
||||
import { computeFills } from './new-bet'
|
||||
import { binarySearch } from './util/algos'
|
||||
import { addObjects } from './util/object'
|
||||
|
||||
export type CpmmState = {
|
||||
pool: { [outcome: string]: number }
|
||||
|
@ -147,7 +146,8 @@ function calculateAmountToBuyShares(
|
|||
state: CpmmState,
|
||||
shares: number,
|
||||
outcome: 'YES' | 'NO',
|
||||
unfilledBets: LimitBet[]
|
||||
unfilledBets: LimitBet[],
|
||||
balanceByUserId: { [userId: string]: number }
|
||||
) {
|
||||
// Search for amount between bounds (0, shares).
|
||||
// Min share price is M$0, and max is M$1 each.
|
||||
|
@ -157,7 +157,8 @@ function calculateAmountToBuyShares(
|
|||
amount,
|
||||
state,
|
||||
undefined,
|
||||
unfilledBets
|
||||
unfilledBets,
|
||||
balanceByUserId
|
||||
)
|
||||
|
||||
const totalShares = sumBy(takers, (taker) => taker.shares)
|
||||
|
@ -169,7 +170,8 @@ export function calculateCpmmSale(
|
|||
state: CpmmState,
|
||||
shares: number,
|
||||
outcome: 'YES' | 'NO',
|
||||
unfilledBets: LimitBet[]
|
||||
unfilledBets: LimitBet[],
|
||||
balanceByUserId: { [userId: string]: number }
|
||||
) {
|
||||
if (Math.round(shares) < 0) {
|
||||
throw new Error('Cannot sell non-positive shares')
|
||||
|
@ -180,15 +182,17 @@ export function calculateCpmmSale(
|
|||
state,
|
||||
shares,
|
||||
oppositeOutcome,
|
||||
unfilledBets
|
||||
unfilledBets,
|
||||
balanceByUserId
|
||||
)
|
||||
|
||||
const { cpmmState, makers, takers, totalFees } = computeFills(
|
||||
const { cpmmState, makers, takers, totalFees, ordersToCancel } = computeFills(
|
||||
oppositeOutcome,
|
||||
buyAmount,
|
||||
state,
|
||||
undefined,
|
||||
unfilledBets
|
||||
unfilledBets,
|
||||
balanceByUserId
|
||||
)
|
||||
|
||||
// Transform buys of opposite outcome into sells.
|
||||
|
@ -211,6 +215,7 @@ export function calculateCpmmSale(
|
|||
fees: totalFees,
|
||||
makers,
|
||||
takers: saleTakers,
|
||||
ordersToCancel,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -218,9 +223,16 @@ export function getCpmmProbabilityAfterSale(
|
|||
state: CpmmState,
|
||||
shares: number,
|
||||
outcome: 'YES' | 'NO',
|
||||
unfilledBets: LimitBet[]
|
||||
unfilledBets: LimitBet[],
|
||||
balanceByUserId: { [userId: string]: number }
|
||||
) {
|
||||
const { cpmmState } = calculateCpmmSale(state, shares, outcome, unfilledBets)
|
||||
const { cpmmState } = calculateCpmmSale(
|
||||
state,
|
||||
shares,
|
||||
outcome,
|
||||
unfilledBets,
|
||||
balanceByUserId
|
||||
)
|
||||
return getCpmmProbability(cpmmState.pool, cpmmState.p)
|
||||
}
|
||||
|
||||
|
@ -254,48 +266,22 @@ export function addCpmmLiquidity(
|
|||
return { newPool, liquidity, newP }
|
||||
}
|
||||
|
||||
const calculateLiquidityDelta = (p: number) => (l: LiquidityProvision) => {
|
||||
const oldLiquidity = getCpmmLiquidity(l.pool, p)
|
||||
export function getCpmmLiquidityPoolWeights(liquidities: LiquidityProvision[]) {
|
||||
const userAmounts = groupBy(liquidities, (w) => w.userId)
|
||||
const totalAmount = sumBy(liquidities, (w) => w.amount)
|
||||
|
||||
const newPool = addObjects(l.pool, { YES: l.amount, NO: l.amount })
|
||||
const newLiquidity = getCpmmLiquidity(newPool, p)
|
||||
|
||||
const liquidity = newLiquidity - oldLiquidity
|
||||
return liquidity
|
||||
}
|
||||
|
||||
export function getCpmmLiquidityPoolWeights(
|
||||
state: CpmmState,
|
||||
liquidities: LiquidityProvision[],
|
||||
excludeAntes: boolean
|
||||
) {
|
||||
const calcLiqudity = calculateLiquidityDelta(state.p)
|
||||
const liquidityShares = liquidities.map(calcLiqudity)
|
||||
const shareSum = sum(liquidityShares)
|
||||
|
||||
const weights = liquidityShares.map((shares, i) => ({
|
||||
weight: shares / shareSum,
|
||||
providerId: liquidities[i].userId,
|
||||
}))
|
||||
|
||||
const includedWeights = excludeAntes
|
||||
? weights.filter((_, i) => !liquidities[i].isAnte)
|
||||
: weights
|
||||
|
||||
const userWeights = groupBy(includedWeights, (w) => w.providerId)
|
||||
const totalUserWeights = mapValues(userWeights, (userWeight) =>
|
||||
sumBy(userWeight, (w) => w.weight)
|
||||
return mapValues(
|
||||
userAmounts,
|
||||
(amounts) => sumBy(amounts, (w) => w.amount) / totalAmount
|
||||
)
|
||||
return totalUserWeights
|
||||
}
|
||||
|
||||
export function getUserLiquidityShares(
|
||||
userId: string,
|
||||
state: CpmmState,
|
||||
liquidities: LiquidityProvision[],
|
||||
excludeAntes: boolean
|
||||
liquidities: LiquidityProvision[]
|
||||
) {
|
||||
const weights = getCpmmLiquidityPoolWeights(state, liquidities, excludeAntes)
|
||||
const weights = getCpmmLiquidityPoolWeights(liquidities)
|
||||
const userWeight = weights[userId] ?? 0
|
||||
|
||||
return mapValues(state.pool, (shares) => userWeight * shares)
|
||||
|
|
|
@ -1,9 +1,17 @@
|
|||
import { last, sortBy, sum, sumBy } from 'lodash'
|
||||
import { calculatePayout } from './calculate'
|
||||
import { Bet } from './bet'
|
||||
import { Contract } from './contract'
|
||||
import { Dictionary, groupBy, last, partition, sum, sumBy, uniq } from 'lodash'
|
||||
import { calculatePayout, getContractBetMetrics } from './calculate'
|
||||
import { Bet, LimitBet } from './bet'
|
||||
import {
|
||||
Contract,
|
||||
CPMMBinaryContract,
|
||||
CPMMContract,
|
||||
DPMContract,
|
||||
} from './contract'
|
||||
import { PortfolioMetrics, User } from './user'
|
||||
import { DAY_MS } from './util/time'
|
||||
import { getBinaryCpmmBetInfo, getNewMultiBetInfo } from './new-bet'
|
||||
import { getCpmmProbability } from './calculate-cpmm'
|
||||
import { removeUndefinedProps } from './util/object'
|
||||
|
||||
const computeInvestmentValue = (
|
||||
bets: Bet[],
|
||||
|
@ -21,6 +29,93 @@ const computeInvestmentValue = (
|
|||
})
|
||||
}
|
||||
|
||||
export const computeInvestmentValueCustomProb = (
|
||||
bets: Bet[],
|
||||
contract: Contract,
|
||||
p: number
|
||||
) => {
|
||||
return sumBy(bets, (bet) => {
|
||||
if (!contract || contract.isResolved) return 0
|
||||
if (bet.sale || bet.isSold) return 0
|
||||
const { outcome, shares } = bet
|
||||
|
||||
const betP = outcome === 'YES' ? p : 1 - p
|
||||
|
||||
const value = betP * shares
|
||||
if (isNaN(value)) return 0
|
||||
return value
|
||||
})
|
||||
}
|
||||
|
||||
export const computeElasticity = (
|
||||
bets: Bet[],
|
||||
contract: Contract,
|
||||
betAmount = 50
|
||||
) => {
|
||||
const { mechanism, outcomeType } = contract
|
||||
return mechanism === 'cpmm-1' &&
|
||||
(outcomeType === 'BINARY' || outcomeType === 'PSEUDO_NUMERIC')
|
||||
? computeBinaryCpmmElasticity(bets, contract, betAmount)
|
||||
: computeDpmElasticity(contract, betAmount)
|
||||
}
|
||||
|
||||
export const computeBinaryCpmmElasticity = (
|
||||
bets: Bet[],
|
||||
contract: CPMMContract,
|
||||
betAmount: number
|
||||
) => {
|
||||
const limitBets = bets
|
||||
.filter(
|
||||
(b) =>
|
||||
!b.isFilled &&
|
||||
!b.isSold &&
|
||||
!b.isRedemption &&
|
||||
!b.sale &&
|
||||
!b.isCancelled &&
|
||||
b.limitProb !== undefined
|
||||
)
|
||||
.sort((a, b) => a.createdTime - b.createdTime) as LimitBet[]
|
||||
|
||||
const userIds = uniq(limitBets.map((b) => b.userId))
|
||||
// Assume all limit orders are good.
|
||||
const userBalances = Object.fromEntries(
|
||||
userIds.map((id) => [id, Number.MAX_SAFE_INTEGER])
|
||||
)
|
||||
|
||||
const { newPool: poolY, newP: pY } = getBinaryCpmmBetInfo(
|
||||
'YES',
|
||||
betAmount,
|
||||
contract,
|
||||
undefined,
|
||||
limitBets,
|
||||
userBalances
|
||||
)
|
||||
const resultYes = getCpmmProbability(poolY, pY)
|
||||
|
||||
const { newPool: poolN, newP: pN } = getBinaryCpmmBetInfo(
|
||||
'NO',
|
||||
betAmount,
|
||||
contract,
|
||||
undefined,
|
||||
limitBets,
|
||||
userBalances
|
||||
)
|
||||
const resultNo = getCpmmProbability(poolN, pN)
|
||||
|
||||
// handle AMM overflow
|
||||
const safeYes = Number.isFinite(resultYes) ? resultYes : 1
|
||||
const safeNo = Number.isFinite(resultNo) ? resultNo : 0
|
||||
|
||||
return safeYes - safeNo
|
||||
}
|
||||
|
||||
export const computeDpmElasticity = (
|
||||
contract: DPMContract,
|
||||
betAmount: number
|
||||
) => {
|
||||
return getNewMultiBetInfo('', 2 * betAmount, contract).newBet.probAfter
|
||||
}
|
||||
|
||||
const computeTotalPool = (userContracts: Contract[], startTime = 0) => {
|
||||
const periodFilteredContracts = userContracts.filter(
|
||||
(contract) => contract.createdTime >= startTime
|
||||
|
@ -104,14 +199,9 @@ export const calculateNewPortfolioMetrics = (
|
|||
}
|
||||
|
||||
const calculateProfitForPeriod = (
|
||||
startTime: number,
|
||||
descendingPortfolio: PortfolioMetrics[],
|
||||
startingPortfolio: PortfolioMetrics | undefined,
|
||||
currentProfit: number
|
||||
) => {
|
||||
const startingPortfolio = descendingPortfolio.find(
|
||||
(p) => p.timestamp < startTime
|
||||
)
|
||||
|
||||
if (startingPortfolio === undefined) {
|
||||
return currentProfit
|
||||
}
|
||||
|
@ -126,33 +216,100 @@ export const calculatePortfolioProfit = (portfolio: PortfolioMetrics) => {
|
|||
}
|
||||
|
||||
export const calculateNewProfit = (
|
||||
portfolioHistory: PortfolioMetrics[],
|
||||
portfolioHistory: Record<
|
||||
'current' | 'day' | 'week' | 'month',
|
||||
PortfolioMetrics | undefined
|
||||
>,
|
||||
newPortfolio: PortfolioMetrics
|
||||
) => {
|
||||
const allTimeProfit = calculatePortfolioProfit(newPortfolio)
|
||||
const descendingPortfolio = sortBy(
|
||||
portfolioHistory,
|
||||
(p) => p.timestamp
|
||||
).reverse()
|
||||
|
||||
const newProfit = {
|
||||
daily: calculateProfitForPeriod(
|
||||
Date.now() - 1 * DAY_MS,
|
||||
descendingPortfolio,
|
||||
allTimeProfit
|
||||
),
|
||||
weekly: calculateProfitForPeriod(
|
||||
Date.now() - 7 * DAY_MS,
|
||||
descendingPortfolio,
|
||||
allTimeProfit
|
||||
),
|
||||
monthly: calculateProfitForPeriod(
|
||||
Date.now() - 30 * DAY_MS,
|
||||
descendingPortfolio,
|
||||
allTimeProfit
|
||||
),
|
||||
daily: calculateProfitForPeriod(portfolioHistory.day, allTimeProfit),
|
||||
weekly: calculateProfitForPeriod(portfolioHistory.week, allTimeProfit),
|
||||
monthly: calculateProfitForPeriod(portfolioHistory.month, allTimeProfit),
|
||||
allTime: allTimeProfit,
|
||||
}
|
||||
|
||||
return newProfit
|
||||
}
|
||||
|
||||
export const calculateMetricsByContract = (
|
||||
bets: Bet[],
|
||||
contractsById: Dictionary<Contract>
|
||||
) => {
|
||||
const betsByContract = groupBy(bets, (bet) => bet.contractId)
|
||||
const unresolvedContracts = Object.keys(betsByContract)
|
||||
.map((cid) => contractsById[cid])
|
||||
.filter((c) => c && !c.isResolved)
|
||||
|
||||
return unresolvedContracts.map((c) => {
|
||||
const bets = betsByContract[c.id] ?? []
|
||||
const current = getContractBetMetrics(c, bets)
|
||||
|
||||
let periodMetrics
|
||||
if (c.mechanism === 'cpmm-1' && c.outcomeType === 'BINARY') {
|
||||
const periods = ['day', 'week', 'month'] as const
|
||||
periodMetrics = Object.fromEntries(
|
||||
periods.map((period) => [
|
||||
period,
|
||||
calculatePeriodProfit(c, bets, period),
|
||||
])
|
||||
)
|
||||
}
|
||||
|
||||
return removeUndefinedProps({
|
||||
contractId: c.id,
|
||||
...current,
|
||||
from: periodMetrics,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export type ContractMetrics = ReturnType<
|
||||
typeof calculateMetricsByContract
|
||||
>[number]
|
||||
|
||||
const calculatePeriodProfit = (
|
||||
contract: CPMMBinaryContract,
|
||||
bets: Bet[],
|
||||
period: 'day' | 'week' | 'month'
|
||||
) => {
|
||||
const days = period === 'day' ? 1 : period === 'week' ? 7 : 30
|
||||
const fromTime = Date.now() - days * DAY_MS
|
||||
const [previousBets, recentBets] = partition(
|
||||
bets,
|
||||
(b) => b.createdTime < fromTime
|
||||
)
|
||||
|
||||
const prevProb = contract.prob - contract.probChanges[period]
|
||||
const prob = contract.resolutionProbability
|
||||
? contract.resolutionProbability
|
||||
: contract.prob
|
||||
|
||||
const previousBetsValue = computeInvestmentValueCustomProb(
|
||||
previousBets,
|
||||
contract,
|
||||
prevProb
|
||||
)
|
||||
const currentBetsValue = computeInvestmentValueCustomProb(
|
||||
previousBets,
|
||||
contract,
|
||||
prob
|
||||
)
|
||||
|
||||
const { profit: recentProfit, invested: recentInvested } =
|
||||
getContractBetMetrics(contract, recentBets)
|
||||
|
||||
const profit = currentBetsValue - previousBetsValue + recentProfit
|
||||
const invested = previousBetsValue + recentInvested
|
||||
const profitPercent = invested === 0 ? 0 : 100 * (profit / invested)
|
||||
|
||||
return {
|
||||
profit,
|
||||
profitPercent,
|
||||
invested,
|
||||
prevValue: previousBetsValue,
|
||||
value: currentBetsValue,
|
||||
}
|
||||
}
|
||||
|
|
|
@ -78,7 +78,8 @@ export function calculateShares(
|
|||
export function calculateSaleAmount(
|
||||
contract: Contract,
|
||||
bet: Bet,
|
||||
unfilledBets: LimitBet[]
|
||||
unfilledBets: LimitBet[],
|
||||
balanceByUserId: { [userId: string]: number }
|
||||
) {
|
||||
return contract.mechanism === 'cpmm-1' &&
|
||||
(contract.outcomeType === 'BINARY' ||
|
||||
|
@ -87,7 +88,8 @@ export function calculateSaleAmount(
|
|||
contract,
|
||||
Math.abs(bet.shares),
|
||||
bet.outcome as 'YES' | 'NO',
|
||||
unfilledBets
|
||||
unfilledBets,
|
||||
balanceByUserId
|
||||
).saleValue
|
||||
: calculateDpmSaleAmount(contract, bet)
|
||||
}
|
||||
|
@ -102,14 +104,16 @@ export function getProbabilityAfterSale(
|
|||
contract: Contract,
|
||||
outcome: string,
|
||||
shares: number,
|
||||
unfilledBets: LimitBet[]
|
||||
unfilledBets: LimitBet[],
|
||||
balanceByUserId: { [userId: string]: number }
|
||||
) {
|
||||
return contract.mechanism === 'cpmm-1'
|
||||
? getCpmmProbabilityAfterSale(
|
||||
contract,
|
||||
shares,
|
||||
outcome as 'YES' | 'NO',
|
||||
unfilledBets
|
||||
unfilledBets,
|
||||
balanceByUserId
|
||||
)
|
||||
: getDpmProbabilityAfterSale(contract.totalShares, outcome, shares)
|
||||
}
|
||||
|
@ -174,6 +178,8 @@ function getDpmInvested(yourBets: Bet[]) {
|
|||
})
|
||||
}
|
||||
|
||||
export type ContractBetMetrics = ReturnType<typeof getContractBetMetrics>
|
||||
|
||||
export function getContractBetMetrics(contract: Contract, yourBets: Bet[]) {
|
||||
const { resolution } = contract
|
||||
const isCpmm = contract.mechanism === 'cpmm-1'
|
||||
|
@ -210,9 +216,8 @@ export function getContractBetMetrics(contract: Contract, yourBets: Bet[]) {
|
|||
}
|
||||
}
|
||||
|
||||
const netPayout = payout - loan
|
||||
const profit = payout + saleValue + redeemed - totalInvested
|
||||
const profitPercent = (profit / totalInvested) * 100
|
||||
const profitPercent = totalInvested === 0 ? 0 : (profit / totalInvested) * 100
|
||||
|
||||
const invested = isCpmm ? getCpmmInvested(yourBets) : getDpmInvested(yourBets)
|
||||
const hasShares = Object.values(totalShares).some(
|
||||
|
@ -221,8 +226,8 @@ export function getContractBetMetrics(contract: Contract, yourBets: Bet[]) {
|
|||
|
||||
return {
|
||||
invested,
|
||||
loan,
|
||||
payout,
|
||||
netPayout,
|
||||
profit,
|
||||
profitPercent,
|
||||
totalShares,
|
||||
|
@ -233,8 +238,8 @@ export function getContractBetMetrics(contract: Contract, yourBets: Bet[]) {
|
|||
export function getContractBetNullMetrics() {
|
||||
return {
|
||||
invested: 0,
|
||||
loan: 0,
|
||||
payout: 0,
|
||||
netPayout: 0,
|
||||
profit: 0,
|
||||
profitPercent: 0,
|
||||
totalShares: {} as { [outcome: string]: number },
|
||||
|
|
|
@ -576,7 +576,7 @@ Work towards sustainable, systemic change.`,
|
|||
|
||||
If you would like to support our work, you can do so by getting involved or by donating.`,
|
||||
},
|
||||
{
|
||||
{
|
||||
name: 'CaRLA',
|
||||
website: 'https://carlaef.org/',
|
||||
photo: 'https://i.imgur.com/IsNVTOY.png',
|
||||
|
@ -589,6 +589,15 @@ CaRLA uses legal advocacy and education to ensure all cities comply with their o
|
|||
|
||||
In addition to housing impact litigation, we provide free legal aid, education and workshops, counseling and advocacy to advocates, homeowners, small developers, and city and state government officials.`,
|
||||
},
|
||||
{
|
||||
name: 'Mriya',
|
||||
website: 'https://mriya-ua.org/',
|
||||
photo:
|
||||
'https://firebasestorage.googleapis.com/v0/b/mantic-markets.appspot.com/o/user-images%2Fdefault%2Fci2h3hStFM.47?alt=media&token=0d2cdc3d-e4d8-4f5e-8f23-4a586b6ff637',
|
||||
preview: 'Donate supplies to soldiers in Ukraine',
|
||||
description:
|
||||
'Donate supplies to soldiers in Ukraine, including tourniquets and plate carriers.',
|
||||
},
|
||||
].map((charity) => {
|
||||
const slug = charity.name.toLowerCase().replace(/\s/g, '-')
|
||||
return {
|
||||
|
|
|
@ -18,6 +18,7 @@ export type Comment<T extends AnyCommentType = AnyCommentType> = {
|
|||
userName: string
|
||||
userUsername: string
|
||||
userAvatarUrl?: string
|
||||
bountiesAwarded?: number
|
||||
} & T
|
||||
|
||||
export type OnContract = {
|
||||
|
|
|
@ -30,7 +30,7 @@ export function contractTextDetails(contract: Contract) {
|
|||
const { closeTime, groupLinks } = contract
|
||||
const { createdDate, resolvedDate, volumeLabel } = contractMetrics(contract)
|
||||
|
||||
const groupHashtags = groupLinks?.slice(0, 5).map((g) => `#${g.name}`)
|
||||
const groupHashtags = groupLinks?.map((g) => `#${g.name.replace(/ /g, '')}`)
|
||||
|
||||
return (
|
||||
`${resolvedDate ? `${createdDate} - ${resolvedDate}` : createdDate}` +
|
||||
|
|
|
@ -10,6 +10,7 @@ export type AnyOutcomeType =
|
|||
| PseudoNumeric
|
||||
| FreeResponse
|
||||
| Numeric
|
||||
|
||||
export type AnyContractType =
|
||||
| (CPMM & Binary)
|
||||
| (CPMM & PseudoNumeric)
|
||||
|
@ -49,6 +50,7 @@ export type Contract<T extends AnyContractType = AnyContractType> = {
|
|||
volume: number
|
||||
volume24Hours: number
|
||||
volume7Days: number
|
||||
elasticity: number
|
||||
|
||||
collectedFees: Fees
|
||||
|
||||
|
@ -57,10 +59,14 @@ export type Contract<T extends AnyContractType = AnyContractType> = {
|
|||
uniqueBettorIds?: string[]
|
||||
uniqueBettorCount?: number
|
||||
popularityScore?: number
|
||||
dailyScore?: number
|
||||
followerCount?: number
|
||||
featuredOnHomeRank?: number
|
||||
likedByUserIds?: string[]
|
||||
likedByUserCount?: number
|
||||
flaggedByUsernames?: string[]
|
||||
openCommentBounties?: number
|
||||
unlistedById?: string
|
||||
} & T
|
||||
|
||||
export type BinaryContract = Contract & Binary
|
||||
|
@ -86,7 +92,8 @@ export type CPMM = {
|
|||
mechanism: 'cpmm-1'
|
||||
pool: { [outcome: string]: number }
|
||||
p: number // probability constant in y^p * n^(1-p) = k
|
||||
totalLiquidity: number // in M$
|
||||
totalLiquidity: number // for historical reasons, this the total subsidy amount added in M$
|
||||
subsidyPool: number // current value of subsidy pool in M$
|
||||
prob: number
|
||||
probChanges: {
|
||||
day: number
|
||||
|
|
|
@ -11,7 +11,10 @@ export const REFERRAL_AMOUNT = econ?.REFERRAL_AMOUNT ?? 250
|
|||
|
||||
export const UNIQUE_BETTOR_BONUS_AMOUNT = econ?.UNIQUE_BETTOR_BONUS_AMOUNT ?? 10
|
||||
export const BETTING_STREAK_BONUS_AMOUNT =
|
||||
econ?.BETTING_STREAK_BONUS_AMOUNT ?? 10
|
||||
export const BETTING_STREAK_BONUS_MAX = econ?.BETTING_STREAK_BONUS_MAX ?? 50
|
||||
econ?.BETTING_STREAK_BONUS_AMOUNT ?? 5
|
||||
export const BETTING_STREAK_BONUS_MAX = econ?.BETTING_STREAK_BONUS_MAX ?? 25
|
||||
export const BETTING_STREAK_RESET_HOUR = econ?.BETTING_STREAK_RESET_HOUR ?? 7
|
||||
export const FREE_MARKETS_PER_USER_MAX = econ?.FREE_MARKETS_PER_USER_MAX ?? 5
|
||||
export const COMMENT_BOUNTY_AMOUNT = econ?.COMMENT_BOUNTY_AMOUNT ?? 250
|
||||
|
||||
export const UNIQUE_BETTOR_LIQUIDITY = 20
|
||||
|
|
|
@ -16,6 +16,6 @@ export const DEV_CONFIG: EnvConfig = {
|
|||
cloudRunId: 'w3txbmd3ba',
|
||||
cloudRunRegion: 'uc',
|
||||
amplitudeApiKey: 'fd8cbfd964b9a205b8678a39faae71b3',
|
||||
// this is Phil's deployment
|
||||
twitchBotEndpoint: 'https://king-prawn-app-5btyw.ondigitalocean.app',
|
||||
twitchBotEndpoint: 'https://dev-twitch-bot.manifold.markets',
|
||||
sprigEnvironmentId: 'Tu7kRZPm7daP',
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@ export type EnvConfig = {
|
|||
firebaseConfig: FirebaseConfig
|
||||
amplitudeApiKey?: string
|
||||
twitchBotEndpoint?: string
|
||||
sprigEnvironmentId?: string
|
||||
|
||||
// IDs for v2 cloud functions -- find these by deploying a cloud function and
|
||||
// examining the URL, https://[name]-[cloudRunId]-[cloudRunRegion].a.run.app
|
||||
|
@ -40,6 +41,7 @@ export type Economy = {
|
|||
BETTING_STREAK_BONUS_MAX?: number
|
||||
BETTING_STREAK_RESET_HOUR?: number
|
||||
FREE_MARKETS_PER_USER_MAX?: number
|
||||
COMMENT_BOUNTY_AMOUNT?: number
|
||||
}
|
||||
|
||||
type FirebaseConfig = {
|
||||
|
@ -56,6 +58,7 @@ type FirebaseConfig = {
|
|||
export const PROD_CONFIG: EnvConfig = {
|
||||
domain: 'manifold.markets',
|
||||
amplitudeApiKey: '2d6509fd4185ebb8be29709842752a15',
|
||||
sprigEnvironmentId: 'sQcrq9TDqkib',
|
||||
|
||||
firebaseConfig: {
|
||||
apiKey: 'AIzaSyDp3J57vLeAZCzxLD-vcPaGIkAmBoGOSYw',
|
||||
|
@ -67,7 +70,7 @@ export const PROD_CONFIG: EnvConfig = {
|
|||
appId: '1:128925704902:web:f61f86944d8ffa2a642dc7',
|
||||
measurementId: 'G-SSFK1Q138D',
|
||||
},
|
||||
twitchBotEndpoint: 'https://twitch-bot-nggbo3neva-uc.a.run.app',
|
||||
twitchBotEndpoint: 'https://twitch-bot.manifold.markets',
|
||||
cloudRunId: 'nggbo3neva',
|
||||
cloudRunRegion: 'uc',
|
||||
adminEmails: [
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
export const FLAT_TRADE_FEE = 0.1 // M$0.1
|
||||
|
||||
export const PLATFORM_FEE = 0
|
||||
export const CREATOR_FEE = 0
|
||||
export const LIQUIDITY_FEE = 0
|
||||
|
|
3
common/globalConfig.ts
Normal file
3
common/globalConfig.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
export type GlobalConfig = {
|
||||
pinnedItems: { itemId: string; type: 'post' | 'contract' }[]
|
||||
}
|
|
@ -10,6 +10,7 @@ export type Group = {
|
|||
totalContracts: number
|
||||
totalMembers: number
|
||||
aboutPostId?: string
|
||||
postIds: string[]
|
||||
chatDisabled?: boolean
|
||||
mostRecentContractAddedTime?: number
|
||||
cachedLeaderboard?: {
|
||||
|
@ -22,6 +23,7 @@ export type Group = {
|
|||
score: number
|
||||
}[]
|
||||
}
|
||||
pinnedItems: { itemId: string; type: 'post' | 'contract' }[]
|
||||
}
|
||||
|
||||
export const MAX_GROUP_NAME_LENGTH = 75
|
||||
|
@ -37,3 +39,4 @@ export type GroupLink = {
|
|||
createdTime: number
|
||||
userId?: string
|
||||
}
|
||||
export type GroupContractDoc = { contractId: string; createdTime: number }
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
export type Like = {
|
||||
id: string // will be id of the object liked, i.e. contract.id
|
||||
userId: string
|
||||
type: 'contract'
|
||||
type: 'contract' | 'post'
|
||||
createdTime: number
|
||||
tipTxnId?: string // only holds most recent tip txn id
|
||||
}
|
||||
export const LIKE_TIP_AMOUNT = 5
|
||||
export const LIKE_TIP_AMOUNT = 10
|
||||
export const TIP_UNDO_DURATION = 2000
|
||||
|
|
|
@ -17,8 +17,7 @@ import {
|
|||
import {
|
||||
CPMMBinaryContract,
|
||||
DPMBinaryContract,
|
||||
FreeResponseContract,
|
||||
MultipleChoiceContract,
|
||||
DPMContract,
|
||||
NumericContract,
|
||||
PseudoNumericContract,
|
||||
} from './contract'
|
||||
|
@ -144,7 +143,8 @@ export const computeFills = (
|
|||
betAmount: number,
|
||||
state: CpmmState,
|
||||
limitProb: number | undefined,
|
||||
unfilledBets: LimitBet[]
|
||||
unfilledBets: LimitBet[],
|
||||
balanceByUserId: { [userId: string]: number }
|
||||
) => {
|
||||
if (isNaN(betAmount)) {
|
||||
throw new Error('Invalid bet amount: ${betAmount}')
|
||||
|
@ -166,10 +166,12 @@ export const computeFills = (
|
|||
shares: number
|
||||
timestamp: number
|
||||
}[] = []
|
||||
const ordersToCancel: LimitBet[] = []
|
||||
|
||||
let amount = betAmount
|
||||
let cpmmState = { pool: state.pool, p: state.p }
|
||||
let totalFees = noFees
|
||||
const currentBalanceByUserId = { ...balanceByUserId }
|
||||
|
||||
let i = 0
|
||||
while (true) {
|
||||
|
@ -186,9 +188,20 @@ export const computeFills = (
|
|||
takers.push(taker)
|
||||
} else {
|
||||
// Matched against bet.
|
||||
i++
|
||||
const { userId } = maker.bet
|
||||
const makerBalance = currentBalanceByUserId[userId]
|
||||
|
||||
if (floatingGreaterEqual(makerBalance, maker.amount)) {
|
||||
currentBalanceByUserId[userId] = makerBalance - maker.amount
|
||||
} else {
|
||||
// Insufficient balance. Cancel maker bet.
|
||||
ordersToCancel.push(maker.bet)
|
||||
continue
|
||||
}
|
||||
|
||||
takers.push(taker)
|
||||
makers.push(maker)
|
||||
i++
|
||||
}
|
||||
|
||||
amount -= taker.amount
|
||||
|
@ -196,7 +209,7 @@ export const computeFills = (
|
|||
if (floatingEqual(amount, 0)) break
|
||||
}
|
||||
|
||||
return { takers, makers, totalFees, cpmmState }
|
||||
return { takers, makers, totalFees, cpmmState, ordersToCancel }
|
||||
}
|
||||
|
||||
export const getBinaryCpmmBetInfo = (
|
||||
|
@ -204,15 +217,17 @@ export const getBinaryCpmmBetInfo = (
|
|||
betAmount: number,
|
||||
contract: CPMMBinaryContract | PseudoNumericContract,
|
||||
limitProb: number | undefined,
|
||||
unfilledBets: LimitBet[]
|
||||
unfilledBets: LimitBet[],
|
||||
balanceByUserId: { [userId: string]: number }
|
||||
) => {
|
||||
const { pool, p } = contract
|
||||
const { takers, makers, cpmmState, totalFees } = computeFills(
|
||||
const { takers, makers, cpmmState, totalFees, ordersToCancel } = computeFills(
|
||||
outcome,
|
||||
betAmount,
|
||||
{ pool, p },
|
||||
limitProb,
|
||||
unfilledBets
|
||||
unfilledBets,
|
||||
balanceByUserId
|
||||
)
|
||||
const probBefore = getCpmmProbability(contract.pool, contract.p)
|
||||
const probAfter = getCpmmProbability(cpmmState.pool, cpmmState.p)
|
||||
|
@ -247,6 +262,7 @@ export const getBinaryCpmmBetInfo = (
|
|||
newP: cpmmState.p,
|
||||
newTotalLiquidity,
|
||||
makers,
|
||||
ordersToCancel,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -255,14 +271,16 @@ export const getBinaryBetStats = (
|
|||
betAmount: number,
|
||||
contract: CPMMBinaryContract | PseudoNumericContract,
|
||||
limitProb: number,
|
||||
unfilledBets: LimitBet[]
|
||||
unfilledBets: LimitBet[],
|
||||
balanceByUserId: { [userId: string]: number }
|
||||
) => {
|
||||
const { newBet } = getBinaryCpmmBetInfo(
|
||||
outcome,
|
||||
betAmount ?? 0,
|
||||
contract,
|
||||
limitProb,
|
||||
unfilledBets as LimitBet[]
|
||||
unfilledBets,
|
||||
balanceByUserId
|
||||
)
|
||||
const remainingMatched =
|
||||
((newBet.orderAmount ?? 0) - newBet.amount) /
|
||||
|
@ -325,7 +343,7 @@ export const getNewBinaryDpmBetInfo = (
|
|||
export const getNewMultiBetInfo = (
|
||||
outcome: string,
|
||||
amount: number,
|
||||
contract: FreeResponseContract | MultipleChoiceContract
|
||||
contract: DPMContract
|
||||
) => {
|
||||
const { pool, totalShares, totalBets } = contract
|
||||
|
||||
|
|
|
@ -12,7 +12,6 @@ import {
|
|||
visibility,
|
||||
} from './contract'
|
||||
import { User } from './user'
|
||||
import { parseTags, richTextToString } from './util/parse'
|
||||
import { removeUndefinedProps } from './util/object'
|
||||
import { JSONContent } from '@tiptap/core'
|
||||
|
||||
|
@ -38,15 +37,6 @@ export function getNewContract(
|
|||
answers: string[],
|
||||
visibility: visibility
|
||||
) {
|
||||
const tags = parseTags(
|
||||
[
|
||||
question,
|
||||
richTextToString(description),
|
||||
...extraTags.map((tag) => `#${tag}`),
|
||||
].join(' ')
|
||||
)
|
||||
const lowercaseTags = tags.map((tag) => tag.toLowerCase())
|
||||
|
||||
const propsByOutcomeType =
|
||||
outcomeType === 'BINARY'
|
||||
? getBinaryCpmmProps(initialProb, ante) // getBinaryDpmProps(initialProb, ante)
|
||||
|
@ -70,9 +60,10 @@ export function getNewContract(
|
|||
|
||||
question: question.trim(),
|
||||
description,
|
||||
tags,
|
||||
lowercaseTags,
|
||||
tags: [],
|
||||
lowercaseTags: [],
|
||||
visibility,
|
||||
unlistedById: visibility === 'unlisted' ? creator.id : undefined,
|
||||
isResolved: false,
|
||||
createdTime: Date.now(),
|
||||
closeTime,
|
||||
|
@ -80,6 +71,7 @@ export function getNewContract(
|
|||
volume: 0,
|
||||
volume24Hours: 0,
|
||||
volume7Days: 0,
|
||||
elasticity: propsByOutcomeType.mechanism === 'cpmm-1' ? 0.38 : 0.75,
|
||||
|
||||
collectedFees: {
|
||||
creatorFee: 0,
|
||||
|
@ -120,6 +112,7 @@ const getBinaryCpmmProps = (initialProb: number, ante: number) => {
|
|||
mechanism: 'cpmm-1',
|
||||
outcomeType: 'BINARY',
|
||||
totalLiquidity: ante,
|
||||
subsidyPool: 0,
|
||||
initialProbability: p,
|
||||
p,
|
||||
pool: pool,
|
||||
|
|
|
@ -4,7 +4,7 @@ export type Notification = {
|
|||
id: string
|
||||
userId: string
|
||||
reasonText?: string
|
||||
reason?: notification_reason_types
|
||||
reason?: notification_reason_types | notification_preference
|
||||
createdTime: number
|
||||
viewTime?: number
|
||||
isSeen: boolean
|
||||
|
@ -46,6 +46,7 @@ export type notification_source_types =
|
|||
| 'loan'
|
||||
| 'like'
|
||||
| 'tip_and_like'
|
||||
| 'badge'
|
||||
|
||||
export type notification_source_update_types =
|
||||
| 'created'
|
||||
|
@ -96,6 +97,7 @@ type notification_descriptions = {
|
|||
[key in notification_preference]: {
|
||||
simple: string
|
||||
detailed: string
|
||||
necessary?: boolean
|
||||
}
|
||||
}
|
||||
export const NOTIFICATION_DESCRIPTIONS: notification_descriptions = {
|
||||
|
@ -116,8 +118,8 @@ export const NOTIFICATION_DESCRIPTIONS: notification_descriptions = {
|
|||
detailed: "Only answers by market creator on markets you're watching",
|
||||
},
|
||||
betting_streaks: {
|
||||
simple: 'For predictions made over consecutive days',
|
||||
detailed: 'Bonuses for predictions made over consecutive days',
|
||||
simple: `For prediction streaks`,
|
||||
detailed: `Bonuses for predictions made over consecutive days (Prediction streaks)})`,
|
||||
},
|
||||
comments_by_followed_users_on_watched_markets: {
|
||||
simple: 'Only comments by users you follow',
|
||||
|
@ -159,8 +161,8 @@ export const NOTIFICATION_DESCRIPTIONS: notification_descriptions = {
|
|||
detailed: 'Large changes in probability on markets that you watch',
|
||||
},
|
||||
profit_loss_updates: {
|
||||
simple: 'Weekly profit and loss updates',
|
||||
detailed: 'Weekly profit and loss updates',
|
||||
simple: 'Weekly portfolio updates',
|
||||
detailed: 'Weekly portfolio updates',
|
||||
},
|
||||
referral_bonuses: {
|
||||
simple: 'For referring new users',
|
||||
|
@ -208,8 +210,9 @@ export const NOTIFICATION_DESCRIPTIONS: notification_descriptions = {
|
|||
detailed: 'Bonuses for unique predictors on your markets',
|
||||
},
|
||||
your_contract_closed: {
|
||||
simple: 'Your market has closed and you need to resolve it',
|
||||
detailed: 'Your market has closed and you need to resolve it',
|
||||
simple: 'Your market has closed and you need to resolve it (necessary)',
|
||||
detailed: 'Your market has closed and you need to resolve it (necessary)',
|
||||
necessary: true,
|
||||
},
|
||||
all_comments_on_watched_markets: {
|
||||
simple: 'All new comments',
|
||||
|
@ -235,6 +238,15 @@ export const NOTIFICATION_DESCRIPTIONS: notification_descriptions = {
|
|||
simple: `Only on markets you're invested in`,
|
||||
detailed: `Answers on markets that you're watching and that you're invested in`,
|
||||
},
|
||||
badges_awarded: {
|
||||
simple: 'New badges awarded',
|
||||
detailed: 'New badges you have earned',
|
||||
},
|
||||
opt_out_all: {
|
||||
simple: 'Opt out of all notifications (excludes when your markets close)',
|
||||
detailed:
|
||||
'Opt out of all notifications excluding your own market closure notifications',
|
||||
},
|
||||
}
|
||||
|
||||
export type BettingStreakData = {
|
||||
|
|
|
@ -8,11 +8,13 @@
|
|||
},
|
||||
"sideEffects": false,
|
||||
"dependencies": {
|
||||
"@tiptap/core": "2.0.0-beta.182",
|
||||
"@tiptap/extension-image": "2.0.0-beta.30",
|
||||
"@tiptap/extension-link": "2.0.0-beta.43",
|
||||
"@tiptap/extension-mention": "2.0.0-beta.102",
|
||||
"@tiptap/starter-kit": "2.0.0-beta.191",
|
||||
"@tiptap/core": "2.0.0-beta.199",
|
||||
"@tiptap/extension-image": "2.0.0-beta.199",
|
||||
"@tiptap/extension-link": "2.0.0-beta.199",
|
||||
"@tiptap/extension-mention": "2.0.0-beta.199",
|
||||
"@tiptap/html": "2.0.0-beta.199",
|
||||
"@tiptap/starter-kit": "2.0.0-beta.199",
|
||||
"@tiptap/suggestion": "2.0.0-beta.199",
|
||||
"lodash": "4.17.21"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
@ -168,7 +168,7 @@ export const getPayoutsMultiOutcome = (
|
|||
const winnings = (shares / sharesByOutcome[outcome]) * prob * poolTotal
|
||||
const profit = winnings - amount
|
||||
|
||||
const payout = amount + (1 - DPM_FEES) * Math.max(0, profit)
|
||||
const payout = amount + (1 - DPM_FEES) * profit
|
||||
return { userId, profit, payout }
|
||||
})
|
||||
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
|
||||
import { Bet } from './bet'
|
||||
import { getProbability } from './calculate'
|
||||
import { getCpmmLiquidityPoolWeights } from './calculate-cpmm'
|
||||
|
@ -56,10 +55,11 @@ export const getLiquidityPoolPayouts = (
|
|||
outcome: string,
|
||||
liquidities: LiquidityProvision[]
|
||||
) => {
|
||||
const { pool } = contract
|
||||
const finalPool = pool[outcome]
|
||||
const { pool, subsidyPool } = contract
|
||||
const finalPool = pool[outcome] + (subsidyPool ?? 0)
|
||||
if (finalPool < 1e-3) return []
|
||||
|
||||
const weights = getCpmmLiquidityPoolWeights(contract, liquidities, false)
|
||||
const weights = getCpmmLiquidityPoolWeights(liquidities)
|
||||
|
||||
return Object.entries(weights).map(([providerId, weight]) => ({
|
||||
userId: providerId,
|
||||
|
@ -95,10 +95,11 @@ export const getLiquidityPoolProbPayouts = (
|
|||
p: number,
|
||||
liquidities: LiquidityProvision[]
|
||||
) => {
|
||||
const { pool } = contract
|
||||
const finalPool = p * pool.YES + (1 - p) * pool.NO
|
||||
const { pool, subsidyPool } = contract
|
||||
const finalPool = p * pool.YES + (1 - p) * pool.NO + (subsidyPool ?? 0)
|
||||
if (finalPool < 1e-3) return []
|
||||
|
||||
const weights = getCpmmLiquidityPoolWeights(contract, liquidities, false)
|
||||
const weights = getCpmmLiquidityPoolWeights(liquidities)
|
||||
|
||||
return Object.entries(weights).map(([providerId, weight]) => ({
|
||||
userId: providerId,
|
||||
|
|
|
@ -3,10 +3,27 @@ import { JSONContent } from '@tiptap/core'
|
|||
export type Post = {
|
||||
id: string
|
||||
title: string
|
||||
subtitle: string
|
||||
content: JSONContent
|
||||
creatorId: string // User id
|
||||
createdTime: number
|
||||
slug: string
|
||||
|
||||
// denormalized user fields
|
||||
creatorName: string
|
||||
creatorUsername: string
|
||||
creatorAvatarUrl?: string
|
||||
|
||||
likedByUserIds?: string[]
|
||||
likedByUserCount?: number
|
||||
}
|
||||
|
||||
export type DateDoc = Post & {
|
||||
bounty: number
|
||||
birthday: number
|
||||
type: 'date-doc'
|
||||
contractSlug: string
|
||||
}
|
||||
|
||||
export const MAX_POST_TITLE_LENGTH = 480
|
||||
export const MAX_POST_SUBTITLE_LENGTH = 480
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
import { groupBy, sumBy, mapValues } from 'lodash'
|
||||
import { groupBy, sumBy, mapValues, keyBy, sortBy } from 'lodash'
|
||||
|
||||
import { Bet } from './bet'
|
||||
import { getContractBetMetrics } from './calculate'
|
||||
import { getContractBetMetrics, resolvedPayout } from './calculate'
|
||||
import { Contract } from './contract'
|
||||
import { ContractComment } from './comment'
|
||||
|
||||
export function scoreCreators(contracts: Contract[]) {
|
||||
const creatorScore = mapValues(
|
||||
|
@ -30,8 +31,11 @@ export function scoreTraders(contracts: Contract[], bets: Bet[][]) {
|
|||
}
|
||||
|
||||
export function scoreUsersByContract(contract: Contract, bets: Bet[]) {
|
||||
const betsByUser = groupBy(bets, bet => bet.userId)
|
||||
return mapValues(betsByUser, bets => getContractBetMetrics(contract, bets).profit)
|
||||
const betsByUser = groupBy(bets, (bet) => bet.userId)
|
||||
return mapValues(
|
||||
betsByUser,
|
||||
(bets) => getContractBetMetrics(contract, bets).profit
|
||||
)
|
||||
}
|
||||
|
||||
export function addUserScores(
|
||||
|
@ -43,3 +47,47 @@ export function addUserScores(
|
|||
dest[userId] += score
|
||||
}
|
||||
}
|
||||
|
||||
export function scoreCommentorsAndBettors(
|
||||
contract: Contract,
|
||||
bets: Bet[],
|
||||
comments: ContractComment[]
|
||||
) {
|
||||
const commentsById = keyBy(comments, 'id')
|
||||
const betsById = keyBy(bets, 'id')
|
||||
|
||||
// If 'id2' is the sale of 'id1', both are logged with (id2 - id1) of profit
|
||||
// Otherwise, we record the profit at resolution time
|
||||
const profitById: Record<string, number> = {}
|
||||
for (const bet of bets) {
|
||||
if (bet.sale) {
|
||||
const originalBet = betsById[bet.sale.betId]
|
||||
const profit = bet.sale.amount - originalBet.amount
|
||||
profitById[bet.id] = profit
|
||||
profitById[originalBet.id] = profit
|
||||
} else {
|
||||
profitById[bet.id] = resolvedPayout(contract, bet) - bet.amount
|
||||
}
|
||||
}
|
||||
|
||||
// Now find the betId with the highest profit
|
||||
const topBetId = sortBy(bets, (b) => -profitById[b.id])[0]?.id
|
||||
const topBettor = betsById[topBetId]?.userName
|
||||
|
||||
// And also the commentId of the comment with the highest profit
|
||||
const topCommentId = sortBy(
|
||||
comments,
|
||||
(c) => c.betId && -profitById[c.betId]
|
||||
)[0]?.id
|
||||
const topCommentBetId = commentsById[topCommentId]?.betId
|
||||
|
||||
return {
|
||||
topCommentId,
|
||||
topBetId,
|
||||
topBettor,
|
||||
profitById,
|
||||
commentsById,
|
||||
betsById,
|
||||
topCommentBetId,
|
||||
}
|
||||
}
|
||||
|
|
|
@ -84,15 +84,17 @@ export const getCpmmSellBetInfo = (
|
|||
outcome: 'YES' | 'NO',
|
||||
contract: CPMMContract,
|
||||
unfilledBets: LimitBet[],
|
||||
balanceByUserId: { [userId: string]: number },
|
||||
loanPaid: number
|
||||
) => {
|
||||
const { pool, p } = contract
|
||||
|
||||
const { saleValue, cpmmState, fees, makers, takers } = calculateCpmmSale(
|
||||
const { saleValue, cpmmState, fees, makers, takers, ordersToCancel } = calculateCpmmSale(
|
||||
contract,
|
||||
shares,
|
||||
outcome,
|
||||
unfilledBets
|
||||
unfilledBets,
|
||||
balanceByUserId,
|
||||
)
|
||||
|
||||
const probBefore = getCpmmProbability(pool, p)
|
||||
|
@ -134,5 +136,6 @@ export const getCpmmSellBetInfo = (
|
|||
fees,
|
||||
makers,
|
||||
takers,
|
||||
ordersToCancel
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,6 +8,7 @@ type AnyTxnType =
|
|||
| UniqueBettorBonus
|
||||
| BettingStreakBonus
|
||||
| CancelUniqueBettorBonus
|
||||
| CommentBountyRefund
|
||||
type SourceType = 'USER' | 'CONTRACT' | 'CHARITY' | 'BANK'
|
||||
|
||||
export type Txn<T extends AnyTxnType = AnyTxnType> = {
|
||||
|
@ -31,6 +32,8 @@ export type Txn<T extends AnyTxnType = AnyTxnType> = {
|
|||
| 'UNIQUE_BETTOR_BONUS'
|
||||
| 'BETTING_STREAK_BONUS'
|
||||
| 'CANCEL_UNIQUE_BETTOR_BONUS'
|
||||
| 'COMMENT_BOUNTY'
|
||||
| 'REFUND_COMMENT_BOUNTY'
|
||||
|
||||
// Any extra data
|
||||
data?: { [key: string]: any }
|
||||
|
@ -98,6 +101,34 @@ type CancelUniqueBettorBonus = {
|
|||
}
|
||||
}
|
||||
|
||||
type CommentBountyDeposit = {
|
||||
fromType: 'USER'
|
||||
toType: 'BANK'
|
||||
category: 'COMMENT_BOUNTY'
|
||||
data: {
|
||||
contractId: string
|
||||
}
|
||||
}
|
||||
|
||||
type CommentBountyWithdrawal = {
|
||||
fromType: 'BANK'
|
||||
toType: 'USER'
|
||||
category: 'COMMENT_BOUNTY'
|
||||
data: {
|
||||
contractId: string
|
||||
commentId: string
|
||||
}
|
||||
}
|
||||
|
||||
type CommentBountyRefund = {
|
||||
fromType: 'BANK'
|
||||
toType: 'USER'
|
||||
category: 'REFUND_COMMENT_BOUNTY'
|
||||
data: {
|
||||
contractId: string
|
||||
}
|
||||
}
|
||||
|
||||
export type DonationTxn = Txn & Donation
|
||||
export type TipTxn = Txn & Tip
|
||||
export type ManalinkTxn = Txn & Manalink
|
||||
|
@ -105,3 +136,5 @@ export type ReferralTxn = Txn & Referral
|
|||
export type BettingStreakBonusTxn = Txn & BettingStreakBonus
|
||||
export type UniqueBettorBonusTxn = Txn & UniqueBettorBonus
|
||||
export type CancelUniqueBettorBonusTxn = Txn & CancelUniqueBettorBonus
|
||||
export type CommentBountyDepositTxn = Txn & CommentBountyDeposit
|
||||
export type CommentBountyWithdrawalTxn = Txn & CommentBountyWithdrawal
|
||||
|
|
|
@ -53,6 +53,9 @@ export type notification_preferences = {
|
|||
profit_loss_updates: notification_destination_types[]
|
||||
onboarding_flow: notification_destination_types[]
|
||||
thank_you_for_purchases: notification_destination_types[]
|
||||
badges_awarded: notification_destination_types[]
|
||||
opt_out_all: notification_destination_types[]
|
||||
// When adding a new notification preference, use add-new-notification-preference.ts to existing users
|
||||
}
|
||||
|
||||
export const getDefaultNotificationPreferences = (
|
||||
|
@ -65,7 +68,7 @@ export const getDefaultNotificationPreferences = (
|
|||
const email = noEmails ? undefined : emailIf ? 'email' : undefined
|
||||
return filterDefined([browser, email]) as notification_destination_types[]
|
||||
}
|
||||
return {
|
||||
const defaults: notification_preferences = {
|
||||
// Watched Markets
|
||||
all_comments_on_watched_markets: constructPref(true, false),
|
||||
all_answers_on_watched_markets: constructPref(true, false),
|
||||
|
@ -107,7 +110,7 @@ export const getDefaultNotificationPreferences = (
|
|||
loan_income: constructPref(true, false),
|
||||
betting_streaks: constructPref(true, false),
|
||||
referral_bonuses: constructPref(true, true),
|
||||
unique_bettors_on_your_contract: constructPref(true, false),
|
||||
unique_bettors_on_your_contract: constructPref(true, true),
|
||||
tipped_comments_on_watched_markets: constructPref(true, true),
|
||||
tips_on_your_markets: constructPref(true, true),
|
||||
limit_order_fills: constructPref(true, false),
|
||||
|
@ -121,7 +124,11 @@ export const getDefaultNotificationPreferences = (
|
|||
probability_updates_on_watched_markets: constructPref(true, false),
|
||||
thank_you_for_purchases: constructPref(false, false),
|
||||
onboarding_flow: constructPref(false, false),
|
||||
} as notification_preferences
|
||||
|
||||
opt_out_all: [],
|
||||
badges_awarded: constructPref(true, false),
|
||||
}
|
||||
return defaults
|
||||
}
|
||||
|
||||
// Adding a new key:value here is optional, you can just use a key of notification_subscription_types
|
||||
|
@ -172,23 +179,44 @@ export const getNotificationDestinationsForUser = (
|
|||
reason: notification_reason_types | notification_preference
|
||||
) => {
|
||||
const notificationSettings = privateUser.notificationPreferences
|
||||
let destinations
|
||||
let subscriptionType: notification_preference | undefined
|
||||
if (Object.keys(notificationSettings).includes(reason)) {
|
||||
subscriptionType = reason as notification_preference
|
||||
destinations = notificationSettings[subscriptionType]
|
||||
} else {
|
||||
const key = reason as notification_reason_types
|
||||
subscriptionType = notificationReasonToSubscriptionType[key]
|
||||
destinations = subscriptionType
|
||||
? notificationSettings[subscriptionType]
|
||||
: []
|
||||
}
|
||||
const unsubscribeEndpoint = getFunctionUrl('unsubscribe')
|
||||
return {
|
||||
sendToEmail: destinations.includes('email'),
|
||||
sendToBrowser: destinations.includes('browser'),
|
||||
unsubscribeUrl: `${unsubscribeEndpoint}?id=${privateUser.id}&type=${subscriptionType}`,
|
||||
urlToManageThisNotification: `${DOMAIN}/notifications?tab=settings§ion=${subscriptionType}`,
|
||||
try {
|
||||
let destinations
|
||||
let subscriptionType: notification_preference | undefined
|
||||
if (Object.keys(notificationSettings).includes(reason)) {
|
||||
subscriptionType = reason as notification_preference
|
||||
destinations = notificationSettings[subscriptionType]
|
||||
} else {
|
||||
const key = reason as notification_reason_types
|
||||
subscriptionType = notificationReasonToSubscriptionType[key]
|
||||
destinations = subscriptionType
|
||||
? notificationSettings[subscriptionType]
|
||||
: []
|
||||
}
|
||||
const optOutOfAllSettings = notificationSettings['opt_out_all']
|
||||
// Your market closure notifications are high priority, opt-out doesn't affect their delivery
|
||||
const optedOutOfEmail =
|
||||
optOutOfAllSettings.includes('email') &&
|
||||
subscriptionType !== 'your_contract_closed'
|
||||
const optedOutOfBrowser =
|
||||
optOutOfAllSettings.includes('browser') &&
|
||||
subscriptionType !== 'your_contract_closed'
|
||||
return {
|
||||
sendToEmail: destinations.includes('email') && !optedOutOfEmail,
|
||||
sendToBrowser: destinations.includes('browser') && !optedOutOfBrowser,
|
||||
unsubscribeUrl: `${unsubscribeEndpoint}?id=${privateUser.id}&type=${subscriptionType}`,
|
||||
urlToManageThisNotification: `${DOMAIN}/notifications?tab=settings§ion=${subscriptionType}`,
|
||||
}
|
||||
} catch (e) {
|
||||
// Fail safely
|
||||
console.log(
|
||||
`couldn't get notification destinations for type ${reason} for user ${privateUser.id}`
|
||||
)
|
||||
return {
|
||||
sendToEmail: false,
|
||||
sendToBrowser: false,
|
||||
unsubscribeUrl: '',
|
||||
urlToManageThisNotification: '',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { notification_preferences } from './user-notification-preferences'
|
||||
import { ENV_CONFIG } from 'common/envs/constants'
|
||||
import { ENV_CONFIG } from './envs/constants'
|
||||
import { MarketCreatorBadge, ProvenCorrectBadge, StreakerBadge } from './badge'
|
||||
|
||||
export type User = {
|
||||
id: string
|
||||
|
@ -11,7 +12,6 @@ export type User = {
|
|||
|
||||
// For their user page
|
||||
bio?: string
|
||||
bannerUrl?: string
|
||||
website?: string
|
||||
twitterHandle?: string
|
||||
discordHandle?: string
|
||||
|
@ -33,6 +33,8 @@ export type User = {
|
|||
allTime: number
|
||||
}
|
||||
|
||||
fractionResolvedCorrectly: number
|
||||
|
||||
nextLoanCached: number
|
||||
followerCountCached: number
|
||||
|
||||
|
@ -49,6 +51,18 @@ export type User = {
|
|||
hasSeenContractFollowModal?: boolean
|
||||
freeMarketsCreated?: number
|
||||
isBannedFromPosting?: boolean
|
||||
|
||||
achievements: {
|
||||
provenCorrect?: {
|
||||
badges: ProvenCorrectBadge[]
|
||||
}
|
||||
marketCreator?: {
|
||||
badges: MarketCreatorBadge[]
|
||||
}
|
||||
streaker?: {
|
||||
badges: StreakerBadge[]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type PrivateUser = {
|
||||
|
@ -57,6 +71,7 @@ export type PrivateUser = {
|
|||
|
||||
email?: string
|
||||
weeklyTrendingEmailSent?: boolean
|
||||
weeklyPortfolioUpdateEmailSent?: boolean
|
||||
manaBonusEmailSent?: boolean
|
||||
initialDeviceToken?: string
|
||||
initialIpAddress?: string
|
||||
|
@ -78,7 +93,8 @@ export type PortfolioMetrics = {
|
|||
userId: string
|
||||
}
|
||||
|
||||
export const MANIFOLD_USERNAME = 'ManifoldMarkets'
|
||||
export const MANIFOLD_USER_USERNAME = 'ManifoldMarkets'
|
||||
export const MANIFOLD_USER_NAME = 'ManifoldMarkets'
|
||||
export const MANIFOLD_AVATAR_URL = 'https://manifold.markets/logo-bg-white.png'
|
||||
|
||||
// TODO: remove. Hardcoding the strings would be better.
|
||||
|
|
24
common/util/color.ts
Normal file
24
common/util/color.ts
Normal file
|
@ -0,0 +1,24 @@
|
|||
export const interpolateColor = (color1: string, color2: string, p: number) => {
|
||||
const rgb1 = parseInt(color1.replace('#', ''), 16)
|
||||
const rgb2 = parseInt(color2.replace('#', ''), 16)
|
||||
|
||||
const [r1, g1, b1] = toArray(rgb1)
|
||||
const [r2, g2, b2] = toArray(rgb2)
|
||||
|
||||
const q = 1 - p
|
||||
const rr = Math.round(r1 * q + r2 * p)
|
||||
const rg = Math.round(g1 * q + g2 * p)
|
||||
const rb = Math.round(b1 * q + b2 * p)
|
||||
|
||||
const hexString = Number((rr << 16) + (rg << 8) + rb).toString(16)
|
||||
const hex = `#${'0'.repeat(6 - hexString.length)}${hexString}`
|
||||
return hex
|
||||
}
|
||||
|
||||
function toArray(rgb: number) {
|
||||
const r = rgb >> 16
|
||||
const g = (rgb >> 8) % 256
|
||||
const b = rgb % 256
|
||||
|
||||
return [r, g, b]
|
||||
}
|
|
@ -8,7 +8,14 @@ const formatter = new Intl.NumberFormat('en-US', {
|
|||
})
|
||||
|
||||
export function formatMoney(amount: number) {
|
||||
const newAmount = Math.round(amount) === 0 ? 0 : Math.floor(amount) // handle -0 case
|
||||
const newAmount =
|
||||
// handle -0 case
|
||||
Math.round(amount) === 0
|
||||
? 0
|
||||
: // Handle 499.9999999999999 case
|
||||
(amount > 0 ? Math.floor : Math.ceil)(
|
||||
amount + 0.00000000001 * Math.sign(amount)
|
||||
)
|
||||
return ENV_CONFIG.moneyMoniker + formatter.format(newAmount).replace('$', '')
|
||||
}
|
||||
|
||||
|
@ -53,6 +60,16 @@ export function formatLargeNumber(num: number, sigfigs = 2): string {
|
|||
return `${numStr}${suffix[i] ?? ''}`
|
||||
}
|
||||
|
||||
export function shortFormatNumber(num: number): string {
|
||||
if (num < 1000) return showPrecision(num, 3)
|
||||
|
||||
const suffix = ['', 'K', 'M', 'B', 'T', 'Q']
|
||||
const i = Math.floor(Math.log10(num) / 3)
|
||||
|
||||
const numStr = showPrecision(num / Math.pow(10, 3 * i), 2)
|
||||
return `${numStr}${suffix[i] ?? ''}`
|
||||
}
|
||||
|
||||
export function toCamelCase(words: string) {
|
||||
const camelCase = words
|
||||
.split(' ')
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { MAX_TAG_LENGTH } from '../contract'
|
||||
import { generateText, JSONContent } from '@tiptap/core'
|
||||
import { generateText, JSONContent, Node } from '@tiptap/core'
|
||||
import { generateJSON } from '@tiptap/html'
|
||||
// Tiptap starter extensions
|
||||
import { Blockquote } from '@tiptap/extension-blockquote'
|
||||
import { Bold } from '@tiptap/extension-bold'
|
||||
|
@ -25,6 +25,7 @@ import Iframe from './tiptap-iframe'
|
|||
import TiptapTweet from './tiptap-tweet-type'
|
||||
import { find } from 'linkifyjs'
|
||||
import { uniq } from 'lodash'
|
||||
import { TiptapSpoiler } from './tiptap-spoiler'
|
||||
|
||||
/** get first url in text. like "notion.so " -> "http://notion.so"; "notion" -> null */
|
||||
export function getUrl(text: string) {
|
||||
|
@ -32,34 +33,6 @@ export function getUrl(text: string) {
|
|||
return results.length ? results[0].href : null
|
||||
}
|
||||
|
||||
export function parseTags(text: string) {
|
||||
const regex = /(?:^|\s)(?:[#][a-z0-9_]+)/gi
|
||||
const matches = (text.match(regex) || []).map((match) =>
|
||||
match.trim().substring(1).substring(0, MAX_TAG_LENGTH)
|
||||
)
|
||||
const tagSet = new Set()
|
||||
const uniqueTags: string[] = []
|
||||
// Keep casing of last tag.
|
||||
matches.reverse()
|
||||
for (const tag of matches) {
|
||||
const lowercase = tag.toLowerCase()
|
||||
if (!tagSet.has(lowercase)) {
|
||||
tagSet.add(lowercase)
|
||||
uniqueTags.push(tag)
|
||||
}
|
||||
}
|
||||
uniqueTags.reverse()
|
||||
return uniqueTags
|
||||
}
|
||||
|
||||
export function parseWordsAsTags(text: string) {
|
||||
const taggedText = text
|
||||
.split(/\s+/)
|
||||
.map((tag) => (tag.startsWith('#') ? tag : `#${tag}`))
|
||||
.join(' ')
|
||||
return parseTags(taggedText)
|
||||
}
|
||||
|
||||
// TODO: fuzzy matching
|
||||
export const wordIn = (word: string, corpus: string) =>
|
||||
corpus.toLocaleLowerCase().includes(word.toLocaleLowerCase())
|
||||
|
@ -79,8 +52,28 @@ export function parseMentions(data: JSONContent): string[] {
|
|||
return uniq(mentions)
|
||||
}
|
||||
|
||||
// can't just do [StarterKit, Image...] because it doesn't work with cjs imports
|
||||
export const exhibitExts = [
|
||||
// TODO: this is a hack to get around the fact that tiptap doesn't have a
|
||||
// way to add a node view without bundling in tsx
|
||||
function skippableComponent(name: string): Node<any, any> {
|
||||
return Node.create({
|
||||
name,
|
||||
|
||||
group: 'block',
|
||||
|
||||
content: 'inline*',
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: 'grid-cards-component',
|
||||
},
|
||||
]
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const stringParseExts = [
|
||||
// StarterKit extensions
|
||||
Blockquote,
|
||||
Bold,
|
||||
BulletList,
|
||||
|
@ -97,14 +90,26 @@ export const exhibitExts = [
|
|||
Paragraph,
|
||||
Strike,
|
||||
Text,
|
||||
|
||||
Image,
|
||||
// other extensions
|
||||
Link,
|
||||
Mention,
|
||||
Iframe,
|
||||
TiptapTweet,
|
||||
Image.extend({ renderText: () => '[image]' }),
|
||||
Mention, // user @mention
|
||||
Mention.extend({ name: 'contract-mention' }), // market %mention
|
||||
Iframe.extend({
|
||||
renderText: ({ node }) =>
|
||||
'[embed]' + node.attrs.src ? `(${node.attrs.src})` : '',
|
||||
}),
|
||||
skippableComponent('gridCardsComponent'),
|
||||
skippableComponent('staticReactEmbedComponent'),
|
||||
TiptapTweet.extend({ renderText: () => '[tweet]' }),
|
||||
TiptapSpoiler.extend({ renderHTML: () => ['span', '[spoiler]', 0] }),
|
||||
]
|
||||
|
||||
export function richTextToString(text?: JSONContent) {
|
||||
return !text ? '' : generateText(text, exhibitExts)
|
||||
if (!text) return ''
|
||||
return generateText(text, stringParseExts)
|
||||
}
|
||||
|
||||
export function htmlToRichText(html: string) {
|
||||
return generateJSON(html, stringParseExts)
|
||||
}
|
||||
|
|
|
@ -1,3 +1,6 @@
|
|||
export const MINUTE_MS = 60 * 1000
|
||||
export const HOUR_MS = 60 * MINUTE_MS
|
||||
export const DAY_MS = 24 * HOUR_MS
|
||||
|
||||
export const sleep = (ms: number) =>
|
||||
new Promise((resolve) => setTimeout(resolve, ms))
|
||||
|
|
116
common/util/tiptap-spoiler.ts
Normal file
116
common/util/tiptap-spoiler.ts
Normal file
|
@ -0,0 +1,116 @@
|
|||
// adapted from @n8body/tiptap-spoiler
|
||||
|
||||
import {
|
||||
Mark,
|
||||
markInputRule,
|
||||
markPasteRule,
|
||||
mergeAttributes,
|
||||
} from '@tiptap/core'
|
||||
import type { ElementType } from 'react'
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
spoilerEditor: {
|
||||
setSpoiler: () => ReturnType
|
||||
toggleSpoiler: () => ReturnType
|
||||
unsetSpoiler: () => ReturnType
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type SpoilerOptions = {
|
||||
HTMLAttributes: Record<string, any>
|
||||
spoilerOpenClass: string
|
||||
spoilerCloseClass?: string
|
||||
inputRegex: RegExp
|
||||
pasteRegex: RegExp
|
||||
as: ElementType
|
||||
}
|
||||
|
||||
const spoilerInputRegex = /(?:^|\s)((?:\|\|)((?:[^||]+))(?:\|\|))$/
|
||||
const spoilerPasteRegex = /(?:^|\s)((?:\|\|)((?:[^||]+))(?:\|\|))/g
|
||||
|
||||
export const TiptapSpoiler = Mark.create<SpoilerOptions>({
|
||||
name: 'spoiler',
|
||||
|
||||
inline: true,
|
||||
group: 'inline',
|
||||
inclusive: false,
|
||||
exitable: true,
|
||||
content: 'inline*',
|
||||
|
||||
priority: 1001, // higher priority than other formatting so they go inside
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: { 'aria-label': 'spoiler' },
|
||||
spoilerOpenClass: '',
|
||||
spoilerCloseClass: undefined,
|
||||
inputRegex: spoilerInputRegex,
|
||||
pasteRegex: spoilerPasteRegex,
|
||||
as: 'span',
|
||||
editing: false,
|
||||
}
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
setSpoiler:
|
||||
() =>
|
||||
({ commands }) =>
|
||||
commands.setMark(this.name),
|
||||
toggleSpoiler:
|
||||
() =>
|
||||
({ commands }) =>
|
||||
commands.toggleMark(this.name),
|
||||
unsetSpoiler:
|
||||
() =>
|
||||
({ commands }) =>
|
||||
commands.unsetMark(this.name),
|
||||
}
|
||||
},
|
||||
|
||||
addInputRules() {
|
||||
return [
|
||||
markInputRule({
|
||||
find: this.options.inputRegex,
|
||||
type: this.type,
|
||||
}),
|
||||
]
|
||||
},
|
||||
|
||||
addPasteRules() {
|
||||
return [
|
||||
markPasteRule({
|
||||
find: this.options.pasteRegex,
|
||||
type: this.type,
|
||||
}),
|
||||
]
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: 'span',
|
||||
getAttrs: (node) =>
|
||||
(node as HTMLElement).ariaLabel?.toLowerCase() === 'spoiler' && null,
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
const elem = document.createElement(this.options.as as string)
|
||||
|
||||
Object.entries(
|
||||
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
|
||||
class: this.options.spoilerCloseClass ?? this.options.spoilerOpenClass,
|
||||
})
|
||||
).forEach(([attr, val]) => elem.setAttribute(attr, val))
|
||||
|
||||
elem.addEventListener('click', () => {
|
||||
elem.setAttribute('class', this.options.spoilerOpenClass)
|
||||
})
|
||||
|
||||
return elem
|
||||
},
|
||||
})
|
|
@ -55,6 +55,7 @@ Returns the authenticated user.
|
|||
Gets all groups, in no particular order.
|
||||
|
||||
Parameters:
|
||||
|
||||
- `availableToUserId`: Optional. if specified, only groups that the user can
|
||||
join and groups they've already joined will be returned.
|
||||
|
||||
|
@ -64,24 +65,23 @@ Requires no authorization.
|
|||
|
||||
Gets a group by its slug.
|
||||
|
||||
Requires no authorization.
|
||||
Requires no authorization.
|
||||
Note: group is singular in the URL.
|
||||
|
||||
### `GET /v0/group/by-id/[id]`
|
||||
|
||||
Gets a group by its unique ID.
|
||||
|
||||
Requires no authorization.
|
||||
Requires no authorization.
|
||||
Note: group is singular in the URL.
|
||||
|
||||
### `GET /v0/group/by-id/[id]/markets`
|
||||
|
||||
Gets a group's markets by its unique ID.
|
||||
|
||||
Requires no authorization.
|
||||
Requires no authorization.
|
||||
Note: group is singular in the URL.
|
||||
|
||||
|
||||
### `GET /v0/markets`
|
||||
|
||||
Lists all markets, ordered by creation date descending.
|
||||
|
@ -158,13 +158,16 @@ Requires no authorization.
|
|||
// i.e. https://manifold.markets/Austin/test-market is the same as https://manifold.markets/foo/test-market
|
||||
url: string
|
||||
|
||||
outcomeType: string // BINARY, FREE_RESPONSE, or NUMERIC
|
||||
outcomeType: string // BINARY, FREE_RESPONSE, MULTIPLE_CHOICE, NUMERIC, or PSEUDO_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
|
||||
min?: number // PSEUDO_NUMERIC markets only, the minimum resolvable value
|
||||
max?: number // PSEUDO_NUMERIC markets only, the maximum resolvable value
|
||||
isLogScale?: bool // PSEUDO_NUMERIC markets only, if true `number = (max - min + 1)^probability + minstart - 1`, otherwise `number = min + (max - min) * probability`
|
||||
|
||||
volume: number
|
||||
volume7Days: number
|
||||
|
@ -408,7 +411,7 @@ Requires no authorization.
|
|||
type FullMarket = LiteMarket & {
|
||||
bets: Bet[]
|
||||
comments: Comment[]
|
||||
answers?: Answer[]
|
||||
answers?: Answer[] // dpm-2 markets only
|
||||
description: JSONContent // Rich text content. See https://tiptap.dev/guide/output#option-1-json
|
||||
textDescription: string // string description without formatting, images, or embeds
|
||||
}
|
||||
|
@ -554,7 +557,7 @@ Creates a new market on behalf of the authorized user.
|
|||
|
||||
Parameters:
|
||||
|
||||
- `outcomeType`: Required. One of `BINARY`, `FREE_RESPONSE`, or `NUMERIC`.
|
||||
- `outcomeType`: Required. One of `BINARY`, `FREE_RESPONSE`, `MULTIPLE_CHOICE`, or `PSEUDO_NUMERIC`.
|
||||
- `question`: Required. The headline question for the market.
|
||||
- `description`: Required. A long description describing the rules for the market.
|
||||
- Note: string descriptions do **not** turn into links, mentions, formatted text. Instead, rich text descriptions must be in [TipTap json](https://tiptap.dev/guide/output#option-1-json).
|
||||
|
@ -569,6 +572,12 @@ 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.
|
||||
- `isLogScale`: If true, your numeric market will increase exponentially from min to max.
|
||||
- `initialValue`: An initial value for the market, between min and max, exclusive.
|
||||
|
||||
For multiple choice markets, you must also provide:
|
||||
|
||||
- `answers`: An array of strings, each of which will be a valid answer for the market.
|
||||
|
||||
Example request:
|
||||
|
||||
|
@ -582,6 +591,18 @@ $ curl https://manifold.markets/api/v0/market -X POST -H 'Content-Type: applicat
|
|||
"initialProb":25}'
|
||||
```
|
||||
|
||||
### `POST /v0/market/[marketId]/add-liquidity`
|
||||
|
||||
Adds a specified amount of liquidity into the market.
|
||||
|
||||
- `amount`: Required. The amount of liquidity to add, in M$.
|
||||
|
||||
### `POST /v0/market/[marketId]/close`
|
||||
|
||||
Closes a market on behalf of the authorized user.
|
||||
|
||||
- `closeTime`: Optional. Milliseconds since the epoch to close the market at. If not provided, the market will be closed immediately. Cannot provide close time in past.
|
||||
|
||||
### `POST /v0/market/[marketId]/resolve`
|
||||
|
||||
Resolves a market on behalf of the authorized user.
|
||||
|
@ -593,15 +614,18 @@ For binary markets:
|
|||
- `outcome`: Required. One of `YES`, `NO`, `MKT`, or `CANCEL`.
|
||||
- `probabilityInt`: Optional. The probability to use for `MKT` resolution.
|
||||
|
||||
For free response markets:
|
||||
For free response or multiple choice markets:
|
||||
|
||||
- `outcome`: Required. One of `MKT`, `CANCEL`, or a `number` indicating the answer index.
|
||||
- `resolutions`: An array of `{ answer, pct }` objects to use as the weights for resolving in favor of multiple free response options. Can only be set with `MKT` outcome.
|
||||
- `resolutions`: An array of `{ answer, pct }` objects to use as the weights for resolving in favor of multiple free response options. Can only be set with `MKT` outcome. Note that the total weights must add to 100.
|
||||
|
||||
For numeric markets:
|
||||
|
||||
- `outcome`: Required. One of `CANCEL`, or a `number` indicating the selected numeric bucket ID.
|
||||
- `value`: The value that the market may resolves to.
|
||||
- `probabilityInt`: Required if `value` is present. Should be equal to
|
||||
- If log scale: `log10(value - min + 1) / log10(max - min + 1)`
|
||||
- Otherwise: `(value - min) / (max - min)`
|
||||
|
||||
Example request:
|
||||
|
||||
|
@ -656,6 +680,17 @@ $ curl https://manifold.markets/api/v0/market/{marketId}/sell -X POST \
|
|||
--data-raw '{"outcome": "YES", "shares": 10}'
|
||||
```
|
||||
|
||||
### `POST /v0/comment`
|
||||
|
||||
Creates a comment in the specified market. Only supports top-level comments for now.
|
||||
|
||||
Parameters:
|
||||
|
||||
- `contractId`: Required. The ID of the market to comment on.
|
||||
- `content`: The comment to post, formatted as [TipTap json](https://tiptap.dev/guide/output#option-1-json), OR
|
||||
- `html`: The comment to post, formatted as an HTML string, OR
|
||||
- `markdown`: The comment to post, formatted as a markdown string.
|
||||
|
||||
### `GET /v0/bets`
|
||||
|
||||
Gets a list of bets, ordered by creation date descending.
|
||||
|
@ -745,6 +780,7 @@ Requires no authorization.
|
|||
|
||||
## Changelog
|
||||
|
||||
- 2022-09-24: Expand market POST docs to include new market types (`PSEUDO_NUMERIC`, `MULTIPLE_CHOICE`)
|
||||
- 2022-07-15: Add user by username and user by ID APIs
|
||||
- 2022-06-08: Add paging to markets endpoint
|
||||
- 2022-06-05: Add new authorized write endpoints
|
||||
|
|
|
@ -8,9 +8,8 @@ A list of community-created projects built on, or related to, Manifold Markets.
|
|||
|
||||
## 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
|
||||
- [WagerWith.me](https://www.wagerwith.me/) — Bet with your friends, with full Manifold integration to bet with M$.
|
||||
- [Alignment Markets](https://alignmentmarkets.com/) - Bet on the progress of benchmarks in ML safety!
|
||||
|
||||
## API / Dev
|
||||
|
||||
|
@ -28,6 +27,7 @@ A list of community-created projects built on, or related to, Manifold Markets.
|
|||
- [mana](https://github.com/AnnikaCodes/mana) - A Discord bot for Manifold by [@arae](https://manifold.markets/arae)
|
||||
|
||||
## Writeups
|
||||
|
||||
- [Information Markets, Decision Markets, Attention Markets, Action Markets](https://astralcodexten.substack.com/p/information-markets-decision-markets) by Scott Alexander
|
||||
- [Mismatched Monetary Motivation in Manifold Markets](https://kevin.zielnicki.com/2022/02/17/manifold/) by Kevin Zielnicki
|
||||
- [Introducing the Salem/CSPI Forecasting Tournament](https://www.cspicenter.com/p/introducing-the-salemcspi-forecasting) by Richard Hanania
|
||||
|
@ -36,5 +36,12 @@ A list of community-created projects built on, or related to, Manifold Markets.
|
|||
|
||||
## Art
|
||||
|
||||
- Folded origami and doodles by [@hamnox](https://manifold.markets/hamnox) ![](https://i.imgur.com/nVGY4pL.png)
|
||||
- Laser-cut Foldy by [@wasabipesto](https://manifold.markets/wasabipesto) ![](https://i.imgur.com/g9S6v3P.jpg)
|
||||
- Folded origami and doodles by [@hamnox](https://manifold.markets/hamnox) ![](https://i.imgur.com/nVGY4pL.png)
|
||||
- Laser-cut Foldy by [@wasabipesto](https://manifold.markets/wasabipesto) ![](https://i.imgur.com/g9S6v3P.jpg)
|
||||
|
||||
## Alumni
|
||||
|
||||
_These projects are no longer active, but were really really cool!_
|
||||
|
||||
- [Research.Bet](https://research.bet/) - Prediction market for scientific papers, using Manifold
|
||||
- [CivicDashboard](https://civicdash.org/dashboard) - Uses Manifold to for tracked solutions for the SF city government
|
||||
|
|
|
@ -15,6 +15,22 @@ Our community is the beating heart of Manifold; your individual contributions ar
|
|||
|
||||
## Awarded bounties
|
||||
|
||||
💥 *Awarded on 2022-10-07*
|
||||
|
||||
**[Pepe](https://manifold.markets/Pepe): M$10,000**
|
||||
**[Jack](https://manifold.markets/jack): M$2,000**
|
||||
**[Martin](https://manifold.markets/MartinRandall): M$2,000**
|
||||
**[Yev](https://manifold.markets/Yev): M$2,000**
|
||||
**[Michael](https://manifold.markets/MichaelWheatley): M$2,000**
|
||||
|
||||
- For discovering an infinite mana exploit using limit orders, and informing the Manifold team of it privately.
|
||||
|
||||
**[Matt](https://manifold.markets/MattP): M$5,000**
|
||||
**[Adrian](https://manifold.markets/ahalekelly): M$5,000**
|
||||
**[Yev](https://manifold.markets/Yev): M$5,000**
|
||||
|
||||
- For discovering an AMM liquidity exploit and informing the Manifold team of it privately.
|
||||
|
||||
🎈 *Awarded on 2022-06-14*
|
||||
|
||||
**[Wasabipesto](https://manifold.markets/wasabipesto): M$20,000**
|
||||
|
|
|
@ -4,11 +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.
|
||||
|
||||
### What is the name for the currency Manifold uses, represented by M$?
|
||||
|
||||
Manifold Dollars, or mana for short.
|
||||
Nope! Each account starts with a free 1000 mana (or M$1000 for short). If you invest it wisely, you can increase your total without ever needing to put any real money into the site.
|
||||
|
||||
### Can M$ be sold for real money?
|
||||
|
||||
|
|
|
@ -23,11 +23,17 @@ service cloud.firestore {
|
|||
allow read;
|
||||
}
|
||||
|
||||
match /globalConfig/globalConfig {
|
||||
allow read;
|
||||
allow update: if isAdmin()
|
||||
allow create: if isAdmin()
|
||||
}
|
||||
|
||||
match /users/{userId} {
|
||||
allow read;
|
||||
allow update: if userId == request.auth.uid
|
||||
&& request.resource.data.diff(resource.data).affectedKeys()
|
||||
.hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories', 'lastPingTime','shouldShowWelcome', 'hasSeenContractFollowModal', 'homeSections']);
|
||||
.hasOnly(['bio', 'website', 'twitterHandle', 'discordHandle', 'followedCategories', 'lastPingTime','shouldShowWelcome', 'hasSeenContractFollowModal', 'homeSections']);
|
||||
// User referral rules
|
||||
allow update: if userId == request.auth.uid
|
||||
&& request.resource.data.diff(resource.data).affectedKeys()
|
||||
|
@ -44,6 +50,10 @@ service cloud.firestore {
|
|||
allow read;
|
||||
}
|
||||
|
||||
match /{somePath=**}/contract-metrics/{contractId} {
|
||||
allow read;
|
||||
}
|
||||
|
||||
match /{somePath=**}/challenges/{challengeId}{
|
||||
allow read;
|
||||
}
|
||||
|
@ -100,9 +110,9 @@ service cloud.firestore {
|
|||
match /contracts/{contractId} {
|
||||
allow read;
|
||||
allow update: if request.resource.data.diff(resource.data).affectedKeys()
|
||||
.hasOnly(['tags', 'lowercaseTags', 'groupSlugs', 'groupLinks']);
|
||||
.hasOnly(['tags', 'lowercaseTags', 'groupSlugs', 'groupLinks', 'flaggedByUsernames']);
|
||||
allow update: if request.resource.data.diff(resource.data).affectedKeys()
|
||||
.hasOnly(['description', 'closeTime', 'question'])
|
||||
.hasOnly(['description', 'closeTime', 'question', 'visibility', 'unlistedById'])
|
||||
&& resource.data.creatorId == request.auth.uid;
|
||||
allow update: if isAdmin();
|
||||
match /comments/{commentId} {
|
||||
|
@ -176,7 +186,7 @@ service cloud.firestore {
|
|||
allow update: if (request.auth.uid == resource.data.creatorId || isAdmin())
|
||||
&& request.resource.data.diff(resource.data)
|
||||
.affectedKeys()
|
||||
.hasOnly(['name', 'about', 'anyoneCanJoin', 'aboutPostId' ]);
|
||||
.hasOnly(['name', 'about', 'anyoneCanJoin', 'aboutPostId', 'pinnedItems' ]);
|
||||
allow delete: if request.auth.uid == resource.data.creatorId;
|
||||
|
||||
match /groupContracts/{contractId} {
|
||||
|
|
3
functions/.env.dev
Normal file
3
functions/.env.dev
Normal file
|
@ -0,0 +1,3 @@
|
|||
# This sets which EnvConfig is deployed to Firebase Cloud Functions
|
||||
|
||||
NEXT_PUBLIC_FIREBASE_ENV=DEV
|
|
@ -26,7 +26,7 @@ module.exports = {
|
|||
caughtErrorsIgnorePattern: '^_',
|
||||
},
|
||||
],
|
||||
'unused-imports/no-unused-imports': 'error',
|
||||
'unused-imports/no-unused-imports': 'warn',
|
||||
},
|
||||
},
|
||||
],
|
||||
|
|
|
@ -20,7 +20,7 @@ Adapted from https://firebase.google.com/docs/functions/get-started
|
|||
3. `$ firebase login` to authenticate the CLI tools to Firebase
|
||||
4. `$ firebase use dev` to choose the dev project
|
||||
|
||||
### For local development
|
||||
#### (Installing) For local development
|
||||
|
||||
0. [Install](https://cloud.google.com/sdk/docs/install) gcloud CLI
|
||||
1. If you don't have java (or see the error `Error: Process java -version has exited with code 1. Please make sure Java is installed and on your system PATH.`):
|
||||
|
@ -35,10 +35,10 @@ Adapted from https://firebase.google.com/docs/functions/get-started
|
|||
|
||||
## Developing locally
|
||||
|
||||
0. `$ firebase use dev` if you haven't already
|
||||
1. `$ yarn serve` to spin up the emulators 0. The Emulator UI is at http://localhost:4000; the functions are hosted on :5001.
|
||||
Note: You have to kill and restart emulators when you change code; no hot reload =(
|
||||
2. `$ yarn dev:emulate` in `/web` to connect to emulators with the frontend 0. Note: emulated database is cleared after every shutdown
|
||||
0. `$ ./dev.sh localdb` to start the local emulator and front end
|
||||
1. If you change db trigger code, you have to start (doesn't have to complete) the deploy of it to dev to cause a hard emulator code refresh `$ firebase deploy --only functions:dbTriggerNameHere`
|
||||
- There's surely a better way to cause/react to a db trigger update but just adding this here for now as it works
|
||||
2. If you want to test a scheduled function replace your function in `test-scheduled-function.ts` and send a GET to `http://localhost:8088/testscheduledfunction` (Best user experience is via [Postman](https://www.postman.com/downloads/)!)
|
||||
|
||||
## Firestore Commands
|
||||
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
"firestore": "dev-mantic-markets.appspot.com"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "yarn compile && rm -rf dist && mkdir -p dist/functions && cp -R ../common/lib dist/common && cp -R lib/src dist/functions/src && cp ../yarn.lock dist && cp package.json dist && cp .env dist",
|
||||
"build": "yarn compile && rm -rf dist && mkdir -p dist/functions && cp -R ../common/lib dist/common && cp -R lib/src dist/functions/src && cp ../yarn.lock dist && cp package.json dist && cp .env.prod dist && cp .env.dev dist",
|
||||
"compile": "tsc -b",
|
||||
"watch": "tsc -w",
|
||||
"shell": "yarn build && firebase functions:shell",
|
||||
|
@ -15,9 +15,9 @@
|
|||
"dev": "nodemon src/serve.ts",
|
||||
"localDbScript": "firebase emulators:start --only functions,firestore,pubsub --import=./firestore_export",
|
||||
"serve": "firebase use dev && yarn build && firebase emulators:start --only functions,firestore,pubsub --import=./firestore_export",
|
||||
"db:update-local-from-remote": "yarn db:backup-remote && gsutil rsync -r gs://$npm_package_config_firestore/firestore_export ./firestore_export",
|
||||
"db:update-local-from-remote": "yarn db:backup-remote && gsutil -m rsync -r gs://$npm_package_config_firestore/firestore_export ./firestore_export",
|
||||
"db:backup-local": "firebase emulators:export --force ./firestore_export",
|
||||
"db:rename-remote-backup-folder": "gsutil mv gs://$npm_package_config_firestore/firestore_export gs://$npm_package_config_firestore/firestore_export_$(date +%d-%m-%Y-%H-%M)",
|
||||
"db:rename-remote-backup-folder": "gsutil -m mv gs://$npm_package_config_firestore/firestore_export gs://$npm_package_config_firestore/firestore_export_$(date +%d-%m-%Y-%H-%M)",
|
||||
"db:backup-remote": "yarn db:rename-remote-backup-folder && gcloud firestore export gs://$npm_package_config_firestore/firestore_export/",
|
||||
"verify": "(cd .. && yarn verify)",
|
||||
"verify:dir": "npx eslint . --max-warnings 0; tsc -b -v --pretty"
|
||||
|
@ -26,11 +26,13 @@
|
|||
"dependencies": {
|
||||
"@amplitude/node": "1.10.0",
|
||||
"@google-cloud/functions-framework": "3.1.2",
|
||||
"@tiptap/core": "2.0.0-beta.182",
|
||||
"@tiptap/extension-image": "2.0.0-beta.30",
|
||||
"@tiptap/extension-link": "2.0.0-beta.43",
|
||||
"@tiptap/extension-mention": "2.0.0-beta.102",
|
||||
"@tiptap/starter-kit": "2.0.0-beta.191",
|
||||
"@tiptap/core": "2.0.0-beta.199",
|
||||
"@tiptap/extension-image": "2.0.0-beta.199",
|
||||
"@tiptap/extension-link": "2.0.0-beta.199",
|
||||
"@tiptap/extension-mention": "2.0.0-beta.199",
|
||||
"@tiptap/html": "2.0.0-beta.199",
|
||||
"@tiptap/starter-kit": "2.0.0-beta.199",
|
||||
"@tiptap/suggestion": "2.0.0-beta.199",
|
||||
"cors": "2.8.5",
|
||||
"dayjs": "1.11.4",
|
||||
"express": "4.18.1",
|
||||
|
@ -38,17 +40,19 @@
|
|||
"firebase-functions": "3.21.2",
|
||||
"lodash": "4.17.21",
|
||||
"mailgun-js": "0.22.0",
|
||||
"marked": "4.1.1",
|
||||
"module-alias": "2.2.2",
|
||||
"node-fetch": "2",
|
||||
"react-masonry-css": "1.0.16",
|
||||
"stripe": "8.194.0",
|
||||
"zod": "3.17.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/mailgun-js": "0.22.12",
|
||||
"@types/marked": "4.0.7",
|
||||
"@types/module-alias": "2.0.1",
|
||||
"@types/node-fetch": "2.6.2",
|
||||
"firebase-functions-test": "0.3.3"
|
||||
"firebase-functions-test": "0.3.3",
|
||||
"puppeteer": "18.0.5"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
|
|
|
@ -3,24 +3,18 @@ import { z } from 'zod'
|
|||
|
||||
import { Contract, CPMMContract } from '../../common/contract'
|
||||
import { User } from '../../common/user'
|
||||
import { removeUndefinedProps } from '../../common/util/object'
|
||||
import { getNewLiquidityProvision } from '../../common/add-liquidity'
|
||||
import { APIError, newEndpoint, validate } from './api'
|
||||
import {
|
||||
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
|
||||
HOUSE_LIQUIDITY_PROVIDER_ID,
|
||||
} from '../../common/antes'
|
||||
import { isProd } from './utils'
|
||||
|
||||
const bodySchema = z.object({
|
||||
contractId: z.string(),
|
||||
amount: z.number().gt(0),
|
||||
})
|
||||
|
||||
export const addliquidity = newEndpoint({}, async (req, auth) => {
|
||||
export const addsubsidy = newEndpoint({}, async (req, auth) => {
|
||||
const { amount, contractId } = validate(bodySchema, req.body)
|
||||
|
||||
if (!isFinite(amount)) throw new APIError(400, 'Invalid amount')
|
||||
if (!isFinite(amount) || amount < 1) throw new APIError(400, 'Invalid amount')
|
||||
|
||||
// run as transaction to prevent race conditions
|
||||
return await firestore.runTransaction(async (transaction) => {
|
||||
|
@ -50,7 +44,7 @@ export const addliquidity = newEndpoint({}, async (req, auth) => {
|
|||
.collection(`contracts/${contractId}/liquidity`)
|
||||
.doc()
|
||||
|
||||
const { newLiquidityProvision, newPool, newP, newTotalLiquidity } =
|
||||
const { newLiquidityProvision, newTotalLiquidity, newSubsidyPool } =
|
||||
getNewLiquidityProvision(
|
||||
user.id,
|
||||
amount,
|
||||
|
@ -58,21 +52,10 @@ export const addliquidity = newEndpoint({}, async (req, auth) => {
|
|||
newLiquidityProvisionDoc.id
|
||||
)
|
||||
|
||||
if (newP !== undefined && !isFinite(newP)) {
|
||||
return {
|
||||
status: 'error',
|
||||
message: 'Liquidity injection rejected due to overflow error.',
|
||||
}
|
||||
}
|
||||
|
||||
transaction.update(
|
||||
contractDoc,
|
||||
removeUndefinedProps({
|
||||
pool: newPool,
|
||||
p: newP,
|
||||
totalLiquidity: newTotalLiquidity,
|
||||
})
|
||||
)
|
||||
transaction.update(contractDoc, {
|
||||
subsidyPool: newSubsidyPool,
|
||||
totalLiquidity: newTotalLiquidity,
|
||||
} as Partial<CPMMContract>)
|
||||
|
||||
const newBalance = user.balance - amount
|
||||
const newTotalDeposits = user.totalDeposits - amount
|
||||
|
@ -93,41 +76,3 @@ export const addliquidity = newEndpoint({}, async (req, auth) => {
|
|||
})
|
||||
|
||||
const firestore = admin.firestore()
|
||||
|
||||
export const addHouseLiquidity = (contract: CPMMContract, amount: number) => {
|
||||
return firestore.runTransaction(async (transaction) => {
|
||||
const newLiquidityProvisionDoc = firestore
|
||||
.collection(`contracts/${contract.id}/liquidity`)
|
||||
.doc()
|
||||
|
||||
const providerId = isProd()
|
||||
? HOUSE_LIQUIDITY_PROVIDER_ID
|
||||
: DEV_HOUSE_LIQUIDITY_PROVIDER_ID
|
||||
|
||||
const { newLiquidityProvision, newPool, newP, newTotalLiquidity } =
|
||||
getNewLiquidityProvision(
|
||||
providerId,
|
||||
amount,
|
||||
contract,
|
||||
newLiquidityProvisionDoc.id
|
||||
)
|
||||
|
||||
if (newP !== undefined && !isFinite(newP)) {
|
||||
throw new APIError(
|
||||
500,
|
||||
'Liquidity injection rejected due to overflow error.'
|
||||
)
|
||||
}
|
||||
|
||||
transaction.update(
|
||||
firestore.doc(`contracts/${contract.id}`),
|
||||
removeUndefinedProps({
|
||||
pool: newPool,
|
||||
p: newP,
|
||||
totalLiquidity: newTotalLiquidity,
|
||||
})
|
||||
)
|
||||
|
||||
transaction.create(newLiquidityProvisionDoc, newLiquidityProvision)
|
||||
})
|
||||
}
|
|
@ -14,7 +14,7 @@ import {
|
|||
export { APIError } from '../../common/api'
|
||||
|
||||
type Output = Record<string, unknown>
|
||||
type AuthedUser = {
|
||||
export type AuthedUser = {
|
||||
uid: string
|
||||
creds: JwtCredentials | (KeyCredentials & { privateUser: PrivateUser })
|
||||
}
|
||||
|
@ -146,3 +146,24 @@ export const newEndpoint = (endpointOpts: EndpointOptions, fn: Handler) => {
|
|||
},
|
||||
} as EndpointDefinition
|
||||
}
|
||||
|
||||
export const newEndpointNoAuth = (
|
||||
endpointOpts: EndpointOptions,
|
||||
fn: (req: Request) => Promise<Output>
|
||||
) => {
|
||||
const opts = Object.assign({}, DEFAULT_OPTS, endpointOpts)
|
||||
return {
|
||||
opts,
|
||||
handler: async (req: Request, res: Response) => {
|
||||
log(`${req.method} ${req.url} ${JSON.stringify(req.body)}`)
|
||||
try {
|
||||
if (opts.method !== req.method) {
|
||||
throw new APIError(405, `This endpoint supports only ${opts.method}.`)
|
||||
}
|
||||
res.status(200).json(await fn(req))
|
||||
} catch (e) {
|
||||
writeResponseError(e, res)
|
||||
}
|
||||
},
|
||||
} as EndpointDefinition
|
||||
}
|
||||
|
|
58
functions/src/close-market.ts
Normal file
58
functions/src/close-market.ts
Normal file
|
@ -0,0 +1,58 @@
|
|||
import * as admin from 'firebase-admin'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { Contract } from '../../common/contract'
|
||||
import { getUser } from './utils'
|
||||
|
||||
import { isAdmin, isManifoldId } from '../../common/envs/constants'
|
||||
import { APIError, newEndpoint, validate } from './api'
|
||||
|
||||
const bodySchema = z.object({
|
||||
contractId: z.string(),
|
||||
closeTime: z.number().int().nonnegative().optional(),
|
||||
})
|
||||
|
||||
export const closemarket = newEndpoint({}, async (req, auth) => {
|
||||
const { contractId, closeTime } = validate(bodySchema, req.body)
|
||||
const contractDoc = firestore.doc(`contracts/${contractId}`)
|
||||
const contractSnap = await contractDoc.get()
|
||||
if (!contractSnap.exists)
|
||||
throw new APIError(404, 'No contract exists with the provided ID')
|
||||
const contract = contractSnap.data() as Contract
|
||||
const { creatorId } = contract
|
||||
const firebaseUser = await admin.auth().getUser(auth.uid)
|
||||
|
||||
if (
|
||||
creatorId !== auth.uid &&
|
||||
!isManifoldId(auth.uid) &&
|
||||
!isAdmin(firebaseUser.email)
|
||||
)
|
||||
throw new APIError(403, 'User is not creator of contract')
|
||||
|
||||
const now = Date.now()
|
||||
if (!closeTime && contract.closeTime && contract.closeTime < now)
|
||||
throw new APIError(400, 'Contract already closed')
|
||||
|
||||
if (closeTime && closeTime < now)
|
||||
throw new APIError(
|
||||
400,
|
||||
'Close time must be in the future. ' +
|
||||
'Alternatively, do not provide a close time to close immediately.'
|
||||
)
|
||||
|
||||
const creator = await getUser(creatorId)
|
||||
if (!creator) throw new APIError(500, 'Creator not found')
|
||||
|
||||
const updatedContract = {
|
||||
...contract,
|
||||
closeTime: closeTime ? closeTime : now,
|
||||
}
|
||||
|
||||
await contractDoc.update(updatedContract)
|
||||
|
||||
console.log('contract ', contractId, 'closed')
|
||||
|
||||
return updatedContract
|
||||
})
|
||||
|
||||
const firestore = admin.firestore()
|
|
@ -7,6 +7,7 @@ import { getNewMultiBetInfo } from '../../common/new-bet'
|
|||
import { Answer, MAX_ANSWER_LENGTH } from '../../common/answer'
|
||||
import { getValues } from './utils'
|
||||
import { APIError, newEndpoint, validate } from './api'
|
||||
import { addUserToContractFollowers } from './follow-market'
|
||||
|
||||
const bodySchema = z.object({
|
||||
contractId: z.string().max(MAX_ANSWER_LENGTH),
|
||||
|
@ -96,6 +97,8 @@ export const createanswer = newEndpoint(opts, async (req, auth) => {
|
|||
return answer
|
||||
})
|
||||
|
||||
await addUserToContractFollowers(contractId, auth.uid)
|
||||
|
||||
return answer
|
||||
})
|
||||
|
||||
|
|
105
functions/src/create-comment.ts
Normal file
105
functions/src/create-comment.ts
Normal file
|
@ -0,0 +1,105 @@
|
|||
import * as admin from 'firebase-admin'
|
||||
|
||||
import { getContract, getUser, log } from './utils'
|
||||
import { APIError, newEndpoint, validate } from './api'
|
||||
import { JSONContent } from '@tiptap/core'
|
||||
import { z } from 'zod'
|
||||
import { removeUndefinedProps } from '../../common/util/object'
|
||||
import { htmlToRichText } from '../../common/util/parse'
|
||||
import { marked } from 'marked'
|
||||
|
||||
const contentSchema: z.ZodType<JSONContent> = z.lazy(() =>
|
||||
z.intersection(
|
||||
z.record(z.any()),
|
||||
z.object({
|
||||
type: z.string().optional(),
|
||||
attrs: z.record(z.any()).optional(),
|
||||
content: z.array(contentSchema).optional(),
|
||||
marks: z
|
||||
.array(
|
||||
z.intersection(
|
||||
z.record(z.any()),
|
||||
z.object({
|
||||
type: z.string(),
|
||||
attrs: z.record(z.any()).optional(),
|
||||
})
|
||||
)
|
||||
)
|
||||
.optional(),
|
||||
text: z.string().optional(),
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
const postSchema = z.object({
|
||||
contractId: z.string(),
|
||||
content: contentSchema.optional(),
|
||||
html: z.string().optional(),
|
||||
markdown: z.string().optional(),
|
||||
})
|
||||
|
||||
const MAX_COMMENT_JSON_LENGTH = 20000
|
||||
|
||||
// For now, only supports creating a new top-level comment on a contract.
|
||||
// Replies, posts, chats are not supported yet.
|
||||
export const createcomment = newEndpoint({}, async (req, auth) => {
|
||||
const firestore = admin.firestore()
|
||||
const { contractId, content, html, markdown } = validate(postSchema, req.body)
|
||||
|
||||
const creator = await getUser(auth.uid)
|
||||
const contract = await getContract(contractId)
|
||||
|
||||
if (!creator) {
|
||||
throw new APIError(400, 'No user exists with the authenticated user ID.')
|
||||
}
|
||||
if (!contract) {
|
||||
throw new APIError(400, 'No contract exists with the given ID.')
|
||||
}
|
||||
|
||||
let contentJson = null
|
||||
if (content) {
|
||||
contentJson = content
|
||||
} else if (html) {
|
||||
console.log('html', html)
|
||||
contentJson = htmlToRichText(html)
|
||||
} else if (markdown) {
|
||||
const markedParse = marked.parse(markdown)
|
||||
log('parsed', markedParse)
|
||||
contentJson = htmlToRichText(markedParse)
|
||||
log('json', contentJson)
|
||||
}
|
||||
|
||||
if (!contentJson) {
|
||||
throw new APIError(400, 'No comment content provided.')
|
||||
}
|
||||
|
||||
if (JSON.stringify(contentJson).length > MAX_COMMENT_JSON_LENGTH) {
|
||||
throw new APIError(
|
||||
400,
|
||||
`Comment is too long; should be less than ${MAX_COMMENT_JSON_LENGTH} as a JSON string.`
|
||||
)
|
||||
}
|
||||
|
||||
const ref = firestore.collection(`contracts/${contractId}/comments`).doc()
|
||||
|
||||
const comment = removeUndefinedProps({
|
||||
id: ref.id,
|
||||
content: contentJson,
|
||||
createdTime: Date.now(),
|
||||
|
||||
userId: creator.id,
|
||||
userName: creator.name,
|
||||
userUsername: creator.username,
|
||||
userAvatarUrl: creator.avatarUrl,
|
||||
|
||||
// OnContract fields
|
||||
commentType: 'contract',
|
||||
contractId: contractId,
|
||||
contractSlug: contract.slug,
|
||||
contractQuestion: contract.question,
|
||||
})
|
||||
|
||||
await ref.set(comment)
|
||||
|
||||
return { status: 'success', comment }
|
||||
})
|
|
@ -61,6 +61,8 @@ export const creategroup = newEndpoint({}, async (req, auth) => {
|
|||
anyoneCanJoin,
|
||||
totalContracts: 0,
|
||||
totalMembers: memberIds.length,
|
||||
postIds: [],
|
||||
pinnedItems: [],
|
||||
}
|
||||
|
||||
await groupRef.create(group)
|
||||
|
|
|
@ -16,7 +16,7 @@ import { slugify } from '../../common/util/slugify'
|
|||
import { randomString } from '../../common/util/random'
|
||||
|
||||
import { chargeUser, getContract, isProd } from './utils'
|
||||
import { APIError, newEndpoint, validate, zTimestamp } from './api'
|
||||
import { APIError, AuthedUser, newEndpoint, validate, zTimestamp } from './api'
|
||||
|
||||
import { FIXED_ANTE, FREE_MARKETS_PER_USER_MAX } from '../../common/economy'
|
||||
import {
|
||||
|
@ -92,7 +92,11 @@ const multipleChoiceSchema = z.object({
|
|||
answers: z.string().trim().min(1).array().min(2),
|
||||
})
|
||||
|
||||
export const createmarket = newEndpoint({}, async (req, auth) => {
|
||||
export const createmarket = newEndpoint({}, (req, auth) => {
|
||||
return createMarketHelper(req.body, auth)
|
||||
})
|
||||
|
||||
export async function createMarketHelper(body: any, auth: AuthedUser) {
|
||||
const {
|
||||
question,
|
||||
description,
|
||||
|
@ -101,16 +105,13 @@ export const createmarket = newEndpoint({}, async (req, auth) => {
|
|||
outcomeType,
|
||||
groupId,
|
||||
visibility = 'public',
|
||||
} = validate(bodySchema, req.body)
|
||||
} = validate(bodySchema, body)
|
||||
|
||||
let min, max, initialProb, isLogScale, answers
|
||||
|
||||
if (outcomeType === 'PSEUDO_NUMERIC' || outcomeType === 'NUMERIC') {
|
||||
let initialValue
|
||||
;({ min, max, initialValue, isLogScale } = validate(
|
||||
numericSchema,
|
||||
req.body
|
||||
))
|
||||
;({ min, max, initialValue, isLogScale } = validate(numericSchema, body))
|
||||
if (max - min <= 0.01 || initialValue <= min || initialValue >= max)
|
||||
throw new APIError(400, 'Invalid range.')
|
||||
|
||||
|
@ -126,11 +127,11 @@ export const createmarket = newEndpoint({}, async (req, auth) => {
|
|||
}
|
||||
|
||||
if (outcomeType === 'BINARY') {
|
||||
;({ initialProb } = validate(binarySchema, req.body))
|
||||
;({ initialProb } = validate(binarySchema, body))
|
||||
}
|
||||
|
||||
if (outcomeType === 'MULTIPLE_CHOICE') {
|
||||
;({ answers } = validate(multipleChoiceSchema, req.body))
|
||||
;({ answers } = validate(multipleChoiceSchema, body))
|
||||
}
|
||||
|
||||
const userDoc = await firestore.collection('users').doc(auth.uid).get()
|
||||
|
@ -186,17 +187,17 @@ export const createmarket = newEndpoint({}, async (req, auth) => {
|
|||
|
||||
// convert string descriptions into JSONContent
|
||||
const newDescription =
|
||||
typeof description === 'string'
|
||||
!description || typeof description === 'string'
|
||||
? {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: description }],
|
||||
content: [{ type: 'text', text: description || ' ' }],
|
||||
},
|
||||
],
|
||||
}
|
||||
: description ?? {}
|
||||
: description
|
||||
|
||||
const contract = getNewContract(
|
||||
contractRef.id,
|
||||
|
@ -323,7 +324,7 @@ export const createmarket = newEndpoint({}, async (req, auth) => {
|
|||
}
|
||||
|
||||
return contract
|
||||
})
|
||||
}
|
||||
|
||||
const getSlug = async (question: string) => {
|
||||
const proposedSlug = slugify(question)
|
||||
|
|
|
@ -6,7 +6,13 @@ import {
|
|||
Notification,
|
||||
notification_reason_types,
|
||||
} from '../../common/notification'
|
||||
import { User } from '../../common/user'
|
||||
import {
|
||||
MANIFOLD_AVATAR_URL,
|
||||
MANIFOLD_USER_NAME,
|
||||
MANIFOLD_USER_USERNAME,
|
||||
PrivateUser,
|
||||
User,
|
||||
} from '../../common/user'
|
||||
import { Contract } from '../../common/contract'
|
||||
import { getPrivateUser, getValues } from './utils'
|
||||
import { Comment } from '../../common/comment'
|
||||
|
@ -30,27 +36,26 @@ import {
|
|||
import { filterDefined } from '../../common/util/array'
|
||||
import { getNotificationDestinationsForUser } from '../../common/user-notification-preferences'
|
||||
import { ContractFollow } from '../../common/follow'
|
||||
import { Badge } from 'common/badge'
|
||||
const firestore = admin.firestore()
|
||||
|
||||
type recipients_to_reason_texts = {
|
||||
[userId: string]: { reason: notification_reason_types }
|
||||
}
|
||||
|
||||
export const createNotification = async (
|
||||
export const createFollowOrMarketSubsidizedNotification = async (
|
||||
sourceId: string,
|
||||
sourceType: 'contract' | 'liquidity' | 'follow',
|
||||
sourceUpdateType: 'closed' | 'created',
|
||||
sourceType: 'liquidity' | 'follow',
|
||||
sourceUpdateType: 'created',
|
||||
sourceUser: User,
|
||||
idempotencyKey: string,
|
||||
sourceText: string,
|
||||
miscData?: {
|
||||
contract?: Contract
|
||||
recipients?: string[]
|
||||
slug?: string
|
||||
title?: string
|
||||
}
|
||||
) => {
|
||||
const { contract: sourceContract, recipients, slug, title } = miscData ?? {}
|
||||
const { contract: sourceContract, recipients } = miscData ?? {}
|
||||
|
||||
const shouldReceiveNotification = (
|
||||
userId: string,
|
||||
|
@ -94,23 +99,15 @@ export const createNotification = async (
|
|||
sourceContractCreatorUsername: sourceContract?.creatorUsername,
|
||||
sourceContractTitle: sourceContract?.question,
|
||||
sourceContractSlug: sourceContract?.slug,
|
||||
sourceSlug: slug ? slug : sourceContract?.slug,
|
||||
sourceTitle: title ? title : sourceContract?.question,
|
||||
sourceSlug: sourceContract?.slug,
|
||||
sourceTitle: sourceContract?.question,
|
||||
}
|
||||
await notificationRef.set(removeUndefinedProps(notification))
|
||||
}
|
||||
|
||||
if (!sendToEmail) continue
|
||||
|
||||
if (reason === 'your_contract_closed' && privateUser && sourceContract) {
|
||||
// TODO: include number and names of bettors waiting for creator to resolve their market
|
||||
await sendMarketCloseEmail(
|
||||
reason,
|
||||
sourceUser,
|
||||
privateUser,
|
||||
sourceContract
|
||||
)
|
||||
} else if (reason === 'subsidized_your_market') {
|
||||
if (reason === 'subsidized_your_market') {
|
||||
// TODO: send email to creator of market that was subsidized
|
||||
} else if (reason === 'on_new_follow') {
|
||||
// TODO: send email to user who was followed
|
||||
|
@ -127,20 +124,7 @@ export const createNotification = async (
|
|||
reason: 'on_new_follow',
|
||||
}
|
||||
return await sendNotificationsIfSettingsPermit(userToReasonTexts)
|
||||
} else if (
|
||||
sourceType === 'contract' &&
|
||||
sourceUpdateType === 'closed' &&
|
||||
sourceContract
|
||||
) {
|
||||
userToReasonTexts[sourceContract.creatorId] = {
|
||||
reason: 'your_contract_closed',
|
||||
}
|
||||
return await sendNotificationsIfSettingsPermit(userToReasonTexts)
|
||||
} else if (
|
||||
sourceType === 'liquidity' &&
|
||||
sourceUpdateType === 'created' &&
|
||||
sourceContract
|
||||
) {
|
||||
} else if (sourceType === 'liquidity' && sourceContract) {
|
||||
if (shouldReceiveNotification(sourceContract.creatorId, userToReasonTexts))
|
||||
userToReasonTexts[sourceContract.creatorId] = {
|
||||
reason: 'subsidized_your_market',
|
||||
|
@ -213,6 +197,7 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async (
|
|||
return await notificationRef.set(removeUndefinedProps(notification))
|
||||
}
|
||||
|
||||
const needNotFollowContractReasons = ['tagged_user']
|
||||
const stillFollowingContract = (userId: string) => {
|
||||
return contractFollowersIds.includes(userId)
|
||||
}
|
||||
|
@ -221,7 +206,12 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async (
|
|||
userId: string,
|
||||
reason: notification_reason_types
|
||||
) => {
|
||||
if (!stillFollowingContract(userId) || sourceUser.id == userId) return
|
||||
if (
|
||||
(!stillFollowingContract(userId) &&
|
||||
!needNotFollowContractReasons.includes(reason)) ||
|
||||
sourceUser.id == userId
|
||||
)
|
||||
return
|
||||
const privateUser = await getPrivateUser(userId)
|
||||
if (!privateUser) return
|
||||
const { sendToBrowser, sendToEmail } = getNotificationDestinationsForUser(
|
||||
|
@ -1046,3 +1036,122 @@ export const createContractResolvedNotifications = async (
|
|||
)
|
||||
)
|
||||
}
|
||||
|
||||
export const createBountyNotification = async (
|
||||
fromUser: User,
|
||||
toUserId: string,
|
||||
amount: number,
|
||||
idempotencyKey: string,
|
||||
contract: Contract,
|
||||
commentId?: string
|
||||
) => {
|
||||
const privateUser = await getPrivateUser(toUserId)
|
||||
if (!privateUser) return
|
||||
const { sendToBrowser } = getNotificationDestinationsForUser(
|
||||
privateUser,
|
||||
'tip_received'
|
||||
)
|
||||
if (!sendToBrowser) return
|
||||
|
||||
const slug = commentId
|
||||
const notificationRef = firestore
|
||||
.collection(`/users/${toUserId}/notifications`)
|
||||
.doc(idempotencyKey)
|
||||
const notification: Notification = {
|
||||
id: idempotencyKey,
|
||||
userId: toUserId,
|
||||
reason: 'tip_received',
|
||||
createdTime: Date.now(),
|
||||
isSeen: false,
|
||||
sourceId: commentId ? commentId : contract.id,
|
||||
sourceType: 'tip',
|
||||
sourceUpdateType: 'created',
|
||||
sourceUserName: fromUser.name,
|
||||
sourceUserUsername: fromUser.username,
|
||||
sourceUserAvatarUrl: fromUser.avatarUrl,
|
||||
sourceText: amount.toString(),
|
||||
sourceContractCreatorUsername: contract.creatorUsername,
|
||||
sourceContractTitle: contract.question,
|
||||
sourceContractSlug: contract.slug,
|
||||
sourceSlug: slug,
|
||||
sourceTitle: contract.question,
|
||||
}
|
||||
return await notificationRef.set(removeUndefinedProps(notification))
|
||||
}
|
||||
|
||||
export const createBadgeAwardedNotification = async (
|
||||
user: User,
|
||||
badge: Badge
|
||||
) => {
|
||||
const privateUser = await getPrivateUser(user.id)
|
||||
if (!privateUser) return
|
||||
const { sendToBrowser } = getNotificationDestinationsForUser(
|
||||
privateUser,
|
||||
'badges_awarded'
|
||||
)
|
||||
if (!sendToBrowser) return
|
||||
|
||||
const notificationRef = firestore
|
||||
.collection(`/users/${user.id}/notifications`)
|
||||
.doc()
|
||||
const notification: Notification = {
|
||||
id: notificationRef.id,
|
||||
userId: user.id,
|
||||
reason: 'badges_awarded',
|
||||
createdTime: Date.now(),
|
||||
isSeen: false,
|
||||
sourceId: badge.type,
|
||||
sourceType: 'badge',
|
||||
sourceUpdateType: 'created',
|
||||
sourceUserName: MANIFOLD_USER_NAME,
|
||||
sourceUserUsername: MANIFOLD_USER_USERNAME,
|
||||
sourceUserAvatarUrl: MANIFOLD_AVATAR_URL,
|
||||
sourceText: `You earned a new ${badge.name} badge!`,
|
||||
sourceSlug: `/${user.username}?show=badges&badge=${badge.type}`,
|
||||
sourceTitle: badge.name,
|
||||
data: {
|
||||
badge,
|
||||
},
|
||||
}
|
||||
return await notificationRef.set(removeUndefinedProps(notification))
|
||||
|
||||
// TODO send email notification
|
||||
}
|
||||
|
||||
export const createMarketClosedNotification = async (
|
||||
contract: Contract,
|
||||
creator: User,
|
||||
privateUser: PrivateUser,
|
||||
idempotencyKey: string
|
||||
) => {
|
||||
const notificationRef = firestore
|
||||
.collection(`/users/${creator.id}/notifications`)
|
||||
.doc(idempotencyKey)
|
||||
const notification: Notification = {
|
||||
id: idempotencyKey,
|
||||
userId: creator.id,
|
||||
reason: 'your_contract_closed',
|
||||
createdTime: Date.now(),
|
||||
isSeen: false,
|
||||
sourceId: contract.id,
|
||||
sourceType: 'contract',
|
||||
sourceUpdateType: 'closed',
|
||||
sourceContractId: contract?.id,
|
||||
sourceUserName: creator.name,
|
||||
sourceUserUsername: creator.username,
|
||||
sourceUserAvatarUrl: creator.avatarUrl,
|
||||
sourceText: contract.closeTime?.toString() ?? new Date().toString(),
|
||||
sourceContractCreatorUsername: creator.username,
|
||||
sourceContractTitle: contract.question,
|
||||
sourceContractSlug: contract.slug,
|
||||
sourceSlug: contract.slug,
|
||||
sourceTitle: contract.question,
|
||||
}
|
||||
await notificationRef.set(removeUndefinedProps(notification))
|
||||
await sendMarketCloseEmail(
|
||||
'your_contract_closed',
|
||||
creator,
|
||||
privateUser,
|
||||
contract
|
||||
)
|
||||
}
|
||||
|
|
|
@ -3,10 +3,17 @@ import * as admin from 'firebase-admin'
|
|||
import { getUser } from './utils'
|
||||
import { slugify } from '../../common/util/slugify'
|
||||
import { randomString } from '../../common/util/random'
|
||||
import { Post, MAX_POST_TITLE_LENGTH } from '../../common/post'
|
||||
import {
|
||||
Post,
|
||||
MAX_POST_TITLE_LENGTH,
|
||||
MAX_POST_SUBTITLE_LENGTH,
|
||||
} from '../../common/post'
|
||||
import { APIError, newEndpoint, validate } from './api'
|
||||
import { JSONContent } from '@tiptap/core'
|
||||
import { z } from 'zod'
|
||||
import { removeUndefinedProps } from '../../common/util/object'
|
||||
import { createMarketHelper } from './create-market'
|
||||
import { DAY_MS } from '../../common/util/time'
|
||||
|
||||
const contentSchema: z.ZodType<JSONContent> = z.lazy(() =>
|
||||
z.intersection(
|
||||
|
@ -33,12 +40,21 @@ const contentSchema: z.ZodType<JSONContent> = z.lazy(() =>
|
|||
|
||||
const postSchema = z.object({
|
||||
title: z.string().min(1).max(MAX_POST_TITLE_LENGTH),
|
||||
subtitle: z.string().min(1).max(MAX_POST_SUBTITLE_LENGTH),
|
||||
content: contentSchema,
|
||||
groupId: z.string().optional(),
|
||||
|
||||
// Date doc fields:
|
||||
bounty: z.number().optional(),
|
||||
birthday: z.number().optional(),
|
||||
type: z.string().optional(),
|
||||
question: z.string().optional(),
|
||||
})
|
||||
|
||||
export const createpost = newEndpoint({}, async (req, auth) => {
|
||||
const firestore = admin.firestore()
|
||||
const { title, content } = validate(postSchema, req.body)
|
||||
const { title, subtitle, content, groupId, question, ...otherProps } =
|
||||
validate(postSchema, req.body)
|
||||
|
||||
const creator = await getUser(auth.uid)
|
||||
if (!creator)
|
||||
|
@ -50,16 +66,59 @@ export const createpost = newEndpoint({}, async (req, auth) => {
|
|||
|
||||
const postRef = firestore.collection('posts').doc()
|
||||
|
||||
const post: Post = {
|
||||
// If this is a date doc, create a market for it.
|
||||
let contractSlug
|
||||
if (question) {
|
||||
const closeTime = Date.now() + DAY_MS * 30 * 3
|
||||
|
||||
try {
|
||||
const result = await createMarketHelper(
|
||||
{
|
||||
question,
|
||||
closeTime,
|
||||
outcomeType: 'BINARY',
|
||||
visibility: 'unlisted',
|
||||
initialProb: 50,
|
||||
// Dating group!
|
||||
groupId: 'j3ZE8fkeqiKmRGumy3O1',
|
||||
},
|
||||
auth
|
||||
)
|
||||
contractSlug = result.slug
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
const post: Post = removeUndefinedProps({
|
||||
...otherProps,
|
||||
id: postRef.id,
|
||||
creatorId: creator.id,
|
||||
slug,
|
||||
title,
|
||||
subtitle,
|
||||
createdTime: Date.now(),
|
||||
content: content,
|
||||
}
|
||||
contractSlug,
|
||||
creatorName: creator.name,
|
||||
creatorUsername: creator.username,
|
||||
creatorAvatarUrl: creator.avatarUrl,
|
||||
itemType: 'post',
|
||||
})
|
||||
|
||||
await postRef.create(post)
|
||||
if (groupId) {
|
||||
const groupRef = firestore.collection('groups').doc(groupId)
|
||||
const group = await groupRef.get()
|
||||
if (group.exists) {
|
||||
const groupData = group.data()
|
||||
if (groupData) {
|
||||
const postIds = groupData.postIds ?? []
|
||||
postIds.push(postRef.id)
|
||||
await groupRef.update({ postIds })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { status: 'success', post }
|
||||
})
|
||||
|
|
|
@ -69,6 +69,8 @@ export const createuser = newEndpoint(opts, async (req, auth) => {
|
|||
followerCountCached: 0,
|
||||
followedCategories: DEFAULT_CATEGORIES,
|
||||
shouldShowWelcome: true,
|
||||
fractionResolvedCorrectly: 1,
|
||||
achievements: {},
|
||||
}
|
||||
|
||||
await firestore.collection('users').doc(auth.uid).create(user)
|
||||
|
|
69
functions/src/drizzle-liquidity.ts
Normal file
69
functions/src/drizzle-liquidity.ts
Normal file
|
@ -0,0 +1,69 @@
|
|||
import * as functions from 'firebase-functions'
|
||||
import * as admin from 'firebase-admin'
|
||||
|
||||
import { CPMMContract } from '../../common/contract'
|
||||
import { batchedWaitAll } from '../../common/util/promise'
|
||||
import { APIError } from '../../common/api'
|
||||
import { addCpmmLiquidity } from '../../common/calculate-cpmm'
|
||||
import { formatMoneyWithDecimals } from '../../common/util/format'
|
||||
|
||||
const firestore = admin.firestore()
|
||||
|
||||
export const drizzleLiquidity = async () => {
|
||||
const snap = await firestore
|
||||
.collection('contracts')
|
||||
.where('subsidyPool', '>', 1e-7)
|
||||
.get()
|
||||
|
||||
const contractIds = snap.docs.map((doc) => doc.id)
|
||||
console.log('found', contractIds.length, 'markets to drizzle')
|
||||
console.log()
|
||||
|
||||
await batchedWaitAll(
|
||||
contractIds.map((cid) => () => drizzleMarket(cid)),
|
||||
10
|
||||
)
|
||||
}
|
||||
|
||||
export const drizzleLiquidityScheduler = functions.pubsub
|
||||
.schedule('* * * * *') // every minute
|
||||
.onRun(drizzleLiquidity)
|
||||
|
||||
const drizzleMarket = async (contractId: string) => {
|
||||
await firestore.runTransaction(async (trans) => {
|
||||
const snap = await trans.get(firestore.doc(`contracts/${contractId}`))
|
||||
const contract = snap.data() as CPMMContract
|
||||
const { subsidyPool, pool, p, slug, popularityScore } = contract
|
||||
if ((subsidyPool ?? 0) < 1e-7) return
|
||||
|
||||
const r = Math.random()
|
||||
const logPopularity = Math.log10((popularityScore ?? 0) + 1)
|
||||
const v = Math.max(1, Math.min(5, logPopularity))
|
||||
const amount = subsidyPool <= 0.5 ? subsidyPool : r * v * 0.01 * subsidyPool
|
||||
|
||||
const { newPool, newP } = addCpmmLiquidity(pool, p, amount)
|
||||
|
||||
if (!isFinite(newP)) {
|
||||
throw new APIError(
|
||||
500,
|
||||
'Liquidity injection rejected due to overflow error.'
|
||||
)
|
||||
}
|
||||
|
||||
await trans.update(firestore.doc(`contracts/${contract.id}`), {
|
||||
pool: newPool,
|
||||
p: newP,
|
||||
subsidyPool: subsidyPool - amount,
|
||||
})
|
||||
|
||||
console.log(
|
||||
'added subsidy',
|
||||
formatMoneyWithDecimals(amount),
|
||||
'of',
|
||||
formatMoneyWithDecimals(subsidyPool),
|
||||
'pool to',
|
||||
slug
|
||||
)
|
||||
console.log()
|
||||
})
|
||||
}
|
|
@ -483,11 +483,7 @@
|
|||
color: #999;
|
||||
text-decoration: underline;
|
||||
margin: 0;
|
||||
">our Discord</a>! Or,
|
||||
<a href="{{unsubscribeUrl}}" style="
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
" target="_blank">click here to unsubscribe from this type of notification</a>.
|
||||
">our Discord</a>!
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
|
|
@ -0,0 +1,411 @@
|
|||
<!DOCTYPE html>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml"
|
||||
xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||
|
||||
<head>
|
||||
<title>Weekly Portfolio Update on Manifold</title>
|
||||
<!--[if !mso]><!-->
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<!--<![endif]-->
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<style type="text/css">
|
||||
#outlook a {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-ms-text-size-adjust: 100%;
|
||||
font-family:"Readex Pro", Helvetica, sans-serif;
|
||||
}
|
||||
table { margin: 0 auto; }
|
||||
|
||||
table,
|
||||
td {
|
||||
border-collapse: collapse;
|
||||
mso-table-lspace: 0;
|
||||
mso-table-rspace: 0;
|
||||
}
|
||||
th {color:#000000; font-size:17px;}
|
||||
th, td {padding: 10px; }
|
||||
td{ font-size: 17px}
|
||||
th, td { vertical-align: center; text-align: left }
|
||||
a { vertical-align: center; text-align: left}
|
||||
img {
|
||||
border: 0;
|
||||
height: auto;
|
||||
line-height: 100%;
|
||||
outline: none;
|
||||
text-decoration: none;
|
||||
-ms-interpolation-mode: bicubic;
|
||||
}
|
||||
|
||||
p {
|
||||
display: block;
|
||||
margin: 13px 0;
|
||||
}
|
||||
p.change{
|
||||
margin: 0; vertical-align: middle;font-size:16px;display: inline; padding: 2px; border-radius: 5px; width: 20px; text-align: right;
|
||||
}
|
||||
p.prob{
|
||||
font-size: 22px;display: inline; vertical-align: middle; font-weight: bold; width: 50px;
|
||||
}
|
||||
a.question{
|
||||
font-size: 18px;display: inline; vertical-align: middle;
|
||||
}
|
||||
td.question{
|
||||
vertical-align: middle; padding-bottom: 15px; text-align: left;
|
||||
}
|
||||
td.probs{
|
||||
text-align: right; padding-left: 10px; min-width: 115px
|
||||
}
|
||||
</style>
|
||||
<!--[if mso]>
|
||||
<noscript>
|
||||
<xml>
|
||||
<o:OfficeDocumentSettings>
|
||||
<o:AllowPNG />
|
||||
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||
</o:OfficeDocumentSettings>
|
||||
</xml>
|
||||
</noscript>
|
||||
<![endif]-->
|
||||
<!--[if lte mso 11]>
|
||||
<style type="text/css">
|
||||
.mj-outlook-group-fix {
|
||||
width: 100% !important;
|
||||
}
|
||||
</style>
|
||||
<![endif]-->
|
||||
<!--[if !mso]><!-->
|
||||
<link href="https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700" rel="stylesheet" type="text/css" />
|
||||
<link href="https://fonts.googleapis.com/css?family=Readex+Pro" rel="stylesheet" type="text/css" />
|
||||
<link href="https://fonts.googleapis.com/css?family=Readex+Pro" rel="stylesheet" type="text/css" />
|
||||
<style type="text/css">
|
||||
@import url(https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700);
|
||||
@import url(https://fonts.googleapis.com/css?family=Readex+Pro);
|
||||
@import url(https://fonts.googleapis.com/css?family=Readex+Pro);
|
||||
</style>
|
||||
<!--<![endif]-->
|
||||
<style type="text/css">
|
||||
@media only screen and (min-width: 480px) {
|
||||
.mj-column-per-100 {
|
||||
width: 100% !important;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<style media="screen and (min-width:480px)">
|
||||
.moz-text-html .mj-column-per-100 {
|
||||
width: 100% !important;
|
||||
max-width: 100%;
|
||||
}
|
||||
</style>
|
||||
<style type="text/css">
|
||||
[owa] .mj-column-per-100 {
|
||||
width: 100% !important;
|
||||
max-width: 100%;
|
||||
}
|
||||
</style>
|
||||
<style type="text/css">
|
||||
@media only screen and (max-width: 480px) {
|
||||
table.mj-full-width-mobile {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
td.mj-full-width-mobile {
|
||||
width: auto !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body style="word-spacing: normal; background-color: #f4f4f4">
|
||||
<div style="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 5px 0px;padding-bottom:0px;padding-left:0px;padding-right:0px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
||||
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix"
|
||||
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="vertical-align:top;" width="100%">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td align="center"
|
||||
style="background:#ffffff;font-size:0px;padding:10px 25px 10px 25px;padding-right:25px;padding-left:25px;word-break:break-word;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="border-collapse:collapse;border-spacing:0px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="width:550px;">
|
||||
|
||||
<a href="https://manifold.markets" target="_blank">
|
||||
|
||||
<img alt="banner logo" height="auto"
|
||||
src="https://manifold.markets/logo-banner.png"
|
||||
style="border:none;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:13px;"
|
||||
title="" width="550">
|
||||
|
||||
</a>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
<div style="
|
||||
background: #ffffff;
|
||||
background-color: #ffffff;
|
||||
margin: 0px auto;
|
||||
max-width: 600px;
|
||||
">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="background: #ffffff; background-color: #ffffff; width: 100%">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td style="
|
||||
direction: ltr;
|
||||
font-size: 0px;
|
||||
padding: 20px 0px 0px 0px;
|
||||
padding-bottom: 0px;
|
||||
padding-left: 0px;
|
||||
padding-right: 0px;
|
||||
padding-top: 20px;
|
||||
text-align: center;
|
||||
">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="
|
||||
font-size: 0px;
|
||||
text-align: left;
|
||||
direction: ltr;
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
width: 100%;
|
||||
">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="vertical-align: top; margin-bottom: 30px" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left"
|
||||
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
|
||||
<div
|
||||
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
|
||||
<p class="text-build-content"
|
||||
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"
|
||||
data-testid="4XoHRGw1Y"><span
|
||||
style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
|
||||
</span>Hi {{name}},</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td align="left"
|
||||
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:20px;word-break:break-word;">
|
||||
<div
|
||||
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
|
||||
<p class="text-build-content"
|
||||
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 0px;"
|
||||
data-testid="4XoHRGw1Y">
|
||||
<span style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
|
||||
We ran the numbers and here's how you did this past week!
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<!--/ show 5 columns with headers titled: Investment value, 7-day change, current balance, tips received, and markets made/-->
|
||||
<tr>
|
||||
<tr>
|
||||
<th style='font-size: 22px; text-align: center'>
|
||||
Profit
|
||||
</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style='padding-bottom: 30px; text-align: center'>
|
||||
<p class='change' style='font-size: 24px; padding:4px; {{profit_style}}'>
|
||||
{{profit}}
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
<td align="center"
|
||||
style="font-size:0px;padding:10px 20px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="border-collapse:collapse;border-spacing:0px; ">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th style='width: 170px'>
|
||||
🔥 Prediction streak
|
||||
</th>
|
||||
<td>
|
||||
{{prediction_streak}}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>
|
||||
💸 Tips received
|
||||
</th>
|
||||
<td>
|
||||
{{tips_received}}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>
|
||||
📈 Markets traded
|
||||
</th>
|
||||
<td>
|
||||
{{markets_traded}}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>
|
||||
❓ Markets created
|
||||
</th>
|
||||
|
||||
<td>
|
||||
{{markets_created}}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th style='width: 55px'>
|
||||
🥳 Traders attracted
|
||||
</th>
|
||||
<td>
|
||||
{{unique_bettors}}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
<div style="margin: 0px auto; max-width: 600px">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="
|
||||
direction: ltr;
|
||||
font-size: 0px;
|
||||
padding: 0 0 20px 0;
|
||||
text-align: center;
|
||||
">
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
<div style="margin: 0px auto; max-width: 600px">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="width: 100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="
|
||||
direction: ltr;
|
||||
font-size: 0px;
|
||||
padding: 20px 0px 20px 0px;
|
||||
text-align: center;
|
||||
">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="
|
||||
font-size: 0px;
|
||||
text-align: left;
|
||||
direction: ltr;
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
width: 100%;
|
||||
">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||
width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="vertical-align: top; padding: 0">
|
||||
<table border="0" cellpadding="0" cellspacing="0"
|
||||
role="presentation" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" style="
|
||||
font-size: 0px;
|
||||
padding: 10px 25px;
|
||||
word-break: break-word;
|
||||
">
|
||||
<div style="
|
||||
font-family: Ubuntu, Helvetica, Arial,
|
||||
sans-serif;
|
||||
font-size: 11px;
|
||||
line-height: 22px;
|
||||
text-align: center;
|
||||
color: #000000;
|
||||
">
|
||||
<p style="margin: 10px 0">
|
||||
This e-mail has been sent to
|
||||
{{name}},
|
||||
<a href="{{unsubscribeUrl}}" style="
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
" target="_blank">click here to unsubscribe from this type of notification</a>.
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="
|
||||
font-size: 0px;
|
||||
padding: 10px 25px;
|
||||
word-break: break-word;
|
||||
"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
510
functions/src/email-templates/weekly-portfolio-update.html
Normal file
510
functions/src/email-templates/weekly-portfolio-update.html
Normal file
|
@ -0,0 +1,510 @@
|
|||
<!DOCTYPE html>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml"
|
||||
xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||
|
||||
<head>
|
||||
<title>Weekly Portfolio Update on Manifold</title>
|
||||
<!--[if !mso]><!-->
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<!--<![endif]-->
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<style type="text/css">
|
||||
#outlook a {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-ms-text-size-adjust: 100%;
|
||||
font-family:"Readex Pro", Helvetica, sans-serif;
|
||||
}
|
||||
table { margin: 0 auto; }
|
||||
|
||||
table,
|
||||
td {
|
||||
border-collapse: collapse;
|
||||
mso-table-lspace: 0;
|
||||
mso-table-rspace: 0;
|
||||
}
|
||||
th {color:#000000; font-size:17px;}
|
||||
th, td {padding: 10px; }
|
||||
td{ font-size: 17px}
|
||||
th, td { vertical-align: center; text-align: left }
|
||||
a { vertical-align: center; text-align: left}
|
||||
img {
|
||||
border: 0;
|
||||
height: auto;
|
||||
line-height: 100%;
|
||||
outline: none;
|
||||
text-decoration: none;
|
||||
-ms-interpolation-mode: bicubic;
|
||||
}
|
||||
|
||||
p {
|
||||
display: block;
|
||||
margin: 13px 0;
|
||||
}
|
||||
p.change{
|
||||
margin: 0; vertical-align: middle;font-size:16px;display: inline; padding: 2px; border-radius: 5px; width: 20px; text-align: right;
|
||||
}
|
||||
p.prob{
|
||||
font-size: 22px;display: inline; vertical-align: middle; font-weight: bold; width: 50px;
|
||||
}
|
||||
a.question{
|
||||
font-size: 18px;display: inline; vertical-align: middle;
|
||||
}
|
||||
td.question{
|
||||
vertical-align: middle; padding-bottom: 15px; text-align: left;
|
||||
}
|
||||
td.probs{
|
||||
text-align: right; padding-left: 10px; min-width: 115px
|
||||
}
|
||||
</style>
|
||||
<!--[if mso]>
|
||||
<noscript>
|
||||
<xml>
|
||||
<o:OfficeDocumentSettings>
|
||||
<o:AllowPNG />
|
||||
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||
</o:OfficeDocumentSettings>
|
||||
</xml>
|
||||
</noscript>
|
||||
<![endif]-->
|
||||
<!--[if lte mso 11]>
|
||||
<style type="text/css">
|
||||
.mj-outlook-group-fix {
|
||||
width: 100% !important;
|
||||
}
|
||||
</style>
|
||||
<![endif]-->
|
||||
<!--[if !mso]><!-->
|
||||
<link href="https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700" rel="stylesheet" type="text/css" />
|
||||
<link href="https://fonts.googleapis.com/css?family=Readex+Pro" rel="stylesheet" type="text/css" />
|
||||
<link href="https://fonts.googleapis.com/css?family=Readex+Pro" rel="stylesheet" type="text/css" />
|
||||
<style type="text/css">
|
||||
@import url(https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700);
|
||||
@import url(https://fonts.googleapis.com/css?family=Readex+Pro);
|
||||
@import url(https://fonts.googleapis.com/css?family=Readex+Pro);
|
||||
</style>
|
||||
<!--<![endif]-->
|
||||
<style type="text/css">
|
||||
@media only screen and (min-width: 480px) {
|
||||
.mj-column-per-100 {
|
||||
width: 100% !important;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<style media="screen and (min-width:480px)">
|
||||
.moz-text-html .mj-column-per-100 {
|
||||
width: 100% !important;
|
||||
max-width: 100%;
|
||||
}
|
||||
</style>
|
||||
<style type="text/css">
|
||||
[owa] .mj-column-per-100 {
|
||||
width: 100% !important;
|
||||
max-width: 100%;
|
||||
}
|
||||
</style>
|
||||
<style type="text/css">
|
||||
@media only screen and (max-width: 480px) {
|
||||
table.mj-full-width-mobile {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
td.mj-full-width-mobile {
|
||||
width: auto !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body style="word-spacing: normal; background-color: #f4f4f4">
|
||||
<div style="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 5px 0px;padding-bottom:0px;padding-left:0px;padding-right:0px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
||||
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix"
|
||||
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="vertical-align:top;" width="100%">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td align="center"
|
||||
style="background:#ffffff;font-size:0px;padding:10px 25px 10px 25px;padding-right:25px;padding-left:25px;word-break:break-word;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="border-collapse:collapse;border-spacing:0px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="width:550px;">
|
||||
|
||||
<a href="https://manifold.markets" target="_blank">
|
||||
|
||||
<img alt="banner logo" height="auto"
|
||||
src="https://manifold.markets/logo-banner.png"
|
||||
style="border:none;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:13px;"
|
||||
title="" width="550">
|
||||
|
||||
</a>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
<div style="
|
||||
background: #ffffff;
|
||||
background-color: #ffffff;
|
||||
margin: 0px auto;
|
||||
max-width: 600px;
|
||||
">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="background: #ffffff; background-color: #ffffff; width: 100%">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td style="
|
||||
direction: ltr;
|
||||
font-size: 0px;
|
||||
padding: 20px 0px 0px 0px;
|
||||
padding-bottom: 0px;
|
||||
padding-left: 0px;
|
||||
padding-right: 0px;
|
||||
padding-top: 20px;
|
||||
text-align: center;
|
||||
">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="
|
||||
font-size: 0px;
|
||||
text-align: left;
|
||||
direction: ltr;
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
width: 100%;
|
||||
">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="vertical-align: top" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left"
|
||||
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
|
||||
<div
|
||||
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
|
||||
<p class="text-build-content"
|
||||
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"
|
||||
data-testid="4XoHRGw1Y"><span
|
||||
style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
|
||||
</span>Hi {{name}},</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td align="left"
|
||||
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:20px;word-break:break-word;">
|
||||
<div
|
||||
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
|
||||
<p class="text-build-content"
|
||||
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 0px;"
|
||||
data-testid="4XoHRGw1Y">
|
||||
<span style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
|
||||
We ran the numbers and here's how you did this past week!
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<!--/ show 5 columns with headers titled: Investment value, 7-day change, current balance, tips received, and markets made/-->
|
||||
<tr>
|
||||
<tr>
|
||||
<th style='font-size: 22px; text-align: center'>
|
||||
Profit
|
||||
</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style='padding-bottom: 30px; text-align: center'>
|
||||
<p class='change' style='font-size: 24px; padding:4px; {{profit_style}}'>
|
||||
{{profit}}
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
<td align="center"
|
||||
style="font-size:0px;padding:10px 20px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="border-collapse:collapse;border-spacing:0px; ">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th style='width: 170px'>
|
||||
🔥 Prediction streak
|
||||
</th>
|
||||
<td>
|
||||
{{prediction_streak}}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>
|
||||
💸 Tips received
|
||||
</th>
|
||||
<td>
|
||||
{{tips_received}}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>
|
||||
📈 Markets traded
|
||||
</th>
|
||||
<td>
|
||||
{{markets_traded}}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>
|
||||
❓ Markets created
|
||||
</th>
|
||||
|
||||
<td>
|
||||
{{markets_created}}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th style='width: 55px'>
|
||||
🥳 Traders attracted
|
||||
</th>
|
||||
<td>
|
||||
{{unique_bettors}}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td align="left"
|
||||
style="font-size:0px;padding:10px 25px;padding-top:20px;padding-bottom:0px;word-break:break-word;">
|
||||
<div
|
||||
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
|
||||
<p class="text-build-content"
|
||||
style="line-height: 24px; margin: 10px 0; margin-top: 20px; margin-bottom: 20px;"
|
||||
data-testid="4XoHRGw1Y">
|
||||
<span style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
|
||||
And here's some recent changes in your investments:
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
<tr>
|
||||
<td
|
||||
style="font-size:0; padding-left:10px;padding-top:10px;padding-bottom:0;word-break:break-word;">
|
||||
<table role="presentation">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class='question'>
|
||||
<a class='question' href='{{question1Url}}'>
|
||||
{{question1Title}}
|
||||
<!-- Will the US economy recover from the pandemic?-->
|
||||
</a>
|
||||
</td>
|
||||
<td class='probs'>
|
||||
<p class='prob'>
|
||||
{{question1Prob}}
|
||||
<!-- 9.9%-->
|
||||
<p class='change' style='{{question1ChangeStyle}}'>
|
||||
{{question1Change}}
|
||||
<!-- +17%-->
|
||||
</p>
|
||||
</p>
|
||||
</td>
|
||||
</tr><tr>
|
||||
<td class='question'>
|
||||
<a class='question' href='{{question2Url}}'>
|
||||
{{question2Title}}
|
||||
<!-- Will the US economy recover from the pandemic? blah blah blah-->
|
||||
</a>
|
||||
</td>
|
||||
<td class='probs'>
|
||||
<p class='prob'>
|
||||
{{question2Prob}}
|
||||
<!-- 99.9%-->
|
||||
<p class='change' style='{{question2ChangeStyle}}'>
|
||||
{{question2Change}}
|
||||
<!-- +7%-->
|
||||
</p>
|
||||
</p>
|
||||
</td>
|
||||
</tr><tr>
|
||||
<!-- <td style="{{investment_value_style}}">-->
|
||||
<td class='question'>
|
||||
<a class='question' href='{{question3Url}}'>
|
||||
{{question3Title}}
|
||||
<!-- Will the US economy recover from the pandemic?-->
|
||||
</a>
|
||||
</td>
|
||||
<td class='probs'>
|
||||
<p class='prob'>
|
||||
{{question3Prob}}
|
||||
<!-- 99.9%-->
|
||||
<p class='change' style='{{question3ChangeStyle}}'>
|
||||
{{question3Change}}
|
||||
<!-- +17%-->
|
||||
</p>
|
||||
</p>
|
||||
</td>
|
||||
</tr><tr>
|
||||
<!-- <td style="{{investment_value_style}}">-->
|
||||
<td class='question'>
|
||||
<a class='question' href='{{question4Url}}'>
|
||||
{{question4Title}}
|
||||
<!-- Will the US economy recover from the pandemic?-->
|
||||
</a>
|
||||
</td>
|
||||
<td class='probs'>
|
||||
<p class='prob'>
|
||||
{{question4Prob}}
|
||||
<!-- 99.9%-->
|
||||
<p class='change' style='{{question4ChangeStyle}}'>
|
||||
{{question4Change}}
|
||||
<!-- +17%-->
|
||||
</p>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
<div style="margin: 0px auto; max-width: 600px">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="
|
||||
direction: ltr;
|
||||
font-size: 0px;
|
||||
padding: 0 0 20px 0;
|
||||
text-align: center;
|
||||
">
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
<div style="margin: 0px auto; max-width: 600px">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="width: 100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="
|
||||
direction: ltr;
|
||||
font-size: 0px;
|
||||
padding: 20px 0px 20px 0px;
|
||||
text-align: center;
|
||||
">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="
|
||||
font-size: 0px;
|
||||
text-align: left;
|
||||
direction: ltr;
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
width: 100%;
|
||||
">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||
width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="vertical-align: top; padding: 0">
|
||||
<table border="0" cellpadding="0" cellspacing="0"
|
||||
role="presentation" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" style="
|
||||
font-size: 0px;
|
||||
padding: 10px 25px;
|
||||
word-break: break-word;
|
||||
">
|
||||
<div style="
|
||||
font-family: Ubuntu, Helvetica, Arial,
|
||||
sans-serif;
|
||||
font-size: 11px;
|
||||
line-height: 22px;
|
||||
text-align: center;
|
||||
color: #000000;
|
||||
">
|
||||
<p style="margin: 10px 0">
|
||||
This e-mail has been sent to
|
||||
{{name}},
|
||||
<a href="{{unsubscribeUrl}}" style="
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
" target="_blank">click here to unsubscribe from this type of notification</a>.
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="
|
||||
font-size: 0px;
|
||||
padding: 10px 25px;
|
||||
word-break: break-word;
|
||||
"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
|
@ -12,14 +12,15 @@ import { getValueFromBucket } from '../../common/calculate-dpm'
|
|||
import { formatNumericProbability } from '../../common/pseudo-numeric'
|
||||
|
||||
import { sendTemplateEmail, sendTextEmail } from './send-email'
|
||||
import { getUser } from './utils'
|
||||
import { contractUrl, getUser, log } from './utils'
|
||||
import { buildCardUrl, getOpenGraphProps } from '../../common/contract-details'
|
||||
import { notification_reason_types } from '../../common/notification'
|
||||
import { Dictionary } from 'lodash'
|
||||
import { getNotificationDestinationsForUser } from '../../common/user-notification-preferences'
|
||||
import {
|
||||
getNotificationDestinationsForUser,
|
||||
notification_preference,
|
||||
} from '../../common/user-notification-preferences'
|
||||
PerContractInvestmentsData,
|
||||
OverallPerformanceData,
|
||||
} from './weekly-portfolio-emails'
|
||||
|
||||
export const sendMarketResolutionEmail = async (
|
||||
reason: notification_reason_types,
|
||||
|
@ -152,9 +153,10 @@ export const sendWelcomeEmail = async (
|
|||
const { name } = user
|
||||
const firstName = name.split(' ')[0]
|
||||
|
||||
const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${
|
||||
'onboarding_flow' as notification_preference
|
||||
}`
|
||||
const { unsubscribeUrl } = getNotificationDestinationsForUser(
|
||||
privateUser,
|
||||
'onboarding_flow'
|
||||
)
|
||||
|
||||
return await sendTemplateEmail(
|
||||
privateUser.email,
|
||||
|
@ -210,19 +212,17 @@ export const sendOneWeekBonusEmail = async (
|
|||
user: User,
|
||||
privateUser: PrivateUser
|
||||
) => {
|
||||
if (
|
||||
!privateUser ||
|
||||
!privateUser.email ||
|
||||
!privateUser.notificationPreferences.onboarding_flow.includes('email')
|
||||
)
|
||||
return
|
||||
if (!privateUser || !privateUser.email) return
|
||||
|
||||
const { name } = user
|
||||
const firstName = name.split(' ')[0]
|
||||
|
||||
const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${
|
||||
'onboarding_flow' as notification_preference
|
||||
}`
|
||||
const { unsubscribeUrl, sendToEmail } = getNotificationDestinationsForUser(
|
||||
privateUser,
|
||||
'onboarding_flow'
|
||||
)
|
||||
if (!sendToEmail) return
|
||||
|
||||
return await sendTemplateEmail(
|
||||
privateUser.email,
|
||||
'Manifold Markets one week anniversary gift',
|
||||
|
@ -243,19 +243,15 @@ export const sendCreatorGuideEmail = async (
|
|||
privateUser: PrivateUser,
|
||||
sendTime: string
|
||||
) => {
|
||||
if (
|
||||
!privateUser ||
|
||||
!privateUser.email ||
|
||||
!privateUser.notificationPreferences.onboarding_flow.includes('email')
|
||||
)
|
||||
return
|
||||
if (!privateUser || !privateUser.email) return
|
||||
|
||||
const { name } = user
|
||||
const firstName = name.split(' ')[0]
|
||||
|
||||
const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${
|
||||
'onboarding_flow' as notification_preference
|
||||
}`
|
||||
const { unsubscribeUrl, sendToEmail } = getNotificationDestinationsForUser(
|
||||
privateUser,
|
||||
'onboarding_flow'
|
||||
)
|
||||
if (!sendToEmail) return
|
||||
return await sendTemplateEmail(
|
||||
privateUser.email,
|
||||
'Create your own prediction market',
|
||||
|
@ -275,22 +271,16 @@ export const sendThankYouEmail = async (
|
|||
user: User,
|
||||
privateUser: PrivateUser
|
||||
) => {
|
||||
if (
|
||||
!privateUser ||
|
||||
!privateUser.email ||
|
||||
!privateUser.notificationPreferences.thank_you_for_purchases.includes(
|
||||
'email'
|
||||
)
|
||||
)
|
||||
return
|
||||
if (!privateUser || !privateUser.email) return
|
||||
|
||||
const { name } = user
|
||||
const firstName = name.split(' ')[0]
|
||||
const { unsubscribeUrl, sendToEmail } = getNotificationDestinationsForUser(
|
||||
privateUser,
|
||||
'thank_you_for_purchases'
|
||||
)
|
||||
|
||||
const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${
|
||||
'thank_you_for_purchases' as notification_preference
|
||||
}`
|
||||
|
||||
if (!sendToEmail) return
|
||||
return await sendTemplateEmail(
|
||||
privateUser.email,
|
||||
'Thanks for your Manifold purchase',
|
||||
|
@ -311,12 +301,7 @@ export const sendMarketCloseEmail = async (
|
|||
privateUser: PrivateUser,
|
||||
contract: Contract
|
||||
) => {
|
||||
const { sendToEmail, unsubscribeUrl } = getNotificationDestinationsForUser(
|
||||
privateUser,
|
||||
reason
|
||||
)
|
||||
|
||||
if (!privateUser.email || !sendToEmail) return
|
||||
if (!privateUser.email) return
|
||||
|
||||
const { username, name, id: userId } = user
|
||||
const firstName = name.split(' ')[0]
|
||||
|
@ -325,6 +310,7 @@ export const sendMarketCloseEmail = async (
|
|||
|
||||
const url = `https://${DOMAIN}/${username}/${slug}`
|
||||
|
||||
// We ignore if they were able to unsubscribe from market close emails, this is a necessary email
|
||||
return await sendTemplateEmail(
|
||||
privateUser.email,
|
||||
'Your market has closed',
|
||||
|
@ -332,7 +318,7 @@ export const sendMarketCloseEmail = async (
|
|||
{
|
||||
question,
|
||||
url,
|
||||
unsubscribeUrl,
|
||||
unsubscribeUrl: '',
|
||||
userId,
|
||||
name: firstName,
|
||||
volume: formatMoney(volume),
|
||||
|
@ -462,16 +448,13 @@ export const sendInterestingMarketsEmail = async (
|
|||
contractsToSend: Contract[],
|
||||
deliveryTime?: string
|
||||
) => {
|
||||
if (
|
||||
!privateUser ||
|
||||
!privateUser.email ||
|
||||
!privateUser.notificationPreferences.trending_markets.includes('email')
|
||||
)
|
||||
return
|
||||
if (!privateUser || !privateUser.email) return
|
||||
|
||||
const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${
|
||||
'trending_markets' as notification_preference
|
||||
}`
|
||||
const { unsubscribeUrl, sendToEmail } = getNotificationDestinationsForUser(
|
||||
privateUser,
|
||||
'trending_markets'
|
||||
)
|
||||
if (!sendToEmail) return
|
||||
|
||||
const { name } = user
|
||||
const firstName = name.split(' ')[0]
|
||||
|
@ -507,10 +490,6 @@ export const sendInterestingMarketsEmail = async (
|
|||
)
|
||||
}
|
||||
|
||||
function contractUrl(contract: Contract) {
|
||||
return `https://manifold.markets/${contract.creatorUsername}/${contract.slug}`
|
||||
}
|
||||
|
||||
function imageSourceUrl(contract: Contract) {
|
||||
return buildCardUrl(getOpenGraphProps(contract))
|
||||
}
|
||||
|
@ -612,3 +591,45 @@ export const sendNewUniqueBettorsEmail = async (
|
|||
}
|
||||
)
|
||||
}
|
||||
|
||||
export const sendWeeklyPortfolioUpdateEmail = async (
|
||||
user: User,
|
||||
privateUser: PrivateUser,
|
||||
investments: PerContractInvestmentsData[],
|
||||
overallPerformance: OverallPerformanceData
|
||||
) => {
|
||||
if (!privateUser || !privateUser.email) return
|
||||
|
||||
const { unsubscribeUrl, sendToEmail } = getNotificationDestinationsForUser(
|
||||
privateUser,
|
||||
'profit_loss_updates'
|
||||
)
|
||||
|
||||
if (!sendToEmail) return
|
||||
|
||||
const { name } = user
|
||||
const firstName = name.split(' ')[0]
|
||||
const templateData: Record<string, string> = {
|
||||
name: firstName,
|
||||
unsubscribeUrl,
|
||||
...overallPerformance,
|
||||
}
|
||||
investments.forEach((investment, i) => {
|
||||
templateData[`question${i + 1}Title`] = investment.questionTitle
|
||||
templateData[`question${i + 1}Url`] = investment.questionUrl
|
||||
templateData[`question${i + 1}Prob`] = investment.questionProb
|
||||
templateData[`question${i + 1}Change`] = formatMoney(investment.profit)
|
||||
templateData[`question${i + 1}ChangeStyle`] = investment.profitStyle
|
||||
})
|
||||
|
||||
await sendTemplateEmail(
|
||||
privateUser.email,
|
||||
// 'iansphilips@gmail.com',
|
||||
`Here's your weekly portfolio update!`,
|
||||
investments.length === 0
|
||||
? 'portfolio-update-no-movers'
|
||||
: 'portfolio-update',
|
||||
templateData
|
||||
)
|
||||
log('Sent portfolio update email to', privateUser.email)
|
||||
}
|
||||
|
|
42
functions/src/helpers/add-house-subsidy.ts
Normal file
42
functions/src/helpers/add-house-subsidy.ts
Normal file
|
@ -0,0 +1,42 @@
|
|||
import * as admin from 'firebase-admin'
|
||||
|
||||
import { CPMMContract } from '../../../common/contract'
|
||||
import { isProd } from '../utils'
|
||||
import {
|
||||
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
|
||||
HOUSE_LIQUIDITY_PROVIDER_ID,
|
||||
} from '../../../common/antes'
|
||||
import { getNewLiquidityProvision } from '../../../common/add-liquidity'
|
||||
|
||||
const firestore = admin.firestore()
|
||||
|
||||
export const addHouseSubsidy = (contractId: string, amount: number) => {
|
||||
return firestore.runTransaction(async (transaction) => {
|
||||
const newLiquidityProvisionDoc = firestore
|
||||
.collection(`contracts/${contractId}/liquidity`)
|
||||
.doc()
|
||||
|
||||
const providerId = isProd()
|
||||
? HOUSE_LIQUIDITY_PROVIDER_ID
|
||||
: DEV_HOUSE_LIQUIDITY_PROVIDER_ID
|
||||
|
||||
const contractDoc = firestore.doc(`contracts/${contractId}`)
|
||||
const snap = await contractDoc.get()
|
||||
const contract = snap.data() as CPMMContract
|
||||
|
||||
const { newLiquidityProvision, newTotalLiquidity, newSubsidyPool } =
|
||||
getNewLiquidityProvision(
|
||||
providerId,
|
||||
amount,
|
||||
contract,
|
||||
newLiquidityProvisionDoc.id
|
||||
)
|
||||
|
||||
transaction.update(contractDoc, {
|
||||
subsidyPool: newSubsidyPool,
|
||||
totalLiquidity: newTotalLiquidity,
|
||||
} as Partial<CPMMContract>)
|
||||
|
||||
transaction.create(newLiquidityProvisionDoc, newLiquidityProvision)
|
||||
})
|
||||
}
|
|
@ -9,7 +9,7 @@ export * from './on-create-user'
|
|||
export * from './on-create-bet'
|
||||
export * from './on-create-comment-on-contract'
|
||||
export * from './on-view'
|
||||
export * from './update-metrics'
|
||||
export { scheduleUpdateMetrics } from './update-metrics'
|
||||
export * from './update-stats'
|
||||
export * from './update-loans'
|
||||
export * from './backup-db'
|
||||
|
@ -27,9 +27,11 @@ export * from './on-delete-group'
|
|||
export * from './score-contracts'
|
||||
export * from './weekly-markets-emails'
|
||||
export * from './reset-betting-streaks'
|
||||
export * from './reset-weekly-emails-flag'
|
||||
export * from './reset-weekly-emails-flags'
|
||||
export * from './on-update-contract-follow'
|
||||
export * from './on-update-like'
|
||||
export * from './weekly-portfolio-emails'
|
||||
export * from './drizzle-liquidity'
|
||||
|
||||
// v2
|
||||
export * from './health'
|
||||
|
@ -43,13 +45,14 @@ export * from './sell-bet'
|
|||
export * from './sell-shares'
|
||||
export * from './claim-manalink'
|
||||
export * from './create-market'
|
||||
export * from './add-liquidity'
|
||||
export * from './withdraw-liquidity'
|
||||
export * from './create-group'
|
||||
export * from './resolve-market'
|
||||
export * from './unsubscribe'
|
||||
export * from './stripe'
|
||||
export * from './mana-bonus-email'
|
||||
export * from './close-market'
|
||||
export * from './update-comment-bounty'
|
||||
export * from './add-subsidy'
|
||||
|
||||
import { health } from './health'
|
||||
import { transact } from './transact'
|
||||
|
@ -62,16 +65,19 @@ import { sellbet } from './sell-bet'
|
|||
import { sellshares } from './sell-shares'
|
||||
import { claimmanalink } from './claim-manalink'
|
||||
import { createmarket } from './create-market'
|
||||
import { addliquidity } from './add-liquidity'
|
||||
import { withdrawliquidity } from './withdraw-liquidity'
|
||||
import { createcomment } from './create-comment'
|
||||
import { addcommentbounty, awardcommentbounty } from './update-comment-bounty'
|
||||
import { creategroup } from './create-group'
|
||||
import { resolvemarket } from './resolve-market'
|
||||
import { closemarket } from './close-market'
|
||||
import { unsubscribe } from './unsubscribe'
|
||||
import { stripewebhook, createcheckoutsession } from './stripe'
|
||||
import { getcurrentuser } from './get-current-user'
|
||||
import { acceptchallenge } from './accept-challenge'
|
||||
import { createpost } from './create-post'
|
||||
import { savetwitchcredentials } from './save-twitch-credentials'
|
||||
import { updatemetrics } from './update-metrics'
|
||||
import { addsubsidy } from './add-subsidy'
|
||||
|
||||
const toCloudFunction = ({ opts, handler }: EndpointDefinition) => {
|
||||
return onRequest(opts, handler as any)
|
||||
|
@ -87,10 +93,13 @@ const sellBetFunction = toCloudFunction(sellbet)
|
|||
const sellSharesFunction = toCloudFunction(sellshares)
|
||||
const claimManalinkFunction = toCloudFunction(claimmanalink)
|
||||
const createMarketFunction = toCloudFunction(createmarket)
|
||||
const addLiquidityFunction = toCloudFunction(addliquidity)
|
||||
const withdrawLiquidityFunction = toCloudFunction(withdrawliquidity)
|
||||
const addSubsidyFunction = toCloudFunction(addsubsidy)
|
||||
const addCommentBounty = toCloudFunction(addcommentbounty)
|
||||
const createCommentFunction = toCloudFunction(createcomment)
|
||||
const awardCommentBounty = toCloudFunction(awardcommentbounty)
|
||||
const createGroupFunction = toCloudFunction(creategroup)
|
||||
const resolveMarketFunction = toCloudFunction(resolvemarket)
|
||||
const closeMarketFunction = toCloudFunction(closemarket)
|
||||
const unsubscribeFunction = toCloudFunction(unsubscribe)
|
||||
const stripeWebhookFunction = toCloudFunction(stripewebhook)
|
||||
const createCheckoutSessionFunction = toCloudFunction(createcheckoutsession)
|
||||
|
@ -98,6 +107,7 @@ const getCurrentUserFunction = toCloudFunction(getcurrentuser)
|
|||
const acceptChallenge = toCloudFunction(acceptchallenge)
|
||||
const createPostFunction = toCloudFunction(createpost)
|
||||
const saveTwitchCredentials = toCloudFunction(savetwitchcredentials)
|
||||
const updateMetricsFunction = toCloudFunction(updatemetrics)
|
||||
|
||||
export {
|
||||
healthFunction as health,
|
||||
|
@ -111,15 +121,19 @@ export {
|
|||
sellSharesFunction as sellshares,
|
||||
claimManalinkFunction as claimmanalink,
|
||||
createMarketFunction as createmarket,
|
||||
addLiquidityFunction as addliquidity,
|
||||
withdrawLiquidityFunction as withdrawliquidity,
|
||||
addSubsidyFunction as addsubsidy,
|
||||
createGroupFunction as creategroup,
|
||||
resolveMarketFunction as resolvemarket,
|
||||
closeMarketFunction as closemarket,
|
||||
unsubscribeFunction as unsubscribe,
|
||||
stripeWebhookFunction as stripewebhook,
|
||||
createCheckoutSessionFunction as createcheckoutsession,
|
||||
getCurrentUserFunction as getcurrentuser,
|
||||
acceptChallenge as acceptchallenge,
|
||||
createPostFunction as createpost,
|
||||
saveTwitchCredentials as savetwitchcredentials
|
||||
saveTwitchCredentials as savetwitchcredentials,
|
||||
createCommentFunction as createcomment,
|
||||
addCommentBounty as addcommentbounty,
|
||||
awardCommentBounty as awardcommentbounty,
|
||||
updateMetricsFunction as updatemetrics,
|
||||
}
|
||||
|
|
|
@ -3,8 +3,10 @@ import * as admin from 'firebase-admin'
|
|||
|
||||
import { Contract } from '../../common/contract'
|
||||
import { getPrivateUser, getUserByUsername } from './utils'
|
||||
import { createNotification } from './create-notification'
|
||||
import { createMarketClosedNotification } from './create-notification'
|
||||
import { DAY_MS } from '../../common/util/time'
|
||||
|
||||
const SEND_NOTIFICATIONS_EVERY_DAYS = 5
|
||||
export const marketCloseNotifications = functions
|
||||
.runWith({ secrets: ['MAILGUN_KEY'] })
|
||||
.pubsub.schedule('every 1 hours')
|
||||
|
@ -14,31 +16,31 @@ export const marketCloseNotifications = functions
|
|||
|
||||
const firestore = admin.firestore()
|
||||
|
||||
async function sendMarketCloseEmails() {
|
||||
export async function sendMarketCloseEmails() {
|
||||
const contracts = await firestore.runTransaction(async (transaction) => {
|
||||
const snap = await transaction.get(
|
||||
firestore.collection('contracts').where('isResolved', '!=', true)
|
||||
)
|
||||
const contracts = snap.docs.map((doc) => doc.data() as Contract)
|
||||
const now = Date.now()
|
||||
const closeContracts = contracts.filter(
|
||||
(contract) =>
|
||||
contract.closeTime &&
|
||||
contract.closeTime < now &&
|
||||
shouldSendFirstOrFollowUpCloseNotification(contract)
|
||||
)
|
||||
|
||||
return snap.docs
|
||||
.map((doc) => {
|
||||
const contract = doc.data() as Contract
|
||||
|
||||
if (
|
||||
contract.resolution ||
|
||||
(contract.closeEmailsSent ?? 0) >= 1 ||
|
||||
contract.closeTime === undefined ||
|
||||
(contract.closeTime ?? 0) > Date.now()
|
||||
await Promise.all(
|
||||
closeContracts.map(async (contract) => {
|
||||
await transaction.update(
|
||||
firestore.collection('contracts').doc(contract.id),
|
||||
{
|
||||
closeEmailsSent: admin.firestore.FieldValue.increment(1),
|
||||
}
|
||||
)
|
||||
return undefined
|
||||
|
||||
transaction.update(doc.ref, {
|
||||
closeEmailsSent: (contract.closeEmailsSent ?? 0) + 1,
|
||||
})
|
||||
|
||||
return contract
|
||||
})
|
||||
.filter((x) => !!x) as Contract[]
|
||||
)
|
||||
return closeContracts
|
||||
})
|
||||
|
||||
for (const contract of contracts) {
|
||||
|
@ -55,14 +57,40 @@ async function sendMarketCloseEmails() {
|
|||
const privateUser = await getPrivateUser(user.id)
|
||||
if (!privateUser) continue
|
||||
|
||||
await createNotification(
|
||||
contract.id,
|
||||
'contract',
|
||||
'closed',
|
||||
await createMarketClosedNotification(
|
||||
contract,
|
||||
user,
|
||||
'closed' + contract.id.slice(6, contract.id.length),
|
||||
contract.closeTime?.toString() ?? new Date().toString(),
|
||||
{ contract }
|
||||
privateUser,
|
||||
contract.id + '-closed-at-' + contract.closeTime
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// The downside of this approach is if this function goes down for the entire
|
||||
// day of a multiple of the time period after the market has closed, it won't
|
||||
// keep sending them notifications bc when it comes back online the time period will have passed
|
||||
function shouldSendFirstOrFollowUpCloseNotification(contract: Contract) {
|
||||
if (!contract.closeEmailsSent || contract.closeEmailsSent === 0) return true
|
||||
const { closedMultipleOfNDaysAgo, fullTimePeriodsSinceClose } =
|
||||
marketClosedMultipleOfNDaysAgo(contract)
|
||||
return (
|
||||
contract.closeEmailsSent > 0 &&
|
||||
closedMultipleOfNDaysAgo &&
|
||||
contract.closeEmailsSent === fullTimePeriodsSinceClose
|
||||
)
|
||||
}
|
||||
|
||||
function marketClosedMultipleOfNDaysAgo(contract: Contract) {
|
||||
const now = Date.now()
|
||||
const closeTime = contract.closeTime
|
||||
if (!closeTime)
|
||||
return { closedMultipleOfNDaysAgo: false, fullTimePeriodsSinceClose: 0 }
|
||||
const daysSinceClose = Math.floor((now - closeTime) / DAY_MS)
|
||||
return {
|
||||
closedMultipleOfNDaysAgo:
|
||||
daysSinceClose % SEND_NOTIFICATIONS_EVERY_DAYS == 0,
|
||||
fullTimePeriodsSinceClose: Math.floor(
|
||||
daysSinceClose / SEND_NOTIFICATIONS_EVERY_DAYS
|
||||
),
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,6 +12,7 @@ import {
|
|||
revalidateStaticProps,
|
||||
} from './utils'
|
||||
import {
|
||||
createBadgeAwardedNotification,
|
||||
createBetFillNotification,
|
||||
createBettingStreakBonusNotification,
|
||||
createUniqueBettorBonusNotification,
|
||||
|
@ -24,6 +25,7 @@ import {
|
|||
BETTING_STREAK_BONUS_MAX,
|
||||
BETTING_STREAK_RESET_HOUR,
|
||||
UNIQUE_BETTOR_BONUS_AMOUNT,
|
||||
UNIQUE_BETTOR_LIQUIDITY,
|
||||
} from '../../common/economy'
|
||||
import {
|
||||
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
|
||||
|
@ -33,6 +35,11 @@ import { APIError } from '../../common/api'
|
|||
import { User } from '../../common/user'
|
||||
import { DAY_MS } from '../../common/util/time'
|
||||
import { BettingStreakBonusTxn, UniqueBettorBonusTxn } from '../../common/txn'
|
||||
import { addHouseSubsidy } from './helpers/add-house-subsidy'
|
||||
import {
|
||||
StreakerBadge,
|
||||
streakerBadgeRarityThresholds,
|
||||
} from '../../common/badge'
|
||||
|
||||
const firestore = admin.firestore()
|
||||
const BONUS_START_DATE = new Date('2022-07-13T15:30:00.000Z').getTime()
|
||||
|
@ -103,7 +110,7 @@ const updateBettingStreak = async (
|
|||
|
||||
const newBettingStreak = (bettor?.currentBettingStreak ?? 0) + 1
|
||||
// Otherwise, add 1 to their betting streak
|
||||
await trans.update(userDoc, {
|
||||
trans.update(userDoc, {
|
||||
currentBettingStreak: newBettingStreak,
|
||||
lastBetTime: bet.createdTime,
|
||||
})
|
||||
|
@ -143,7 +150,7 @@ const updateBettingStreak = async (
|
|||
log('message:', result.message)
|
||||
return
|
||||
}
|
||||
if (result.txn)
|
||||
if (result.txn) {
|
||||
await createBettingStreakBonusNotification(
|
||||
user,
|
||||
result.txn.id,
|
||||
|
@ -153,6 +160,8 @@ const updateBettingStreak = async (
|
|||
newBettingStreak,
|
||||
eventId
|
||||
)
|
||||
await handleBettingStreakBadgeAward(user, newBettingStreak)
|
||||
}
|
||||
}
|
||||
|
||||
const updateUniqueBettorsAndGiveCreatorBonus = async (
|
||||
|
@ -191,7 +200,7 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
|
|||
log(`Got ${previousUniqueBettorIds} unique bettors`)
|
||||
isNewUniqueBettor && log(`And a new unique bettor ${bettor.id}`)
|
||||
|
||||
await trans.update(contractDoc, {
|
||||
trans.update(contractDoc, {
|
||||
uniqueBettorIds: newUniqueBettorIds,
|
||||
uniqueBettorCount: newUniqueBettorIds.length,
|
||||
})
|
||||
|
@ -204,8 +213,13 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
|
|||
return { newUniqueBettorIds }
|
||||
}
|
||||
)
|
||||
|
||||
if (!newUniqueBettorIds) return
|
||||
|
||||
if (oldContract.mechanism === 'cpmm-1') {
|
||||
await addHouseSubsidy(oldContract.id, UNIQUE_BETTOR_LIQUIDITY)
|
||||
}
|
||||
|
||||
const bonusTxnDetails = {
|
||||
contractId: oldContract.id,
|
||||
uniqueNewBettorId: bettor.id,
|
||||
|
@ -215,7 +229,9 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
|
|||
: DEV_HOUSE_LIQUIDITY_PROVIDER_ID
|
||||
const fromSnap = await firestore.doc(`users/${fromUserId}`).get()
|
||||
if (!fromSnap.exists) throw new APIError(400, 'From user not found.')
|
||||
|
||||
const fromUser = fromSnap.data() as User
|
||||
|
||||
const result = await firestore.runTransaction(async (trans) => {
|
||||
const bonusTxn: TxnData = {
|
||||
fromId: fromUser.id,
|
||||
|
@ -228,7 +244,9 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
|
|||
description: JSON.stringify(bonusTxnDetails),
|
||||
data: bonusTxnDetails,
|
||||
} as Omit<UniqueBettorBonusTxn, 'id' | 'createdTime'>
|
||||
|
||||
const { status, message, txn } = await runTxn(trans, bonusTxn)
|
||||
|
||||
return { status, newUniqueBettorIds, message, txn }
|
||||
})
|
||||
|
||||
|
@ -296,3 +314,39 @@ const notifyFills = async (
|
|||
const currentDateBettingStreakResetTime = () => {
|
||||
return new Date().setUTCHours(BETTING_STREAK_RESET_HOUR, 0, 0, 0)
|
||||
}
|
||||
|
||||
async function handleBettingStreakBadgeAward(
|
||||
user: User,
|
||||
newBettingStreak: number
|
||||
) {
|
||||
const alreadyHasBadgeForFirstStreak =
|
||||
user.achievements?.streaker?.badges.some(
|
||||
(badge) => badge.data.totalBettingStreak === 1
|
||||
)
|
||||
// TODO: check if already awarded 50th streak as well
|
||||
if (newBettingStreak === 1 && alreadyHasBadgeForFirstStreak) return
|
||||
|
||||
if (streakerBadgeRarityThresholds.includes(newBettingStreak)) {
|
||||
const badge = {
|
||||
type: 'STREAKER',
|
||||
name: 'Streaker',
|
||||
data: {
|
||||
totalBettingStreak: newBettingStreak,
|
||||
},
|
||||
createdTime: Date.now(),
|
||||
} as StreakerBadge
|
||||
// update user
|
||||
await firestore
|
||||
.collection('users')
|
||||
.doc(user.id)
|
||||
.update({
|
||||
achievements: {
|
||||
...user.achievements,
|
||||
streaker: {
|
||||
badges: [...(user.achievements?.streaker?.badges ?? []), badge],
|
||||
},
|
||||
},
|
||||
})
|
||||
await createBadgeAwardedNotification(user, badge)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,11 +1,20 @@
|
|||
import * as functions from 'firebase-functions'
|
||||
|
||||
import { getUser } from './utils'
|
||||
import { createNewContractNotification } from './create-notification'
|
||||
import { getUser, getValues } from './utils'
|
||||
import {
|
||||
createBadgeAwardedNotification,
|
||||
createNewContractNotification,
|
||||
} from './create-notification'
|
||||
import { Contract } from '../../common/contract'
|
||||
import { parseMentions, richTextToString } from '../../common/util/parse'
|
||||
import { JSONContent } from '@tiptap/core'
|
||||
import { addUserToContractFollowers } from './follow-market'
|
||||
import { User } from '../../common/user'
|
||||
import * as admin from 'firebase-admin'
|
||||
import {
|
||||
MarketCreatorBadge,
|
||||
marketCreatorBadgeRarityThresholds,
|
||||
} from '../../common/badge'
|
||||
|
||||
export const onCreateContract = functions
|
||||
.runWith({ secrets: ['MAILGUN_KEY'] })
|
||||
|
@ -28,4 +37,43 @@ export const onCreateContract = functions
|
|||
richTextToString(desc),
|
||||
mentioned
|
||||
)
|
||||
await handleMarketCreatorBadgeAward(contractCreator)
|
||||
})
|
||||
|
||||
const firestore = admin.firestore()
|
||||
|
||||
async function handleMarketCreatorBadgeAward(contractCreator: User) {
|
||||
// get all contracts by user and calculate size of array
|
||||
const contracts = await getValues<Contract>(
|
||||
firestore
|
||||
.collection(`contracts`)
|
||||
.where('creatorId', '==', contractCreator.id)
|
||||
.where('resolution', '!=', 'CANCEL')
|
||||
)
|
||||
if (marketCreatorBadgeRarityThresholds.includes(contracts.length)) {
|
||||
const badge = {
|
||||
type: 'MARKET_CREATOR',
|
||||
name: 'Market Creator',
|
||||
data: {
|
||||
totalContractsCreated: contracts.length,
|
||||
},
|
||||
createdTime: Date.now(),
|
||||
} as MarketCreatorBadge
|
||||
// update user
|
||||
await firestore
|
||||
.collection('users')
|
||||
.doc(contractCreator.id)
|
||||
.update({
|
||||
achievements: {
|
||||
...contractCreator.achievements,
|
||||
marketCreator: {
|
||||
badges: [
|
||||
...(contractCreator.achievements?.marketCreator?.badges ?? []),
|
||||
badge,
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
await createBadgeAwardedNotification(contractCreator, badge)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import * as functions from 'firebase-functions'
|
||||
import { getContract, getUser, log } from './utils'
|
||||
import { createNotification } from './create-notification'
|
||||
import { createFollowOrMarketSubsidizedNotification } from './create-notification'
|
||||
import { LiquidityProvision } from '../../common/liquidity-provision'
|
||||
import { addUserToContractFollowers } from './follow-market'
|
||||
import { FIXED_ANTE } from '../../common/economy'
|
||||
|
@ -36,7 +36,7 @@ export const onCreateLiquidityProvision = functions.firestore
|
|||
if (!liquidityProvider) throw new Error('Could not find liquidity provider')
|
||||
await addUserToContractFollowers(contract.id, liquidityProvider.id)
|
||||
|
||||
await createNotification(
|
||||
await createFollowOrMarketSubsidizedNotification(
|
||||
contract.id,
|
||||
'liquidity',
|
||||
'created',
|
||||
|
|
|
@ -2,7 +2,7 @@ import * as functions from 'firebase-functions'
|
|||
import * as admin from 'firebase-admin'
|
||||
|
||||
import { getUser } from './utils'
|
||||
import { createNotification } from './create-notification'
|
||||
import { createFollowOrMarketSubsidizedNotification } from './create-notification'
|
||||
import { FieldValue } from 'firebase-admin/firestore'
|
||||
|
||||
export const onFollowUser = functions.firestore
|
||||
|
@ -23,7 +23,7 @@ export const onFollowUser = functions.firestore
|
|||
followerCountCached: FieldValue.increment(1),
|
||||
})
|
||||
|
||||
await createNotification(
|
||||
await createFollowOrMarketSubsidizedNotification(
|
||||
followingUser.id,
|
||||
'follow',
|
||||
'created',
|
||||
|
|
|
@ -1,44 +1,160 @@
|
|||
import * as functions from 'firebase-functions'
|
||||
import { getUser } from './utils'
|
||||
import { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification'
|
||||
import { getUser, getValues } from './utils'
|
||||
import {
|
||||
createBadgeAwardedNotification,
|
||||
createCommentOrAnswerOrUpdatedContractNotification,
|
||||
} from './create-notification'
|
||||
import { Contract } from '../../common/contract'
|
||||
import { Bet } from '../../common/bet'
|
||||
import * as admin from 'firebase-admin'
|
||||
import { ContractComment } from '../../common/comment'
|
||||
import { scoreCommentorsAndBettors } from '../../common/scoring'
|
||||
import {
|
||||
MINIMUM_UNIQUE_BETTORS_FOR_PROVEN_CORRECT_BADGE,
|
||||
ProvenCorrectBadge,
|
||||
} from '../../common/badge'
|
||||
import { GroupContractDoc } from '../../common/group'
|
||||
|
||||
export const onUpdateContract = functions.firestore
|
||||
.document('contracts/{contractId}')
|
||||
.onUpdate(async (change, context) => {
|
||||
const contract = change.after.data() as Contract
|
||||
const previousContract = change.before.data() as Contract
|
||||
const { eventId } = context
|
||||
const { closeTime, question } = contract
|
||||
|
||||
const contractUpdater = await getUser(contract.creatorId)
|
||||
if (!contractUpdater) throw new Error('Could not find contract updater')
|
||||
|
||||
const previousValue = change.before.data() as Contract
|
||||
|
||||
// Resolution is handled in resolve-market.ts
|
||||
if (!previousValue.isResolved && contract.isResolved) return
|
||||
|
||||
if (
|
||||
previousValue.closeTime !== contract.closeTime ||
|
||||
previousValue.question !== contract.question
|
||||
if (!previousContract.isResolved && contract.isResolved) {
|
||||
// No need to notify users of resolution, that's handled in resolve-market
|
||||
return await handleResolvedContract(contract)
|
||||
} else if (previousContract.groupSlugs !== contract.groupSlugs) {
|
||||
await handleContractGroupUpdated(previousContract, contract)
|
||||
} else if (
|
||||
previousContract.closeTime !== closeTime ||
|
||||
previousContract.question !== question
|
||||
) {
|
||||
let sourceText = ''
|
||||
if (
|
||||
previousValue.closeTime !== contract.closeTime &&
|
||||
contract.closeTime
|
||||
) {
|
||||
sourceText = contract.closeTime.toString()
|
||||
} else if (previousValue.question !== contract.question) {
|
||||
sourceText = contract.question
|
||||
}
|
||||
|
||||
await createCommentOrAnswerOrUpdatedContractNotification(
|
||||
contract.id,
|
||||
'contract',
|
||||
'updated',
|
||||
contractUpdater,
|
||||
eventId,
|
||||
sourceText,
|
||||
contract
|
||||
)
|
||||
await handleUpdatedCloseTime(previousContract, contract, eventId)
|
||||
}
|
||||
})
|
||||
|
||||
async function handleResolvedContract(contract: Contract) {
|
||||
if (
|
||||
(contract.uniqueBettorCount ?? 0) <
|
||||
MINIMUM_UNIQUE_BETTORS_FOR_PROVEN_CORRECT_BADGE ||
|
||||
contract.resolution === 'CANCEL'
|
||||
)
|
||||
return
|
||||
|
||||
// get all bets on this contract
|
||||
const bets = await getValues<Bet>(
|
||||
firestore.collection(`contracts/${contract.id}/bets`)
|
||||
)
|
||||
|
||||
// get comments on this contract
|
||||
const comments = await getValues<ContractComment>(
|
||||
firestore.collection(`contracts/${contract.id}/comments`)
|
||||
)
|
||||
|
||||
const { topCommentId, profitById, commentsById, betsById, topCommentBetId } =
|
||||
scoreCommentorsAndBettors(contract, bets, comments)
|
||||
if (topCommentBetId && profitById[topCommentBetId] > 0) {
|
||||
// award proven correct badge to user
|
||||
const comment = commentsById[topCommentId]
|
||||
const bet = betsById[topCommentBetId]
|
||||
|
||||
const user = await getUser(comment.userId)
|
||||
if (!user) return
|
||||
const newProvenCorrectBadge = {
|
||||
createdTime: Date.now(),
|
||||
type: 'PROVEN_CORRECT',
|
||||
name: 'Proven Correct',
|
||||
data: {
|
||||
contractSlug: contract.slug,
|
||||
contractCreatorUsername: contract.creatorUsername,
|
||||
commentId: comment.id,
|
||||
betAmount: bet.amount,
|
||||
contractTitle: contract.question,
|
||||
},
|
||||
} as ProvenCorrectBadge
|
||||
// update user
|
||||
await firestore
|
||||
.collection('users')
|
||||
.doc(user.id)
|
||||
.update({
|
||||
achievements: {
|
||||
...user.achievements,
|
||||
provenCorrect: {
|
||||
badges: [
|
||||
...(user.achievements?.provenCorrect?.badges ?? []),
|
||||
newProvenCorrectBadge,
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
await createBadgeAwardedNotification(user, newProvenCorrectBadge)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpdatedCloseTime(
|
||||
previousContract: Contract,
|
||||
contract: Contract,
|
||||
eventId: string
|
||||
) {
|
||||
const contractUpdater = await getUser(contract.creatorId)
|
||||
if (!contractUpdater) throw new Error('Could not find contract updater')
|
||||
let sourceText = ''
|
||||
if (previousContract.closeTime !== contract.closeTime && contract.closeTime) {
|
||||
sourceText = contract.closeTime.toString()
|
||||
} else if (previousContract.question !== contract.question) {
|
||||
sourceText = contract.question
|
||||
}
|
||||
|
||||
await createCommentOrAnswerOrUpdatedContractNotification(
|
||||
contract.id,
|
||||
'contract',
|
||||
'updated',
|
||||
contractUpdater,
|
||||
eventId,
|
||||
sourceText,
|
||||
contract
|
||||
)
|
||||
}
|
||||
|
||||
async function handleContractGroupUpdated(
|
||||
previousContract: Contract,
|
||||
contract: Contract
|
||||
) {
|
||||
const prevLength = previousContract.groupSlugs?.length ?? 0
|
||||
const newLength = contract.groupSlugs?.length ?? 0
|
||||
if (prevLength < newLength) {
|
||||
// Contract was added to a new group
|
||||
const groupId = contract.groupLinks?.find(
|
||||
(link) =>
|
||||
!previousContract.groupLinks
|
||||
?.map((l) => l.groupId)
|
||||
.includes(link.groupId)
|
||||
)?.groupId
|
||||
if (!groupId) throw new Error('Could not find new group id')
|
||||
|
||||
await firestore
|
||||
.collection(`groups/${groupId}/groupContracts`)
|
||||
.doc(contract.id)
|
||||
.set({
|
||||
contractId: contract.id,
|
||||
createdTime: Date.now(),
|
||||
} as GroupContractDoc)
|
||||
}
|
||||
if (prevLength > newLength) {
|
||||
// Contract was removed from a group
|
||||
const groupId = previousContract.groupLinks?.find(
|
||||
(link) =>
|
||||
!contract.groupLinks?.map((l) => l.groupId).includes(link.groupId)
|
||||
)?.groupId
|
||||
if (!groupId) throw new Error('Could not find old group id')
|
||||
|
||||
await firestore
|
||||
.collection(`groups/${groupId}/groupContracts`)
|
||||
.doc(contract.id)
|
||||
.delete()
|
||||
}
|
||||
}
|
||||
const firestore = admin.firestore()
|
||||
|
|
|
@ -5,8 +5,6 @@ import { HOUSE_LIQUIDITY_PROVIDER_ID } from '../../common/antes'
|
|||
import { createReferralNotification } from './create-notification'
|
||||
import { ReferralTxn } from '../../common/txn'
|
||||
import { Contract } from '../../common/contract'
|
||||
import { LimitBet } from '../../common/bet'
|
||||
import { QuerySnapshot } from 'firebase-admin/firestore'
|
||||
import { Group } from '../../common/group'
|
||||
import { REFERRAL_AMOUNT } from '../../common/economy'
|
||||
const firestore = admin.firestore()
|
||||
|
@ -21,10 +19,6 @@ export const onUpdateUser = functions.firestore
|
|||
if (prevUser.referredByUserId !== user.referredByUserId) {
|
||||
await handleUserUpdatedReferral(user, eventId)
|
||||
}
|
||||
|
||||
if (user.balance <= 0) {
|
||||
await cancelLimitOrders(user.id)
|
||||
}
|
||||
})
|
||||
|
||||
async function handleUserUpdatedReferral(user: User, eventId: string) {
|
||||
|
@ -123,15 +117,3 @@ async function handleUserUpdatedReferral(user: User, eventId: string) {
|
|||
)
|
||||
})
|
||||
}
|
||||
|
||||
async function cancelLimitOrders(userId: string) {
|
||||
const snapshot = (await firestore
|
||||
.collectionGroup('bets')
|
||||
.where('userId', '==', userId)
|
||||
.where('isFilled', '==', false)
|
||||
.get()) as QuerySnapshot<LimitBet>
|
||||
|
||||
await Promise.all(
|
||||
snapshot.docs.map((doc) => doc.ref.update({ isCancelled: true }))
|
||||
)
|
||||
}
|
||||
|
|
|
@ -11,6 +11,7 @@ import { groupBy, mapValues, sumBy, uniq } from 'lodash'
|
|||
import { APIError, newEndpoint, validate } from './api'
|
||||
import { Contract, CPMM_MIN_POOL_QTY } from '../../common/contract'
|
||||
import { User } from '../../common/user'
|
||||
import { FLAT_TRADE_FEE } from '../../common/fees'
|
||||
import {
|
||||
BetInfo,
|
||||
getBinaryCpmmBetInfo,
|
||||
|
@ -23,6 +24,7 @@ import { floatingEqual } from '../../common/util/math'
|
|||
import { redeemShares } from './redeem-shares'
|
||||
import { log } from './utils'
|
||||
import { addUserToContractFollowers } from './follow-market'
|
||||
import { filterDefined } from '../../common/util/array'
|
||||
|
||||
const bodySchema = z.object({
|
||||
contractId: z.string(),
|
||||
|
@ -73,9 +75,11 @@ export const placebet = newEndpoint({}, async (req, auth) => {
|
|||
newTotalLiquidity,
|
||||
newP,
|
||||
makers,
|
||||
ordersToCancel,
|
||||
} = await (async (): Promise<
|
||||
BetInfo & {
|
||||
makers?: maker[]
|
||||
ordersToCancel?: LimitBet[]
|
||||
}
|
||||
> => {
|
||||
if (
|
||||
|
@ -99,17 +103,16 @@ export const placebet = newEndpoint({}, async (req, auth) => {
|
|||
limitProb = Math.round(limitProb * 100) / 100
|
||||
}
|
||||
|
||||
const unfilledBetsSnap = await trans.get(
|
||||
getUnfilledBetsQuery(contractDoc)
|
||||
)
|
||||
const unfilledBets = unfilledBetsSnap.docs.map((doc) => doc.data())
|
||||
const { unfilledBets, balanceByUserId } =
|
||||
await getUnfilledBetsAndUserBalances(trans, contractDoc)
|
||||
|
||||
return getBinaryCpmmBetInfo(
|
||||
outcome,
|
||||
amount,
|
||||
contract,
|
||||
limitProb,
|
||||
unfilledBets
|
||||
unfilledBets,
|
||||
balanceByUserId
|
||||
)
|
||||
} else if (
|
||||
(outcomeType == 'FREE_RESPONSE' || outcomeType === 'MULTIPLE_CHOICE') &&
|
||||
|
@ -152,11 +155,25 @@ export const placebet = newEndpoint({}, async (req, auth) => {
|
|||
if (makers) {
|
||||
updateMakers(makers, betDoc.id, contractDoc, trans)
|
||||
}
|
||||
if (ordersToCancel) {
|
||||
for (const bet of ordersToCancel) {
|
||||
trans.update(contractDoc.collection('bets').doc(bet.id), {
|
||||
isCancelled: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const balanceChange =
|
||||
newBet.amount !== 0
|
||||
? // quick bet
|
||||
newBet.amount + FLAT_TRADE_FEE
|
||||
: // limit order
|
||||
FLAT_TRADE_FEE
|
||||
|
||||
trans.update(userDoc, { balance: FieldValue.increment(-balanceChange) })
|
||||
log('Updated user balance.')
|
||||
|
||||
if (newBet.amount !== 0) {
|
||||
trans.update(userDoc, { balance: FieldValue.increment(-newBet.amount) })
|
||||
log('Updated user balance.')
|
||||
|
||||
trans.update(
|
||||
contractDoc,
|
||||
removeUndefinedProps({
|
||||
|
@ -193,13 +210,36 @@ export const placebet = newEndpoint({}, async (req, auth) => {
|
|||
|
||||
const firestore = admin.firestore()
|
||||
|
||||
export const getUnfilledBetsQuery = (contractDoc: DocumentReference) => {
|
||||
const getUnfilledBetsQuery = (contractDoc: DocumentReference) => {
|
||||
return contractDoc
|
||||
.collection('bets')
|
||||
.where('isFilled', '==', false)
|
||||
.where('isCancelled', '==', false) as Query<LimitBet>
|
||||
}
|
||||
|
||||
export const getUnfilledBetsAndUserBalances = async (
|
||||
trans: Transaction,
|
||||
contractDoc: DocumentReference
|
||||
) => {
|
||||
const unfilledBetsSnap = await trans.get(getUnfilledBetsQuery(contractDoc))
|
||||
const unfilledBets = unfilledBetsSnap.docs.map((doc) => doc.data())
|
||||
|
||||
// Get balance of all users with open limit orders.
|
||||
const userIds = uniq(unfilledBets.map((bet) => bet.userId))
|
||||
const userDocs =
|
||||
userIds.length === 0
|
||||
? []
|
||||
: await trans.getAll(
|
||||
...userIds.map((userId) => firestore.doc(`users/${userId}`))
|
||||
)
|
||||
const users = filterDefined(userDocs.map((doc) => doc.data() as User))
|
||||
const balanceByUserId = Object.fromEntries(
|
||||
users.map((user) => [user.id, user.balance])
|
||||
)
|
||||
|
||||
return { unfilledBets, balanceByUserId }
|
||||
}
|
||||
|
||||
type maker = {
|
||||
bet: LimitBet
|
||||
amount: number
|
||||
|
|
|
@ -2,7 +2,7 @@ import * as functions from 'firebase-functions'
|
|||
import * as admin from 'firebase-admin'
|
||||
import { getAllPrivateUsers } from './utils'
|
||||
|
||||
export const resetWeeklyEmailsFlag = functions
|
||||
export const resetWeeklyEmailsFlags = functions
|
||||
.runWith({
|
||||
timeoutSeconds: 300,
|
||||
memory: '4GB',
|
||||
|
@ -17,6 +17,7 @@ export const resetWeeklyEmailsFlag = functions
|
|||
privateUsers.map(async (user) => {
|
||||
return firestore.collection('private-users').doc(user.id).update({
|
||||
weeklyTrendingEmailSent: false,
|
||||
weeklyPortfolioUpdateEmailSent: false,
|
||||
})
|
||||
})
|
||||
)
|
|
@ -1,6 +1,6 @@
|
|||
import * as admin from 'firebase-admin'
|
||||
import { z } from 'zod'
|
||||
import { mapValues, groupBy, sumBy } from 'lodash'
|
||||
import { mapValues, groupBy, sumBy, uniqBy } from 'lodash'
|
||||
|
||||
import {
|
||||
Contract,
|
||||
|
@ -9,12 +9,20 @@ import {
|
|||
RESOLUTIONS,
|
||||
} from '../../common/contract'
|
||||
import { Bet } from '../../common/bet'
|
||||
import { getContractPath, getUser, getValues, isProd, log, payUser, revalidateStaticProps } from './utils'
|
||||
import {
|
||||
getContractPath,
|
||||
getUser,
|
||||
getValues,
|
||||
isProd,
|
||||
log,
|
||||
payUsers,
|
||||
payUsersMultipleTransactions,
|
||||
revalidateStaticProps,
|
||||
} from './utils'
|
||||
import {
|
||||
getLoanPayouts,
|
||||
getPayouts,
|
||||
groupPayoutsByUser,
|
||||
Payout,
|
||||
} from '../../common/payouts'
|
||||
import { isAdmin, isManifoldId } from '../../common/envs/constants'
|
||||
import { removeUndefinedProps } from '../../common/util/object'
|
||||
|
@ -28,6 +36,7 @@ import {
|
|||
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
|
||||
HOUSE_LIQUIDITY_PROVIDER_ID,
|
||||
} from '../../common/antes'
|
||||
import { User } from 'common/user'
|
||||
|
||||
const bodySchema = z.object({
|
||||
contractId: z.string(),
|
||||
|
@ -81,13 +90,10 @@ export const resolvemarket = newEndpoint(opts, async (req, auth) => {
|
|||
if (!contractSnap.exists)
|
||||
throw new APIError(404, 'No contract exists with the provided ID')
|
||||
const contract = contractSnap.data() as Contract
|
||||
const { creatorId, closeTime } = contract
|
||||
const { creatorId } = contract
|
||||
const firebaseUser = await admin.auth().getUser(auth.uid)
|
||||
|
||||
const { value, resolutions, probabilityInt, outcome } = getResolutionParams(
|
||||
contract,
|
||||
req.body
|
||||
)
|
||||
const resolutionParams = getResolutionParams(contract, req.body)
|
||||
|
||||
if (
|
||||
creatorId !== auth.uid &&
|
||||
|
@ -101,6 +107,16 @@ export const resolvemarket = newEndpoint(opts, async (req, auth) => {
|
|||
const creator = await getUser(creatorId)
|
||||
if (!creator) throw new APIError(500, 'Creator not found')
|
||||
|
||||
return await resolveMarket(contract, creator, resolutionParams)
|
||||
})
|
||||
|
||||
export const resolveMarket = async (
|
||||
contract: Contract,
|
||||
creator: User,
|
||||
{ value, resolutions, probabilityInt, outcome }: ResolutionParams
|
||||
) => {
|
||||
const { creatorId, closeTime, id: contractId } = contract
|
||||
|
||||
const resolutionProbability =
|
||||
probabilityInt !== undefined ? probabilityInt / 100 : undefined
|
||||
|
||||
|
@ -123,15 +139,19 @@ export const resolvemarket = newEndpoint(opts, async (req, auth) => {
|
|||
(doc) => doc.data() as LiquidityProvision
|
||||
)
|
||||
|
||||
const { payouts, creatorPayout, liquidityPayouts, collectedFees } =
|
||||
getPayouts(
|
||||
outcome,
|
||||
contract,
|
||||
bets,
|
||||
liquidities,
|
||||
resolutions,
|
||||
resolutionProbability
|
||||
)
|
||||
const {
|
||||
payouts: traderPayouts,
|
||||
creatorPayout,
|
||||
liquidityPayouts,
|
||||
collectedFees,
|
||||
} = getPayouts(
|
||||
outcome,
|
||||
contract,
|
||||
bets,
|
||||
liquidities,
|
||||
resolutions,
|
||||
resolutionProbability
|
||||
)
|
||||
|
||||
const updatedContract = {
|
||||
...contract,
|
||||
|
@ -145,35 +165,50 @@ export const resolvemarket = newEndpoint(opts, async (req, auth) => {
|
|||
resolutions,
|
||||
collectedFees,
|
||||
}),
|
||||
subsidyPool: 0,
|
||||
}
|
||||
|
||||
await contractDoc.update(updatedContract)
|
||||
|
||||
console.log('contract ', contractId, 'resolved to:', outcome)
|
||||
|
||||
const openBets = bets.filter((b) => !b.isSold && !b.sale)
|
||||
const loanPayouts = getLoanPayouts(openBets)
|
||||
|
||||
const payoutsWithoutLoans = [
|
||||
{ userId: creatorId, payout: creatorPayout, deposit: creatorPayout },
|
||||
...liquidityPayouts.map((p) => ({ ...p, deposit: p.payout })),
|
||||
...traderPayouts,
|
||||
]
|
||||
const payouts = [...payoutsWithoutLoans, ...loanPayouts]
|
||||
|
||||
if (!isProd())
|
||||
console.log(
|
||||
'payouts:',
|
||||
payouts,
|
||||
'trader payouts:',
|
||||
traderPayouts,
|
||||
'creator payout:',
|
||||
creatorPayout,
|
||||
'liquidity payout:'
|
||||
'liquidity payout:',
|
||||
liquidityPayouts,
|
||||
'loan payouts:',
|
||||
loanPayouts
|
||||
)
|
||||
|
||||
if (creatorPayout)
|
||||
await processPayouts([{ userId: creatorId, payout: creatorPayout }], true)
|
||||
const userCount = uniqBy(payouts, 'userId').length
|
||||
const contractDoc = firestore.doc(`contracts/${contractId}`)
|
||||
|
||||
await processPayouts(liquidityPayouts, true)
|
||||
if (userCount <= 499) {
|
||||
await firestore.runTransaction(async (transaction) => {
|
||||
payUsers(transaction, payouts)
|
||||
transaction.update(contractDoc, updatedContract)
|
||||
})
|
||||
} else {
|
||||
await payUsersMultipleTransactions(payouts)
|
||||
await contractDoc.update(updatedContract)
|
||||
}
|
||||
|
||||
console.log('contract ', contractId, 'resolved to:', outcome)
|
||||
|
||||
await processPayouts([...payouts, ...loanPayouts])
|
||||
await undoUniqueBettorRewardsIfCancelResolution(contract, outcome)
|
||||
|
||||
await revalidateStaticProps(getContractPath(contract))
|
||||
|
||||
const userPayoutsWithoutLoans = groupPayoutsByUser(payouts)
|
||||
const userPayoutsWithoutLoans = groupPayoutsByUser(payoutsWithoutLoans)
|
||||
|
||||
const userInvestments = mapValues(
|
||||
groupBy(bets, (bet) => bet.userId),
|
||||
|
@ -200,18 +235,6 @@ export const resolvemarket = newEndpoint(opts, async (req, auth) => {
|
|||
)
|
||||
|
||||
return updatedContract
|
||||
})
|
||||
|
||||
const processPayouts = async (payouts: Payout[], isDeposit = false) => {
|
||||
const userPayouts = groupPayoutsByUser(payouts)
|
||||
|
||||
const payoutPromises = Object.entries(userPayouts).map(([userId, payout]) =>
|
||||
payUser(userId, payout, isDeposit)
|
||||
)
|
||||
|
||||
return await Promise.all(payoutPromises)
|
||||
.catch((e) => ({ status: 'error', message: e }))
|
||||
.then(() => ({ status: 'success' }))
|
||||
}
|
||||
|
||||
function getResolutionParams(contract: Contract, body: string) {
|
||||
|
@ -278,6 +301,8 @@ function getResolutionParams(contract: Contract, body: string) {
|
|||
throw new APIError(500, `Invalid outcome type: ${outcomeType}`)
|
||||
}
|
||||
|
||||
type ResolutionParams = ReturnType<typeof getResolutionParams>
|
||||
|
||||
function validateAnswer(
|
||||
contract: FreeResponseContract | MultipleChoiceContract,
|
||||
answer: number
|
||||
|
|
|
@ -1,12 +1,15 @@
|
|||
import * as functions from 'firebase-functions'
|
||||
import * as admin from 'firebase-admin'
|
||||
import { Bet } from 'common/bet'
|
||||
import { uniq } from 'lodash'
|
||||
import { Contract } from 'common/contract'
|
||||
import { Bet } from '../../common/bet'
|
||||
import { Contract } from '../../common/contract'
|
||||
import { log } from './utils'
|
||||
import { removeUndefinedProps } from '../../common/util/object'
|
||||
import { DAY_MS, HOUR_MS } from '../../common/util/time'
|
||||
|
||||
export const scoreContracts = functions.pubsub
|
||||
.schedule('every 1 hours')
|
||||
export const scoreContracts = functions
|
||||
.runWith({ memory: '4GB', timeoutSeconds: 540 })
|
||||
.pubsub.schedule('every 1 hours')
|
||||
.onRun(async () => {
|
||||
await scoreContractsInternal()
|
||||
})
|
||||
|
@ -14,11 +17,12 @@ const firestore = admin.firestore()
|
|||
|
||||
async function scoreContractsInternal() {
|
||||
const now = Date.now()
|
||||
const lastHour = now - 60 * 60 * 1000
|
||||
const last3Days = now - 1000 * 60 * 60 * 24 * 3
|
||||
const hourAgo = now - HOUR_MS
|
||||
const dayAgo = now - DAY_MS
|
||||
const threeDaysAgo = now - DAY_MS * 3
|
||||
const activeContractsSnap = await firestore
|
||||
.collection('contracts')
|
||||
.where('lastUpdatedTime', '>', lastHour)
|
||||
.where('lastUpdatedTime', '>', hourAgo)
|
||||
.get()
|
||||
const activeContracts = activeContractsSnap.docs.map(
|
||||
(doc) => doc.data() as Contract
|
||||
|
@ -39,16 +43,33 @@ async function scoreContractsInternal() {
|
|||
for (const contract of contracts) {
|
||||
const bets = await firestore
|
||||
.collection(`contracts/${contract.id}/bets`)
|
||||
.where('createdTime', '>', last3Days)
|
||||
.where('createdTime', '>', threeDaysAgo)
|
||||
.get()
|
||||
const bettors = bets.docs
|
||||
.map((doc) => doc.data() as Bet)
|
||||
.map((bet) => bet.userId)
|
||||
const score = uniq(bettors).length
|
||||
if (contract.popularityScore !== score)
|
||||
const popularityScore = uniq(bettors).length
|
||||
|
||||
const wasCreatedToday = contract.createdTime > dayAgo
|
||||
|
||||
let dailyScore: number | undefined
|
||||
if (
|
||||
contract.outcomeType === 'BINARY' &&
|
||||
contract.mechanism === 'cpmm-1' &&
|
||||
!wasCreatedToday
|
||||
) {
|
||||
const percentChange = Math.abs(contract.probChanges.day)
|
||||
dailyScore = popularityScore * percentChange
|
||||
}
|
||||
|
||||
if (
|
||||
contract.popularityScore !== popularityScore ||
|
||||
contract.dailyScore !== dailyScore
|
||||
) {
|
||||
await firestore
|
||||
.collection('contracts')
|
||||
.doc(contract.id)
|
||||
.update({ popularityScore: score })
|
||||
.update(removeUndefinedProps({ popularityScore, dailyScore }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
30
functions/src/scripts/add-new-notification-preference.ts
Normal file
30
functions/src/scripts/add-new-notification-preference.ts
Normal file
|
@ -0,0 +1,30 @@
|
|||
import * as admin from 'firebase-admin'
|
||||
|
||||
import { initAdmin } from './script-init'
|
||||
import { getAllPrivateUsers } from 'functions/src/utils'
|
||||
initAdmin()
|
||||
|
||||
const firestore = admin.firestore()
|
||||
|
||||
async function main() {
|
||||
const privateUsers = await getAllPrivateUsers()
|
||||
await Promise.all(
|
||||
privateUsers.map((privateUser) => {
|
||||
if (!privateUser.id) return Promise.resolve()
|
||||
if (privateUser.notificationPreferences.badges_awarded === undefined) {
|
||||
return firestore
|
||||
.collection('private-users')
|
||||
.doc(privateUser.id)
|
||||
.update({
|
||||
notificationPreferences: {
|
||||
...privateUser.notificationPreferences,
|
||||
badges_awarded: ['browser'],
|
||||
},
|
||||
})
|
||||
}
|
||||
return
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
if (require.main === module) main().then(() => process.exit())
|
136
functions/src/scripts/backfill-badges.ts
Normal file
136
functions/src/scripts/backfill-badges.ts
Normal file
|
@ -0,0 +1,136 @@
|
|||
import * as admin from 'firebase-admin'
|
||||
|
||||
import { initAdmin } from './script-init'
|
||||
import { getAllUsers, getValues } from '../utils'
|
||||
import { Contract } from 'common/contract'
|
||||
import {
|
||||
MarketCreatorBadge,
|
||||
marketCreatorBadgeRarityThresholds,
|
||||
StreakerBadge,
|
||||
streakerBadgeRarityThresholds,
|
||||
} from 'common/badge'
|
||||
import { User } from 'common/user'
|
||||
initAdmin()
|
||||
|
||||
const firestore = admin.firestore()
|
||||
|
||||
async function main() {
|
||||
const users = await getAllUsers()
|
||||
// const users = filterDefined([await getUser('6hHpzvRG0pMq8PNJs7RZj2qlZGn2')]) // dev ian
|
||||
// const users = filterDefined([await getUser('uglwf3YKOZNGjjEXKc5HampOFRE2')]) // prod David
|
||||
// const users = filterDefined([await getUser('AJwLWoo3xue32XIiAVrL5SyR1WB2')]) // prod ian
|
||||
await Promise.all(
|
||||
users.map(async (user) => {
|
||||
if (!user.id) return
|
||||
// Only backfill users without achievements
|
||||
if (user.achievements === undefined) {
|
||||
await firestore.collection('users').doc(user.id).update({
|
||||
achievements: {},
|
||||
})
|
||||
user.achievements = {}
|
||||
user.achievements = await awardMarketCreatorBadges(user)
|
||||
user.achievements = await awardBettingStreakBadges(user)
|
||||
console.log('Added achievements to user', user.id)
|
||||
// going to ignore backfilling the proven correct badges for now
|
||||
} else {
|
||||
// Make corrections to existing achievements
|
||||
await awardMarketCreatorBadges(user)
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
if (require.main === module) main().then(() => process.exit())
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
async function removeErrorBadges(user: User) {
|
||||
if (
|
||||
user.achievements.streaker?.badges.some(
|
||||
(b) => b.data.totalBettingStreak > 1
|
||||
)
|
||||
) {
|
||||
console.log(
|
||||
`User ${
|
||||
user.id
|
||||
} has a streaker badge with streaks ${user.achievements.streaker?.badges.map(
|
||||
(b) => b.data.totalBettingStreak
|
||||
)}`
|
||||
)
|
||||
// delete non 1,50 streaks
|
||||
user.achievements.streaker.badges =
|
||||
user.achievements.streaker.badges.filter((b) =>
|
||||
streakerBadgeRarityThresholds.includes(b.data.totalBettingStreak)
|
||||
)
|
||||
// update user
|
||||
await firestore.collection('users').doc(user.id).update({
|
||||
achievements: user.achievements,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function awardMarketCreatorBadges(user: User) {
|
||||
// Award market maker badges
|
||||
const contracts = (
|
||||
await getValues<Contract>(
|
||||
firestore.collection(`contracts`).where('creatorId', '==', user.id)
|
||||
)
|
||||
).filter((c) => !c.resolution || c.resolution != 'CANCEL')
|
||||
|
||||
const achievements = {
|
||||
...user.achievements,
|
||||
marketCreator: {
|
||||
badges: [...(user.achievements.marketCreator?.badges ?? [])],
|
||||
},
|
||||
}
|
||||
for (const threshold of marketCreatorBadgeRarityThresholds) {
|
||||
const alreadyHasBadge = user.achievements.marketCreator?.badges.some(
|
||||
(b) => b.data.totalContractsCreated === threshold
|
||||
)
|
||||
if (alreadyHasBadge) continue
|
||||
if (contracts.length >= threshold) {
|
||||
console.log(`User ${user.id} has at least ${threshold} contracts`)
|
||||
const badge = {
|
||||
type: 'MARKET_CREATOR',
|
||||
name: 'Market Creator',
|
||||
data: {
|
||||
totalContractsCreated: threshold,
|
||||
},
|
||||
createdTime: Date.now(),
|
||||
} as MarketCreatorBadge
|
||||
achievements.marketCreator.badges.push(badge)
|
||||
}
|
||||
}
|
||||
// update user
|
||||
await firestore.collection('users').doc(user.id).update({
|
||||
achievements,
|
||||
})
|
||||
return achievements
|
||||
}
|
||||
|
||||
async function awardBettingStreakBadges(user: User) {
|
||||
const streak = user.currentBettingStreak ?? 0
|
||||
const achievements = {
|
||||
...user.achievements,
|
||||
streaker: {
|
||||
badges: [...(user.achievements?.streaker?.badges ?? [])],
|
||||
},
|
||||
}
|
||||
for (const threshold of streakerBadgeRarityThresholds) {
|
||||
if (streak >= threshold) {
|
||||
const badge = {
|
||||
type: 'STREAKER',
|
||||
name: 'Streaker',
|
||||
data: {
|
||||
totalBettingStreak: threshold,
|
||||
},
|
||||
createdTime: Date.now(),
|
||||
} as StreakerBadge
|
||||
achievements.streaker.badges.push(badge)
|
||||
}
|
||||
}
|
||||
// update user
|
||||
await firestore.collection('users').doc(user.id).update({
|
||||
achievements,
|
||||
})
|
||||
return achievements
|
||||
}
|
24
functions/src/scripts/backfill-subsidy-pool.ts
Normal file
24
functions/src/scripts/backfill-subsidy-pool.ts
Normal file
|
@ -0,0 +1,24 @@
|
|||
import * as admin from 'firebase-admin'
|
||||
import { initAdmin } from './script-init'
|
||||
|
||||
initAdmin()
|
||||
const firestore = admin.firestore()
|
||||
|
||||
if (require.main === module) {
|
||||
const contractsRef = firestore.collection('contracts')
|
||||
contractsRef.get().then(async (contractsSnaps) => {
|
||||
|
||||
console.log(`Loaded ${contractsSnaps.size} contracts.`)
|
||||
|
||||
const needsFilling = contractsSnaps.docs.filter((ct) => {
|
||||
return !('subsidyPool' in ct.data())
|
||||
})
|
||||
|
||||
console.log(`Found ${needsFilling.length} contracts to update.`)
|
||||
await Promise.all(
|
||||
needsFilling.map((ct) => ct.ref.update({ subsidyPool: 0 }))
|
||||
)
|
||||
|
||||
console.log(`Updated all contracts.`)
|
||||
})
|
||||
}
|
54
functions/src/scripts/contest/bulk-add-liquidity.ts
Normal file
54
functions/src/scripts/contest/bulk-add-liquidity.ts
Normal file
|
@ -0,0 +1,54 @@
|
|||
// Run with `npx ts-node src/scripts/contest/resolve-markets.ts`
|
||||
|
||||
const DOMAIN = 'http://localhost:3000'
|
||||
// Dev API key for Cause Exploration Prizes (@CEP)
|
||||
// const API_KEY = '188f014c-0ba2-4c35-9e6d-88252e281dbf'
|
||||
// DEV API key for Criticism and Red Teaming (@CARTBot)
|
||||
const API_KEY = '6ff1f78a-32fe-43b2-b31b-9e3c78c5f18c'
|
||||
|
||||
// Warning: Checking these in can be dangerous!
|
||||
// Prod API key for @CEPBot
|
||||
|
||||
// Can just curl /v0/group/{slug} to get a group
|
||||
async function getGroupBySlug(slug: string) {
|
||||
const resp = await fetch(`${DOMAIN}/api/v0/group/${slug}`)
|
||||
return await resp.json()
|
||||
}
|
||||
|
||||
async function getMarketsByGroupId(id: string) {
|
||||
// API structure: /v0/group/by-id/[id]/markets
|
||||
const resp = await fetch(`${DOMAIN}/api/v0/group/by-id/${id}/markets`)
|
||||
return await resp.json()
|
||||
}
|
||||
|
||||
async function addLiquidityById(id: string, amount: number) {
|
||||
const resp = await fetch(`${DOMAIN}/api/v0/market/${id}/add-liquidity`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Key ${API_KEY}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
amount: amount,
|
||||
}),
|
||||
})
|
||||
return await resp.json()
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const group = await getGroupBySlug('cart-contest')
|
||||
const markets = await getMarketsByGroupId(group.id)
|
||||
|
||||
// Count up some metrics
|
||||
console.log('Number of markets', markets.length)
|
||||
|
||||
// Resolve each market to NO
|
||||
for (const market of markets.slice(0, 3)) {
|
||||
console.log(market.slug, market.totalLiquidity)
|
||||
const resp = await addLiquidityById(market.id, 200)
|
||||
console.log(resp)
|
||||
}
|
||||
}
|
||||
main()
|
||||
|
||||
export {}
|
115
functions/src/scripts/contest/bulk-create-markets.ts
Normal file
115
functions/src/scripts/contest/bulk-create-markets.ts
Normal file
|
@ -0,0 +1,115 @@
|
|||
// Run with `npx ts-node src/scripts/contest/create-markets.ts`
|
||||
|
||||
import { data } from './criticism-and-red-teaming'
|
||||
|
||||
// Dev API key for Cause Exploration Prizes (@CEP)
|
||||
// const API_KEY = '188f014c-0ba2-4c35-9e6d-88252e281dbf'
|
||||
// DEV API key for Criticism and Red Teaming (@CARTBot)
|
||||
const API_KEY = '6ff1f78a-32fe-43b2-b31b-9e3c78c5f18c'
|
||||
|
||||
type CEPSubmission = {
|
||||
title: string
|
||||
author?: string
|
||||
link: string
|
||||
}
|
||||
|
||||
// Use the API to create a new market for this Cause Exploration Prize submission
|
||||
async function postMarket(submission: CEPSubmission) {
|
||||
const { title, author } = submission
|
||||
const response = await fetch('https://dev.manifold.markets/api/v0/market', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Key ${API_KEY}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
outcomeType: 'BINARY',
|
||||
question: `"${title}" by ${author ?? 'anonymous'}`,
|
||||
description: makeDescription(submission),
|
||||
closeTime: Date.parse('2022-09-30').valueOf(),
|
||||
initialProb: 10,
|
||||
// Super secret options:
|
||||
// groupId: 'y2hcaGybXT1UfobK3XTx', // [DEV] CEP Tournament
|
||||
// groupId: 'cMcpBQ2p452jEcJD2SFw', // [PROD] Predict CEP
|
||||
groupId: 'h3MhjYbSSG6HbxY8ZTwE', // [DEV] CART
|
||||
// groupId: 'K86LmEmidMKdyCHdHNv4', // [PROD] CART
|
||||
visibility: 'unlisted',
|
||||
// TODO: Increase liquidity?
|
||||
}),
|
||||
})
|
||||
const data = await response.json()
|
||||
console.log('Created market:', data.slug)
|
||||
}
|
||||
|
||||
async function postAll() {
|
||||
for (const submission of data.slice(0, 3)) {
|
||||
await postMarket(submission)
|
||||
}
|
||||
}
|
||||
postAll()
|
||||
|
||||
/* Example curl 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}'
|
||||
*/
|
||||
|
||||
function makeDescription(submission: CEPSubmission) {
|
||||
const { title, author, link } = submission
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
content: [
|
||||
{ text: `Will ${author ?? 'anonymous'}'s post "`, type: 'text' },
|
||||
{
|
||||
marks: [
|
||||
{
|
||||
attrs: {
|
||||
target: '_blank',
|
||||
href: link,
|
||||
class:
|
||||
'no-underline !text-indigo-700 z-10 break-words hover:underline hover:decoration-indigo-400 hover:decoration-2',
|
||||
},
|
||||
type: 'link',
|
||||
},
|
||||
],
|
||||
type: 'text',
|
||||
text: title,
|
||||
},
|
||||
{ text: '" win any prize in the ', type: 'text' },
|
||||
{
|
||||
text: 'EA Criticism and Red Teaming Contest',
|
||||
type: 'text',
|
||||
marks: [
|
||||
{
|
||||
attrs: {
|
||||
target: '_blank',
|
||||
class:
|
||||
'no-underline !text-indigo-700 z-10 break-words hover:underline hover:decoration-indigo-400 hover:decoration-2',
|
||||
href: 'https://forum.effectivealtruism.org/posts/8hvmvrgcxJJ2pYR4X/announcing-a-contest-ea-criticism-and-red-teaming',
|
||||
},
|
||||
type: 'link',
|
||||
},
|
||||
],
|
||||
},
|
||||
{ text: '?', type: 'text' },
|
||||
],
|
||||
type: 'paragraph',
|
||||
},
|
||||
{ type: 'paragraph' },
|
||||
{
|
||||
type: 'iframe',
|
||||
attrs: {
|
||||
allowfullscreen: true,
|
||||
src: link,
|
||||
frameborder: 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
type: 'doc',
|
||||
}
|
||||
}
|
65
functions/src/scripts/contest/bulk-resolve-markets.ts
Normal file
65
functions/src/scripts/contest/bulk-resolve-markets.ts
Normal file
|
@ -0,0 +1,65 @@
|
|||
// Run with `npx ts-node src/scripts/contest/resolve-markets.ts`
|
||||
|
||||
const DOMAIN = 'dev.manifold.markets'
|
||||
// Dev API key for Cause Exploration Prizes (@CEP)
|
||||
const API_KEY = '188f014c-0ba2-4c35-9e6d-88252e281dbf'
|
||||
const GROUP_SLUG = 'cart-contest'
|
||||
|
||||
// Can just curl /v0/group/{slug} to get a group
|
||||
async function getGroupBySlug(slug: string) {
|
||||
const resp = await fetch(`https://${DOMAIN}/api/v0/group/${slug}`)
|
||||
return await resp.json()
|
||||
}
|
||||
|
||||
async function getMarketsByGroupId(id: string) {
|
||||
// API structure: /v0/group/by-id/[id]/markets
|
||||
const resp = await fetch(`https://${DOMAIN}/api/v0/group/by-id/${id}/markets`)
|
||||
return await resp.json()
|
||||
}
|
||||
|
||||
/* Example curl request:
|
||||
# Resolve a binary market
|
||||
$ curl https://manifold.markets/api/v0/market/{marketId}/resolve -X POST \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H 'Authorization: Key {...}' \
|
||||
--data-raw '{"outcome": "YES"}'
|
||||
*/
|
||||
async function resolveMarketById(
|
||||
id: string,
|
||||
outcome: 'YES' | 'NO' | 'MKT' | 'CANCEL'
|
||||
) {
|
||||
const resp = await fetch(`https://${DOMAIN}/api/v0/market/${id}/resolve`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Key ${API_KEY}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
outcome,
|
||||
}),
|
||||
})
|
||||
return await resp.json()
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const group = await getGroupBySlug(GROUP_SLUG)
|
||||
const markets = await getMarketsByGroupId(group.id)
|
||||
|
||||
// Count up some metrics
|
||||
console.log('Number of markets', markets.length)
|
||||
console.log(
|
||||
'Number of resolved markets',
|
||||
markets.filter((m: any) => m.isResolved).length
|
||||
)
|
||||
|
||||
// Resolve each market to NO
|
||||
for (const market of markets) {
|
||||
if (!market.isResolved) {
|
||||
console.log(`Resolving market ${market.url} to NO`)
|
||||
await resolveMarketById(market.id, 'NO')
|
||||
}
|
||||
}
|
||||
}
|
||||
main()
|
||||
|
||||
export {}
|
1219
functions/src/scripts/contest/criticism-and-red-teaming.ts
Normal file
1219
functions/src/scripts/contest/criticism-and-red-teaming.ts
Normal file
File diff suppressed because it is too large
Load Diff
55
functions/src/scripts/contest/scrape-ea.ts
Normal file
55
functions/src/scripts/contest/scrape-ea.ts
Normal file
|
@ -0,0 +1,55 @@
|
|||
// Run with `npx ts-node src/scripts/contest/scrape-ea.ts`
|
||||
import * as fs from 'fs'
|
||||
import * as puppeteer from 'puppeteer'
|
||||
|
||||
export function scrapeEA(contestLink: string, fileName: string) {
|
||||
;(async () => {
|
||||
const browser = await puppeteer.launch({ headless: true })
|
||||
const page = await browser.newPage()
|
||||
await page.goto(contestLink)
|
||||
|
||||
let loadMoreButton = await page.$('.LoadMore-root')
|
||||
|
||||
while (loadMoreButton) {
|
||||
await loadMoreButton.click()
|
||||
await page.waitForNetworkIdle()
|
||||
loadMoreButton = await page.$('.LoadMore-root')
|
||||
}
|
||||
|
||||
/* Run javascript inside the page */
|
||||
const data = await page.evaluate(() => {
|
||||
const list = []
|
||||
const items = document.querySelectorAll('.PostsItem2-root')
|
||||
|
||||
for (const item of items) {
|
||||
const link =
|
||||
'https://forum.effectivealtruism.org' +
|
||||
item?.querySelector('a')?.getAttribute('href')
|
||||
|
||||
// Replace '&' with '&'
|
||||
const clean = (str: string | undefined) => str?.replace(/&/g, '&')
|
||||
|
||||
list.push({
|
||||
title: clean(item?.querySelector('a>span>span')?.innerHTML),
|
||||
author: item?.querySelector('a.UsersNameDisplay-userName')?.innerHTML,
|
||||
link: link,
|
||||
})
|
||||
}
|
||||
|
||||
return list
|
||||
})
|
||||
|
||||
fs.writeFileSync(
|
||||
`./src/scripts/contest/${fileName}.ts`,
|
||||
`export const data = ${JSON.stringify(data, null, 2)}`
|
||||
)
|
||||
|
||||
console.log(data)
|
||||
await browser.close()
|
||||
})()
|
||||
}
|
||||
|
||||
scrapeEA(
|
||||
'https://forum.effectivealtruism.org/topics/criticism-and-red-teaming-contest',
|
||||
'criticism-and-red-teaming'
|
||||
)
|
|
@ -41,6 +41,8 @@ const createGroup = async (
|
|||
anyoneCanJoin: true,
|
||||
totalContracts: contracts.length,
|
||||
totalMembers: 1,
|
||||
postIds: [],
|
||||
pinnedItems: [],
|
||||
}
|
||||
await groupRef.create(group)
|
||||
// create a GroupMemberDoc for the creator
|
||||
|
|
|
@ -3,7 +3,6 @@
|
|||
|
||||
import { DocumentSnapshot, Transaction } from 'firebase-admin/firestore'
|
||||
import { isEqual, zip } from 'lodash'
|
||||
import { UpdateSpec } from '../utils'
|
||||
|
||||
export type DocumentValue = {
|
||||
doc: DocumentSnapshot
|
||||
|
@ -54,7 +53,7 @@ export function getDiffUpdate(diff: DocumentDiff) {
|
|||
return {
|
||||
doc: diff.dest.doc.ref,
|
||||
fields: Object.fromEntries(zip(diff.dest.fields, diff.src.vals)),
|
||||
} as UpdateSpec
|
||||
}
|
||||
}
|
||||
|
||||
export function applyDiff(transaction: Transaction, diff: DocumentDiff) {
|
||||
|
|
8
functions/src/scripts/drizzle.ts
Normal file
8
functions/src/scripts/drizzle.ts
Normal file
|
@ -0,0 +1,8 @@
|
|||
import { initAdmin } from './script-init'
|
||||
initAdmin()
|
||||
|
||||
import { drizzleLiquidity } from '../drizzle-liquidity'
|
||||
|
||||
if (require.main === module) {
|
||||
drizzleLiquidity().then(() => process.exit())
|
||||
}
|
59
functions/src/scripts/resolve-markets-again.ts
Normal file
59
functions/src/scripts/resolve-markets-again.ts
Normal file
|
@ -0,0 +1,59 @@
|
|||
import { initAdmin } from './script-init'
|
||||
initAdmin()
|
||||
|
||||
import { zip } from 'lodash'
|
||||
import { filterDefined } from 'common/util/array'
|
||||
import { resolveMarket } from '../resolve-market'
|
||||
import { getContract, getUser } from '../utils'
|
||||
|
||||
if (require.main === module) {
|
||||
const contractIds = process.argv.slice(2)
|
||||
if (contractIds.length === 0) {
|
||||
throw new Error('No contract ids provided')
|
||||
}
|
||||
resolveMarketsAgain(contractIds).then(() => process.exit(0))
|
||||
}
|
||||
|
||||
async function resolveMarketsAgain(contractIds: string[]) {
|
||||
const maybeContracts = await Promise.all(contractIds.map(getContract))
|
||||
if (maybeContracts.some((c) => !c)) {
|
||||
throw new Error('Invalid contract id')
|
||||
}
|
||||
const contracts = filterDefined(maybeContracts)
|
||||
|
||||
const maybeCreators = await Promise.all(
|
||||
contracts.map((c) => getUser(c.creatorId))
|
||||
)
|
||||
if (maybeCreators.some((c) => !c)) {
|
||||
throw new Error('No creator found')
|
||||
}
|
||||
const creators = filterDefined(maybeCreators)
|
||||
|
||||
if (
|
||||
!contracts.every((c) => c.resolution === 'YES' || c.resolution === 'NO')
|
||||
) {
|
||||
throw new Error('Only YES or NO resolutions supported')
|
||||
}
|
||||
|
||||
const resolutionParams = contracts.map((c) => ({
|
||||
outcome: c.resolution as string,
|
||||
value: undefined,
|
||||
probabilityInt: undefined,
|
||||
resolutions: undefined,
|
||||
}))
|
||||
|
||||
const params = zip(contracts, creators, resolutionParams)
|
||||
|
||||
for (const [contract, creator, resolutionParams] of params) {
|
||||
if (contract && creator && resolutionParams) {
|
||||
console.log('Resolving', contract.question)
|
||||
try {
|
||||
await resolveMarket(contract, creator, resolutionParams)
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Resolved all contracts.`)
|
||||
}
|
|
@ -1,46 +0,0 @@
|
|||
import * as admin from 'firebase-admin'
|
||||
import { uniq } from 'lodash'
|
||||
|
||||
import { initAdmin } from './script-init'
|
||||
initAdmin()
|
||||
|
||||
import { Contract } from '../../../common/contract'
|
||||
import { parseTags } from '../../../common/util/parse'
|
||||
import { getValues } from '../utils'
|
||||
|
||||
async function updateContractTags() {
|
||||
const firestore = admin.firestore()
|
||||
console.log('Updating contracts tags')
|
||||
|
||||
const contracts = await getValues<Contract>(firestore.collection('contracts'))
|
||||
|
||||
console.log('Loaded', contracts.length, 'contracts')
|
||||
|
||||
for (const contract of contracts) {
|
||||
const contractRef = firestore.doc(`contracts/${contract.id}`)
|
||||
|
||||
const tags = uniq([
|
||||
...parseTags(contract.question + contract.description),
|
||||
...(contract.tags ?? []),
|
||||
])
|
||||
const lowercaseTags = tags.map((tag) => tag.toLowerCase())
|
||||
|
||||
console.log(
|
||||
'Updating tags',
|
||||
contract.slug,
|
||||
'from',
|
||||
contract.tags,
|
||||
'to',
|
||||
tags
|
||||
)
|
||||
|
||||
await contractRef.update({
|
||||
tags,
|
||||
lowercaseTags,
|
||||
} as Partial<Contract>)
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
updateContractTags().then(() => process.exit())
|
||||
}
|
|
@ -89,17 +89,20 @@ const getGroups = async () => {
|
|||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
async function updateTotalContractsAndMembers() {
|
||||
const groups = await getGroups()
|
||||
for (const group of groups) {
|
||||
log('updating group total contracts and members', group.slug)
|
||||
const groupRef = admin.firestore().collection('groups').doc(group.id)
|
||||
const totalMembers = (await groupRef.collection('groupMembers').get()).size
|
||||
const totalContracts = (await groupRef.collection('groupContracts').get())
|
||||
.size
|
||||
await groupRef.update({
|
||||
totalMembers,
|
||||
totalContracts,
|
||||
await Promise.all(
|
||||
groups.map(async (group) => {
|
||||
log('updating group total contracts and members', group.slug)
|
||||
const groupRef = admin.firestore().collection('groups').doc(group.id)
|
||||
const totalMembers = (await groupRef.collection('groupMembers').get())
|
||||
.size
|
||||
const totalContracts = (await groupRef.collection('groupContracts').get())
|
||||
.size
|
||||
await groupRef.update({
|
||||
totalMembers,
|
||||
totalContracts,
|
||||
})
|
||||
})
|
||||
}
|
||||
)
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
async function removeUnusedMemberAndContractFields() {
|
||||
|
@ -117,6 +120,6 @@ async function removeUnusedMemberAndContractFields() {
|
|||
if (require.main === module) {
|
||||
initAdmin()
|
||||
// convertGroupFieldsToGroupDocuments()
|
||||
// updateTotalContractsAndMembers()
|
||||
removeUnusedMemberAndContractFields()
|
||||
updateTotalContractsAndMembers()
|
||||
// removeUnusedMemberAndContractFields()
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { mapValues, groupBy, sumBy, uniq } from 'lodash'
|
||||
import * as admin from 'firebase-admin'
|
||||
import { z } from 'zod'
|
||||
import { FieldValue } from 'firebase-admin/firestore'
|
||||
|
||||
import { APIError, newEndpoint, validate } from './api'
|
||||
import { Contract, CPMM_MIN_POOL_QTY } from '../../common/contract'
|
||||
|
@ -10,8 +11,7 @@ import { addObjects, removeUndefinedProps } from '../../common/util/object'
|
|||
import { log } from './utils'
|
||||
import { Bet } from '../../common/bet'
|
||||
import { floatingEqual, floatingLesserEqual } from '../../common/util/math'
|
||||
import { getUnfilledBetsQuery, updateMakers } from './place-bet'
|
||||
import { FieldValue } from 'firebase-admin/firestore'
|
||||
import { getUnfilledBetsAndUserBalances, updateMakers } from './place-bet'
|
||||
import { redeemShares } from './redeem-shares'
|
||||
import { removeUserFromContractFollowers } from './follow-market'
|
||||
|
||||
|
@ -29,16 +29,18 @@ export const sellshares = newEndpoint({}, async (req, auth) => {
|
|||
const contractDoc = firestore.doc(`contracts/${contractId}`)
|
||||
const userDoc = firestore.doc(`users/${auth.uid}`)
|
||||
const betsQ = contractDoc.collection('bets').where('userId', '==', auth.uid)
|
||||
const [[contractSnap, userSnap], userBetsSnap, unfilledBetsSnap] =
|
||||
await Promise.all([
|
||||
transaction.getAll(contractDoc, userDoc),
|
||||
transaction.get(betsQ),
|
||||
transaction.get(getUnfilledBetsQuery(contractDoc)),
|
||||
])
|
||||
const [
|
||||
[contractSnap, userSnap],
|
||||
userBetsSnap,
|
||||
{ unfilledBets, balanceByUserId },
|
||||
] = await Promise.all([
|
||||
transaction.getAll(contractDoc, userDoc),
|
||||
transaction.get(betsQ),
|
||||
getUnfilledBetsAndUserBalances(transaction, contractDoc),
|
||||
])
|
||||
if (!contractSnap.exists) throw new APIError(400, 'Contract not found.')
|
||||
if (!userSnap.exists) throw new APIError(400, 'User not found.')
|
||||
const userBets = userBetsSnap.docs.map((doc) => doc.data() as Bet)
|
||||
const unfilledBets = unfilledBetsSnap.docs.map((doc) => doc.data())
|
||||
|
||||
const contract = contractSnap.data() as Contract
|
||||
const user = userSnap.data() as User
|
||||
|
@ -86,13 +88,15 @@ export const sellshares = newEndpoint({}, async (req, auth) => {
|
|||
let loanPaid = saleFrac * loanAmount
|
||||
if (!isFinite(loanPaid)) loanPaid = 0
|
||||
|
||||
const { newBet, newPool, newP, fees, makers } = getCpmmSellBetInfo(
|
||||
soldShares,
|
||||
chosenOutcome,
|
||||
contract,
|
||||
unfilledBets,
|
||||
loanPaid
|
||||
)
|
||||
const { newBet, newPool, newP, fees, makers, ordersToCancel } =
|
||||
getCpmmSellBetInfo(
|
||||
soldShares,
|
||||
chosenOutcome,
|
||||
contract,
|
||||
unfilledBets,
|
||||
balanceByUserId,
|
||||
loanPaid
|
||||
)
|
||||
|
||||
if (
|
||||
!newP ||
|
||||
|
@ -127,6 +131,12 @@ export const sellshares = newEndpoint({}, async (req, auth) => {
|
|||
})
|
||||
)
|
||||
|
||||
for (const bet of ordersToCancel) {
|
||||
transaction.update(contractDoc.collection('bets').doc(bet.id), {
|
||||
isCancelled: true,
|
||||
})
|
||||
}
|
||||
|
||||
return { newBet, makers, maxShares, soldShares }
|
||||
})
|
||||
|
||||
|
|
|
@ -19,8 +19,7 @@ import { sellbet } from './sell-bet'
|
|||
import { sellshares } from './sell-shares'
|
||||
import { claimmanalink } from './claim-manalink'
|
||||
import { createmarket } from './create-market'
|
||||
import { addliquidity } from './add-liquidity'
|
||||
import { withdrawliquidity } from './withdraw-liquidity'
|
||||
import { createcomment } from './create-comment'
|
||||
import { creategroup } from './create-group'
|
||||
import { resolvemarket } from './resolve-market'
|
||||
import { unsubscribe } from './unsubscribe'
|
||||
|
@ -28,6 +27,8 @@ import { stripewebhook, createcheckoutsession } from './stripe'
|
|||
import { getcurrentuser } from './get-current-user'
|
||||
import { createpost } from './create-post'
|
||||
import { savetwitchcredentials } from './save-twitch-credentials'
|
||||
import { testscheduledfunction } from './test-scheduled-function'
|
||||
import { addcommentbounty, awardcommentbounty } from './update-comment-bounty'
|
||||
|
||||
type Middleware = (req: Request, res: Response, next: NextFunction) => void
|
||||
const app = express()
|
||||
|
@ -53,14 +54,15 @@ addJsonEndpointRoute('/transact', transact)
|
|||
addJsonEndpointRoute('/changeuserinfo', changeuserinfo)
|
||||
addJsonEndpointRoute('/createuser', createuser)
|
||||
addJsonEndpointRoute('/createanswer', createanswer)
|
||||
addJsonEndpointRoute('/createcomment', createcomment)
|
||||
addJsonEndpointRoute('/placebet', placebet)
|
||||
addJsonEndpointRoute('/cancelbet', cancelbet)
|
||||
addJsonEndpointRoute('/sellbet', sellbet)
|
||||
addJsonEndpointRoute('/sellshares', sellshares)
|
||||
addJsonEndpointRoute('/claimmanalink', claimmanalink)
|
||||
addJsonEndpointRoute('/createmarket', createmarket)
|
||||
addJsonEndpointRoute('/addliquidity', addliquidity)
|
||||
addJsonEndpointRoute('/withdrawliquidity', withdrawliquidity)
|
||||
addJsonEndpointRoute('/addCommentBounty', addcommentbounty)
|
||||
addJsonEndpointRoute('/awardCommentBounty', awardcommentbounty)
|
||||
addJsonEndpointRoute('/creategroup', creategroup)
|
||||
addJsonEndpointRoute('/resolvemarket', resolvemarket)
|
||||
addJsonEndpointRoute('/unsubscribe', unsubscribe)
|
||||
|
@ -69,6 +71,7 @@ addJsonEndpointRoute('/getcurrentuser', getcurrentuser)
|
|||
addJsonEndpointRoute('/savetwitchcredentials', savetwitchcredentials)
|
||||
addEndpointRoute('/stripewebhook', stripewebhook, express.raw())
|
||||
addEndpointRoute('/createpost', createpost)
|
||||
addEndpointRoute('/testscheduledfunction', testscheduledfunction)
|
||||
|
||||
app.listen(PORT)
|
||||
console.log(`Serving functions on port ${PORT}.`)
|
||||
|
|
17
functions/src/test-scheduled-function.ts
Normal file
17
functions/src/test-scheduled-function.ts
Normal file
|
@ -0,0 +1,17 @@
|
|||
import { APIError, newEndpoint } from './api'
|
||||
import { isProd } from './utils'
|
||||
import { sendMarketCloseEmails } from 'functions/src/market-close-notifications'
|
||||
|
||||
// Function for testing scheduled functions locally
|
||||
export const testscheduledfunction = newEndpoint(
|
||||
{ method: 'GET', memory: '4GiB' },
|
||||
async (_req) => {
|
||||
if (isProd())
|
||||
throw new APIError(400, 'This function is only available in dev mode')
|
||||
|
||||
// Replace your function here
|
||||
await sendMarketCloseEmails()
|
||||
|
||||
return { success: true }
|
||||
}
|
||||
)
|
|
@ -4,6 +4,7 @@ import { getPrivateUser } from './utils'
|
|||
import { PrivateUser } from '../../common/user'
|
||||
import { NOTIFICATION_DESCRIPTIONS } from '../../common/notification'
|
||||
import { notification_preference } from '../../common/user-notification-preferences'
|
||||
import { getFunctionUrl } from '../../common/api'
|
||||
|
||||
export const unsubscribe: EndpointDefinition = {
|
||||
opts: { method: 'GET', minInstances: 1 },
|
||||
|
@ -20,6 +21,8 @@ export const unsubscribe: EndpointDefinition = {
|
|||
res.status(400).send('Invalid subscription type parameter.')
|
||||
return
|
||||
}
|
||||
const optOutAllType: notification_preference = 'opt_out_all'
|
||||
const wantsToOptOutAll = notificationSubscriptionType === optOutAllType
|
||||
|
||||
const user = await getPrivateUser(id)
|
||||
|
||||
|
@ -31,28 +34,36 @@ export const unsubscribe: EndpointDefinition = {
|
|||
const previousDestinations =
|
||||
user.notificationPreferences[notificationSubscriptionType]
|
||||
|
||||
let newDestinations = previousDestinations
|
||||
if (wantsToOptOutAll) newDestinations.push('email')
|
||||
else
|
||||
newDestinations = previousDestinations.filter(
|
||||
(destination) => destination !== 'email'
|
||||
)
|
||||
|
||||
console.log(previousDestinations)
|
||||
const { email } = user
|
||||
|
||||
const update: Partial<PrivateUser> = {
|
||||
notificationPreferences: {
|
||||
...user.notificationPreferences,
|
||||
[notificationSubscriptionType]: previousDestinations.filter(
|
||||
(destination) => destination !== 'email'
|
||||
),
|
||||
[notificationSubscriptionType]: newDestinations,
|
||||
},
|
||||
}
|
||||
|
||||
await firestore.collection('private-users').doc(id).update(update)
|
||||
const unsubscribeEndpoint = getFunctionUrl('unsubscribe')
|
||||
|
||||
res.send(
|
||||
`
|
||||
<!DOCTYPE html>
|
||||
const optOutAllUrl = `${unsubscribeEndpoint}?id=${id}&type=${optOutAllType}`
|
||||
if (wantsToOptOutAll) {
|
||||
res.send(
|
||||
`
|
||||
<!DOCTYPE html>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml"
|
||||
xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||
|
||||
<head>
|
||||
<title>Manifold Markets 7th Day Anniversary Gift!</title>
|
||||
<title>Unsubscribe from Manifold Markets emails</title>
|
||||
<!--[if !mso]><!-->
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<!--<![endif]-->
|
||||
|
@ -163,19 +174,6 @@ export const unsubscribe: EndpointDefinition = {
|
|||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left"
|
||||
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
|
||||
<div
|
||||
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
|
||||
<p class="text-build-content"
|
||||
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"
|
||||
data-testid="4XoHRGw1Y"><span
|
||||
style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
|
||||
Hello!</span></p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left"
|
||||
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
|
||||
|
@ -186,20 +184,9 @@ export const unsubscribe: EndpointDefinition = {
|
|||
data-testid="4XoHRGw1Y">
|
||||
<span
|
||||
style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
|
||||
${email} has been unsubscribed from email notifications related to:
|
||||
${email} has opted out of receiving unnecessary email notifications
|
||||
</span>
|
||||
<br/>
|
||||
<br/>
|
||||
|
||||
<span style="font-weight: bold; color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">${NOTIFICATION_DESCRIPTIONS[notificationSubscriptionType].detailed}.</span>
|
||||
</p>
|
||||
<br/>
|
||||
<br/>
|
||||
<br/>
|
||||
<span>Click
|
||||
<a href='https://manifold.markets/notifications?tab=settings'>here</a>
|
||||
to manage the rest of your notification settings.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
</td>
|
||||
|
@ -219,9 +206,193 @@ export const unsubscribe: EndpointDefinition = {
|
|||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
)
|
||||
} else {
|
||||
res.send(
|
||||
`
|
||||
<!DOCTYPE html>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml"
|
||||
xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||
|
||||
<head>
|
||||
<title>Unsubscribe from Manifold Markets emails</title>
|
||||
<!--[if !mso]><!-->
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<!--<![endif]-->
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<style type="text/css">
|
||||
#outlook a {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-ms-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
table,
|
||||
td {
|
||||
border-collapse: collapse;
|
||||
mso-table-lspace: 0pt;
|
||||
mso-table-rspace: 0pt;
|
||||
}
|
||||
|
||||
img {
|
||||
border: 0;
|
||||
height: auto;
|
||||
line-height: 100%;
|
||||
outline: none;
|
||||
text-decoration: none;
|
||||
-ms-interpolation-mode: bicubic;
|
||||
}
|
||||
|
||||
p {
|
||||
display: block;
|
||||
margin: 13px 0;
|
||||
}
|
||||
</style>
|
||||
<!--[if mso]>
|
||||
<noscript>
|
||||
<xml>
|
||||
<o:OfficeDocumentSettings>
|
||||
<o:AllowPNG/>
|
||||
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||
</o:OfficeDocumentSettings>
|
||||
</xml>
|
||||
</noscript>
|
||||
<![endif]-->
|
||||
<!--[if lte mso 11]>
|
||||
<style type="text/css">
|
||||
.mj-outlook-group-fix { width:100% !important; }
|
||||
</style>
|
||||
<![endif]-->
|
||||
<style type="text/css">
|
||||
@media only screen and (min-width:480px) {
|
||||
.mj-column-per-100 {
|
||||
width: 100% !important;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<style media="screen and (min-width:480px)">
|
||||
.moz-text-html .mj-column-per-100 {
|
||||
width: 100% !important;
|
||||
max-width: 100%;
|
||||
}
|
||||
</style>
|
||||
<style type="text/css">
|
||||
[owa] .mj-column-per-100 {
|
||||
width: 100% !important;
|
||||
max-width: 100%;
|
||||
}
|
||||
</style>
|
||||
<style type="text/css">
|
||||
@media only screen and (max-width:480px) {
|
||||
table.mj-full-width-mobile {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
td.mj-full-width-mobile {
|
||||
width: auto !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body style="word-spacing:normal;background-color:#F4F4F4;">
|
||||
<div style="background-color:#F4F4F4;">
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:600px;">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="background:#ffffff;background-color:#ffffff;width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
style="direction:ltr;font-size:0px;padding:0px 0px 0px 0px;padding-bottom:0px;padding-left:0px;padding-right:0px;padding-top:0px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix"
|
||||
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;"
|
||||
width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="width:550px;">
|
||||
<a href="https://manifold.markets" target="_blank">
|
||||
<img alt="banner logo" height="auto" src="https://manifold.markets/logo-banner.png"
|
||||
style="border:none;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:13px;"
|
||||
title="" width="550">
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left"
|
||||
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
|
||||
<div
|
||||
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
|
||||
<p class="text-build-content"
|
||||
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"
|
||||
data-testid="4XoHRGw1Y"><span
|
||||
style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
|
||||
Hello!</span></p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left"
|
||||
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
|
||||
<div
|
||||
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
|
||||
<p class="text-build-content"
|
||||
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"
|
||||
data-testid="4XoHRGw1Y">
|
||||
<span
|
||||
style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
|
||||
${email} has been unsubscribed from email notifications related to:
|
||||
</span>
|
||||
<br/>
|
||||
<br/>
|
||||
|
||||
<span style="font-weight: bold; color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">${NOTIFICATION_DESCRIPTIONS[notificationSubscriptionType].detailed}.</span>
|
||||
</p>
|
||||
<br/>
|
||||
<br/>
|
||||
<br/>
|
||||
<span>Click
|
||||
<a href=${optOutAllUrl}>here</a>
|
||||
to unsubscribe from all unnecessary emails.
|
||||
</span>
|
||||
<br/>
|
||||
<br/>
|
||||
<span>Click
|
||||
<a href='https://manifold.markets/notifications?tab=settings'>here</a>
|
||||
to manage the rest of your notification settings.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<p></p>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
)
|
||||
)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
|
162
functions/src/update-comment-bounty.ts
Normal file
162
functions/src/update-comment-bounty.ts
Normal file
|
@ -0,0 +1,162 @@
|
|||
import * as admin from 'firebase-admin'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { Contract } from '../../common/contract'
|
||||
import { User } from '../../common/user'
|
||||
import { removeUndefinedProps } from '../../common/util/object'
|
||||
import { APIError, newEndpoint, validate } from './api'
|
||||
import {
|
||||
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
|
||||
HOUSE_LIQUIDITY_PROVIDER_ID,
|
||||
} from '../../common/antes'
|
||||
import { isProd } from './utils'
|
||||
import {
|
||||
CommentBountyDepositTxn,
|
||||
CommentBountyWithdrawalTxn,
|
||||
} from '../../common/txn'
|
||||
import { runTxn } from './transact'
|
||||
import { Comment } from '../../common/comment'
|
||||
import { createBountyNotification } from './create-notification'
|
||||
|
||||
const bodySchema = z.object({
|
||||
contractId: z.string(),
|
||||
amount: z.number().gt(0),
|
||||
})
|
||||
const awardBodySchema = z.object({
|
||||
contractId: z.string(),
|
||||
commentId: z.string(),
|
||||
amount: z.number().gt(0),
|
||||
})
|
||||
|
||||
export const addcommentbounty = newEndpoint({}, async (req, auth) => {
|
||||
const { amount, contractId } = validate(bodySchema, req.body)
|
||||
|
||||
if (!isFinite(amount)) throw new APIError(400, 'Invalid amount')
|
||||
|
||||
// run as transaction to prevent race conditions
|
||||
return await firestore.runTransaction(async (transaction) => {
|
||||
const userDoc = firestore.doc(`users/${auth.uid}`)
|
||||
const userSnap = await transaction.get(userDoc)
|
||||
if (!userSnap.exists) throw new APIError(400, 'User not found')
|
||||
const user = userSnap.data() as User
|
||||
|
||||
const contractDoc = firestore.doc(`contracts/${contractId}`)
|
||||
const contractSnap = await transaction.get(contractDoc)
|
||||
if (!contractSnap.exists) throw new APIError(400, 'Invalid contract')
|
||||
const contract = contractSnap.data() as Contract
|
||||
|
||||
if (user.balance < amount)
|
||||
throw new APIError(400, 'Insufficient user balance')
|
||||
|
||||
const newCommentBountyTxn = {
|
||||
fromId: user.id,
|
||||
fromType: 'USER',
|
||||
toId: isProd()
|
||||
? HOUSE_LIQUIDITY_PROVIDER_ID
|
||||
: DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
|
||||
toType: 'BANK',
|
||||
amount,
|
||||
token: 'M$',
|
||||
category: 'COMMENT_BOUNTY',
|
||||
data: {
|
||||
contractId,
|
||||
},
|
||||
description: `Deposit M$${amount} from ${user.id} for comment bounty for contract ${contractId}`,
|
||||
} as CommentBountyDepositTxn
|
||||
|
||||
const result = await runTxn(transaction, newCommentBountyTxn)
|
||||
|
||||
transaction.update(
|
||||
contractDoc,
|
||||
removeUndefinedProps({
|
||||
openCommentBounties: (contract.openCommentBounties ?? 0) + amount,
|
||||
})
|
||||
)
|
||||
|
||||
return result
|
||||
})
|
||||
})
|
||||
export const awardcommentbounty = newEndpoint({}, async (req, auth) => {
|
||||
const { amount, commentId, contractId } = validate(awardBodySchema, req.body)
|
||||
|
||||
if (!isFinite(amount)) throw new APIError(400, 'Invalid amount')
|
||||
|
||||
// run as transaction to prevent race conditions
|
||||
const res = await firestore.runTransaction(async (transaction) => {
|
||||
const userDoc = firestore.doc(`users/${auth.uid}`)
|
||||
const userSnap = await transaction.get(userDoc)
|
||||
if (!userSnap.exists) throw new APIError(400, 'User not found')
|
||||
const user = userSnap.data() as User
|
||||
|
||||
const contractDoc = firestore.doc(`contracts/${contractId}`)
|
||||
const contractSnap = await transaction.get(contractDoc)
|
||||
if (!contractSnap.exists) throw new APIError(400, 'Invalid contract')
|
||||
const contract = contractSnap.data() as Contract
|
||||
|
||||
if (user.id !== contract.creatorId)
|
||||
throw new APIError(
|
||||
400,
|
||||
'Only contract creator can award comment bounties'
|
||||
)
|
||||
|
||||
const commentDoc = firestore.doc(
|
||||
`contracts/${contractId}/comments/${commentId}`
|
||||
)
|
||||
const commentSnap = await transaction.get(commentDoc)
|
||||
if (!commentSnap.exists) throw new APIError(400, 'Invalid comment')
|
||||
|
||||
const comment = commentSnap.data() as Comment
|
||||
const amountAvailable = contract.openCommentBounties ?? 0
|
||||
if (amountAvailable < amount)
|
||||
throw new APIError(400, 'Insufficient open bounty balance')
|
||||
|
||||
const newCommentBountyTxn = {
|
||||
fromId: isProd()
|
||||
? HOUSE_LIQUIDITY_PROVIDER_ID
|
||||
: DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
|
||||
fromType: 'BANK',
|
||||
toId: comment.userId,
|
||||
toType: 'USER',
|
||||
amount,
|
||||
token: 'M$',
|
||||
category: 'COMMENT_BOUNTY',
|
||||
data: {
|
||||
contractId,
|
||||
commentId,
|
||||
},
|
||||
description: `Withdrawal M$${amount} from BANK for comment ${comment.id} bounty for contract ${contractId}`,
|
||||
} as CommentBountyWithdrawalTxn
|
||||
|
||||
const result = await runTxn(transaction, newCommentBountyTxn)
|
||||
|
||||
await transaction.update(
|
||||
contractDoc,
|
||||
removeUndefinedProps({
|
||||
openCommentBounties: amountAvailable - amount,
|
||||
})
|
||||
)
|
||||
await transaction.update(
|
||||
commentDoc,
|
||||
removeUndefinedProps({
|
||||
bountiesAwarded: (comment.bountiesAwarded ?? 0) + amount,
|
||||
})
|
||||
)
|
||||
|
||||
return { ...result, comment, contract, user }
|
||||
})
|
||||
if (res.txn?.id) {
|
||||
const { comment, contract, user } = res
|
||||
await createBountyNotification(
|
||||
user,
|
||||
comment.userId,
|
||||
amount,
|
||||
res.txn.id,
|
||||
contract,
|
||||
comment.id
|
||||
)
|
||||
}
|
||||
|
||||
return res
|
||||
})
|
||||
|
||||
const firestore = admin.firestore()
|
|
@ -12,7 +12,7 @@ import { filterDefined } from '../../common/util/array'
|
|||
const firestore = admin.firestore()
|
||||
|
||||
export const updateLoans = functions
|
||||
.runWith({ memory: '2GB', timeoutSeconds: 540 })
|
||||
.runWith({ memory: '8GB', timeoutSeconds: 540 })
|
||||
// Run every day at midnight.
|
||||
.pubsub.schedule('0 0 * * *')
|
||||
.timeZone('America/Los_Angeles')
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
import * as functions from 'firebase-functions'
|
||||
import * as admin from 'firebase-admin'
|
||||
import { groupBy, isEmpty, keyBy, last, sortBy } from 'lodash'
|
||||
import { groupBy, keyBy, sortBy } from 'lodash'
|
||||
import fetch from 'node-fetch'
|
||||
|
||||
import { getValues, log, logMemory, writeAsync } from './utils'
|
||||
import { Bet } from '../../common/bet'
|
||||
import { Contract, CPMM } from '../../common/contract'
|
||||
|
||||
import { PortfolioMetrics, User } from '../../common/user'
|
||||
import { DAY_MS } from '../../common/util/time'
|
||||
import { getLoanUpdates } from '../../common/loans'
|
||||
|
@ -14,41 +15,82 @@ import {
|
|||
calculateNewPortfolioMetrics,
|
||||
calculateNewProfit,
|
||||
calculateProbChanges,
|
||||
calculateMetricsByContract,
|
||||
computeElasticity,
|
||||
computeVolume,
|
||||
} from '../../common/calculate-metrics'
|
||||
import { getProbability } from '../../common/calculate'
|
||||
import { Group } from 'common/group'
|
||||
import { Group } from '../../common/group'
|
||||
import { batchedWaitAll } from '../../common/util/promise'
|
||||
import { newEndpointNoAuth } from './api'
|
||||
import { getFunctionUrl } from '../../common/api'
|
||||
import { filterDefined } from '../../common/util/array'
|
||||
|
||||
const firestore = admin.firestore()
|
||||
export const scheduleUpdateMetrics = functions.pubsub
|
||||
.schedule('every 15 minutes')
|
||||
.onRun(async () => {
|
||||
const url = getFunctionUrl('updatemetrics')
|
||||
console.log('Scheduling update metrics', url)
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
|
||||
export const updateMetrics = functions
|
||||
.runWith({ memory: '4GB', timeoutSeconds: 540 })
|
||||
.pubsub.schedule('every 15 minutes')
|
||||
.onRun(updateMetricsCore)
|
||||
const json = await response.json()
|
||||
|
||||
if (response.ok) console.log(json)
|
||||
else console.error(json)
|
||||
})
|
||||
|
||||
export const updatemetrics = newEndpointNoAuth(
|
||||
{ timeoutSeconds: 2000, memory: '8GiB', minInstances: 0 },
|
||||
async (_req) => {
|
||||
await updateMetricsCore()
|
||||
return { success: true }
|
||||
}
|
||||
)
|
||||
|
||||
export async function updateMetricsCore() {
|
||||
const [users, contracts, bets, allPortfolioHistories, groups] =
|
||||
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
|
||||
),
|
||||
getValues<Group>(firestore.collection('groups')),
|
||||
])
|
||||
console.log('Loading users')
|
||||
const users = await getValues<User>(firestore.collection('users'))
|
||||
|
||||
console.log('Loading contracts')
|
||||
const contracts = await getValues<Contract>(firestore.collection('contracts'))
|
||||
|
||||
console.log('Loading portfolio history')
|
||||
const userPortfolioHistory = await loadPortfolioHistory(users)
|
||||
|
||||
console.log('Loading groups')
|
||||
const groups = await getValues<Group>(firestore.collection('groups'))
|
||||
|
||||
console.log('Loading bets')
|
||||
const contractBets = await batchedWaitAll(
|
||||
contracts
|
||||
.filter((c) => c.id)
|
||||
.map(
|
||||
(c) => () =>
|
||||
getValues<Bet>(
|
||||
firestore.collection('contracts').doc(c.id).collection('bets')
|
||||
)
|
||||
),
|
||||
100
|
||||
)
|
||||
const bets = contractBets.flat()
|
||||
|
||||
console.log('Loading group contracts')
|
||||
const contractsByGroup = await Promise.all(
|
||||
groups.map((group) => {
|
||||
return getValues(
|
||||
groups.map((group) =>
|
||||
getValues(
|
||||
firestore
|
||||
.collection('groups')
|
||||
.doc(group.id)
|
||||
.collection('groupContracts')
|
||||
)
|
||||
})
|
||||
)
|
||||
)
|
||||
log(
|
||||
`Loaded ${users.length} users, ${contracts.length} contracts, and ${bets.length} bets.`
|
||||
|
@ -84,6 +126,7 @@ export async function updateMetricsCore() {
|
|||
fields: {
|
||||
volume24Hours: computeVolume(contractBets, now - DAY_MS),
|
||||
volume7Days: computeVolume(contractBets, now - DAY_MS * 7),
|
||||
elasticity: computeElasticity(contractBets, contract),
|
||||
...cpmmFields,
|
||||
},
|
||||
}
|
||||
|
@ -96,11 +139,10 @@ export async function updateMetricsCore() {
|
|||
)
|
||||
const contractsByUser = groupBy(contracts, (contract) => contract.creatorId)
|
||||
const betsByUser = groupBy(bets, (bet) => bet.userId)
|
||||
const portfolioHistoryByUser = groupBy(allPortfolioHistories, (p) => p.userId)
|
||||
|
||||
const userMetrics = users.map((user) => {
|
||||
const currentBets = betsByUser[user.id] ?? []
|
||||
const portfolioHistory = portfolioHistoryByUser[user.id] ?? []
|
||||
const portfolioHistory = userPortfolioHistory[user.id] ?? []
|
||||
const userContracts = contractsByUser[user.id] ?? []
|
||||
const newCreatorVolume = calculateCreatorVolume(userContracts)
|
||||
const newPortfolio = calculateNewPortfolioMetrics(
|
||||
|
@ -108,21 +150,51 @@ export async function updateMetricsCore() {
|
|||
contractsById,
|
||||
currentBets
|
||||
)
|
||||
const lastPortfolio = last(portfolioHistory)
|
||||
const currPortfolio = portfolioHistory.current
|
||||
const didPortfolioChange =
|
||||
lastPortfolio === undefined ||
|
||||
lastPortfolio.balance !== newPortfolio.balance ||
|
||||
lastPortfolio.totalDeposits !== newPortfolio.totalDeposits ||
|
||||
lastPortfolio.investmentValue !== newPortfolio.investmentValue
|
||||
currPortfolio === undefined ||
|
||||
currPortfolio.balance !== newPortfolio.balance ||
|
||||
currPortfolio.totalDeposits !== newPortfolio.totalDeposits ||
|
||||
currPortfolio.investmentValue !== newPortfolio.investmentValue
|
||||
|
||||
const newProfit = calculateNewProfit(portfolioHistory, newPortfolio)
|
||||
|
||||
const metricsByContract = calculateMetricsByContract(
|
||||
currentBets,
|
||||
contractsById
|
||||
)
|
||||
|
||||
const contractRatios = userContracts
|
||||
.map((contract) => {
|
||||
if (
|
||||
!contract.flaggedByUsernames ||
|
||||
contract.flaggedByUsernames?.length === 0
|
||||
) {
|
||||
return 0
|
||||
}
|
||||
const contractRatio =
|
||||
contract.flaggedByUsernames.length / (contract.uniqueBettorCount || 1)
|
||||
|
||||
return contractRatio
|
||||
})
|
||||
.filter((ratio) => ratio > 0)
|
||||
const badResolutions = contractRatios.filter(
|
||||
(ratio) => ratio > BAD_RESOLUTION_THRESHOLD
|
||||
)
|
||||
let newFractionResolvedCorrectly = 1
|
||||
if (userContracts.length > 0) {
|
||||
newFractionResolvedCorrectly =
|
||||
(userContracts.length - badResolutions.length) / userContracts.length
|
||||
}
|
||||
|
||||
return {
|
||||
user,
|
||||
newCreatorVolume,
|
||||
newPortfolio,
|
||||
newProfit,
|
||||
didPortfolioChange,
|
||||
newFractionResolvedCorrectly,
|
||||
metricsByContract,
|
||||
}
|
||||
})
|
||||
|
||||
|
@ -138,61 +210,61 @@ export async function updateMetricsCore() {
|
|||
const nextLoanByUser = keyBy(userPayouts, (payout) => payout.user.id)
|
||||
|
||||
const userUpdates = userMetrics.map(
|
||||
({
|
||||
user,
|
||||
newCreatorVolume,
|
||||
newPortfolio,
|
||||
newProfit,
|
||||
didPortfolioChange,
|
||||
}) => {
|
||||
({ user, newCreatorVolume, newProfit, newFractionResolvedCorrectly }) => {
|
||||
const nextLoanCached = nextLoanByUser[user.id]?.payout ?? 0
|
||||
return {
|
||||
fieldUpdates: {
|
||||
doc: firestore.collection('users').doc(user.id),
|
||||
fields: {
|
||||
creatorVolumeCached: newCreatorVolume,
|
||||
profitCached: newProfit,
|
||||
nextLoanCached,
|
||||
},
|
||||
},
|
||||
|
||||
subcollectionUpdates: {
|
||||
doc: firestore
|
||||
.collection('users')
|
||||
.doc(user.id)
|
||||
.collection('portfolioHistory')
|
||||
.doc(),
|
||||
fields: didPortfolioChange ? newPortfolio : {},
|
||||
doc: firestore.collection('users').doc(user.id),
|
||||
fields: {
|
||||
creatorVolumeCached: newCreatorVolume,
|
||||
profitCached: newProfit,
|
||||
nextLoanCached,
|
||||
fractionResolvedCorrectly: newFractionResolvedCorrectly,
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
await writeAsync(
|
||||
firestore,
|
||||
userUpdates.map((u) => u.fieldUpdates)
|
||||
await writeAsync(firestore, userUpdates)
|
||||
|
||||
const portfolioHistoryUpdates = filterDefined(
|
||||
userMetrics.map(({ user, newPortfolio, didPortfolioChange }) => {
|
||||
return didPortfolioChange
|
||||
? {
|
||||
doc: firestore
|
||||
.collection('users')
|
||||
.doc(user.id)
|
||||
.collection('portfolioHistory')
|
||||
.doc(),
|
||||
fields: newPortfolio,
|
||||
}
|
||||
: null
|
||||
})
|
||||
)
|
||||
await writeAsync(
|
||||
firestore,
|
||||
userUpdates
|
||||
.filter((u) => !isEmpty(u.subcollectionUpdates.fields))
|
||||
.map((u) => u.subcollectionUpdates),
|
||||
'set'
|
||||
await writeAsync(firestore, portfolioHistoryUpdates, 'set')
|
||||
|
||||
const contractMetricsUpdates = userMetrics.flatMap(
|
||||
({ user, metricsByContract }) => {
|
||||
const collection = firestore
|
||||
.collection('users')
|
||||
.doc(user.id)
|
||||
.collection('contract-metrics')
|
||||
return metricsByContract.map((metrics) => ({
|
||||
doc: collection.doc(metrics.contractId),
|
||||
fields: metrics,
|
||||
}))
|
||||
}
|
||||
)
|
||||
|
||||
await writeAsync(firestore, contractMetricsUpdates, 'set')
|
||||
|
||||
log(`Updated metrics for ${users.length} users.`)
|
||||
|
||||
try {
|
||||
const groupUpdates = groups.map((group, index) => {
|
||||
const groupContractIds = contractsByGroup[index] as GroupContractDoc[]
|
||||
const groupContracts = groupContractIds
|
||||
.map((e) => contractsById[e.contractId])
|
||||
.filter((e) => e !== undefined) as Contract[]
|
||||
const bets = groupContracts.map((e) => {
|
||||
if (e != null && e.id in betsByContract) {
|
||||
return betsByContract[e.id] ?? []
|
||||
} else {
|
||||
return []
|
||||
}
|
||||
})
|
||||
const groupContracts = filterDefined(
|
||||
groupContractIds.map((e) => contractsById[e.contractId])
|
||||
)
|
||||
const bets = groupContracts.map((e) => betsByContract[e.id] ?? [])
|
||||
|
||||
const creatorScores = scoreCreators(groupContracts)
|
||||
const traderScores = scoreTraders(groupContracts, bets)
|
||||
|
@ -224,3 +296,46 @@ const topUserScores = (scores: { [userId: string]: number }) => {
|
|||
}
|
||||
|
||||
type GroupContractDoc = { contractId: string; createdTime: number }
|
||||
|
||||
const BAD_RESOLUTION_THRESHOLD = 0.1
|
||||
|
||||
const loadPortfolioHistory = async (users: User[]) => {
|
||||
const now = Date.now()
|
||||
const userPortfolioHistory = await batchedWaitAll(
|
||||
users.map((user) => async () => {
|
||||
const query = firestore
|
||||
.collection('users')
|
||||
.doc(user.id)
|
||||
.collection('portfolioHistory')
|
||||
.orderBy('timestamp', 'desc')
|
||||
.limit(1)
|
||||
|
||||
const portfolioMetrics = await Promise.all([
|
||||
getValues<PortfolioMetrics>(query),
|
||||
getValues<PortfolioMetrics>(
|
||||
query.where('timestamp', '<', now - DAY_MS)
|
||||
),
|
||||
getValues<PortfolioMetrics>(
|
||||
query.where('timestamp', '<', now - 7 * DAY_MS)
|
||||
),
|
||||
getValues<PortfolioMetrics>(
|
||||
query.where('timestamp', '<', now - 30 * DAY_MS)
|
||||
),
|
||||
])
|
||||
const [current, day, week, month] = portfolioMetrics.map(
|
||||
(p) => p[0] as PortfolioMetrics | undefined
|
||||
)
|
||||
|
||||
return {
|
||||
userId: user.id,
|
||||
current,
|
||||
day,
|
||||
week,
|
||||
month,
|
||||
}
|
||||
}),
|
||||
100
|
||||
)
|
||||
|
||||
return keyBy(userPortfolioHistory, (p) => p.userId)
|
||||
}
|
||||
|
|
|
@ -18,7 +18,7 @@ import { average } from '../../common/util/math'
|
|||
|
||||
const firestore = admin.firestore()
|
||||
|
||||
const numberOfDays = 90
|
||||
const numberOfDays = 180
|
||||
|
||||
const getBetsQuery = (startTime: number, endTime: number) =>
|
||||
firestore
|
||||
|
@ -343,6 +343,6 @@ export const updateStatsCore = async () => {
|
|||
}
|
||||
|
||||
export const updateStats = functions
|
||||
.runWith({ memory: '2GB', timeoutSeconds: 540 })
|
||||
.runWith({ memory: '4GB', timeoutSeconds: 540 })
|
||||
.pubsub.schedule('every 60 minutes')
|
||||
.onRun(updateStatsCore)
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
import * as admin from 'firebase-admin'
|
||||
import fetch from 'node-fetch'
|
||||
import { FieldValue, Transaction } from 'firebase-admin/firestore'
|
||||
import { chunk, groupBy, mapValues, sumBy } from 'lodash'
|
||||
|
||||
import { chunk } from 'lodash'
|
||||
import { Contract } from '../../common/contract'
|
||||
import { PrivateUser, User } from '../../common/user'
|
||||
import { Group } from '../../common/group'
|
||||
|
@ -47,7 +48,7 @@ export const writeAsync = async (
|
|||
const batch = db.batch()
|
||||
for (const { doc, fields } of chunks[i]) {
|
||||
if (operationType === 'update') {
|
||||
batch.update(doc, fields)
|
||||
batch.update(doc, fields as any)
|
||||
} else {
|
||||
batch.set(doc, fields)
|
||||
}
|
||||
|
@ -112,6 +113,12 @@ export const getAllPrivateUsers = async () => {
|
|||
return users.docs.map((doc) => doc.data() as PrivateUser)
|
||||
}
|
||||
|
||||
export const getAllUsers = async () => {
|
||||
const firestore = admin.firestore()
|
||||
const users = await firestore.collection('users').get()
|
||||
return users.docs.map((doc) => doc.data() as User)
|
||||
}
|
||||
|
||||
export const getUserByUsername = async (username: string) => {
|
||||
const firestore = admin.firestore()
|
||||
const snap = await firestore
|
||||
|
@ -122,38 +129,29 @@ export const getUserByUsername = async (username: string) => {
|
|||
return snap.empty ? undefined : (snap.docs[0].data() as User)
|
||||
}
|
||||
|
||||
const firestore = admin.firestore()
|
||||
|
||||
const updateUserBalance = (
|
||||
transaction: Transaction,
|
||||
userId: string,
|
||||
delta: number,
|
||||
isDeposit = false
|
||||
balanceDelta: number,
|
||||
depositDelta: number
|
||||
) => {
|
||||
const firestore = admin.firestore()
|
||||
return firestore.runTransaction(async (transaction) => {
|
||||
const userDoc = firestore.doc(`users/${userId}`)
|
||||
const userSnap = await transaction.get(userDoc)
|
||||
if (!userSnap.exists) return
|
||||
const user = userSnap.data() as User
|
||||
const userDoc = firestore.doc(`users/${userId}`)
|
||||
|
||||
const newUserBalance = user.balance + delta
|
||||
|
||||
// if (newUserBalance < 0)
|
||||
// throw new Error(
|
||||
// `User (${userId}) balance cannot be negative: ${newUserBalance}`
|
||||
// )
|
||||
|
||||
if (isDeposit) {
|
||||
const newTotalDeposits = (user.totalDeposits || 0) + delta
|
||||
transaction.update(userDoc, { totalDeposits: newTotalDeposits })
|
||||
}
|
||||
|
||||
transaction.update(userDoc, { balance: newUserBalance })
|
||||
// Note: Balance is allowed to go negative.
|
||||
transaction.update(userDoc, {
|
||||
balance: FieldValue.increment(balanceDelta),
|
||||
totalDeposits: FieldValue.increment(depositDelta),
|
||||
})
|
||||
}
|
||||
|
||||
export const payUser = (userId: string, payout: number, isDeposit = false) => {
|
||||
if (!isFinite(payout)) throw new Error('Payout is not finite: ' + payout)
|
||||
|
||||
return updateUserBalance(userId, payout, isDeposit)
|
||||
return firestore.runTransaction(async (transaction) => {
|
||||
updateUserBalance(transaction, userId, payout, isDeposit ? payout : 0)
|
||||
})
|
||||
}
|
||||
|
||||
export const chargeUser = (
|
||||
|
@ -164,9 +162,73 @@ export const chargeUser = (
|
|||
if (!isFinite(charge) || charge <= 0)
|
||||
throw new Error('User charge is not positive: ' + charge)
|
||||
|
||||
return updateUserBalance(userId, -charge, isAnte)
|
||||
return payUser(userId, -charge, isAnte)
|
||||
}
|
||||
|
||||
const checkAndMergePayouts = (
|
||||
payouts: {
|
||||
userId: string
|
||||
payout: number
|
||||
deposit?: number
|
||||
}[]
|
||||
) => {
|
||||
for (const { payout, deposit } of payouts) {
|
||||
if (!isFinite(payout)) {
|
||||
throw new Error('Payout is not finite: ' + payout)
|
||||
}
|
||||
if (deposit !== undefined && !isFinite(deposit)) {
|
||||
throw new Error('Deposit is not finite: ' + deposit)
|
||||
}
|
||||
}
|
||||
|
||||
const groupedPayouts = groupBy(payouts, 'userId')
|
||||
return Object.values(
|
||||
mapValues(groupedPayouts, (payouts, userId) => ({
|
||||
userId,
|
||||
payout: sumBy(payouts, 'payout'),
|
||||
deposit: sumBy(payouts, (p) => p.deposit ?? 0),
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
// Max 500 users in one transaction.
|
||||
export const payUsers = (
|
||||
transaction: Transaction,
|
||||
payouts: {
|
||||
userId: string
|
||||
payout: number
|
||||
deposit?: number
|
||||
}[]
|
||||
) => {
|
||||
const mergedPayouts = checkAndMergePayouts(payouts)
|
||||
for (const { userId, payout, deposit } of mergedPayouts) {
|
||||
updateUserBalance(transaction, userId, payout, deposit)
|
||||
}
|
||||
}
|
||||
|
||||
export const payUsersMultipleTransactions = async (
|
||||
payouts: {
|
||||
userId: string
|
||||
payout: number
|
||||
deposit?: number
|
||||
}[]
|
||||
) => {
|
||||
const mergedPayouts = checkAndMergePayouts(payouts)
|
||||
const payoutChunks = chunk(mergedPayouts, 500)
|
||||
|
||||
for (const payoutChunk of payoutChunks) {
|
||||
await firestore.runTransaction(async (transaction) => {
|
||||
for (const { userId, payout, deposit } of payoutChunk) {
|
||||
updateUserBalance(transaction, userId, payout, deposit)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const getContractPath = (contract: Contract) => {
|
||||
return `/${contract.creatorUsername}/${contract.slug}`
|
||||
}
|
||||
|
||||
export function contractUrl(contract: Contract) {
|
||||
return `https://manifold.markets/${contract.creatorUsername}/${contract.slug}`
|
||||
}
|
||||
|
|
|
@ -4,21 +4,24 @@ import * as admin from 'firebase-admin'
|
|||
import { Contract } from '../../common/contract'
|
||||
import {
|
||||
getAllPrivateUsers,
|
||||
getGroup,
|
||||
getPrivateUser,
|
||||
getUser,
|
||||
getValues,
|
||||
isProd,
|
||||
log,
|
||||
} from './utils'
|
||||
import { sendInterestingMarketsEmail } from './emails'
|
||||
import { createRNG, shuffle } from '../../common/util/random'
|
||||
import { DAY_MS } from '../../common/util/time'
|
||||
import { DAY_MS, HOUR_MS } from '../../common/util/time'
|
||||
import { filterDefined } from '../../common/util/array'
|
||||
import { Follow } from '../../common/follow'
|
||||
import { countBy, uniq, uniqBy } from 'lodash'
|
||||
import { sendInterestingMarketsEmail } from './emails'
|
||||
|
||||
export const weeklyMarketsEmails = functions
|
||||
.runWith({ secrets: ['MAILGUN_KEY'], memory: '4GB' })
|
||||
// every minute on Monday for an hour at 12pm PT (UTC -07:00)
|
||||
.pubsub.schedule('* 19 * * 1')
|
||||
// every minute on Monday for 2 hours starting at 12pm PT (UTC -07:00)
|
||||
.pubsub.schedule('* 19-20 * * 1')
|
||||
.timeZone('Etc/UTC')
|
||||
.onRun(async () => {
|
||||
await sendTrendingMarketsEmailsToAllUsers()
|
||||
|
@ -40,18 +43,30 @@ export async function getTrendingContracts() {
|
|||
)
|
||||
}
|
||||
|
||||
async function sendTrendingMarketsEmailsToAllUsers() {
|
||||
export async function sendTrendingMarketsEmailsToAllUsers() {
|
||||
const numContractsToSend = 6
|
||||
const privateUsers = isProd()
|
||||
? await getAllPrivateUsers()
|
||||
: filterDefined([await getPrivateUser('6hHpzvRG0pMq8PNJs7RZj2qlZGn2')])
|
||||
// get all users that haven't unsubscribed from weekly emails
|
||||
const privateUsersToSendEmailsTo = privateUsers.filter((user) => {
|
||||
return (
|
||||
user.notificationPreferences.trending_markets.includes('email') &&
|
||||
!user.weeklyTrendingEmailSent
|
||||
: filterDefined([
|
||||
await getPrivateUser('6hHpzvRG0pMq8PNJs7RZj2qlZGn2'), // dev Ian
|
||||
])
|
||||
const privateUsersToSendEmailsTo = privateUsers
|
||||
// Get all users that haven't unsubscribed from weekly emails
|
||||
.filter(
|
||||
(user) =>
|
||||
user.notificationPreferences.trending_markets.includes('email') &&
|
||||
!user.weeklyTrendingEmailSent
|
||||
)
|
||||
})
|
||||
.slice(0, 90) // Send the emails out in batches
|
||||
|
||||
// For testing different users on prod: (only send ian an email though)
|
||||
// const privateUsersToSendEmailsTo = filterDefined([
|
||||
// await getPrivateUser('AJwLWoo3xue32XIiAVrL5SyR1WB2'), // prod Ian
|
||||
// // isProd()
|
||||
// await getPrivateUser('FptiiMZZ6dQivihLI8MYFQ6ypSw1'), // prod Mik
|
||||
// // : await getPrivateUser('6hHpzvRG0pMq8PNJs7RZj2qlZGn2'), // dev Ian
|
||||
// ])
|
||||
|
||||
log(
|
||||
'Sending weekly trending emails to',
|
||||
privateUsersToSendEmailsTo.length,
|
||||
|
@ -68,38 +83,358 @@ async function sendTrendingMarketsEmailsToAllUsers() {
|
|||
!contract.groupSlugs?.includes('manifold-features') &&
|
||||
!contract.groupSlugs?.includes('manifold-6748e065087e')
|
||||
)
|
||||
.slice(0, 20)
|
||||
log(
|
||||
`Found ${trendingContracts.length} trending contracts:\n`,
|
||||
trendingContracts.map((c) => c.question).join('\n ')
|
||||
.slice(0, 50)
|
||||
|
||||
const uniqueTrendingContracts = removeSimilarQuestions(
|
||||
trendingContracts,
|
||||
trendingContracts,
|
||||
true
|
||||
).slice(0, 20)
|
||||
|
||||
await Promise.all(
|
||||
privateUsersToSendEmailsTo.map(async (privateUser) => {
|
||||
if (!privateUser.email) {
|
||||
log(`No email for ${privateUser.username}`)
|
||||
return
|
||||
}
|
||||
|
||||
const unbetOnFollowedMarkets = await getUserUnBetOnFollowsMarkets(
|
||||
privateUser.id
|
||||
)
|
||||
const unBetOnGroupMarkets = await getUserUnBetOnGroupsMarkets(
|
||||
privateUser.id,
|
||||
unbetOnFollowedMarkets
|
||||
)
|
||||
const similarBettorsMarkets = await getSimilarBettorsMarkets(
|
||||
privateUser.id,
|
||||
unBetOnGroupMarkets
|
||||
)
|
||||
|
||||
const marketsAvailableToSend = uniqBy(
|
||||
[
|
||||
...chooseRandomSubset(unbetOnFollowedMarkets, 2),
|
||||
// // Most people will belong to groups but may not follow other users,
|
||||
// so choose more from the other subsets if the followed markets is sparse
|
||||
...chooseRandomSubset(
|
||||
unBetOnGroupMarkets,
|
||||
unbetOnFollowedMarkets.length < 2 ? 3 : 2
|
||||
),
|
||||
...chooseRandomSubset(
|
||||
similarBettorsMarkets,
|
||||
unbetOnFollowedMarkets.length < 2 ? 3 : 2
|
||||
),
|
||||
],
|
||||
(contract) => contract.id
|
||||
)
|
||||
// // at least send them trending contracts if nothing else
|
||||
if (marketsAvailableToSend.length < numContractsToSend) {
|
||||
const trendingMarketsToSend =
|
||||
numContractsToSend - marketsAvailableToSend.length
|
||||
log(
|
||||
`not enough personalized markets, sending ${trendingMarketsToSend} trending`
|
||||
)
|
||||
marketsAvailableToSend.push(
|
||||
...removeSimilarQuestions(
|
||||
uniqueTrendingContracts,
|
||||
marketsAvailableToSend,
|
||||
false
|
||||
)
|
||||
.filter(
|
||||
(contract) => !contract.uniqueBettorIds?.includes(privateUser.id)
|
||||
)
|
||||
.slice(0, trendingMarketsToSend)
|
||||
)
|
||||
}
|
||||
|
||||
if (marketsAvailableToSend.length < numContractsToSend) {
|
||||
log(
|
||||
'not enough new, unbet-on contracts to send to user',
|
||||
privateUser.id
|
||||
)
|
||||
await firestore.collection('private-users').doc(privateUser.id).update({
|
||||
weeklyTrendingEmailSent: true,
|
||||
})
|
||||
return
|
||||
}
|
||||
// choose random subset of contracts to send to user
|
||||
const contractsToSend = chooseRandomSubset(
|
||||
marketsAvailableToSend,
|
||||
numContractsToSend
|
||||
)
|
||||
|
||||
const user = await getUser(privateUser.id)
|
||||
if (!user) return
|
||||
|
||||
log(
|
||||
'sending contracts:',
|
||||
contractsToSend.map((c) => c.question + ' ' + c.popularityScore)
|
||||
)
|
||||
// if they don't have enough markets, find user bets and get the other bettor ids who most overlap on those markets, then do the same thing as above for them
|
||||
await sendInterestingMarketsEmail(user, privateUser, contractsToSend)
|
||||
await firestore.collection('private-users').doc(user.id).update({
|
||||
weeklyTrendingEmailSent: true,
|
||||
})
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const MINIMUM_POPULARITY_SCORE = 10
|
||||
|
||||
const getUserUnBetOnFollowsMarkets = async (userId: string) => {
|
||||
const follows = await getValues<Follow>(
|
||||
firestore.collection('users').doc(userId).collection('follows')
|
||||
)
|
||||
|
||||
for (const privateUser of privateUsersToSendEmailsTo) {
|
||||
if (!privateUser.email) {
|
||||
log(`No email for ${privateUser.username}`)
|
||||
continue
|
||||
}
|
||||
const contractsAvailableToSend = trendingContracts.filter((contract) => {
|
||||
return !contract.uniqueBettorIds?.includes(privateUser.id)
|
||||
const unBetOnContractsFromFollows = await Promise.all(
|
||||
follows.map(async (follow) => {
|
||||
const unresolvedContracts = await getValues<Contract>(
|
||||
firestore
|
||||
.collection('contracts')
|
||||
.where('isResolved', '==', false)
|
||||
.where('visibility', '==', 'public')
|
||||
.where('creatorId', '==', follow.userId)
|
||||
// can't use multiple inequality (/orderBy) operators on different fields,
|
||||
// so have to filter for closed contracts separately
|
||||
.orderBy('popularityScore', 'desc')
|
||||
.limit(50)
|
||||
)
|
||||
// filter out contracts that have close times less than 6 hours from now
|
||||
const openContracts = unresolvedContracts.filter(
|
||||
(contract) => (contract?.closeTime ?? 0) > Date.now() + 6 * HOUR_MS
|
||||
)
|
||||
|
||||
return openContracts.filter(
|
||||
(contract) => !contract.uniqueBettorIds?.includes(userId)
|
||||
)
|
||||
})
|
||||
if (contractsAvailableToSend.length < numContractsToSend) {
|
||||
log('not enough new, unbet-on contracts to send to user', privateUser.id)
|
||||
continue
|
||||
}
|
||||
// choose random subset of contracts to send to user
|
||||
const contractsToSend = chooseRandomSubset(
|
||||
contractsAvailableToSend,
|
||||
numContractsToSend
|
||||
)
|
||||
|
||||
const sortedMarkets = uniqBy(
|
||||
unBetOnContractsFromFollows.flat(),
|
||||
(contract) => contract.id
|
||||
)
|
||||
.filter(
|
||||
(contract) =>
|
||||
contract.popularityScore !== undefined &&
|
||||
contract.popularityScore > MINIMUM_POPULARITY_SCORE
|
||||
)
|
||||
.sort((a, b) => (b.popularityScore ?? 0) - (a.popularityScore ?? 0))
|
||||
|
||||
const user = await getUser(privateUser.id)
|
||||
if (!user) continue
|
||||
const uniqueSortedMarkets = removeSimilarQuestions(
|
||||
sortedMarkets,
|
||||
sortedMarkets,
|
||||
true
|
||||
)
|
||||
|
||||
await sendInterestingMarketsEmail(user, privateUser, contractsToSend)
|
||||
await firestore.collection('private-users').doc(user.id).update({
|
||||
weeklyTrendingEmailSent: true,
|
||||
const topSortedMarkets = uniqueSortedMarkets.slice(0, 10)
|
||||
// log(
|
||||
// 'top 10 sorted markets by followed users',
|
||||
// topSortedMarkets.map((c) => c.question + ' ' + c.popularityScore)
|
||||
// )
|
||||
return topSortedMarkets
|
||||
}
|
||||
|
||||
const getUserUnBetOnGroupsMarkets = async (
|
||||
userId: string,
|
||||
differentThanTheseContracts: Contract[]
|
||||
) => {
|
||||
const snap = await firestore
|
||||
.collectionGroup('groupMembers')
|
||||
.where('userId', '==', userId)
|
||||
.get()
|
||||
|
||||
const groupIds = filterDefined(
|
||||
snap.docs.map((doc) => doc.ref.parent.parent?.id)
|
||||
)
|
||||
const groups = filterDefined(
|
||||
await Promise.all(groupIds.map(async (groupId) => await getGroup(groupId)))
|
||||
)
|
||||
if (groups.length === 0) return []
|
||||
|
||||
const unBetOnContractsFromGroups = await Promise.all(
|
||||
groups.map(async (group) => {
|
||||
const unresolvedContracts = await getValues<Contract>(
|
||||
firestore
|
||||
.collection('contracts')
|
||||
.where('isResolved', '==', false)
|
||||
.where('visibility', '==', 'public')
|
||||
.where('groupSlugs', 'array-contains', group.slug)
|
||||
// can't use multiple inequality (/orderBy) operators on different fields,
|
||||
// so have to filter for closed contracts separately
|
||||
.orderBy('popularityScore', 'desc')
|
||||
.limit(50)
|
||||
)
|
||||
// filter out contracts that have close times less than 6 hours from now
|
||||
const openContracts = unresolvedContracts.filter(
|
||||
(contract) => (contract?.closeTime ?? 0) > Date.now() + 6 * HOUR_MS
|
||||
)
|
||||
|
||||
return openContracts.filter(
|
||||
(contract) => !contract.uniqueBettorIds?.includes(userId)
|
||||
)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
const sortedMarkets = uniqBy(
|
||||
unBetOnContractsFromGroups.flat(),
|
||||
(contract) => contract.id
|
||||
)
|
||||
.filter(
|
||||
(contract) =>
|
||||
contract.popularityScore !== undefined &&
|
||||
contract.popularityScore > MINIMUM_POPULARITY_SCORE
|
||||
)
|
||||
.sort((a, b) => (b.popularityScore ?? 0) - (a.popularityScore ?? 0))
|
||||
|
||||
const uniqueSortedMarkets = removeSimilarQuestions(
|
||||
sortedMarkets,
|
||||
sortedMarkets,
|
||||
true
|
||||
)
|
||||
const topSortedMarkets = removeSimilarQuestions(
|
||||
uniqueSortedMarkets,
|
||||
differentThanTheseContracts,
|
||||
false
|
||||
).slice(0, 10)
|
||||
|
||||
// log(
|
||||
// 'top 10 sorted group markets',
|
||||
// topSortedMarkets.map((c) => c.question + ' ' + c.popularityScore)
|
||||
// )
|
||||
return topSortedMarkets
|
||||
}
|
||||
|
||||
// Gets markets followed by similar bettors and bet on by similar bettors
|
||||
const getSimilarBettorsMarkets = async (
|
||||
userId: string,
|
||||
differentThanTheseContracts: Contract[]
|
||||
) => {
|
||||
// get contracts with unique bettor ids with this user
|
||||
const contractsUserHasBetOn = await getValues<Contract>(
|
||||
firestore
|
||||
.collection('contracts')
|
||||
.where('uniqueBettorIds', 'array-contains', userId)
|
||||
)
|
||||
if (contractsUserHasBetOn.length === 0) return []
|
||||
// count the number of times each unique bettor id appears on those contracts
|
||||
const bettorIdsToCounts = countBy(
|
||||
contractsUserHasBetOn.map((contract) => contract.uniqueBettorIds).flat(),
|
||||
(bettorId) => bettorId
|
||||
)
|
||||
|
||||
// sort by number of times they appear with at least 2 appearances
|
||||
const sortedBettorIds = Object.entries(bettorIdsToCounts)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.filter((bettorId) => bettorId[1] > 2)
|
||||
.map((entry) => entry[0])
|
||||
.filter((bettorId) => bettorId !== userId)
|
||||
|
||||
// get the top 10 most similar bettors (excluding this user)
|
||||
const similarBettorIds = sortedBettorIds.slice(0, 10)
|
||||
if (similarBettorIds.length === 0) return []
|
||||
|
||||
// get contracts with unique bettor ids with this user
|
||||
const contractsSimilarBettorsHaveBetOn = uniqBy(
|
||||
(
|
||||
await getValues<Contract>(
|
||||
firestore
|
||||
.collection('contracts')
|
||||
.where(
|
||||
'uniqueBettorIds',
|
||||
'array-contains-any',
|
||||
similarBettorIds.slice(0, 10)
|
||||
)
|
||||
.orderBy('popularityScore', 'desc')
|
||||
.limit(200)
|
||||
)
|
||||
).filter(
|
||||
(contract) =>
|
||||
!contract.uniqueBettorIds?.includes(userId) &&
|
||||
(contract.popularityScore ?? 0) > MINIMUM_POPULARITY_SCORE
|
||||
),
|
||||
(contract) => contract.id
|
||||
)
|
||||
|
||||
// sort the contracts by how many times similar bettor ids are in their unique bettor ids array
|
||||
const sortedContractsInSimilarBettorsBets = contractsSimilarBettorsHaveBetOn
|
||||
.map((contract) => {
|
||||
const appearances = contract.uniqueBettorIds?.filter((bettorId) =>
|
||||
similarBettorIds.includes(bettorId)
|
||||
).length
|
||||
return [contract, appearances] as [Contract, number]
|
||||
})
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map((entry) => entry[0])
|
||||
|
||||
const uniqueSortedContractsInSimilarBettorsBets = removeSimilarQuestions(
|
||||
sortedContractsInSimilarBettorsBets,
|
||||
sortedContractsInSimilarBettorsBets,
|
||||
true
|
||||
)
|
||||
|
||||
const topMostSimilarContracts = removeSimilarQuestions(
|
||||
uniqueSortedContractsInSimilarBettorsBets,
|
||||
differentThanTheseContracts,
|
||||
false
|
||||
).slice(0, 10)
|
||||
|
||||
// log(
|
||||
// 'top 10 sorted contracts other similar bettors have bet on',
|
||||
// topMostSimilarContracts.map((c) => c.question)
|
||||
// )
|
||||
|
||||
return topMostSimilarContracts
|
||||
}
|
||||
|
||||
// search contract array by question and remove contracts with 3 matching words in the question
|
||||
const removeSimilarQuestions = (
|
||||
contractsToFilter: Contract[],
|
||||
byContracts: Contract[],
|
||||
allowExactSameContracts: boolean
|
||||
) => {
|
||||
// log(
|
||||
// 'contracts to filter by',
|
||||
// byContracts.map((c) => c.question + ' ' + c.popularityScore)
|
||||
// )
|
||||
let contractsToRemove: Contract[] = []
|
||||
byContracts.length > 0 &&
|
||||
byContracts.forEach((contract) => {
|
||||
const contractQuestion = stripNonAlphaChars(
|
||||
contract.question.toLowerCase()
|
||||
)
|
||||
const contractQuestionWords = uniq(contractQuestion.split(' ')).filter(
|
||||
(w) => !IGNORE_WORDS.includes(w)
|
||||
)
|
||||
contractsToRemove = contractsToRemove.concat(
|
||||
contractsToFilter.filter(
|
||||
// Remove contracts with more than 2 matching (uncommon) words and a lower popularity score
|
||||
(c2) => {
|
||||
const significantOverlap =
|
||||
// TODO: we should probably use a library for comparing strings/sentiments
|
||||
uniq(
|
||||
stripNonAlphaChars(c2.question.toLowerCase()).split(' ')
|
||||
).filter((word) => contractQuestionWords.includes(word)).length >
|
||||
2
|
||||
const lessPopular =
|
||||
(c2.popularityScore ?? 0) < (contract.popularityScore ?? 0)
|
||||
return (
|
||||
(significantOverlap && lessPopular) ||
|
||||
(allowExactSameContracts ? false : c2.id === contract.id)
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
})
|
||||
// log(
|
||||
// 'contracts to filter out',
|
||||
// contractsToRemove.map((c) => c.question)
|
||||
// )
|
||||
|
||||
const returnContracts = contractsToFilter.filter(
|
||||
(cf) => !contractsToRemove.map((c) => c.id).includes(cf.id)
|
||||
)
|
||||
|
||||
return returnContracts
|
||||
}
|
||||
|
||||
const fiveMinutes = 5 * 60 * 1000
|
||||
|
@ -110,3 +445,40 @@ function chooseRandomSubset(contracts: Contract[], count: number) {
|
|||
shuffle(contracts, rng)
|
||||
return contracts.slice(0, count)
|
||||
}
|
||||
|
||||
function stripNonAlphaChars(str: string) {
|
||||
return str.replace(/[^\w\s']|_/g, '').replace(/\s+/g, ' ')
|
||||
}
|
||||
|
||||
const IGNORE_WORDS = [
|
||||
'the',
|
||||
'a',
|
||||
'an',
|
||||
'and',
|
||||
'or',
|
||||
'of',
|
||||
'to',
|
||||
'in',
|
||||
'on',
|
||||
'will',
|
||||
'be',
|
||||
'is',
|
||||
'are',
|
||||
'for',
|
||||
'by',
|
||||
'at',
|
||||
'from',
|
||||
'what',
|
||||
'when',
|
||||
'which',
|
||||
'that',
|
||||
'it',
|
||||
'as',
|
||||
'if',
|
||||
'then',
|
||||
'than',
|
||||
'but',
|
||||
'have',
|
||||
'has',
|
||||
'had',
|
||||
]
|
||||
|
|
280
functions/src/weekly-portfolio-emails.ts
Normal file
280
functions/src/weekly-portfolio-emails.ts
Normal file
|
@ -0,0 +1,280 @@
|
|||
import * as functions from 'firebase-functions'
|
||||
import * as admin from 'firebase-admin'
|
||||
|
||||
import { Contract, CPMMContract } from '../../common/contract'
|
||||
import {
|
||||
getAllPrivateUsers,
|
||||
getPrivateUser,
|
||||
getUser,
|
||||
getValue,
|
||||
getValues,
|
||||
isProd,
|
||||
log,
|
||||
} from './utils'
|
||||
import { filterDefined } from '../../common/util/array'
|
||||
import { DAY_MS } from '../../common/util/time'
|
||||
import { partition, sortBy, sum, uniq } from 'lodash'
|
||||
import { Bet } from '../../common/bet'
|
||||
import { computeInvestmentValueCustomProb } from '../../common/calculate-metrics'
|
||||
import { sendWeeklyPortfolioUpdateEmail } from './emails'
|
||||
import { contractUrl } from './utils'
|
||||
import { Txn } from '../../common/txn'
|
||||
import { formatMoney } from '../../common/util/format'
|
||||
import { getContractBetMetrics } from '../../common/calculate'
|
||||
|
||||
export const weeklyPortfolioUpdateEmails = functions
|
||||
.runWith({ secrets: ['MAILGUN_KEY'], memory: '4GB' })
|
||||
// every minute on Friday for an hour at 12pm PT (UTC -07:00)
|
||||
.pubsub.schedule('* 19 * * 5')
|
||||
.timeZone('Etc/UTC')
|
||||
.onRun(async () => {
|
||||
await sendPortfolioUpdateEmailsToAllUsers()
|
||||
})
|
||||
|
||||
const firestore = admin.firestore()
|
||||
|
||||
export async function sendPortfolioUpdateEmailsToAllUsers() {
|
||||
const privateUsers = isProd()
|
||||
? // ian & stephen's ids
|
||||
// filterDefined([
|
||||
// await getPrivateUser('AJwLWoo3xue32XIiAVrL5SyR1WB2'),
|
||||
// await getPrivateUser('tlmGNz9kjXc2EteizMORes4qvWl2'),
|
||||
// ])
|
||||
await getAllPrivateUsers()
|
||||
: filterDefined([await getPrivateUser('6hHpzvRG0pMq8PNJs7RZj2qlZGn2')])
|
||||
// get all users that haven't unsubscribed from weekly emails
|
||||
const privateUsersToSendEmailsTo = privateUsers
|
||||
.filter((user) => {
|
||||
return isProd()
|
||||
? user.notificationPreferences.profit_loss_updates.includes('email') &&
|
||||
!user.weeklyPortfolioUpdateEmailSent
|
||||
: user.notificationPreferences.profit_loss_updates.includes('email')
|
||||
})
|
||||
// Send emails in batches
|
||||
.slice(0, 200)
|
||||
log(
|
||||
'Sending weekly portfolio emails to',
|
||||
privateUsersToSendEmailsTo.length,
|
||||
'users'
|
||||
)
|
||||
|
||||
const usersBets: { [userId: string]: Bet[] } = {}
|
||||
// get all bets made by each user
|
||||
await Promise.all(
|
||||
privateUsersToSendEmailsTo.map(async (user) => {
|
||||
return getValues<Bet>(
|
||||
firestore.collectionGroup('bets').where('userId', '==', user.id)
|
||||
).then((bets) => {
|
||||
usersBets[user.id] = bets
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
const usersToContractsCreated: { [userId: string]: Contract[] } = {}
|
||||
// Get all contracts created by each user
|
||||
await Promise.all(
|
||||
privateUsersToSendEmailsTo.map(async (user) => {
|
||||
return getValues<Contract>(
|
||||
firestore
|
||||
.collection('contracts')
|
||||
.where('creatorId', '==', user.id)
|
||||
.where('createdTime', '>', Date.now() - 7 * DAY_MS)
|
||||
).then((contracts) => {
|
||||
usersToContractsCreated[user.id] = contracts
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
// Get all txns the users received over the past week
|
||||
const usersToTxnsReceived: { [userId: string]: Txn[] } = {}
|
||||
await Promise.all(
|
||||
privateUsersToSendEmailsTo.map(async (user) => {
|
||||
return getValues<Txn>(
|
||||
firestore
|
||||
.collection(`txns`)
|
||||
.where('toId', '==', user.id)
|
||||
.where('createdTime', '>', Date.now() - 7 * DAY_MS)
|
||||
).then((txn) => {
|
||||
usersToTxnsReceived[user.id] = txn
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
// Get a flat map of all the bets that users made to get the contracts they bet on
|
||||
const contractsUsersBetOn = filterDefined(
|
||||
await Promise.all(
|
||||
uniq(
|
||||
Object.values(usersBets).flatMap((bets) =>
|
||||
bets.map((bet) => bet.contractId)
|
||||
)
|
||||
).map((contractId) =>
|
||||
getValue<Contract>(firestore.collection('contracts').doc(contractId))
|
||||
)
|
||||
)
|
||||
)
|
||||
await Promise.all(
|
||||
privateUsersToSendEmailsTo.map(async (privateUser) => {
|
||||
const user = await getUser(privateUser.id)
|
||||
// Don't send to a user unless they're over 5 days old
|
||||
if (!user || user.createdTime > Date.now() - 5 * DAY_MS)
|
||||
return await setEmailFlagAsSent(privateUser.id)
|
||||
const userBets = usersBets[privateUser.id] as Bet[]
|
||||
const contractsUserBetOn = contractsUsersBetOn.filter((contract) =>
|
||||
userBets.some((bet) => bet.contractId === contract.id)
|
||||
)
|
||||
const contractsBetOnInLastWeek = uniq(
|
||||
userBets
|
||||
.filter((bet) => bet.createdTime > Date.now() - 7 * DAY_MS)
|
||||
.map((bet) => bet.contractId)
|
||||
)
|
||||
const totalTips = sum(
|
||||
usersToTxnsReceived[privateUser.id]
|
||||
.filter((txn) => txn.category === 'TIP')
|
||||
.map((txn) => txn.amount)
|
||||
)
|
||||
const greenBg = 'rgba(0,160,0,0.2)'
|
||||
const redBg = 'rgba(160,0,0,0.2)'
|
||||
const clearBg = 'rgba(255,255,255,0)'
|
||||
const roundedProfit =
|
||||
Math.round(user.profitCached.weekly) === 0
|
||||
? 0
|
||||
: Math.floor(user.profitCached.weekly)
|
||||
const performanceData = {
|
||||
profit: formatMoney(user.profitCached.weekly),
|
||||
profit_style: `background-color: ${
|
||||
roundedProfit > 0 ? greenBg : roundedProfit === 0 ? clearBg : redBg
|
||||
}`,
|
||||
markets_created:
|
||||
usersToContractsCreated[privateUser.id].length.toString(),
|
||||
tips_received: formatMoney(totalTips),
|
||||
unique_bettors: usersToTxnsReceived[privateUser.id]
|
||||
.filter((txn) => txn.category === 'UNIQUE_BETTOR_BONUS')
|
||||
.length.toString(),
|
||||
markets_traded: contractsBetOnInLastWeek.length.toString(),
|
||||
prediction_streak:
|
||||
(user.currentBettingStreak?.toString() ?? '0') + ' days',
|
||||
// More options: bonuses, tips given,
|
||||
} as OverallPerformanceData
|
||||
|
||||
const investmentValueDifferences = sortBy(
|
||||
filterDefined(
|
||||
contractsUserBetOn.map((contract) => {
|
||||
const cpmmContract = contract as CPMMContract
|
||||
if (cpmmContract === undefined || cpmmContract.prob === undefined)
|
||||
return
|
||||
const bets = userBets.filter(
|
||||
(bet) => bet.contractId === contract.id
|
||||
)
|
||||
const previousBets = bets.filter(
|
||||
(b) => b.createdTime < Date.now() - 7 * DAY_MS
|
||||
)
|
||||
|
||||
const betsInLastWeek = bets.filter(
|
||||
(b) => b.createdTime >= Date.now() - 7 * DAY_MS
|
||||
)
|
||||
|
||||
const marketProbabilityAWeekAgo =
|
||||
cpmmContract.prob - cpmmContract.probChanges.week
|
||||
const currentMarketProbability = cpmmContract.resolutionProbability
|
||||
? cpmmContract.resolutionProbability
|
||||
: cpmmContract.prob
|
||||
|
||||
// TODO: returns 0 for resolved markets - doesn't include them
|
||||
const betsMadeAWeekAgoValue = computeInvestmentValueCustomProb(
|
||||
previousBets,
|
||||
contract,
|
||||
marketProbabilityAWeekAgo
|
||||
)
|
||||
const currentBetsMadeAWeekAgoValue =
|
||||
computeInvestmentValueCustomProb(
|
||||
previousBets,
|
||||
contract,
|
||||
currentMarketProbability
|
||||
)
|
||||
const betsMadeInLastWeekProfit = getContractBetMetrics(
|
||||
contract,
|
||||
betsInLastWeek
|
||||
).profit
|
||||
const profit =
|
||||
betsMadeInLastWeekProfit +
|
||||
(currentBetsMadeAWeekAgoValue - betsMadeAWeekAgoValue)
|
||||
return {
|
||||
currentValue: currentBetsMadeAWeekAgoValue,
|
||||
pastValue: betsMadeAWeekAgoValue,
|
||||
profit,
|
||||
contractSlug: contract.slug,
|
||||
marketProbAWeekAgo: marketProbabilityAWeekAgo,
|
||||
questionTitle: contract.question,
|
||||
questionUrl: contractUrl(contract),
|
||||
questionProb: cpmmContract.resolution
|
||||
? cpmmContract.resolution
|
||||
: Math.round(cpmmContract.prob * 100) + '%',
|
||||
profitStyle: `color: ${
|
||||
profit > 0 ? 'rgba(0,160,0,1)' : '#a80000'
|
||||
};`,
|
||||
} as PerContractInvestmentsData
|
||||
})
|
||||
),
|
||||
(differences) => Math.abs(differences.profit)
|
||||
).reverse()
|
||||
|
||||
const [winningInvestments, losingInvestments] = partition(
|
||||
investmentValueDifferences.filter(
|
||||
(diff) => diff.pastValue > 0.01 && Math.abs(diff.profit) > 1
|
||||
),
|
||||
(investmentsData: PerContractInvestmentsData) => {
|
||||
return investmentsData.profit > 0
|
||||
}
|
||||
)
|
||||
// pick 3 winning investments and 3 losing investments
|
||||
const topInvestments = winningInvestments.slice(0, 2)
|
||||
const worstInvestments = losingInvestments.slice(0, 2)
|
||||
// if no bets in the last week ANd no market movers AND no markets created, don't send email
|
||||
if (
|
||||
contractsBetOnInLastWeek.length === 0 &&
|
||||
topInvestments.length === 0 &&
|
||||
worstInvestments.length === 0 &&
|
||||
usersToContractsCreated[privateUser.id].length === 0
|
||||
) {
|
||||
log(
|
||||
`No bets in last week, no market movers, no markets created. Not sending an email to ${privateUser.email} .`
|
||||
)
|
||||
return await setEmailFlagAsSent(privateUser.id)
|
||||
}
|
||||
// Set the flag beforehand just to be safe
|
||||
await setEmailFlagAsSent(privateUser.id)
|
||||
await sendWeeklyPortfolioUpdateEmail(
|
||||
user,
|
||||
privateUser,
|
||||
topInvestments.concat(worstInvestments) as PerContractInvestmentsData[],
|
||||
performanceData
|
||||
)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
async function setEmailFlagAsSent(privateUserId: string) {
|
||||
await firestore.collection('private-users').doc(privateUserId).update({
|
||||
weeklyPortfolioUpdateEmailSent: true,
|
||||
})
|
||||
}
|
||||
|
||||
export type PerContractInvestmentsData = {
|
||||
questionTitle: string
|
||||
questionUrl: string
|
||||
questionProb: string
|
||||
profitStyle: string
|
||||
currentValue: number
|
||||
pastValue: number
|
||||
profit: number
|
||||
}
|
||||
|
||||
export type OverallPerformanceData = {
|
||||
profit: string
|
||||
prediction_streak: string
|
||||
markets_traded: string
|
||||
profit_style: string
|
||||
tips_received: string
|
||||
markets_created: string
|
||||
unique_bettors: string
|
||||
}
|
|
@ -1,121 +0,0 @@
|
|||
import * as admin from 'firebase-admin'
|
||||
import { z } from 'zod'
|
||||
|
||||
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, newEndpoint, validate } from './api'
|
||||
import { redeemShares } from './redeem-shares'
|
||||
|
||||
const bodySchema = z.object({
|
||||
contractId: z.string(),
|
||||
})
|
||||
|
||||
export const withdrawliquidity = newEndpoint({}, async (req, auth) => {
|
||||
const { contractId } = validate(bodySchema, req.body)
|
||||
|
||||
return await firestore
|
||||
.runTransaction(async (trans) => {
|
||||
const lpDoc = firestore.doc(`users/${auth.uid}`)
|
||||
const lpSnap = await trans.get(lpDoc)
|
||||
if (!lpSnap.exists) throw new APIError(400, 'User not found.')
|
||||
const lp = lpSnap.data() as User
|
||||
|
||||
const contractDoc = firestore.doc(`contracts/${contractId}`)
|
||||
const contractSnap = await trans.get(contractDoc)
|
||||
if (!contractSnap.exists) throw new APIError(400, 'Contract not found.')
|
||||
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(
|
||||
auth.uid,
|
||||
contract,
|
||||
liquidities,
|
||||
true
|
||||
)
|
||||
|
||||
// zero all added amounts for now
|
||||
// can add support for partial withdrawals in the future
|
||||
liquiditiesSnap.docs
|
||||
.filter(
|
||||
(_, i) => !liquidities[i].isAnte && liquidities[i].userId === auth.uid
|
||||
)
|
||||
.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: auth.uid,
|
||||
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(auth.uid, contractId)
|
||||
console.log('userid', auth.uid, 'withdraws', result)
|
||||
return result
|
||||
})
|
||||
})
|
||||
|
||||
const firestore = admin.firestore()
|
|
@ -14,11 +14,6 @@ export function getHtml(parsedReq: ParsedRequest) {
|
|||
numericValue,
|
||||
resolution,
|
||||
} = parsedReq
|
||||
const MAX_QUESTION_CHARS = 100
|
||||
const truncatedQuestion =
|
||||
question.length > MAX_QUESTION_CHARS
|
||||
? question.slice(0, MAX_QUESTION_CHARS) + '...'
|
||||
: question
|
||||
const hideAvatar = creatorAvatarUrl ? '' : 'hidden'
|
||||
|
||||
let resolutionColor = 'text-primary'
|
||||
|
@ -69,7 +64,7 @@ export function getHtml(parsedReq: ParsedRequest) {
|
|||
<meta charset="utf-8">
|
||||
<title>Generated Image</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://cdn.tailwindcss.com?plugins=line-clamp"></script>
|
||||
</head>
|
||||
<style>
|
||||
${getTemplateCss(theme, fontSize)}
|
||||
|
@ -109,8 +104,8 @@ export function getHtml(parsedReq: ParsedRequest) {
|
|||
</div>
|
||||
|
||||
<div class="flex flex-row justify-between gap-12 pt-36">
|
||||
<div class="text-indigo-700 text-6xl leading-tight">
|
||||
${truncatedQuestion}
|
||||
<div class="text-indigo-700 text-6xl leading-tight line-clamp-4">
|
||||
${question}
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
${
|
||||
|
@ -127,7 +122,7 @@ export function getHtml(parsedReq: ParsedRequest) {
|
|||
|
||||
<!-- Metadata -->
|
||||
<div class="absolute bottom-16">
|
||||
<div class="text-gray-500 text-3xl max-w-[80vw]">
|
||||
<div class="text-gray-500 text-3xl max-w-[80vw] line-clamp-2">
|
||||
${metadata}
|
||||
</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