diff --git a/.husky/pre-push b/.husky/pre-push index 7415a45c..d202af83 100644 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -1,2 +1,3 @@ npm run test -node clear-state.js \ No newline at end of file +node clear-state.js +git add . \ No newline at end of file diff --git a/README.md b/README.md index a7a22f23..7c909851 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,7 @@ Let's explore the different commands, their options, and outputs. - [Wait Commmand](#wait-command): Wait for a specified amount of seconds - [Account Commands](#account-commands): Create and manage accounts - [Token Commands](#token-commands): Create and manage tokens +- [Topic Commands](#topic-commands): Create and manage topics - [Hbar Command](#hbar-command): Transfer Hbars between accounts - [Backup Commands](#backup-commands): Create a backup of your state - [Record Commands](#record-commands): Record CLI interactions and store it in scripts @@ -442,6 +443,66 @@ Flags: - **From:** (required) Account ID to transfer the token from. - **Balance:** (required) Amount of token to transfer. +## Topic Commands + +### Overview + +The `topic` command in the Hedera CLI tool provides functionality for creating topics and retrieving information about topics on the Hedera network. + +``` +topic create +topic list +topic message submit +topic message find +``` + +#### Usage + +**1. Create Topic:** + +Creates a new topic with a specified memo, submit key, and admin key. If you don't provide any options, a public topic will be generated. Setting the submit key creates a private topic. If you don't set an admin key, the topic is immutable. + +```sh +hcli topic create [-s, --submit-key ] [-a, --admin-key ] [--memo ] +``` + +Flags: +- **Submit Key:** (optional) Submit key for the topic. +- **Admin Key:** (optional) Admin key for the topic. +- **Memo:** (optional) Memo for the topic (100 bytes). + +**2. List Topics:** + +Lists all topics on the Hedera network known by the CLI tool. + +```sh +hcli topic list +``` + +**3. Submit Message to Topic:** + +Submits a message to a specified topic. + +```sh +hcli topic message submit -t,--topic-id -m,--message +``` + +Flags: +- **Topic ID:** (required) Topic ID to submit the message to. +- **Message:** (required) Message to submit to the topic. + +**4. Find Messages for Topic:** + +Finds messages for a specified topic by its sequence number. + +```sh +hcli topic message find -t,--topic-id -s,--sequence-number +``` + +Flags: +- **Topic ID:** (required) Topic ID to find the message for. +- **Sequence Number:** (required) Sequence number of the message you want to find. + ## Hbar Command ### Overview diff --git a/__tests__/commands/account/balance.test.ts b/__tests__/commands/account/balance.test.ts index fddd876d..a20018db 100644 --- a/__tests__/commands/account/balance.test.ts +++ b/__tests__/commands/account/balance.test.ts @@ -4,12 +4,20 @@ import accountUtils from "../../../src/utils/account"; import api from "../../../src/api"; import { accountResponse, getAccountInfoResponseMock } from "../../helpers/api/apiAccountHelper"; +import { baseState } from "../../helpers/state"; +import stateController from "../../../src/state/stateController"; + +jest.mock('../../../src/state/state'); // Mock the original module -> looks for __mocks__/state.ts in same directory describe("account balance command", () => { const logSpy = jest.spyOn(console, 'log'); const getAccountBalanceSpy = jest.spyOn(accountUtils, "getAccountBalance"); describe("account balance - success path", () => { + beforeEach(() => { + stateController.saveState(baseState); + }); + afterEach(() => { // Spy cleanup logSpy.mockClear(); @@ -24,7 +32,7 @@ describe("account balance command", () => { commands.accountCommands(program); // Act - await program.parse(["node", "hedera-cli.ts", "account", "balance", "-a", accountResponse.account, "--only-hbar"]); + await program.parseAsync(["node", "hedera-cli.ts", "account", "balance", "-a", accountResponse.account, "--only-hbar"]); // Assert expect(getAccountBalanceSpy).toHaveBeenCalledWith(accountResponse.account, true, undefined); @@ -40,7 +48,7 @@ describe("account balance command", () => { commands.accountCommands(program); // Act - await program.parse(["node", "hedera-cli.ts", "account", "balance", "-a", accountResponse.account, "--token-id", accountResponse.balance.tokens[0].token_id]); + await program.parseAsync(["node", "hedera-cli.ts", "account", "balance", "-a", accountResponse.account, "--token-id", accountResponse.balance.tokens[0].token_id]); // Assert expect(getAccountBalanceSpy).toHaveBeenCalledWith(accountResponse.account, undefined, accountResponse.balance.tokens[0].token_id); diff --git a/__tests__/commands/account/create.test.ts b/__tests__/commands/account/create.test.ts index 4284e2ec..a63391fe 100644 --- a/__tests__/commands/account/create.test.ts +++ b/__tests__/commands/account/create.test.ts @@ -3,8 +3,26 @@ import { Command } from "commander"; import commands from "../../../src/commands"; import accountUtils from "../../../src/utils/account"; import stateController from "../../../src/state/stateController"; +import { AccountId } from "@hashgraph/sdk"; jest.mock("../../../src/state/state"); // Mock the original module -> looks for __mocks__/state.ts in same directory +jest.mock('@hashgraph/sdk', () => { + const originalModule = jest.requireActual('@hashgraph/sdk'); + + return { + ...originalModule, + AccountCreateTransaction: jest.fn().mockImplementation(() => ({ + setKey: jest.fn().mockReturnThis(), + setInitialBalance: jest.fn().mockReturnThis(), + setMaxAutomaticTokenAssociations: jest.fn().mockReturnThis(), + execute: jest.fn().mockResolvedValue({ + getReceipt: jest.fn().mockResolvedValue({ + accountId: AccountId.fromString('0.0.1234'), + }) + }), + })), + }; +}); describe("account create command", () => { beforeEach(() => { diff --git a/__tests__/commands/state/clear.test.ts b/__tests__/commands/state/clear.test.ts index 59b0be76..8aadceb3 100644 --- a/__tests__/commands/state/clear.test.ts +++ b/__tests__/commands/state/clear.test.ts @@ -71,6 +71,7 @@ describe("state clear command", () => { // Assert expect(saveKeyStateControllerSpy).toHaveBeenCalledWith('accounts', {}); expect(saveKeyStateControllerSpy).toHaveBeenCalledWith('tokens', {}); + expect(saveKeyStateControllerSpy).toHaveBeenCalledWith('topics', {}); expect(stateController.getAll()).toEqual(scriptState); }); diff --git a/__tests__/commands/state/download.test.ts b/__tests__/commands/state/download.test.ts index edf874df..fbff7771 100644 --- a/__tests__/commands/state/download.test.ts +++ b/__tests__/commands/state/download.test.ts @@ -4,7 +4,6 @@ import { downloadState, script_basic, accountState, - token, } from '../../helpers/state'; import { Command } from 'commander'; import commands from '../../../src/commands'; diff --git a/__tests__/commands/token/associate.test.ts b/__tests__/commands/token/associate.test.ts new file mode 100644 index 00000000..8097fbee --- /dev/null +++ b/__tests__/commands/token/associate.test.ts @@ -0,0 +1,64 @@ +import { alice, tokenState } from '../../helpers/state'; +import { Command } from 'commander'; +import commands from '../../../src/commands'; +import stateController from '../../../src/state/stateController'; + +let tokenId = Object.keys(tokenState.tokens)[0]; +jest.mock('../../../src/state/state'); // Mock the original module -> looks for __mocks__/state.ts in same directory +jest.mock('@hashgraph/sdk', () => { + const originalModule = jest.requireActual('@hashgraph/sdk'); + + return { + ...originalModule, + TokenAssociateTransaction: jest.fn().mockImplementation(() => ({ + setAccountId: jest.fn().mockReturnThis(), + setTokenIds: jest.fn().mockReturnThis(), + sign: jest.fn().mockReturnThis(), + freezeWith: jest.fn().mockReturnThis(), + execute: jest.fn().mockResolvedValue({ + getReceipt: jest.fn().mockResolvedValue({}), + }), + })), + }; +}); + +describe('token associate command', () => { + beforeEach(() => { + const tokenStateWithAlice = { + ...tokenState, + accounts: { + [alice.alias]: alice, + }, + }; + stateController.saveState(tokenStateWithAlice); + }); + + describe('token associate - success path', () => { + test('✅ ', async () => { + // Arrange + const program = new Command(); + commands.tokenCommands(program); + + // Act + await program.parseAsync([ + 'node', + 'hedera-cli.ts', + 'token', + 'associate', + '-a', + alice.accountId, + '-t', + tokenId, + ]); + + // Assert + const tokens = stateController.get('tokens'); + expect(tokens[tokenId].associations).toEqual([ + { + alias: alice.alias, + accountId: alice.accountId, + }, + ]); + }); + }); +}); diff --git a/__tests__/commands/token/create.test.ts b/__tests__/commands/token/create.test.ts index e314aa5e..41b3dfe9 100644 --- a/__tests__/commands/token/create.test.ts +++ b/__tests__/commands/token/create.test.ts @@ -1,14 +1,44 @@ -import { alice, bob, baseState } from "../../helpers/state"; -import { Command } from "commander"; -import commands from "../../../src/commands"; -import stateController from "../../../src/state/stateController"; +import { alice, bob, baseState } from '../../helpers/state'; +import { Command } from 'commander'; +import commands from '../../../src/commands'; +import stateController from '../../../src/state/stateController'; -jest.mock("../../../src/state/state"); // Mock the original module -> looks for __mocks__/state.ts in same directory +import { TokenId } from '@hashgraph/sdk'; +import { Token } from '../../../types'; -describe("token create command", () => { - const mockProcessExit = jest.spyOn(process, 'exit').mockImplementation(((code) => { - throw new Error(`Process.exit(${code})`); // Forces the code to throw instead of exit - })); +let tokenId = '0.0.1234'; +jest.mock('../../../src/state/state'); // Mock the original module -> looks for __mocks__/state.ts in same directory +jest.mock('@hashgraph/sdk', () => { + const originalModule = jest.requireActual('@hashgraph/sdk'); + + return { + ...originalModule, + TokenCreateTransaction: jest.fn().mockImplementation(() => ({ + setTokenName: jest.fn().mockReturnThis(), + setTokenSymbol: jest.fn().mockReturnThis(), + setDecimals: jest.fn().mockReturnThis(), + setInitialSupply: jest.fn().mockReturnThis(), + setTokenType: jest.fn().mockReturnThis(), + setSupplyType: jest.fn().mockReturnThis(), + setTreasuryAccountId: jest.fn().mockReturnThis(), + setAdminKey: jest.fn().mockReturnThis(), + sign: jest.fn().mockReturnThis(), + freezeWith: jest.fn().mockReturnThis(), + execute: jest.fn().mockResolvedValue({ + getReceipt: jest.fn().mockResolvedValue({ + tokenId: TokenId.fromString(tokenId), + }), + }), + })), + }; +}); + +describe('token create command', () => { + const mockProcessExit = jest + .spyOn(process, 'exit') + .mockImplementation((code) => { + throw new Error(`Process.exit(${code})`); // Forces the code to throw instead of exit + }); const saveKeyStateControllerSpy = jest.spyOn(stateController, 'saveKey'); @@ -22,43 +52,70 @@ describe("token create command", () => { saveKeyStateControllerSpy.mockClear(); }); - describe("token create - success path", () => { - test("✅ ", async () => { + describe('token create - success path', () => { + test('✅ ', async () => { // Arrange const program = new Command(); commands.tokenCommands(program); + const tokenName = 'test-token'; + const tokenSymbol = 'TST'; + const tokenSupplyType = 'infinite'; + const totalSupply = 1000; + const decimals = 2; // Act - try { - await program.parseAsync([ - "node", - "hedera-cli.ts", - "token", - "create", - "-t", - alice.accountId, - "-k", - alice.privateKey, - "-n", - "test-token", - "-s", - "TST", - "-d", - "2", - "-i", - "1000", - "--supply-type", - "infinite", - "-a", - bob.privateKey - ]); - } catch (error) { - expect(error).toEqual(Error(`Process.exit(1)`)); - } + await program.parseAsync([ + 'node', + 'hedera-cli.ts', + 'token', + 'create', + '-t', + alice.accountId, + '-k', + alice.privateKey, + '-n', + tokenName, + '-s', + tokenSymbol, + '-d', + decimals.toString(), + '-i', + totalSupply.toString(), + '--supply-type', + tokenSupplyType, + '-a', + bob.privateKey, + ]); // Assert - expect(Object.keys(stateController.get('tokens')).length).toEqual(1); - expect(saveKeyStateControllerSpy).toHaveBeenCalledWith('tokens', expect.any(Object)); + const tokens = stateController.get('tokens'); + expect(Object.keys(tokens).length).toEqual(1); + expect(tokens[tokenId]).toEqual({ + tokenId: tokenId, + name: tokenName, + symbol: tokenSymbol, + decimals: decimals, + initialSupply: totalSupply, + supplyType: tokenSupplyType.toUpperCase(), + treasuryId: alice.accountId, + associations: [], + maxSupply: tokenSupplyType.toUpperCase() === 'FINITE' ? totalSupply : 0, + keys: { + treasuryKey: alice.privateKey, + adminKey: bob.privateKey, + supplyKey: '', + wipeKey: '', + kycKey: '', + freezeKey: '', + pauseKey: '', + feeScheduleKey: '', + }, + network: 'testnet', + } as Token); + expect(saveKeyStateControllerSpy).toHaveBeenCalledWith( + 'tokens', + expect.any(Object), + ); }); }); }); diff --git a/__tests__/commands/token/transfer.test.ts b/__tests__/commands/token/transfer.test.ts new file mode 100644 index 00000000..931d7fff --- /dev/null +++ b/__tests__/commands/token/transfer.test.ts @@ -0,0 +1,82 @@ +import { alice, bob, tokenState } from '../../helpers/state'; +import { Command } from 'commander'; +import commands from '../../../src/commands'; +import stateController from '../../../src/state/stateController'; +import { TransactionId } from '@hashgraph/sdk'; +import { Logger } from "../../../src/utils/logger"; + +const logger = Logger.getInstance(); + +let tokenId = Object.keys(tokenState.tokens)[0]; +const txId = "0.0.14288@1706880903.830877722"; +jest.mock('../../../src/state/state'); // Mock the original module -> looks for __mocks__/state.ts in same directory +jest.mock('@hashgraph/sdk', () => { + const originalModule = jest.requireActual('@hashgraph/sdk'); + + return { + ...originalModule, + TransferTransaction: jest.fn().mockImplementation(() => ({ + addTokenTransfer: jest.fn().mockReturnThis(), + sign: jest.fn().mockReturnThis(), + freezeWith: jest.fn().mockReturnThis(), + execute: jest.fn().mockResolvedValue({ + transactionId: TransactionId.fromString(txId), + getReceipt: jest.fn().mockResolvedValue({ + status: { + _code: 22, + message: 'Success', + }, + + }), + }), + })), + }; +}); + +describe('token transfer command', () => { + const logSpy = jest.spyOn(logger, 'log'); + + beforeEach(() => { + const tokenStateWithAlice = { + ...tokenState, + accounts: { + [alice.alias]: alice, + [bob.alias]: bob, + }, + }; + stateController.saveState(tokenStateWithAlice); + }); + + afterEach(() => { + // Spy cleanup + logSpy.mockClear(); + }); + + describe('token transfer - success path', () => { + test('✅ ', async () => { + // Arrange + const program = new Command(); + commands.tokenCommands(program); + const balance = 10; + + // Act + await program.parseAsync([ + 'node', + 'hedera-cli.ts', + 'token', + 'transfer', + '-t', + tokenId, + '--to', + bob.alias, + '--from', + alice.alias, + '-b', + balance.toString() + ]); + + // Assert + expect(logSpy).toHaveBeenCalledWith(`Transfer successful with tx ID: ${txId}`); + }); + }); +}); diff --git a/__tests__/commands/topic/create.test.ts b/__tests__/commands/topic/create.test.ts new file mode 100644 index 00000000..19690687 --- /dev/null +++ b/__tests__/commands/topic/create.test.ts @@ -0,0 +1,54 @@ +import { topicState, topic, baseState } from '../../helpers/state'; +import commands from '../../../src/commands'; +import stateController from '../../../src/state/stateController'; +import { Command } from 'commander'; + +jest.mock('../../../src/state/state'); // Mock the original module -> looks for __mocks__/state.ts in same directory +jest.mock('@hashgraph/sdk', () => { + const originalModule = jest.requireActual('@hashgraph/sdk'); + + return { + ...originalModule, + TopicCreateTransaction: jest.fn().mockImplementation(() => ({ + setTopicMemo: jest.fn().mockReturnThis(), + setAdminKey: jest.fn().mockReturnThis(), + setSubmitKey: jest.fn().mockReturnThis(), + freezeWith: jest.fn().mockReturnThis(), + execute: jest.fn().mockResolvedValue({ + getReceipt: jest.fn().mockResolvedValue({ + topicId: topic.topicId, + }) + }), + })), + }; +}); + +describe('topic create command', () => { + const logSpy = jest.spyOn(console, 'log'); + + beforeEach(() => { + stateController.saveState(baseState); + }); + + describe('topic create - success path', () => { + afterEach(() => { + // Spy cleanup + logSpy.mockClear(); + }); + + test('✅ Create a new topic', async () => { + // Arrange + const program = new Command(); + commands.topicCommands(program); + + // Act + await program.parseAsync(['node', 'hedera-cli.ts', 'topic', 'create', '--memo', topic.memo]); + + // Assert + const topics = stateController.get('topics'); + expect(Object.keys(topics).length).toEqual(1); + expect(topics[topic.topicId]).toEqual(topic); + expect(logSpy).toHaveBeenCalledWith(`Created new topic: ${topic.topicId}`); + }); + }); +}); diff --git a/__tests__/commands/topic/list.test.ts b/__tests__/commands/topic/list.test.ts new file mode 100644 index 00000000..f577ce35 --- /dev/null +++ b/__tests__/commands/topic/list.test.ts @@ -0,0 +1,36 @@ +import { topicState, topic } from "../../helpers/state"; +import commands from "../../../src/commands"; +import stateController from "../../../src/state/stateController"; +import { Command } from "commander"; + +jest.mock("../../../src/state/state"); // Mock the original module -> looks for __mocks__/state.ts in same directory + +describe("topic list command", () => { + const logSpy = jest.spyOn(console, 'log'); + + beforeEach(() => { + stateController.saveState(topicState); + }); + + describe("topic message submit - success path", () => { + afterEach(() => { + // Spy cleanup + logSpy.mockClear(); + }); + + test("✅ List all topics", async () => { + // Arrange + const program = new Command(); + commands.topicCommands(program); + + // Act + program.parse(["node", "hedera-cli.ts", "topic", "list"]); + + // Assert + expect(logSpy).toHaveBeenCalledWith(`Topics:`); + expect(logSpy).toHaveBeenCalledWith(`\tTopic ID: ${topic.topicId}`); + expect(logSpy).toHaveBeenCalledWith(`\t\t- Submit key: No`); + expect(logSpy).toHaveBeenCalledWith(`\t\t- Admin key: No`); + }); + }); +}); diff --git a/__tests__/commands/topic/messageFind.test.ts b/__tests__/commands/topic/messageFind.test.ts new file mode 100644 index 00000000..131291de --- /dev/null +++ b/__tests__/commands/topic/messageFind.test.ts @@ -0,0 +1,148 @@ +import axios from 'axios'; + +import { topicState, topic } from '../../helpers/state'; +import commands from '../../../src/commands'; +import stateController from '../../../src/state/stateController'; +import { Command } from 'commander'; +import api from '../../../src/api'; +import { findMessageResponseMock, topicMessageResponse, findMessagesResponseMock, topicMessagesResponse } from '../../helpers/api/apiTopicHelper' +import { Logger } from "../../../src/utils/logger"; +import stateUtils from '../../../src/utils/state'; + +const logger = Logger.getInstance(); +jest.mock('../../../src/state/state'); // Mock the original module -> looks for __mocks__/state.ts in same directory +jest.mock('axios'); + +describe('topic message find command', () => { + const logSpy = jest.spyOn(logger, 'log'); + const errorSpy = jest.spyOn(logger, 'error'); + + beforeEach(() => { + stateController.saveState(topicState); + }); + + describe('topic message find - success path', () => { + afterEach(() => { + // Spy cleanup + logSpy.mockClear(); + errorSpy.mockClear(); + }); + + test('✅ Find message for topic ID and sequence number', async () => { + // Arrange + const program = new Command(); + commands.topicCommands(program); + api.topic.findMessage = jest.fn().mockResolvedValue(findMessageResponseMock); + + // Act + await program.parseAsync([ + 'node', + 'hedera-cli.ts', + 'topic', + 'message', + 'find', + '--topic-id', + topic.topicId, + '--sequence-number', + topicMessageResponse.sequence_number.toString(), + ]); + + // Assert + expect(logSpy).toHaveBeenCalledWith( + `Message found: "${Buffer.from( + topicMessageResponse.message, + 'base64', + ).toString('ascii')}"` + ); + }); + + test('✅ Find message for topic ID and sequence number filters', async () => { + // Arrange + const program = new Command(); + commands.topicCommands(program); + const mockedAxios = axios as jest.Mocked; + const mockResponse = { data: topicMessagesResponse }; + mockedAxios.get.mockResolvedValue(mockResponse); + + // Act + await program.parseAsync([ + 'node', + 'hedera-cli.ts', + 'topic', + 'message', + 'find', + '--topic-id', + topic.topicId, + '--sequence-number-gte', + topicMessagesResponse.messages.length.toString(), + ]); + + // Assert + expect(mockedAxios.get).toHaveBeenCalledWith(`${stateUtils.getMirrorNodeURL()}/topics/${topic.topicId}/messages?sequencenumber=gte:${topicMessagesResponse.messages.length.toString()}&limit=100`); + }); + }); + + describe('topic message find - failing path', () => { + afterEach(() => { + // Spy cleanup + logSpy.mockClear(); + errorSpy.mockClear(); + }); + + test('❌ If no sequence number and sequence number filters are provided throw error', async () => { + // Arrange + const program = new Command(); + commands.topicCommands(program); + api.topic.findMessage = jest.fn().mockResolvedValue(findMessageResponseMock); + + // Act + await program.parseAsync([ + 'node', + 'hedera-cli.ts', + 'topic', + 'message', + 'find', + '--topic-id', + topic.topicId, + ]); + + // Assert + expect(errorSpy).toHaveBeenCalledWith( + 'Please provide a sequence number or a sequence number filter' + ); + }); + + test('❌ If no messages with filter are found, show "no messages found" message', async () => { + // Arrange + const program = new Command(); + commands.topicCommands(program); + const customFindMessageResponseMock = { + data: { + messages: [], + links: { + next: null, + }, + }, + }; + api.topic.findMessagesWithFilters = jest.fn().mockResolvedValue(customFindMessageResponseMock); + + // Act + await program.parseAsync([ + 'node', + 'hedera-cli.ts', + 'topic', + 'message', + 'find', + '--topic-id', + topic.topicId, + '--sequence-number-gte', + '1000000', + ]); + + // Assert + expect(logSpy).toHaveBeenCalledWith( + 'No messages found' + ); + }); + }); +}); diff --git a/__tests__/commands/topic/messageSubmit.test.ts b/__tests__/commands/topic/messageSubmit.test.ts new file mode 100644 index 00000000..49f3c674 --- /dev/null +++ b/__tests__/commands/topic/messageSubmit.test.ts @@ -0,0 +1,71 @@ +import { topicState, topic, baseState } from '../../helpers/state'; +import commands from '../../../src/commands'; +import stateController from '../../../src/state/stateController'; +import { Command } from 'commander'; +import sdkMock from '../../helpers/sdk'; +import { TopicMessageSubmitTransaction } from '@hashgraph/sdk'; + +jest.mock('../../../src/state/state'); // Mock the original module -> looks for __mocks__/state.ts in same directory +// Mock the @hashgraph/sdk module directly in the test file +jest.mock('@hashgraph/sdk', () => { + const originalModule = jest.requireActual('@hashgraph/sdk'); + + return { + ...originalModule, + TopicMessageSubmitTransaction: jest + .fn() + .mockImplementation(() => sdkMock.mockTopicMessageSubmitTransaction()), + }; +}); + +describe('topic message submit command', () => { + const logSpy = jest.spyOn(console, 'log'); + + beforeEach(() => { + stateController.saveState(topicState); + sdkMock.setCustomMockImplementation(null); + }); + + describe('topic message submit - success path', () => { + afterEach(() => { + // Spy cleanup + logSpy.mockClear(); + }); + + test('✅ Submit message to topic ID', async () => { + // Arrange + const program = new Command(); + commands.topicCommands(program); + const message = 'Hello world!'; + + // Overwrite the mock implementation of TopicCreateTransaction to return a sequence number of 1 + const sequenceNumber = 2; + sdkMock.setCustomMockImplementation(() => ({ + freezeWith: jest.fn().mockReturnThis(), + execute: jest.fn().mockResolvedValue({ + getReceipt: jest.fn().mockResolvedValue({ + topicSequenceNumber: sequenceNumber, + }), + }), + }) as unknown as TopicMessageSubmitTransaction); + + // Act + await program.parseAsync([ + 'node', + 'hedera-cli.ts', + 'topic', + 'message', + 'submit', + '--message', + message, + '-t', + topic.topicId, + ]); + + // Assert + expect(logSpy).toHaveBeenCalledWith( + `Message submitted with sequence number: ${sequenceNumber}`, + ); + }); + }); +}); diff --git a/__tests__/e2e.test.ts b/__tests__/e2e.test.ts index e3838bbe..1b56f3c8 100644 --- a/__tests__/e2e.test.ts +++ b/__tests__/e2e.test.ts @@ -6,10 +6,16 @@ import { program } from 'commander'; import commands from '../src/commands'; import stateController from '../src/state/stateController'; import api from '../src/api'; +import { Logger } from '../src/utils/logger'; + import { Token } from '../types'; +const logger = Logger.getInstance(); + describe('End to end tests', () => { + const logSpy = jest.spyOn(logger, 'log'); + beforeEach(() => { stateController.saveState(baseState); // reset state to base state for each test }); @@ -32,6 +38,7 @@ describe('End to end tests', () => { afterAll(() => { stateController.saveState(baseState); + logSpy.mockClear(); }); /** @@ -422,4 +429,123 @@ describe('End to end tests', () => { { account: accounts[accountAliasUser].accountId, balance: 1 }, ]); }); + + /** + * E2E testing flow for topics: + * - Create a topic with admin key and submit key + * - Submit a message to topic (submit key should sign) + * - Find the message and verify it is correct + */ + test('✅ Topic features', async () => { + // Arrange: Setup init + commands.setupCommands(program); + + // Act + await program.parseAsync(['node', 'hedera-cli.ts', 'setup', 'init']); + + // Assert + let accounts = stateController.get('accounts'); + expect(accounts['testnet-operator']).toBeDefined(); + + // Arrange: Create 2 accounts + commands.accountCommands(program); + const accountAliasAdmin = 'admin'; + const accountAliasSubmit = 'submit'; + + // Act + await program.parseAsync([ + 'node', + 'hedera-cli.ts', + 'account', + 'create', + '-a', + accountAliasAdmin, + '-b', + '300000000', + ]); + await program.parseAsync([ + 'node', + 'hedera-cli.ts', + 'account', + 'create', + '-a', + accountAliasSubmit, + '-b', + '300000000', + ]); + + // Assert + accounts = stateController.get('accounts'); + expect(accounts[accountAliasAdmin]).toBeDefined(); + expect(accounts[accountAliasSubmit]).toBeDefined(); + + // Arrange: Create a topic with admin key and submit key + commands.topicCommands(program); + const topicMemo = 'test-topic'; + + // Act + await program.parseAsync([ + 'node', + 'hedera-cli.ts', + 'topic', + 'create', + '--memo', + topicMemo, + '-a', + accounts[accountAliasAdmin].privateKey, + '-s', + accounts[accountAliasSubmit].privateKey, + ]); + + // Assert + let topics = stateController.get('topics'); + expect(Object.keys(topics).length).toBe(1); + expect(topics[Object.keys(topics)[0]].memo).toEqual(topicMemo); + + // Arrange: Submit a message to topic (submit key should sign) + const message = 'Hello world!'; + + // Act + await program.parseAsync([ + 'node', + 'hedera-cli.ts', + 'topic', + 'message', + 'submit', + '-m', + message, + '-t', + Object.keys(topics)[0], + ]); + await new Promise((resolve) => setTimeout(resolve, 5000)); + + // Assert + const response = await api.topic.findMessage(Object.keys(topics)[0], 1); // first message + expect(Buffer.from( + response.data.message, + 'base64', + ).toString('ascii')).toEqual(message); // decode buffer + + // Arrange: Find the message and verify it is correct + // Act + await program.parseAsync([ + 'node', + 'hedera-cli.ts', + 'topic', + 'message', + 'find', + '-t', + Object.keys(topics)[0], + '-s', + '1', + ]); + + // Assert + expect(logSpy).toHaveBeenCalledWith( + `Message found: "${Buffer.from( + response.data.message, + 'base64', + ).toString('ascii')}"` + ); + }); }); diff --git a/__tests__/helpers/api/apiAccountHelper.ts b/__tests__/helpers/api/apiAccountHelper.ts index 5731d0a5..9af4e8f2 100644 --- a/__tests__/helpers/api/apiAccountHelper.ts +++ b/__tests__/helpers/api/apiAccountHelper.ts @@ -31,4 +31,4 @@ export const accountResponse: AccountResponse = { export const getAccountInfoResponseMock: APIResponse = { data: accountResponse, -}; +}; \ No newline at end of file diff --git a/__tests__/helpers/api/apiTopicHelper.ts b/__tests__/helpers/api/apiTopicHelper.ts new file mode 100644 index 00000000..05e4a7fb --- /dev/null +++ b/__tests__/helpers/api/apiTopicHelper.ts @@ -0,0 +1,49 @@ +import { TopicMessageResponse, APIResponse, TopicMessagesResponse } from '../../../types'; +import { topic } from '../../helpers/state' + +export const topicMessageResponse: TopicMessageResponse = { + chunk_info: { + initial_transaction_id: { + account_id: '0.0.458179', + nonce: 0, + scheduled: false, + transaction_valid_start: '1706704154.157177180', + }, + number: 1, + total: 1, + }, + consensus_timestamp: '1706704163.840322003', + message: 'bXkgbWVzc2FnZQ==', // "my message" + payer_account_id: '0.0.458179', + running_hash: + '+582vzqudSAftHP/xL21zS+1BwOlC/UGJW5K5Tb2I8wyeho54b7j5iNy4Ap//arW', + running_hash_version: 3, + sequence_number: 1, + topic_id: topic.topicId, +}; + +export const topicMessagesResponse: TopicMessagesResponse = { + messages: [ + createMessage(1), + createMessage(2), + createMessage(3), + ], + links: { + next: null, + }, +} + +export const findMessageResponseMock: APIResponse = { + data: topicMessageResponse, +}; + +export const findMessagesResponseMock: APIResponse = { + data: topicMessagesResponse, +}; + +function createMessage(sequenceNumber: number): TopicMessageResponse { + return { + ...topicMessageResponse, + sequence_number: sequenceNumber, + }; +} \ No newline at end of file diff --git a/__tests__/helpers/sdk.ts b/__tests__/helpers/sdk.ts new file mode 100644 index 00000000..6edf2adc --- /dev/null +++ b/__tests__/helpers/sdk.ts @@ -0,0 +1,32 @@ +import { TopicMessageSubmitTransaction } from '@hashgraph/sdk'; + +let customMockImplementation: (() => TopicMessageSubmitTransaction) | null = + null; + +export const setCustomMockImplementation = ( + customImpl: (() => TopicMessageSubmitTransaction) | null, +) => { + customMockImplementation = customImpl; +}; + +const defaultMockImplementation = () => + ({ + freezeWith: jest.fn().mockReturnThis(), + execute: jest.fn().mockResolvedValue({ + getReceipt: jest.fn().mockResolvedValue({ + topicSequenceNumber: 1, + }), + }), + }) as unknown as TopicMessageSubmitTransaction; + +const mockTopicMessageSubmitTransaction = () => + customMockImplementation + ? customMockImplementation() + : defaultMockImplementation(); + +const sdkMock = { + setCustomMockImplementation, + mockTopicMessageSubmitTransaction, +} + +export default sdkMock; \ No newline at end of file diff --git a/__tests__/helpers/state.ts b/__tests__/helpers/state.ts index 3e436467..bb763a80 100644 --- a/__tests__/helpers/state.ts +++ b/__tests__/helpers/state.ts @@ -10,14 +10,14 @@ export const baseState: State = { scriptExecutionName: "", accounts: {}, scripts: {}, - testnetOperatorKey: - "302e020100300506032b65700422042087592ee314bd0f42c4cf9f82b494481a2bb77bab0dc4454eedfe00f60168646f", - testnetOperatorId: "0.0.458179", + testnetOperatorKey: "302e020100300506032b65700422042087592ee314bd0f42c4cf9f82b494481a2bb77bab0dc4454eedfe00f60168646f", + testnetOperatorId: "0.0.2221463", mainnetOperatorKey: "", mainnetOperatorId: "", previewnetOperatorKey: "", previewnetOperatorId: "", tokens: {}, + topics: {} }; /* accounts */ @@ -86,6 +86,13 @@ export const token = { }, }; +export const topic = { + topicId: "0.0.123", + memo: "test", + adminKey: "", + submitKey: "", +}; + export const accountState: State = { ...baseState, accounts: { @@ -108,6 +115,13 @@ export const tokenState: State = { }, }; +export const topicState: State = { + ...baseState, + topics: { + [topic.topicId]: topic, + }, +}; + export const fullState: State = { ...baseState, accounts: { @@ -120,6 +134,9 @@ export const fullState: State = { tokens: { [token.tokenId]: token, }, + topics: { + [topic.topicId]: topic, + }, }; export const downloadState: object = { @@ -133,6 +150,9 @@ export const downloadState: object = { tokens: { [token.tokenId]: token, }, + topics: { + [topic.topicId]: topic, + }, } export const testnetOperatorKey = '302e020100300506032b6570042204202ef1cb430150535aa15bdcc6609ff2ef4ec843eb35f1d0cc655a4cad2130b796'; // dummy account diff --git a/package.json b/package.json index 90b80aec..19722213 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "unit-test": "jest -- __tests__/commands/**/*.test.ts", "e2e-test": "jest -- __tests__/e2e.test.ts --runInBand", "test": "npm run unit-test && npm run e2e-test", + "single-test": "jest", "prepare": "husky" }, "keywords": [ diff --git a/src/api/index.ts b/src/api/index.ts index 5470787d..1149828e 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -1,7 +1,9 @@ import account from './account'; import token from './token'; +import topic from './topic'; export default { account, token, + topic, }; diff --git a/src/api/topic.ts b/src/api/topic.ts new file mode 100644 index 00000000..ce178961 --- /dev/null +++ b/src/api/topic.ts @@ -0,0 +1,74 @@ +import axios from 'axios'; + +import type { + APIResponse, + TopicMessageResponse, + TopicMessagesResponse, + Filter, +} from '../../types'; +import stateUtils from '../utils/state'; +import apiUtils from '../utils/api'; +import { Logger } from '../utils/logger'; + +const logger = Logger.getInstance(); + +/** + * API functions: + * - findMessage(topicId, sequenceNumber): Find a message in a topic by sequence number + */ +async function findMessage( + topicId: string, + sequenceNumber: number, +): Promise> { + try { + const mirrorNodeURL = stateUtils.getMirrorNodeURL(); + const response = await axios.get( + `${mirrorNodeURL}/topics/${topicId}/messages/${sequenceNumber}`, + ); + return response; + } catch (error) { + if (axios.isAxiosError(error)) { + logger.error( + `Resource ${topicId} doesn't exist or ${sequenceNumber} is too high. ${error.message}`, + ); + } else { + logger.error('Unexpected error:', error as object); + } + process.exit(1); + } +} + +/** + * Finds messages in a topic based on provided filters. + * @param topicId The ID of the topic. + * @param filters Filters to apply for the search. + * @note There's a limit for 100 messages per request. TODO: Add pagination. + * @returns Promise resolving to the API response. + */ +async function findMessagesWithFilters( + topicId: string, + filters: Filter[], +): Promise> { + try { + const mirrorNodeURL = stateUtils.getMirrorNodeURL(); + const baseUrl = `${mirrorNodeURL}/topics/${topicId}/messages`; + const fullUrl = apiUtils.constructQueryUrl(baseUrl, filters); + + const response = await axios.get(fullUrl); + return response; + } catch (error) { + if (axios.isAxiosError(error)) { + logger.error( + `Failed to find messages for topic ${topicId} with filters. ${error.message}`, + ); + } else { + logger.error('Unexpected error:', error as object); + } + process.exit(1); + } +} + +export default { + findMessage, + findMessagesWithFilters, +}; diff --git a/src/commands/index.ts b/src/commands/index.ts index 8f32de64..2ec6151e 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -7,6 +7,7 @@ import setupCommands from './setup'; import stateCommands from './state'; import recordCommands from './record'; import scriptCommands from './script'; +import topicCommands from './topic'; import waitCommands from './wait'; const commands = { @@ -19,6 +20,7 @@ const commands = { stateCommands, recordCommands, scriptCommands, + topicCommands, waitCommands, }; diff --git a/src/commands/script/examples.json b/src/commands/script/examples.json index 73de1f93..f7fc51d1 100644 --- a/src/commands/script/examples.json +++ b/src/commands/script/examples.json @@ -33,6 +33,17 @@ "wait 5" ], "args": {} + }, + "script-topic-create": { + "name": "topic-create", + "creation": 1697103669402, + "commands": [ + "network use testnet", + "account create -a random --args privateKey,privKeyAdmin", + "account create -a random --args privateKey,privKeySubmit", + "topic create --admin-key {{privKeyAdmin}} --submit-key {{privKeySubmit}} --args topicId,topicId" + ], + "args": {} } } } \ No newline at end of file diff --git a/src/commands/state/clear.ts b/src/commands/state/clear.ts index 8eea43c8..d6535eca 100644 --- a/src/commands/state/clear.ts +++ b/src/commands/state/clear.ts @@ -19,9 +19,15 @@ export default (program: any) => { .option('-a, --skip-accounts', 'Skip resetting accounts', false) .option('-t, --skip-tokens', 'Skip resetting tokens', false) .option('-s, --skip-scripts', 'Skip resetting scripts', false) + .option('-t, --skip-topics', 'Skip resetting topics', false) .action((options: ResetOptions) => { logger.verbose('Clearing state'); - clear(options.skipAccounts, options.skipTokens, options.skipScripts); + clear( + options.skipAccounts, + options.skipTokens, + options.skipScripts, + options.skipTopics, + ); }); }; @@ -29,8 +35,9 @@ function clear( skipAccounts: boolean, skipTokens: boolean, skipScripts: boolean, + skipTopics: boolean, ): void { - if (!skipAccounts && !skipTokens && !skipScripts) { + if (!skipAccounts && !skipTokens && !skipScripts && !skipTopics) { stateUtils.clearState(); return; } @@ -38,6 +45,7 @@ function clear( if (!skipAccounts) stateController.saveKey('accounts', {}); if (!skipTokens) stateController.saveKey('tokens', {}); if (!skipScripts) stateController.saveKey('scripts', {}); + if (!skipTopics) stateController.saveKey('topics', {}); logger.log('State cleared successfully'); } @@ -45,4 +53,5 @@ interface ResetOptions { skipAccounts: boolean; skipTokens: boolean; skipScripts: boolean; + skipTopics: boolean; } diff --git a/src/commands/token/create.ts b/src/commands/token/create.ts index 47e06266..e8914f5a 100644 --- a/src/commands/token/create.ts +++ b/src/commands/token/create.ts @@ -138,7 +138,7 @@ async function createFungibleToken( // Store new token in state logger.verbose(`Storing new token with ID ${tokenId} in state`); const tokens: Record = stateController.get('tokens'); - const updatedTokens = { + const updatedTokens: Record = { ...tokens, [tokenId.toString()]: { tokenId: tokenId.toString(), @@ -146,10 +146,20 @@ async function createFungibleToken( name, symbol, treasuryId, - treasuryKey, decimals, + supplyType: supplyType.toUpperCase(), + maxSupply: supplyType.toUpperCase() === 'FINITE' ? initialSupply : 0, initialSupply, - adminKey, + keys: { + treasuryKey, + adminKey, + supplyKey: '', + wipeKey: '', + kycKey: '', + freezeKey: '', + pauseKey: '', + feeScheduleKey: '', + }, network: stateUtils.getNetwork(), }, }; diff --git a/src/commands/topic/create.ts b/src/commands/topic/create.ts new file mode 100644 index 00000000..ba362b33 --- /dev/null +++ b/src/commands/topic/create.ts @@ -0,0 +1,108 @@ +import stateUtils from '../../utils/state'; +import { Logger } from '../../utils/logger'; +import signUtils from '../../utils/sign'; +import stateController from '../../state/stateController'; +import dynamicVariablesUtils from '../../utils/dynamicVariables'; +import { TopicCreateTransaction, PrivateKey } from '@hashgraph/sdk'; + +import type { Command, Topic } from '../../../types'; + +const logger = Logger.getInstance(); + +export default (program: any) => { + program + .command('create') + .hook('preAction', (thisCommand: Command) => { + const command = [ + thisCommand.parent.action().name(), + ...thisCommand.parent.args, + ]; + stateUtils.recordCommand(command); + }) + .description('Create a new topic') + .option('-a, --admin-key ', 'The admin key') + .option('-s, --submit-key ', 'The submit key') + .option('--memo ', 'The memo') + .option( + '--args ', + 'Store arguments for scripts', + (value: string, previous: string) => + previous ? previous.concat(value) : [value], + [], + ) + .action(async (options: CreateTopicOptions) => { + options = dynamicVariablesUtils.replaceOptions(options); // allow dynamic vars for admin-key and submit-key + logger.verbose('Creating topic'); + + const client = stateUtils.getHederaClient(); + + let topicId; + try { + const topicCreateTx = new TopicCreateTransaction(); + if (options.memo) { + topicCreateTx.setTopicMemo(options.memo); + } + if (options.adminKey) { + topicCreateTx.setAdminKey(PrivateKey.fromStringDer(options.adminKey)); + } + if (options.submitKey) { + topicCreateTx.setSubmitKey( + PrivateKey.fromStringDer(options.submitKey), + ); + } + + // Signing + topicCreateTx.freezeWith(client); + const signedTopicCreateTx = await signUtils.signByType( + topicCreateTx, + 'topicCreate', + { + adminKey: options.adminKey, + submitKey: options.submitKey, + }, + ); + + const topicCreateTxResponse = await signedTopicCreateTx.execute(client); + const receipt = await topicCreateTxResponse.getReceipt(client); + topicId = receipt.topicId; + } catch (error) { + logger.error('Error creating new topic:', error as object); + client.close(); + process.exit(1); + } + + if (!topicId) { + logger.error('Failed to create new topic'); + client.close(); + process.exit(1); + } + + logger.log(`Created new topic: ${topicId.toString()}`); + + const topic = { + topicId: topicId.toString(), + adminKey: options.adminKey || '', + submitKey: options.submitKey || '', + memo: options.memo || '', + }; + + const topics: Record = stateController.get('topics'); + const updatedTopics = { ...topics, [topicId.toString()]: topic }; + stateController.saveKey('topics', updatedTopics); + logger.verbose(`Saved topic to state: ${topicId.toString()}`); + + client.close(); + dynamicVariablesUtils.storeArgs( + options.args, + dynamicVariablesUtils.commandActions.topic.create.action, + topic, + ); + }); +}; + +interface CreateTopicOptions { + adminKey: string; + submitKey: string; + memo: string; + args: string[]; +} diff --git a/src/commands/topic/index.ts b/src/commands/topic/index.ts new file mode 100644 index 00000000..0c91cebe --- /dev/null +++ b/src/commands/topic/index.ts @@ -0,0 +1,15 @@ +import createCommand from './create'; +import messageCommand from './message'; +import listCommand from './list'; + +export default (program: any) => { + const state = program + .command('topic') + .description( + 'Hedera Consensus Service commands handling topics and messages', + ); + + createCommand(state); + messageCommand(state); + listCommand(state); +}; diff --git a/src/commands/topic/list.ts b/src/commands/topic/list.ts new file mode 100644 index 00000000..679400e2 --- /dev/null +++ b/src/commands/topic/list.ts @@ -0,0 +1,24 @@ +import stateUtils from '../../utils/state'; +import { Logger } from '../../utils/logger'; +import topicUtils from '../../utils/topic'; + +import type { Command } from '../../../types'; + +const logger = Logger.getInstance(); + +export default (program: any) => { + program + .command('list') + .hook('preAction', (thisCommand: Command) => { + const command = [ + thisCommand.parent.action().name(), + ...thisCommand.parent.args, + ]; + stateUtils.recordCommand(command); + }) + .description('List all topics') + .action(() => { + logger.verbose(`Listing all topic IDs and if they contain keys`); + topicUtils.list(); + }); +}; diff --git a/src/commands/topic/message.ts b/src/commands/topic/message.ts new file mode 100644 index 00000000..0499b86e --- /dev/null +++ b/src/commands/topic/message.ts @@ -0,0 +1,231 @@ +import stateUtils from '../../utils/state'; +import { Logger } from '../../utils/logger'; +import stateController from '../../state/stateController'; +import dynamicVariablesUtils from '../../utils/dynamicVariables'; +import { TopicMessageSubmitTransaction, PrivateKey } from '@hashgraph/sdk'; +import api from '../../api'; + +import type { Command, Filter } from '../../../types'; + +const logger = Logger.getInstance(); + +export default (program: any) => { + const message = program.command('message'); + + message + .command('submit') + .hook('preAction', (thisCommand: Command) => { + const command = [ + thisCommand.parent.action().name(), + ...thisCommand.parent.args, + ]; + stateUtils.recordCommand(command); + }) + .description('Create a new topic') + .requiredOption('-m, --message ', 'Submit a message to the topic') + .requiredOption('-t, --topic-id ', 'The topic ID') + .action(async (options: SubmitMessageOptions) => { + options = dynamicVariablesUtils.replaceOptions(options); // allow dynamic vars for admin-key and submit-key + logger.verbose(`Submitting message to topic: ${options.topicId}`); + + const client = stateUtils.getHederaClient(); + + let sequenceNumber; + try { + const submitMessageTx = await new TopicMessageSubmitTransaction({ + topicId: options.topicId, + message: options.message, + }).freezeWith(client); + + // Signing if submit key is set + const topics = stateController.get('topics'); + if (topics[options.topicId].submitKey) { + const submitKey = PrivateKey.fromStringDer( + topics[options.topicId].submitKey, + ); + submitMessageTx.sign(submitKey); + } + + const topicMessageTxResponse = await submitMessageTx.execute(client); + const receipt = await topicMessageTxResponse.getReceipt(client); + sequenceNumber = receipt.topicSequenceNumber; + } catch (error) { + logger.error('Error sending message to topic', error as object); + client.close(); + process.exit(1); + } + + logger.log(`Message submitted with sequence number: ${sequenceNumber}`); + client.close(); + }); + + message + .command('find') + .hook('preAction', (thisCommand: Command) => { + const command = [ + thisCommand.parent.action().name(), + ...thisCommand.parent.args, + ]; + stateUtils.recordCommand(command); + }) + .description('Find a message by sequence number') + .option('-s, --sequence-number ', 'The sequence number') + .option('-t, --topic-id ', 'The topic ID') + .option( + '--sequence-number-gt ', + 'The sequence number greater than', + ) + .option( + '--sequence-number-lt ', + 'The sequence number less than', + ) + .option( + '--sequence-number-gte ', + 'The sequence number greater than or equal to', + ) + .option( + '--sequence-number-lte ', + 'The sequence number less than or equal to', + ) + .option( + '--sequence-number-eq ', + 'The sequence number equal to', + ) + .option( + '--sequence-number-ne ', + 'The sequence number not equal to', + ) + .action(async (options: FindMessageOptions) => { + options = dynamicVariablesUtils.replaceOptions(options); // allow dynamic vars for admin-key and submit-key + logger.verbose( + `Finding message for topic: ${options.topicId} and sequence number: ${options.sequenceNumber}`, + ); + + // Define the keys of options we are interested in + const sequenceNumberOptions: string[] = [ + 'sequenceNumberGt', + 'sequenceNumberLt', + 'sequenceNumberGte', + 'sequenceNumberLte', + 'sequenceNumberEq', + 'sequenceNumberNe', + ]; + + // Check if any of the sequence number options is set + const isAnyOptionSet = sequenceNumberOptions.some( + (option: string) => options[option as keyof FindMessageOptions], + ); + + if (!isAnyOptionSet && !options.sequenceNumber) { + logger.error( + 'Please provide a sequence number or a sequence number filter', + ); + return; + } + + if (!isAnyOptionSet) { + // If no sequence number options are set, proceed with the original logic + const response = await api.topic.findMessage( + options.topicId, + Number(options.sequenceNumber), + ); + logger.log( + `Message found: "${Buffer.from( + response.data.message, + 'base64', + ).toString('ascii')}"`, + ); + return; + } + + // Assuming options can include multiple filters + let filters: Filter[] = []; // Populate this based on the options provided + formatFilters(filters, options); + + // Call the new API function + const response = await api.topic.findMessagesWithFilters( + options.topicId, + filters, + ); + + if (response.data.messages.length === 0) { + logger.log('No messages found'); + return; + } + + response.data.messages.forEach((message) => { + logger.log( + `Message ${message.sequence_number}: "${Buffer.from( + message.message, + 'base64', + ).toString('ascii')}"`, + ); + }); + }); +}; + +/** + * Format the filters based on the options provided. + * @param filters The filters to populate. + * @param options The options provided. + */ +function formatFilters(filters: Filter[], options: FindMessageOptions) { + if (options.sequenceNumberGt) { + filters.push({ + field: 'sequencenumber', + operation: 'gt', + value: Number(options.sequenceNumberGt), + }); + } + if (options.sequenceNumberLt) { + filters.push({ + field: 'sequencenumber', + operation: 'lt', + value: Number(options.sequenceNumberLt), + }); + } + if (options.sequenceNumberGte) { + filters.push({ + field: 'sequencenumber', + operation: 'gte', + value: Number(options.sequenceNumberGte), + }); + } + if (options.sequenceNumberLte) { + filters.push({ + field: 'sequencenumber', + operation: 'lte', + value: Number(options.sequenceNumberLte), + }); + } + if (options.sequenceNumberEq) { + filters.push({ + field: 'sequencenumber', + operation: 'eq', + value: Number(options.sequenceNumberEq), + }); + } + if (options.sequenceNumberNe) { + filters.push({ + field: 'sequencenumber', + operation: 'ne', + value: Number(options.sequenceNumberNe), + }); + } +} + +interface SubmitMessageOptions { + message: string; + topicId: string; +} + +interface FindMessageOptions { + sequenceNumber: string; + sequenceNumberGt: string; + sequenceNumberGte: string; + sequenceNumberLt: string; + sequenceNumberLte: string; + sequenceNumberEq: string; + sequenceNumberNe: string; + topicId: string; +} diff --git a/src/hedera-cli.ts b/src/hedera-cli.ts index 94db734c..2f625459 100755 --- a/src/hedera-cli.ts +++ b/src/hedera-cli.ts @@ -28,5 +28,6 @@ commands.backupCommands(program); commands.tokenCommands(program); commands.hbarCommands(program); commands.waitCommands(program); +commands.topicCommands(program); program.parseAsync(process.argv); diff --git a/src/state/base_state.json b/src/state/base_state.json index d2056007..a6859f81 100644 --- a/src/state/base_state.json +++ b/src/state/base_state.json @@ -3,7 +3,7 @@ "mirrorNodeTestnet": "https://testnet.mirrornode.hedera.com/api/v1", "mirrorNodeMainnet": "https://mainnet.mirrornode.hedera.com/api/v1", "testnetOperatorKey": "302e020100300506032b65700422042087592ee314bd0f42c4cf9f82b494481a2bb77bab0dc4454eedfe00f60168646f", - "testnetOperatorId": "0.0.458179", + "testnetOperatorId": "0.0.2221463", "mainnetOperatorKey": "", "mainnetOperatorId": "", "previewnetOperatorId": "", @@ -25,5 +25,6 @@ ] } }, - "tokens": {} + "tokens": {}, + "topics": {} } diff --git a/src/state/config.ts b/src/state/config.ts index 6e2918b6..68d07c75 100644 --- a/src/state/config.ts +++ b/src/state/config.ts @@ -9,6 +9,7 @@ export default { accounts: {}, tokens: {}, scripts: {}, + topics: {}, previewnetOperatorId: '', previewnetOperatorKey: '', testnetOperatorKey: '', diff --git a/src/state/state.json b/src/state/state.json index d2056007..7bc3b079 100644 --- a/src/state/state.json +++ b/src/state/state.json @@ -2,28 +2,18 @@ "network": "testnet", "mirrorNodeTestnet": "https://testnet.mirrornode.hedera.com/api/v1", "mirrorNodeMainnet": "https://mainnet.mirrornode.hedera.com/api/v1", - "testnetOperatorKey": "302e020100300506032b65700422042087592ee314bd0f42c4cf9f82b494481a2bb77bab0dc4454eedfe00f60168646f", - "testnetOperatorId": "0.0.458179", - "mainnetOperatorKey": "", - "mainnetOperatorId": "", - "previewnetOperatorId": "", - "previewnetOperatorKey": "", "recording": 0, "recordingScriptName": "", "scriptExecution": 0, "scriptExecutionName": "", "accounts": {}, - "scripts": { - "script-init": { - "name": "init", - "commands": [ - "network use testnet", - "account create -a alice -b 1000000000 --type ecdsa", - "account create -a bob -b 1000000000 --type ecdsa", - "account create -a clarice -b 1000000000 --type ecdsa", - "state download --url https://raw.githubusercontent.com/hashgraph/hedera-cli/main/src/commands/script/examples.json --merge" - ] - } - }, - "tokens": {} -} + "tokens": {}, + "scripts": {}, + "topics": {}, + "previewnetOperatorId": "", + "previewnetOperatorKey": "", + "testnetOperatorKey": "", + "testnetOperatorId": "", + "mainnetOperatorKey": "", + "mainnetOperatorId": "" +} \ No newline at end of file diff --git a/src/utils/api.ts b/src/utils/api.ts new file mode 100644 index 00000000..2d8cec8f --- /dev/null +++ b/src/utils/api.ts @@ -0,0 +1,27 @@ +import { Filter } from '../../types/api/shared'; + +/** + * Constructs the query part of the URL based on provided filters. + * @param baseUrl The base URL without query parameters. + * @param filters Array of filters to apply. + * @returns The full URL with query parameters. + */ +function constructQueryUrl(baseUrl: string, filters: Filter[]): string { + if (filters.length === 0) { + return baseUrl; + } + + const queryParams = filters.map( + (filter) => + `${encodeURIComponent(filter.field)}=${ + filter.operation + }:${encodeURIComponent(filter.value)}`, + ); + return `${baseUrl}?${queryParams.join('&')}&limit=100`; +} + +const apiUtils = { + constructQueryUrl, +}; + +export default apiUtils; diff --git a/src/utils/dynamicVariables.ts b/src/utils/dynamicVariables.ts index 723135cb..c287729b 100644 --- a/src/utils/dynamicVariables.ts +++ b/src/utils/dynamicVariables.ts @@ -105,6 +105,11 @@ const commandActions: CommandActions = { action: 'tokenCreateFromFile', }, }, + topic: { + create: { + action: 'topicCreate', + }, + }, }; interface CommandOutputs { @@ -150,6 +155,11 @@ const commandOutputs: CommandOutputs = { feeScheduleKey: 'feeScheduleKey', treasuryKey: 'treasuryKey', }, + topicCreate: { + adminKey: 'adminKey', + submitKey: 'submitKey', + topicId: 'topicId', + }, }; const dynamicVariables = { diff --git a/src/utils/sign.ts b/src/utils/sign.ts index 55cbe86c..c45a420c 100644 --- a/src/utils/sign.ts +++ b/src/utils/sign.ts @@ -64,6 +64,9 @@ const signingRequirements: Record> = { tokenCreate: { sign: ['treasuryKey', 'adminKey'], }, + topicCreate: { + sign: ['adminKey', 'submitKey'], + }, }; const signUtils = { diff --git a/src/utils/state.ts b/src/utils/state.ts index 0220e66e..fe2cbd97 100644 --- a/src/utils/state.ts +++ b/src/utils/state.ts @@ -4,7 +4,7 @@ import axios from 'axios'; import { Logger } from '../utils/logger'; import stateController from '../state/stateController'; -import type { Account, Script, Token } from '../../types'; +import type { Account, Script, Token, Topic } from '../../types'; const logger = Logger.getInstance(); @@ -204,6 +204,7 @@ function clearState(): void { state.accounts = {}; state.tokens = {}; state.scripts = {}; + state.topics = {}; state.scriptExecution = 0; state.scriptExecutionName = ''; state.recording = 0; @@ -234,6 +235,7 @@ function importState(data: any, overwrite: boolean, merge: boolean) { stateController.saveKey('accounts', data.accounts || {}); stateController.saveKey('tokens', data.tokens || {}); stateController.saveKey('scripts', data.scripts || {}); + stateController.saveKey('topics', data.topics || {}); logger.log('State overwritten successfully'); process.exit(0); } @@ -249,6 +251,10 @@ function importState(data: any, overwrite: boolean, merge: boolean) { if (data.scripts && Object.entries(data.scripts).length > 0) { addScripts(data.scripts, merge); } + + if (data.topics && Object.entries(data.topics).length > 0) { + addTopics(data.topics, merge); + } } function addScripts(importedScripts: Script[], merge: boolean) { @@ -324,6 +330,26 @@ function addTokens(importedTokens: Token[], merge: boolean) { }); } +function addTopics(importedTopics: Topic[], merge: boolean) { + const topics: Record = stateController.get('topics'); + Object.values(importedTopics).forEach((topic: Topic) => { + const existingTopic = topics[topic.topicId]; + + if (!merge && existingTopic) { + logger.error(`Topic with ID ${topic.topicId} already exists`); + process.exit(1); + } + + if (merge && existingTopic) { + logger.log(`Topic ${topic.topicId} already exists, overwriting it`); + } + + topics[topic.topicId] = topic; + stateController.saveKey('topics', topics); + logger.log(`Topic ${topic.topicId} added successfully`); + }); +} + const stateUtils = { getMirrorNodeURL, getHederaClient, diff --git a/src/utils/token.ts b/src/utils/token.ts index 57c8df17..1f6180ff 100644 --- a/src/utils/token.ts +++ b/src/utils/token.ts @@ -1,6 +1,5 @@ import { TokenAssociateTransaction, - PrivateKey, TokenSupplyType, TransferTransaction, } from '@hashgraph/sdk'; diff --git a/src/utils/topic.ts b/src/utils/topic.ts new file mode 100644 index 00000000..63eb082f --- /dev/null +++ b/src/utils/topic.ts @@ -0,0 +1,28 @@ +import stateController from '../state/stateController'; +import { Logger } from './logger'; + +import type { Topic } from '../../types'; + +const logger = Logger.getInstance(); + +function list() { + const topics: Topic[] = Object.values(stateController.get('topics')); + + if (topics.length === 0) { + logger.log('No topics found'); + return; + } + + logger.log('Topics:'); + topics.forEach((topic) => { + logger.log(`\tTopic ID: ${topic.topicId}`); + logger.log(`\t\t- Submit key: ${topic.submitKey ? 'Yes' : 'No'}`); + logger.log(`\t\t- Admin key: ${topic.adminKey ? 'Yes' : 'No'}`); + }); +} + +const topicUtils = { + list, +}; + +export default topicUtils; diff --git a/types/api/index.d.ts b/types/api/index.d.ts index 0640699b..853392cb 100644 --- a/types/api/index.d.ts +++ b/types/api/index.d.ts @@ -1,10 +1,13 @@ import type { AccountResponse } from "./account.d.ts"; import type { TokenResponse, BalanceResponse } from "./token.d.ts"; +import type { TopicResponse } from "./topic.d.ts"; -type APIResponseTypes = AccountResponse | TokenResponse | BalanceResponse; +type APIResponseTypes = AccountResponse | TokenResponse | BalanceResponse | TopicResponse; export type APIResponse = { data: T; } export type * from './account.d.ts'; -export type * from './token.d.ts'; \ No newline at end of file +export type * from './token.d.ts'; +export type * from './topic.d.ts'; +export type * from './shared.d.ts'; \ No newline at end of file diff --git a/types/api/shared.d.ts b/types/api/shared.d.ts index 5cb8831a..9ef2bb7f 100644 --- a/types/api/shared.d.ts +++ b/types/api/shared.d.ts @@ -2,3 +2,9 @@ export interface Key { _type: string; key: string; } + +export interface Filter { + field: string; + operation: 'gt' | 'lt' | 'gte' | 'lte' | 'eq' | 'ne'; + value: number | string; +} \ No newline at end of file diff --git a/types/api/topic.d.ts b/types/api/topic.d.ts new file mode 100644 index 00000000..6fee7a98 --- /dev/null +++ b/types/api/topic.d.ts @@ -0,0 +1,26 @@ +export type TopicMessageResponse = { + chunk_info: { + initial_transaction_id: { + account_id: string; + nonce: number; + scheduled: boolean; + transaction_valid_start: string; + }; + number: number; + total: number; + }; + consensus_timestamp: string; + message: string; + payer_account_id: string; + running_hash: string; + running_hash_version: number; + sequence_number: number; + topic_id: string; +}; + +export interface TopicMessagesResponse { + messages: TopicResponse[]; + links: { + next: string | null; + }; +} \ No newline at end of file diff --git a/types/state.d.ts b/types/state.d.ts index 667250bd..4800835b 100644 --- a/types/state.d.ts +++ b/types/state.d.ts @@ -2,6 +2,13 @@ import { TokenSupplyType, } from "@hashgraph/sdk"; +export type Topic = { + topicId: string; + memo?: string; + adminKey?: string; + submitKey?: string; +} + export type Account = { network: string; alias: string; @@ -62,6 +69,7 @@ export interface State { accounts: Record; scripts: Record; tokens: Record; + topics: Record; previewnetOperatorKey: string; previewnetOperatorId: string; testnetOperatorKey: string;