Merge branch 'dlt_gradido_node_as_subprocess' into dlt_inspector_as_submodule

This commit is contained in:
einhornimmond 2025-10-25 16:28:59 +02:00
commit 1604344445
26 changed files with 571 additions and 23 deletions

View File

@ -43,6 +43,7 @@ const DLT_CONNECTOR_PORT = process.env.DLT_CONNECTOR_PORT ?? 6010
const dltConnector = {
DLT_CONNECTOR: process.env.DLT_CONNECTOR === 'true' || false,
DLT_CONNECTOR_URL: process.env.DLT_CONNECTOR_URL ?? `${COMMUNITY_URL}:${DLT_CONNECTOR_PORT}`,
DLT_GRADIDO_NODE_SERVER_HOME_FOLDER: process.env.DLT_GRADIDO_NODE_SERVER_HOME_FOLDER ?? '~/.gradido',
}
const community = {

View File

@ -79,6 +79,10 @@ export const schema = Joi.object({
.when('DLT_CONNECTOR', { is: true, then: Joi.required() })
.description('The URL for GDT API endpoint'),
DLT_GRADIDO_NODE_SERVER_HOME_FOLDER: Joi.string()
.default('~/.gradido')
.description('The home folder for the gradido dlt node server'),
EMAIL: Joi.boolean()
.default(false)
.description('Enable or disable email functionality')

View File

@ -4,4 +4,5 @@ export interface PublicCommunityInfo {
creationDate: Date
publicKey: string
publicJwtKey: string
hieroTopicId: string | null
}

View File

@ -2,6 +2,7 @@ import {
Community as DbCommunity,
FederatedCommunity as DbFederatedCommunity,
getHomeCommunity,
getReachableCommunities,
} from 'database'
import { IsNull } from 'typeorm'
@ -15,6 +16,9 @@ import { getLogger } from 'log4js'
import { startCommunityAuthentication } from './authenticateCommunities'
import { PublicCommunityInfoLoggingView } from './client/1_0/logging/PublicCommunityInfoLogging.view'
import { ApiVersionType } from 'core'
import { CONFIG } from '@/config'
import * as path from 'node:path'
import * as fs from 'node:fs'
const logger = getLogger(`${LOG4JS_BASE_CATEGORY_NAME}.federation.validateCommunities`)
@ -83,6 +87,8 @@ export async function validateCommunities(): Promise<void> {
logger.error(`Error:`, err)
}
}
// export communities for gradido dlt node server
await exportCommunitiesToDltNodeServer()
}
export async function writeJwtKeyPairInHomeCommunity(): Promise<DbCommunity> {
@ -138,6 +144,52 @@ async function writeForeignCommunity(
com.publicKey = dbCom.publicKey
com.publicJwtKey = pubInfo.publicJwtKey
com.url = dbCom.endPoint
com.hieroTopicId = pubInfo.hieroTopicId
await DbCommunity.save(com)
}
}
// prototype, later add api call to gradido dlt node server for adding/updating communities
type CommunityForDltNodeServer = {
communityId: string
hieroTopicId: string
alias: string
folder: string
}
async function exportCommunitiesToDltNodeServer(): Promise<void> {
if (!CONFIG.DLT_CONNECTOR) {
return Promise.resolve()
}
const folder = CONFIG.DLT_GRADIDO_NODE_SERVER_HOME_FOLDER
try {
fs.accessSync(folder, fs.constants.R_OK | fs.constants.W_OK)
} catch (err) {
logger.error(`Error: home folder for DLT Gradido Node Server ${folder} does not exist`)
return
}
const dbComs = await getReachableCommunities(CONFIG.FEDERATION_VALIDATE_COMMUNITY_TIMER * 4)
const communitiesForDltNodeServer: CommunityForDltNodeServer[] = []
// make sure communityName is unique
const communityName = new Set<string>()
dbComs.forEach((com) => {
if (!com.communityUuid || !com.hieroTopicId) {
return
}
let alias = com.name
if (!alias || communityName.has(alias)) {
alias = com.communityUuid
}
communityName.add(alias)
communitiesForDltNodeServer.push({
communityId: com.communityUuid,
hieroTopicId: com.hieroTopicId,
alias,
// use only alpha-numeric chars for folder name
folder: alias.replace(/[^a-zA-Z0-9]/g, '_')
})
})
const dltNodeServerCommunitiesFile = path.join(folder, 'communities.json')
fs.writeFileSync(dltNodeServerCommunitiesFile, JSON.stringify(communitiesForDltNodeServer, null, 2))
logger.debug(`Written communitiesForDltNodeServer to ${dltNodeServerCommunitiesFile}`)
}

View File

@ -90,6 +90,7 @@ DLT_CONNECTOR=false
DLT_CONNECTOR_PORT=6010
DLT_NODE_SERVER_PORT=8340
DLT_NODE_SERVER_URL=$URL_PROTOCOL://$COMMUNITY_HOST/dlt
DLT_GRADIDO_NODE_SERVER_HOME_FOLDER=/home/gradido/.gradido
# used for combining a newsletter on klicktipp with this gradido community
# if used, user will be subscribed on register and can unsubscribe in his account

View File

@ -13,12 +13,12 @@ IOTA_HOME_COMMUNITY_SEED=aabbccddeeff00112233445566778899aabbccddeeff00112233445
DLT_CONNECTOR_PORT=6010
# Gradido Node Server URL
NODE_SERVER_URL=http://localhost:8340/api
DLT_NODE_SERVER_PORT=8340
# Gradido Blockchain
GRADIDO_BLOCKCHAIN_CRYPTO_APP_SECRET=21ffbbc616fe
GRADIDO_BLOCKCHAIN_SERVER_CRYPTO_KEY=a51ef8ac7ef1abf162fb7a65261acd7a
# Route to Backend
BACKEND_SERVER_URL=http://localhost:4000
PORT=4000
JWT_SECRET=secret123

View File

@ -7,3 +7,4 @@ package-json.lock
coverage
# emacs
*~
gradido_node

View File

@ -11,8 +11,11 @@
"@hashgraph/sdk": "^2.70.0",
"@sinclair/typebox": "^0.34.33",
"@sinclair/typemap": "^0.10.1",
"@types/adm-zip": "^0.5.7",
"@types/bun": "^1.2.17",
"@types/uuid": "^8.3.4",
"adm-zip": "^0.5.16",
"async-mutex": "^0.5.0",
"dotenv": "^10.0.0",
"elysia": "1.3.8",
"graphql-request": "^7.2.0",
@ -248,6 +251,8 @@
"@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="],
"@types/adm-zip": ["@types/adm-zip@0.5.7", "", { "dependencies": { "@types/node": "*" } }, "sha512-DNEs/QvmyRLurdQPChqq0Md4zGvPwHerAJYWk9l2jCbD1VPpnzRJorOdiq4zsw09NFbYnhfsoEhWtxIzXpn2yw=="],
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
"@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
@ -284,6 +289,8 @@
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
"adm-zip": ["adm-zip@0.5.16", "", {}, "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ=="],
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
"anser": ["anser@1.4.10", "", {}, "sha512-hCv9AqTQ8ycjpSd3upOJd7vFwW1JaoYQ7tpham03GJ1ca8/65rqn0RpaWpItOAd6ylW9wAw6luXYPJIyPFVOww=="],
@ -306,6 +313,8 @@
"async-limiter": ["async-limiter@1.0.1", "", {}, "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ=="],
"async-mutex": ["async-mutex@0.5.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA=="],
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
"atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="],

