Skip to content

Commit 91d9f04

Browse files
authored
feat(1739): schedule transaction implementation (#1752)
Signed-off-by: mmyslblocky <michal.myslinski@blockydevs.com>
1 parent dba603c commit 91d9f04

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

57 files changed

+2948
-31
lines changed

docs/adr/ADR-011-schedule-transaction-plugin.md

Lines changed: 841 additions & 0 deletions
Large diffs are not rendered by default.

src/__tests__/mocks/mocks.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import type { NetworkService } from '@/core/services/network/network-service.int
3030
import type { OutputService } from '@/core/services/output/output-service.interface';
3131
import type { OutputHandlerOptions } from '@/core/services/output/types';
3232
import type { PluginManagementService } from '@/core/services/plugin-management/plugin-management-service.interface';
33+
import type { ScheduleTransactionService } from '@/core/services/schedule-transaction/schedule-transaction-service.interface';
3334
import type { StateService } from '@/core/services/state/state-service.interface';
3435
import type { TxExecuteService } from '@/core/services/tx-execute/tx-execute-service.interface';
3536
import type { TxSignService } from '@/core/services/tx-sign/tx-sign-service.interface';
@@ -346,6 +347,7 @@ export const createMirrorNodeMock =
346347
getTopicMessage: jest.fn(),
347348
getTopicMessages: jest.fn(),
348349
getTokenInfo: jest.fn(),
350+
getScheduled: jest.fn(),
349351
getNftInfo: jest.fn(),
350352
getTopicInfo: jest.fn(),
351353
getTransactionRecord: jest.fn(),
@@ -496,6 +498,14 @@ const makeContractTransactionServiceMock = (): ContractTransactionService =>
496498
deleteContract: jest.fn(),
497499
}) as unknown as ContractTransactionService;
498500

