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.
- package/.claude/settings.local.json +280 -0
- package/CLAUDE.md +46 -0
- package/CONNECT4_PRODUCTION_DEPLOY.md +155 -0
- package/CURRENT_SESSION.md +171 -0
- package/CURRENT_SESSION_DRAW.md +516 -0
- package/MARCH_MADNESS_SURVIVOR.md +254 -0
- package/PANDA.md +166 -0
- package/Procfile +4 -0
- package/README.md +476 -0
- package/controllers/livescoresController.js +376 -0
- package/controllers/pickemController.js +554 -0
- package/controllers/survivorAdminController.js +887 -0
- package/controllers/survivorController.js +623 -0
- package/cron/oracleMonitor.js +77 -0
- package/cron/pickemOracleMonitor.js +73 -0
- package/data/jackpot-history.json +952 -0
- package/data/ncaaTeams.js +406 -0
- package/documentation/API_SECURITY_GUIDE.md +327 -0
- package/documentation/ARCADE_API.md +593 -0
- package/documentation/ARCADE_IMPLEMENTATION_SUMMARY.md +399 -0
- package/documentation/ARCADE_QUICKSTART.md +242 -0
- package/documentation/AUTOMATIC_MODE_ORACLE.md +321 -0
- package/documentation/BUG_FIX_COHORT_DATE_DISPLAY.md +171 -0
- package/documentation/CLAIM_MIGRATION_INSTRUCTIONS.md +52 -0
- package/documentation/CLAIM_STATUS_FIX.md +67 -0
- package/documentation/CLI_TOOL_GUIDE.md +372 -0
- package/documentation/COHORT_RETENTION_ANALYSIS.md +295 -0
- package/documentation/COHORT_RETENTION_IMPLEMENTATION_COMPLETE.md +461 -0
- package/documentation/COHORT_RETENTION_SUMMARY.md +204 -0
- package/documentation/COMPLETE_PROJECT_SUMMARY.md +490 -0
- package/documentation/DATABASE_QUERIES.md +269 -0
- package/documentation/DATABASE_RETENTION_POLICY.md +390 -0
- package/documentation/DATABASE_SETUP_GUIDE.md +361 -0
- package/documentation/DATABASE_SETUP_SUMMARY.md +247 -0
- package/documentation/DEMO_API_CURL_COMMANDS.md +656 -0
- package/documentation/DEPLOYMENT_SUMMARY.txt +100 -0
- package/documentation/DUPLICATE_NOTIFICATIONS_FIXED.md +201 -0
- package/documentation/EXCHANGE_RATES_INTEGRATION.md +371 -0
- package/documentation/FINAL_API_PROTECTION_TABLE.md +175 -0
- package/documentation/GAME_START_NOTIFICATIONS_DEPLOYMENT.md +256 -0
- package/documentation/GAME_START_NOTIFICATIONS_INTEGRATION.md +275 -0
- package/documentation/HEROKU_DEPLOYMENT.md +134 -0
- package/documentation/HEROKU_SCHEDULER_SETUP.md +271 -0
- package/documentation/JACKPOT_API.md +521 -0
- package/documentation/JACKPOT_DEPLOYMENT_GUIDE.md +362 -0
- package/documentation/JWT_IMPLEMENTATION_SUMMARY.md +373 -0
- package/documentation/JWT_QUICK_SETUP.md +268 -0
- package/documentation/JWT_TESTING_GUIDE.md +404 -0
- package/documentation/KEEPER_RECOVERY_GUIDE.md +381 -0
- package/documentation/KEEPER_SETUP.md +206 -0
- package/documentation/KEEPER_STATE_MACHINE.md +423 -0
- package/documentation/LATEST_PRODUCTION_SETUP.md +387 -0
- package/documentation/LOCAL_VOTING_TEST.md +279 -0
- package/documentation/ORACLE_FIXES_SUMMARY.md +188 -0
- package/documentation/ORACLE_POSTGRESQL_UPDATE.md +202 -0
- package/documentation/PAYMENT_DEPLOYMENT.md +209 -0
- package/documentation/PNL_TRACKING_SETUP.md +189 -0
- package/documentation/PREVENTING_LOCKUP_ERRORS.md +472 -0
- package/documentation/PRODUCTION_READY_SUMMARY.md +227 -0
- package/documentation/PUBLIC_VS_PRIVATE_ENDPOINTS.md +278 -0
- package/documentation/QUICK_AUTH_SETUP.md +99 -0
- package/documentation/QUICK_DEPLOY.md +224 -0
- package/documentation/QUICK_FIX.md +114 -0
- package/documentation/QUICK_START.md +152 -0
- package/documentation/REFEREE_MODE_GUIDE.md +392 -0
- package/documentation/RETENTION_CORE_ACTION_UPDATE.md +313 -0
- package/documentation/RETENTION_UPDATE_SUMMARY.md +108 -0
- package/documentation/RUN_MIGRATION_NOW.md +39 -0
- package/documentation/SCRIPTS_UPDATE_SUMMARY.md +251 -0
- package/documentation/SETUP_GUIDE.md +184 -0
- package/documentation/STATE_MACHINE_IMPLEMENTATION.md +250 -0
- package/documentation/TELEGRAM_NOTIFICATIONS_DIAGNOSIS.md +361 -0
- package/documentation/UNIFIED_ARCHITECTURE.md +231 -0
- package/documentation/VOTING_DEPLOYMENT_SUMMARY.md +392 -0
- package/documentation/WEBSOCKET_ARCHITECTURE.md +881 -0
- package/documentation/WHAT_WE_BUILT_TODAY.md +369 -0
- package/documentation/latest/LATEST_PRODUCTION_SETUP.md +865 -0
- package/ecosystem.config.js +65 -0
- package/env.template +125 -0
- package/middleware/apiKeyAuth.js +136 -0
- package/middleware/authenticate.js +214 -0
- package/middleware/developerUserAuth.js +76 -0
- package/middleware/socketAuth.js +69 -0
- package/package.json +49 -0
- package/postman/Dubs-API-v1-With-Voting.postman_collection.json +555 -0
- package/postman/Dubs-API-v1.postman_collection.json +205 -0
- package/postman/Dubs_Developer_API.postman_collection.json +662 -0
- package/postman/QUICKSTART.md +118 -0
- package/postman/QUICK_REFERENCE.md +246 -0
- package/postman/README.md +71 -0
- package/postman/VOTING_API_GUIDE.md +426 -0
- package/refactor/Animations.md +148 -0
- package/refactor/Chat.md +252 -0
- package/routes/actionsRoutes.js +699 -0
- package/routes/adminRoutes.js +370 -0
- package/routes/analyticsRoutes.js +1262 -0
- package/routes/arcadeRoutes.js +557 -0
- package/routes/authRoutes.js +2310 -0
- package/routes/avatarRoutes.js +85 -0
- package/routes/botRoutes.js +211 -0
- package/routes/chatRoutes.js +377 -0
- package/routes/cryptoPriceRoutes.js +105 -0
- package/routes/developerRoutes.js +4201 -0
- package/routes/deviceRoutes.js +214 -0
- package/routes/dmRoutes.js +167 -0
- package/routes/esportsRoutes.js +806 -0
- package/routes/exchangeRateRoutes.js +233 -0
- package/routes/gamesRoutes.js +3028 -0
- package/routes/jackpotRoutes.js +754 -0
- package/routes/keeperMonitoringRoutes.js +156 -0
- package/routes/keeperWebhookRoutes.js +466 -0
- package/routes/livescoresRoutes.js +31 -0
- package/routes/pickemAdminRoutes.js +199 -0
- package/routes/pickemRoutes.js +231 -0
- package/routes/playerStatsRoutes.js +147 -0
- package/routes/portfolioRoutes.js +217 -0
- package/routes/promoRoutes.js +418 -0
- package/routes/referralEarningsRoutes.js +392 -0
- package/routes/socialRoutes.js +459 -0
- package/routes/sportsRoutes.js +1271 -0
- package/routes/survivorAdminRoutes.js +345 -0
- package/routes/survivorRoutes.js +756 -0
- package/routes/uploadRoutes.js +256 -0
- package/routes/userProfileRoutes.js +244 -0
- package/routes/whatsNewRoutes.js +331 -0
- package/scripts/.claude/settings.local.json +15 -0
- package/scripts/README.md +170 -0
- package/scripts/RESTART_EVERYTHING.sh +104 -0
- package/scripts/add-claim-columns.sql +48 -0
- package/scripts/add-crypto-prices-cache.sql +27 -0
- package/scripts/add-exchange-rates-cache.sql +40 -0
- package/scripts/add-game-invite-column.sql +23 -0
- package/scripts/add-game-invite-notification.sql +33 -0
- package/scripts/add-game-invite-telegram-pref.sql +16 -0
- package/scripts/add-game-joined-notification.sql +16 -0
- package/scripts/add-game-joined-pref.js +40 -0
- package/scripts/add-game-joined-preference.sql +6 -0
- package/scripts/add-game-start-notifications.sql +41 -0
- package/scripts/add-notification-flags-to-games.sql +55 -0
- package/scripts/add-pending-game-dismissals.sql +19 -0
- package/scripts/add-preferred-currency.sql +34 -0
- package/scripts/add-winner-columns.js +61 -0
- package/scripts/add_mention_system.sql +53 -0
- package/scripts/add_payment_system.sql +96 -0
- package/scripts/add_sports_event_id_column.sql +22 -0
- package/scripts/analyze-cohort-data-heroku.js +276 -0
- package/scripts/analyze-cohort-data.js +295 -0
- package/scripts/analyze-prod-cohorts.sh +10 -0
- package/scripts/backfill-matchup-images.js +245 -0
- package/scripts/backfill-missing-signatures.js +175 -0
- package/scripts/backfill-referral-earnings.js +202 -0
- package/scripts/check-chat-schema.js +130 -0
- package/scripts/check-db.sh +14 -0
- package/scripts/check_oracle_in_game.js +54 -0
- package/scripts/cleanup-database.js +193 -0
- package/scripts/clear-notification-cache.js +85 -0
- package/scripts/convert-mnemonic.js +50 -0
- package/scripts/create-users-table.sql +44 -0
- package/scripts/debug-cohort-counts.js +248 -0
- package/scripts/debug-winner-calc.js +84 -0
- package/scripts/deploy-payment-system.sh +118 -0
- package/scripts/deploy-to-heroku.sh +63 -0
- package/scripts/diagnose-locked-round.js +143 -0
- package/scripts/dubs-cli.js +720 -0
- package/scripts/dump-account.js +65 -0
- package/scripts/find-vrf-offset.js +48 -0
- package/scripts/fix-chat-notifications-constraint.sql +122 -0
- package/scripts/fix-claim-columns.js +124 -0
- package/scripts/fix-constraint-now.js +44 -0
- package/scripts/fix-lock-timestamps.js +96 -0
- package/scripts/fix-locked-round.sh +126 -0
- package/scripts/fix-missing-badges.sql +91 -0
- package/scripts/fix-payment-notifications.sql +41 -0
- package/scripts/force-new-round.js +55 -0
- package/scripts/force-resolve-and-claim.js +278 -0
- package/scripts/important/README.md +115 -0
- package/scripts/important/authority-force-lock.js +197 -0
- package/scripts/important/authority-resolve-game.js +267 -0
- package/scripts/important/check-game-status.js +373 -0
- package/scripts/important/list-pending-games-by-version.js +270 -0
- package/scripts/important/reconcile-v1-v2-payouts.js +270 -0
- package/scripts/initialize-jackpot.js +111 -0
- package/scripts/jackpot/.claude/settings.local.json +10 -0
- package/scripts/jackpot/force-reset.js +84 -0
- package/scripts/jackpot/initialize-mainnet.js +100 -0
- package/scripts/jackpot/keeper.js +742 -0
- package/scripts/jackpot/status.js +107 -0
- package/scripts/jackpot/update-round-duration.js +143 -0
- package/scripts/keeper-bot.js +112 -0
- package/scripts/list-pending-games.js +131 -0
- package/scripts/migrate-chat-v2.js +127 -0
- package/scripts/migrate-chat-winners.js +84 -0
- package/scripts/migrate-chat.sh +17 -0
- package/scripts/migrate-game-invite.js +83 -0
- package/scripts/migrate-heroku-game-notifications.sh +159 -0
- package/scripts/migrations/001_analytics_tables.sql +422 -0
- package/scripts/migrations/002_add_matchup_image_url.sql +14 -0
- package/scripts/migrations/003_referral_earnings.sql +208 -0
- package/scripts/migrations/004_add_whats_new_notification_type.sql +62 -0
- package/scripts/migrations/005_add_connect4_your_turn_notification.sql +61 -0
- package/scripts/migrations/005_push_notifications.sql +55 -0
- package/scripts/migrations/006_add_draw_team_players.sql +28 -0
- package/scripts/migrations/006_add_game_cancelled_notification.sql +62 -0
- package/scripts/migrations/007_add_gif_url.sql +8 -0
- package/scripts/migrations/008_add_connect4_columns.sql +139 -0
- package/scripts/migrations/008_add_pool_tracking.sql +22 -0
- package/scripts/migrations/009_create_survivor_pool_tables.sql +174 -0
- package/scripts/migrations/010_add_survivor_pool_outcome.sql +28 -0
- package/scripts/migrations/011_create_developer_tables.sql +67 -0
- package/scripts/migrations/011_fix_keeper_tables.sql +85 -0
- package/scripts/migrations/012_create_developer_webhooks.sql +31 -0
- package/scripts/migrations/013_add_network_mode.sql +18 -0
- package/scripts/migrations/014_create_developer_app_users.sql +19 -0
- package/scripts/migrations/015_add_ui_config.sql +4 -0
- package/scripts/migrations/016_add_resolution_secret.sql +4 -0
- package/scripts/migrations/017_add_external_game_id.sql +3 -0
- package/scripts/migrations/018_create_pickem_tables.sql +115 -0
- package/scripts/migrations/019_expo_push_tokens.sql +19 -0
- package/scripts/migrations/create_whats_new_tables.sql +88 -0
- package/scripts/migrations/drop_live_games_tables.sql +34 -0
- package/scripts/open-jackpot-round.js +85 -0
- package/scripts/purge-all-data.sh +329 -0
- package/scripts/purge-all-data.sql +142 -0
- package/scripts/purge-heroku-data.sh +149 -0
- package/scripts/purge-heroku-data.sql +62 -0
- package/scripts/rebuild-heroku-database.sh +113 -0
- package/scripts/recover-funds.js +357 -0
- package/scripts/regenerate-epl-images.js +278 -0
- package/scripts/resize-s3-matchup-images.js +374 -0
- package/scripts/resolve-direct.js +88 -0
- package/scripts/resolve-mock-game.js +124 -0
- package/scripts/resolve-pickem-game.js +55 -0
- package/scripts/resolve-round-manual.js +83 -0
- package/scripts/resolve-stuck-game.js +382 -0
- package/scripts/resolve-stuck-round.js +42 -0
- package/scripts/run-connect4-migration.sh +16 -0
- package/scripts/run-mention-migration.sh +32 -0
- package/scripts/run-payment-migration.sh +51 -0
- package/scripts/run-preferred-currency-migration.sh +31 -0
- package/scripts/run-referral-earnings-migration.sh +32 -0
- package/scripts/run-survivor-outcome-migration.sh +16 -0
- package/scripts/seed-test-users.js +346 -0
- package/scripts/setup-auth-tables.js +78 -0
- package/scripts/setup-complete-database.sql +992 -0
- package/scripts/setup-database-fresh.sh +359 -0
- package/scripts/setup-heroku-keeper.sh +48 -0
- package/scripts/setup-keeper-database.js +83 -0
- package/scripts/setup-keeper-state-db.sql +110 -0
- package/scripts/setup-oracle.sh +39 -0
- package/scripts/setup-pnl-tracking.js +111 -0
- package/scripts/start-devnet.sh +14 -0
- package/scripts/test-arcade-devnet.sh +160 -0
- package/scripts/test-arcade-match.sh +109 -0
- package/scripts/test-automatic-mode.sh +239 -0
- package/scripts/test-connect4-cancel-claim.js +370 -0
- package/scripts/test-connect4-e2e.js +369 -0
- package/scripts/test-connect4-resolve.js +369 -0
- package/scripts/test-game-state-endpoint.js +136 -0
- package/scripts/test-invite-notification.js +86 -0
- package/scripts/test-jackpot-api.sh +71 -0
- package/scripts/test-poll-confirmation.js +267 -0
- package/scripts/test-resolve-game.js +271 -0
- package/scripts/test-resolve-signature.js +223 -0
- package/scripts/test-signature-preservation.js +124 -0
- package/scripts/test-state-machine.js +291 -0
- package/scripts/test-webhook-receiver.js +60 -0
- package/scripts/update-notification-constraint.js +52 -0
- package/scripts/verify-account-layout.js +145 -0
- package/scripts/verify-winner-algorithm.js +278 -0
- package/server.js +5259 -0
- package/services/arcadeMatchService.js +763 -0
- package/services/automaticGameOracle.js +1596 -0
- package/services/chatService.js +1612 -0
- package/services/connect4GameService.js +1049 -0
- package/services/connect4NotificationService.js +374 -0
- package/services/cryptoPriceService.js +223 -0
- package/services/customGameResolver.js +260 -0
- package/services/db.js +79 -0
- package/services/directMessageService.js +389 -0
- package/services/discordNotifications.js +160 -0
- package/services/exchangeRateService.js +289 -0
- package/services/expoPushService.js +314 -0
- package/services/gamesCacheService.js +539 -0
- package/services/jackpotHistory.js +331 -0
- package/services/jackpotService.js +856 -0
- package/services/keeperStateService.js +355 -0
- package/services/matchupImageService.js +591 -0
- package/services/notificationCacheService.js +407 -0
- package/services/pickemOracle.js +440 -0
- package/services/playerStatsService.js +389 -0
- package/services/portfolioService.js +555 -0
- package/services/promoService.js +757 -0
- package/services/promoTreasuryService.js +239 -0
- package/services/pushNotifications.js +353 -0
- package/services/redisService.js +422 -0
- package/services/referralEarningsService.js +728 -0
- package/services/s3Service.js +396 -0
- package/services/socialService.js +1202 -0
- package/services/survivorOracle.js +469 -0
- package/services/survivorSimulator.js +475 -0
- package/services/telegramNotifications.js +461 -0
- package/services/userProfileStatsService.js +1185 -0
- package/services/whatsNewService.js +388 -0
- 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;
|