Skip to content
Open
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
57 changes: 57 additions & 0 deletions frontend/hooks/useDepositReminder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
"use client"

import { useCallback } from "react"
import { Contract, rpc, xdr, scValToNative } from "@stellar/stellar-sdk"
import { STELLAR_RPC_URL, STELLAR_NETWORK_PASSPHRASE } from "@/components/web3-provider"
import { supabase } from "@/lib/supabase"

/**
* Returns a function that:
* 1. Calls `deposit_reminder_due` on the given rotational pool contract.
* 2. If true, inserts a `deposit_reminder` row into `pool_activity` so the
* notify-pool-event webhook fires emails/in-app notifications.
*
* Call this from a cron-like effect or a settings panel "Test reminder" button.
*/
export function useDepositReminder() {
const checkAndDispatch = useCallback(
async (contractId: string, poolId: string): Promise<boolean> => {
try {
const server = new rpc.Server(STELLAR_RPC_URL)

// Build a simulation request for deposit_reminder_due (read-only view)
const contract = new Contract(contractId)
const tx = new (await import("@stellar/stellar-sdk")).TransactionBuilder(
await server.getAccount(contractId),
{ fee: "100", networkPassphrase: STELLAR_NETWORK_PASSPHRASE }
)
.addOperation(contract.call("deposit_reminder_due"))
.setTimeout(30)
.build()

const sim = await server.simulateTransaction(tx)
if (rpc.Api.isSimulationError(sim)) return false

const returnVal: xdr.ScVal | undefined = (sim as rpc.Api.SimulateTransactionSuccessResponse).result?.retval
if (!returnVal) return false

const isDue: boolean = scValToNative(returnVal)
if (!isDue) return false

// Insert activity row — triggers the notify-pool-event webhook
await supabase.from("pool_activity").insert({
pool_id: poolId,
activity_type: "deposit_reminder",
description: "Deposit reminder: round deadline approaching",
})

return true
} catch {
return false
}
},
[]
)

return { checkAndDispatch }
}
2 changes: 1 addition & 1 deletion frontend/hooks/useNotifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export function useNotifications(walletAddress: string | null) {
table: "notifications",
filter: `wallet_address=eq.${walletAddress.toLowerCase()}`,
},
(payload) => {
(payload: { new: AppNotification }) => {
setNotifications((prev) =>
[payload.new as AppNotification, ...prev].slice(0, 10)
)
Expand Down
2 changes: 2 additions & 0 deletions frontend/hooks/useUserProfile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export interface NotificationPreferences {
email_on_deposit: boolean
email_on_round: boolean
email_on_target: boolean
email_on_reminder: boolean
}

export interface UserProfile {
Expand All @@ -21,6 +22,7 @@ const DEFAULT_PREFS: NotificationPreferences = {
email_on_deposit: true,
email_on_round: true,
email_on_target: true,
email_on_reminder: true,
}

export function useUserProfile(walletAddress: string | null) {
Expand Down
3 changes: 3 additions & 0 deletions frontend/lib/supabase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,7 @@ export type Database = {
email_on_deposit: boolean
email_on_round: boolean
email_on_target: boolean
email_on_reminder: boolean
}
created_at: string
updated_at: string
Expand All @@ -218,6 +219,7 @@ export type Database = {
email_on_deposit?: boolean
email_on_round?: boolean
email_on_target?: boolean
email_on_reminder?: boolean
}
created_at?: string
updated_at?: string
Expand All @@ -229,6 +231,7 @@ export type Database = {
email_on_deposit?: boolean
email_on_round?: boolean
email_on_target?: boolean
email_on_reminder?: boolean
}
updated_at?: string
}
Expand Down
41 changes: 41 additions & 0 deletions smartcontract/contracts/rotational/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ pub enum DataKey {
HasDeposited(Address),
ReputationTracker,
TokenDecimals,
ReminderLeadTime,
}

// ── Contract ──────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -372,6 +373,9 @@ impl RotationalPool {
if storage.has(&DataKey::ReputationTracker) {
storage.extend_ttl(&DataKey::ReputationTracker, LEDGER_THRESHOLD, LEDGER_BUMP);
}
if storage.has(&DataKey::ReminderLeadTime) {
storage.extend_ttl(&DataKey::ReminderLeadTime, LEDGER_THRESHOLD, LEDGER_BUMP);
}
}

// ── Views ──────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -435,6 +439,43 @@ impl RotationalPool {
.unwrap_or(0)
}

/// Set how many seconds before the round deadline members should be reminded
/// to deposit. Admin-only. A value of 0 disables reminders.
pub fn set_reminder_lead_time(env: Env, admin: Address, seconds: u64) {
admin.require_auth();
let storage = env.storage().persistent();
let stored_admin: Address = storage.get(&DataKey::Admin).unwrap();
assert!(admin == stored_admin, "not admin");
storage.set(&DataKey::ReminderLeadTime, &seconds);
Self::bump_config_state_internal(&env);
}

/// Returns the configured reminder lead time in seconds (0 = disabled).
pub fn reminder_lead_time(env: Env) -> u64 {
env.storage()
.persistent()
.get(&DataKey::ReminderLeadTime)
.unwrap_or(0)
}

/// Returns true when a deposit reminder should be sent, i.e. the current
/// time is within `reminder_lead_time` seconds of the next payout deadline
/// and the pool is still active.
pub fn deposit_reminder_due(env: Env) -> bool {
let storage = env.storage().persistent();
let lead: u64 = storage.get(&DataKey::ReminderLeadTime).unwrap_or(0);
if lead == 0 {
return false;
}
let active: bool = storage.get(&DataKey::Active).unwrap_or(false);
if !active {
return false;
}
let next_payout: u64 = storage.get(&DataKey::NextPayoutTime).unwrap_or(0);
let now = env.ledger().timestamp();
now >= next_payout.saturating_sub(lead) && now < next_payout
}

// ── Helpers ────────────────────────────────────────────────────────────

fn is_member(members: &Vec<Address>, who: &Address) -> bool {
Expand Down
124 changes: 124 additions & 0 deletions smartcontract/contracts/rotational/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -934,3 +934,127 @@ fn test_bump_state() {
});
}


