Skip to content

Commit b4fb0f5

Browse files
committed
feat: add HOL audit trail hcs-2 + hcs-1
Signed-off-by: Piotr Kierzniewski <piotr.kierzniewski@blockydevs.com>
1 parent f006cc1 commit b4fb0f5

21 files changed

Lines changed: 1721 additions & 0 deletions
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import z from 'zod';
2+
3+
import { HOL_AUDIT_ENTRY_TYPE, HOL_AUDIT_ENTRY_VERSION, HOL_AUDIT_ENTRY_SOURCE } from './constants';
4+
5+
export type AuditEntry = {
6+
type: typeof HOL_AUDIT_ENTRY_TYPE;
7+
version: typeof HOL_AUDIT_ENTRY_VERSION;
8+
source: string;
9+
timestamp: string;
10+
tool: string;
11+
params: Record<string, any>;
12+
result: {
13+
raw: Record<string, any>;
14+
message: string | null;
15+
};
16+
};
17+
18+
export type BuildAuditEntryParams = {
19+
tool: string;
20+
params?: Record<string, any>;
21+
result?: {
22+
raw?: Record<string, any>;
23+
message?: string | null;
24+
};
25+
};
26+
27+
export const auditEntrySchema = z.object({
28+
type: z.literal(HOL_AUDIT_ENTRY_TYPE),
29+
version: z.literal(HOL_AUDIT_ENTRY_VERSION),
30+
source: z.string().describe('Identifier of the system that produced this entry.'),
31+
timestamp: z.string().describe('ISO 8601 timestamp of when the tool was executed.'),
32+
tool: z.string().describe('Name of the tool that was executed.'),
33+
params: z.record(z.any()).describe('Normalised parameters passed to the tool.'),
34+
result: z.object({
35+
raw: z.record(z.any()).describe('Raw result returned by the tool.'),
36+
message: z.string().nullable().describe('Human-readable result message.'),
37+
}),
38+
});
39+
40+
export function buildAuditEntry(params: BuildAuditEntryParams): AuditEntry {
41+
return {
42+
type: HOL_AUDIT_ENTRY_TYPE,
43+
version: HOL_AUDIT_ENTRY_VERSION,
44+
source: HOL_AUDIT_ENTRY_SOURCE,
45+
timestamp: new Date().toISOString(),
46+
tool: params.tool,
47+
params: params.params ?? {},
48+
result: {
49+
raw: params.result?.raw ?? {},
50+
message: params.result?.message ?? null,
51+
},
52+
};
53+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import type { AuditEntry } from './audit-entry';
2+
import type { AuditWriter } from './writers/types';
3+
import { isSessionAware } from './writers/types';
4+
5+
export class AuditSession {
6+
private sessionId: string | null;
7+
private writer: AuditWriter;
8+
private initPromise: Promise<void> | null = null;
9+
10+
constructor(writer: AuditWriter, sessionId?: string) {
11+
this.writer = writer;
12+
this.sessionId = sessionId ?? null;
13+
14+
if (this.sessionId && isSessionAware(writer)) {
15+
writer.setSessionId(this.sessionId);
16+
}
17+
}
18+
19+
getSessionId(): string | null {
20+
return this.sessionId;
21+
}
22+
23+
async writeEntry(entry: AuditEntry): Promise<void> {
24+
await this.ensureInitialized();
25+
await this.writer.write(entry);
26+
}
27+
28+
private async ensureInitialized(): Promise<void> {
29+
if (this.sessionId) return;
30+
31+
if (!this.initPromise) {
32+
this.initPromise = this.initialize();
33+
}
34+
35+
await this.initPromise;
36+
}
37+
38+
private async initialize(): Promise<void> {
39+
this.sessionId = await this.writer.initialize();
40+
41+
if (isSessionAware(this.writer)) {
42+
this.writer.setSessionId(this.sessionId);
43+
}
44+
}
45+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export const HOL_AUDIT_ENTRY_VERSION = '1.0' as const;
2+
export const HOL_AUDIT_ENTRY_SOURCE = 'hedera-agent-kit-js' as const;
3+
export const HOL_AUDIT_ENTRY_TYPE = 'hedera-agent-kit:audit-entry' as const;
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export * from './constants';
2+
export * from './audit-entry';
3+
export * from './audit-session';
4+
export * from './writers/types';
5+
export * from './writers/hol-audit-writer';
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { Client } from '@hashgraph/sdk';
2+
3+
import { Hcs1FileBuilder } from '@/shared/hol/hcs1-file-builder';
4+
import { Hcs2RegistryBuilder } from '@/shared/hol/hcs2-registry-builder';
5+
import { HCS2_REGISTRY_TYPE } from '@/shared/hol/constants';
6+
import type { AuditEntry } from '../audit-entry';
7+
import type { SessionAwareWriter } from './types';
8+
9+
export class HolAuditWriter implements SessionAwareWriter {
10+
private client: Client;
11+
private sessionId!: string;
12+
13+
constructor(client: Client) {
14+
this.client = client;
15+
}
16+
17+
setSessionId(sessionId: string): void {
18+
this.sessionId = sessionId;
19+
}
20+
21+
async initialize(): Promise<string> {
22+
const tx = Hcs2RegistryBuilder.createRegistry({
23+
autoRenewAccountId: this.client.operatorAccountId!.toString(),
24+
submitKey: this.client.operatorPublicKey!,
25+
registryType: HCS2_REGISTRY_TYPE.INDEXED,
26+
ttl: 0,
27+
});
28+
29+
const response = await tx.execute(this.client);
30+
const receipt = await response.getReceipt(this.client);
31+
if (!receipt.topicId) {
32+
throw new Error('Failed to create session topic');
33+
}
34+
35+
return receipt.topicId.toString();
36+
}
37+
38+
async write(entry: AuditEntry): Promise<void> {
39+
const { topicTransaction, buildMessageTransactions } = Hcs1FileBuilder.createFile({
40+
autoRenewAccountId: this.client.operatorAccountId!.toString(),
41+
submitKey: this.client.operatorPublicKey!,
42+
content: JSON.stringify(entry),
43+
});
44+
45+
const topicResponse = await topicTransaction.execute(this.client);
46+
const topicReceipt = await topicResponse.getReceipt(this.client);
47+
if (!topicReceipt.topicId) {
48+
throw new Error('Failed to create HCS-1 topic for audit entry');
49+
}
50+
const entryTopicId = topicReceipt.topicId.toString();
51+
52+
const messageTxs = buildMessageTransactions(entryTopicId);
53+
for (const messageTx of messageTxs) {
54+
await messageTx.execute(this.client);
55+
}
56+
57+
const registerTx = Hcs2RegistryBuilder.registerEntry({
58+
registryTopicId: this.sessionId,
59+
targetTopicId: entryTopicId,
60+
});
61+
62+
await registerTx.execute(this.client);
63+
}
64+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import type { AuditEntry } from '../audit-entry';
2+
3+
export type AuditWriter = {
4+
/** One-time setup: create resources (e.g. registry topic). Returns session identifier. */
5+
initialize(): Promise<string>;
6+
/** Write a single audit entry. */
7+
write(entry: AuditEntry): Promise<void>;
8+
};
9+
10+
export type SessionAwareWriter = AuditWriter & {
11+
setSessionId(sessionId: string): void;
12+
};
13+
14+
export function isSessionAware(writer: AuditWriter): writer is SessionAwareWriter {
15+
return 'setSessionId' in writer;
16+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
export const HCS1_CHUNK_THRESHOLD = 1024;
2+
export const HCS1_CHUNK_ENVELOPE_SIZE = 16; // JSON envelope overhead: {"o":NNN,"c":"..."} ≈ 16 bytes
3+
export const HCS1_CHUNK_SIZE = HCS1_CHUNK_THRESHOLD - HCS1_CHUNK_ENVELOPE_SIZE;
4+
5+
export const HCS2_PROTOCOL = 'hcs-2' as const;
6+
7+
export const HCS2_OPERATION = {
8+
REGISTER: 'register',
9+
UPDATE: 'update',
10+
DELETE: 'delete',
11+
MIGRATE: 'migrate',
12+
} as const;
13+
14+
export const HCS2_REGISTRY_TYPE = {
15+
INDEXED: 0,
16+
NON_INDEXED: 1,
17+
} as const;
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { createHash } from 'crypto';
2+
import { brotliCompressSync, constants as zlibConstants } from 'zlib';
3+
4+
import { PublicKey } from '@hashgraph/sdk';
5+
6+
import HederaBuilder from '@/shared/hedera-utils/hedera-builder';
7+
import { HCS1_CHUNK_SIZE } from './constants';
8+
9+
export type CreateHcs1FileParams = {
10+
autoRenewAccountId: string;
11+
submitKey: PublicKey;
12+
content: string;
13+
mimeType?: string;
14+
};
15+
16+
export type Hcs1Message = {
17+
o: number;
18+
c: string;
19+
};
20+
21+
export type Hcs1FileResult = {
22+
topicTransaction: any;
23+
buildMessageTransactions: (topicId: string) => any[];
24+
};
25+
26+
export class Hcs1FileBuilder {
27+
static createFile(params: CreateHcs1FileParams): Hcs1FileResult {
28+
const mimeType = params.mimeType ?? 'application/json';
29+
const contentBuffer = Buffer.from(params.content, 'utf-8');
30+
31+
const hash = createHash('sha256').update(contentBuffer).digest('hex');
32+
33+
const compressed = brotliCompressSync(contentBuffer, {
34+
params: { [zlibConstants.BROTLI_PARAM_QUALITY]: zlibConstants.BROTLI_MAX_QUALITY },
35+
});
36+
const base64Data = compressed.toString('base64');
37+
38+
const dataUri = `data:${mimeType};base64,${base64Data}`;
39+
40+
const chunks: string[] = [];
41+
for (let i = 0; i < dataUri.length; i += HCS1_CHUNK_SIZE) {
42+
chunks.push(dataUri.slice(i, i + HCS1_CHUNK_SIZE));
43+
}
44+
45+
const topicMemo = `${hash}:brotli:base64`;
46+
47+
const topicTransaction = HederaBuilder.createTopic({
48+
topicMemo,
49+
autoRenewAccountId: params.autoRenewAccountId,
50+
isSubmitKey: false,
51+
submitKey: params.submitKey,
52+
});
53+
54+
return {
55+
topicTransaction,
56+
buildMessageTransactions: (topicId: string) =>
57+
chunks.map((chunk, index) => {
58+
const chunkMessage: Hcs1Message = { o: index, c: chunk };
59+
return HederaBuilder.submitTopicMessage({
60+
topicId,
61+
message: JSON.stringify(chunkMessage),
62+
});
63+
}),
64+
};
65+
}
66+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { PublicKey } from '@hashgraph/sdk';
2+
3+
import HederaBuilder from '@/shared/hedera-utils/hedera-builder';
4+
import { HCS2_PROTOCOL, HCS2_OPERATION, HCS2_REGISTRY_TYPE } from './constants';
5+
6+
type Hcs2Operation = (typeof HCS2_OPERATION)[keyof typeof HCS2_OPERATION];
7+
8+
export type CreateHcs2RegistryParams = {
9+
autoRenewAccountId: string;
10+
submitKey: PublicKey;
11+
registryType?: 0 | 1;
12+
ttl?: number;
13+
};
14+
15+
export type RegisterHcs2EntryParams = {
16+
registryTopicId: string;
17+
targetTopicId: string;
18+
metadata?: string;
19+
memo?: string;
20+
};
21+
22+
export type Hcs2Message = {
23+
p: typeof HCS2_PROTOCOL;
24+
op: Hcs2Operation;
25+
t_id: string;
26+
uid?: number;
27+
metadata?: string;
28+
m?: string;
29+
};
30+
31+
export class Hcs2RegistryBuilder {
32+
static createRegistry(params: CreateHcs2RegistryParams) {
33+
const registryType = params.registryType ?? HCS2_REGISTRY_TYPE.INDEXED;
34+
const ttl = params.ttl ?? 0;
35+
36+
return HederaBuilder.createTopic({
37+
topicMemo: `${HCS2_PROTOCOL}:${registryType}:${ttl}`,
38+
autoRenewAccountId: params.autoRenewAccountId,
39+
isSubmitKey: false,
40+
submitKey: params.submitKey,
41+
});
42+
}
43+
44+
static registerEntry(params: RegisterHcs2EntryParams) {
45+
const message: Hcs2Message = {
46+
p: HCS2_PROTOCOL,
47+
op: HCS2_OPERATION.REGISTER,
48+
t_id: params.targetTopicId,
49+
metadata: params.metadata,
50+
m: params.memo,
51+
};
52+
53+
return HederaBuilder.submitTopicMessage({
54+
topicId: params.registryTopicId,
55+
message: JSON.stringify(message),
56+
});
57+
}
58+
}

typescript/src/shared/hol/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export * from './constants';
2+
export * from './hcs1-file-builder';
3+
export * from './hcs2-registry-builder';

0 commit comments

Comments
 (0)