Skip to content

Commit 48fe16f

Browse files
authored
Merge pull request #890 from Dev-Ben-Theo/feature/channels-jitter-blinding-factors
feat: Add payment channel management, reminder jitter, and blinding f…
2 parents f37c946 + c3b9ed8 commit 48fe16f

16 files changed

Lines changed: 763 additions & 31 deletions

backend/src/routes/compliance.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ router.get('/export', authenticate, exportRateLimit, async (req: AuthenticatedRe
105105
archive.append(JSON.stringify(data.emailAccounts, null, 2), { name: 'email_accounts.json' });
106106
archive.append(JSON.stringify(data.teams, null, 2), { name: 'teams.json' });
107107
archive.append(JSON.stringify(data.blockchainLogs, null, 2), { name: 'blockchain_logs.json' });
108+
archive.append(JSON.stringify(data.blindingFactors, null, 2), { name: 'blinding_factors.json' });
108109

109110
const readme = [
110111
'Syncro — Personal Data Export',
@@ -121,6 +122,7 @@ router.get('/export', authenticate, exportRateLimit, async (req: AuthenticatedRe
121122
' email_accounts.json — Connected email accounts',
122123
' teams.json — Team membership records',
123124
' blockchain_logs.json — On-chain contract events and renewal approvals',
125+
' blinding_factors.json — Pedersen commitment blinding factors for audit data',
124126
'',
125127
'For questions or deletion requests, contact support.',
126128
].join('\n');

backend/src/schemas/user-preferences.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export const userPreferencesUpdateSchema = z.object({
1212
.min(1, 'At least one reminder timing is required')
1313
.max(10, 'Maximum 10 reminder timings allowed')
1414
.optional(),
15+
reminder_jitter_level: z.enum(['off', 'low', 'medium', 'high']).optional(),
1516
email_opt_ins: z
1617
.object({
1718
marketing: z.boolean().optional(),

backend/src/services/compliance-service.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export interface UserExportData {
1515
contractEvents: any[];
1616
renewalApprovals: any[];
1717
};
18+
blindingFactors: any[];
1819
}
1920

2021
interface TokenVerificationResult {
@@ -86,6 +87,7 @@ export class ComplianceService {
8687
teamsResult,
8788
contractEventsResult,
8889
renewalApprovalsResult,
90+
blindingFactorsResult,
8991
] = await Promise.all([
9092
supabase.from('profiles').select('*').eq('id', userId).single(),
9193
supabase.from('subscriptions').select('*').eq('user_id', userId),
@@ -96,6 +98,7 @@ export class ComplianceService {
9698
supabase.from('team_members').select('*').eq('user_id', userId),
9799
supabase.from('contract_events').select('*').eq('user_id', userId),
98100
supabase.from('renewal_approvals').select('*').eq('user_id', userId),
101+
supabase.from('commitment_blinding_factors').select('*').eq('user_id', userId),
99102
]);
100103

101104
return {
@@ -110,6 +113,7 @@ export class ComplianceService {
110113
contractEvents: contractEventsResult.data || [],
111114
renewalApprovals: renewalApprovalsResult.data || [],
112115
},
116+
blindingFactors: blindingFactorsResult.data || [],
113117
};
114118
}
115119

backend/src/services/reminder-engine.ts

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -128,29 +128,55 @@ export class ReminderEngine {
128128
throw preferencesError;
129129
}
130130

131-
const prefsByUser = new Map<string, { reminder_timing?: number[] }>();
132-
(preferences ?? []).forEach((pref: { user_id: string; reminder_timing?: number[] }) => {
131+
const prefsByUser = new Map<string, { reminder_timing?: number[]; reminder_jitter_level?: string }>();
132+
(preferences ?? []).forEach((pref: { user_id: string; reminder_timing?: number[]; reminder_jitter_level?: string }) => {
133133
prefsByUser.set(pref.user_id, pref);
134134
});
135135

136136
const today = new Date();
137137
today.setHours(0, 0, 0, 0);
138138

139+
const jitterLevels: Record<string, number> = {
140+
off: 0,
141+
low: 2,
142+
medium: 6,
143+
high: 12
144+
};
145+
139146
for (const subscription of activeSubscriptions) {
140-
const timing = prefsByUser.get(subscription.user_id)?.reminder_timing ?? daysBefore;
147+
const userPref = prefsByUser.get(subscription.user_id);
148+
const timing = userPref?.reminder_timing ?? daysBefore;
149+
const jitterLevel = userPref?.reminder_jitter_level ?? 'off';
150+
const maxJitter = jitterLevels[jitterLevel] || 0;
141151
const renewalDate = new Date(subscription.active_until as string);
142152

143153
for (const day of timing) {
144-
const reminderDate = subDays(renewalDate, day);
154+
// Calculate base reminder date
155+
let reminderDate = subDays(renewalDate, day);
145156
reminderDate.setHours(0, 0, 0, 0);
146157

158+
let jitterOffsetHours = 0;
159+
if (maxJitter > 0) {
160+
// Generate random jitter between -maxJitter and +maxJitter
161+
jitterOffsetHours = (Math.random() * 2 * maxJitter) - maxJitter;
162+
// Apply jitter
163+
reminderDate = new Date(reminderDate.getTime() + jitterOffsetHours * 60 * 60 * 1000);
164+
// Ensure reminder is not after renewal date
165+
if (reminderDate > renewalDate) {
166+
reminderDate = new Date(renewalDate);
167+
// Recalculate offset to be the maximum possible before renewal
168+
jitterOffsetHours = (renewalDate.getTime() - subDays(renewalDate, day).setHours(0,0,0,0)) / (60*60*1000);
169+
}
170+
}
171+
147172
if (reminderDate >= today) {
148173
rows.push({
149174
subscription_id: subscription.id,
150175
user_id: subscription.user_id,
151176
reminder_date: reminderDate.toISOString().split('T')[0],
152177
reminder_type: 'renewal',
153178
days_before: day,
179+
jitter_offset_hours: maxJitter > 0 ? jitterOffsetHours : null,
154180
status: 'pending',
155181
created_at: new Date().toISOString(),
156182
updated_at: new Date().toISOString(),

backend/src/services/user-preference-service.ts

Lines changed: 24 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -4,29 +4,30 @@ import { UserPreferences, PartialUserPreferences } from '../types/reminder';
44

55
export class UserPreferenceService {
66
private readonly defaultPreferences: Omit<UserPreferences, 'user_id' | 'updated_at'> = {
7-
notification_channels: ['email'],
8-
reminder_timing: [7, 3, 1],
9-
email_opt_ins: {
10-
marketing: false,
11-
reminders: true,
12-
updates: true,
13-
},
14-
automation_flags: {
15-
auto_renew: false,
16-
auto_retry: true,
17-
},
18-
risk_notification_threshold: 'HIGH',
19-
quiet_hours_enabled: false,
20-
quiet_hours_start: '22:00',
21-
quiet_hours_end: '08:00',
22-
quiet_hours_timezone: 'UTC',
23-
critical_alerts_only: true,
24-
currency: 'USD',
25-
timezone: 'UTC',
26-
locale: 'en-US',
27-
calendar_sync_enabled: false,
28-
calendar_export_reminders: true,
29-
};
7+
notification_channels: ['email'],
8+
reminder_timing: [7, 3, 1],
9+
reminder_jitter_level: 'off', // default to no jitter
10+
email_opt_ins: {
11+
marketing: false,
12+
reminders: true,
13+
updates: true,
14+
},
15+
automation_flags: {
16+
auto_renew: false,
17+
auto_retry: true,
18+
},
19+
risk_notification_threshold: 'HIGH',
20+
quiet_hours_enabled: false,
21+
quiet_hours_start: '22:00',
22+
quiet_hours_end: '08:00',
23+
quiet_hours_timezone: 'UTC',
24+
critical_alerts_only: true,
25+
currency: 'USD',
26+
timezone: 'UTC',
27+
locale: 'en-US',
28+
calendar_sync_enabled: false,
29+
calendar_export_reminders: true,
30+
};
3031

3132
/**
3233
* Get user preferences, returning defaults if not found

backend/src/types/reminder.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export interface ReminderSchedule {
66
reminder_type: 'renewal' | 'trial_expiry' | 'cancellation';
77
days_before: number;
88
status: 'pending' | 'sent' | 'failed' | 'cancelled';
9+
jitter_offset_hours?: number; // ±N hours, stored for audit
910
created_at: string;
1011
updated_at: string;
1112
}
@@ -105,6 +106,7 @@ export interface UserPreferences {
105106
user_id: string;
106107
notification_channels: ('email' | 'push' | 'telegram' | 'slack')[];
107108
reminder_timing: number[]; // days before
109+
reminder_jitter_level: 'off' | 'low' | 'medium' | 'high'; // low=±2h, medium=±6h, high=±12h
108110
email_opt_ins: {
109111
marketing: boolean;
110112
reminders: boolean;
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
'use client';
2+
3+
import { useEffect, useState } from 'react';
4+
import Link from 'next/link';
5+
import { getChannels, PaymentChannel } from '@/lib/payment-channel';
6+
import { ChannelCard } from '@/components/channels/ChannelCard';
7+
import { ChannelDetail } from '@/components/channels/ChannelDetail';
8+
import { OpenChannelModal } from '@/components/channels/OpenChannelModal';
9+
import { Button } from '@/components/ui/button';
10+
11+
export default function ChannelsPage() {
12+
const [channels, setChannels] = useState<PaymentChannel[]>([]);
13+
const [selectedChannel, setSelectedChannel] = useState<PaymentChannel | null>(null);
14+
const [isModalOpen, setIsModalOpen] = useState(false);
15+
const [isLoading, setIsLoading] = useState(true);
16+
17+
const loadChannels = async () => {
18+
try {
19+
const data = await getChannels();
20+
setChannels(data);
21+
} catch (err) {
22+
console.error(err);
23+
} finally {
24+
setIsLoading(false);
25+
}
26+
};
27+
28+
useEffect(() => {
29+
loadChannels();
30+
// Poll for updates every 10 seconds
31+
const interval = setInterval(loadChannels, 10000);
32+
return () => clearInterval(interval);
33+
}, []);
34+
35+
const handleChannelOpened = (channel: PaymentChannel) => {
36+
setChannels((prev) => [...prev, channel]);
37+
};
38+
39+
const handleChannelUpdate = (updated: PaymentChannel) => {
40+
setChannels((prev) => prev.map((c) => (c.id === updated.id ? updated : c)));
41+
setSelectedChannel(updated);
42+
};
43+
44+
const hasLowBalance = channels.some((c) => parseFloat(c.balance) < 10);
45+
const hasExpiring = channels.some((c) => c.expiry && new Date(c.expiry) < new Date(Date.now() + 7 * 24 * 60 * 60 * 1000));
46+
47+
return (
48+
<main className="min-h-screen bg-gray-50 py-12 px-4">
49+
<div className="max-w-4xl mx-auto">
50+
<div className="flex justify-between items-center mb-8">
51+
<div>
52+
<Link
53+
href="/settings/privacy"
54+
className="inline-flex items-center text-sm text-gray-500 hover:text-gray-700 mb-4 transition-colors"
55+
>
56+
<svg className="w-4 h-4 mr-1.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
57+
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
58+
</svg>
59+
Back to Privacy & Data
60+
</Link>
61+
<h1 className="text-2xl font-semibold text-gray-900 mb-1">Payment Channels</h1>
62+
<p className="text-sm text-gray-500">Open, monitor, and manage your payment channels.</p>
63+
</div>
64+
<Button onClick={() => setIsModalOpen(true)}>Open Channel</Button>
65+
</div>
66+
67+
{(hasLowBalance || hasExpiring) && (
68+
<div className="mb-6 space-y-3">
69+
{hasLowBalance && (
70+
<div className="flex items-start gap-2 text-sm text-yellow-700 bg-yellow-50 border border-yellow-200 rounded-lg px-4 py-3">
71+
<svg className="w-4 h-4 flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
72+
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
73+
</svg>
74+
<span>One or more channels have low balances. Consider topping up to avoid service interruptions.</span>
75+
</div>
76+
)}
77+
{hasExpiring && (
78+
<div className="flex items-start gap-2 text-sm text-orange-700 bg-orange-50 border border-orange-200 rounded-lg px-4 py-3">
79+
<svg className="w-4 h-4 flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
80+
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
81+
</svg>
82+
<span>One or more channels are expiring soon. Close or renew them to avoid disputes.</span>
83+
</div>
84+
)}
85+
</div>
86+
)}
87+
88+
{selectedChannel ? (
89+
<ChannelDetail
90+
channel={selectedChannel}
91+
onBack={() => setSelectedChannel(null)}
92+
onUpdate={handleChannelUpdate}
93+
/>
94+
) : (
95+
<>
96+
{isLoading ? (
97+
<div className="grid gap-4 md:grid-cols-2">
98+
{[1, 2].map((i) => (
99+
<div key={i} className="bg-white rounded-2xl border border-gray-200 shadow-sm p-6 animate-pulse">
100+
<div className="h-6 bg-gray-200 rounded w-1/2 mb-2" />
101+
<div className="h-4 bg-gray-200 rounded w-1/3 mb-6" />
102+
<div className="h-8 bg-gray-200 rounded w-1/4 mb-2" />
103+
<div className="h-4 bg-gray-200 rounded w-2/3" />
104+
</div>
105+
))}
106+
</div>
107+
) : channels.length === 0 ? (
108+
<div className="text-center py-12">
109+
<h3 className="text-lg font-medium text-gray-900 mb-2">No channels yet</h3>
110+
<p className="text-sm text-gray-500 mb-6">Open a payment channel to get started.</p>
111+
<Button onClick={() => setIsModalOpen(true)}>Open Channel</Button>
112+
</div>
113+
) : (
114+
<div className="grid gap-4 md:grid-cols-2">
115+
{channels.map((channel) => (
116+
<ChannelCard
117+
key={channel.id}
118+
channel={channel}
119+
onSelect={setSelectedChannel}
120+
/>
121+
))}
122+
</div>
123+
)}
124+
</>
125+
)}
126+
</div>
127+
128+
<OpenChannelModal
129+
isOpen={isModalOpen}
130+
onClose={() => setIsModalOpen(false)}
131+
onChannelOpened={handleChannelOpened}
132+
/>
133+
</main>
134+
);
135+
}

0 commit comments

Comments
 (0)