diff --git a/docs/output-schemas-guide.md b/docs/output-schemas-guide.md index 29bf5b439..73ba7a180 100644 --- a/docs/output-schemas-guide.md +++ b/docs/output-schemas-guide.md @@ -318,10 +318,16 @@ interface CommandOutputSpec { "initialSupply": "1000000", "supplyType": "INFINITE", "transactionId": "0.0.123@1700000000.123456789", - "alias": "my-token" + "alias": "my-token", + "network": "testnet", + "autoRenewPeriodSeconds": 2592000, + "autoRenewAccountId": "0.0.11111", + "expirationTime": "2030-01-01T00:00:00.000Z" } ``` +`autoRenewPeriodSeconds`, `autoRenewAccountId`, and `expirationTime` are **optional**. They are present when auto-renew or fixed expiration was configured; `expirationTime` is an ISO 8601 string when a fixed expiration was used (omitted when auto-renew period + account take precedence). + #### `token transfer-ft` **Output**: @@ -417,10 +423,15 @@ interface CommandOutputSpec { "success": true, "transactionId": "0.0.123@1700000000.123456789" } - ] + ], + "autoRenewPeriodSeconds": 2592000, + "autoRenewAccountId": "0.0.11111", + "expirationTime": "2030-01-01T00:00:00.000Z" } ``` +Same optional lifecycle fields as `token create-ft`: `autoRenewPeriodSeconds`, `autoRenewAccountId`, `expirationTime` (ISO string when fixed expiration was applied). + #### `token create-nft-from-file` **Output**: diff --git a/examples/token/token-fixed-fee-hbar.json b/examples/token/token-fixed-fee-hbar.json index 31a0c7018..53e50a82d 100644 --- a/examples/token/token-fixed-fee-hbar.json +++ b/examples/token/token-fixed-fee-hbar.json @@ -8,6 +8,8 @@ "treasuryKey": "", "adminKey": "", "memo": "Token with fixed fee paid in HBAR (tinybars)", + "autoRenewPeriod": "30d", + "autoRenewAccount": "", "customFees": [ { "type": "fixed", diff --git a/examples/token/token-fixed-fee-token.json b/examples/token/token-fixed-fee-token.json index 7a6604d0f..efa79a8d8 100644 --- a/examples/token/token-fixed-fee-token.json +++ b/examples/token/token-fixed-fee-token.json @@ -8,6 +8,8 @@ "treasuryKey": "", "adminKey": "", "memo": "Token with fixed fee paid in same token units", + "autoRenewPeriod": "30d", + "autoRenewAccount": "", "customFees": [ { "type": "fixed", diff --git a/examples/token/token-fractional-fee-receiver-pays.json b/examples/token/token-fractional-fee-receiver-pays.json index 52cd46903..976f157a1 100644 --- a/examples/token/token-fractional-fee-receiver-pays.json +++ b/examples/token/token-fractional-fee-receiver-pays.json @@ -8,6 +8,8 @@ "treasuryKey": "", "adminKey": "", "memo": "Token with fractional fee (receiver pays, netOfTransfers=false)", + "autoRenewPeriod": "30d", + "autoRenewAccount": "", "customFees": [ { "type": "fractional", diff --git a/examples/token/token-fractional-fee-sender-pays.json b/examples/token/token-fractional-fee-sender-pays.json index 98bafa043..d8e0f1841 100644 --- a/examples/token/token-fractional-fee-sender-pays.json +++ b/examples/token/token-fractional-fee-sender-pays.json @@ -8,6 +8,8 @@ "treasuryKey": "", "adminKey": "", "memo": "Token with fractional fee (sender pays, netOfTransfers=true)", + "autoRenewPeriod": "30d", + "autoRenewAccount": "", "customFees": [ { "type": "fractional", diff --git a/examples/token/token-full-example.json b/examples/token/token-full-example.json index 1bca9e865..86add2ec7 100644 --- a/examples/token/token-full-example.json +++ b/examples/token/token-full-example.json @@ -14,6 +14,8 @@ "pauseKey": "", "feeScheduleKey": "", "memo": "Full example token with all fields", + "autoRenewPeriod": "90d", + "autoRenewAccount": "", "associations": ["", "..."], "customFees": [ { diff --git a/skills/hiero-cli/references/token.md b/skills/hiero-cli/references/token.md index c4d55a250..01c1d3bef 100644 --- a/skills/hiero-cli/references/token.md +++ b/skills/hiero-cli/references/token.md @@ -12,21 +12,24 @@ Commands marked **[batchify]** support the `--batch ` flag to queue into a Create a new fungible token with specified properties. -| Option | Short | Type | Required | Default | Description | -| ------------------ | ----- | ------ | -------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | -| `--token-name` | `-T` | string | **yes** | — | Token name | -| `--symbol` | `-Y` | string | **yes** | — | Token symbol | -| `--treasury` | `-t` | string | no | operator | Treasury account: `accountId:privateKey`, key reference, or alias | -| `--decimals` | `-d` | number | no | `0` | Number of decimal places | -| `--initial-supply` | `-i` | string | no | `1000000` | Initial supply. Default: display units. Append `"t"` for raw units | -| `--supply-type` | `-S` | string | no | `INFINITE` | Supply type: `INFINITE` or `FINITE` | -| `--max-supply` | `-m` | string | no | — | Max supply (required when `supply-type=FINITE`). Append `"t"` for raw units | -| `--admin-key` | `-a` | string | no | operator key | Admin key: `accountId:privateKey`, `{ed25519\|ecdsa}:private:{hex}`, key reference, or alias | -| `--supply-key` | `-s` | string | no | — | Supply key: `accountId:privateKey`, account ID, `{ed25519\|ecdsa}:public:{hex}`, `{ed25519\|ecdsa}:private:{hex}`, key reference, or alias | -| `--name` | `-n` | string | no | — | Local alias to register for this token | -| `--key-manager` | `-k` | string | no | config default | Key manager: `local` or `local_encrypted` | -| `--memo` | `-M` | string | no | — | Token memo (max 100 chars) | -| `--batch` | `-B` | string | no | — | Queue into a named batch instead of executing immediately | +| Option | Short | Type | Required | Default | Description | +| ---------------------- | ----- | ------ | -------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | +| `--token-name` | `-T` | string | **yes** | — | Token name | +| `--symbol` | `-Y` | string | **yes** | — | Token symbol | +| `--treasury` | `-t` | string | no | operator | Treasury account: `accountId:privateKey`, key reference, or alias | +| `--decimals` | `-d` | number | no | `0` | Number of decimal places | +| `--initial-supply` | `-i` | string | no | `1000000` | Initial supply. Default: display units. Append `"t"` for raw units | +| `--supply-type` | `-S` | string | no | `INFINITE` | Supply type: `INFINITE` or `FINITE` | +| `--max-supply` | `-m` | string | no | — | Max supply (required when `supply-type=FINITE`). Append `"t"` for raw units | +| `--admin-key` | `-a` | string | no | operator key | Admin key: `accountId:privateKey`, `{ed25519\|ecdsa}:private:{hex}`, key reference, or alias | +| `--supply-key` | `-s` | string | no | — | Supply key: `accountId:privateKey`, account ID, `{ed25519\|ecdsa}:public:{hex}`, `{ed25519\|ecdsa}:private:{hex}`, key reference, or alias | +| `--name` | `-n` | string | no | — | Local alias to register for this token | +| `--key-manager` | `-k` | string | no | config default | Key manager: `local` or `local_encrypted` | +| `--memo` | `-M` | string | no | — | Token memo (max 100 chars) | +| `--auto-renew-period` | `-R` | string | no | — | Auto-renew interval: integer = seconds, or suffix `s` / `m` / `h` / `d`. Requires `--auto-renew-account` | +| `--auto-renew-account` | `-A` | string | no | — | Account that pays auto-renewal (alias, `accountId:key`, key reference, etc.) | +| `--expiration-time` | `-x` | string | no | — | Fixed expiration (ISO 8601). Ignored (with warning) if auto-renew period + account are set | +| `--batch` | `-B` | string | no | — | Queue into a named batch instead of executing immediately | **Example:** @@ -35,7 +38,7 @@ hcli token create-ft --token-name "MyToken" --symbol MTK --decimals 2 --initial- hcli token create-ft --token-name "MyToken" --symbol MTK --batch myBatch ``` -**Output:** `{ tokenId, name, symbol, decimals, initialSupply, transactionId }` +**Output:** `{ tokenId, name, symbol, treasuryId, decimals, initialSupply, supplyType, transactionId, alias?, network, autoRenewPeriodSeconds?, autoRenewAccountId?, expirationTime? }` — `expirationTime` is an ISO string when fixed expiration was used; lifecycle fields are omitted when not set. --- @@ -85,6 +88,10 @@ hcli token create-ft-from-file --file ./my-token.json hcli token create-ft-from-file --file ./my-token.json --batch myBatch ``` +Optional JSON fields in the definition file: `autoRenewPeriod` (seconds or suffixed duration), `autoRenewAccount` (same formats as treasury/keys), `expirationTime` (ISO 8601). If `autoRenewPeriod` is set, `autoRenewAccount` is required; if both auto-renew fields are set, `expirationTime` is ignored (warning logged). + +**Output:** Same shape as CLI `create-ft`, plus `associations[]`, including optional `autoRenewPeriodSeconds`, `autoRenewAccountId`, `expirationTime`. + --- ### `hcli token create-nft-from-file` [batchify] diff --git a/src/core/schemas/__tests__/unit/common-schemas.test.ts b/src/core/schemas/__tests__/unit/common-schemas.test.ts index 84c040a50..727260068 100644 --- a/src/core/schemas/__tests__/unit/common-schemas.test.ts +++ b/src/core/schemas/__tests__/unit/common-schemas.test.ts @@ -9,8 +9,12 @@ import { SHORT_KEY, TEST_ACCOUNT_ID, } from '@/core/schemas/__tests__/helpers/fixtures'; -import { AccountIdWithPrivateKeySchema } from '@/core/schemas/common-schemas'; +import { + AccountIdWithPrivateKeySchema, + ExpirationTimeSchema, +} from '@/core/schemas/common-schemas'; import { INVALID_KEY } from '@/core/services/topic/__tests__/unit/mocks'; +import { DAY_IN_SECONDS } from '@/core/shared/constants'; describe('AccountIdWithPrivateKeySchema', () => { const accountId = TEST_ACCOUNT_ID; @@ -132,3 +136,22 @@ describe('AccountIdWithPrivateKeySchema', () => { }); }); }); + +describe('ExpirationTimeSchema', () => { + test('accepts undefined and omits expiration', () => { + expect(ExpirationTimeSchema.parse(undefined)).toBeUndefined(); + }); + + test('rejects expiration in the past', () => { + expect(() => + ExpirationTimeSchema.parse('2000-01-01T00:00:00.000Z'), + ).toThrow(); + }); + + test('accepts expiration strictly in the future', () => { + const future = new Date(Date.now() + 7 * DAY_IN_SECONDS).toISOString(); + const d = ExpirationTimeSchema.parse(future); + expect(d).toBeInstanceOf(Date); + expect(d!.getTime()).toBeGreaterThan(Date.now()); + }); +}); diff --git a/src/core/schemas/common-schemas.ts b/src/core/schemas/common-schemas.ts index a29d8d9c0..fca0ff959 100644 --- a/src/core/schemas/common-schemas.ts +++ b/src/core/schemas/common-schemas.ts @@ -15,12 +15,19 @@ import { CredentialType, KeyManager, } from '@/core/services/kms/kms-types.interface'; -import { HederaTokenType, KeyAlgorithm } from '@/core/shared/constants'; +import { + HEDERA_AUTO_RENEW_PERIOD_MAX, + HEDERA_AUTO_RENEW_PERIOD_MIN, + HEDERA_EXPIRATION_TIME_MAX, + HederaTokenType, + KeyAlgorithm, +} from '@/core/shared/constants'; import { EntityReferenceType, SupplyType, SupportedNetwork, } from '@/core/types/shared.types'; +import { parseAutoRenewPeriodToSeconds } from '@/core/utils/parse-auto-renew-period'; // Raw key patterns (without prefix) for validation const PUBLIC_KEY_PATTERN = @@ -474,6 +481,8 @@ export const TokenAliasNameSchema = AliasNameSchema.describe( 'Token alias name (local identifier, not on-chain name)', ); +export const TokenFreezeDefaultSchema = z.boolean().default(false); + /** * Memo Input * Optional memo field for transactions @@ -968,3 +977,55 @@ export const AutoRenewPeriodSchema = z .min(1, 'Auto renew period must be at least 1 second') .optional() .describe('Auto renew period in seconds'); + +/** Output / mirror fields: optional seconds, validated when present. */ +export const HederaAutoRenewPeriodSecondsOptionalSchema = z + .number() + .int() + .min(HEDERA_AUTO_RENEW_PERIOD_MIN) + .max(HEDERA_AUTO_RENEW_PERIOD_MAX) + .optional(); + +/** + * Optional field from CLI (string/number) or JSON → seconds, or `undefined`. + */ +export const AutoRenewPeriodSecondsSchema: z.ZodType = z + .union([z.string(), z.number()]) + .optional() + .transform((val): number | undefined => { + if (!val || val === '') { + return undefined; + } + return typeof val === 'number' ? val : parseAutoRenewPeriodToSeconds(val); + }) + .refine( + (sec) => + !sec || + (sec >= HEDERA_AUTO_RENEW_PERIOD_MIN && + sec <= HEDERA_AUTO_RENEW_PERIOD_MAX), + { + message: `Auto-renew period must be between ${HEDERA_AUTO_RENEW_PERIOD_MIN} and ${HEDERA_AUTO_RENEW_PERIOD_MAX} seconds (30–92 days inclusive).`, + }, + ); + +/** + * Optional ISO 8601 datetime string → `Date`. + * When set, the instant must be strictly after the current time. + */ +export const ExpirationTimeSchema: z.ZodType = z.coerce + .date() + .optional() + .refine((s) => !s || !Number.isNaN(new Date(s).getTime()), { + message: + 'Invalid expiration time. Use an ISO 8601 datetime (e.g. 2026-12-31T23:59:59.000Z).', + }) + .refine( + (d) => + !d || + (d.getTime() > Date.now() && + d.getTime() <= + new Date(Date.now() + HEDERA_EXPIRATION_TIME_MAX).getTime()), + { + message: 'Expiration time must be set in 92 days period.', + }, + ); diff --git a/src/core/services/mirrornode/__tests__/unit/mocks.ts b/src/core/services/mirrornode/__tests__/unit/mocks.ts index c11b1a401..c34d6c929 100644 --- a/src/core/services/mirrornode/__tests__/unit/mocks.ts +++ b/src/core/services/mirrornode/__tests__/unit/mocks.ts @@ -117,6 +117,9 @@ export const createMockTokenInfo = ( created_timestamp: '2024-01-01T12:00:00.000Z', deleted: false, freeze_default: false, + auto_renew_period: 7776000, + auto_renew_account: '0.0.1234', + expiry_timestamp: 1893456000000000000, pause_status: 'UNPAUSED', memo: '', ...overrides, @@ -139,6 +142,9 @@ export const createMockMirrorNodeTokenByIdJson = ( created_timestamp: '2024-01-01T12:00:00.000Z', deleted: false, freeze_default: false, + auto_renew_period: 7776000, + auto_renew_account: '0.0.1234', + expiry_timestamp: 1893456000000000000, pause_status: 'UNPAUSED', memo: '', ...overrides, diff --git a/src/core/services/mirrornode/schemas.ts b/src/core/services/mirrornode/schemas.ts index 43fbb85f3..fba910cb8 100644 --- a/src/core/services/mirrornode/schemas.ts +++ b/src/core/services/mirrornode/schemas.ts @@ -33,10 +33,14 @@ export const TokenInfoSchema: z.ZodType = z.object({ wipe_key: optionalKeyRef, supply_key: optionalKeyRef, fee_schedule_key: optionalKeyRef, + metadata_key: optionalKeyRef, pause_key: optionalKeyRef, created_timestamp: z.string(), deleted: z.boolean().nullable().optional(), freeze_default: z.boolean().optional(), + auto_renew_account: z.string(), + auto_renew_period: z.number(), + expiry_timestamp: z.number(), pause_status: z.string(), memo: z.string(), }); diff --git a/src/core/services/mirrornode/types.ts b/src/core/services/mirrornode/types.ts index 06e1564d6..8adb9ea5c 100644 --- a/src/core/services/mirrornode/types.ts +++ b/src/core/services/mirrornode/types.ts @@ -83,10 +83,14 @@ export interface TokenInfo { wipe_key?: MirrorNodeTokenKey | null; supply_key?: MirrorNodeTokenKey | null; fee_schedule_key?: MirrorNodeTokenKey | null; + metadata_key?: MirrorNodeTokenKey | null; pause_key?: MirrorNodeTokenKey | null; created_timestamp: string; deleted?: boolean | null; freeze_default?: boolean; + auto_renew_account?: string; + auto_renew_period?: number; + expiry_timestamp?: number; pause_status: string; memo: string; } diff --git a/src/core/services/token/__tests__/unit/mocks.ts b/src/core/services/token/__tests__/unit/mocks.ts index 8ef756e84..6511e2e72 100644 --- a/src/core/services/token/__tests__/unit/mocks.ts +++ b/src/core/services/token/__tests__/unit/mocks.ts @@ -23,8 +23,13 @@ export const createMockTokenCreateTransaction = () => ({ setWipeKey: jest.fn().mockReturnThis(), setKycKey: jest.fn().mockReturnThis(), setFreezeKey: jest.fn().mockReturnThis(), + setFreezeDefault: jest.fn().mockReturnThis(), setPauseKey: jest.fn().mockReturnThis(), setFeeScheduleKey: jest.fn().mockReturnThis(), + setMetadataKey: jest.fn().mockReturnThis(), + setAutoRenewAccountId: jest.fn().mockReturnThis(), + setAutoRenewPeriod: jest.fn().mockReturnThis(), + setExpirationTime: jest.fn().mockReturnThis(), }); export const createMockTokenAssociateTransaction = () => ({ diff --git a/src/core/services/token/__tests__/unit/token-service.test.ts b/src/core/services/token/__tests__/unit/token-service.test.ts index e583bfae8..5c7850844 100644 --- a/src/core/services/token/__tests__/unit/token-service.test.ts +++ b/src/core/services/token/__tests__/unit/token-service.test.ts @@ -7,10 +7,13 @@ import type { Logger } from '@/core/services/logger/logger-service.interface'; import { AccountId, Hbar, TokenId, TokenType } from '@hashgraph/sdk'; -import { ECDSA_HEX_PUBLIC_KEY } from '@/__tests__/mocks/fixtures'; +import { + ECDSA_HEX_PUBLIC_KEY, + MOCK_ACCOUNT_ID, +} from '@/__tests__/mocks/fixtures'; import { makeLogger } from '@/__tests__/mocks/mocks'; import { TokenServiceImpl } from '@/core/services/token/token-service'; -import { HederaTokenType } from '@/core/shared/constants'; +import { DAY_IN_SECONDS, HederaTokenType } from '@/core/shared/constants'; import { SupplyType } from '@/core/types/shared.types'; import { type CustomFee, @@ -283,6 +286,15 @@ describe('TokenServiceImpl', () => { expect(result).toBe(mockTokenCreateTransaction); }); + it('should not set freeze key or freeze default when freeze key omitted', () => { + tokenService.createTokenTransaction(baseParams); + + expect(mockTokenCreateTransaction.setFreezeKey).not.toHaveBeenCalled(); + expect( + mockTokenCreateTransaction.setFreezeDefault, + ).not.toHaveBeenCalled(); + }); + it('should create token with FINITE supply type and max supply', () => { const params = { ...baseParams, @@ -337,6 +349,57 @@ describe('TokenServiceImpl', () => { expect(mockTokenCreateTransaction.setTokenMemo).not.toHaveBeenCalled(); }); + it('should not set admin key when omitted', () => { + const { adminPublicKey: _admin, ...paramsWithoutAdmin } = baseParams; + + tokenService.createTokenTransaction(paramsWithoutAdmin); + + expect(mockTokenCreateTransaction.setAdminKey).not.toHaveBeenCalled(); + }); + + it('should set metadata key when provided', () => { + const params = { + ...baseParams, + metadataPublicKey: mockPublicKeyInstance as unknown as PublicKey, + }; + + tokenService.createTokenTransaction(params); + + expect(mockTokenCreateTransaction.setMetadataKey).toHaveBeenCalledWith( + mockPublicKeyInstance, + ); + }); + + it('should set freeze default when freeze key is set', () => { + const paramsWithFreeze = { + ...baseParams, + freezePublicKey: mockPublicKeyInstance as unknown as PublicKey, + freezeDefault: true, + }; + + tokenService.createTokenTransaction(paramsWithFreeze); + + expect(mockTokenCreateTransaction.setFreezeKey).toHaveBeenCalledWith( + mockPublicKeyInstance, + ); + expect(mockTokenCreateTransaction.setFreezeDefault).toHaveBeenCalledWith( + true, + ); + }); + + it('should set freeze default false when freeze key is set without freezeDefault', () => { + const paramsWithFreeze = { + ...baseParams, + freezePublicKey: mockPublicKeyInstance as unknown as PublicKey, + }; + + tokenService.createTokenTransaction(paramsWithFreeze); + + expect(mockTokenCreateTransaction.setFreezeDefault).toHaveBeenCalledWith( + false, + ); + }); + it('should set custom fees when provided', () => { const customFees: CustomFee[] = [ { @@ -573,6 +636,61 @@ describe('TokenServiceImpl', () => { mockCustomFractionalFee.setAllCollectorsAreExempt, ).toHaveBeenCalledWith(true); }); + + it('should set auto-renew account and period when both provided', () => { + const params = { + ...baseParams, + autoRenewAccountId: MOCK_ACCOUNT_ID, + autoRenewPeriodSeconds: 30 * DAY_IN_SECONDS, + }; + + tokenService.createTokenTransaction(params); + + expect( + mockTokenCreateTransaction.setAutoRenewAccountId, + ).toHaveBeenCalledWith(mockAccountIdInstance); + expect( + mockTokenCreateTransaction.setAutoRenewPeriod, + ).toHaveBeenCalledWith(30 * DAY_IN_SECONDS); + expect( + mockTokenCreateTransaction.setExpirationTime, + ).not.toHaveBeenCalled(); + }); + + it('should set expiration time when auto-renew period is not used', () => { + const exp = new Date('2028-01-01T00:00:00.000Z'); + const params = { + ...baseParams, + expirationTime: exp, + }; + + tokenService.createTokenTransaction(params); + + expect(mockTokenCreateTransaction.setExpirationTime).toHaveBeenCalledWith( + exp, + ); + expect( + mockTokenCreateTransaction.setAutoRenewPeriod, + ).not.toHaveBeenCalled(); + }); + + it('should prefer auto-renew over expiration when both are passed', () => { + const params = { + ...baseParams, + autoRenewAccountId: MOCK_ACCOUNT_ID, + autoRenewPeriodSeconds: 30 * DAY_IN_SECONDS, + expirationTime: new Date('2028-01-01T00:00:00.000Z'), + }; + + tokenService.createTokenTransaction(params); + + expect( + mockTokenCreateTransaction.setAutoRenewPeriod, + ).toHaveBeenCalledWith(30 * DAY_IN_SECONDS); + expect( + mockTokenCreateTransaction.setExpirationTime, + ).not.toHaveBeenCalled(); + }); }); describe('createTokenAssociationTransaction', () => { diff --git a/src/core/services/token/token-service.ts b/src/core/services/token/token-service.ts index 9debbc4eb..77dffebc8 100644 --- a/src/core/services/token/token-service.ts +++ b/src/core/services/token/token-service.ts @@ -92,10 +92,15 @@ export class TokenServiceImpl implements TokenService { wipePublicKey, kycPublicKey, freezePublicKey, + freezeDefault, pausePublicKey, feeSchedulePublicKey, + metadataPublicKey, customFees, memo, + autoRenewPeriodSeconds, + autoRenewAccountId, + expirationTime, } = params; // Convert supply type string to enum @@ -160,6 +165,10 @@ export class TokenServiceImpl implements TokenService { if (freezePublicKey) { tokenCreateTx.setFreezeKey(freezePublicKey); this.logger.debug(`[TOKEN SERVICE] Set freeze key`); + tokenCreateTx.setFreezeDefault(freezeDefault ?? false); + this.logger.debug( + `[TOKEN SERVICE] Set freeze default: ${String(freezeDefault ?? false)}`, + ); } if (pausePublicKey) { @@ -172,6 +181,25 @@ export class TokenServiceImpl implements TokenService { this.logger.debug(`[TOKEN SERVICE] Set fee schedule key`); } + if (metadataPublicKey) { + tokenCreateTx.setMetadataKey(metadataPublicKey); + this.logger.debug(`[TOKEN SERVICE] Set metadata key`); + } + + if (autoRenewPeriodSeconds && autoRenewAccountId) { + tokenCreateTx + .setAutoRenewAccountId(AccountId.fromString(autoRenewAccountId)) + .setAutoRenewPeriod(autoRenewPeriodSeconds); + this.logger.debug( + `[TOKEN SERVICE] Set auto-renew: account ${autoRenewAccountId}, period ${String(autoRenewPeriodSeconds)}s`, + ); + } else if (expirationTime) { + tokenCreateTx.setExpirationTime(expirationTime); + this.logger.debug( + `[TOKEN SERVICE] Set expiration time: ${expirationTime.toISOString()}`, + ); + } + this.logger.debug( `[TOKEN SERVICE] Created token creation transaction for ${name}`, ); diff --git a/src/core/shared/constants.ts b/src/core/shared/constants.ts index 76aafa6b5..9133018eb 100644 --- a/src/core/shared/constants.ts +++ b/src/core/shared/constants.ts @@ -37,3 +37,11 @@ export const MirrorTokenTypeToHederaTokenType = { [TokenType.FungibleCommon.toString()]: HederaTokenType.FUNGIBLE_COMMON, [TokenType.NonFungibleUnique.toString()]: HederaTokenType.NON_FUNGIBLE_TOKEN, } satisfies Record; + +export const MINUTE_IN_SECONDS = 60; +export const HOUR_IN_SECONDS = 60 * MINUTE_IN_SECONDS; +export const DAY_IN_SECONDS = 24 * HOUR_IN_SECONDS; + +export const HEDERA_AUTO_RENEW_PERIOD_MIN = 30 * DAY_IN_SECONDS; // 30 days +export const HEDERA_AUTO_RENEW_PERIOD_MAX = 92 * DAY_IN_SECONDS; // 92 days (per network rules) +export const HEDERA_EXPIRATION_TIME_MAX = 92 * DAY_IN_SECONDS * 1000; // 92 days (per network rules) diff --git a/src/core/types/token.types.ts b/src/core/types/token.types.ts index f900e7747..83d984e35 100644 --- a/src/core/types/token.types.ts +++ b/src/core/types/token.types.ts @@ -68,10 +68,15 @@ export interface TokenCreateParams { wipePublicKey?: PublicKey; kycPublicKey?: PublicKey; freezePublicKey?: PublicKey; + freezeDefault?: boolean; pausePublicKey?: PublicKey; feeSchedulePublicKey?: PublicKey; + metadataPublicKey?: PublicKey; customFees?: CustomFee[]; memo?: string; + autoRenewPeriodSeconds?: number; + autoRenewAccountId?: string; + expirationTime?: Date; } /** diff --git a/src/core/utils/parse-auto-renew-period.ts b/src/core/utils/parse-auto-renew-period.ts new file mode 100644 index 000000000..f2da729ea --- /dev/null +++ b/src/core/utils/parse-auto-renew-period.ts @@ -0,0 +1,46 @@ +import { + DAY_IN_SECONDS, + HOUR_IN_SECONDS, + MINUTE_IN_SECONDS, +} from '@/core/shared/constants'; + +const DURATION_SUFFIX_SECONDS: Record = { + s: 1, + m: MINUTE_IN_SECONDS, + h: HOUR_IN_SECONDS, + d: DAY_IN_SECONDS, +}; + +/** + * Parses auto-renew period CLI/file input into seconds for Hedera `TokenCreateTransaction.setAutoRenewPeriod`. + * + * - Plain integer (no suffix) → seconds (e.g. `500` → 500) + * - `500s` → 500 seconds + * - `50m` → minutes → seconds + * - `2h` → hours → seconds + * - `1d` → days → seconds + */ +export function parseAutoRenewPeriodToSeconds(raw: string): number { + const trimmed = raw.trim(); + if (!trimmed) { + throw new Error('Auto-renew period cannot be empty'); + } + + const withSuffix = trimmed.match(/^(\d+)([smhd])$/i); + if (withSuffix) { + const n = parseInt(withSuffix[1], 10); + const mult = DURATION_SUFFIX_SECONDS[withSuffix[2].toLowerCase()]; + if (!mult) { + throw new Error(`Unsupported suffix in "${raw}"`); + } + return n * mult; + } + + if (/^\d+$/.test(trimmed)) { + return parseInt(trimmed, 10); + } + + throw new Error( + `Invalid auto-renew period "${raw}". Use a non-negative integer (seconds), or add suffix s, m, h, or d (e.g. 500, 500s, 50m, 2h, 1d).`, + ); +} diff --git a/src/plugins/token/README.md b/src/plugins/token/README.md index d9a1db0a0..bdca40861 100644 --- a/src/plugins/token/README.md +++ b/src/plugins/token/README.md @@ -97,7 +97,7 @@ src/plugins/token/ │ ├── types.ts # AssociateNormalisedParamsSchema for batch item validation │ └── index.ts # Hook exports ├── utils/ -│ ├── nft-build-output.ts # NFT output builder utilities +│ ├── token-build-output.ts # NFT output builder utilities │ ├── token-amount-helpers.ts # Token amount processing helpers │ ├── token-data-builders.ts # Token data builders for create-from-file │ ├── token-associations.ts # Token association processing @@ -151,6 +151,23 @@ hcli token create-ft \ --name mytoken-alias ``` +**Auto-renew and expiration** (optional): + +- `--auto-renew-period` / `-R`: Auto-renew interval. A plain integer is **seconds** (e.g. `500`). You may use a suffix: `s` (seconds), `m` (minutes), `h` (hours), `d` (days), e.g. `500s`, `50m`, `2h`, `1d`. **Requires** `--auto-renew-account`; if the period is set without an account, validation fails. +- `--auto-renew-account` / `-r`: Account that pays for auto-renewal (same accepted formats as `--treasury`: alias, `accountId:privateKey`, key reference, etc.). +- `--expiration-time` / `-x`: Fixed expiration as an **ISO 8601** datetime (e.g. `2030-12-31T23:59:59.000Z`). If both **auto-renew period and account** are set, **expiration is ignored** and a warning is logged (auto-renew takes precedence). + +Command output includes optional `autoRenewPeriodSeconds`, `autoRenewAccountId`, and `expirationTime` (ISO string when a fixed expiration was applied). + +```bash +# Auto-renew every 30 days, paid by a dedicated account +hcli token create-ft \ + --token-name "My Token" \ + --symbol "MTK" \ + --auto-renew-period 30d \ + --auto-renew-account 0.0.789012:302e020100300506032b657004220420... +``` + **Batch support:** Pass `--batch ` to add token creation to a batch instead of executing immediately. See the [Batch Support](#-batch-support) section. ### Token Mint FT @@ -434,6 +451,9 @@ The token file supports aliases and raw keys with optional key type prefixes: "pauseKey": "", "feeScheduleKey": "", "memo": "Optional token memo", + "autoRenewPeriod": "86400", + "autoRenewAccount": "", + "expirationTime": "2030-12-31T23:59:59.000Z", "associations": ["", "..."], "customFees": [ { @@ -447,6 +467,12 @@ The token file supports aliases and raw keys with optional key type prefixes: } ``` +Optional lifecycle fields (same rules as `token create-ft`): + +- `autoRenewPeriod`: string or number; parsed to seconds (plain number = seconds; suffixes `s`, `m`, `h`, `d` supported). **Requires** `autoRenewAccount` when set. +- `autoRenewAccount`: account that pays auto-renewal (same key formats as other keys). +- `expirationTime`: ISO 8601 string. Ignored with a warning if both `autoRenewPeriod` and `autoRenewAccount` are set. + **Supported formats for treasury and keys:** - **Alias**: `"my-account"` - resolved via alias service diff --git a/src/plugins/token/__tests__/unit/batch-create-ft-from-file.test.ts b/src/plugins/token/__tests__/unit/batch-create-ft-from-file.test.ts index f89fa3614..92e180fa8 100644 --- a/src/plugins/token/__tests__/unit/batch-create-ft-from-file.test.ts +++ b/src/plugins/token/__tests__/unit/batch-create-ft-from-file.test.ts @@ -50,6 +50,7 @@ const createFlatNormalizedParams = ( publicKey: 'pk-treasury', }, adminKey: { keyRefId: 'kr-admin', publicKey: 'pk-admin' }, + freezeDefault: false, ...overrides, }); diff --git a/src/plugins/token/__tests__/unit/batch-create-ft.test.ts b/src/plugins/token/__tests__/unit/batch-create-ft.test.ts index 3f1c0ef19..ce7444630 100644 --- a/src/plugins/token/__tests__/unit/batch-create-ft.test.ts +++ b/src/plugins/token/__tests__/unit/batch-create-ft.test.ts @@ -46,6 +46,7 @@ const createFtBatchDataItem = ( keyRefId: 'kr-admin', publicKey: 'pk-admin', }, + freezeDefault: false, }, transactionId: '0.0.1234@1234567890.000000000', ...overrides, diff --git a/src/plugins/token/__tests__/unit/create.test.ts b/src/plugins/token/__tests__/unit/create.test.ts index 34ca6f7db..2ff49d9b5 100644 --- a/src/plugins/token/__tests__/unit/create.test.ts +++ b/src/plugins/token/__tests__/unit/create.test.ts @@ -2,7 +2,7 @@ import type { CommandHandlerArgs } from '@/core/plugins/plugin.interface'; import type { TransactionResult } from '@/core/types/shared.types'; import { assertOutput } from '@/__tests__/utils/assert-output'; -import { InternalError, StateError } from '@/core/errors'; +import { InternalError, StateError, ValidationError } from '@/core/errors'; import { AliasType } from '@/core/services/alias/alias-service.interface'; import { HederaTokenType } from '@/core/shared/constants'; import { SupplyType } from '@/core/types/shared.types'; @@ -156,8 +156,19 @@ describe('createTokenHandler', () => { maxSupplyRaw: undefined, treasuryId: '0.0.100000', adminPublicKey: undefined, + supplyPublicKey: undefined, + freezePublicKey: undefined, + wipePublicKey: undefined, + kycPublicKey: undefined, + pausePublicKey: undefined, + feeSchedulePublicKey: undefined, + metadataPublicKey: undefined, + freezeDefault: undefined, tokenType: HederaTokenType.FUNGIBLE_COMMON, memo: undefined, + autoRenewPeriodSeconds: undefined, + autoRenewAccountId: undefined, + expirationTime: undefined, }); // When adminKey is not provided, only treasury signs (which is the operator) expect(txExecute.execute).toHaveBeenCalledWith(expect.anything()); @@ -191,6 +202,25 @@ describe('createTokenHandler', () => { // Act & Assert - Error is thrown before try-catch block in handler await expect(tokenCreateFt(args)).rejects.toThrow('No operator set'); }); + + test('throws ValidationError when autoRenewPeriod is set without autoRenewAccount', async () => { + const { api } = makeApiMocks(); + + const logger = makeLogger(); + const args: CommandHandlerArgs = { + args: { + tokenName: 'TestToken', + symbol: 'TEST', + autoRenewPeriod: '30d', + }, + api, + state: api.state, + config: api.config, + logger, + }; + + await expect(tokenCreateFt(args)).rejects.toThrow(ValidationError); + }); }); describe('error scenarios', () => { diff --git a/src/plugins/token/__tests__/unit/createFromFile.test.ts b/src/plugins/token/__tests__/unit/createFromFile.test.ts index 09b67fd4b..d33f5e227 100644 --- a/src/plugins/token/__tests__/unit/createFromFile.test.ts +++ b/src/plugins/token/__tests__/unit/createFromFile.test.ts @@ -380,6 +380,14 @@ describe('tokenCreateFtFromFileHandler', () => { supplyType: SupplyType.INFINITE, maxSupplyRaw: 0n, adminPublicKey: expect.any(Object), + supplyPublicKey: undefined, + freezePublicKey: undefined, + wipePublicKey: undefined, + kycPublicKey: undefined, + pausePublicKey: undefined, + feeSchedulePublicKey: undefined, + metadataPublicKey: undefined, + freezeDefault: undefined, customFees: [ { type: 'fixed', @@ -391,6 +399,9 @@ describe('tokenCreateFtFromFileHandler', () => { ], memo: 'Test token created from file', tokenType: HederaTokenType.FUNGIBLE_COMMON, + autoRenewPeriodSeconds: undefined, + autoRenewAccountId: undefined, + expirationTime: undefined, }); }); @@ -846,6 +857,8 @@ describe('tokenCreateFtFromFileHandler', () => { mockFs.access.mockResolvedValue(undefined); mockPath.resolve.mockReturnValue('/resolved/path/to/test.json'); + const logger = makeLogger(); + const { api } = makeApiMocks({ tokenTransactions: { createTokenTransaction: jest @@ -893,8 +906,7 @@ describe('tokenCreateFtFromFileHandler', () => { }), }, }); - - const logger = makeLogger(); + api.logger = logger; const args: CommandHandlerArgs = { args: { file: 'test', diff --git a/src/plugins/token/__tests__/unit/helpers/fixtures.ts b/src/plugins/token/__tests__/unit/helpers/fixtures.ts index b0b5c6339..70f3cabad 100644 --- a/src/plugins/token/__tests__/unit/helpers/fixtures.ts +++ b/src/plugins/token/__tests__/unit/helpers/fixtures.ts @@ -307,6 +307,7 @@ export const validTokenKeys = { freezeKey: 'freeze-key', pauseKey: 'pause-key', feeScheduleKey: 'fee-schedule-key', + metadataKey: 'metadata-key', treasuryKey: 'treasury-key', }; @@ -485,7 +486,18 @@ export const expectedTokenTransactionParams = { maxSupplyRaw: undefined, treasuryId: '0.0.123456', adminPublicKey: expect.any(Object), + supplyPublicKey: undefined, + freezePublicKey: undefined, + wipePublicKey: undefined, + kycPublicKey: undefined, + pausePublicKey: undefined, + feeSchedulePublicKey: undefined, + metadataPublicKey: undefined, + freezeDefault: undefined, memo: undefined, + autoRenewPeriodSeconds: undefined, + autoRenewAccountId: undefined, + expirationTime: undefined, }; /** @@ -517,6 +529,13 @@ export const expectedTokenTransactionParamsFromFile = { maxSupplyRaw: 10000n, treasuryId: '0.0.123456', adminPublicKey: expect.any(Object), // PublicKey object from keyResolver + supplyPublicKey: undefined, + freezePublicKey: undefined, + wipePublicKey: undefined, + kycPublicKey: undefined, + pausePublicKey: undefined, + feeSchedulePublicKey: undefined, + metadataPublicKey: undefined, customFees: [ { type: CustomFeeType.FIXED, @@ -527,7 +546,11 @@ export const expectedTokenTransactionParamsFromFile = { }, ], tokenType: HederaTokenType.FUNGIBLE_COMMON, + freezeDefault: undefined, memo: 'Test token created from file', + autoRenewPeriodSeconds: undefined, + autoRenewAccountId: undefined, + expirationTime: undefined, }; /** diff --git a/src/plugins/token/__tests__/unit/token-lifecycle.test.ts b/src/plugins/token/__tests__/unit/token-lifecycle.test.ts index da7471473..d916666c4 100644 --- a/src/plugins/token/__tests__/unit/token-lifecycle.test.ts +++ b/src/plugins/token/__tests__/unit/token-lifecycle.test.ts @@ -166,8 +166,19 @@ describe('Token Lifecycle Integration', () => { maxSupplyRaw: 100000n, treasuryId: _treasuryAccountId, adminPublicKey: expect.any(Object), + supplyPublicKey: undefined, + freezePublicKey: undefined, + wipePublicKey: undefined, + kycPublicKey: undefined, + pausePublicKey: undefined, + feeSchedulePublicKey: undefined, + metadataPublicKey: undefined, + freezeDefault: undefined, tokenType: HederaTokenType.FUNGIBLE_COMMON, memo: undefined, + autoRenewPeriodSeconds: undefined, + autoRenewAccountId: undefined, + expirationTime: undefined, }); expect( diff --git a/src/plugins/token/__tests__/unit/view.test.ts b/src/plugins/token/__tests__/unit/view.test.ts index 29f4e7a7f..3560c225f 100644 --- a/src/plugins/token/__tests__/unit/view.test.ts +++ b/src/plugins/token/__tests__/unit/view.test.ts @@ -2,8 +2,10 @@ import type { CommandHandlerArgs } from '@/core/plugins/plugin.interface'; import '@/core/utils/json-serialize'; +import { MOCK_ACCOUNT_ID } from '@/__tests__/mocks/fixtures'; import { assertOutput } from '@/__tests__/utils/assert-output'; import { AliasType } from '@/core/services/alias/alias-service.interface'; +import { DAY_IN_SECONDS } from '@/core/shared/constants'; import { tokenView, TokenViewOutputSchema, @@ -38,7 +40,10 @@ describe('tokenViewHandler', () => { max_supply: '0', type: 'FUNGIBLE_COMMON', supply_type: 'INFINITE', - treasury_account_id: '0.0.789012', + treasury_account_id: MOCK_ACCOUNT_ID, + freeze_default: true, + auto_renew_period: 30 * DAY_IN_SECONDS, + auto_renew_account: MOCK_ACCOUNT_ID, admin_key: null, supply_key: null, memo: 'Test memo', @@ -72,6 +77,10 @@ describe('tokenViewHandler', () => { expect(output.name).toBe('TestToken'); expect(output.symbol).toBe('TEST'); expect(output.type).toBe('FUNGIBLE_COMMON'); + expect(output.freezeDefault).toBe(true); + expect(output.treasury).toBe(MOCK_ACCOUNT_ID); + expect(output.autoRenewPeriodSeconds).toBe(30 * DAY_IN_SECONDS); + expect(output.autoRenewAccountId).toBe(MOCK_ACCOUNT_ID); }); test('should view token using alias', async () => { @@ -84,10 +93,11 @@ describe('tokenViewHandler', () => { max_supply: '0', type: 'FUNGIBLE_COMMON', supply_type: 'INFINITE', - treasury_account_id: '0.0.789012', + treasury_account_id: MOCK_ACCOUNT_ID, admin_key: null, supply_key: null, memo: 'Test memo', + freeze_default: false, created_timestamp: '1700000000.000000000', }; diff --git a/src/plugins/token/commands/create-ft-from-file/handler.ts b/src/plugins/token/commands/create-ft-from-file/handler.ts index 0c6281b40..fc0e3ed51 100644 --- a/src/plugins/token/commands/create-ft-from-file/handler.ts +++ b/src/plugins/token/commands/create-ft-from-file/handler.ts @@ -1,6 +1,11 @@ -import type { CommandHandlerArgs, CommandResult } from '@/core'; +import type { CommandHandlerArgs, CommandResult, CoreApi } from '@/core'; +import type { + ResolvedAccountCredential, + ResolvedPublicKey, +} from '@/core/services/key-resolver/types'; import type { KeyManager } from '@/core/services/kms/kms-types.interface'; import type { SupplyType } from '@/core/types/shared.types'; +import type { FungibleTokenFileDefinition } from '@/plugins/token/schema'; import type { TokenCreateFtFromFileOutput } from './output'; import type { TokenCreateFtFromFileAssociationOutput, @@ -10,10 +15,8 @@ import type { TokenCreateFtFromFileSignTransactionResult, } from './types'; -import { PublicKey } from '@hashgraph/sdk'; - import { BaseTransactionCommand } from '@/core/commands/command'; -import { StateError } from '@/core/errors'; +import { StateError, ValidationError } from '@/core/errors'; import { AliasType } from '@/core/services/alias/alias-service.interface'; import { composeKey } from '@/core/utils/key-composer'; import { processTokenAssociations } from '@/plugins/token/utils/token-associations'; @@ -22,7 +25,7 @@ import { readAndValidateTokenFile } from '@/plugins/token/utils/token-file-helpe import { resolveOptionalKey, toPublicKey, -} from '@/plugins/token/utils/token-resolve-optional-key'; +} from '@/plugins/token/utils/token-key-resolver'; import { ZustandTokenStateHelper } from '@/plugins/token/zustand-state-helper'; import { TokenCreateFtFromFileInputSchema } from './input'; @@ -58,56 +61,52 @@ export class TokenCreateFtFromFileCommand extends BaseTransactionCommand< const network = api.network.getCurrentNetwork(); api.alias.availableOrThrow(tokenDefinition.name, network); - const treasury = await api.keyResolver.resolveAccountCredentials( - tokenDefinition.treasuryKey, - keyManager, - false, - ['token:treasury'], - ); - const adminKey = await api.keyResolver.resolveSigningKey( - tokenDefinition.adminKey, - keyManager, - false, - ['token:admin', `token:${tokenDefinition.name}`], - ); - logger.info('🔑 Resolved admin key for signing'); + const { + treasury, + adminKey, + supplyKey, + wipeKey, + kycKey, + freezeKey, + pauseKey, + feeScheduleKey, + metadataKey, + keyRefIds, + } = await this.resolveKeys(api, tokenDefinition, keyManager); - const supplyKey = await resolveOptionalKey( - tokenDefinition.supplyKey, - keyManager, - api.keyResolver, - 'token:supply', - ); - const wipeKey = await resolveOptionalKey( - tokenDefinition.wipeKey, - keyManager, - api.keyResolver, - 'token:wipe', - ); - const kycKey = await resolveOptionalKey( - tokenDefinition.kycKey, - keyManager, - api.keyResolver, - 'token:kyc', - ); - const freezeKey = await resolveOptionalKey( - tokenDefinition.freezeKey, - keyManager, - api.keyResolver, - 'token:freeze', - ); - const pauseKey = await resolveOptionalKey( - tokenDefinition.pauseKey, - keyManager, - api.keyResolver, - 'token:pause', - ); - const feeScheduleKey = await resolveOptionalKey( - tokenDefinition.feeScheduleKey, - keyManager, - api.keyResolver, - 'token:feeSchedule', - ); + const autoRenewPeriodSeconds = tokenDefinition.autoRenewPeriod; + const autoRenewAccountCredential = tokenDefinition.autoRenewAccount + ? await api.keyResolver.resolveAccountCredentials( + tokenDefinition.autoRenewAccount, + keyManager, + false, + ['token:auto-renew'], + ) + : undefined; + + if (autoRenewPeriodSeconds !== undefined && !autoRenewAccountCredential) { + throw new ValidationError( + 'autoRenewAccount is required when autoRenewPeriod is set', + { + context: { + autoRenewPeriodSeconds, + }, + }, + ); + } + + let expirationTime: Date | undefined = tokenDefinition.expirationTime; + if ( + autoRenewPeriodSeconds !== undefined && + autoRenewAccountCredential !== undefined + ) { + if (expirationTime !== undefined) { + logger.warn( + 'expirationTime is ignored because autoRenewPeriod is set; auto-renew period takes precedence over fixed expiration.', + ); + } + expirationTime = undefined; + } return { filename: validArgs.file, @@ -131,7 +130,22 @@ export class TokenCreateFtFromFileCommand extends BaseTransactionCommand< freezeKey, pauseKey, feeScheduleKey, - keyRefIds: [adminKey.keyRefId, treasury.keyRefId], + keyRefIds, + metadataKey, + adminPublicKey: toPublicKey(adminKey), + supplyPublicKey: toPublicKey(supplyKey), + wipePublicKey: toPublicKey(wipeKey), + kycPublicKey: toPublicKey(kycKey), + freezePublicKey: toPublicKey(freezeKey), + pausePublicKey: toPublicKey(pauseKey), + feeSchedulePublicKey: toPublicKey(feeScheduleKey), + metadataPublicKey: toPublicKey(metadataKey), + freezeDefault: tokenDefinition.freezeDefault, + autoRenewPeriodSeconds: autoRenewAccountCredential + ? autoRenewPeriodSeconds + : undefined, + autoRenewAccountId: autoRenewAccountCredential?.accountId, + expirationTime, }; } @@ -149,15 +163,22 @@ export class TokenCreateFtFromFileCommand extends BaseTransactionCommand< tokenType: normalisedParams.tokenType, supplyType: normalisedParams.supplyType.toUpperCase() as SupplyType, maxSupplyRaw: normalisedParams.maxSupply, - adminPublicKey: PublicKey.fromString(normalisedParams.adminKey.publicKey), - supplyPublicKey: toPublicKey(normalisedParams.supplyKey), - wipePublicKey: toPublicKey(normalisedParams.wipeKey), - kycPublicKey: toPublicKey(normalisedParams.kycKey), - freezePublicKey: toPublicKey(normalisedParams.freezeKey), - pausePublicKey: toPublicKey(normalisedParams.pauseKey), - feeSchedulePublicKey: toPublicKey(normalisedParams.feeScheduleKey), + adminPublicKey: normalisedParams.adminPublicKey, + supplyPublicKey: normalisedParams.supplyPublicKey, + wipePublicKey: normalisedParams.wipePublicKey, + kycPublicKey: normalisedParams.kycPublicKey, + freezePublicKey: normalisedParams.freezePublicKey, + pausePublicKey: normalisedParams.pausePublicKey, + feeSchedulePublicKey: normalisedParams.feeSchedulePublicKey, + metadataPublicKey: normalisedParams.metadataPublicKey, + freezeDefault: normalisedParams.freezeKey + ? normalisedParams.freezeDefault + : undefined, customFees: normalisedParams.customFees, memo: normalisedParams.memo, + autoRenewPeriodSeconds: normalisedParams.autoRenewPeriodSeconds, + autoRenewAccountId: normalisedParams.autoRenewAccountId, + expirationTime: normalisedParams.expirationTime, }); return { transaction }; } @@ -169,11 +190,13 @@ export class TokenCreateFtFromFileCommand extends BaseTransactionCommand< ): Promise { const { api, logger } = args; const signingKeys = [ - normalisedParams.adminKey.keyRefId, normalisedParams.treasury.keyRefId, + ...(normalisedParams.adminKey + ? [normalisedParams.adminKey.keyRefId] + : []), ]; logger.info( - `🔑 Signing transaction with admin key and treasury key (${signingKeys.length} keys)`, + `🔑 Signing token create with treasury${normalisedParams.adminKey ? ' and admin' : ''} (${signingKeys.length} key(s))`, ); const transaction = await api.txSign.sign( buildTransactionResult.transaction, @@ -258,10 +281,110 @@ export class TokenCreateFtFromFileCommand extends BaseTransactionCommand< transactionId: result.transactionId, network: normalisedParams.network, associations, + autoRenewPeriodSeconds: normalisedParams.autoRenewPeriodSeconds, + autoRenewAccountId: normalisedParams.autoRenewAccountId, + expirationTime: normalisedParams.expirationTime?.toISOString(), }; return { result: outputData }; } + + private async resolveKeys( + api: CoreApi, + tokenDefinition: FungibleTokenFileDefinition, + keyManager: KeyManager, + ): Promise<{ + treasury: ResolvedAccountCredential; + adminKey?: ResolvedPublicKey; + supplyKey?: ResolvedPublicKey; + wipeKey?: ResolvedPublicKey; + kycKey?: ResolvedPublicKey; + freezeKey?: ResolvedPublicKey; + pauseKey?: ResolvedPublicKey; + feeScheduleKey?: ResolvedPublicKey; + metadataKey?: ResolvedPublicKey; + keyRefIds: string[]; + }> { + const treasury = await api.keyResolver.resolveAccountCredentials( + tokenDefinition.treasuryKey, + keyManager, + false, + ['token:treasury'], + ); + const keyRefIds: string[] = [treasury.keyRefId]; + + const adminKey = await resolveOptionalKey( + tokenDefinition.adminKey, + keyManager, + api.keyResolver, + 'token:admin', + ); + if (adminKey) { + keyRefIds.push(adminKey.keyRefId); + api.logger.info('🔑 Resolved admin key for signing'); + } + + const supplyKey = await resolveOptionalKey( + tokenDefinition.supplyKey, + keyManager, + api.keyResolver, + 'token:supply', + ); + const wipeKey = await resolveOptionalKey( + tokenDefinition.wipeKey, + keyManager, + api.keyResolver, + 'token:wipe', + ); + const kycKey = await resolveOptionalKey( + tokenDefinition.kycKey, + keyManager, + api.keyResolver, + 'token:kyc', + ); + const freezeKey = await resolveOptionalKey( + tokenDefinition.freezeKey, + keyManager, + api.keyResolver, + 'token:freeze', + ); + if (tokenDefinition.freezeDefault && !freezeKey) { + api.logger.warn( + 'freezeDefault was requested but no freeze key is set; freeze default will be skipped.', + ); + } + const pauseKey = await resolveOptionalKey( + tokenDefinition.pauseKey, + keyManager, + api.keyResolver, + 'token:pause', + ); + const feeScheduleKey = await resolveOptionalKey( + tokenDefinition.feeScheduleKey, + keyManager, + api.keyResolver, + 'token:feeSchedule', + ); + const metadataKey = await resolveOptionalKey( + tokenDefinition.metadataKey, + keyManager, + api.keyResolver, + 'token:metadata', + ); + + return { + treasury, + adminKey, + supplyKey, + wipeKey, + kycKey, + freezeKey, + pauseKey, + feeScheduleKey, + metadataKey, + keyRefIds, + }; + } } export async function tokenCreateFtFromFile( diff --git a/src/plugins/token/commands/create-ft-from-file/output.ts b/src/plugins/token/commands/create-ft-from-file/output.ts index 3e9c1ffa4..fd9ca6549 100644 --- a/src/plugins/token/commands/create-ft-from-file/output.ts +++ b/src/plugins/token/commands/create-ft-from-file/output.ts @@ -5,6 +5,7 @@ import { z } from 'zod'; import { EntityIdSchema, + HederaAutoRenewPeriodSecondsOptionalSchema, NetworkSchema, SupplyTypeSchema, TransactionIdSchema, @@ -37,6 +38,16 @@ export const TokenCreateFtFromFileOutputSchema = z.object({ associations: z .array(TokenAssociationResultSchema) .describe('Fungible token associations created'), + autoRenewPeriodSeconds: HederaAutoRenewPeriodSecondsOptionalSchema.describe( + 'Auto-renew period in seconds when set (30–92 days)', + ), + autoRenewAccountId: EntityIdSchema.optional().describe( + 'Account paying auto-renewal when set', + ), + expirationTime: z + .string() + .optional() + .describe('Token expiration as ISO 8601 when fixed expiration was set'), }); export type TokenCreateFtFromFileOutput = z.infer< @@ -55,6 +66,15 @@ export const TOKEN_CREATE_FT_FROM_FILE_TEMPLATE = ` Supply Type: {{supplyType}} {{#if alias}} Alias: {{alias}} +{{/if}} +{{#if autoRenewPeriodSeconds}} + Auto-renew period: {{autoRenewPeriodSeconds}}s +{{/if}} +{{#if autoRenewAccountId}} + Auto-renew account: {{hashscanLink autoRenewAccountId "account" network}} +{{/if}} +{{#if expirationTime}} + Expiration: {{expirationTime}} {{/if}} Network: {{network}} Transaction ID: {{hashscanLink transactionId "transaction" network}} diff --git a/src/plugins/token/commands/create-ft-from-file/types.ts b/src/plugins/token/commands/create-ft-from-file/types.ts index 5525ee2ca..494d1da76 100644 --- a/src/plugins/token/commands/create-ft-from-file/types.ts +++ b/src/plugins/token/commands/create-ft-from-file/types.ts @@ -1,3 +1,4 @@ +import type { PublicKey } from '@hashgraph/sdk'; import type { BaseBuildTransactionResult, BaseNormalizedParams, @@ -32,13 +33,26 @@ export interface TokenCreateFtFromFileNormalizedParams extends BaseNormalizedPar keyManager: KeyManager; network: SupportedNetwork; treasury: ResolvedAccountCredential; - adminKey: ResolvedPublicKey; + adminKey?: ResolvedPublicKey; supplyKey?: ResolvedPublicKey; wipeKey?: ResolvedPublicKey; kycKey?: ResolvedPublicKey; freezeKey?: ResolvedPublicKey; pauseKey?: ResolvedPublicKey; feeScheduleKey?: ResolvedPublicKey; + metadataKey?: ResolvedPublicKey; + adminPublicKey?: PublicKey; + supplyPublicKey?: PublicKey; + wipePublicKey?: PublicKey; + kycPublicKey?: PublicKey; + freezePublicKey?: PublicKey; + pausePublicKey?: PublicKey; + feeSchedulePublicKey?: PublicKey; + metadataPublicKey?: PublicKey; + freezeDefault: boolean; + autoRenewPeriodSeconds?: number; + autoRenewAccountId?: string; + expirationTime?: Date; } export interface TokenCreateFtFromFileBuildTransactionResult extends BaseBuildTransactionResult {} diff --git a/src/plugins/token/commands/create-ft/handler.ts b/src/plugins/token/commands/create-ft/handler.ts index d96c667ba..2112a0163 100644 --- a/src/plugins/token/commands/create-ft/handler.ts +++ b/src/plugins/token/commands/create-ft/handler.ts @@ -1,4 +1,8 @@ -import type { CommandHandlerArgs, CommandResult } from '@/core'; +import type { CommandHandlerArgs, CommandResult, CoreApi } from '@/core'; +import type { + ResolvedAccountCredential, + ResolvedPublicKey, +} from '@/core/services/key-resolver/types'; import type { KeyManager } from '@/core/services/kms/kms-types.interface'; import type { TokenCreateFtOutput } from './output'; import type { @@ -8,10 +12,8 @@ import type { TokenCreateFtSignTransactionResult, } from './types'; -import { PublicKey } from '@hashgraph/sdk'; - import { BaseTransactionCommand } from '@/core/commands/command'; -import { StateError } from '@/core/errors'; +import { StateError, ValidationError } from '@/core/errors'; import { AliasType } from '@/core/services/alias/alias-service.interface'; import { HederaTokenType } from '@/core/shared/constants'; import { SupplyType } from '@/core/types/shared.types'; @@ -21,10 +23,13 @@ import { buildTokenData, determineFiniteMaxSupply, } from '@/plugins/token/utils/token-data-builders'; -import { resolveOptionalKey } from '@/plugins/token/utils/token-resolve-optional-key'; +import { + resolveOptionalKey, + toPublicKey, +} from '@/plugins/token/utils/token-key-resolver'; import { ZustandTokenStateHelper } from '@/plugins/token/zustand-state-helper'; -import { TokenCreateFtInputSchema } from './input'; +import { type TokenCreateFtInput, TokenCreateFtInputSchema } from './input'; export const TOKEN_CREATE_FT_COMMAND_NAME = 'token_create-ft'; @@ -42,7 +47,9 @@ export class TokenCreateFtCommand extends BaseTransactionCommand< args: CommandHandlerArgs, ): Promise { const { api, logger } = args; - const validArgs = TokenCreateFtInputSchema.parse(args.args); + const validArgs: TokenCreateFtInput = TokenCreateFtInputSchema.parse( + args.args, + ); const keyManager = validArgs.keyManager ?? @@ -58,26 +65,51 @@ export class TokenCreateFtCommand extends BaseTransactionCommand< api.alias.availableOrThrow(validArgs.name, network); - const treasury = await api.keyResolver.resolveAccountCredentials( - validArgs.treasury, - keyManager, - true, - ['token:treasury'], - ); + const { + treasury, + admin, + supply, + freeze, + wipe, + kyc, + pause, + feeSchedule, + metadata, + } = await this.resolveKeys(api, validArgs, keyManager); - const admin = await resolveOptionalKey( - validArgs.adminKey, - keyManager, - api.keyResolver, - 'token:admin', - ); + const autoRenewPeriodSeconds = validArgs.autoRenewPeriod; + const autoRenewAccountCredential = validArgs.autoRenewAccount + ? await api.keyResolver.resolveAccountCredentials( + validArgs.autoRenewAccount, + keyManager, + false, + ['token:auto-renew'], + ) + : undefined; - const supply = await resolveOptionalKey( - validArgs.supplyKey, - keyManager, - api.keyResolver, - 'token:supply', - ); + if (autoRenewPeriodSeconds && !autoRenewAccountCredential) { + throw new ValidationError( + 'Auto-renew account is required when auto-renew period is set', + { + context: { + autoRenewPeriodSeconds, + }, + }, + ); + } + + let expirationTime: Date | undefined = validArgs.expirationTime; + if ( + autoRenewPeriodSeconds !== undefined && + autoRenewAccountCredential !== undefined + ) { + if (expirationTime) { + logger.warn( + 'Expiration time is ignored because auto-renew period is set; auto-renew period takes precedence over fixed expiration.', + ); + } + expirationTime = undefined; + } let finalMaxSupply: bigint | undefined; if (validArgs.supplyType === SupplyType.FINITE) { @@ -117,7 +149,27 @@ export class TokenCreateFtCommand extends BaseTransactionCommand< treasury, admin, supply, + freeze, + wipe, + kyc, + pause, + feeSchedule, + metadata, + adminPublicKey: toPublicKey(admin), + supplyPublicKey: toPublicKey(supply), + wipePublicKey: toPublicKey(wipe), + kycPublicKey: toPublicKey(kyc), + freezePublicKey: toPublicKey(freeze), + pausePublicKey: toPublicKey(pause), + feeSchedulePublicKey: toPublicKey(feeSchedule), + metadataPublicKey: toPublicKey(metadata), + freezeDefault: validArgs.freezeDefault, finalMaxSupply, + autoRenewPeriodSeconds: autoRenewAccountCredential + ? autoRenewPeriodSeconds + : undefined, + autoRenewAccountId: autoRenewAccountCredential?.accountId, + expirationTime, keyRefIds: [treasury.keyRefId, ...adminKeyRefIds], }; } @@ -136,13 +188,21 @@ export class TokenCreateFtCommand extends BaseTransactionCommand< tokenType: normalisedParams.tokenType, supplyType: normalisedParams.supplyType, maxSupplyRaw: normalisedParams.finalMaxSupply, - adminPublicKey: normalisedParams.admin - ? PublicKey.fromString(normalisedParams.admin.publicKey) - : undefined, - supplyPublicKey: normalisedParams.supply - ? PublicKey.fromString(normalisedParams.supply.publicKey) + adminPublicKey: normalisedParams.adminPublicKey, + supplyPublicKey: normalisedParams.supplyPublicKey, + wipePublicKey: normalisedParams.wipePublicKey, + kycPublicKey: normalisedParams.kycPublicKey, + freezePublicKey: normalisedParams.freezePublicKey, + pausePublicKey: normalisedParams.pausePublicKey, + feeSchedulePublicKey: normalisedParams.feeSchedulePublicKey, + metadataPublicKey: normalisedParams.metadataPublicKey, + freezeDefault: normalisedParams.freeze + ? normalisedParams.freezeDefault : undefined, memo: normalisedParams.memo, + autoRenewPeriodSeconds: normalisedParams.autoRenewPeriodSeconds, + autoRenewAccountId: normalisedParams.autoRenewAccountId, + expirationTime: normalisedParams.expirationTime, }); return { transaction }; } @@ -210,7 +270,13 @@ export class TokenCreateFtCommand extends BaseTransactionCommand< supplyType: normalisedParams.supplyType, adminPublicKey: normalisedParams.admin?.publicKey, supplyPublicKey: normalisedParams.supply?.publicKey, - network: api.network.getCurrentNetwork(), + freezePublicKey: normalisedParams.freeze?.publicKey, + wipePublicKey: normalisedParams.wipe?.publicKey, + kycPublicKey: normalisedParams.kyc?.publicKey, + pausePublicKey: normalisedParams.pause?.publicKey, + feeSchedulePublicKey: normalisedParams.feeSchedule?.publicKey, + metadataPublicKey: normalisedParams.metadata?.publicKey, + network: normalisedParams.network, }); const key = composeKey(normalisedParams.network, result.tokenId!); @@ -239,10 +305,103 @@ export class TokenCreateFtCommand extends BaseTransactionCommand< transactionId: result.transactionId, alias: normalisedParams.alias, network: normalisedParams.network, + autoRenewPeriodSeconds: normalisedParams.autoRenewPeriodSeconds, + autoRenewAccountId: normalisedParams.autoRenewAccountId, + expirationTime: normalisedParams.expirationTime?.toISOString(), }; return { result: outputData }; } + + private async resolveKeys( + api: CoreApi, + validArgs: TokenCreateFtInput, + keyManager: KeyManager, + ): Promise<{ + treasury: ResolvedAccountCredential; + admin?: ResolvedPublicKey; + supply?: ResolvedPublicKey; + freeze?: ResolvedPublicKey; + wipe?: ResolvedPublicKey; + kyc?: ResolvedPublicKey; + pause?: ResolvedPublicKey; + feeSchedule?: ResolvedPublicKey; + metadata?: ResolvedPublicKey; + }> { + const treasury = await api.keyResolver.resolveAccountCredentials( + validArgs.treasury, + keyManager, + true, + ['token:treasury'], + ); + + const admin = await resolveOptionalKey( + validArgs.adminKey, + keyManager, + api.keyResolver, + 'token:admin', + ); + + const supply = await resolveOptionalKey( + validArgs.supplyKey, + keyManager, + api.keyResolver, + 'token:supply', + ); + const freeze = await resolveOptionalKey( + validArgs.freezeKey, + keyManager, + api.keyResolver, + 'token:freeze', + ); + if (validArgs.freezeDefault && !freeze) { + api.logger.warn( + 'freezeDefault was requested but no freeze key is set; freeze default will be skipped.', + ); + } + const wipe = await resolveOptionalKey( + validArgs.wipeKey, + keyManager, + api.keyResolver, + 'token:wipe', + ); + const kyc = await resolveOptionalKey( + validArgs.kycKey, + keyManager, + api.keyResolver, + 'token:kyc', + ); + const pause = await resolveOptionalKey( + validArgs.pauseKey, + keyManager, + api.keyResolver, + 'token:pause', + ); + const feeSchedule = await resolveOptionalKey( + validArgs.feeScheduleKey, + keyManager, + api.keyResolver, + 'token:feeSchedule', + ); + const metadata = await resolveOptionalKey( + validArgs.metadataKey, + keyManager, + api.keyResolver, + 'token:metadata', + ); + + return { + treasury, + admin, + supply, + freeze, + wipe, + kyc, + pause, + feeSchedule, + metadata, + }; + } } export async function tokenCreateFt( diff --git a/src/plugins/token/commands/create-ft/input.ts b/src/plugins/token/commands/create-ft/input.ts index 0b89f9fb0..2b23c8999 100644 --- a/src/plugins/token/commands/create-ft/input.ts +++ b/src/plugins/token/commands/create-ft/input.ts @@ -2,12 +2,15 @@ import { z } from 'zod'; import { AmountInputSchema, + AutoRenewPeriodSecondsSchema, + ExpirationTimeSchema, HtsDecimalsSchema, KeyManagerTypeSchema, KeySchema, MemoSchema, SupplyTypeSchema, TokenAliasNameSchema, + TokenFreezeDefaultSchema, TokenNameSchema, TokenSymbolSchema, } from '@/core/schemas'; @@ -46,6 +49,23 @@ export const TokenCreateFtInputSchema = z supplyKey: KeySchema.optional().describe( 'Supply key. Accepts any key format.', ), + freezeKey: KeySchema.optional().describe( + 'Freeze key. Accepts any key format.', + ), + freezeDefault: TokenFreezeDefaultSchema.describe( + 'When true and a freeze key is set, new token associations are frozen by default. Ignored without a freeze key.', + ), + wipeKey: KeySchema.optional().describe('Wipe key. Accepts any key format.'), + kycKey: KeySchema.optional().describe('KYC key. Accepts any key format.'), + pauseKey: KeySchema.optional().describe( + 'Pause key. Accepts any key format.', + ), + feeScheduleKey: KeySchema.optional().describe( + 'Fee schedule key. Accepts any key format.', + ), + metadataKey: KeySchema.optional().describe( + 'Metadata key. Accepts any key format.', + ), name: TokenAliasNameSchema.optional().describe( 'Optional alias to register for the token', ), @@ -55,7 +75,17 @@ export const TokenCreateFtInputSchema = z memo: MemoSchema.describe( 'Optional memo for the token (max 100 characters)', ), + autoRenewPeriod: AutoRenewPeriodSecondsSchema.describe( + 'Auto-renew period: integer seconds, or with suffix s/m/h/d (e.g. 500, 500s, 50m, 2h, 1d). Requires auto renew account property.', + ), + autoRenewAccount: KeySchema.optional().describe( + 'Account that pays auto-renewal. Required when auto renew period property is set.', + ), + expirationTime: ExpirationTimeSchema.describe( + 'Absolute token expiration (ISO 8601), must be after the current time. Ignored when auto-renew period and account are set.', + ), }) .superRefine(validateSupplyTypeAndMaxSupply); -export type TokenCreateFtInput = z.infer; +/** Parsed CLI args (after transforms); use with `TokenCreateFtInputSchema.parse`. */ +export type TokenCreateFtInput = z.output; diff --git a/src/plugins/token/commands/create-ft/output.ts b/src/plugins/token/commands/create-ft/output.ts index a0c8f44da..b3f813f44 100644 --- a/src/plugins/token/commands/create-ft/output.ts +++ b/src/plugins/token/commands/create-ft/output.ts @@ -5,6 +5,7 @@ import { z } from 'zod'; import { EntityIdSchema, + HederaAutoRenewPeriodSecondsOptionalSchema, NetworkSchema, SupplyTypeSchema, TransactionIdSchema, @@ -24,6 +25,16 @@ export const TokenCreateFtOutputSchema = z.object({ transactionId: TransactionIdSchema, alias: z.string().describe('Fungible token alias').optional(), network: NetworkSchema, + autoRenewPeriodSeconds: HederaAutoRenewPeriodSecondsOptionalSchema.describe( + 'Auto-renew period in seconds when set (30–92 days)', + ), + autoRenewAccountId: EntityIdSchema.optional().describe( + 'Account paying auto-renewal when set', + ), + expirationTime: z + .string() + .optional() + .describe('Token expiration as ISO 8601 when fixed expiration was set'), }); export type TokenCreateFtOutput = z.infer; @@ -40,6 +51,15 @@ export const TOKEN_CREATE_FT_TEMPLATE = ` Supply Type: {{supplyType}} {{#if alias}} Alias: {{alias}} +{{/if}} +{{#if autoRenewPeriodSeconds}} + Auto-renew period: {{autoRenewPeriodSeconds}}s +{{/if}} +{{#if autoRenewAccountId}} + Auto-renew account: {{hashscanLink autoRenewAccountId "account" network}} +{{/if}} +{{#if expirationTime}} + Expiration: {{expirationTime}} {{/if}} Network: {{network}} Transaction ID: {{hashscanLink transactionId "transaction" network}} diff --git a/src/plugins/token/commands/create-ft/types.ts b/src/plugins/token/commands/create-ft/types.ts index b5e712ec1..2173a4693 100644 --- a/src/plugins/token/commands/create-ft/types.ts +++ b/src/plugins/token/commands/create-ft/types.ts @@ -1,3 +1,4 @@ +import type { PublicKey } from '@hashgraph/sdk'; import type { BaseBuildTransactionResult, BaseNormalizedParams, @@ -26,7 +27,25 @@ export interface TokenCreateFtNormalizedParams extends BaseNormalizedParams { treasury: ResolvedAccountCredential; admin?: ResolvedPublicKey; supply?: ResolvedPublicKey; + freeze?: ResolvedPublicKey; + wipe?: ResolvedPublicKey; + kyc?: ResolvedPublicKey; + pause?: ResolvedPublicKey; + feeSchedule?: ResolvedPublicKey; + metadata?: ResolvedPublicKey; + adminPublicKey?: PublicKey; + supplyPublicKey?: PublicKey; + wipePublicKey?: PublicKey; + kycPublicKey?: PublicKey; + freezePublicKey?: PublicKey; + pausePublicKey?: PublicKey; + feeSchedulePublicKey?: PublicKey; + metadataPublicKey?: PublicKey; + freezeDefault: boolean; finalMaxSupply?: bigint; + autoRenewPeriodSeconds?: number; + autoRenewAccountId?: string; + expirationTime?: Date; } export interface TokenCreateFtBuildTransactionResult extends BaseBuildTransactionResult {} diff --git a/src/plugins/token/commands/create-nft-from-file/handler.ts b/src/plugins/token/commands/create-nft-from-file/handler.ts index a7ae1574f..3f3991834 100644 --- a/src/plugins/token/commands/create-nft-from-file/handler.ts +++ b/src/plugins/token/commands/create-nft-from-file/handler.ts @@ -23,7 +23,7 @@ import { readAndValidateNftTokenFile } from '@/plugins/token/utils/token-file-he import { resolveOptionalKey, toPublicKey, -} from '@/plugins/token/utils/token-resolve-optional-key'; +} from '@/plugins/token/utils/token-key-resolver'; import { ZustandTokenStateHelper } from '@/plugins/token/zustand-state-helper'; import { TokenCreateNftFromFileInputSchema } from './input'; diff --git a/src/plugins/token/commands/create-nft/handler.ts b/src/plugins/token/commands/create-nft/handler.ts index c63c079e8..51e69f539 100644 --- a/src/plugins/token/commands/create-nft/handler.ts +++ b/src/plugins/token/commands/create-nft/handler.ts @@ -25,7 +25,7 @@ import { import { resolveOptionalKey, toPublicKey, -} from '@/plugins/token/utils/token-resolve-optional-key'; +} from '@/plugins/token/utils/token-key-resolver'; import { ZustandTokenStateHelper } from '@/plugins/token/zustand-state-helper'; export const TOKEN_CREATE_NFT_COMMAND_NAME = 'token_create-nft'; diff --git a/src/plugins/token/commands/import/handler.ts b/src/plugins/token/commands/import/handler.ts index 609d75129..bfeafb323 100644 --- a/src/plugins/token/commands/import/handler.ts +++ b/src/plugins/token/commands/import/handler.ts @@ -67,6 +67,7 @@ export class TokenImportCommand implements Command { freezePublicKey: tokenInfo.freeze_key?.key, pausePublicKey: tokenInfo.pause_key?.key, feeSchedulePublicKey: tokenInfo.fee_schedule_key?.key, + metadataPublicKey: tokenInfo.metadata_key?.key, decimals: parseInt(tokenInfo.decimals, 10) || 0, initialSupply: BigInt(tokenInfo.total_supply), tokenType, diff --git a/src/plugins/token/commands/view/handler.ts b/src/plugins/token/commands/view/handler.ts index c01cbffac..7e0030c48 100644 --- a/src/plugins/token/commands/view/handler.ts +++ b/src/plugins/token/commands/view/handler.ts @@ -6,7 +6,7 @@ import type { ViewTokenNormalizedParams } from './types'; import { NotFoundError, ValidationError } from '@/core/errors'; import { resolveTokenParameter } from '@/plugins/token/resolver-helper'; -import { buildOutput } from '@/plugins/token/utils/nft-build-output'; +import { tokenBuildOutput } from '@/plugins/token/utils/token-build-output'; import { TokenViewInputSchema } from './input'; @@ -48,7 +48,11 @@ export class TokenViewCommand implements Command { nftInfo = await api.mirror.getNftInfo(tokenId, serialNum); } - const output: TokenViewOutput = buildOutput(tokenInfo, nftInfo, network); + const output: TokenViewOutput = tokenBuildOutput( + tokenInfo, + nftInfo, + network, + ); return { result: output }; } } diff --git a/src/plugins/token/commands/view/output.ts b/src/plugins/token/commands/view/output.ts index ddd150137..825e4454b 100644 --- a/src/plugins/token/commands/view/output.ts +++ b/src/plugins/token/commands/view/output.ts @@ -5,6 +5,7 @@ import { z } from 'zod'; import { EntityIdSchema, + HederaAutoRenewPeriodSecondsOptionalSchema, NetworkSchema, PublicKeyDefinitionSchema, SupplyTypeSchema, @@ -34,6 +35,11 @@ export const TokenViewOutputSchema = z.object({ memo: z.string().optional(), createdTimestamp: z.string().optional(), + freezeDefault: z.boolean(), + autoRenewPeriodSeconds: HederaAutoRenewPeriodSecondsOptionalSchema, + autoRenewAccountId: EntityIdSchema.optional(), + expirationTime: z.string().optional(), + // === Token keys === adminKey: PublicKeyDefinitionSchema.nullable().optional(), supplyKey: PublicKeyDefinitionSchema.nullable().optional(), @@ -75,6 +81,16 @@ export const TOKEN_VIEW_TEMPLATE = ` {{#if treasury}} Treasury: {{hashscanLink treasury "account" network}} {{/if}} + Freeze default: {{freezeDefault}} +{{#if autoRenewPeriodSeconds}} + Auto-renew period: {{autoRenewPeriodSeconds}}s +{{/if}} +{{#if autoRenewAccountId}} + Auto-renew account: {{hashscanLink autoRenewAccountId "account" network}} +{{/if}} +{{#if expirationTime}} + Expiration: {{expirationTime}} +{{/if}} NFT Details: @@ -99,7 +115,7 @@ export const TOKEN_VIEW_TEMPLATE = ` ID: {{hashscanLink tokenId "token" network}} Name: {{name}} Symbol: {{symbol}} - + Freeze default: {{freezeDefault}} {{#if (eq type "NON_FUNGIBLE_UNIQUE")}} Current Supply: {{totalSupply}} {{#if (eq supplyType "FINITE")}} @@ -117,6 +133,15 @@ export const TOKEN_VIEW_TEMPLATE = ` {{#if treasury}} Treasury: {{hashscanLink treasury "account" network}} {{/if~}} +{{#if autoRenewPeriodSeconds}} + Auto-renew period: {{autoRenewPeriodSeconds}}s +{{/if}} +{{#if autoRenewAccountId}} + Auto-renew account: {{hashscanLink autoRenewAccountId "account" network}} +{{/if}} +{{#if expirationTime}} + Expiration: {{expirationTime}} +{{/if}} {{#if adminKey}} Admin Key: {{adminKey}} {{/if~}} diff --git a/src/plugins/token/hooks/batch-create-ft-from-file/types.ts b/src/plugins/token/hooks/batch-create-ft-from-file/types.ts index cc097f50c..dc7d0848b 100644 --- a/src/plugins/token/hooks/batch-create-ft-from-file/types.ts +++ b/src/plugins/token/hooks/batch-create-ft-from-file/types.ts @@ -9,6 +9,7 @@ import { ResolvedPublicKeySchema, SupplyTypeSchema, TinybarSchema, + TokenFreezeDefaultSchema, TokenNameSchema, TokenSymbolSchema, TokenTypeSchema, @@ -35,11 +36,16 @@ export const CreateFtFromFileNormalizedParamsSchema = z.object({ keyManager: keyManagerNameSchema, network: NetworkSchema, treasury: ResolvedAccountCredentialSchema, - adminKey: ResolvedPublicKeySchema, + adminKey: ResolvedPublicKeySchema.optional(), supplyKey: ResolvedPublicKeySchema.optional(), wipeKey: ResolvedPublicKeySchema.optional(), kycKey: ResolvedPublicKeySchema.optional(), freezeKey: ResolvedPublicKeySchema.optional(), pauseKey: ResolvedPublicKeySchema.optional(), feeScheduleKey: ResolvedPublicKeySchema.optional(), + metadataKey: ResolvedPublicKeySchema.optional(), + freezeDefault: TokenFreezeDefaultSchema, + autoRenewPeriodSeconds: z.number().optional(), + autoRenewAccountId: z.string().optional(), + expirationTime: z.coerce.date().optional(), }); diff --git a/src/plugins/token/hooks/batch-create-ft/handler.ts b/src/plugins/token/hooks/batch-create-ft/handler.ts index 63cb09ebe..16327f839 100644 --- a/src/plugins/token/hooks/batch-create-ft/handler.ts +++ b/src/plugins/token/hooks/batch-create-ft/handler.ts @@ -87,8 +87,14 @@ export class TokenCreateFtBatchStateHook extends AbstractHook { initialSupply: normalisedParams.initialSupply, tokenType: normalisedParams.tokenType, supplyType: normalisedParams.supplyType, - adminPublicKey: normalisedParams.admin.publicKey, + adminPublicKey: normalisedParams.admin?.publicKey, supplyPublicKey: normalisedParams.supply?.publicKey, + freezePublicKey: normalisedParams.freeze?.publicKey, + wipePublicKey: normalisedParams.wipe?.publicKey, + kycPublicKey: normalisedParams.kyc?.publicKey, + pausePublicKey: normalisedParams.pause?.publicKey, + feeSchedulePublicKey: normalisedParams.feeSchedule?.publicKey, + metadataPublicKey: normalisedParams.metadata?.publicKey, network: normalisedParams.network, }); diff --git a/src/plugins/token/hooks/batch-create-ft/types.ts b/src/plugins/token/hooks/batch-create-ft/types.ts index b4274d069..652a8b1eb 100644 --- a/src/plugins/token/hooks/batch-create-ft/types.ts +++ b/src/plugins/token/hooks/batch-create-ft/types.ts @@ -6,6 +6,7 @@ import { ResolvedPublicKeySchema, SupplyTypeSchema, TinybarSchema, + TokenFreezeDefaultSchema, } from '@/core/schemas/common-schemas'; import { keyManagerNameSchema } from '@/core/services/kms/kms-types.interface'; import { HederaTokenType } from '@/core/shared/constants'; @@ -18,6 +19,7 @@ export const CreateFtNormalizedParamsSchema = z.object({ supplyType: SupplyTypeSchema, alias: z.string().optional(), memo: z.string().optional(), + finalMaxSupply: TinybarSchema.optional(), tokenType: z.enum([ HederaTokenType.NON_FUNGIBLE_TOKEN, HederaTokenType.FUNGIBLE_COMMON, @@ -25,6 +27,16 @@ export const CreateFtNormalizedParamsSchema = z.object({ network: NetworkSchema, keyManager: keyManagerNameSchema, treasury: ResolvedAccountCredentialSchema, - admin: ResolvedAccountCredentialSchema, + admin: ResolvedPublicKeySchema.optional(), supply: ResolvedPublicKeySchema.optional(), + freeze: ResolvedPublicKeySchema.optional(), + wipe: ResolvedPublicKeySchema.optional(), + kyc: ResolvedPublicKeySchema.optional(), + pause: ResolvedPublicKeySchema.optional(), + feeSchedule: ResolvedPublicKeySchema.optional(), + metadata: ResolvedPublicKeySchema.optional(), + freezeDefault: TokenFreezeDefaultSchema, + autoRenewPeriodSeconds: z.number().optional(), + autoRenewAccountId: z.string().optional(), + expirationTime: z.coerce.date().optional(), }); diff --git a/src/plugins/token/manifest.ts b/src/plugins/token/manifest.ts index 917ea7e04..b20b54228 100644 --- a/src/plugins/token/manifest.ts +++ b/src/plugins/token/manifest.ts @@ -367,7 +367,7 @@ export const tokenPluginManifest: PluginManifest = { type: OptionType.STRING, required: false, description: - 'Admin key of token. Can be {accountId}:{privateKey} pair, account private key in {ed25519|ecdsa}:private:{private-key} format, key reference or account alias. Defaults to operator key.', + 'Optional admin key. Can be {accountId}:{privateKey} pair, account private key in {ed25519|ecdsa}:private:{private-key} format, key reference or account alias. Omit for a token without an admin key.', }, { name: 'supply-key', @@ -377,6 +377,63 @@ export const tokenPluginManifest: PluginManifest = { description: 'Supply key of token. Can be {accountId}:{privateKey} pair, account ID, account public key in {ed25519|ecdsa}:public:{public-key} format, account private key in {ed25519|ecdsa}:private:{private-key} format, key reference or account alias.', }, + { + name: 'freeze-key', + short: 'f', + type: OptionType.STRING, + required: false, + description: + 'Optional freeze key. Can be {accountId}:{privateKey} pair, account ID, account public key in {ed25519|ecdsa}:public:{public-key} format, account private key in {ed25519|ecdsa}:private:{private-key} format, key reference or account alias.', + }, + { + name: 'freeze-default', + short: 'F', + type: OptionType.BOOLEAN, + required: false, + default: false, + description: + 'When true and a freeze key is set, new token associations are frozen by default. Ignored without a freeze key (a warning is logged).', + }, + { + name: 'wipe-key', + short: 'w', + type: OptionType.STRING, + required: false, + description: + 'Optional wipe key. Can be {accountId}:{privateKey} pair, account ID, account public key in {ed25519|ecdsa}:public:{public-key} format, account private key in {ed25519|ecdsa}:private:{private-key} format, key reference or account alias.', + }, + { + name: 'kyc-key', + short: 'y', + type: OptionType.STRING, + required: false, + description: + 'Optional KYC key. Can be {accountId}:{privateKey} pair, account ID, account public key in {ed25519|ecdsa}:public:{public-key} format, account private key in {ed25519|ecdsa}:private:{private-key} format, key reference or account alias.', + }, + { + name: 'pause-key', + short: 'p', + type: OptionType.STRING, + required: false, + description: + 'Optional pause key. Can be {accountId}:{privateKey} pair, account ID, account public key in {ed25519|ecdsa}:public:{public-key} format, account private key in {ed25519|ecdsa}:private:{private-key} format, key reference or account alias.', + }, + { + name: 'fee-schedule-key', + short: 'e', + type: OptionType.STRING, + required: false, + description: + 'Optional fee schedule key. Can be {accountId}:{privateKey} pair, account ID, account public key in {ed25519|ecdsa}:public:{public-key} format, account private key in {ed25519|ecdsa}:private:{private-key} format, key reference or account alias.', + }, + { + name: 'metadata-key', + short: 'D', + type: OptionType.STRING, + required: false, + description: + 'Optional metadata key. Can be {accountId}:{privateKey} pair, account ID, account public key in {ed25519|ecdsa}:public:{public-key} format, account private key in {ed25519|ecdsa}:private:{private-key} format, key reference or account alias.', + }, { name: 'name', short: 'n', @@ -400,6 +457,30 @@ export const tokenPluginManifest: PluginManifest = { description: 'Optional memo for the fungible token (max 100 characters)', }, + { + name: 'auto-renew-period', + short: 'R', + type: OptionType.STRING, + required: false, + description: + 'Auto-renew period: seconds as integer, or with suffix s/m/h/d (e.g. 500, 500s, 50m, 2h, 1d). Requires --auto-renew-account.', + }, + { + name: 'auto-renew-account', + short: 'r', + type: OptionType.STRING, + required: false, + description: + 'Account that pays auto-renewal (account id, alias, or key reference). Required when --auto-renew-period is set.', + }, + { + name: 'expiration-time', + short: 'x', + type: OptionType.STRING, + required: false, + description: + 'Absolute token expiration as ISO 8601 datetime. Ignored when --auto-renew-period and --auto-renew-account are set.', + }, ], handler: tokenCreateFt, output: { diff --git a/src/plugins/token/schema.ts b/src/plugins/token/schema.ts index 3de58b0c5..e0913c795 100644 --- a/src/plugins/token/schema.ts +++ b/src/plugins/token/schema.ts @@ -6,7 +6,9 @@ import { z } from 'zod'; import { AmountInputSchema, + AutoRenewPeriodSecondsSchema, EntityIdSchema, + ExpirationTimeSchema, HtsDecimalsSchema, KeySchema, MemoSchema, @@ -99,6 +101,7 @@ export const TokenDataSchema = z.object({ freezePublicKey: z.string().optional(), pausePublicKey: z.string().optional(), feeSchedulePublicKey: z.string().optional(), + metadataPublicKey: z.string().optional(), decimals: z .number() @@ -184,13 +187,20 @@ export const FungibleTokenFileSchema = z initialSupply: AmountInputSchema, maxSupply: AmountInputSchema.default('0'), treasuryKey: KeySchema, - adminKey: KeySchema, + adminKey: KeySchema.optional(), supplyKey: KeySchema.optional(), wipeKey: KeySchema.optional(), kycKey: KeySchema.optional(), freezeKey: KeySchema.optional(), + freezeDefault: z + .boolean() + .default(false) + .describe( + 'When true and freezeKey is set, new associations are frozen by default.', + ), pauseKey: KeySchema.optional(), feeScheduleKey: KeySchema.optional(), + metadataKey: KeySchema.optional(), associations: z.array(KeySchema).default([]), customFees: z .array(TokenFileCustomFeeSchema) @@ -198,6 +208,9 @@ export const FungibleTokenFileSchema = z .default([]), memo: MemoSchema.default(''), tokenType: TokenTypeSchema, + autoRenewPeriod: AutoRenewPeriodSecondsSchema, + autoRenewAccount: KeySchema.optional(), + expirationTime: ExpirationTimeSchema, }) .transform((data) => ({ ...data, diff --git a/src/plugins/token/utils/nft-build-output.ts b/src/plugins/token/utils/token-build-output.ts similarity index 83% rename from src/plugins/token/utils/nft-build-output.ts rename to src/plugins/token/utils/token-build-output.ts index 299cd69e6..0c0bd1261 100644 --- a/src/plugins/token/utils/nft-build-output.ts +++ b/src/plugins/token/utils/token-build-output.ts @@ -44,10 +44,16 @@ function formatHederaTimestamp(timestamp?: string): string | undefined { return date.toISOString().replace('T', ' ').substring(0, 19); } +/** Converts Mirror Node `expiry_timestamp` (nanoseconds since epoch) to ISO 8601. */ +function expiryTimestampToIso(expiry?: number | null): string | undefined { + if (!expiry || !Number.isFinite(expiry) || expiry === 0) return undefined; + return new Date(expiry / 1e6).toISOString(); +} + /** * Build output object based on token type and mode */ -export function buildOutput( +export function tokenBuildOutput( tokenInfo: TokenInfo, nftInfo: NftInfo | null, network: SupportedNetwork, @@ -71,6 +77,10 @@ export function buildOutput( createdTimestamp: formatHederaTimestamp(tokenInfo.created_timestamp), adminKey: tokenInfo.admin_key?.key || null, supplyKey: tokenInfo.supply_key?.key || null, + freezeDefault: tokenInfo.freeze_default ?? false, + autoRenewPeriodSeconds: tokenInfo.auto_renew_period, + autoRenewAccountId: tokenInfo.auto_renew_account || undefined, + expirationTime: expiryTimestampToIso(tokenInfo.expiry_timestamp), }; // Add decimals only for Fungible Tokens diff --git a/src/plugins/token/utils/token-data-builders.ts b/src/plugins/token/utils/token-data-builders.ts index d6c410315..9b6fbb297 100644 --- a/src/plugins/token/utils/token-data-builders.ts +++ b/src/plugins/token/utils/token-data-builders.ts @@ -21,6 +21,12 @@ export function buildTokenData( supplyType: string; adminPublicKey?: string; supplyPublicKey?: string; + wipePublicKey?: string; + kycPublicKey?: string; + freezePublicKey?: string; + pausePublicKey?: string; + feeSchedulePublicKey?: string; + metadataPublicKey?: string; network: SupportedNetwork; }, ): TokenData { @@ -38,7 +44,13 @@ export function buildTokenData( ? params.initialSupply : 0n, adminPublicKey: params.adminPublicKey, - supplyPublicKey: params?.supplyPublicKey, + supplyPublicKey: params.supplyPublicKey, + wipePublicKey: params.wipePublicKey, + kycPublicKey: params.kycPublicKey, + freezePublicKey: params.freezePublicKey, + pausePublicKey: params.pausePublicKey, + feeSchedulePublicKey: params.feeSchedulePublicKey, + metadataPublicKey: params.metadataPublicKey, network: params.network, associations: [], customFees: [], @@ -52,6 +64,7 @@ export interface TokenKeyOptions { freezePublicKey?: string; pausePublicKey?: string; feeSchedulePublicKey?: string; + metadataPublicKey?: string; } export function buildTokenDataFromFile( @@ -63,13 +76,14 @@ export function buildTokenDataFromFile( name: normalisedParams.name, symbol: normalisedParams.symbol, treasuryId: normalisedParams.treasury.accountId, - adminPublicKey: normalisedParams.adminKey.publicKey, + adminPublicKey: normalisedParams.adminKey?.publicKey, supplyPublicKey: normalisedParams.supplyKey?.publicKey, wipePublicKey: normalisedParams.wipeKey?.publicKey, kycPublicKey: normalisedParams.kycKey?.publicKey, freezePublicKey: normalisedParams.freezeKey?.publicKey, pausePublicKey: normalisedParams.pauseKey?.publicKey, feeSchedulePublicKey: normalisedParams.feeScheduleKey?.publicKey, + metadataPublicKey: normalisedParams.metadataKey?.publicKey, decimals: normalisedParams.decimals, initialSupply: normalisedParams.initialSupply, tokenType: normalisedParams.tokenType, diff --git a/src/plugins/token/utils/token-resolve-optional-key.ts b/src/plugins/token/utils/token-key-resolver.ts similarity index 100% rename from src/plugins/token/utils/token-resolve-optional-key.ts rename to src/plugins/token/utils/token-key-resolver.ts