diff --git a/.github/workflows/dependency-security.yml b/.github/workflows/dependency-security.yml index 16f125ae0..18804cc58 100644 --- a/.github/workflows/dependency-security.yml +++ b/.github/workflows/dependency-security.yml @@ -79,7 +79,7 @@ jobs: uses: dtolnay/rust-toolchain@stable - name: Install cargo-audit - run: cargo install cargo-audit --locked + uses: taiki-e/install-action@cargo-audit - name: Run Cargo Audit run: cargo audit --deny warnings diff --git a/.github/workflows/load-tests.yml b/.github/workflows/load-tests.yml index 3ec333d8a..ca8ac309a 100644 --- a/.github/workflows/load-tests.yml +++ b/.github/workflows/load-tests.yml @@ -20,11 +20,11 @@ jobs: - name: Run k6 load test uses: grafana/k6-action@v0.3.1 env: - BASE_URL: ${{ secrets.STAGING_BACKEND_URL || 'https://yieldvault-backend-staging.vercel.app' }} + BASE_URL: ${{ vars.STAGING_BACKEND_URL || secrets.STAGING_BACKEND_URL || 'https://yieldvault-backend-staging.vercel.app' }} WALLET_ADDRESS: ${{ secrets.LOAD_TEST_WALLET_ADDRESS }} - ASSET: ${{ secrets.LOAD_TEST_ASSET || 'USDC' }} - AMOUNT: ${{ secrets.LOAD_TEST_AMOUNT || '100.0' }} - METRICS_PATH: ${{ secrets.LOAD_TEST_METRICS_PATH || '/metrics' }} + ASSET: ${{ vars.LOAD_TEST_ASSET || secrets.LOAD_TEST_ASSET || 'USDC' }} + AMOUNT: ${{ vars.LOAD_TEST_AMOUNT || secrets.LOAD_TEST_AMOUNT || '100.0' }} + METRICS_PATH: ${{ vars.LOAD_TEST_METRICS_PATH || secrets.LOAD_TEST_METRICS_PATH || '/metrics' }} with: filename: tests/load/vault-load.test.js diff --git a/.github/workflows/production-deploy.yml b/.github/workflows/production-deploy.yml index fcaa5729f..21fedf7e5 100644 --- a/.github/workflows/production-deploy.yml +++ b/.github/workflows/production-deploy.yml @@ -109,11 +109,11 @@ jobs: - name: Build (production mode) env: - VITE_SOROBAN_RPC_URL: ${{ secrets.PROD_SOROBAN_RPC_URL }} - VITE_STELLAR_NETWORK_PASSPHRASE: ${{ secrets.PROD_STELLAR_NETWORK_PASSPHRASE }} - VITE_VAULT_CONTRACT_ID: ${{ inputs.vault_contract_id || secrets.PROD_VAULT_CONTRACT_ID }} - VITE_API_BASE_URL: ${{ secrets.PROD_API_BASE_URL }} - VITE_SENTRY_DSN: ${{ secrets.PROD_SENTRY_DSN }} + VITE_SOROBAN_RPC_URL: ${{ vars.PROD_SOROBAN_RPC_URL || secrets.PROD_SOROBAN_RPC_URL }} + VITE_STELLAR_NETWORK_PASSPHRASE: ${{ vars.PROD_STELLAR_NETWORK_PASSPHRASE || secrets.PROD_STELLAR_NETWORK_PASSPHRASE }} + VITE_VAULT_CONTRACT_ID: ${{ inputs.vault_contract_id || vars.PROD_VAULT_CONTRACT_ID || secrets.PROD_VAULT_CONTRACT_ID }} + VITE_API_BASE_URL: ${{ vars.PROD_API_BASE_URL || secrets.PROD_API_BASE_URL }} + VITE_SENTRY_DSN: ${{ vars.PROD_SENTRY_DSN || secrets.PROD_SENTRY_DSN }} VITE_SENTRY_ENVIRONMENT: production VITE_SENTRY_TRACES_SAMPLE_RATE: '0.1' VITE_SENTRY_REPLAYS_SESSION_SAMPLE_RATE: '0.1' @@ -167,13 +167,17 @@ jobs: - name: Pull Vercel environment info working-directory: frontend - run: vercel pull --yes --environment=production --token=${{ secrets.VERCEL_TOKEN }} + env: + VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} + run: vercel pull --yes --environment=production - name: Deploy to Vercel (production) id: deploy working-directory: frontend + env: + VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} run: | - DEPLOY_URL=$(vercel deploy --prebuilt --prod --token=${{ secrets.VERCEL_TOKEN }} 2>&1 | tail -1) + DEPLOY_URL=$(vercel deploy --prebuilt --prod 2>&1 | tail -1) echo "production_url=$DEPLOY_URL" >> $GITHUB_OUTPUT echo "Deployed to: $DEPLOY_URL" diff --git a/.github/workflows/rust-security.yml b/.github/workflows/rust-security.yml index 4bf476e0c..1f106e046 100644 --- a/.github/workflows/rust-security.yml +++ b/.github/workflows/rust-security.yml @@ -57,7 +57,9 @@ jobs: working-directory: frontend - name: Install cargo-audit - run: cargo install cargo-audit --version 0.22.1 --locked + uses: taiki-e/install-action@v2 + with: + tool: cargo-audit@0.22.1 - name: Run cargo audit run: | @@ -78,8 +80,15 @@ jobs: run: cargo clippy -p share-price-math --all-targets -- -D warnings working-directory: contracts + - name: Install cargo-deny + uses: taiki-e/install-action@v2 + with: + tool: cargo-deny@0.18.3 + - name: Run cargo-deny (supply chain security) run: | + cargo deny check advisories + cargo deny check licenses cargo install cargo-deny --version 0.18.3 --locked cargo deny check advisories bans licenses sources diff --git a/.github/workflows/rust-wasm.yml b/.github/workflows/rust-wasm.yml index 7a875f138..00befd34d 100644 --- a/.github/workflows/rust-wasm.yml +++ b/.github/workflows/rust-wasm.yml @@ -103,7 +103,7 @@ jobs: ${{ runner.os }}-cargo-fuzz- - name: Install cargo-fuzz - run: cargo install cargo-fuzz --locked + uses: taiki-e/install-action@cargo-fuzz - name: Run share_price_math fuzz target run: | @@ -184,6 +184,9 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v4 with: + node-version: 20 + cache: npm + cache-dependency-path: package-lock.json node-version-file: '.nvmrc' - name: Install root dependencies diff --git a/.github/workflows/staging-deploy.yml b/.github/workflows/staging-deploy.yml index 768fe57ab..4160234a2 100644 --- a/.github/workflows/staging-deploy.yml +++ b/.github/workflows/staging-deploy.yml @@ -33,6 +33,9 @@ jobs: sudo apt-get install -y pkg-config libdbus-1-dev - name: Install Soroban CLI + uses: taiki-e/install-action@v2 + with: + tool: soroban-cli run: | cargo install --locked soroban-cli --version 21.0.0 @@ -54,6 +57,7 @@ jobs: with: node-version-file: '.nvmrc' cache: 'npm' + cache-dependency-path: backend/package-lock.json - name: Deploy Backend if: env.DATABASE_URL != '' && env.VERCEL_TOKEN != '' diff --git a/.github/workflows/test-coverage.yml b/.github/workflows/test-coverage.yml index effb7dd3d..f3cb9db19 100644 --- a/.github/workflows/test-coverage.yml +++ b/.github/workflows/test-coverage.yml @@ -25,6 +25,9 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: + node-version: '20' + cache: npm + cache-dependency-path: backend/package-lock.json node-version-file: '.nvmrc' - name: Install Dependencies @@ -52,6 +55,9 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: + node-version: '20' + cache: npm + cache-dependency-path: frontend/package-lock.json node-version-file: '.nvmrc' - name: Install Dependencies @@ -80,6 +86,9 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: + node-version: '20' + cache: npm + cache-dependency-path: package-lock.json node-version-file: '.nvmrc' - name: Download Backend Coverage Artifact diff --git a/backend/src/middleware/tenantGuard.ts b/backend/src/middleware/tenantGuard.ts index a9269c1ac..d719fa7f7 100644 --- a/backend/src/middleware/tenantGuard.ts +++ b/backend/src/middleware/tenantGuard.ts @@ -56,6 +56,10 @@ export interface TenantGuardOptions { * Defaults to Permission.ADMIN_READ. */ adminBypassPermission?: Permission; + /** + * Whether anonymous requests should be rejected with 401 Unauthorized. + */ + requireAuth?: boolean; } /** @@ -84,12 +88,21 @@ export function tenantGuard(options: TenantGuardOptions) { walletParamPath, allowAdminBypass = false, adminBypassPermission = Permission.ADMIN_READ, + requireAuth = false, } = options; return (req: Request, res: Response, next: NextFunction): void => { // 1. Extract the requested wallet address from the request const rawRequestedWallet = extractFromRequest(req, walletParamPath); if (!rawRequestedWallet || typeof rawRequestedWallet !== 'string') { + if (requireAuth) { + res.status(401).json({ + error: 'Unauthorized', + status: 401, + message: 'Authentication required', + }); + return; + } // If no wallet is requested, this might be a public endpoint or // a different kind of resource – let it pass through for now next(); @@ -97,10 +110,7 @@ export function tenantGuard(options: TenantGuardOptions) { } const requestedWallet = normalizeWalletAddress(rawRequestedWallet); - // 2. Get the authenticated wallet address - const authenticatedWallet = getAuthenticatedWalletAddress(req); - - // 3. Check for admin bypass + // 2. Check for admin bypass const authHeader = req.headers.authorization || ''; const isApiKey = authHeader.match(/^ApiKey\s+(.+)$/i); if (isApiKey && allowAdminBypass && hasPermission(req, adminBypassPermission)) { @@ -109,10 +119,19 @@ export function tenantGuard(options: TenantGuardOptions) { return; } + // 3. Get the authenticated wallet address + const authenticatedWallet = getAuthenticatedWalletAddress(req); + // 4. Check if authenticated wallet matches requested wallet. - // Anonymous requests should be allowed to view public wallet data - // (for example, transaction history or portfolio listings). if (!authenticatedWallet) { + if (requireAuth) { + res.status(401).json({ + error: 'Unauthorized', + status: 401, + message: 'Authentication required', + }); + return; + } next(); return; } diff --git a/backend/src/referralEndpoints.ts b/backend/src/referralEndpoints.ts index 799682538..759b0c73b 100644 --- a/backend/src/referralEndpoints.ts +++ b/backend/src/referralEndpoints.ts @@ -1,3 +1,11 @@ +import { Router, Request, Response } from "express"; +import { referralService } from "./referralService"; +import { logger } from "./middleware/structuredLogging"; +import { normalizeWalletAddress } from "./walletUtils"; +import { cacheMiddleware } from "./middleware/cache"; +import { buildApiErrorBody, type ApiErrorBody } from "./middleware/apiError"; +import { tenantGuard } from "./middleware/tenantGuard"; +import { Permission } from "./middleware/rbac"; import { Router, Request, Response } from 'express'; import { referralService } from './referralService'; import { logger } from './middleware/structuredLogging'; @@ -6,12 +14,13 @@ import { cacheMiddleware } from './middleware/cache'; import { buildApiErrorBody, type ApiErrorBody, sendApiError } from './middleware/apiError'; const router = Router(); -const REFERRAL_CACHE_TTL_MS = parseInt(process.env.CACHE_LIST_ENDPOINTS_TTL_MS || '30000', 10); +const REFERRAL_CACHE_TTL_MS = parseInt(process.env.CACHE_LIST_ENDPOINTS_TTL_MS || "30000", 10); -const WALLET_REQUIRED_MESSAGE = 'Wallet address is required'; -const REFERRAL_STATS_NOT_FOUND_MESSAGE = 'No referral activity found for this wallet'; -const REFERRAL_STATS_ERROR_MESSAGE = 'Failed to fetch referral stats'; -const REFERRAL_CODE_ERROR_MESSAGE = 'Failed to get referral code'; +const WALLET_REQUIRED_MESSAGE = "Wallet address is required"; +const REFERRAL_STATS_NOT_FOUND_MESSAGE = "No referral activity found for this wallet"; +const REFERRAL_STATS_ERROR_MESSAGE = "Failed to fetch referral stats"; +const REFERRAL_CODE_NOT_FOUND_MESSAGE = "No referral code found for this wallet"; +const REFERRAL_CODE_ERROR_MESSAGE = "Failed to get referral code"; export function buildReferralWalletRequiredBody(): ApiErrorBody { return buildApiErrorBody({ status: 400, message: WALLET_REQUIRED_MESSAGE }); @@ -25,6 +34,10 @@ export function buildReferralStatsErrorBody(): ApiErrorBody { return buildApiErrorBody({ status: 500, message: REFERRAL_STATS_ERROR_MESSAGE }); } +export function buildReferralCodeNotFoundBody(): ApiErrorBody { + return buildApiErrorBody({ status: 404, message: REFERRAL_CODE_NOT_FOUND_MESSAGE }); +} + export function buildReferralCodeErrorBody(): ApiErrorBody { return buildApiErrorBody({ status: 500, message: REFERRAL_CODE_ERROR_MESSAGE }); } @@ -34,7 +47,7 @@ export function buildReferralCodeErrorBody(): ApiErrorBody { * /api/v1/referrals/{wallet}: * get: * summary: Get referral stats for a wallet - * description: Returns referral count and total reward earned for the given wallet address. + * description: Returns referral count and total reward earned for the given wallet address. Requires ownership or admin permissions. * tags: [Referrals] * parameters: * - in: path @@ -52,24 +65,52 @@ export function buildReferralCodeErrorBody(): ApiErrorBody { * properties: * referral_count: { type: integer } * total_reward_earned: { type: string } + * 401: + * description: Authentication required + * 403: + * description: Forbidden - cannot access another wallet's referral data * 404: * description: Wallet has no referral activity * 500: * description: Internal server error */ -router.get('/:wallet', cacheMiddleware({ ttl: REFERRAL_CACHE_TTL_MS }), async (req: Request, res: Response) => { - const { wallet } = req.params; +router.get( + "/:wallet", + tenantGuard({ + walletParamPath: "params.wallet", + allowAdminBypass: true, + adminBypassPermission: Permission.ADMIN_READ, + requireAuth: true, + }), + cacheMiddleware({ ttl: REFERRAL_CACHE_TTL_MS }), + async (req: Request, res: Response) => { + const { wallet } = req.params; + if (!wallet) { + return res.status(400).json(buildReferralWalletRequiredBody()); + } + + const normalizedWallet = normalizeWalletAddress(wallet); if (!wallet) { sendApiError(req, res, { status: 400, code: 'BAD_REQUEST', message: WALLET_REQUIRED_MESSAGE }); return; } - const normalizedWallet = normalizeWalletAddress(wallet); + try { + const stats = await referralService.getReferralStats(normalizedWallet); - try { - const stats = await referralService.getReferralStats(normalizedWallet); + if (!stats) { + return res.status(404).json(buildReferralStatsNotFoundBody()); + } + return res.status(200).json(stats); + } catch (error) { + logger.log("error", "Error fetching referral stats", { + error: error instanceof Error ? error.message : String(error), + wallet: normalizedWallet, + }); + return res.status(500).json(buildReferralStatsErrorBody()); + } if (!stats) { sendApiError(req, res, { status: 404, code: 'NOT_FOUND', message: REFERRAL_STATS_NOT_FOUND_MESSAGE }); return; @@ -84,14 +125,14 @@ router.get('/:wallet', cacheMiddleware({ ttl: REFERRAL_CACHE_TTL_MS }), async (r sendApiError(req, res, { status: 500, code: 'INTERNAL_SERVER_ERROR', message: REFERRAL_STATS_ERROR_MESSAGE }); return; } -}); +); /** * @openapi * /api/v1/referrals/code/{wallet}: * get: * summary: Get referral code for a wallet - * description: Returns the referral code for the given wallet address, creating one if it doesn't exist. + * description: Returns the existing referral code for the given wallet address (read-only). Requires ownership or admin permissions. * tags: [Referrals] * parameters: * - in: path @@ -108,17 +149,108 @@ router.get('/:wallet', cacheMiddleware({ ttl: REFERRAL_CACHE_TTL_MS }), async (r * type: object * properties: * code: { type: string } + * 401: + * description: Authentication required + * 403: + * description: Forbidden - cannot access another wallet's referral code + * 404: + * description: No referral code found for this wallet * 500: * description: Internal server error */ -router.get('/code/:wallet', cacheMiddleware({ ttl: REFERRAL_CACHE_TTL_MS }), async (req: Request, res: Response) => { - const { wallet } = req.params; +router.get( + "/code/:wallet", + tenantGuard({ + walletParamPath: "params.wallet", + allowAdminBypass: true, + adminBypassPermission: Permission.ADMIN_READ, + requireAuth: true, + }), + cacheMiddleware({ ttl: REFERRAL_CACHE_TTL_MS }), + async (req: Request, res: Response) => { + const { wallet } = req.params; + + if (!wallet) { + return res.status(400).json(buildReferralWalletRequiredBody()); + } + const normalizedWallet = normalizeWalletAddress(wallet); + + try { + const code = await referralService.getReferralCode(normalizedWallet); + if (!code) { + return res.status(404).json(buildReferralCodeNotFoundBody()); + } + return res.status(200).json({ code }); + } catch (error) { + logger.log("error", "Error getting referral code", { + error: error instanceof Error ? error.message : String(error), + wallet: normalizedWallet, + }); + return res.status(500).json(buildReferralCodeErrorBody()); + } if (!wallet) { sendApiError(req, res, { status: 400, code: 'BAD_REQUEST', message: WALLET_REQUIRED_MESSAGE }); return; } +); + +/** + * @openapi + * /api/v1/referrals/code/{wallet}: + * post: + * summary: Create or retrieve referral code for a wallet + * description: Creates a referral code for the wallet if one does not exist, or returns the existing one. Requires ownership or admin permissions. + * tags: [Referrals] + * parameters: + * - in: path + * name: wallet + * required: true + * schema: { type: string } + * description: Wallet address to create or get referral code for + * responses: + * 200: + * description: Referral code + * content: + * application/json: + * schema: + * type: object + * properties: + * code: { type: string } + * 401: + * description: Authentication required + * 403: + * description: Forbidden - cannot create code for another wallet + * 500: + * description: Internal server error + */ +router.post( + "/code/:wallet", + tenantGuard({ + walletParamPath: "params.wallet", + allowAdminBypass: true, + adminBypassPermission: Permission.ADMIN_READ, + requireAuth: true, + }), + async (req: Request, res: Response) => { + const { wallet } = req.params; + if (!wallet) { + return res.status(400).json(buildReferralWalletRequiredBody()); + } + + const normalizedWallet = normalizeWalletAddress(wallet); + + try { + const code = await referralService.getOrCreateReferralCode(normalizedWallet); + return res.status(200).json({ code }); + } catch (error) { + logger.log("error", "Error creating or getting referral code", { + error: error instanceof Error ? error.message : String(error), + wallet: normalizedWallet, + }); + return res.status(500).json(buildReferralCodeErrorBody()); + } const normalizedWallet = normalizeWalletAddress(wallet); try { @@ -132,6 +264,6 @@ router.get('/code/:wallet', cacheMiddleware({ ttl: REFERRAL_CACHE_TTL_MS }), asy sendApiError(req, res, { status: 500, code: 'INTERNAL_SERVER_ERROR', message: REFERRAL_CODE_ERROR_MESSAGE }); return; } -}); +); export default router; diff --git a/backend/src/referralService.ts b/backend/src/referralService.ts index b7eade1e2..4de67bdc4 100644 --- a/backend/src/referralService.ts +++ b/backend/src/referralService.ts @@ -203,6 +203,20 @@ export class ReferralService { * Get or create a referral code for a wallet address. * Generates a unique 8-character alphanumeric code if one doesn't exist. */ + /** + * Get existing referral code for a wallet address without creating one (pure read). + */ + async getReferralCode(ownerAddress: string): Promise { + const prisma = getPrisma(); + const normalizedOwner = normalizeWalletAddress(ownerAddress); + + const existing = await prisma.referralCode.findFirst({ + where: { ownerAddress: normalizedOwner }, + }); + + return existing ? existing.code : null; + } + async getOrCreateReferralCode(ownerAddress: string): Promise { const prisma = getPrisma(); const normalizedOwner = normalizeWalletAddress(ownerAddress); diff --git a/contracts/share-price-math/src/rounding.rs b/contracts/share-price-math/src/rounding.rs index 1ad3230e9..794a773e0 100644 --- a/contracts/share-price-math/src/rounding.rs +++ b/contracts/share-price-math/src/rounding.rs @@ -3,6 +3,7 @@ //! The vault must never round in the user's favour: rounding always leaves the //! residual dust with the vault, so `deposit -> withdraw` round trips can never //! mint value out of nothing. + //! //! | Operation | Direction | Rounding | //! |------------|-----------|----------| diff --git a/contracts/vault/src/lib.rs b/contracts/vault/src/lib.rs index c32bc206d..22e6d6983 100644 --- a/contracts/vault/src/lib.rs +++ b/contracts/vault/src/lib.rs @@ -838,10 +838,10 @@ impl YieldVault { /// * `env` - Soroban environment /// * `strategy` - Strategy address to set as active /// - /// # Panics - /// Panics if the strategy is not whitelisted + /// # Errors + /// Returns `Err(VaultError::StrategyNotWhitelisted)` if the strategy is not whitelisted. pub fn set_strategy(env: Env, strategy: Address) -> Result<(), VaultError> { - let admin: Address = get_admin(&env).expect("Admin not set"); + let admin: Address = get_admin(&env).ok_or(VaultError::GovernanceSignersNotConfigured)?; admin.require_auth(); let now = env.ledger().timestamp(); @@ -857,6 +857,8 @@ impl YieldVault { .instance() .get(&DataKey::LastStrategySwitchTime) .unwrap_or(0); + let earliest_switch = last_switch.saturating_add(cooldown); + if env.ledger().timestamp() < earliest_switch { let earliest_switch = last_switch.checked_add(cooldown).expect("overflow"); if now < earliest_switch { return Err(VaultError::AdminParamChangeTooSoon); @@ -1032,9 +1034,9 @@ impl YieldVault { caller: Address, reason: PauseReason, ) -> Result<(), VaultError> { - let admin = get_admin(&env).expect("Admin not set"); + let admin = get_admin(&env).ok_or(VaultError::GovernanceSignersNotConfigured)?; let pauser_addr = Self::pauser(env.clone()); - permissions::require_pauser_or_admin_auth(&caller, &admin, pauser_addr.as_ref()); + permissions::require_pauser_or_admin_auth(&caller, &admin, pauser_addr.as_ref())?; let mut state = Self::get_state(&env); state.is_paused = true; @@ -1048,9 +1050,9 @@ impl YieldVault { /// Unpauses the contract with role-restricted authorization. /// Caller must be either the Admin or the assigned Pauser role address. pub fn unpause_with_role(env: Env, caller: Address) -> Result<(), VaultError> { - let admin = get_admin(&env).expect("Admin not set"); + let admin = get_admin(&env).ok_or(VaultError::GovernanceSignersNotConfigured)?; let pauser_addr = Self::pauser(env.clone()); - permissions::require_pauser_or_admin_auth(&caller, &admin, pauser_addr.as_ref()); + permissions::require_pauser_or_admin_auth(&caller, &admin, pauser_addr.as_ref())?; let mut state = Self::get_state(&env); state.is_paused = false; @@ -1238,11 +1240,13 @@ impl YieldVault { /// Once the dispute window closes the proposal can no longer be cancelled /// and must proceed through secondary confirmation. pub fn cancel_emergency_action(env: Env, proposal_id: u32) -> Result<(), VaultError> { - let admin: Address = get_admin(&env).expect("Admin not set"); + let admin: Address = get_admin(&env).ok_or(VaultError::GovernanceSignersNotConfigured)?; admin.require_auth(); - let mut proposal = emergency::read_proposal(&env, proposal_id).expect("proposal not found"); - assert!(!proposal.executed, "proposal already executed"); + let mut proposal = emergency::read_proposal(&env, proposal_id).ok_or(VaultError::NoPendingWithdrawal)?; + if proposal.executed { + return Err(VaultError::ProposalAlreadyExecuted); + } if proposal.cancelled { return Err(VaultError::ProposalCancelled); @@ -1261,14 +1265,17 @@ impl YieldVault { /// Sets the dispute window duration (in seconds) for new emergency proposals. /// /// Only the admin may configure this. Defaults to `3600` (1 hour) if never set. - pub fn set_emergency_dispute_window(env: Env, seconds: u64) { - let admin: Address = get_admin(&env).expect("Admin not set"); + pub fn set_emergency_dispute_window(env: Env, seconds: u64) -> Result<(), VaultError> { + let admin: Address = get_admin(&env).ok_or(VaultError::GovernanceSignersNotConfigured)?; admin.require_auth(); - assert!(seconds > 0, "dispute window must be positive"); + if seconds == 0 { + return Err(VaultError::InvalidAmount); + } env.storage().instance().set( &DataKey::Emergency(EmergencyStorageKey::DisputeWindow), &seconds, ); + Ok(()) } /// Returns the configured dispute window in seconds (default 3600). @@ -1461,6 +1468,31 @@ impl YieldVault { let state = Self::get_state(&env); let idle_assets = state.total_assets; + // Each config key is read at most once per call (issue #1538). + let strategy_assets = if let Some(strategy_addr) = Self::strategy(env.clone()) { + let token = Self::token(env.clone()); + if Self::is_oracle_enabled(env.clone()) { + if let Some(oracle_addr) = Self::price_oracle(env.clone()) { + let oracle_client = OracleClient::new(&env, &oracle_addr); + let price_data = oracle_client.get_price(&token, &token); + let max_age = Self::oracle_heartbeat(env.clone()); + let last: Option = env + .storage() + .instance() + .get(&DataKeyExt::Risk(RiskExtKey::LastPx)); + let _last_price = Self::last_oracle_price(&env); + let last_price = Self::last_oracle_price(&env); + oracle::OracleValidator::validate_price_data( + &env, + &price_data, + max_age, + Some(oracle::MAX_PRICE_DEVIATION_BPS), + last.as_ref(), + last_price.as_ref(), + ) + .map_err(|_| VaultError::OracleValidationFailed)?; + Self::set_last_oracle_price(&env, &price_data); + } let token = Self::token(env.clone()); if Self::is_oracle_enabled(env.clone()) { if let Some(oracle_addr) = Self::price_oracle(env.clone()) { @@ -1589,13 +1621,16 @@ impl YieldVault { .unwrap_or(0) } - pub fn snapshot_user_balance(env: Env, user: Address) { + pub fn snapshot_user_balance(env: Env, user: Address) -> Result<(), VaultError> { user.require_auth(); - let checkpoint_id = env + let checkpoint_id = match env .storage() .instance() .get::<_, u32>(&DataKey::CheckpointNonce) - .expect("no checkpoint"); + { + Some(id) => id, + None => return Err(VaultError::NoPendingWithdrawal), + }; let balance = Self::balance(env.clone(), user.clone()); env.storage() .instance() @@ -1607,6 +1642,7 @@ impl YieldVault { }), &balance, ); + Ok(()) } pub fn balance_at(env: Env, user: Address, checkpoint_id: u32) -> i128 { @@ -1656,7 +1692,7 @@ impl YieldVault { .get(&DataKey::ConfiguredStrategy(soroban_sdk::symbol_short!( "Korean" ))) - .unwrap(); + .ok_or(VaultError::StrategyNotConfigured)?; let strategy_client = KoreanDebtStrategyClient::new(&env, &strategy); let harvested = strategy_client.harvest_yield(); @@ -2909,6 +2945,15 @@ impl YieldVault { return Err(VaultError::TimelockNotExpired); } + let current_balance = Self::balance(env.clone(), user.clone()); + if current_balance < pending.shares { + return Err(VaultError::InsufficientShares); + } + + if pending.shares <= 0 { + return Err(VaultError::InvalidAmount); + } + env.storage() .instance() .remove(&DataKey::PendingWithdrawal(user.clone())); @@ -2934,7 +2979,11 @@ impl YieldVault { shares: i128, assets_to_return: i128, ) -> Result { - let token_addr = env.storage().instance().get(&DataKey::TokenAsset).unwrap(); + let token_addr = env + .storage() + .instance() + .get(&DataKey::TokenAsset) + .ok_or(VaultError::GovernanceSignersNotConfigured)?; let token_client = token::Client::new(env, &token_addr); // Check if vault has enough idle assets, otherwise queue the withdrawal @@ -3130,7 +3179,10 @@ impl YieldVault { return 0; } - let token_addr = env.storage().instance().get(&DataKey::TokenAsset).unwrap(); + let token_addr: Address = match env.storage().instance().get(&DataKey::TokenAsset) { + Some(addr) => addr, + None => return 0, + }; let token_client = token::Client::new(&env, &token_addr); let mut processed: u32 = 0; let mut head = Self::withdrawal_queue_head(&env); diff --git a/contracts/vault/src/permissions.rs b/contracts/vault/src/permissions.rs index 65fd13277..fd5f0013c 100644 --- a/contracts/vault/src/permissions.rs +++ b/contracts/vault/src/permissions.rs @@ -13,6 +13,8 @@ //! - Migration-safe updates: old and new signer sets coexist during transition //! - Operations require threshold signatures from the current active set +use soroban_sdk::{Address, Vec}; +use crate::VaultError; use crate::VaultError; use soroban_sdk::{Address, Env, Map, Vec}; @@ -39,9 +41,18 @@ pub fn require_strategy_auth(caller: &Address, expected_strategy: &Address) { } /// Verifies that the caller is authorized for pausability operations (either Admin or Pauser role) -pub fn require_pauser_or_admin_auth(caller: &Address, admin: &Address, pauser: Option<&Address>) { +pub fn require_pauser_or_admin_auth( + caller: &Address, + admin: &Address, + pauser: Option<&Address>, +) -> Result<(), VaultError> { caller.require_auth(); let is_admin = caller == admin; + let is_pauser = pauser.map_or(false, |p| caller == p); + if !(is_admin || is_pauser) { + return Err(VaultError::RescueUnauthorized); + } + Ok(()) let is_pauser = pauser == Some(caller); assert!( is_admin || is_pauser,