"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
    return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.Operator = exports.ParseError = exports.VOTE_NO_KICK = exports.VOTE_KICK = void 0;
exports.parsePartitionFromReviewRequestMetadata = parsePartitionFromReviewRequestMetadata;
const utils_1 = require("@streamr/utils");
const zod_1 = require("zod");
const OperatorAbi_json_1 = __importDefault(require("../ethereumArtifacts/OperatorAbi.json"));
const SponsorshipAbi_json_1 = __importDefault(require("../ethereumArtifacts/SponsorshipAbi.json"));
const ChainEventPoller_1 = require("./ChainEventPoller");
const contract_1 = require("./contract");
exports.VOTE_KICK = '0x0000000000000000000000000000000000000000000000000000000000000001';
exports.VOTE_NO_KICK = '0x0000000000000000000000000000000000000000000000000000000000000000';
class ParseError extends Error {
    reasonText;
    constructor(reasonText) {
        super(`Failed to parse metadata: ${reasonText}`);
        this.reasonText = reasonText;
    }
}
exports.ParseError = ParseError;
function parsePartitionFromReviewRequestMetadata(metadataAsString) {
    if (metadataAsString === undefined) {
        throw new ParseError('no metadata');
    }
    let metadata;
    try {
        metadata = JSON.parse(metadataAsString);
    }
    catch {
        throw new ParseError('malformed metadata');
    }
    const partition = Number(metadata.partition);
    if (isNaN(partition)) {
        throw new ParseError('invalid or missing "partition" field');
    }
    try {
        (0, utils_1.ensureValidStreamPartitionIndex)(partition);
    }
    catch {
        throw new ParseError('invalid partition numbering');
    }
    return partition;
}
const compareBigInts = (a, b) => {
    if (a < b) {
        return -1;
    }
    else if (a > b) {
        return 1;
    }
    else {
        return 0;
    }
};
const logger = new utils_1.Logger(module);
/**
 * @deprecated This in an internal class
 * @hidden
 */
