Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
841 changes: 841 additions & 0 deletions docs/adr/ADR-011-schedule-transaction-plugin.md

Large diffs are not rendered by default.

13 changes: 13 additions & 0 deletions src/__tests__/mocks/mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import type { NetworkService } from '@/core/services/network/network-service.int
import type { OutputService } from '@/core/services/output/output-service.interface';
import type { OutputHandlerOptions } from '@/core/services/output/types';
import type { PluginManagementService } from '@/core/services/plugin-management/plugin-management-service.interface';
import type { ScheduleTransactionService } from '@/core/services/schedule-transaction/schedule-transaction-service.interface';
import type { StateService } from '@/core/services/state/state-service.interface';
import type { TxExecuteService } from '@/core/services/tx-execute/tx-execute-service.interface';
import type { TxSignService } from '@/core/services/tx-sign/tx-sign-service.interface';
Expand Down Expand Up @@ -346,6 +347,7 @@ export const createMirrorNodeMock =
getTopicMessage: jest.fn(),
getTopicMessages: jest.fn(),
getTokenInfo: jest.fn(),
getScheduled: jest.fn(),
getNftInfo: jest.fn(),
getTopicInfo: jest.fn(),
getTransactionRecord: jest.fn(),
Expand Down Expand Up @@ -496,6 +498,14 @@ const makeContractTransactionServiceMock = (): ContractTransactionService =>
deleteContract: jest.fn(),
}) as unknown as ContractTransactionService;

export const makeScheduleTransactionServiceMock =
(): jest.Mocked<ScheduleTransactionService> =>
({
buildScheduleCreateTransaction: jest.fn(),
buildScheduleSignTransaction: jest.fn(),
buildScheduleDeleteTransaction: jest.fn(),
}) as unknown as jest.Mocked<ScheduleTransactionService>;

