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,555 @@
1
+ /**
2
+ * Portfolio Service
3
+ *
4
+ * Fetches a user's Solana portfolio (SOL balance + SPL tokens) directly from
5
+ * Solana RPC using @solana/web3.js.
6
+ *
7
+ * Token metadata strategy (same as jelli-portfolio-api):
8
+ * 1. Jupiter token list (most comprehensive, loaded on startup)
9
+ * 2. Metaplex on-chain metadata (for tokens not in Jupiter)
10
+ * 3. Off-chain metadata from URI
11
+ */
12
+
13
+ const { Connection, PublicKey, LAMPORTS_PER_SOL } = require('@solana/web3.js');
14
+ const axios = require('axios');
15
+ const redisService = require('./redisService');
16
+
17
+ // SPL Token Program IDs
18
+ const TOKEN_PROGRAM_ID = new PublicKey('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA');
19
+ const TOKEN_2022_PROGRAM_ID = new PublicKey('TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb');
20
+
21
+ // Metaplex Token Metadata Program ID
22
+ const METADATA_PROGRAM_ID = new PublicKey('metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s');
23
+
24
+ // Redis key prefix for portfolio cache
25
+ const REDIS_KEY_PREFIX = 'portfolio:';
26
+
27
+ class PortfolioService {
28
+ constructor(options = {}) {
29
+ // Token metadata cache (loaded from Jupiter)
30
+ this.tokenCache = new Map();
31
+ this.initialized = false;
32
+
33
+ // Portfolio cache
34
+ this.memoryCache = new Map();
35
+ this.CACHE_TTL = 30; // seconds for Redis
36
+ this.CACHE_TTL_MS = 30 * 1000; // ms for memory
37
+
38
+ // Alchemy RPC URLs
39
+ const alchemyKey = process.env.ALCHEMY_API_KEY || 'M7pyy3QL4xYOndpcYukNhf-yq2IG6eyl';
40
+ const mainnetRpc = process.env.PORTFOLIO_MAINNET_RPC || `https://solana-mainnet.g.alchemy.com/v2/${alchemyKey}`;
41
+ const devnetRpc = process.env.PORTFOLIO_DEVNET_RPC || `https://solana-devnet.g.alchemy.com/v2/${alchemyKey}`;
42
+
43
+ this.alchemyKey = alchemyKey;
44
+ this.rpcUrls = {
45
+ 'mainnet-beta': mainnetRpc,
46
+ 'devnet': devnetRpc,
47
+ };
48
+
49
+ this.connections = {
50
+ 'mainnet-beta': new Connection(mainnetRpc, 'confirmed'),
51
+ 'devnet': new Connection(devnetRpc, 'confirmed'),
52
+ };
53
+
54
+ console.log('[Portfolio] Service initialized with Alchemy');
55
+ console.log(' Mainnet RPC:', mainnetRpc.replace(alchemyKey, '***'));
56
+ console.log(' Devnet RPC:', devnetRpc.replace(alchemyKey, '***'));
57
+
58
+ // Load Jupiter token list on startup
59
+ this.initializeTokenList();
60
+ }
61
+
62
+ /**
63
+ * Load Solana token registry
64
+ */
65
+ async initializeTokenList() {
66
+ try {
67
+ console.log('[Portfolio] Loading Solana token registry...');
68
+ const response = await axios.get('https://raw.githubusercontent.com/solana-labs/token-list/main/src/tokens/solana.tokenlist.json', {
69
+ timeout: 15000,
70
+ });
71
+
72
+ if (response.data?.tokens && Array.isArray(response.data.tokens)) {
73
+ let count = 0;
74
+ response.data.tokens.forEach(token => {
75
+ if (token.address && token.symbol) {
76
+ this.tokenCache.set(token.address, {
77
+ mint: token.address,
78
+ symbol: token.symbol,
79
+ name: token.name || token.symbol,
80
+ logo: token.logoURI || null,
81
+ decimals: token.decimals || 9,
82
+ source: 'solana-registry',
83
+ verified: true,
84
+ tags: token.tags || [],
85
+ });
86
+ count++;
87
+ }
88
+ });
89
+ console.log(`[Portfolio] Loaded ${count} tokens from Solana registry`);
90
+ }
91
+ this.initialized = true;
92
+ } catch (error) {
93
+ console.warn('[Portfolio] Failed to load token list:', error.message);
94
+ this.initialized = true;
95
+ }
96
+ }
97
+
98
+ _getConnection(network = 'mainnet-beta') {
99
+ return this.connections[network] || this.connections['mainnet-beta'];
100
+ }
101
+
102
+ _isValidAddress(address) {
103
+ try {
104
+ new PublicKey(address);
105
+ return true;
106
+ } catch {
107
+ return false;
108
+ }
109
+ }
110
+
111
+ _getRedisKey(walletAddress, network) {
112
+ return `${REDIS_KEY_PREFIX}${network}:${walletAddress}`;
113
+ }
114
+
115
+ async _getCached(walletAddress, network) {
116
+ const redisKey = this._getRedisKey(walletAddress, network);
117
+
118
+ if (redisService.isAvailable()) {
119
+ try {
120
+ const cached = await redisService.get(redisKey);
121
+ if (cached) return JSON.parse(cached);
122
+ } catch (error) {
123
+ console.error('[Portfolio] Redis get error:', error.message);
124
+ }
125
+ }
126
+
127
+ const memKey = `${walletAddress}:${network}`;
128
+ const memCached = this.memoryCache.get(memKey);
129
+ if (memCached && Date.now() - memCached.timestamp < this.CACHE_TTL_MS) {
130
+ return memCached.data;
131
+ }
132
+ return null;
133
+ }
134
+
135
+ async _setCache(walletAddress, network, data) {
136
+ const redisKey = this._getRedisKey(walletAddress, network);
137
+ const memKey = `${walletAddress}:${network}`;
138
+
139
+ this.memoryCache.set(memKey, { data, timestamp: Date.now() });
140
+
141
+ if (redisService.isAvailable()) {
142
+ try {
143
+ await redisService.set(redisKey, JSON.stringify(data), this.CACHE_TTL);
144
+ } catch (error) {
145
+ console.error('[Portfolio] Redis set error:', error.message);
146
+ }
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Get token metadata - check Jupiter cache first, then fetch Metaplex
152
+ */
153
+ async _getTokenMetadata(mintAddress, connection) {
154
+ // Check Jupiter cache first
155
+ if (this.tokenCache.has(mintAddress)) {
156
+ return this.tokenCache.get(mintAddress);
157
+ }
158
+
159
+ // Try Metaplex on-chain metadata
160
+ try {
161
+ const metadata = await this._fetchMetaplexMetadata(mintAddress, connection);
162
+ if (metadata) {
163
+ this.tokenCache.set(mintAddress, metadata);
164
+ return metadata;
165
+ }
166
+ } catch (error) {
167
+ // Silent fail
168
+ }
169
+
170
+ // Fallback
171
+ return {
172
+ symbol: mintAddress.slice(0, 4).toUpperCase(),
173
+ name: `Unknown Token`,
174
+ logo: null,
175
+ decimals: null,
176
+ };
177
+ }
178
+
179
+ /**
180
+ * Fetch Metaplex metadata (copied from jelli-portfolio-api)
181
+ */
182
+ async _fetchMetaplexMetadata(mintAddress, connection) {
183
+ try {
184
+ console.log(`[Portfolio] Fetching Metaplex metadata for ${mintAddress}`);
185
+
186
+ // Metaplex Token Metadata Program ID
187
+ const TOKEN_METADATA_PROGRAM_ID = 'metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s';
188
+
189
+ // Create PublicKey objects for PDA derivation
190
+ const mintPublicKey = new PublicKey(mintAddress);
191
+ const metadataProgramId = new PublicKey(TOKEN_METADATA_PROGRAM_ID);
192
+
193
+ // Derive the metadata account PDA
194
+ const [metadataAddress] = PublicKey.findProgramAddressSync(
195
+ [
196
+ Buffer.from('metadata'),
197
+ metadataProgramId.toBuffer(),
198
+ mintPublicKey.toBuffer()
199
+ ],
200
+ metadataProgramId
201
+ );
202
+
203
+ console.log(`[Portfolio] Derived metadata address for ${mintAddress}: ${metadataAddress.toString()}`);
204
+
205
+ // Fetch the metadata account using RPC endpoint
206
+ const rpcUrl = connection._rpcEndpoint;
207
+ const metadataResponse = await axios.post(rpcUrl, {
208
+ jsonrpc: '2.0',
209
+ id: 1,
210
+ method: 'getAccountInfo',
211
+ params: [
212
+ metadataAddress.toString(),
213
+ {
214
+ encoding: 'base64'
215
+ }
216
+ ]
217
+ }, {
218
+ headers: { 'Content-Type': 'application/json' },
219
+ timeout: 10000
220
+ });
221
+
222
+ const metadataAccount = metadataResponse.data.result?.value;
223
+
224
+ if (!metadataAccount || !metadataAccount.data) {
225
+ console.log(`[Portfolio] No Metaplex metadata account found for ${mintAddress}`);
226
+ return null;
227
+ }
228
+
229
+ // Decode the metadata account data
230
+ const accountData = Buffer.from(metadataAccount.data[0], 'base64');
231
+
232
+ // Parse the metadata account
233
+ try {
234
+ // Skip the first byte (account discriminator) and parse the basic fields
235
+ let offset = 1; // Skip discriminator
236
+
237
+ // Skip update authority (32 bytes)
238
+ offset += 32;
239
+
240
+ // Skip mint (32 bytes)
241
+ offset += 32;
242
+
243
+ // Read name length (4 bytes)
244
+ const nameLength = accountData.readUInt32LE(offset);
245
+ offset += 4;
246
+
247
+ // Read name
248
+ const name = accountData.slice(offset, offset + nameLength).toString('utf8').replace(/\0/g, '');
249
+ offset += nameLength;
250
+
251
+ // Read symbol length (4 bytes)
252
+ const symbolLength = accountData.readUInt32LE(offset);
253
+ offset += 4;
254
+
255
+ // Read symbol
256
+ const symbol = accountData.slice(offset, offset + symbolLength).toString('utf8').replace(/\0/g, '');
257
+ offset += symbolLength;
258
+
259
+ // Read URI length (4 bytes)
260
+ const uriLength = accountData.readUInt32LE(offset);
261
+ offset += 4;
262
+
263
+ // Read URI
264
+ const uri = accountData.slice(offset, offset + uriLength).toString('utf8').replace(/\0/g, '');
265
+
266
+ console.log(`[Portfolio] Parsed Metaplex metadata for ${mintAddress}: name="${name}", symbol="${symbol}", uri="${uri}"`);
267
+
268
+ // Fetch off-chain metadata if URI exists
269
+ let offChainMetadata = null;
270
+ let logo = null;
271
+
272
+ if (uri && uri.trim()) {
273
+ offChainMetadata = await this._fetchMetadataFromUri(uri.trim());
274
+ logo = offChainMetadata?.image || null;
275
+ }
276
+
277
+ // Only return if we have meaningful data
278
+ if (name || symbol || logo) {
279
+ return {
280
+ mint: mintAddress,
281
+ symbol: symbol || 'UNKNOWN',
282
+ name: name || symbol || 'Unknown Token',
283
+ logo,
284
+ decimals: null,
285
+ source: 'metaplex',
286
+ uri: uri || null,
287
+ offChainMetadata
288
+ };
289
+ }
290
+
291
+ return null;
292
+
293
+ } catch (parseError) {
294
+ console.warn(`[Portfolio] Error parsing Metaplex metadata account for ${mintAddress}:`, parseError.message);
295
+ return null;
296
+ }
297
+
298
+ } catch (error) {
299
+ console.warn(`[Portfolio] Error fetching Metaplex metadata for ${mintAddress}:`, error.message);
300
+ return null;
301
+ }
302
+ }
303
+
304
+ /**
305
+ * Fetch off-chain metadata from URI
306
+ */
307
+ async _fetchMetadataFromUri(uri) {
308
+ try {
309
+ if (!uri) return null;
310
+
311
+ // Convert IPFS to HTTP gateway
312
+ let fetchUrl = uri;
313
+ if (uri.startsWith('ipfs://')) {
314
+ fetchUrl = uri.replace('ipfs://', 'https://ipfs.io/ipfs/');
315
+ }
316
+
317
+ const response = await axios.get(fetchUrl, {
318
+ timeout: 5000,
319
+ headers: { 'Accept': 'application/json', 'User-Agent': 'Dubs-Portfolio-API/1.0' }
320
+ });
321
+
322
+ const metadata = response.data;
323
+
324
+ // Get image URL
325
+ let image = metadata.image || metadata.image_url || null;
326
+ if (image && image.startsWith('ipfs://')) {
327
+ image = image.replace('ipfs://', 'https://ipfs.io/ipfs/');
328
+ }
329
+
330
+ return {
331
+ name: metadata.name || null,
332
+ description: metadata.description || null,
333
+ image,
334
+ };
335
+ } catch (error) {
336
+ return null;
337
+ }
338
+ }
339
+
340
+ /**
341
+ * Get full portfolio using Alchemy RPC + DAS API
342
+ * 1. Get token accounts from both SPL and Token-2022 programs
343
+ * 2. Get metadata via getAsset for each token
344
+ */
345
+ async getPortfolio(walletAddress, network = 'mainnet-beta', forceRefresh = false) {
346
+ if (!walletAddress || !this._isValidAddress(walletAddress)) {
347
+ throw new Error('Invalid wallet address');
348
+ }
349
+
350
+ // Check cache
351
+ if (!forceRefresh) {
352
+ const cached = await this._getCached(walletAddress, network);
353
+ if (cached) {
354
+ console.log(`[Portfolio] Cache hit for ${walletAddress.slice(0, 8)}...`);
355
+ return { ...cached, fromCache: true };
356
+ }
357
+ }
358
+
359
+ const startTime = Date.now();
360
+ const connection = this._getConnection(network);
361
+ const rpcUrl = this.rpcUrls[network] || this.rpcUrls['mainnet-beta'];
362
+
363
+ try {
364
+ // Fetch SOL balance and token accounts (both programs) in parallel
365
+ const [solBalance, splTokensRes, token2022Res] = await Promise.all([
366
+ connection.getBalance(new PublicKey(walletAddress)),
367
+ axios.post(rpcUrl, {
368
+ jsonrpc: '2.0', id: 'spl',
369
+ method: 'getTokenAccountsByOwner',
370
+ params: [walletAddress, { programId: TOKEN_PROGRAM_ID.toString() }, { encoding: 'jsonParsed' }]
371
+ }, { headers: { 'Content-Type': 'application/json' }, timeout: 30000 }),
372
+ axios.post(rpcUrl, {
373
+ jsonrpc: '2.0', id: 't22',
374
+ method: 'getTokenAccountsByOwner',
375
+ params: [walletAddress, { programId: TOKEN_2022_PROGRAM_ID.toString() }, { encoding: 'jsonParsed' }]
376
+ }, { headers: { 'Content-Type': 'application/json' }, timeout: 30000 }),
377
+ ]);
378
+
379
+ // Combine token accounts
380
+ const splTokens = (splTokensRes.data?.result?.value || []).map(a => ({ ...a, isToken2022: false }));
381
+ const token2022s = (token2022Res.data?.result?.value || []).map(a => ({ ...a, isToken2022: true }));
382
+ const allAccounts = [...splTokens, ...token2022s];
383
+
384
+ console.log(`[Portfolio] Found ${splTokens.length} SPL + ${token2022s.length} Token-2022 accounts`);
385
+
386
+ // Filter non-zero balances and collect mints
387
+ const accountsWithBalance = allAccounts.filter(a => {
388
+ const amount = a.account?.data?.parsed?.info?.tokenAmount;
389
+ return amount && parseFloat(amount.uiAmount) > 0;
390
+ });
391
+
392
+ // Get metadata for all mints via getAsset
393
+ const tokenBalances = [];
394
+ const nftBalances = [];
395
+ const allTokens = [];
396
+
397
+ for (const account of accountsWithBalance) {
398
+ const info = account.account.data.parsed.info;
399
+ const mint = info.mint;
400
+ const tokenAmount = info.tokenAmount;
401
+ const isToken2022 = account.isToken2022;
402
+
403
+ // Get metadata - check local cache first, then DAS API
404
+ let metadata = { symbol: 'UNKNOWN', name: 'Unknown Token', logo: null, source: 'unknown' };
405
+
406
+ // Check Solana registry cache first
407
+ if (this.tokenCache.has(mint)) {
408
+ const cached = this.tokenCache.get(mint);
409
+ metadata = {
410
+ symbol: cached.symbol,
411
+ name: cached.name,
412
+ logo: cached.logo,
413
+ source: 'solana-registry'
414
+ };
415
+ }
416
+
417
+ // Try DAS API for additional/better metadata
418
+ try {
419
+ const assetRes = await axios.post(rpcUrl, {
420
+ jsonrpc: '2.0', id: mint,
421
+ method: 'getAsset',
422
+ params: { id: mint }
423
+ }, { headers: { 'Content-Type': 'application/json' }, timeout: 10000 });
424
+
425
+ const asset = assetRes.data?.result;
426
+ if (asset) {
427
+ const content = asset.content || {};
428
+ const meta = content.metadata || {};
429
+ const links = content.links || {};
430
+ const files = content.files || [];
431
+
432
+ // Only override if DAS has better data
433
+ if (meta.symbol) metadata.symbol = meta.symbol;
434
+ if (meta.name) metadata.name = meta.name;
435
+ if (links.image || files[0]?.uri) {
436
+ metadata.logo = links.image || files[0]?.uri;
437
+ }
438
+ metadata.source = 'alchemy-das';
439
+ }
440
+ } catch (e) {
441
+ // Silent fail - use cached data
442
+ }
443
+
444
+ const balance = parseFloat(tokenAmount.uiAmount);
445
+ const decimals = tokenAmount.decimals;
446
+ const isNFT = decimals === 0 && balance === 1;
447
+
448
+ const tokenData = {
449
+ mint,
450
+ tokenAccount: account.pubkey.toString(),
451
+ symbol: metadata.symbol,
452
+ name: metadata.name,
453
+ balance,
454
+ balanceFormatted: balance.toFixed(Math.min(decimals, 6)),
455
+ decimals,
456
+ rawBalance: tokenAmount.amount,
457
+ isNFT,
458
+ type: isNFT ? 'nft' : 'fungible',
459
+ programId: isToken2022 ? TOKEN_2022_PROGRAM_ID.toString() : TOKEN_PROGRAM_ID.toString(),
460
+ programType: isToken2022 ? 'Token-2022 Program' : 'Token Program',
461
+ isTokenExtension: isToken2022,
462
+ metadata: {
463
+ logoURI: metadata.logo,
464
+ verified: false,
465
+ source: 'alchemy-das',
466
+ },
467
+ network: 'solana',
468
+ environment: network,
469
+ timestamp: Date.now(),
470
+ };
471
+
472
+ allTokens.push(tokenData);
473
+ if (isNFT) {
474
+ nftBalances.push(tokenData);
475
+ } else {
476
+ tokenBalances.push(tokenData);
477
+ }
478
+ }
479
+
480
+ // Sort
481
+ tokenBalances.sort((a, b) => b.balance - a.balance);
482
+ nftBalances.sort((a, b) => a.name.localeCompare(b.name));
483
+
484
+ const elapsed = Date.now() - startTime;
485
+ const token2022Count = allTokens.filter(t => t.isTokenExtension).length;
486
+ console.log(`[Portfolio] Fetched ${walletAddress.slice(0, 8)}... in ${elapsed}ms (${tokenBalances.length} fungible, ${nftBalances.length} NFTs, ${token2022Count} Token-2022)`);
487
+
488
+ const result = {
489
+ address: walletAddress,
490
+ network: 'solana',
491
+ environment: network,
492
+ nativeBalance: {
493
+ network: 'solana',
494
+ environment: network,
495
+ symbol: 'SOL',
496
+ balance: solBalance / LAMPORTS_PER_SOL,
497
+ balanceFormatted: (solBalance / LAMPORTS_PER_SOL).toFixed(6),
498
+ rawBalance: solBalance.toString(),
499
+ decimals: 9,
500
+ timestamp: Date.now(),
501
+ source: 'blockchain',
502
+ },
503
+ tokenBalances,
504
+ nftBalances,
505
+ allTokens,
506
+ tokenCount: allTokens.length,
507
+ fungibleTokenCount: tokenBalances.length,
508
+ nftCount: nftBalances.length,
509
+ timestamp: Date.now(),
510
+ fetchTimeMs: elapsed,
511
+ };
512
+
513
+ await this._setCache(walletAddress, network, result);
514
+
515
+ return { ...result, fromCache: false };
516
+ } catch (error) {
517
+ console.error('[Portfolio] Error fetching portfolio:', error);
518
+ throw new Error(`Failed to fetch portfolio: ${error.message}`);
519
+ }
520
+ }
521
+
522
+ async invalidateCache(walletAddress, network = 'mainnet-beta') {
523
+ const redisKey = this._getRedisKey(walletAddress, network);
524
+ const memKey = `${walletAddress}:${network}`;
525
+
526
+ this.memoryCache.delete(memKey);
527
+
528
+ if (redisService.isAvailable()) {
529
+ try {
530
+ await redisService.del(redisKey);
531
+ } catch (error) {
532
+ console.error('[Portfolio] Redis delete error:', error.message);
533
+ }
534
+ }
535
+
536
+ console.log(`[Portfolio] Cache invalidated for ${walletAddress.slice(0, 8)}...`);
537
+ }
538
+
539
+ clearCache() {
540
+ this.memoryCache.clear();
541
+ console.log('[Portfolio] Memory cache cleared');
542
+ }
543
+
544
+ getCacheStats() {
545
+ return {
546
+ memoryEntries: this.memoryCache.size,
547
+ tokensCached: this.tokenCache.size,
548
+ ttlSeconds: this.CACHE_TTL,
549
+ redisAvailable: redisService.isAvailable(),
550
+ initialized: this.initialized,
551
+ };
552
+ }
553
+ }
554
+
555
+ module.exports = PortfolioService;