class Operator {
    contractAddress;
    contract;
    contractReadonly; // TODO: implement lazy loading because all methods don't use this
    contractFactory;
    rpcProviderSource;
    theGraphClient;
    authentication;
    getEthersOverrides;
    eventEmitter = new utils_1.ObservableEventEmitter();
    constructor(contractAddress, contractFactory, rpcProviderSource, theGraphClient, authentication, destroySignal, loggerFactory, getEthersOverrides, eventPollInterval) {
        this.contractAddress = contractAddress;
        this.contractFactory = contractFactory;
        this.rpcProviderSource = rpcProviderSource;
        this.contractReadonly = contractFactory.createReadContract((0, utils_1.toEthereumAddress)(contractAddress), OperatorAbi_json_1.default, rpcProviderSource.getProvider(), 'operator');
        this.theGraphClient = theGraphClient;
        this.authentication = authentication;
        this.getEthersOverrides = getEthersOverrides;
        this.initEventGateways(contractAddress, loggerFactory, eventPollInterval);
        destroySignal.onDestroy.listen(() => {
            this.eventEmitter.removeAllListeners();
        });
    }
    initEventGateways(contractAddress, loggerFactory, eventPollInterval) {
        const chainEventPoller = new ChainEventPoller_1.ChainEventPoller(this.rpcProviderSource.getSubProviders().map((p) => {
            return this.contractFactory.createEventContract(contractAddress, OperatorAbi_json_1.default, p);
        }), eventPollInterval);
        const stakeEventTransformation = (sponsorship) => ({
            sponsorship: (0, utils_1.toEthereumAddress)(sponsorship)
        });
        (0, contract_1.initContractEventGateway)({
            sourceName: 'Staked',
            sourceEmitter: chainEventPoller,
            targetName: 'staked',
            targetEmitter: this.eventEmitter,
            transformation: stakeEventTransformation,
            loggerFactory
        });
        (0, contract_1.initContractEventGateway)({
            sourceName: 'Unstaked',
            sourceEmitter: chainEventPoller,
            targetName: 'unstaked',
            targetEmitter: this.eventEmitter,
            transformation: stakeEventTransformation,
            loggerFactory
        });
        const reviewRequestTransform = (sponsorship, targetOperator, voteStartTimestampInSecs, voteEndTimestampInSecs, metadataAsString) => {
            const partition = parsePartitionFromReviewRequestMetadata(metadataAsString);
            return {
                sponsorship: (0, utils_1.toEthereumAddress)(sponsorship),
                targetOperator: (0, utils_1.toEthereumAddress)(targetOperator),
                partition,
                votingPeriodStartTimestamp: Number(voteStartTimestampInSecs) * 1000,
                votingPeriodEndTimestamp: Number(voteEndTimestampInSecs) * 1000
            };
        };
        (0, contract_1.initContractEventGateway)({
            sourceName: 'ReviewRequest',
            sourceEmitter: chainEventPoller,
            targetName: 'reviewRequested',
            targetEmitter: this.eventEmitter,
            transformation: reviewRequestTransform,
            loggerFactory
        });
    }
    async writeHeartbeat(nodeDescriptor) {
        const metadata = JSON.stringify(nodeDescriptor);
        await this.connectToContract();
        await (await this.contract.heartbeat(metadata, await this.getEthersOverrides())).wait();
    }
    async getTimestampOfLastHeartbeat() {
        const result = await this.theGraphClient.queryEntity({
            query: `{
                operator(id: "${await this.getContractAddress()}") {
                    latestHeartbeatTimestamp
                }
            }`
        });
        // eslint-disable-next-line @typescript-eslint/prefer-optional-chain
        if (result.operator === null || result.operator.latestHeartbeatTimestamp === null) {
            return undefined;
        }
        else {
            const timestampInSecs = parseInt(result.operator.latestHeartbeatTimestamp);
            if (isNaN(timestampInSecs)) {
                throw new Error('Assertion failed: unexpected non-integer latestHeartbeatTimestamp'); // should never happen
            }
            return timestampInSecs * 1000;
        }
    }
    async getContractAddress() {
        return (0, utils_1.toEthereumAddress)(await this.contractReadonly.getAddress());
    }
    async getSponsorships() {
        const operatorAddress = await this.getContractAddress();
        const createQuery = (lastId, pageSize) => {
            return {
                query: `
                    {
                        operator(id: "${operatorAddress}") {
                            stakes(where: {id_gt: "${lastId}"}, first: ${pageSize}) {
                                id
                                sponsorship {
                                    id
                                    operatorCount
                                    stream {
                                        id
                                    }
                                }
                            }
                        }
                    }
                    `
            };
        };
        const parseItems = (response) => {
            return response.operator?.stakes ?? [];
        };
        const queryResult = this.theGraphClient.queryEntities(createQuery, parseItems);
        const results = [];
        for await (const stake of queryResult) {
            results.push({
                sponsorshipAddress: (0, utils_1.toEthereumAddress)(stake.sponsorship.id),
                streamId: (0, utils_1.toStreamID)(stake.sponsorship.stream.id),
                operatorCount: stake.sponsorship.operatorCount
            });
        }
        return results;
    }
    // TODO could move this method as this is functionality is not specific to one Operator contract instance
    async getExpiredFlags(sponsorshipAddresses, maxAgeInMs) {
        const maxVoteEndTimestamp = Math.floor((Date.now() - maxAgeInMs) / 1000);
        const createQuery = (lastId, pageSize) => {
            return {
                query: `
                {
                    flags (where : {
                        id_gt: "${lastId}",
                        voteEndTimestamp_lt: ${maxVoteEndTimestamp},
                        result_in: ["waiting", "voting"],
                        sponsorship_in: ${JSON.stringify(sponsorshipAddresses)}
                    }, first: ${pageSize}) {
                        id
                        flaggingTimestamp
                        target {
                            id
                        }
                        sponsorship {
                            id
                        }
                    }
                }`
            };
        };
        const flagEntities = this.theGraphClient.queryEntities(createQuery);
        const flags = [];
        for await (const flagEntity of flagEntities) {
            flags.push({
                id: flagEntity.id,
                flaggingTimestamp: flagEntity.flaggingTimestamp,
                targetOperator: (0, utils_1.toEthereumAddress)(flagEntity.target.id),
                sponsorship: (0, utils_1.toEthereumAddress)(flagEntity.sponsorship.id)
            });
        }
        return flags;
    }
    // TODO could move this method as this is functionality is not specific to one Operator contract instance
    async getOperatorsInSponsorship(sponsorshipAddress) {
        const createQuery = (lastId, pageSize) => {
            return {
                query: `
                    {
                        sponsorship(id: "${sponsorshipAddress}") {
                            stakes(where: {id_gt: "${lastId}"}, first: ${pageSize}) {
                                id
                                operator {
                                    id
                                }
                            }
                        }
                    }
                    `
            };
        };
        const parseItems = (response) => {
            return response.sponsorship?.stakes ?? [];
        };
        const queryResult = this.theGraphClient.queryEntities(createQuery, parseItems);
        const operatorIds = [];
        for await (const stake of queryResult) {
            operatorIds.push((0, utils_1.toEthereumAddress)(stake.operator.id));
        }
        return operatorIds;
    }
    async flag(sponsorship, operator, partition) {
        const metadata = JSON.stringify({ partition });
        await this.connectToContract();
        await (await this.contract.flag(sponsorship, operator, metadata, await this.getEthersOverrides())).wait();
    }
    /**
     * Find the sum of earnings in Sponsorships (that the Operator must withdraw before the sum reaches a limit),
     * SUBJECT TO the constraints, set in the OperatorServiceConfig:
     *  - only take at most maxSponsorshipsInWithdraw addresses (those with most earnings), or all if undefined
     *  - only take sponsorships that have more than minSponsorshipEarningsInWithdraw, or all if undefined
     */
    async getEarnings(minSponsorshipEarningsInWithdraw, maxSponsorshipsInWithdraw) {
        const { addresses: allSponsorshipAddresses, earnings, maxAllowedEarnings, } = await this.contractReadonly.getSponsorshipsAndEarnings();
        const sponsorships = allSponsorshipAddresses
            .map((address, i) => ({ address, earnings: earnings[i] }))
            .filter((sponsorship) => sponsorship.earnings >= minSponsorshipEarningsInWithdraw)
            .sort((a, b) => compareBigInts(a.earnings, b.earnings)) // TODO: after Node 20, use .toSorted() instead
            .slice(0, maxSponsorshipsInWithdraw); // take all if maxSponsorshipsInWithdraw is undefined
        return {
            sponsorshipAddresses: sponsorships.map((sponsorship) => (0, utils_1.toEthereumAddress)(sponsorship.address)),
            sum: sponsorships.reduce((sum, sponsorship) => sum += sponsorship.earnings, 0n),
            maxAllowedEarnings: maxAllowedEarnings
        };
    }
    async withdrawEarningsFromSponsorships(sponsorshipAddresses) {
        await this.connectToContract();
        await (await this.contract.withdrawEarningsFromSponsorships(sponsorshipAddresses, await this.getEthersOverrides())).wait();
    }
    async triggerAnotherOperatorWithdraw(targetOperatorAddress, sponsorshipAddresses) {
        await this.connectToContract();
        await (await this.contract.triggerAnotherOperatorWithdraw(targetOperatorAddress, sponsorshipAddresses, await this.getEthersOverrides())).wait();
    }
    // TODO could move this method as this is functionality is not specific to one Operator contract instance
    async getStakedOperators() {
        const createQuery = (lastId, pageSize) => {
            return {
                query: `
                    {
                        operators(where: {totalStakeInSponsorshipsWei_gt: "0", id_gt: "${lastId}"}, first: ${pageSize}) {
                            id
                        }
                    }
                    `
            };
        };
        const queryResult = this.theGraphClient.queryEntities(createQuery);
        const operatorAddresses = [];
        for await (const operator of queryResult) {
            operatorAddresses.push((0, utils_1.toEthereumAddress)(operator.id));
        }
        return operatorAddresses;
    }
    async *pullStakedStreams(requiredBlockNumber) {
        const contractAddress = await this.getContractAddress();
        const createQuery = (lastId, pageSize) => {
            return {
                query: `
                    {
                        operator(id: "${contractAddress}") {
                            stakes(where: {id_gt: "${lastId}"}, first: ${pageSize}) {
                                sponsorship {
                                    id
                                    stream {
                                        id
                                    }
                                }
                            }
                        }
                        _meta {
                            block {
                            number
                            }
                        }
                    }
                    `
            };
        };
        const parseItems = (response) => {
            if (!response.operator) {
                logger.error('Unable to find operator in The Graph', { operatorContractAddress: contractAddress });
                return [];
            }
            return response.operator.stakes;
        };
        this.theGraphClient.updateRequiredBlockNumber(requiredBlockNumber);
        const entities = this.theGraphClient.queryEntities(createQuery, parseItems);
        for await (const entity of entities) {
            yield {
                sponsorship: (0, utils_1.toEthereumAddress)(entity.sponsorship.id),
                streamId: (0, utils_1.toStreamID)(entity.sponsorship.stream.id)
            };
        }
    }
    async hasOpenFlag(sponsorshipAddress) {
        const operatorContractAddress = await this.getContractAddress();
        const createQuery = () => {
            return {
                query: `
                    {
                        flags(where: {
                            sponsorship: "${sponsorshipAddress}",
                            target: "${operatorContractAddress}",
                            result_in: ["waiting", "voting"]
                        }) {
                            id
                        }
                    }
                    `
            };
        };
        const queryResult = this.theGraphClient.queryEntities(createQuery);
        const flags = await (0, utils_1.collect)(queryResult, 1);
        if (flags.length > 0) {
            logger.debug('Found open flag', { flag: flags[0] });
            return true;
        }
        else {
            return false;
        }
    }
    // TODO could move this method as this is functionality is not specific to one Operator contract instance
    async getStreamId(sponsorshipAddress) {
        const sponsorship = this.contractFactory.createReadContract((0, utils_1.toEthereumAddress)(sponsorshipAddress), SponsorshipAbi_json_1.default, this.rpcProviderSource.getProvider(), 'sponsorship');
        return (0, utils_1.toStreamID)(await sponsorship.streamId());
    }
    async voteOnFlag(sponsorshipAddress, targetOperator, kick) {
        const voteData = kick ? exports.VOTE_KICK : exports.VOTE_NO_KICK;
        await this.connectToContract();
        // typical gas cost 99336, but this has shown insufficient sometimes
        // this estimate should be very conservative, i.e. higher than any observed flag-resolution gas cost
        const gasLimit = 1300000n;
        // estimateGas throws if transaction would fail, so doing the gas estimation will avoid sending failing transactions
        const gasEstimate = await this.contract.voteOnFlag.estimateGas(sponsorshipAddress, targetOperator, voteData);
        if (gasEstimate > gasLimit) {
            throw new Error(`Gas estimate (${gasEstimate}) exceeds limit (${gasLimit})`);
        }
        // TODO should we set gasLimit only here, or also for other transactions made by ContractFacade?
        await (await this.contract.voteOnFlag(sponsorshipAddress, targetOperator, voteData, { ...(await this.getEthersOverrides()), gasLimit })).wait();
    }
    async closeFlag(sponsorshipAddress, targetOperatorAddress) {
        // voteOnFlag is not used to vote here but to close the expired flag. The vote data gets ignored.
        // Anyone can call this function at this point.
        await this.voteOnFlag(sponsorshipAddress, targetOperatorAddress, false);
    }
    async fetchRedundancyFactor() {
        const MetadataSchema = zod_1.z.object({
            redundancyFactor: zod_1.z.number()
                .int()
                .gte(1)
        });
        const metadataAsString = await this.contractReadonly.metadata();
        if (metadataAsString.length === 0) {
            return 1;
        }
        let metadata;
        try {
            metadata = JSON.parse(metadataAsString);
        }
        catch {
            logger.warn('Encountered malformed metadata', { operatorAddress: await this.getContractAddress(), metadataAsString });
            return undefined;
        }
        let validatedMetadata;
        try {
            validatedMetadata = MetadataSchema.parse(metadata);
        }
        catch (err) {
            logger.warn('Encountered invalid metadata', {
                operatorAddress: await this.getContractAddress(),
                metadataAsString,
                reason: err?.reason
            });
            return undefined;
        }
        return validatedMetadata.redundancyFactor;
    }
    getCurrentBlockNumber() {
        return this.rpcProviderSource.getProvider().getBlockNumber();
    }
    on(eventName, listener) {
        this.eventEmitter.on(eventName, listener); // TODO why casting?
    }
    off(eventName, listener) {
        this.eventEmitter.off(eventName, listener); // TODO why casting?
    }
    async connectToContract() {
        if (this.contract === undefined) {
            const signer = await this.authentication.getTransactionSigner(this.rpcProviderSource);
            this.contract = this.contractFactory.createWriteContract(this.contractAddress, OperatorAbi_json_1.default, signer, 'operator');
        }
    }
}
exports.Operator = Operator;
//# sourceMappingURL=Operator.js.map