const makeContractVerifierServiceMock = (): ContractVerifierService =>
({
verifyContract: jest.fn(),
Expand Down Expand Up @@ -534,6 +544,7 @@ export const makeArgs = (
const contractQuery = api.contractQuery || makeContractQueryServiceMock();
const identityResolution =
api.identityResolution || makeIdentityResolutionServiceMock();
const schedule = api.schedule || makeScheduleTransactionServiceMock();

const restApi = api;

Expand Down Expand Up @@ -572,6 +583,7 @@ export const makeArgs = (
getTopicMessage: jest.fn(),
getTopicMessages: jest.fn(),
getTokenInfo: jest.fn(),
getScheduled: jest.fn(),
getNftInfo: jest.fn(),
getTopicInfo: jest.fn(),
getTransactionRecord: jest.fn(),
Expand All @@ -595,6 +607,7 @@ export const makeArgs = (
keyResolver: makeKeyResolverMock({ network, alias, kms }),
contractQuery,
identityResolution,
schedule,
...restApi,
} as unknown as CoreApi;

Expand Down
5 changes: 5 additions & 0 deletions src/core/core-api/core-api.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import type { NetworkService } from '@/core/services/network/network-service.int
import type { OutputService } from '@/core/services/output/output-service.interface';
import type { PluginManagementService } from '@/core/services/plugin-management/plugin-management-service.interface';
import type { ReceiptService } from '@/core/services/receipt/receipt-service.interface';
import type { ScheduleTransactionService } from '@/core/services/schedule-transaction/schedule-transaction-service.interface';
import type { StateService } from '@/core/services/state/state-service.interface';
import type { TokenService } from '@/core/services/token/token-service.interface';
import type { TopicService } from '@/core/services/topic/topic-transaction-service.interface';
Expand Down Expand Up @@ -112,5 +113,9 @@ export interface CoreApi {
contractQuery: ContractQueryService;
identityResolution: IdentityResolutionService;
batch: BatchTransactionService;
/**
* Build ScheduleCreate / ScheduleSign / ScheduleDelete transactions (SDK builders).
*/
schedule: ScheduleTransactionService;
receipt: ReceiptService;
}
4 changes: 4 additions & 0 deletions src/core/core-api/core-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import type { NetworkService } from '@/core/services/network/network-service.int
import type { OutputService } from '@/core/services/output/output-service.interface';
import type { PluginManagementService } from '@/core/services/plugin-management/plugin-management-service.interface';
import type { ReceiptService } from '@/core/services/receipt/receipt-service.interface';
import type { ScheduleTransactionService } from '@/core/services/schedule-transaction/schedule-transaction-service.interface';
import type { StateService } from '@/core/services/state/state-service.interface';
import type { TokenService } from '@/core/services/token/token-service.interface';
import type { TopicService } from '@/core/services/topic/topic-transaction-service.interface';
Expand All @@ -48,6 +49,7 @@ import { NetworkServiceImpl } from '@/core/services/network/network-service';
import { OutputServiceImpl } from '@/core/services/output/output-service';
import { PluginManagementServiceImpl } from '@/core/services/plugin-management/plugin-management-service';
import { ReceiptServiceImpl } from '@/core/services/receipt/receipt-service';
import { ScheduleTransactionServiceImpl } from '@/core/services/schedule-transaction/schedule-transaction-service';
import { ZustandGenericStateServiceImpl } from '@/core/services/state/state-service';
import { TokenServiceImpl } from '@/core/services/token/token-service';
import { TopicServiceImpl } from '@/core/services/topic/topic-transaction-service';
Expand Down Expand Up @@ -77,6 +79,7 @@ export class CoreApiImplementation implements CoreApi {
public contractQuery: ContractQueryService;
public identityResolution: IdentityResolutionService;
public batch: BatchTransactionService;
public schedule: ScheduleTransactionService;
public receipt: ReceiptService;

constructor(storageDir?: string) {
Expand Down Expand Up @@ -136,6 +139,7 @@ export class CoreApiImplementation implements CoreApi {
this.mirror,
);
this.batch = new BatchTransactionServiceImpl(this.logger);
this.schedule = new ScheduleTransactionServiceImpl(this.logger);
this.receipt = new ReceiptServiceImpl(this.logger, this.network);
}
}
Expand Down
1 change: 1 addition & 0 deletions src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export type * from './services/mirrornode/hedera-mirrornode-service.interface';
export type { NetworkService } from './services/network/network-service.interface';
export type * from './services/output/output-service.interface';
export type * from './services/plugin-management/plugin-management-service.interface';
export type * from './services/schedule-transaction/schedule-transaction-service.interface';
export type * from './services/state/state-service.interface';
export type * from './services/token/token-service.interface';
export type * from './services/topic/topic-transaction-service.interface';
Expand Down
48 changes: 48 additions & 0 deletions src/core/schemas/common-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
HEDERA_AUTO_RENEW_PERIOD_MAX,
HEDERA_AUTO_RENEW_PERIOD_MIN,
HEDERA_EXPIRATION_TIME_MAX,
HEDERA_SCHEDULE_EXPIRATION_MAX,
HederaTokenType,
KeyAlgorithm,
} from '@/core/shared/constants';
Expand Down Expand Up @@ -410,6 +411,26 @@ export const TokenReferenceObjectSchema = z
})
.describe('Token identifier (ID or alias)');

/**
* Parsed token reference as a discriminated object by type (entity ID or alias).
*/
export const ScheduleReferenceObjectSchema = z
.string()
.trim()
.min(1, 'Schedule identifier cannot be empty')
.transform((val): { type: EntityReferenceType; value: string } => {
if (EntityIdSchema.safeParse(val).success) {
return { type: EntityReferenceType.ENTITY_ID, value: val };
}
if (AliasNameSchema.safeParse(val).success) {
return { type: EntityReferenceType.ALIAS, value: val };
}
throw new ValidationError(
'Schedule reference must be a valid Hedera ID (0.0.xxx) or alias name',
);
})
.describe('Schedule identifier (ID or alias)');

/**
* Account Reference Input (ID or Name)
* Extended schema for referencing accounts specifically
Expand Down Expand Up @@ -1037,3 +1058,30 @@ export const ExpirationTimeSchema: z.ZodType<Date | undefined> = z.coerce
message: 'Expiration time must be set in 92 days period.',
},
);

export const ScheduleExpirationSchema: z.ZodType<Date | undefined> = 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_SCHEDULE_EXPIRATION_MAX).getTime()),
{
message: 'Expiration time must be set in 62 days period.',
},
)
.describe('Expiration time (ISO 8601). Max 62 days from now.');

export const WaitForExpirySchema = z
.boolean()
.optional()
.default(false)
.describe(
'If true, execute at expiration instead of when signatures are complete.',
);
13 changes: 0 additions & 13 deletions src/core/services/kms/kms-types.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,16 +125,3 @@ export type Credential =
| KeyReferenceCredential
| AliasCredential
| EvmAddressCredential;

/**
* Key resolution - explicit keypair or alias reference
*/
export type KeyOrAccountAlias = KeypairCredential | AliasCredential;

/**
* Parsed "accountId=privateKey" format
*/
export type AccountIdWithPrivateKey = {
accountId: string;
privateKey: string;
};
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
createMockAccountListItemAPIResponse,
createMockExchangeRateResponse,
createMockGetAccountsAPIResponse,
createMockMirrorNodeScheduleByIdJson,
createMockMirrorNodeTokenByIdJson,
createMockNftInfo,
createMockTokenAirdropsResponse,
Expand All @@ -45,6 +46,7 @@ const TEST_ACCOUNT_ID = '0.0.1234';
const TEST_TOKEN_ID = '0.0.2000';
const TEST_SERIAL_NUMBER = 1;
const TEST_TOPIC_ID = '0.0.3000';
const TEST_SCHEDULE_ID = '0.0.5678';
const TEST_TX_ID = '0.0.1234-1700000000-000000000';

// Network URLs
Expand Down Expand Up @@ -863,6 +865,57 @@ describe('HederaMirrornodeServiceDefaultImpl', () => {
});
});