501+
export const makeScheduleTransactionServiceMock =
502+
(): jest.Mocked<ScheduleTransactionService> =>
503+
({
504+
buildScheduleCreateTransaction: jest.fn(),
505+
buildScheduleSignTransaction: jest.fn(),
506+
buildScheduleDeleteTransaction: jest.fn(),
507+
}) as unknown as jest.Mocked<ScheduleTransactionService>;
508+
499509
const makeContractVerifierServiceMock = (): ContractVerifierService =>
500510
({
501511
verifyContract: jest.fn(),
@@ -535,6 +545,7 @@ export const makeArgs = (
535545
const contractQuery = api.contractQuery || makeContractQueryServiceMock();
536546
const identityResolution =
537547
api.identityResolution || makeIdentityResolutionServiceMock();
548+
const schedule = api.schedule || makeScheduleTransactionServiceMock();
538549

539550
const restApi = api;
540551

@@ -573,6 +584,7 @@ export const makeArgs = (
573584
getTopicMessage: jest.fn(),
574585
getTopicMessages: jest.fn(),
575586
getTokenInfo: jest.fn(),
587+
getScheduled: jest.fn(),
576588
getNftInfo: jest.fn(),
577589
getTopicInfo: jest.fn(),
578590
getTransactionRecord: jest.fn(),
@@ -596,6 +608,7 @@ export const makeArgs = (
596608
keyResolver: makeKeyResolverMock({ network, alias, kms }),
597609
contractQuery,
598610
identityResolution,
611+
schedule,
599612
...restApi,
600613
} as unknown as CoreApi;
601614

src/core/core-api/core-api.interface.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import type { NetworkService } from '@/core/services/network/network-service.int
2020
import type { OutputService } from '@/core/services/output/output-service.interface';
2121
import type { PluginManagementService } from '@/core/services/plugin-management/plugin-management-service.interface';
2222
import type { ReceiptService } from '@/core/services/receipt/receipt-service.interface';
23+
import type { ScheduleTransactionService } from '@/core/services/schedule-transaction/schedule-transaction-service.interface';
2324
import type { StateService } from '@/core/services/state/state-service.interface';
2425
import type { TokenService } from '@/core/services/token/token-service.interface';
2526
import type { TopicService } from '@/core/services/topic/topic-transaction-service.interface';
@@ -112,5 +113,9 @@ export interface CoreApi {
112113
contractQuery: ContractQueryService;
113114
identityResolution: IdentityResolutionService;
114115
batch: BatchTransactionService;
116+
/**
117+
* Build ScheduleCreate / ScheduleSign / ScheduleDelete transactions (SDK builders).
118+
*/
119+
schedule: ScheduleTransactionService;
115120
receipt: ReceiptService;
116121
}

src/core/core-api/core-api.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import type { NetworkService } from '@/core/services/network/network-service.int
2424
import type { OutputService } from '@/core/services/output/output-service.interface';
2525
import type { PluginManagementService } from '@/core/services/plugin-management/plugin-management-service.interface';
2626
import type { ReceiptService } from '@/core/services/receipt/receipt-service.interface';
27+
import type { ScheduleTransactionService } from '@/core/services/schedule-transaction/schedule-transaction-service.interface';
2728
import type { StateService } from '@/core/services/state/state-service.interface';
2829
import type { TokenService } from '@/core/services/token/token-service.interface';
2930
import type { TopicService } from '@/core/services/topic/topic-transaction-service.interface';
@@ -48,6 +49,7 @@ import { NetworkServiceImpl } from '@/core/services/network/network-service';
4849
import { OutputServiceImpl } from '@/core/services/output/output-service';
4950
import { PluginManagementServiceImpl } from '@/core/services/plugin-management/plugin-management-service';
5051
import { ReceiptServiceImpl } from '@/core/services/receipt/receipt-service';
52+
import { ScheduleTransactionServiceImpl } from '@/core/services/schedule-transaction/schedule-transaction-service';
5153
import { ZustandGenericStateServiceImpl } from '@/core/services/state/state-service';
5254
import { TokenServiceImpl } from '@/core/services/token/token-service';
5355
import { TopicServiceImpl } from '@/core/services/topic/topic-transaction-service';
@@ -77,6 +79,7 @@ export class CoreApiImplementation implements CoreApi {
7779
public contractQuery: ContractQueryService;
7880
public identityResolution: IdentityResolutionService;
7981
public batch: BatchTransactionService;
82+
public schedule: ScheduleTransactionService;
8083
public receipt: ReceiptService;
8184

8285
constructor(storageDir?: string) {
@@ -136,6 +139,7 @@ export class CoreApiImplementation implements CoreApi {
136139
this.mirror,
137140
);
138141
this.batch = new BatchTransactionServiceImpl(this.logger);
142+
this.schedule = new ScheduleTransactionServiceImpl(this.logger);
139143
this.receipt = new ReceiptServiceImpl(this.logger, this.network);
140144
}
141145
}

src/core/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ export type * from './services/mirrornode/hedera-mirrornode-service.interface';
3535
export type { NetworkService } from './services/network/network-service.interface';
3636
export type * from './services/output/output-service.interface';
3737
export type * from './services/plugin-management/plugin-management-service.interface';
38+
export type * from './services/schedule-transaction/schedule-transaction-service.interface';
3839
export type * from './services/state/state-service.interface';
3940
export type * from './services/token/token-service.interface';
4041
export type * from './services/topic/topic-transaction-service.interface';

src/core/schemas/common-schemas.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
HEDERA_AUTO_RENEW_PERIOD_MAX,
2020
HEDERA_AUTO_RENEW_PERIOD_MIN,
2121
HEDERA_EXPIRATION_TIME_MAX,
22+
HEDERA_SCHEDULE_EXPIRATION_MAX,
2223
HederaTokenType,
2324
KeyAlgorithm,
2425
} from '@/core/shared/constants';
@@ -410,6 +411,26 @@ export const TokenReferenceObjectSchema = z
410411
})
411412
.describe('Token identifier (ID or alias)');
412413

414+
/**
415+
* Parsed token reference as a discriminated object by type (entity ID or alias).
416+
*/
417+
export const ScheduleReferenceObjectSchema = z
418+
.string()
419+
.trim()
420+
.min(1, 'Schedule identifier cannot be empty')
421+
.transform((val): { type: EntityReferenceType; value: string } => {
422+
if (EntityIdSchema.safeParse(val).success) {
423+
return { type: EntityReferenceType.ENTITY_ID, value: val };
424+
}
425+
if (AliasNameSchema.safeParse(val).success) {
426+
return { type: EntityReferenceType.ALIAS, value: val };
427+
}
428+
throw new ValidationError(
429+
'Schedule reference must be a valid Hedera ID (0.0.xxx) or alias name',
430+
);
431+
})
432+
.describe('Schedule identifier (ID or alias)');
433+
413434
/**
414435
* Account Reference Input (ID or Name)
415436
* Extended schema for referencing accounts specifically
@@ -1037,3 +1058,30 @@ export const ExpirationTimeSchema: z.ZodType<Date | undefined> = z.coerce
10371058
message: 'Expiration time must be set in 92 days period.',
10381059
},
10391060
);
1061+
1062+
export const ScheduleExpirationSchema: z.ZodType<Date | undefined> = z.coerce
1063+
.date()
1064+
.optional()
1065+
.refine((s) => !s || !Number.isNaN(new Date(s).getTime()), {
1066+
message:
1067+
'Invalid expiration time. Use an ISO 8601 datetime (e.g. 2026-12-31T23:59:59.000Z).',
1068+
})
1069+
.refine(
1070+
(d) =>
1071+
!d ||
1072+
(d.getTime() > Date.now() &&
1073+
d.getTime() <=
1074+
new Date(Date.now() + HEDERA_SCHEDULE_EXPIRATION_MAX).getTime()),
1075+
{
1076+
message: 'Expiration time must be set in 62 days period.',
1077+
},
1078+
)
1079+
.describe('Expiration time (ISO 8601). Max 62 days from now.');
1080+
1081+
export const WaitForExpirySchema = z
1082+
.boolean()
1083+
.optional()
1084+
.default(false)
1085+
.describe(
1086+
'If true, execute at expiration instead of when signatures are complete.',
1087+
);

src/core/services/kms/kms-types.interface.ts

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -125,16 +125,3 @@ export type Credential =
125125
| KeyReferenceCredential
126126
| AliasCredential
127127
| EvmAddressCredential;
128-
129-
/**
130-
* Key resolution - explicit keypair or alias reference
131-
*/
132-
export type KeyOrAccountAlias = KeypairCredential | AliasCredential;
133-
134-
/**
135-
* Parsed "accountId=privateKey" format
136-
*/
137-
export type AccountIdWithPrivateKey = {
138-
accountId: string;
139-
privateKey: string;
140-
};

src/core/services/mirrornode/__tests__/unit/hedera-mirrornode-service.test.ts

Lines changed: 58 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
createMockAccountListItemAPIResponse,
2525
createMockExchangeRateResponse,
2626
createMockGetAccountsAPIResponse,
27+
createMockMirrorNodeScheduleByIdJson,
2728
createMockMirrorNodeTokenByIdJson,
2829
createMockNftInfo,
2930
createMockTokenAirdropsResponse,
@@ -45,6 +46,7 @@ const TEST_ACCOUNT_ID = '0.0.1234';
4546
const TEST_TOKEN_ID = '0.0.2000';
4647
const TEST_SERIAL_NUMBER = 1;
4748
const TEST_TOPIC_ID = '0.0.3000';
49+
const TEST_SCHEDULE_ID = '0.0.5678';
4850
const TEST_TX_ID = '0.0.1234-1700000000-000000000';
4951

5052
// Network URLs
@@ -408,7 +410,7 @@ describe('HederaMirrornodeServiceDefaultImpl', () => {
408410
`${TESTNET_API_URL}/accounts?balance=false&limit=25&order=asc`,
409411
);
410412
expect(result.accounts).toHaveLength(1);
411-
expect(result.accounts[0].accountId).toBe('0.0.1234');
413+
expect(result.accounts[0].accountId).toBe(TEST_ACCOUNT_ID);
412414
expect(result.accounts[0].createdTimestamp).toBe(
413415
mockAccount.created_timestamp,
414416
);
@@ -432,7 +434,7 @@ describe('HederaMirrornodeServiceDefaultImpl', () => {
432434

433435
await service.getAccounts({
434436
accountBalance: { operator: AccountBalanceOperator.GTE, value: 1000 },
435-
accountId: '0.0.1234',
437+
accountId: TEST_ACCOUNT_ID,
436438
accountPublicKey:
437439
'3c3d546321ff6f63d701d2ec5c277095874e19f4a235bee1e6bb19258bf362be',
438440
balance: false,
@@ -599,7 +601,7 @@ describe('HederaMirrornodeServiceDefaultImpl', () => {
599601

600602
const result = await service.getAccounts();
601603

602-
expect(result.accounts[0].accountId).toBe('0.0.1234');
604+
expect(result.accounts[0].accountId).toBe(TEST_ACCOUNT_ID);
603605
expect(result.accounts[0].accountPublicKey).toBeUndefined();
604606
expect(result.accounts[0].keyAlgorithm).toBeUndefined();
605607
});
@@ -846,7 +848,7 @@ describe('HederaMirrornodeServiceDefaultImpl', () => {
846848
);
847849
expect(result.token_id).toBe(TEST_TOKEN_ID);
848850
expect(result.symbol).toBe('TEST');
849-
expect(result.treasury_account_id).toBe('0.0.1234');
851+
expect(result.treasury_account_id).toBe(TEST_ACCOUNT_ID);
850852
});
851853

852854
it('should throw error on HTTP 404', async () => {
@@ -863,6 +865,57 @@ describe('HederaMirrornodeServiceDefaultImpl', () => {
863865
});
864866
});
865867

868+
describe('getScheduled', () => {
869+
it('should fetch schedule info with correct URL', async () => {
870+
const { service } = setupService();
871+
const mockJson = createMockMirrorNodeScheduleByIdJson({
872+
schedule_id: TEST_SCHEDULE_ID,
873+
});
874+
(global.fetch as jest.Mock).mockResolvedValue({
875+
ok: true,
876+
json: jest.fn().mockResolvedValue(mockJson),
877+
});
878+
879+
const result = await service.getScheduled(TEST_SCHEDULE_ID);
880+
881+
expect(global.fetch).toHaveBeenCalledWith(
882+
`${TESTNET_API_URL}/schedules/${TEST_SCHEDULE_ID}`,
883+
);
884+
expect(result.schedule_id).toBe(TEST_SCHEDULE_ID);
885+
expect(result.creator_account_id).toBe(TEST_ACCOUNT_ID);
886+
expect(result.payer_account_id).toBe(TEST_ACCOUNT_ID);
887+
expect(result.wait_for_expiry).toBe(false);
888+
});
889+
890+
it('should throw error on HTTP 404', async () => {
891+
const { service } = setupService();
892+
(global.fetch as jest.Mock).mockResolvedValue({
893+
ok: false,
894+
status: 404,
895+
statusText: 'Not Found',
896+
});
897+
898+
await expect(service.getScheduled(TEST_SCHEDULE_ID)).rejects.toThrow(
899+
NotFoundError,
900+
);
901+
});
902+
903+
it('should work with mainnet network', async () => {
904+
const { service } = setupService(SupportedNetwork.MAINNET);
905+
const mockJson = createMockMirrorNodeScheduleByIdJson();
906+
(global.fetch as jest.Mock).mockResolvedValue({
907+
ok: true,
908+
json: jest.fn().mockResolvedValue(mockJson),
909+
});
910+
911+
await service.getScheduled(TEST_SCHEDULE_ID);
912+
913+
expect(global.fetch).toHaveBeenCalledWith(
914+
`${MAINNET_API_URL}/schedules/${TEST_SCHEDULE_ID}`,
915+
);
916+
});
917+
});
918+
866919
describe('getNftInfo', () => {
867920
it('should fetch NFT info with correct URL', async () => {
868921
const { service } = setupService();
@@ -882,7 +935,7 @@ describe('HederaMirrornodeServiceDefaultImpl', () => {
882935
);
883936
expect(result.token_id).toBe(TEST_TOKEN_ID);
884937
expect(result.serial_number).toBe(TEST_SERIAL_NUMBER);
885-
expect(result.account_id).toBe('0.0.1234');
938+
expect(result.account_id).toBe(TEST_ACCOUNT_ID);
886939
});
887940

888941
it('should throw error on HTTP 404', async () => {

src/core/services/mirrornode/__tests__/unit/mocks.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,26 @@ export const createMockMirrorNodeTokenByIdJson = (
149149
...overrides,
150150
});
151151

152+
/**
153+
* Raw JSON body for GET /api/v1/schedules/{id} (Mirror Node). Use with `fetch` mocks.
154+
*/
155+
export const createMockMirrorNodeScheduleByIdJson = (
156+
overrides: Record<string, unknown> = {},
157+
): Record<string, unknown> => ({
158+
schedule_id: '0.0.5678',
159+
consensus_timestamp: '2024-01-01T12:00:00.000Z',
160+
creator_account_id: '0.0.1234',
161+
payer_account_id: '0.0.1234',
162+
deleted: false,
163+
executed_timestamp: null,
164+
expiration_time: null,
165+
memo: '',
166+
wait_for_expiry: false,
167+
admin_key: null,
168+
signatures: [],
169+
...overrides,
170+
});
171+
152172
export const createMockTopicInfo = (
153173
overrides: Partial<TopicInfo> = {},
154174
): TopicInfo => ({

src/core/services/mirrornode/hedera-mirrornode-service.interface.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import type {
1111
GetAccountsQueryParams,
1212
GetAccountsResponse,
1313
NftInfo,
14+
ScheduleInfo,
1415
TokenAirdropsResponse,
1516
TokenBalancesResponse,
1617
TokenInfo,
@@ -64,6 +65,11 @@ export interface HederaMirrornodeService {
6465
*/
6566
getTokenInfo(tokenId: string): Promise<TokenInfo>;
6667

68+
/**
69+
* Get schedule entity by id (Mirror Node GET /api/v1/schedules/{scheduleId})
70+
*/
71+
getScheduled(scheduleId: string): Promise<ScheduleInfo>;
72+
6773
/**
6874
* Get NFT information by token ID and serial number
6975
*/

0 commit comments

Comments
 (0)