Compare commits

...

238 Commits

Author SHA1 Message Date
Phil
c762869b83
Migrate prod bot (#1052)
* Updated dev config to point to new Twitch dev bot hosting location.

* Updated prod config to point to new Twitch bot hosting location.
2022-10-14 16:41:06 +01:00
FRC
aaa09f49c0
New component to add arbitrary (but static) react embeds to posts programatically + midterms component (#1051) 2022-10-14 16:21:54 +01:00
FRC
4d214c01b4
Tipping in posts (#1045)
* Tipping in posts

* Rm itemType field
2022-10-14 13:05:07 +01:00
ingawei
0a70652667
fixed tip button bug (#1049)
* fixed tip button bug
2022-10-14 02:20:32 -05:00
mqp
b4162a0896 Auto-prettification 2022-10-14 06:49:23 +00:00
ingawei
2ecece02c3 Auto-remove unused imports 2022-10-14 06:48:40 +00:00
ingawei
4359ad0530
added cancelling tipping and lil coin (#1047)
* added cancelling tipping and lil coin
* forced timeout to fix weird toast bug
2022-10-14 01:47:51 -05:00
ingawei
3f8988bf27
Inga/colorful answer comments (#1040)
* changing answers in comments to match colors, no longer indenting those responses
2022-10-14 00:07:54 -05:00
Austin Chen
41a46aad9b Update stability-client to fix /dream 2022-10-13 21:44:22 -07:00
James Grugett
4097082c75
Dynamically choose outcome in Position tooltip (#1048) 2022-10-13 23:29:57 -05:00
mantikoros
58cd0e57bd track tournaments 2022-10-13 21:22:32 -05:00
Austin Chen
3a63503161 Fix @/% suggestion regression
See https://github.com/ueberdosis/tiptap/pull/3239
2022-10-13 19:06:03 -07:00
mantikoros
abd06f272b contract card: show traders instead of volume 2022-10-13 21:05:21 -05:00
Austin Chen
ab1c3020da Warn users about Dream breakage 2022-10-13 18:45:09 -07:00
Sinclair Chen
c044460a91
Don't break build on unused import (#1046)
* Fix lint warnings

* Let vercel build when unused import
2022-10-13 18:16:22 -07:00
mantikoros
7a6725ee77 contract card: less busy design 2022-10-13 19:56:16 -05:00
Austin Chen
823d1ddd4c Update stability-client 2022-10-13 17:01:05 -07:00
Austin Chen
7d490e0de1 Expand image on hover 2022-10-13 16:23:25 -07:00
Sinclair Chen
96d2255cb1 fix find and replace mistake 2022-10-13 15:15:45 -07:00
Sinclair Chen
4a139c5cc2 Fix limit prob styling 2022-10-13 14:55:48 -07:00
James Grugett
47eb8abed0 Don't include loans in email payout message 2022-10-13 16:05:40 -05:00
Sinclair Chen
903fcc83b3 Fix form-control styling 2022-10-13 13:20:04 -07:00
Austin Chen
0615bb2d4b
List ManifoldDream as a bot 2022-10-13 13:12:22 -07:00
mantikoros
3508c94634 tip button: fix tip color 2022-10-13 14:33:41 -05:00
mantikoros
29c0dfe3fe tip button: remove fill color 2022-10-13 14:27:47 -05:00
mantikoros
18a3b66164 dream label 2022-10-13 14:16:30 -05:00
James Grugett
9e4f41253f Filter out resolved markets from daily movers 2022-10-13 13:49:48 -05:00
Sinclair Chen
546b0231e7
Die Daisy (#1042)
* un-daisy labels

* un-daisy inputs

* un-daisy input groups

* fixup input

* un-daisy selects

* un-daisy slider

* Uninstall daisy. Migrate colors

* un-daisy tables

* fix input error styling

* lint
2022-10-13 11:23:42 -07:00
Phil
8bb44593f3
Updated dev config to point to new Twitch dev bot hosting location. (#1044) 2022-10-13 17:19:50 +01:00
Pico2x
2c2bc61788 Fix bug with parsing in abritrary react components 2022-10-13 16:56:35 +01:00
Pico2x
34c9dbb3e7 Revert "Revert "New implementation of market card embeddings (#1025)""
This reverts commit d6525bae9f.
2022-10-13 16:25:15 +01:00
Ian Philips
d6525bae9f Revert "New implementation of market card embeddings (#1025)"
This reverts commit 3fc53112b9.
2022-10-13 09:19:28 -06:00
Ian Philips
da32a756a8 Need not follow contract for tag notification 2022-10-13 08:41:33 -06:00
Ian Philips
fa476c78dd Handle numeric outcomes in movers 2022-10-13 08:18:16 -06:00
Ian Philips
e7ba7e715f default cursor on open answer 2022-10-13 08:04:01 -06:00
Ian Philips
9bf82c6082 Match colors on portfolio 2022-10-13 08:00:04 -06:00
Ian Philips
3e1876f0dc Add flaggedByUsernames to firestore rules 2022-10-13 07:58:14 -06:00
ingawei
5ba4a9dce7
Inga/tip button (#1043)
* added tip jar
* made market actions/comments and manalink buttons IconButtons
2022-10-13 01:53:26 -05:00
James Grugett
4e5b78f4ee Use in-memory store for home featured section data 2022-10-12 21:15:40 -05:00
James Grugett
bc6fab399e Make text grayer 2022-10-12 20:51:48 -05:00
James Grugett
c2d112e516 Position => shares 2022-10-12 20:51:20 -05:00
ingawei
3cbe8ad8bb
Inga/comment bounty fix (#1041)
* fixed bounty button in comments
2022-10-12 20:34:07 -05:00
ingawei
6226291e02 made comments smaller 2022-10-12 17:53:37 -07:00
Austin Chen
fa4dba4da3 Document the new /comment endpoint 2022-10-12 17:26:00 -07:00
Austin Chen
9eff69be75
Add /createcomment API endpoint (#946)
* /dream api: Upload StableDiffusion image to Firestore

* Minor tweaks

* Set content type on uploaded image

This makes it so the image doesn't auto-download when opened in a new tab

* Allow users to dream directly from within Manifold

* Remove unused import

* Implement a /comment endpoint which supports html and markdown

* Upgrade @tiptap/core to latest

* Update all tiptap deps to beta.199

* Add @tiptap/suggestion

* Import @tiptap/html in the right place

* ... add deps everywhere

So I have no idea how common deps work apparently

* Add tiptap/suggestion too

* Clean up dream

* More cleanups

* Rework /comment endpoint

* Move API to /comment

* Change imports in case that matters

* Add a couple todos

* Dynamically import micromark

* Parallellize gsutil with -m option

* Adding comments via api working, editor.tsx erroring out

* Unused import

* Remove disabled state from useTextEditor

Co-authored-by: Ian Philips <iansphilips@gmail.com>
2022-10-12 17:25:17 -07:00
James Grugett
789bec2a4f Expand replies by default (quick fix for comment links not working) 2022-10-12 17:49:04 -05:00
James Grugett
18042cd4d1 Return listener 2022-10-12 17:39:13 -05:00
mantikoros
04a126707b fix welcome dialog page freezing bug 2022-10-12 17:33:18 -05:00
mantikoros
7a412fdb0d add key props 2022-10-12 17:33:18 -05:00
James Grugett
e2dc4c6b8f Resolve markets again script 2022-10-12 17:30:21 -05:00
mantikoros
204d302d87 portfolio copy: "Open" => "Active" 2022-10-12 16:57:21 -05:00
James Grugett
ae39c1175b
Better resolve market payouts (#1038)
* Check payout preconditions first. Try to pay out market in 1 transaction.

* Format

* toBatch => lodash's chunk
2022-10-12 16:21:37 -05:00
Marshall Polaris
c44f223064
Fix some hydration issues (#1033)
* Extract `useIsClient` hook

* Fix a bunch of hydration bugs relevant to the contract page
2022-10-12 14:07:07 -07:00
mantikoros
aa717a767d backfill subsidyPool 2022-10-12 16:05:15 -05:00
Sinclair Chen
d9f57b7daa
Remove all daisy buttons (#1036)
* Tweak limit order UI and fix button

* Style all follow/unfollow buttons blue

also get rid of highlight-blue button

* remove all other uses of 'btn'

* Style group follow button like user follow

* lint and format
2022-10-12 12:27:17 -07:00
mantikoros
93ceaa52c4 payouts: fix subsidyPool undefined bug 2022-10-12 14:24:54 -05:00
James Grugett
de76557326 Show shares as contract card position. Fix bug on outcome 2022-10-12 13:55:58 -05:00
James Grugett
da1fcb646f Fix infinite re-render on home page 2022-10-12 13:42:54 -05:00
ingawei
1d618ba337
Inga/fr remove double comments (#1019)
incorporating answer comments into general comments section
2022-10-12 13:05:58 -05:00
Pico2x
2cda3a4d4f Don't show spinner if pinned items is undefined or zero 2022-10-12 18:12:32 +01:00
Sinclair Chen
e44fc8ae13
Simplify rich text to string parsing logic (#1022)
* Simplify rich text to string parsing logic

* lint
2022-10-12 10:09:59 -07:00
James Grugett
e6a90e18e4 Add more padding and improve layout of post card 2022-10-12 11:59:29 -05:00
Austin Chen
cee8caa3e8
Generate images from StableDiffusion (#1035)
* Generate images from StableDiffusion

* Update yarn.lock

* Log an error, remove extra comment

* Code cleanup

* Note about the API
2022-10-12 09:53:04 -07:00
Pico2x
b49264ddfa Show featured to all users 2022-10-12 17:44:43 +01:00
Pico2x
12ed569ff6 Update post card subtitle color 2022-10-12 17:33:02 +01:00
Pico2x
00acc262a0 Update post-cards to show profile pic correctly 2022-10-12 17:31:00 +01:00
Pico2x
fd7d4eb5e2 Make post-cards consistent with contract cards 2022-10-12 17:22:58 +01:00
Pico2x
8ae1166c49 Posts now have denormalized creator username and name 2022-10-12 16:42:28 +01:00
FRC
84e2b63c49
Entire homepage now loads simultaneously including featured (#1034) 2022-10-12 16:02:57 +01:00
FRCassarino
f19ef83ac2 Auto-remove unused imports 2022-10-12 14:38:59 +00:00
Pico2x
0c11f3b450 Fix is admin in featured 2022-10-12 15:38:06 +01:00
Pico2x
de9ffa2b52 Fix bug in which Featured section shows for everyone 2022-10-12 15:29:32 +01:00
Ian Philips
decb3213f6 Ignore cancel resolutions for proven correct 2022-10-12 08:05:55 -06:00
FRC
ff6278b147
Featured items to homepage (#1024)
* Featured items to homepage

* Fix nits
2022-10-12 15:04:39 +01:00
FRC
3fc53112b9
New implementation of market card embeddings (#1025)
* Grids of cards now implemented by rendering component instead of iframe

* Sinclair's nit
2022-10-12 13:24:22 +01:00
Marshall Polaris
59cdc9f776
Update FR colors, consolidate non-top answers into "Other" (#1031)
* Update FR colors, consolidate non-top answers into "Other"

* Fix answer panel coloration to not be weird and work on Firefox
2022-10-11 23:59:11 -07:00
ingawei
f587e0256d
standardizing red and green colors (#1032) 2022-10-12 01:58:20 -05:00
ingawei
1c209f68f6
de daisy sell button (#1030)
* de daisy sell button
2022-10-12 01:31:32 -05:00
ingawei
b4e7d88ed8
de daisied cancel limit bet button (#1029) 2022-10-12 01:13:43 -05:00
ingawei
b2cd6bbe03
Inga/de daisy follow button (#1028)
* de daisy follow button
2022-10-12 01:00:52 -05:00
ingawei
a6d5d5ad15
made create a post button not daisy (#1027)
yay no daisy
2022-10-12 00:24:59 -05:00
ingawei
beeca57d4e
getting rid of daisy for limit order button (#1026)
* getting rid of daisy for limit order button, got rid of betChoice in limit order panel
2022-10-12 00:09:45 -05:00
Ian Philips
fb8bd1acfb Handle slugs with and without leading / 2022-10-11 17:22:58 -06:00
Ian Philips
4215821f35 Fix firebase query for market creators 2022-10-11 16:59:47 -06:00
Ian Philips
a71c3d6a4a Fix notification link 2022-10-11 16:59:20 -06:00
James Grugett
cdcce421a8 Include today's bets in daily profit 2022-10-11 17:32:35 -05:00
Ian Philips
8beff6eb1f Format money 2022-10-11 15:36:35 -06:00
Ian Philips
f714918b88 Update functions readme with local dev details 2022-10-11 15:32:53 -06:00
James Grugett
946d74489f Gray scale position and profit 2022-10-11 16:29:15 -05:00
mantikoros
220d0841bd move liquidity to info dialog 2022-10-11 16:22:35 -05:00
James Grugett
9d44190b9a Fix group url nav to correct tabs 2022-10-11 16:04:40 -05:00
Sinclair Chen
3cdd790ae9
Autosave post, market, comment rich text (#1015)
* Fix freezing when typing big docs

* Make rich text fields autosave to localstorage

* Add autosave for comments

* delete vestigial text editor from challenges

* Clear autosave on submit post/market/comment

* lint
2022-10-11 12:52:27 -07:00
mantikoros
6c1ac89cbe typo 2022-10-11 14:28:10 -05:00
mantikoros
0d8a84ef06 re-order tournaments 2022-10-11 14:26:52 -05:00
James Grugett
d528566ffa People's=>Party #2 2022-10-11 13:46:58 -05:00
James Grugett
b0f8369d9c People's=>Party 2022-10-11 13:41:30 -05:00
James Grugett
721c18cf6c Add tournament for CCP 20th Congress 2022-10-11 13:37:40 -05:00
mantikoros
43b06ae6fa tip button: show number of tips 2022-10-11 13:30:30 -05:00
Ian Philips
bfdb5ae595 Cleanup comments 2022-10-11 10:26:37 -06:00
Ian Philips
274f7fa849 Send market closed notifications every 5 days 2022-10-11 10:22:38 -06:00
Ian Philips
d507c4092e Code for removing erroneous badges 2022-10-11 08:51:51 -06:00
Ian Philips
e970a908c6 Switch logic to includes 2022-10-11 08:35:59 -06:00
Pico2x
4fd0e5caad Fix bug in which new users are flagged as unreliable 2022-10-11 13:39:40 +01:00
James Grugett
70b2b14f80
Daily profit 💰 (#1023)
* Daily profit client side

* Filter out those where profit rounds to 0

* Tabs to spaces
2022-10-11 00:32:55 -05:00
mantikoros
0ec15ff2f8
Make liquidity great again (#1020)
* add subsidy

* drizzle liquidity

* update liquidity panel

* remove addliquidity

* update cloud functions index

* remove json endpoints

* imports

* drizzle liquidity: add velocity; dev script; run every minute

* adjust speed

* logging

* liquidity button, dialog

* modal size

* modal

* info table

* pay back excess liquidity

* remove client withdrawal

* house liquidity subsidy

* disable liquidity button if market resolved or closed

* format tip amount
2022-10-10 21:56:16 -05:00
Sinclair Chen
8bb9885aee
Fix @mention 500 error and can't close market bug (#1021)
* Fix @mention 500 error. Refactor text concat exts

* lint
2022-10-10 18:47:02 -07:00
Ian Philips
c46c384d1d Add more bot tags, better creator name scaling 2022-10-10 15:38:27 -06:00
Ian Philips
4f5c93be96 Badge notifications ui changes 2022-10-10 15:01:18 -06:00
James Grugett
f03e5d7af0
Refactor portfolio query (#1018)
* Fetch less data for portfolio query

* Rename var
2022-10-10 15:51:51 -05:00
Sinclair Chen
fb0a09664e delete bannerUrl from user type 2022-10-10 13:51:27 -07:00
Ian Philips
17d0fb7da6 Change badge award notif setting group 2022-10-10 14:41:24 -06:00
Ian Philips
867cdf2496 Only backfill unfilled users' badges 2022-10-10 14:34:06 -06:00
Ian Philips
f26ba1c4a2
Award badges for market creation, betting streaks, proven correct (#891)
* Award badges for market creation, betting streaks, proven correct

* Styling

* Add minimum unique bettors for proven correct

* Add name, refactor

* Add notifications for badge awards

* Correct styling

* Need at least 3 unique bettors for market maker badge

* Lint

* Switch to badges_awarded

* Don't include n/a resolutions in market creator badge

* Add badges by rarities to profile

* Show badges on profile, soon on market page

* Add achievements to new user

* Backfill all users badges
2022-10-10 14:32:29 -06:00
James Grugett
cdc64c6475 Correctly configure env var for firebase functions 2022-10-10 14:55:33 -05:00
James Grugett
5d561acdf8 Fix NaN 2022-10-10 14:23:16 -05:00
James Grugett
84f79ffe7c Remove undefined props 2022-10-10 13:34:02 -05:00
James Grugett
f6fd703005
Store each user's contract bet metrics (#1017)
* Implement most of caching metrics per user per contract

* Small group updates refactor

* Write contract-metrics subcollection

* Fix type error
2022-10-10 13:05:17 -05:00
mantikoros
b8ef272784 withdrawal warning 2022-10-10 13:01:57 -05:00
Ian Philips
a4699b79ed If unlisted during creation, fill in creator id 2022-10-10 10:48:01 -06:00
Ian Philips
66071e16fa Try without timeout seconds on pubsub 2022-10-10 09:53:42 -06:00
Ian Philips
b3136ebcac Update update-metrics timeout sends 2022-10-10 09:48:46 -06:00
Ian Philips
a143a96919 Fix unable to close contract 2022-10-10 09:24:37 -06:00
Ian Philips
dea65a4ba0 Better error handling for notification destinations 2022-10-10 07:41:41 -06:00
Ian Philips
a310963952 update prefs safely 2022-10-10 07:01:44 -06:00
Sinclair Chen
8d06e4b4d2
refactor text input into one component (#1016)
* Add responsive text input component

* Add styled expanding textarea component
2022-10-09 19:37:24 -07:00
James Grugett
dc51e2cf46 Rename updateMetrics to scheduleUpdateMetrics 2022-10-09 19:11:44 -05:00
sipec
4831c25ce0 Auto-prettification 2022-10-09 23:10:02 +00:00
Sinclair Chen
60f2552139 copy: Referrals -> Refer a friend 2022-10-09 16:09:21 -07:00
mantikoros
4b8d381da5 hide comment bounty when market closed or resolved 2022-10-09 17:14:20 -05:00
mantikoros
565177b76f track midterms, date docs 2022-10-09 17:02:34 -05:00
Sinclair Chen
8bd21c6693 hotfix %mention, add load-fail state 2022-10-08 22:52:36 -07:00
James Grugett
310a41d63e Make loan and bet streak links hoverable in notifications 2022-10-08 12:51:58 -05:00
mantikoros
e1636d0f13 update metrics: fix divide by zero, elasticity NaN bug 2022-10-08 12:16:45 -05:00
James Grugett
d00ea65279 Add MattP to winners for AMM liquidity exploit 2022-10-07 17:45:42 -05:00
James Grugett
60bb5379cb Update bounties doc 2022-10-07 17:30:20 -05:00
James Grugett
f3dedfb27a
Call updatemetrics v2 cloud function from scheduled function (#1014)
* Call updatemetrics v2 cloud function from scheduled function

* Set limits on bets and contracts loaded for portfolio page. Show warning if limit is hit

* mqp review: Use console.error if !response.ok
2022-10-07 17:10:12 -05:00
mantikoros
efa2e44937 comment bounty dialog, styling 2022-10-07 16:26:30 -05:00
mantikoros
84bc490ed3 comment sort: move below input, newest as default 2022-10-07 16:26:30 -05:00
James Grugett
443397b7dc
Action to merge main into main2 automatically 2022-10-07 15:13:57 -05:00
James Grugett
b57ff68654 Fix highlight & scroll to comment 2022-10-07 14:40:38 -05:00
Sinclair Chen
f0b35993c9 fix hydration error (button inside button) 2022-10-07 10:56:27 -07:00
James Grugett
8f56ccad22 Set limits on bets and contracts loaded for portfolio page. Show warning if limit is hit 2022-10-07 11:55:47 -05:00
mantikoros
9e289146af flat trade fee of M$0.1 aka bot tax 2022-10-06 23:04:48 -05:00
James Grugett
4285198f09 Merge branch 'main' into main2 2022-10-06 22:19:39 -05:00
James Grugett
f533d9bfcb
Verify balance of limit order "makers" (#1007)
* Fetch balance of users with open limit orders & cancel orders with insufficient balance

* Fix imports

* Fix bugs

* Fix a bug

* Remove redundant cast

* buttons overlaying content fix (#1005)

* buttons overlaying content fix

* stats: round DAU number

* made set width for portfolio/profit fields (#1006)

* tournaments: included resolved markets

* made delete red, moved button for regular posts (#1008)

* Fix localstorage saved user being overwritten on every page load

* Market page: Show no right panel while user loading

* Don't flash sign in button if user is loading

* election map coloring

* market group modal scroll fix (#1009)

* midterms: posititoning, make mobile friendly

* Un-daisy share buttons (#1010)

* Make embed and challenge buttons non-daisyui

* Allow link Buttons. Change tweet, dupe buttons.

* lint

* don't insert extra lines when upload photos

* Map fixes (#1011)

* usa map: fix sizing

* useSetIframeBackbroundColor

* preload contracts

* seo

* remove hook

* turn off sprig on dev

* Render timestamp only on client to prevent error of server not matching client

* Make sized container have default height so graph doesn't jump

* midterms: use null in static props

* Create common card component (#1012)

* Create common card component

* lint

* add key prop to pills

* redirect to /home after login

* create market: use transaction

* card: reduce border size

* Update groupContracts in db trigger

* Default sort to best

* Save comment sort per user rather than per contract

* Refactor Pinned Items into a reusable component

* Revert "create market: use transaction"

This reverts commit e1f24f24a9.

* Mark @v with a (Bot) label

* fix padding on daily movers

* fix type errors

* Wrap sprig init in check for window

* unindex date-docs from search engines

* Auto-prettification

* compute elasticity

* change dpm elasticity

* Fix google lighthouse issues (#1013)

* don't hide free response panel on open resolve

* liquidity sort

* Limit order trade log: '/' to 'of'. Remove 'of' in 'of YES'.

* Date doc: Toggle to disable creating a prediction market

* Listen for date doc changes

* Fix merge error

* Don't cancel all a users limit orders if they go negative

Co-authored-by: ingawei <46611122+ingawei@users.noreply.github.com>
Co-authored-by: mantikoros <sgrugett@gmail.com>
Co-authored-by: Sinclair Chen <abc.sinclair@gmail.com>
Co-authored-by: mantikoros <95266179+mantikoros@users.noreply.github.com>
Co-authored-by: Ian Philips <iansphilips@gmail.com>
Co-authored-by: Pico2x <pico2x@gmail.com>
Co-authored-by: Austin Chen <akrolsmir@gmail.com>
Co-authored-by: sipec <sipec@users.noreply.github.com>
2022-10-06 22:16:49 -05:00
Austin Chen
71b0c71729 Tag ArbitrageBot with bot badge 2022-10-06 21:52:55 -05:00
mantikoros
25333317b0 Show elasticity; volume tooltip 2022-10-06 21:52:55 -05:00
Austin Chen
42a7d04b4d Tag ArbitrageBot with bot badge 2022-10-06 20:17:34 -04:00
James Grugett
b1d386ca5a Listen for date doc changes 2022-10-06 18:54:22 -05:00
James Grugett
0dc8753a92 Listen for date doc changes 2022-10-06 18:50:53 -05:00
mantikoros
454f2d1417 Merge branch 'main' into main2 2022-10-06 18:48:56 -05:00
James Grugett
d846b9fb30 Date doc: Toggle to disable creating a prediction market 2022-10-06 18:42:56 -05:00
James Grugett
77e0631ea4 Limit order trade log: '/' to 'of'. Remove 'of' in 'of YES'. 2022-10-06 18:42:56 -05:00
James Grugett
badd67c278 Date doc: Toggle to disable creating a prediction market 2022-10-06 18:36:27 -05:00
mantikoros
80622dc7ee liquidity sort 2022-10-06 18:23:27 -05:00
James Grugett
9d12fa3af0 Limit order trade log: '/' to 'of'. Remove 'of' in 'of YES'. 2022-10-06 18:03:44 -05:00
Sinclair Chen
d9c8925ea0 don't hide free response panel on open resolve 2022-10-06 15:20:46 -07:00
Sinclair Chen
adb809f973
Fix google lighthouse issues (#1013) 2022-10-06 15:19:37 -07:00
mantikoros
a63405ca7c change dpm elasticity 2022-10-06 16:47:52 -05:00
mantikoros
7ca0fb72fc compute elasticity 2022-10-06 16:36:32 -05:00
sipec
ac37f94cf7 Auto-prettification 2022-10-06 20:50:29 +00:00
Sinclair Chen
bc5af50b0c unindex date-docs from search engines 2022-10-06 13:49:39 -07:00
James Grugett
4162cca3ff Wrap sprig init in check for window 2022-10-06 15:23:51 -05:00
mantikoros
91da39370f fix type errors 2022-10-06 14:54:22 -05:00
Sinclair Chen
2f2c586d5d fix padding on daily movers 2022-10-06 12:01:00 -07:00
Austin Chen
853e3e4896 Mark @v with a (Bot) label 2022-10-06 14:20:35 -04:00
Pico2x
edbd0feb37 Merge branch 'main' of https://github.com/manifoldmarkets/manifold 2022-10-06 17:04:32 +01:00
Pico2x
59de979949 Refactor Pinned Items into a reusable component 2022-10-06 17:04:00 +01:00
mantikoros
b8d65acc3f Revert "create market: use transaction"
This reverts commit e1f24f24a9.
2022-10-06 10:54:42 -05:00
Ian Philips
26f04fb04a Save comment sort per user rather than per contract 2022-10-06 10:16:29 -04:00
Ian Philips
e127f9646a Default sort to best 2022-10-06 09:53:55 -04:00
Ian Philips
25ef17498a Update groupContracts in db trigger 2022-10-06 09:26:35 -04:00
mantikoros
68075db3da card: reduce border size 2022-10-05 22:20:51 -05:00
mantikoros
e1f24f24a9 create market: use transaction 2022-10-05 22:18:19 -05:00
mantikoros
cd8245fbee redirect to /home after login 2022-10-05 21:38:13 -05:00
mantikoros
f1e400765a add key prop to pills 2022-10-05 21:31:34 -05:00
Sinclair Chen
94624c5387
Create common card component (#1012)
* Create common card component

* lint
2022-10-05 18:02:24 -07:00
mantikoros
7ce09ae39d midterms: use null in static props 2022-10-05 19:24:03 -05:00
James Grugett
935bdd12a7 Make sized container have default height so graph doesn't jump 2022-10-05 18:44:30 -05:00
James Grugett
5d7721e041 Render timestamp only on client to prevent error of server not matching client 2022-10-05 18:44:30 -05:00
mantikoros
a149777c0e turn off sprig on dev 2022-10-05 18:36:32 -05:00
mantikoros
81fb2456bd remove hook 2022-10-05 18:29:08 -05:00
mantikoros
f8ec306ee9
Map fixes (#1011)
* usa map: fix sizing

* useSetIframeBackbroundColor

* preload contracts

* seo
2022-10-05 18:20:40 -05:00
Sinclair Chen
a53fb49ec3 don't insert extra lines when upload photos 2022-10-05 16:08:01 -07:00
Sinclair Chen
7863a4232d
Un-daisy share buttons (#1010)
* Make embed and challenge buttons non-daisyui

* Allow link Buttons. Change tweet, dupe buttons.

* lint
2022-10-05 15:51:10 -07:00
mantikoros
a3b841423f midterms: posititoning, make mobile friendly 2022-10-05 17:12:50 -05:00
ingawei
b8911cafe8
market group modal scroll fix (#1009) 2022-10-05 17:07:41 -05:00
mantikoros
60aa294131 election map coloring 2022-10-05 16:58:47 -05:00
James Grugett
0818a94307 Don't flash sign in button if user is loading 2022-10-05 16:56:47 -05:00
James Grugett
a3acd3fa3c Market page: Show no right panel while user loading 2022-10-05 16:52:16 -05:00
James Grugett
1ef1af8234 Fix localstorage saved user being overwritten on every page load 2022-10-05 16:49:28 -05:00
ingawei
189da4a0cf
made delete red, moved button for regular posts (#1008) 2022-10-05 16:21:03 -05:00
mantikoros
10f0bbc63d tournaments: included resolved markets 2022-10-05 15:46:20 -05:00
ingawei
2d56525d65
made set width for portfolio/profit fields (#1006) 2022-10-05 15:40:26 -05:00
mantikoros
f1f8082600 stats: round DAU number 2022-10-05 15:27:20 -05:00
ingawei
ec006f25c4
buttons overlaying content fix (#1005)
* buttons overlaying content fix
2022-10-05 15:19:10 -05:00
James Grugett
b40a114168 Print error from usePagination 2022-10-05 15:13:40 -05:00
Ian Philips
4bbadeb27c Parse images and iframes from tiptap to string descriptions 2022-10-05 14:08:51 -06:00
mantikoros
2596d54831 update tournaments page 2022-10-05 14:46:15 -05:00
Austin Chen
0df5497ffb Fix regression of unable to purchase mana 2022-10-05 15:35:02 -04:00
Ian Philips
27dabc193c Comment out logs 2022-10-05 13:09:40 -06:00
Ian Philips
6ec1b38a21 Add more stringent duplication req and popularity score 2022-10-05 13:07:23 -06:00
James Grugett
f35eb42d7b Make search query params work on page load 2022-10-05 13:27:46 -05:00
James Grugett
18f8ad433d Hide sort and query options when searching 2022-10-05 13:12:30 -05:00
Sinclair Chen
37e8f2ff5a
Make %mentions actually look like mentions (#993)
* Make contract mentions inline text

* Add tooltip for author, close time, volume

* Make pill wider
2022-10-05 11:11:05 -07:00
Ian Philips
328aa1457d Send interesting markets based on groups, follows, similar bettors 2022-10-05 10:41:57 -06:00
Austin Chen
b9ba3e75fa Show top 5k markets in sitemap 2022-10-05 11:49:47 -04:00
Austin Chen
70bfec2742 Improve sitemaps 2022-10-05 11:41:32 -04:00
Pico2x
26281556f7 Merge branch 'main' of https://github.com/manifoldmarkets/manifold 2022-10-05 15:54:13 +01:00
Austin Chen
730abf584a Revert "Warn whenever rich text editor has unsaved changes"
This reverts commit 419219c703.
2022-10-05 10:54:04 -04:00
Pico2x
34d09316e0 Yscale now updates when zooming in on chart 2022-10-05 15:46:18 +01:00
Austin Chen
6f41ab8efd Keep unlisted state on duplicate 2022-10-05 10:38:23 -04:00
Pico2x
f1207e87ec Update share-modal.tsx 2022-10-05 15:18:53 +01:00
Pico2x
4e22b8e332 Merge branch 'main' of https://github.com/manifoldmarkets/manifold 2022-10-05 15:01:44 +01:00
Pico2x
07de8cc86a Slightly less horrible color palette 2022-10-05 15:01:41 +01:00
akrolsmir
f07a022d63 Auto-remove unused imports 2022-10-05 13:58:54 +00:00
Austin Chen
d42ec42b0e Standardize on CopyLinkButton 2022-10-05 09:55:46 -04:00
Austin Chen
6fa4e17a58 Remove "Add my comment" button for signed out users 2022-10-05 09:35:21 -04:00
Austin Chen
af3a3a3934 Clean up style on Get M$ 2022-10-05 09:30:51 -04:00
Pico2x
9e3477970d Merge branch 'main' of https://github.com/manifoldmarkets/manifold 2022-10-05 14:19:38 +01:00
Pico2x
3390c34d0a Mobile tooltip isn't occluded by finger anymore 2022-10-05 14:19:26 +01:00
Austin Chen
419219c703 Warn whenever rich text editor has unsaved changes 2022-10-05 09:16:05 -04:00
Pico2x
8aaca848b2 Updating group name works again. 2022-10-05 14:02:17 +01:00
Pico2x
e4d7d0a232 Mobile graphs no longer show the zoom function. 2022-10-05 13:57:45 +01:00
Pico2x
e9050973e1 Update post-comments.tsx 2022-10-05 11:53:59 +01:00
FRC
83d9a1f3e2
Posts changes (#988)
* Add post subtitle

* Add "Post" badge to post card

* Move post tab to overview tab, refactor components

* Fix styling nits.
2022-10-05 11:37:23 +01:00
Marshall Polaris
49e97ddac1
Small add-on React 18 fixes (#1004)
* Bump React types for main code to 18

* Replace react-beautiful-dnd with maintained fork

* Add a type annotation
2022-10-05 00:23:23 -07:00
Marshall Polaris
a9d5dd7fc8
Memoize FeedBet component (#999) 2022-10-04 23:17:09 -07:00
Marshall Polaris
ddb186dd98
Change Tipper interface, memoize FeedComment (#1000)
* Change `Tipper` interface, memoize `FeedComment`

* Fixup per James feedback

* More fixup per James feedback
2022-10-04 23:16:56 -07:00
Marshall Polaris
d2273087cf
React 17.0.2 -> 18.2.0 (#1003)
* React 17.0.2 -> 18.2.0

* Adjust title tag to only have one text node (no internal spaces)

* react-expanding-textarea 2.3.5 -> 2.3.6
2022-10-04 23:16:39 -07:00
James Grugett
6a0b577aeb Small home refactor 2022-10-05 00:54:38 -05:00
James Grugett
ca6197c7bb Show total loan amount on portfolio 2022-10-05 00:33:05 -05:00
Sinclair Chen
ed6ea011c2 copy: "Dating docs" -> "Dating" 2022-10-04 18:33:26 -07:00
James Grugett
83d33792aa Loan repaid => Loan payment 2022-10-04 20:10:26 -05:00
sipec
583c5b225e Auto-remove unused imports 2022-10-05 00:49:36 +00:00
Sinclair Chen
9f256aa7a8 refactor: require label on buy/confirm buttons 2022-10-04 17:48:24 -07:00
mantikoros
7a271fce29 fix button 2022-10-04 19:35:53 -05:00
James Grugett
d8ef363f06 Add profit amount to sell dialog. 2022-10-04 19:35:29 -05:00
260 changed files with 7858 additions and 4000 deletions

View 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 }}

View File

@ -26,7 +26,7 @@ module.exports = {
caughtErrorsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_',
}, },
], ],
'unused-imports/no-unused-imports': 'error', 'unused-imports/no-unused-imports': 'warn',
}, },
}, },
], ],

View File

@ -1,4 +1,4 @@
import { addCpmmLiquidity, getCpmmLiquidity } from './calculate-cpmm' import { getCpmmLiquidity } from './calculate-cpmm'
import { CPMMContract } from './contract' import { CPMMContract } from './contract'
import { LiquidityProvision } from './liquidity-provision' import { LiquidityProvision } from './liquidity-provision'
@ -8,25 +8,23 @@ export const getNewLiquidityProvision = (
contract: CPMMContract, contract: CPMMContract,
newLiquidityProvisionId: string newLiquidityProvisionId: string
) => { ) => {
const { pool, p, totalLiquidity } = contract const { pool, p, totalLiquidity, subsidyPool } = contract
const { newPool, newP } = addCpmmLiquidity(pool, p, amount) const liquidity = getCpmmLiquidity(pool, p)
const liquidity =
getCpmmLiquidity(newPool, newP) - getCpmmLiquidity(pool, newP)
const newLiquidityProvision: LiquidityProvision = { const newLiquidityProvision: LiquidityProvision = {
id: newLiquidityProvisionId, id: newLiquidityProvisionId,
userId: userId, userId: userId,
contractId: contract.id, contractId: contract.id,
amount, amount,
pool: newPool, pool,
p: newP, p,
liquidity, liquidity,
createdTime: Date.now(), createdTime: Date.now(),
} }
const newTotalLiquidity = (totalLiquidity ?? 0) + amount 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
View 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
}

View File

@ -1,11 +1,10 @@
import { sum, groupBy, mapValues, sumBy } from 'lodash' import { groupBy, mapValues, sumBy } from 'lodash'
import { LimitBet } from './bet' import { LimitBet } from './bet'
import { CREATOR_FEE, Fees, LIQUIDITY_FEE, PLATFORM_FEE } from './fees' import { CREATOR_FEE, Fees, LIQUIDITY_FEE, PLATFORM_FEE } from './fees'
import { LiquidityProvision } from './liquidity-provision' import { LiquidityProvision } from './liquidity-provision'
import { computeFills } from './new-bet' import { computeFills } from './new-bet'
import { binarySearch } from './util/algos' import { binarySearch } from './util/algos'
import { addObjects } from './util/object'
export type CpmmState = { export type CpmmState = {
pool: { [outcome: string]: number } pool: { [outcome: string]: number }
@ -147,7 +146,8 @@ function calculateAmountToBuyShares(
state: CpmmState, state: CpmmState,
shares: number, shares: number,
outcome: 'YES' | 'NO', outcome: 'YES' | 'NO',
unfilledBets: LimitBet[] unfilledBets: LimitBet[],
balanceByUserId: { [userId: string]: number }
) { ) {
// Search for amount between bounds (0, shares). // Search for amount between bounds (0, shares).
// Min share price is M$0, and max is M$1 each. // Min share price is M$0, and max is M$1 each.
@ -157,7 +157,8 @@ function calculateAmountToBuyShares(
amount, amount,
state, state,
undefined, undefined,
unfilledBets unfilledBets,
balanceByUserId
) )
const totalShares = sumBy(takers, (taker) => taker.shares) const totalShares = sumBy(takers, (taker) => taker.shares)
@ -169,7 +170,8 @@ export function calculateCpmmSale(
state: CpmmState, state: CpmmState,
shares: number, shares: number,
outcome: 'YES' | 'NO', outcome: 'YES' | 'NO',
unfilledBets: LimitBet[] unfilledBets: LimitBet[],
balanceByUserId: { [userId: string]: number }
) { ) {
if (Math.round(shares) < 0) { if (Math.round(shares) < 0) {
throw new Error('Cannot sell non-positive shares') throw new Error('Cannot sell non-positive shares')
@ -180,15 +182,17 @@ export function calculateCpmmSale(
state, state,
shares, shares,
oppositeOutcome, oppositeOutcome,
unfilledBets unfilledBets,
balanceByUserId
) )
const { cpmmState, makers, takers, totalFees } = computeFills( const { cpmmState, makers, takers, totalFees, ordersToCancel } = computeFills(
oppositeOutcome, oppositeOutcome,
buyAmount, buyAmount,
state, state,
undefined, undefined,
unfilledBets unfilledBets,
balanceByUserId
) )
// Transform buys of opposite outcome into sells. // Transform buys of opposite outcome into sells.
@ -211,6 +215,7 @@ export function calculateCpmmSale(
fees: totalFees, fees: totalFees,
makers, makers,
takers: saleTakers, takers: saleTakers,
ordersToCancel,
} }
} }
@ -218,9 +223,16 @@ export function getCpmmProbabilityAfterSale(
state: CpmmState, state: CpmmState,
shares: number, shares: number,
outcome: 'YES' | 'NO', 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) return getCpmmProbability(cpmmState.pool, cpmmState.p)
} }
@ -254,48 +266,22 @@ export function addCpmmLiquidity(
return { newPool, liquidity, newP } return { newPool, liquidity, newP }
} }
const calculateLiquidityDelta = (p: number) => (l: LiquidityProvision) => { export function getCpmmLiquidityPoolWeights(liquidities: LiquidityProvision[]) {
const oldLiquidity = getCpmmLiquidity(l.pool, p) 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 }) return mapValues(
const newLiquidity = getCpmmLiquidity(newPool, p) userAmounts,
(amounts) => sumBy(amounts, (w) => w.amount) / totalAmount
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 totalUserWeights
} }
export function getUserLiquidityShares( export function getUserLiquidityShares(
userId: string, userId: string,
state: CpmmState, state: CpmmState,
liquidities: LiquidityProvision[], liquidities: LiquidityProvision[]
excludeAntes: boolean
) { ) {
const weights = getCpmmLiquidityPoolWeights(state, liquidities, excludeAntes) const weights = getCpmmLiquidityPoolWeights(liquidities)
const userWeight = weights[userId] ?? 0 const userWeight = weights[userId] ?? 0
return mapValues(state.pool, (shares) => userWeight * shares) return mapValues(state.pool, (shares) => userWeight * shares)

View File

@ -1,9 +1,17 @@
import { last, sortBy, sum, sumBy } from 'lodash' import { Dictionary, groupBy, last, partition, sum, sumBy, uniq } from 'lodash'
import { calculatePayout } from './calculate' import { calculatePayout, getContractBetMetrics } from './calculate'
import { Bet } from './bet' import { Bet, LimitBet } from './bet'
import { Contract } from './contract' import {
Contract,
CPMMBinaryContract,
CPMMContract,
DPMContract,
} from './contract'
import { PortfolioMetrics, User } from './user' import { PortfolioMetrics, User } from './user'
import { DAY_MS } from './util/time' import { DAY_MS } from './util/time'
import { getBinaryCpmmBetInfo, getNewMultiBetInfo } from './new-bet'
import { getCpmmProbability } from './calculate-cpmm'
import { removeUndefinedProps } from './util/object'
const computeInvestmentValue = ( const computeInvestmentValue = (
bets: Bet[], bets: Bet[],
@ -33,13 +41,81 @@ export const computeInvestmentValueCustomProb = (
const betP = outcome === 'YES' ? p : 1 - p const betP = outcome === 'YES' ? p : 1 - p
const payout = betP * shares const value = betP * shares
const value = payout - (bet.loanAmount ?? 0)
if (isNaN(value)) return 0 if (isNaN(value)) return 0
return value 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 computeTotalPool = (userContracts: Contract[], startTime = 0) => {
const periodFilteredContracts = userContracts.filter( const periodFilteredContracts = userContracts.filter(
(contract) => contract.createdTime >= startTime (contract) => contract.createdTime >= startTime
@ -123,14 +199,9 @@ export const calculateNewPortfolioMetrics = (
} }
const calculateProfitForPeriod = ( const calculateProfitForPeriod = (
startTime: number, startingPortfolio: PortfolioMetrics | undefined,
descendingPortfolio: PortfolioMetrics[],
currentProfit: number currentProfit: number
) => { ) => {
const startingPortfolio = descendingPortfolio.find(
(p) => p.timestamp < startTime
)
if (startingPortfolio === undefined) { if (startingPortfolio === undefined) {
return currentProfit return currentProfit
} }
@ -145,33 +216,100 @@ export const calculatePortfolioProfit = (portfolio: PortfolioMetrics) => {
} }
export const calculateNewProfit = ( export const calculateNewProfit = (
portfolioHistory: PortfolioMetrics[], portfolioHistory: Record<
'current' | 'day' | 'week' | 'month',
PortfolioMetrics | undefined
>,
newPortfolio: PortfolioMetrics newPortfolio: PortfolioMetrics
) => { ) => {
const allTimeProfit = calculatePortfolioProfit(newPortfolio) const allTimeProfit = calculatePortfolioProfit(newPortfolio)
const descendingPortfolio = sortBy(
portfolioHistory,
(p) => p.timestamp
).reverse()
const newProfit = { const newProfit = {
daily: calculateProfitForPeriod( daily: calculateProfitForPeriod(portfolioHistory.day, allTimeProfit),
Date.now() - 1 * DAY_MS, weekly: calculateProfitForPeriod(portfolioHistory.week, allTimeProfit),
descendingPortfolio, monthly: calculateProfitForPeriod(portfolioHistory.month, allTimeProfit),
allTimeProfit
),
weekly: calculateProfitForPeriod(
Date.now() - 7 * DAY_MS,
descendingPortfolio,
allTimeProfit
),
monthly: calculateProfitForPeriod(
Date.now() - 30 * DAY_MS,
descendingPortfolio,
allTimeProfit
),
allTime: allTimeProfit, allTime: allTimeProfit,
} }
return newProfit 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,
}
}

View File

@ -78,7 +78,8 @@ export function calculateShares(
export function calculateSaleAmount( export function calculateSaleAmount(
contract: Contract, contract: Contract,
bet: Bet, bet: Bet,
unfilledBets: LimitBet[] unfilledBets: LimitBet[],
balanceByUserId: { [userId: string]: number }
) { ) {
return contract.mechanism === 'cpmm-1' && return contract.mechanism === 'cpmm-1' &&
(contract.outcomeType === 'BINARY' || (contract.outcomeType === 'BINARY' ||
@ -87,7 +88,8 @@ export function calculateSaleAmount(
contract, contract,
Math.abs(bet.shares), Math.abs(bet.shares),
bet.outcome as 'YES' | 'NO', bet.outcome as 'YES' | 'NO',
unfilledBets unfilledBets,
balanceByUserId
).saleValue ).saleValue
: calculateDpmSaleAmount(contract, bet) : calculateDpmSaleAmount(contract, bet)
} }
@ -102,14 +104,16 @@ export function getProbabilityAfterSale(
contract: Contract, contract: Contract,
outcome: string, outcome: string,
shares: number, shares: number,
unfilledBets: LimitBet[] unfilledBets: LimitBet[],
balanceByUserId: { [userId: string]: number }
) { ) {
return contract.mechanism === 'cpmm-1' return contract.mechanism === 'cpmm-1'
? getCpmmProbabilityAfterSale( ? getCpmmProbabilityAfterSale(
contract, contract,
shares, shares,
outcome as 'YES' | 'NO', outcome as 'YES' | 'NO',
unfilledBets unfilledBets,
balanceByUserId
) )
: getDpmProbabilityAfterSale(contract.totalShares, outcome, shares) : 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[]) { export function getContractBetMetrics(contract: Contract, yourBets: Bet[]) {
const { resolution } = contract const { resolution } = contract
const isCpmm = contract.mechanism === 'cpmm-1' 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 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 invested = isCpmm ? getCpmmInvested(yourBets) : getDpmInvested(yourBets)
const hasShares = Object.values(totalShares).some( const hasShares = Object.values(totalShares).some(
@ -221,8 +226,8 @@ export function getContractBetMetrics(contract: Contract, yourBets: Bet[]) {
return { return {
invested, invested,
loan,
payout, payout,
netPayout,
profit, profit,
profitPercent, profitPercent,
totalShares, totalShares,
@ -233,8 +238,8 @@ export function getContractBetMetrics(contract: Contract, yourBets: Bet[]) {
export function getContractBetNullMetrics() { export function getContractBetNullMetrics() {
return { return {
invested: 0, invested: 0,
loan: 0,
payout: 0, payout: 0,
netPayout: 0,
profit: 0, profit: 0,
profitPercent: 0, profitPercent: 0,
totalShares: {} as { [outcome: string]: number }, totalShares: {} as { [outcome: string]: number },

View File

@ -595,7 +595,8 @@ In addition to housing impact litigation, we provide free legal aid, education a
photo: 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', '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', preview: 'Donate supplies to soldiers in Ukraine',
description: 'Donate supplies to soldiers in Ukraine, including tourniquets and plate carriers.', description:
'Donate supplies to soldiers in Ukraine, including tourniquets and plate carriers.',
}, },
].map((charity) => { ].map((charity) => {
const slug = charity.name.toLowerCase().replace(/\s/g, '-') const slug = charity.name.toLowerCase().replace(/\s/g, '-')

View File

@ -10,6 +10,7 @@ export type AnyOutcomeType =
| PseudoNumeric | PseudoNumeric
| FreeResponse | FreeResponse
| Numeric | Numeric
export type AnyContractType = export type AnyContractType =
| (CPMM & Binary) | (CPMM & Binary)
| (CPMM & PseudoNumeric) | (CPMM & PseudoNumeric)
@ -49,6 +50,7 @@ export type Contract<T extends AnyContractType = AnyContractType> = {
volume: number volume: number
volume24Hours: number volume24Hours: number
volume7Days: number volume7Days: number
elasticity: number
collectedFees: Fees collectedFees: Fees
@ -90,7 +92,8 @@ export type CPMM = {
mechanism: 'cpmm-1' mechanism: 'cpmm-1'
pool: { [outcome: string]: number } pool: { [outcome: string]: number }
p: number // probability constant in y^p * n^(1-p) = k 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 prob: number
probChanges: { probChanges: {
day: number day: number

View File

@ -16,3 +16,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 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 FREE_MARKETS_PER_USER_MAX = econ?.FREE_MARKETS_PER_USER_MAX ?? 5
export const COMMENT_BOUNTY_AMOUNT = econ?.COMMENT_BOUNTY_AMOUNT ?? 250 export const COMMENT_BOUNTY_AMOUNT = econ?.COMMENT_BOUNTY_AMOUNT ?? 250
export const UNIQUE_BETTOR_LIQUIDITY = 20

View File

@ -16,7 +16,6 @@ export const DEV_CONFIG: EnvConfig = {
cloudRunId: 'w3txbmd3ba', cloudRunId: 'w3txbmd3ba',
cloudRunRegion: 'uc', cloudRunRegion: 'uc',
amplitudeApiKey: 'fd8cbfd964b9a205b8678a39faae71b3', amplitudeApiKey: 'fd8cbfd964b9a205b8678a39faae71b3',
// this is Phil's deployment twitchBotEndpoint: 'https://dev-twitch-bot.manifold.markets',
twitchBotEndpoint: 'https://king-prawn-app-5btyw.ondigitalocean.app',
sprigEnvironmentId: 'Tu7kRZPm7daP', sprigEnvironmentId: 'Tu7kRZPm7daP',
} }

View File

@ -70,7 +70,7 @@ export const PROD_CONFIG: EnvConfig = {
appId: '1:128925704902:web:f61f86944d8ffa2a642dc7', appId: '1:128925704902:web:f61f86944d8ffa2a642dc7',
measurementId: 'G-SSFK1Q138D', measurementId: 'G-SSFK1Q138D',
}, },
twitchBotEndpoint: 'https://twitch-bot-nggbo3neva-uc.a.run.app', twitchBotEndpoint: 'https://twitch-bot.manifold.markets',
cloudRunId: 'nggbo3neva', cloudRunId: 'nggbo3neva',
cloudRunRegion: 'uc', cloudRunRegion: 'uc',
adminEmails: [ adminEmails: [

View File

@ -1,3 +1,5 @@
export const FLAT_TRADE_FEE = 0.1 // M$0.1
export const PLATFORM_FEE = 0 export const PLATFORM_FEE = 0
export const CREATOR_FEE = 0 export const CREATOR_FEE = 0
export const LIQUIDITY_FEE = 0 export const LIQUIDITY_FEE = 0

3
common/globalConfig.ts Normal file
View File

@ -0,0 +1,3 @@
export type GlobalConfig = {
pinnedItems: { itemId: string; type: 'post' | 'contract' }[]
}

View File

@ -39,3 +39,4 @@ export type GroupLink = {
createdTime: number createdTime: number
userId?: string userId?: string
} }
export type GroupContractDoc = { contractId: string; createdTime: number }

View File

@ -1,8 +1,9 @@
export type Like = { export type Like = {
id: string // will be id of the object liked, i.e. contract.id id: string // will be id of the object liked, i.e. contract.id
userId: string userId: string
type: 'contract' type: 'contract' | 'post'
createdTime: number createdTime: number
tipTxnId?: string // only holds most recent tip txn id tipTxnId?: string // only holds most recent tip txn id
} }
export const LIKE_TIP_AMOUNT = 10 export const LIKE_TIP_AMOUNT = 10
export const TIP_UNDO_DURATION = 2000

View File

@ -17,8 +17,7 @@ import {
import { import {
CPMMBinaryContract, CPMMBinaryContract,
DPMBinaryContract, DPMBinaryContract,
FreeResponseContract, DPMContract,
MultipleChoiceContract,
NumericContract, NumericContract,
PseudoNumericContract, PseudoNumericContract,
} from './contract' } from './contract'
@ -144,7 +143,8 @@ export const computeFills = (
betAmount: number, betAmount: number,
state: CpmmState, state: CpmmState,
limitProb: number | undefined, limitProb: number | undefined,
unfilledBets: LimitBet[] unfilledBets: LimitBet[],
balanceByUserId: { [userId: string]: number }
) => { ) => {
if (isNaN(betAmount)) { if (isNaN(betAmount)) {
throw new Error('Invalid bet amount: ${betAmount}') throw new Error('Invalid bet amount: ${betAmount}')
@ -166,10 +166,12 @@ export const computeFills = (
shares: number shares: number
timestamp: number timestamp: number
}[] = [] }[] = []
const ordersToCancel: LimitBet[] = []
let amount = betAmount let amount = betAmount
let cpmmState = { pool: state.pool, p: state.p } let cpmmState = { pool: state.pool, p: state.p }
let totalFees = noFees let totalFees = noFees
const currentBalanceByUserId = { ...balanceByUserId }
let i = 0 let i = 0
while (true) { while (true) {
@ -186,9 +188,20 @@ export const computeFills = (
takers.push(taker) takers.push(taker)
} else { } else {
// Matched against bet. // 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) takers.push(taker)
makers.push(maker) makers.push(maker)
i++
} }
amount -= taker.amount amount -= taker.amount
@ -196,7 +209,7 @@ export const computeFills = (
if (floatingEqual(amount, 0)) break if (floatingEqual(amount, 0)) break
} }
return { takers, makers, totalFees, cpmmState } return { takers, makers, totalFees, cpmmState, ordersToCancel }
} }
export const getBinaryCpmmBetInfo = ( export const getBinaryCpmmBetInfo = (
@ -204,15 +217,17 @@ export const getBinaryCpmmBetInfo = (
betAmount: number, betAmount: number,
contract: CPMMBinaryContract | PseudoNumericContract, contract: CPMMBinaryContract | PseudoNumericContract,
limitProb: number | undefined, limitProb: number | undefined,
unfilledBets: LimitBet[] unfilledBets: LimitBet[],
balanceByUserId: { [userId: string]: number }
) => { ) => {
const { pool, p } = contract const { pool, p } = contract
const { takers, makers, cpmmState, totalFees } = computeFills( const { takers, makers, cpmmState, totalFees, ordersToCancel } = computeFills(
outcome, outcome,
betAmount, betAmount,
{ pool, p }, { pool, p },
limitProb, limitProb,
unfilledBets unfilledBets,
balanceByUserId
) )
const probBefore = getCpmmProbability(contract.pool, contract.p) const probBefore = getCpmmProbability(contract.pool, contract.p)
const probAfter = getCpmmProbability(cpmmState.pool, cpmmState.p) const probAfter = getCpmmProbability(cpmmState.pool, cpmmState.p)
@ -247,6 +262,7 @@ export const getBinaryCpmmBetInfo = (
newP: cpmmState.p, newP: cpmmState.p,
newTotalLiquidity, newTotalLiquidity,
makers, makers,
ordersToCancel,
} }
} }
@ -255,14 +271,16 @@ export const getBinaryBetStats = (
betAmount: number, betAmount: number,
contract: CPMMBinaryContract | PseudoNumericContract, contract: CPMMBinaryContract | PseudoNumericContract,
limitProb: number, limitProb: number,
unfilledBets: LimitBet[] unfilledBets: LimitBet[],
balanceByUserId: { [userId: string]: number }
) => { ) => {
const { newBet } = getBinaryCpmmBetInfo( const { newBet } = getBinaryCpmmBetInfo(
outcome, outcome,
betAmount ?? 0, betAmount ?? 0,
contract, contract,
limitProb, limitProb,
unfilledBets as LimitBet[] unfilledBets,
balanceByUserId
) )
const remainingMatched = const remainingMatched =
((newBet.orderAmount ?? 0) - newBet.amount) / ((newBet.orderAmount ?? 0) - newBet.amount) /
@ -325,7 +343,7 @@ export const getNewBinaryDpmBetInfo = (
export const getNewMultiBetInfo = ( export const getNewMultiBetInfo = (
outcome: string, outcome: string,
amount: number, amount: number,
contract: FreeResponseContract | MultipleChoiceContract contract: DPMContract
) => { ) => {
const { pool, totalShares, totalBets } = contract const { pool, totalShares, totalBets } = contract

View File

@ -63,6 +63,7 @@ export function getNewContract(
tags: [], tags: [],
lowercaseTags: [], lowercaseTags: [],
visibility, visibility,
unlistedById: visibility === 'unlisted' ? creator.id : undefined,
isResolved: false, isResolved: false,
createdTime: Date.now(), createdTime: Date.now(),
closeTime, closeTime,
@ -70,6 +71,7 @@ export function getNewContract(
volume: 0, volume: 0,
volume24Hours: 0, volume24Hours: 0,
volume7Days: 0, volume7Days: 0,
elasticity: propsByOutcomeType.mechanism === 'cpmm-1' ? 0.38 : 0.75,
collectedFees: { collectedFees: {
creatorFee: 0, creatorFee: 0,
@ -110,6 +112,7 @@ const getBinaryCpmmProps = (initialProb: number, ante: number) => {
mechanism: 'cpmm-1', mechanism: 'cpmm-1',
outcomeType: 'BINARY', outcomeType: 'BINARY',
totalLiquidity: ante, totalLiquidity: ante,
subsidyPool: 0,
initialProbability: p, initialProbability: p,
p, p,
pool: pool, pool: pool,

View File

@ -4,7 +4,7 @@ export type Notification = {
id: string id: string
userId: string userId: string
reasonText?: string reasonText?: string
reason?: notification_reason_types reason?: notification_reason_types | notification_preference
createdTime: number createdTime: number
viewTime?: number viewTime?: number
isSeen: boolean isSeen: boolean
@ -46,6 +46,7 @@ export type notification_source_types =
| 'loan' | 'loan'
| 'like' | 'like'
| 'tip_and_like' | 'tip_and_like'
| 'badge'
export type notification_source_update_types = export type notification_source_update_types =
| 'created' | 'created'
@ -237,6 +238,10 @@ export const NOTIFICATION_DESCRIPTIONS: notification_descriptions = {
simple: `Only on markets you're invested in`, simple: `Only on markets you're invested in`,
detailed: `Answers on markets that you're watching and that 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: { opt_out_all: {
simple: 'Opt out of all notifications (excludes when your markets close)', simple: 'Opt out of all notifications (excludes when your markets close)',
detailed: detailed:

View File

@ -8,11 +8,13 @@
}, },
"sideEffects": false, "sideEffects": false,
"dependencies": { "dependencies": {
"@tiptap/core": "2.0.0-beta.182", "@tiptap/core": "2.0.0-beta.199",
"@tiptap/extension-image": "2.0.0-beta.30", "@tiptap/extension-image": "2.0.0-beta.199",
"@tiptap/extension-link": "2.0.0-beta.43", "@tiptap/extension-link": "2.0.0-beta.199",
"@tiptap/extension-mention": "2.0.0-beta.102", "@tiptap/extension-mention": "2.0.0-beta.199",
"@tiptap/starter-kit": "2.0.0-beta.191", "@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" "lodash": "4.17.21"
}, },
"devDependencies": { "devDependencies": {

View File

@ -1,4 +1,3 @@
import { Bet } from './bet' import { Bet } from './bet'
import { getProbability } from './calculate' import { getProbability } from './calculate'
import { getCpmmLiquidityPoolWeights } from './calculate-cpmm' import { getCpmmLiquidityPoolWeights } from './calculate-cpmm'
@ -56,10 +55,11 @@ export const getLiquidityPoolPayouts = (
outcome: string, outcome: string,
liquidities: LiquidityProvision[] liquidities: LiquidityProvision[]
) => { ) => {
const { pool } = contract const { pool, subsidyPool } = contract
const finalPool = pool[outcome] 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]) => ({ return Object.entries(weights).map(([providerId, weight]) => ({
userId: providerId, userId: providerId,
@ -95,10 +95,11 @@ export const getLiquidityPoolProbPayouts = (
p: number, p: number,
liquidities: LiquidityProvision[] liquidities: LiquidityProvision[]
) => { ) => {
const { pool } = contract const { pool, subsidyPool } = contract
const finalPool = p * pool.YES + (1 - p) * pool.NO 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]) => ({ return Object.entries(weights).map(([providerId, weight]) => ({
userId: providerId, userId: providerId,

View File

@ -3,10 +3,19 @@ import { JSONContent } from '@tiptap/core'
export type Post = { export type Post = {
id: string id: string
title: string title: string
subtitle: string
content: JSONContent content: JSONContent
creatorId: string // User id creatorId: string // User id
createdTime: number createdTime: number
slug: string slug: string
// denormalized user fields
creatorName: string
creatorUsername: string
creatorAvatarUrl?: string
likedByUserIds?: string[]
likedByUserCount?: number
} }
export type DateDoc = Post & { export type DateDoc = Post & {
@ -17,3 +26,4 @@ export type DateDoc = Post & {
} }
export const MAX_POST_TITLE_LENGTH = 480 export const MAX_POST_TITLE_LENGTH = 480
export const MAX_POST_SUBTITLE_LENGTH = 480

View File

@ -1,8 +1,9 @@
import { groupBy, sumBy, mapValues } from 'lodash' import { groupBy, sumBy, mapValues, keyBy, sortBy } from 'lodash'
import { Bet } from './bet' import { Bet } from './bet'
import { getContractBetMetrics } from './calculate' import { getContractBetMetrics, resolvedPayout } from './calculate'
import { Contract } from './contract' import { Contract } from './contract'
import { ContractComment } from './comment'
export function scoreCreators(contracts: Contract[]) { export function scoreCreators(contracts: Contract[]) {
const creatorScore = mapValues( const creatorScore = mapValues(
@ -30,8 +31,11 @@ export function scoreTraders(contracts: Contract[], bets: Bet[][]) {
} }
export function scoreUsersByContract(contract: Contract, bets: Bet[]) { export function scoreUsersByContract(contract: Contract, bets: Bet[]) {
const betsByUser = groupBy(bets, bet => bet.userId) const betsByUser = groupBy(bets, (bet) => bet.userId)
return mapValues(betsByUser, bets => getContractBetMetrics(contract, bets).profit) return mapValues(
betsByUser,
(bets) => getContractBetMetrics(contract, bets).profit
)
} }
export function addUserScores( export function addUserScores(
@ -43,3 +47,47 @@ export function addUserScores(
dest[userId] += score 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,
}
}

View File

@ -84,15 +84,17 @@ export const getCpmmSellBetInfo = (
outcome: 'YES' | 'NO', outcome: 'YES' | 'NO',
contract: CPMMContract, contract: CPMMContract,
unfilledBets: LimitBet[], unfilledBets: LimitBet[],
balanceByUserId: { [userId: string]: number },
loanPaid: number loanPaid: number
) => { ) => {
const { pool, p } = contract const { pool, p } = contract
const { saleValue, cpmmState, fees, makers, takers } = calculateCpmmSale( const { saleValue, cpmmState, fees, makers, takers, ordersToCancel } = calculateCpmmSale(
contract, contract,
shares, shares,
outcome, outcome,
unfilledBets unfilledBets,
balanceByUserId,
) )
const probBefore = getCpmmProbability(pool, p) const probBefore = getCpmmProbability(pool, p)
@ -134,5 +136,6 @@ export const getCpmmSellBetInfo = (
fees, fees,
makers, makers,
takers, takers,
ordersToCancel
} }
} }

View File

@ -53,7 +53,7 @@ export type notification_preferences = {
profit_loss_updates: notification_destination_types[] profit_loss_updates: notification_destination_types[]
onboarding_flow: notification_destination_types[] onboarding_flow: notification_destination_types[]
thank_you_for_purchases: notification_destination_types[] thank_you_for_purchases: notification_destination_types[]
badges_awarded: notification_destination_types[]
opt_out_all: notification_destination_types[] opt_out_all: notification_destination_types[]
// When adding a new notification preference, use add-new-notification-preference.ts to existing users // When adding a new notification preference, use add-new-notification-preference.ts to existing users
} }
@ -126,6 +126,7 @@ export const getDefaultNotificationPreferences = (
onboarding_flow: constructPref(false, false), onboarding_flow: constructPref(false, false),
opt_out_all: [], opt_out_all: [],
badges_awarded: constructPref(true, false),
} }
return defaults return defaults
} }
@ -178,6 +179,8 @@ export const getNotificationDestinationsForUser = (
reason: notification_reason_types | notification_preference reason: notification_reason_types | notification_preference
) => { ) => {
const notificationSettings = privateUser.notificationPreferences const notificationSettings = privateUser.notificationPreferences
const unsubscribeEndpoint = getFunctionUrl('unsubscribe')
try {
let destinations let destinations
let subscriptionType: notification_preference | undefined let subscriptionType: notification_preference | undefined
if (Object.keys(notificationSettings).includes(reason)) { if (Object.keys(notificationSettings).includes(reason)) {
@ -198,11 +201,22 @@ export const getNotificationDestinationsForUser = (
const optedOutOfBrowser = const optedOutOfBrowser =
optOutOfAllSettings.includes('browser') && optOutOfAllSettings.includes('browser') &&
subscriptionType !== 'your_contract_closed' subscriptionType !== 'your_contract_closed'
const unsubscribeEndpoint = getFunctionUrl('unsubscribe')
return { return {
sendToEmail: destinations.includes('email') && !optedOutOfEmail, sendToEmail: destinations.includes('email') && !optedOutOfEmail,
sendToBrowser: destinations.includes('browser') && !optedOutOfBrowser, sendToBrowser: destinations.includes('browser') && !optedOutOfBrowser,
unsubscribeUrl: `${unsubscribeEndpoint}?id=${privateUser.id}&type=${subscriptionType}`, unsubscribeUrl: `${unsubscribeEndpoint}?id=${privateUser.id}&type=${subscriptionType}`,
urlToManageThisNotification: `${DOMAIN}/notifications?tab=settings&section=${subscriptionType}`, urlToManageThisNotification: `${DOMAIN}/notifications?tab=settings&section=${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: '',
}
}
} }

View File

@ -1,5 +1,6 @@
import { notification_preferences } from './user-notification-preferences' 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 = { export type User = {
id: string id: string
@ -11,7 +12,6 @@ export type User = {
// For their user page // For their user page
bio?: string bio?: string
bannerUrl?: string
website?: string website?: string
twitterHandle?: string twitterHandle?: string
discordHandle?: string discordHandle?: string
@ -51,6 +51,18 @@ export type User = {
hasSeenContractFollowModal?: boolean hasSeenContractFollowModal?: boolean
freeMarketsCreated?: number freeMarketsCreated?: number
isBannedFromPosting?: boolean isBannedFromPosting?: boolean
achievements: {
provenCorrect?: {
badges: ProvenCorrectBadge[]
}
marketCreator?: {
badges: MarketCreatorBadge[]
}
streaker?: {
badges: StreakerBadge[]
}
}
} }
export type PrivateUser = { export type PrivateUser = {
@ -81,7 +93,8 @@ export type PortfolioMetrics = {
userId: string 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' export const MANIFOLD_AVATAR_URL = 'https://manifold.markets/logo-bg-white.png'
// TODO: remove. Hardcoding the strings would be better. // TODO: remove. Hardcoding the strings would be better.

24
common/util/color.ts Normal file
View 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]
}

View File

@ -60,6 +60,16 @@ export function formatLargeNumber(num: number, sigfigs = 2): string {
return `${numStr}${suffix[i] ?? ''}` 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) { export function toCamelCase(words: string) {
const camelCase = words const camelCase = words
.split(' ') .split(' ')

View File

@ -1,4 +1,5 @@
import { generateText, JSONContent } from '@tiptap/core' import { generateText, JSONContent, Node } from '@tiptap/core'
import { generateJSON } from '@tiptap/html'
// Tiptap starter extensions // Tiptap starter extensions
import { Blockquote } from '@tiptap/extension-blockquote' import { Blockquote } from '@tiptap/extension-blockquote'
import { Bold } from '@tiptap/extension-bold' import { Bold } from '@tiptap/extension-bold'
@ -23,7 +24,7 @@ import { Mention } from '@tiptap/extension-mention'
import Iframe from './tiptap-iframe' import Iframe from './tiptap-iframe'
import TiptapTweet from './tiptap-tweet-type' import TiptapTweet from './tiptap-tweet-type'
import { find } from 'linkifyjs' import { find } from 'linkifyjs'
import { cloneDeep, uniq } from 'lodash' import { uniq } from 'lodash'
import { TiptapSpoiler } from './tiptap-spoiler' import { TiptapSpoiler } from './tiptap-spoiler'
/** get first url in text. like "notion.so " -> "http://notion.so"; "notion" -> null */ /** get first url in text. like "notion.so " -> "http://notion.so"; "notion" -> null */
@ -51,8 +52,28 @@ export function parseMentions(data: JSONContent): string[] {
return uniq(mentions) return uniq(mentions)
} }
// can't just do [StarterKit, Image...] because it doesn't work with cjs imports // TODO: this is a hack to get around the fact that tiptap doesn't have a
export const exhibitExts = [ // 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, Blockquote,
Bold, Bold,
BulletList, BulletList,
@ -69,28 +90,26 @@ export const exhibitExts = [
Paragraph, Paragraph,
Strike, Strike,
Text, Text,
// other extensions
Image,
Link, Link,
Mention, Image.extend({ renderText: () => '[image]' }),
Iframe, Mention, // user @mention
TiptapTweet, Mention.extend({ name: 'contract-mention' }), // market %mention
TiptapSpoiler, 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) { export function richTextToString(text?: JSONContent) {
if (!text) return '' if (!text) return ''
// remove spoiler tags. return generateText(text, stringParseExts)
const newText = cloneDeep(text)
dfs(newText, (current) => {
if (current.marks?.some((m) => m.type === TiptapSpoiler.name)) {
current.text = '[spoiler]'
}
})
return generateText(newText, exhibitExts)
} }
const dfs = (data: JSONContent, f: (current: JSONContent) => any) => { export function htmlToRichText(html: string) {
data.content?.forEach((d) => dfs(d, f)) return generateJSON(html, stringParseExts)
f(data)
} }

View File

@ -680,6 +680,17 @@ $ curl https://manifold.markets/api/v0/market/{marketId}/sell -X POST \
--data-raw '{"outcome": "YES", "shares": 10}' --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` ### `GET /v0/bets`
Gets a list of bets, ordered by creation date descending. Gets a list of bets, ordered by creation date descending.

View File

@ -15,6 +15,22 @@ Our community is the beating heart of Manifold; your individual contributions ar
## Awarded bounties ## 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* 🎈 *Awarded on 2022-06-14*
**[Wasabipesto](https://manifold.markets/wasabipesto): M$20,000** **[Wasabipesto](https://manifold.markets/wasabipesto): M$20,000**

View File

@ -23,11 +23,17 @@ service cloud.firestore {
allow read; allow read;
} }
match /globalConfig/globalConfig {
allow read;
allow update: if isAdmin()
allow create: if isAdmin()
}
match /users/{userId} { match /users/{userId} {
allow read; allow read;
allow update: if userId == request.auth.uid allow update: if userId == request.auth.uid
&& request.resource.data.diff(resource.data).affectedKeys() && request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories', 'lastPingTime','shouldShowWelcome', 'hasSeenContractFollowModal', 'homeSections']); .hasOnly(['bio', 'website', 'twitterHandle', 'discordHandle', 'followedCategories', 'lastPingTime','shouldShowWelcome', 'hasSeenContractFollowModal', 'homeSections']);
// User referral rules // User referral rules
allow update: if userId == request.auth.uid allow update: if userId == request.auth.uid
&& request.resource.data.diff(resource.data).affectedKeys() && request.resource.data.diff(resource.data).affectedKeys()
@ -44,6 +50,10 @@ service cloud.firestore {
allow read; allow read;
} }
match /{somePath=**}/contract-metrics/{contractId} {
allow read;
}
match /{somePath=**}/challenges/{challengeId}{ match /{somePath=**}/challenges/{challengeId}{
allow read; allow read;
} }
@ -100,7 +110,7 @@ service cloud.firestore {
match /contracts/{contractId} { match /contracts/{contractId} {
allow read; allow read;
allow update: if request.resource.data.diff(resource.data).affectedKeys() allow update: if request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['tags', 'lowercaseTags', 'groupSlugs', 'groupLinks']); .hasOnly(['tags', 'lowercaseTags', 'groupSlugs', 'groupLinks', 'flaggedByUsernames']);
allow update: if request.resource.data.diff(resource.data).affectedKeys() allow update: if request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['description', 'closeTime', 'question', 'visibility', 'unlistedById']) .hasOnly(['description', 'closeTime', 'question', 'visibility', 'unlistedById'])
&& resource.data.creatorId == request.auth.uid; && resource.data.creatorId == request.auth.uid;

3
functions/.env.dev Normal file
View File

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

View File

@ -26,7 +26,7 @@ module.exports = {
caughtErrorsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_',
}, },
], ],
'unused-imports/no-unused-imports': 'error', 'unused-imports/no-unused-imports': 'warn',
}, },
}, },
], ],

View File

@ -20,7 +20,7 @@ Adapted from https://firebase.google.com/docs/functions/get-started
3. `$ firebase login` to authenticate the CLI tools to Firebase 3. `$ firebase login` to authenticate the CLI tools to Firebase
4. `$ firebase use dev` to choose the dev project 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 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.`): 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 ## Developing locally
0. `$ firebase use dev` if you haven't already 0. `$ ./dev.sh localdb` to start the local emulator and front end
1. `$ yarn serve` to spin up the emulators 0. The Emulator UI is at http://localhost:4000; the functions are hosted on :5001. 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`
Note: You have to kill and restart emulators when you change code; no hot reload =( - 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. `$ yarn dev:emulate` in `/web` to connect to emulators with the frontend 0. Note: emulated database is cleared after every shutdown 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 ## Firestore Commands

View File

@ -5,7 +5,7 @@
"firestore": "dev-mantic-markets.appspot.com" "firestore": "dev-mantic-markets.appspot.com"
}, },
"scripts": { "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", "compile": "tsc -b",
"watch": "tsc -w", "watch": "tsc -w",
"shell": "yarn build && firebase functions:shell", "shell": "yarn build && firebase functions:shell",
@ -15,9 +15,9 @@
"dev": "nodemon src/serve.ts", "dev": "nodemon src/serve.ts",
"localDbScript": "firebase emulators:start --only functions,firestore,pubsub --import=./firestore_export", "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", "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: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/", "db:backup-remote": "yarn db:rename-remote-backup-folder && gcloud firestore export gs://$npm_package_config_firestore/firestore_export/",
"verify": "(cd .. && yarn verify)", "verify": "(cd .. && yarn verify)",
"verify:dir": "npx eslint . --max-warnings 0; tsc -b -v --pretty" "verify:dir": "npx eslint . --max-warnings 0; tsc -b -v --pretty"
@ -26,11 +26,13 @@
"dependencies": { "dependencies": {
"@amplitude/node": "1.10.0", "@amplitude/node": "1.10.0",
"@google-cloud/functions-framework": "3.1.2", "@google-cloud/functions-framework": "3.1.2",
"@tiptap/core": "2.0.0-beta.182", "@tiptap/core": "2.0.0-beta.199",
"@tiptap/extension-image": "2.0.0-beta.30", "@tiptap/extension-image": "2.0.0-beta.199",
"@tiptap/extension-link": "2.0.0-beta.43", "@tiptap/extension-link": "2.0.0-beta.199",
"@tiptap/extension-mention": "2.0.0-beta.102", "@tiptap/extension-mention": "2.0.0-beta.199",
"@tiptap/starter-kit": "2.0.0-beta.191", "@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", "cors": "2.8.5",
"dayjs": "1.11.4", "dayjs": "1.11.4",
"express": "4.18.1", "express": "4.18.1",
@ -38,6 +40,7 @@
"firebase-functions": "3.21.2", "firebase-functions": "3.21.2",
"lodash": "4.17.21", "lodash": "4.17.21",
"mailgun-js": "0.22.0", "mailgun-js": "0.22.0",
"marked": "4.1.1",
"module-alias": "2.2.2", "module-alias": "2.2.2",
"node-fetch": "2", "node-fetch": "2",
"stripe": "8.194.0", "stripe": "8.194.0",
@ -45,6 +48,7 @@
}, },
"devDependencies": { "devDependencies": {
"@types/mailgun-js": "0.22.12", "@types/mailgun-js": "0.22.12",
"@types/marked": "4.0.7",
"@types/module-alias": "2.0.1", "@types/module-alias": "2.0.1",
"@types/node-fetch": "2.6.2", "@types/node-fetch": "2.6.2",
"firebase-functions-test": "0.3.3", "firebase-functions-test": "0.3.3",

View File

@ -3,24 +3,18 @@ import { z } from 'zod'
import { Contract, CPMMContract } from '../../common/contract' import { Contract, CPMMContract } from '../../common/contract'
import { User } from '../../common/user' import { User } from '../../common/user'
import { removeUndefinedProps } from '../../common/util/object'
import { getNewLiquidityProvision } from '../../common/add-liquidity' import { getNewLiquidityProvision } from '../../common/add-liquidity'
import { APIError, newEndpoint, validate } from './api' 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({ const bodySchema = z.object({
contractId: z.string(), contractId: z.string(),
amount: z.number().gt(0), 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) 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 // run as transaction to prevent race conditions
return await firestore.runTransaction(async (transaction) => { return await firestore.runTransaction(async (transaction) => {
@ -50,7 +44,7 @@ export const addliquidity = newEndpoint({}, async (req, auth) => {
.collection(`contracts/${contractId}/liquidity`) .collection(`contracts/${contractId}/liquidity`)
.doc() .doc()
const { newLiquidityProvision, newPool, newP, newTotalLiquidity } = const { newLiquidityProvision, newTotalLiquidity, newSubsidyPool } =
getNewLiquidityProvision( getNewLiquidityProvision(
user.id, user.id,
amount, amount,
@ -58,21 +52,10 @@ export const addliquidity = newEndpoint({}, async (req, auth) => {
newLiquidityProvisionDoc.id newLiquidityProvisionDoc.id
) )
if (newP !== undefined && !isFinite(newP)) { transaction.update(contractDoc, {
return { subsidyPool: newSubsidyPool,
status: 'error',
message: 'Liquidity injection rejected due to overflow error.',
}
}
transaction.update(
contractDoc,
removeUndefinedProps({
pool: newPool,
p: newP,
totalLiquidity: newTotalLiquidity, totalLiquidity: newTotalLiquidity,
}) } as Partial<CPMMContract>)
)
const newBalance = user.balance - amount const newBalance = user.balance - amount
const newTotalDeposits = user.totalDeposits - amount const newTotalDeposits = user.totalDeposits - amount
@ -93,41 +76,3 @@ export const addliquidity = newEndpoint({}, async (req, auth) => {
}) })
const firestore = admin.firestore() 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)
})
}

View File

@ -146,3 +146,24 @@ export const newEndpoint = (endpointOpts: EndpointOptions, fn: Handler) => {
}, },
} as EndpointDefinition } 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
}

View 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 }
})

View File

@ -6,7 +6,13 @@ import {
Notification, Notification,
notification_reason_types, notification_reason_types,
} from '../../common/notification' } 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 { Contract } from '../../common/contract'
import { getPrivateUser, getValues } from './utils' import { getPrivateUser, getValues } from './utils'
import { Comment } from '../../common/comment' import { Comment } from '../../common/comment'
@ -30,27 +36,26 @@ import {
import { filterDefined } from '../../common/util/array' import { filterDefined } from '../../common/util/array'
import { getNotificationDestinationsForUser } from '../../common/user-notification-preferences' import { getNotificationDestinationsForUser } from '../../common/user-notification-preferences'
import { ContractFollow } from '../../common/follow' import { ContractFollow } from '../../common/follow'
import { Badge } from 'common/badge'
const firestore = admin.firestore() const firestore = admin.firestore()
type recipients_to_reason_texts = { type recipients_to_reason_texts = {
[userId: string]: { reason: notification_reason_types } [userId: string]: { reason: notification_reason_types }
} }
export const createNotification = async ( export const createFollowOrMarketSubsidizedNotification = async (
sourceId: string, sourceId: string,
sourceType: 'contract' | 'liquidity' | 'follow', sourceType: 'liquidity' | 'follow',
sourceUpdateType: 'closed' | 'created', sourceUpdateType: 'created',
sourceUser: User, sourceUser: User,
idempotencyKey: string, idempotencyKey: string,
sourceText: string, sourceText: string,
miscData?: { miscData?: {
contract?: Contract contract?: Contract
recipients?: string[] recipients?: string[]
slug?: string
title?: string
} }
) => { ) => {
const { contract: sourceContract, recipients, slug, title } = miscData ?? {} const { contract: sourceContract, recipients } = miscData ?? {}
const shouldReceiveNotification = ( const shouldReceiveNotification = (
userId: string, userId: string,
@ -94,23 +99,15 @@ export const createNotification = async (
sourceContractCreatorUsername: sourceContract?.creatorUsername, sourceContractCreatorUsername: sourceContract?.creatorUsername,
sourceContractTitle: sourceContract?.question, sourceContractTitle: sourceContract?.question,
sourceContractSlug: sourceContract?.slug, sourceContractSlug: sourceContract?.slug,
sourceSlug: slug ? slug : sourceContract?.slug, sourceSlug: sourceContract?.slug,
sourceTitle: title ? title : sourceContract?.question, sourceTitle: sourceContract?.question,
} }
await notificationRef.set(removeUndefinedProps(notification)) await notificationRef.set(removeUndefinedProps(notification))
} }
if (!sendToEmail) continue if (!sendToEmail) continue
if (reason === 'your_contract_closed' && privateUser && sourceContract) { if (reason === 'subsidized_your_market') {
// 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') {
// TODO: send email to creator of market that was subsidized // TODO: send email to creator of market that was subsidized
} else if (reason === 'on_new_follow') { } else if (reason === 'on_new_follow') {
// TODO: send email to user who was followed // TODO: send email to user who was followed
@ -127,20 +124,7 @@ export const createNotification = async (
reason: 'on_new_follow', reason: 'on_new_follow',
} }
return await sendNotificationsIfSettingsPermit(userToReasonTexts) return await sendNotificationsIfSettingsPermit(userToReasonTexts)
} else if ( } else if (sourceType === 'liquidity' && sourceContract) {
sourceType === 'contract' &&
sourceUpdateType === 'closed' &&
sourceContract
) {
userToReasonTexts[sourceContract.creatorId] = {
reason: 'your_contract_closed',
}
return await sendNotificationsIfSettingsPermit(userToReasonTexts)
} else if (
sourceType === 'liquidity' &&
sourceUpdateType === 'created' &&
sourceContract
) {
if (shouldReceiveNotification(sourceContract.creatorId, userToReasonTexts)) if (shouldReceiveNotification(sourceContract.creatorId, userToReasonTexts))
userToReasonTexts[sourceContract.creatorId] = { userToReasonTexts[sourceContract.creatorId] = {
reason: 'subsidized_your_market', reason: 'subsidized_your_market',
@ -213,6 +197,7 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async (
return await notificationRef.set(removeUndefinedProps(notification)) return await notificationRef.set(removeUndefinedProps(notification))
} }
const needNotFollowContractReasons = ['tagged_user']
const stillFollowingContract = (userId: string) => { const stillFollowingContract = (userId: string) => {
return contractFollowersIds.includes(userId) return contractFollowersIds.includes(userId)
} }
@ -221,7 +206,12 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async (
userId: string, userId: string,
reason: notification_reason_types 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) const privateUser = await getPrivateUser(userId)
if (!privateUser) return if (!privateUser) return
const { sendToBrowser, sendToEmail } = getNotificationDestinationsForUser( const { sendToBrowser, sendToEmail } = getNotificationDestinationsForUser(
@ -1087,6 +1077,81 @@ export const createBountyNotification = async (
sourceTitle: contract.question, sourceTitle: contract.question,
} }
return await notificationRef.set(removeUndefinedProps(notification)) return await notificationRef.set(removeUndefinedProps(notification))
}
// maybe TODO: send email notification to comment creator
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
)
} }

View File

@ -3,7 +3,11 @@ import * as admin from 'firebase-admin'
import { getUser } from './utils' import { getUser } from './utils'
import { slugify } from '../../common/util/slugify' import { slugify } from '../../common/util/slugify'
import { randomString } from '../../common/util/random' import { randomString } from '../../common/util/random'
import { 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 { APIError, newEndpoint, validate } from './api'
import { JSONContent } from '@tiptap/core' import { JSONContent } from '@tiptap/core'
import { z } from 'zod' import { z } from 'zod'
@ -36,6 +40,7 @@ const contentSchema: z.ZodType<JSONContent> = z.lazy(() =>
const postSchema = z.object({ const postSchema = z.object({
title: z.string().min(1).max(MAX_POST_TITLE_LENGTH), title: z.string().min(1).max(MAX_POST_TITLE_LENGTH),
subtitle: z.string().min(1).max(MAX_POST_SUBTITLE_LENGTH),
content: contentSchema, content: contentSchema,
groupId: z.string().optional(), groupId: z.string().optional(),
@ -48,10 +53,8 @@ const postSchema = z.object({
export const createpost = newEndpoint({}, async (req, auth) => { export const createpost = newEndpoint({}, async (req, auth) => {
const firestore = admin.firestore() const firestore = admin.firestore()
const { title, content, groupId, question, ...otherProps } = validate( const { title, subtitle, content, groupId, question, ...otherProps } =
postSchema, validate(postSchema, req.body)
req.body
)
const creator = await getUser(auth.uid) const creator = await getUser(auth.uid)
if (!creator) if (!creator)
@ -68,6 +71,7 @@ export const createpost = newEndpoint({}, async (req, auth) => {
if (question) { if (question) {
const closeTime = Date.now() + DAY_MS * 30 * 3 const closeTime = Date.now() + DAY_MS * 30 * 3
try {
const result = await createMarketHelper( const result = await createMarketHelper(
{ {
question, question,
@ -81,6 +85,9 @@ export const createpost = newEndpoint({}, async (req, auth) => {
auth auth
) )
contractSlug = result.slug contractSlug = result.slug
} catch (e) {
console.error(e)
}
} }
const post: Post = removeUndefinedProps({ const post: Post = removeUndefinedProps({
@ -89,9 +96,14 @@ export const createpost = newEndpoint({}, async (req, auth) => {
creatorId: creator.id, creatorId: creator.id,
slug, slug,
title, title,
subtitle,
createdTime: Date.now(), createdTime: Date.now(),
content: content, content: content,
contractSlug, contractSlug,
creatorName: creator.name,
creatorUsername: creator.username,
creatorAvatarUrl: creator.avatarUrl,
itemType: 'post',
}) })
await postRef.create(post) await postRef.create(post)

View File

@ -70,6 +70,7 @@ export const createuser = newEndpoint(opts, async (req, auth) => {
followedCategories: DEFAULT_CATEGORIES, followedCategories: DEFAULT_CATEGORIES,
shouldShowWelcome: true, shouldShowWelcome: true,
fractionResolvedCorrectly: 1, fractionResolvedCorrectly: 1,
achievements: {},
} }
await firestore.collection('users').doc(auth.uid).create(user) await firestore.collection('users').doc(auth.uid).create(user)

View 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()
})
}

View File

@ -12,7 +12,7 @@ import { getValueFromBucket } from '../../common/calculate-dpm'
import { formatNumericProbability } from '../../common/pseudo-numeric' import { formatNumericProbability } from '../../common/pseudo-numeric'
import { sendTemplateEmail, sendTextEmail } from './send-email' import { sendTemplateEmail, sendTextEmail } from './send-email'
import { contractUrl, getUser } from './utils' import { contractUrl, getUser, log } from './utils'
import { buildCardUrl, getOpenGraphProps } from '../../common/contract-details' import { buildCardUrl, getOpenGraphProps } from '../../common/contract-details'
import { notification_reason_types } from '../../common/notification' import { notification_reason_types } from '../../common/notification'
import { Dictionary } from 'lodash' import { Dictionary } from 'lodash'
@ -212,20 +212,16 @@ export const sendOneWeekBonusEmail = async (
user: User, user: User,
privateUser: PrivateUser privateUser: PrivateUser
) => { ) => {
if ( if (!privateUser || !privateUser.email) return
!privateUser ||
!privateUser.email ||
!privateUser.notificationPreferences.onboarding_flow.includes('email')
)
return
const { name } = user const { name } = user
const firstName = name.split(' ')[0] const firstName = name.split(' ')[0]
const { unsubscribeUrl } = getNotificationDestinationsForUser( const { unsubscribeUrl, sendToEmail } = getNotificationDestinationsForUser(
privateUser, privateUser,
'onboarding_flow' 'onboarding_flow'
) )
if (!sendToEmail) return
return await sendTemplateEmail( return await sendTemplateEmail(
privateUser.email, privateUser.email,
@ -247,19 +243,15 @@ export const sendCreatorGuideEmail = async (
privateUser: PrivateUser, privateUser: PrivateUser,
sendTime: string sendTime: string
) => { ) => {
if ( if (!privateUser || !privateUser.email) return
!privateUser ||
!privateUser.email ||
!privateUser.notificationPreferences.onboarding_flow.includes('email')
)
return
const { name } = user const { name } = user
const firstName = name.split(' ')[0] const firstName = name.split(' ')[0]
const { unsubscribeUrl } = getNotificationDestinationsForUser( const { unsubscribeUrl, sendToEmail } = getNotificationDestinationsForUser(
privateUser, privateUser,
'onboarding_flow' 'onboarding_flow'
) )
if (!sendToEmail) return
return await sendTemplateEmail( return await sendTemplateEmail(
privateUser.email, privateUser.email,
'Create your own prediction market', 'Create your own prediction market',
@ -279,22 +271,16 @@ export const sendThankYouEmail = async (
user: User, user: User,
privateUser: PrivateUser privateUser: PrivateUser
) => { ) => {
if ( if (!privateUser || !privateUser.email) return
!privateUser ||
!privateUser.email ||
!privateUser.notificationPreferences.thank_you_for_purchases.includes(
'email'
)
)
return
const { name } = user const { name } = user
const firstName = name.split(' ')[0] const firstName = name.split(' ')[0]
const { unsubscribeUrl } = getNotificationDestinationsForUser( const { unsubscribeUrl, sendToEmail } = getNotificationDestinationsForUser(
privateUser, privateUser,
'thank_you_for_purchases' 'thank_you_for_purchases'
) )
if (!sendToEmail) return
return await sendTemplateEmail( return await sendTemplateEmail(
privateUser.email, privateUser.email,
'Thanks for your Manifold purchase', 'Thanks for your Manifold purchase',
@ -315,12 +301,7 @@ export const sendMarketCloseEmail = async (
privateUser: PrivateUser, privateUser: PrivateUser,
contract: Contract contract: Contract
) => { ) => {
const { sendToEmail, unsubscribeUrl } = getNotificationDestinationsForUser( if (!privateUser.email) return
privateUser,
reason
)
if (!privateUser.email || !sendToEmail) return
const { username, name, id: userId } = user const { username, name, id: userId } = user
const firstName = name.split(' ')[0] const firstName = name.split(' ')[0]
@ -329,6 +310,7 @@ export const sendMarketCloseEmail = async (
const url = `https://${DOMAIN}/${username}/${slug}` 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( return await sendTemplateEmail(
privateUser.email, privateUser.email,
'Your market has closed', 'Your market has closed',
@ -336,7 +318,7 @@ export const sendMarketCloseEmail = async (
{ {
question, question,
url, url,
unsubscribeUrl, unsubscribeUrl: '',
userId, userId,
name: firstName, name: firstName,
volume: formatMoney(volume), volume: formatMoney(volume),
@ -466,17 +448,13 @@ export const sendInterestingMarketsEmail = async (
contractsToSend: Contract[], contractsToSend: Contract[],
deliveryTime?: string deliveryTime?: string
) => { ) => {
if ( if (!privateUser || !privateUser.email) return
!privateUser ||
!privateUser.email ||
!privateUser.notificationPreferences.trending_markets.includes('email')
)
return
const { unsubscribeUrl } = getNotificationDestinationsForUser( const { unsubscribeUrl, sendToEmail } = getNotificationDestinationsForUser(
privateUser, privateUser,
'trending_markets' 'trending_markets'
) )
if (!sendToEmail) return
const { name } = user const { name } = user
const firstName = name.split(' ')[0] const firstName = name.split(' ')[0]
@ -620,18 +598,15 @@ export const sendWeeklyPortfolioUpdateEmail = async (
investments: PerContractInvestmentsData[], investments: PerContractInvestmentsData[],
overallPerformance: OverallPerformanceData overallPerformance: OverallPerformanceData
) => { ) => {
if ( if (!privateUser || !privateUser.email) return
!privateUser ||
!privateUser.email ||
!privateUser.notificationPreferences.profit_loss_updates.includes('email')
)
return
const { unsubscribeUrl } = getNotificationDestinationsForUser( const { unsubscribeUrl, sendToEmail } = getNotificationDestinationsForUser(
privateUser, privateUser,
'profit_loss_updates' 'profit_loss_updates'
) )
if (!sendToEmail) return
const { name } = user const { name } = user
const firstName = name.split(' ')[0] const firstName = name.split(' ')[0]
const templateData: Record<string, string> = { const templateData: Record<string, string> = {
@ -656,4 +631,5 @@ export const sendWeeklyPortfolioUpdateEmail = async (
: 'portfolio-update', : 'portfolio-update',
templateData templateData
) )
log('Sent portfolio update email to', privateUser.email)
} }

View 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)
})
}

View File

@ -9,7 +9,7 @@ export * from './on-create-user'
export * from './on-create-bet' export * from './on-create-bet'
export * from './on-create-comment-on-contract' export * from './on-create-comment-on-contract'
export * from './on-view' export * from './on-view'
export * from './update-metrics' export { scheduleUpdateMetrics } from './update-metrics'
export * from './update-stats' export * from './update-stats'
export * from './update-loans' export * from './update-loans'
export * from './backup-db' export * from './backup-db'
@ -31,6 +31,7 @@ export * from './reset-weekly-emails-flags'
export * from './on-update-contract-follow' export * from './on-update-contract-follow'
export * from './on-update-like' export * from './on-update-like'
export * from './weekly-portfolio-emails' export * from './weekly-portfolio-emails'
export * from './drizzle-liquidity'
// v2 // v2
export * from './health' export * from './health'
@ -44,8 +45,6 @@ export * from './sell-bet'
export * from './sell-shares' export * from './sell-shares'
export * from './claim-manalink' export * from './claim-manalink'
export * from './create-market' export * from './create-market'
export * from './add-liquidity'
export * from './withdraw-liquidity'
export * from './create-group' export * from './create-group'
export * from './resolve-market' export * from './resolve-market'
export * from './unsubscribe' export * from './unsubscribe'
@ -53,6 +52,7 @@ export * from './stripe'
export * from './mana-bonus-email' export * from './mana-bonus-email'
export * from './close-market' export * from './close-market'
export * from './update-comment-bounty' export * from './update-comment-bounty'
export * from './add-subsidy'
import { health } from './health' import { health } from './health'
import { transact } from './transact' import { transact } from './transact'
@ -65,9 +65,8 @@ import { sellbet } from './sell-bet'
import { sellshares } from './sell-shares' import { sellshares } from './sell-shares'
import { claimmanalink } from './claim-manalink' import { claimmanalink } from './claim-manalink'
import { createmarket } from './create-market' import { createmarket } from './create-market'
import { addliquidity } from './add-liquidity' import { createcomment } from './create-comment'
import { addcommentbounty, awardcommentbounty } from './update-comment-bounty' import { addcommentbounty, awardcommentbounty } from './update-comment-bounty'
import { withdrawliquidity } from './withdraw-liquidity'
import { creategroup } from './create-group' import { creategroup } from './create-group'
import { resolvemarket } from './resolve-market' import { resolvemarket } from './resolve-market'
import { closemarket } from './close-market' import { closemarket } from './close-market'
@ -77,6 +76,8 @@ import { getcurrentuser } from './get-current-user'
import { acceptchallenge } from './accept-challenge' import { acceptchallenge } from './accept-challenge'
import { createpost } from './create-post' import { createpost } from './create-post'
import { savetwitchcredentials } from './save-twitch-credentials' import { savetwitchcredentials } from './save-twitch-credentials'
import { updatemetrics } from './update-metrics'
import { addsubsidy } from './add-subsidy'
const toCloudFunction = ({ opts, handler }: EndpointDefinition) => { const toCloudFunction = ({ opts, handler }: EndpointDefinition) => {
return onRequest(opts, handler as any) return onRequest(opts, handler as any)
@ -92,10 +93,10 @@ const sellBetFunction = toCloudFunction(sellbet)
const sellSharesFunction = toCloudFunction(sellshares) const sellSharesFunction = toCloudFunction(sellshares)
const claimManalinkFunction = toCloudFunction(claimmanalink) const claimManalinkFunction = toCloudFunction(claimmanalink)
const createMarketFunction = toCloudFunction(createmarket) const createMarketFunction = toCloudFunction(createmarket)
const addLiquidityFunction = toCloudFunction(addliquidity) const addSubsidyFunction = toCloudFunction(addsubsidy)
const addCommentBounty = toCloudFunction(addcommentbounty) const addCommentBounty = toCloudFunction(addcommentbounty)
const createCommentFunction = toCloudFunction(createcomment)
const awardCommentBounty = toCloudFunction(awardcommentbounty) const awardCommentBounty = toCloudFunction(awardcommentbounty)
const withdrawLiquidityFunction = toCloudFunction(withdrawliquidity)
const createGroupFunction = toCloudFunction(creategroup) const createGroupFunction = toCloudFunction(creategroup)
const resolveMarketFunction = toCloudFunction(resolvemarket) const resolveMarketFunction = toCloudFunction(resolvemarket)
const closeMarketFunction = toCloudFunction(closemarket) const closeMarketFunction = toCloudFunction(closemarket)
@ -106,6 +107,7 @@ const getCurrentUserFunction = toCloudFunction(getcurrentuser)
const acceptChallenge = toCloudFunction(acceptchallenge) const acceptChallenge = toCloudFunction(acceptchallenge)
const createPostFunction = toCloudFunction(createpost) const createPostFunction = toCloudFunction(createpost)
const saveTwitchCredentials = toCloudFunction(savetwitchcredentials) const saveTwitchCredentials = toCloudFunction(savetwitchcredentials)
const updateMetricsFunction = toCloudFunction(updatemetrics)
export { export {
healthFunction as health, healthFunction as health,
@ -119,8 +121,7 @@ export {
sellSharesFunction as sellshares, sellSharesFunction as sellshares,
claimManalinkFunction as claimmanalink, claimManalinkFunction as claimmanalink,
createMarketFunction as createmarket, createMarketFunction as createmarket,
addLiquidityFunction as addliquidity, addSubsidyFunction as addsubsidy,
withdrawLiquidityFunction as withdrawliquidity,
createGroupFunction as creategroup, createGroupFunction as creategroup,
resolveMarketFunction as resolvemarket, resolveMarketFunction as resolvemarket,
closeMarketFunction as closemarket, closeMarketFunction as closemarket,
@ -131,6 +132,8 @@ export {
acceptChallenge as acceptchallenge, acceptChallenge as acceptchallenge,
createPostFunction as createpost, createPostFunction as createpost,
saveTwitchCredentials as savetwitchcredentials, saveTwitchCredentials as savetwitchcredentials,
createCommentFunction as createcomment,
addCommentBounty as addcommentbounty, addCommentBounty as addcommentbounty,
awardCommentBounty as awardcommentbounty, awardCommentBounty as awardcommentbounty,
updateMetricsFunction as updatemetrics,
} }

View File

@ -3,8 +3,10 @@ import * as admin from 'firebase-admin'
import { Contract } from '../../common/contract' import { Contract } from '../../common/contract'
import { getPrivateUser, getUserByUsername } from './utils' 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 export const marketCloseNotifications = functions
.runWith({ secrets: ['MAILGUN_KEY'] }) .runWith({ secrets: ['MAILGUN_KEY'] })
.pubsub.schedule('every 1 hours') .pubsub.schedule('every 1 hours')
@ -14,31 +16,31 @@ export const marketCloseNotifications = functions
const firestore = admin.firestore() const firestore = admin.firestore()
async function sendMarketCloseEmails() { export async function sendMarketCloseEmails() {
const contracts = await firestore.runTransaction(async (transaction) => { const contracts = await firestore.runTransaction(async (transaction) => {
const snap = await transaction.get( const snap = await transaction.get(
firestore.collection('contracts').where('isResolved', '!=', true) firestore.collection('contracts').where('isResolved', '!=', true)
) )
const contracts = snap.docs.map((doc) => doc.data() as Contract)
return snap.docs const now = Date.now()
.map((doc) => { const closeContracts = contracts.filter(
const contract = doc.data() as Contract (contract) =>
contract.closeTime &&
if ( contract.closeTime < now &&
contract.resolution || shouldSendFirstOrFollowUpCloseNotification(contract)
(contract.closeEmailsSent ?? 0) >= 1 ||
contract.closeTime === undefined ||
(contract.closeTime ?? 0) > Date.now()
) )
return undefined
transaction.update(doc.ref, { await Promise.all(
closeEmailsSent: (contract.closeEmailsSent ?? 0) + 1, closeContracts.map(async (contract) => {
await transaction.update(
firestore.collection('contracts').doc(contract.id),
{
closeEmailsSent: admin.firestore.FieldValue.increment(1),
}
)
}) })
)
return contract return closeContracts
})
.filter((x) => !!x) as Contract[]
}) })
for (const contract of contracts) { for (const contract of contracts) {
@ -55,14 +57,40 @@ async function sendMarketCloseEmails() {
const privateUser = await getPrivateUser(user.id) const privateUser = await getPrivateUser(user.id)
if (!privateUser) continue if (!privateUser) continue
await createNotification( await createMarketClosedNotification(
contract.id, contract,
'contract',
'closed',
user, user,
contract.id + '-closed-at-' + contract.closeTime, privateUser,
contract.closeTime?.toString() ?? new Date().toString(), contract.id + '-closed-at-' + contract.closeTime
{ contract }
) )
} }
} }
// 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
),
}
}

View File

@ -12,6 +12,7 @@ import {
revalidateStaticProps, revalidateStaticProps,
} from './utils' } from './utils'
import { import {
createBadgeAwardedNotification,
createBetFillNotification, createBetFillNotification,
createBettingStreakBonusNotification, createBettingStreakBonusNotification,
createUniqueBettorBonusNotification, createUniqueBettorBonusNotification,
@ -24,6 +25,7 @@ import {
BETTING_STREAK_BONUS_MAX, BETTING_STREAK_BONUS_MAX,
BETTING_STREAK_RESET_HOUR, BETTING_STREAK_RESET_HOUR,
UNIQUE_BETTOR_BONUS_AMOUNT, UNIQUE_BETTOR_BONUS_AMOUNT,
UNIQUE_BETTOR_LIQUIDITY,
} from '../../common/economy' } from '../../common/economy'
import { import {
DEV_HOUSE_LIQUIDITY_PROVIDER_ID, DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
@ -33,6 +35,11 @@ import { APIError } from '../../common/api'
import { User } from '../../common/user' import { User } from '../../common/user'
import { DAY_MS } from '../../common/util/time' import { DAY_MS } from '../../common/util/time'
import { BettingStreakBonusTxn, UniqueBettorBonusTxn } from '../../common/txn' import { BettingStreakBonusTxn, UniqueBettorBonusTxn } from '../../common/txn'
import { addHouseSubsidy } from './helpers/add-house-subsidy'
import {
StreakerBadge,
streakerBadgeRarityThresholds,
} from '../../common/badge'
const firestore = admin.firestore() const firestore = admin.firestore()
const BONUS_START_DATE = new Date('2022-07-13T15:30:00.000Z').getTime() 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 const newBettingStreak = (bettor?.currentBettingStreak ?? 0) + 1
// Otherwise, add 1 to their betting streak // Otherwise, add 1 to their betting streak
await trans.update(userDoc, { trans.update(userDoc, {
currentBettingStreak: newBettingStreak, currentBettingStreak: newBettingStreak,
lastBetTime: bet.createdTime, lastBetTime: bet.createdTime,
}) })
@ -143,7 +150,7 @@ const updateBettingStreak = async (
log('message:', result.message) log('message:', result.message)
return return
} }
if (result.txn) if (result.txn) {
await createBettingStreakBonusNotification( await createBettingStreakBonusNotification(
user, user,
result.txn.id, result.txn.id,
@ -153,6 +160,8 @@ const updateBettingStreak = async (
newBettingStreak, newBettingStreak,
eventId eventId
) )
await handleBettingStreakBadgeAward(user, newBettingStreak)
}
} }
const updateUniqueBettorsAndGiveCreatorBonus = async ( const updateUniqueBettorsAndGiveCreatorBonus = async (
@ -191,7 +200,7 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
log(`Got ${previousUniqueBettorIds} unique bettors`) log(`Got ${previousUniqueBettorIds} unique bettors`)
isNewUniqueBettor && log(`And a new unique bettor ${bettor.id}`) isNewUniqueBettor && log(`And a new unique bettor ${bettor.id}`)
await trans.update(contractDoc, { trans.update(contractDoc, {
uniqueBettorIds: newUniqueBettorIds, uniqueBettorIds: newUniqueBettorIds,
uniqueBettorCount: newUniqueBettorIds.length, uniqueBettorCount: newUniqueBettorIds.length,
}) })
@ -204,8 +213,13 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
return { newUniqueBettorIds } return { newUniqueBettorIds }
} }
) )
if (!newUniqueBettorIds) return if (!newUniqueBettorIds) return
if (oldContract.mechanism === 'cpmm-1') {
await addHouseSubsidy(oldContract.id, UNIQUE_BETTOR_LIQUIDITY)
}
const bonusTxnDetails = { const bonusTxnDetails = {
contractId: oldContract.id, contractId: oldContract.id,
uniqueNewBettorId: bettor.id, uniqueNewBettorId: bettor.id,
@ -215,7 +229,9 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
: DEV_HOUSE_LIQUIDITY_PROVIDER_ID : DEV_HOUSE_LIQUIDITY_PROVIDER_ID
const fromSnap = await firestore.doc(`users/${fromUserId}`).get() const fromSnap = await firestore.doc(`users/${fromUserId}`).get()
if (!fromSnap.exists) throw new APIError(400, 'From user not found.') if (!fromSnap.exists) throw new APIError(400, 'From user not found.')
const fromUser = fromSnap.data() as User const fromUser = fromSnap.data() as User
const result = await firestore.runTransaction(async (trans) => { const result = await firestore.runTransaction(async (trans) => {
const bonusTxn: TxnData = { const bonusTxn: TxnData = {
fromId: fromUser.id, fromId: fromUser.id,
@ -228,7 +244,9 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
description: JSON.stringify(bonusTxnDetails), description: JSON.stringify(bonusTxnDetails),
data: bonusTxnDetails, data: bonusTxnDetails,
} as Omit<UniqueBettorBonusTxn, 'id' | 'createdTime'> } as Omit<UniqueBettorBonusTxn, 'id' | 'createdTime'>
const { status, message, txn } = await runTxn(trans, bonusTxn) const { status, message, txn } = await runTxn(trans, bonusTxn)
return { status, newUniqueBettorIds, message, txn } return { status, newUniqueBettorIds, message, txn }
}) })
@ -296,3 +314,39 @@ const notifyFills = async (
const currentDateBettingStreakResetTime = () => { const currentDateBettingStreakResetTime = () => {
return new Date().setUTCHours(BETTING_STREAK_RESET_HOUR, 0, 0, 0) 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)
}
}

View File

@ -1,11 +1,20 @@
import * as functions from 'firebase-functions' import * as functions from 'firebase-functions'
import { getUser } from './utils' import { getUser, getValues } from './utils'
import { createNewContractNotification } from './create-notification' import {
createBadgeAwardedNotification,
createNewContractNotification,
} from './create-notification'
import { Contract } from '../../common/contract' import { Contract } from '../../common/contract'
import { parseMentions, richTextToString } from '../../common/util/parse' import { parseMentions, richTextToString } from '../../common/util/parse'
import { JSONContent } from '@tiptap/core' import { JSONContent } from '@tiptap/core'
import { addUserToContractFollowers } from './follow-market' 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 export const onCreateContract = functions
.runWith({ secrets: ['MAILGUN_KEY'] }) .runWith({ secrets: ['MAILGUN_KEY'] })
@ -28,4 +37,43 @@ export const onCreateContract = functions
richTextToString(desc), richTextToString(desc),
mentioned 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)
}
}

View File

@ -1,6 +1,6 @@
import * as functions from 'firebase-functions' import * as functions from 'firebase-functions'
import { getContract, getUser, log } from './utils' import { getContract, getUser, log } from './utils'
import { createNotification } from './create-notification' import { createFollowOrMarketSubsidizedNotification } from './create-notification'
import { LiquidityProvision } from '../../common/liquidity-provision' import { LiquidityProvision } from '../../common/liquidity-provision'
import { addUserToContractFollowers } from './follow-market' import { addUserToContractFollowers } from './follow-market'
import { FIXED_ANTE } from '../../common/economy' 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') if (!liquidityProvider) throw new Error('Could not find liquidity provider')
await addUserToContractFollowers(contract.id, liquidityProvider.id) await addUserToContractFollowers(contract.id, liquidityProvider.id)
await createNotification( await createFollowOrMarketSubsidizedNotification(
contract.id, contract.id,
'liquidity', 'liquidity',
'created', 'created',

View File

@ -2,7 +2,7 @@ import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import { getUser } from './utils' import { getUser } from './utils'
import { createNotification } from './create-notification' import { createFollowOrMarketSubsidizedNotification } from './create-notification'
import { FieldValue } from 'firebase-admin/firestore' import { FieldValue } from 'firebase-admin/firestore'
export const onFollowUser = functions.firestore export const onFollowUser = functions.firestore
@ -23,7 +23,7 @@ export const onFollowUser = functions.firestore
followerCountCached: FieldValue.increment(1), followerCountCached: FieldValue.increment(1),
}) })
await createNotification( await createFollowOrMarketSubsidizedNotification(
followingUser.id, followingUser.id,
'follow', 'follow',
'created', 'created',

View File

@ -1,7 +1,19 @@
import * as functions from 'firebase-functions' import * as functions from 'firebase-functions'
import { getUser } from './utils' import { getUser, getValues } from './utils'
import { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification' import {
createBadgeAwardedNotification,
createCommentOrAnswerOrUpdatedContractNotification,
} from './create-notification'
import { Contract } from '../../common/contract' 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 export const onUpdateContract = functions.firestore
.document('contracts/{contractId}') .document('contracts/{contractId}')
@ -9,17 +21,14 @@ export const onUpdateContract = functions.firestore
const contract = change.after.data() as Contract const contract = change.after.data() as Contract
const previousContract = change.before.data() as Contract const previousContract = change.before.data() as Contract
const { eventId } = context const { eventId } = context
const { openCommentBounties, closeTime, question } = contract const { closeTime, question } = contract
if ( if (!previousContract.isResolved && contract.isResolved) {
!previousContract.isResolved &&
contract.isResolved &&
(openCommentBounties ?? 0) > 0
) {
// No need to notify users of resolution, that's handled in resolve-market // No need to notify users of resolution, that's handled in resolve-market
return return await handleResolvedContract(contract)
} } else if (previousContract.groupSlugs !== contract.groupSlugs) {
if ( await handleContractGroupUpdated(previousContract, contract)
} else if (
previousContract.closeTime !== closeTime || previousContract.closeTime !== closeTime ||
previousContract.question !== question previousContract.question !== question
) { ) {
@ -27,6 +36,64 @@ export const onUpdateContract = functions.firestore
} }
}) })
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( async function handleUpdatedCloseTime(
previousContract: Contract, previousContract: Contract,
contract: Contract, contract: Contract,
@ -51,3 +118,43 @@ async function handleUpdatedCloseTime(
contract 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()

View File

@ -5,8 +5,6 @@ import { HOUSE_LIQUIDITY_PROVIDER_ID } from '../../common/antes'
import { createReferralNotification } from './create-notification' import { createReferralNotification } from './create-notification'
import { ReferralTxn } from '../../common/txn' import { ReferralTxn } from '../../common/txn'
import { Contract } from '../../common/contract' import { Contract } from '../../common/contract'
import { LimitBet } from '../../common/bet'
import { QuerySnapshot } from 'firebase-admin/firestore'
import { Group } from '../../common/group' import { Group } from '../../common/group'
import { REFERRAL_AMOUNT } from '../../common/economy' import { REFERRAL_AMOUNT } from '../../common/economy'
const firestore = admin.firestore() const firestore = admin.firestore()
@ -21,10 +19,6 @@ export const onUpdateUser = functions.firestore
if (prevUser.referredByUserId !== user.referredByUserId) { if (prevUser.referredByUserId !== user.referredByUserId) {
await handleUserUpdatedReferral(user, eventId) await handleUserUpdatedReferral(user, eventId)
} }
if (user.balance <= 0) {
await cancelLimitOrders(user.id)
}
}) })
async function handleUserUpdatedReferral(user: User, eventId: string) { 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 }))
)
}

View File

@ -11,6 +11,7 @@ import { groupBy, mapValues, sumBy, uniq } from 'lodash'
import { APIError, newEndpoint, validate } from './api' import { APIError, newEndpoint, validate } from './api'
import { Contract, CPMM_MIN_POOL_QTY } from '../../common/contract' import { Contract, CPMM_MIN_POOL_QTY } from '../../common/contract'
import { User } from '../../common/user' import { User } from '../../common/user'
import { FLAT_TRADE_FEE } from '../../common/fees'
import { import {
BetInfo, BetInfo,
getBinaryCpmmBetInfo, getBinaryCpmmBetInfo,
@ -23,6 +24,7 @@ import { floatingEqual } from '../../common/util/math'
import { redeemShares } from './redeem-shares' import { redeemShares } from './redeem-shares'
import { log } from './utils' import { log } from './utils'
import { addUserToContractFollowers } from './follow-market' import { addUserToContractFollowers } from './follow-market'
import { filterDefined } from '../../common/util/array'
const bodySchema = z.object({ const bodySchema = z.object({
contractId: z.string(), contractId: z.string(),
@ -73,9 +75,11 @@ export const placebet = newEndpoint({}, async (req, auth) => {
newTotalLiquidity, newTotalLiquidity,
newP, newP,
makers, makers,
ordersToCancel,
} = await (async (): Promise< } = await (async (): Promise<
BetInfo & { BetInfo & {
makers?: maker[] makers?: maker[]
ordersToCancel?: LimitBet[]
} }
> => { > => {
if ( if (
@ -99,17 +103,16 @@ export const placebet = newEndpoint({}, async (req, auth) => {
limitProb = Math.round(limitProb * 100) / 100 limitProb = Math.round(limitProb * 100) / 100
} }
const unfilledBetsSnap = await trans.get( const { unfilledBets, balanceByUserId } =
getUnfilledBetsQuery(contractDoc) await getUnfilledBetsAndUserBalances(trans, contractDoc)
)
const unfilledBets = unfilledBetsSnap.docs.map((doc) => doc.data())
return getBinaryCpmmBetInfo( return getBinaryCpmmBetInfo(
outcome, outcome,
amount, amount,
contract, contract,
limitProb, limitProb,
unfilledBets unfilledBets,
balanceByUserId
) )
} else if ( } else if (
(outcomeType == 'FREE_RESPONSE' || outcomeType === 'MULTIPLE_CHOICE') && (outcomeType == 'FREE_RESPONSE' || outcomeType === 'MULTIPLE_CHOICE') &&
@ -152,11 +155,25 @@ export const placebet = newEndpoint({}, async (req, auth) => {
if (makers) { if (makers) {
updateMakers(makers, betDoc.id, contractDoc, trans) updateMakers(makers, betDoc.id, contractDoc, trans)
} }
if (ordersToCancel) {
for (const bet of ordersToCancel) {
trans.update(contractDoc.collection('bets').doc(bet.id), {
isCancelled: true,
})
}
}
if (newBet.amount !== 0) { const balanceChange =
trans.update(userDoc, { balance: FieldValue.increment(-newBet.amount) }) 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.') log('Updated user balance.')
if (newBet.amount !== 0) {
trans.update( trans.update(
contractDoc, contractDoc,
removeUndefinedProps({ removeUndefinedProps({
@ -193,13 +210,36 @@ export const placebet = newEndpoint({}, async (req, auth) => {
const firestore = admin.firestore() const firestore = admin.firestore()
export const getUnfilledBetsQuery = (contractDoc: DocumentReference) => { const getUnfilledBetsQuery = (contractDoc: DocumentReference) => {
return contractDoc return contractDoc
.collection('bets') .collection('bets')
.where('isFilled', '==', false) .where('isFilled', '==', false)
.where('isCancelled', '==', false) as Query<LimitBet> .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 = { type maker = {
bet: LimitBet bet: LimitBet
amount: number amount: number

View File

@ -1,6 +1,6 @@
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import { z } from 'zod' import { z } from 'zod'
import { mapValues, groupBy, sumBy } from 'lodash' import { mapValues, groupBy, sumBy, uniqBy } from 'lodash'
import { import {
Contract, Contract,
@ -9,12 +9,20 @@ import {
RESOLUTIONS, RESOLUTIONS,
} from '../../common/contract' } from '../../common/contract'
import { Bet } from '../../common/bet' 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 { import {
getLoanPayouts, getLoanPayouts,
getPayouts, getPayouts,
groupPayoutsByUser, groupPayoutsByUser,
Payout,
} from '../../common/payouts' } from '../../common/payouts'
import { isAdmin, isManifoldId } from '../../common/envs/constants' import { isAdmin, isManifoldId } from '../../common/envs/constants'
import { removeUndefinedProps } from '../../common/util/object' import { removeUndefinedProps } from '../../common/util/object'
@ -28,6 +36,7 @@ import {
DEV_HOUSE_LIQUIDITY_PROVIDER_ID, DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
HOUSE_LIQUIDITY_PROVIDER_ID, HOUSE_LIQUIDITY_PROVIDER_ID,
} from '../../common/antes' } from '../../common/antes'
import { User } from 'common/user'
const bodySchema = z.object({ const bodySchema = z.object({
contractId: z.string(), contractId: z.string(),
@ -81,13 +90,10 @@ export const resolvemarket = newEndpoint(opts, async (req, auth) => {
if (!contractSnap.exists) if (!contractSnap.exists)
throw new APIError(404, 'No contract exists with the provided ID') throw new APIError(404, 'No contract exists with the provided ID')
const contract = contractSnap.data() as Contract const contract = contractSnap.data() as Contract
const { creatorId, closeTime } = contract const { creatorId } = contract
const firebaseUser = await admin.auth().getUser(auth.uid) const firebaseUser = await admin.auth().getUser(auth.uid)
const { value, resolutions, probabilityInt, outcome } = getResolutionParams( const resolutionParams = getResolutionParams(contract, req.body)
contract,
req.body
)
if ( if (
creatorId !== auth.uid && creatorId !== auth.uid &&
@ -101,6 +107,16 @@ export const resolvemarket = newEndpoint(opts, async (req, auth) => {
const creator = await getUser(creatorId) const creator = await getUser(creatorId)
if (!creator) throw new APIError(500, 'Creator not found') 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 = const resolutionProbability =
probabilityInt !== undefined ? probabilityInt / 100 : undefined probabilityInt !== undefined ? probabilityInt / 100 : undefined
@ -123,8 +139,12 @@ export const resolvemarket = newEndpoint(opts, async (req, auth) => {
(doc) => doc.data() as LiquidityProvision (doc) => doc.data() as LiquidityProvision
) )
const { payouts, creatorPayout, liquidityPayouts, collectedFees } = const {
getPayouts( payouts: traderPayouts,
creatorPayout,
liquidityPayouts,
collectedFees,
} = getPayouts(
outcome, outcome,
contract, contract,
bets, bets,
@ -145,35 +165,50 @@ export const resolvemarket = newEndpoint(opts, async (req, auth) => {
resolutions, resolutions,
collectedFees, collectedFees,
}), }),
subsidyPool: 0,
} }
await contractDoc.update(updatedContract)
console.log('contract ', contractId, 'resolved to:', outcome)
const openBets = bets.filter((b) => !b.isSold && !b.sale) const openBets = bets.filter((b) => !b.isSold && !b.sale)
const loanPayouts = getLoanPayouts(openBets) 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()) if (!isProd())
console.log( console.log(
'payouts:', 'trader payouts:',
payouts, traderPayouts,
'creator payout:', 'creator payout:',
creatorPayout, creatorPayout,
'liquidity payout:' 'liquidity payout:',
liquidityPayouts,
'loan payouts:',
loanPayouts
) )
if (creatorPayout) const userCount = uniqBy(payouts, 'userId').length
await processPayouts([{ userId: creatorId, payout: creatorPayout }], true) 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 undoUniqueBettorRewardsIfCancelResolution(contract, outcome)
await revalidateStaticProps(getContractPath(contract)) await revalidateStaticProps(getContractPath(contract))
const userPayoutsWithoutLoans = groupPayoutsByUser(payouts) const userPayoutsWithoutLoans = groupPayoutsByUser(payoutsWithoutLoans)
const userInvestments = mapValues( const userInvestments = mapValues(
groupBy(bets, (bet) => bet.userId), groupBy(bets, (bet) => bet.userId),
@ -200,18 +235,6 @@ export const resolvemarket = newEndpoint(opts, async (req, auth) => {
) )
return updatedContract 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) { function getResolutionParams(contract: Contract, body: string) {
@ -278,6 +301,8 @@ function getResolutionParams(contract: Contract, body: string) {
throw new APIError(500, `Invalid outcome type: ${outcomeType}`) throw new APIError(500, `Invalid outcome type: ${outcomeType}`)
} }
type ResolutionParams = ReturnType<typeof getResolutionParams>
function validateAnswer( function validateAnswer(
contract: FreeResponseContract | MultipleChoiceContract, contract: FreeResponseContract | MultipleChoiceContract,
answer: number answer: number

View File

@ -11,15 +11,18 @@ async function main() {
await Promise.all( await Promise.all(
privateUsers.map((privateUser) => { privateUsers.map((privateUser) => {
if (!privateUser.id) return Promise.resolve() if (!privateUser.id) return Promise.resolve()
if (privateUser.notificationPreferences.badges_awarded === undefined) {
return firestore return firestore
.collection('private-users') .collection('private-users')
.doc(privateUser.id) .doc(privateUser.id)
.update({ .update({
notificationPreferences: { notificationPreferences: {
...privateUser.notificationPreferences, ...privateUser.notificationPreferences,
opt_out_all: [], badges_awarded: ['browser'],
}, },
}) })
}
return
}) })
) )
} }

View 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
}

View 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.`)
})
}

View File

@ -3,7 +3,6 @@
import { DocumentSnapshot, Transaction } from 'firebase-admin/firestore' import { DocumentSnapshot, Transaction } from 'firebase-admin/firestore'
import { isEqual, zip } from 'lodash' import { isEqual, zip } from 'lodash'
import { UpdateSpec } from '../utils'
export type DocumentValue = { export type DocumentValue = {
doc: DocumentSnapshot doc: DocumentSnapshot
@ -54,7 +53,7 @@ export function getDiffUpdate(diff: DocumentDiff) {
return { return {
doc: diff.dest.doc.ref, doc: diff.dest.doc.ref,
fields: Object.fromEntries(zip(diff.dest.fields, diff.src.vals)), fields: Object.fromEntries(zip(diff.dest.fields, diff.src.vals)),
} as UpdateSpec }
} }
export function applyDiff(transaction: Transaction, diff: DocumentDiff) { export function applyDiff(transaction: Transaction, diff: DocumentDiff) {

View File

@ -0,0 +1,8 @@
import { initAdmin } from './script-init'
initAdmin()
import { drizzleLiquidity } from '../drizzle-liquidity'
if (require.main === module) {
drizzleLiquidity().then(() => process.exit())
}

View 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.`)
}

View File

@ -89,17 +89,20 @@ const getGroups = async () => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
async function updateTotalContractsAndMembers() { async function updateTotalContractsAndMembers() {
const groups = await getGroups() const groups = await getGroups()
for (const group of groups) { await Promise.all(
groups.map(async (group) => {
log('updating group total contracts and members', group.slug) log('updating group total contracts and members', group.slug)
const groupRef = admin.firestore().collection('groups').doc(group.id) const groupRef = admin.firestore().collection('groups').doc(group.id)
const totalMembers = (await groupRef.collection('groupMembers').get()).size const totalMembers = (await groupRef.collection('groupMembers').get())
.size
const totalContracts = (await groupRef.collection('groupContracts').get()) const totalContracts = (await groupRef.collection('groupContracts').get())
.size .size
await groupRef.update({ await groupRef.update({
totalMembers, totalMembers,
totalContracts, totalContracts,
}) })
} })
)
} }
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
async function removeUnusedMemberAndContractFields() { async function removeUnusedMemberAndContractFields() {
@ -117,6 +120,6 @@ async function removeUnusedMemberAndContractFields() {
if (require.main === module) { if (require.main === module) {
initAdmin() initAdmin()
// convertGroupFieldsToGroupDocuments() // convertGroupFieldsToGroupDocuments()
// updateTotalContractsAndMembers() updateTotalContractsAndMembers()
removeUnusedMemberAndContractFields() // removeUnusedMemberAndContractFields()
} }

View File

@ -1,6 +1,7 @@
import { mapValues, groupBy, sumBy, uniq } from 'lodash' import { mapValues, groupBy, sumBy, uniq } from 'lodash'
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import { z } from 'zod' import { z } from 'zod'
import { FieldValue } from 'firebase-admin/firestore'
import { APIError, newEndpoint, validate } from './api' import { APIError, newEndpoint, validate } from './api'
import { Contract, CPMM_MIN_POOL_QTY } from '../../common/contract' import { Contract, CPMM_MIN_POOL_QTY } from '../../common/contract'
@ -10,8 +11,7 @@ import { addObjects, removeUndefinedProps } from '../../common/util/object'
import { log } from './utils' import { log } from './utils'
import { Bet } from '../../common/bet' import { Bet } from '../../common/bet'
import { floatingEqual, floatingLesserEqual } from '../../common/util/math' import { floatingEqual, floatingLesserEqual } from '../../common/util/math'
import { getUnfilledBetsQuery, updateMakers } from './place-bet' import { getUnfilledBetsAndUserBalances, updateMakers } from './place-bet'
import { FieldValue } from 'firebase-admin/firestore'
import { redeemShares } from './redeem-shares' import { redeemShares } from './redeem-shares'
import { removeUserFromContractFollowers } from './follow-market' import { removeUserFromContractFollowers } from './follow-market'
@ -29,16 +29,18 @@ export const sellshares = newEndpoint({}, async (req, auth) => {
const contractDoc = firestore.doc(`contracts/${contractId}`) const contractDoc = firestore.doc(`contracts/${contractId}`)
const userDoc = firestore.doc(`users/${auth.uid}`) const userDoc = firestore.doc(`users/${auth.uid}`)
const betsQ = contractDoc.collection('bets').where('userId', '==', auth.uid) const betsQ = contractDoc.collection('bets').where('userId', '==', auth.uid)
const [[contractSnap, userSnap], userBetsSnap, unfilledBetsSnap] = const [
await Promise.all([ [contractSnap, userSnap],
userBetsSnap,
{ unfilledBets, balanceByUserId },
] = await Promise.all([
transaction.getAll(contractDoc, userDoc), transaction.getAll(contractDoc, userDoc),
transaction.get(betsQ), transaction.get(betsQ),
transaction.get(getUnfilledBetsQuery(contractDoc)), getUnfilledBetsAndUserBalances(transaction, contractDoc),
]) ])
if (!contractSnap.exists) throw new APIError(400, 'Contract not found.') if (!contractSnap.exists) throw new APIError(400, 'Contract not found.')
if (!userSnap.exists) throw new APIError(400, 'User not found.') if (!userSnap.exists) throw new APIError(400, 'User not found.')
const userBets = userBetsSnap.docs.map((doc) => doc.data() as Bet) 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 contract = contractSnap.data() as Contract
const user = userSnap.data() as User const user = userSnap.data() as User
@ -86,11 +88,13 @@ export const sellshares = newEndpoint({}, async (req, auth) => {
let loanPaid = saleFrac * loanAmount let loanPaid = saleFrac * loanAmount
if (!isFinite(loanPaid)) loanPaid = 0 if (!isFinite(loanPaid)) loanPaid = 0
const { newBet, newPool, newP, fees, makers } = getCpmmSellBetInfo( const { newBet, newPool, newP, fees, makers, ordersToCancel } =
getCpmmSellBetInfo(
soldShares, soldShares,
chosenOutcome, chosenOutcome,
contract, contract,
unfilledBets, unfilledBets,
balanceByUserId,
loanPaid loanPaid
) )
@ -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 } return { newBet, makers, maxShares, soldShares }
}) })

View File

@ -19,8 +19,7 @@ import { sellbet } from './sell-bet'
import { sellshares } from './sell-shares' import { sellshares } from './sell-shares'
import { claimmanalink } from './claim-manalink' import { claimmanalink } from './claim-manalink'
import { createmarket } from './create-market' import { createmarket } from './create-market'
import { addliquidity } from './add-liquidity' import { createcomment } from './create-comment'
import { withdrawliquidity } from './withdraw-liquidity'
import { creategroup } from './create-group' import { creategroup } from './create-group'
import { resolvemarket } from './resolve-market' import { resolvemarket } from './resolve-market'
import { unsubscribe } from './unsubscribe' import { unsubscribe } from './unsubscribe'
@ -55,16 +54,15 @@ addJsonEndpointRoute('/transact', transact)
addJsonEndpointRoute('/changeuserinfo', changeuserinfo) addJsonEndpointRoute('/changeuserinfo', changeuserinfo)
addJsonEndpointRoute('/createuser', createuser) addJsonEndpointRoute('/createuser', createuser)
addJsonEndpointRoute('/createanswer', createanswer) addJsonEndpointRoute('/createanswer', createanswer)
addJsonEndpointRoute('/createcomment', createcomment)
addJsonEndpointRoute('/placebet', placebet) addJsonEndpointRoute('/placebet', placebet)
addJsonEndpointRoute('/cancelbet', cancelbet) addJsonEndpointRoute('/cancelbet', cancelbet)
addJsonEndpointRoute('/sellbet', sellbet) addJsonEndpointRoute('/sellbet', sellbet)
addJsonEndpointRoute('/sellshares', sellshares) addJsonEndpointRoute('/sellshares', sellshares)
addJsonEndpointRoute('/claimmanalink', claimmanalink) addJsonEndpointRoute('/claimmanalink', claimmanalink)
addJsonEndpointRoute('/createmarket', createmarket) addJsonEndpointRoute('/createmarket', createmarket)
addJsonEndpointRoute('/addliquidity', addliquidity)
addJsonEndpointRoute('/addCommentBounty', addcommentbounty) addJsonEndpointRoute('/addCommentBounty', addcommentbounty)
addJsonEndpointRoute('/awardCommentBounty', awardcommentbounty) addJsonEndpointRoute('/awardCommentBounty', awardcommentbounty)
addJsonEndpointRoute('/withdrawliquidity', withdrawliquidity)
addJsonEndpointRoute('/creategroup', creategroup) addJsonEndpointRoute('/creategroup', creategroup)
addJsonEndpointRoute('/resolvemarket', resolvemarket) addJsonEndpointRoute('/resolvemarket', resolvemarket)
addJsonEndpointRoute('/unsubscribe', unsubscribe) addJsonEndpointRoute('/unsubscribe', unsubscribe)

View File

@ -1,6 +1,6 @@
import { APIError, newEndpoint } from './api' import { APIError, newEndpoint } from './api'
import { isProd } from './utils' import { isProd } from './utils'
import { sendTrendingMarketsEmailsToAllUsers } from 'functions/src/weekly-markets-emails' import { sendMarketCloseEmails } from 'functions/src/market-close-notifications'
// Function for testing scheduled functions locally // Function for testing scheduled functions locally
export const testscheduledfunction = newEndpoint( export const testscheduledfunction = newEndpoint(
@ -10,7 +10,7 @@ export const testscheduledfunction = newEndpoint(
throw new APIError(400, 'This function is only available in dev mode') throw new APIError(400, 'This function is only available in dev mode')
// Replace your function here // Replace your function here
await sendTrendingMarketsEmailsToAllUsers() await sendMarketCloseEmails()
return { success: true } return { success: true }
} }

View File

@ -1,10 +1,11 @@
import * as functions from 'firebase-functions' import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import { 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 { getValues, log, logMemory, writeAsync } from './utils'
import { Bet } from '../../common/bet' import { Bet } from '../../common/bet'
import { Contract, CPMM } from '../../common/contract' import { Contract, CPMM } from '../../common/contract'
import { PortfolioMetrics, User } from '../../common/user' import { PortfolioMetrics, User } from '../../common/user'
import { DAY_MS } from '../../common/util/time' import { DAY_MS } from '../../common/util/time'
import { getLoanUpdates } from '../../common/loans' import { getLoanUpdates } from '../../common/loans'
@ -14,18 +15,44 @@ import {
calculateNewPortfolioMetrics, calculateNewPortfolioMetrics,
calculateNewProfit, calculateNewProfit,
calculateProbChanges, calculateProbChanges,
calculateMetricsByContract,
computeElasticity,
computeVolume, computeVolume,
} from '../../common/calculate-metrics' } from '../../common/calculate-metrics'
import { getProbability } from '../../common/calculate' import { getProbability } from '../../common/calculate'
import { Group } from '../../common/group' import { Group } from '../../common/group'
import { batchedWaitAll } from '../../common/util/promise' 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() 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 const json = await response.json()
.runWith({ memory: '8GB', timeoutSeconds: 540 })
.pubsub.schedule('every 15 minutes') if (response.ok) console.log(json)
.onRun(updateMetricsCore) 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() { export async function updateMetricsCore() {
console.log('Loading users') console.log('Loading users')
@ -35,11 +62,7 @@ export async function updateMetricsCore() {
const contracts = await getValues<Contract>(firestore.collection('contracts')) const contracts = await getValues<Contract>(firestore.collection('contracts'))
console.log('Loading portfolio history') console.log('Loading portfolio history')
const allPortfolioHistories = await getValues<PortfolioMetrics>( const userPortfolioHistory = await loadPortfolioHistory(users)
firestore
.collectionGroup('portfolioHistory')
.where('timestamp', '>', Date.now() - 31 * DAY_MS) // so it includes just over a month ago
)
console.log('Loading groups') console.log('Loading groups')
const groups = await getValues<Group>(firestore.collection('groups')) const groups = await getValues<Group>(firestore.collection('groups'))
@ -103,6 +126,7 @@ export async function updateMetricsCore() {
fields: { fields: {
volume24Hours: computeVolume(contractBets, now - DAY_MS), volume24Hours: computeVolume(contractBets, now - DAY_MS),
volume7Days: computeVolume(contractBets, now - DAY_MS * 7), volume7Days: computeVolume(contractBets, now - DAY_MS * 7),
elasticity: computeElasticity(contractBets, contract),
...cpmmFields, ...cpmmFields,
}, },
} }
@ -115,11 +139,10 @@ export async function updateMetricsCore() {
) )
const contractsByUser = groupBy(contracts, (contract) => contract.creatorId) const contractsByUser = groupBy(contracts, (contract) => contract.creatorId)
const betsByUser = groupBy(bets, (bet) => bet.userId) const betsByUser = groupBy(bets, (bet) => bet.userId)
const portfolioHistoryByUser = groupBy(allPortfolioHistories, (p) => p.userId)
const userMetrics = users.map((user) => { const userMetrics = users.map((user) => {
const currentBets = betsByUser[user.id] ?? [] const currentBets = betsByUser[user.id] ?? []
const portfolioHistory = portfolioHistoryByUser[user.id] ?? [] const portfolioHistory = userPortfolioHistory[user.id] ?? []
const userContracts = contractsByUser[user.id] ?? [] const userContracts = contractsByUser[user.id] ?? []
const newCreatorVolume = calculateCreatorVolume(userContracts) const newCreatorVolume = calculateCreatorVolume(userContracts)
const newPortfolio = calculateNewPortfolioMetrics( const newPortfolio = calculateNewPortfolioMetrics(
@ -127,14 +150,20 @@ export async function updateMetricsCore() {
contractsById, contractsById,
currentBets currentBets
) )
const lastPortfolio = last(portfolioHistory) const currPortfolio = portfolioHistory.current
const didPortfolioChange = const didPortfolioChange =
lastPortfolio === undefined || currPortfolio === undefined ||
lastPortfolio.balance !== newPortfolio.balance || currPortfolio.balance !== newPortfolio.balance ||
lastPortfolio.totalDeposits !== newPortfolio.totalDeposits || currPortfolio.totalDeposits !== newPortfolio.totalDeposits ||
lastPortfolio.investmentValue !== newPortfolio.investmentValue currPortfolio.investmentValue !== newPortfolio.investmentValue
const newProfit = calculateNewProfit(portfolioHistory, newPortfolio) const newProfit = calculateNewProfit(portfolioHistory, newPortfolio)
const metricsByContract = calculateMetricsByContract(
currentBets,
contractsById
)
const contractRatios = userContracts const contractRatios = userContracts
.map((contract) => { .map((contract) => {
if ( if (
@ -144,7 +173,7 @@ export async function updateMetricsCore() {
return 0 return 0
} }
const contractRatio = const contractRatio =
contract.flaggedByUsernames.length / (contract.uniqueBettorCount ?? 1) contract.flaggedByUsernames.length / (contract.uniqueBettorCount || 1)
return contractRatio return contractRatio
}) })
@ -152,7 +181,7 @@ export async function updateMetricsCore() {
const badResolutions = contractRatios.filter( const badResolutions = contractRatios.filter(
(ratio) => ratio > BAD_RESOLUTION_THRESHOLD (ratio) => ratio > BAD_RESOLUTION_THRESHOLD
) )
let newFractionResolvedCorrectly = 0 let newFractionResolvedCorrectly = 1
if (userContracts.length > 0) { if (userContracts.length > 0) {
newFractionResolvedCorrectly = newFractionResolvedCorrectly =
(userContracts.length - badResolutions.length) / userContracts.length (userContracts.length - badResolutions.length) / userContracts.length
@ -165,6 +194,7 @@ export async function updateMetricsCore() {
newProfit, newProfit,
didPortfolioChange, didPortfolioChange,
newFractionResolvedCorrectly, newFractionResolvedCorrectly,
metricsByContract,
} }
}) })
@ -180,17 +210,9 @@ export async function updateMetricsCore() {
const nextLoanByUser = keyBy(userPayouts, (payout) => payout.user.id) const nextLoanByUser = keyBy(userPayouts, (payout) => payout.user.id)
const userUpdates = userMetrics.map( const userUpdates = userMetrics.map(
({ ({ user, newCreatorVolume, newProfit, newFractionResolvedCorrectly }) => {
user,
newCreatorVolume,
newPortfolio,
newProfit,
didPortfolioChange,
newFractionResolvedCorrectly,
}) => {
const nextLoanCached = nextLoanByUser[user.id]?.payout ?? 0 const nextLoanCached = nextLoanByUser[user.id]?.payout ?? 0
return { return {
fieldUpdates: {
doc: firestore.collection('users').doc(user.id), doc: firestore.collection('users').doc(user.id),
fields: { fields: {
creatorVolumeCached: newCreatorVolume, creatorVolumeCached: newCreatorVolume,
@ -198,45 +220,51 @@ export async function updateMetricsCore() {
nextLoanCached, nextLoanCached,
fractionResolvedCorrectly: newFractionResolvedCorrectly, fractionResolvedCorrectly: newFractionResolvedCorrectly,
}, },
}, }
}
)
await writeAsync(firestore, userUpdates)
subcollectionUpdates: { const portfolioHistoryUpdates = filterDefined(
userMetrics.map(({ user, newPortfolio, didPortfolioChange }) => {
return didPortfolioChange
? {
doc: firestore doc: firestore
.collection('users') .collection('users')
.doc(user.id) .doc(user.id)
.collection('portfolioHistory') .collection('portfolioHistory')
.doc(), .doc(),
fields: didPortfolioChange ? newPortfolio : {}, fields: newPortfolio,
},
} }
: null
})
)
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, await writeAsync(firestore, contractMetricsUpdates, 'set')
userUpdates.map((u) => u.fieldUpdates)
)
await writeAsync(
firestore,
userUpdates
.filter((u) => !isEmpty(u.subcollectionUpdates.fields))
.map((u) => u.subcollectionUpdates),
'set'
)
log(`Updated metrics for ${users.length} users.`) log(`Updated metrics for ${users.length} users.`)
try { try {
const groupUpdates = groups.map((group, index) => { const groupUpdates = groups.map((group, index) => {
const groupContractIds = contractsByGroup[index] as GroupContractDoc[] const groupContractIds = contractsByGroup[index] as GroupContractDoc[]
const groupContracts = groupContractIds const groupContracts = filterDefined(
.map((e) => contractsById[e.contractId]) groupContractIds.map((e) => contractsById[e.contractId])
.filter((e) => e !== undefined) as Contract[] )
const bets = groupContracts.map((e) => { const bets = groupContracts.map((e) => betsByContract[e.id] ?? [])
if (e != null && e.id in betsByContract) {
return betsByContract[e.id] ?? []
} else {
return []
}
})
const creatorScores = scoreCreators(groupContracts) const creatorScores = scoreCreators(groupContracts)
const traderScores = scoreTraders(groupContracts, bets) const traderScores = scoreTraders(groupContracts, bets)
@ -270,3 +298,44 @@ const topUserScores = (scores: { [userId: string]: number }) => {
type GroupContractDoc = { contractId: string; createdTime: number } type GroupContractDoc = { contractId: string; createdTime: number }
const BAD_RESOLUTION_THRESHOLD = 0.1 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)
}

View File

@ -1,7 +1,8 @@
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import fetch from 'node-fetch' 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 { Contract } from '../../common/contract'
import { PrivateUser, User } from '../../common/user' import { PrivateUser, User } from '../../common/user'
import { Group } from '../../common/group' import { Group } from '../../common/group'
@ -47,7 +48,7 @@ export const writeAsync = async (
const batch = db.batch() const batch = db.batch()
for (const { doc, fields } of chunks[i]) { for (const { doc, fields } of chunks[i]) {
if (operationType === 'update') { if (operationType === 'update') {
batch.update(doc, fields) batch.update(doc, fields as any)
} else { } else {
batch.set(doc, fields) batch.set(doc, fields)
} }
@ -112,6 +113,12 @@ export const getAllPrivateUsers = async () => {
return users.docs.map((doc) => doc.data() as PrivateUser) 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) => { export const getUserByUsername = async (username: string) => {
const firestore = admin.firestore() const firestore = admin.firestore()
const snap = await 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) return snap.empty ? undefined : (snap.docs[0].data() as User)
} }
const updateUserBalance = (
userId: string,
delta: number,
isDeposit = false
) => {
const firestore = admin.firestore() const firestore = admin.firestore()
return firestore.runTransaction(async (transaction) => {
const updateUserBalance = (
transaction: Transaction,
userId: string,
balanceDelta: number,
depositDelta: number
) => {
const userDoc = firestore.doc(`users/${userId}`) const userDoc = firestore.doc(`users/${userId}`)
const userSnap = await transaction.get(userDoc)
if (!userSnap.exists) return
const user = userSnap.data() as User
const newUserBalance = user.balance + delta // Note: Balance is allowed to go negative.
transaction.update(userDoc, {
// if (newUserBalance < 0) balance: FieldValue.increment(balanceDelta),
// throw new Error( totalDeposits: FieldValue.increment(depositDelta),
// `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 })
}) })
} }
export const payUser = (userId: string, payout: number, isDeposit = false) => { export const payUser = (userId: string, payout: number, isDeposit = false) => {
if (!isFinite(payout)) throw new Error('Payout is not finite: ' + payout) 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 = ( export const chargeUser = (
@ -164,7 +162,67 @@ export const chargeUser = (
if (!isFinite(charge) || charge <= 0) if (!isFinite(charge) || charge <= 0)
throw new Error('User charge is not positive: ' + charge) 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) => { export const getContractPath = (contract: Contract) => {

View File

@ -2,12 +2,20 @@ import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import { Contract } from '../../common/contract' import { Contract } from '../../common/contract'
import { getGroup, getPrivateUser, getUser, getValues, log } from './utils' import {
getAllPrivateUsers,
getGroup,
getPrivateUser,
getUser,
getValues,
isProd,
log,
} from './utils'
import { createRNG, shuffle } from '../../common/util/random' import { createRNG, shuffle } from '../../common/util/random'
import { DAY_MS, HOUR_MS } from '../../common/util/time' import { DAY_MS, HOUR_MS } from '../../common/util/time'
import { filterDefined } from '../../common/util/array' import { filterDefined } from '../../common/util/array'
import { Follow } from '../../common/follow' import { Follow } from '../../common/follow'
import { countBy, uniqBy } from 'lodash' import { countBy, uniq, uniqBy } from 'lodash'
import { sendInterestingMarketsEmail } from './emails' import { sendInterestingMarketsEmail } from './emails'
export const weeklyMarketsEmails = functions export const weeklyMarketsEmails = functions
@ -37,27 +45,27 @@ export async function getTrendingContracts() {
export async function sendTrendingMarketsEmailsToAllUsers() { export async function sendTrendingMarketsEmailsToAllUsers() {
const numContractsToSend = 6 const numContractsToSend = 6
// const privateUsers = const privateUsers = isProd()
// isProd() ? await getAllPrivateUsers()
// ? await getAllPrivateUsers() : filterDefined([
// filterDefined([ await getPrivateUser('6hHpzvRG0pMq8PNJs7RZj2qlZGn2'), // dev Ian
// await getPrivateUser('6hHpzvRG0pMq8PNJs7RZj2qlZGn2'), // dev Ian
// ])
const privateUsersToSendEmailsTo =
// get all users that haven't unsubscribed from weekly emails
// isProd()
// ? privateUsers
// .filter((user) => {
// user.notificationPreferences.trending_markets.includes('email') &&
// !user.weeklyTrendingEmailSent
// })
// .slice(125) // Send the emails out in batches
// :
// privateUsers
filterDefined([
await getPrivateUser('AJwLWoo3xue32XIiAVrL5SyR1WB2'), // prod Ian
await getPrivateUser('FptiiMZZ6dQivihLI8MYFQ6ypSw1'),
]) ])
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( log(
'Sending weekly trending emails to', 'Sending weekly trending emails to',
@ -75,11 +83,13 @@ export async function sendTrendingMarketsEmailsToAllUsers() {
!contract.groupSlugs?.includes('manifold-features') && !contract.groupSlugs?.includes('manifold-features') &&
!contract.groupSlugs?.includes('manifold-6748e065087e') !contract.groupSlugs?.includes('manifold-6748e065087e')
) )
.slice(0, 20) .slice(0, 50)
// log(
// `Found ${trendingContracts.length} trending contracts:\n`, const uniqueTrendingContracts = removeSimilarQuestions(
// trendingContracts.map((c) => c.question).join('\n ') trendingContracts,
// ) trendingContracts,
true
).slice(0, 20)
await Promise.all( await Promise.all(
privateUsersToSendEmailsTo.map(async (privateUser) => { privateUsersToSendEmailsTo.map(async (privateUser) => {
@ -87,28 +97,54 @@ export async function sendTrendingMarketsEmailsToAllUsers() {
log(`No email for ${privateUser.username}`) log(`No email for ${privateUser.username}`)
return return
} }
const unbetOnFollowedMarkets = await getUserUnBetOnFollowsMarkets(
privateUser.id
)
const unBetOnGroupMarkets = await getUserUnBetOnGroupsMarkets(
privateUser.id,
unbetOnFollowedMarkets
)
const similarBettorsMarkets = await getSimilarBettorsMarkets(
privateUser.id,
unBetOnGroupMarkets
)
const marketsAvailableToSend = uniqBy( const marketsAvailableToSend = uniqBy(
[ [
...(await getUserUnBetOnFollowsMarkets( ...chooseRandomSubset(unbetOnFollowedMarkets, 2),
privateUser.id, // // Most people will belong to groups but may not follow other users,
privateUser.id // so choose more from the other subsets if the followed markets is sparse
)), ...chooseRandomSubset(
...(await getUserUnBetOnGroupsMarkets(privateUser.id)), unBetOnGroupMarkets,
...(await getSimilarBettorsMarkets(privateUser.id)), unbetOnFollowedMarkets.length < 2 ? 3 : 2
),
...chooseRandomSubset(
similarBettorsMarkets,
unbetOnFollowedMarkets.length < 2 ? 3 : 2
),
], ],
(contract) => contract.id (contract) => contract.id
) )
// at least send them trending contracts if nothing else // // at least send them trending contracts if nothing else
if (marketsAvailableToSend.length < numContractsToSend) if (marketsAvailableToSend.length < numContractsToSend) {
const trendingMarketsToSend =
numContractsToSend - marketsAvailableToSend.length
log(
`not enough personalized markets, sending ${trendingMarketsToSend} trending`
)
marketsAvailableToSend.push( marketsAvailableToSend.push(
...trendingContracts ...removeSimilarQuestions(
uniqueTrendingContracts,
marketsAvailableToSend,
false
)
.filter( .filter(
(contract) => (contract) => !contract.uniqueBettorIds?.includes(privateUser.id)
!contract.uniqueBettorIds?.includes(privateUser.id) &&
!marketsAvailableToSend.map((c) => c.id).includes(contract.id)
) )
.slice(0, numContractsToSend - marketsAvailableToSend.length) .slice(0, trendingMarketsToSend)
) )
}
if (marketsAvailableToSend.length < numContractsToSend) { if (marketsAvailableToSend.length < numContractsToSend) {
log( log(
@ -129,17 +165,12 @@ export async function sendTrendingMarketsEmailsToAllUsers() {
const user = await getUser(privateUser.id) const user = await getUser(privateUser.id)
if (!user) return if (!user) return
console.log( log(
'sending contracts:', 'sending contracts:',
contractsToSend.map((c) => [c.question, c.popularityScore]) 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 // 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 sendInterestingMarketsEmail(user, privateUser, contractsToSend)
await sendInterestingMarketsEmail(
user,
privateUsersToSendEmailsTo[0],
contractsToSend
)
await firestore.collection('private-users').doc(user.id).update({ await firestore.collection('private-users').doc(user.id).update({
weeklyTrendingEmailSent: true, weeklyTrendingEmailSent: true,
}) })
@ -147,20 +178,12 @@ export async function sendTrendingMarketsEmailsToAllUsers() {
) )
} }
// TODO: figure out a good minimum popularity score to filter by const MINIMUM_POPULARITY_SCORE = 10
const MINIMUM_POPULARITY_SCORE = 2
const getUserUnBetOnFollowsMarkets = async ( const getUserUnBetOnFollowsMarkets = async (userId: string) => {
userId: string,
unBetOnByUserId: string
) => {
const follows = await getValues<Follow>( const follows = await getValues<Follow>(
firestore.collection('users').doc(userId).collection('follows') firestore.collection('users').doc(userId).collection('follows')
) )
console.log(
'follows',
follows.map((f) => f.userId)
)
const unBetOnContractsFromFollows = await Promise.all( const unBetOnContractsFromFollows = await Promise.all(
follows.map(async (follow) => { follows.map(async (follow) => {
@ -181,29 +204,40 @@ const getUserUnBetOnFollowsMarkets = async (
) )
return openContracts.filter( return openContracts.filter(
(contract) => !contract.uniqueBettorIds?.includes(unBetOnByUserId) (contract) => !contract.uniqueBettorIds?.includes(userId)
) )
}) })
) )
const sortedMarkets = unBetOnContractsFromFollows const sortedMarkets = uniqBy(
.flat() unBetOnContractsFromFollows.flat(),
(contract) => contract.id
)
.filter( .filter(
(contract) => (contract) =>
contract.popularityScore !== undefined && contract.popularityScore !== undefined &&
contract.popularityScore > MINIMUM_POPULARITY_SCORE contract.popularityScore > MINIMUM_POPULARITY_SCORE
) )
.sort((a, b) => (b.popularityScore ?? 0) - (a.popularityScore ?? 0)) .sort((a, b) => (b.popularityScore ?? 0) - (a.popularityScore ?? 0))
console.log(
'sorted top 10 follow Markets', const uniqueSortedMarkets = removeSimilarQuestions(
sortedMarkets sortedMarkets,
.slice(0, 10) sortedMarkets,
.map((c) => [c.question, c.popularityScore, c.creatorId]) true
) )
return sortedMarkets
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) => { const getUserUnBetOnGroupsMarkets = async (
userId: string,
differentThanTheseContracts: Contract[]
) => {
const snap = await firestore const snap = await firestore
.collectionGroup('groupMembers') .collectionGroup('groupMembers')
.where('userId', '==', userId) .where('userId', '==', userId)
@ -215,10 +249,8 @@ const getUserUnBetOnGroupsMarkets = async (userId: string) => {
const groups = filterDefined( const groups = filterDefined(
await Promise.all(groupIds.map(async (groupId) => await getGroup(groupId))) await Promise.all(groupIds.map(async (groupId) => await getGroup(groupId)))
) )
console.log( if (groups.length === 0) return []
'groups',
groups.map((g) => g.name)
)
const unBetOnContractsFromGroups = await Promise.all( const unBetOnContractsFromGroups = await Promise.all(
groups.map(async (group) => { groups.map(async (group) => {
const unresolvedContracts = await getValues<Contract>( const unresolvedContracts = await getValues<Contract>(
@ -242,37 +274,53 @@ const getUserUnBetOnGroupsMarkets = async (userId: string) => {
) )
}) })
) )
const sortedMarkets = unBetOnContractsFromGroups
.flat() const sortedMarkets = uniqBy(
unBetOnContractsFromGroups.flat(),
(contract) => contract.id
)
.filter( .filter(
(contract) => (contract) =>
contract.popularityScore !== undefined && contract.popularityScore !== undefined &&
contract.popularityScore > MINIMUM_POPULARITY_SCORE contract.popularityScore > MINIMUM_POPULARITY_SCORE
) )
.sort((a, b) => (b.popularityScore ?? 0) - (a.popularityScore ?? 0)) .sort((a, b) => (b.popularityScore ?? 0) - (a.popularityScore ?? 0))
console.log(
'top 10 sorted group Markets', const uniqueSortedMarkets = removeSimilarQuestions(
sortedMarkets sortedMarkets,
.slice(0, 10) sortedMarkets,
.map((c) => [c.question, c.popularityScore, c.groupSlugs]) true
) )
return sortedMarkets 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 // Gets markets followed by similar bettors and bet on by similar bettors
const getSimilarBettorsMarkets = async (userId: string) => { const getSimilarBettorsMarkets = async (
userId: string,
differentThanTheseContracts: Contract[]
) => {
// get contracts with unique bettor ids with this user // get contracts with unique bettor ids with this user
const contractsUserHasBetOn = await getValues<Contract>( const contractsUserHasBetOn = await getValues<Contract>(
firestore firestore
.collection('contracts') .collection('contracts')
.where('uniqueBettorIds', 'array-contains', userId) .where('uniqueBettorIds', 'array-contains', userId)
) )
if (contractsUserHasBetOn.length === 0) return []
// count the number of times each unique bettor id appears on those contracts // count the number of times each unique bettor id appears on those contracts
const bettorIdsToCounts = countBy( const bettorIdsToCounts = countBy(
contractsUserHasBetOn.map((contract) => contract.uniqueBettorIds).flat(), contractsUserHasBetOn.map((contract) => contract.uniqueBettorIds).flat(),
(bettorId) => bettorId (bettorId) => bettorId
) )
console.log('bettorIdCounts', bettorIdsToCounts)
// sort by number of times they appear with at least 2 appearances // sort by number of times they appear with at least 2 appearances
const sortedBettorIds = Object.entries(bettorIdsToCounts) const sortedBettorIds = Object.entries(bettorIdsToCounts)
@ -283,10 +331,11 @@ const getSimilarBettorsMarkets = async (userId: string) => {
// get the top 10 most similar bettors (excluding this user) // get the top 10 most similar bettors (excluding this user)
const similarBettorIds = sortedBettorIds.slice(0, 10) const similarBettorIds = sortedBettorIds.slice(0, 10)
console.log('top sortedBettorIds', similarBettorIds) if (similarBettorIds.length === 0) return []
// get contracts with unique bettor ids with this user // get contracts with unique bettor ids with this user
const contractsSimilarBettorsHaveBetOn = ( const contractsSimilarBettorsHaveBetOn = uniqBy(
(
await getValues<Contract>( await getValues<Contract>(
firestore firestore
.collection('contracts') .collection('contracts')
@ -296,13 +345,18 @@ const getSimilarBettorsMarkets = async (userId: string) => {
similarBettorIds.slice(0, 10) similarBettorIds.slice(0, 10)
) )
.orderBy('popularityScore', 'desc') .orderBy('popularityScore', 'desc')
.limit(100) .limit(200)
)
).filter(
(contract) =>
!contract.uniqueBettorIds?.includes(userId) &&
(contract.popularityScore ?? 0) > MINIMUM_POPULARITY_SCORE
),
(contract) => contract.id
) )
).filter((contract) => !contract.uniqueBettorIds?.includes(userId))
// sort the contracts by how many times similar bettor ids are in their unique bettor ids array // sort the contracts by how many times similar bettor ids are in their unique bettor ids array
const sortedContractsToAppearancesInSimilarBettorsBets = const sortedContractsInSimilarBettorsBets = contractsSimilarBettorsHaveBetOn
contractsSimilarBettorsHaveBetOn
.map((contract) => { .map((contract) => {
const appearances = contract.uniqueBettorIds?.filter((bettorId) => const appearances = contract.uniqueBettorIds?.filter((bettorId) =>
similarBettorIds.includes(bettorId) similarBettorIds.includes(bettorId)
@ -310,30 +364,79 @@ const getSimilarBettorsMarkets = async (userId: string) => {
return [contract, appearances] as [Contract, number] return [contract, appearances] as [Contract, number]
}) })
.sort((a, b) => b[1] - a[1]) .sort((a, b) => b[1] - a[1])
console.log( .map((entry) => entry[0])
'sortedContractsToAppearancesInSimilarBettorsBets',
sortedContractsToAppearancesInSimilarBettorsBets.map((c) => [ const uniqueSortedContractsInSimilarBettorsBets = removeSimilarQuestions(
c[0].question, sortedContractsInSimilarBettorsBets,
c[1], sortedContractsInSimilarBettorsBets,
]) true
) )
const topMostSimilarContracts = const topMostSimilarContracts = removeSimilarQuestions(
sortedContractsToAppearancesInSimilarBettorsBets.map((entry) => entry[0]) uniqueSortedContractsInSimilarBettorsBets,
differentThanTheseContracts,
false
).slice(0, 10)
console.log( // log(
'top 10 sortedContractsToAppearancesInSimilarBettorsBets', // 'top 10 sorted contracts other similar bettors have bet on',
topMostSimilarContracts // topMostSimilarContracts.map((c) => c.question)
.map((c) => [ // )
c.question,
c.uniqueBettorIds?.filter((bid) => similarBettorIds.includes(bid)),
])
.slice(0, 10)
)
return topMostSimilarContracts 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 const fiveMinutes = 5 * 60 * 1000
const seed = Math.round(Date.now() / fiveMinutes).toString() const seed = Math.round(Date.now() / fiveMinutes).toString()
const rng = createRNG(seed) const rng = createRNG(seed)
@ -342,3 +445,40 @@ function chooseRandomSubset(contracts: Contract[], count: number) {
shuffle(contracts, rng) shuffle(contracts, rng)
return contracts.slice(0, count) 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',
]

View File

@ -112,13 +112,12 @@ export async function sendPortfolioUpdateEmailsToAllUsers() {
) )
) )
) )
log('Found', contractsUsersBetOn.length, 'contracts')
let count = 0
await Promise.all( await Promise.all(
privateUsersToSendEmailsTo.map(async (privateUser) => { privateUsersToSendEmailsTo.map(async (privateUser) => {
const user = await getUser(privateUser.id) const user = await getUser(privateUser.id)
// Don't send to a user unless they're over 5 days old // Don't send to a user unless they're over 5 days old
if (!user || user.createdTime > Date.now() - 5 * DAY_MS) return if (!user || user.createdTime > Date.now() - 5 * DAY_MS)
return await setEmailFlagAsSent(privateUser.id)
const userBets = usersBets[privateUser.id] as Bet[] const userBets = usersBets[privateUser.id] as Bet[]
const contractsUserBetOn = contractsUsersBetOn.filter((contract) => const contractsUserBetOn = contractsUsersBetOn.filter((contract) =>
userBets.some((bet) => bet.contractId === contract.id) userBets.some((bet) => bet.contractId === contract.id)
@ -219,13 +218,6 @@ export async function sendPortfolioUpdateEmailsToAllUsers() {
(differences) => Math.abs(differences.profit) (differences) => Math.abs(differences.profit)
).reverse() ).reverse()
log(
'Found',
investmentValueDifferences.length,
'investment differences for user',
privateUser.id
)
const [winningInvestments, losingInvestments] = partition( const [winningInvestments, losingInvestments] = partition(
investmentValueDifferences.filter( investmentValueDifferences.filter(
(diff) => diff.pastValue > 0.01 && Math.abs(diff.profit) > 1 (diff) => diff.pastValue > 0.01 && Math.abs(diff.profit) > 1
@ -245,29 +237,28 @@ export async function sendPortfolioUpdateEmailsToAllUsers() {
usersToContractsCreated[privateUser.id].length === 0 usersToContractsCreated[privateUser.id].length === 0
) { ) {
log( log(
'No bets in last week, no market movers, no markets created. Not sending an email.' `No bets in last week, no market movers, no markets created. Not sending an email to ${privateUser.email} .`
) )
await firestore.collection('private-users').doc(privateUser.id).update({ return await setEmailFlagAsSent(privateUser.id)
weeklyPortfolioUpdateEmailSent: true,
})
return
} }
// Set the flag beforehand just to be safe
await setEmailFlagAsSent(privateUser.id)
await sendWeeklyPortfolioUpdateEmail( await sendWeeklyPortfolioUpdateEmail(
user, user,
privateUser, privateUser,
topInvestments.concat(worstInvestments) as PerContractInvestmentsData[], topInvestments.concat(worstInvestments) as PerContractInvestmentsData[],
performanceData performanceData
) )
await firestore.collection('private-users').doc(privateUser.id).update({
weeklyPortfolioUpdateEmailSent: true,
})
log('Sent weekly portfolio update email to', privateUser.email)
count++
log('sent out emails to users:', count)
}) })
) )
} }
async function setEmailFlagAsSent(privateUserId: string) {
await firestore.collection('private-users').doc(privateUserId).update({
weeklyPortfolioUpdateEmailSent: true,
})
}
export type PerContractInvestmentsData = { export type PerContractInvestmentsData = {
questionTitle: string questionTitle: string
questionUrl: string questionUrl: string

View File

@ -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()

View File

@ -24,8 +24,5 @@
"prettier": "2.7.1", "prettier": "2.7.1",
"ts-node": "10.9.1", "ts-node": "10.9.1",
"typescript": "4.8.2" "typescript": "4.8.2"
},
"resolutions": {
"@types/react": "17.0.43"
} }
} }

View File

@ -22,8 +22,9 @@ module.exports = {
'@next/next/no-typos': 'off', '@next/next/no-typos': 'off',
'linebreak-style': ['error', 'unix'], 'linebreak-style': ['error', 'unix'],
'lodash/import-scope': [2, 'member'], 'lodash/import-scope': [2, 'member'],
'unused-imports/no-unused-imports': 'error', 'unused-imports/no-unused-imports': 'warn',
}, },
ignorePatterns: ['/public/mtg/*'],
env: { env: {
browser: true, browser: true,
node: true, node: true,

1
web/.gitignore vendored
View File

@ -3,3 +3,4 @@
node_modules node_modules
out out
tsconfig.tsbuildinfo tsconfig.tsbuildinfo
.env*

10
web/components/NoSEO.tsx Normal file
View File

@ -0,0 +1,10 @@
import Head from 'next/head'
/** Exclude page from search results */
export function NoSEO() {
return (
<Head>
<meta name="robots" content="noindex,follow" />
</Head>
)
}

View File

@ -15,7 +15,7 @@ export function SEO(props: {
return ( return (
<Head> <Head>
<title>{title} | Manifold Markets</title> <title>{`${title} | Manifold Markets`}</title>
<meta <meta
property="og:title" property="og:title"

View File

@ -35,7 +35,7 @@ export function AddFundsModal(props: {
<div className="text-xl">{manaToUSD(amountSelected)}</div> <div className="text-xl">{manaToUSD(amountSelected)}</div>
</div> </div>
<div className="modal-action"> <div className="flex">
<Button color="gray-white" onClick={() => setOpen(false)}> <Button color="gray-white" onClick={() => setOpen(false)}>
Back Back
</Button> </Button>

View File

@ -6,6 +6,9 @@ import { Col } from './layout/col'
import { ENV_CONFIG } from 'common/envs/constants' import { ENV_CONFIG } from 'common/envs/constants'
import { Row } from './layout/row' import { Row } from './layout/row'
import { AddFundsModal } from './add-funds-modal' import { AddFundsModal } from './add-funds-modal'
import { Input } from './input'
import Slider from 'rc-slider'
import 'rc-slider/assets/index.css'
export function AmountInput(props: { export function AmountInput(props: {
amount: number | undefined amount: number | undefined
@ -39,18 +42,13 @@ export function AmountInput(props: {
return ( return (
<> <>
<Col className={className}> <Col className={clsx('relative', className)}>
<label className="font-sm md:font-lg relative"> <label className="font-sm md:font-lg relative">
<span className="text-greyscale-4 absolute top-1/2 my-auto ml-2 -translate-y-1/2"> <span className="text-greyscale-4 absolute top-1/2 my-auto ml-2 -translate-y-1/2">
{label} {label}
</span> </span>
<input <Input
className={clsx( className={clsx('w-24 pl-9 !text-base md:w-auto', inputClassName)}
'placeholder:text-greyscale-4 border-greyscale-2 rounded-md pl-9',
error && 'input-error',
'w-24 md:w-auto',
inputClassName
)}
ref={inputRef} ref={inputRef}
type="text" type="text"
pattern="[0-9]*" pattern="[0-9]*"
@ -58,13 +56,14 @@ export function AmountInput(props: {
placeholder="0" placeholder="0"
maxLength={6} maxLength={6}
value={amount ?? ''} value={amount ?? ''}
error={!!error}
disabled={disabled} disabled={disabled}
onChange={(e) => onAmountChange(e.target.value)} onChange={(e) => onAmountChange(e.target.value)}
/> />
</label> </label>
{error && ( {error && (
<div className="absolute mt-11 whitespace-nowrap text-xs font-medium tracking-wide text-red-500"> <div className="absolute -bottom-5 whitespace-nowrap text-xs font-medium tracking-wide text-red-500">
{error === 'Insufficient balance' ? ( {error === 'Insufficient balance' ? (
<> <>
Not enough funds. Not enough funds.
@ -148,7 +147,7 @@ export function BuyAmountInput(props: {
return ( return (
<> <>
<Row className="gap-4"> <Row className="items-center gap-4">
<AmountInput <AmountInput
amount={amount} amount={amount}
onChange={onAmountChange} onChange={onAmountChange}
@ -160,14 +159,23 @@ export function BuyAmountInput(props: {
inputRef={inputRef} inputRef={inputRef}
/> />
{showSlider && ( {showSlider && (
<input <Slider
type="range" min={0}
min="0" max={205}
max="205"
value={getRaw(amount ?? 0)} value={getRaw(amount ?? 0)}
onChange={(e) => onAmountChange(parseRaw(parseInt(e.target.value)))} onChange={(value) => onAmountChange(parseRaw(value as number))}
className="range range-lg only-thumb my-auto align-middle xl:hidden" className="mx-4 !h-4 xl:hidden [&>.rc-slider-rail]:bg-gray-200 [&>.rc-slider-track]:bg-indigo-400 [&>.rc-slider-handle]:bg-indigo-400"
step="5" railStyle={{ height: 16, top: 0, left: 0 }}
trackStyle={{ height: 16, top: 0 }}
handleStyle={{
height: 32,
width: 32,
opacity: 1,
border: 'none',
boxShadow: 'none',
top: -2,
}}
step={5}
/> />
)} )}
</Row> </Row>

View File

@ -126,7 +126,10 @@ export function AnswerBetPanel(props: {
</div> </div>
{!isModal && ( {!isModal && (
<button className="btn-ghost btn-circle" onClick={closePanel}> <button
className="hover:bg-greyscale-2 rounded-full"
onClick={closePanel}
>
<XIcon <XIcon
className="mx-auto h-8 w-8 text-gray-500" className="mx-auto h-8 w-8 text-gray-500"
aria-hidden="true" aria-hidden="true"
@ -192,6 +195,7 @@ export function AnswerBetPanel(props: {
isSubmitting={isSubmitting} isSubmitting={isSubmitting}
disabled={!!betDisabled} disabled={!!betDisabled}
color={'indigo'} color={'indigo'}
actionLabel="Buy"
/> />
) : ( ) : (
<BetSignUpPrompt /> <BetSignUpPrompt />

View File

@ -10,6 +10,7 @@ import { formatPercent } from 'common/util/format'
import { getDpmOutcomeProbability } from 'common/calculate-dpm' import { getDpmOutcomeProbability } from 'common/calculate-dpm'
import { tradingAllowed } from 'web/lib/firebase/contracts' import { tradingAllowed } from 'web/lib/firebase/contracts'
import { Linkify } from '../linkify' import { Linkify } from '../linkify'
import { Input } from '../input'
export function AnswerItem(props: { export function AnswerItem(props: {
answer: Answer answer: Answer
@ -74,8 +75,8 @@ export function AnswerItem(props: {
<Row className="items-center justify-end gap-4 self-end sm:self-start"> <Row className="items-center justify-end gap-4 self-end sm:self-start">
{!wasResolvedTo && {!wasResolvedTo &&
(showChoice === 'checkbox' ? ( (showChoice === 'checkbox' ? (
<input <Input
className="input input-bordered w-24 justify-self-end text-2xl" className="w-24 justify-self-end !text-2xl"
type="number" type="number"
placeholder={`${roundedProb}`} placeholder={`${roundedProb}`}
maxLength={9} maxLength={9}
@ -92,15 +93,15 @@ export function AnswerItem(props: {
<div <div
className={clsx( className={clsx(
'text-2xl', 'text-2xl',
tradingAllowed(contract) ? 'text-green-500' : 'text-gray-500' tradingAllowed(contract) ? 'text-teal-500' : 'text-gray-500'
)} )}
> >
{probPercent} {probPercent}
</div> </div>
))} ))}
{showChoice ? ( {showChoice ? (
<div className="form-control py-1"> <div className="flex flex-col py-1">
<label className="label cursor-pointer gap-3"> <label className="cursor-pointer gap-3 px-1 py-2">
<span className="">Choose this answer</span> <span className="">Choose this answer</span>
{showChoice === 'radio' && ( {showChoice === 'radio' && (
<input <input
@ -143,7 +144,7 @@ export function AnswerItem(props: {
<div <div
className={clsx( className={clsx(
'text-xl', 'text-xl',
resolution === 'MKT' ? 'text-blue-700' : 'text-green-700' resolution === 'MKT' ? 'text-blue-700' : 'text-teal-600'
)} )}
> >
Chosen{' '} Chosen{' '}

View File

@ -10,6 +10,7 @@ import { ChooseCancelSelector } from '../yes-no-selector'
import { ResolveConfirmationButton } from '../confirmation-button' import { ResolveConfirmationButton } from '../confirmation-button'
import { removeUndefinedProps } from 'common/util/object' import { removeUndefinedProps } from 'common/util/object'
import { BETTOR, PAST_BETS } from 'common/user' import { BETTOR, PAST_BETS } from 'common/user'
import { Button } from '../button'
export function AnswerResolvePanel(props: { export function AnswerResolvePanel(props: {
isAdmin: boolean isAdmin: boolean
@ -109,14 +110,14 @@ export function AnswerResolvePanel(props: {
)} )}
> >
{resolveOption && ( {resolveOption && (
<button <Button
className="btn btn-ghost" color="gray-white"
onClick={() => { onClick={() => {
setResolveOption(undefined) setResolveOption(undefined)
}} }}
> >
Clear Clear
</button> </Button>
)} )}
<ResolveConfirmationButton <ResolveConfirmationButton

View File

@ -23,14 +23,23 @@ import { Linkify } from 'web/components/linkify'
import { Button } from 'web/components/button' import { Button } from 'web/components/button'
import { useAdmin } from 'web/hooks/use-admin' import { useAdmin } from 'web/hooks/use-admin'
import { needsAdminToResolve } from 'web/pages/[username]/[contractSlug]' import { needsAdminToResolve } from 'web/pages/[username]/[contractSlug]'
import { CATEGORY_COLORS } from '../charts/contract/choice' import { CHOICE_ANSWER_COLORS } from '../charts/contract/choice'
import { useChartAnswers } from '../charts/contract/choice' import { useChartAnswers } from '../charts/contract/choice'
import { ChatIcon } from '@heroicons/react/outline'
export function getAnswerColor(answer: Answer, answersArray: string[]) {
const colorIndex = answersArray.indexOf(answer.text)
return colorIndex != undefined && colorIndex < CHOICE_ANSWER_COLORS.length
? CHOICE_ANSWER_COLORS[colorIndex]
: '#B1B1C7'
}
export function AnswersPanel(props: { export function AnswersPanel(props: {
contract: FreeResponseContract | MultipleChoiceContract contract: FreeResponseContract | MultipleChoiceContract
onAnswerCommentClick: (answer: Answer) => void
}) { }) {
const isAdmin = useAdmin() const isAdmin = useAdmin()
const { contract } = props const { contract, onAnswerCommentClick } = props
const { creatorId, resolution, resolutions, totalBets, outcomeType } = const { creatorId, resolution, resolutions, totalBets, outcomeType } =
contract contract
const [showAllAnswers, setShowAllAnswers] = useState(false) const [showAllAnswers, setShowAllAnswers] = useState(false)
@ -105,8 +114,8 @@ export function AnswersPanel(props: {
? 'checkbox' ? 'checkbox'
: undefined : undefined
const colorSortedAnswer = useChartAnswers(contract).map( const answersArray = useChartAnswers(contract).map(
(value, _index) => value.text (answer, _index) => answer.text
) )
return ( return (
@ -137,7 +146,8 @@ export function AnswersPanel(props: {
key={item.id} key={item.id}
answer={item} answer={item}
contract={contract} contract={contract}
colorIndex={colorSortedAnswer.indexOf(item.text)} onAnswerCommentClick={onAnswerCommentClick}
color={getAnswerColor(item, answersArray)}
/> />
))} ))}
{hasZeroBetAnswers && !showAllAnswers && ( {hasZeroBetAnswers && !showAllAnswers && (
@ -157,9 +167,7 @@ export function AnswersPanel(props: {
<div className="pb-4 text-gray-500">No answers yet...</div> <div className="pb-4 text-gray-500">No answers yet...</div>
)} )}
{outcomeType === 'FREE_RESPONSE' && {outcomeType === 'FREE_RESPONSE' && tradingAllowed(contract) && (
tradingAllowed(contract) &&
(!resolveOption || resolveOption === 'CANCEL') && (
<CreateAnswerPanel contract={contract} /> <CreateAnswerPanel contract={contract} />
)} )}
@ -184,15 +192,15 @@ export function AnswersPanel(props: {
function OpenAnswer(props: { function OpenAnswer(props: {
contract: FreeResponseContract | MultipleChoiceContract contract: FreeResponseContract | MultipleChoiceContract
answer: Answer answer: Answer
colorIndex: number | undefined color: string
onAnswerCommentClick: (answer: Answer) => void
}) { }) {
const { answer, contract, colorIndex } = props const { answer, contract, onAnswerCommentClick, color } = props
const { username, avatarUrl, text } = answer const { username, avatarUrl, text } = answer
const prob = getDpmOutcomeProbability(contract.totalShares, answer.id) const prob = getDpmOutcomeProbability(contract.totalShares, answer.id)
const probPercent = formatPercent(prob) const probPercent = formatPercent(prob)
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const color = const colorWidth = 100 * Math.max(prob, 0.01)
colorIndex != undefined ? CATEGORY_COLORS[colorIndex] : '#B1B1C7'
return ( return (
<Col className="my-1 px-2"> <Col className="my-1 px-2">
@ -208,9 +216,12 @@ function OpenAnswer(props: {
<Col <Col
className={clsx( className={clsx(
'bg-greyscale-1 relative w-full rounded-lg transition-all', 'relative w-full rounded-lg transition-all',
tradingAllowed(contract) ? 'text-greyscale-7' : 'text-greyscale-5' tradingAllowed(contract) ? 'text-greyscale-7' : 'text-greyscale-5'
)} )}
style={{
background: `linear-gradient(to right, ${color}90 ${colorWidth}%, #FBFBFF ${colorWidth}%)`,
}}
> >
<Row className="z-20 -mb-1 justify-between gap-2 py-2 px-3"> <Row className="z-20 -mb-1 justify-between gap-2 py-2 px-3">
<Row> <Row>
@ -219,10 +230,7 @@ function OpenAnswer(props: {
username={username} username={username}
avatarUrl={avatarUrl} avatarUrl={avatarUrl}
/> />
<Linkify <Linkify className="text-md whitespace-pre-line" text={text} />
className="text-md cursor-pointer whitespace-pre-line"
text={text}
/>
</Row> </Row>
<Row className="gap-2"> <Row className="gap-2">
<div className="my-auto text-xl">{probPercent}</div> <div className="my-auto text-xl">{probPercent}</div>
@ -236,13 +244,16 @@ function OpenAnswer(props: {
BUY BUY
</Button> </Button>
)} )}
{
<button
className="p-1"
onClick={() => onAnswerCommentClick(answer)}
>
<ChatIcon className="text-greyscale-4 hover:text-greyscale-6 h-5 w-5 transition-colors" />
</button>
}
</Row> </Row>
</Row> </Row>
<hr
color={color}
className="absolute z-0 h-full w-full rounded-l-lg border-none opacity-30"
style={{ width: `${100 * Math.max(prob, 0.01)}%` }}
/>
</Col> </Col>
</Col> </Col>
) )

View File

@ -1,6 +1,5 @@
import clsx from 'clsx' import clsx from 'clsx'
import React, { useState } from 'react' import React, { useState } from 'react'
import Textarea from 'react-expanding-textarea'
import { findBestMatch } from 'string-similarity' import { findBestMatch } from 'string-similarity'
import { FreeResponseContract } from 'common/contract' import { FreeResponseContract } from 'common/contract'
@ -26,6 +25,7 @@ import { MAX_ANSWER_LENGTH } from 'common/answer'
import { withTracking } from 'web/lib/service/analytics' import { withTracking } from 'web/lib/service/analytics'
import { lowerCase } from 'lodash' import { lowerCase } from 'lodash'
import { Button } from '../button' import { Button } from '../button'
import { ExpandingInput } from '../expanding-input'
export function CreateAnswerPanel(props: { contract: FreeResponseContract }) { export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
const { contract } = props const { contract } = props
@ -122,10 +122,10 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
<Col className="gap-4 rounded"> <Col className="gap-4 rounded">
<Col className="flex-1 gap-2 px-4 xl:px-0"> <Col className="flex-1 gap-2 px-4 xl:px-0">
<div className="mb-1">Add your answer</div> <div className="mb-1">Add your answer</div>
<Textarea <ExpandingInput
value={text} value={text}
onChange={(e) => changeAnswer(e.target.value)} onChange={(e) => changeAnswer(e.target.value)}
className="textarea textarea-bordered w-full resize-none" className="w-full"
placeholder="Type your answer..." placeholder="Type your answer..."
rows={1} rows={1}
maxLength={MAX_ANSWER_LENGTH} maxLength={MAX_ANSWER_LENGTH}
@ -197,17 +197,15 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
</> </>
)} )}
{user ? ( {user ? (
<button <Button
className={clsx( color="green"
'btn mt-2', size="lg"
canSubmit ? 'btn-outline' : 'btn-disabled', loading={isSubmitting}
isSubmitting && 'loading'
)}
disabled={!canSubmit} disabled={!canSubmit}
onClick={withTracking(submitAnswer, 'submit answer')} onClick={withTracking(submitAnswer, 'submit answer')}
> >
Submit Submit
</button> </Button>
) : ( ) : (
text && ( text && (
<Button <Button

View File

@ -1,8 +1,8 @@
import { MAX_ANSWER_LENGTH } from 'common/answer' import { MAX_ANSWER_LENGTH } from 'common/answer'
import Textarea from 'react-expanding-textarea'
import { XIcon } from '@heroicons/react/solid' import { XIcon } from '@heroicons/react/solid'
import { Col } from '../layout/col' import { Col } from '../layout/col'
import { Row } from '../layout/row' import { Row } from '../layout/row'
import { ExpandingInput } from '../expanding-input'
export function MultipleChoiceAnswers(props: { export function MultipleChoiceAnswers(props: {
answers: string[] answers: string[]
@ -27,10 +27,10 @@ export function MultipleChoiceAnswers(props: {
{answers.map((answer, i) => ( {answers.map((answer, i) => (
<Row className="mb-2 items-center gap-2 align-middle"> <Row className="mb-2 items-center gap-2 align-middle">
{i + 1}.{' '} {i + 1}.{' '}
<Textarea <ExpandingInput
value={answer} value={answer}
onChange={(e) => setAnswer(i, e.target.value)} onChange={(e) => setAnswer(i, e.target.value)}
className="textarea textarea-bordered ml-2 w-full resize-none" className="ml-2 w-full"
placeholder="Type your answer..." placeholder="Type your answer..."
rows={1} rows={1}
maxLength={MAX_ANSWER_LENGTH} maxLength={MAX_ANSWER_LENGTH}

View File

@ -1,13 +1,13 @@
import clsx from 'clsx' import clsx from 'clsx'
import { DragDropContext, Droppable, Draggable } from 'react-beautiful-dnd' import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd'
import { MenuIcon } from '@heroicons/react/solid' import { MenuIcon } from '@heroicons/react/solid'
import { toast } from 'react-hot-toast' import { toast } from 'react-hot-toast'
import { XCircleIcon } from '@heroicons/react/outline'
import { Col } from 'web/components/layout/col' import { Col } from 'web/components/layout/col'
import { Row } from 'web/components/layout/row' import { Row } from 'web/components/layout/row'
import { Subtitle } from 'web/components/subtitle' import { Subtitle } from 'web/components/subtitle'
import { keyBy } from 'lodash' import { keyBy } from 'lodash'
import { XCircleIcon } from '@heroicons/react/outline'
import { Button } from './button' import { Button } from './button'
import { updateUser } from 'web/lib/firebase/users' import { updateUser } from 'web/lib/firebase/users'
import { leaveGroup } from 'web/lib/firebase/groups' import { leaveGroup } from 'web/lib/firebase/groups'

View File

@ -68,11 +68,11 @@ export function AuthProvider(props: {
}, [setAuthUser, serverUser]) }, [setAuthUser, serverUser])
useEffect(() => { useEffect(() => {
if (authUser != null) { if (authUser) {
// Persist to local storage, to reduce login blink next time. // Persist to local storage, to reduce login blink next time.
// Note: Cap on localStorage size is ~5mb // Note: Cap on localStorage size is ~5mb
localStorage.setItem(CACHED_USER_KEY, JSON.stringify(authUser)) localStorage.setItem(CACHED_USER_KEY, JSON.stringify(authUser))
} else { } else if (authUser === null) {
localStorage.removeItem(CACHED_USER_KEY) localStorage.removeItem(CACHED_USER_KEY)
} }
}, [authUser]) }, [authUser])

View File

@ -5,7 +5,6 @@ import { awardCommentBounty } from 'web/lib/firebase/api'
import { track } from 'web/lib/service/analytics' import { track } from 'web/lib/service/analytics'
import { Row } from './layout/row' import { Row } from './layout/row'
import { Contract } from 'common/contract' import { Contract } from 'common/contract'
import { TextButton } from 'web/components/text-button'
import { COMMENT_BOUNTY_AMOUNT } from 'common/economy' import { COMMENT_BOUNTY_AMOUNT } from 'common/economy'
import { formatMoney } from 'common/util/format' import { formatMoney } from 'common/util/format'
@ -37,10 +36,17 @@ export function AwardBountyButton(prop: {
const canUp = me && me.id !== comment.userId && contract.creatorId === me.id const canUp = me && me.id !== comment.userId && contract.creatorId === me.id
if (!canUp) return <div /> if (!canUp) return <div />
return ( return (
<Row className={clsx('-ml-2 items-center gap-0.5', !canUp ? '-ml-6' : '')}> <Row
<TextButton className={'font-bold'} onClick={submit}> className={clsx('my-auto items-center gap-0.5', !canUp ? '-ml-6' : '')}
>
<button
className={
'rounded-full border border-indigo-400 bg-indigo-50 py-0.5 px-2 text-xs text-indigo-400 transition-colors hover:bg-indigo-400 hover:text-white'
}
onClick={submit}
>
Award {formatMoney(COMMENT_BOUNTY_AMOUNT)} Award {formatMoney(COMMENT_BOUNTY_AMOUNT)}
</TextButton> </button>
</Row> </Row>
) )
} }

View File

@ -0,0 +1,62 @@
import { User } from 'common/user'
import { useEffect, useState } from 'react'
import { getBadgesByRarity } from 'common/badge'
import { Row } from 'web/components/layout/row'
import clsx from 'clsx'
import { BadgesModal } from 'web/components/profile/badges-modal'
import { ParsedUrlQuery } from 'querystring'
export const goldClassName = 'text-amber-400'
export const silverClassName = 'text-gray-500'
export const bronzeClassName = 'text-amber-900'
export function BadgeDisplay(props: {
user: User | undefined | null
query: ParsedUrlQuery
}) {
const { user, query } = props
const [showBadgesModal, setShowBadgesModal] = useState(false)
useEffect(() => {
const showBadgesModal = query['show'] == 'badges'
setShowBadgesModal(showBadgesModal)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// get number of badges of each rarity type
const badgesByRarity = getBadgesByRarity(user)
const badgesByRarityItems = Object.entries(badgesByRarity).map(
([rarity, numBadges]) => {
return (
<Row
key={rarity}
className={clsx(
'items-center gap-2',
rarity === 'bronze'
? bronzeClassName
: rarity === 'silver'
? silverClassName
: goldClassName
)}
>
<span className={clsx('-m-0.5 text-lg')}></span>
<span className="text-xs">{numBadges}</span>
</Row>
)
}
)
return (
<Row
className={'cursor-pointer gap-2'}
onClick={() => setShowBadgesModal(true)}
>
{badgesByRarityItems}
{user && (
<BadgesModal
isOpen={showBadgesModal}
setOpen={setShowBadgesModal}
user={user}
/>
)}
</Row>
)
}

View File

@ -16,7 +16,7 @@ import { Button } from 'web/components/button'
import { BetSignUpPrompt } from './sign-up-prompt' import { BetSignUpPrompt } from './sign-up-prompt'
import { User } from 'web/lib/firebase/users' import { User } from 'web/lib/firebase/users'
import { SellRow } from './sell-row' import { SellRow } from './sell-row'
import { useUnfilledBets } from 'web/hooks/use-bets' import { useUnfilledBetsAndBalanceByUserId } from 'web/hooks/use-bets'
import { PlayMoneyDisclaimer } from './play-money-disclaimer' import { PlayMoneyDisclaimer } from './play-money-disclaimer'
/** Button that opens BetPanel in a new modal */ /** Button that opens BetPanel in a new modal */
@ -100,7 +100,9 @@ export function SignedInBinaryMobileBetting(props: {
user: User user: User
}) { }) {
const { contract, user } = props const { contract, user } = props
const unfilledBets = useUnfilledBets(contract.id) ?? [] const { unfilledBets, balanceByUserId } = useUnfilledBetsAndBalanceByUserId(
contract.id
)
return ( return (
<> <>
@ -111,6 +113,7 @@ export function SignedInBinaryMobileBetting(props: {
contract={contract as CPMMBinaryContract} contract={contract as CPMMBinaryContract}
user={user} user={user}
unfilledBets={unfilledBets} unfilledBets={unfilledBets}
balanceByUserId={balanceByUserId}
mobileView={true} mobileView={true}
/> />
</Col> </Col>

View File

@ -10,7 +10,7 @@ import { BuyAmountInput } from './amount-input'
import { Button } from './button' import { Button } from './button'
import { Row } from './layout/row' import { Row } from './layout/row'
import { YesNoSelector } from './yes-no-selector' import { YesNoSelector } from './yes-no-selector'
import { useUnfilledBets } from 'web/hooks/use-bets' import { useUnfilledBetsAndBalanceByUserId } from 'web/hooks/use-bets'
import { useUser } from 'web/hooks/use-user' import { useUser } from 'web/hooks/use-user'
import { BetSignUpPrompt } from './sign-up-prompt' import { BetSignUpPrompt } from './sign-up-prompt'
import { getCpmmProbability } from 'common/calculate-cpmm' import { getCpmmProbability } from 'common/calculate-cpmm'
@ -34,14 +34,17 @@ export function BetInline(props: {
const [error, setError] = useState<string>() const [error, setError] = useState<string>()
const isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC' const isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC'
const unfilledBets = useUnfilledBets(contract.id) ?? [] const { unfilledBets, balanceByUserId } = useUnfilledBetsAndBalanceByUserId(
contract.id
)
const { newPool, newP } = getBinaryCpmmBetInfo( const { newPool, newP } = getBinaryCpmmBetInfo(
outcome ?? 'YES', outcome ?? 'YES',
amount ?? 0, amount ?? 0,
contract, contract,
undefined, undefined,
unfilledBets unfilledBets,
balanceByUserId
) )
const resultProb = getCpmmProbability(newPool, newP) const resultProb = getCpmmProbability(newPool, newP)
useEffect(() => setProbAfter(resultProb), [setProbAfter, resultProb]) useEffect(() => setProbAfter(resultProb), [setProbAfter, resultProb])
@ -89,10 +92,7 @@ export function BetInline(props: {
/> />
<BuyAmountInput <BuyAmountInput
className="-mb-4" className="-mb-4"
inputClassName={clsx( inputClassName="w-20 !text-base"
'input-sm w-20 !text-base',
error && 'input-error'
)}
amount={amount} amount={amount}
onChange={setAmount} onChange={setAmount}
error="" // handle error ourselves error="" // handle error ourselves

View File

@ -25,7 +25,7 @@ import {
NoLabel, NoLabel,
YesLabel, YesLabel,
} from './outcome-label' } from './outcome-label'
import { getProbability } from 'common/calculate' import { getContractBetMetrics, getProbability } from 'common/calculate'
import { useFocus } from 'web/hooks/use-focus' import { useFocus } from 'web/hooks/use-focus'
import { useUserContractBets } from 'web/hooks/use-user-bets' import { useUserContractBets } from 'web/hooks/use-user-bets'
import { calculateCpmmSale, getCpmmProbability } from 'common/calculate-cpmm' import { calculateCpmmSale, getCpmmProbability } from 'common/calculate-cpmm'
@ -35,7 +35,7 @@ import { useSaveBinaryShares } from './use-save-binary-shares'
import { BetSignUpPrompt } from './sign-up-prompt' import { BetSignUpPrompt } from './sign-up-prompt'
import { ProbabilityOrNumericInput } from './probability-input' import { ProbabilityOrNumericInput } from './probability-input'
import { track } from 'web/lib/service/analytics' import { track } from 'web/lib/service/analytics'
import { useUnfilledBets } from 'web/hooks/use-bets' import { useUnfilledBetsAndBalanceByUserId } from 'web/hooks/use-bets'
import { LimitBets } from './limit-bets' import { LimitBets } from './limit-bets'
import { PillButton } from './buttons/pill-button' import { PillButton } from './buttons/pill-button'
import { YesNoSelector } from './yes-no-selector' import { YesNoSelector } from './yes-no-selector'
@ -47,6 +47,7 @@ import { Modal } from './layout/modal'
import { Title } from './title' import { Title } from './title'
import toast from 'react-hot-toast' import toast from 'react-hot-toast'
import { CheckIcon } from '@heroicons/react/solid' import { CheckIcon } from '@heroicons/react/solid'
import { Button } from './button'
export function BetPanel(props: { export function BetPanel(props: {
contract: CPMMBinaryContract | PseudoNumericContract contract: CPMMBinaryContract | PseudoNumericContract
@ -55,7 +56,9 @@ export function BetPanel(props: {
const { contract, className } = props const { contract, className } = props
const user = useUser() const user = useUser()
const userBets = useUserContractBets(user?.id, contract.id) const userBets = useUserContractBets(user?.id, contract.id)
const unfilledBets = useUnfilledBets(contract.id) ?? [] const { unfilledBets, balanceByUserId } = useUnfilledBetsAndBalanceByUserId(
contract.id
)
const { sharesOutcome } = useSaveBinaryShares(contract, userBets) const { sharesOutcome } = useSaveBinaryShares(contract, userBets)
const [isLimitOrder, setIsLimitOrder] = useState(false) const [isLimitOrder, setIsLimitOrder] = useState(false)
@ -86,12 +89,14 @@ export function BetPanel(props: {
contract={contract} contract={contract}
user={user} user={user}
unfilledBets={unfilledBets} unfilledBets={unfilledBets}
balanceByUserId={balanceByUserId}
/> />
<LimitOrderPanel <LimitOrderPanel
hidden={!isLimitOrder} hidden={!isLimitOrder}
contract={contract} contract={contract}
user={user} user={user}
unfilledBets={unfilledBets} unfilledBets={unfilledBets}
balanceByUserId={balanceByUserId}
/> />
</> </>
) : ( ) : (
@ -117,7 +122,9 @@ export function SimpleBetPanel(props: {
const user = useUser() const user = useUser()
const [isLimitOrder, setIsLimitOrder] = useState(false) const [isLimitOrder, setIsLimitOrder] = useState(false)
const unfilledBets = useUnfilledBets(contract.id) ?? [] const { unfilledBets, balanceByUserId } = useUnfilledBetsAndBalanceByUserId(
contract.id
)
return ( return (
<Col className={className}> <Col className={className}>
@ -142,6 +149,7 @@ export function SimpleBetPanel(props: {
contract={contract} contract={contract}
user={user} user={user}
unfilledBets={unfilledBets} unfilledBets={unfilledBets}
balanceByUserId={balanceByUserId}
onBuySuccess={onBetSuccess} onBuySuccess={onBetSuccess}
/> />
<LimitOrderPanel <LimitOrderPanel
@ -149,6 +157,7 @@ export function SimpleBetPanel(props: {
contract={contract} contract={contract}
user={user} user={user}
unfilledBets={unfilledBets} unfilledBets={unfilledBets}
balanceByUserId={balanceByUserId}
onBuySuccess={onBetSuccess} onBuySuccess={onBetSuccess}
/> />
@ -167,13 +176,21 @@ export function SimpleBetPanel(props: {
export function BuyPanel(props: { export function BuyPanel(props: {
contract: CPMMBinaryContract | PseudoNumericContract contract: CPMMBinaryContract | PseudoNumericContract
user: User | null | undefined user: User | null | undefined
unfilledBets: Bet[] unfilledBets: LimitBet[]
balanceByUserId: { [userId: string]: number }
hidden: boolean hidden: boolean
onBuySuccess?: () => void onBuySuccess?: () => void
mobileView?: boolean mobileView?: boolean
}) { }) {
const { contract, user, unfilledBets, hidden, onBuySuccess, mobileView } = const {
props contract,
user,
unfilledBets,
balanceByUserId,
hidden,
onBuySuccess,
mobileView,
} = props
const initialProb = getProbability(contract) const initialProb = getProbability(contract)
const isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC' const isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC'
@ -254,14 +271,15 @@ export function BuyPanel(props: {
}) })
} }
const betDisabled = isSubmitting || !betAmount || error const betDisabled = isSubmitting || !betAmount || !!error
const { newPool, newP, newBet } = getBinaryCpmmBetInfo( const { newPool, newP, newBet } = getBinaryCpmmBetInfo(
outcome ?? 'YES', outcome ?? 'YES',
betAmount ?? 0, betAmount ?? 0,
contract, contract,
undefined, undefined,
unfilledBets as LimitBet[] unfilledBets,
balanceByUserId
) )
const [seeLimit, setSeeLimit] = useState(false) const [seeLimit, setSeeLimit] = useState(false)
@ -395,6 +413,7 @@ export function BuyPanel(props: {
disabled={!!betDisabled || outcome === undefined} disabled={!!betDisabled || outcome === undefined}
size="xl" size="xl"
color={outcome === 'NO' ? 'red' : 'green'} color={outcome === 'NO' ? 'red' : 'green'}
actionLabel="Wager"
/> />
)} )}
<button <button
@ -415,6 +434,7 @@ export function BuyPanel(props: {
contract={contract} contract={contract}
user={user} user={user}
unfilledBets={unfilledBets} unfilledBets={unfilledBets}
balanceByUserId={balanceByUserId}
/> />
<LimitBets <LimitBets
contract={contract} contract={contract}
@ -430,11 +450,19 @@ export function BuyPanel(props: {
function LimitOrderPanel(props: { function LimitOrderPanel(props: {
contract: CPMMBinaryContract | PseudoNumericContract contract: CPMMBinaryContract | PseudoNumericContract
user: User | null | undefined user: User | null | undefined
unfilledBets: Bet[] unfilledBets: LimitBet[]
balanceByUserId: { [userId: string]: number }
hidden: boolean hidden: boolean
onBuySuccess?: () => void onBuySuccess?: () => void
}) { }) {
const { contract, user, unfilledBets, hidden, onBuySuccess } = props const {
contract,
user,
unfilledBets,
balanceByUserId,
hidden,
onBuySuccess,
} = props
const initialProb = getProbability(contract) const initialProb = getProbability(contract)
const isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC' const isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC'
@ -442,7 +470,6 @@ function LimitOrderPanel(props: {
const [betAmount, setBetAmount] = useState<number | undefined>(undefined) const [betAmount, setBetAmount] = useState<number | undefined>(undefined)
const [lowLimitProb, setLowLimitProb] = useState<number | undefined>() const [lowLimitProb, setLowLimitProb] = useState<number | undefined>()
const [highLimitProb, setHighLimitProb] = useState<number | undefined>() const [highLimitProb, setHighLimitProb] = useState<number | undefined>()
const betChoice = 'YES'
const [error, setError] = useState<string | undefined>() const [error, setError] = useState<string | undefined>()
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
@ -466,7 +493,7 @@ function LimitOrderPanel(props: {
!betAmount || !betAmount ||
rangeError || rangeError ||
outOfRangeError || outOfRangeError ||
error || !!error ||
(!hasYesLimitBet && !hasNoLimitBet) (!hasYesLimitBet && !hasNoLimitBet)
const yesLimitProb = const yesLimitProb =
@ -580,7 +607,8 @@ function LimitOrderPanel(props: {
yesAmount, yesAmount,
contract, contract,
yesLimitProb ?? initialProb, yesLimitProb ?? initialProb,
unfilledBets as LimitBet[] unfilledBets,
balanceByUserId
) )
const yesReturnPercent = formatPercent(yesReturn) const yesReturnPercent = formatPercent(yesReturn)
@ -594,7 +622,8 @@ function LimitOrderPanel(props: {
noAmount, noAmount,
contract, contract,
noLimitProb ?? initialProb, noLimitProb ?? initialProb,
unfilledBets as LimitBet[] unfilledBets,
balanceByUserId
) )
const noReturnPercent = formatPercent(noReturn) const noReturnPercent = formatPercent(noReturn)
@ -602,9 +631,9 @@ function LimitOrderPanel(props: {
return ( return (
<Col className={hidden ? 'hidden' : ''}> <Col className={hidden ? 'hidden' : ''}>
<Row className="mt-1 items-center gap-4"> <Row className="mt-1 mb-4 items-center gap-4">
<Col className="gap-2"> <Col className="gap-2">
<div className="relative ml-1 text-sm text-gray-500"> <div className="text-sm text-gray-500">
Buy {isPseudoNumeric ? <HigherLabel /> : <YesLabel />} up to Buy {isPseudoNumeric ? <HigherLabel /> : <YesLabel />} up to
</div> </div>
<ProbabilityOrNumericInput <ProbabilityOrNumericInput
@ -612,10 +641,11 @@ function LimitOrderPanel(props: {
prob={lowLimitProb} prob={lowLimitProb}
setProb={setLowLimitProb} setProb={setLowLimitProb}
isSubmitting={isSubmitting} isSubmitting={isSubmitting}
placeholder="10"
/> />
</Col> </Col>
<Col className="gap-2"> <Col className="gap-2">
<div className="ml-1 text-sm text-gray-500"> <div className="text-sm text-gray-500">
Buy {isPseudoNumeric ? <LowerLabel /> : <NoLabel />} down to Buy {isPseudoNumeric ? <LowerLabel /> : <NoLabel />} down to
</div> </div>
<ProbabilityOrNumericInput <ProbabilityOrNumericInput
@ -623,6 +653,7 @@ function LimitOrderPanel(props: {
prob={highLimitProb} prob={highLimitProb}
setProb={setHighLimitProb} setProb={setHighLimitProb}
isSubmitting={isSubmitting} isSubmitting={isSubmitting}
placeholder="90"
/> />
</Col> </Col>
</Row> </Row>
@ -754,22 +785,18 @@ function LimitOrderPanel(props: {
{(hasYesLimitBet || hasNoLimitBet) && <Spacer h={8} />} {(hasYesLimitBet || hasNoLimitBet) && <Spacer h={8} />}
{user && ( {user && (
<button <Button
className={clsx( size="xl"
'btn flex-1', disabled={betDisabled}
betDisabled color={'indigo'}
? 'btn-disabled' loading={isSubmitting}
: betChoice === 'YES' className="flex-1"
? 'btn-primary' onClick={submitBet}
: 'border-none bg-red-400 hover:bg-red-500',
isSubmitting ? 'loading' : ''
)}
onClick={betDisabled ? undefined : submitBet}
> >
{isSubmitting {isSubmitting
? 'Submitting...' ? 'Submitting...'
: `Submit order${hasTwoBets ? 's' : ''}`} : `Submit order${hasTwoBets ? 's' : ''}`}
</button> </Button>
)} )}
</Col> </Col>
) )
@ -829,7 +856,9 @@ export function SellPanel(props: {
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
const [wasSubmitted, setWasSubmitted] = useState(false) const [wasSubmitted, setWasSubmitted] = useState(false)
const unfilledBets = useUnfilledBets(contract.id) ?? [] const { unfilledBets, balanceByUserId } = useUnfilledBetsAndBalanceByUserId(
contract.id
)
const betDisabled = isSubmitting || !amount || error !== undefined const betDisabled = isSubmitting || !amount || error !== undefined
@ -843,6 +872,9 @@ export function SellPanel(props: {
const saleFrac = soldShares / shares const saleFrac = soldShares / shares
const loanPaid = saleFrac * loanAmount const loanPaid = saleFrac * loanAmount
const { invested } = getContractBetMetrics(contract, userBets)
const costBasis = invested * saleFrac
async function submitSell() { async function submitSell() {
if (!user || !amount) return if (!user || !amount) return
@ -885,9 +917,11 @@ export function SellPanel(props: {
contract, contract,
sellQuantity ?? 0, sellQuantity ?? 0,
sharesOutcome, sharesOutcome,
unfilledBets unfilledBets,
balanceByUserId
) )
const netProceeds = saleValue - loanPaid const netProceeds = saleValue - loanPaid
const profit = saleValue - costBasis
const resultProb = getCpmmProbability(cpmmState.pool, cpmmState.p) const resultProb = getCpmmProbability(cpmmState.pool, cpmmState.p)
const getValue = getMappedValue(contract) const getValue = getMappedValue(contract)
@ -948,20 +982,12 @@ export function SellPanel(props: {
<Col className="mt-3 w-full gap-3 text-sm"> <Col className="mt-3 w-full gap-3 text-sm">
<Row className="items-center justify-between gap-2 text-gray-500"> <Row className="items-center justify-between gap-2 text-gray-500">
Sale amount Sale amount
<span className="text-neutral">{formatMoney(saleValue)}</span> <span className="text-gray-700">{formatMoney(saleValue)}</span>
</Row>
{loanPaid !== 0 && (
<>
<Row className="items-center justify-between gap-2 text-gray-500">
Loan repaid
<span className="text-neutral">{formatMoney(-loanPaid)}</span>
</Row> </Row>
<Row className="items-center justify-between gap-2 text-gray-500"> <Row className="items-center justify-between gap-2 text-gray-500">
Net proceeds Profit
<span className="text-neutral">{formatMoney(netProceeds)}</span> <span className="text-gray-700">{formatMoney(profit)}</span>
</Row> </Row>
</>
)}
<Row className="items-center justify-between"> <Row className="items-center justify-between">
<div className="text-gray-500"> <div className="text-gray-500">
{isPseudoNumeric ? 'Estimated value' : 'Probability'} {isPseudoNumeric ? 'Estimated value' : 'Probability'}
@ -972,20 +998,32 @@ export function SellPanel(props: {
{format(resultProb)} {format(resultProb)}
</div> </div>
</Row> </Row>
{loanPaid !== 0 && (
<>
<Row className="mt-6 items-center justify-between gap-2 text-gray-500">
Loan payment
<span className="text-gray-700">{formatMoney(-loanPaid)}</span>
</Row>
<Row className="items-center justify-between gap-2 text-gray-500">
Net proceeds
<span className="text-gray-700">{formatMoney(netProceeds)}</span>
</Row>
</>
)}
</Col> </Col>
<Spacer h={8} /> <Spacer h={8} />
<WarningConfirmationButton <WarningConfirmationButton
marketType="binary" marketType="binary"
amount={netProceeds} amount={undefined}
warning={warning} warning={warning}
isSubmitting={isSubmitting} isSubmitting={isSubmitting}
onSubmit={betDisabled ? undefined : submitSell} onSubmit={betDisabled ? undefined : submitSell}
disabled={!!betDisabled} disabled={!!betDisabled}
size="xl" size="xl"
color="blue" color="blue"
actionLabel="Sell" actionLabel={`Sell ${Math.floor(soldShares)} shares`}
/> />
{wasSubmitted && <div className="mt-4">Sell submitted!</div>} {wasSubmitted && <div className="mt-4">Sell submitted!</div>}

View File

@ -25,10 +25,8 @@ export function BetsSummary(props: {
const isBinary = outcomeType === 'BINARY' const isBinary = outcomeType === 'BINARY'
const bets = props.userBets.filter((b) => !b.isAnte) const bets = props.userBets.filter((b) => !b.isAnte)
const { profitPercent, payout, profit, invested } = getContractBetMetrics( const { profitPercent, payout, profit, invested, hasShares } =
contract, getContractBetMetrics(contract, bets)
bets
)
const excludeSales = bets.filter((b) => !b.isSold && !b.sale) const excludeSales = bets.filter((b) => !b.isSold && !b.sale)
const yesWinnings = sumBy(excludeSales, (bet) => const yesWinnings = sumBy(excludeSales, (bet) =>
@ -39,6 +37,7 @@ export function BetsSummary(props: {
) )
const position = yesWinnings - noWinnings const position = yesWinnings - noWinnings
const outcome = hasShares ? (position > 0 ? 'YES' : 'NO') : undefined
const prob = isBinary ? getProbability(contract) : 0 const prob = isBinary ? getProbability(contract) : 0
const expectation = prob * yesWinnings + (1 - prob) * noWinnings const expectation = prob * yesWinnings + (1 - prob) * noWinnings
@ -60,7 +59,9 @@ export function BetsSummary(props: {
<Col> <Col>
<div className="whitespace-nowrap text-sm text-gray-500"> <div className="whitespace-nowrap text-sm text-gray-500">
Position{' '} Position{' '}
<InfoTooltip text="Number of shares you own on net. 1 YES share = M$1 if the market resolves YES." /> <InfoTooltip
text={`Number of shares you own on net. 1 ${outcome} share = M$1 if the market resolves ${outcome}.`}
/>
</div> </div>
<div className="whitespace-nowrap"> <div className="whitespace-nowrap">
{position > 1e-7 ? ( {position > 1e-7 ? (

View File

@ -4,7 +4,7 @@ import dayjs from 'dayjs'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/solid' import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/solid'
import { Bet } from 'web/lib/firebase/bets' import { Bet, MAX_USER_BETS_LOADED } from 'web/lib/firebase/bets'
import { User } from 'web/lib/firebase/users' import { User } from 'web/lib/firebase/users'
import { import {
formatMoney, formatMoney,
@ -17,6 +17,7 @@ import {
Contract, Contract,
contractPath, contractPath,
getBinaryProbPercent, getBinaryProbPercent,
MAX_USER_BET_CONTRACTS_LOADED,
} from 'web/lib/firebase/contracts' } from 'web/lib/firebase/contracts'
import { Row } from './layout/row' import { Row } from './layout/row'
import { sellBet } from 'web/lib/firebase/api' import { sellBet } from 'web/lib/firebase/api'
@ -37,7 +38,7 @@ import { NumericContract } from 'common/contract'
import { formatNumericProbability } from 'common/pseudo-numeric' import { formatNumericProbability } from 'common/pseudo-numeric'
import { useUser } from 'web/hooks/use-user' import { useUser } from 'web/hooks/use-user'
import { useUserBets } from 'web/hooks/use-user-bets' import { useUserBets } from 'web/hooks/use-user-bets'
import { useUnfilledBets } from 'web/hooks/use-bets' import { useUnfilledBetsAndBalanceByUserId } from 'web/hooks/use-bets'
import { LimitBet } from 'common/bet' import { LimitBet } from 'common/bet'
import { Pagination } from './pagination' import { Pagination } from './pagination'
import { LimitOrderTable } from './limit-bets' import { LimitOrderTable } from './limit-bets'
@ -50,6 +51,9 @@ import {
usePersistentState, usePersistentState,
} from 'web/hooks/use-persistent-state' } from 'web/hooks/use-persistent-state'
import { safeLocalStorage } from 'web/lib/util/local' import { safeLocalStorage } from 'web/lib/util/local'
import { ExclamationIcon } from '@heroicons/react/outline'
import { Select } from './select'
import { Table } from './table'
type BetSort = 'newest' | 'profit' | 'closeTime' | 'value' type BetSort = 'newest' | 'profit' | 'closeTime' | 'value'
type BetFilter = 'open' | 'limit_bet' | 'sold' | 'closed' | 'resolved' | 'all' type BetFilter = 'open' | 'limit_bet' | 'sold' | 'closed' | 'resolved' | 'all'
@ -80,6 +84,10 @@ export function BetsList(props: { user: User }) {
return contractList ? keyBy(contractList, 'id') : undefined return contractList ? keyBy(contractList, 'id') : undefined
}, [contractList]) }, [contractList])
const loadedPartialData =
userBets?.length === MAX_USER_BETS_LOADED ||
contractList?.length === MAX_USER_BET_CONTRACTS_LOADED
const [sort, setSort] = usePersistentState<BetSort>('newest', { const [sort, setSort] = usePersistentState<BetSort>('newest', {
key: 'bets-list-sort', key: 'bets-list-sort',
store: storageStore(safeLocalStorage()), store: storageStore(safeLocalStorage()),
@ -160,43 +168,53 @@ export function BetsList(props: { user: User }) {
unsettled, unsettled,
(c) => contractsMetrics[c.id].payout (c) => contractsMetrics[c.id].payout
) )
const currentNetInvestment = sumBy( const currentLoan = sumBy(unsettled, (c) => contractsMetrics[c.id].loan)
unsettled,
(c) => contractsMetrics[c.id].netPayout
)
const investedProfitPercent = const investedProfitPercent =
((currentBetsValue - currentInvested) / (currentInvested + 0.1)) * 100 ((currentBetsValue - currentInvested) / (currentInvested + 0.1)) * 100
return ( return (
<Col> <Col>
<Row className="justify-between gap-4 sm:flex-row"> {loadedPartialData && (
<Row className="my-4 items-center gap-2 self-start rounded bg-yellow-50 p-4">
<ExclamationIcon className="h-5 w-5" />
<div>Partial trade data only</div>
</Row>
)}
<Col className="justify-between gap-4 sm:flex-row">
<Row className="gap-4">
<Col> <Col>
<div className="text-greyscale-6 text-xs sm:text-sm"> <div className="text-greyscale-6 text-xs sm:text-sm">
Investment value Investment value
</div> </div>
<div className="text-lg"> <div className="text-lg">
{formatMoney(currentNetInvestment)}{' '} {formatMoney(currentBetsValue)}{' '}
<ProfitBadge profitPercent={investedProfitPercent} /> <ProfitBadge profitPercent={investedProfitPercent} />
</div> </div>
</Col> </Col>
<Col>
<div className="text-greyscale-6 text-xs sm:text-sm">
Total loans
</div>
<div className="text-lg">{formatMoney(currentLoan)}</div>
</Col>
</Row>
<Row className="gap-2"> <Row className="gap-2">
<select <Select
className="border-greyscale-4 self-start overflow-hidden rounded border px-2 py-2 text-sm"
value={filter} value={filter}
onChange={(e) => setFilter(e.target.value as BetFilter)} onChange={(e) => setFilter(e.target.value as BetFilter)}
> >
<option value="open">Open</option> <option value="open">Active</option>
<option value="limit_bet">Limit orders</option> <option value="limit_bet">Limit orders</option>
<option value="sold">Sold</option> <option value="sold">Sold</option>
<option value="closed">Closed</option> <option value="closed">Closed</option>
<option value="resolved">Resolved</option> <option value="resolved">Resolved</option>
<option value="all">All</option> <option value="all">All</option>
</select> </Select>
<select <Select
className="border-greyscale-4 self-start overflow-hidden rounded px-2 py-2 text-sm"
value={sort} value={sort}
onChange={(e) => setSort(e.target.value as BetSort)} onChange={(e) => setSort(e.target.value as BetSort)}
> >
@ -204,9 +222,9 @@ export function BetsList(props: { user: User }) {
<option value="value">Value</option> <option value="value">Value</option>
<option value="profit">Profit</option> <option value="profit">Profit</option>
<option value="closeTime">Close date</option> <option value="closeTime">Close date</option>
</select> </Select>
</Row>
</Row> </Row>
</Col>
<Col className="mt-6 divide-y"> <Col className="mt-6 divide-y">
{displayedContracts.length === 0 ? ( {displayedContracts.length === 0 ? (
@ -407,7 +425,9 @@ export function ContractBetsTable(props: {
const isNumeric = outcomeType === 'NUMERIC' const isNumeric = outcomeType === 'NUMERIC'
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC' const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'
const unfilledBets = useUnfilledBets(contract.id) ?? [] const { unfilledBets, balanceByUserId } = useUnfilledBetsAndBalanceByUserId(
contract.id
)
return ( return (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
@ -431,7 +451,7 @@ export function ContractBetsTable(props: {
</> </>
)} )}
<table className="table-zebra table-compact table w-full text-gray-500"> <Table>
<thead> <thead>
<tr className="p-2"> <tr className="p-2">
<th></th> <th></th>
@ -456,10 +476,11 @@ export function ContractBetsTable(props: {
contract={contract} contract={contract}
isYourBet={isYourBets} isYourBet={isYourBets}
unfilledBets={unfilledBets} unfilledBets={unfilledBets}
balanceByUserId={balanceByUserId}
/> />
))} ))}
</tbody> </tbody>
</table> </Table>
</div> </div>
) )
} }
@ -470,8 +491,10 @@ function BetRow(props: {
saleBet?: Bet saleBet?: Bet
isYourBet: boolean isYourBet: boolean
unfilledBets: LimitBet[] unfilledBets: LimitBet[]
balanceByUserId: { [userId: string]: number }
}) { }) {
const { bet, saleBet, contract, isYourBet, unfilledBets } = props const { bet, saleBet, contract, isYourBet, unfilledBets, balanceByUserId } =
props
const { const {
amount, amount,
outcome, outcome,
@ -499,9 +522,9 @@ function BetRow(props: {
} else if (contract.isResolved) { } else if (contract.isResolved) {
return resolvedPayout(contract, bet) return resolvedPayout(contract, bet)
} else { } else {
return calculateSaleAmount(contract, bet, unfilledBets) return calculateSaleAmount(contract, bet, unfilledBets, balanceByUserId)
} }
}, [contract, bet, saleBet, unfilledBets]) }, [contract, bet, saleBet, unfilledBets, balanceByUserId])
const saleDisplay = isAnte ? ( const saleDisplay = isAnte ? (
'ANTE' 'ANTE'
@ -528,7 +551,7 @@ function BetRow(props: {
return ( return (
<tr> <tr>
<td className="text-neutral"> <td className="text-gray-700">
{isYourBet && {isYourBet &&
!isCPMM && !isCPMM &&
!isResolved && !isResolved &&
@ -540,6 +563,7 @@ function BetRow(props: {
contract={contract} contract={contract}
bet={bet} bet={bet}
unfilledBets={unfilledBets} unfilledBets={unfilledBets}
balanceByUserId={balanceByUserId}
/> />
)} )}
</td> </td>
@ -585,8 +609,9 @@ function SellButton(props: {
contract: Contract contract: Contract
bet: Bet bet: Bet
unfilledBets: LimitBet[] unfilledBets: LimitBet[]
balanceByUserId: { [userId: string]: number }
}) { }) {
const { contract, bet, unfilledBets } = props const { contract, bet, unfilledBets, balanceByUserId } = props
const { outcome, shares, loanAmount } = bet const { outcome, shares, loanAmount } = bet
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
@ -600,10 +625,16 @@ function SellButton(props: {
contract, contract,
outcome, outcome,
shares, shares,
unfilledBets unfilledBets,
balanceByUserId
) )
const saleAmount = calculateSaleAmount(contract, bet, unfilledBets) const saleAmount = calculateSaleAmount(
contract,
bet,
unfilledBets,
balanceByUserId
)
const profit = saleAmount - bet.amount const profit = saleAmount - bet.amount
return ( return (

View File

@ -13,14 +13,48 @@ export type ColorType =
| 'gray-outline' | 'gray-outline'
| 'gradient' | 'gradient'
| 'gray-white' | 'gray-white'
| 'highlight-blue'
const sizeClasses = {
'2xs': 'px-2 py-1 text-xs',
xs: 'px-2.5 py-1.5 text-sm',
sm: 'px-3 py-2 text-sm',
md: 'px-4 py-2 text-sm',
lg: 'px-4 py-2 text-base',
xl: 'px-6 py-2.5 text-base font-semibold',
'2xl': 'px-6 py-3 text-xl font-semibold',
}
export function buttonClass(size: SizeType, color: ColorType | 'override') {
return clsx(
'font-md inline-flex items-center justify-center rounded-md ring-inset shadow-sm transition-colors disabled:cursor-not-allowed',
sizeClasses[size],
color === 'green' &&
'disabled:bg-greyscale-2 bg-teal-500 text-white hover:bg-teal-600',
color === 'red' &&
'disabled:bg-greyscale-2 bg-red-400 text-white hover:bg-red-500',
color === 'yellow' &&
'disabled:bg-greyscale-2 bg-yellow-400 text-white hover:bg-yellow-500',
color === 'blue' &&
'disabled:bg-greyscale-2 bg-blue-400 text-white hover:bg-blue-500',
color === 'indigo' &&
'disabled:bg-greyscale-2 bg-indigo-500 text-white hover:bg-indigo-600',
color === 'gray' &&
'bg-greyscale-1 text-greyscale-6 hover:bg-greyscale-2 disabled:opacity-50',
color === 'gray-outline' &&
'ring-2 ring-greyscale-4 text-greyscale-4 hover:bg-greyscale-4 hover:text-white disabled:opacity-50',
color === 'gradient' &&
'disabled:bg-greyscale-2 bg-gradient-to-r from-indigo-500 to-blue-500 text-white hover:from-indigo-700 hover:to-blue-700',
color === 'gray-white' &&
'text-greyscale-6 hover:bg-greyscale-2 shadow-none disabled:opacity-50'
)
}
export function Button(props: { export function Button(props: {
className?: string className?: string
onClick?: MouseEventHandler<any> | undefined onClick?: MouseEventHandler<any> | undefined
children?: ReactNode children?: ReactNode
size?: SizeType size?: SizeType
color?: ColorType color?: ColorType | 'override'
type?: 'button' | 'reset' | 'submit' type?: 'button' | 'reset' | 'submit'
disabled?: boolean disabled?: boolean
loading?: boolean loading?: boolean
@ -36,44 +70,10 @@ export function Button(props: {
loading, loading,
} = props } = props
const sizeClasses = {
'2xs': 'px-2 py-1 text-xs',
xs: 'px-2.5 py-1.5 text-sm',
sm: 'px-3 py-2 text-sm',
md: 'px-4 py-2 text-sm',
lg: 'px-4 py-2 text-base',
xl: 'px-6 py-2.5 text-base font-semibold',
'2xl': 'px-6 py-3 text-xl font-semibold',
}[size]
return ( return (
<button <button
type={type} type={type}
className={clsx( className={clsx(buttonClass(size, color), className)}
'font-md items-center justify-center rounded-md border border-transparent shadow-sm transition-colors disabled:cursor-not-allowed',
sizeClasses,
color === 'green' &&
'disabled:bg-greyscale-2 bg-teal-500 text-white hover:bg-teal-600',
color === 'red' &&
'disabled:bg-greyscale-2 bg-red-400 text-white hover:bg-red-500',
color === 'yellow' &&
'disabled:bg-greyscale-2 bg-yellow-400 text-white hover:bg-yellow-500',
color === 'blue' &&
'disabled:bg-greyscale-2 bg-blue-400 text-white hover:bg-blue-500',
color === 'indigo' &&
'disabled:bg-greyscale-2 bg-indigo-500 text-white hover:bg-indigo-600',
color === 'gray' &&
'bg-greyscale-1 text-greyscale-6 hover:bg-greyscale-2 disabled:opacity-50',
color === 'gray-outline' &&
'border-greyscale-4 text-greyscale-4 hover:bg-greyscale-4 border-2 hover:text-white disabled:opacity-50',
color === 'gradient' &&
'disabled:bg-greyscale-2 border-none bg-gradient-to-r from-indigo-500 to-blue-500 text-white hover:from-indigo-700 hover:to-blue-700',
color === 'gray-white' &&
'text-greyscale-6 hover:bg-greyscale-2 border-none shadow-none disabled:opacity-50',
color === 'highlight-blue' &&
'text-highlight-blue disabled:bg-greyscale-2 border-none shadow-none',
className
)}
disabled={disabled || loading} disabled={disabled || loading}
onClick={onClick} onClick={onClick}
> >
@ -82,3 +82,39 @@ export function Button(props: {
</button> </button>
) )
} }
export function IconButton(props: {
className?: string
onClick?: MouseEventHandler<any> | undefined
children?: ReactNode
size?: SizeType
type?: 'button' | 'reset' | 'submit'
disabled?: boolean
loading?: boolean
}) {
const {
children,
className,
onClick,
size = 'md',
type = 'button',
disabled = false,
loading,
} = props
return (
<button
type={type}
className={clsx(
'inline-flex items-center justify-center transition-colors disabled:cursor-not-allowed',
sizeClasses[size],
'disabled:text-greyscale-2 text-greyscale-5 hover:text-greyscale-6',
className
)}
disabled={disabled || loading}
onClick={onClick}
>
{children}
</button>
)
}

16
web/components/card.tsx Normal file
View File

@ -0,0 +1,16 @@
import clsx from 'clsx'
export function Card(props: JSX.IntrinsicElements['div']) {
const { children, className, ...rest } = props
return (
<div
className={clsx(
'cursor-pointer rounded-lg border bg-white transition-shadow hover:shadow-md focus:shadow-md',
className
)}
{...rest}
>
{children}
</div>
)
}

View File

@ -1,8 +1,7 @@
import clsx from 'clsx' import clsx from 'clsx'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { LinkIcon, SwitchVerticalIcon } from '@heroicons/react/outline' import { SwitchVerticalIcon } from '@heroicons/react/outline'
import toast from 'react-hot-toast'
import { Col } from '../layout/col' import { Col } from '../layout/col'
import { Row } from '../layout/row' import { Row } from '../layout/row'
@ -16,16 +15,15 @@ import { SiteLink } from 'web/components/site-link'
import { formatMoney } from 'common/util/format' import { formatMoney } from 'common/util/format'
import { NoLabel, YesLabel } from '../outcome-label' import { NoLabel, YesLabel } from '../outcome-label'
import { QRCode } from '../qr-code' import { QRCode } from '../qr-code'
import { copyToClipboard } from 'web/lib/util/copy'
import { AmountInput } from '../amount-input' import { AmountInput } from '../amount-input'
import { getProbability } from 'common/calculate' import { getProbability } from 'common/calculate'
import { createMarket } from 'web/lib/firebase/api' import { createMarket } from 'web/lib/firebase/api'
import { removeUndefinedProps } from 'common/util/object' import { removeUndefinedProps } from 'common/util/object'
import { FIXED_ANTE } from 'common/economy' import { FIXED_ANTE } from 'common/economy'
import Textarea from 'react-expanding-textarea'
import { useTextEditor } from 'web/components/editor'
import { LoadingIndicator } from 'web/components/loading-indicator' import { LoadingIndicator } from 'web/components/loading-indicator'
import { track } from 'web/lib/service/analytics' import { track } from 'web/lib/service/analytics'
import { CopyLinkButton } from '../copy-link-button'
import { ExpandingInput } from '../expanding-input'
type challengeInfo = { type challengeInfo = {
amount: number amount: number
@ -44,7 +42,6 @@ export function CreateChallengeModal(props: {
const { user, contract, isOpen, setOpen } = props const { user, contract, isOpen, setOpen } = props
const [challengeSlug, setChallengeSlug] = useState('') const [challengeSlug, setChallengeSlug] = useState('')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const { editor } = useTextEditor({ placeholder: '' })
return ( return (
<Modal open={isOpen} setOpen={setOpen}> <Modal open={isOpen} setOpen={setOpen}>
@ -65,7 +62,6 @@ export function CreateChallengeModal(props: {
question: newChallenge.question, question: newChallenge.question,
outcomeType: 'BINARY', outcomeType: 'BINARY',
initialProb: 50, initialProb: 50,
description: editor?.getJSON(),
ante: FIXED_ANTE, ante: FIXED_ANTE,
closeTime: dayjs().add(30, 'day').valueOf(), closeTime: dayjs().add(30, 'day').valueOf(),
}) })
@ -154,9 +150,9 @@ function CreateChallengeForm(props: {
{contract ? ( {contract ? (
<span className="underline">{contract.question}</span> <span className="underline">{contract.question}</span>
) : ( ) : (
<Textarea <ExpandingInput
placeholder="e.g. Will a Democrat be the next president?" placeholder="e.g. Will a Democrat be the next president?"
className="input input-bordered mt-1 w-full resize-none" className="mt-1 w-full"
autoFocus={true} autoFocus={true}
maxLength={MAX_QUESTION_LENGTH} maxLength={MAX_QUESTION_LENGTH}
value={challengeInfo.question} value={challengeInfo.question}
@ -175,7 +171,7 @@ function CreateChallengeForm(props: {
<div>You'll bet:</div> <div>You'll bet:</div>
<Row <Row
className={ className={
'form-control w-full max-w-xs items-center justify-between gap-4 pr-3' 'w-full max-w-xs items-center justify-between gap-4 pr-3'
} }
> >
<AmountInput <AmountInput
@ -302,16 +298,7 @@ function CreateChallengeForm(props: {
<Title className="!my-0" text="Challenge Created!" /> <Title className="!my-0" text="Challenge Created!" />
<div>Share the challenge using the link.</div> <div>Share the challenge using the link.</div>
<button <CopyLinkButton url={challengeSlug} />
onClick={() => {
copyToClipboard(challengeSlug)
toast('Link copied to clipboard!')
}}
className={'btn btn-outline mb-4 whitespace-nowrap normal-case'}
>
<LinkIcon className={'mr-2 h-5 w-5'} />
Copy link
</button>
<QRCode url={challengeSlug} className="self-center" /> <QRCode url={challengeSlug} className="self-center" />
<Row className={'gap-1 text-gray-500'}> <Row className={'gap-1 text-gray-500'}>

View File

@ -6,6 +6,8 @@ import { Charity } from 'common/charity'
import { useCharityTxns } from 'web/hooks/use-charity-txns' import { useCharityTxns } from 'web/hooks/use-charity-txns'
import { manaToUSD } from '../../../common/util/format' import { manaToUSD } from '../../../common/util/format'
import { Row } from '../layout/row' import { Row } from '../layout/row'
import { Col } from '../layout/col'
import { Card } from '../card'
export function CharityCard(props: { charity: Charity; match?: number }) { export function CharityCard(props: { charity: Charity; match?: number }) {
const { charity } = props const { charity } = props
@ -15,8 +17,9 @@ export function CharityCard(props: { charity: Charity; match?: number }) {
const raised = sumBy(txns, (txn) => txn.amount) const raised = sumBy(txns, (txn) => txn.amount)
return ( return (
<Link href={`/charity/${slug}`} passHref> <Link href={`/charity/${slug}`}>
<div className="card card-compact transition:shadow flex-1 cursor-pointer border-2 bg-white hover:shadow-md"> <a className="flex-1">
<Card className="!rounded-2xl">
<Row className="mt-6 mb-2"> <Row className="mt-6 mb-2">
{tags?.includes('Featured') && <FeaturedBadge />} {tags?.includes('Featured') && <FeaturedBadge />}
</Row> </Row>
@ -29,8 +32,7 @@ export function CharityCard(props: { charity: Charity; match?: number }) {
)} )}
</figure> </figure>
</div> </div>
<div className="card-body"> <Col className="p-8">
{/* <h3 className="card-title line-clamp-3">{name}</h3> */}
<div className="line-clamp-4 text-sm">{preview}</div> <div className="line-clamp-4 text-sm">{preview}</div>
{raised > 0 && ( {raised > 0 && (
<> <>
@ -50,8 +52,9 @@ export function CharityCard(props: { charity: Charity; match?: number }) {
</Row> </Row>
</> </>
)} )}
</div> </Col>
</div> </Card>
</a>
</Link> </Link>
) )
} }

View File

@ -1,5 +1,5 @@
import { useMemo } from 'react' import { useMemo } from 'react'
import { last, sum, sortBy, groupBy } from 'lodash' import { last, range, sum, sortBy, groupBy } from 'lodash'
import { scaleTime, scaleLinear } from 'd3-scale' import { scaleTime, scaleLinear } from 'd3-scale'
import { curveStepAfter } from 'd3-shape' import { curveStepAfter } from 'd3-shape'
@ -19,86 +19,36 @@ import { MultiPoint, MultiValueHistoryChart } from '../generic-charts'
import { Row } from 'web/components/layout/row' import { Row } from 'web/components/layout/row'
import { Avatar } from 'web/components/avatar' import { Avatar } from 'web/components/avatar'
// thanks to https://observablehq.com/@jonhelfman/optimal-orders-for-choosing-categorical-colors type ChoiceContract = FreeResponseContract | MultipleChoiceContract
export const CATEGORY_COLORS = [
'#00b8dd', export const CHOICE_ANSWER_COLORS = [
'#eecafe', '#97C1EB',
'#874c62', '#F39F83',
'#6457ca', '#F9EBA5',
'#f773ba', '#FFC7D2',
'#9c6bbc', '#C7ECFF',
'#a87744', '#8CDEC7',
'#af8a04', '#DBE96F',
'#bff9aa',
'#f3d89d',
'#c9a0f5',
'#ff00e5',
'#9dc6f7',
'#824475',
'#d973cc',
'#bc6808',
'#056e70',
'#677932',
'#00b287',
'#c8ab6c',
'#a2fb7a',
'#f8db68',
'#14675a',
'#8288f4',
'#fe1ca0',
'#ad6aff',
'#786306',
'#9bfbaf',
'#b00cf7',
'#2f7ec5',
'#4b998b',
'#42fa0e',
'#5b80a1',
'#962d9d',
'#3385ff',
'#48c5ab',
'#b2c873',
'#4cf9a4',
'#00ffff',
'#3cca73',
'#99ae17',
'#7af5cf',
'#52af45',
'#fbb80f',
'#29971b',
'#187c9a',
'#00d539',
'#bbfa1a',
'#61f55c',
'#cabc03',
'#ff9000',
'#779100',
'#bcfd6f',
'#70a560',
] ]
export const CHOICE_OTHER_COLOR = '#CCC'
export const CHOICE_ALL_COLORS = [...CHOICE_ANSWER_COLORS, CHOICE_OTHER_COLOR]
const MARGIN = { top: 20, right: 10, bottom: 20, left: 40 } const MARGIN = { top: 20, right: 10, bottom: 20, left: 40 }
const MARGIN_X = MARGIN.left + MARGIN.right const MARGIN_X = MARGIN.left + MARGIN.right
const MARGIN_Y = MARGIN.top + MARGIN.bottom const MARGIN_Y = MARGIN.top + MARGIN.bottom
const getTrackedAnswers = ( const getAnswers = (contract: ChoiceContract) => {
contract: FreeResponseContract | MultipleChoiceContract, const { answers, outcomeType } = contract
topN: number const validAnswers = answers.filter(
) => { (answer) => answer.id !== '0' || outcomeType === 'MULTIPLE_CHOICE'
const { answers, outcomeType, totalBets } = contract
const validAnswers = answers.filter((answer) => {
return (
(answer.id !== '0' || outcomeType === 'MULTIPLE_CHOICE') &&
totalBets[answer.id] > 0.000000001
) )
})
return sortBy( return sortBy(
validAnswers, validAnswers,
(answer) => -1 * getOutcomeProbability(contract, answer.id) (answer) => -1 * getOutcomeProbability(contract, answer.id)
).slice(0, topN) )
} }
const getBetPoints = (answers: Answer[], bets: Bet[]) => { const getBetPoints = (answers: Answer[], bets: Bet[], topN?: number) => {
const sortedBets = sortBy(bets, (b) => b.createdTime) const sortedBets = sortBy(bets, (b) => b.createdTime)
const betsByOutcome = groupBy(sortedBets, (bet) => bet.outcome) const betsByOutcome = groupBy(sortedBets, (bet) => bet.outcome)
const sharesByOutcome = Object.fromEntries( const sharesByOutcome = Object.fromEntries(
@ -112,11 +62,14 @@ const getBetPoints = (answers: Answer[], bets: Bet[]) => {
const sharesSquared = sum( const sharesSquared = sum(
Object.values(sharesByOutcome).map((shares) => shares ** 2) Object.values(sharesByOutcome).map((shares) => shares ** 2)
) )
points.push({ const probs = answers.map((a) => sharesByOutcome[a.id] ** 2 / sharesSquared)
x: new Date(bet.createdTime),
y: answers.map((a) => sharesByOutcome[a.id] ** 2 / sharesSquared), if (topN != null && answers.length > topN) {
obj: bet, const y = [...probs.slice(0, topN), sum(probs.slice(topN))]
}) points.push({ x: new Date(bet.createdTime), y, obj: bet })
} else {
points.push({ x: new Date(bet.createdTime), y: probs, obj: bet })
}
} }
return points return points
} }
@ -144,17 +97,12 @@ const Legend = (props: { className?: string; items: LegendItem[] }) => {
) )
} }
export function useChartAnswers( export function useChartAnswers(contract: ChoiceContract) {
contract: FreeResponseContract | MultipleChoiceContract return useMemo(() => getAnswers(contract), [contract])
) {
return useMemo(
() => getTrackedAnswers(contract, CATEGORY_COLORS.length),
[contract]
)
} }
export const ChoiceContractChart = (props: { export const ChoiceContractChart = (props: {
contract: FreeResponseContract | MultipleChoiceContract contract: ChoiceContract
bets: Bet[] bets: Bet[]
width: number width: number
height: number height: number
@ -163,18 +111,33 @@ export const ChoiceContractChart = (props: {
const { contract, bets, width, height, onMouseOver } = props const { contract, bets, width, height, onMouseOver } = props
const [start, end] = getDateRange(contract) const [start, end] = getDateRange(contract)
const answers = useChartAnswers(contract) const answers = useChartAnswers(contract)
const betPoints = useMemo(() => getBetPoints(answers, bets), [answers, bets]) const topN = Math.min(CHOICE_ANSWER_COLORS.length, answers.length)
const data = useMemo( const betPoints = useMemo(
() => [ () => getBetPoints(answers, bets, topN),
{ x: new Date(start), y: answers.map((_) => 0) }, [answers, bets, topN]
)
const endProbs = useMemo(
() => answers.map((a) => getOutcomeProbability(contract, a.id)),
[answers, contract]
)
const data = useMemo(() => {
const yCount = answers.length > topN ? topN + 1 : topN
const startY = range(0, yCount).map((_) => 0)
const endY =
answers.length > topN
? [...endProbs.slice(0, topN), sum(endProbs.slice(topN))]
: endProbs
return [
{ x: new Date(start), y: startY },
...betPoints, ...betPoints,
{ {
x: new Date(end ?? Date.now() + DAY_MS), x: new Date(end ?? Date.now() + DAY_MS),
y: answers.map((a) => getOutcomeProbability(contract, a.id)), y: endY,
}, },
], ]
[answers, contract, betPoints, start, end] }, [answers.length, topN, betPoints, endProbs, start, end])
)
const rightmostDate = getRightmostVisibleDate( const rightmostDate = getRightmostVisibleDate(
end, end,
last(betPoints)?.x?.getTime(), last(betPoints)?.x?.getTime(),
@ -191,8 +154,8 @@ export const ChoiceContractChart = (props: {
const d = xScale.invert(x) const d = xScale.invert(x)
const legendItems = sortBy( const legendItems = sortBy(
data.y.map((p, i) => ({ data.y.map((p, i) => ({
color: CATEGORY_COLORS[i], color: CHOICE_ALL_COLORS[i],
label: answers[i].text, label: i === CHOICE_ANSWER_COLORS.length ? 'Other' : answers[i].text,
value: formatPct(p), value: formatPct(p),
p, p,
})), })),
@ -224,7 +187,7 @@ export const ChoiceContractChart = (props: {
yScale={yScale} yScale={yScale}
yKind="percent" yKind="percent"
data={data} data={data}
colors={CATEGORY_COLORS} colors={CHOICE_ALL_COLORS}
curve={curveStepAfter} curve={curveStepAfter}
onMouseOver={onMouseOver} onMouseOver={onMouseOver}
Tooltip={ChoiceTooltip} Tooltip={ChoiceTooltip}

View File

@ -219,6 +219,7 @@ export const MultiValueHistoryChart = <P extends MultiPoint>(props: {
const onSelect = useEvent((ev: D3BrushEvent<P>) => { const onSelect = useEvent((ev: D3BrushEvent<P>) => {
if (ev.selection) { if (ev.selection) {
const [mouseX0, mouseX1] = ev.selection as [number, number] const [mouseX0, mouseX1] = ev.selection as [number, number]
setViewXScale(() => setViewXScale(() =>
xScale.copy().domain([xScale.invert(mouseX0), xScale.invert(mouseX1)]) xScale.copy().domain([xScale.invert(mouseX0), xScale.invert(mouseX1)])
) )
@ -325,11 +326,26 @@ export const SingleValueHistoryChart = <P extends HistoryPoint>(props: {
const onSelect = useEvent((ev: D3BrushEvent<P>) => { const onSelect = useEvent((ev: D3BrushEvent<P>) => {
if (ev.selection) { if (ev.selection) {
const [mouseX0, mouseX1] = ev.selection as [number, number] const [mouseX0, mouseX1] = ev.selection as [number, number]
setViewXScale(() => const newViewXScale = xScale
xScale.copy().domain([xScale.invert(mouseX0), xScale.invert(mouseX1)]) .copy()
) .domain([xScale.invert(mouseX0), xScale.invert(mouseX1)])
setViewXScale(() => newViewXScale)
const dataInView = data.filter((p) => {
const x = newViewXScale(p.x)
return x >= 0 && x <= w
})
const yMin = Math.min(...dataInView.map((p) => p.y))
const yMax = Math.max(...dataInView.map((p) => p.y))
// Prevents very small selections from being too zoomed in
if (yMax - yMin > 0.05) {
// adds a little padding to the top and bottom of the selection
yScale.domain([yMin - (yMax - yMin) * 0.1, yMax + (yMax - yMin) * 0.1])
}
} else { } else {
setViewXScale(undefined) setViewXScale(undefined)
yScale.domain([0, 1])
} }
}) })

View File

@ -9,6 +9,7 @@ import clsx from 'clsx'
import { Contract } from 'common/contract' import { Contract } from 'common/contract'
import { useMeasureSize } from 'web/hooks/use-measure-size' import { useMeasureSize } from 'web/hooks/use-measure-size'
import { useIsMobile } from 'web/hooks/use-is-mobile'
export type Point<X, Y, T = unknown> = { x: X; y: Y; obj?: T } export type Point<X, Y, T = unknown> = { x: X; y: Y; obj?: T }
@ -168,6 +169,7 @@ export const SVGChart = <X, TT>(props: {
const innerW = w - (margin.left + margin.right) const innerW = w - (margin.left + margin.right)
const innerH = h - (margin.top + margin.bottom) const innerH = h - (margin.top + margin.bottom)
const clipPathId = useMemo(() => nanoid(), []) const clipPathId = useMemo(() => nanoid(), [])
const isMobile = useIsMobile()
const justSelected = useRef(false) const justSelected = useRef(false)
useEffect(() => { useEffect(() => {
@ -207,6 +209,15 @@ export const SVGChart = <X, TT>(props: {
} }
} }
const onTouchMove = (ev: React.TouchEvent) => {
if (onMouseOver) {
const touch = ev.touches[0]
const x = touch.pageX - ev.currentTarget.getBoundingClientRect().left
const y = touch.pageY - ev.currentTarget.getBoundingClientRect().top
onMouseOver(x, y)
}
}
const onPointerLeave = () => { const onPointerLeave = () => {
onMouseLeave?.() onMouseLeave?.()
} }
@ -222,8 +233,9 @@ export const SVGChart = <X, TT>(props: {
ttParams.y, ttParams.y,
innerW, innerW,
innerH, innerH,
tooltipMeasure.width, tooltipMeasure.width ?? 140,
tooltipMeasure.height tooltipMeasure.height ?? 35,
isMobile ?? false
)} )}
> >
<Tooltip <Tooltip
@ -242,6 +254,7 @@ export const SVGChart = <X, TT>(props: {
<XAxis axis={xAxis} w={innerW} h={innerH} /> <XAxis axis={xAxis} w={innerW} h={innerH} />
<YAxis axis={yAxis} w={innerW} h={innerH} /> <YAxis axis={yAxis} w={innerW} h={innerH} />
<g clipPath={`url(#${clipPathId})`}>{children}</g> <g clipPath={`url(#${clipPathId})`}>{children}</g>
{!isMobile ? (
<g <g
ref={overlayRef} ref={overlayRef}
x="0" x="0"
@ -254,6 +267,17 @@ export const SVGChart = <X, TT>(props: {
onPointerMove={onPointerMove} onPointerMove={onPointerMove}
onPointerLeave={onPointerLeave} onPointerLeave={onPointerLeave}
/> />
) : (
<rect
x="0"
y="0"
width={innerW}
height={innerH}
fill="transparent"
onTouchMove={onTouchMove}
onTouchEnd={onPointerLeave}
/>
)}
</g> </g>
</svg> </svg>
</div> </div>
@ -267,23 +291,28 @@ export const getTooltipPosition = (
mouseY: number, mouseY: number,
containerWidth: number, containerWidth: number,
containerHeight: number, containerHeight: number,
tooltipWidth?: number, tooltipWidth: number,
tooltipHeight?: number tooltipHeight: number,
isMobile: boolean
) => { ) => {
let left = mouseX + 12 let left = mouseX + 12
let bottom = containerHeight - mouseY + 12 let bottom = !isMobile
? containerHeight - mouseY + 12
: containerHeight - tooltipHeight + 12
if (tooltipWidth != null) { if (tooltipWidth != null) {
const overflow = left + tooltipWidth - containerWidth const overflow = left + tooltipWidth - containerWidth
if (overflow > 0) { if (overflow > 0) {
left -= overflow left -= overflow
} }
} }
if (tooltipHeight != null) { if (tooltipHeight != null) {
const overflow = tooltipHeight - mouseY const overflow = tooltipHeight - mouseY
if (overflow > 0) { if (overflow > 0) {
bottom -= overflow bottom -= overflow
} }
} }
return { left, bottom } return { left, bottom }
} }

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