describe('getScheduled', () => {
it('should fetch schedule info with correct URL', async () => {
const { service } = setupService();
const mockJson = createMockMirrorNodeScheduleByIdJson({
schedule_id: TEST_SCHEDULE_ID,
});
(global.fetch as jest.Mock).mockResolvedValue({
ok: true,
json: jest.fn().mockResolvedValue(mockJson),
});

const result = await service.getScheduled(TEST_SCHEDULE_ID);

expect(global.fetch).toHaveBeenCalledWith(
`${TESTNET_API_URL}/schedules/${TEST_SCHEDULE_ID}`,
);
expect(result.schedule_id).toBe(TEST_SCHEDULE_ID);
expect(result.creator_account_id).toBe('0.0.1234');
expect(result.payer_account_id).toBe('0.0.1234');
Comment on lines +885 to +886
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please do not use string literals. Maybe it's worth to change the name of MOCK_CONTRACT_ID to somrhing more generic so we can reuse it across the tests? Just a suggestion.

expect(result.wait_for_expiry).toBe(false);
});

it('should throw error on HTTP 404', async () => {
const { service } = setupService();
(global.fetch as jest.Mock).mockResolvedValue({
ok: false,
status: 404,
statusText: 'Not Found',
});

await expect(service.getScheduled(TEST_SCHEDULE_ID)).rejects.toThrow(
NotFoundError,
);
});

it('should work with mainnet network', async () => {
const { service } = setupService(SupportedNetwork.MAINNET);
const mockJson = createMockMirrorNodeScheduleByIdJson();
(global.fetch as jest.Mock).mockResolvedValue({
ok: true,
json: jest.fn().mockResolvedValue(mockJson),
});

await service.getScheduled(TEST_SCHEDULE_ID);

expect(global.fetch).toHaveBeenCalledWith(
`${MAINNET_API_URL}/schedules/${TEST_SCHEDULE_ID}`,
);
});
});

describe('getNftInfo', () => {
it('should fetch NFT info with correct URL', async () => {
const { service } = setupService();
Expand Down
20 changes: 20 additions & 0 deletions src/core/services/mirrornode/__tests__/unit/mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,26 @@ export const createMockMirrorNodeTokenByIdJson = (
...overrides,
});

/**
* Raw JSON body for GET /api/v1/schedules/{id} (Mirror Node). Use with `fetch` mocks.
*/
export const createMockMirrorNodeScheduleByIdJson = (
overrides: Record<string, unknown> = {},
): Record<string, unknown> => ({
schedule_id: '0.0.5678',
consensus_timestamp: '2024-01-01T12:00:00.000Z',
creator_account_id: '0.0.1234',
payer_account_id: '0.0.1234',
deleted: false,
executed_timestamp: null,
expiration_time: null,
memo: '',
wait_for_expiry: false,
admin_key: null,
signatures: [],
...overrides,
});

export const createMockTopicInfo = (
overrides: Partial<TopicInfo> = {},
): TopicInfo => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type {
GetAccountsQueryParams,
GetAccountsResponse,
NftInfo,
ScheduleInfo,
TokenAirdropsResponse,
TokenBalancesResponse,
TokenInfo,
Expand Down Expand Up @@ -64,6 +65,11 @@ export interface HederaMirrornodeService {
*/
getTokenInfo(tokenId: string): Promise<TokenInfo>;

/**
* Get schedule entity by id (Mirror Node GET /api/v1/schedules/{scheduleId})
*/
getScheduled(scheduleId: string): Promise<ScheduleInfo>;

/**
* Get NFT information by token ID and serial number
*/
Expand Down
30 changes: 30 additions & 0 deletions src/core/services/mirrornode/hedera-mirrornode-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type {
GetAccountsQueryParams,
GetAccountsResponse,
NftInfo,
ScheduleInfo,
TokenAirdropsResponse,
TokenBalancesResponse,
TokenInfo,
Expand All @@ -38,6 +39,7 @@ import {
AccountAPIResponseSchema,
GetAccountsAPIResponseSchema,
NftInfoSchema,
ScheduleInfoSchema,
TokenAirdropsResponseSchema,
TokenBalancesResponseSchema,
TokenInfoSchema,
Expand Down Expand Up @@ -374,6 +376,34 @@ export class HederaMirrornodeServiceDefaultImpl implements HederaMirrornodeServi
}
}

async getScheduled(scheduleId: string): Promise<ScheduleInfo> {
const url = `${this.getApiBaseUrl()}/schedules/${scheduleId}`;
try {
const response = await fetch(url);

if (!response.ok) {
await handleMirrorNodeErrorResponse(
response,
`Failed to get schedule for ${scheduleId}`,
true,
`Schedule ${scheduleId} not found`,
);
}

return parseWithSchema(
ScheduleInfoSchema,
await response.json(),
`Mirror Node GET /schedules/${scheduleId}`,
);
} catch (error) {
if (error instanceof CliError) throw error;
throw new NetworkError(`Failed to fetch schedule ${scheduleId}`, {
cause: error,
recoverable: true,
});
}
}

async getNftInfo(tokenId: string, serialNumber: number): Promise<NftInfo> {
const url = `${this.getApiBaseUrl()}/tokens/${tokenId}/nfts/${serialNumber}`;
try {
Expand Down
Loading
Loading