From 9569d20cead7c62ef3276127bac70cc7ee416cbf Mon Sep 17 00:00:00 2001 From: Ebenezer199914 Date: Tue, 23 Jun 2026 21:19:31 +0000 Subject: [PATCH] feat(rotational): configurable deposit reminders before round deadline (#93) - Add ReminderLeadTime DataKey to rotational contract - Add set_reminder_lead_time (admin-only) and reminder_lead_time (getter) - Add deposit_reminder_due view: true when within the lead-time window - Extend TTL for ReminderLeadTime in bump_config_state_internal - Add 7 unit tests covering default, set/get, auth guard, and all boundary conditions of deposit_reminder_due - Add email_on_reminder to NotificationPreferences (useUserProfile, useNotifications, supabase.ts types, notify-pool-event edge function) - Handle deposit_reminder activity_type in notify-pool-event with email + in-app notification delivery - Add useDepositReminder hook to check contract view and dispatch activity - Add DB migration: reminder_lead_time column on pools table - Fix pre-existing TS7006 implicit-any in useNotifications.ts --- frontend/hooks/useDepositReminder.ts | 57 ++++++++ frontend/hooks/useNotifications.ts | 2 +- frontend/hooks/useUserProfile.ts | 2 + frontend/lib/supabase.ts | 3 + smartcontract/contracts/rotational/src/lib.rs | 41 ++++++ .../contracts/rotational/src/tests.rs | 124 ++++++++++++++++++ supabase/functions/notify-pool-event/index.ts | 13 +- .../20260623000000_add_reminder_lead_time.sql | 4 + 8 files changed, 244 insertions(+), 2 deletions(-) create mode 100644 frontend/hooks/useDepositReminder.ts create mode 100644 supabase/migrations/20260623000000_add_reminder_lead_time.sql diff --git a/frontend/hooks/useDepositReminder.ts b/frontend/hooks/useDepositReminder.ts new file mode 100644 index 0000000..c31aa80 --- /dev/null +++ b/frontend/hooks/useDepositReminder.ts @@ -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 => { + 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 } +} diff --git a/frontend/hooks/useNotifications.ts b/frontend/hooks/useNotifications.ts index e6df3da..705f0d9 100644 --- a/frontend/hooks/useNotifications.ts +++ b/frontend/hooks/useNotifications.ts @@ -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) ) diff --git a/frontend/hooks/useUserProfile.ts b/frontend/hooks/useUserProfile.ts index a1edf6b..d5f88f1 100644 --- a/frontend/hooks/useUserProfile.ts +++ b/frontend/hooks/useUserProfile.ts @@ -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 { @@ -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) { diff --git a/frontend/lib/supabase.ts b/frontend/lib/supabase.ts index b92d66c..11562e5 100644 --- a/frontend/lib/supabase.ts +++ b/frontend/lib/supabase.ts @@ -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 @@ -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 @@ -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 } diff --git a/smartcontract/contracts/rotational/src/lib.rs b/smartcontract/contracts/rotational/src/lib.rs index a9ef5da..c8292cc 100644 --- a/smartcontract/contracts/rotational/src/lib.rs +++ b/smartcontract/contracts/rotational/src/lib.rs @@ -23,6 +23,7 @@ pub enum DataKey { HasDeposited(Address), ReputationTracker, TokenDecimals, + ReminderLeadTime, } // ── Contract ────────────────────────────────────────────────────────────────── @@ -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 ────────────────────────────────────────────────────────────── @@ -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
, who: &Address) -> bool { diff --git a/smartcontract/contracts/rotational/src/tests.rs b/smartcontract/contracts/rotational/src/tests.rs index b17b4d9..9c86251 100644 --- a/smartcontract/contracts/rotational/src/tests.rs +++ b/smartcontract/contracts/rotational/src/tests.rs @@ -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()); +} diff --git a/supabase/functions/notify-pool-event/index.ts b/supabase/functions/notify-pool-event/index.ts index 95c6516..367d181 100644 --- a/supabase/functions/notify-pool-event/index.ts +++ b/supabase/functions/notify-pool-event/index.ts @@ -42,6 +42,7 @@ interface NotificationPreferences { email_on_deposit: boolean email_on_round: boolean email_on_target: boolean + email_on_reminder: boolean } interface UserProfile { @@ -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 }) } @@ -175,6 +176,15 @@ serve(async (req) => { `

Your savings pool ${poolName} has reached its savings target!

You are now eligible to withdraw your funds. Log in to proceed.

` ) + } 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( + `

The next payout deadline for ${poolName} is approaching.

+

Please make sure your deposit is in before the round closes.

` + ) } // Write in-app notifications for all recipients @@ -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 diff --git a/supabase/migrations/20260623000000_add_reminder_lead_time.sql b/supabase/migrations/20260623000000_add_reminder_lead_time.sql new file mode 100644 index 0000000..0236550 --- /dev/null +++ b/supabase/migrations/20260623000000_add_reminder_lead_time.sql @@ -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;