@moneypot/hub 1.2.7 → 1.3.0-dev.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -28,6 +28,7 @@ import {
28
28
  type ServerOptions,
29
29
  defaultPlugins,
30
30
  startAndListen,
31
+ MakeOutcomeBetPlugin,
31
32
  } from "@moneypot/hub";
32
33
  import path from "path";
33
34
 
@@ -40,6 +41,9 @@ const options: ServerOptions = {
40
41
  // These are required for the hub server to function
41
42
  ...defaultPlugins,
42
43
  // Add your custom plugins here to extend server functionality
44
+ MakeOutcomeBetPlugin({
45
+ houseEdge: 0.01,
46
+ }),
43
47
  ],
44
48
 
45
49
  // File path where the generated GraphQL schema definition will be saved
@@ -6,8 +6,8 @@ export * from "./types.js";
6
6
  export * from "./public.js";
7
7
  export * from "./util.js";
8
8
  export declare function getPgClient(connectionString: string): InstanceType<typeof pg.Client>;
9
- export declare const postgraphilePool: import("pg").Pool;
10
- export declare const superuserPool: import("pg").Pool;
9
+ export declare const postgraphilePool: pg.Pool;
10
+ export declare const superuserPool: pg.Pool;
11
11
  export interface QueryExecutor {
12
12
  query<T extends pg.QueryResultRow = any>(queryText: string, values?: any[]): Promise<pg.QueryResult<T>>;
13
13
  }
@@ -1,5 +1,6 @@
1
1
  import { Express } from "express";
2
2
  import { Logger } from "./logger.js";
3
+ export { MakeOutcomeBetPlugin } from "./plugins/hub-make-outcome-bet.js";
3
4
  export { defaultPlugins, type PluginContext, type PluginIdentity, type UserSessionContext, } from "./server/graphile.config.js";
