Skip to content

Commit 23d4d65

Browse files
committed
feat: topic import now matches admin and submit keys from mirror node with KMS and stores the matched key references for signing
Signed-off-by: rozekmichal <michal.rozek@blockydevs.com>
1 parent 0677215 commit 23d4d65

File tree

5 files changed

+344
-7
lines changed

5 files changed

+344
-7
lines changed

src/__tests__/mocks/mocks.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import type { Destination } from '@/core/services/key-resolver/types';
2020
import type { KmsService } from '@/core/services/kms/kms-service.interface';
2121
import type {
2222
Credential,
23-
KeyManager,
23+
KmsCredentialRecord,
2424
} from '@/core/services/kms/kms-types.interface';
2525
import type { Logger } from '@/core/services/logger/logger-service.interface';
2626
import type { HederaMirrornodeService } from '@/core/services/mirrornode/hedera-mirrornode-service.interface';
@@ -38,7 +38,10 @@ import type { TopicData } from '@/plugins/topic/schema';
3838
import { createMockTransaction } from '@/__tests__/mocks/hedera-sdk-mocks';
3939
import { StateError, ValidationError } from '@/core';
4040
import { AliasType } from '@/core/services/alias/alias-service.interface';
41-
import { CredentialType } from '@/core/services/kms/kms-types.interface';
41+
import {
42+
CredentialType,
43+
KeyManager,
44+
} from '@/core/services/kms/kms-types.interface';
4245
import { KeyAlgorithm } from '@/core/shared/constants';
4346
import { SupportedNetwork } from '@/core/types/shared.types';
4447

@@ -150,13 +153,30 @@ export const makeKmsMock = (): jest.Mocked<KmsService> => ({
150153
signContractCreateFlow: jest.fn(),
151154
});
152155

