From 9863395f4c8c6cc8d7eb0991f2472cd5fcd6f374 Mon Sep 17 00:00:00 2001 From: rozekmichal Date: Fri, 27 Mar 2026 14:01:25 +0100 Subject: [PATCH 1/4] feat: add Zod validation for Mirror Node account responses and rename mirrornode schema module to schemas.ts Signed-off-by: rozekmichal --- .../mirrornode/hedera-mirrornode-service.ts | 12 ++-- src/core/services/mirrornode/schema.ts | 33 ---------- src/core/services/mirrornode/schemas.ts | 64 +++++++++++++++++++ src/core/services/mirrornode/types.ts | 21 +++--- 4 files changed, 83 insertions(+), 47 deletions(-) delete mode 100644 src/core/services/mirrornode/schema.ts create mode 100644 src/core/services/mirrornode/schemas.ts diff --git a/src/core/services/mirrornode/hedera-mirrornode-service.ts b/src/core/services/mirrornode/hedera-mirrornode-service.ts index 8de7f7b50..47f49ef77 100644 --- a/src/core/services/mirrornode/hedera-mirrornode-service.ts +++ b/src/core/services/mirrornode/hedera-mirrornode-service.ts @@ -1,7 +1,6 @@ import type { NetworkService } from '@/core/services/network/network-service.interface'; import type { HederaMirrornodeService } from './hedera-mirrornode-service.interface'; import type { - AccountAPIResponse, AccountListItemAPIResponse, AccountListItemDto, AccountResponse, @@ -36,7 +35,7 @@ import { KeyAlgorithm } from '@/core/shared/constants'; import { parseWithSchema } from '@/core/shared/validation/parse-with-schema.zod'; import { handleMirrorNodeErrorResponse } from '@/core/utils/handle-mirror-node-error-response'; -import { TokenInfoSchema } from './schema'; +import { AccountAPIResponseSchema, TokenInfoSchema } from './schemas'; import { NetworkToBaseUrl } from './types'; export class HederaMirrornodeServiceDefaultImpl implements HederaMirrornodeService { @@ -82,7 +81,11 @@ export class HederaMirrornodeServiceDefaultImpl implements HederaMirrornodeServi return null; } - const data = (await response.json()) as AccountAPIResponse; + const data = parseWithSchema( + AccountAPIResponseSchema, + await response.json(), + `Mirror Node GET /accounts/${accountId}`, + ); if (!data.account) { throw new NotFoundError(`Account ${accountId} not found`); @@ -102,8 +105,7 @@ export class HederaMirrornodeServiceDefaultImpl implements HederaMirrornodeServi keyAlgorithm: this.getKeyAlgorithm(data.key._type), }; } catch (error) { - if (error instanceof NotFoundError || error instanceof NetworkError) - throw error; + if (error instanceof CliError) throw error; throw new NetworkError(`Failed to fetch account ${accountId}`, { cause: error, recoverable: true, diff --git a/src/core/services/mirrornode/schema.ts b/src/core/services/mirrornode/schema.ts deleted file mode 100644 index c2488396c..000000000 --- a/src/core/services/mirrornode/schema.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { TokenInfo } from './types'; - -import { z } from 'zod'; - -const mirrorKeyObject = z.object({ - _type: z.string(), - key: z.string(), -}); - -const optionalKeyRef = z.union([mirrorKeyObject, z.null()]).optional(); - -export const TokenInfoSchema: z.ZodType = z.object({ - token_id: z.string(), - symbol: z.string(), - name: z.string(), - decimals: z.string(), - total_supply: z.string(), - max_supply: z.string(), - type: z.string(), - treasury_account_id: z.string(), - admin_key: optionalKeyRef, - kyc_key: optionalKeyRef, - freeze_key: optionalKeyRef, - wipe_key: optionalKeyRef, - supply_key: optionalKeyRef, - fee_schedule_key: optionalKeyRef, - pause_key: optionalKeyRef, - created_timestamp: z.string(), - deleted: z.boolean().nullable().optional(), - freeze_default: z.boolean().optional(), - pause_status: z.string(), - memo: z.string(), -}); diff --git a/src/core/services/mirrornode/schemas.ts b/src/core/services/mirrornode/schemas.ts new file mode 100644 index 000000000..765d41380 --- /dev/null +++ b/src/core/services/mirrornode/schemas.ts @@ -0,0 +1,64 @@ +import type { + AccountAPIBalance, + AccountAPIKey, + AccountAPIResponse, + TokenInfo, +} from './types'; + +import { z } from 'zod'; + +const mirrorKeyObject = z.object({ + _type: z.string(), + key: z.string(), +}); + +const optionalKeyRef = z.union([mirrorKeyObject, z.null()]).optional(); + +export const TokenInfoSchema: z.ZodType = z.object({ + token_id: z.string(), + symbol: z.string(), + name: z.string(), + decimals: z.string(), + total_supply: z.string(), + max_supply: z.string(), + type: z.string(), + treasury_account_id: z.string(), + admin_key: optionalKeyRef, + kyc_key: optionalKeyRef, + freeze_key: optionalKeyRef, + wipe_key: optionalKeyRef, + supply_key: optionalKeyRef, + fee_schedule_key: optionalKeyRef, + pause_key: optionalKeyRef, + created_timestamp: z.string(), + deleted: z.boolean().nullable().optional(), + freeze_default: z.boolean().optional(), + pause_status: z.string(), + memo: z.string(), +}); + +const mirrorNodeKeyTypeSchema = z.enum(['ECDSA_SECP256K1', 'ED25519']); + +export const AccountAPIBalanceSchema: z.ZodType = z.object({ + balance: z.number(), + timestamp: z.string(), +}); + +export const AccountAPIKeySchema: z.ZodType = z.object({ + _type: mirrorNodeKeyTypeSchema, + key: z.string(), +}); + +export const AccountAPIResponseSchema: z.ZodType = z.object( + { + account: z.string(), + alias: z.string().optional(), + balance: AccountAPIBalanceSchema, + created_timestamp: z.string(), + evm_address: z.string().optional(), + key: AccountAPIKeySchema.optional(), + max_automatic_token_associations: z.number(), + memo: z.string(), + receiver_sig_required: z.boolean(), + }, +); diff --git a/src/core/services/mirrornode/types.ts b/src/core/services/mirrornode/types.ts index 136a47954..87c36b296 100644 --- a/src/core/services/mirrornode/types.ts +++ b/src/core/services/mirrornode/types.ts @@ -16,20 +16,23 @@ export const NetworkToBaseUrl = new Map([ export type MirrorNodeKeyType = 'ECDSA_SECP256K1' | 'ED25519'; -// Account API Response +export interface AccountAPIBalance { + balance: number; + timestamp: string; +} + +export interface AccountAPIKey { + _type: MirrorNodeKeyType; + key: string; +} + export interface AccountAPIResponse { account: string; alias?: string; - balance: { - balance: number; - timestamp: string; - }; + balance: AccountAPIBalance; created_timestamp: string; evm_address?: string; - key?: { - _type: MirrorNodeKeyType; - key: string; - }; + key?: AccountAPIKey; max_automatic_token_associations: number; memo: string; receiver_sig_required: boolean; From abaf4452e03846885a13dc129b646263de575117 Mon Sep 17 00:00:00 2001 From: rozekmichal Date: Fri, 27 Mar 2026 14:49:19 +0100 Subject: [PATCH 2/4] feat: add Zod validation for Mirror Node GET /accounts list responses and nested list item shapes Signed-off-by: rozekmichal --- .../mirrornode/hedera-mirrornode-service.ts | 19 +++++++-- src/core/services/mirrornode/schemas.ts | 39 +++++++++++++++++++ src/core/services/mirrornode/types.ts | 19 ++++++--- 3 files changed, 67 insertions(+), 10 deletions(-) diff --git a/src/core/services/mirrornode/hedera-mirrornode-service.ts b/src/core/services/mirrornode/hedera-mirrornode-service.ts index 47f49ef77..aa4745321 100644 --- a/src/core/services/mirrornode/hedera-mirrornode-service.ts +++ b/src/core/services/mirrornode/hedera-mirrornode-service.ts @@ -35,7 +35,11 @@ import { KeyAlgorithm } from '@/core/shared/constants'; import { parseWithSchema } from '@/core/shared/validation/parse-with-schema.zod'; import { handleMirrorNodeErrorResponse } from '@/core/utils/handle-mirror-node-error-response'; -import { AccountAPIResponseSchema, TokenInfoSchema } from './schemas'; +import { + AccountAPIResponseSchema, + GetAccountsAPIResponseSchema, + TokenInfoSchema, +} from './schemas'; import { NetworkToBaseUrl } from './types'; export class HederaMirrornodeServiceDefaultImpl implements HederaMirrornodeService { @@ -181,14 +185,21 @@ export class HederaMirrornodeServiceDefaultImpl implements HederaMirrornodeServi break; } - const data = (await response.json()) as GetAccountsAPIResponse; + const pagePayload: GetAccountsAPIResponse = parseWithSchema( + GetAccountsAPIResponseSchema, + await response.json(), + `Mirror Node GET /accounts (page ${fetchedPages})`, + ); - allAccounts.push(...(data.accounts ?? [])); + allAccounts.push(...(pagePayload.accounts ?? [])); if (fetchedPages >= 100) { break; } - url = data.links?.next ? this.getBaseUrl() + data.links.next : null; + url = pagePayload.links?.next + ? this.getBaseUrl() + pagePayload.links.next + : null; } catch (error) { + if (error instanceof CliError) throw error; if (error instanceof NetworkError) throw error; throw new NetworkError(`Failed to fetch accounts`, { cause: error, diff --git a/src/core/services/mirrornode/schemas.ts b/src/core/services/mirrornode/schemas.ts index 765d41380..bfedabb25 100644 --- a/src/core/services/mirrornode/schemas.ts +++ b/src/core/services/mirrornode/schemas.ts @@ -2,6 +2,10 @@ import type { AccountAPIBalance, AccountAPIKey, AccountAPIResponse, + AccountListItemAPIResponse, + AccountListItemBalance, + AccountListItemTokenBalance, + GetAccountsAPIResponse, TokenInfo, } from './types'; @@ -62,3 +66,38 @@ export const AccountAPIResponseSchema: z.ZodType = z.object( receiver_sig_required: z.boolean(), }, ); + +const accountListItemTokenBalanceSchema: z.ZodType = + z.object({ + token_id: z.string(), + balance: z.number(), + }); + +export const AccountListItemBalanceSchema: z.ZodType = + z.object({ + timestamp: z.string(), + balance: z.number(), + tokens: z.array(accountListItemTokenBalanceSchema).optional(), + }); + +export const AccountListItemAPIResponseSchema: z.ZodType = + z.object({ + account: z.string(), + alias: z.string().optional(), + balance: AccountListItemBalanceSchema.optional(), + created_timestamp: z.string(), + evm_address: z.string().optional(), + key: z.union([AccountAPIKeySchema, z.null()]).optional(), + deleted: z.boolean().optional(), + memo: z.string().optional(), + }); + +export const GetAccountsAPIResponseSchema: z.ZodType = + z.object({ + accounts: z.array(AccountListItemAPIResponseSchema), + links: z + .object({ + next: z.string().nullable().optional(), + }) + .optional(), + }); diff --git a/src/core/services/mirrornode/types.ts b/src/core/services/mirrornode/types.ts index 87c36b296..526cad5f8 100644 --- a/src/core/services/mirrornode/types.ts +++ b/src/core/services/mirrornode/types.ts @@ -284,17 +284,24 @@ export interface GetAccountsQueryParams { order?: MirrorNodeRequestOrderParameter; } +export interface AccountListItemTokenBalance { + token_id: string; + balance: number; +} + +export interface AccountListItemBalance { + timestamp: string; + balance: number; + tokens?: AccountListItemTokenBalance[]; +} + export interface AccountListItemAPIResponse { account: string; alias?: string; - balance?: { - timestamp: string; - balance: number; - tokens?: Array<{ token_id: string; balance: number }>; - }; + balance?: AccountListItemBalance; created_timestamp: string; evm_address?: string; - key?: { _type: MirrorNodeKeyType; key: string } | null; + key?: AccountAPIKey | null; deleted?: boolean; memo?: string; } From 78cf575a9d1cb198991d6f65cf1021bd2a35a395 Mon Sep 17 00:00:00 2001 From: rozekmichal Date: Mon, 30 Mar 2026 09:59:27 +0200 Subject: [PATCH 3/4] feat: add token balances validation schema Signed-off-by: rozekmichal --- .../mirrornode/__tests__/unit/mocks.ts | 4 +--- .../mirrornode/hedera-mirrornode-service.ts | 8 +++++++- src/core/services/mirrornode/schemas.ts | 18 ++++++++++++++++++ src/core/services/mirrornode/types.ts | 17 ++++++++--------- 4 files changed, 34 insertions(+), 13 deletions(-) diff --git a/src/core/services/mirrornode/__tests__/unit/mocks.ts b/src/core/services/mirrornode/__tests__/unit/mocks.ts index c11b1a401..ab8848c62 100644 --- a/src/core/services/mirrornode/__tests__/unit/mocks.ts +++ b/src/core/services/mirrornode/__tests__/unit/mocks.ts @@ -67,8 +67,6 @@ export const createMockGetAccountsAPIResponse = ( export const createMockTokenBalancesResponse = ( overrides: Partial = {}, ): TokenBalancesResponse => ({ - account: '0.0.1234', - balance: 0, tokens: [ { token_id: '0.0.2000', @@ -76,7 +74,7 @@ export const createMockTokenBalancesResponse = ( decimals: 6, }, ], - timestamp: '2024-01-01T12:00:00.000Z', + links: { next: null }, ...overrides, }); diff --git a/src/core/services/mirrornode/hedera-mirrornode-service.ts b/src/core/services/mirrornode/hedera-mirrornode-service.ts index aa4745321..2c5b9d107 100644 --- a/src/core/services/mirrornode/hedera-mirrornode-service.ts +++ b/src/core/services/mirrornode/hedera-mirrornode-service.ts @@ -38,6 +38,7 @@ import { handleMirrorNodeErrorResponse } from '@/core/utils/handle-mirror-node-e import { AccountAPIResponseSchema, GetAccountsAPIResponseSchema, + TokenBalancesResponseSchema, TokenInfoSchema, } from './schemas'; import { NetworkToBaseUrl } from './types'; @@ -135,8 +136,13 @@ export class HederaMirrornodeServiceDefaultImpl implements HederaMirrornodeServi ); } - return (await response.json()) as TokenBalancesResponse; + return parseWithSchema( + TokenBalancesResponseSchema, + await response.json(), + `Mirror Node GET /accounts/${accountId}/tokens`, + ); } catch (error) { + if (error instanceof CliError) throw error; if (error instanceof NotFoundError || error instanceof NetworkError) throw error; throw new NetworkError( diff --git a/src/core/services/mirrornode/schemas.ts b/src/core/services/mirrornode/schemas.ts index bfedabb25..0e850d280 100644 --- a/src/core/services/mirrornode/schemas.ts +++ b/src/core/services/mirrornode/schemas.ts @@ -6,6 +6,8 @@ import type { AccountListItemBalance, AccountListItemTokenBalance, GetAccountsAPIResponse, + TokenBalanceInfo, + TokenBalancesResponse, TokenInfo, } from './types'; @@ -101,3 +103,19 @@ export const GetAccountsAPIResponseSchema: z.ZodType = }) .optional(), }); + +export const TokenBalanceInfoSchema: z.ZodType = z.object({ + token_id: z.string(), + balance: z.number(), + decimals: z.number().optional(), +}); + +export const TokenBalancesResponseSchema: z.ZodType = + z.object({ + tokens: z.array(TokenBalanceInfoSchema), + links: z + .object({ + next: z.string().nullable().optional(), + }) + .optional(), + }); diff --git a/src/core/services/mirrornode/types.ts b/src/core/services/mirrornode/types.ts index 526cad5f8..ff08ebbeb 100644 --- a/src/core/services/mirrornode/types.ts +++ b/src/core/services/mirrornode/types.ts @@ -49,18 +49,17 @@ export interface AccountResponse { keyAlgorithm: KeyAlgorithm; } -// Token Balance Response -export interface TokenBalancesResponse { - account: string; - balance: number; - tokens: TokenBalanceInfo[]; - timestamp: string; -} - export interface TokenBalanceInfo { token_id: string; balance: number; - decimals: number; + decimals?: number; +} + +export interface TokenBalancesResponse { + tokens: TokenBalanceInfo[]; + links?: { + next?: string | null; + }; } export type MirrorNodeTokenKey = { From 6338b93b8ae88f03fdd511bb2e18638fdbd772c5 Mon Sep 17 00:00:00 2001 From: rozekmichal Date: Mon, 30 Mar 2026 11:00:14 +0200 Subject: [PATCH 4/4] fix: add alias as nullable Signed-off-by: rozekmichal --- src/core/services/mirrornode/hedera-mirrornode-service.ts | 2 +- src/core/services/mirrornode/schemas.ts | 4 ++-- src/core/services/mirrornode/types.ts | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/core/services/mirrornode/hedera-mirrornode-service.ts b/src/core/services/mirrornode/hedera-mirrornode-service.ts index aa4745321..e7ebaeaf2 100644 --- a/src/core/services/mirrornode/hedera-mirrornode-service.ts +++ b/src/core/services/mirrornode/hedera-mirrornode-service.ts @@ -221,7 +221,7 @@ export class HederaMirrornodeServiceDefaultImpl implements HederaMirrornodeServi accountId: apiAccount.account, createdTimestamp: apiAccount.created_timestamp, }; - if (apiAccount.alias !== undefined) dto.alias = apiAccount.alias; + if (apiAccount.alias != null) dto.alias = apiAccount.alias; if (apiAccount.deleted !== undefined) dto.deleted = apiAccount.deleted; if (apiAccount.memo !== undefined) dto.memo = apiAccount.memo; if (apiAccount.evm_address !== undefined) diff --git a/src/core/services/mirrornode/schemas.ts b/src/core/services/mirrornode/schemas.ts index bfedabb25..43fbb85f3 100644 --- a/src/core/services/mirrornode/schemas.ts +++ b/src/core/services/mirrornode/schemas.ts @@ -56,7 +56,7 @@ export const AccountAPIKeySchema: z.ZodType = z.object({ export const AccountAPIResponseSchema: z.ZodType = z.object( { account: z.string(), - alias: z.string().optional(), + alias: z.string().nullable().optional(), balance: AccountAPIBalanceSchema, created_timestamp: z.string(), evm_address: z.string().optional(), @@ -83,7 +83,7 @@ export const AccountListItemBalanceSchema: z.ZodType = export const AccountListItemAPIResponseSchema: z.ZodType = z.object({ account: z.string(), - alias: z.string().optional(), + alias: z.string().nullable().optional(), balance: AccountListItemBalanceSchema.optional(), created_timestamp: z.string(), evm_address: z.string().optional(), diff --git a/src/core/services/mirrornode/types.ts b/src/core/services/mirrornode/types.ts index 526cad5f8..06e1564d6 100644 --- a/src/core/services/mirrornode/types.ts +++ b/src/core/services/mirrornode/types.ts @@ -28,7 +28,7 @@ export interface AccountAPIKey { export interface AccountAPIResponse { account: string; - alias?: string; + alias?: string | null; balance: AccountAPIBalance; created_timestamp: string; evm_address?: string; @@ -297,7 +297,7 @@ export interface AccountListItemBalance { export interface AccountListItemAPIResponse { account: string; - alias?: string; + alias?: string | null; balance?: AccountListItemBalance; created_timestamp: string; evm_address?: string;