From b850a816e3e3be3cea11b6ea70aa6200c77f9969 Mon Sep 17 00:00:00 2001 From: Michael Kantor <6068672+kantorcodes@users.noreply.github.com> Date: Mon, 20 Oct 2025 15:36:10 -0400 Subject: [PATCH 1/2] Ensure HCS-8 demo shuts down client cleanly --- __tests__/hcs-8/sdk.test.ts | 71 ++++++++ __tests__/hcs-8/state.test.ts | 110 +++++++++++++ __tests__/hcs-9/metadata.test.ts | 73 +++++++++ demo/hcs-8/poll-demo.log | 15 ++ demo/hcs-8/poll-demo.ts | 92 +++++++++++ package.json | 10 +- pnpm-lock.yaml | 12 ++ src/hcs-8/assembler.ts | 87 ++++++++++ src/hcs-8/base-client.ts | 168 +++++++++++++++++++ src/hcs-8/builders.ts | 150 +++++++++++++++++ src/hcs-8/index.ts | 7 + src/hcs-8/parser.ts | 60 +++++++ src/hcs-8/sdk.ts | 267 +++++++++++++++++++++++++++++++ src/hcs-8/state.ts | 253 +++++++++++++++++++++++++++++ src/hcs-8/types.ts | 168 +++++++++++++++++++ src/hcs-9/evaluator.ts | 223 ++++++++++++++++++++++++++ src/hcs-9/index.ts | 2 + src/hcs-9/types.ts | 154 ++++++++++++++++++ src/index.ts | 2 + src/services/mirror-node.ts | 49 ++++-- 20 files changed, 1960 insertions(+), 13 deletions(-) create mode 100644 __tests__/hcs-8/sdk.test.ts create mode 100644 __tests__/hcs-8/state.test.ts create mode 100644 __tests__/hcs-9/metadata.test.ts create mode 100644 demo/hcs-8/poll-demo.log create mode 100644 demo/hcs-8/poll-demo.ts create mode 100644 src/hcs-8/assembler.ts create mode 100644 src/hcs-8/base-client.ts create mode 100644 src/hcs-8/builders.ts create mode 100644 src/hcs-8/index.ts create mode 100644 src/hcs-8/parser.ts create mode 100644 src/hcs-8/sdk.ts create mode 100644 src/hcs-8/state.ts create mode 100644 src/hcs-8/types.ts create mode 100644 src/hcs-9/evaluator.ts create mode 100644 src/hcs-9/index.ts create mode 100644 src/hcs-9/types.ts diff --git a/__tests__/hcs-8/sdk.test.ts b/__tests__/hcs-8/sdk.test.ts new file mode 100644 index 00000000..2c1ec0a3 --- /dev/null +++ b/__tests__/hcs-8/sdk.test.ts @@ -0,0 +1,71 @@ +import { PrivateKey } from '@hashgraph/sdk'; +import { Hcs8Client } from '../../src/hcs-8'; +import { HederaMirrorNode } from '../../src/services/mirror-node'; + +describe('HCS-8 SDK client selection', () => { + const originalEnv = { ...process.env }; + const operatorKey = PrivateKey.generateED25519().toString(); + + beforeEach(() => { + jest.restoreAllMocks(); + process.env = { ...originalEnv }; + jest + .spyOn(HederaMirrorNode.prototype, 'requestAccount') + .mockResolvedValue({ key: { _type: 'ED25519' } } as any); + Hcs8Client.__resetProxyAgentForTests(); + }); + + afterAll(() => { + process.env = originalEnv; + }); + + const createClient = (config: Partial[0]> = {}) => + new Hcs8Client({ + network: 'testnet', + operatorId: '0.0.1001', + operatorKey, + silent: true, + ...config, + }); + + it('uses the native NodeClient when no proxy is present', () => { + delete process.env.HTTPS_PROXY; + delete process.env.https_proxy; + delete process.env.HTTP_PROXY; + delete process.env.http_proxy; + const client = createClient(); + expect(client.getClient().constructor.name).toBe('NodeClient'); + }); + + it('prefers the WebClient when forced via configuration', () => { + delete process.env.HTTPS_PROXY; + delete process.env.https_proxy; + delete process.env.HTTP_PROXY; + delete process.env.http_proxy; + const client = createClient({ forceWebClient: true }); + expect(client.getClient().constructor.name).toBe('WebClient'); + }); + + it('enables the WebClient automatically when a proxy is detected', () => { + process.env.HTTPS_PROXY = 'http://proxy:8080'; + + const client = createClient(); + expect(client.getClient().constructor.name).toBe('WebClient'); + expect(Hcs8Client.__isProxyConfiguredForTests()).toBe(true); + }); + + it('closes the underlying SDK client when requested', () => { + const client = createClient(); + const sdkClient = client.getClient(); + const closeSpy = jest + .spyOn(sdkClient, 'close') + .mockImplementation(() => undefined); + + client.close(); + + expect(closeSpy).toHaveBeenCalledTimes(1); + + closeSpy.mockRestore(); + sdkClient.close(); + }); +}); diff --git a/__tests__/hcs-8/state.test.ts b/__tests__/hcs-8/state.test.ts new file mode 100644 index 00000000..9001995e --- /dev/null +++ b/__tests__/hcs-8/state.test.ts @@ -0,0 +1,110 @@ +import { PollStateMachine } from '../../src/hcs-8/state'; +import { + buildManageMessage, + buildRegisterMessage, + buildVoteMessage, + buildUpdateMessage, + buildRegisterChunks, +} from '../../src/hcs-8/builders'; +import { RegisterSequenceAssembler } from '../../src/hcs-8/assembler'; +import { PollMetadata } from '../../src/hcs-9'; + +const baseMetadata: PollMetadata = { + schema: 'hcs-9', + title: 'Hashgraph Roadmap', + description: 'Vote for the next feature to build', + author: '0.0.1001', + votingRules: { + schema: 'hcs-9', + allocations: [{ schema: 'hcs-9:equal-weight', weight: 1 }], + permissions: [{ schema: 'hcs-9:allow-all' }], + rules: [{ name: 'allowVoteChanges' }, { name: 'allowMultipleChoice' }], + }, + permissionsRules: [{ schema: 'hcs-9:allow-all' }], + manageRules: { schema: 'hcs-9', permissions: [{ schema: 'hcs-9:allow-author' }] }, + updateRules: { + schema: 'hcs-9', + permissions: [{ schema: 'hcs-9:allow-author' }], + updateSettings: { endDate: true }, + }, + options: [ + { schema: 'hcs-9', id: 0, title: 'Smart Contract Toolkit' }, + { schema: 'hcs-9', id: 1, title: 'Wallet Integrations' }, + ], + status: 'inactive', + startDate: '1720000000', + endConditionRules: [{ schema: 'hcs-9:end-date', endDate: '1720003600' }], +}; + +describe('HCS-8 poll state machine', () => { + it('processes register, manage, vote and close lifecycle', () => { + const machine = new PollStateMachine(); + machine.apply(buildRegisterMessage(baseMetadata), '1'); + expect(machine.getState().metadata?.title).toBe('Hashgraph Roadmap'); + expect(machine.getState().status).toBe('inactive'); + + machine.apply(buildManageMessage(baseMetadata.author, 'open'), '2'); + expect(machine.getState().status).toBe('active'); + + const voteMessage = buildVoteMessage(baseMetadata.author, [ + { accountId: baseMetadata.author, optionId: 0, weight: 1 }, + ]); + machine.apply(voteMessage, '3'); + + const stateAfterVote = machine.getState(); + expect(stateAfterVote.results.totalWeight).toBe(1); + expect(stateAfterVote.results.optionWeight.get(0)).toBe(1); + + machine.apply(buildManageMessage(baseMetadata.author, 'close'), '4'); + expect(machine.getState().status).toBe('closed'); + }); + + it('rejects unauthorised updates', () => { + const machine = new PollStateMachine(); + machine.apply(buildRegisterMessage(baseMetadata), '1'); + machine.apply( + buildUpdateMessage('0.0.other', { endDate: '1720009999' }), + '2', + ); + expect(machine.getState().errors.some((error) => error.operation === 'update')).toBe(true); + }); + + it('supports multi-message register assembly', () => { + const assembler = new RegisterSequenceAssembler(); + const chunks = buildRegisterChunks(baseMetadata, 'chunked register', { chunkSize: 40 }); + expect(chunks.length).toBeGreaterThan(1); + + let complete; + chunks.forEach((chunk, index) => { + const parsed = assembler.ingest(chunk, `${index}`); + if (parsed) { + complete = parsed.message; + } + }); + + expect(complete).toBeDefined(); + const machine = new PollStateMachine(); + machine.apply(complete!, 'final'); + expect(machine.getState().metadata?.title).toBe(baseMetadata.title); + }); + + it('enforces vote weight allocations and changes', () => { + const machine = new PollStateMachine(); + machine.apply(buildRegisterMessage(baseMetadata), '1'); + machine.apply(buildManageMessage(baseMetadata.author, 'open'), '2'); + + const firstVote = buildVoteMessage('0.0.2001', [ + { accountId: '0.0.2001', optionId: 0, weight: 1 }, + ]); + machine.apply(firstVote, '3'); + + const secondVote = buildVoteMessage('0.0.2001', [ + { accountId: '0.0.2001', optionId: 1, weight: 1 }, + ]); + machine.apply(secondVote, '4'); + + const state = machine.getState(); + expect(state.results.optionWeight.get(0)).toBeUndefined(); + expect(state.results.optionWeight.get(1)).toBe(1); + }); +}); diff --git a/__tests__/hcs-9/metadata.test.ts b/__tests__/hcs-9/metadata.test.ts new file mode 100644 index 00000000..83387ea4 --- /dev/null +++ b/__tests__/hcs-9/metadata.test.ts @@ -0,0 +1,73 @@ +import { parsePollMetadata, pollMetadataSchema } from '../../src/hcs-9'; + +describe('HCS-9 poll metadata schema', () => { + const baseMetadata = { + schema: 'hcs-9' as const, + title: 'Community Survey', + description: 'A poll to determine roadmap priorities.', + author: '0.0.1001', + votingRules: { + schema: 'hcs-9' as const, + allocations: [{ schema: 'hcs-9:equal-weight' as const, weight: 1 }], + permissions: [{ schema: 'hcs-9:allow-all' as const }], + rules: [{ name: 'allowVoteChanges' as const }], + }, + permissionsRules: [{ schema: 'hcs-9:allow-all' as const }], + manageRules: { + schema: 'hcs-9' as const, + permissions: [{ schema: 'hcs-9:allow-author' as const }], + }, + updateRules: { + schema: 'hcs-9' as const, + permissions: [{ schema: 'hcs-9:allow-author' as const }], + updateSettings: { + endDate: true, + description: true, + }, + }, + options: [ + { schema: 'hcs-9' as const, id: 0, title: 'Feature A' }, + { schema: 'hcs-9' as const, id: 1, title: 'Feature B' }, + ], + status: 'inactive' as const, + startDate: '1720000000', + endConditionRules: [{ schema: 'hcs-9:end-date' as const, endDate: '1720003600' }], + }; + + it('validates a complete metadata payload', () => { + expect(() => parsePollMetadata(baseMetadata)).not.toThrow(); + }); + + it('rejects metadata with duplicate option structure errors', () => { + const invalid = { + ...baseMetadata, + options: [{ schema: 'hcs-9' as const, id: 0, title: '' }], + }; + expect(() => parsePollMetadata(invalid)).toThrow(); + }); + + it('requires the schema to be hcs-9', () => { + const invalid = { ...baseMetadata, schema: 'custom' }; + expect(() => pollMetadataSchema.parse(invalid)).toThrow(); + }); + + it('supports fixed weight allocations', () => { + const metadata = { + ...baseMetadata, + votingRules: { + ...baseMetadata.votingRules, + allocations: [ + { + schema: 'hcs-9:fixed-weight' as const, + allocations: [ + { accountId: '0.0.2001', weight: 5 }, + { accountId: '0.0.2002', weight: 2 }, + ], + defaultWeight: 1, + }, + ], + }, + }; + expect(() => parsePollMetadata(metadata)).not.toThrow(); + }); +}); diff --git a/demo/hcs-8/poll-demo.log b/demo/hcs-8/poll-demo.log new file mode 100644 index 00000000..24eeb616 --- /dev/null +++ b/demo/hcs-8/poll-demo.log @@ -0,0 +1,15 @@ +@hashgraphonline/standards-sdk@0.1.116-canary.1 demo:hcs-8 /workspace/standards-sdk +tsx demo/hcs-8/poll-demo.ts + +2025-10-19T18:14:13.297Z INFO [HCS8BaseClient] Configuring mirror node proxy: http://proxy:8080 +Creating poll topic... +Topic created: 0.0.7094410 +Submitting register message... +Opening poll... +Casting vote for option 0 +Closing poll... +Waiting for mirror node propagation... +Fetching poll state from mirror node... +Poll status: closed +Total vote weight: 1 +Option 0 weight: 1 diff --git a/demo/hcs-8/poll-demo.ts b/demo/hcs-8/poll-demo.ts new file mode 100644 index 00000000..7edb4af0 --- /dev/null +++ b/demo/hcs-8/poll-demo.ts @@ -0,0 +1,92 @@ +import 'dotenv/config'; +import { Hcs8Client } from '../../src/hcs-8'; +import { PollMetadata } from '../../src/hcs-9'; + +async function main() { + const operatorId = process.env.ACCOUNT_ID; + const operatorKey = process.env.PRIVATE_KEY; + if (!operatorId || !operatorKey) { + throw new Error('ACCOUNT_ID and PRIVATE_KEY must be set in the environment.'); + } + + const client = new Hcs8Client({ + network: 'testnet', + operatorId, + operatorKey, + logLevel: 'info', + }); + + const metadata: PollMetadata = { + schema: 'hcs-9', + title: 'HCS-8 Demo Poll', + description: 'Choose the focus for the next SDK milestone.', + author: operatorId, + votingRules: { + schema: 'hcs-9', + allocations: [{ schema: 'hcs-9:equal-weight', weight: 1 }], + permissions: [{ schema: 'hcs-9:allow-all' }], + rules: [{ name: 'allowVoteChanges' }], + }, + permissionsRules: [{ schema: 'hcs-9:allow-all' }], + manageRules: { + schema: 'hcs-9', + permissions: [{ schema: 'hcs-9:allow-author' }], + }, + updateRules: { + schema: 'hcs-9', + permissions: [{ schema: 'hcs-9:allow-author' }], + updateSettings: { endDate: true }, + }, + options: [ + { schema: 'hcs-9', id: 0, title: 'Developer Tooling' }, + { schema: 'hcs-9', id: 1, title: 'Network Integrations' }, + ], + status: 'inactive', + startDate: `${Math.floor(Date.now() / 1000)}`, + endConditionRules: [ + { schema: 'hcs-9:end-date', endDate: `${Math.floor(Date.now() / 1000) + 86400}` }, + ], + }; + + try { + console.log('Creating poll topic...'); + const { topicId } = await client.createPollTopic(); + console.log(`Topic created: ${topicId}`); + + console.log('Submitting register message...'); + await client.submitRegister(topicId, metadata, 'Register demo poll'); + + console.log('Opening poll...'); + await client.submitManage(topicId, operatorId, 'open', 'Open poll for voting'); + + console.log('Casting vote for option 0'); + await client.submitVote(topicId, operatorId, [ + { accountId: operatorId, optionId: 0, weight: 1 }, + ]); + + console.log('Closing poll...'); + await client.submitManage(topicId, operatorId, 'close', 'Poll concluded'); + + console.log('Waiting for mirror node propagation...'); + await new Promise(resolve => setTimeout(resolve, 5000)); + + console.log('Fetching poll state from mirror node...'); + const state = await client.getPollState(topicId); + console.log('Poll status:', state.status); + console.log('Total vote weight:', state.results.totalWeight); + for (const [optionId, weight] of state.results.optionWeight.entries()) { + console.log(`Option ${optionId} weight: ${weight}`); + } + } finally { + client.close(); + } +} + +main() + .then(() => { + process.exit(0); + }) + .catch((error) => { + console.error(error); + process.exit(1); + }); diff --git a/package.json b/package.json index ef86e732..92b95dcb 100644 --- a/package.json +++ b/package.json @@ -80,6 +80,7 @@ "demo:hcs-20:mint-transfer-burn": "tsx demo/hcs-20/mint-transfer-burn.ts", "demo:hcs-6:browser": "pnpm run build && pnpm --dir demo/hcs-6/browser i && pnpm --dir demo/hcs-6/browser dev", "demo:hcs-5": "tsx demo/hcs-5/mint-hashinal.ts", + "demo:hcs-8": "tsx demo/hcs-8/poll-demo.ts", "demo:registry-broker": "tsx demo/registry-broker-demo.ts", "watch": "nodemon --watch src --ext ts,tsx --exec \"npm run build && yalc push\"", "test": "jest", @@ -101,9 +102,9 @@ "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", "json-to-plain-text": "^1.1.4", + "localtunnel": "^2.0.2", "nodemon": "^3.1.0", "openai": "^4.91.1", - "localtunnel": "^2.0.2", "prettier": "^3.1.0", "process": "^0.11.10", "rimraf": "^6.0.1", @@ -115,12 +116,12 @@ "vite-plugin-node-polyfills": "^0.24.0" }, "dependencies": { - "@hiero-did-sdk/registrar": "^0.1.2", - "@hiero-did-sdk/resolver": "^0.1.2", "@hashgraph/hedera-wallet-connect": "^1.5.1-0", "@hashgraph/proto": "^2.22.0", "@hashgraph/sdk": "2.72.0", "@hashgraphonline/hashinal-wc": "^1.0.103", + "@hiero-did-sdk/registrar": "^0.1.2", + "@hiero-did-sdk/resolver": "^0.1.2", "@kiloscribe/inscription-sdk": "^1.0.60", "axios": "^1.10.0", "bignumber.js": "^9.3.1", @@ -129,13 +130,14 @@ "dotenv": "^16.4.7", "ethers": "^6.13.5", "file-type": "^20.4.1", + "https-proxy-agent": "^7.0.6", "ioredis": "^5.6.0", "mime-types": "^2.1.35", "pino": "^9.7.0", "pino-pretty": "^13.0.0", + "undici": "^7.16.0", "zod": "^3.24.2" }, - "peerDependencies": {}, "peerDependenciesMeta": {}, "packageManager": "pnpm@10.11.1+sha512.e519b9f7639869dc8d5c3c5dfef73b3f091094b0a006d7317353c72b124e80e1afd429732e28705ad6bfa1ee879c1fce46c128ccebd3192101f43dd67c667912" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1d926935..febbe12f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,6 +50,9 @@ importers: file-type: specifier: ^20.4.1 version: 20.5.0 + https-proxy-agent: + specifier: ^7.0.6 + version: 7.0.6 ioredis: specifier: ^5.6.0 version: 5.6.1 @@ -62,6 +65,9 @@ importers: pino-pretty: specifier: ^13.0.0 version: 13.0.0 + undici: + specifier: ^7.16.0 + version: 7.16.0 zod: specifier: ^3.24.2 version: 3.25.76 @@ -5814,6 +5820,10 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici@7.16.0: + resolution: {integrity: sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==} + engines: {node: '>=20.18.1'} + unfetch@4.2.0: resolution: {integrity: sha512-F9p7yYCn6cIW9El1zi0HI6vqpeIvBsr3dSuRO6Xuppb1u5rXpCPmMvLSyECLhybr9isec8Ohl0hPekMVrEinDA==} @@ -13999,6 +14009,8 @@ snapshots: undici-types@6.21.0: {} + undici@7.16.0: {} + unfetch@4.2.0: {} universal-user-agent@7.0.3: {} diff --git a/src/hcs-8/assembler.ts b/src/hcs-8/assembler.ts new file mode 100644 index 00000000..9cf372c7 --- /dev/null +++ b/src/hcs-8/assembler.ts @@ -0,0 +1,87 @@ +import { parseRegisterPayload } from './parser'; +import { + Hcs8BaseMessage, + Hcs8Operation, + SequenceAssemblyContext, + Hcs8RegisterMessage, +} from './types'; + +export interface RegisterAssemblyResult { + message: Hcs8RegisterMessage; + context?: SequenceAssemblyContext; +} + +export class RegisterSequenceAssembler { + private readonly sequences = new Map(); + private lastUid = -1; + + public ingest( + message: Hcs8BaseMessage, + timestamp: string, + ): RegisterAssemblyResult | null { + if (message.op !== 'register') { + throw new Error('Sequence assembler only accepts register operations'); + } + + if (!message.sid) { + const register: Hcs8RegisterMessage = { + ...message, + d: parseRegisterPayload(message.d), + }; + return { message: register }; + } + + const [uid, num, len] = message.sid; + if (this.lastUid >= 0 && uid < this.lastUid) { + throw new Error('Sequence uid must be monotonically increasing'); + } + this.lastUid = Math.max(this.lastUid, uid); + + let context = this.sequences.get(uid); + if (!context) { + context = { + uid, + op: message.op as Hcs8Operation, + length: len, + payloads: Array(len).fill(''), + memo: message.m, + firstTimestamp: timestamp, + lastTimestamp: timestamp, + }; + this.sequences.set(uid, context); + } + + if (context.op !== message.op) { + throw new Error('Sequence messages must share the same operation'); + } + if (context.length !== len) { + throw new Error('Sequence length changed unexpectedly'); + } + + if (num >= context.payloads.length) { + throw new Error('Sequence index exceeds declared length'); + } + + context.payloads[num] = + typeof message.d === 'string' ? message.d : JSON.stringify(message.d); + context.lastTimestamp = timestamp; + + if (context.payloads.some((chunk) => chunk.length === 0)) { + return null; + } + + this.sequences.delete(uid); + const combinedPayload = context.payloads.join(''); + const payload = parseRegisterPayload(combinedPayload); + const register: Hcs8RegisterMessage = { + ...message, + sid: undefined, + d: payload, + m: context.memo ?? message.m, + }; + return { + message: register, + context, + }; + } +} diff --git a/src/hcs-8/base-client.ts b/src/hcs-8/base-client.ts new file mode 100644 index 00000000..ca0df7d9 --- /dev/null +++ b/src/hcs-8/base-client.ts @@ -0,0 +1,168 @@ +import { Logger, type ILogger } from '../utils/logger'; +import { HederaMirrorNode } from '../services/mirror-node'; +import { NetworkType } from '../utils/types'; +import type { MirrorNodeConfig } from '../services'; +import { RegisterSequenceAssembler } from './assembler'; +import { + decodeMessage, + parseManagePayload, + parseUpdatePayload, + parseVotePayload, +} from './parser'; +import { + AnyHcs8Message, + Hcs8BaseMessage, + Hcs8ClientConfig, + PollProcessingOptions, + ParsedTopicMessage, +} from './types'; +import { PollStateMachine } from './state'; +import type { TopicMessage } from '../services/types'; + +export class Hcs8BaseClient { + protected readonly logger: ILogger; + protected readonly mirrorNode: HederaMirrorNode; + protected readonly network: NetworkType; + private readonly assembler = new RegisterSequenceAssembler(); + + constructor(config: Hcs8ClientConfig) { + this.network = config.network; + this.logger = + config.logger || + Logger.getInstance({ + level: config.logLevel || 'info', + module: 'HCS8BaseClient', + silent: config.silent, + }); + + this.mirrorNode = new HederaMirrorNode( + this.network, + this.logger, + config.mirrorNode, + ); + } + + public async getPollState( + topicId: string, + options: PollProcessingOptions = {}, + ) { + const rawMessages = await this.mirrorNode.getTopicMessages(topicId, { + limit: 200, + order: 'asc', + }); + + const stateMachine = new PollStateMachine(); + for (const message of rawMessages) { + const payload = this.extractMessagePayload(message); + if (!payload) { + this.logger.warn('Skipping HCS-8 message without payload content'); + continue; + } + const normalized = { ...message, message: payload } as TopicMessage; + if ( + options.stopAtTimestamp && + normalized.consensus_timestamp > options.stopAtTimestamp + ) { + break; + } + const parsed = this.parseTopicMessage(normalized); + if (!parsed) { + continue; + } + stateMachine.apply(parsed.message, parsed.timestamp); + } + + return stateMachine.getState(); + } + + public configureMirrorNode(config: MirrorNodeConfig): void { + this.mirrorNode.configureMirrorNode(config); + } + + protected parseTopicMessage(message: TopicMessage): ParsedTopicMessage | null { + try { + const decoded = decodeMessage(message.message); + if (decoded.op === 'register') { + const result = this.assembler.ingest(decoded, message.consensus_timestamp); + if (!result) { + return null; + } + return { + raw: message.message, + timestamp: message.consensus_timestamp, + payerAccountId: message.payer_account_id, + message: result.message, + }; + } + + const payload = this.parseOperationPayload(decoded, message); + if (!payload) { + return null; + } + return { + raw: message.message, + timestamp: message.consensus_timestamp, + payerAccountId: message.payer_account_id, + message: payload, + }; + } catch (error) { + this.logger.error(`Failed to parse hcs-8 message: ${error}`); + return null; + } + } + + private parseOperationPayload( + decoded: Hcs8BaseMessage, + message: TopicMessage, + ): AnyHcs8Message | null { + switch (decoded.op) { + case 'manage': + return { + ...decoded, + d: parseManagePayload(decoded.d), + } as AnyHcs8Message; + case 'update': + return { + ...decoded, + d: parseUpdatePayload(decoded.d), + } as AnyHcs8Message; + case 'vote': { + const accountId = message.payer_account_id; + if (!accountId) { + this.logger.warn('Skipping vote message without payer account id'); + return null; + } + return { + ...decoded, + d: parseVotePayload(decoded.d, accountId), + } as AnyHcs8Message; + } + default: + this.logger.warn(`Unsupported hcs-8 operation ${decoded.op}`); + return null; + } + } + + private extractMessagePayload(message: TopicMessage & { decoded_message?: string }): string | null { + if (message.decoded_message) { + return message.decoded_message; + } + const raw = message.message; + if (typeof raw !== 'string') { + return null; + } + try { + if (typeof Buffer !== 'undefined') { + return Buffer.from(raw, 'base64').toString('utf-8'); + } + if (typeof atob === 'function') { + const bytes = Uint8Array.from(atob(raw), char => char.charCodeAt(0)); + return new TextDecoder().decode(bytes); + } + return raw; + } catch (error) { + this.logger.error(`Failed to decode HCS-8 message payload: ${error}`); + return null; + } + } +} diff --git a/src/hcs-8/builders.ts b/src/hcs-8/builders.ts new file mode 100644 index 00000000..b991f9fa --- /dev/null +++ b/src/hcs-8/builders.ts @@ -0,0 +1,150 @@ +import { pollMetadataSchema, PollMetadata, VoteEntry } from '../hcs-9'; +import { + Hcs8ManageAction, + Hcs8ManageMessage, + Hcs8RegisterChunkMessage, + Hcs8RegisterMessage, + Hcs8UpdateMessage, + Hcs8VoteMessage, + SequenceInfo, + managePayloadSchema, + registerPayloadSchema, + updateChangeSchema, + updatePayloadSchema, + votePayloadSchema, +} from './types'; + +const DATA_PREFIX = 'data:application/json;utf8,'; + +function encodePayload(data: unknown): string { + return `${DATA_PREFIX}${JSON.stringify(data)}`; +} + +export function buildRegisterMessage( + metadata: PollMetadata, + memo?: string, +): Hcs8RegisterMessage { + const validated = pollMetadataSchema.parse(metadata); + const payload = registerPayloadSchema.parse({ metadata: validated }); + return { + p: 'hcs-8', + op: 'register', + d: payload, + m: memo, + }; +} + +export function buildManageMessage( + accountId: string, + action: Hcs8ManageAction, + memo?: string, +): Hcs8ManageMessage { + const payload = managePayloadSchema.parse({ accountId, action }); + return { + p: 'hcs-8', + op: 'manage', + d: payload, + m: memo, + }; +} + +export function buildUpdateMessage( + accountId: string, + change: Hcs8UpdateMessage['d']['change'], + memo?: string, +): Hcs8UpdateMessage { + const parsedChange = change ? updateChangeSchema.parse(change) : undefined; + if (!parsedChange || Object.keys(parsedChange).length === 0) { + throw new Error('Update change requires at least one field'); + } + const payload = updatePayloadSchema.parse({ accountId, change: parsedChange }); + return { + p: 'hcs-8', + op: 'update', + d: payload, + m: memo, + }; +} + +export function buildVoteMessage( + accountId: string, + votes: VoteEntry[], + memo?: string, +): Hcs8VoteMessage { + const payload = votePayloadSchema.parse({ accountId, votes }); + return { + p: 'hcs-8', + op: 'vote', + d: payload, + m: memo, + }; +} + +export function encodeMessagePayload( + message: + | Hcs8RegisterMessage + | Hcs8ManageMessage + | Hcs8UpdateMessage + | Hcs8VoteMessage, +): string { + const toEncode: Record = { ...message }; + switch (message.op) { + case 'register': + toEncode.d = encodePayload(message.d.metadata); + break; + case 'manage': + toEncode.d = encodePayload(message.d); + break; + case 'update': + toEncode.d = encodePayload(message.d); + break; + case 'vote': + toEncode.d = encodePayload(message.d.votes); + break; + } + return JSON.stringify(toEncode); +} + +function chunkString(data: string, chunkSize: number): string[] { + const chunks: string[] = []; + let offset = 0; + while (offset < data.length) { + chunks.push(data.slice(offset, offset + chunkSize)); + offset += chunkSize; + } + return chunks; +} + +export interface RegisterChunkOptions { + uid?: number; + chunkSize?: number; +} + +export function buildRegisterChunks( + metadata: PollMetadata, + memo?: string, + options: RegisterChunkOptions = {}, +): Hcs8RegisterChunkMessage[] { + const encoded = encodePayload(pollMetadataSchema.parse(metadata)); + const chunkSize = options.chunkSize ?? 900; + const uid = options.uid ?? 0; + if (Buffer.byteLength(encoded, 'utf8') <= chunkSize) { + return [ + { + p: 'hcs-8', + op: 'register', + d: encoded, + m: memo, + }, + ]; + } + + const segments = chunkString(encoded, chunkSize); + return segments.map((segment, index) => ({ + p: 'hcs-8' as const, + op: 'register' as const, + sid: [uid, index, segments.length] satisfies SequenceInfo, + d: segment, + m: index === 0 ? memo : undefined, + })); +} diff --git a/src/hcs-8/index.ts b/src/hcs-8/index.ts new file mode 100644 index 00000000..5a6bc644 --- /dev/null +++ b/src/hcs-8/index.ts @@ -0,0 +1,7 @@ +export * from './types'; +export * from './parser'; +export * from './assembler'; +export * from './state'; +export * from './builders'; +export * from './base-client'; +export * from './sdk'; diff --git a/src/hcs-8/parser.ts b/src/hcs-8/parser.ts new file mode 100644 index 00000000..578ad4c2 --- /dev/null +++ b/src/hcs-8/parser.ts @@ -0,0 +1,60 @@ +import { parsePollMetadata, parseVoteEntries } from '../hcs-9'; +import { + Hcs8BaseMessage, + ManagePayload, + RegisterPayload, + UpdatePayload, + VotePayload, + hcs8BaseMessageSchema, + managePayloadSchema, + registerPayloadSchema, + updatePayloadSchema, + votePayloadSchema, +} from './types'; + +const DATA_PREFIX = 'data:application/json;utf8,'; + +function normaliseDataField(data: unknown): unknown { + if (typeof data !== 'string') { + return data; + } + + if (data.startsWith(DATA_PREFIX)) { + const trimmed = data.slice(DATA_PREFIX.length); + return trimmed.trim(); + } + return data.trim(); +} + +export function decodeMessage(raw: string): Hcs8BaseMessage { + const parsed = JSON.parse(raw); + return hcs8BaseMessageSchema.parse(parsed); +} + +export function parseRegisterPayload(data: unknown): RegisterPayload { + const json = normaliseDataField(data); + const obj = typeof json === 'string' ? JSON.parse(json) : json; + const parsed = registerPayloadSchema.parse({ + metadata: parsePollMetadata(obj), + }); + return parsed; +} + +export function parseManagePayload(data: unknown): ManagePayload { + const json = normaliseDataField(data); + const obj = typeof json === 'string' ? JSON.parse(json) : json; + return managePayloadSchema.parse(obj); +} + +export function parseUpdatePayload(data: unknown): UpdatePayload { + const json = normaliseDataField(data); + const obj = typeof json === 'string' ? JSON.parse(json) : json; + return updatePayloadSchema.parse(obj); +} + +export function parseVotePayload(data: unknown, accountId: string): VotePayload { + const json = normaliseDataField(data); + const obj = typeof json === 'string' ? JSON.parse(json) : json; + const entries = parseVoteEntries(obj); + return votePayloadSchema.parse({ accountId, votes: entries }); +} diff --git a/src/hcs-8/sdk.ts b/src/hcs-8/sdk.ts new file mode 100644 index 00000000..49bcff89 --- /dev/null +++ b/src/hcs-8/sdk.ts @@ -0,0 +1,267 @@ +import { + AccountId, + Client, + PrivateKey, + TopicCreateTransaction, + TopicId, + TopicMessageSubmitTransaction, + TransactionReceipt, + PublicKey, + WebClient, +} from '@hashgraph/sdk'; +import * as undici from 'undici'; +import { Logger, type ILogger } from '../utils/logger'; +import { NetworkType } from '../utils/types'; +import type { MirrorNodeConfig } from '../services'; +import { Hcs8BaseClient } from './base-client'; +import { + buildManageMessage, + buildRegisterChunks, + buildUpdateMessage, + buildVoteMessage, + encodeMessagePayload, +} from './builders'; +import { PollMetadata, VoteEntry } from '../hcs-9'; +import { Hcs8ManageAction, UpdateChange } from './types'; +import { + createNodeOperatorContext, + type NodeOperatorContext, +} from '../common/node-operator-resolver'; + +export interface Hcs8SdkClientConfig { + network: NetworkType; + operatorId: string | AccountId; + operatorKey: string | PrivateKey; + keyType?: 'ed25519' | 'ecdsa'; + logLevel?: Parameters[0]['level']; + mirrorNode?: MirrorNodeConfig; + silent?: boolean; + logger?: ILogger; + forceWebClient?: boolean; +} + +export interface CreatePollTopicOptions { + submitKey?: string | PublicKey | PrivateKey | boolean; + adminKey?: string | PublicKey | PrivateKey | boolean; + memo?: string; +} + +export class Hcs8Client extends Hcs8BaseClient { + private readonly client: Client; + private readonly operatorCtx: NodeOperatorContext; + private readonly shouldUseWebClient: boolean; + private static proxyConfigured = false; + + constructor(config: Hcs8SdkClientConfig) { + super({ + network: config.network, + logLevel: config.logLevel, + mirrorNode: config.mirrorNode, + logger: config.logger, + silent: config.silent, + }); + + this.shouldUseWebClient = + Boolean(config.forceWebClient) || this.detectProxyUrl() !== undefined; + + this.operatorCtx = createNodeOperatorContext({ + network: config.network, + operatorId: config.operatorId, + operatorKey: config.operatorKey, + keyType: config.keyType, + mirrorNode: this.mirrorNode, + logger: this.logger, + client: this.createClient(config.network), + }); + this.client = this.operatorCtx.client; + } + + public async createPollTopic( + options: CreatePollTopicOptions = {}, + ): Promise<{ topicId: string; receipt: TransactionReceipt }> + { + await this.ensureInitialized(); + const tx = new TopicCreateTransaction().setTopicMemo('hcs-8:poll'); + + if (options.adminKey) { + const key = this.resolveKey(options.adminKey); + tx.setAdminKey(key); + } + + if (options.submitKey) { + const key = this.resolveKey(options.submitKey); + tx.setSubmitKey(key); + } + + if (options.memo) { + tx.setTopicMemo(options.memo); + } + + const resp = await tx.execute(this.client); + const receipt = await resp.getReceipt(this.client); + const topicId = receipt.topicId?.toString(); + if (!topicId) { + throw new Error('Topic creation failed to return a topic ID'); + } + return { topicId, receipt }; + } + + public async submitRegister( + topicId: string, + metadata: PollMetadata, + memo?: string, + ): Promise { + await this.ensureInitialized(); + const messages = buildRegisterChunks(metadata, memo); + const receipts: TransactionReceipt[] = []; + for (const message of messages) { + const tx = new TopicMessageSubmitTransaction() + .setTopicId(TopicId.fromString(topicId)) + .setMessage(JSON.stringify(message)); + const resp = await tx.execute(this.client); + receipts.push(await resp.getReceipt(this.client)); + } + return receipts; + } + + public async submitManage( + topicId: string, + accountId: string, + action: Hcs8ManageAction, + memo?: string, + ): Promise { + await this.ensureInitialized(); + const message = buildManageMessage(accountId, action, memo); + return this.submit(topicId, message); + } + + public async submitUpdate( + topicId: string, + accountId: string, + change: UpdateChange, + memo?: string, + ): Promise { + await this.ensureInitialized(); + const message = buildUpdateMessage(accountId, change, memo); + return this.submit(topicId, message); + } + + public async submitVote( + topicId: string, + accountId: string, + votes: VoteEntry[], + memo?: string, + ): Promise { + await this.ensureInitialized(); + const message = buildVoteMessage(accountId, votes, memo); + return this.submit(topicId, message); + } + + public getClient(): Client { + return this.client; + } + + public close(): void { + this.client.close(); + } + + private async submit( + topicId: string, + message: Parameters[0], + ): Promise { + const payload = encodeMessagePayload(message); + const tx = new TopicMessageSubmitTransaction() + .setTopicId(TopicId.fromString(topicId)) + .setMessage(payload); + const resp = await tx.execute(this.client); + return await resp.getReceipt(this.client); + } + + private resolveKey(key: string | PublicKey | PrivateKey | boolean): PublicKey { + if (typeof key === 'boolean') { + return this.operatorCtx.operatorKey.publicKey; + } + if (key instanceof PrivateKey) { + return key.publicKey; + } + if (key instanceof PublicKey) { + return key; + } + return PublicKey.fromString(key); + } + + private createClient(network: NetworkType): Client { + const proxyUrl = this.detectProxyUrl(); + + if (this.shouldUseWebClient) { + if (proxyUrl) { + this.configureProxy(proxyUrl); + } + return this.createWebClient(network); + } + + switch (network) { + case 'mainnet': + return Client.forMainnet(); + case 'previewnet': + return Client.forPreviewnet(); + default: + return Client.forTestnet(); + } + } + + private async ensureInitialized(): Promise { + await this.operatorCtx.ensureInitialized(); + } + + private detectProxyUrl(): string | undefined { + if (typeof process === 'undefined') { + return undefined; + } + return ( + process.env.HTTPS_PROXY || + process.env.https_proxy || + process.env.HTTP_PROXY || + process.env.http_proxy + ); + } + + private configureProxy(proxyUrl: string): void { + if (Hcs8Client.proxyConfigured) { + return; + } + try { + undici.setGlobalDispatcher(new undici.ProxyAgent(proxyUrl)); + Hcs8Client.proxyConfigured = true; + } catch (error) { + this.logger.warn( + `Failed to configure proxy agent for Hedera WebClient: ${error}`, + ); + } + } + + private createWebClient(network: NetworkType): Client { + switch (network) { + case 'mainnet': + return WebClient.forMainnet(); + case 'previewnet': + return WebClient.forPreviewnet(); + default: + return WebClient.forTestnet(); + } + } + + /** + * @internal Testing utility to reset proxy configuration state. + */ + public static __resetProxyAgentForTests(): void { + Hcs8Client.proxyConfigured = false; + } + + /** + * @internal Testing helper to inspect proxy configuration state. + */ + public static __isProxyConfiguredForTests(): boolean { + return Hcs8Client.proxyConfigured; + } +} diff --git a/src/hcs-8/state.ts b/src/hcs-8/state.ts new file mode 100644 index 00000000..5195b4b9 --- /dev/null +++ b/src/hcs-8/state.ts @@ -0,0 +1,253 @@ +import { + PollRuleEvaluator, + applyVotes, + cloneResults, + enforceStatusTransition, + isTerminalStatus, + pollMetadataSchema, + pollOptionSchema, +} from '../hcs-9'; +import { + AnyHcs8Message, + Hcs8ManageMessage, + Hcs8RegisterMessage, + Hcs8UpdateMessage, + Hcs8VoteMessage, + PollError, + PollOperationRecord, + PollState, +} from './types'; + +export class PollStateMachine { + private readonly state: PollState; + private evaluator?: PollRuleEvaluator; + + constructor() { + this.state = { + status: 'inactive', + results: cloneResults(), + operations: [], + errors: [], + }; + } + + public getState(): PollState { + return this.state; + } + + public apply(message: AnyHcs8Message, timestamp: string): void { + switch (message.op) { + case 'register': + this.applyRegister(message, timestamp); + break; + case 'manage': + this.applyManage(message, timestamp); + break; + case 'update': + this.applyUpdate(message, timestamp); + break; + case 'vote': + this.applyVote(message, timestamp); + break; + default: + this.recordError(message.op, 'Unsupported operation', timestamp); + break; + } + } + + private applyRegister(message: Hcs8RegisterMessage, timestamp: string): void { + if (this.state.metadata) { + this.recordError('register', 'Register operation already processed', timestamp); + return; + } + + const metadata = pollMetadataSchema.parse(message.d.metadata); + this.state.metadata = metadata; + this.state.status = metadata.status; + this.state.createdTimestamp = timestamp; + this.state.updatedTimestamp = timestamp; + this.evaluator = new PollRuleEvaluator(metadata); + this.state.results = cloneResults(); + this.recordOperation('register', metadata.author, message.m, timestamp); + } + + private applyManage(message: Hcs8ManageMessage, timestamp: string): void { + if (!this.ensureReady(message.op, timestamp)) { + return; + } + if (!this.evaluator?.canManage(message.d.accountId)) { + this.recordError(message.op, 'Account not permitted to manage poll', timestamp); + return; + } + const nextStatus = enforceStatusTransition(this.state.status, message.d.action); + if (nextStatus === this.state.status) { + this.recordError(message.op, 'No status change occurred', timestamp); + return; + } + this.state.status = nextStatus; + this.state.updatedTimestamp = timestamp; + this.recordOperation(message.op, message.d.accountId, message.m, timestamp); + } + + private applyUpdate(message: Hcs8UpdateMessage, timestamp: string): void { + if (!this.ensureReady(message.op, timestamp)) { + return; + } + if (!this.evaluator?.canUpdate(message.d.accountId)) { + this.recordError(message.op, 'Account not permitted to update poll', timestamp); + return; + } + if (!message.d.change || Object.keys(message.d.change).length === 0) { + this.recordError(message.op, 'No update fields specified', timestamp); + return; + } + + const nextMetadata = { ...this.state.metadata! }; + for (const [field, value] of Object.entries(message.d.change)) { + switch (field) { + case 'title': + case 'description': + case 'startDate': + case 'endDate': + case 'status': + case 'customParameters': + if (!this.evaluator?.canUpdateField(field)) { + this.recordError(message.op, `Updates to ${field} are not permitted`, timestamp); + return; + } + (nextMetadata as Record)[field] = value; + break; + case 'options': + if (!this.evaluator?.canUpdateField('options')) { + this.recordError(message.op, 'Updates to options are not permitted', timestamp); + return; + } + if (!Array.isArray(value)) { + this.recordError(message.op, 'Updated options must be an array', timestamp); + return; + } + (nextMetadata as Record).options = value.map((option) => + pollOptionSchema.parse(option), + ); + break; + default: + this.recordError(message.op, `Unsupported update field: ${field}`, timestamp); + return; + } + } + + const validated = pollMetadataSchema.parse(nextMetadata); + this.state.metadata = validated; + this.state.status = validated.status; + this.state.updatedTimestamp = timestamp; + this.evaluator = new PollRuleEvaluator(validated); + this.recordOperation(message.op, message.d.accountId, message.m, timestamp); + } + + private applyVote(message: Hcs8VoteMessage, timestamp: string): void { + if (!this.ensureReady(message.op, timestamp)) { + return; + } + if (isTerminalStatus(this.state.status) || this.state.status !== 'active') { + this.recordError(message.op, 'Poll is not open for voting', timestamp); + return; + } + + const voter = message.d.accountId; + if (!this.evaluator?.canVote(voter)) { + this.recordError(message.op, 'Account not permitted to vote', timestamp); + return; + } + + const votes = message.d.votes; + if (!votes || votes.length === 0) { + this.recordError(message.op, 'Vote payload contains no entries', timestamp); + return; + } + + if (!this.evaluator.allowMultipleChoice() && votes.length > 1) { + this.recordError(message.op, 'Multiple choices not permitted', timestamp); + return; + } + + const availableWeight = this.evaluator.getVoteWeight(voter); + const totalWeight = votes.reduce((sum, vote) => sum + vote.weight, 0); + if (totalWeight > availableWeight + Number.EPSILON) { + this.recordError(message.op, 'Vote weight exceeds allocation', timestamp); + return; + } + if (!this.evaluator.allowAbstain() && totalWeight < availableWeight) { + this.recordError(message.op, 'Unused vote weight is not permitted', timestamp); + return; + } + + for (const vote of votes) { + if (!this.state.metadata!.options.some((option) => option.id === vote.optionId)) { + this.recordError(message.op, `Unknown option id ${vote.optionId}`, timestamp); + return; + } + } + + const existingVotes = this.state.results.voterWeight.get(voter); + if (existingVotes && !this.evaluator.allowVoteChanges()) { + this.recordError(message.op, 'Vote changes are not permitted', timestamp); + return; + } + if (existingVotes && this.evaluator.allowVoteChanges()) { + this.removeExistingVotes(voter); + } + + this.state.results = applyVotes(this.state.results, votes); + this.state.updatedTimestamp = timestamp; + this.recordOperation(message.op, voter, message.m, timestamp); + } + + private removeExistingVotes(accountId: string): void { + const voterMap = this.state.results.voterWeight.get(accountId); + if (!voterMap) { + return; + } + for (const [optionId, weight] of voterMap.entries()) { + const currentWeight = this.state.results.optionWeight.get(optionId) ?? 0; + const nextWeight = currentWeight - weight; + if (nextWeight <= 0) { + this.state.results.optionWeight.delete(optionId); + } else { + this.state.results.optionWeight.set(optionId, nextWeight); + } + this.state.results.totalWeight = Math.max(0, this.state.results.totalWeight - weight); + } + this.state.results.voterWeight.delete(accountId); + } + + private recordOperation( + operation: AnyHcs8Message['op'], + accountId: string | undefined, + memo: string | undefined, + timestamp: string, + ): void { + const record: PollOperationRecord = { + operation, + accountId, + memo, + timestamp, + }; + this.state.operations.push(record); + } + + private recordError(operation: AnyHcs8Message['op'], reason: string, timestamp: string): void { + const entry: PollError = { operation, reason, timestamp }; + this.state.errors.push(entry); + } + + private ensureReady(op: AnyHcs8Message['op'], timestamp: string): boolean { + if (!this.state.metadata) { + this.recordError(op, 'Poll has not been registered', timestamp); + return false; + } + if (!this.evaluator) { + this.evaluator = new PollRuleEvaluator(this.state.metadata); + } + return true; + } +} diff --git a/src/hcs-8/types.ts b/src/hcs-8/types.ts new file mode 100644 index 00000000..0f1d658b --- /dev/null +++ b/src/hcs-8/types.ts @@ -0,0 +1,168 @@ +import { z } from 'zod'; +import { + PollMetadata, + PollResults, + PollStatus, + VoteEntry, + pollMetadataSchema, + pollOptionSchema, + pollStatusSchema, + voteEntrySchema, +} from '../hcs-9'; +import type { ILogger, LogLevel } from '../utils/logger'; +import type { NetworkType } from '../utils/types'; +import type { MirrorNodeConfig } from '../services'; + +export const hcs8OperationSchema = z.enum(['register', 'manage', 'update', 'vote']); +export type Hcs8Operation = z.infer; + +export const sequenceInfoSchema = z + .tuple([ + z.number().int().nonnegative(), + z.number().int().nonnegative(), + z.number().int().positive(), + ]) + .refine(([_, num, len]) => num < len, { + message: 'Sequence num must be less than len', + }); +export type SequenceInfo = z.infer; + +export const hcs8BaseMessageSchema = z.object({ + p: z.literal('hcs-8'), + op: hcs8OperationSchema, + sid: sequenceInfoSchema.optional(), + d: z.unknown(), + m: z.string().optional(), +}); +export type Hcs8BaseMessage = z.infer; + +export const registerPayloadSchema = z.object({ + metadata: pollMetadataSchema, +}); +export type RegisterPayload = z.infer; + +export const manageActionSchema = z.enum(['open', 'pause', 'close', 'cancel']); +export type Hcs8ManageAction = z.infer; + +export const managePayloadSchema = z.object({ + accountId: z.string().min(3, 'Manage payload requires accountId'), + action: manageActionSchema, +}); +export type ManagePayload = z.infer; + +export const updateChangeSchema = z + .object({ + title: z.string().min(1).optional(), + description: z.string().max(8192).optional(), + startDate: z.string().regex(/^[0-9]+$/).optional(), + endDate: z.string().regex(/^[0-9]+$/).optional(), + status: pollStatusSchema.optional(), + options: z.array(pollOptionSchema).optional(), + customParameters: z.record(z.string(), z.unknown()).optional(), + }) + .strict() + .partial(); +export type UpdateChange = z.infer; + +export const updatePayloadSchema = z.object({ + accountId: z.string().min(3, 'Update payload requires accountId'), + change: updateChangeSchema.optional(), +}); +export type UpdatePayload = z.infer; + +export const votePayloadSchema = z.object({ + accountId: z.string().min(3, 'Vote payload requires accountId'), + votes: z.array(voteEntrySchema).min(1), +}); +export type VotePayload = z.infer; + +export type Hcs8Message = Omit & { + d: TData; +}; + +export type Hcs8RegisterChunkMessage = Omit & { + op: 'register'; + d: string; +}; + +export type Hcs8RegisterMessage = Hcs8Message & { + op: 'register'; +}; +export type Hcs8ManageMessage = Hcs8Message & { + op: 'manage'; +}; +export type Hcs8UpdateMessage = Hcs8Message & { + op: 'update'; +}; +export type Hcs8VoteMessage = Hcs8Message & { op: 'vote' }; +export type AnyHcs8Message = + | Hcs8RegisterMessage + | Hcs8ManageMessage + | Hcs8UpdateMessage + | Hcs8VoteMessage; + +export interface PollState { + metadata?: PollMetadata; + status: PollStatus; + results: PollResults; + createdTimestamp?: string; + updatedTimestamp?: string; + operations: PollOperationRecord[]; + errors: PollError[]; +} + +export interface PollOperationRecord { + operation: Hcs8Operation; + accountId?: string; + memo?: string; + timestamp: string; +} + +export interface PollError { + operation: Hcs8Operation; + reason: string; + timestamp: string; +} + +export interface ParsedTopicMessage { + raw: string; + timestamp: string; + payerAccountId?: string; + message: AnyHcs8Message; +} + +export interface SequenceAssemblyContext { + uid: number; + op: Hcs8Operation; + length: number; + memo?: string; + payloads: string[]; + authorAccountId?: string; + firstTimestamp: string; + lastTimestamp: string; +} + +export interface PollLedgerEntry { + accountId: string; + votes: VoteEntry[]; + timestamp: string; +} + +export interface PollLedger { + register?: ParsedTopicMessage; + manage: ParsedTopicMessage[]; + update: ParsedTopicMessage[]; + vote: PollLedgerEntry[]; +} + +export interface PollProcessingOptions { + stopAtTimestamp?: string; +} + +export interface Hcs8ClientConfig { + network: NetworkType; + mirrorNode?: MirrorNodeConfig; + logger?: ILogger; + logLevel?: LogLevel; + silent?: boolean; +} diff --git a/src/hcs-9/evaluator.ts b/src/hcs-9/evaluator.ts new file mode 100644 index 00000000..459a5f69 --- /dev/null +++ b/src/hcs-9/evaluator.ts @@ -0,0 +1,223 @@ +import { + AllocationModule, + DEFAULT_VOTE_WEIGHT, + PollMetadata, + PollResults, + PollStatus, + VoteEntry, + VotingRulesModule, + UpdateRulesModule, + PermissionsModule, +} from './types'; + +export class PollRuleEvaluator { + constructor(private readonly metadata: PollMetadata) {} + + public getVoteWeight(accountId: string): number { + const rules = this.metadata.votingRules; + const weight = this.evaluateAllocations(rules, accountId); + return weight > 0 ? weight : DEFAULT_VOTE_WEIGHT; + } + + public canVote(accountId: string): boolean { + if (!this.isGloballyPermitted(accountId)) { + return false; + } + return this.isPermissionGranted( + this.metadata.votingRules.permissions, + accountId, + true, + ); + } + + public canManage(accountId: string): boolean { + if (!this.isGloballyPermitted(accountId)) { + return false; + } + return this.isPermissionGranted( + this.metadata.manageRules?.permissions, + accountId, + false, + ); + } + + public canUpdate(accountId: string): boolean { + if (!this.isGloballyPermitted(accountId)) { + return false; + } + return this.isPermissionGranted( + this.metadata.updateRules?.permissions, + accountId, + false, + ); + } + + public allowVoteChanges(): boolean { + return Boolean( + this.metadata.votingRules.rules?.some( + (rule) => rule.name === 'allowVoteChanges', + ), + ); + } + + public allowMultipleChoice(): boolean { + return Boolean( + this.metadata.votingRules.rules?.some( + (rule) => rule.name === 'allowMultipleChoice', + ), + ); + } + + public allowAbstain(): boolean { + return Boolean( + this.metadata.votingRules.rules?.some((rule) => rule.name === 'allowAbstain'), + ); + } + + public canUpdateField(field: keyof NonNullable): boolean { + const settings = this.metadata.updateRules?.updateSettings; + if (!settings) { + return false; + } + return Boolean(settings[field]); + } + + private evaluateAllocations(rules: VotingRulesModule, accountId: string): number { + if (!rules.allocations || rules.allocations.length === 0) { + return DEFAULT_VOTE_WEIGHT; + } + + let weight = 0; + for (const module of rules.allocations) { + weight += this.resolveAllocationWeight(module, accountId); + } + return weight; + } + + private resolveAllocationWeight( + module: AllocationModule, + accountId: string, + ): number { + switch (module.schema) { + case 'hcs-9:equal-weight': + return module.weight; + case 'hcs-9:fixed-weight': { + const matched = module.allocations.find( + (entry) => entry.accountId === accountId, + ); + if (matched) { + return matched.weight; + } + return module.defaultWeight ?? 0; + } + default: + return 0; + } + } + + private isGloballyPermitted(accountId: string): boolean { + if (!this.metadata.permissionsRules || this.metadata.permissionsRules.length === 0) { + return true; + } + return this.isPermissionGranted( + this.metadata.permissionsRules, + accountId, + true, + ); + } + + private isPermissionGranted( + modules: PermissionsModule[] | undefined, + accountId: string, + defaultValue: boolean, + ): boolean { + if (!modules || modules.length === 0) { + return defaultValue; + } + + for (const module of modules) { + switch (module.schema) { + case 'hcs-9:allow-all': + return true; + case 'hcs-9:allow-author': + if (accountId === this.metadata.author) { + return true; + } + break; + case 'hcs-9:account-list': + if (module.accounts.includes(accountId)) { + return true; + } + break; + default: + break; + } + } + return false; + } +} + +export function applyVotes( + existing: PollResults, + entries: VoteEntry[], +): PollResults { + const optionTotals = new Map(existing.optionWeight); + const voterTotals = new Map(existing.voterWeight); + let totalWeight = existing.totalWeight; + + for (const entry of entries) { + const optionWeight = optionTotals.get(entry.optionId) ?? 0; + optionTotals.set(entry.optionId, optionWeight + entry.weight); + + const voterMap = new Map(voterTotals.get(entry.accountId) ?? []); + const voterWeight = voterMap.get(entry.optionId) ?? 0; + voterMap.set(entry.optionId, voterWeight + entry.weight); + voterTotals.set(entry.accountId, voterMap); + + totalWeight += entry.weight; + } + + return { optionWeight: optionTotals, voterWeight: voterTotals, totalWeight }; +} + +export function cloneResults(): PollResults { + return { + optionWeight: new Map(), + voterWeight: new Map(), + totalWeight: 0, + }; +} + +export function enforceStatusTransition( + current: PollStatus, + action: string, +): PollStatus { + switch (action) { + case 'open': + if (current === 'inactive' || current === 'paused') { + return 'active'; + } + return current; + case 'pause': + if (current === 'active') { + return 'paused'; + } + return current; + case 'close': + if (current === 'active' || current === 'paused') { + return 'closed'; + } + return current; + case 'cancel': + if (current !== 'cancelled') { + return 'cancelled'; + } + return current; + default: + return current; + } +} + +export function isTerminalStatus(status: PollStatus): boolean { + return status === 'closed' || status === 'cancelled'; +} diff --git a/src/hcs-9/index.ts b/src/hcs-9/index.ts new file mode 100644 index 00000000..b5af89dc --- /dev/null +++ b/src/hcs-9/index.ts @@ -0,0 +1,2 @@ +export * from './types'; +export * from './evaluator'; diff --git a/src/hcs-9/types.ts b/src/hcs-9/types.ts new file mode 100644 index 00000000..f9138c71 --- /dev/null +++ b/src/hcs-9/types.ts @@ -0,0 +1,154 @@ +import { z } from 'zod'; + +export const pollStatusSchema = z.enum([ + 'inactive', + 'active', + 'paused', + 'closed', + 'cancelled', +]); +export type PollStatus = z.infer; + +export const pollOptionSchema = z.object({ + schema: z.literal('hcs-9'), + id: z.number().int().nonnegative(), + title: z.string().min(1, 'Option title must not be empty'), + description: z.string().max(4096).optional(), +}); +export type PollOption = z.infer; + +export const accountListPermissionSchema = z.object({ + schema: z.literal('hcs-9:account-list'), + accounts: z.array(z.string().min(3)).min(1), +}); + +export const allowAllPermissionSchema = z.object({ + schema: z.literal('hcs-9:allow-all'), +}); + +export const allowAuthorPermissionSchema = z.object({ + schema: z.literal('hcs-9:allow-author'), +}); + +export const permissionsModuleSchema = z.discriminatedUnion('schema', [ + accountListPermissionSchema, + allowAllPermissionSchema, + allowAuthorPermissionSchema, +]); +export type PermissionsModule = z.infer; + +export const equalWeightAllocationSchema = z.object({ + schema: z.literal('hcs-9:equal-weight'), + weight: z.number().positive().default(1), +}); + +export const fixedWeightAllocationSchema = z.object({ + schema: z.literal('hcs-9:fixed-weight'), + allocations: z + .array( + z.object({ + accountId: z.string().min(3), + weight: z.number().nonnegative(), + }), + ) + .min(1), + defaultWeight: z.number().nonnegative().optional(), +}); + +export const allocationModuleSchema = z.discriminatedUnion('schema', [ + equalWeightAllocationSchema, + fixedWeightAllocationSchema, +]); +export type AllocationModule = z.infer; + +export const voteRuleSchema = z.object({ + name: z.enum(['allowVoteChanges', 'allowMultipleChoice', 'allowAbstain']), +}); +export type VoteRule = z.infer; + +export const votingRulesSchema = z.object({ + schema: z.literal('hcs-9'), + allocations: z.array(allocationModuleSchema).optional(), + permissions: z.array(permissionsModuleSchema).optional(), + rules: z.array(voteRuleSchema).optional(), +}); +export type VotingRulesModule = z.infer; + +export const manageRulesSchema = z.object({ + schema: z.literal('hcs-9'), + permissions: z.array(permissionsModuleSchema).optional(), +}); +export type ManageRulesModule = z.infer; + +export const updateSettingsSchema = z.object({ + title: z.boolean().optional(), + description: z.boolean().optional(), + startDate: z.boolean().optional(), + endDate: z.boolean().optional(), + status: z.boolean().optional(), + options: z.boolean().optional(), + customParameters: z.boolean().optional(), +}); + +export const updateRulesSchema = z.object({ + schema: z.literal('hcs-9'), + permissions: z.array(permissionsModuleSchema).optional(), + updateSettings: updateSettingsSchema.optional(), +}); +export type UpdateRulesModule = z.infer; + +export const endDateConditionSchema = z.object({ + schema: z.literal('hcs-9:end-date'), + endDate: z.string().regex(/^[0-9]+$/, 'endDate must be a unix timestamp string'), +}); + +export const totalVotesConditionSchema = z.object({ + schema: z.literal('hcs-9:total-votes'), + threshold: z.number().positive(), +}); + +export const endConditionModuleSchema = z.discriminatedUnion('schema', [ + endDateConditionSchema, + totalVotesConditionSchema, +]); +export type EndConditionModule = z.infer; + +export const pollMetadataSchema = z.object({ + schema: z.literal('hcs-9'), + title: z.string().min(1, 'Poll title must not be empty'), + description: z.string().max(8192).optional(), + author: z.string().min(3), + votingRules: votingRulesSchema, + permissionsRules: z.array(permissionsModuleSchema).optional(), + manageRules: manageRulesSchema.optional(), + updateRules: updateRulesSchema.optional(), + options: z.array(pollOptionSchema).min(1), + status: pollStatusSchema, + startDate: z.string().regex(/^[0-9]+$/).optional(), + endConditionRules: z.array(endConditionModuleSchema).optional(), + customParameters: z.record(z.string(), z.unknown()).optional(), +}); +export type PollMetadata = z.infer; + +export const voteEntrySchema = z.object({ + accountId: z.string().min(3), + optionId: z.number().int().nonnegative(), + weight: z.number().positive(), +}); +export type VoteEntry = z.infer; + +export function parsePollMetadata(input: unknown): PollMetadata { + return pollMetadataSchema.parse(input); +} + +export function parseVoteEntries(input: unknown): VoteEntry[] { + return z.array(voteEntrySchema).min(1).parse(input); +} + +export interface PollResults { + totalWeight: number; + optionWeight: Map; + voterWeight: Map>; +} + +export const DEFAULT_VOTE_WEIGHT = 1; diff --git a/src/index.ts b/src/index.ts index 1b5a9dae..f2377df5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,8 @@ export * from './hcs-3/src'; export * from './hcs-6'; export * from './hcs-7'; +export * from './hcs-8'; +export * from './hcs-9'; export * from './hcs-10'; export * from './hcs-11'; export * from './hcs-12'; diff --git a/src/services/mirror-node.ts b/src/services/mirror-node.ts index 7f1f21aa..3c4e1e53 100644 --- a/src/services/mirror-node.ts +++ b/src/services/mirror-node.ts @@ -1,5 +1,6 @@ import { PublicKey, Timestamp, AccountId } from '@hashgraph/sdk'; -import axios, { AxiosRequestConfig } from 'axios'; +import axios, { AxiosInstance, AxiosRequestConfig } from 'axios'; +import { HttpsProxyAgent } from 'https-proxy-agent'; import { Logger, ILogger } from '../utils/logger'; import { proto } from '@hashgraph/proto'; import { @@ -87,6 +88,7 @@ export class HederaMirrorNode { private isServerEnvironment: boolean; private apiKey?: string; private customHeaders: Record; + private readonly httpClient: AxiosInstance; private maxRetries: number = 5; private initialDelayMs: number = 2000; @@ -109,6 +111,7 @@ export class HederaMirrorNode { module: 'MirrorNode', }); this.isServerEnvironment = typeof window === 'undefined'; + this.httpClient = this.createHttpClient(); if (config?.customUrl) { this.logger.info(`Using custom mirror node URL: ${config.customUrl}`); @@ -426,14 +429,10 @@ export class HederaMirrorNode { continue; } - messageJson.sequence_number = message.sequence_number; messages.push({ - ...messageJson, - consensus_timestamp: message.consensus_timestamp, - sequence_number: message.sequence_number, - running_hash: message.running_hash, - running_hash_version: message.running_hash_version, - topic_id: message.topic_id, + ...message, + decoded_message: messageContent, + parsed_message: messageJson, payer: message.payer_account_id, created: new Date(Number(message.consensus_timestamp) * 1000), }); @@ -731,7 +730,7 @@ export class HederaMirrorNode { while (attempt < this.maxRetries) { try { - const response = await axios.get(url, config); + const response = await this.httpClient.get(url, config); return response.data; } catch (error: any) { attempt++; @@ -770,6 +769,38 @@ export class HederaMirrorNode { ); } + private createHttpClient(): AxiosInstance { + const proxyUrl = this.detectProxyUrl(); + if (proxyUrl) { + try { + const agent = new HttpsProxyAgent(proxyUrl); + this.logger.info(`Configuring mirror node proxy: ${proxyUrl}`); + return axios.create({ + httpAgent: agent, + httpsAgent: agent, + proxy: false, + }); + } catch (error) { + this.logger.warn( + `Failed to configure mirror node proxy ${proxyUrl}: ${error}`, + ); + } + } + return axios.create(); + } + + private detectProxyUrl(): string | undefined { + if (typeof process === 'undefined') { + return undefined; + } + return ( + process.env.HTTPS_PROXY || + process.env.https_proxy || + process.env.HTTP_PROXY || + process.env.http_proxy + ); + } + /** * Private helper to make fetch requests with retry logic. */ From dd1bde52c8aba15ebbd10f6a3107dccff8436783 Mon Sep 17 00:00:00 2001 From: Michael Kantor <6068672+kantorcodes@users.noreply.github.com> Date: Tue, 21 Oct 2025 15:54:23 -0400 Subject: [PATCH 2/2] Validate voter identity when applying HCS-8 votes --- __tests__/hcs-8/state.test.ts | 22 ++++++++++++++++++++++ src/hcs-8/state.ts | 11 ++++++++++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/__tests__/hcs-8/state.test.ts b/__tests__/hcs-8/state.test.ts index 9001995e..1a7a6e47 100644 --- a/__tests__/hcs-8/state.test.ts +++ b/__tests__/hcs-8/state.test.ts @@ -107,4 +107,26 @@ describe('HCS-8 poll state machine', () => { expect(state.results.optionWeight.get(0)).toBeUndefined(); expect(state.results.optionWeight.get(1)).toBe(1); }); + + it('rejects votes that attempt to forge account identities', () => { + const machine = new PollStateMachine(); + machine.apply(buildRegisterMessage(baseMetadata), '1'); + machine.apply(buildManageMessage(baseMetadata.author, 'open'), '2'); + + const forgedVote = buildVoteMessage('0.0.2001', [ + { accountId: '0.0.9999', optionId: 0, weight: 1 }, + ]); + machine.apply(forgedVote, '3'); + + const state = machine.getState(); + expect(state.results.totalWeight).toBe(0); + expect(state.errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + operation: 'vote', + reason: 'Vote entry account must match payer account', + }), + ]), + ); + }); }); diff --git a/src/hcs-8/state.ts b/src/hcs-8/state.ts index 5195b4b9..fe10b3d5 100644 --- a/src/hcs-8/state.ts +++ b/src/hcs-8/state.ts @@ -182,6 +182,14 @@ export class PollStateMachine { } for (const vote of votes) { + if (vote.accountId !== voter) { + this.recordError( + message.op, + 'Vote entry account must match payer account', + timestamp, + ); + return; + } if (!this.state.metadata!.options.some((option) => option.id === vote.optionId)) { this.recordError(message.op, `Unknown option id ${vote.optionId}`, timestamp); return; @@ -197,7 +205,8 @@ export class PollStateMachine { this.removeExistingVotes(voter); } - this.state.results = applyVotes(this.state.results, votes); + const normalizedVotes = votes.map((vote) => ({ ...vote, accountId: voter })); + this.state.results = applyVotes(this.state.results, normalizedVotes); this.state.updatedTimestamp = timestamp; this.recordOperation(message.op, voter, message.m, timestamp); }