156+
/**
157+
* Create a minimal KmsCredentialRecord
158+
*/
159+
export const createMockKmsRecord = (
160+
keyRefId: string,
161+
publicKey: string,
162+
): KmsCredentialRecord => {
163+
const now = new Date().toISOString();
164+
return {
165+
keyRefId,
166+
publicKey,
167+
keyManager: KeyManager.local,
168+
keyAlgorithm: KeyAlgorithm.ED25519,
169+
createdAt: now,
170+
updatedAt: now,
171+
};
172+
};
173+
153174
/**
154175
* Create a mocked AliasService
155176
*/
156177
export const makeAliasMock = (): jest.Mocked<AliasService> => ({
157178
register: jest.fn(),
158179
resolve: jest.fn().mockImplementation((alias, type) => {
159-
// Domyślnie zwracaj dane dla typowych aliasów używanych w testach
160180
if (type === AliasType.Account) {
161181
const accountAliases: Record<string, AccountAlias> = {
162182
'admin-key': {

src/plugins/topic/__tests__/unit/import.test.ts

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,18 @@ import type { HederaMirrornodeService } from '@/core/services/mirrornode/hedera-
33
import type { NetworkService } from '@/core/services/network/network-service.interface';
44

55
import {
6+
ECDSA_DER_PUBLIC_KEY,
7+
ECDSA_HEX_PUBLIC_KEY,
8+
ED25519_DER_PUBLIC_KEY,
9+
ED25519_HEX_PUBLIC_KEY,
10+
MOCK_TOPIC_ADMIN_KEY_REF_ID,
11+
MOCK_TOPIC_SUBMIT_KEY_REF_ID,
12+
} from '@/__tests__/mocks/fixtures';
13+
import {
14+
createMockKmsRecord,
615
makeAliasMock,
716
makeArgs,
17+
makeKmsMock,
818
makeLogger,
919
makeMirrorMock,
1020
makeNetworkMock,
@@ -144,6 +154,167 @@ describe('topic plugin - import command (ADR-007)', () => {
144154
expect(output.name).toBe(undefined);
145155
});
146156

157+
test('imports topic with admin_key and submit_key matched in KMS', async () => {
158+
const logger = makeLogger();
159+
const saveTopicMock = jest.fn().mockReturnValue(undefined);
160+
161+
MockedHelper.mockImplementation(() => ({
162+
loadTopic: jest.fn().mockReturnValue(null),
163+
saveTopic: saveTopicMock,
164+
}));
165+
166+
const topicInfo = createMockTopicInfo({
167+
topic_id: '0.0.123456',
168+
memo: 'Topic with keys',
169+
created_timestamp: '1704067200.000000000',
170+
admin_key: { _type: 'ED25519', key: ED25519_DER_PUBLIC_KEY },
171+
submit_key: { _type: 'ECDSA_SECP256K1', key: ECDSA_DER_PUBLIC_KEY },
172+
});
173+
174+
const mirrorMock = makeMirrorMock() as Partial<HederaMirrornodeService> & {
175+
getTopicInfo: jest.Mock;
176+
};
177+
mirrorMock.getTopicInfo = jest.fn().mockResolvedValue(topicInfo);
178+
179+
const kms = makeKmsMock();
180+
kms.findByPublicKey.mockImplementation((publicKey: string) => {
181+
if (publicKey === ED25519_HEX_PUBLIC_KEY)
182+
return createMockKmsRecord(MOCK_TOPIC_ADMIN_KEY_REF_ID, publicKey);
183+
if (publicKey === ECDSA_HEX_PUBLIC_KEY)
184+
return createMockKmsRecord(MOCK_TOPIC_SUBMIT_KEY_REF_ID, publicKey);
185+
return undefined;
186+
});
187+
188+
const api: Partial<CoreApi> = {
189+
mirror: mirrorMock as HederaMirrornodeService,
190+
network: makeNetworkMock(SupportedNetwork.TESTNET) as NetworkService,
191+
alias: makeAliasMock(),
192+
logger,
193+
state: makeStateMock(),
194+
kms,
195+
};
196+
197+
const args = makeArgs(api, logger, { topic: '0.0.123456' });
198+
199+
const result = await topicImport(args);
200+
201+
expect(saveTopicMock).toHaveBeenCalledWith(
202+
`${SupportedNetwork.TESTNET}:0.0.123456`,
203+
expect.objectContaining({
204+
topicId: '0.0.123456',
205+
adminKeyRefIds: [MOCK_TOPIC_ADMIN_KEY_REF_ID],
206+
submitKeyRefIds: [MOCK_TOPIC_SUBMIT_KEY_REF_ID],
207+
}),
208+
);
209+
210+
const output = assertOutput(result.result, TopicImportOutputSchema);
211+
expect(output.adminKeyPresent).toBe(true);
212+
expect(output.submitKeyPresent).toBe(true);
213+
expect(output.adminKeysMatched).toBe(1);
214+
expect(output.submitKeysMatched).toBe(1);
215+
});
216+
217+
test('imports topic with admin_key when key is not in KMS', async () => {
218+
const logger = makeLogger();
219+
const saveTopicMock = jest.fn().mockReturnValue(undefined);
220+
221+
MockedHelper.mockImplementation(() => ({
222+
loadTopic: jest.fn().mockReturnValue(null),
223+
saveTopic: saveTopicMock,
224+
}));
225+
226+
const topicInfo = createMockTopicInfo({
227+
topic_id: '0.0.123456',
228+
created_timestamp: '1704067200.000000000',
229+
admin_key: { _type: 'ED25519', key: ED25519_DER_PUBLIC_KEY },
230+
});
231+
232+
const mirrorMock = makeMirrorMock() as Partial<HederaMirrornodeService> & {
233+
getTopicInfo: jest.Mock;
234+
};
235+
mirrorMock.getTopicInfo = jest.fn().mockResolvedValue(topicInfo);
236+
237+
const kms = makeKmsMock();
238+
kms.findByPublicKey.mockReturnValue(undefined);
239+
240+
const api: Partial<CoreApi> = {
241+
mirror: mirrorMock as HederaMirrornodeService,
242+
network: makeNetworkMock(SupportedNetwork.TESTNET) as NetworkService,
243+
alias: makeAliasMock(),
244+
logger,
245+
state: makeStateMock(),
246+
kms,
247+
};
248+
249+
const args = makeArgs(api, logger, { topic: '0.0.123456' });
250+
251+
const result = await topicImport(args);
252+
253+
expect(saveTopicMock).toHaveBeenCalledWith(
254+
`${SupportedNetwork.TESTNET}:0.0.123456`,
255+
expect.objectContaining({
256+
topicId: '0.0.123456',
257+
adminKeyRefIds: undefined,
258+
}),
259+
);
260+
261+
const output = assertOutput(result.result, TopicImportOutputSchema);
262+
expect(output.adminKeyPresent).toBe(true);
263+
expect(output.adminKeysMatched).toBe(0);
264+
});
265+
266+
test('imports topic with submit_key when key is in KMS', async () => {
267+
const logger = makeLogger();
268+
const saveTopicMock = jest.fn().mockReturnValue(undefined);
269+
270+
MockedHelper.mockImplementation(() => ({
271+
loadTopic: jest.fn().mockReturnValue(null),
272+
saveTopic: saveTopicMock,
273+
}));
274+
275+
const topicInfo = createMockTopicInfo({
276+
topic_id: '0.0.123456',
277+
created_timestamp: '1704067200.000000000',
278+
submit_key: { _type: 'ED25519', key: ED25519_DER_PUBLIC_KEY },
279+
});
280+
281+
const mirrorMock = makeMirrorMock() as Partial<HederaMirrornodeService> & {
282+
getTopicInfo: jest.Mock;
283+
};
284+
mirrorMock.getTopicInfo = jest.fn().mockResolvedValue(topicInfo);
285+
286+
const kms = makeKmsMock();
287+
kms.findByPublicKey.mockImplementation((publicKey: string) =>
288+
publicKey === ED25519_HEX_PUBLIC_KEY
289+
? createMockKmsRecord(MOCK_TOPIC_SUBMIT_KEY_REF_ID, publicKey)
290+
: undefined,
291+
);
292+
293+
const api: Partial<CoreApi> = {
294+
mirror: mirrorMock as HederaMirrornodeService,
295+
network: makeNetworkMock(SupportedNetwork.TESTNET) as NetworkService,
296+
alias: makeAliasMock(),
297+
logger,
298+
state: makeStateMock(),
299+
kms,
300+
};
301+
302+
const args = makeArgs(api, logger, { topic: '0.0.123456' });
303+
304+
const result = await topicImport(args);
305+
306+
expect(saveTopicMock).toHaveBeenCalledWith(
307+
`${SupportedNetwork.TESTNET}:0.0.123456`,
308+
expect.objectContaining({
309+
submitKeyRefIds: [MOCK_TOPIC_SUBMIT_KEY_REF_ID],
310+
}),
311+
);
312+
313+
const output = assertOutput(result.result, TopicImportOutputSchema);
314+
expect(output.submitKeyPresent).toBe(true);
315+
expect(output.submitKeysMatched).toBe(1);
316+
});
317+
147318
test('throws when topic already exists in state', async () => {
148319
const logger = makeLogger();
149320

src/plugins/topic/commands/import/handler.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ import { ValidationError } from '@/core/errors';
88
import { AliasType } from '@/core/services/alias/alias-service.interface';
99
import { hederaTimestampToIso } from '@/core/utils/hedera-timestamp';
1010
import { composeKey } from '@/core/utils/key-composer';
11+
import {
12+
extractPublicKeysFromMirrorNodeKey,
13+
matchKeysWithKms,
14+
} from '@/plugins/topic/utils/extract-public-keys';
1115
import { ZustandTopicStateHelper } from '@/plugins/topic/zustand-state-helper';
1216

1317
import { TopicImportInputSchema } from './input';
@@ -55,12 +59,31 @@ export class TopicImportCommand implements Command {
5559
}
5660

5761
const createdAt = hederaTimestampToIso(topicInfo.created_timestamp);
62+
63+
const adminKeysExtracted = extractPublicKeysFromMirrorNodeKey(
64+
topicInfo.admin_key,
65+
);
66+
const submitKeysExtracted = extractPublicKeysFromMirrorNodeKey(
67+
topicInfo.submit_key,
68+
);
69+
70+
const adminKeyRefIds =
71+
adminKeysExtracted && adminKeysExtracted.publicKeys.length > 0
72+
? matchKeysWithKms(adminKeysExtracted.publicKeys, api.kms)
73+
: [];
74+
const submitKeyRefIds =
75+
submitKeysExtracted && submitKeysExtracted.publicKeys.length > 0
76+
? matchKeysWithKms(submitKeysExtracted.publicKeys, api.kms)
77+
: [];
78+
5879
const topicData: TopicData = {
5980
name: normalisedParams.alias,
6081
topicId: normalisedParams.topicId,
6182
memo: topicInfo.memo || '(No memo)',
62-
adminKeyRefIds: [],
63-
submitKeyRefIds: [],
83+
adminKeyRefIds: adminKeyRefIds.length > 0 ? adminKeyRefIds : undefined,
84+
submitKeyRefIds: submitKeyRefIds.length > 0 ? submitKeyRefIds : undefined,
85+
adminKeyThreshold: adminKeysExtracted?.threshold,
86+
submitKeyThreshold: submitKeysExtracted?.threshold,
6487
network: normalisedParams.network,
6588
createdAt,
6689
};
@@ -74,6 +97,8 @@ export class TopicImportCommand implements Command {
7497
memo: topicInfo.memo || undefined,
7598
adminKeyPresent: Boolean(topicInfo.admin_key),
7699
submitKeyPresent: Boolean(topicInfo.submit_key),
100+
adminKeysMatched: adminKeyRefIds.length,
101+
submitKeysMatched: submitKeyRefIds.length,
77102
};
78103

79104
return { result };

src/plugins/topic/commands/import/output.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,16 @@ export const TopicImportOutputSchema = z.object({
1919
submitKeyPresent: z
2020
.boolean()
2121
.describe('Whether submit key is set on the topic'),
22+
adminKeysMatched: z
23+
.number()
24+
.int()
25+
.min(0)
26+
.describe('Number of admin keys matched with KMS'),
27+
submitKeysMatched: z
28+
.number()
29+
.int()
30+
.min(0)
31+
.describe('Number of submit keys matched with KMS'),
2232
});
2333

2434
export type TopicImportOutput = z.infer<typeof TopicImportOutputSchema>;
@@ -35,7 +45,7 @@ export const TOPIC_IMPORT_TEMPLATE = `
3545
{{#if memo}}
3646
Memo: {{memo}}
3747
{{/if}}
38-
Admin key: {{#if adminKeyPresent}}✅ Present{{else}}❌ Not set{{/if}}
39-
Submit key: {{#if submitKeyPresent}}✅ Present{{else}}❌ Not set (public topic){{/if}}
48+
Admin key: {{#if adminKeyPresent}}✅ Present{{#if adminKeysMatched}} ({{adminKeysMatched}} matched in KMS){{/if}}{{else}}❌ Not set{{/if}}
49+
Submit key: {{#if submitKeyPresent}}✅ Present{{#if submitKeysMatched}} ({{submitKeysMatched}} matched in KMS){{/if}}{{else}}❌ Not set (public topic){{/if}}
4050
4151
`.trim();

0 commit comments

Comments
 (0)