feat(cli): Add avs validator status command (#4056)

### Description

<!--
What's included in this PR?
-->

- Command to check all operators on our AVS and show the chains that
they are validating on
- Already been reviewed here
https://github.com/hyperlane-xyz/hyperlane-monorepo/pull/4004, this PR
was to merge it main instead of cli-2.0

Example usage:

```
yarn hyperlane avs check --chain ethereum --registry $REGISTRY
Hyperlane CLI
Checking AVS validator status for ethereum, this may take up to a minute to run...
️ MerkleTreeHook is not deployed on anvil8545



Operator name: Abacus Works AVS Operator
Operator address: 0xFe114FcC7609578f525219a8eF77e2CCe27C5357
Validator address: 0x03c842db86A6A3E524D4a6615390c1Ea8E2b9541
  Validating on...
  ethereum
  Storage location: s3://hyperlane-mainnet3-ethereum-validator-0/us-east-1
  Latest merkle tree checkpoint index: 8219
  Latest validator checkpoint index: 8219
   Validator is signing latest checkpoint



Operator name: Kelp by Kiln
Operator address: 0x96fC0751e0febe7296d4625500f8e4535a002c7d
Validator address: 0xEa5f21513182e97D0169a4d2E7aC71Ae8827F5bC
  Validating on...
  ethereum
  Storage location: s3://kiln-mainnet-hyperlane-validator-signatures/eu-west-1/ethereum
  Latest merkle tree checkpoint index: 8219
   Failed to fetch latest signed checkpoint index
  The following warnings were encountered:
   ️ Failed to fetch latest signed checkpoint index of validator on ethereum, this is likely due to failing to read an S3 bucket
```

### Drive-by changes

<!--
Are there any minor or drive-by changes also included?
-->

### Related issues

<!--
- Fixes #[issue number here]
-->

- Fixes #3976 

### Backward compatibility

<!--
Are these changes backward compatible? Are there any infrastructure
implications, e.g. changes that would prohibit deploying older commits
using this infra tooling?

Yes/No
-->
No

### Testing

<!--
What kind of testing have these changes undergone?

None/Manual/Unit Tests
-->
Manual
pull/4034/head
Mohammed Hussan 5 months ago committed by GitHub
parent aecb65a986
commit 44cc9bf6b3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 6
      .changeset/brave-penguins-ring.md
  2. 5
      solidity/contracts/interfaces/avs/vendored/IDelegationManager.sol
  3. 449
      typescript/cli/src/avs/check.ts
  4. 3
      typescript/cli/src/avs/config.ts
  5. 2
      typescript/cli/src/avs/stakeRegistry.ts
  6. 69
      typescript/cli/src/commands/avs.ts
  7. 17
      typescript/cli/src/commands/options.ts
  8. 8
      typescript/cli/src/logger.ts
  9. 8
      typescript/cli/src/utils/files.ts
  10. 69
      typescript/cli/src/validator/utils.ts
  11. 2
      typescript/sdk/src/aws/s3.ts
  12. 4
      typescript/sdk/src/aws/validator.ts

@ -0,0 +1,6 @@
---
'@hyperlane-xyz/cli': minor
'@hyperlane-xyz/core': minor
---
Add CLI command to support AVS validator status check

@ -20,6 +20,11 @@ interface IDelegationManager {
uint32 stakerOptOutWindowBlocks;
}
event OperatorMetadataURIUpdated(
address indexed operator,
string metadataURI
);
function registerAsOperator(
OperatorDetails calldata registeringOperatorDetails,
string calldata metadataURI

@ -0,0 +1,449 @@
import { Wallet } from 'ethers';
import {
ECDSAStakeRegistry__factory,
IDelegationManager__factory,
MerkleTreeHook__factory,
ValidatorAnnounce__factory,
} from '@hyperlane-xyz/core';
import { ChainMap, ChainName, MultiProvider } from '@hyperlane-xyz/sdk';
import { Address, ProtocolType, isObjEmpty } from '@hyperlane-xyz/utils';
import { CommandContext } from '../context/types.js';
import {
errorRed,
log,
logBlue,
logBlueKeyValue,
logBoldBlue,
logDebug,
logGreen,
warnYellow,
} from '../logger.js';
import { indentYamlOrJson } from '../utils/files.js';
import {
getLatestMerkleTreeCheckpointIndex,
getLatestValidatorCheckpointIndexAndUrl,
getValidatorStorageLocations,
isValidatorSigningLatestCheckpoint,
} from '../validator/utils.js';
import { avsAddresses } from './config.js';
import { readOperatorFromEncryptedJson } from './stakeRegistry.js';
interface ChainInfo {
storageLocation?: string;
latestMerkleTreeCheckpointIndex?: number;
latestValidatorCheckpointIndex?: number;
validatorSynced?: boolean;
warnings?: string[];
}
interface ValidatorInfo {
operatorAddress: Address;
operatorName?: string;
chains: ChainMap<ChainInfo>;
}
export const checkValidatorAvsSetup = async (
chain: string,
context: CommandContext,
operatorKeyPath?: string,
operatorAddress?: string,
) => {
logBlue(
`Checking AVS validator status for ${chain}, ${
!operatorKeyPath ? 'this may take up to a minute to run' : ''
}...`,
);
const { multiProvider } = context;
const topLevelErrors: string[] = [];
let operatorWallet: Wallet | undefined;
if (operatorKeyPath) {
operatorWallet = await readOperatorFromEncryptedJson(operatorKeyPath);
}
const avsOperatorRecord = await getAvsOperators(
chain,
multiProvider,
topLevelErrors,
operatorAddress ?? operatorWallet?.address,
);
await setOperatorName(
chain,
avsOperatorRecord,
multiProvider,
topLevelErrors,
);
if (!isObjEmpty(avsOperatorRecord)) {
await setValidatorInfo(context, avsOperatorRecord, topLevelErrors);
}
logOutput(avsOperatorRecord, topLevelErrors);
};
const getAvsOperators = async (
chain: string,
multiProvider: MultiProvider,
topLevelErrors: string[],
operatorKey?: string,
): Promise<ChainMap<ValidatorInfo>> => {
const avsOperators: Record<Address, ValidatorInfo> = {};
const ecdsaStakeRegistryAddress = getEcdsaStakeRegistryAddress(
chain,
topLevelErrors,
);
if (!ecdsaStakeRegistryAddress) {
return avsOperators;
}
const ecdsaStakeRegistry = ECDSAStakeRegistry__factory.connect(
ecdsaStakeRegistryAddress,
multiProvider.getProvider(chain),
);
if (operatorKey) {
// If operator key is provided, only fetch the operator's validator info
const signingKey = await ecdsaStakeRegistry.getLastestOperatorSigningKey(
operatorKey,
);
avsOperators[signingKey] = {
operatorAddress: operatorKey,
chains: {},
};
return avsOperators;
}
const filter = ecdsaStakeRegistry.filters.SigningKeyUpdate(null, null);
const provider = multiProvider.getProvider(chain);
const latestBlock = await provider.getBlockNumber();
const blockLimit = 50000; // 50k blocks per query
let fromBlock = 1625972; // when ecdsaStakeRegistry was deployed
while (fromBlock < latestBlock) {
const toBlock = Math.min(fromBlock + blockLimit, latestBlock);
const logs = await ecdsaStakeRegistry.queryFilter(
filter,
fromBlock,
toBlock,
);
logs.forEach((log) => {
const event = ecdsaStakeRegistry.interface.parseLog(log);
const operatorKey = event.args.operator;
const signingKey = event.args.newSigningKey;
if (avsOperators[signingKey]) {
avsOperators[signingKey].operatorAddress = operatorKey;
} else {
avsOperators[signingKey] = {
operatorAddress: operatorKey,
chains: {},
};
}
});
fromBlock = toBlock + 1;
}
return avsOperators;
};
const getAVSMetadataURI = async (
chain: string,
operatorAddress: string,
multiProvider: MultiProvider,
): Promise<string | undefined> => {
const delegationManagerAddress = avsAddresses[chain]['delegationManager'];
const delegationManager = IDelegationManager__factory.connect(
delegationManagerAddress,
multiProvider.getProvider(chain),
);
const filter = delegationManager.filters.OperatorMetadataURIUpdated(
operatorAddress,
null,
);
const provider = multiProvider.getProvider(chain);
const latestBlock = await provider.getBlockNumber();
const blockLimit = 50000; // 50k blocks per query
let fromBlock = 17445563;
while (fromBlock < latestBlock) {
const toBlock = Math.min(fromBlock + blockLimit, latestBlock);
const logs = await delegationManager.queryFilter(
filter,
fromBlock,
toBlock,
);
if (logs.length > 0) {
const event = delegationManager.interface.parseLog(logs[0]);
return event.args.metadataURI;
}
fromBlock = toBlock + 1;
}
return undefined;
};
const setOperatorName = async (
chain: string,
avsOperatorRecord: Record<Address, ValidatorInfo>,
multiProvider: MultiProvider,
topLevelErrors: string[] = [],
) => {
for (const [_, validatorInfo] of Object.entries(avsOperatorRecord)) {
const metadataURI = await getAVSMetadataURI(
chain,
validatorInfo.operatorAddress,
multiProvider,
);
if (metadataURI) {
const operatorName = await fetchOperatorName(metadataURI);
if (operatorName) {
validatorInfo.operatorName = operatorName;
} else {
topLevelErrors.push(
` Failed to fetch operator name from metadataURI: ${metadataURI}`,
);
}
}
}
};
const setValidatorInfo = async (
context: CommandContext,
avsOperatorRecord: Record<Address, ValidatorInfo>,
topLevelErrors: string[],
) => {
const { multiProvider, registry, chainMetadata } = context;
const failedToReadChains: string[] = [];
const validatorAddresses = Object.keys(avsOperatorRecord);
const chains = await registry.getChains();
const addresses = await registry.getAddresses();
for (const chain of chains) {
// skip if chain is not an Ethereum chain
if (chainMetadata[chain].protocol !== ProtocolType.Ethereum) continue;
const chainAddresses = addresses[chain];
// skip if no contract addresses are found for this chain
if (chainAddresses === undefined) continue;
if (!chainAddresses.validatorAnnounce) {
topLevelErrors.push(` ValidatorAnnounce is not deployed on ${chain}`);
}
if (!chainAddresses.merkleTreeHook) {
topLevelErrors.push(` MerkleTreeHook is not deployed on ${chain}`);
}
if (!chainAddresses.validatorAnnounce || !chainAddresses.merkleTreeHook) {
continue;
}
const validatorAnnounce = ValidatorAnnounce__factory.connect(
chainAddresses.validatorAnnounce,
multiProvider.getProvider(chain),
);
const merkleTreeHook = MerkleTreeHook__factory.connect(
chainAddresses.merkleTreeHook,
multiProvider.getProvider(chain),
);
const latestMerkleTreeCheckpointIndex =
await getLatestMerkleTreeCheckpointIndex(merkleTreeHook, chain);
const validatorStorageLocations = await getValidatorStorageLocations(
validatorAnnounce,
validatorAddresses,
chain,
);
if (!validatorStorageLocations) {
failedToReadChains.push(chain);
continue;
}
for (let i = 0; i < validatorAddresses.length; i++) {
const validatorAddress = validatorAddresses[i];
const storageLocation = validatorStorageLocations[i];
const warnings: string[] = [];
// Skip if no storage location is found, address is not validating on this chain or if storage location string doesn't not start with s3://
if (
storageLocation.length === 0 ||
!storageLocation[0].startsWith('s3://')
) {
continue;
}
const [latestValidatorCheckpointIndex, latestCheckpointUrl] =
(await getLatestValidatorCheckpointIndexAndUrl(storageLocation[0])) ?? [
undefined,
undefined,
];
if (!latestMerkleTreeCheckpointIndex) {
warnings.push(
` Failed to fetch latest checkpoint index of merkleTreeHook on ${chain}.`,
);
}
if (!latestValidatorCheckpointIndex) {
warnings.push(
` Failed to fetch latest signed checkpoint index of validator on ${chain}, this is likely due to failing to read an S3 bucket`,
);
}
let validatorSynced = undefined;
if (latestMerkleTreeCheckpointIndex && latestValidatorCheckpointIndex) {
validatorSynced = isValidatorSigningLatestCheckpoint(
latestValidatorCheckpointIndex,
latestMerkleTreeCheckpointIndex,
);
}
const chainInfo: ChainInfo = {
storageLocation: latestCheckpointUrl,
latestMerkleTreeCheckpointIndex,
latestValidatorCheckpointIndex,
validatorSynced,
warnings,
};
const validatorInfo = avsOperatorRecord[validatorAddress];
if (validatorInfo) {
validatorInfo.chains[chain as ChainName] = chainInfo;
}
}
}
if (failedToReadChains.length > 0) {
topLevelErrors.push(
` Failed to read storage locations onchain for ${failedToReadChains.join(
', ',
)}`,
);
}
};
const logOutput = (
avsKeysRecord: Record<Address, ValidatorInfo>,
topLevelErrors: string[],
) => {
if (topLevelErrors.length > 0) {
for (const error of topLevelErrors) {
errorRed(error);
}
}
for (const [validatorAddress, data] of Object.entries(avsKeysRecord)) {
log('\n\n');
if (data.operatorName) logBlueKeyValue('Operator name', data.operatorName);
logBlueKeyValue('Operator address', data.operatorAddress);
logBlueKeyValue('Validator address', validatorAddress);
if (!isObjEmpty(data.chains)) {
logBoldBlue(indentYamlOrJson('Validating on...', 2));
for (const [chain, chainInfo] of Object.entries(data.chains)) {
logBoldBlue(indentYamlOrJson(chain, 2));
if (chainInfo.storageLocation) {
logBlueKeyValue(
indentYamlOrJson('Storage location', 2),
chainInfo.storageLocation,
);
}
if (chainInfo.latestMerkleTreeCheckpointIndex) {
logBlueKeyValue(
indentYamlOrJson('Latest merkle tree checkpoint index', 2),
String(chainInfo.latestMerkleTreeCheckpointIndex),
);
}
if (chainInfo.latestValidatorCheckpointIndex) {
logBlueKeyValue(
indentYamlOrJson('Latest validator checkpoint index', 2),
String(chainInfo.latestValidatorCheckpointIndex),
);
if (chainInfo.validatorSynced) {
logGreen(
indentYamlOrJson('✅ Validator is signing latest checkpoint', 2),
);
} else {
errorRed(
indentYamlOrJson(
'❌ Validator is not signing latest checkpoint',
2,
),
);
}
} else {
errorRed(
indentYamlOrJson(
'❌ Failed to fetch latest signed checkpoint index',
2,
),
);
}
if (chainInfo.warnings && chainInfo.warnings.length > 0) {
warnYellow(
indentYamlOrJson('The following warnings were encountered:', 2),
);
for (const warning of chainInfo.warnings) {
warnYellow(indentYamlOrJson(warning, 3));
}
}
}
} else {
logBlue('Validator is not validating on any chain');
}
}
};
const getEcdsaStakeRegistryAddress = (
chain: string,
topLevelErrors: string[],
): Address | undefined => {
try {
return avsAddresses[chain]['ecdsaStakeRegistry'];
} catch (err) {
topLevelErrors.push(
` EcdsaStakeRegistry address not found for ${chain}`,
);
return undefined;
}
};
const fetchOperatorName = async (metadataURI: string) => {
try {
const response = await fetch(metadataURI);
const data = await response.json();
return data['name'];
} catch (err) {
logDebug(`Failed to fetch operator name from ${metadataURI}: ${err}`);
return undefined;
}
};

@ -3,6 +3,7 @@ import { Address } from '@hyperlane-xyz/utils';
interface AVSContracts {
avsDirectory: Address;
delegationManager: Address;
proxyAdmin: Address;
ecdsaStakeRegistry: Address;
hyperlaneServiceManager: Address;
@ -12,12 +13,14 @@ interface AVSContracts {
export const avsAddresses: ChainMap<AVSContracts> = {
holesky: {
avsDirectory: '0x055733000064333CaDDbC92763c58BF0192fFeBf',
delegationManager: '0xA44151489861Fe9e3055d95adC98FbD462B948e7',
proxyAdmin: '0x33dB966328Ea213b0f76eF96CA368AB37779F065',
ecdsaStakeRegistry: '0xFfa913705484C9BAea32Ffe9945BeA099A1DFF72',
hyperlaneServiceManager: '0xc76E477437065093D353b7d56c81ff54D167B0Ab',
},
ethereum: {
avsDirectory: '0x135dda560e946695d6f155dacafc6f1f25c1f5af',
delegationManager: '0x39053D51B77DC0d36036Fc1fCc8Cb819df8Ef37A',
proxyAdmin: '0x75EE15Ee1B4A75Fa3e2fDF5DF3253c25599cc659',
ecdsaStakeRegistry: '0x272CF0BB70D3B4f79414E0823B426d2EaFd48910',
hyperlaneServiceManager: '0xe8E59c6C8B56F2c178f63BCFC4ce5e5e2359c8fc',

@ -109,7 +109,7 @@ export async function deregisterOperator({
);
}
async function readOperatorFromEncryptedJson(
export async function readOperatorFromEncryptedJson(
operatorKeyPath: string,
): Promise<Wallet> {
const encryptedJson = readFileAtPath(resolvePath(operatorKeyPath));

@ -1,14 +1,21 @@
import { CommandModule, Options } from 'yargs';
import { ChainName } from '@hyperlane-xyz/sdk';
import { Address } from '@hyperlane-xyz/utils';
import { Address, ProtocolType } from '@hyperlane-xyz/utils';
import { checkValidatorAvsSetup } from '../avs/check.js';
import {
deregisterOperator,
registerOperatorWithSignature,
} from '../avs/stakeRegistry.js';
import { CommandModuleWithWriteContext } from '../context/types.js';
import { log } from '../logger.js';
import { errorRed, log } from '../logger.js';
import {
avsChainCommandOption,
demandOption,
operatorKeyPathCommandOption,
} from './options.js';
/**
* Parent command
@ -20,6 +27,7 @@ export const avsCommand: CommandModule = {
yargs
.command(registerCommand)
.command(deregisterCommand)
.command(checkCommand)
.version(false)
.demandCommand(),
handler: () => log('Command required'),
@ -29,17 +37,8 @@ export const avsCommand: CommandModule = {
* Registration command
*/
export const registrationOptions: { [k: string]: Options } = {
chain: {
type: 'string',
description: 'Chain to interact with the AVS on',
demandOption: true,
choices: ['holesky', 'ethereum'],
},
operatorKeyPath: {
type: 'string',
description: 'Path to the operator key file',
demandOption: true,
},
chain: avsChainCommandOption,
operatorKeyPath: demandOption(operatorKeyPathCommandOption),
avsSigningKeyAddress: {
type: 'string',
description: 'Address of the AVS signing key',
@ -87,3 +86,47 @@ const deregisterCommand: CommandModuleWithWriteContext<{
process.exit(0);
},
};
const checkCommand: CommandModuleWithWriteContext<{
chain: ChainName;
operatorKeyPath?: string;
operatorAddress?: string;
}> = {
command: 'check',
describe: 'Check AVS',
builder: {
chain: avsChainCommandOption,
operatorKeyPath: operatorKeyPathCommandOption,
operatorAddress: {
type: 'string',
description: 'Address of the operator to check',
},
},
handler: async ({ context, chain, operatorKeyPath, operatorAddress }) => {
const { multiProvider } = context;
// validate chain
if (!multiProvider.hasChain(chain)) {
errorRed(
`❌ No metadata found for ${chain}. Ensure it is included in your configured registry.`,
);
process.exit(1);
}
const chainMetadata = multiProvider.getChainMetadata(chain);
if (chainMetadata.protocol !== ProtocolType.Ethereum) {
errorRed(`\n❌ Validator AVS check only supports EVM chains. Exiting.`);
process.exit(1);
}
await checkValidatorAvsSetup(
chain,
context,
operatorKeyPath,
operatorAddress,
);
process.exit(0);
},
};

@ -8,6 +8,11 @@ import { ENV } from '../utils/env.js';
/* Global options */
export const demandOption = (option: Options): Options => ({
...option,
demandOption: true,
});
export const logFormatCommandOption: Options = {
type: 'string',
description: 'Log output format',
@ -178,3 +183,15 @@ export const awsKeyIdCommandOption: Options = {
type: 'string',
describe: 'Key ID from AWS KMS',
};
export const operatorKeyPathCommandOption: Options = {
type: 'string',
description: 'Path to the operator key file',
};
export const avsChainCommandOption: Options = {
type: 'string',
description: 'Chain to interact with the AVS on',
demandOption: true,
choices: ['holesky', 'ethereum'],
};

@ -35,6 +35,9 @@ export function logColor(
}
}
export const logBlue = (...args: any) => logColor('info', chalk.blue, ...args);
export const logBlueKeyValue = (key: string, value: string) => {
logBlue(`${chalk.bold(`${key}:`)} ${value}`);
};
export const logPink = (...args: any) =>
logColor('info', chalk.magentaBright, ...args);
export const logGray = (...args: any) => logColor('info', chalk.gray, ...args);
@ -43,11 +46,16 @@ export const logGreen = (...args: any) =>
export const logRed = (...args: any) => logColor('info', chalk.red, ...args);
export const logBoldUnderlinedRed = (...args: any) =>
logColor('info', chalk.red.bold.underline, ...args);
export const logBoldBlue = (...args: any) =>
logColor('info', chalk.blue.bold, ...args);
export const logTip = (...args: any) =>
logColor('info', chalk.bgYellow, ...args);
export const warnYellow = (...args: any) =>
logColor('warn', chalk.yellow, ...args);
export const errorRed = (...args: any) => logColor('error', chalk.red, ...args);
export const logDebug = (msg: string, ...args: any) =>
logger.debug(msg, ...args);
// No support for table in pino so print directly to console
export const logTable = (...args: any) => console.table(...args);

@ -210,3 +210,11 @@ export async function runFileSelectionStep(
if (filename) return filename;
else throw new Error(`No filepath entered ${description}`);
}
export function indentYamlOrJson(str: string, indentLevel: number): string {
const indent = ' '.repeat(indentLevel);
return str
.split('\n')
.map((line) => indent + line)
.join('\n');
}

@ -0,0 +1,69 @@
import { MerkleTreeHook, ValidatorAnnounce } from '@hyperlane-xyz/core';
import { S3Validator } from '@hyperlane-xyz/sdk';
import { logDebug } from '../logger.js';
export const getLatestMerkleTreeCheckpointIndex = async (
merkleTreeHook: MerkleTreeHook,
chainName?: string,
): Promise<number | undefined> => {
try {
const [_, latestCheckpointIndex] = await merkleTreeHook.latestCheckpoint();
return latestCheckpointIndex;
} catch (err) {
const debugMessage = `Failed to get latest checkpoint index from merkleTreeHook contract ${
chainName ? `on ${chainName}` : ''
} : ${err}`;
logDebug(debugMessage);
return undefined;
}
};
export const getValidatorStorageLocations = async (
validatorAnnounce: ValidatorAnnounce,
validators: string[],
chainName?: string,
): Promise<string[][] | undefined> => {
try {
return await validatorAnnounce.getAnnouncedStorageLocations(validators);
} catch (err) {
const debugMessage = `Failed to get announced storage locations from validatorAnnounce contract ${
chainName ? `on ${chainName}` : ''
} : ${err}`;
logDebug(debugMessage);
return undefined;
}
};
export const getLatestValidatorCheckpointIndexAndUrl = async (
s3StorageLocation: string,
): Promise<[number, string] | undefined> => {
let s3Validator: S3Validator;
try {
s3Validator = await S3Validator.fromStorageLocation(s3StorageLocation);
} catch (err) {
logDebug(
`Failed to instantiate S3Validator at location ${s3StorageLocation}: ${err}`,
);
return undefined;
}
try {
const latestCheckpointIndex = await s3Validator.getLatestCheckpointIndex();
return [latestCheckpointIndex, s3Validator.getLatestCheckpointUrl()];
} catch (err) {
logDebug(
`Failed to get latest checkpoint index from S3Validator at location ${s3StorageLocation}: ${err}`,
);
return undefined;
}
};
export const isValidatorSigningLatestCheckpoint = (
latestValidatorCheckpointIndex: number,
latestMerkleTreeCheckpointIndex: number,
): boolean => {
const diff = Math.abs(
latestValidatorCheckpointIndex - latestMerkleTreeCheckpointIndex,
);
return diff < latestMerkleTreeCheckpointIndex / 100;
};

@ -75,6 +75,6 @@ export class S3Wrapper {
url(key: string): string {
const Key = this.formatKey(key);
return `https://${this.config.bucket}.${this.config.region}.s3.amazonaws.com/${Key}`;
return `https://${this.config.bucket}.s3.${this.config.region}.amazonaws.com/${Key}`;
}
}

@ -103,4 +103,8 @@ export class S3Validator extends BaseValidator {
storageLocation(): string {
return `${LOCATION_PREFIX}/${this.s3Bucket.config.bucket}/${this.s3Bucket.config.region}`;
}
getLatestCheckpointUrl(): string {
return this.s3Bucket.url(LATEST_KEY);
}
}

Loading…
Cancel
Save