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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions __tests__/hcs-8/sdk.test.ts
Original file line number Diff line number Diff line change
@@ -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<ConstructorParameters<typeof Hcs8Client>[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();
});
});
132 changes: 132 additions & 0 deletions __tests__/hcs-8/state.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
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);
});

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',
}),
]),
);
});
});
73 changes: 73 additions & 0 deletions __tests__/hcs-9/metadata.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
15 changes: 15 additions & 0 deletions demo/hcs-8/poll-demo.log
Original file line number Diff line number Diff line change
@@ -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
92 changes: 92 additions & 0 deletions demo/hcs-8/poll-demo.ts
Original file line number Diff line number Diff line change
@@ -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);
});
Loading
Loading