Skip to content

Commit 9a12dbc

Browse files
authored
Merge pull request #166 from nafsonig/feat/multi
Feat/multi
2 parents 1c23e4b + b91e457 commit 9a12dbc

File tree

5 files changed

+792
-74
lines changed

5 files changed

+792
-74
lines changed

apps/api-service/src/gas-estimation/__tests__/fee-configuration.service.spec.ts

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,148 @@ describe("FeeConfigurationService", () => {
341341
expect(updatedSettings.requireApprovalForLargeChanges).toBe(true);
342342
expect(updatedSettings.enableUserNotifications).toBe(true);
343343
});
344+
345+
it("should validate multisig threshold against signer count", async () => {
346+
await expect(
347+
service.updateAdminSettings(
348+
{
349+
multisigSigners: ["admin-1"],
350+
multisigApprovalThreshold: 2,
351+
},
352+
"admin-user",
353+
),
354+
).rejects.toThrow(
355+
"Multisig approval threshold cannot exceed the number of configured signers.",
356+
);
357+
});
358+
});
359+
360+
describe("multisig approval workflow", () => {
361+
beforeEach(async () => {
362+
await service.updateAdminSettings(
363+
{
364+
multisigSigners: ["admin-1", "admin-2"],
365+
multisigApprovalThreshold: 2,
366+
},
367+
"admin-user",
368+
);
369+
});
370+
371+
it("should require a multisig approval request for large changes", async () => {
372+
const updateRequest: FeeUpdateRequest = {
373+
basePricePerRequest: 0.00003,
374+
reason: "Large price adjustment",
375+
notifyUsers: false,
376+
};
377+
378+
await expect(
379+
service.updateConfiguration("default", updateRequest, "admin-1"),
380+
).rejects.toThrow(
381+
"Large fee changes must be submitted through the multisig approval workflow.",
382+
);
383+
384+
const approvalRequest = await service.createApprovalRequest(
385+
"default",
386+
updateRequest,
387+
"admin-1",
388+
);
389+
390+
expect(approvalRequest.status).toBe("PENDING");
391+
expect(approvalRequest.approvals).toEqual(["admin-1"]);
392+
expect(approvalRequest.threshold).toBe(2);
393+
});
394+
395+
it("should apply the update after enough signer approvals", async () => {
396+
const updateRequest: FeeUpdateRequest = {
397+
basePricePerRequest: 0.00003,
398+
reason: "Critical pricing change",
399+
notifyUsers: false,
400+
};
401+
402+
const approvalRequest = await service.createApprovalRequest(
403+
"default",
404+
updateRequest,
405+
"admin-1",
406+
);
407+
408+
const approvedRequest = await service.approveApprovalRequest(
409+
approvalRequest.id,
410+
"admin-2",
411+
);
412+
413+
expect(approvedRequest.status).toBe("APPROVED");
414+
const currentConfig = await service.getCurrentConfiguration();
415+
expect(currentConfig.basePricePerRequest).toBe(0.00003);
416+
});
417+
418+
it("should reject approvals from unauthorized users", async () => {
419+
const updateRequest: FeeUpdateRequest = {
420+
basePricePerRequest: 0.00003,
421+
reason: "Unauthorized approval attempt",
422+
notifyUsers: false,
423+
};
424+
425+
const approvalRequest = await service.createApprovalRequest(
426+
"default",
427+
updateRequest,
428+
"admin-1",
429+
);
430+
431+
await expect(
432+
service.approveApprovalRequest(approvalRequest.id, "not-a-signer"),
433+
).rejects.toThrow(
434+
"User not-a-signer is not authorized to approve this request",
435+
);
436+
});
437+
});
438+
439+
describe("timelock delay", () => {
440+
beforeEach(async () => {
441+
await service.updateAdminSettings(
442+
{
443+
timelockDelayMinutes: 1,
444+
},
445+
"admin-user",
446+
);
447+
448+
jest.useFakeTimers({ now: new Date("2026-03-29T12:00:00.000Z") });
449+
});
450+
451+
afterEach(() => {
452+
jest.useRealTimers();
453+
});
454+
455+
it("should schedule direct updates when the delay has not yet elapsed", async () => {
456+
const updateRequest: FeeUpdateRequest = {
457+
basePricePerRequest: 0.00002,
458+
reason: "Delayed pricing update",
459+
notifyUsers: false,
460+
};
461+
462+
const currentConfig = await service.updateConfiguration(
463+
"default",
464+
updateRequest,
465+
"admin-user",
466+
);
467+
468+
expect(currentConfig.basePricePerRequest).toBe(0.00001);
469+
470+
const scheduledUpdates = await service.getScheduledUpdates("default");
471+
expect(scheduledUpdates.length).toBe(1);
472+
expect(scheduledUpdates[0].status).toBe("SCHEDULED");
473+
expect(scheduledUpdates[0].scheduledAt.toISOString()).toBe(
474+
new Date("2026-03-29T12:01:00.000Z").toISOString(),
475+
);
476+
477+
jest.setSystemTime(new Date("2026-03-29T12:02:00.000Z"));
478+
const executed = await service.processPendingScheduledUpdates();
479+
480+
expect(executed.length).toBe(1);
481+
expect(executed[0].status).toBe("EXECUTED");
482+
483+
const updatedConfig = await service.getCurrentConfiguration();
484+
expect(updatedConfig.basePricePerRequest).toBe(0.00002);
485+
});
344486
});
345487

