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,742 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* š¤ Complete Jackpot Keeper Bot
|
|
5
|
+
*
|
|
6
|
+
* Manages full round lifecycle:
|
|
7
|
+
* 1. Lock round when timer expires
|
|
8
|
+
* 2. Submit oracle randomness
|
|
9
|
+
* 3. Resolve and pay winner
|
|
10
|
+
* 4. Open new round
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const { Connection, Keypair, Transaction, PublicKey } = require('@solana/web3.js');
|
|
14
|
+
const axios = require('axios');
|
|
15
|
+
const crypto = require('crypto');
|
|
16
|
+
const fs = require('fs');
|
|
17
|
+
const path = require('path');
|
|
18
|
+
const { instance: historyInstance } = require('../../services/jackpotHistory');
|
|
19
|
+
const KeeperStateService = require('../../services/keeperStateService');
|
|
20
|
+
|
|
21
|
+
const RPC_URL = process.env.SOLANA_RPC_URL || process.env.SOLANA_NETWORK || 'https://api.devnet.solana.com';
|
|
22
|
+
const API_BASE = process.env.API_BASE_URL || 'http://localhost:3001';
|
|
23
|
+
|
|
24
|
+
class JackpotKeeper {
|
|
25
|
+
constructor(io = null) {
|
|
26
|
+
this.connection = new Connection(RPC_URL, 'confirmed');
|
|
27
|
+
this.history = historyInstance; // Use shared singleton instance
|
|
28
|
+
this.state = new KeeperStateService();
|
|
29
|
+
this.programId = new PublicKey(process.env.JACKPOT_PROGRAM_ID || 'BHidyz25KWkNPdTHgeANzMg25MM2KEiNnG4yE5F46XUz');
|
|
30
|
+
this.startTime = Date.now();
|
|
31
|
+
this.roundsCompleted = 0;
|
|
32
|
+
this.consecutiveFailures = 0;
|
|
33
|
+
this.io = io; // WebSocket server instance
|
|
34
|
+
this.lastEntryCount = {}; // Track entry count per round for change detection
|
|
35
|
+
this.vrfDataPath = path.join(__dirname, '..', '..', '.vrf-data.json'); // Persists VRF seeds across restarts
|
|
36
|
+
|
|
37
|
+
// Load wallet - Priority: ENV var > oracle wallet file > main wallet file
|
|
38
|
+
let secretKey;
|
|
39
|
+
|
|
40
|
+
if (process.env.KEEPER_PRIVATE_KEY) {
|
|
41
|
+
// For Heroku - use environment variable
|
|
42
|
+
console.log('š Using wallet from KEEPER_PRIVATE_KEY environment variable');
|
|
43
|
+
secretKey = JSON.parse(process.env.KEEPER_PRIVATE_KEY);
|
|
44
|
+
} else {
|
|
45
|
+
// For local development - use wallet files
|
|
46
|
+
let walletPath;
|
|
47
|
+
const oracleWalletPath = path.join(__dirname, '..', '..', 'wallets', 'jackpot_oracle.json');
|
|
48
|
+
const mainWalletPath = path.join(require('os').homedir(), '.config/solana/id.json');
|
|
49
|
+
|
|
50
|
+
if (fs.existsSync(oracleWalletPath)) {
|
|
51
|
+
walletPath = oracleWalletPath;
|
|
52
|
+
console.log('š Using oracle wallet from wallets directory');
|
|
53
|
+
} else if (fs.existsSync(mainWalletPath)) {
|
|
54
|
+
walletPath = mainWalletPath;
|
|
55
|
+
console.log('š Using main wallet from home directory');
|
|
56
|
+
} else {
|
|
57
|
+
throw new Error('ā No wallet found! Set KEEPER_PRIVATE_KEY env var or add wallet file');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
secretKey = JSON.parse(fs.readFileSync(walletPath, 'utf-8'));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
this.wallet = Keypair.fromSecretKey(Uint8Array.from(secretKey));
|
|
64
|
+
|
|
65
|
+
console.log('š¤ Complete Jackpot Keeper Bot');
|
|
66
|
+
console.log(' RPC:', RPC_URL.includes('devnet') ? 'Devnet' : RPC_URL.slice(0, 40) + '...');
|
|
67
|
+
console.log(' Program:', this.programId.toString());
|
|
68
|
+
console.log(' Wallet:', this.wallet.publicKey.toString());
|
|
69
|
+
console.log();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Persist VRF data to disk so it survives keeper restarts
|
|
73
|
+
_saveVrfData() {
|
|
74
|
+
try {
|
|
75
|
+
if (this.currentRoundData) {
|
|
76
|
+
fs.writeFileSync(this.vrfDataPath, JSON.stringify(this.currentRoundData));
|
|
77
|
+
}
|
|
78
|
+
} catch (e) {
|
|
79
|
+
console.warn('ā ļø Could not persist VRF data:', e.message);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
_loadVrfData() {
|
|
84
|
+
try {
|
|
85
|
+
if (fs.existsSync(this.vrfDataPath)) {
|
|
86
|
+
return JSON.parse(fs.readFileSync(this.vrfDataPath, 'utf-8'));
|
|
87
|
+
}
|
|
88
|
+
} catch (e) {
|
|
89
|
+
console.warn('ā ļø Could not load VRF data:', e.message);
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
_clearVrfData() {
|
|
95
|
+
try {
|
|
96
|
+
if (fs.existsSync(this.vrfDataPath)) {
|
|
97
|
+
fs.unlinkSync(this.vrfDataPath);
|
|
98
|
+
}
|
|
99
|
+
} catch (e) { /* ignore */ }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Polling-based tx confirmation (Alchemy doesn't support signatureSubscribe websocket)
|
|
103
|
+
async confirmTx(signature, timeout = 60000) {
|
|
104
|
+
const start = Date.now();
|
|
105
|
+
while (Date.now() - start < timeout) {
|
|
106
|
+
const statuses = await this.connection.getSignatureStatuses([signature]);
|
|
107
|
+
const status = statuses?.value?.[0];
|
|
108
|
+
if (status?.err) {
|
|
109
|
+
throw new Error(`Transaction failed: ${JSON.stringify(status.err)}`);
|
|
110
|
+
}
|
|
111
|
+
if (status?.confirmationStatus === 'confirmed' || status?.confirmationStatus === 'finalized') {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
115
|
+
}
|
|
116
|
+
throw new Error('Transaction confirmation timeout');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async getCurrentRound() {
|
|
120
|
+
try {
|
|
121
|
+
const { data } = await axios.get(`${API_BASE}/jackpot/round/current`);
|
|
122
|
+
|
|
123
|
+
// ALSO fetch entries to get REAL entry count
|
|
124
|
+
if (data.round) {
|
|
125
|
+
const entriesRes = await axios.get(`${API_BASE}/jackpot/round/${data.round.roundId}/entries`);
|
|
126
|
+
data.round.entryCount = entriesRes.data.entries?.length || 0;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return data.round;
|
|
130
|
+
} catch (error) {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async lockRound(roundId) {
|
|
136
|
+
console.log(`š Locking round ${roundId}...`);
|
|
137
|
+
const startTime = Date.now();
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
// Update state: locking
|
|
141
|
+
await this.state.updateRound(roundId, {
|
|
142
|
+
status: 'locking',
|
|
143
|
+
last_attempt_at: new Date()
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
const { data } = await axios.post(`${API_BASE}/jackpot/build/lock-round`, {
|
|
147
|
+
keeperAddress: this.wallet.publicKey.toString(),
|
|
148
|
+
roundId,
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
const tx = Transaction.from(Buffer.from(data.transaction, 'base64'));
|
|
152
|
+
tx.recentBlockhash = (await this.connection.getLatestBlockhash()).blockhash;
|
|
153
|
+
tx.feePayer = this.wallet.publicKey;
|
|
154
|
+
tx.sign(this.wallet);
|
|
155
|
+
|
|
156
|
+
const signature = await this.connection.sendRawTransaction(tx.serialize(), {
|
|
157
|
+
skipPreflight: true,
|
|
158
|
+
});
|
|
159
|
+
await this.confirmTx(signature);
|
|
160
|
+
|
|
161
|
+
const duration = Date.now() - startTime;
|
|
162
|
+
|
|
163
|
+
// Update state: locked
|
|
164
|
+
await this.state.updateRound(roundId, {
|
|
165
|
+
status: 'locked',
|
|
166
|
+
locked_at: new Date(),
|
|
167
|
+
lock_signature: signature
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// Log action
|
|
171
|
+
await this.state.logAction(roundId, 'lock', true, null, signature, duration);
|
|
172
|
+
|
|
173
|
+
// Store server seed data for verification later (memory + disk)
|
|
174
|
+
this.currentRoundData = {
|
|
175
|
+
serverSeed: data.serverSeed,
|
|
176
|
+
serverSeedHash: data.serverSeedHash,
|
|
177
|
+
};
|
|
178
|
+
this._saveVrfData();
|
|
179
|
+
|
|
180
|
+
console.log(`ā
Round ${roundId} locked! Sig: ${signature.slice(0, 8)}...`);
|
|
181
|
+
console.log(`š Server seed hash: ${data.serverSeedHash?.slice(0, 16)}...`);
|
|
182
|
+
|
|
183
|
+
// Trigger WebSocket broadcast via webhook
|
|
184
|
+
axios.post(`${API_BASE}/api/keeper-webhook/round-locked`, {
|
|
185
|
+
roundId
|
|
186
|
+
}).catch(err => console.warn('Webhook broadcast failed:', err.message));
|
|
187
|
+
|
|
188
|
+
return true;
|
|
189
|
+
} catch (error) {
|
|
190
|
+
console.error('ā Lock failed:', error.message);
|
|
191
|
+
|
|
192
|
+
// Log failure
|
|
193
|
+
await this.state.incrementRetry(roundId, error.message);
|
|
194
|
+
await this.state.logAction(roundId, 'lock', false, error.message, null, Date.now() - startTime);
|
|
195
|
+
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async revealRandomness(roundId) {
|
|
201
|
+
console.log(`š² Revealing randomness for round ${roundId}...`);
|
|
202
|
+
const startTime = Date.now();
|
|
203
|
+
|
|
204
|
+
try {
|
|
205
|
+
// Update state: revealing
|
|
206
|
+
await this.state.updateRound(roundId, {
|
|
207
|
+
status: 'revealing',
|
|
208
|
+
last_attempt_at: new Date()
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// Generate oracle seed (in production, fetch from Random.org)
|
|
212
|
+
const oracleSeed = crypto.randomBytes(32).toString('hex');
|
|
213
|
+
|
|
214
|
+
const { data } = await axios.post(`${API_BASE}/jackpot/oracle/reveal`, {
|
|
215
|
+
roundId: roundId.toString(),
|
|
216
|
+
oracleSeed,
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
const duration = Date.now() - startTime;
|
|
220
|
+
|
|
221
|
+
// Update state: revealed
|
|
222
|
+
await this.state.updateRound(roundId, {
|
|
223
|
+
status: 'revealed',
|
|
224
|
+
revealed_at: new Date(),
|
|
225
|
+
reveal_signature: data.signature
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
// Log action
|
|
229
|
+
await this.state.logAction(roundId, 'reveal', true, null, data.signature, duration);
|
|
230
|
+
|
|
231
|
+
console.log(`ā
Randomness revealed! Sig: ${data.signature.slice(0, 8)}...`);
|
|
232
|
+
console.log(`š Oracle seed: ${oracleSeed.slice(0, 16)}...`);
|
|
233
|
+
|
|
234
|
+
// Store oracle seed for verification (memory + disk)
|
|
235
|
+
if (this.currentRoundData) {
|
|
236
|
+
this.currentRoundData.oracleSeed = oracleSeed;
|
|
237
|
+
} else {
|
|
238
|
+
// Recover from disk if memory was lost
|
|
239
|
+
this.currentRoundData = this._loadVrfData() || {};
|
|
240
|
+
this.currentRoundData.oracleSeed = oracleSeed;
|
|
241
|
+
}
|
|
242
|
+
this._saveVrfData();
|
|
243
|
+
|
|
244
|
+
// VRF is written INSTANTLY when transaction confirms - no need to poll!
|
|
245
|
+
console.log('ā
VRF result written on-chain! Ready to resolve immediately!');
|
|
246
|
+
|
|
247
|
+
return true;
|
|
248
|
+
} catch (error) {
|
|
249
|
+
// Error 6014 = RandomnessAlreadyConsumed - this is OK, skip to resolve
|
|
250
|
+
if (error.message && error.message.includes('6014')) {
|
|
251
|
+
console.log('ā¹ļø Randomness already revealed, proceeding to resolve');
|
|
252
|
+
return true;
|
|
253
|
+
}
|
|
254
|
+
console.error('ā Reveal failed:', error.message);
|
|
255
|
+
|
|
256
|
+
// Log failure
|
|
257
|
+
await this.state.incrementRetry(roundId, error.message);
|
|
258
|
+
await this.state.logAction(roundId, 'reveal', false, error.message, null, Date.now() - startTime);
|
|
259
|
+
|
|
260
|
+
return false;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async waitForVrfResult() {
|
|
265
|
+
const [roundPda] = PublicKey.findProgramAddressSync(
|
|
266
|
+
[Buffer.from('round'), Buffer.alloc(8, 1)],
|
|
267
|
+
this.programId
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
// Poll for up to 20 seconds
|
|
271
|
+
for (let i = 0; i < 40; i++) {
|
|
272
|
+
try {
|
|
273
|
+
const accountInfo = await this.connection.getAccountInfo(roundPda);
|
|
274
|
+
if (accountInfo) {
|
|
275
|
+
// Check if VRF result exists (Option<u128> discriminant at offset 51)
|
|
276
|
+
const hasVrf = accountInfo.data[51] === 1;
|
|
277
|
+
if (hasVrf) {
|
|
278
|
+
return true;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
} catch (e) {
|
|
282
|
+
// Ignore errors, keep polling
|
|
283
|
+
}
|
|
284
|
+
await new Promise(resolve => setTimeout(resolve, 500)); // Check every 0.5s
|
|
285
|
+
}
|
|
286
|
+
return false;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async resolveRound(roundId) {
|
|
290
|
+
console.log(`š° Resolving round ${roundId}...`);
|
|
291
|
+
const startTime = Date.now();
|
|
292
|
+
|
|
293
|
+
try {
|
|
294
|
+
// Update state: resolving
|
|
295
|
+
await this.state.updateRound(roundId, {
|
|
296
|
+
status: 'resolving',
|
|
297
|
+
last_attempt_at: new Date()
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
// ALWAYS use Round 1 PDAs (account reuse!)
|
|
301
|
+
const [roundPda] = PublicKey.findProgramAddressSync([Buffer.from('round'), Buffer.from([1,0,0,0,0,0,0,0])], this.programId);
|
|
302
|
+
const [entriesPda] = PublicKey.findProgramAddressSync([Buffer.from('entries'), Buffer.from([1,0,0,0,0,0,0,0])], this.programId);
|
|
303
|
+
|
|
304
|
+
const roundAccountBefore = await this.connection.getAccountInfo(roundPda);
|
|
305
|
+
const totalPot = roundAccountBefore.data.readBigUInt64LE(33);
|
|
306
|
+
|
|
307
|
+
// Get entries
|
|
308
|
+
const entriesAccount = await this.connection.getAccountInfo(entriesPda);
|
|
309
|
+
const entryCount = entriesAccount.data.readUInt32LE(16);
|
|
310
|
+
|
|
311
|
+
const { data } = await axios.post(`${API_BASE}/jackpot/build/resolve`, {
|
|
312
|
+
keeperAddress: this.wallet.publicKey.toString(),
|
|
313
|
+
roundId,
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
const tx = Transaction.from(Buffer.from(data.transaction, 'base64'));
|
|
317
|
+
tx.recentBlockhash = (await this.connection.getLatestBlockhash()).blockhash;
|
|
318
|
+
tx.feePayer = this.wallet.publicKey;
|
|
319
|
+
tx.sign(this.wallet);
|
|
320
|
+
|
|
321
|
+
const signature = await this.connection.sendRawTransaction(tx.serialize(), {
|
|
322
|
+
skipPreflight: true,
|
|
323
|
+
});
|
|
324
|
+
await this.confirmTx(signature);
|
|
325
|
+
|
|
326
|
+
const winner = data.winner;
|
|
327
|
+
const winAmount = Number(totalPot) * 0.95; // 95% after 5% fee
|
|
328
|
+
|
|
329
|
+
const duration = Date.now() - startTime;
|
|
330
|
+
|
|
331
|
+
// Update state: resolved
|
|
332
|
+
await this.state.updateRound(roundId, {
|
|
333
|
+
status: 'resolved',
|
|
334
|
+
resolved_at: new Date(),
|
|
335
|
+
resolve_signature: signature,
|
|
336
|
+
winner_pubkey: winner,
|
|
337
|
+
win_amount: winAmount.toString(),
|
|
338
|
+
total_pot: totalPot.toString(),
|
|
339
|
+
entry_count: entryCount
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
// Log action
|
|
343
|
+
await this.state.logAction(roundId, 'resolve', true, null, signature, duration);
|
|
344
|
+
|
|
345
|
+
console.log(`š Round ${roundId} resolved!`);
|
|
346
|
+
console.log(` Winner: ${winner.slice(0, 8)}...`);
|
|
347
|
+
console.log(` Prize: ${(winAmount / 1e9).toFixed(4)} SOL`);
|
|
348
|
+
console.log(` Sig: ${signature.slice(0, 8)}...`);
|
|
349
|
+
|
|
350
|
+
// Recover VRF data from disk if memory was lost (e.g. keeper restarted)
|
|
351
|
+
if (!this.currentRoundData) {
|
|
352
|
+
this.currentRoundData = this._loadVrfData();
|
|
353
|
+
if (this.currentRoundData) {
|
|
354
|
+
console.log('š Recovered VRF data from disk');
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Capture VRF data before clearing
|
|
359
|
+
const vrfServerSeed = this.currentRoundData?.serverSeed || null;
|
|
360
|
+
const vrfServerSeedHash = this.currentRoundData?.serverSeedHash || null;
|
|
361
|
+
const vrfOracleSeed = this.currentRoundData?.oracleSeed || null;
|
|
362
|
+
|
|
363
|
+
// Clear current round data (memory + disk)
|
|
364
|
+
this.currentRoundData = null;
|
|
365
|
+
this._clearVrfData();
|
|
366
|
+
|
|
367
|
+
// Increment success counter
|
|
368
|
+
this.roundsCompleted++;
|
|
369
|
+
this.consecutiveFailures = 0;
|
|
370
|
+
|
|
371
|
+
// Trigger WebSocket broadcast + save history via webhook
|
|
372
|
+
// The SERVER process has DATABASE_URL, so its addRound writes to PostgreSQL
|
|
373
|
+
axios.post(`${API_BASE}/api/keeper-webhook/winner-selected`, {
|
|
374
|
+
roundId,
|
|
375
|
+
winner,
|
|
376
|
+
winAmount: winAmount.toString(),
|
|
377
|
+
totalPot: totalPot.toString(),
|
|
378
|
+
entryCount,
|
|
379
|
+
signature,
|
|
380
|
+
// Provably fair verification data
|
|
381
|
+
serverSeed: vrfServerSeed,
|
|
382
|
+
serverSeedHash: vrfServerSeedHash,
|
|
383
|
+
oracleSeed: vrfOracleSeed,
|
|
384
|
+
}).then(() => {
|
|
385
|
+
console.log(`ā” Webhook called: winner_selected (${winner.slice(0,8)}...)`);
|
|
386
|
+
}).catch(err => console.warn('Webhook broadcast failed:', err.message));
|
|
387
|
+
|
|
388
|
+
return true;
|
|
389
|
+
} catch (error) {
|
|
390
|
+
console.error('ā Resolve failed:', error.message);
|
|
391
|
+
|
|
392
|
+
// Log failure
|
|
393
|
+
await this.state.incrementRetry(roundId, error.message);
|
|
394
|
+
await this.state.logAction(roundId, 'resolve', false, error.message, null, Date.now() - startTime);
|
|
395
|
+
|
|
396
|
+
this.consecutiveFailures++;
|
|
397
|
+
|
|
398
|
+
return false;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
async openOrResetRound(previousRoundId = null) {
|
|
403
|
+
console.log(`š Opening/resetting round...`);
|
|
404
|
+
const startTime = Date.now();
|
|
405
|
+
|
|
406
|
+
try {
|
|
407
|
+
// Check if round 1 accounts exist - if so, use reset (account reuse like Solpot!)
|
|
408
|
+
const roundIdBuf = Buffer.alloc(8);
|
|
409
|
+
roundIdBuf.writeBigUInt64LE(1n);
|
|
410
|
+
const [round1Pda] = PublicKey.findProgramAddressSync([Buffer.from('round'), roundIdBuf], this.programId);
|
|
411
|
+
|
|
412
|
+
const round1Exists = await this.connection.getAccountInfo(round1Pda);
|
|
413
|
+
|
|
414
|
+
if (round1Exists) {
|
|
415
|
+
// RESET (reuse accounts - FREE!)
|
|
416
|
+
console.log(`ā»ļø Resetting round (reusing accounts - Solpot style)...`);
|
|
417
|
+
|
|
418
|
+
// Update state: resetting (if we have previous round ID)
|
|
419
|
+
if (previousRoundId) {
|
|
420
|
+
await this.state.updateRound(previousRoundId, {
|
|
421
|
+
status: 'resetting',
|
|
422
|
+
last_attempt_at: new Date()
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const { data } = await axios.post(`${API_BASE}/jackpot/build/reset-round`, {
|
|
427
|
+
keeperAddress: this.wallet.publicKey.toString(),
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
const tx = Transaction.from(Buffer.from(data.transaction, 'base64'));
|
|
431
|
+
tx.recentBlockhash = (await this.connection.getLatestBlockhash()).blockhash;
|
|
432
|
+
tx.feePayer = this.wallet.publicKey;
|
|
433
|
+
tx.sign(this.wallet);
|
|
434
|
+
|
|
435
|
+
const signature = await this.connection.sendRawTransaction(tx.serialize(), {
|
|
436
|
+
skipPreflight: true,
|
|
437
|
+
});
|
|
438
|
+
await this.confirmTx(signature);
|
|
439
|
+
|
|
440
|
+
const duration = Date.now() - startTime;
|
|
441
|
+
const newRoundId = data.roundId;
|
|
442
|
+
|
|
443
|
+
// Update previous round: reset complete
|
|
444
|
+
if (previousRoundId) {
|
|
445
|
+
await this.state.updateRound(previousRoundId, {
|
|
446
|
+
reset_at: new Date(),
|
|
447
|
+
reset_signature: signature
|
|
448
|
+
});
|
|
449
|
+
await this.state.logAction(previousRoundId, 'reset', true, null, signature, duration);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Create new round in DB
|
|
453
|
+
await this.state.createRound(newRoundId, 'open');
|
|
454
|
+
|
|
455
|
+
// Trigger WebSocket broadcast via webhook
|
|
456
|
+
axios.post(`${API_BASE}/api/keeper-webhook/round-opened`, {
|
|
457
|
+
roundId: newRoundId
|
|
458
|
+
}).catch(err => console.warn('Webhook broadcast failed:', err.message));
|
|
459
|
+
|
|
460
|
+
console.log(`ā»ļø Round ${newRoundId} opened! (Cost: ~$0.01 gas only!) Sig: ${signature.slice(0, 8)}...`);
|
|
461
|
+
|
|
462
|
+
// Wait for RPC to sync (prevents stale data loop)
|
|
463
|
+
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
464
|
+
|
|
465
|
+
return true;
|
|
466
|
+
|
|
467
|
+
} else {
|
|
468
|
+
// OPEN (first time only - pay rent)
|
|
469
|
+
console.log(`š Opening FIRST round (one-time rent cost)...`);
|
|
470
|
+
const { data } = await axios.post(`${API_BASE}/jackpot/build/open-round`, {
|
|
471
|
+
keeperAddress: this.wallet.publicKey.toString(),
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
const tx = Transaction.from(Buffer.from(data.transaction, 'base64'));
|
|
475
|
+
tx.recentBlockhash = (await this.connection.getLatestBlockhash()).blockhash;
|
|
476
|
+
tx.feePayer = this.wallet.publicKey;
|
|
477
|
+
tx.sign(this.wallet);
|
|
478
|
+
|
|
479
|
+
const signature = await this.connection.sendRawTransaction(tx.serialize(), {
|
|
480
|
+
skipPreflight: true,
|
|
481
|
+
});
|
|
482
|
+
await this.confirmTx(signature);
|
|
483
|
+
|
|
484
|
+
const duration = Date.now() - startTime;
|
|
485
|
+
const newRoundId = data.roundId;
|
|
486
|
+
|
|
487
|
+
// Create new round in DB
|
|
488
|
+
await this.state.createRound(newRoundId, 'open');
|
|
489
|
+
await this.state.logAction(newRoundId, 'open', true, null, signature, duration);
|
|
490
|
+
|
|
491
|
+
console.log(`š Round ${newRoundId} opened! (Paid rent once) Sig: ${signature.slice(0, 8)}...`);
|
|
492
|
+
return true;
|
|
493
|
+
}
|
|
494
|
+
} catch (error) {
|
|
495
|
+
console.error('ā Open/reset failed:', error.message || error);
|
|
496
|
+
if (error.response) {
|
|
497
|
+
console.error('API Error:', error.response.data);
|
|
498
|
+
}
|
|
499
|
+
if (error.logs) {
|
|
500
|
+
console.error('Transaction logs:', error.logs);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// Log failure
|
|
504
|
+
if (previousRoundId) {
|
|
505
|
+
await this.state.logAction(previousRoundId, 'reset', false, error.message, null, Date.now() - startTime);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
return false;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
async processRound(round) {
|
|
513
|
+
const roundId = round.roundId;
|
|
514
|
+
const entryCount = round.entryCount || 0;
|
|
515
|
+
|
|
516
|
+
// Ensure round exists in database
|
|
517
|
+
let dbRound = await this.state.getRound(roundId);
|
|
518
|
+
if (!dbRound) {
|
|
519
|
+
await this.state.createRound(roundId, round.status.toLowerCase());
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
if (round.status === 'Open') {
|
|
523
|
+
// Check if time expired
|
|
524
|
+
if (round.timeRemainingSlots <= 0) {
|
|
525
|
+
console.log(`\nā° Round ${roundId} timer expired! (${entryCount} entries)`);
|
|
526
|
+
|
|
527
|
+
// Skip if no entries - just reset/open new round
|
|
528
|
+
if (entryCount === 0) {
|
|
529
|
+
console.log(`ā ļø Round ${roundId} has no entries, resetting...`);
|
|
530
|
+
await this.openOrResetRound(roundId);
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// Lock ā Reveal Randomness ā Resolve ā Open (3-step VRF flow)
|
|
535
|
+
if (await this.lockRound(roundId)) {
|
|
536
|
+
if (await this.revealRandomness(roundId)) {
|
|
537
|
+
if (await this.resolveRound(roundId)) {
|
|
538
|
+
// Wait 5s for carousel animation + user to see winner
|
|
539
|
+
console.log('ā³ Waiting 5s for winner animation...');
|
|
540
|
+
await new Promise(resolve => setTimeout(resolve, 5000));
|
|
541
|
+
await this.openOrResetRound(roundId);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
} else {
|
|
546
|
+
const secsLeft = Math.floor(round.timeRemainingSlots * 0.4);
|
|
547
|
+
console.log(`ā³ Round ${roundId} - ${secsLeft}s left, ${entryCount} entries, pot: ${(Number(round.totalPotLamports)/1e9).toFixed(3)} SOL`);
|
|
548
|
+
|
|
549
|
+
// CRITICAL: Broadcast timer update to all clients every check
|
|
550
|
+
axios.post(`${API_BASE}/api/keeper-webhook/timer-update`, {
|
|
551
|
+
roundId,
|
|
552
|
+
timeRemaining: secsLeft,
|
|
553
|
+
entryCount,
|
|
554
|
+
totalPot: round.totalPotLamports,
|
|
555
|
+
status: round.status
|
|
556
|
+
}).catch(err => console.warn('Timer broadcast failed:', err.message));
|
|
557
|
+
|
|
558
|
+
// Also track entry count changes for logging
|
|
559
|
+
if (this.lastEntryCount[roundId] !== entryCount) {
|
|
560
|
+
this.lastEntryCount[roundId] = entryCount;
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
} else if (round.status === 'Locked') {
|
|
564
|
+
// Check if we already resolved this round (prevent loops)
|
|
565
|
+
if (this.lastResolvedRound === roundId) {
|
|
566
|
+
console.log(`ā ļø Already resolved round ${roundId}, forcing reset...`);
|
|
567
|
+
await this.openOrResetRound();
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
console.log(`š Round ${roundId} locked, paying out...`);
|
|
572
|
+
|
|
573
|
+
// Track consecutive failures for this locked round
|
|
574
|
+
this.lockedRoundFailures = this.lockedRoundFailures || {};
|
|
575
|
+
this.lockedRoundFailures[roundId] = this.lockedRoundFailures[roundId] || 0;
|
|
576
|
+
|
|
577
|
+
// Try to resolve once
|
|
578
|
+
if (await this.resolveRound(roundId)) {
|
|
579
|
+
this.lastResolvedRound = roundId; // Track it
|
|
580
|
+
this.lockedRoundFailures[roundId] = 0; // Reset counter
|
|
581
|
+
await new Promise(resolve => setTimeout(resolve, 5000)); // Same 5s delay
|
|
582
|
+
await this.openOrResetRound();
|
|
583
|
+
} else {
|
|
584
|
+
this.lockedRoundFailures[roundId]++;
|
|
585
|
+
console.log(`ā Resolve failed (attempt ${this.lockedRoundFailures[roundId]}), will retry next cycle`);
|
|
586
|
+
|
|
587
|
+
// After 5 consecutive failures, try to force reset (round is stuck without VRF)
|
|
588
|
+
if (this.lockedRoundFailures[roundId] >= 5) {
|
|
589
|
+
console.log(`šØ Round ${roundId} stuck after 5 attempts! VRF may not have been consumed.`);
|
|
590
|
+
console.log(`ā»ļø Forcing reset to start fresh round...`);
|
|
591
|
+
this.lockedRoundFailures[roundId] = 0;
|
|
592
|
+
await this.openOrResetRound(roundId);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
} else if (round.status === 'Resolved') {
|
|
596
|
+
console.log(`ā
Round ${roundId} resolved, resetting...`);
|
|
597
|
+
await this.openOrResetRound();
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
async recoverStuckRounds() {
|
|
602
|
+
console.log('š§ Checking for stuck rounds...');
|
|
603
|
+
|
|
604
|
+
try {
|
|
605
|
+
const stuckRounds = await this.state.getStuckRounds();
|
|
606
|
+
|
|
607
|
+
if (stuckRounds.length === 0) {
|
|
608
|
+
console.log('ā
No stuck rounds found');
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
console.log(`ā ļø Found ${stuckRounds.length} stuck round(s)!`);
|
|
613
|
+
|
|
614
|
+
for (const stuck of stuckRounds) {
|
|
615
|
+
console.log(`\nš§ Recovering round ${stuck.round_id} stuck in '${stuck.status}' for ${Math.floor(stuck.seconds_stuck)}s`);
|
|
616
|
+
console.log(` Retries so far: ${stuck.retry_count}`);
|
|
617
|
+
console.log(` Last error: ${stuck.last_error || 'none'}`);
|
|
618
|
+
|
|
619
|
+
// Get current on-chain state
|
|
620
|
+
const round = await this.getCurrentRound();
|
|
621
|
+
|
|
622
|
+
if (!round || round.roundId !== stuck.round_id) {
|
|
623
|
+
console.log(` Round ${stuck.round_id} no longer active on-chain, marking resolved in DB`);
|
|
624
|
+
await this.state.updateRound(stuck.round_id, { status: 'resolved', resolved_at: new Date() });
|
|
625
|
+
continue;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// Resume based on current state
|
|
629
|
+
console.log(` On-chain status: ${round.status}, DB status: ${stuck.status}`);
|
|
630
|
+
|
|
631
|
+
if (stuck.status === 'locking' || stuck.status === 'locked') {
|
|
632
|
+
if (round.status === 'Locked') {
|
|
633
|
+
console.log(` ā Lock completed, moving to reveal`);
|
|
634
|
+
await this.state.updateRound(stuck.round_id, { status: 'locked' });
|
|
635
|
+
await this.revealRandomness(round.roundId);
|
|
636
|
+
} else {
|
|
637
|
+
console.log(` Retrying lock...`);
|
|
638
|
+
await this.lockRound(round.roundId);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
else if (stuck.status === 'revealing' || stuck.status === 'revealed') {
|
|
643
|
+
if (round.status === 'Locked') {
|
|
644
|
+
console.log(` Retrying reveal...`);
|
|
645
|
+
await this.revealRandomness(round.roundId);
|
|
646
|
+
} else {
|
|
647
|
+
console.log(` Status mismatch, skipping to resolve`);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
else if (stuck.status === 'resolving') {
|
|
652
|
+
console.log(` Retrying resolve...`);
|
|
653
|
+
await this.resolveRound(round.roundId);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
else if (stuck.status === 'resetting') {
|
|
657
|
+
console.log(` Retrying reset...`);
|
|
658
|
+
await this.openOrResetRound(stuck.round_id);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
console.log('\nā
Recovery complete\n');
|
|
663
|
+
|
|
664
|
+
} catch (error) {
|
|
665
|
+
console.error('ā Recovery failed:', error.message);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
async recordHealthMetrics() {
|
|
670
|
+
const uptime = Math.floor((Date.now() - this.startTime) / 1000);
|
|
671
|
+
|
|
672
|
+
await this.state.recordHealth({
|
|
673
|
+
roundsCompleted: this.roundsCompleted,
|
|
674
|
+
consecutiveFailures: this.consecutiveFailures,
|
|
675
|
+
lastSuccessRound: this.lastResolvedRound || 0,
|
|
676
|
+
lastError: this.lastError || null,
|
|
677
|
+
uptimeSeconds: uptime
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
async run() {
|
|
682
|
+
console.log('š Keeper bot started!\n');
|
|
683
|
+
|
|
684
|
+
// Run recovery on startup
|
|
685
|
+
await this.recoverStuckRounds();
|
|
686
|
+
|
|
687
|
+
// Check if round exists, if not open/reset one
|
|
688
|
+
let round = await this.getCurrentRound();
|
|
689
|
+
if (!round) {
|
|
690
|
+
console.log('No active round, opening/resetting...');
|
|
691
|
+
await this.openOrResetRound();
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
let healthCheckCounter = 0;
|
|
695
|
+
let lastHealthTime = Date.now();
|
|
696
|
+
let lastRecoveryTime = Date.now();
|
|
697
|
+
|
|
698
|
+
// Main loop
|
|
699
|
+
while (true) {
|
|
700
|
+
try {
|
|
701
|
+
round = await this.getCurrentRound();
|
|
702
|
+
|
|
703
|
+
if (round) {
|
|
704
|
+
await this.processRound(round);
|
|
705
|
+
} else {
|
|
706
|
+
console.log('No round found, opening/resetting...');
|
|
707
|
+
await this.openOrResetRound();
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
// Health check every ~60 seconds
|
|
711
|
+
if (Date.now() - lastHealthTime >= 60000) {
|
|
712
|
+
await this.recordHealthMetrics();
|
|
713
|
+
lastHealthTime = Date.now();
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// Check for stuck rounds every ~5 minutes
|
|
717
|
+
if (Date.now() - lastRecoveryTime >= 300000) {
|
|
718
|
+
await this.recoverStuckRounds();
|
|
719
|
+
lastRecoveryTime = Date.now();
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// Adaptive poll interval based on time remaining
|
|
723
|
+
const secsLeft = round ? Math.floor(round.timeRemainingSlots * 0.4) : 0;
|
|
724
|
+
const pollMs = secsLeft > 86400 ? 300000 // > 1 day ā 5 minutes
|
|
725
|
+
: secsLeft > 3600 ? 60000 // > 1 hour ā 60 seconds
|
|
726
|
+
: secsLeft > 300 ? 5000 // > 5 min ā 5 seconds
|
|
727
|
+
: 2000; // ⤠5 min ā 2 seconds (action imminent)
|
|
728
|
+
|
|
729
|
+
await new Promise(resolve => setTimeout(resolve, pollMs));
|
|
730
|
+
|
|
731
|
+
} catch (error) {
|
|
732
|
+
console.error('ā Error:', error.message);
|
|
733
|
+
this.lastError = error.message;
|
|
734
|
+
await new Promise(resolve => setTimeout(resolve, 10000));
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
const keeper = new JackpotKeeper();
|
|
741
|
+
keeper.run().catch(console.error);
|
|
742
|
+
|