diff --git a/.github/codecov.yml b/.github/codecov.yml new file mode 100644 index 0000000..7124552 --- /dev/null +++ b/.github/codecov.yml @@ -0,0 +1,19 @@ +coverage: + status: + project: + backend: + target: 60% + flags: + - backend + contracts: + target: 70% + flags: + - contracts + patch: + default: + target: 60% + +comment: + layout: "diff, flags, files" + behavior: default + require_changes: false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c6cad24..8a602e7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -159,12 +159,24 @@ jobs: - name: Run Backend Tests run: | ls -la src/generated/prisma - npx vitest run --reporter=basic + npm install @vitest/coverage-v8@2.1.9 --no-save + npx vitest run --coverage --reporter=basic working-directory: backend env: DATABASE_URL: postgresql://postgres:password@127.0.0.1:5432/flowfi_test NODE_ENV: test + - name: Upload backend coverage to Codecov + uses: codecov/codecov-action@v5 + with: + files: backend/coverage/lcov.info + flags: backend + name: backend-coverage + fail_ci_if_error: true + verbose: true + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + contracts: name: Soroban Contracts CI runs-on: ubuntu-latest @@ -191,6 +203,24 @@ jobs: run: cargo test working-directory: contracts + - name: Install cargo-tarpaulin + run: cargo install cargo-tarpaulin --locked + + - name: Run Contract Coverage + run: cargo tarpaulin --workspace --out Xml --output-dir coverage --fail-under 70 + working-directory: contracts + + - name: Upload contract coverage to Codecov + uses: codecov/codecov-action@v5 + with: + files: contracts/coverage/cobertura.xml + flags: contracts + name: contracts-coverage + fail_ci_if_error: true + verbose: true + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + - name: Install Stellar CLI run: | curl -fsSL https://github.com/stellar/stellar-cli/raw/main/install.sh | sh -s -- --install-deps diff --git a/.gitignore b/.gitignore index 18aabf5..24e170d 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ coverage # Soroban test runner — auto-generated, never commit **/test_snapshots/ +fix.md \ No newline at end of file diff --git a/README.md b/README.md index 489aae6..93890da 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # FlowFi +[![codecov](https://codecov.io/gh/LabsCrypt/flowfi/branch/main/graph/badge.svg)](https://codecov.io/gh/LabsCrypt/flowfi) + **DeFi Payment Streaming on Stellar** _Programmable, real-time payment streams and recurring subscriptions._ diff --git a/backend/src/services/claimable.service.ts b/backend/src/services/claimable.service.ts index 24a41f8..84ed08d 100644 --- a/backend/src/services/claimable.service.ts +++ b/backend/src/services/claimable.service.ts @@ -45,8 +45,10 @@ function saturatingSubI128(a: bigint, b: bigint): bigint { return clampI128(a - b); } -function saturatingMulI128(a: bigint, b: bigint): bigint { - return clampI128(a * b); +function checkedMulI128(a: bigint, b: bigint): bigint | null { + const value = a * b; + if (value > I128_MAX || value < I128_MIN) return null; + return value; } function parseI128(value: string, fieldName: string): bigint { @@ -82,9 +84,9 @@ function getStateFingerprint(stream: ClaimableStreamState): string { /** * Mirrors Soroban's overflow-safe claimable calculation: * - elapsed = now.saturating_sub(last_update_time) - * - streamed = (elapsed * rate_per_second) with i128 saturation + * - streamed = (elapsed * rate_per_second) with i128 overflow detection * - remaining = deposited_amount.saturating_sub(withdrawn_amount) - * - claimable = min(streamed, remaining) + * - claimable = remaining on multiplication overflow, otherwise min(streamed, remaining) */ export class ClaimableAmountService { private readonly cacheTtlMs: number; @@ -138,10 +140,12 @@ export class ClaimableAmountService { const depositedAmount = parseI128(stream.depositedAmount, 'depositedAmount'); const withdrawnAmount = parseI128(stream.withdrawnAmount, 'withdrawnAmount'); - const streamedAmount = saturatingMulI128(elapsed, ratePerSecond); const remainingAmount = saturatingSubI128(depositedAmount, withdrawnAmount); + const streamedAmount = checkedMulI128(elapsed, ratePerSecond); const rawClaimable = - streamedAmount > remainingAmount ? remainingAmount : streamedAmount; + streamedAmount === null || streamedAmount > remainingAmount + ? remainingAmount + : streamedAmount; // "Actionable" mirrors what a client can withdraw right now. const actionableAmount = diff --git a/backend/tests/claimable.service.test.ts b/backend/tests/claimable.service.test.ts index 8be98eb..c4628b5 100644 --- a/backend/tests/claimable.service.test.ts +++ b/backend/tests/claimable.service.test.ts @@ -131,7 +131,7 @@ describe('ClaimableAmountService', () => { expect(third.cached).toBe(false); }); - it('saturates overflow-safe multiplication to i128 max', () => { + it('caps multiplication overflow at the remaining balance', () => { const i128Max = ((1n << 127n) - 1n).toString(); vi.setSystemTime(1_000_000); const service = new ClaimableAmountService({ @@ -143,9 +143,57 @@ describe('ClaimableAmountService', () => { streamId: 6, ratePerSecond: i128Max, depositedAmount: i128Max, + withdrawnAmount: '42', }), }, 1000); // 1000 seconds elapsed - expect(result.claimableAmount).toBe(i128Max); + expect(result.claimableAmount).toBe(((1n << 127n) - 1n - 42n).toString()); + }); + + it('fuzzes claimable invariants for random amounts, durations, and pauses', () => { + const service = new ClaimableAmountService({ + cacheTtlMs: 0, + }); + let seed = 0x4f1bbcdcn; + + const next = () => { + seed = (seed * 6364136223846793005n + 1442695040888963407n) & ((1n << 64n) - 1n); + return seed; + }; + + for (let iteration = 0; iteration < 10_000; iteration += 1) { + const deposited = 1n + (next() % 1_000_000_000_000n); + const withdrawn = next() % (deposited + 1n); + const duration = 1n + (next() % 1_000_000n); + const elapsed = next() % (duration * 4n); + const rate = + iteration % 97 === 0 + ? (1n << 127n) - 1n + : 1n + (deposited / duration) + (next() % 100_000n); + const pauseStart = next() % (elapsed + 1n); + const paused = (next() & 1n) === 1n; + const now = Number(elapsed); + const remaining = deposited - withdrawn; + + const result = service.getClaimableAmount({ + ...makeStreamState({ + streamId: 10_000 + iteration, + ratePerSecond: rate.toString(), + depositedAmount: deposited.toString(), + withdrawnAmount: withdrawn.toString(), + lastUpdateTime: 0, + isPaused: paused, + pausedAt: paused ? Number(pauseStart) : null, + totalPausedDuration: paused ? Number(elapsed - pauseStart) : 0, + }), + }, now); + + const claimable = BigInt(result.claimableAmount); + const cancelRefund = deposited - withdrawn - claimable; + + expect(withdrawn <= deposited, `iteration ${iteration}: withdrawn exceeded deposited`).toBe(true); + expect(claimable <= remaining, `iteration ${iteration}: claimable exceeded remaining`).toBe(true); + expect(cancelRefund + withdrawn + claimable <= deposited, `iteration ${iteration}: cancel settlement exceeded deposit`).toBe(true); + } }); }); diff --git a/backend/vitest.config.ts b/backend/vitest.config.ts index 151f773..d785841 100644 --- a/backend/vitest.config.ts +++ b/backend/vitest.config.ts @@ -7,7 +7,16 @@ export default defineConfig({ setupFiles: [], include: ['tests/**/*.{test,spec}.ts', 'src/__tests__/**/*.{test,spec}.ts'], coverage: { - reporter: ['text', 'json', 'html'], + enabled: true, + provider: 'v8', + reportsDirectory: './coverage', + reporter: ['text', 'json', 'html', 'lcov'], + thresholds: { + statements: 60, + branches: 60, + functions: 60, + lines: 60, + }, }, testTimeout: 30000, hookTimeout: 30000, diff --git a/contracts/stream_contract/src/lib.rs b/contracts/stream_contract/src/lib.rs index d7c5aa6..935226a 100644 --- a/contracts/stream_contract/src/lib.rs +++ b/contracts/stream_contract/src/lib.rs @@ -421,7 +421,7 @@ impl StreamContract { /// /// # Overflow Protection /// - Uses `checked_mul` for rate_per_second * elapsed_seconds multiplication - /// - Caps at stream.deposited_amount if overflow would occur + /// - Caps at remaining deposited balance if overflow would occur /// - Uses `checked_sub` for deposited - already_withdrawn calculation /// - Overflow boundary: i128::MAX (~1.7e19) for both rate and duration fn calculate_claimable(stream: &Stream, now: u64) -> i128 { @@ -440,13 +440,6 @@ impl StreamContract { }; let elapsed = effective_now.saturating_sub(stream.last_update_time); - // Use checked_mul to prevent overflow when multiplying rate * elapsed - // If overflow would occur, cap at deposited_amount (full deposit) - let streamed = match (elapsed as i128).checked_mul(stream.rate_per_second) { - Some(result) => result, - None => return stream.deposited_amount, // Overflow: cap at full deposit - }; - // Use checked_sub for deposited - withdrawn calculation let remaining = match stream .deposited_amount @@ -456,6 +449,13 @@ impl StreamContract { None => 0, // Underflow: already withdrawn more than deposited }; + // Use checked_mul to prevent overflow when multiplying rate * elapsed. + // If overflow would occur, cap at the remaining balance. + let streamed = match (elapsed as i128).checked_mul(stream.rate_per_second) { + Some(result) => result, + None => return remaining, + }; + streamed.min(remaining) } diff --git a/contracts/stream_contract/src/test.rs b/contracts/stream_contract/src/test.rs index b8816c0..24a9f95 100644 --- a/contracts/stream_contract/src/test.rs +++ b/contracts/stream_contract/src/test.rs @@ -1883,6 +1883,78 @@ fn test_fuzz_large_amount_no_overflow() { } } +#[test] +fn test_fuzz_claimable_overflow_and_cancel_invariants() { + let env = Env::default(); + let sender = Address::generate(&env); + let recipient = Address::generate(&env); + let token_address = Address::generate(&env); + + let mut seed = 0x4f1bbcdcu64; + for iteration in 0..10_000 { + seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); + let deposited = 1 + ((seed >> 1) as i128 % 1_000_000_000_000); + seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); + let withdrawn = (seed >> 1) as i128 % (deposited + 1); + seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); + let duration = 1 + (seed % 1_000_000); + seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); + let elapsed = seed % (duration.saturating_mul(4)); + seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); + let rate_per_second = if iteration % 97 == 0 { + i128::MAX + } else { + 1 + (deposited / duration as i128) + ((seed >> 1) as i128 % 100_000) + }; + seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); + let paused = seed & 1 == 1; + seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); + let pause_start = seed % (elapsed + 1); + + let effective_elapsed = if paused { pause_start } else { elapsed }; + let stream = Stream { + sender: sender.clone(), + recipient: recipient.clone(), + token_address: token_address.clone(), + rate_per_second, + deposited_amount: deposited, + withdrawn_amount: withdrawn, + start_time: 0, + last_update_time: 0, + is_active: true, + paused, + paused_at: if paused { Some(effective_elapsed) } else { None }, + status: if paused { StreamStatus::Paused } else { StreamStatus::Active }, + }; + + let claimable = StreamContract::calculate_claimable(&stream, elapsed); + let remaining = deposited - withdrawn; + let withdrawn_after_cancel = withdrawn.saturating_add(claimable); + let cancel_refund = deposited.saturating_sub(withdrawn_after_cancel); + + assert!( + withdrawn <= deposited, + "Iteration {}: withdrawn {} > deposited {}", + iteration, + withdrawn, + deposited + ); + assert!( + claimable <= remaining, + "Iteration {}: claimable {} > remaining {}", + iteration, + claimable, + remaining + ); + assert!( + cancel_refund + withdrawn_after_cancel <= deposited, + "Iteration {}: cancel settlement {} + {} > deposited {}", + iteration, + cancel_refund, + withdrawn_after_cancel, + deposited + ); + } // ─── transfer_admin (#459) ───────────────────────────────────────────────────── #[test]