Skip to content

Commit b96cc9c

Browse files
committed
feat: dw autoreport
1 parent 433a2d6 commit b96cc9c

File tree

4 files changed

+355
-5
lines changed

4 files changed

+355
-5
lines changed

features/utils/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,4 @@ export * from './quarantine.js';
66
export * from './settled-growth.js';
77
export * from './get-boolean.js';
88
export * from './connection.js';
9+
export * from './try-fetch.js';

features/utils/try-fetch.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
export const tryFetchPost = async <TResult = any>(
2+
url: string,
3+
body: TResult,
4+
) => {
5+
let success = false;
6+
let result = null;
7+
let error = null;
8+
try {
9+
const response = await fetch(url, {
10+
method: 'POST',
11+
headers: { 'Content-Type': 'application/json' },
12+
body: JSON.stringify(body, (_, value) =>
13+
typeof value === 'bigint' ? value.toString() : value,
14+
),
15+
});
16+
if (!response.ok) {
17+
throw new Error(
18+
`HTTP error! status: ${response.status}, statusText: ${response.statusText}`,
19+
);
20+
}
21+
result = (await response.json()) as TResult;
22+
success = true;
23+
} catch (errorCaught) {
24+
error = errorCaught;
25+
}
26+
return {
27+
success,
28+
result,
29+
error,
30+
};
31+
};

programs/defi-wrapper/use-cases/wrapper-operations/write.ts

Lines changed: 316 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,21 +12,29 @@ import {
1212
logTable,
1313
callWriteMethodWithReceiptBatchCalls,
1414
stringArrayToAddressArray,
15+
callReadMethodSilent,
16+
stringToNumber,
1517
} from 'utils';
1618
import { wrapperOperations } from './main.js';
1719
import { getWithdrawalQueueContract } from 'contracts/defi-wrapper/withdrawal-queue.js';
1820
import {
1921
encodeFunctionData,
2022
formatEther,
2123
parseEventLogs,
24+
WatchContractEventOnLogsParameter,
2225
zeroAddress,
2326
type Address,
2427
} from 'viem';
25-
import { getDashboardContract, getVaultHubContract } from 'contracts';
28+
import {
29+
getDashboardContract,
30+
getLazyOracleContract,
31+
getVaultHubContract,
32+
} from 'contracts';
2633
import { getStvPoolContract } from 'contracts/defi-wrapper/stv-pool.js';
2734
import { bigIntMin } from 'utils/bigInt.js';
2835
import { getStvStethPoolContract } from 'contracts/defi-wrapper/stv-steth-pool.js';
29-
import { areVaultParamsInSync } from 'features';
36+
import { areVaultParamsInSync, tryFetchPost } from 'features';
37+
import { LazyOracleAbi } from 'abi';
3038