346488
describe("validateUpdateRequest", () => {

apps/api-service/src/gas-estimation/controllers/fee-configuration.controller.ts

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,161 @@ export class FeeConfigurationController {
113113
}
114114
}
115115

116+
/**
117+
* Create a multisig approval request for a large fee update
118+
*/
119+
@Post(':configId/approval-requests')
120+
async createApprovalRequest(
121+
@Param('configId') configId: string,
122+
@Body() updateRequest: FeeUpdateRequest,
123+
) {
124+
try {
125+
const adminUserId = 'admin-user'; // Placeholder
126+
const approvalRequest = await this.feeConfigurationService.createApprovalRequest(
127+
configId,
128+
updateRequest,
129+
adminUserId,
130+
);
131+
return {
132+
success: true,
133+
data: approvalRequest,
134+
message: 'Approval request created successfully',
135+
};
136+
} catch (error) {
137+
throw new HttpException(
138+
error.message || 'Failed to create approval request',
139+
HttpStatus.BAD_REQUEST,
140+
);
141+
}
142+
}
143+
144+
@Get('approval-requests')
145+
async getApprovalRequests(@Query('configId') configId?: string) {
146+
try {
147+
const approvalRequests = await this.feeConfigurationService.getApprovalRequests(configId);
148+
return {
149+
success: true,
150+
data: approvalRequests,
151+
};
152+
} catch (error) {
153+
throw new HttpException(
154+
error.message || 'Failed to fetch approval requests',
155+
HttpStatus.INTERNAL_SERVER_ERROR,
156+
);
157+
}
158+
}
159+
160+
@Get('approval-requests/:requestId')
161+
async getApprovalRequest(@Param('requestId') requestId: string) {
162+
try {
163+
const approvalRequest = await this.feeConfigurationService.getApprovalRequest(requestId);
164+
return {
165+
success: true,
166+
data: approvalRequest,
167+
};
168+
} catch (error) {
169+
throw new HttpException(
170+
error.message || 'Failed to fetch approval request',
171+
HttpStatus.INTERNAL_SERVER_ERROR,
172+
);
173+
}
174+
}
175+
176+
@Get('scheduled-updates')
177+
async getScheduledUpdates(@Query('configId') configId?: string) {
178+
try {
179+
const scheduledUpdates = await this.feeConfigurationService.getScheduledUpdates(configId);
180+
return {
181+
success: true,
182+
data: scheduledUpdates,
183+
};
184+
} catch (error) {
185+
throw new HttpException(
186+
error.message || 'Failed to fetch scheduled updates',
187+
HttpStatus.INTERNAL_SERVER_ERROR,
188+
);
189+
}
190+
}
191+
192+
@Get('scheduled-updates/:updateId')
193+
async getScheduledUpdate(@Param('updateId') updateId: string) {
194+
try {
195+
const scheduledUpdate = await this.feeConfigurationService.getScheduledUpdate(updateId);
196+
return {
197+
success: true,
198+
data: scheduledUpdate,
199+
};
200+
} catch (error) {
201+
throw new HttpException(
202+
error.message || 'Failed to fetch scheduled update',
203+
HttpStatus.INTERNAL_SERVER_ERROR,
204+
);
205+
}
206+
}
207+
208+
@Post('scheduled-updates/process')
209+
async processScheduledUpdates() {
210+
try {
211+
const executedUpdates = await this.feeConfigurationService.processPendingScheduledUpdates();
212+
return {
213+
success: true,
214+
data: executedUpdates,
215+
message: 'Processed pending scheduled updates',
216+
};
217+
} catch (error) {
218+
throw new HttpException(
219+
error.message || 'Failed to process scheduled updates',
220+
HttpStatus.BAD_REQUEST,
221+
);
222+
}
223+
}
224+
225+
@Post('approval-requests/:requestId/approve')
226+
async approveApprovalRequest(
227+
@Param('requestId') requestId: string,
228+
) {
229+
try {
230+
const adminUserId = 'admin-user'; // Placeholder
231+
const approvalRequest = await this.feeConfigurationService.approveApprovalRequest(
232+
requestId,
233+
adminUserId,
234+
);
235+
return {
236+
success: true,
237+
data: approvalRequest,
238+
message: 'Approval recorded successfully',
239+
};
240+
} catch (error) {
241+
throw new HttpException(
242+
error.message || 'Failed to approve request',
243+
HttpStatus.BAD_REQUEST,
244+
);
245+
}
246+
}
247+
248+
@Post('approval-requests/:requestId/reject')
249+
async rejectApprovalRequest(
250+
@Param('requestId') requestId: string,
251+
) {
252+
try {
253+
const adminUserId = 'admin-user'; // Placeholder
254+
const approvalRequest = await this.feeConfigurationService.rejectApprovalRequest(
255+
requestId,
256+
adminUserId,
257+
);
258+
return {
259+
success: true,
260+
data: approvalRequest,
261+
message: 'Approval request rejected successfully',
262+
};
263+
} catch (error) {
264+
throw new HttpException(
265+
error.message || 'Failed to reject request',
266+
HttpStatus.BAD_REQUEST,
267+
);
268+
}
269+
}
270+
116271
/**
117272
* Create new fee configuration
118273
*/

apps/api-service/src/gas-estimation/interfaces/fee-config.interface.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,38 @@ export interface FeeValidationResult {
121121
};
122122
}
123123

