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
14 changes: 11 additions & 3 deletions Contract/streaks/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ use crate::activity::Activity;
use crate::authorization::Authorization;
use crate::events::Events;
use crate::leaderboard::Leaderboard;
use crate::state::{State, INITIAL_FREEZES};
use crate::state::{State, INITIAL_FREEZES, MAX_FREEZES};
use crate::ttl::Ttl;

pub use crate::state::StreakKey;
Expand Down Expand Up @@ -223,7 +223,7 @@ impl StreaksContract {
Ok(())
}

/// Add freezes to a user's account.
/// Add freezes to a user's account, up to the configured `MAX_FREEZES` cap.
///
/// # Authorization
/// Requires vault authorization.
Expand All @@ -232,17 +232,25 @@ impl StreaksContract {
/// - `Error::NotInitialized` if the contract has not been initialized.
/// - `Error::Unauthorized` if the caller is not authorized.
/// - `Error::StreakNotFound` if the user has no streak.
/// - `Error::InvalidAmount` if the addition would push the user's freeze
/// balance above `MAX_FREEZES`.
pub fn add_freezes(env: Env, user: Address, amount: u32) -> Result<(), Error> {
Authorization::require_vault_authorization(&env)?;
Authorization::ensure_initialized(&env)?;

let mut streak = State::get_streak(&env, &user)?;

streak.available_freezes = streak
let new_total = streak
.available_freezes
.checked_add(amount)
.ok_or(Error::Overflow)?;

if new_total > MAX_FREEZES {
return Err(Error::InvalidAmount);
}

streak.available_freezes = new_total;

State::set_streak(&env, &user, &streak)?;

Ok(())
Expand Down
7 changes: 7 additions & 0 deletions Contract/streaks/src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@ pub enum StreakKey {
/// Initial number of freezes granted to every new streak.
pub const INITIAL_FREEZES: u32 = 3;

/// Maximum number of freezes a user may hold at once.
///
/// Caps `add_freezes` to keep freeze accumulation from undermining the
/// streak discipline mechanic. Manual consumption via `use_freeze` and
/// `update_streak` is unaffected by this cap.
pub const MAX_FREEZES: u32 = 10;

pub struct State;

impl State {
Expand Down
99 changes: 99 additions & 0 deletions Contract/streaks/tests/test_streaks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,105 @@ fn test_add_freezes_authorized() {
assert_eq!(streak.available_freezes, 5); // 3 + 2
}

#[test]
fn test_add_freezes_up_to_exact_max_succeeds() {
let env = Env::default();
env.mock_all_auths();

let vault_id = Address::generate(&env);
let contract_id = env.register_contract(None, StreaksContract);
let client = StreaksContractClient::new(&env, &contract_id);

client.initialize(&vault_id);
let user = Address::generate(&env);

client.initialize_streak(&user);

// Starts at 3 (INITIAL_FREEZES); adding 7 lands exactly on MAX_FREEZES (10).
client.add_freezes(&user, &7);

let streak = client.get_user_streak(&user);
assert_eq!(streak.available_freezes, 10);
}

#[test]
fn test_add_freezes_beyond_max_rejected() {
let env = Env::default();
env.mock_all_auths();

let vault_id = Address::generate(&env);
let contract_id = env.register_contract(None, StreaksContract);
let client = StreaksContractClient::new(&env, &contract_id);

client.initialize(&vault_id);
let user = Address::generate(&env);

client.initialize_streak(&user);

// Bring the user right up to the cap.
client.add_freezes(&user, &7);
let streak = client.get_user_streak(&user);
assert_eq!(streak.available_freezes, 10);

// A single additional freeze should now be rejected.
let result = client.try_add_freezes(&user, &1);
assert!(result.is_err());

// Balance must remain unchanged after the rejected addition.
let streak = client.get_user_streak(&user);
assert_eq!(streak.available_freezes, 10);
}

#[test]
fn test_add_freezes_far_over_max_rejected_in_one_call() {
let env = Env::default();
env.mock_all_auths();

let vault_id = Address::generate(&env);
let contract_id = env.register_contract(None, StreaksContract);
let client = StreaksContractClient::new(&env, &contract_id);

client.initialize(&vault_id);
let user = Address::generate(&env);

client.initialize_streak(&user);

// Starting balance is 3; requesting 1000 more in one call must be rejected
// outright rather than silently clamped.
let result = client.try_add_freezes(&user, &1000);
assert!(result.is_err());

let streak = client.get_user_streak(&user);
assert_eq!(streak.available_freezes, 3);
}

#[test]
fn test_manual_freeze_consumption_after_capped_addition() {
let env = Env::default();
env.mock_all_auths();

let vault_id = Address::generate(&env);
let contract_id = env.register_contract(None, StreaksContract);
let client = StreaksContractClient::new(&env, &contract_id);

client.initialize(&vault_id);
let user = Address::generate(&env);

client.initialize_streak(&user);

// Fill up to the max.
client.add_freezes(&user, &7);
assert_eq!(client.get_user_streak(&user).available_freezes, 10);

// Manual consumption still works normally at the cap.
client.use_freeze(&user);
assert_eq!(client.get_user_streak(&user).available_freezes, 9);

// Consuming freezes should re-open room under the cap for future additions.
client.add_freezes(&user, &1);
assert_eq!(client.get_user_streak(&user).available_freezes, 10);
}

#[test]
fn test_streak_active_check() {
let env = Env::default();
Expand Down
136 changes: 136 additions & 0 deletions Frontend/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
# 🔐 Vaulty — Frontend

> **Save consistently. Grow your wealth. Unlock financial opportunities.**

This is the **web frontend** for Vaulty, a non-custodial decentralized savings platform built on the Stellar network. This package (`frontend/`) is the Next.js + React application that gives users a gamified, visually rewarding interface for saving, tracking streaks, earning yield, lending, borrowing, and investing — all while their funds remain in their own Stellar wallet.

This README covers the frontend workspace only. For contract and backend details, see the root repo README and the `contract/` and `backend/` workspace docs.

---

## What This App Does

The frontend is the primary surface where users experience Vaulty's core promise: saving money should feel engaging, not tedious. It is responsible for:

* Rendering savings vaults, goals, and lock periods
* Visualizing saving streaks and the GitHub-style savings calendar
* Triggering deposit celebration animations (vault pulse, confetti, milestone screens)
* Surfacing yield, lending, borrowing, and investment portfolio data
* Connecting to the user's Stellar wallet and initiating on-chain actions
* Driving the Nigerian bank deposit/withdrawal flow via the anchor partner integration (through the backend API)
* Displaying the Discipline Score and unlocked achievements
* Handling in-app notifications

The frontend never holds custody of funds and never talks to Soroban contracts with anything other than the user's own signed transactions — all fund movement is signed by the user's wallet.

---

## Tech Stack

| Layer | Technology |
|---|---|
| Framework | Next.js (App Router) |
| UI Library | React |
| Styling | Tailwind CSS |
| Wallet / Chain | Stellar SDK, Soroban client bindings |
| State Management | Client-side store (see `src/stores/`) |
| Language | TypeScript |
| Hosting | Vercel |
| CI/CD | GitHub Actions |

---

## Repository Structure

```
frontend/
├── public/ # Static assets, icons, images
├── src/
│ ├── app/ # Next.js app router pages/layouts
│ ├── components/ # Shared UI components (buttons, cards, modals)
│ ├── features/ # Feature-scoped modules
│ │ ├── vaults/ # Vault creation, list, detail views
│ │ ├── streaks/ # Streak tracker, calendar UI
│ │ ├── lending/ # Lending marketplace UI
│ │ ├── borrowing/ # Borrow-against-savings UI
│ │ ├── investments/ # Portfolio allocation UI
│ │ └── notifications/ # In-app notification center
│ ├── hooks/ # Shared React hooks (useVault, useWallet, etc.)
│ ├── lib/ # Stellar SDK/wallet connection, API client
│ ├── stores/ # Client-side state management
│ ├── styles/ # Tailwind config, global styles
│ └── types/ # Shared frontend TypeScript types
├── .env.example
├── next.config.js
├── tailwind.config.ts
└── package.json
```

### Feature Modules

* **`vaults/`** — Create, view, and manage savings vaults (Emergency Fund, School Fees, Rent, etc.). Displays target amount, progress, lock period, balance, deposit history, and maturity date.
* **`streaks/`** — Renders saving streaks (7/30/100/365-day), streak freezes, and the savings calendar showing daily deposits, missed days, and consistency.
* **`lending/`** — UI for supplying idle assets to the decentralized lending pool; shows active loans, expected returns, and loan maturity.
* **`borrowing/`** — UI for borrowing against a vault as collateral without breaking a savings streak.
* **`investments/`** — Portfolio allocation UI (Conservative / Balanced / Growth). Gated behind the regional legal review described in the root README before public launch.
* **`notifications/`** — In-app notification center for streak reminders, goal proximity alerts, and milestone congratulations.

---

## Key UI/UX Elements

* **Vault Pulse Animation** — plays on every successful deposit, alongside balance growth animation and progress updates
* **Milestone Celebrations** — confetti, animated vault, and celebration screens on achievements (First Deposit, $100 Saved, One Year Streak, etc.)
* **Savings Calendar** — GitHub-style contribution grid showing daily deposit activity
* **Discipline Score** — visual score display reflecting saving consistency, streak length, goal completion, repayment history, and investment activity
* **Smart Notifications** — contextual, encouraging copy (e.g. "You're only $15 away from your emergency fund goal.")

All animations exist to reinforce saving as a positive habit, not just to decorate the app — this is core to the product's differentiation from purely custodial competitors.

---

## Wallet & Chain Interaction

The frontend integrates with the Stellar network through:

* Wallet connection (Stellar-compatible wallets) via `src/lib/`
* Read calls to Soroban contracts for vault state, streak verification, and yield data
* User-signed transactions for deposits, withdrawals, lending, borrowing, and vault creation
* No private keys or signing authority ever touch the frontend's own state — all signing happens client-side via the connected wallet

Fiat-related flows (Nigerian bank deposit/withdrawal) are **not** handled directly by the frontend against the anchor partner — they go through the backend's `anchor-integration` module, which the frontend calls via its API client in `src/lib/`.

---

## Getting Started

```bash
# Install dependencies (from the monorepo root, or inside frontend/ if standalone)
npm install

# Copy environment variables
cp .env.example .env.local

# Run the dev server
npm dev
```

The app will be available at `http://localhost:3000`.

### Environment Variables

See `.env.example` for required variables, which typically include:

* Stellar network config (testnet/mainnet horizon URL)
* Backend API base URL
* Wallet connector configuration
* Feature flags (e.g. enabling lending/borrowing/investments per phase)

---

## Development Notes

* This app currently targets **Phase 1–2** of the product roadmap (savings vaults, streaks, deposits, yield display, achievements, notifications). Lending, borrowing, and investment UIs exist as feature modules but should remain **gated/hidden** in production builds until Phase 3 (post-audit, post-legal-review) per the root README's roadmap.
* Yield, APY, and interest figures displayed in the UI should always be sourced from on-chain contract data (via `lib/`) rather than hardcoded or backend-cached values, to preserve the "verifiable on-chain" differentiator.
* Shared types between frontend and backend live in `src/types/` for now; if the API surface grows, these should move to a shared `packages/shared-types` workspace (see root README notes on repo structure).

Loading