Skip to content

Commit 52cd569

Browse files
authored
Merge pull request #927 from Junirezz/fix/847-channels-integrate-payment-channels-with-renewal-executor
[#847] [P2] Channels: Integrate payment channels with renewal executor
2 parents 303341e + 5b33d4d commit 52cd569

9 files changed

Lines changed: 431 additions & 38 deletions

File tree

backend/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ import { adminAuth } from './middleware/admin';
6868
import { createAdminLimiter, RateLimiterFactory } from './middleware/rate-limit-factory';
6969
import { scheduleAutoResume } from './jobs/auto-resume';
7070
import { startSettlementBatchJob } from './jobs/settlement-batch-job';
71+
import { startChannelSettlementJob } from './jobs/channel-settlement-job';
7172
import { startJobAlertMonitor, stopJobAlertMonitor } from './jobs/job-alert-monitor';
7273
import giftCardLedgerRoutes from './routes/gift-card-ledger';
7374
import notificationDeadLetterRoutes from './routes/notification-dead-letter';
@@ -487,6 +488,7 @@ const server = app.listen(PORT, async () => {
487488

488489
scheduleAutoResume();
489490
startSettlementBatchJob();
491+
startChannelSettlementJob();
490492
startJobAlertMonitor();
491493

492494
telegramCommandService.init();
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import cron from 'node-cron';
2+
import logger from '../config/logger';
3+
import { runWithCorrelationId } from '../middleware/requestContext';
4+
import { channelStateService } from '../services/channel-state';
5+
import { paymentChannelService } from '../services/payment-channel-service';
6+
import { settlementBatcher } from '../services/settlement-batcher';
7+
8+
/**
9+
* Periodically settles accumulated channel balances on-chain per user schedule
10+
* (monthly or quarterly). Runs daily at 02:00 UTC.
11+
*/
12+
export function startChannelSettlementJob(): void {
13+
cron.schedule('0 2 * * *', () =>
14+
runWithCorrelationId('cron:channel-settlement', async (cid) => {
15+
if (process.env.PAYMENT_CHANNELS_ENABLED !== 'true') return;
16+
17+
try {
18+
const due = await channelStateService.getChannelsDueForSettlement();
19+
if (due.length === 0) return;
20+
21+
for (const candidate of due) {
22+
try {
23+
await settlementBatcher.enqueue({
24+
userId: candidate.userId,
25+
subscriptionId: candidate.channelId,
26+
amount: candidate.executorBalance,
27+
settlementType: 'channel_close',
28+
payload: { channelId: candidate.channelId },
29+
});
30+
31+
await paymentChannelService.initiateClose(
32+
candidate.userId,
33+
candidate.channelId,
34+
);
35+
await channelStateService.markChannelSettled(
36+
candidate.channelId,
37+
candidate.userId,
38+
);
39+
40+
logger.info('Channel scheduled for settlement', {
41+
correlationId: cid,
42+
channelId: candidate.channelId,
43+
amount: candidate.executorBalance,
44+
});
45+
} catch (err) {
46+
logger.error('Channel settlement failed', {
47+
correlationId: cid,
48+
channelId: candidate.channelId,
49+
error: err instanceof Error ? err.message : String(err),
50+
});
51+
}
52+
}
53+
} catch (error) {
54+
logger.error('Channel settlement job failed', { correlationId: cid, error });
55+
}
56+
}),
57+
);
58+
59+
logger.info('Channel settlement cron job scheduled (daily 02:00 UTC)');
60+
}
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import { supabase } from '../config/database';
2+
import logger from '../config/logger';
3+
import {
4+
paymentChannelService,
5+
type PaymentChannelRecord,
6+
} from './payment-channel-service';
7+
8+
export type SettlementSchedule = 'monthly' | 'quarterly';
9+
10+
export interface ChannelPaymentLog {
11+
channelId: string;
12+
userId: string;
13+
subscriptionId: string;
14+
amount: number;
15+
sequenceNumber: number;
16+
}
17+
18+
export interface ChannelSettlementCandidate {
19+
channelId: string;
20+
userId: string;
21+
executorBalance: number;
22+
}
23+
24+
export class ChannelStateService {
25+
/**
26+
* Returns the newest active channel with sufficient off-chain balance.
27+
*/
28+
async findPayableChannel(
29+
userId: string,
30+
amount: number,
31+
): Promise<PaymentChannelRecord | null> {
32+
const channels = await paymentChannelService.listChannels(userId);
33+
for (const channel of channels) {
34+
if (channel.state !== 'active') continue;
35+
const balance =
36+
channel.channelState?.userBalance ?? Number.parseFloat(channel.balance);
37+
if (balance >= amount) return channel;
38+
}
39+
return null;
40+
}
41+
42+
/**
43+
* Applies an off-chain state update and records the payment locally (not on-chain).
44+
*/
45+
async applyRenewalPayment(
46+
channelId: string,
47+
userId: string,
48+
subscriptionId: string,
49+
amount: number,
50+
): Promise<PaymentChannelRecord> {
51+
const updated = await paymentChannelService.applyOffChainRenewal(
52+
channelId,
53+
userId,
54+
amount,
55+
);
56+
57+
await this.logChannelPayment({
58+
channelId,
59+
userId,
60+
subscriptionId,
61+
amount,
62+
sequenceNumber: updated.channelState?.sequenceNumber ?? 0,
63+
});
64+
65+
return updated;
66+
}
67+
68+
async logChannelPayment(payment: ChannelPaymentLog): Promise<void> {
69+
const { error } = await supabase.from('channel_payments').insert({
70+
channel_id: payment.channelId,
71+
user_id: payment.userId,
72+
subscription_id: payment.subscriptionId,
73+
amount: payment.amount,
74+
sequence_number: payment.sequenceNumber,
75+
created_at: new Date().toISOString(),
76+
});
77+
78+
if (error) {
79+
logger.warn('Failed to log channel payment', {
80+
channelId: payment.channelId,
81+
error: error.message,
82+
});
83+
}
84+
}
85+
86+
async getSettlementSchedule(userId: string): Promise<SettlementSchedule> {
87+
const { data } = await supabase
88+
.from('profiles')
89+
.select('channel_settlement_schedule')
90+
.eq('id', userId)
91+
.maybeSingle();
92+
93+
return data?.channel_settlement_schedule === 'quarterly' ? 'quarterly' : 'monthly';
94+
}
95+
96+
/**
97+
* Active channels whose executor-side balance should be settled on-chain.
98+
*/
99+
async getChannelsDueForSettlement(): Promise<ChannelSettlementCandidate[]> {
100+
const { data: channels, error } = await supabase
101+
.from('payment_channels')
102+
.select('id, user_id, channel_state, last_settlement_at, state')
103+
.eq('state', 'active');
104+
105+
if (error) throw error;
106+
107+
const due: ChannelSettlementCandidate[] = [];
108+
const now = Date.now();
109+
110+
for (const row of channels ?? []) {
111+
const state = row.channel_state as { executorBalance?: number } | null;
112+
const executorBalance = state?.executorBalance ?? 0;
113+
if (executorBalance <= 0) continue;
114+
115+
const schedule = await this.getSettlementSchedule(row.user_id as string);
116+
const intervalMs =
117+
schedule === 'quarterly'
118+
? 90 * 24 * 60 * 60 * 1000
119+
: 30 * 24 * 60 * 60 * 1000;
120+
121+
const lastSettlement = row.last_settlement_at
122+
? new Date(row.last_settlement_at as string).getTime()
123+
: 0;
124+
125+
if (now - lastSettlement >= intervalMs) {
126+
due.push({
127+
channelId: row.id as string,
128+
userId: row.user_id as string,
129+
executorBalance,
130+
});
131+
}
132+
}
133+
134+
return due;
135+
}
136+
137+
async markChannelSettled(channelId: string, userId: string): Promise<void> {
138+
const { error } = await supabase
139+
.from('payment_channels')
140+
.update({
141+
last_settlement_at: new Date().toISOString(),
142+
updated_at: new Date().toISOString(),
143+
})
144+
.eq('id', channelId)
145+
.eq('user_id', userId);
146+
147+
if (error) throw error;
148+
}
149+
}
150+
151+
export const channelStateService = new ChannelStateService();

backend/src/services/renewal-executor.ts

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import logger from '../config/logger';
22
import { supabase } from '../config/database';
33
import { blockchainService } from './blockchain-service';
44
import { webhookService } from './webhook-service';
5-
import { paymentChannelService } from './payment-channel-service';
5+
import { channelStateService } from './channel-state';
66
import { settlementBatcher } from './settlement-batcher';
77
import { addMonths, addQuarters, addYears } from 'date-fns';
88
import { deriveEphemeralStealthAddress } from '@syncro/shared/crypto';
@@ -240,22 +240,26 @@ export class RenewalExecutor {
240240
return { used: false };
241241
}
242242

243-
const { data: channel } = await supabase
244-
.from('payment_channels')
245-
.select('id')
246-
.eq('user_id', userId)
247-
.eq('state', 'active')
248-
.order('created_at', { ascending: false })
249-
.limit(1)
250-
.maybeSingle();
251-
252-
if (!channel) return { used: false };
253-
254243
try {
255-
await paymentChannelService.applyOffChainRenewal(channel.id, userId, amount);
256-
logger.info('Off-chain channel renewal applied', { channelId: channel.id, subscriptionId });
244+
const channel = await channelStateService.findPayableChannel(userId, amount);
245+
if (!channel) return { used: false };
246+
247+
await channelStateService.applyRenewalPayment(
248+
channel.id,
249+
userId,
250+
subscriptionId,
251+
amount,
252+
);
253+
logger.info('Off-chain channel renewal applied', {
254+
channelId: channel.id,
255+
subscriptionId,
256+
});
257257
return { used: true };
258-
} catch {
258+
} catch (err) {
259+
logger.warn('Channel renewal failed, falling back to on-chain', {
260+
subscriptionId,
261+
error: err instanceof Error ? err.message : String(err),
262+
});
259263
return { used: false };
260264
}
261265
}
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
jest.mock('../src/config/database', () => ({
2+
supabase: { from: jest.fn() },
3+
}));
4+
5+
jest.mock('../src/config/logger', () => ({
6+
default: { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() },
7+
__esModule: true,
8+
}));
9+
10+
jest.mock('../src/services/payment-channel-service', () => ({
11+
paymentChannelService: {
12+
listChannels: jest.fn(),
13+
applyOffChainRenewal: jest.fn(),
14+
},
15+
}));
16+
17+
import { ChannelStateService } from '../src/services/channel-state';
18+
import { supabase } from '../src/config/database';
19+
import { paymentChannelService } from '../src/services/payment-channel-service';
20+
21+
describe('ChannelStateService', () => {
22+
let service: ChannelStateService;
23+
24+
beforeEach(() => {
25+
jest.clearAllMocks();
26+
service = new ChannelStateService();
27+
});
28+
29+
describe('findPayableChannel', () => {
30+
it('returns active channel with sufficient balance', async () => {
31+
(paymentChannelService.listChannels as jest.Mock).mockResolvedValue([
32+
{
33+
id: 'ch-1',
34+
state: 'active',
35+
balance: '50',
36+
channelState: { userBalance: 50, executorBalance: 0, sequenceNumber: 0, totalDeposited: 50 },
37+
},
38+
{
39+
id: 'ch-2',
40+
state: 'active',
41+
balance: '5',
42+
channelState: { userBalance: 5, executorBalance: 0, sequenceNumber: 0, totalDeposited: 5 },
43+
},
44+
]);
45+
46+
const channel = await service.findPayableChannel('user-1', 10);
47+
expect(channel?.id).toBe('ch-1');
48+
});
49+
50+
it('returns null when no channel has enough balance', async () => {
51+
(paymentChannelService.listChannels as jest.Mock).mockResolvedValue([
52+
{
53+
id: 'ch-1',
54+
state: 'active',
55+
balance: '5',
56+
channelState: { userBalance: 5, executorBalance: 0, sequenceNumber: 0, totalDeposited: 5 },
57+
},
58+
]);
59+
60+
const channel = await service.findPayableChannel('user-1', 10);
61+
expect(channel).toBeNull();
62+
});
63+
});
64+
65+
describe('applyRenewalPayment', () => {
66+
it('updates channel state and logs payment locally', async () => {
67+
const updatedChannel = {
68+
id: 'ch-1',
69+
state: 'active',
70+
balance: '40',
71+
channelState: { userBalance: 40, executorBalance: 10, sequenceNumber: 1, totalDeposited: 50 },
72+
};
73+
74+
(paymentChannelService.applyOffChainRenewal as jest.Mock).mockResolvedValue(updatedChannel);
75+
(supabase.from as jest.Mock).mockReturnValue({
76+
insert: jest.fn().mockResolvedValue({ error: null }),
77+
});
78+
79+
const result = await service.applyRenewalPayment('ch-1', 'user-1', 'sub-1', 10);
80+
81+
expect(paymentChannelService.applyOffChainRenewal).toHaveBeenCalledWith('ch-1', 'user-1', 10);
82+
expect(supabase.from).toHaveBeenCalledWith('channel_payments');
83+
expect(result.channelState?.sequenceNumber).toBe(1);
84+
});
85+
});
86+
87+
describe('getSettlementSchedule', () => {
88+
it('defaults to monthly', async () => {
89+
(supabase.from as jest.Mock).mockReturnValue({
90+
select: jest.fn().mockReturnThis(),
91+
eq: jest.fn().mockReturnThis(),
92+
maybeSingle: jest.fn().mockResolvedValue({ data: null }),
93+
});
94+
95+
const schedule = await service.getSettlementSchedule('user-1');
96+
expect(schedule).toBe('monthly');
97+
});
98+
99+
it('returns quarterly when configured', async () => {
100+
(supabase.from as jest.Mock).mockReturnValue({
101+
select: jest.fn().mockReturnThis(),
102+
eq: jest.fn().mockReturnThis(),
103+
maybeSingle: jest.fn().mockResolvedValue({
104+
data: { channel_settlement_schedule: 'quarterly' },
105+
}),
106+
});
107+
108+
const schedule = await service.getSettlementSchedule('user-1');
109+
expect(schedule).toBe('quarterly');
110+
});
111+
});
112+
});

0 commit comments

Comments
 (0)