124+
export type ApprovalStatus = "PENDING" | "APPROVED" | "REJECTED";
125+
126+
export interface ApprovalRequest {
127+
id: string;
128+
configurationId: string;
129+
requestedBy: string;
130+
request: FeeUpdateRequest;
131+
requiredSigners: string[];
132+
approvals: string[];
133+
rejections: string[];
134+
threshold: number;
135+
status: ApprovalStatus;
136+
createdAt: Date;
137+
updatedAt: Date;
138+
reason: string;
139+
effectiveDate: Date;
140+
notifyUsers: boolean;
141+
}
142+
143+
export type ScheduledUpdateStatus = "SCHEDULED" | "EXECUTED" | "CANCELLED";
144+
145+
export interface ScheduledUpdate {
146+
id: string;
147+
configurationId: string;
148+
request: FeeUpdateRequest;
149+
createdBy: string;
150+
scheduledAt: Date;
151+
status: ScheduledUpdateStatus;
152+
createdAt: Date;
153+
updatedAt: Date;
154+
}
155+
124156
export interface FeeConfigurationHistory {
125157
id: string;
126158
configurationId: string;
@@ -200,7 +232,10 @@ export interface AdminFeeSettings {
200232
allowFeeUpdates: boolean;
201233
requireApprovalForLargeChanges: boolean;
202234
largeChangeThreshold: number; // percentage change
203-
approvalRequiredUsers: string[]; // Admin user IDs required for approval
235+
approvalRequiredUsers: string[]; // Legacy list of admin user IDs required for approval
236+
multisigSigners: string[]; // Authorized multisig signer user IDs
237+
multisigApprovalThreshold: number; // Number of signers required to approve large changes
238+
timelockDelayMinutes: number; // Minimum delay before upgrade execution
204239
defaultGracePeriod: number; // days before fee changes take effect
205240
enableUserNotifications: boolean;
206241
notificationChannels: ("email" | "sms" | "in-app" | "webhook")[];

0 commit comments

Comments
 (0)