@@ -12,21 +12,29 @@ import {
1212 logTable ,
1313 callWriteMethodWithReceiptBatchCalls ,
1414 stringArrayToAddressArray ,
15+ callReadMethodSilent ,
16+ stringToNumber ,
1517} from 'utils' ;
1618import { wrapperOperations } from './main.js' ;
1719import { getWithdrawalQueueContract } from 'contracts/defi-wrapper/withdrawal-queue.js' ;
1820import {
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' ;
2633import { getStvPoolContract } from 'contracts/defi-wrapper/stv-pool.js' ;
2734import { bigIntMin } from 'utils/bigInt.js' ;
2835import { 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
3139export 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