3139
export const wrapperOperationsWrite = wrapperOperations
3240
.command('write')
@@ -346,3 +354,309 @@ wrapperOperationsWrite
346354
})),
347355
});
348356
});
357+
358+
wrapperOperationsWrite
359+
.command('auto-report')
360+
.description(
361+
`watches for new reports, automatically submits report and finalizes withdrawals. Will run indefinitely.
362+
For finalization make sure private key has FINALIZE_ROLE in the withdrawal queue.
363+
⚠️⚠️⚠️ For production use consider running with a process manager ⚠️⚠️⚠️`,
364+
)
365+
.argument('<poolAddress>', 'pool address', stringToAddress)
366+
.option('--skip-report', 'skip report submission step', false)
367+
.option('--skip-finalize', 'skip finalize withdrawals step', false)
368+
.option(
369+
'--callback-url <callbackUrl>',
370+
'callback url to notify when report is submitted and withdrawals are finalized via POST request',
371+
)
372+
.option(
373+
'--gas-coverage-recipient <gasCoverageRecipient>',
374+
'address to receive gas coverage(if any), defaults to tx sender',
375+
stringToAddress,
376+
zeroAddress,
377+
)
378+
.option(
379+
'--max-requests <maxRequestCount>',
380+
'maximum number of requests to finalize',
381+
stringToBigInt,
382+
1000n,
383+
)
384+
.option(
385+
'--polling-interval <pollingInterval>',
386+
'polling interval in ms for checking new reports, default: 5 * 60_000 (5 minutes)',
387+
stringToNumber,
388+
5 * 60_000,
389+
)
390+
.action(
391+
async (
392+
address: Address,
393+
{
394+
skipReport,
395+
skipFinalize,
396+
maxRequests,
397+
gasCoverageRecipient,
398+
pollingInterval,
399+
callbackUrl,
400+
}: {
401+
skipReport: boolean;
402+
skipFinalize: boolean;
403+
maxRequests: bigint;
404+
pollingInterval: number;
405+
gasCoverageRecipient: Address;
406+
callbackUrl?: string;
407+
},
408+
) => {
409+
if (skipReport && !skipFinalize) {
410+
logError(
411+
'Cannot skip report submission when finalizing withdrawals. Report must be fresh before finalization.',
412+
);
413+
return;
414+
}
415+
416+
const pool = await getStvPoolContract(address);
417+
const vaultAddress = await callReadMethod({
418+
contract: pool,
419+
methodName: 'VAULT',
420+
payload: [],
421+
});
422+
const withdrawalQueueAddress = await callReadMethod({
423+
contract: pool,
424+
methodName: 'WITHDRAWAL_QUEUE',
425+
payload: [],
426+
});
427+
const lazyOracle = await getLazyOracleContract();
428+
const vaultHub = await getVaultHubContract();
429+
430+
const withdrawalQueue = await getWithdrawalQueueContract(
431+
withdrawalQueueAddress,
432+
);
433+
434+
const FINALIZER_ROLE = await callReadMethod({
435+
contract: withdrawalQueue,
436+
methodName: 'FINALIZE_ROLE',
437+
payload: [],
438+
});
439+
440+
const finalizers = await callReadMethod({
441+
contract: withdrawalQueue,
442+
methodName: 'getRoleMembers',
443+
payload: [[FINALIZER_ROLE]],
444+
});
445+
446+
const finalizer = finalizers[0];
447+
if (!finalizer) {
448+
logError(
449+
'No FINALIZE_ROLE holders found for the withdrawal queue. Cannot proceed with auto-reporting.',
450+
);
451+
return;
452+
}
453+
454+
const onNewReport = async (
455+
events: WatchContractEventOnLogsParameter<
456+
typeof LazyOracleAbi,
457+
'VaultsReportDataUpdated',
458+
true
459+
>,
460+
) => {
461+
const event = events[0];
462+
if (event) {
463+
logInfo('New report is available');
464+
logTable({
465+
data: [
466+
['Data CID', event.args.cid],
467+
['Merkle Root', event.args.root],
468+
['Ref slot', event.args.refSlot],
469+
['Timestamp', event.args.timestamp],
470+
],
471+
});
472+
}
473+
474+
const isConnected = await callReadMethod({
475+
contract: vaultHub,
476+
methodName: 'isVaultConnected',
477+
payload: [[vaultAddress]],
478+
});
479+
480+
const isReportFresh = await callReadMethodSilent({
481+
contract: vaultHub,
482+
methodName: 'isReportFresh',
483+
payload: [[vaultAddress]],
484+
});
485+
486+
if (!isConnected) {
487+
logError(
488+
`Vault ${vaultAddress} is not connected to VaultHub. Cannot proceed with report submission.`,
489+
);
490+
}
491+
492+
if (isReportFresh) {
493+
logInfo('Report is already fresh. No submission needed.');
494+
}
495+
496+
if (!skipReport && isConnected && !isReportFresh) {
497+
const { isFresh } = await submitReport({
498+
vault: vaultAddress,
499+
skipConfirmation: true,
500+
});
501+
logInfo(`Report submission completed. isFresh: ${isFresh}`);
502+
} else {
503+
logInfo('Report submission step skipped.');
504+
}
505+
506+
let canFinalize = false;
507+
let requestsToFinalize = 0n;
508+
let requestsFinalized = 0n;
509+
let assetsFinalized = 0n;
510+
511+
const unfinalizedRequestsNumber = await callReadMethod({
512+
contract: withdrawalQueue,
513+
methodName: 'unfinalizedRequestsNumber',
514+
payload: [],
515+
});
516+
const unfinalizedAssets = await callReadMethod({
517+
contract: withdrawalQueue,
518+
methodName: 'unfinalizedAssets',
519+
payload: [],
520+
});
521+
const lastFinalizedRequestId = await callReadMethod({
522+
contract: withdrawalQueue,
523+
methodName: 'getLastFinalizedRequestId',
524+
payload: [],
525+
});
526+
527+
try {
528+
if (unfinalizedRequestsNumber > 0n) {
529+
const { result: requestFinalized } =
530+
await withdrawalQueue.simulate.finalize(
531+
[maxRequests, zeroAddress],
532+
{
533+
account: finalizers[0],
534+
},
535+
);
536+
537+
logInfo(
538+
`Finalization Simulation:
539+
requests finalized ${requestsToFinalize}/${unfinalizedRequestsNumber}`,
540+
);
541+
542+
requestsToFinalize = requestFinalized;
543+
544+
// this is a sanity check, should not happen in contract
545+
if (requestFinalized <= 0n)
546+
throw new Error('No requests finalized in simulation');
547+
} else {
548+
logInfo('No pending withdrawals to finalize.');
549+
}
550+
} catch (error) {
551+
canFinalize = false;
552+
logInfo('Finalization simulation failed', error);
553+
}
554+
555+
if (!skipFinalize) {
556+
if (canFinalize) {
557+
logInfo('Proceeding to finalize withdrawals...');
558+
await callWriteMethodWithReceipt({
559+
contract: withdrawalQueue,
560+
methodName: 'finalize',
561+
payload: [maxRequests, gasCoverageRecipient],
562+
});
563+
requestsFinalized = await callReadMethod({
564+
contract: withdrawalQueue,
565+
methodName: 'getLastFinalizedRequestId',
566+
payload: [],
567+
});
568+
assetsFinalized = await callReadMethod({
569+
contract: withdrawalQueue,
570+
methodName: 'unfinalizedAssets',
571+
payload: [],
572+
});
573+
574+
assetsFinalized = unfinalizedAssets - assetsFinalized;
575+
requestsFinalized -= lastFinalizedRequestId;
576+
} else {
577+
logInfo(
578+
'Finalization step skipped due to lack of pending withdrawals or simulation failure.',
579+
);
580+
}
581+
} else {
582+
logInfo('Finalization step skipped.');
583+
}
584+
585+
logTable({
586+
data: [
587+
['Pool Address', address],
588+
['Vault Address', vaultAddress],
589+
['Is Connected', isConnected],
590+
['Was Report Fresh', isReportFresh],
591+
['Report Submitted', !skipReport && isConnected && !isReportFresh],
592+
['Can Finalize', canFinalize],
593+
['Finalization Requested', !skipFinalize && canFinalize],
594+
['Total Unfinalized Requests', unfinalizedRequestsNumber],
595+
[
596+
'Total Unfinalized Assets',
597+
formatEther(unfinalizedAssets) + ' ETH',
598+
],
599+
['Requests Finalized', requestsFinalized],
600+
['Assets Finalized', formatEther(assetsFinalized) + ' ETH'],
601+
],
602+
});
603+
604+
if (callbackUrl) {
605+
const { error } = await tryFetchPost(callbackUrl, {
606+
poolAddress: address,
607+
vaultAddress: vaultAddress,
608+
isConnected: isConnected,
609+
wasReportFresh: isReportFresh,
610+
reportSubmitted: !skipReport && isConnected && !isReportFresh,
611+
canFinalize,
612+
finalizationRequested: !skipFinalize && canFinalize,
613+
totalUnfinalizedRequests: unfinalizedRequestsNumber,
614+
totalUnfinalizedAssets: unfinalizedAssets,
615+
requestsFinalized: requestsFinalized,
616+
assetsFinalized: assetsFinalized,
617+
});
618+
if (error) {
619+
logError(`Failed to send callback to ${callbackUrl}: ${error}`);
620+
}
621+
}
622+
};
623+
624+
// dry run to submit report & finalize withdrawals immediately if possible
625+
logInfo(
626+
'Performing initial dry-run in case report is already available...',
627+
);
628+
await onNewReport([]);
629+
630+
logInfo('Starting watching for reports...');
631+
lazyOracle.watchEvent.VaultsReportDataUpdated(
632+
{},
633+
{
634+
onLogs: () => void onNewReport,
635+
batch: false,
636+
poll: true,
637+
strict: true,
638+
pollingInterval,
639+
onError: (error) => {
640+
logError(
641+
`Error while watching VaultsReportDataUpdated events: ${error}`,
642+
);
643+
if (callbackUrl) {
644+
void tryFetchPost(callbackUrl, {
645+
error: `Error while watching VaultsReportDataUpdated events: ${error}`,
646+
}).then(({ error }) => {
647+
if (error) {
648+
logError(
649+
`Failed to send error callback to ${callbackUrl}: ${error}`,
650+
);
651+
}
652+
});
653+
}
654+
655+
process.exit(1);
656+
},
657+
},
658+
);
659+
// empty await to keep the process alive
660+
await new Promise(() => {});
661+
},
662+
);

0 commit comments

Comments
 (0)