// ── Reminder lead time tests ──────────────────────────────────────────────────

fn init_pool_for_reminder(env: &Env) -> (RotationalPoolClient<'_>, Address, Address, Address) {
use soroban_sdk::Vec;
let token_admin = Address::generate(env);
let token_contract = env.register_stellar_asset_contract_v2(token_admin.clone());
let token_address = token_contract.address();
let treasury = Address::generate(env);
let admin = Address::generate(env);
let member_a = Address::generate(env);
let member_b = Address::generate(env);
let mut members = Vec::new(env);
members.push_back(member_a.clone());
members.push_back(member_b.clone());
let contract_id = env.register_contract(None, RotationalPool);
let client = RotationalPoolClient::new(env, &contract_id);
client.initialize(&token_address, &admin, &members, &100i128, &3600u64, &0u32, &0u32, &treasury);
(client, admin, member_a, member_b)
}

#[test]
fn test_reminder_lead_time_defaults_to_zero() {
let env = Env::default();
env.mock_all_auths();
let (client, _, _, _) = init_pool_for_reminder(&env);
assert_eq!(client.reminder_lead_time(), 0);
}

#[test]
fn test_set_and_get_reminder_lead_time() {
let env = Env::default();
env.mock_all_auths();
let (client, admin, _, _) = init_pool_for_reminder(&env);
client.set_reminder_lead_time(&admin, &600u64);
assert_eq!(client.reminder_lead_time(), 600);
}

#[test]
#[should_panic(expected = "not admin")]
fn test_non_admin_cannot_set_reminder_lead_time() {
let env = Env::default();
env.mock_all_auths();
let (client, _, member_a, _) = init_pool_for_reminder(&env);
client.set_reminder_lead_time(&member_a, &600u64);
}

#[test]
fn test_deposit_reminder_due_false_when_lead_time_zero() {
let env = Env::default();
env.mock_all_auths();
let (client, _, _, _) = init_pool_for_reminder(&env);
// lead time not set — should never be due
assert!(!client.deposit_reminder_due());
}

#[test]
fn test_deposit_reminder_due_false_before_window() {
let env = Env::default();
env.mock_all_auths();
let (client, admin, _, _) = init_pool_for_reminder(&env);
// round_duration = 3600, so next_payout_time = 0 + 3600 = 3600
// lead time = 600; window starts at 3000
client.set_reminder_lead_time(&admin, &600u64);
// now = 0 → before window
assert!(!client.deposit_reminder_due());
}

