diff --git a/.github/workflows/backend-governance.yml b/.github/workflows/backend-governance.yml index 73cbdd89c..500828a3d 100644 --- a/.github/workflows/backend-governance.yml +++ b/.github/workflows/backend-governance.yml @@ -62,6 +62,9 @@ jobs: - name: Type-check backend run: npm run build + - name: Validate Prometheus alert rules + run: docker run --rm --entrypoint=/bin/promtool -v "${{ github.workspace }}:/workspace" prom/prometheus:v2.55.0 check rules /workspace/backend/monitoring/prometheus-alerts.yml + - name: Validate Prisma schema and migration consistency run: npm run prisma:schema-check diff --git a/backend/docs/PROMETHEUS_ALERTS_RUNBOOK.md b/backend/docs/PROMETHEUS_ALERTS_RUNBOOK.md new file mode 100644 index 000000000..38c5bb020 --- /dev/null +++ b/backend/docs/PROMETHEUS_ALERTS_RUNBOOK.md @@ -0,0 +1,40 @@ +# Backend Prometheus Alert Runbook + +These alerts enforce the backend operational SLOs below. Prometheus rule annotations link to the relevant response section. + +| Signal | SLO / threshold | +| --- | --- | +| Background jobs | Every registered job remains healthy; no sustained dead-letter backlog | +| Event outbox | Oldest pending or retrying event remains under 60 seconds; backlog stays at or below 100 | +| Email queue | Oldest queued or retrying email remains under 5 minutes; backlog stays at or below 100 | +| Webhook delivery | No failed deliveries remain unresolved; pending backlog stays at or below 100 | +| Database queries | P95 query latency remains below 1 second; no sustained query-budget breaches | +| Database backups | Scheduled backup job remains healthy | + +## Background Jobs + +1. Check `/admin/jobs/monitor` and `/admin/jobs/metrics` for the unhealthy job, recurring failures, and dead letters. +2. Inspect the job's logs and dependency health. Resolve the underlying cause before retrying or discarding dead-letter work. +3. For `databaseBackup`, verify `BACKUP_ENABLED`, the S3 bucket/endpoint/credentials, and the most recent object in the configured backup destination. Do not mark the job healthy until a usable backup is confirmed. + +## Webhooks And Event Outbox + +1. Check `/admin/jobs/monitor` for webhook delivery metrics and inspect the admin webhook delivery/dead-letter endpoints. +2. Check event outbox entries for pending, failed, or dead-lettered records. Verify downstream webhook endpoint health and delivery secrets. +3. Restore downstream availability, then retry only the affected dead-letter records. Confirm that backlog age and count return below the SLO. + +## Email Queue + +1. Inspect `/admin/emails/queue` and worker logs for retry counts and the latest delivery errors. +2. Verify the mail provider configuration and connectivity. Replay dead-lettered messages only after the provider issue is resolved. +3. Confirm queue age and pending count return below the SLO. + +## Database And Dependencies + +1. Compare P95 query latency and budget-breach metrics by Prisma model/action; correlate with database CPU, connection saturation, and slow-query logs. +2. Check database connectivity, pool configuration, and recent migrations. Reduce the offending query load or address the slow query before scaling the pool. +3. For external dependency latency, check the affected provider's status and dependency-specific operations before changing retry settings. + +## Alert Validation + +The backend governance workflow runs `promtool check rules` against `backend/monitoring/prometheus-alerts.yml` on backend changes. diff --git a/backend/docs/TRANSFER_ORCHESTRATION.md b/backend/docs/TRANSFER_ORCHESTRATION.md index d8fa4f20e..3a9d2341c 100644 --- a/backend/docs/TRANSFER_ORCHESTRATION.md +++ b/backend/docs/TRANSFER_ORCHESTRATION.md @@ -114,6 +114,21 @@ await resolveInDoubtTransfer(storeKey, { discard: true }); `listInDoubtTransfers()` and `getInDoubtTransfer(storeKey)` expose the queue. Neither leaks the internal fingerprint. +The admin API exposes the same controls: + +```http +GET /admin/transfers/in-doubt +POST /admin/transfers/{storeKey}/resolve +``` + +Both routes require an admin API key. The resolve body must be exactly one of +`{"transactionHash":""}` or `{"discard":true}`. Use the hash +only after confirming the transfer landed; discard only after checking that no +matching transaction landed, because it releases the idempotency key for a new +submission. Each resolution attempt is recorded as the +`transfers.in_doubt.confirm` or `transfers.in_doubt.discard` admin audit action. +Unknown or already-resolved keys return `404`. + ## Errors Every failure is a `TransferOrchestrationError` subclass carrying `code`, diff --git a/backend/monitoring/prometheus-alerts.yml b/backend/monitoring/prometheus-alerts.yml index 15b871e9c..466402d8e 100644 --- a/backend/monitoring/prometheus-alerts.yml +++ b/backend/monitoring/prometheus-alerts.yml @@ -64,4 +64,93 @@ groups: service: yieldvault-backend annotations: summary: "YieldVault external dependency is slow" - description: "P95 {{ $labels.dependency }} {{ $labels.operation }} latency is above 2 seconds." \ No newline at end of file + description: "P95 {{ $labels.dependency }} {{ $labels.operation }} latency is above 2 seconds." + runbook_url: "https://github.com/Junirezz/YieldVault-RWA/blob/main/backend/docs/PROMETHEUS_ALERTS_RUNBOOK.md#database-and-dependencies" + + - alert: YieldVaultBackgroundJobUnhealthy + expr: job_health_status == 0 + for: 5m + labels: + severity: critical + service: yieldvault-backend + annotations: + summary: "YieldVault background job is unhealthy" + description: "Job {{ $labels.job_name }} is reporting degraded health." + runbook_url: "https://github.com/Junirezz/YieldVault-RWA/blob/main/backend/docs/PROMETHEUS_ALERTS_RUNBOOK.md#background-jobs" + + - alert: YieldVaultJobDeadLettersPresent + expr: job_dead_letter_count > 0 + for: 5m + labels: + severity: warning + service: yieldvault-backend + annotations: + summary: "YieldVault background job has dead letters" + description: "Job {{ $labels.job_name }} has {{ $value }} dead-letter records." + runbook_url: "https://github.com/Junirezz/YieldVault-RWA/blob/main/backend/docs/PROMETHEUS_ALERTS_RUNBOOK.md#background-jobs" + + - alert: YieldVaultDatabaseBackupUnhealthy + expr: job_health_status{job_name="databaseBackup"} == 0 or job_dead_letter_count{job_name="databaseBackup"} > 0 + for: 5m + labels: + severity: critical + service: yieldvault-backend + annotations: + summary: "YieldVault database backup job is unhealthy" + description: "The scheduled database backup job is reporting degraded health." + runbook_url: "https://github.com/Junirezz/YieldVault-RWA/blob/main/backend/docs/PROMETHEUS_ALERTS_RUNBOOK.md#database-backups" + + - alert: YieldVaultEventOutboxLagHigh + expr: event_outbox_oldest_pending_age_seconds > 60 or event_outbox_pending_count > 100 + for: 5m + labels: + severity: warning + service: yieldvault-backend + annotations: + summary: "YieldVault event outbox is lagging" + description: "Outbox events are older than 60 seconds or more than 100 events are awaiting relay." + runbook_url: "https://github.com/Junirezz/YieldVault-RWA/blob/main/backend/docs/PROMETHEUS_ALERTS_RUNBOOK.md#webhooks-and-event-outbox" + + - alert: YieldVaultEmailQueueLagHigh + expr: email_queue_oldest_pending_age_seconds > 300 or email_queue_pending_count > 100 + for: 5m + labels: + severity: warning + service: yieldvault-backend + annotations: + summary: "YieldVault email queue is lagging" + description: "Queued email is older than 5 minutes or more than 100 emails are awaiting delivery." + runbook_url: "https://github.com/Junirezz/YieldVault-RWA/blob/main/backend/docs/PROMETHEUS_ALERTS_RUNBOOK.md#email-queue" + + - alert: YieldVaultWebhookDeliveryBacklog + expr: webhook_delivery_failed_count > 0 or webhook_delivery_pending_count > 100 + for: 5m + labels: + severity: warning + service: yieldvault-backend + annotations: + summary: "YieldVault webhook deliveries are failing or backlogged" + description: "Webhook deliveries have failures or more than 100 pending deliveries." + runbook_url: "https://github.com/Junirezz/YieldVault-RWA/blob/main/backend/docs/PROMETHEUS_ALERTS_RUNBOOK.md#webhooks-and-event-outbox" + + - alert: YieldVaultDatabaseQueryLatencyHigh + expr: histogram_quantile(0.95, sum by (model, action, le) (rate(db_query_duration_seconds_bucket[5m]))) > 1 + for: 5m + labels: + severity: warning + service: yieldvault-backend + annotations: + summary: "YieldVault database query latency is high" + description: "P95 {{ $labels.model }}.{{ $labels.action }} query latency is above the 1-second SLO." + runbook_url: "https://github.com/Junirezz/YieldVault-RWA/blob/main/backend/docs/PROMETHEUS_ALERTS_RUNBOOK.md#database-and-dependencies" + + - alert: YieldVaultDatabaseQueryBudgetBreaches + expr: sum(rate(db_query_budget_breach_total[5m])) > 0 + for: 10m + labels: + severity: warning + service: yieldvault-backend + annotations: + summary: "YieldVault database query budgets are being exceeded" + description: "Database query budget breaches continue to occur." + runbook_url: "https://github.com/Junirezz/YieldVault-RWA/blob/main/backend/docs/PROMETHEUS_ALERTS_RUNBOOK.md#database-and-dependencies" \ No newline at end of file diff --git a/backend/src/emailQueue.ts b/backend/src/emailQueue.ts index 86bef4791..50d3f44a9 100644 --- a/backend/src/emailQueue.ts +++ b/backend/src/emailQueue.ts @@ -158,6 +158,25 @@ export class EmailQueueService { }); } + async getQueueMetrics(): Promise<{ pending: number; oldestPendingAgeSeconds: number }> { + const where = { status: { in: ['pending', 'failed'] } }; + const [pending, oldestPending] = await Promise.all([ + this.queueDelegate.count({ where }), + this.queueDelegate.findFirst({ + where, + orderBy: { createdAt: 'asc' }, + select: { createdAt: true }, + }), + ]); + + return { + pending, + oldestPendingAgeSeconds: oldestPending?.createdAt + ? Math.max(0, (Date.now() - new Date(oldestPending.createdAt).getTime()) / 1000) + : 0, + }; + } + async replayEmail(emailId: string): Promise { await this.queueDelegate.findUniqueOrThrow({ where: { id: emailId }, diff --git a/backend/src/eventOutbox.ts b/backend/src/eventOutbox.ts index 7d508bf9f..a7d05bec4 100644 --- a/backend/src/eventOutbox.ts +++ b/backend/src/eventOutbox.ts @@ -127,6 +127,7 @@ export interface OutboxMetrics { deadLettered: number; locked: number; total: number; + oldestPendingAgeSeconds: number; } // ─── Configuration ─────────────────────────────────────────────────────────── @@ -775,7 +776,7 @@ class EventOutboxService { /** Returns a snapshot of outbox metrics for monitoring/administration. */ async getMetrics(): Promise { - const [pending, relayed, failed, deadLettered, locked, total] = await Promise.all([ + const [pending, relayed, failed, deadLettered, locked, total, oldestPending] = await Promise.all([ getEventOutboxDelegate().count({ where: { status: 'pending' } }), getEventOutboxDelegate().count({ where: { status: 'relayed' } }), getEventOutboxDelegate().count({ where: { status: 'failed' } }), @@ -787,9 +788,24 @@ class EventOutboxService { }, }), getEventOutboxDelegate().count(), + getEventOutboxDelegate().findFirst({ + where: { status: { in: ['pending', 'failed'] } }, + orderBy: { createdAt: 'asc' }, + select: { createdAt: true }, + }), ]); - return { pending, relayed, failed, deadLettered, locked, total }; + return { + pending, + relayed, + failed, + deadLettered, + locked, + total, + oldestPendingAgeSeconds: oldestPending + ? Math.max(0, (Date.now() - new Date(oldestPending.createdAt).getTime()) / 1000) + : 0, + }; } /** Lists outbox entries with optional filters. */ diff --git a/backend/src/index.ts b/backend/src/index.ts index b8f267179..8172dc720 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -64,6 +64,8 @@ import { corsMiddleware } from './middleware/cors'; import { geofencingMiddleware } from './middleware/geofencing'; import { registerInvalidationHook } from './middleware/cache'; import { invalidateVaultCaches } from './vaultDataCache'; +import { getRedisCacheHealth, redisCacheClient } from './redisCache'; +import { validate, LoginSchema, NonceRequestSchema, RefreshSchema, WebhookRegisterSchema, WebhookUpdateSchema, ApyBackfillBodySchema, MaintenanceToggleSchema, MaintenanceWindowBodySchema, FeatureFlagOverrideSchema, CacheInvalidateSchema, EventReplayBodySchema, WithdrawalLimitOverrideSchema, AllowlistWalletBodySchema, ImpersonationSessionBodySchema, ApiKeyRegisterSchema, ApiKeyRotateSchema, ApiKeyRevokeSchema, WebhookVerifyBodySchema, BulkExportBodySchema, TransactionBackfillBodySchema, GovernanceSnapshotExportSchema, ReportExportBodySchema, ChecksumVerifyBodySchema, DeadLetterResolveSchema, DeadLetterIdsSchema, DeadLetterProcessSchema, ScopedTokenCreateSchema, PaginationQuerySchema, WebhookListQuerySchema, IdParamSchema, WindowIdParamSchema, CreateVaultBodySchema, TransferResolveBodySchema, TransferResolveParamsSchema } from './middleware/validate'; import { redisCacheClient } from './redisCache'; import { validate, LoginSchema, NonceRequestSchema, RefreshSchema } from './middleware/validate'; import { tieredJsonBodyParser } from './middleware/payloadLimit'; @@ -97,6 +99,12 @@ import { rateLimitEvents, httpErrorCount, httpClientErrorCount, + eventOutboxPendingCount, + eventOutboxOldestPendingAgeSeconds, + emailQueuePendingCount, + emailQueueOldestPendingAgeSeconds, + webhookDeliveryPendingCount, + webhookDeliveryFailedCount, } from './metrics'; import { latencyMonitoringService } from './latencyMonitoring'; import { listEndpointSlaRegistry } from './endpointSlaRegistry'; @@ -116,6 +124,20 @@ import { import { diagnosticsBundleHandler } from './diagnosticsBundle'; import { errorBoundaryMiddleware } from './middleware/errorBoundary'; import { apiErrorContractMiddleware, sendApiError } from './middleware/apiError'; +import { + exportGovernanceSnapshots, + listGovernanceSnapshots, +} from './governanceSnapshotExport'; +import { + getIdempotencyRetentionMetrics, + pruneStaleIdempotencyRecords, + startIdempotencyRetentionScheduler, +} from './idempotencyRetention'; +import { scopedAdminTokenStore } from './scopedAdminTokens'; +import { + listInDoubtTransfers, + resolveInDoubtTransfer, +} from './transferOrchestrator'; import { startIdempotencyRetentionScheduler } from './idempotencyRetention'; import vaultRoutes from './routes/vault'; import transactionRoutes from './routes/transactions'; @@ -300,6 +322,23 @@ app.get('/metrics', async (_req: Request, res: Response) => { try { syncJobGovernanceMetrics(); latencyMonitoringService.syncSloMetrics(); + try { + const [outbox, emails] = await Promise.all([ + eventOutboxService.getMetrics(), + emailQueueService.getQueueMetrics(), + ]); + const webhooks = getWebhookDeliveryMetrics(); + eventOutboxPendingCount.set(outbox.pending + outbox.failed); + eventOutboxOldestPendingAgeSeconds.set(outbox.oldestPendingAgeSeconds); + emailQueuePendingCount.set(emails.pending); + emailQueueOldestPendingAgeSeconds.set(emails.oldestPendingAgeSeconds); + webhookDeliveryPendingCount.set(webhooks.pending); + webhookDeliveryFailedCount.set(webhooks.failed); + } catch (error) { + logger.log('warn', 'Failed to refresh queue Prometheus gauges', { + error: error instanceof Error ? error.message : String(error), + }); + } res.set('Content-Type', register.contentType); res.end(await register.metrics()); } catch (err) { @@ -334,6 +373,47 @@ app.get('/admin/sla/registry', validateApiKey, (_req: Request, res: Response) => }); }); +app.get('/admin/transfers/in-doubt', validateApiKey, (_req: Request, res: Response) => { + res.status(200).json({ transfers: listInDoubtTransfers() }); +}); + +app.post( + '/admin/transfers/:key/resolve', + validateApiKey, + validate({ params: TransferResolveParamsSchema, body: TransferResolveBodySchema }), + async (req: Request, res: Response) => { + const { key } = req.params; + const resolution = req.body as + | { transactionHash: string } + | { discard: true }; + const action = 'discard' in resolution ? 'discard' : 'confirm'; + + try { + const resolved = await resolveInDoubtTransfer(key, resolution); + const statusCode = resolved ? 200 : 404; + await recordAdminAuditLog(req, `transfers.in_doubt.${action}`, statusCode, { + storeKey: key, + resolution: action, + }); + if (!resolved) { + res.status(404).json({ error: 'In-doubt transfer not found' }); + return; + } + res.status(200).json({ resolved: true, storeKey: key, resolution: action }); + } catch (error) { + await recordAdminAuditLog(req, `transfers.in_doubt.${action}`, 500, { + storeKey: key, + resolution: action, + }); + logger.log('error', 'Failed to resolve in-doubt transfer', { + storeKey: key, + error: error instanceof Error ? error.message : String(error), + }); + res.status(500).json({ error: 'Failed to resolve in-doubt transfer' }); + } + }, +); + /** * GET /admin/rate-limits * Rate-limit monitoring snapshot: allowed vs limited counts by tier. diff --git a/backend/src/metrics.ts b/backend/src/metrics.ts index e899da633..c8bdf3a93 100644 --- a/backend/src/metrics.ts +++ b/backend/src/metrics.ts @@ -101,6 +101,42 @@ export const dbSlowQueryAlertTotal = new Counter({ registers: [register], }); +export const eventOutboxPendingCount = new Gauge({ + name: 'event_outbox_pending_count', + help: 'Event outbox entries awaiting relay, including retryable failures', + registers: [register], +}); + +export const eventOutboxOldestPendingAgeSeconds = new Gauge({ + name: 'event_outbox_oldest_pending_age_seconds', + help: 'Age of the oldest event outbox entry awaiting relay', + registers: [register], +}); + +export const emailQueuePendingCount = new Gauge({ + name: 'email_queue_pending_count', + help: 'Emails queued or waiting for retry', + registers: [register], +}); + +export const emailQueueOldestPendingAgeSeconds = new Gauge({ + name: 'email_queue_oldest_pending_age_seconds', + help: 'Age of the oldest email queued or waiting for retry', + registers: [register], +}); + +export const webhookDeliveryPendingCount = new Gauge({ + name: 'webhook_delivery_pending_count', + help: 'Webhook deliveries waiting to complete', + registers: [register], +}); + +export const webhookDeliveryFailedCount = new Gauge({ + name: 'webhook_delivery_failed_count', + help: 'Webhook deliveries that failed', + registers: [register], +}); + export const cacheHitCount = new Counter({ name: 'cache_hit_count', help: 'Number of cache hits for GET requests', @@ -224,13 +260,22 @@ export function syncJobGovernanceMetrics(): void { const metrics = getJobMetrics(); const failureCounts = metrics.failureCounts as Record; const recurringFailures = metrics.recurringFailures as Partial>; + const deadLetterCounts: Record = {}; + + for (const record of metrics.deadLetters) { + if (record.status === 'dead-letter') { + deadLetterCounts[record.jobName] = (deadLetterCounts[record.jobName] ?? 0) + 1; + } + } - for (const [jobName, count] of Object.entries(failureCounts)) { - jobDeadLetterCount.set({ job_name: jobName }, count); - jobHealthStatus.set( - { job_name: jobName }, - jobName in recurringFailures ? 0 : 1, - ); + const jobNames = new Set([ + ...Object.keys(metrics.runtime), + ...Object.keys(failureCounts), + ...Object.keys(deadLetterCounts), + ]); + for (const jobName of jobNames) { + jobDeadLetterCount.set({ job_name: jobName }, deadLetterCounts[jobName] ?? 0); + jobHealthStatus.set({ job_name: jobName }, jobName in recurringFailures ? 0 : 1); } } diff --git a/backend/src/middleware/validate.ts b/backend/src/middleware/validate.ts index 1d0c1462b..279150a27 100644 --- a/backend/src/middleware/validate.ts +++ b/backend/src/middleware/validate.ts @@ -91,6 +91,15 @@ export const RefreshSchema = z }) .strict(); +export const TransferResolveParamsSchema = z.object({ + key: z.string().regex(/^transfer:[a-f0-9]{16}:[A-Za-z0-9._:~-]{8,255}$/), +}).strict(); + +export const TransferResolveBodySchema = z.union([ + z.object({ transactionHash: z.string().min(1).max(256) }).strict(), + z.object({ discard: z.literal(true) }).strict(), +]); + // ─── Webhook schemas ────────────────────────────────────────────────────────── /** diff --git a/contracts/mock-strategy/src/mock_benji_strategy.rs b/contracts/mock-strategy/src/mock_benji_strategy.rs index 32c604282..8b53cee70 100644 --- a/contracts/mock-strategy/src/mock_benji_strategy.rs +++ b/contracts/mock-strategy/src/mock_benji_strategy.rs @@ -1,5 +1,7 @@ use soroban_sdk::{contract, contractimpl, contracttype, token, Address, Env}; +const NAV_SCALE: i128 = 1_000_000_000_000_000_000; + #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub enum MockBenjiDataKey { @@ -40,6 +42,7 @@ impl MockBenjiStrategy { panic!("already initialized"); } admin.require_auth(); + assert!(base_nav > 0, "NAV must be positive"); env.storage() .instance() @@ -71,6 +74,7 @@ impl MockBenjiStrategy { .get(&MockBenjiDataKey::Admin) .unwrap(); admin.require_auth(); + assert!(base_nav > 0, "NAV must be positive"); env.storage() .instance() @@ -133,12 +137,17 @@ impl MockBenjiStrategy { .instance() .get(&MockBenjiDataKey::BenjiToken) .unwrap(); + let nav = Self::nav(env.clone()); let asset_client = token::Client::new(&env, &asset_addr); asset_client.transfer(&vault, &env.current_contract_address(), &amount); let benji_client = token::StellarAssetClient::new(&env, &benji_addr); - benji_client.mint(&env.current_contract_address(), &amount); + let benji_amount = amount + .checked_mul(NAV_SCALE) + .expect("BENJI amount overflow") + / nav; + benji_client.mint(&env.current_contract_address(), &benji_amount); } /// Withdraws underlying asset from the strategy. @@ -157,13 +166,27 @@ impl MockBenjiStrategy { .instance() .get(&MockBenjiDataKey::Asset) .unwrap(); + let benji_addr: Address = env + .storage() + .instance() + .get(&MockBenjiDataKey::BenjiToken) + .unwrap(); + let nav = Self::nav(env.clone()); let asset_client = token::Client::new(&env, &asset_addr); asset_client.transfer(&env.current_contract_address(), &vault, &amount); + + let scaled_amount = amount + .checked_mul(NAV_SCALE) + .and_then(|scaled| scaled.checked_add(nav - 1)) + .expect("BENJI amount overflow"); + let benji_amount = scaled_amount / nav; + let benji_client = token::StellarAssetClient::new(&env, &benji_addr); + benji_client.burn(&env.current_contract_address(), &benji_amount); } /// Returns the total value of assets held by this strategy. /// - /// Measured as the mock BENJI token balance of this contract. + /// Measured as the BENJI balance multiplied by the configured NAV. pub fn total_value(env: Env) -> i128 { let benji_addr: Address = env .storage() @@ -171,7 +194,10 @@ impl MockBenjiStrategy { .get(&MockBenjiDataKey::BenjiToken) .unwrap(); let benji_client = token::Client::new(&env, &benji_addr); - benji_client.balance(&env.current_contract_address()) + benji_client + .balance(&env.current_contract_address()) + .saturating_mul(Self::nav(env)) + / NAV_SCALE } /// Returns the address of the underlying asset token. @@ -261,21 +287,40 @@ mod tests { // Deposit: vault -> strategy (asset), strategy mints BENJI 1:1 client.deposit(&deposit_amount); - // total_value returns BENJI token balance + // At the initial 1:1 NAV, total value matches the BENJI token balance. assert_eq!(client.total_value(), deposit_amount); - // Withdraw: strategy -> vault (asset). BENJI is NOT burned - // (matches production BenjiStrategy behavior). + // Withdraw returns the underlying asset and burns the corresponding BENJI. client.withdraw(&deposit_amount); - // BENJI balance unchanged after withdraw - assert_eq!(client.total_value(), deposit_amount); + assert_eq!(client.total_value(), 0); + let benji_client = token::Client::new(&env, &benji_token); + assert_eq!(benji_client.balance(&contract_addr), 0); // Asset was returned to vault: strategy holds 0 asset let asset_client = token::Client::new(&env, &asset); assert_eq!(asset_client.balance(&contract_addr), 0); } + #[test] + fn test_nav_accrual_and_withdrawal_burn_are_reflected_in_total_value() { + let (env, admin, vault, asset, benji_token) = setup(); + let contract_addr = env.register_contract(None, MockBenjiStrategy); + let client = MockBenjiStrategyClient::new(&env, &contract_addr); + + client.initialize(&admin, &vault, &asset, &benji_token, &NAV_SCALE); + client.deposit(&100); + assert_eq!(client.total_value(), 100); + + client.set_nav(&(2 * NAV_SCALE)); + assert_eq!(client.total_value(), 200); + + client.withdraw(&50); + assert_eq!(client.total_value(), 150); + let benji_client = token::Client::new(&env, &benji_token); + assert_eq!(benji_client.balance(&contract_addr), 75); + } + #[test] fn test_advance_epoch() { let (env, admin, vault, asset, benji_token) = setup(); diff --git a/contracts/vault/src/feature_tests.rs b/contracts/vault/src/feature_tests.rs index cdcfc8290..fa3855db3 100644 --- a/contracts/vault/src/feature_tests.rs +++ b/contracts/vault/src/feature_tests.rs @@ -134,6 +134,35 @@ fn test_accrue_yield_fee_rounding_deterministic() { assert_eq!(vault.total_assets(), 330); } +#[test] +fn test_accrue_yield_uses_admin_enabled_fee_curve() { + let env = Env::default(); + env.mock_all_auths(); + + let (vault, _, usdc_sa, admin) = setup_vault(&env); + assert!(!vault.fee_curve().enabled); + + let curve = fee_curve::FeeCurve { + enabled: true, + base_fee_bps: 25, + optimal_fee_bps: 100, + max_fee_bps: 500, + optimal_utilization_bps: 8_000, + }; + vault.queue_fee_curve_change(&curve); + let pending = vault.pending_fee_curve_change().unwrap(); + env.ledger().set_timestamp(pending.eta); + vault.execute_fee_curve_change(); + assert!(vault.fee_curve().enabled); + + usdc_sa.mint(&admin, &10_000); + vault.accrue_yield(&10_000); + + // With no strategy, utilization is zero and the enabled curve charges its base rate. + assert_eq!(vault.treasury_balance(), 25); + assert_eq!(vault.total_assets(), 9_975); +} + // ── Dispute window tests ────────────────────────────────────────────────────── #[test] diff --git a/contracts/vault/src/lib.rs b/contracts/vault/src/lib.rs index b5bd3f42d..8be88db29 100644 --- a/contracts/vault/src/lib.rs +++ b/contracts/vault/src/lib.rs @@ -3406,7 +3406,7 @@ impl YieldVault { return Err(VaultError::InvalidAmount); } - let fee_bps: i128 = env.storage().instance().get(&DataKey::FeeBps).unwrap_or(0); + let fee_bps = Self::effective_fee_bps(env.clone())?; let (fee_amount, net_yield) = fee_math::calculate_protocol_fee(amount, fee_bps)?; let token_addr = Self::token(env.clone()); @@ -3804,10 +3804,16 @@ impl YieldVault { pub fn effective_fee_bps(env: Env) -> Result { let curve = Self::fee_curve(env.clone()); let static_fee_bps = Self::fee_bps(env.clone()); - if !curve.enabled { - return Ok(static_fee_bps); - } - Ok(fee_curve::fee_bps_at(&curve, Self::utilization_bps(env)?)) + let utilization = if curve.enabled { + Self::utilization_bps(env)? + } else { + 0 + }; + Ok(fee_curve::effective_fee_bps( + &curve, + static_fee_bps, + utilization, + )) } /// Queue a new dynamic fee curve. Takes effect once @@ -4663,9 +4669,15 @@ impl YieldVault { let static_fee_bps: i128 = env.storage().instance().get(&DataKey::FeeBps).unwrap_or(0); let (fee_bps, utilization) = if curve.enabled { let utilization = Self::utilization_bps(env.clone())?; - (fee_curve::fee_bps_at(&curve, utilization), utilization) + ( + fee_curve::effective_fee_bps(&curve, static_fee_bps, utilization), + utilization, + ) } else { - (static_fee_bps, 0) + ( + fee_curve::effective_fee_bps(&curve, static_fee_bps, 0), + 0, + ) }; let token_addr = Self::token(env.clone());