dubs-server 1.0.0

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.
Files changed (304) hide show
  1. package/.claude/settings.local.json +280 -0
  2. package/CLAUDE.md +46 -0
  3. package/CONNECT4_PRODUCTION_DEPLOY.md +155 -0
  4. package/CURRENT_SESSION.md +171 -0
  5. package/CURRENT_SESSION_DRAW.md +516 -0
  6. package/MARCH_MADNESS_SURVIVOR.md +254 -0
  7. package/PANDA.md +166 -0
  8. package/Procfile +4 -0
  9. package/README.md +476 -0
  10. package/controllers/livescoresController.js +376 -0
  11. package/controllers/pickemController.js +554 -0
  12. package/controllers/survivorAdminController.js +887 -0
  13. package/controllers/survivorController.js +623 -0
  14. package/cron/oracleMonitor.js +77 -0
  15. package/cron/pickemOracleMonitor.js +73 -0
  16. package/data/jackpot-history.json +952 -0
  17. package/data/ncaaTeams.js +406 -0
  18. package/documentation/API_SECURITY_GUIDE.md +327 -0
  19. package/documentation/ARCADE_API.md +593 -0
  20. package/documentation/ARCADE_IMPLEMENTATION_SUMMARY.md +399 -0
  21. package/documentation/ARCADE_QUICKSTART.md +242 -0
  22. package/documentation/AUTOMATIC_MODE_ORACLE.md +321 -0
  23. package/documentation/BUG_FIX_COHORT_DATE_DISPLAY.md +171 -0
  24. package/documentation/CLAIM_MIGRATION_INSTRUCTIONS.md +52 -0
  25. package/documentation/CLAIM_STATUS_FIX.md +67 -0
  26. package/documentation/CLI_TOOL_GUIDE.md +372 -0
  27. package/documentation/COHORT_RETENTION_ANALYSIS.md +295 -0
  28. package/documentation/COHORT_RETENTION_IMPLEMENTATION_COMPLETE.md +461 -0
  29. package/documentation/COHORT_RETENTION_SUMMARY.md +204 -0
  30. package/documentation/COMPLETE_PROJECT_SUMMARY.md +490 -0
  31. package/documentation/DATABASE_QUERIES.md +269 -0
  32. package/documentation/DATABASE_RETENTION_POLICY.md +390 -0
  33. package/documentation/DATABASE_SETUP_GUIDE.md +361 -0
  34. package/documentation/DATABASE_SETUP_SUMMARY.md +247 -0
  35. package/documentation/DEMO_API_CURL_COMMANDS.md +656 -0
  36. package/documentation/DEPLOYMENT_SUMMARY.txt +100 -0
  37. package/documentation/DUPLICATE_NOTIFICATIONS_FIXED.md +201 -0
  38. package/documentation/EXCHANGE_RATES_INTEGRATION.md +371 -0
  39. package/documentation/FINAL_API_PROTECTION_TABLE.md +175 -0
  40. package/documentation/GAME_START_NOTIFICATIONS_DEPLOYMENT.md +256 -0
  41. package/documentation/GAME_START_NOTIFICATIONS_INTEGRATION.md +275 -0
  42. package/documentation/HEROKU_DEPLOYMENT.md +134 -0
  43. package/documentation/HEROKU_SCHEDULER_SETUP.md +271 -0
  44. package/documentation/JACKPOT_API.md +521 -0
  45. package/documentation/JACKPOT_DEPLOYMENT_GUIDE.md +362 -0
  46. package/documentation/JWT_IMPLEMENTATION_SUMMARY.md +373 -0
  47. package/documentation/JWT_QUICK_SETUP.md +268 -0
  48. package/documentation/JWT_TESTING_GUIDE.md +404 -0
  49. package/documentation/KEEPER_RECOVERY_GUIDE.md +381 -0
  50. package/documentation/KEEPER_SETUP.md +206 -0
  51. package/documentation/KEEPER_STATE_MACHINE.md +423 -0
  52. package/documentation/LATEST_PRODUCTION_SETUP.md +387 -0
  53. package/documentation/LOCAL_VOTING_TEST.md +279 -0
  54. package/documentation/ORACLE_FIXES_SUMMARY.md +188 -0
  55. package/documentation/ORACLE_POSTGRESQL_UPDATE.md +202 -0
  56. package/documentation/PAYMENT_DEPLOYMENT.md +209 -0
  57. package/documentation/PNL_TRACKING_SETUP.md +189 -0
  58. package/documentation/PREVENTING_LOCKUP_ERRORS.md +472 -0
  59. package/documentation/PRODUCTION_READY_SUMMARY.md +227 -0
  60. package/documentation/PUBLIC_VS_PRIVATE_ENDPOINTS.md +278 -0
  61. package/documentation/QUICK_AUTH_SETUP.md +99 -0
  62. package/documentation/QUICK_DEPLOY.md +224 -0
  63. package/documentation/QUICK_FIX.md +114 -0
  64. package/documentation/QUICK_START.md +152 -0
  65. package/documentation/REFEREE_MODE_GUIDE.md +392 -0
  66. package/documentation/RETENTION_CORE_ACTION_UPDATE.md +313 -0
  67. package/documentation/RETENTION_UPDATE_SUMMARY.md +108 -0
  68. package/documentation/RUN_MIGRATION_NOW.md +39 -0
  69. package/documentation/SCRIPTS_UPDATE_SUMMARY.md +251 -0
  70. package/documentation/SETUP_GUIDE.md +184 -0
  71. package/documentation/STATE_MACHINE_IMPLEMENTATION.md +250 -0
  72. package/documentation/TELEGRAM_NOTIFICATIONS_DIAGNOSIS.md +361 -0
  73. package/documentation/UNIFIED_ARCHITECTURE.md +231 -0
  74. package/documentation/VOTING_DEPLOYMENT_SUMMARY.md +392 -0
  75. package/documentation/WEBSOCKET_ARCHITECTURE.md +881 -0
  76. package/documentation/WHAT_WE_BUILT_TODAY.md +369 -0
  77. package/documentation/latest/LATEST_PRODUCTION_SETUP.md +865 -0
  78. package/ecosystem.config.js +65 -0
  79. package/env.template +125 -0
  80. package/middleware/apiKeyAuth.js +136 -0
  81. package/middleware/authenticate.js +214 -0
  82. package/middleware/developerUserAuth.js +76 -0
  83. package/middleware/socketAuth.js +69 -0
  84. package/package.json +49 -0
  85. package/postman/Dubs-API-v1-With-Voting.postman_collection.json +555 -0
  86. package/postman/Dubs-API-v1.postman_collection.json +205 -0
  87. package/postman/Dubs_Developer_API.postman_collection.json +662 -0
  88. package/postman/QUICKSTART.md +118 -0
  89. package/postman/QUICK_REFERENCE.md +246 -0
  90. package/postman/README.md +71 -0
  91. package/postman/VOTING_API_GUIDE.md +426 -0
  92. package/refactor/Animations.md +148 -0
  93. package/refactor/Chat.md +252 -0
  94. package/routes/actionsRoutes.js +699 -0
  95. package/routes/adminRoutes.js +370 -0
  96. package/routes/analyticsRoutes.js +1262 -0
  97. package/routes/arcadeRoutes.js +557 -0
  98. package/routes/authRoutes.js +2310 -0
  99. package/routes/avatarRoutes.js +85 -0
  100. package/routes/botRoutes.js +211 -0
  101. package/routes/chatRoutes.js +377 -0
  102. package/routes/cryptoPriceRoutes.js +105 -0
  103. package/routes/developerRoutes.js +4201 -0
  104. package/routes/deviceRoutes.js +214 -0
  105. package/routes/dmRoutes.js +167 -0
  106. package/routes/esportsRoutes.js +806 -0
  107. package/routes/exchangeRateRoutes.js +233 -0
  108. package/routes/gamesRoutes.js +3028 -0
  109. package/routes/jackpotRoutes.js +754 -0
  110. package/routes/keeperMonitoringRoutes.js +156 -0
  111. package/routes/keeperWebhookRoutes.js +466 -0
  112. package/routes/livescoresRoutes.js +31 -0
  113. package/routes/pickemAdminRoutes.js +199 -0
  114. package/routes/pickemRoutes.js +231 -0
  115. package/routes/playerStatsRoutes.js +147 -0
  116. package/routes/portfolioRoutes.js +217 -0
  117. package/routes/promoRoutes.js +418 -0
  118. package/routes/referralEarningsRoutes.js +392 -0
  119. package/routes/socialRoutes.js +459 -0
  120. package/routes/sportsRoutes.js +1271 -0
  121. package/routes/survivorAdminRoutes.js +345 -0
  122. package/routes/survivorRoutes.js +756 -0
  123. package/routes/uploadRoutes.js +256 -0
  124. package/routes/userProfileRoutes.js +244 -0
  125. package/routes/whatsNewRoutes.js +331 -0
  126. package/scripts/.claude/settings.local.json +15 -0
  127. package/scripts/README.md +170 -0
  128. package/scripts/RESTART_EVERYTHING.sh +104 -0
  129. package/scripts/add-claim-columns.sql +48 -0
  130. package/scripts/add-crypto-prices-cache.sql +27 -0
  131. package/scripts/add-exchange-rates-cache.sql +40 -0
  132. package/scripts/add-game-invite-column.sql +23 -0
  133. package/scripts/add-game-invite-notification.sql +33 -0
  134. package/scripts/add-game-invite-telegram-pref.sql +16 -0
  135. package/scripts/add-game-joined-notification.sql +16 -0
  136. package/scripts/add-game-joined-pref.js +40 -0
  137. package/scripts/add-game-joined-preference.sql +6 -0
  138. package/scripts/add-game-start-notifications.sql +41 -0
  139. package/scripts/add-notification-flags-to-games.sql +55 -0
  140. package/scripts/add-pending-game-dismissals.sql +19 -0
  141. package/scripts/add-preferred-currency.sql +34 -0
  142. package/scripts/add-winner-columns.js +61 -0
  143. package/scripts/add_mention_system.sql +53 -0
  144. package/scripts/add_payment_system.sql +96 -0
  145. package/scripts/add_sports_event_id_column.sql +22 -0
  146. package/scripts/analyze-cohort-data-heroku.js +276 -0
  147. package/scripts/analyze-cohort-data.js +295 -0
  148. package/scripts/analyze-prod-cohorts.sh +10 -0
  149. package/scripts/backfill-matchup-images.js +245 -0
  150. package/scripts/backfill-missing-signatures.js +175 -0
  151. package/scripts/backfill-referral-earnings.js +202 -0
  152. package/scripts/check-chat-schema.js +130 -0
  153. package/scripts/check-db.sh +14 -0
  154. package/scripts/check_oracle_in_game.js +54 -0
  155. package/scripts/cleanup-database.js +193 -0
  156. package/scripts/clear-notification-cache.js +85 -0
  157. package/scripts/convert-mnemonic.js +50 -0
  158. package/scripts/create-users-table.sql +44 -0
  159. package/scripts/debug-cohort-counts.js +248 -0
  160. package/scripts/debug-winner-calc.js +84 -0
  161. package/scripts/deploy-payment-system.sh +118 -0
  162. package/scripts/deploy-to-heroku.sh +63 -0
  163. package/scripts/diagnose-locked-round.js +143 -0
  164. package/scripts/dubs-cli.js +720 -0
  165. package/scripts/dump-account.js +65 -0
  166. package/scripts/find-vrf-offset.js +48 -0
  167. package/scripts/fix-chat-notifications-constraint.sql +122 -0
  168. package/scripts/fix-claim-columns.js +124 -0
  169. package/scripts/fix-constraint-now.js +44 -0
  170. package/scripts/fix-lock-timestamps.js +96 -0
  171. package/scripts/fix-locked-round.sh +126 -0
  172. package/scripts/fix-missing-badges.sql +91 -0
  173. package/scripts/fix-payment-notifications.sql +41 -0
  174. package/scripts/force-new-round.js +55 -0
  175. package/scripts/force-resolve-and-claim.js +278 -0
  176. package/scripts/important/README.md +115 -0
  177. package/scripts/important/authority-force-lock.js +197 -0
  178. package/scripts/important/authority-resolve-game.js +267 -0
  179. package/scripts/important/check-game-status.js +373 -0
  180. package/scripts/important/list-pending-games-by-version.js +270 -0
  181. package/scripts/important/reconcile-v1-v2-payouts.js +270 -0
  182. package/scripts/initialize-jackpot.js +111 -0
  183. package/scripts/jackpot/.claude/settings.local.json +10 -0
  184. package/scripts/jackpot/force-reset.js +84 -0
  185. package/scripts/jackpot/initialize-mainnet.js +100 -0
  186. package/scripts/jackpot/keeper.js +742 -0
  187. package/scripts/jackpot/status.js +107 -0
  188. package/scripts/jackpot/update-round-duration.js +143 -0
  189. package/scripts/keeper-bot.js +112 -0
  190. package/scripts/list-pending-games.js +131 -0
  191. package/scripts/migrate-chat-v2.js +127 -0
  192. package/scripts/migrate-chat-winners.js +84 -0
  193. package/scripts/migrate-chat.sh +17 -0
  194. package/scripts/migrate-game-invite.js +83 -0
  195. package/scripts/migrate-heroku-game-notifications.sh +159 -0
  196. package/scripts/migrations/001_analytics_tables.sql +422 -0
  197. package/scripts/migrations/002_add_matchup_image_url.sql +14 -0
  198. package/scripts/migrations/003_referral_earnings.sql +208 -0
  199. package/scripts/migrations/004_add_whats_new_notification_type.sql +62 -0
  200. package/scripts/migrations/005_add_connect4_your_turn_notification.sql +61 -0
  201. package/scripts/migrations/005_push_notifications.sql +55 -0
  202. package/scripts/migrations/006_add_draw_team_players.sql +28 -0
  203. package/scripts/migrations/006_add_game_cancelled_notification.sql +62 -0
  204. package/scripts/migrations/007_add_gif_url.sql +8 -0
  205. package/scripts/migrations/008_add_connect4_columns.sql +139 -0
  206. package/scripts/migrations/008_add_pool_tracking.sql +22 -0
  207. package/scripts/migrations/009_create_survivor_pool_tables.sql +174 -0
  208. package/scripts/migrations/010_add_survivor_pool_outcome.sql +28 -0
  209. package/scripts/migrations/011_create_developer_tables.sql +67 -0
  210. package/scripts/migrations/011_fix_keeper_tables.sql +85 -0
  211. package/scripts/migrations/012_create_developer_webhooks.sql +31 -0
  212. package/scripts/migrations/013_add_network_mode.sql +18 -0
  213. package/scripts/migrations/014_create_developer_app_users.sql +19 -0
  214. package/scripts/migrations/015_add_ui_config.sql +4 -0
  215. package/scripts/migrations/016_add_resolution_secret.sql +4 -0
  216. package/scripts/migrations/017_add_external_game_id.sql +3 -0
  217. package/scripts/migrations/018_create_pickem_tables.sql +115 -0
  218. package/scripts/migrations/019_expo_push_tokens.sql +19 -0
  219. package/scripts/migrations/create_whats_new_tables.sql +88 -0
  220. package/scripts/migrations/drop_live_games_tables.sql +34 -0
  221. package/scripts/open-jackpot-round.js +85 -0
  222. package/scripts/purge-all-data.sh +329 -0
  223. package/scripts/purge-all-data.sql +142 -0
  224. package/scripts/purge-heroku-data.sh +149 -0
  225. package/scripts/purge-heroku-data.sql +62 -0
  226. package/scripts/rebuild-heroku-database.sh +113 -0
  227. package/scripts/recover-funds.js +357 -0
  228. package/scripts/regenerate-epl-images.js +278 -0
  229. package/scripts/resize-s3-matchup-images.js +374 -0
  230. package/scripts/resolve-direct.js +88 -0
  231. package/scripts/resolve-mock-game.js +124 -0
  232. package/scripts/resolve-pickem-game.js +55 -0
  233. package/scripts/resolve-round-manual.js +83 -0
  234. package/scripts/resolve-stuck-game.js +382 -0
  235. package/scripts/resolve-stuck-round.js +42 -0
  236. package/scripts/run-connect4-migration.sh +16 -0
  237. package/scripts/run-mention-migration.sh +32 -0
  238. package/scripts/run-payment-migration.sh +51 -0
  239. package/scripts/run-preferred-currency-migration.sh +31 -0
  240. package/scripts/run-referral-earnings-migration.sh +32 -0
  241. package/scripts/run-survivor-outcome-migration.sh +16 -0
  242. package/scripts/seed-test-users.js +346 -0
  243. package/scripts/setup-auth-tables.js +78 -0
  244. package/scripts/setup-complete-database.sql +992 -0
  245. package/scripts/setup-database-fresh.sh +359 -0
  246. package/scripts/setup-heroku-keeper.sh +48 -0
  247. package/scripts/setup-keeper-database.js +83 -0
  248. package/scripts/setup-keeper-state-db.sql +110 -0
  249. package/scripts/setup-oracle.sh +39 -0
  250. package/scripts/setup-pnl-tracking.js +111 -0
  251. package/scripts/start-devnet.sh +14 -0
  252. package/scripts/test-arcade-devnet.sh +160 -0
  253. package/scripts/test-arcade-match.sh +109 -0
  254. package/scripts/test-automatic-mode.sh +239 -0
  255. package/scripts/test-connect4-cancel-claim.js +370 -0
  256. package/scripts/test-connect4-e2e.js +369 -0
  257. package/scripts/test-connect4-resolve.js +369 -0
  258. package/scripts/test-game-state-endpoint.js +136 -0
  259. package/scripts/test-invite-notification.js +86 -0
  260. package/scripts/test-jackpot-api.sh +71 -0
  261. package/scripts/test-poll-confirmation.js +267 -0
  262. package/scripts/test-resolve-game.js +271 -0
  263. package/scripts/test-resolve-signature.js +223 -0
  264. package/scripts/test-signature-preservation.js +124 -0
  265. package/scripts/test-state-machine.js +291 -0
  266. package/scripts/test-webhook-receiver.js +60 -0
  267. package/scripts/update-notification-constraint.js +52 -0
  268. package/scripts/verify-account-layout.js +145 -0
  269. package/scripts/verify-winner-algorithm.js +278 -0
  270. package/server.js +5259 -0
  271. package/services/arcadeMatchService.js +763 -0
  272. package/services/automaticGameOracle.js +1596 -0
  273. package/services/chatService.js +1612 -0
  274. package/services/connect4GameService.js +1049 -0
  275. package/services/connect4NotificationService.js +374 -0
  276. package/services/cryptoPriceService.js +223 -0
  277. package/services/customGameResolver.js +260 -0
  278. package/services/db.js +79 -0
  279. package/services/directMessageService.js +389 -0
  280. package/services/discordNotifications.js +160 -0
  281. package/services/exchangeRateService.js +289 -0
  282. package/services/expoPushService.js +314 -0
  283. package/services/gamesCacheService.js +539 -0
  284. package/services/jackpotHistory.js +331 -0
  285. package/services/jackpotService.js +856 -0
  286. package/services/keeperStateService.js +355 -0
  287. package/services/matchupImageService.js +591 -0
  288. package/services/notificationCacheService.js +407 -0
  289. package/services/pickemOracle.js +440 -0
  290. package/services/playerStatsService.js +389 -0
  291. package/services/portfolioService.js +555 -0
  292. package/services/promoService.js +757 -0
  293. package/services/promoTreasuryService.js +239 -0
  294. package/services/pushNotifications.js +353 -0
  295. package/services/redisService.js +422 -0
  296. package/services/referralEarningsService.js +728 -0
  297. package/services/s3Service.js +396 -0
  298. package/services/socialService.js +1202 -0
  299. package/services/survivorOracle.js +469 -0
  300. package/services/survivorSimulator.js +475 -0
  301. package/services/telegramNotifications.js +461 -0
  302. package/services/userProfileStatsService.js +1185 -0
  303. package/services/whatsNewService.js +388 -0
  304. package/utils/urlHelper.js +95 -0