#[test]
fn test_deposit_reminder_due_true_inside_window() {
let env = Env::default();
env.mock_all_auths();
let (client, admin, _, _) = init_pool_for_reminder(&env);
// next_payout_time = 3600, lead = 600 → window [3000, 3600)
client.set_reminder_lead_time(&admin, &600u64);
env.ledger().set_timestamp(3100);
assert!(client.deposit_reminder_due());
}

#[test]
fn test_deposit_reminder_due_false_after_deadline() {
let env = Env::default();
env.mock_all_auths();
let (client, admin, _, _) = init_pool_for_reminder(&env);
client.set_reminder_lead_time(&admin, &600u64);
// at or after next_payout_time = 3600 → not due (payout time passed)
env.ledger().set_timestamp(3600);
assert!(!client.deposit_reminder_due());
}

#[test]
fn test_deposit_reminder_due_false_when_pool_inactive() {
let env = Env::default();
env.mock_all_auths();
let token_admin = Address::generate(&env);
let token_contract = env.register_stellar_asset_contract_v2(token_admin.clone());
let token_address = token_contract.address();
let token_client = token::StellarAssetClient::new(&env, &token_address);
let treasury = Address::generate(&env);
let admin = Address::generate(&env);
let relayer = Address::generate(&env);
let member_a = Address::generate(&env);
let member_b = Address::generate(&env);
let mut members = Vec::new(&env);
members.push_back(member_a.clone());
members.push_back(member_b.clone());
let contract_id = env.register_contract(None, RotationalPool);
let client = RotationalPoolClient::new(&env, &contract_id);
client.initialize(&token_address, &admin, &members, &100i128, &100u64, &0u32, &0u32, &treasury);
client.set_reminder_lead_time(&admin, &60u64);
token_client.mint(&member_a, &200i128);
token_client.mint(&member_b, &200i128);
// Complete both rounds so pool becomes inactive
client.deposit(&member_a); client.deposit(&member_b);
env.ledger().set_timestamp(100);
client.trigger_payout(&relayer);
client.deposit(&member_a); client.deposit(&member_b);
env.ledger().set_timestamp(200);
client.trigger_payout(&relayer);
assert!(!client.is_active());
// Even inside the window, inactive pool should not fire reminder
env.ledger().set_timestamp(250);
assert!(!client.deposit_reminder_due());
}
13 changes: 12 additions & 1 deletion supabase/functions/notify-pool-event/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ interface NotificationPreferences {
email_on_deposit: boolean
email_on_round: boolean
email_on_target: boolean
email_on_reminder: boolean
}

interface UserProfile {
Expand Down Expand Up @@ -99,7 +100,7 @@ serve(async (req) => {
const act = payload.record
const { activity_type, pool_id, user_address, amount } = act

const HANDLED = ["payout", "deposit", "round_advance", "target_reached"]
const HANDLED = ["payout", "deposit", "round_advance", "target_reached", "deposit_reminder"]
if (!HANDLED.includes(activity_type)) {
return new Response("ok", { status: 200 })
}
Expand Down Expand Up @@ -175,6 +176,15 @@ serve(async (req) => {
`<p>Your savings pool <strong>${poolName}</strong> has reached its savings target!</p>
<p>You are now eligible to withdraw your funds. Log in to proceed.</p>`
)
} else if (activity_type === "deposit_reminder") {
recipients = allMembers.filter((a) => a !== user_address)
prefKey = "email_on_reminder"
subject = `Reminder: deposit due soon in ${poolName}`
inAppMsg = subject
bodyHtml = emailHtml(
`<p>The next payout deadline for <strong>${poolName}</strong> is approaching.</p>
<p>Please make sure your deposit is in before the round closes.</p>`
)
}

// Write in-app notifications for all recipients
Expand All @@ -199,6 +209,7 @@ serve(async (req) => {
email_on_deposit: true,
email_on_round: true,
email_on_target: true,
email_on_reminder: true,
...(profile.notification_preferences ?? {}),
}
if (!prefs[prefKey]) return
Expand Down
4 changes: 4 additions & 0 deletions supabase/migrations/20260623000000_add_reminder_lead_time.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
-- Deposit reminders (issue #93): store the admin-configured lead time (seconds)
-- for deposit reminders on rotational pools. NULL means reminders are disabled.
alter table public.pools
add column if not exists reminder_lead_time bigint null;
Loading