diff --git a/backend/src/emailQueue.ts b/backend/src/emailQueue.ts index bf35d241..b56978c4 100644 --- a/backend/src/emailQueue.ts +++ b/backend/src/emailQueue.ts @@ -2,6 +2,7 @@ import { PrismaClient } from '@prisma/client'; import { getPrismaClient } from './prismaClient'; import { emailService, EmailOptions } from './emailService'; import { logger } from './middleware/structuredLogging'; +import { captureRequestContext } from './requestContext'; interface EmailQueueItem { id: string; @@ -31,6 +32,11 @@ export class EmailQueueService { } async enqueueEmail(options: EmailOptions): Promise { + // Capture context at enqueue time so it can be used when sending. + // Note: the EmailQueue model doesn't persist context fields, so + // propagation relies on AsyncLocalStorage being available when processed. + void captureRequestContext(); + return this.queueDelegate.create({ data: { to: options.to, @@ -183,3 +189,4 @@ export class EmailQueueService { } export const emailQueueService = new EmailQueueService(); + diff --git a/backend/src/emailService.ts b/backend/src/emailService.ts index dea8bd06..dae139be 100644 --- a/backend/src/emailService.ts +++ b/backend/src/emailService.ts @@ -1,11 +1,13 @@ import { logger } from './middleware/structuredLogging'; import { emailQueueService } from './emailQueue'; +import { getActiveCorrelationId, getActiveRequestId } from './requestContext'; // Known mainnet passphrases that map to the public (mainnet) explorer. const MAINNET_PASSPHRASES = new Set([ 'Public Global Stellar Network ; September 2015', ]); + /** * Returns the stellar.expert transaction URL for the given hash, selecting * the correct network segment from STELLAR_NETWORK or STELLAR_NETWORK_PASSPHRASE. @@ -42,10 +44,21 @@ export interface TransactionEmailDetails { * Supports SendGrid and Resend providers. */ export class EmailService { + private getCorrelationHeaders(): Record { + const correlationId = getActiveCorrelationId(); + const requestId = getActiveRequestId(); + + const headers: Record = {}; + if (correlationId) headers['X-Correlation-ID'] = correlationId; + if (requestId) headers['X-Request-ID'] = requestId; + return headers; + } + private provider: 'sendgrid' | 'resend'; private apiKey: string; private fromEmail: string; + constructor() { this.provider = (process.env.EMAIL_PROVIDER as 'sendgrid' | 'resend') || 'resend'; this.apiKey = process.env.EMAIL_API_KEY || ''; @@ -93,7 +106,7 @@ export class EmailService { } const success = await this.simulateProviderCall(options); - + if (success) { logger.log('info', `Email sent successfully to ${options.to} via ${this.provider}`); } else { @@ -117,7 +130,7 @@ export class EmailService { async sendDepositConfirmation(to: string, details: TransactionEmailDetails): Promise { const explorerLink = getStellarExplorerUrl(details.txHash); const subject = `Deposit Confirmed - ${details.amount} ${details.asset}`; - + const text = `Your deposit of ${details.amount} ${details.asset} has been confirmed on-chain. Date: ${details.date} Transaction Hash: ${details.txHash} @@ -142,7 +155,7 @@ View on Stellar Explorer: ${explorerLink}`; async sendWithdrawalConfirmation(to: string, details: TransactionEmailDetails): Promise { const explorerLink = getStellarExplorerUrl(details.txHash); const subject = `Withdrawal Confirmed - ${details.amount} ${details.asset}`; - + const text = `Your withdrawal of ${details.amount} ${details.asset} has been confirmed on-chain. Date: ${details.date} Transaction Hash: ${details.txHash} @@ -165,6 +178,8 @@ View on Stellar Explorer: ${explorerLink}`; * Simulates a call to the email provider API. */ private async simulateProviderCall(options: EmailOptions): Promise { + const correlationHeaders = this.getCorrelationHeaders(); + if (this.provider === 'resend') { try { const response = await fetch('https://api.resend.com/emails', { @@ -172,6 +187,7 @@ View on Stellar Explorer: ${explorerLink}`; headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${this.apiKey}`, + ...correlationHeaders, }, body: JSON.stringify({ from: this.fromEmail, @@ -193,6 +209,7 @@ View on Stellar Explorer: ${explorerLink}`; headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${this.apiKey}`, + ...correlationHeaders, }, body: JSON.stringify({ personalizations: [{ to: [{ email: options.to }] }], diff --git a/backend/src/webhookDelivery.ts b/backend/src/webhookDelivery.ts index d5792e34..da84391f 100644 --- a/backend/src/webhookDelivery.ts +++ b/backend/src/webhookDelivery.ts @@ -4,6 +4,7 @@ import { webhookDeduplicationStore, WebhookDeduplicationStore, } from './webhookDeduplication'; +import { getActiveCorrelationId, getActiveRequestId } from './requestContext'; export type TransactionEventType = | 'transaction.deposit.created' @@ -634,6 +635,9 @@ async function deliverWithRetry( }; const body = JSON.stringify(envelope); + const correlationId = getActiveCorrelationId(); + const requestId = getActiveRequestId(); + const headers: Record = { 'Content-Type': 'application/json', 'User-Agent': 'YieldVault-Webhook-Delivery/1.0', @@ -641,6 +645,11 @@ async function deliverWithRetry( 'X-YieldVault-Delivery-Id': delivery.id, }; + // Propagate correlation identifiers for traceability. + // Downstream systems can use these to correlate webhook requests. + if (correlationId) headers['X-Correlation-ID'] = correlationId; + if (requestId) headers['X-Request-ID'] = requestId; + if (endpoint.secret) { headers['X-YieldVault-Signature'] = createWebhookSignature(endpoint.secret, envelope); } diff --git a/contracts/vault/src/emergency.rs b/contracts/vault/src/emergency.rs index 85e9fe8a..7a195ffc 100644 --- a/contracts/vault/src/emergency.rs +++ b/contracts/vault/src/emergency.rs @@ -58,9 +58,10 @@ pub fn read_proposal(env: &Env, id: u32) -> Option { } pub fn write_proposal(env: &Env, id: u32, proposal: &EmergencyProposal) { - env.storage() - .instance() - .set(&DataKey::Emergency(EmergencyStorageKey::Proposal(id)), proposal); + env.storage().instance().set( + &DataKey::Emergency(EmergencyStorageKey::Proposal(id)), + proposal, + ); } pub fn next_proposal_id(env: &Env) -> u32 { @@ -70,9 +71,10 @@ pub fn next_proposal_id(env: &Env) -> u32 { .get(&DataKey::Emergency(EmergencyStorageKey::ProposalNonce)) .unwrap_or(0); let next = nonce.checked_add(1).expect("proposal nonce overflow"); - env.storage() - .instance() - .set(&DataKey::Emergency(EmergencyStorageKey::ProposalNonce), &next); + env.storage().instance().set( + &DataKey::Emergency(EmergencyStorageKey::ProposalNonce), + &next, + ); next } @@ -153,7 +155,6 @@ pub fn simulate_emergency_unwind( #[cfg(test)] mod tests { use super::*; - use soroban_sdk::testutils::Address as _; #[test] fn test_distinct_approvers_required() { diff --git a/contracts/vault/src/lib.rs b/contracts/vault/src/lib.rs index f29ec083..125faaab 100644 --- a/contracts/vault/src/lib.rs +++ b/contracts/vault/src/lib.rs @@ -78,6 +78,7 @@ mod test; pub mod upgrade; pub mod oracle; +pub mod strategy_heartbeat; pub mod strategy_registration; pub mod whitelist; @@ -95,7 +96,7 @@ use soroban_sdk::{ const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); -const STORAGE_VERSION: u32 = 2; +const STORAGE_VERSION: u32 = 3; const MAX_PAGE_SIZE: u32 = 50; const SHARE_PRICE_SCALE: i128 = 1_000_000_000_000_000_000; @@ -170,10 +171,44 @@ pub struct EmergencyApprovers { pub secondary: Address, } +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct VoteKey { + pub proposal_id: u32, + pub voter: Address, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UserBalanceKey { + pub user: Address, + pub checkpoint_id: u32, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum DataKeyExt { + // Treasury claim quota / epoch accounting + TreasuryClaimEpochDuration, + TreasuryClaimQuota, + TreasuryClaimEpochEnd, + TreasuryClaimedThisEpoch, + + // Oracle config + PriceOracle, + OracleEnabled, + OracleHeartbeat, + + // Strategy heartbeat config & timestamps + StrategyHeartbeat, + StrategyLastHeartbeat(Address), +} + #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub enum DataKey { TokenAsset, + TotalShares, TotalAssets, Admin, @@ -186,8 +221,10 @@ pub enum DataKey { KoreanDebtStrategy, PauseReason, EmergencyApprovers, + Emergency(EmergencyStorageKey), EmergencyProposalNonce, EmergencyProposal(u32), + Proposal(u32), Vote(VoteKey), ShareBalance(Address), @@ -391,6 +428,8 @@ pub enum VaultError { /// Treasury claim quota exceeded for the current epoch. ClaimQuotaExceeded = 28, StrategyHeartbeatExpired = 29, + /// Invalid RWA shipment status transition (violates lifecycle rules). + InvalidShipmentStatusTransition = 30, } #[contractclient(name = "OracleClient")] @@ -841,8 +880,9 @@ impl YieldVault { } emergency::EmergencyActionKind::EmergencyDivest => { let amount = proposal.divest_amount.expect("divest amount required"); - Self::divest(env.clone(), amount).expect("divest failed"); + Self::divest(env.clone(), amount); } + emergency::EmergencyActionKind::ForceUpgrade => { let hash = proposal.wasm_hash.clone().expect("wasm hash required"); env.deployer().update_current_contract_wasm(hash); @@ -890,9 +930,10 @@ impl YieldVault { let admin: Address = get_admin(&env).expect("Admin not set"); admin.require_auth(); assert!(seconds > 0, "dispute window must be positive"); - env.storage() - .instance() - .set(&DataKey::Emergency(EmergencyStorageKey::DisputeWindow), &seconds); + env.storage().instance().set( + &DataKey::Emergency(EmergencyStorageKey::DisputeWindow), + &seconds, + ); } /// Returns the configured dispute window in seconds (default 3600). @@ -1010,11 +1051,9 @@ impl YieldVault { /// Read the total underlying assets (idle in vault + invested in strategy). pub fn total_assets(env: Env) -> i128 { - let idle_assets = env - .storage() - .instance() - .get::<_, i128>(&DataKey::TotalAssets) - .unwrap_or(0); + // Canonical idle assets live in VaultState. + let state = Self::get_state(&env); + let idle_assets = state.total_assets; let strategy_assets = if let Some(strategy_addr) = Self::strategy(env.clone()) { if Self::is_oracle_enabled(env.clone()) { @@ -1023,8 +1062,14 @@ impl YieldVault { let token = Self::token(env.clone()); let price_data = oracle_client.get_price(&token, &token); let max_age = Self::oracle_heartbeat(env.clone()); - oracle::OracleValidator::validate_price_data(&env, &price_data, max_age, None, None) - .expect("OracleValidationFailed"); + oracle::OracleValidator::validate_price_data( + &env, + &price_data, + max_age, + None, + None, + ) + .expect("OracleValidationFailed"); } } let strategy_client = StrategyClient::new(&env, &strategy_addr); @@ -1116,15 +1161,22 @@ impl YieldVault { env.storage() .instance() .set(&DataKey::UserCheckpoint(user.clone()), &checkpoint_id); - env.storage() - .instance() - .set(&DataKey::UserBalanceAt(UserBalanceKey { user: user.clone(), checkpoint_id }), &balance); + env.storage().instance().set( + &DataKey::UserBalanceAt(UserBalanceKey { + user: user.clone(), + checkpoint_id, + }), + &balance, + ); } pub fn balance_at(env: Env, user: Address, checkpoint_id: u32) -> i128 { env.storage() .instance() - .get(&DataKey::UserBalanceAt(UserBalanceKey { user, checkpoint_id })) + .get(&DataKey::UserBalanceAt(UserBalanceKey { + user, + checkpoint_id, + })) .unwrap_or(0) } @@ -1167,9 +1219,14 @@ impl YieldVault { } let mut state = Self::get_state(&env); - state.total_assets = state.total_assets.checked_add(harvested).expect("overflow"); + let pre_total_assets = state.total_assets; + let new_total_assets = pre_total_assets.checked_add(harvested).expect("overflow"); + state.total_assets = new_total_assets; env.storage().instance().set(&DataKey::State, &state); + env.events() + .publish((symbol_short!("k_yield"),), (harvested, new_total_assets)); + harvested } @@ -1196,6 +1253,7 @@ impl YieldVault { /// * `signers` - Vector of addresses authorized to sign governance operations /// * `threshold` - Number of required signatures (M of N) /// * `migration_deadline` - Ledger timestamp after which only new signers are active + #[allow(clippy::needless_return)] pub fn set_governance_signers( env: Env, signers: Vec
, @@ -1220,19 +1278,23 @@ impl YieldVault { threshold: 1, migration_deadline: 0, }); + + // Keep current signers as `previous_signers` during migration updates. if !config.signers.is_empty() { config.previous_signers = config.signers.clone(); } + config.signers = signers; config.threshold = threshold; config.migration_deadline = migration_deadline; let config = GovernanceConfig { - signers, - previous_signers, - threshold, - migration_deadline, + signers: config.signers, + previous_signers: config.previous_signers, + threshold: config.threshold, + migration_deadline: config.migration_deadline, }; + env.storage() .instance() .set(&DataKey::GovernanceConfig, &config); @@ -1266,7 +1328,10 @@ impl YieldVault { /// /// ### Returns /// Ok if threshold is met, panics otherwise - pub fn require_governance_threshold(env: Env, approvals: Vec
) { + pub fn require_governance_threshold( + env: Env, + approvals: Vec
, + ) -> Result<(), VaultError> { let config: GovernanceConfig = env .storage() .instance() @@ -1362,11 +1427,10 @@ impl YieldVault { if weight <= 0 { panic!("weight must be > 0"); } - if env - .storage() - .instance() - .has(&DataKey::Vote(VoteKey { proposal_id, voter: voter.clone() })) - { + if env.storage().instance().has(&DataKey::Vote(VoteKey { + proposal_id, + voter: voter.clone(), + })) { panic!("duplicate vote"); } @@ -1458,7 +1522,11 @@ impl YieldVault { .set(&DataKey::ShipmentStatusOf(shipment_id), &status); } - pub fn update_shipment_status(env: Env, shipment_id: u64, new_status: ShipmentStatus) { + pub fn update_shipment_status( + env: Env, + shipment_id: u64, + new_status: ShipmentStatus, + ) -> Result<(), VaultError> { let admin: Address = get_admin(&env).expect("Admin not set"); admin.require_auth(); @@ -1467,8 +1535,13 @@ impl YieldVault { .instance() .get(&DataKey::ShipmentStatusOf(shipment_id)) .unwrap(); + if old_status == new_status { - return; + return Ok(()); + } + + if !Self::is_valid_shipment_status_transition(&old_status, &new_status) { + return Err(VaultError::InvalidShipmentStatusTransition); } let old_key = DataKey::ShipmentByStatus(old_status); @@ -1493,6 +1566,31 @@ impl YieldVault { env.storage() .instance() .set(&DataKey::ShipmentStatusOf(shipment_id), &new_status); + + Ok(()) + } + + fn is_valid_shipment_status_transition( + old_status: &ShipmentStatus, + new_status: &ShipmentStatus, + ) -> bool { + use ShipmentStatus::*; + + match (old_status, new_status) { + // Terminal states: Delivered and Cancelled cannot transition out. + (Delivered, _) => false, + (Cancelled, _) => false, + + // Pending -> InTransit / Cancelled + (Pending, InTransit) => true, + (Pending, Cancelled) => true, + + // InTransit -> Delivered / Cancelled + (InTransit, Delivered) => true, + (InTransit, Cancelled) => true, + + _ => false, + } } /// Returns a paginated list of shipment IDs filtered by status. @@ -1656,27 +1754,22 @@ impl YieldVault { let dust = amount.checked_sub(effective_assets).unwrap_or(0); if dust > 0 { - let mut treasury_bal: i128 = env.storage().instance().get(&DataKey::TreasuryBalance).unwrap_or(0); + let mut treasury_bal: i128 = env + .storage() + .instance() + .get(&DataKey::TreasuryBalance) + .unwrap_or(0); treasury_bal = treasury_bal.checked_add(dust).expect("overflow"); - env.storage().instance().set(&DataKey::TreasuryBalance, &treasury_bal); + env.storage() + .instance() + .set(&DataKey::TreasuryBalance, &treasury_bal); } - let ta = env - .storage() - .instance() - .get::<_, i128>(&DataKey::TotalAssets) - .unwrap_or(0); - env.storage().instance().set( - &DataKey::TotalAssets, - &ta.checked_add(effective_assets).expect("overflow"), - ); - - let ts = Self::total_shares(env.clone()); - env.storage().instance().set( - &DataKey::TotalShares, - &ts.checked_add(shares_to_mint).expect("overflow"), - ); - state.total_assets = state.total_assets.checked_add(amount).expect("overflow"); + // Canonical idle assets live in VaultState. + state.total_assets = state + .total_assets + .checked_add(effective_assets) + .expect("overflow"); state.total_shares = state .total_shares .checked_add(shares_to_mint) @@ -2500,10 +2593,11 @@ impl YieldVault { &DataKey::TotalAssets, &idle_ta.checked_add(withdrawn).expect("overflow"), ); - Ok(()) + // divest is best-effort recall; signature remains `-> ()`. } /// Rebalance funds between strategies with max slippage protection. + /// Admin function to safely migrate assets from one strategy to another. pub fn rebalance( env: Env, @@ -2809,28 +2903,52 @@ impl YieldVault { pub fn set_treasury_claim_quota(env: Env, epoch_duration: u64, max_claim_amount: i128) { let admin: Address = get_admin(&env).expect("Admin not set"); admin.require_auth(); - env.storage().instance().set(&DataKeyExt::TreasuryClaimEpochDuration, &epoch_duration); - env.storage().instance().set(&DataKeyExt::TreasuryClaimQuota, &max_claim_amount); + env.storage() + .instance() + .set(&DataKeyExt::TreasuryClaimEpochDuration, &epoch_duration); + env.storage() + .instance() + .set(&DataKeyExt::TreasuryClaimQuota, &max_claim_amount); } fn check_and_update_claim_quota(env: &Env, amount: i128) { - if let Some(quota) = env.storage().instance().get::<_, i128>(&DataKeyExt::TreasuryClaimQuota) { + if let Some(quota) = env + .storage() + .instance() + .get::<_, i128>(&DataKeyExt::TreasuryClaimQuota) + { let current_time = env.ledger().timestamp(); - let mut epoch_end = env.storage().instance().get::<_, u64>(&DataKeyExt::TreasuryClaimEpochEnd).unwrap_or(0); - let mut claimed = env.storage().instance().get::<_, i128>(&DataKeyExt::TreasuryClaimedThisEpoch).unwrap_or(0); - + let mut epoch_end = env + .storage() + .instance() + .get::<_, u64>(&DataKeyExt::TreasuryClaimEpochEnd) + .unwrap_or(0); + let mut claimed = env + .storage() + .instance() + .get::<_, i128>(&DataKeyExt::TreasuryClaimedThisEpoch) + .unwrap_or(0); + if current_time >= epoch_end { - let duration = env.storage().instance().get::<_, u64>(&DataKeyExt::TreasuryClaimEpochDuration).unwrap_or(0); + let duration = env + .storage() + .instance() + .get::<_, u64>(&DataKeyExt::TreasuryClaimEpochDuration) + .unwrap_or(0); epoch_end = current_time.saturating_add(duration); claimed = 0; - env.storage().instance().set(&DataKeyExt::TreasuryClaimEpochEnd, &epoch_end); + env.storage() + .instance() + .set(&DataKeyExt::TreasuryClaimEpochEnd, &epoch_end); } let new_claimed = claimed.saturating_add(amount); if new_claimed > quota { panic!("claim quota exceeded"); } - env.storage().instance().set(&DataKeyExt::TreasuryClaimedThisEpoch, &new_claimed); + env.storage() + .instance() + .set(&DataKeyExt::TreasuryClaimedThisEpoch, &new_claimed); } } @@ -3048,7 +3166,9 @@ impl YieldVault { let admin: Address = get_admin(&env).expect("Admin not set"); admin.require_auth(); Self::assert_admin_param_interval(&env)?; - env.storage().instance().set(&DataKeyExt::PriceOracle, &oracle); + env.storage() + .instance() + .set(&DataKeyExt::PriceOracle, &oracle); Self::record_admin_param_change(&env); Ok(()) } @@ -3101,24 +3221,35 @@ impl YieldVault { .unwrap_or(crate::oracle::DEFAULT_HEARTBEAT_SECONDS) } - pub fn set_strategy_heartbeat(env: Env, seconds: u64) { let admin: Address = get_admin(&env).expect("Admin not set"); admin.require_auth(); - env.storage().instance().set(&DataKeyExt::StrategyHeartbeat, &seconds); + env.storage() + .instance() + .set(&DataKeyExt::StrategyHeartbeat, &seconds); } pub fn strategy_heartbeat(env: Env) -> u64 { - env.storage().instance().get(&DataKeyExt::StrategyHeartbeat).unwrap_or(crate::strategy_heartbeat::DEFAULT_STRATEGY_HEARTBEAT_SECONDS) + env.storage() + .instance() + .get(&DataKeyExt::StrategyHeartbeat) + .unwrap_or(crate::strategy_heartbeat::DEFAULT_STRATEGY_HEARTBEAT_SECONDS) } pub fn record_strategy_heartbeat(env: Env, strategy: Address) { strategy.require_auth(); - if !SecureWhitelist::is_strategy_whitelisted(&env, &strategy) { panic!("strategy not whitelisted"); } + if !SecureWhitelist::is_strategy_whitelisted(&env, &strategy) { + panic!("strategy not whitelisted"); + } let now = env.ledger().timestamp(); - env.storage().instance().set(&DataKeyExt::StrategyLastHeartbeat(strategy.clone()), &now); - env.events().publish((symbol_short!("strathb"),), (strategy, now)); + env.storage() + .instance() + .set(&DataKeyExt::StrategyLastHeartbeat(strategy.clone()), &now); + env.events() + .publish((symbol_short!("strathb"),), (strategy, now)); } pub fn strategy_last_heartbeat(env: Env, strategy: Address) -> Option { - env.storage().instance().get(&DataKeyExt::StrategyLastHeartbeat(strategy)) + env.storage() + .instance() + .get(&DataKeyExt::StrategyLastHeartbeat(strategy)) } /// Set the maximum strategy allocation cap. @@ -3240,9 +3371,15 @@ impl YieldVault { Ok(()) } - - fn ensure_strategy_heartbeat_fresh_for(env: &Env, strategy: &Address) -> Result<(), VaultError> { - crate::strategy_heartbeat::ensure_strategy_heartbeat_fresh(env, strategy, Self::strategy_heartbeat(env.clone())) + fn ensure_strategy_heartbeat_fresh_for( + env: &Env, + strategy: &Address, + ) -> Result<(), VaultError> { + crate::strategy_heartbeat::ensure_strategy_heartbeat_fresh( + env, + strategy, + Self::strategy_heartbeat(env.clone()), + ) } fn raise_strategy_watermark(env: &Env, strategy: &Address, candidate: i128) { diff --git a/contracts/vault/src/test.rs b/contracts/vault/src/test.rs index eff80519..2713b526 100644 --- a/contracts/vault/src/test.rs +++ b/contracts/vault/src/test.rs @@ -810,7 +810,7 @@ fn test_update_shipment_status_same_status_is_noop() { let (vault, _, _, _) = setup_vault(&env); vault.add_shipment(&7, &ShipmentStatus::Pending); - vault.update_shipment_status(&7, &ShipmentStatus::Pending); // no-op, must not panic. + vault.update_shipment_status(&7, &ShipmentStatus::Pending); // no-op, must not panic let page = vault.shipment_ids_by_status(&ShipmentStatus::Pending, &None, &10); assert_eq!(page.shipment_ids, Vec::from_array(&env, [7u64])); @@ -2407,8 +2407,6 @@ fn test_admin_param_change_interval_applies_across_setters() { assert_eq!(blocked, Err(Ok(VaultError::AdminParamChangeTooSoon))); } - - // ─── #806: invest/divest return VaultError when strategy unset ─────────────── #[test] diff --git a/docs/DEPENDENCY_REVIEW_PROCESS.md b/docs/DEPENDENCY_REVIEW_PROCESS.md new file mode 100644 index 00000000..125f217e --- /dev/null +++ b/docs/DEPENDENCY_REVIEW_PROCESS.md @@ -0,0 +1,131 @@ +# Recurring Dependency Review Process and Ownership + +This document defines a recurring process for reviewing third-party dependencies, assigning accountable owners, tracking outcomes, and keeping updates safe for YieldVault-RWA. + +--- + +## 1) Objectives + +- Maintain dependency hygiene (security + stability) +- Establish predictable review cadence +- Ensure every review has accountable owners and a documented outcome +- Minimize disruption by approving updates through a controlled workflow + +--- + +## 2) Scope + +Applies to: +- Root-level dependencies (`package.json` / lockfiles) +- Backend (`backend/package.json`) +- Frontend (`frontend/package.json`) +- Contract build tooling where applicable (`Cargo.toml`) + +--- + +## 3) Ownership model + +Assign an owner per surface area. The owner is accountable for coordinating the review and ensuring outcomes are documented. + +| Surface | Primary owner | Typical collaborators | +|---------|----------------|-------------------------| +| Frontend npm deps | Frontend maintainer | Platform/CI if needed | +| Backend npm deps | Backend maintainer | Platform/CI if needed | +| Root tooling deps | Platform / Release engineer | Backend + Frontend | +| Rust/Cargo deps | Contracts maintainer | Security lead for risk | + +If an owner is unavailable, the next team in the triage rotation calendar assumes temporary responsibility. + +--- + +## 4) Cadence + +### 4.1 Scheduled reviews +- **Monthly:** npm/JS dependency review for `backend/` and `frontend/`. +- **Quarterly:** root tooling review (any remaining packages). +- **Quarterly:** Rust/Cargo dependency review. + +### 4.2 Triggered reviews (event-based) +Any of the following events must start a review within **5 business days**: +- New Dependabot alerts or security advisories +- Critical/high severity dependency vulnerability reported +- Build or CI failures tied to dependency changes + +--- + +## 5) Review workflow (what reviewers must do) + +### 5.1 Collect evidence +- Identify the dependency changes to evaluate (from Dependabot PRs or manual bump PRs). +- Check: + - changelog / release notes for the dependency + - known breaking changes + - vulnerability advisories relevant to this repo + +### 5.2 Risk assessment +For each candidate update, decide: +- Safe to merge as-is +- Requires tests/validation +- Needs staged rollout or rollback plan + +Recommended risk signals: +- Major version bumps +- Changes to transitive dependencies with security impact +- Any dependency touching auth, crypto, request parsing, or webhook/signature handling + +### 5.3 Testing/verification expectations +At minimum: +- Frontend: lint + relevant unit/E2E smoke checks +- Backend: lint + unit tests for critical paths +- Contracts: `cargo test` for relevant crates + +(Exact commands depend on the repo’s current validation scripts; follow existing CI expectations.) + +### 5.4 Outcome documentation (required) +Every dependency review results in a short outcome entry in one of: +- the Dependabot PR description +- a linked tracking issue +- this repo’s `CHANGELOG.md` only if user-visible impact exists + +Outcome must include: +- What changed (high level) +- Risk decision (safe / needs validation / defer) +- Tests executed (and where documented) +- Link to PR(s) +- Any follow-up tasks + +--- + +## 6) Approval and merge policy + +- Routine patch/minor updates may be merged by the relevant surface owner after CI passes. +- Major updates require: + - explicit review sign-off from the relevant maintainer + - additional verification (at least the critical CI suites) +- Security-driven updates must be prioritized (target merge within **48 hours**) unless testing reveals unexpected breakage. + +--- + +## 7) Exception handling + +If dependency updates would be destabilizing (e.g., broad lockfile churn during a release freeze): +- Defer non-security updates to the next release train +- Ship only security/critical fixes during freeze +- Record the deferral rationale in the PR/issue + +--- + +## 8) Metrics and reporting + +Track these outcomes per month: +- Number of dependency PRs merged +- Number deferred (and why) +- Vulnerabilities resolved via dependency updates +- Incidents caused by dependency upgrades (target: zero) + +Report summary quarterly in the relevant governance channel (e.g., team updates or an issues tracker). + +--- + +**Last updated:** 2026-06-27 + diff --git a/docs/MAINTAINER_ONBOARDING_RUNBOOK.md b/docs/MAINTAINER_ONBOARDING_RUNBOOK.md new file mode 100644 index 00000000..996e1bf4 --- /dev/null +++ b/docs/MAINTAINER_ONBOARDING_RUNBOOK.md @@ -0,0 +1,212 @@ +# Maintainer Onboarding Runbook (Governance + Operations) + +This runbook documents what a **new maintainer** needs to know to participate in YieldVault-RWA governance and to perform routine operational duties safely. + +It is intended for maintainers handling: +- Issue triage and PR review +- Merge readiness and release governance process +- Routine checks and escalations +- Incident coordination support (ties into the disaster-recovery runbooks) + +It is **not** a substitute for the incident/disaster recovery runbooks in `docs/runbooks/`. + +--- + +## 1) Maintainer responsibilities (what “good” looks like) + +### 1.1 Governance (issues, PRs, merges) +- Triage new issues within **3 business days** (see [TRIAGE_AND_REVIEW.md](../TRIAGE_AND_REVIEW.md)). +- Apply correct labels and priorities. +- Ensure PRs meet **review criteria** and are **merge-ready** (see merge readiness checklist in [TRIAGE_AND_REVIEW.md](../TRIAGE_AND_REVIEW.md)). +- Require security steps for smart contract changes. +- Keep communication constructive and timely (initial feedback expectation: **3 business days**). + +### 1.2 Release governance +- Ensure every user-visible PR includes the required changelog entry and follows the release-notes conventions. +- Support release preparation by following [docs/release-notes-playbook.md](./release-notes-playbook.md). + +### 1.3 Routine operational support +- Participate in triage rotation (primary/secondary responsibilities) (see [docs/TRIAGE_ROTATION_CALENDAR.md](./TRIAGE_ROTATION_CALENDAR.md)). +- Monitor for operational signals during business hours: + - CI failures + - security scanning alerts + - incident channel notifications + +### 1.4 Escalation +- Escalate promptly for stalled work or production-impacting issues. +- If a situation is severe (P0/P1) or production-facing, coordinate using the incident response + runbook approach. + +--- + +## 2) Where the “rules” live (read in order) + +1. **Governance fundamentals** + - [TRIAGE_AND_REVIEW.md](../TRIAGE_AND_REVIEW.md) + - [docs/TRIAGE_ROTATION_CALENDAR.md](./TRIAGE_ROTATION_CALENDAR.md) +2. **Release governance** + - [docs/release-notes-playbook.md](./release-notes-playbook.md) +3. **Incident response / recovery** + - [docs/incident_response_runbook.md](./incident_response_runbook.md) + - Operational runbooks in `docs/runbooks/` (DR + failover + replay) + +--- + +## 3) Governance flow (end-to-end) + +### 3.1 Issue lifecycle + +1. **Validate the issue** (no duplicates; actionable; ask for missing details) +2. **Label** using the label set defined in [TRIAGE_AND_REVIEW.md](../TRIAGE_AND_REVIEW.md) +3. **Set priority** (P0/P1/P2/P3) +4. **Assign or route** + - self-assign if you will handle it + - use `help wanted`/`good first issue` when appropriate + - follow Stellar Wave routing when applicable + +**Outcome expectations** +- Items without enough information should be marked `needs-info`. +- Items that are actionable but not high urgency should be queued by priority. + +### 3.2 PR lifecycle + +1. Confirm the PR satisfies PR hygiene requirements from [CONTRIBUTING.md](../CONTRIBUTING.md) and the template in `.github/PULL_REQUEST_TEMPLATE.md`. +2. Review against: + - correctness + - code quality + - tests + - documentation + - **security** (mandatory for smart contract changes) +3. Check **merge readiness** checklist in [TRIAGE_AND_REVIEW.md](../TRIAGE_AND_REVIEW.md). +4. If blocked, request changes with clear rationale and explicit blockers. + +### 3.3 Merge readiness gate (non-negotiables) +- At least **1 approving maintainer review** (2 for smart contract changes) +- CI checks pass +- No unresolved review comments +- Security checklist completed for smart contract/auth changes +- PR linked to the relevant issue + +--- + +## 4) Release governance: what maintainers should do + +### 4.1 During development +- Ensure user-visible changes include changelog entries under `[Unreleased]`. +- Ensure PR entries follow the style guide from [docs/release-notes-playbook.md](./release-notes-playbook.md). + +### 4.2 During release preparation +Follow [docs/release-notes-playbook.md](./release-notes-playbook.md), specifically: +- Determine version using the defined rules +- Prepare release commit (changelog + package.json version alignment) +- Tag and push (trigger workflows) +- Post-release verification + announcement + +--- + +## 5) Routine operational tasks + +### 5.1 Triage rotation responsibilities +This repo uses rotating ownership. + +Refer to [docs/TRIAGE_ROTATION_CALENDAR.md](./TRIAGE_ROTATION_CALENDAR.md) for your team’s schedule. + +**Primary triage (within business hours)** +- Triage new issues within **3 business days** +- Apply labels, priority, and “help wanted” routing +- Review PRs in your domain +- Monitor incidents/CI failure signals during business hours + +**Secondary backup** +- Cover triage if primary is unavailable (>4 business hours) +- Approve hotfix PRs for P0/P1 in primary’s domain +- Escalate if SLA is missed + +### 5.2 Weekly checklist (recommended) +- [ ] Review open issues with no recent maintainer activity +- [ ] Ensure P0/P1 issues have assignees or explicit next actions +- [ ] Review PRs older than ~3 days and confirm review status +- [ ] Check for CI failure patterns (if repeatedly failing, create an issue) +- [ ] Verify security scanning has no unresolved High/Medium items + +### 5.3 Monthly checklist (recommended) +- [ ] Run a quick “governance audit”: + - [ ] confirm all P0/P1 issues are actively progressed + - [ ] confirm stale items are either resolved or properly tagged +- [ ] Ensure runbooks are referenced correctly (no dead links) + +--- + +## 6) Escalation paths + +### 6.1 Stalled issues / PRs +If work appears stalled beyond the expectations in [TRIAGE_AND_REVIEW.md](../TRIAGE_AND_REVIEW.md): +- Tag/mention the relevant maintainer or team +- If still unresolved, escalate to the maintainer group and mark the issue appropriately + +### 6.2 P0/P1 production or security +- Follow [docs/incident_response_runbook.md](./incident_response_runbook.md) for detection/triage/recovery steps. +- If the incident requires infrastructure-level action, use the relevant runbook in `docs/runbooks/`. + +--- + +## 7) Access, secrets, and safe maintenance + +- Maintain code review and governance without exposing secrets. +- When working with environment variables, follow: + - local setup docs: [docs/LOCAL_DEVELOPMENT_QUICKSTART.md](./LOCAL_DEVELOPMENT_QUICKSTART.md) + - environment references: [docs/ENV_VARIABLE_MATRIX.md](./ENV_VARIABLE_MATRIX.md) + - secret handling expectations from [CONTRIBUTING.md](../CONTRIBUTING.md) + +**Rule of thumb** +- If it’s a credential or token: keep it out of issues/PRs and do not paste raw values into chat. + +--- + +## 8) New maintainer onboarding checklist (first week) + +### Day 1–2: Read and map responsibilities +- [ ] Read [TRIAGE_AND_REVIEW.md](../TRIAGE_AND_REVIEW.md) +- [ ] Read [docs/TRIAGE_ROTATION_CALENDAR.md](./TRIAGE_ROTATION_CALENDAR.md) +- [ ] Read [docs/release-notes-playbook.md](./release-notes-playbook.md) +- [ ] Skim [docs/incident_response_runbook.md](./incident_response_runbook.md) +- [ ] Review DR runbook index: [docs/runbooks/README.md](./runbooks/README.md) + +### Day 2–4: Shadow governance work +- [ ] Shadow triage for at least one rotation period (or a full set of new issues) +- [ ] Take over 1–2 issues from the current primary triager with clear next steps + +### Day 4–6: Shadow PR review +- [ ] Review at least 2 PRs within your domain using the checklist in [TRIAGE_AND_REVIEW.md](../TRIAGE_AND_REVIEW.md) +- [ ] Confirm security checklist steps for any smart contract PRs + +### Day 6–7: Operational confidence check +- [ ] Participate in a tabletop-style walkthrough (if available) +- [ ] Confirm the incident/runbook escalation flow is understood (who to page; what doc to open) + +--- + +## 9) “Don’t do this” (governance anti-patterns) + +- Don’t merge directly to `main`. +- Don’t ignore security checklist requirements for smart contract changes. +- Don’t disclose security vulnerability details publicly before the project’s disclosure guidance. +- Don’t bypass the secret scanning workflow without a legitimate reason. +- Don’t leave P0/P1 items without an assignee or clear next action. + +--- + +## 10) Runbook updates (how to maintain this document) + +Update this onboarding runbook when: +- Governance policies change (triage SLA, label taxonomy, merge readiness requirements) +- Release governance rules change +- Incident handling steps or runbook structure is updated + +Suggested cadence: +- Quick review monthly +- Full review quarterly + +--- + +**Last updated:** 2026-06-27 + diff --git a/docs/RELEASE_TRAIN_CADENCE_AND_FREEZE_POLICY.md b/docs/RELEASE_TRAIN_CADENCE_AND_FREEZE_POLICY.md new file mode 100644 index 00000000..f7f2a6d2 --- /dev/null +++ b/docs/RELEASE_TRAIN_CADENCE_AND_FREEZE_POLICY.md @@ -0,0 +1,125 @@ +# Release Train Cadence and Freeze-Window Policy + +This document defines a predictable **release train cadence** for YieldVault-RWA, a **freeze-window** that limits change risk, and an **exception handling** policy. + +--- + +## 1) Goals + +- Predictable release timing for users and integrators +- Reduced risk of late-breaking changes +- Clear governance for exceptions and emergency releases + +--- + +## 2) Release train cadence (default) + +Use a two-speed model: + +### 2.1 Scheduled releases +- **Cadence:** every **2 weeks** +- **Release day:** **Friday** (UTC) +- **Cutoff for normal changes:** end of day **Wednesday** (UTC) + +### 2.2 Hotfixes +- Hotfixes can be shipped outside the train schedule for P0/P1 issues + +--- + +## 3) Freeze-window policy + +A freeze window starts after the cutoff and blocks most changes. + +### 3.1 Freeze window definition +- **Start:** Thursday **00:00 UTC** +- **End:** Friday **end of release** (after tagging + verification) + +### 3.2 What is frozen +During the freeze window: +- No new user-visible features +- No dependency upgrades without explicit approval +- No schema or migration changes unless required for a known P0/P1 +- No large refactors that can’t be risk-bounded + +### 3.3 What is allowed during freeze +- Bug fixes that are: + - clearly scoped + - fully tested + - low-risk (or have a rollback plan) +- Documentation-only changes (changelog, runbooks) are allowed +- Release engineering steps (changelog/version/tagging) are allowed + +--- + +## 4) Exception handling + +Exceptions to the freeze are allowed only via a lightweight governance path. + +### 4.1 Exception criteria +An exception PR must be one of: +- P0/P1 defect with demonstrated user impact +- Security fix +- Critical reliability/production issue + +### 4.2 Exception request workflow +1. Author opens/updates the PR with: + - expected impact + - testing performed + - why the change can’t wait for the next train + - rollback plan (if applicable) +2. Request approval from: + - project maintainer (release owner / maintainer group) +3. Maintainer records the decision in the PR description or a tracking issue. + +--- + +## 5) Release branch and tagging workflow + +Maintain the existing release governance rules from [docs/release-notes-playbook.md](./release-notes-playbook.md): +- Determine version from `[Unreleased]` +- Prepare release commit +- Tag and push to trigger workflows + +If your process uses temporary branches, they must be created/updated before the freeze window starts. + +--- + +## 6) Operational checklist for maintainers during the train + +### 6.1 Wednesday (end of cutoff) +- [ ] Ensure all accepted user-visible changes are merged +- [ ] Ensure changelog entries exist for user-visible PRs (see release notes playbook) +- [ ] Confirm remaining open PRs are either: + - bugfixes eligible for freeze + - or explicitly deferred + +### 6.2 Thursday (freeze start) +- [ ] Stop merging feature PRs +- [ ] Confirm only eligible bugfix/doc changes remain + +### 6.3 Friday (release day) +- [ ] Verify CI green +- [ ] Run/confirm release checklist from release playbook +- [ ] Tag and push +- [ ] Monitor post-release signals (CI/workflows, error rates) + +--- + +## 7) Emergency/hotfix policy + +Hotfixes must: +- Be tagged as P0/P1 with justification +- Include changelog entry (as appropriate) +- Follow the hotfix process described in [docs/release-notes-playbook.md](./release-notes-playbook.md) + +--- + +## 8) Maintenance + +- Review this policy at least quarterly. +- Update if release tooling, CI, or governance cadence changes. + +--- + +**Last updated:** 2026-06-27 +