Skip to main content

hypercall_admin/
pm_settlement.rs

1//! Portfolio-margin settlement pool admin endpoints.
2
3use axum::{extract::State, http::StatusCode};
4use hypercall_engine::command::{
5    AccruePmSettlementInterestCommand, EngineCommand, JournalPmRecoveryPlanCommand,
6    MarkPmRecoveryActionSubmittedCommand, PmRecoveryActionResult, PmRecoveryExternalKind,
7    PmRecoveryPlanCommand, ResolvePmRecoveryActionCommand, SetPmSettlementPoolConfigCommand,
8};
9use hypercall_margin::portfolio::PmSettlementPoolConfig;
10use hypercall_runtime_api::{error::ApiError, sonic_json::SonicJson};
11use hypercall_types::{api_models::ApiResponse, WalletAddress};
12use rust_decimal::Decimal;
13use serde::{Deserialize, Serialize};
14
15use crate::state::{AdminState, ENGINE_RESPONSE_TIMEOUT};
16
17#[derive(Debug, Deserialize)]
18pub struct SetPmSettlementPoolConfigRequest {
19    pub request_id: uuid::Uuid,
20    pub input_digest: String,
21    pub underlying: String,
22    pub config_version: u32,
23    pub config: PmSettlementPoolConfig,
24    pub timestamp_ms: u64,
25}
26
27#[derive(Debug, Deserialize)]
28pub struct AccruePmSettlementInterestRequest {
29    pub request_id: uuid::Uuid,
30    pub input_digest: String,
31    pub wallet: WalletAddress,
32    pub underlying: String,
33    pub to_ms: i64,
34    pub timestamp_ms: u64,
35}
36
37#[derive(Debug, Deserialize)]
38pub struct JournalPmRecoveryPlanRequest {
39    pub request_id: uuid::Uuid,
40    pub input_digest: String,
41    pub plan: PmRecoveryPlanCommand,
42    pub timestamp_ms: u64,
43}
44
45#[derive(Debug, Deserialize)]
46pub struct MarkPmRecoveryActionSubmittedRequest {
47    pub request_id: uuid::Uuid,
48    pub input_digest: String,
49    pub wallet: WalletAddress,
50    pub plan_id: String,
51    pub action_index: u32,
52    pub attempt: u32,
53    pub external_id: String,
54    pub external_kind: PmRecoveryExternalKind,
55    pub timestamp_ms: u64,
56}
57
58#[derive(Debug, Deserialize)]
59pub struct ResolvePmRecoveryActionRequest {
60    pub request_id: uuid::Uuid,
61    pub input_digest: String,
62    pub wallet: WalletAddress,
63    pub plan_id: String,
64    pub action_index: u32,
65    pub attempt: u32,
66    pub result: PmRecoveryActionResult,
67    pub recovered_usdc: Decimal,
68    pub liability_reduction_usdc: Decimal,
69    pub result_external_id: Option<String>,
70    pub timestamp_ms: u64,
71}
72
73#[derive(Debug, Serialize)]
74pub struct PmRecoveryProjectionResponse {
75    pub plans: Vec<hypercall_db::PmRecoveryPlanProjection>,
76    pub actions: Vec<hypercall_db::PmRecoveryActionProjection>,
77}
78
79#[derive(Debug, Serialize)]
80pub struct PmSettlementGateStateResponse {
81    pub portfolio_margin_pool_enabled: bool,
82    pub allowlist_count: usize,
83    pub allowlist_wallets: Vec<WalletAddress>,
84    pub pools: Vec<PmSettlementGatePoolState>,
85    pub account_blockers: PmSettlementAccountBlockers,
86    pub recovery_actions: PmSettlementRecoveryActionGateState,
87    pub projection_freshness: PmSettlementProjectionFreshness,
88    pub expansion_ready: PmSettlementExpansionReadiness,
89}
90
91#[derive(Debug, Serialize)]
92pub struct PmSettlementGatePoolState {
93    pub underlying: String,
94    pub pool_available_usdc: Decimal,
95    pub pool_target_usdc: Decimal,
96    pub pool_capacity_usdc: Decimal,
97    pub pool_utilization: Option<Decimal>,
98    pub active_timing_bridge_usdc: Decimal,
99    pub active_settlement_debt_usdc: Decimal,
100    pub normal_utilization_cap: Option<Decimal>,
101    pub crisis_utilization_cap: Option<Decimal>,
102    pub below_target: bool,
103    pub utilization_unavailable: bool,
104    pub above_crisis_cap: bool,
105    pub projection_seq: i64,
106    pub last_engine_command_id: i64,
107}
108
109#[derive(Debug, Default, Serialize)]
110pub struct PmSettlementAccountBlockers {
111    pub total_accounts: usize,
112    pub bridged_accounts: usize,
113    pub debt_accounts: usize,
114    pub overdue_bridge_accounts: usize,
115    pub active_recovery_accounts: usize,
116    pub active_bridge_usdc: Decimal,
117    pub active_debt_usdc: Decimal,
118}
119
120#[derive(Debug, Default, Serialize)]
121pub struct PmSettlementRecoveryActionGateState {
122    pub total_actions: usize,
123    pub planned_actions: usize,
124    pub submitted_actions: usize,
125    pub terminal_actions: usize,
126}
127
128#[derive(Debug, Default, Serialize)]
129pub struct PmSettlementProjectionFreshness {
130    pub projection_rows: usize,
131    pub max_projection_seq: i64,
132    pub max_engine_command_id: i64,
133}
134
135#[derive(Debug, Serialize)]
136pub struct PmSettlementExpansionReadiness {
137    pub allowlist_configured: bool,
138    pub pool_facts_available: bool,
139    pub projections_present: bool,
140    pub no_pool_below_target: bool,
141    pub no_pool_above_crisis_cap: bool,
142    pub no_account_debt: bool,
143    pub no_active_recovery_accounts: bool,
144    pub no_overdue_bridge: bool,
145    pub no_planned_recovery_actions: bool,
146    pub no_submitted_recovery_actions: bool,
147    pub ready_for_single_wallet_smoke: bool,
148    pub ready_for_allowlist_expansion: bool,
149}
150
151fn ensure_pm_settlement_pool_enabled(state: &AdminState) -> Result<(), ApiError> {
152    if state.runtime_config.portfolio_margin_pool_enabled {
153        Ok(())
154    } else {
155        Err(ApiError::new(
156            StatusCode::SERVICE_UNAVAILABLE,
157            "portfolio_margin_pool_disabled",
158            "portfolio_margin_pool_enabled is false; PM settlement pool mutations are disabled",
159        ))
160    }
161}
162
163fn ensure_pm_settlement_wallet_allowed(
164    state: &AdminState,
165    wallet: &WalletAddress,
166) -> Result<(), ApiError> {
167    if state
168        .runtime_config
169        .portfolio_margin_settlement_allowlist
170        .contains(wallet)
171    {
172        Ok(())
173    } else {
174        Err(ApiError::new(
175            StatusCode::FORBIDDEN,
176            "portfolio_margin_wallet_not_allowlisted",
177            "wallet is not allowlisted for PM settlement pool recovery commands",
178        ))
179    }
180}
181
182async fn submit_pm_settlement_command(
183    state: &AdminState,
184    command: EngineCommand,
185) -> Result<(), ApiError> {
186    let sender = state
187        .pm_settlement_admin_sender
188        .as_ref()
189        .ok_or_else(|| ApiError::internal_error("PM settlement admin channel is not configured"))?;
190    let (tx, rx) = tokio::sync::oneshot::channel();
191    sender
192        .send(hypercall_runtime_api::PmSettlementAdminRequest {
193            command,
194            applied_tx: tx,
195        })
196        .await
197        .map_err(|_| ApiError::internal_error("PM settlement admin channel is closed"))?;
198    tokio::time::timeout(ENGINE_RESPONSE_TIMEOUT, rx)
199        .await
200        .map_err(|_| ApiError::gateway_timeout("PM settlement command timed out"))?
201        .map_err(|_| ApiError::internal_error("PM settlement command acknowledgement dropped"))?
202        .map_err(ApiError::bad_request)
203}
204
205pub async fn set_pm_settlement_pool_config(
206    State(state): State<AdminState>,
207    SonicJson(request): SonicJson<SetPmSettlementPoolConfigRequest>,
208) -> Result<SonicJson<ApiResponse<String>>, ApiError> {
209    ensure_pm_settlement_pool_enabled(&state)?;
210    submit_pm_settlement_command(
211        &state,
212        EngineCommand::SetPmSettlementPoolConfig(SetPmSettlementPoolConfigCommand {
213            request_id: request.request_id,
214            input_digest: request.input_digest,
215            underlying: request.underlying,
216            config_version: request.config_version,
217            config: request.config,
218            timestamp_ms: request.timestamp_ms,
219        }),
220    )
221    .await?;
222    Ok(SonicJson(ApiResponse::success(
223        "PM settlement pool config command accepted".to_string(),
224    )))
225}
226
227pub async fn accrue_pm_settlement_interest(
228    State(state): State<AdminState>,
229    SonicJson(request): SonicJson<AccruePmSettlementInterestRequest>,
230) -> Result<SonicJson<ApiResponse<String>>, ApiError> {
231    ensure_pm_settlement_pool_enabled(&state)?;
232    ensure_pm_settlement_wallet_allowed(&state, &request.wallet)?;
233    submit_pm_settlement_command(
234        &state,
235        EngineCommand::AccruePmSettlementInterest(AccruePmSettlementInterestCommand {
236            request_id: request.request_id,
237            input_digest: request.input_digest,
238            wallet: request.wallet,
239            underlying: request.underlying,
240            to_ms: request.to_ms,
241            timestamp_ms: request.timestamp_ms,
242        }),
243    )
244    .await?;
245    Ok(SonicJson(ApiResponse::success(
246        "PM settlement interest accrual command accepted".to_string(),
247    )))
248}
249
250pub async fn journal_pm_recovery_plan(
251    State(state): State<AdminState>,
252    SonicJson(request): SonicJson<JournalPmRecoveryPlanRequest>,
253) -> Result<SonicJson<ApiResponse<String>>, ApiError> {
254    ensure_pm_settlement_pool_enabled(&state)?;
255    ensure_pm_settlement_wallet_allowed(&state, &request.plan.wallet)?;
256    submit_pm_settlement_command(
257        &state,
258        EngineCommand::JournalPmRecoveryPlan(JournalPmRecoveryPlanCommand {
259            request_id: request.request_id,
260            input_digest: request.input_digest,
261            plan: request.plan,
262            timestamp_ms: request.timestamp_ms,
263        }),
264    )
265    .await?;
266    Ok(SonicJson(ApiResponse::success(
267        "PM recovery plan journal command accepted".to_string(),
268    )))
269}
270
271pub async fn mark_pm_recovery_action_submitted(
272    State(state): State<AdminState>,
273    SonicJson(request): SonicJson<MarkPmRecoveryActionSubmittedRequest>,
274) -> Result<SonicJson<ApiResponse<String>>, ApiError> {
275    ensure_pm_settlement_pool_enabled(&state)?;
276    ensure_pm_settlement_wallet_allowed(&state, &request.wallet)?;
277    submit_pm_settlement_command(
278        &state,
279        EngineCommand::MarkPmRecoveryActionSubmitted(MarkPmRecoveryActionSubmittedCommand {
280            request_id: request.request_id,
281            input_digest: request.input_digest,
282            wallet: request.wallet,
283            plan_id: request.plan_id,
284            action_index: request.action_index,
285            attempt: request.attempt,
286            external_id: request.external_id,
287            external_kind: request.external_kind,
288            timestamp_ms: request.timestamp_ms,
289        }),
290    )
291    .await?;
292    Ok(SonicJson(ApiResponse::success(
293        "PM recovery action submission command accepted".to_string(),
294    )))
295}
296
297pub async fn resolve_pm_recovery_action(
298    State(state): State<AdminState>,
299    SonicJson(request): SonicJson<ResolvePmRecoveryActionRequest>,
300) -> Result<SonicJson<ApiResponse<String>>, ApiError> {
301    ensure_pm_settlement_pool_enabled(&state)?;
302    ensure_pm_settlement_wallet_allowed(&state, &request.wallet)?;
303    submit_pm_settlement_command(
304        &state,
305        EngineCommand::ResolvePmRecoveryAction(ResolvePmRecoveryActionCommand {
306            request_id: request.request_id,
307            input_digest: request.input_digest,
308            wallet: request.wallet,
309            plan_id: request.plan_id,
310            action_index: request.action_index,
311            attempt: request.attempt,
312            result: request.result,
313            recovered_usdc: request.recovered_usdc,
314            liability_reduction_usdc: request.liability_reduction_usdc,
315            result_external_id: request.result_external_id,
316            timestamp_ms: request.timestamp_ms,
317        }),
318    )
319    .await?;
320    Ok(SonicJson(ApiResponse::success(
321        "PM recovery action resolution command accepted".to_string(),
322    )))
323}
324
325pub async fn get_pm_settlement_pools(
326    State(state): State<AdminState>,
327) -> Result<SonicJson<ApiResponse<Vec<hypercall_db::PmSettlementPoolProjection>>>, ApiError> {
328    ensure_pm_settlement_pool_enabled(&state)?;
329    let rows = state.db.list_pm_settlement_pools().await.map_err(|error| {
330        tracing::error!("Failed to read PM settlement pool projections: {}", error);
331        ApiError::internal_error("Failed to read PM settlement pool projections")
332    })?;
333    Ok(SonicJson(ApiResponse::success(rows)))
334}
335
336pub async fn get_pm_settlement_accounts(
337    State(state): State<AdminState>,
338) -> Result<SonicJson<ApiResponse<Vec<hypercall_db::PmSettlementAccountProjection>>>, ApiError> {
339    ensure_pm_settlement_pool_enabled(&state)?;
340    let rows = state
341        .db
342        .list_pm_settlement_accounts()
343        .await
344        .map_err(|error| {
345            tracing::error!(
346                "Failed to read PM settlement account projections: {}",
347                error
348            );
349            ApiError::internal_error("Failed to read PM settlement account projections")
350        })?;
351    Ok(SonicJson(ApiResponse::success(rows)))
352}
353
354pub async fn get_pm_settlement_events(
355    State(state): State<AdminState>,
356) -> Result<SonicJson<ApiResponse<Vec<hypercall_db::PmSettlementEventProjection>>>, ApiError> {
357    ensure_pm_settlement_pool_enabled(&state)?;
358    let rows = state
359        .db
360        .list_pm_settlement_events()
361        .await
362        .map_err(|error| {
363            tracing::error!("Failed to read PM settlement event projections: {}", error);
364            ApiError::internal_error("Failed to read PM settlement event projections")
365        })?;
366    Ok(SonicJson(ApiResponse::success(rows)))
367}
368
369pub async fn get_pm_settlement_interest_events(
370    State(state): State<AdminState>,
371) -> Result<SonicJson<ApiResponse<Vec<hypercall_db::PmSettlementInterestEventProjection>>>, ApiError>
372{
373    ensure_pm_settlement_pool_enabled(&state)?;
374    let rows = state
375        .db
376        .list_pm_settlement_interest_events()
377        .await
378        .map_err(|error| {
379            tracing::error!(
380                "Failed to read PM settlement interest event projections: {}",
381                error
382            );
383            ApiError::internal_error("Failed to read PM settlement interest event projections")
384        })?;
385    Ok(SonicJson(ApiResponse::success(rows)))
386}
387
388pub async fn get_pm_settlement_repayment_events(
389    State(state): State<AdminState>,
390) -> Result<SonicJson<ApiResponse<Vec<hypercall_db::PmSettlementRepaymentEventProjection>>>, ApiError>
391{
392    ensure_pm_settlement_pool_enabled(&state)?;
393    let rows = state
394        .db
395        .list_pm_settlement_repayment_events()
396        .await
397        .map_err(|error| {
398            tracing::error!(
399                "Failed to read PM settlement repayment event projections: {}",
400                error
401            );
402            ApiError::internal_error("Failed to read PM settlement repayment event projections")
403        })?;
404    Ok(SonicJson(ApiResponse::success(rows)))
405}
406
407pub async fn get_pm_recovery_projections(
408    State(state): State<AdminState>,
409) -> Result<SonicJson<ApiResponse<PmRecoveryProjectionResponse>>, ApiError> {
410    ensure_pm_settlement_pool_enabled(&state)?;
411    let plans = state.db.list_pm_recovery_plans().await.map_err(|error| {
412        tracing::error!("Failed to read PM recovery plan projections: {}", error);
413        ApiError::internal_error("Failed to read PM recovery plan projections")
414    })?;
415    let actions = state.db.list_pm_recovery_actions().await.map_err(|error| {
416        tracing::error!("Failed to read PM recovery action projections: {}", error);
417        ApiError::internal_error("Failed to read PM recovery action projections")
418    })?;
419    Ok(SonicJson(ApiResponse::success(
420        PmRecoveryProjectionResponse { plans, actions },
421    )))
422}
423
424pub async fn get_pm_settlement_gate_state(
425    State(state): State<AdminState>,
426) -> Result<SonicJson<ApiResponse<PmSettlementGateStateResponse>>, ApiError> {
427    ensure_pm_settlement_pool_enabled(&state)?;
428
429    let pools = state.db.list_pm_settlement_pools().await.map_err(|error| {
430        tracing::error!(
431            "Failed to read PM settlement pool projections for gate state: {}",
432            error
433        );
434        ApiError::internal_error("Failed to read PM settlement pool projections")
435    })?;
436    let pool_gate_counts = state
437        .db
438        .pm_settlement_pool_gate_counts()
439        .await
440        .map_err(|error| {
441            tracing::error!("Failed to read PM settlement pool gate counts: {}", error);
442            ApiError::internal_error("Failed to read PM settlement pool gate counts")
443        })?;
444    let accounts = state
445        .db
446        .list_pm_settlement_accounts()
447        .await
448        .map_err(|error| {
449            tracing::error!(
450                "Failed to read PM settlement account projections for gate state: {}",
451                error
452            );
453            ApiError::internal_error("Failed to read PM settlement account projections")
454        })?;
455    let recovery_plans = state.db.list_pm_recovery_plans().await.map_err(|error| {
456        tracing::error!(
457            "Failed to read PM recovery plan projections for gate state: {}",
458            error
459        );
460        ApiError::internal_error("Failed to read PM recovery plan projections")
461    })?;
462    let recovery_action_rows = state.db.list_pm_recovery_actions().await.map_err(|error| {
463        tracing::error!(
464            "Failed to read PM recovery action projections for gate state: {}",
465            error
466        );
467        ApiError::internal_error("Failed to read PM recovery action projections")
468    })?;
469    let now_ms = chrono::Utc::now().timestamp_millis();
470    let account_gate_counts = state
471        .db
472        .pm_settlement_account_gate_counts(now_ms)
473        .await
474        .map_err(|error| {
475            tracing::error!(
476                "Failed to read PM settlement account gate counts: {}",
477                error
478            );
479            ApiError::internal_error("Failed to read PM settlement account gate counts")
480        })?;
481    let recovery_action_gate_counts =
482        state
483            .db
484            .pm_recovery_action_gate_counts()
485            .await
486            .map_err(|error| {
487                tracing::error!("Failed to read PM recovery action gate counts: {}", error);
488                ApiError::internal_error("Failed to read PM recovery action gate counts")
489            })?;
490
491    let gate_pools = pools
492        .iter()
493        .map(|pool| {
494            let below_target = pool.pool_available_usdc < pool.pool_target_usdc;
495            let utilization_unavailable = pool.pool_utilization.is_none();
496            let above_crisis_cap = match (pool.pool_utilization, pool.crisis_utilization_cap) {
497                (Some(utilization), Some(cap)) => utilization > cap,
498                _ => false,
499            };
500            PmSettlementGatePoolState {
501                underlying: pool.underlying.clone(),
502                pool_available_usdc: pool.pool_available_usdc,
503                pool_target_usdc: pool.pool_target_usdc,
504                pool_capacity_usdc: pool.pool_capacity_usdc,
505                pool_utilization: pool.pool_utilization,
506                active_timing_bridge_usdc: pool.active_timing_bridge_usdc,
507                active_settlement_debt_usdc: pool.active_settlement_debt_usdc,
508                normal_utilization_cap: pool.normal_utilization_cap,
509                crisis_utilization_cap: pool.crisis_utilization_cap,
510                below_target,
511                utilization_unavailable,
512                above_crisis_cap,
513                projection_seq: pool.projection_seq,
514                last_engine_command_id: pool.last_engine_command_id,
515            }
516        })
517        .collect::<Vec<_>>();
518
519    let account_blockers = pm_settlement_account_blockers(account_gate_counts);
520    let recovery_actions = pm_settlement_recovery_action_gate_state(recovery_action_gate_counts);
521    let projection_freshness = pm_settlement_projection_freshness(
522        &pools,
523        &accounts,
524        &recovery_plans,
525        &recovery_action_rows,
526    );
527    let pool_facts_available = pool_gate_counts.total_pools > 0
528        && pool_gate_counts.missing_utilization_pools == 0
529        && pool_gate_counts.missing_crisis_cap_pools == 0;
530    let no_pool_below_target = pool_gate_counts.below_target_pools == 0;
531    let no_pool_above_crisis_cap = pool_gate_counts.above_crisis_cap_pools == 0;
532    let no_account_debt = account_blockers.debt_accounts == 0;
533    let no_active_recovery_accounts = account_blockers.active_recovery_accounts == 0;
534    let no_overdue_bridge = account_blockers.overdue_bridge_accounts == 0;
535    let no_planned_recovery_actions = recovery_actions.planned_actions == 0;
536    let no_submitted_recovery_actions = recovery_actions.submitted_actions == 0;
537    let allowlist_configured = !state
538        .runtime_config
539        .portfolio_margin_settlement_allowlist
540        .is_empty();
541    let projections_present = projection_freshness.projection_rows > 0;
542    let ready_for_single_wallet_smoke =
543        allowlist_configured && pool_facts_available && projections_present;
544    let ready_for_allowlist_expansion = ready_for_single_wallet_smoke
545        && no_pool_below_target
546        && no_pool_above_crisis_cap
547        && no_account_debt
548        && no_active_recovery_accounts
549        && no_overdue_bridge
550        && no_planned_recovery_actions
551        && no_submitted_recovery_actions;
552
553    Ok(SonicJson(ApiResponse::success(
554        PmSettlementGateStateResponse {
555            portfolio_margin_pool_enabled: state.runtime_config.portfolio_margin_pool_enabled,
556            allowlist_count: state
557                .runtime_config
558                .portfolio_margin_settlement_allowlist
559                .len(),
560            allowlist_wallets: state
561                .runtime_config
562                .portfolio_margin_settlement_allowlist
563                .clone(),
564            pools: gate_pools,
565            account_blockers,
566            recovery_actions,
567            projection_freshness,
568            expansion_ready: PmSettlementExpansionReadiness {
569                allowlist_configured,
570                pool_facts_available,
571                projections_present,
572                no_pool_below_target,
573                no_pool_above_crisis_cap,
574                no_account_debt,
575                no_active_recovery_accounts,
576                no_overdue_bridge,
577                no_planned_recovery_actions,
578                no_submitted_recovery_actions,
579                ready_for_single_wallet_smoke,
580                ready_for_allowlist_expansion,
581            },
582        },
583    )))
584}
585
586fn count_to_usize(count: i64) -> usize {
587    usize::try_from(count).expect("Postgres COUNT returned an invalid count")
588}
589
590fn pm_settlement_account_blockers(
591    counts: hypercall_db::PmSettlementAccountGateCounts,
592) -> PmSettlementAccountBlockers {
593    PmSettlementAccountBlockers {
594        total_accounts: count_to_usize(counts.total_accounts),
595        bridged_accounts: count_to_usize(counts.bridged_accounts),
596        debt_accounts: count_to_usize(counts.debt_accounts),
597        overdue_bridge_accounts: count_to_usize(counts.overdue_bridge_accounts),
598        active_recovery_accounts: count_to_usize(counts.active_recovery_accounts),
599        active_bridge_usdc: counts.active_bridge_usdc,
600        active_debt_usdc: counts.active_debt_usdc,
601    }
602}
603
604fn pm_settlement_recovery_action_gate_state(
605    counts: hypercall_db::PmRecoveryActionGateCounts,
606) -> PmSettlementRecoveryActionGateState {
607    PmSettlementRecoveryActionGateState {
608        total_actions: count_to_usize(counts.total_actions),
609        planned_actions: count_to_usize(counts.planned_actions),
610        submitted_actions: count_to_usize(counts.submitted_actions),
611        terminal_actions: count_to_usize(counts.terminal_actions),
612    }
613}
614
615fn pm_settlement_projection_freshness(
616    pools: &[hypercall_db::PmSettlementPoolProjection],
617    accounts: &[hypercall_db::PmSettlementAccountProjection],
618    plans: &[hypercall_db::PmRecoveryPlanProjection],
619    actions: &[hypercall_db::PmRecoveryActionProjection],
620) -> PmSettlementProjectionFreshness {
621    let projection_rows = pools.len() + accounts.len() + plans.len() + actions.len();
622    let max_projection_seq = pools
623        .iter()
624        .map(|row| row.projection_seq)
625        .chain(accounts.iter().map(|row| row.projection_seq))
626        .chain(plans.iter().map(|row| row.projection_seq))
627        .chain(actions.iter().map(|row| row.projection_seq))
628        .max()
629        .unwrap_or_default();
630    let max_engine_command_id = pools
631        .iter()
632        .map(|row| row.last_engine_command_id)
633        .chain(accounts.iter().map(|row| row.last_engine_command_id))
634        .chain(plans.iter().map(|row| row.engine_command_id))
635        .chain(actions.iter().map(|row| row.engine_command_id))
636        .max()
637        .unwrap_or_default();
638
639    PmSettlementProjectionFreshness {
640        projection_rows,
641        max_projection_seq,
642        max_engine_command_id,
643    }
644}