Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .github/codecov.yml
Original file line number Diff line number Diff line change
@@ -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
32 changes: 31 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ coverage

# Soroban test runner β€” auto-generated, never commit
**/test_snapshots/
fix.md
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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._
Expand Down
16 changes: 10 additions & 6 deletions backend/src/services/claimable.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 =
Expand Down
52 changes: 50 additions & 2 deletions backend/tests/claimable.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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);
}
});
});
11 changes: 10 additions & 1 deletion backend/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
16 changes: 8 additions & 8 deletions contracts/stream_contract/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand All @@ -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)
}

Expand Down
72 changes: 72 additions & 0 deletions contracts/stream_contract/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down