4
5
  export type ServerOptions = {
5
6
  configureApp?: (app: Express) => void;
package/dist/src/index.js CHANGED
@@ -5,6 +5,7 @@ import { createHubServer } from "./server/index.js";
5
5
  import { initializeTransferProcessors } from "./process-transfers.js";
6
6
  import { join } from "path";
7
7
  import { logger, setLogger } from "./logger.js";
8
+ export { MakeOutcomeBetPlugin } from "./plugins/hub-make-outcome-bet.js";
8
9
  export { defaultPlugins, } from "./server/graphile.config.js";
9
10
  async function initialize(options) {
10
11
  if (options.logger) {
@@ -54,6 +55,9 @@ async function initialize(options) {
54
55
  });
55
56
  }
56
57
  export async function startAndListen(options) {
58
+ if (options.plugins && options.plugins.some((p) => typeof p === "function")) {
59
+ throw new Error("`plugins` should be an array of GraphileConfig.Plugin but one of the items is a function. Did you forget to call it?");
60
+ }
57
61
  if (options.userDatabaseMigrationsPath &&
58
62
  !options.userDatabaseMigrationsPath.startsWith("/")) {
59
63
  throw new Error(`userDatabaseMigrationsPath must be an absolute path, got ${options.userDatabaseMigrationsPath}`);
@@ -0,0 +1,56 @@
1
+ create type hub.outcome as (
2
+ weight float,
3
+ profit float
4
+ );
5
+
6
+ create table hub.outcome_bet (
7
+ id uuid primary key default hub_hidden.uuid_generate_v7(),
8
+
9
+ -- Operator-given kind like "WHEEL", "PLINKO",
10
+ -- Probably not worth using a postgres enum here since they are not flexible.
11
+ kind text not null,
12
+
13
+ user_id uuid not null references hub.user(id),
14
+ experience_id uuid not null references hub.experience(id),
15
+ casino_id uuid not null references hub.casino(id),
16
+
17
+ currency_key text not null,
18
+ wager float not null,
19
+
20
+ -- -1 = lose wager, -2 = lose 2x wager
21
+ -- 0.5 = 1.5x wager, 1 = 2x wager
22
+ -- In other words, a multiplier in a game might say "2.5x",
23
+ -- but the profit is multiplier-1 = 1.5 profit
24
+ profit float not null,
25
+
26
+ -- null when no outcomes saved
27
+ outcome_idx smallint null,
28
+ outcomes hub.outcome[] not null default '{}',
29
+
30
+ -- Operator-provided data per bet
31
+ metadata jsonb not null default '{}',
32
+
33
+ foreign key (currency_key, casino_id) references hub.currency(key, casino_id)
34
+ );
35
+
36
+ create index outcome_bet_user_id_idx on hub.outcome_bet(user_id);
37
+ create index outcome_bet_experience_id_idx on hub.outcome_bet(experience_id);
38
+ create index outcome_bet_casino_id_idx on hub.outcome_bet(casino_id);
39
+ create index outcome_bet_kind_idx on hub.outcome_bet(kind);
40
+
41
+ -- GRANT
42
+
43
+ grant select on hub.outcome_bet to app_postgraphile;
44
+
45
+ -- RLS
46
+
47
+ alter table hub.outcome_bet enable row level security;
48
+
49
+ create policy select_outcome_bet on hub.outcome_bet for select using (
50
+ hub_hidden.is_operator() or
51
+ (
52
+ user_id = hub_hidden.current_user_id() and
53
+ experience_id = hub_hidden.current_experience_id() and
54
+ casino_id = hub_hidden.current_casino_id()
55
+ )
56
+ );
@@ -0,0 +1,57 @@
1
+ import { z } from "zod";
2
+ declare const InputSchema: z.ZodObject<{
3
+ kind: z.ZodString;
4
+ wager: z.ZodNumber;
5
+ currency: z.ZodString;
6
+ outcomes: z.ZodEffects<z.ZodEffects<z.ZodArray<z.ZodObject<{
7
+ weight: z.ZodNumber;
8
+ profit: z.ZodNumber;
9
+ }, "strict", z.ZodTypeAny, {
10
+ weight: number;
11
+ profit: number;
12
+ }, {
13
+ weight: number;
14
+ profit: number;
15
+ }>, "many">, {
16
+ weight: number;
17
+ profit: number;
18
+ }[], {
19
+ weight: number;
20
+ profit: number;
21
+ }[]>, {
22
+ weight: number;
23
+ profit: number;
24
+ }[], {
25
+ weight: number;
26
+ profit: number;
27
+ }[]>;
28
+ }, "strict", z.ZodTypeAny, {
29
+ currency: string;
30
+ kind: string;
31
+ wager: number;
32
+ outcomes: {
33
+ weight: number;
34
+ profit: number;
35
+ }[];
36
+ }, {
37
+ currency: string;
38
+ kind: string;
39
+ wager: number;
40
+ outcomes: {
41
+ weight: number;
42
+ profit: number;
43
+ }[];
44
+ }>;
45
+ type Input = z.infer<typeof InputSchema>;
46
+ type BetConfig = {
47
+ houseEdge: number;
48
+ saveOutcomes: boolean;
49
+ getMetadata?: (input: Input) => Promise<Record<string, any>>;
50
+ };
51
+ export type BetConfigMap<BetKind extends string> = {
52
+ [betKind in BetKind]: BetConfig;
53
+ };
54
+ export declare function MakeOutcomeBetPlugin<BetKind extends string>({ betConfigs }: {
55
+ betConfigs: BetConfigMap<BetKind>;
56
+ }): GraphileConfig.Plugin;
57
+ export {};
@@ -0,0 +1,325 @@
1
+ import { context, object, sideEffect } from "postgraphile/grafast";
2
+ import { gql, makeExtendSchemaPlugin } from "postgraphile/utils";
3
+ import { z } from "zod";
4
+ import { GraphQLError } from "graphql";
5
+ import { exactlyOneRow, maybeOneRow, superuserPool, withPgPoolTransaction, } from "../db/index.js";
6
+ import { assert } from "tsafe";
7
+ const FLOAT_EPSILON = 1e-10;
8
+ function sum(ns) {
9
+ return ns.reduce((a, b) => a + b, 0);
10
+ }
11
+ function calculatePlayerEV(outcomes) {
12
+ const totalWeight = sum(outcomes.map((o) => o.weight));
13
+ return sum(outcomes.map((o) => (o.weight / totalWeight) * o.profit));
14
+ }
15
+ function calculateHouseEV(outcomes) {
16
+ return -calculatePlayerEV(outcomes);
17
+ }
18
+ const OutcomeSchema = z
19
+ .object({
20
+ weight: z.number().gt(0, `Outcome weight must be > 0`),
21
+ profit: z.number(),
22
+ })
23
+ .strict();
24
+ const InputSchema = z
25
+ .object({
26
+ kind: z.string(),
27
+ wager: z
28
+ .number()
29
+ .int("Wager must be an integer")
30
+ .gte(1, "Wager must be >= 1"),
31
+ currency: z.string(),
32
+ outcomes: z
33
+ .array(OutcomeSchema)
34
+ .min(2, "Outcome count must be >= 2")
35
+ .max(50, "Outcome count must be <= 50")
36
+ .refine((data) => data.some((o) => o.profit < 0), "At least one outcome should have profit < 0")
37
+ .refine((data) => data.some((o) => o.profit > 0), "At least one outcome should have profit > 0"),
38
+ })
39
+ .strict();
40
+ const BetKindSchema = z
41
+ .string()
42
+ .min(1, "Bet kind must be at least 1 character")
43
+ .regex(/^[A-Z_]+$/, "Bet kind must only contain A-Z and underscores")
44
+ .regex(/^[A-Z]/, "Bet kind must start with a letter A-Z");
45
+ const BetConfigsSchema = z.record(BetKindSchema, z.object({
46
+ houseEdge: z
47
+ .number()
48
+ .gte(0, "House edge must be >= 0")
49
+ .lte(1, "House edge must be <= 1"),
50
+ saveOutcomes: z.boolean(),
51
+ getMetadata: z
52
+ .function()
53
+ .args(InputSchema)
54
+ .returns(z.record(z.string(), z.any()))
55
+ .optional(),
56
+ }));
57
+ export function MakeOutcomeBetPlugin({ betConfigs }) {
58
+ BetConfigsSchema.parse(betConfigs);
59
+ const betKinds = Object.keys(betConfigs);
60
+ return makeExtendSchemaPlugin((build) => {
61
+ const outcomeBetTable = build.input.pgRegistry.pgResources.hub_outcome_bet;
62
+ const typeDefs = gql `
63
+ enum BetKind {
64
+ ${betKinds.join("\n")}
65
+ }
66
+
67
+ input HubMakeOutcomeBetInput {
68
+ kind: BetKind!
69
+ wager: Int!
70
+ currency: String!
71
+ outcomes: [HubOutcomeInput!]!
72
+ }
73
+
74
+ type HubMakeOutcomeBetPayload {
75
+ bet: HubOutcomeBet!
76
+ }
77
+
78
+ extend type Mutation {
79
+ hubMakeOutcomeBet(input: HubMakeOutcomeBetInput!): HubMakeOutcomeBetPayload
80
+ }
81
+ `;
82
+ return {
83
+ typeDefs,
84
+ plans: {
85
+ Mutation: {
86
+ hubMakeOutcomeBet: (_, { $input }) => {
87
+ const $identity = context().get("identity");
88
+ const $betId = sideEffect([$identity, $input], async ([identity, rawInput]) => {
89
+ if (identity?.kind !== "user") {
90
+ throw new GraphQLError("Unauthorized");
91
+ }
92
+ let input;
93
+ let betKind;
94
+ try {
95
+ input = InputSchema.parse(rawInput);
96
+ betKind = BetKindSchema.parse(rawInput.kind);
97
+ }
98
+ catch (e) {
99
+ if (e instanceof z.ZodError) {
100
+ throw new GraphQLError(e.errors[0].message);
101
+ }
102
+ throw e;
103
+ }
104
+ const betConfig = betConfigs[betKind];
105
+ if (!betConfig) {
106
+ throw new GraphQLError(`Invalid bet kind`);
107
+ }
108
+ if (!betKinds.includes(rawInput.kind)) {
109
+ throw new GraphQLError(`Invalid bet kind`);
110
+ }
111
+ const houseEV = calculateHouseEV(input.outcomes);
112
+ const minHouseEV = Math.max(0, betConfig.houseEdge - FLOAT_EPSILON);
113
+ if (houseEV < minHouseEV) {
114
+ throw new GraphQLError(`No deal. House EV too low: ${houseEV.toFixed(4)}`);
115
+ }
116
+ const { session } = identity;
117
+ const dbCurrency = await superuserPool
118
+ .query({
119
+ text: `
120
+ SELECT key
121
+ FROM hub.currency
122
+ WHERE key = $1
123
+ AND casino_id = $2
124
+ `,
125
+ values: [input.currency, session.casino_id],
126
+ })
127
+ .then(maybeOneRow);
128
+ if (!dbCurrency) {
129
+ throw new GraphQLError("Currency not found");
130
+ }
131
+ return withPgPoolTransaction(superuserPool, async (pgClient) => {
132
+ const { dbPlayerBalance, dbHouseBankroll, found } = await dbLockBalanceAndBankroll(pgClient, {
133
+ userId: session.user_id,
134
+ casinoId: session.casino_id,
135
+ experienceId: session.experience_id,
136
+ currencyKey: dbCurrency.key,
137
+ });
138
+ if (!found) {
139
+ throw new GraphQLError("No balance entry found for player or house");
140
+ }
141
+ const minProfit = Math.min(...input.outcomes.map((o) => o.profit));
142
+ const maxPlayerLoss = Math.abs(input.wager * minProfit);
143
+ console.log("Determining if player can afford bet", {
144
+ wager: input.wager,
145
+ minProfit,
146
+ maxPlayerLoss,
147
+ dbPlayerBalance,
148
+ });
149
+ if (dbPlayerBalance < maxPlayerLoss) {
150
+ if (minProfit === -1) {
151
+ throw new GraphQLError(`You cannot afford wager`);
152
+ }
153
+ throw new GraphQLError("You cannot afford the worst outcome");
154
+ }
155
+ const maxProfitMultiplier = Math.max(...input.outcomes.map((o) => o.profit));
156
+ const maxPotentialPayout = input.wager * maxProfitMultiplier;
157
+ const maxAllowablePayout = dbHouseBankroll * 0.01;
158
+ if (maxPotentialPayout > maxAllowablePayout) {
159
+ throw new GraphQLError(`House risk limit exceeded. Max payout: ${maxPotentialPayout.toFixed(4)}`);
160
+ }
161
+ const { outcome, outcomeIdx } = pickRandomOutcome(input.outcomes);
162
+ const netPlayerAmount = input.wager * outcome.profit;
163
+ await pgClient.query({
164
+ text: `
165
+ UPDATE hub.balance
166
+ SET amount = amount + $1
167
+ WHERE user_id = $2
168
+ AND casino_id = $3
169
+ AND experience_id = $4
170
+ AND currency_key = $5
171
+ `,
172
+ values: [
173
+ netPlayerAmount,
174
+ session.user_id,
175
+ session.casino_id,
176
+ session.experience_id,
177
+ dbCurrency.key,
178
+ ],
179
+ });
180
+ await pgClient.query({
181
+ text: `
182
+ UPDATE hub.bankroll
183
+ SET amount = amount - $1,
184
+ bets = bets + 1,
185
+ wagered = wagered + $4
186
+ WHERE currency_key = $2
187
+ AND casino_id = $3
188
+ `,
189
+ values: [
190
+ netPlayerAmount,
191
+ dbCurrency.key,
192
+ session.casino_id,
193
+ input.wager,
194
+ ],
195
+ });
196
+ const newBet = {
197
+ kind: rawInput.kind,
198
+ wager: input.wager,
199
+ profit: outcome.profit,
200
+ currency_key: dbCurrency.key,
201
+ user_id: session.user_id,
202
+ casino_id: session.casino_id,
203
+ experience_id: session.experience_id,
204
+ metadata: betConfig.getMetadata
205
+ ? await betConfig.getMetadata(input)
206
+ : {},
207
+ ...(betConfig.saveOutcomes
208
+ ? {
209
+ outcomes: input.outcomes,
210
+ outcome_idx: outcomeIdx,
211
+ }
212
+ : {
213
+ outcomes: [],
214
+ outcome_idx: null,
215
+ }),
216
+ };
217
+ const bet = await pgClient
218
+ .query({
219
+ text: `
220
+ INSERT INTO hub.outcome_bet (
221
+ user_id,
222
+ casino_id,
223
+ experience_id,
224
+ kind,
225
+ currency_key,
226
+ wager,
227
+ profit,
228
+ outcomes,
229
+ outcome_idx,
230
+ metadata
231
+ )
232
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
233
+ RETURNING id
234
+ `,
235
+ values: [
236
+ newBet.user_id,
237
+ newBet.casino_id,
238
+ newBet.experience_id,
239
+ newBet.kind,
240
+ newBet.currency_key,
241
+ newBet.wager,
242
+ newBet.profit,
243
+ newBet.outcomes.map((o) => `(${o.weight},${o.profit})`),
244
+ newBet.outcome_idx,
245
+ newBet.metadata,
246
+ ],
247
+ })
248
+ .then(exactlyOneRow);
249
+ return bet.id;
250
+ });
251
+ });
252
+ return object({
253
+ bet: outcomeBetTable.get({ id: $betId }),
254
+ });
255
+ },
256
+ },
257
+ },
258
+ };
259
+ }, "HubMakeOutcomeBetPlugin");
260
+ }
261
+ function generateRandomNumber() {
262
+ const array = new Uint32Array(1);
263
+ crypto.getRandomValues(array);
264
+ return array[0] / 2 ** 32;
265
+ }
266
+ function pickRandomOutcome(outcomes) {
267
+ assert(outcomes.length >= 2, "Outcome count must be >= 2");
268
+ const totalWeight = sum(outcomes.map((o) => o.weight));
269
+ const outcomesWithProbability = outcomes.map((o) => ({
270
+ ...o,
271
+ probability: o.weight / totalWeight,
272
+ }));
273
+ const totalProb = outcomesWithProbability.reduce((sum, outcome) => sum + outcome.probability, 0);
274
+ assert(Math.abs(totalProb - 1.0) < FLOAT_EPSILON, "Probabilities must sum to ~1");
275
+ const randomValue = generateRandomNumber();
276
+ let cumProb = 0;
277
+ for (let i = 0; i < outcomesWithProbability.length; i++) {
278
+ const outcome = outcomesWithProbability[i];
279
+ cumProb += outcome.probability;
280
+ if (randomValue <= cumProb) {
281
+ return { outcome, outcomeIdx: i };
282
+ }
283
+ }
284
+ return {
285
+ outcome: outcomes[outcomes.length - 1],
286
+ outcomeIdx: outcomes.length - 1,
287
+ };
288
+ }
289
+ async function dbLockBalanceAndBankroll(pgClient, { userId, casinoId, experienceId, currencyKey, }) {
290
+ assert(pgClient._inTransaction, "pgClient must be in a transaction");
291
+ const row = await pgClient
292
+ .query(`
293
+ WITH locked_balance AS (
294
+ SELECT amount
295
+ FROM hub.balance
296
+ WHERE user_id = $1
297
+ AND casino_id = $2
298
+ AND experience_id = $3
299
+ AND currency_key = $4
300
+
301
+ FOR UPDATE
302
+ )
303
+ SELECT
304
+ pb.amount as player_balance,
305
+ br.amount as house_bankroll
306
+ FROM locked_balance pb
307
+ JOIN hub.bankroll br
308
+ ON br.casino_id = $2
309
+ AND br.currency_key = $4
310
+
311
+ FOR UPDATE OF br
312
+ `, [userId, casinoId, experienceId, currencyKey])
313
+ .then(maybeOneRow);
314
+ return row
315
+ ? {
316
+ found: true,
317
+ dbPlayerBalance: row.player_balance,
318
+ dbHouseBankroll: row.house_bankroll,
319
+ }
320
+ : {
321
+ found: false,
322
+ dbPlayerBalance: null,
323
+ dbHouseBankroll: null,
324
+ };
325
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moneypot/hub",
3
- "version": "1.2.7",
3
+ "version": "1.3.0-dev.1",
4
4
  "author": "moneypot.com",
5
5
  "homepage": "https://moneypot.com/hub",
6
6
  "keywords": [