@@ -0,0 +1,1049 @@
1
+ /**
2
+ * Connect 4 Game Service
3
+ *
4
+ * Handles all game logic for Connect 4:
5
+ * - Board state management
6
+ * - Move validation
7
+ * - Win detection
8
+ * - Game state persistence
9
+ * - On-chain payout resolution
10
+ */
11
+
12
+ const { pool } = require('./db');
13
+ const { Connection, Keypair, PublicKey, Transaction, TransactionInstruction } = require('@solana/web3.js');
14
+ const crypto = require('crypto');
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+ const referralEarningsService = require('./referralEarningsService');
18
+
19
+ // Solana configuration
20
+ const PROGRAM_ID = new PublicKey(process.env.PROGRAM_ID || 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v');
21
+ const OPERATOR_WALLET = new PublicKey(process.env.OPERATOR_WALLET || 'BVZXwZpfgyzTBdRFHohkHZppPHnAyqyctRsKy3vWfQib');
22
+ const RPC_URL = process.env.SOLANA_RPC_URL; // Must be set to Alchemy URL
23
+
24
+ // Discriminator for resolve_automatic_game instruction
25
+ const RESOLVE_AUTO_DISCRIMINATOR = Buffer.from([245, 33, 115, 150, 82, 150, 28, 193]);
26
+
27
+ /**
28
+ * Poll for transaction confirmation using getSignatureStatuses
29
+ * This is the Alchemy-compatible approach (no WebSocket subscriptions)
30
+ *
31
+ * @param {Connection} connection - Solana connection
32
+ * @param {string} signature - Transaction signature to confirm
33
+ * @param {number} lastValidBlockHeight - Block height after which tx expires
34
+ * @param {number} timeout - Maximum time to wait in ms (default 30s)
35
+ * @returns {object} - Confirmation status
36
+ */
37
+ async function pollTransactionConfirmation(connection, signature, lastValidBlockHeight, timeout = 30000) {
38
+ const start = Date.now();
39
+ console.log(`🔴🟡 [Connect4] Polling confirmation for ${signature}, lastValidBlockHeight: ${lastValidBlockHeight}`);
40
+
41
+ while (Date.now() - start < timeout) {
42
+ try {
43
+ // Check if blockhash has expired (transaction dropped)
44
+ const currentBlockHeight = await connection.getBlockHeight('confirmed');
45
+ if (currentBlockHeight > lastValidBlockHeight) {
46
+ throw new Error(`Transaction expired: blockhash no longer valid (current: ${currentBlockHeight}, lastValid: ${lastValidBlockHeight})`);
47
+ }
48
+
49
+ // Poll signature status
50
+ const response = await connection.getSignatureStatuses([signature], {
51
+ searchTransactionHistory: true
52
+ });
53
+
54
+ const status = response?.value?.[0];
55
+ if (status) {
56
+ console.log(`🔴🟡 [Connect4] Signature status: ${JSON.stringify(status)}`);
57
+
58
+ // Check for error
59
+ if (status.err) {
60
+ throw new Error(`Transaction failed: ${JSON.stringify(status.err)}`);
61
+ }
62
+
63
+ // Check confirmation level
64
+ if (status.confirmationStatus === 'confirmed' || status.confirmationStatus === 'finalized') {
65
+ console.log(`✅ [Connect4] Transaction confirmed: ${status.confirmationStatus}`);
66
+ return status;
67
+ }
68
+ }
69
+ } catch (pollErr) {
70
+ // If it's a fatal error (not just "not found yet"), rethrow
71
+ if (pollErr.message?.includes('expired') || pollErr.message?.includes('failed')) {
72
+ throw pollErr;
73
+ }
74
+ console.warn(`⚠️ [Connect4] Poll error (will retry): ${pollErr.message}`);
75
+ }
76
+
77
+ // Wait before next poll (1 second as recommended by Solana docs)
78
+ await new Promise(resolve => setTimeout(resolve, 1000));
79
+ }
80
+
81
+ throw new Error(`Transaction confirmation timeout after ${timeout}ms`);
82
+ }
83
+
84
+ /**
85
+ * Load oracle keypair for Connect4 resolution
86
+ * Connect4 games use automatic mode, which means the smart contract sets
87
+ * game.oracle = hardcoded ORACLE_WALLET (FWUJCthDfPcgmTvdQWM5uofxxiYjqJFMMwiLYvS7LBFa)
88
+ * We MUST use the oracle wallet to resolve, not the operator wallet.
89
+ */
90
+ function loadOracleKeypair() {
91
+ try {
92
+ // First try environment variable (JSON array of secret key bytes)
93
+ if (process.env.ORACLE_WALLET_JSON) {
94
+ const secretKey = JSON.parse(process.env.ORACLE_WALLET_JSON);
95
+ return Keypair.fromSecretKey(Uint8Array.from(secretKey));
96
+ }
97
+
98
+ // Fallback to file - use oracle.json (matches smart contract hardcoded ORACLE_WALLET)
99
+ const walletPath = process.env.ORACLE_WALLET_PATH || path.join(__dirname, '../wallets/oracle.json');
100
+ if (fs.existsSync(walletPath)) {
101
+ const secretKey = JSON.parse(fs.readFileSync(walletPath, 'utf-8'));
102
+ return Keypair.fromSecretKey(Uint8Array.from(secretKey));
103
+ }
104
+
105
+ console.warn('⚠️ [Connect4] No oracle keypair found - payouts will be disabled');
106
+ return null;
107
+ } catch (error) {
108
+ console.error('❌ [Connect4] Failed to load oracle keypair:', error.message);
109
+ return null;
110
+ }
111
+ }
112
+
113
+ // Initialize oracle keypair and connection
114
+ let oracleKeypair = null;
115
+ let connection = null;
116
+
117
+ function initializeSolana() {
118
+ console.log(`🔴🟡 [Connect4] initializeSolana called, oracleKeypair exists: ${!!oracleKeypair}`);
119
+ if (!oracleKeypair) {
120
+ console.log(`🔴🟡 [Connect4] Loading oracle keypair...`);
121
+ console.log(`🔴🟡 [Connect4] ORACLE_WALLET_JSON env exists: ${!!process.env.ORACLE_WALLET_JSON}`);
122
+ oracleKeypair = loadOracleKeypair();
123
+ if (oracleKeypair) {
124
+ console.log(`🔴🟡 [Connect4] Oracle wallet loaded: ${oracleKeypair.publicKey.toString()}`);
125
+ } else {
126
+ console.log(`🔴🟡 [Connect4] Failed to load oracle keypair!`);
127
+ }
128
+ }
129
+ if (!connection) {
130
+ connection = new Connection(RPC_URL, 'confirmed');
131
+ console.log(`🔴🟡 [Connect4] Solana connection initialized: ${RPC_URL}`);
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Verify a transaction was successful on-chain
137
+ * Uses polling to check signature status (Alchemy-compatible, no WebSocket)
138
+ *
139
+ * @param {string} signature - Transaction signature to verify
140
+ * @param {number} timeout - Maximum time to wait in ms (default 15s)
141
+ * @returns {object} - { success: boolean, error?: string, status?: object }
142
+ */
143
+ async function verifyTransactionSuccess(signature, timeout = 15000) {
144
+ // Ensure connection is initialized
145
+ if (!connection) {
146
+ initializeSolana();
147
+ }
148
+
149
+ if (!signature || typeof signature !== 'string' || signature.length < 80) {
150
+ console.log(`❌ [TxVerify] Invalid signature format: ${signature}`);
151
+ return { success: false, error: 'Invalid signature format' };
152
+ }
153
+
154
+ const start = Date.now();
155
+ console.log(`🔍 [TxVerify] Verifying transaction: ${signature}`);
156
+ console.log(`🔍 [TxVerify] Using RPC: ${connection.rpcEndpoint}`);
157
+
158
+ while (Date.now() - start < timeout) {
159
+ try {
160
+ const response = await connection.getSignatureStatuses([signature], {
161
+ searchTransactionHistory: true
162
+ });
163
+
164
+ const status = response?.value?.[0];
165
+ console.log(`🔍 [TxVerify] Poll result:`, status ? JSON.stringify(status) : 'null');
166
+
167
+ if (status) {
168
+ // Check for error
169
+ if (status.err) {
170
+ console.log(`❌ [TxVerify] Transaction FAILED on-chain: ${JSON.stringify(status.err)}`);
171
+ return { success: false, error: `Transaction failed: ${JSON.stringify(status.err)}`, status };
172
+ }
173
+
174
+ // Check confirmation level
175
+ if (status.confirmationStatus === 'confirmed' || status.confirmationStatus === 'finalized') {
176
+ console.log(`✅ [TxVerify] Transaction VERIFIED: ${status.confirmationStatus}`);
177
+ return { success: true, status };
178
+ }
179
+
180
+ // Transaction found but not yet confirmed, keep polling
181
+ console.log(`⏳ [TxVerify] Transaction pending: ${status.confirmationStatus || 'processing'}`);
182
+ } else {
183
+ console.log(`⏳ [TxVerify] Transaction not found yet, polling...`);
184
+ }
185
+ } catch (pollErr) {
186
+ console.warn(`⚠️ [TxVerify] Poll error (will retry): ${pollErr.message}`);
187
+ }
188
+
189
+ // Wait before next poll
190
+ await new Promise(resolve => setTimeout(resolve, 1000));
191
+ }
192
+
193
+ // Timeout - transaction not found or not confirmed
194
+ console.log(`⚠️ [TxVerify] Transaction not confirmed within ${timeout}ms - REJECTING`);
195
+ return { success: false, error: `Transaction not confirmed within ${timeout}ms` };
196
+ }
197
+
198
+ // Board dimensions
199
+ const ROWS = 6;
200
+ const COLS = 7;
201
+ const WINNING_LENGTH = 4;
202
+
203
+ /**
204
+ * Create an empty 6x7 board
205
+ * Board is represented as a 2D array where:
206
+ * - null = empty cell
207
+ * - 'home' = player 1's piece
208
+ * - 'away' = player 2's piece
209
+ *
210
+ * Row 0 is the TOP of the board, Row 5 is the BOTTOM
211
+ */
212
+ function createEmptyBoard() {
213
+ return Array(ROWS).fill(null).map(() => Array(COLS).fill(null));
214
+ }
215
+
216
+ /**
217
+ * Find the lowest empty row in a column (where a piece would land)
218
+ * @param {Array} board - 2D board array
219
+ * @param {number} col - Column index (0-6)
220
+ * @returns {number} Row index where piece lands, or -1 if column is full
221
+ */
222
+ function getLowestEmptyRow(board, col) {
223
+ // Start from bottom (row 5) and work up
224
+ for (let row = ROWS - 1; row >= 0; row--) {
225
+ if (board[row][col] === null) {
226
+ return row;
227
+ }
228
+ }
229
+ return -1; // Column is full
230
+ }
231
+
232
+ /**
233
+ * Check if a move results in a win
234
+ * @param {Array} board - 2D board array (after the move)
235
+ * @param {number} row - Row of the last placed piece
236
+ * @param {number} col - Column of the last placed piece
237
+ * @param {string} color - 'home' or 'away'
238
+ * @returns {Array|null} Array of winning cells [{row, col}] or null if no win
239
+ */
240
+ function checkWin(board, row, col, color) {
241
+ // Check all 4 directions: horizontal, vertical, diagonal-down, diagonal-up
242
+ const directions = [
243
+ { dr: 0, dc: 1 }, // horizontal
244
+ { dr: 1, dc: 0 }, // vertical
245
+ { dr: 1, dc: 1 }, // diagonal down-right
246
+ { dr: 1, dc: -1 }, // diagonal down-left
247
+ ];
248
+
249
+ for (const { dr, dc } of directions) {
250
+ const cells = getConnectedCells(board, row, col, dr, dc, color);
251
+ if (cells.length >= WINNING_LENGTH) {
252
+ // Return only the first 4 connected cells
253
+ return cells.slice(0, WINNING_LENGTH);
254
+ }
255
+ }
256
+
257
+ return null;
258
+ }
259
+
260
+ /**
261
+ * Get all connected cells in a direction (both ways)
262
+ */
263
+ function getConnectedCells(board, row, col, dr, dc, color) {
264
+ const cells = [{ row, col }];
265
+
266
+ // Check positive direction
267
+ let r = row + dr;
268
+ let c = col + dc;
269
+ while (isValidCell(r, c) && board[r][c] === color) {
270
+ cells.push({ row: r, col: c });
271
+ r += dr;
272
+ c += dc;
273
+ }
274
+
275
+ // Check negative direction
276
+ r = row - dr;
277
+ c = col - dc;
278
+ while (isValidCell(r, c) && board[r][c] === color) {
279
+ cells.unshift({ row: r, col: c });
280
+ r -= dr;
281
+ c -= dc;
282
+ }
283
+
284
+ return cells;
285
+ }
286
+
287
+ /**
288
+ * Check if cell coordinates are valid
289
+ */
290
+ function isValidCell(row, col) {
291
+ return row >= 0 && row < ROWS && col >= 0 && col < COLS;
292
+ }
293
+
294
+ /**
295
+ * Check if the board is full (draw condition)
296
+ */
297
+ function isBoardFull(board) {
298
+ // Check if top row has any empty cells
299
+ return board[0].every(cell => cell !== null);
300
+ }
301
+
302
+ /**
303
+ * Get game state from database and transform to Connect4Game format
304
+ * @param {string} gameId - Game ID
305
+ * @returns {Object} Connect4Game object
306
+ */
307
+ async function getGameState(gameId) {
308
+ const result = await pool.query(`
309
+ SELECT
310
+ g.game_id,
311
+ g.game_address,
312
+ g.buy_in,
313
+ g.created_by,
314
+ g.game_status,
315
+ g.connect4_board,
316
+ g.connect4_current_turn,
317
+ g.connect4_winner,
318
+ g.connect4_winning_cells,
319
+ g.lock_timestamp,
320
+ g.created_at,
321
+ g.started_at,
322
+ g.completed_at,
323
+ g.invited_player,
324
+ g.image_url,
325
+ g.matchup_image_url,
326
+ -- Player 1 (creator - red)
327
+ u1.id as player1_user_id,
328
+ u1.username as player1_username,
329
+ u1.avatar as player1_avatar,
330
+ u1.wallet_address as player1_wallet,
331
+ -- Player 2 (joiner - yellow)
332
+ g.away_team_players[1] as player2_wallet,
333
+ u2.id as player2_user_id,
334
+ u2.username as player2_username,
335
+ u2.avatar as player2_avatar
336
+ FROM games g
337
+ LEFT JOIN users u1 ON g.created_by = u1.wallet_address
338
+ LEFT JOIN users u2 ON g.away_team_players[1] = u2.wallet_address
339
+ WHERE g.game_id = $1 AND g.game_type = 'connect4'
340
+ `, [gameId]);
341
+
342
+ if (result.rows.length === 0) {
343
+ return null;
344
+ }
345
+
346
+ const row = result.rows[0];
347
+
348
+ // Get board - JSONB is already parsed by node-postgres, no need for JSON.parse
349
+ let board;
350
+ if (row.connect4_board) {
351
+ // If it's a string (shouldn't happen with JSONB but just in case), parse it
352
+ board = typeof row.connect4_board === 'string'
353
+ ? JSON.parse(row.connect4_board)
354
+ : row.connect4_board;
355
+ } else {
356
+ board = createEmptyBoard();
357
+ }
358
+
359
+ // Get winning cells - same as above, JSONB is already parsed
360
+ let winningCells = null;
361
+ if (row.connect4_winning_cells) {
362
+ winningCells = typeof row.connect4_winning_cells === 'string'
363
+ ? JSON.parse(row.connect4_winning_cells)
364
+ : row.connect4_winning_cells;
365
+ }
366
+
367
+ // Map database status to frontend status
368
+ let status = 'waiting';
369
+ if (row.game_status === 'playing' || row.game_status === 'in_progress') {
370
+ status = 'playing';
371
+ } else if (row.game_status === 'completed' || row.game_status === 'resolved') {
372
+ status = 'completed';
373
+ } else if (row.game_status === 'cancelled') {
374
+ status = 'cancelled';
375
+ }
376
+
377
+ // Build Connect4Game object
378
+ // Use homePlayer/awayPlayer naming to match frontend expectations
379
+ const game = {
380
+ gameId: row.game_id,
381
+ gameAddress: row.game_address,
382
+ buyIn: parseFloat(row.buy_in),
383
+
384
+ homePlayer: {
385
+ walletAddress: row.player1_wallet || row.created_by,
386
+ username: row.player1_username || formatWallet(row.created_by),
387
+ avatar: row.player1_avatar || null,
388
+ },
389
+
390
+ awayPlayer: row.player2_wallet ? {
391
+ walletAddress: row.player2_wallet,
392
+ username: row.player2_username || formatWallet(row.player2_wallet),
393
+ avatar: row.player2_avatar || null,
394
+ } : null,
395
+
396
+ invitedPlayer: row.invited_player || null, // For visibility filtering (private games)
397
+ imageUrl: row.image_url || null,
398
+ matchupImageUrl: row.matchup_image_url || null,
399
+
400
+ status,
401
+ board,
402
+ currentTurn: row.connect4_current_turn || 'home',
403
+
404
+ winner: row.connect4_winner || null,
405
+ winningCells,
406
+ winnerWallet: null, // Will be set below if there's a winner
407
+ winnerUsername: null,
408
+ winnerPrize: row.connect4_winner ? parseFloat(row.buy_in) * 2 * 0.99 : null, // 1% fee
409
+
410
+ createdAt: row.created_at,
411
+ startedAt: row.started_at,
412
+ completedAt: row.completed_at,
413
+ // Lock timestamp: stored value or computed as created_at + 130 seconds (2 min 10 sec)
414
+ lockTimestamp: row.lock_timestamp
415
+ ? parseInt(row.lock_timestamp)
416
+ : (row.created_at ? Math.floor(new Date(row.created_at).getTime() / 1000) + 130 : null),
417
+ };
418
+
419
+ // Set winner info
420
+ if (row.connect4_winner === 'home' && game.homePlayer) {
421
+ game.winnerWallet = game.homePlayer.walletAddress;
422
+ game.winnerUsername = game.homePlayer.username;
423
+ } else if (row.connect4_winner === 'away' && game.awayPlayer) {
424
+ game.winnerWallet = game.awayPlayer.walletAddress;
425
+ game.winnerUsername = game.awayPlayer.username;
426
+ } else if (row.connect4_winner === 'draw') {
427
+ game.winnerWallet = 'draw';
428
+ game.winnerUsername = 'Draw';
429
+ }
430
+
431
+ return game;
432
+ }
433
+
434
+ /**
435
+ * Process a move
436
+ * @param {string} gameId - Game ID
437
+ * @param {string} walletAddress - Wallet of player making the move
438
+ * @param {number} column - Column to drop piece (0-6)
439
+ * @returns {Object} Result with board, move details, win info
440
+ */
441
+ async function processMove(gameId, walletAddress, column) {
442
+ // Get current game state
443
+ const game = await getGameState(gameId);
444
+
445
+ if (!game) {
446
+ throw new Error('Game not found');
447
+ }
448
+
449
+ // Allow moves when playing OR waiting (creator can make opening moves while waiting for opponent)
450
+ if (game.status !== 'playing' && game.status !== 'waiting') {
451
+ throw new Error(`Game is not in progress (status: ${game.status})`);
452
+ }
453
+
454
+ // Determine player's color
455
+ let playerColor;
456
+ if (game.homePlayer?.walletAddress === walletAddress) {
457
+ playerColor = 'home';
458
+ } else if (game.awayPlayer?.walletAddress === walletAddress) {
459
+ playerColor = 'away';
460
+ } else {
461
+ throw new Error('You are not a player in this game');
462
+ }
463
+
464
+ // Check if it's their turn
465
+ if (game.currentTurn !== playerColor) {
466
+ throw new Error(`Not your turn. Current turn: ${game.currentTurn}`);
467
+ }
468
+
469
+ // Validate column
470
+ if (column < 0 || column >= COLS) {
471
+ throw new Error(`Invalid column: ${column}. Must be 0-6`);
472
+ }
473
+
474
+ // Find where piece lands
475
+ const row = getLowestEmptyRow(game.board, column);
476
+ if (row === -1) {
477
+ throw new Error('Column is full');
478
+ }
479
+
480
+ // Make the move
481
+ const board = game.board.map(r => [...r]); // Deep copy
482
+ board[row][column] = playerColor;
483
+
484
+ // Check for win
485
+ const winningCells = checkWin(board, row, column, playerColor);
486
+ const isWin = winningCells !== null;
487
+
488
+ // Check for draw
489
+ const isDraw = !isWin && isBoardFull(board);
490
+
491
+ // Determine next turn
492
+ const nextTurn = playerColor === 'home' ? 'away' : 'home';
493
+
494
+ // Determine game result
495
+ let winner = null;
496
+ let newStatus = 'playing';
497
+
498
+ if (isWin) {
499
+ winner = playerColor;
500
+ newStatus = 'completed';
501
+ } else if (isDraw) {
502
+ winner = 'draw';
503
+ newStatus = 'completed';
504
+ }
505
+
506
+ // Update database
507
+ await pool.query(`
508
+ UPDATE games SET
509
+ connect4_board = $1,
510
+ connect4_current_turn = $2,
511
+ connect4_winner = $3,
512
+ connect4_winning_cells = $4,
513
+ game_status = $5,
514
+ completed_at = $6,
515
+ updated_at = NOW()
516
+ WHERE game_id = $7
517
+ `, [
518
+ JSON.stringify(board),
519
+ isWin || isDraw ? null : nextTurn,
520
+ winner,
521
+ winningCells ? JSON.stringify(winningCells) : null,
522
+ newStatus,
523
+ (isWin || isDraw) ? new Date() : null,
524
+ gameId,
525
+ ]);
526
+
527
+ // Get winner info if game ended
528
+ let winnerWallet = null;
529
+ let winnerUsername = null;
530
+ let winnerPrize = null;
531
+
532
+ if (isWin) {
533
+ if (playerColor === 'home') {
534
+ winnerWallet = game.homePlayer.walletAddress;
535
+ winnerUsername = game.homePlayer.username;
536
+ } else {
537
+ winnerWallet = game.awayPlayer.walletAddress;
538
+ winnerUsername = game.awayPlayer.username;
539
+ }
540
+ winnerPrize = game.buyIn * 2 * 0.95; // 5% total fee (1% oracle, 4% operator)
541
+ console.log(`🔴🟡 [Connect4] Game ended with win! Winner: ${winnerUsername} (${winnerWallet})`);
542
+ // NOTE: Payout is NOT automatic - winner must claim via /api/connect4/claim
543
+ } else if (isDraw) {
544
+ console.log(`🔴🟡 [Connect4] Game ended in draw! Players can claim refund via /api/connect4/claim`);
545
+ // NOTE: Refund is NOT automatic - players must claim via /api/connect4/claim
546
+ }
547
+
548
+ return {
549
+ gameId,
550
+ column,
551
+ row,
552
+ color: playerColor,
553
+ board,
554
+ currentTurn: isWin || isDraw ? null : nextTurn,
555
+ winner,
556
+ winningCells,
557
+ winnerWallet,
558
+ winnerUsername,
559
+ winnerPrize,
560
+ isDraw,
561
+ isGameOver: isWin || isDraw,
562
+ };
563
+ }
564
+
565
+ /**
566
+ * Format wallet address for display
567
+ */
568
+ function formatWallet(wallet) {
569
+ if (!wallet) return 'Unknown';
570
+ return `${wallet.slice(0, 4)}...${wallet.slice(-4)}`;
571
+ }
572
+
573
+ /**
574
+ * Resolve a Connect4 game on-chain and distribute winnings
575
+ * @param {string} gameId - Game ID (c4-xxxxx format)
576
+ * @param {string} winner - 'home', 'away', or 'draw'
577
+ * @param {string} winnerWallet - Wallet address of the winner (null for draw)
578
+ * @returns {Object} Result with signature or error
579
+ */
580
+ async function resolveGame(gameId, winner, winnerWallet) {
581
+ console.log(`🔴🟡 [Connect4] Resolving game ${gameId} - Winner: ${winner}`);
582
+
583
+ // Ensure Solana is initialized
584
+ initializeSolana();
585
+
586
+ if (!oracleKeypair) {
587
+ console.error('❌ [Connect4] Cannot resolve: No oracle keypair available');
588
+ return { success: false, error: 'Oracle keypair not available' };
589
+ }
590
+
591
+ try {
592
+ // Get game address and buy-in from database
593
+ const gameResult = await pool.query(
594
+ 'SELECT game_address, created_by, buy_in FROM games WHERE game_id = $1',
595
+ [gameId]
596
+ );
597
+
598
+ if (gameResult.rows.length === 0) {
599
+ throw new Error('Game not found in database');
600
+ }
601
+
602
+ const { game_address, created_by, buy_in } = gameResult.rows[0];
603
+
604
+ // Look up referrer for the game creator (for 1% commission)
605
+ let referrerWallet = null;
606
+ try {
607
+ // Find referrer by matching creator's referral_code to referrer's my_referral_code
608
+ const referrerResult = await pool.query(`
609
+ SELECT referrer.wallet_address as referrer_wallet
610
+ FROM users creator
611
+ JOIN users referrer ON creator.referral_code = referrer.my_referral_code
612
+ WHERE creator.wallet_address = $1
613
+ AND creator.referral_code IS NOT NULL`,
614
+ [created_by]
615
+ );
616
+ if (referrerResult.rows.length > 0 && referrerResult.rows[0].referrer_wallet) {
617
+ referrerWallet = referrerResult.rows[0].referrer_wallet;
618
+ console.log(`🔴🟡 [Connect4] Found referrer for creator: ${referrerWallet}`);
619
+ }
620
+ } catch (refErr) {
621
+ console.warn('⚠️ [Connect4] Could not look up referrer:', refErr.message);
622
+ }
623
+
624
+ // Convert gameId to u64 for on-chain
625
+ const hash = crypto.createHash('sha256').update(gameId).digest();
626
+ const gameIdNum = hash.readBigUInt64LE(0);
627
+ const gameIdBuf = Buffer.alloc(8);
628
+ gameIdBuf.writeBigUInt64LE(gameIdNum);
629
+
630
+ // Get game PDA
631
+ const [gamePDA] = PublicKey.findProgramAddressSync(
632
+ [Buffer.from("game"), gameIdBuf],
633
+ PROGRAM_ID
634
+ );
635
+
636
+ console.log(`🔴🟡 [Connect4] Game PDA: ${gamePDA.toString()}`);
637
+
638
+ // Encode winning team
639
+ // Connect4: red = player1 = home, yellow = player2 = away
640
+ let winningTeamBytes;
641
+ if (winner === 'draw' || winner === null) {
642
+ winningTeamBytes = Buffer.from([0]); // None - refund all
643
+ console.log(`🔴🟡 [Connect4] Encoding winner: DRAW (refund)`);
644
+ } else if (winner === 'home') {
645
+ winningTeamBytes = Buffer.from([1, 0]); // Some(Home)
646
+ console.log(`🔴🟡 [Connect4] Encoding winner: HOME`);
647
+ } else if (winner === 'away') {
648
+ winningTeamBytes = Buffer.from([1, 1]); // Some(Away)
649
+ console.log(`🔴🟡 [Connect4] Encoding winner: AWAY`);
650
+ } else {
651
+ console.error(`⚠️ [Connect4] Unexpected winner value: ${winner}, defaulting to refund`);
652
+ winningTeamBytes = Buffer.from([0]); // None - refund all
653
+ }
654
+
655
+ // Build instruction data
656
+ const data = Buffer.concat([
657
+ RESOLVE_AUTO_DISCRIMINATOR,
658
+ gameIdBuf,
659
+ winningTeamBytes
660
+ ]);
661
+
662
+ // Build accounts
663
+ const keys = [
664
+ { pubkey: gamePDA, isSigner: false, isWritable: true },
665
+ { pubkey: oracleKeypair.publicKey, isSigner: true, isWritable: true }, // Oracle receives 1% fee
666
+ { pubkey: OPERATOR_WALLET, isSigner: false, isWritable: true }, // Operator receives 4% (or 5% if no referrer)
667
+ ];
668
+
669
+ // Add referrer if exists (receives 1% commission)
670
+ if (referrerWallet) {
671
+ try {
672
+ const referrerPubkey = new PublicKey(referrerWallet);
673
+ keys.push({ pubkey: referrerPubkey, isSigner: false, isWritable: true });
674
+ console.log(`🔴🟡 [Connect4] Added referrer to transaction: ${referrerWallet}`);
675
+ } catch (e) {
676
+ console.warn(`⚠️ [Connect4] Invalid referrer wallet: ${referrerWallet}`);
677
+ }
678
+ }
679
+
680
+ // Create instruction
681
+ const ix = new TransactionInstruction({
682
+ keys,
683
+ programId: PROGRAM_ID,
684
+ data,
685
+ });
686
+
687
+ // Build and send transaction
688
+ const tx = new Transaction().add(ix);
689
+ const { blockhash, lastValidBlockHeight } = await connection.getLatestBlockhash('confirmed');
690
+ tx.recentBlockhash = blockhash;
691
+ tx.feePayer = oracleKeypair.publicKey;
692
+ tx.sign(oracleKeypair);
693
+
694
+ const signature = await connection.sendRawTransaction(tx.serialize());
695
+ console.log(`🔴🟡 [Connect4] Resolve tx sent: ${signature}, waiting for confirmation...`);
696
+
697
+ // Wait for confirmation using polling (Alchemy-compatible, no WebSocket)
698
+ try {
699
+ await pollTransactionConfirmation(connection, signature, lastValidBlockHeight, 30000);
700
+ console.log(`✅ [Connect4] Game ${gameId} resolved on-chain! Signature: ${signature}`);
701
+ } catch (confirmErr) {
702
+ // Check if it's already resolved (common case)
703
+ if (confirmErr.message?.includes('AlreadyResolved')) {
704
+ console.log(`🔴🟡 [Connect4] Game already resolved, continuing...`);
705
+ } else {
706
+ console.error(`❌ [Connect4] Resolve confirmation failed:`, confirmErr.message);
707
+ throw confirmErr;
708
+ }
709
+ }
710
+
711
+ // Update database to mark as resolved
712
+ await pool.query(`
713
+ UPDATE games SET
714
+ is_resolved = TRUE,
715
+ claim_signature = $1,
716
+ resolved_at = NOW(),
717
+ updated_at = NOW()
718
+ WHERE game_id = $2
719
+ `, [signature, gameId]);
720
+
721
+ console.log(`✅ [Connect4] Database updated: is_resolved=true, claim_signature=${signature}`);
722
+
723
+ // Update winner's user_game_refs with claim_signature for transaction history linking
724
+ if (winnerWallet) {
725
+ await pool.query(`
726
+ UPDATE user_game_refs SET
727
+ claim_signature = $1,
728
+ claimed_at = NOW(),
729
+ updated_at = NOW()
730
+ WHERE game_id = $2 AND wallet_address = $3
731
+ `, [signature, gameId, winnerWallet]);
732
+ console.log(`✅ [Connect4] Winner's user_game_refs updated with claim_signature`);
733
+ }
734
+
735
+ // Record referral earning with payout signature (if referrer was paid)
736
+ if (referrerWallet) {
737
+ try {
738
+ // Calculate commission: 1% of total pot (buy_in * 2 for 2-player Connect4)
739
+ const potSizeLamports = Math.floor(parseFloat(buy_in) * 2 * 1e9);
740
+ const commissionLamports = Math.floor(potSizeLamports * 0.01);
741
+
742
+ // Record the on-chain commission (creates record if doesn't exist)
743
+ await referralEarningsService.recordOnChainCommission(
744
+ gameId,
745
+ referrerWallet,
746
+ commissionLamports,
747
+ true, // paidOnChain
748
+ signature, // txSignature
749
+ 'connect4' // gameType
750
+ );
751
+ console.log(`✅ [Connect4] Referral earning recorded: ${commissionLamports / 1e9} SOL to ${referrerWallet.slice(0, 8)}...`);
752
+ } catch (refErr) {
753
+ console.warn('⚠️ [Connect4] Failed to record referral earning:', refErr.message);
754
+ }
755
+ }
756
+
757
+ return { success: true, signature };
758
+
759
+ } catch (error) {
760
+ console.error(`❌ [Connect4] Failed to resolve game ${gameId}:`, error.message);
761
+
762
+ // Check if game was already resolved on-chain (error 6033)
763
+ if (error.message && error.message.includes('AlreadyResolved')) {
764
+ console.log(`🔴🟡 [Connect4] Game ${gameId} was already resolved on-chain, updating database`);
765
+ try {
766
+ await pool.query(`
767
+ UPDATE games SET
768
+ is_resolved = TRUE,
769
+ game_status = 'completed',
770
+ completed_at = COALESCE(completed_at, NOW()),
771
+ updated_at = NOW()
772
+ WHERE game_id = $1
773
+ `, [gameId]);
774
+ return { success: true, alreadyResolved: true, message: 'Game was already resolved on-chain' };
775
+ } catch (dbErr) {
776
+ console.error('❌ [Connect4] Failed to update database after AlreadyResolved:', dbErr.message);
777
+ }
778
+ }
779
+
780
+ // Even if on-chain fails, mark the game as completed in database
781
+ try {
782
+ await pool.query(`
783
+ UPDATE games SET
784
+ game_status = 'completed',
785
+ completed_at = NOW(),
786
+ updated_at = NOW()
787
+ WHERE game_id = $1
788
+ `, [gameId]);
789
+ } catch (dbErr) {
790
+ console.error('❌ [Connect4] Failed to update database:', dbErr.message);
791
+ }
792
+
793
+ return { success: false, error: error.message };
794
+ }
795
+ }
796
+
797
+ /**
798
+ * Initialize database columns for Connect4 if not present
799
+ */
800
+ async function initializeConnect4Columns() {
801
+ try {
802
+ // Add Connect4 specific columns if they don't exist
803
+ await pool.query(`
804
+ DO $$
805
+ BEGIN
806
+ -- Add connect4_board column
807
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns
808
+ WHERE table_name = 'games' AND column_name = 'connect4_board') THEN
809
+ ALTER TABLE games ADD COLUMN connect4_board JSONB;
810
+ END IF;
811
+
812
+ -- Add connect4_current_turn column
813
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns
814
+ WHERE table_name = 'games' AND column_name = 'connect4_current_turn') THEN
815
+ ALTER TABLE games ADD COLUMN connect4_current_turn VARCHAR(10) DEFAULT 'home';
816
+ END IF;
817
+
818
+ -- Add connect4_winner column
819
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns
820
+ WHERE table_name = 'games' AND column_name = 'connect4_winner') THEN
821
+ ALTER TABLE games ADD COLUMN connect4_winner VARCHAR(10);
822
+ END IF;
823
+
824
+ -- Add connect4_winning_cells column
825
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns
826
+ WHERE table_name = 'games' AND column_name = 'connect4_winning_cells') THEN
827
+ ALTER TABLE games ADD COLUMN connect4_winning_cells JSONB;
828
+ END IF;
829
+
830
+ -- Add started_at column if missing
831
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns
832
+ WHERE table_name = 'games' AND column_name = 'started_at') THEN
833
+ ALTER TABLE games ADD COLUMN started_at TIMESTAMP;
834
+ END IF;
835
+
836
+ -- Add completed_at column if missing
837
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns
838
+ WHERE table_name = 'games' AND column_name = 'completed_at') THEN
839
+ ALTER TABLE games ADD COLUMN completed_at TIMESTAMP;
840
+ END IF;
841
+
842
+ -- Add game_status column if missing
843
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns
844
+ WHERE table_name = 'games' AND column_name = 'game_status') THEN
845
+ ALTER TABLE games ADD COLUMN game_status VARCHAR(20) DEFAULT 'waiting';
846
+ END IF;
847
+ END $$;
848
+ `);
849
+ console.log('🔴🟡 Connect4 database columns initialized');
850
+ } catch (error) {
851
+ console.error('Failed to initialize Connect4 columns:', error);
852
+ throw error;
853
+ }
854
+ }
855
+
856
+ /**
857
+ * Start a game (called when player 2 joins)
858
+ * Sets status to 'playing' but preserves existing board and turn
859
+ * (creator may have made opening moves while waiting)
860
+ */
861
+ async function startGame(gameId) {
862
+ console.log(`[startGame] 🔴🟡 Starting game ${gameId}...`);
863
+
864
+ // Get current game state to preserve any existing moves
865
+ const currentGame = await getGameState(gameId);
866
+
867
+ console.log(`[startGame] Current game state BEFORE update:`);
868
+ console.log(`[startGame] - status: ${currentGame?.status}`);
869
+ console.log(`[startGame] - currentTurn: ${currentGame?.currentTurn}`);
870
+ console.log(`[startGame] - board has moves: ${JSON.stringify(currentGame?.board) !== JSON.stringify(createEmptyBoard())}`);
871
+
872
+ // Check raw DB values
873
+ const rawResult = await pool.query(
874
+ 'SELECT connect4_board, connect4_current_turn, game_status FROM games WHERE game_id = $1',
875
+ [gameId]
876
+ );
877
+ const rawRow = rawResult.rows[0];
878
+ console.log(`[startGame] Raw DB values:`);
879
+ console.log(`[startGame] - connect4_board: ${rawRow?.connect4_board ? 'HAS VALUE' : 'NULL'}`);
880
+ console.log(`[startGame] - connect4_current_turn: ${rawRow?.connect4_current_turn}`);
881
+ console.log(`[startGame] - game_status: ${rawRow?.game_status}`);
882
+
883
+ // Use existing board if moves were made, otherwise create empty
884
+ const board = currentGame?.board || createEmptyBoard();
885
+ // Preserve current turn (don't reset to red if moves were made)
886
+ const currentTurn = currentGame?.currentTurn || 'home';
887
+
888
+ console.log(`[startGame] Values to use in COALESCE fallback:`);
889
+ console.log(`[startGame] - board: ${JSON.stringify(board) === JSON.stringify(createEmptyBoard()) ? 'EMPTY' : 'HAS MOVES'}`);
890
+ console.log(`[startGame] - currentTurn: ${currentTurn}`);
891
+
892
+ await pool.query(`
893
+ UPDATE games SET
894
+ game_status = 'playing',
895
+ connect4_board = COALESCE(connect4_board, $1),
896
+ connect4_current_turn = COALESCE(connect4_current_turn, $2),
897
+ started_at = NOW(),
898
+ updated_at = NOW()
899
+ WHERE game_id = $3
900
+ `, [JSON.stringify(board), currentTurn, gameId]);
901
+
902
+ const finalGame = await getGameState(gameId);
903
+
904
+ console.log(`[startGame] Game state AFTER update:`);
905
+ console.log(`[startGame] - status: ${finalGame?.status}`);
906
+ console.log(`[startGame] - currentTurn: ${finalGame?.currentTurn}`);
907
+ console.log(`[startGame] - board has moves: ${JSON.stringify(finalGame?.board) !== JSON.stringify(createEmptyBoard())}`);
908
+
909
+ return finalGame;
910
+ }
911
+
912
+ /**
913
+ * Claim prize for a completed Connect4 game
914
+ * Called by the winner (or any player for a draw)
915
+ * @param {string} gameId - Game ID
916
+ * @param {string} walletAddress - Wallet address of the claimer
917
+ * @returns {Object} Result with signature or error
918
+ */
919
+ async function claimPrize(gameId, walletAddress) {
920
+ const logPrefix = `[Connect4 ClaimPrize ${gameId}]`;
921
+ console.log(`${logPrefix} ====== SERVICE STARTED ======`);
922
+ console.log(`${logPrefix} Claimer wallet: ${walletAddress}`);
923
+
924
+ // Get game state
925
+ console.log(`${logPrefix} Fetching game state...`);
926
+ const game = await getGameState(gameId);
927
+
928
+ if (!game) {
929
+ console.log(`${logPrefix} ❌ Game not found`);
930
+ return { success: false, error: 'Game not found' };
931
+ }
932
+
933
+ console.log(`${logPrefix} Game state:`, JSON.stringify({
934
+ status: game.status,
935
+ winner: game.winner,
936
+ homePlayer: game.homePlayer?.walletAddress,
937
+ awayPlayer: game.awayPlayer?.walletAddress,
938
+ buyIn: game.buyIn,
939
+ winnerWallet: game.winnerWallet,
940
+ }));
941
+
942
+ if (game.status !== 'completed' && game.status !== 'cancelled') {
943
+ console.log(`${logPrefix} ❌ Invalid game status: ${game.status}`);
944
+ return { success: false, error: 'Game is not completed yet' };
945
+ }
946
+
947
+ // Check if already resolved on-chain
948
+ console.log(`${logPrefix} Checking if already resolved...`);
949
+ const claimCheck = await pool.query(
950
+ 'SELECT is_resolved, claim_signature FROM games WHERE game_id = $1',
951
+ [gameId]
952
+ );
953
+
954
+ const isResolved = claimCheck.rows[0]?.is_resolved;
955
+ const existingSignature = claimCheck.rows[0]?.claim_signature;
956
+ console.log(`${logPrefix} Resolution status: is_resolved=${isResolved}, has_signature=${!!existingSignature}`);
957
+
958
+ // If game is already resolved on-chain, skip resolution and return success
959
+ // The endpoint will build a claim transaction for the user to sign
960
+ if (isResolved) {
961
+ console.log(`${logPrefix} ✅ Already resolved, returning existing signature`);
962
+ console.log(`${logPrefix} ====== SERVICE SUCCESS (already resolved) ======`);
963
+ return {
964
+ success: true,
965
+ alreadyResolved: true,
966
+ signature: existingSignature,
967
+ message: 'Game resolved, sign transaction to claim prize',
968
+ };
969
+ }
970
+
971
+ // Verify the claimer is authorized
972
+ const isHomePlayer = game.homePlayer?.walletAddress === walletAddress;
973
+ const isAwayPlayer = game.awayPlayer?.walletAddress === walletAddress;
974
+ console.log(`${logPrefix} Authorization check: isHomePlayer=${isHomePlayer}, isAwayPlayer=${isAwayPlayer}`);
975
+
976
+ if (!isHomePlayer && !isAwayPlayer) {
977
+ console.log(`${logPrefix} ❌ Claimer not a player in this game`);
978
+ console.log(`${logPrefix} Claimer: ${walletAddress}`);
979
+ console.log(`${logPrefix} Home: ${game.homePlayer?.walletAddress}`);
980
+ console.log(`${logPrefix} Away: ${game.awayPlayer?.walletAddress}`);
981
+ return { success: false, error: 'You are not a player in this game' };
982
+ }
983
+
984
+ // For wins, only the winner can claim
985
+ // For cancelled games (winner=null) or draws, anyone can claim their refund
986
+ console.log(`${logPrefix} Winner check: game.winner=${game.winner}`);
987
+ if (game.winner !== 'draw' && game.winner !== null) {
988
+ const winnerWalletCheck = game.winner === 'home'
989
+ ? game.homePlayer?.walletAddress
990
+ : game.awayPlayer?.walletAddress;
991
+
992
+ console.log(`${logPrefix} Winner wallet: ${winnerWalletCheck}`);
993
+ if (walletAddress !== winnerWalletCheck) {
994
+ console.log(`${logPrefix} ❌ Claimer is not the winner`);
995
+ return { success: false, error: 'Only the winner can claim the prize' };
996
+ }
997
+ } else {
998
+ console.log(`${logPrefix} Game is draw/cancelled - any player can claim refund`);
999
+ }
1000
+
1001
+ // Attempt to resolve the game on-chain
1002
+ const winnerWallet = game.winner === 'draw' || game.winner === null
1003
+ ? null
1004
+ : (game.winner === 'home' ? game.homePlayer?.walletAddress : game.awayPlayer?.walletAddress);
1005
+
1006
+ console.log(`${logPrefix} Calling resolveGame with winner=${game.winner}, winnerWallet=${winnerWallet}...`);
1007
+ const resolveStartTime = Date.now();
1008
+ const result = await resolveGame(gameId, game.winner, winnerWallet);
1009
+ console.log(`${logPrefix} resolveGame completed in ${Date.now() - resolveStartTime}ms`);
1010
+ console.log(`${logPrefix} resolveGame result:`, JSON.stringify({
1011
+ success: result.success,
1012
+ alreadyResolved: result.alreadyResolved,
1013
+ signature: result.signature,
1014
+ error: result.error,
1015
+ }));
1016
+
1017
+ if (result.success) {
1018
+ console.log(`${logPrefix} ====== SERVICE SUCCESS ======`);
1019
+ return {
1020
+ success: true,
1021
+ alreadyResolved: result.alreadyResolved || false,
1022
+ signature: result.signature,
1023
+ message: result.alreadyResolved
1024
+ ? 'Game already resolved - prize ready to claim!'
1025
+ : (game.winner === 'draw' || game.winner === null ? 'Refund processed!' : 'Prize claimed successfully!'),
1026
+ };
1027
+ } else {
1028
+ console.log(`${logPrefix} ❌ resolveGame failed: ${result.error}`);
1029
+ console.log(`${logPrefix} ====== SERVICE FAILED ======`);
1030
+ return { success: false, error: result.error || 'Failed to claim prize' };
1031
+ }
1032
+ }
1033
+
1034
+ module.exports = {
1035
+ createEmptyBoard,
1036
+ getLowestEmptyRow,
1037
+ checkWin,
1038
+ isBoardFull,
1039
+ getGameState,
1040
+ processMove,
1041
+ resolveGame,
1042
+ claimPrize,
1043
+ initializeConnect4Columns,
1044
+ initializeSolana,
1045
+ startGame,
1046
+ verifyTransactionSuccess,
1047
+ ROWS,
1048
+ COLS,
1049
+ };