View File

@ -24,8 +24,11 @@
"@hashgraph/sdk": "^2.70.0",
"@sinclair/typebox": "^0.34.33",
"@sinclair/typemap": "^0.10.1",
"@types/adm-zip": "^0.5.7",
"@types/bun": "^1.2.17",
"@types/uuid": "^8.3.4",
"adm-zip": "^0.5.16",
"async-mutex": "^0.5.0",
"dotenv": "^10.0.0",
"elysia": "1.3.8",
"graphql-request": "^7.2.0",

View File

@ -8,6 +8,7 @@ import { SendToHieroContext } from '../interactions/sendToHiero/SendToHiero.cont
import { Community, communitySchema } from '../schemas/transaction.schema'
import { isPortOpenRetry } from '../utils/network'
import { type AppContext, type AppContextClients } from './appContext'
import { initGradidoNode } from './initGradidoNode'
export function loadConfig(): Logger {
// configure log4js
@ -36,7 +37,7 @@ export async function checkHomeCommunity(
const { backend, hiero } = appContext.clients
// wait for backend server
await isPortOpenRetry(CONFIG.BACKEND_SERVER_URL)
await isPortOpenRetry(backend.url)
// ask backend for home community
let homeCommunity = await backend.getHomeCommunityDraft()
// on missing topicId, create one
@ -64,8 +65,8 @@ export async function checkHomeCommunity(
}
appContext.cache.setHomeCommunityTopicId(homeCommunity.hieroTopicId)
logger.info(`home community topic: ${homeCommunity.hieroTopicId}`)
logger.info(`gradido node server: ${CONFIG.NODE_SERVER_URL}`)
logger.info(`gradido backend server: ${CONFIG.BACKEND_SERVER_URL}`)
logger.info(`gradido node server: ${appContext.clients.gradidoNode.url}`)
logger.info(`gradido backend server: ${appContext.clients.backend.url}`)
return v.parse(communitySchema, homeCommunity)
}
@ -74,6 +75,9 @@ export async function checkGradidoNode(
logger: Logger,
homeCommunity: Community,
): Promise<void> {
// check if gradido node is running, if not setup and start it
await initGradidoNode(clients)
// ask gradido node if community blockchain was created
try {
if (

View File

@ -0,0 +1,89 @@
import { execSync } from 'node:child_process'
import fs from 'node:fs'
import path from 'node:path'
import AdmZip from 'adm-zip'
import { getLogger } from 'log4js'
import { exportCommunities } from '../client/GradidoNode/communities'
import { GradidoNodeProcess } from '../client/GradidoNode/GradidoNodeProcess'
import { HieroClient } from '../client/hiero/HieroClient'
import { CONFIG } from '../config'
import {
GRADIDO_NODE_HOME_FOLDER_NAME,
GRADIDO_NODE_RUNTIME_PATH,
LOG4JS_BASE_CATEGORY,
} from '../config/const'
import { checkFileExist, checkPathExist } from '../utils/filesystem'
import { isPortOpen } from '../utils/network'
import { AppContextClients } from './appContext'
const logger = getLogger(`${LOG4JS_BASE_CATEGORY}.bootstrap.initGradidoNode`)
export async function initGradidoNode(clients: AppContextClients): Promise<void> {
const url = `http://localhost:${CONFIG.DLT_NODE_SERVER_PORT}`
const isOpen = await isPortOpen(url)
if (isOpen) {
logger.info(`GradidoNode is already running on ${url}`)
return
}
const gradidoNodeHomeFolder = path.join(
CONFIG.DLT_GRADIDO_NODE_SERVER_HOME_FOLDER,
GRADIDO_NODE_HOME_FOLDER_NAME,
)
// check folder, create when missing
checkPathExist(gradidoNodeHomeFolder, true)
await Promise.all([
// write Hedera Address Book
exportHederaAddressbooks(gradidoNodeHomeFolder, clients.hiero),
// check GradidoNode Runtime, download when missing
ensureGradidoNodeRuntimeAvailable(GRADIDO_NODE_RUNTIME_PATH),
// export communities to GradidoNode Folder
exportCommunities(gradidoNodeHomeFolder, clients.backend),
])
GradidoNodeProcess.getInstance().start()
}
async function exportHederaAddressbooks(
homeFolder: string,
hieroClient: HieroClient,
): Promise<void> {
const networkName = CONFIG.HIERO_HEDERA_NETWORK
const addressBook = await hieroClient.downloadAddressBook()
const addressBookPath = path.join(homeFolder, 'addressbook', `${networkName}.pb`)
checkPathExist(path.dirname(addressBookPath), true)
fs.writeFileSync(addressBookPath, addressBook.toBytes())
}
async function ensureGradidoNodeRuntimeAvailable(runtimeFileName: string): Promise<void> {
const runtimeFolder = path.dirname(runtimeFileName)
checkPathExist(runtimeFolder, true)
if (!checkFileExist(runtimeFileName)) {
const runtimeArchiveFilename = createGradidoNodeRuntimeArchiveFilename()
const downloadUrl = new URL(
`https://github.com/gradido/gradido_node/releases/download/v${CONFIG.DLT_GRADIDO_NODE_SERVER_VERSION}/${runtimeArchiveFilename}`,
)
logger.debug(`download GradidoNode Runtime from ${downloadUrl}`)
const archive = await fetch(downloadUrl)
if (!archive.ok) {
throw new Error(`Failed to download GradidoNode Runtime: ${archive.statusText}`)
}
const compressedBuffer = await archive.arrayBuffer()
if (process.platform === 'win32') {
const zip = new AdmZip(Buffer.from(compressedBuffer))
zip.extractAllTo(runtimeFolder, true)
} else {
const archivePath = path.join(runtimeFolder, runtimeArchiveFilename)
logger.debug(`GradidoNode Runtime Archive: ${archivePath}`)
fs.writeFileSync(archivePath, Buffer.from(compressedBuffer))
execSync(`tar -xzf ${archivePath}`, { cwd: runtimeFolder })
}
}
}
function createGradidoNodeRuntimeArchiveFilename(): string {
const version = CONFIG.DLT_GRADIDO_NODE_SERVER_VERSION
const platform: string = process.platform
const fileEnding = platform === 'win32' ? 'zip' : 'tar.gz'
return `gradido_node-v${version}-${platform}-${process.arch}.${fileEnding}`
}

View File

@ -1,4 +1,5 @@
import { Logger } from 'log4js'
import { GradidoNodeProcess } from '../client/GradidoNode/GradidoNodeProcess'
import { type AppContextClients } from './appContext'
export function setupGracefulShutdown(logger: Logger, clients: AppContextClients) {
@ -25,4 +26,5 @@ export function setupGracefulShutdown(logger: Logger, clients: AppContextClients
async function gracefulShutdown(logger: Logger, clients: AppContextClients) {
logger.info('graceful shutdown')
await clients.hiero.waitForPendingPromises()
await GradidoNodeProcess.getInstance().exit()
}

View File

@ -36,13 +36,21 @@ export class GradidoNodeClient {
private static instance: GradidoNodeClient
client: JsonRpcClient
logger: Logger
urlValue: string
private constructor() {
this.logger = getLogger(`${LOG4JS_BASE_CATEGORY}.client.GradidoNodeClient`)
this.urlValue = `http://localhost:${CONFIG.DLT_NODE_SERVER_PORT}/api`
this.logger.addContext('url', this.urlValue)
this.client = new JsonRpcClient({
url: CONFIG.NODE_SERVER_URL,
url: this.urlValue,
})
}
public get url(): string {
return this.urlValue
}
public static getInstance(): GradidoNodeClient {
if (!GradidoNodeClient.instance) {
GradidoNodeClient.instance = new GradidoNodeClient()
@ -233,7 +241,7 @@ export class GradidoNodeClient {
// template rpcCall, check first if port is open before executing json rpc 2.0 request
protected async rpcCall<T>(method: string, parameter: any): Promise<JsonRpcEitherResponse<T>> {
this.logger.debug('call %s with %s', method, parameter)
await isPortOpenRetry(CONFIG.NODE_SERVER_URL)
await isPortOpenRetry(this.url)
return this.client.exec<T>(method, parameter)
}

View File

@ -0,0 +1,121 @@
import { Subprocess, spawn } from 'bun'
import { getLogger, Logger } from 'log4js'
import { CONFIG } from '../../config'
import {
GRADIDO_NODE_KILL_TIMEOUT_MILLISECONDS,
GRADIDO_NODE_MIN_RUNTIME_BEFORE_RESTART_MILLISECONDS,
GRADIDO_NODE_RUNTIME_PATH,
LOG4JS_BASE_CATEGORY,
} from '../../config/const'
/**
* A Singleton class defines the `getInstance` method that lets clients access
* the unique singleton instance.
*
* Singleton Managing GradidoNode if started as subprocess
* will restart GradidoNode if it exits more than `GRADIDO_NODE_MIN_RUNTIME_BEFORE_RESTART_MILLISECONDS` milliseconds after start
* if exit was called, it will first try to exit graceful with SIGTERM and then kill with SIGKILL after `GRADIDO_NODE_KILL_TIMEOUT_MILLISECONDS` milliseconds
*/
export class GradidoNodeProcess {
private static instance: GradidoNodeProcess | null = null
private proc: Subprocess | null = null
private logger: Logger
private lastStarted: Date | null = null
private exitCalled: boolean = false
private constructor() {
// constructor is private to prevent instantiation from outside
// of the class except from the static getInstance method.
this.logger = getLogger(`${LOG4JS_BASE_CATEGORY}.client.GradidoNodeProcess`)
}
/**
* Static method that returns the singleton instance of the class.
* @returns the singleton instance of the class.
*/
public static getInstance(): GradidoNodeProcess {
if (!GradidoNodeProcess.instance) {
GradidoNodeProcess.instance = new GradidoNodeProcess()
}
return GradidoNodeProcess.instance
}
public start() {
if (this.proc) {
this.logger.warn('GradidoNodeProcess already running.')
return
}
this.logger.info(`starting GradidoNodeProcess with path: ${GRADIDO_NODE_RUNTIME_PATH}`)
this.lastStarted = new Date()
const logger = this.logger
this.proc = spawn([GRADIDO_NODE_RUNTIME_PATH], {
env: {
CLIENTS_HIERO_NETWORKTYPE: CONFIG.HIERO_HEDERA_NETWORK,
SERVER_JSON_RPC_PORT: CONFIG.DLT_NODE_SERVER_PORT.toString(),
USERPROFILE: CONFIG.DLT_GRADIDO_NODE_SERVER_HOME_FOLDER,
HOME: CONFIG.DLT_GRADIDO_NODE_SERVER_HOME_FOLDER,
},
onExit(proc, exitCode, signalCode, error) {
logger.warn(`GradidoNodeProcess exited with code ${exitCode} and signalCode ${signalCode}`)
if (error) {
logger.error(`GradidoNodeProcess exit error: ${error}`)
/*if (logger.isDebugEnabled() && proc.stderr) {
// print error messages from GradidoNode in our own log if debug is enabled
proc.stderr
.getReader()
.read()
.then((chunk) => {
logger.debug(chunk.value?.toString())
})
}*/
}
logger.debug(`ressource usage: ${JSON.stringify(proc?.resourceUsage(), null, 2)}`)
const gradidoNodeProcess = GradidoNodeProcess.getInstance()
gradidoNodeProcess.proc = null
if (
!gradidoNodeProcess.exitCalled &&
gradidoNodeProcess.lastStarted &&
Date.now() - gradidoNodeProcess.lastStarted.getTime() >
GRADIDO_NODE_MIN_RUNTIME_BEFORE_RESTART_MILLISECONDS
) {
// restart only if enough time was passed since last start to prevent restart loop
gradidoNodeProcess.start()
}
},
/*stdout: 'ignore',
stderr: logger.isDebugEnabled() ? 'pipe' : 'ignore',*/
stdout: 'inherit',
stderr: 'inherit',
})
}
public async restart() {
if (this.proc) {
await this.exit()
this.exitCalled = false
this.start()
}
}
public async exit(): Promise<void> {
this.exitCalled = true
if (this.proc) {
this.proc.kill('SIGTERM')
const timeout = setTimeout(() => {
this.logger.warn(
`GradidoNode couldn't exit graceful after ${GRADIDO_NODE_KILL_TIMEOUT_MILLISECONDS} milliseconds with SIGTERM, killing with SIGKILL`,
)
this.proc?.kill('SIGKILL')
}, GRADIDO_NODE_KILL_TIMEOUT_MILLISECONDS)
try {
await this.proc.exited
} catch (error) {
this.logger.error(`GradidoNodeProcess exit error: ${error}`)
} finally {
clearTimeout(timeout)
}
} else {
return Promise.resolve()
}
}
}

View File

@ -0,0 +1,84 @@
import fs from 'node:fs'
import path from 'node:path'
import { Mutex } from 'async-mutex'
import { getLogger } from 'log4js'
import { CONFIG } from '../../config'
import { GRADIDO_NODE_HOME_FOLDER_NAME, LOG4JS_BASE_CATEGORY } from '../../config/const'
import { HieroId } from '../../schemas/typeGuard.schema'
import { checkFileExist, checkPathExist } from '../../utils/filesystem'
import { BackendClient } from '../backend/BackendClient'
import { GradidoNodeProcess } from './GradidoNodeProcess'
const logger = getLogger(`${LOG4JS_BASE_CATEGORY}.client.GradidoNode.communities`)
const ensureCommunitiesAvailableMutex: Mutex = new Mutex()
// prototype, later add api call to gradido dlt node server for adding/updating communities
type CommunityForDltNodeServer = {
communityId: string
hieroTopicId: string
alias: string
folder: string
}
export async function ensureCommunitiesAvailable(communityTopicIds: HieroId[]): Promise<void> {
const release = await ensureCommunitiesAvailableMutex.acquire()
try {
const homeFolder = path.join(
CONFIG.DLT_GRADIDO_NODE_SERVER_HOME_FOLDER,
GRADIDO_NODE_HOME_FOLDER_NAME,
)
if (!checkCommunityAvailable(communityTopicIds, homeFolder)) {
await exportCommunities(homeFolder, BackendClient.getInstance())
return GradidoNodeProcess.getInstance().restart()
}
} finally {
release()
}
}
export async function exportCommunities(homeFolder: string, client: BackendClient): Promise<void> {
const communities = await client.getReachableCommunities()
const communitiesPath = path.join(homeFolder, 'communities.json')
checkPathExist(path.dirname(communitiesPath), true)
// make sure communityName is unique
const communityName = new Set<string>()
const communitiesForDltNodeServer: CommunityForDltNodeServer[] = []
for (const com of communities) {
if (!com.uuid || !com.hieroTopicId) {
continue
}
// use name as alias if not empty and unique, otherwise use uuid
let alias = com.name
if (!alias || communityName.has(alias)) {
alias = com.uuid
}
communityName.add(alias)
communitiesForDltNodeServer.push({
communityId: com.uuid,
hieroTopicId: com.hieroTopicId,
alias,
// use only alpha-numeric chars for folder name
folder: alias.replace(/[^a-zA-Z0-9]/g, '_'),
})
}
fs.writeFileSync(communitiesPath, JSON.stringify(communitiesForDltNodeServer, null, 2))
logger.info(`exported ${communitiesForDltNodeServer.length} communities to ${communitiesPath}`)
}
export function checkCommunityAvailable(communityTopicIds: HieroId[], homeFolder: string): boolean {
const communitiesPath = path.join(homeFolder, 'communities.json')
if (!checkFileExist(communitiesPath)) {
return false
}
const communities = JSON.parse(fs.readFileSync(communitiesPath, 'utf-8'))
let foundCount = 0
for (const community of communities) {
if (communityTopicIds.includes(community.hieroTopicId)) {
foundCount++
if (foundCount >= communityTopicIds.length) {
return true
}
}
}
return false
}

View File

@ -5,7 +5,11 @@ import * as v from 'valibot'
import { CONFIG } from '../../config'
import { LOG4JS_BASE_CATEGORY } from '../../config/const'
import { HieroId, Uuidv4 } from '../../schemas/typeGuard.schema'
import { homeCommunityGraphqlQuery, setHomeCommunityTopicId } from './graphql'
import {
getReachableCommunities,
homeCommunityGraphqlQuery,
setHomeCommunityTopicId,
} from './graphql'
import { type Community, communitySchema } from './output.schema'
// Source: https://refactoring.guru/design-patterns/singleton/typescript/example
@ -18,6 +22,7 @@ export class BackendClient {
private static instance: BackendClient
client: GraphQLClient
logger: Logger
urlValue: string
/**
* The Singleton's constructor should always be private to prevent direct
@ -25,8 +30,10 @@ export class BackendClient {
*/
private constructor() {
this.logger = getLogger(`${LOG4JS_BASE_CATEGORY}.client.BackendClient`)
this.logger.addContext('url', CONFIG.BACKEND_SERVER_URL)
this.client = new GraphQLClient(CONFIG.BACKEND_SERVER_URL, {
this.urlValue = `http://localhost:${CONFIG.PORT}`
this.logger.addContext('url', this.urlValue)
this.client = new GraphQLClient(this.urlValue, {
headers: {
'content-type': 'application/json',
},
@ -38,6 +45,10 @@ export class BackendClient {
})
}
public get url(): string {
return this.urlValue
}
/**
* The static method that controls the access to the singleton instance.
*
@ -77,6 +88,19 @@ export class BackendClient {
return v.parse(communitySchema, data.updateHomeCommunity)
}
public async getReachableCommunities(): Promise<Community[]> {
this.logger.info('get reachable communities on backend')
const { data, errors } = await this.client.rawRequest<{ reachableCommunities: Community[] }>(
getReachableCommunities,
{},
await this.getRequestHeader(),
)
if (errors) {
throw errors[0]
}
return v.parse(v.array(communitySchema), data.reachableCommunities)
}
private async getRequestHeader(): Promise<{
authorization: string
}> {

View File

@ -4,17 +4,24 @@ import { gql } from 'graphql-request'
* Schema Definitions for graphql requests
*/
const communityFragment = gql`
fragment Community_common on Community {
uuid
name
hieroTopicId
foreign
creationDate
}
`
// graphql query for getting home community in tune with community schema
export const homeCommunityGraphqlQuery = gql`
query {
homeCommunity {
uuid
name
hieroTopicId
foreign
creationDate
...Community_common
}
}
${communityFragment}
`
export const setHomeCommunityTopicId = gql`
@ -28,3 +35,12 @@ export const setHomeCommunityTopicId = gql`
}
}
`
export const getReachableCommunities = gql`
query {
reachableCommunities {
...Community_common
}
}
${communityFragment}
`

View File

@ -1,8 +1,11 @@
import {
AccountBalance,
AccountBalanceQuery,
AddressBookQuery,
Client,
FileId,
LocalProvider,
NodeAddressBook,
PrivateKey,
TopicCreateTransaction,
TopicId,
@ -166,6 +169,16 @@ export class HieroClient {
return v.parse(hieroIdSchema, createReceipt.topicId?.toString())
}
public async downloadAddressBook(): Promise<NodeAddressBook> {
const query = new AddressBookQuery().setFileId(FileId.ADDRESS_BOOK)
try {
return await query.execute(this.client)
} catch (e) {
this.logger.error(e)
throw e
}
}
public async updateTopic(topicId: HieroId): Promise<void> {
this.logger.addContext('topicId', topicId.toString())
let transaction = new TopicUpdateTransaction()

View File

@ -1,3 +1,21 @@
import path from 'node:path'
export const LOG4JS_BASE_CATEGORY = 'dlt'
// 7 days
export const MIN_TOPIC_EXPIRE_MILLISECONDS_FOR_UPDATE = 1000 * 60 * 60 * 24 * 7
// 10 minutes
export const MIN_TOPIC_EXPIRE_MILLISECONDS_FOR_SEND_MESSAGE = 1000 * 60 * 10
export const GRADIDO_NODE_RUNTIME_PATH = path.join(
__dirname,
'..',
'..',
'gradido_node',
'bin',
'GradidoNode',
)
// if last start was less than this time, do not restart
export const GRADIDO_NODE_MIN_RUNTIME_BEFORE_RESTART_MILLISECONDS = 1000 * 30
export const GRADIDO_NODE_KILL_TIMEOUT_MILLISECONDS = 10000
// currently hard coded in gradido node, update in future
export const GRADIDO_NODE_HOME_FOLDER_NAME = '.gradido'

View File

@ -1,3 +1,4 @@
import path from 'node:path'
import { MemoryBlock } from 'gradido-blockchain-js'
import * as v from 'valibot'
@ -73,12 +74,30 @@ export const configSchema = v.object({
),
500,
),
NODE_SERVER_URL: v.optional(
v.string('The URL of the gradido node server'),
'http://localhost:6010',
DLT_NODE_SERVER_PORT: v.optional(
v.pipe(
v.string('A valid port on which the DLT node server is running'),
v.transform<string, number>((input: string) => Number(input)),
v.minValue(1),
v.maxValue(65535),
),
'8340',
),
BACKEND_SERVER_URL: v.optional(
v.string('The URL of the gradido backend server'),
'http://localhost:6010',
DLT_GRADIDO_NODE_SERVER_VERSION: v.optional(
v.string('The version of the DLT node server'),
'0.9.0',
),
DLT_GRADIDO_NODE_SERVER_HOME_FOLDER: v.optional(
v.string('The home folder for the gradido dlt node server'),
path.join(__dirname, '..', '..', 'gradido_node'),
),
PORT: v.optional(
v.pipe(
v.string('A valid port on which the backend server is running'),
v.transform<string, number>((input: string) => Number(input)),
v.minValue(1),
v.maxValue(65535),
),
'4000',
),
})

View File

@ -7,6 +7,7 @@ import {
} from 'gradido-blockchain-js'
import { getLogger } from 'log4js'
import * as v from 'valibot'
import { ensureCommunitiesAvailable } from '../../client/GradidoNode/communities'
import { HieroClient } from '../../client/hiero/HieroClient'
import { LOG4JS_BASE_CATEGORY } from '../../config/const'
import { InputTransactionType } from '../../data/InputTransactionType.enum'
@ -21,6 +22,7 @@ import {
HieroTransactionIdString,
hieroTransactionIdStringSchema,
} from '../../schemas/typeGuard.schema'
import { isTopicStillOpen } from '../../utils/hiero'
import { AbstractTransactionRole } from './AbstractTransaction.role'
import { CommunityRootTransactionRole } from './CommunityRootTransaction.role'
import { CreationTransactionRole } from './CreationTransaction.role'
@ -39,13 +41,17 @@ const logger = getLogger(`${LOG4JS_BASE_CATEGORY}.interactions.sendToHiero.SendT
export async function SendToHieroContext(
input: TransactionInput | CommunityInput,
): Promise<HieroTransactionIdString> {
const role = chooseCorrectRole(input)
const role = await chooseCorrectRole(input)
const builder = await role.getGradidoTransactionBuilder()
if (builder.isCrossCommunityTransaction()) {
// build cross group transaction
const outboundTransaction = builder.buildOutbound()
validate(outboundTransaction)
if (!isTopicStillOpen(role.getRecipientCommunityTopicId())) {
throw new Error('recipient topic is not open long enough for sending messages')
}
// send outbound transaction to hiero at first, because we need the transaction id for inbound transaction
const outboundHieroTransactionIdString = await sendViaHiero(
outboundTransaction,
@ -102,9 +108,13 @@ async function sendViaHiero(
}
// choose correct role based on transaction type and input type
function chooseCorrectRole(input: TransactionInput | CommunityInput): AbstractTransactionRole {
async function chooseCorrectRole(
input: TransactionInput | CommunityInput,
): Promise<AbstractTransactionRole> {
const communityParsingResult = v.safeParse(communitySchema, input)
if (communityParsingResult.success) {
// make sure gradido node knows community
await ensureCommunitiesAvailable([communityParsingResult.output.hieroTopicId])
return new CommunityRootTransactionRole(communityParsingResult.output)
}
@ -116,6 +126,12 @@ function chooseCorrectRole(input: TransactionInput | CommunityInput): AbstractTr
})
throw new Error('invalid input')
}
// make sure gradido node knows communities
const communityTopicIds = [
transactionParsingResult.output.user.communityTopicId,
transactionParsingResult.output.linkedUser?.communityTopicId,
].filter((id): id is HieroId => id !== undefined)
await ensureCommunitiesAvailable(communityTopicIds)
const transaction = transactionParsingResult.output
switch (transaction.type) {

View File

@ -25,6 +25,12 @@ mock.module('../KeyPairCacheManager', () => {
}
})
mock.module('../client/GradidoNode/communities', () => ({
ensureCommunitiesAvailable: () => {
return Promise.resolve()
},
}))
mock.module('../client/hiero/HieroClient', () => ({
HieroClient: {
getInstance: () => ({

View File

@ -3,6 +3,7 @@ import { Elysia, status, t } from 'elysia'
import { AddressType_NONE } from 'gradido-blockchain-js'
import { getLogger } from 'log4js'
import * as v from 'valibot'
import { ensureCommunitiesAvailable } from '../client/GradidoNode/communities'
import { GradidoNodeClient } from '../client/GradidoNode/GradidoNodeClient'
import { LOG4JS_BASE_CATEGORY } from '../config/const'
import { KeyPairIdentifierLogic } from '../data/KeyPairIdentifier.logic'
@ -125,6 +126,8 @@ async function isAccountExist(identifierAccount: IdentifierAccountInput): Promis
// check and prepare input
const startTime = Date.now()
const identifierAccountParsed = v.parse(identifierAccountSchema, identifierAccount)
// make sure gradido node knows community
await ensureCommunitiesAvailable([identifierAccountParsed.communityTopicId])
const accountKeyPair = await ResolveKeyPair(new KeyPairIdentifierLogic(identifierAccountParsed))
const publicKey = accountKeyPair.getPublicKey()
if (!publicKey) {

View File

@ -0,0 +1,30 @@
import fs from 'node:fs'
import { getLogger } from 'log4js'
import { LOG4JS_BASE_CATEGORY } from '../config/const'
const logger = getLogger(`${LOG4JS_BASE_CATEGORY}.utils.filesystem`)
export function checkFileExist(filePath: string): boolean {
try {
fs.accessSync(filePath, fs.constants.R_OK | fs.constants.W_OK)
return true
} catch (_err) {
// logger.debug(`file ${filePath} does not exist: ${_err}`)
return false
}
}
export function checkPathExist(path: string, createIfMissing: boolean = false): boolean {
const exists = checkFileExist(path)
if (exists) {
return true
}
if (createIfMissing) {
logger.info(`create folder ${path}`)
fs.mkdirSync(path, { recursive: true })
if (!checkPathExist(path)) {
throw new Error(`Failed to create path ${path}`)
}
}
return false
}

View File

@ -0,0 +1,19 @@
import { HieroClient } from '../client/hiero/HieroClient'
import { MIN_TOPIC_EXPIRE_MILLISECONDS_FOR_SEND_MESSAGE } from '../config/const'
import { HieroId } from '../schemas/typeGuard.schema'
/**
* Checks whether the given topic in the Hedera network will remain open
* for sending messages for at least `MIN_TOPIC_EXPIRE_MILLISECONDS_FOR_SEND_MESSAGE` milliseconds.
*
* @param {HieroId} hieroTopicId - The topic ID to check.
* @returns {Promise<boolean>} `true` if the topic is still open long enough, otherwise `false`.
*/
export async function isTopicStillOpen(hieroTopicId: HieroId): Promise<boolean> {
const hieroClient = HieroClient.getInstance()
const topicInfo = await hieroClient.getTopicInfo(hieroTopicId)
return (
topicInfo.expirationTime.getTime() >
new Date().getTime() + MIN_TOPIC_EXPIRE_MILLISECONDS_FOR_SEND_MESSAGE
)
}

View File

@ -12,6 +12,7 @@ export class GetPublicCommunityInfoResult {
this.name = dbCom.name
this.description = dbCom.description
this.creationDate = dbCom.creationDate
this.hieroTopicId = dbCom.hieroTopicId
}
@Field(() => String)
@ -28,4 +29,7 @@ export class GetPublicCommunityInfoResult {
@Field(() => String)
publicJwtKey: string
@Field(() => String)
hieroTopicId: string | null
}