diff --git a/.github/workflows/daily-balance-snapshots.yml b/.github/workflows/daily-balance-snapshots.yml index 473637a4..b91fc47f 100644 --- a/.github/workflows/daily-balance-snapshots.yml +++ b/.github/workflows/daily-balance-snapshots.yml @@ -40,9 +40,10 @@ jobs: env: API_BASE_URL: "https://multisig.meshjs.dev" SNAPSHOT_AUTH_TOKEN: ${{ secrets.SNAPSHOT_AUTH_TOKEN }} - BATCH_SIZE: 10 + BATCH_SIZE: 5 DELAY_BETWEEN_BATCHES: 10 MAX_RETRIES: 3 + REQUEST_TIMEOUT: 45 - name: Notify on failure if: failure() diff --git a/scripts/batch-snapshot-orchestrator.ts b/scripts/batch-snapshot-orchestrator.ts index 7989b58c..5a5c83c9 100644 --- a/scripts/batch-snapshot-orchestrator.ts +++ b/scripts/batch-snapshot-orchestrator.ts @@ -14,9 +14,10 @@ * Environment Variables: * - API_BASE_URL: Base URL for the API (default: http://localhost:3000) * - SNAPSHOT_AUTH_TOKEN: Authentication token for API requests - * - BATCH_SIZE: Number of wallets per batch (default: 10) + * - BATCH_SIZE: Number of wallets per batch (default: 5) * - DELAY_BETWEEN_BATCHES: Delay between batches in seconds (default: 10) * - MAX_RETRIES: Maximum retries for failed batches (default: 3) + * - REQUEST_TIMEOUT: Request timeout in seconds (default: 45) */ interface BatchProgress { @@ -35,6 +36,24 @@ interface BatchProgress { walletId: string; errorType: string; errorMessage: string; + walletStructure?: { + name: string; + type: string; + numRequiredSigners: number; + signersCount: number; + hasStakeCredential: boolean; + hasScriptCbor: boolean; + isArchived: boolean; + verified: number; + hasDRepKeys: boolean; + // Character counts for key fields + scriptCborLength: number; + stakeCredentialLength: number; + signersAddressesLength: number; + signersStakeKeysLength: number; + signersDRepKeysLength: number; + signersDescriptionsLength: number; + }; }>; } @@ -63,6 +82,24 @@ interface BatchResults { errorType: string; errorMessage: string; batchNumber: number; + walletStructure?: { + name: string; + type: string; + numRequiredSigners: number; + signersCount: number; + hasStakeCredential: boolean; + hasScriptCbor: boolean; + isArchived: boolean; + verified: number; + hasDRepKeys: boolean; + // Character counts for key fields + scriptCborLength: number; + stakeCredentialLength: number; + signersAddressesLength: number; + signersStakeKeysLength: number; + signersDRepKeysLength: number; + signersDescriptionsLength: number; + }; }>; failureSummary: Record; } @@ -73,6 +110,7 @@ interface BatchConfig { batchSize: number; delayBetweenBatches: number; maxRetries: number; + requestTimeout: number; // in seconds } interface ApiResponse { @@ -113,20 +151,52 @@ class BatchSnapshotOrchestrator { throw new Error('SNAPSHOT_AUTH_TOKEN environment variable is required'); } + if (authToken.trim().length === 0) { + throw new Error('SNAPSHOT_AUTH_TOKEN environment variable cannot be empty'); + } + + // Validate API base URL format + try { + new URL(apiBaseUrl); + } catch (error) { + throw new Error(`Invalid API_BASE_URL format: ${apiBaseUrl}`); + } + + // Parse and validate numeric environment variables + const batchSize = this.parseAndValidateNumber(process.env.BATCH_SIZE || '5', 'BATCH_SIZE', 1, 10); + const delayBetweenBatches = this.parseAndValidateNumber(process.env.DELAY_BETWEEN_BATCHES || '10', 'DELAY_BETWEEN_BATCHES', 1, 300); + const maxRetries = this.parseAndValidateNumber(process.env.MAX_RETRIES || '3', 'MAX_RETRIES', 1, 10); + const requestTimeout = this.parseAndValidateNumber(process.env.REQUEST_TIMEOUT || '45', 'REQUEST_TIMEOUT', 10, 300); + return { apiBaseUrl, authToken, - batchSize: parseInt(process.env.BATCH_SIZE || '10'), - delayBetweenBatches: parseInt(process.env.DELAY_BETWEEN_BATCHES || '10'), - maxRetries: parseInt(process.env.MAX_RETRIES || '3'), + batchSize, + delayBetweenBatches, + maxRetries, + requestTimeout, }; } + private parseAndValidateNumber(value: string, name: string, min: number, max: number): number { + const parsed = parseInt(value, 10); + + if (isNaN(parsed)) { + throw new Error(`${name} must be a valid integer, got: ${value}`); + } + + if (parsed < min || parsed > max) { + throw new Error(`${name} must be between ${min} and ${max}, got: ${parsed}`); + } + + return parsed; + } + private async makeRequest(url: string, options: RequestInit = {}): Promise> { try { - // Add timeout to prevent hanging requests + // Add configurable timeout to prevent hanging requests const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 30000); // 30 second timeout + const timeoutId = setTimeout(() => controller.abort(), this.config.requestTimeout * 1000); const response = await fetch(url, { ...options, @@ -148,7 +218,7 @@ class BatchSnapshotOrchestrator { return { data, status: response.status }; } catch (error) { if (error instanceof Error && error.name === 'AbortError') { - throw new Error('Request timeout after 30 seconds'); + throw new Error(`Request timeout after ${this.config.requestTimeout} seconds`); } throw error; } @@ -172,6 +242,15 @@ class BatchSnapshotOrchestrator { private async processBatch(batchNumber: number, batchId: string): Promise { console.log(`📦 Processing batch ${batchNumber}...`); + // Validate inputs + if (!Number.isInteger(batchNumber) || batchNumber < 1) { + throw new Error(`Invalid batchNumber: ${batchNumber}. Must be a positive integer.`); + } + + if (!batchId || typeof batchId !== 'string' || batchId.trim().length === 0) { + throw new Error(`Invalid batchId: ${batchId}. Must be a non-empty string.`); + } + for (let attempt = 1; attempt <= this.config.maxRetries; attempt++) { try { const url = new URL(`${this.config.apiBaseUrl}/api/v1/stats/run-snapshots-batch`); @@ -196,6 +275,21 @@ class BatchSnapshotOrchestrator { console.log(` ❌ Failures in this batch:`); data.progress.failures.forEach((failure, index) => { console.log(` ${index + 1}. ${failure.walletId}... - ${failure.errorMessage}`); + if (failure.walletStructure) { + const structure = failure.walletStructure; + console.log(` 📋 Wallet Structure:`); + console.log(` • Name: ${structure.name} (${structure.name.length} chars)`); + console.log(` • Type: ${structure.type} (${structure.type.length} chars)`); + console.log(` • Required Signers: ${structure.numRequiredSigners}/${structure.signersCount}`); + console.log(` • Has Stake Credential: ${structure.hasStakeCredential} (${structure.stakeCredentialLength} chars)`); + console.log(` • Has Script CBOR: ${structure.hasScriptCbor} (${structure.scriptCborLength} chars)`); + console.log(` • Is Archived: ${structure.isArchived}`); + console.log(` • Verified Count: ${structure.verified}`); + console.log(` • Has DRep Keys: ${structure.hasDRepKeys} (${structure.signersDRepKeysLength} items)`); + console.log(` • Signers Addresses: ${structure.signersAddressesLength} items`); + console.log(` • Signers Stake Keys: ${structure.signersStakeKeysLength} items`); + console.log(` • Signers Descriptions: ${structure.signersDescriptionsLength} items`); + } }); } @@ -214,8 +308,10 @@ class BatchSnapshotOrchestrator { return null; } - // Wait before retry - await this.delay(this.config.delayBetweenBatches); + // For 405 errors (Method Not Allowed), wait longer as it might be a server-side issue + const waitTime = errorMessage.includes('405') ? this.config.delayBetweenBatches * 2 : this.config.delayBetweenBatches; + console.log(` ⏳ Waiting ${waitTime}s before retry...`); + await this.delay(waitTime); } } @@ -253,8 +349,11 @@ class BatchSnapshotOrchestrator { // Accumulate failures firstBatch.failures.forEach(failure => { this.results.allFailures.push({ - ...failure, - batchNumber: 1 + walletId: failure.walletId, + errorType: failure.errorType, + errorMessage: failure.errorMessage, + batchNumber: 1, + walletStructure: failure.walletStructure }); this.results.failureSummary[failure.errorType] = (this.results.failureSummary[failure.errorType] || 0) + 1; }); @@ -284,8 +383,11 @@ class BatchSnapshotOrchestrator { // Accumulate failures batchProgress.failures.forEach(failure => { this.results.allFailures.push({ - ...failure, - batchNumber + walletId: failure.walletId, + errorType: failure.errorType, + errorMessage: failure.errorMessage, + batchNumber, + walletStructure: failure.walletStructure }); this.results.failureSummary[failure.errorType] = (this.results.failureSummary[failure.errorType] || 0) + 1; }); diff --git a/src/pages/api/v1/stats/README.md b/src/pages/api/v1/stats/README.md index 7d06ed8e..b5352195 100644 --- a/src/pages/api/v1/stats/README.md +++ b/src/pages/api/v1/stats/README.md @@ -10,6 +10,7 @@ The batch processing system addresses timeout issues when processing large numbe - **Improves**: Reliability, monitoring, and error handling - **Adds**: Comprehensive progress tracking and TVL reporting - **Consolidates**: All snapshot functionality into a single, efficient endpoint +- **Enhances**: Type safety, input validation, and configurable timeouts ## Authentication @@ -32,6 +33,10 @@ The endpoint requires authentication using the `SNAPSHOT_AUTH_TOKEN` environment "batchSize": number } ``` +- **Query Parameters**: + - `batchId`: Unique identifier for the batch session + - `batchNumber`: Current batch number (1-based, must be ≥ 1) + - `batchSize`: Number of wallets per batch (1-5) - **Response**: ```json { @@ -46,11 +51,33 @@ The endpoint requires authentication using the `SNAPSHOT_AUTH_TOKEN` environment "failedInBatch": number, "totalProcessed": number, "totalFailed": number, - "totalAdaBalance": number, "snapshotsStored": number, "isComplete": boolean, "startedAt": "string", - "lastUpdatedAt": "string" + "lastUpdatedAt": "string", + "mainnetWallets": number, + "testnetWallets": number, + "mainnetAdaBalance": number, + "testnetAdaBalance": number, + "failures": [ + { + "walletId": "string", + "errorType": "string", + "errorMessage": "string", + "walletStructure": { + "name": "string", + "type": "string", + "numRequiredSigners": number, + "signersCount": number, + "hasStakeCredential": boolean, + "hasScriptCbor": boolean, + "isArchived": boolean, + "verified": number, + "hasDRepKeys": boolean, + "hasClarityApiKey": boolean + } + } + ] }, "timestamp": "string" } @@ -61,55 +88,68 @@ The endpoint requires authentication using the `SNAPSHOT_AUTH_TOKEN` environment The new system processes wallets in small batches to avoid timeout issues: ### How It Works -1. **Batch Processing**: Wallets are processed in configurable batches (default: 10 wallets per batch) -2. **Progress Tracking**: Each batch returns detailed progress information +1. **Batch Processing**: Wallets are processed in configurable batches (default: 5 wallets per batch) +2. **Progress Tracking**: Each batch returns detailed progress information including network-specific data 3. **Resumable**: Can restart from any batch number if needed 4. **Fault Tolerant**: Failed batches can be retried individually +5. **Input Validation**: Comprehensive validation for batch parameters +6. **Error Tracking**: Detailed error reporting with wallet structure information ### Orchestrator Script -The `scripts/batch-snapshot-orchestrator.js` script manages the entire process: +The `scripts/batch-snapshot-orchestrator.ts` script manages the entire process: - Automatically processes all batches sequentially - Handles retries for failed batches with exponential backoff - Provides comprehensive progress reporting with emojis and detailed statistics -- Configurable batch size, delays, and retry attempts +- Configurable batch size, delays, retry attempts, and request timeouts - Calculates and reports total TVL (Total Value Locked) across all wallets - Tracks execution time and provides final summary - Exports the `BatchSnapshotOrchestrator` class for programmatic use +- Enhanced error handling with detailed failure analysis ### Configuration - **`API_BASE_URL`**: Base URL for the API (default: http://localhost:3000) - **`SNAPSHOT_AUTH_TOKEN`**: Authentication token for API requests (required) -- **`BATCH_SIZE`**: Wallets per batch (default: 10) -- **`DELAY_BETWEEN_BATCHES`**: Seconds between batches (default: 5) +- **`BATCH_SIZE`**: Wallets per batch (default: 5, range: 1-5) +- **`DELAY_BETWEEN_BATCHES`**: Seconds between batches (default: 10) - **`MAX_RETRIES`**: Retry attempts for failed batches (default: 3) +- **`REQUEST_TIMEOUT`**: Request timeout in seconds (default: 60) ## GitHub Actions Integration The daily balance snapshots workflow (`.github/workflows/daily-balance-snapshots.yml`) uses: -1. **Batch Orchestrator**: Runs `scripts/batch-snapshot-orchestrator.js` -2. **No Timeout Issues**: Each batch completes in under 30 seconds +1. **Batch Orchestrator**: Runs `scripts/batch-snapshot-orchestrator.ts` +2. **No Timeout Issues**: Each batch completes within configurable timeout 3. **Comprehensive Reporting**: Detailed progress and final statistics -4. **Manual Trigger**: Currently configured for manual triggering only (schedule disabled for testing) +4. **Enhanced Configuration**: Configurable batch size, delays, retries, and timeouts +5. **Manual Trigger**: Currently configured for manual triggering only (schedule disabled for testing) **Note**: The workflow is currently set to manual trigger only. To enable daily automatic snapshots, uncomment the schedule section in the workflow file. ## Error Handling - **401 Unauthorized**: Invalid or missing authentication token -- **400 Bad Request**: Missing required parameters +- **400 Bad Request**: Missing required parameters or invalid batch parameters - **405 Method Not Allowed**: Incorrect HTTP method - **500 Internal Server Error**: Server-side processing errors +### Error Types Tracked +- **`wallet_build_failed`**: Unable to build multisig wallet from provided data +- **`utxo_fetch_failed`**: Failed to fetch UTxOs from blockchain +- **`address_generation_failed`**: Failed to generate wallet address +- **`balance_calculation_failed`**: Failed to calculate wallet balance +- **`processing_failed`**: General processing failure + ## Database Schema The snapshots are stored in the `balanceSnapshot` table with the following structure: +- `id`: Unique identifier (auto-generated) - `walletId`: Wallet identifier - `walletName`: Human-readable wallet name - `address`: Wallet address used for balance calculation -- `adaBalance`: ADA balance in ADA units +- `adaBalance`: ADA balance in ADA units (Decimal type) - `assetBalances`: JSON object containing all asset balances - `isArchived`: Whether the wallet is archived -- `createdAt`: Timestamp of snapshot creation +- `snapshotDate`: Timestamp of snapshot creation (auto-generated) ## Testing @@ -120,18 +160,33 @@ You can test the batch processing system by running the orchestrator script dire export SNAPSHOT_AUTH_TOKEN=your_token_here # Run the orchestrator (uses localhost by default) -node scripts/batch-snapshot-orchestrator.js +npx tsx scripts/batch-snapshot-orchestrator.ts # Or with custom configuration API_BASE_URL=https://your-api-url.com \ BATCH_SIZE=5 \ DELAY_BETWEEN_BATCHES=10 \ -MAX_RETRIES=5 \ -node scripts/batch-snapshot-orchestrator.js +MAX_RETRIES=3 \ +REQUEST_TIMEOUT=60 \ +npx tsx scripts/batch-snapshot-orchestrator.ts ``` The orchestrator will: - Process all wallets in configurable batches -- Provide detailed progress reporting -- Handle retries for failed batches -- Show comprehensive final statistics including total TVL \ No newline at end of file +- Provide detailed progress reporting with network-specific data +- Handle retries for failed batches with configurable timeouts +- Show comprehensive final statistics including total TVL +- Track and report detailed failure information + +## Recent Improvements + +### Type Safety & Validation +- **Fixed Decimal Type**: Proper handling of Decimal types in database operations +- **Input Validation**: Comprehensive validation for batch parameters (batch number ≥ 1, batch size 1-100) +- **Error Tracking**: Enhanced error handling with detailed wallet structure information + +### Configuration & Reliability +- **Configurable Timeouts**: Request timeout now configurable via `REQUEST_TIMEOUT` environment variable +- **Enhanced Error Handling**: UTxO fetch failures are now properly tracked and reported +- **Network-Specific Reporting**: Separate tracking for mainnet and testnet wallets and balances +- **Improved Documentation**: Updated documentation to reflect all recent changes \ No newline at end of file diff --git a/src/pages/api/v1/stats/run-snapshots-batch.ts b/src/pages/api/v1/stats/run-snapshots-batch.ts index d2b094e5..7ec9d4a7 100644 --- a/src/pages/api/v1/stats/run-snapshots-batch.ts +++ b/src/pages/api/v1/stats/run-snapshots-batch.ts @@ -8,6 +8,7 @@ import type { UTxO, NativeScript } from "@meshsdk/core"; import { getBalance } from "@/utils/getBalance"; import { addressToNetwork } from "@/utils/multisigSDK"; import type { Wallet as DbWallet } from "@prisma/client"; +import { Decimal } from "@prisma/client/runtime/library"; interface WalletBalance { walletId: string; @@ -23,6 +24,24 @@ interface WalletFailure { walletId: string; errorType: string; // e.g., "wallet_build_failed", "utxo_fetch_failed", "balance_calculation_failed" errorMessage: string; // sanitized error message + walletStructure?: { + name: string; + type: string; + numRequiredSigners: number; + signersCount: number; + hasStakeCredential: boolean; + hasScriptCbor: boolean; + isArchived: boolean; + verified: number; + hasDRepKeys: boolean; + // Character counts for key fields + scriptCborLength: number; + stakeCredentialLength: number; + signersAddressesLength: number; + signersStakeKeysLength: number; + signersDRepKeysLength: number; + signersDescriptionsLength: number; + }; } interface BatchProgress { @@ -54,6 +73,27 @@ interface BatchResponse { timestamp: string; } +function getWalletStructure(wallet: DbWallet): WalletFailure['walletStructure'] { + return { + name: wallet.name ? wallet.name.substring(0, 5) + (wallet.name.length > 5 ? '...' : '') : 'N/A', + type: wallet.type || 'unknown', + numRequiredSigners: wallet.numRequiredSigners || 0, + signersCount: wallet.signersAddresses?.length || 0, + hasStakeCredential: !!wallet.stakeCredentialHash, + hasScriptCbor: !!wallet.scriptCbor, + isArchived: wallet.isArchived || false, + verified: wallet.verified?.length || 0, // verified is String[] in schema + hasDRepKeys: !!(wallet.signersDRepKeys && wallet.signersDRepKeys.length > 0), + // Character counts for key fields + scriptCborLength: wallet.scriptCbor?.length || 0, + stakeCredentialLength: wallet.stakeCredentialHash?.length || 0, + signersAddressesLength: wallet.signersAddresses?.length || 0, + signersStakeKeysLength: wallet.signersStakeKeys?.length || 0, + signersDRepKeysLength: wallet.signersDRepKeys?.length || 0, + signersDescriptionsLength: wallet.signersDescriptions?.length || 0, + }; +} + export default async function handler( req: NextApiRequest, res: NextApiResponse, @@ -79,21 +119,84 @@ export default async function handler( } if (!authToken || authToken !== expectedToken) { - console.warn('Unauthorized request attempt', { - ip: req.headers['x-forwarded-for'] || req.connection.remoteAddress, - userAgent: req.headers['user-agent'], - authTokenProvided: !!authToken, - timestamp: new Date().toISOString() - }); return res.status(401).json({ error: "Unauthorized" }); } const { batchId, batchNumber, batchSize } = req.query; const startTime = new Date().toISOString(); - // Convert string parameters to numbers - const parsedBatchNumber = batchNumber ? parseInt(batchNumber as string, 10) : 1; - const parsedBatchSize = batchSize ? parseInt(batchSize as string, 10) : 10; + // Helper function to create error response + const createErrorResponse = (message: string, batchNumber: number = 0, batchSize: number = 0) => ({ + success: false, + message, + progress: { + batchId: (batchId as string) || 'invalid', + totalBatches: 0, + currentBatch: batchNumber, + walletsInBatch: 0, + processedInBatch: 0, + failedInBatch: 0, + totalProcessed: 0, + totalFailed: 0, + snapshotsStored: 0, + isComplete: false, + startedAt: startTime, + lastUpdatedAt: new Date().toISOString(), + mainnetWallets: 0, + testnetWallets: 0, + mainnetAdaBalance: 0, + testnetAdaBalance: 0, + failures: [], + }, + timestamp: new Date().toISOString(), + }); + + // Validate batchId parameter + if (!batchId || typeof batchId !== 'string' || batchId.trim().length === 0) { + return res.status(400).json(createErrorResponse('batchId parameter is required and must be a non-empty string')); + } + + if (batchId.length > 100) { + return res.status(400).json(createErrorResponse('batchId parameter must be 100 characters or less')); + } + + // Validate batchNumber parameter + if (!batchNumber || typeof batchNumber !== 'string') { + return res.status(400).json(createErrorResponse('batchNumber parameter is required and must be a string')); + } + + const parsedBatchNumber = parseInt(batchNumber, 10); + + if (isNaN(parsedBatchNumber)) { + return res.status(400).json(createErrorResponse('batchNumber must be a valid integer')); + } + + if (parsedBatchNumber < 1) { + return res.status(400).json(createErrorResponse('batchNumber must be greater than 0', parsedBatchNumber)); + } + + if (parsedBatchNumber > 10000) { + return res.status(400).json(createErrorResponse('batchNumber must be 10000 or less', parsedBatchNumber)); + } + + // Validate batchSize parameter + if (!batchSize || typeof batchSize !== 'string') { + return res.status(400).json(createErrorResponse('batchSize parameter is required and must be a string')); + } + + const parsedBatchSize = parseInt(batchSize, 10); + + if (isNaN(parsedBatchSize)) { + return res.status(400).json(createErrorResponse('batchSize must be a valid integer')); + } + + if (parsedBatchSize < 1) { + return res.status(400).json(createErrorResponse('batchSize must be greater than 0', parsedBatchNumber, parsedBatchSize)); + } + + if (parsedBatchSize > 5) { + return res.status(400).json(createErrorResponse('batchSize must be 5 or less', parsedBatchNumber, parsedBatchSize)); + } try { console.log(`🔄 Starting batch ${parsedBatchNumber} of balance snapshots...`); @@ -189,7 +292,8 @@ export default async function handler( failures.push({ walletId: wallet.id.slice(0, 8), errorType: "wallet_build_failed", - errorMessage: "Unable to build multisig wallet from provided data" + errorMessage: "Unable to build multisig wallet from provided data", + walletStructure: getWalletStructure(wallet) }); failedInBatch++; continue; @@ -226,8 +330,18 @@ export default async function handler( paymentUtxos = await blockchainProvider.fetchAddressUTxOs(paymentAddress); stakeableUtxos = await blockchainProvider.fetchAddressUTxOs(stakeableAddress); } catch (utxoError) { - console.error(`Failed to fetch UTxOs for wallet ${wallet.id.slice(0, 8)}...:`, utxoError); - // Continue with empty UTxOs + const errorMessage = utxoError instanceof Error ? utxoError.message : 'Unknown UTxO fetch error'; + console.error(`Failed to fetch UTxOs for wallet ${wallet.id.slice(0, 8)}...:`, errorMessage); + + // Track UTxO fetch failures + failures.push({ + walletId: wallet.id.slice(0, 8), + errorType: "utxo_fetch_failed", + errorMessage: "Failed to fetch UTxOs from blockchain", + walletStructure: getWalletStructure(wallet) + }); + failedInBatch++; + continue; } const paymentAddrEmpty = paymentUtxos.length === 0; @@ -248,8 +362,18 @@ export default async function handler( utxos = await fallbackProvider.fetchAddressUTxOs(walletAddress); console.log(`Successfully fetched ${utxos.length} UTxOs for wallet ${wallet.id.slice(0, 8)}... on fallback network ${fallbackNetwork}`); } catch (fallbackError) { - console.error(`Failed to fetch UTxOs for wallet ${wallet.id.slice(0, 8)}... on fallback network ${fallbackNetwork}:`, fallbackError); - // Continue with empty UTxOs - this wallet will show 0 balance + const errorMessage = fallbackError instanceof Error ? fallbackError.message : 'Unknown fallback UTxO fetch error'; + console.error(`Failed to fetch UTxOs for wallet ${wallet.id.slice(0, 8)}... on fallback network ${fallbackNetwork}:`, errorMessage); + + // Track fallback UTxO fetch failures + failures.push({ + walletId: wallet.id.slice(0, 8), + errorType: "utxo_fetch_failed", + errorMessage: "Failed to fetch UTxOs from both networks", + walletStructure: getWalletStructure(wallet) + }); + failedInBatch++; + continue; } } @@ -307,7 +431,8 @@ export default async function handler( failures.push({ walletId: wallet.id.slice(0, 8), errorType, - errorMessage: sanitizedMessage + errorMessage: sanitizedMessage, + walletStructure: getWalletStructure(wallet) }); failedInBatch++; @@ -321,12 +446,12 @@ export default async function handler( const snapshotPromises = walletBalances.map(async (walletBalance: WalletBalance) => { try { - await (db as any).balanceSnapshot.create({ + await db.balanceSnapshot.create({ data: { walletId: walletBalance.walletId, walletName: walletBalance.walletName, address: walletBalance.address, - adaBalance: walletBalance.adaBalance, + adaBalance: new Decimal(walletBalance.adaBalance), assetBalances: walletBalance.balance, isArchived: walletBalance.isArchived, },