diff --git a/.github/workflows/manual_release.yml b/.github/workflows/manual_release.yml index 340162e..eea31ca 100644 --- a/.github/workflows/manual_release.yml +++ b/.github/workflows/manual_release.yml @@ -51,5 +51,5 @@ jobs: file: Dockerfile platforms: linux/amd64,linux/arm64 push: true - tags: | + tags: | ghcr.io/${{ github.repository }}/${{ env.IMAGE_NAME }}:${{ github.event.inputs.tag }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index facd932..e70054b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -94,4 +94,3 @@ jobs: tags: | ghcr.io/${{ github.repository }}/ewx-worker-node-server:latest ghcr.io/${{ github.repository }}/ewx-worker-node-server:${{ needs.release-module.outputs.new_version }} - diff --git a/docs/env-vars.md b/docs/env-vars.md index 1232a6d..a3a290b 100644 --- a/docs/env-vars.md +++ b/docs/env-vars.md @@ -4,42 +4,43 @@ _Object containing the following properties:_ -| Property | Description | Type | Default | -| :----------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :-------------------------- | :------------------------------------------------------- | -| `EXCLUDED_NODES` | NodeRed nodes that are going to be excluded from SmartFlows. If any node type of the SmartFlow equals to one of the EXCLUDED_NODES it will prevent SmartFlow from being installed. | `string` | `'file,file in,watch,exec'` | -| `TARGET_SOLUTION_NAMESPACES` | If set it will only install solutions that namespaces are specified in this env. variable. Comma-separated. | `string` | | -| `RETRY_WORKER_CHECKS` | If it's enabled it will indefinitely await for worker to pass all checks otherwise it will kill process. Useful e.g if worker account is not yet assigned to operator at the moment of configuration. | `boolean` (_nullable_) | `true` | -| `ENABLE_HEALTH_API` | If it's enabled it will enable health check routes. | `boolean` (_nullable_) | `true` | -| `PALLET_RPC_URL` | Read EWX Parachain URL. If provided, it will overwrite value provided from BASE_URLS. | `string` (_url_) | | -| **`VOTING_RPC_URL`** (\*) | Write EWX Parachain URL | `string` (_url_) | | -| **`VOTING_WORKER_SEED`** (\*) | Seed of the worker (not operator) | `string` | | -| `PORT` | Port number of NodeRed Server. | `number` (_>0_) | `8000` | -| `HOST` | Hostname of NodeRed Server. | `string` | `'localhost'` | -| `RED_ENABLE_UI` | Should enable NodeRed UI. | `'true' \| 'false'` | `'false'` | -| `RED_DIRECTORY` | Storage of NodeRed flows. | `string` | `'./node-red-data'` | -| `SQLITE_BASE_PATH` | Base SQLite path. | `string` | `'./sqlite'` | -| `IPFS_API_KEY` | IPFS API Key | `string` (_nullable_) | `null` | -| `IPFS_SECRET_KEY` | IPFS Secret Key | `string` (_nullable_) | `null` | -| `IPFS_URL` | IPFS BASE Url | `string` | `'https://workers-registry.energywebx.com'` | -| `IPFS_CONTEXT_PATH` | IPFS Context Path | `string` | `'/ipfs/'` | -| `IPFS_USER_AGENT_VALUE` | Default user agent that is going to be used for public IPFS queries. | `string` | `'ewx-worker-node-server'` | -| `SOLUTION_QUEUE_PROCESS_DELAY` | How often should refresh EWX Solutions information from chain (in miliseconds). | `number` (_nullable_) | `20000` | -| `LOCAL_SOLUTIONS_PATH` | Path to locally hosted NodeRed solution flow files to be used when installing solutions using "local" prefix within WorkLogic field. | `string` | | -| `SS58_FORMAT` | SS58 Key Format. | `number` (_>0_) | `42` | -| `PRETTY_PRINT` | Should pretty print logs. If you plan to use Grafana or any other log tooling it's recommended to set it to false. | `'true' \| 'false'` | `'false'` | -| `LOG_FILE_PATH` | Full path to log file (e.g., /var/log/app.log or ./logs/app.log). If not provided, file logging is disabled. | `string` | | -| `LOG_RETENTION_DAYS` | Number of days to keep rotated logs | `number` (_>0_) | | -| `HEARTBEAT_PATH` | Path to the heartbeat file used for monitoring. | `string` | `'heartbeat_monitor.txt'` | -| `HEARTBEAT_INTERVAL` | Interval (in ms) at which the heartbeat process updates the file. | `number` (_>0_) | `5000` | -| `HEARTBEAT_PRINT_SUCCESS_LOG` | Should print successful logs. | `'true' \| 'false'` | `'true'` | -| `PALLET_AUTH_SERVER_LOGIN_URL` | Pallet Auth Server Url used for authentication to Workers Registry. If provided, it will overwrite value provided from BASE_URLS. | `string` (_url_) | | -| `PALLET_AUTH_SERVER_DOMAIN` | Pallet Auth Server domain | `string` | `'default'` | -| `WORKER_REGISTRY_URL` | Url of Workers Registry that stores information about Worker Location. If provided, it will overwrite value provided from BASE_URLS. | `string` (_url_) | | -| `BASE_URLS` | Base URLs of EWX resources | `string` (_url_) | `'https://marketplace-cdn.energyweb.org/base_urls.json'` | -| `BUILD_METADATA_PATH` | Path to build metadata file | `string` | `'./build.json'` | -| `SHUTDOWN_TIMEOUT_MS` | Timeout in milliseconds for graceful shutdown (default: 30000) | `number` (_>0_) | `30000` | -| `ADMIN_SERVER_PORT` | Port number for admin server (default: 3003) | `number` (_>0_) | `3003` | -| `ADMIN_API_KEY` | API key for admin endpoints authentication. Must be at least 32 characters. If not set, admin endpoints will be accessible without authentication. | `string` (_min length: 32_) | | -| `VCC_PROXY_URL` | VCC proxy URL. | `string` (_url_) | `'https://ew-vcc-proxy.energyweb.org'` | +| Property | Description | Type | Default | +| :----------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :--------------------------- | :------------------------------------------------------- | +| `EXCLUDED_NODES` | NodeRed nodes that are going to be excluded from SmartFlows. If any node type of the SmartFlow equals to one of the EXCLUDED_NODES it will prevent SmartFlow from being installed. | `string` | `'file,file in,watch,exec'` | +| `TARGET_SOLUTION_NAMESPACES` | If set it will only install solutions that namespaces are specified in this env. variable. Comma-separated. | `string` | | +| `RETRY_WORKER_CHECKS` | If it's enabled it will indefinitely await for worker to pass all checks otherwise it will kill process. Useful e.g if worker account is not yet assigned to operator at the moment of configuration. | `boolean` (_nullable_) | `true` | +| `ENABLE_HEALTH_API` | If it's enabled it will enable health check routes. | `boolean` (_nullable_) | `true` | +| `PALLET_RPC_URL` | Read EWX Parachain URL. If provided, it will overwrite value provided from BASE_URLS. | `string` (_url_) | | +| **`VOTING_RPC_URL`** (\*) | Write EWX Parachain URL | `string` (_url_) | | +| **`VOTING_WORKER_SEED`** (\*) | Seed of the worker (not operator) | `string` | | +| `PORT` | Port number of NodeRed Server. | `number` (_>0_) | `8000` | +| `HOST` | Hostname of NodeRed Server. | `string` | `'localhost'` | +| `RED_ENABLE_UI` | Should enable NodeRed UI. | `'true' \| 'false'` | `'false'` | +| `RED_DIRECTORY` | Storage of NodeRed flows. | `string` | `'./node-red-data'` | +| `SQLITE_BASE_PATH` | Base SQLite path. | `string` | `'./sqlite'` | +| `IPFS_API_KEY` | IPFS API Key | `string` (_nullable_) | `null` | +| `IPFS_SECRET_KEY` | IPFS Secret Key | `string` (_nullable_) | `null` | +| `IPFS_URL` | IPFS BASE Url | `string` | `'https://workers-registry.energywebx.com'` | +| `IPFS_CONTEXT_PATH` | IPFS Context Path | `string` | `'/ipfs/'` | +| `IPFS_USER_AGENT_VALUE` | Default user agent that is going to be used for public IPFS queries. | `string` | `'ewx-worker-node-server'` | +| `SOLUTION_QUEUE_PROCESS_DELAY` | How often should refresh EWX Solutions information from chain (in miliseconds). | `number` (_nullable_) | `20000` | +| `INDEXER_SYNC_BUFFER_BLOCKS` | The maximum block delay allowed for the indexer. | `number` (_≥0_) (_nullable_) | `50` | +| `LOCAL_SOLUTIONS_PATH` | Path to locally hosted NodeRed solution flow files to be used when installing solutions using "local" prefix within WorkLogic field. | `string` | | +| `SS58_FORMAT` | SS58 Key Format. | `number` (_>0_) | `42` | +| `PRETTY_PRINT` | Should pretty print logs. If you plan to use Grafana or any other log tooling it's recommended to set it to false. | `'true' \| 'false'` | `'false'` | +| `LOG_FILE_PATH` | Full path to log file (e.g., /var/log/app.log or ./logs/app.log). If not provided, file logging is disabled. | `string` | | +| `LOG_RETENTION_DAYS` | Number of days to keep rotated logs | `number` (_>0_) | | +| `HEARTBEAT_PATH` | Path to the heartbeat file used for monitoring. | `string` | `'heartbeat_monitor.txt'` | +| `HEARTBEAT_INTERVAL` | Interval (in ms) at which the heartbeat process updates the file. | `number` (_>0_) | `5000` | +| `HEARTBEAT_PRINT_SUCCESS_LOG` | Should print successful logs. | `'true' \| 'false'` | `'true'` | +| `PALLET_AUTH_SERVER_LOGIN_URL` | Pallet Auth Server Url used for authentication to Workers Registry. If provided, it will overwrite value provided from BASE_URLS. | `string` (_url_) | | +| `PALLET_AUTH_SERVER_DOMAIN` | Pallet Auth Server domain | `string` | `'default'` | +| `WORKER_REGISTRY_URL` | Url of Workers Registry that stores information about Worker Location. If provided, it will overwrite value provided from BASE_URLS. | `string` (_url_) | | +| `BASE_URLS` | Base URLs of EWX resources | `string` (_url_) | `'https://marketplace-cdn.energyweb.org/base_urls.json'` | +| `BUILD_METADATA_PATH` | Path to build metadata file | `string` | `'./build.json'` | +| `SHUTDOWN_TIMEOUT_MS` | Timeout in milliseconds for graceful shutdown (default: 30000) | `number` (_>0_) | `30000` | +| `ADMIN_SERVER_PORT` | Port number for admin server (default: 3003) | `number` (_>0_) | `3003` | +| `ADMIN_API_KEY` | API key for admin endpoints authentication. Must be at least 32 characters. If not set, admin endpoints will be accessible without authentication. | `string` (_min length: 32_) | | +| `VCC_PROXY_URL` | VCC proxy URL. | `string` (_url_) | `'https://ew-vcc-proxy.energyweb.org'` | _(\*) Required._ diff --git a/src/config.ts b/src/config.ts index a77cadd..9919fd8 100644 --- a/src/config.ts +++ b/src/config.ts @@ -93,6 +93,11 @@ export const ENV_SCHEMA = z.object({ .number() .default(20000) .describe('How often should refresh EWX Solutions information from chain (in miliseconds).'), + INDEXER_SYNC_BUFFER_BLOCKS: z.coerce + .number() + .nonnegative() + .default(50) + .describe('The maximum block delay allowed for the indexer.'), LOCAL_SOLUTIONS_PATH: z .string() .optional() diff --git a/src/solution.ts b/src/solution.ts index c7b43c7..d6a6166 100644 --- a/src/solution.ts +++ b/src/solution.ts @@ -24,6 +24,7 @@ import { type SolutionGroupId, } from './polkadot/polka'; import { type SolutionGroup } from './polkadot/polka-types'; +import { isIndexerSynced, queryStakeFromIndexer } from './util/indexer-queries'; import { sleep } from './util/sleep'; import { createLogger } from './util/logger'; import { createReadPalletApi } from './util/pallet-api'; @@ -270,11 +271,27 @@ const hasValidGroupConfiguration = async ( return false; } - const hasStake: QueryStakeResult = await queryStake( - api, - operatorAddress, - solutionGroup.namespace, - ); + let hasStake: QueryStakeResult | null = null; + + try { + const isSynced = await isIndexerSynced(currentBlockNumber); + if (isSynced) { + hasStake = await queryStakeFromIndexer(operatorAddress, solutionGroup.namespace); + } + } catch (error) { + logger.warn( + { solutionGroupId: solutionGroup.namespace, error }, + 'failed to retrieve stake from indexer', + ); + } + + if (hasStake == null) { + logger.info( + { solutionGroupId: solutionGroup.namespace }, + 'falling back to direct RPC query for stake', + ); + hasStake = await queryStake(api, operatorAddress, solutionGroup.namespace); + } if (hasStake.currentStake < BigInt(solutionGroup.operatorsConfig.stakingAmounts.min)) { logger.info( diff --git a/src/util/indexer-queries.ts b/src/util/indexer-queries.ts new file mode 100644 index 0000000..00f23f5 --- /dev/null +++ b/src/util/indexer-queries.ts @@ -0,0 +1,156 @@ +import axios from 'axios'; +import { z } from 'zod'; +import { type QueryStakeResult } from '../polkadot/polka'; +import { createLogger } from './logger'; +import { getBaseUrls } from './base-urls'; +import { MAIN_CONFIG } from '../config'; + +const logger = createLogger('Indexer'); + +export const OperatorStakeSchema = z.object({ + data: z.object({ + operatorSubscribedStakes: z.array( + z.object({ + currentStake: z.string().transform((val) => BigInt(val)), + nextStake: z.string().transform((val) => BigInt(val)), + rewardPeriodIndex: z.number().transform((val) => BigInt(val)), + }), + ), + }), +}); + +export type OperatorStakeResponse = z.infer; + +export const SquidStatusSchema = z.object({ + data: z.object({ + squidStatus: z.object({ + finalizedHash: z.string(), + finalizedHeight: z.number().transform((val) => BigInt(val)), + hash: z.string(), + height: z.number().transform((val) => BigInt(val)), + }), + }), +}); + +export type SquidStatusResponse = z.infer; + +const GET_SQUID_STATUS_QUERY = ` + query GetSquidStatus { + squidStatus { + finalizedHash + finalizedHeight + hash + height + } + } +`; + +const GET_OPERATOR_STAKE_QUERY = ` + query GetOperatorStake($operatorAddress: String!, $solutionGroupId: String!) { + operatorSubscribedStakes( + where: { + operator: { id_eq: $operatorAddress }, + solutionGroup: { namespace_eq: $solutionGroupId } + }, + orderBy: rewardPeriodIndex_DESC, + limit: 1 + ) { + currentStake + nextStake + rewardPeriodIndex + } + } +`; + +export const queryStakeFromIndexer = async ( + operatorAddress: string, + solutionGroupId: string, +): Promise => { + try { + const baseUrls = await getBaseUrls(); + + logger.info({ baseUrls, operatorAddress, solutionGroupId }, 'querying stake from indexer'); + + const { data } = await axios.post( + `${baseUrls.baseIndexerUrl}/core/graphql`, + { + query: GET_OPERATOR_STAKE_QUERY, + variables: { + operatorAddress, + solutionGroupId, + }, + }, + { + headers: { + 'Content-Type': 'application/json', + }, + timeout: 10000, + }, + ); + + const parsed = OperatorStakeSchema.safeParse(data); + + if (!parsed.success) { + logger.warn( + { error: parsed.error.flatten(), operatorAddress, solutionGroupId }, + 'indexer response failed validation', + ); + return null; + } + + const stakes = parsed.data.data.operatorSubscribedStakes; + + if (stakes.length > 0) { + const stakeNode = stakes[0]; + return { + currentStake: stakeNode.currentStake, + nextStake: stakeNode.nextStake, + period: stakeNode.rewardPeriodIndex, + }; + } + return null; + } catch (error) { + logger.warn({ error, operatorAddress, solutionGroupId }, 'failed to query stake from indexer'); + return null; + } +}; + +export const isIndexerSynced = async (currentBlock: number): Promise => { + try { + const baseUrls = await getBaseUrls(); + + const { data } = await axios.post( + `${baseUrls.baseIndexerUrl}/core/graphql`, + { + query: GET_SQUID_STATUS_QUERY, + }, + { + headers: { + 'Content-Type': 'application/json', + }, + timeout: 10000, + }, + ); + + const parsed = SquidStatusSchema.safeParse(data); + + if (!parsed.success) { + logger.warn({ error: parsed.error.flatten() }, 'squid status response failed validation'); + return false; + } + + const finalizedHeight = parsed.data.data.squidStatus.finalizedHeight; + const buffer = BigInt(MAIN_CONFIG.INDEXER_SYNC_BUFFER_BLOCKS); + const finalizedHeightWithBuffer = finalizedHeight + buffer; + + logger.info( + { finalizedHeight, currentBlock, buffer, finalizedHeightWithBuffer }, + 'indexer finalized height and current block', + ); + + return finalizedHeightWithBuffer >= BigInt(currentBlock); + } catch (error) { + logger.warn({ error }, 'failed to query squid status from indexer'); + return false; + } +};