diff --git a/apps/backend/package.json b/apps/backend/package.json index eca808e3a..34b9f98c7 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -19,7 +19,7 @@ "test:cov": "npx jest --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", "test:e2e": "npx jest --config ./test/jest-e2e.json", - "test:ci": "npx jest --ci --runInBand", + "test:ci": "npx jest --ci --runInBand --forceExit", "clear-cache": "npx jest --clearCache", "clean:modules": "rimraf node_modules", "deleteAllTransactions": "ts-node ./src/scripts/deleteAllDBTransactions.ts", diff --git a/apps/backend/src/jobs/autoSubmit.service.ts b/apps/backend/src/jobs/autoSubmit.service.ts index 1330ad11a..3b55ebd3c 100644 --- a/apps/backend/src/jobs/autoSubmit.service.ts +++ b/apps/backend/src/jobs/autoSubmit.service.ts @@ -24,12 +24,13 @@ import TransactionService from '../transaction/transaction.service'; import { TransactionStatus } from '../transaction/status.enum'; import { Transaction, - Client, PublicKey, TransactionResponse, TransactionReceipt, Status, + Client, } from '@hiero-ledger/sdk'; +import { buildHederaClient } from '../utils/clientFactory'; import { GetTransactionsResponseDto } from '../transaction/dto/get-transactions-response.dto'; import { hexToUint8Array } from '../utils/utils'; import { LoggerService } from '../logger/logger.service.js'; @@ -142,7 +143,7 @@ export default class AutoSubmitService { async submit(transaction: GetTransactionsResponseDto): Promise { try { - const client: Client = Client.forName(transaction.network); + const client = buildHederaClient(transaction.network, transaction.consensus_nodes); let deserializedTransaction = Transaction.fromBytes( hexToUint8Array(transaction.transaction_message), diff --git a/apps/backend/src/transaction/dto/create-transaction-request.dto.ts b/apps/backend/src/transaction/dto/create-transaction-request.dto.ts index 01c505923..32c008107 100644 --- a/apps/backend/src/transaction/dto/create-transaction-request.dto.ts +++ b/apps/backend/src/transaction/dto/create-transaction-request.dto.ts @@ -26,15 +26,28 @@ import { IsIn, IsInt, IsNotEmpty, + IsOptional, IsString, Matches, Min, + ValidateNested, } from 'class-validator'; +import { Type } from 'class-transformer'; import { hederaIdRegex, hexRegex } from '../../common/regexp'; import { RemoveHexPrefix } from '../../common/decorators/transform-hexPrefix.decorator'; import { Network } from '../network.enum'; import { Transform } from 'class-transformer'; +export class ConsensusNodeDto { + @IsString() + @IsNotEmpty() + url: string; + + @IsString() + @IsNotEmpty() + nodeId: string; +} + export class CreateTransactionRequestDto { @ApiProperty({ description: 'The message to be signed by the keys', @@ -112,6 +125,17 @@ export class CreateTransactionRequestDto { ) network: Network; + @ApiProperty({ + description: + 'Consensus nodes for custom networks (required when network is "custom")', + required: false, + }) + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => ConsensusNodeDto) + consensus_nodes?: ConsensusNodeDto[]; + @ApiProperty({ description: 'The start date of the transaction in ISO 8601 format', example: '2023-08-01T12:00:00Z', @@ -129,6 +153,7 @@ export class CreateTransactionRequestDto { threshold: number, network: Network, start_date: string, + consensus_nodes?: ConsensusNodeDto[], ) { this.transaction_message = transaction_message; this.description = description; @@ -137,5 +162,6 @@ export class CreateTransactionRequestDto { this.threshold = threshold; this.network = network; this.start_date = start_date; + this.consensus_nodes = consensus_nodes; } } diff --git a/apps/backend/src/transaction/dto/get-transactions-response.dto.ts b/apps/backend/src/transaction/dto/get-transactions-response.dto.ts index 05867a803..3447f76c5 100644 --- a/apps/backend/src/transaction/dto/get-transactions-response.dto.ts +++ b/apps/backend/src/transaction/dto/get-transactions-response.dto.ts @@ -30,6 +30,7 @@ export class GetTransactionsResponseDto { network: string; hedera_account_id: string; start_date: string; + consensus_nodes: { url: string; nodeId: string }[] | null; constructor( id: string, @@ -43,6 +44,7 @@ export class GetTransactionsResponseDto { network: string, hedera_account_id: string, start_date: string, + consensus_nodes: { url: string; nodeId: string }[] | null, ) { this.id = id; this.transaction_message = transaction_message; @@ -55,5 +57,6 @@ export class GetTransactionsResponseDto { this.network = network; this.hedera_account_id = hedera_account_id; this.start_date = start_date; + this.consensus_nodes = consensus_nodes; } } diff --git a/apps/backend/src/transaction/network.enum.ts b/apps/backend/src/transaction/network.enum.ts index 1bc6908a4..6b89e0250 100644 --- a/apps/backend/src/transaction/network.enum.ts +++ b/apps/backend/src/transaction/network.enum.ts @@ -2,4 +2,5 @@ export enum Network { MAINNET = 'mainnet', TESTNET = 'testnet', PREVIEWNET = 'previewnet', + CUSTOM = 'custom', } diff --git a/apps/backend/src/transaction/transaction.entity.ts b/apps/backend/src/transaction/transaction.entity.ts index ed133b38d..2b4e6179d 100644 --- a/apps/backend/src/transaction/transaction.entity.ts +++ b/apps/backend/src/transaction/transaction.entity.ts @@ -1,5 +1,4 @@ import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; -import { Network } from './network.enum'; import { TransactionStatus } from './status.enum'; @Entity() @@ -46,12 +45,11 @@ export default class Transaction { @Column() threshold: number; - @Column({ - type: 'enum', - enum: Network, - nullable: false, - }) - network: Network; + @Column({ type: 'varchar', nullable: false }) + network: string; + + @Column({ type: 'jsonb', nullable: true }) + consensus_nodes: { url: string; nodeId: string }[] | null; @Column({ type: 'timestamp with time zone', diff --git a/apps/backend/src/transaction/transaction.service.ts b/apps/backend/src/transaction/transaction.service.ts index 218856a5f..855b08b80 100644 --- a/apps/backend/src/transaction/transaction.service.ts +++ b/apps/backend/src/transaction/transaction.service.ts @@ -44,7 +44,8 @@ import { } from '../common/exceptions/domain-exceptions'; import { TransactionStatus } from './status.enum'; import { Network } from './network.enum'; -import { Client, Transaction as TransactionSdk } from '@hiero-ledger/sdk'; +import { Transaction as TransactionSdk } from '@hiero-ledger/sdk'; +import { buildHederaClient } from '../utils/clientFactory'; @Injectable() export default class TransactionService { @@ -97,7 +98,7 @@ export default class TransactionService { const deserializedTransaction = TransactionSdk.fromBytes( hexToUint8Array(transaction.transaction_message), - ).freezeWith(Client.forName(transaction.network)); + ).freezeWith(buildHederaClient(transaction.network, transaction.consensus_nodes)); if ( !verifySignature( @@ -251,6 +252,7 @@ export default class TransactionService { transaction.network, transaction.hedera_account_id, transaction.start_date.toUTCString(), + transaction.consensus_nodes ?? null, ); } } diff --git a/apps/backend/src/utils/clientFactory.ts b/apps/backend/src/utils/clientFactory.ts new file mode 100644 index 000000000..3d257f76a --- /dev/null +++ b/apps/backend/src/utils/clientFactory.ts @@ -0,0 +1,44 @@ +/* + * + * Hedera Stablecoin SDK + * + * Copyright (C) 2023 Hedera Hashgraph, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +import { Client } from '@hiero-ledger/sdk'; + +export function buildHederaClient( + network: string, + consensusNodes?: { url: string; nodeId: string }[] | null, +): Client { + switch (network) { + case 'mainnet': + return Client.forMainnet(); + case 'testnet': + return Client.forTestnet(); + case 'previewnet': + return Client.forPreviewnet(); + default: { + if (!consensusNodes?.length) + throw new Error( + `Network '${network}' requires consensus_nodes to be provided`, + ); + return Client.forNetwork( + Object.fromEntries(consensusNodes.map((n) => [n.url, n.nodeId])), + ); + } + } +} diff --git a/apps/backend/test/transaction/transaction.controller.spec.ts b/apps/backend/test/transaction/transaction.controller.spec.ts index 8bcb341f9..10fd123ae 100644 --- a/apps/backend/test/transaction/transaction.controller.spec.ts +++ b/apps/backend/test/transaction/transaction.controller.spec.ts @@ -333,6 +333,7 @@ describe('Transaction Controller Test', () => { DEFAULT.network, DEFAULT.hedera_account_id, DEFAULT.start_date.toDateString(), + null, ), ), ); @@ -349,6 +350,7 @@ describe('Transaction Controller Test', () => { DEFAULT.network, DEFAULT.hedera_account_id, DEFAULT.start_date.toDateString(), + null, ); //* 🎬 Act ⬇ const result = await controller.getTransactionById( @@ -381,6 +383,7 @@ function createMockGetAllByPublicKeyTxServiceResult( pendingTransaction.network, pendingTransaction.hedera_account_id, pendingTransaction.start_date.toDateString(), + null, ); return new Pagination( [transactionResponse, transactionResponse], diff --git a/apps/backend/test/transaction/transaction.mock.ts b/apps/backend/test/transaction/transaction.mock.ts index 5f8656e62..f8ade3291 100644 --- a/apps/backend/test/transaction/transaction.mock.ts +++ b/apps/backend/test/transaction/transaction.mock.ts @@ -119,6 +119,7 @@ export default class TransactionMock extends Transaction { signatures: TransactionMock.txPending0().signatures, network: TransactionMock.txPending0().network, start_date: TransactionMock.txPending0().start_date.toDateString(), + consensus_nodes: null, }; static txPending1(command: Partial = {}) { diff --git a/apps/backend/test/transaction/transaction.service.spec.ts b/apps/backend/test/transaction/transaction.service.spec.ts index 62d3cb880..569902cc5 100644 --- a/apps/backend/test/transaction/transaction.service.spec.ts +++ b/apps/backend/test/transaction/transaction.service.spec.ts @@ -25,6 +25,7 @@ import { Repository } from 'typeorm'; import TransactionService from '../../src/transaction/transaction.service'; import Transaction from '../../src/transaction/transaction.entity'; import { SignTransactionRequestDto } from '../../src/transaction/dto/sign-transaction-request.dto'; +import { CreateTransactionRequestDto } from '../../src/transaction/dto/create-transaction-request.dto'; import TransactionMock, { DEFAULT } from './transaction.mock'; import { LoggerService } from '../../src/logger/logger.service'; import { TransactionStatus } from '../../src/transaction/status.enum'; @@ -88,7 +89,7 @@ describe('Transaction Service Test', () => { threshold: pendingTransaction.threshold, network: pendingTransaction.network, start_date: pendingTransaction.start_date.toDateString(), - }; + } as CreateTransactionRequestDto; const expected = TransactionMock.txPending0(); //* 🎬 Act ⬇ @@ -118,7 +119,7 @@ describe('Transaction Service Test', () => { threshold: pendingTransaction.threshold, network: pendingTransaction.network, start_date: pendingTransaction.start_date.toDateString(), - }; + } as CreateTransactionRequestDto; //* 🎬 Act ⬇ const transaction = await service.create(createTransactionDto); @@ -143,7 +144,7 @@ describe('Transaction Service Test', () => { threshold: new_threshold, network: pendingTransaction.network, start_date: pendingTransaction.start_date.toDateString(), - }; + } as CreateTransactionRequestDto; //* 🎬 Act ⬇ const transaction = await service.create(createTransactionDto); @@ -167,7 +168,7 @@ describe('Transaction Service Test', () => { threshold: pendingTransaction.threshold, network: pendingTransaction.network, start_date: pendingTransaction.start_date.toDateString(), - }; + } as CreateTransactionRequestDto; const expected = TransactionMock.txPending0({ threshold: createTransactionDto.key_list.length, diff --git a/packages/contracts/package.json b/packages/contracts/package.json index b49a093c3..d4deac744 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -16,6 +16,10 @@ "import": "./build/typechain-types/index.js", "require": "./build/typechain-types/index.js" }, + "./typechain-types/factories/contracts": { + "import": "./build/typechain-types/factories/contracts/index.js", + "require": "./build/typechain-types/factories/contracts/index.js" + }, "./typechain-types/*": { "import": "./build/typechain-types/*", "require": "./build/typechain-types/*" diff --git a/packages/sdk/__mocks__/fireblocks-sdk.js b/packages/sdk/__mocks__/fireblocks-sdk.js new file mode 100644 index 000000000..510e4c75f --- /dev/null +++ b/packages/sdk/__mocks__/fireblocks-sdk.js @@ -0,0 +1,2 @@ +// __mocks__/fireblocks-sdk.js +module.exports = {}; diff --git a/packages/sdk/example/.env.sample b/packages/sdk/example/.env.sample index e89869a1a..e0d5dcdfd 100644 --- a/packages/sdk/example/.env.sample +++ b/packages/sdk/example/.env.sample @@ -6,4 +6,29 @@ FACTORY_ADDRESS='0.0.XXXX' # Optional: provide an existing token ID to skip creation in testExternalEVM TOKEN_ID='' # Optional: provide an existing token ID to skip creation in testExternalHedera -TOKEN_ID_HEDERA='' \ No newline at end of file +TOKEN_ID_HEDERA='' + +# --- Multisig example (multisigFreeze.ts) --- +# Multisig account — the account whose keys are managed via the backend +MULTISIG_ACCOUNT_ID='0.0.XXXX' +# Backend URL for multisig transaction coordination +BACKEND_URL='http://127.0.0.1:3001/api/transactions/' +# Custom consensus node +CONSENSUS_NODE_URL='host:port' +CONSENSUS_NODE_ID='0.0.X' + +# --- DFNS multisig example (createDFNSMultisigAccount.ts / multisigFreezeDFNS.ts) --- +# Multisig account created by createDFNSMultisigAccount.ts (KeyList includes DFNS key) +DFNS_MULTISIG_ACCOUNT_ID='0.0.XXXX' +# DFNS service account credentials +DFNS_SERVICE_ACCOUNT_AUTHORIZATION_TOKEN='' +DFNS_SERVICE_ACCOUNT_CREDENTIAL_ID='' +DFNS_SERVICE_ACCOUNT_PRIVATE_KEY_OR_PATH='' +# DFNS application settings +DFNS_APP_ORIGIN='' +DFNS_APP_ID='' +DFNS_BASE_URL='' +# DFNS wallet linked to the Hedera account +DFNS_WALLET_ID='' +DFNS_WALLET_PUBLIC_KEY='' +DFNS_HEDERA_ACCOUNT_ID='0.0.XXXX' diff --git a/packages/sdk/example/ts/createDFNSMultisigAccount.ts b/packages/sdk/example/ts/createDFNSMultisigAccount.ts new file mode 100644 index 000000000..227a85a4f --- /dev/null +++ b/packages/sdk/example/ts/createDFNSMultisigAccount.ts @@ -0,0 +1,84 @@ +/** + * Creates a Hedera multisig account whose KeyList includes the DFNS wallet + * public key (+ optionally the deployer key as a co-signer). + * + * The account is created with a 1-of-2 threshold so DFNS alone can sign, + * but the deployer can also sign independently (useful for recovery). + * Change `threshold` to 2 if you need both keys to sign every transaction. + * + * HOW TO RUN: + * npm run create-dfns-multisig-account + * + * After it prints the new account ID, set it in your .env: + * DFNS_MULTISIG_ACCOUNT_ID=0.0.XXXX + */ + +import { + AccountCreateTransaction, + AccountId, + Client, + Hbar, + KeyList, + PrivateKey, + PublicKey, +} from '@hiero-ledger/sdk'; + +require('dotenv').config({ path: __dirname + '/../../.env' }); + +const DEPLOYER_ACCOUNT_ID = process.env.MY_ACCOUNT_ID!; +const DEPLOYER_PRIVATE_KEY = process.env.MY_PRIVATE_KEY_ECDSA!; +const DFNS_WALLET_PUBLIC_KEY = process.env.DFNS_WALLET_PUBLIC_KEY!; + +const CONSENSUS_NODE_URL = process.env.CONSENSUS_NODE_URL ?? '34.94.106.61:50211'; +const CONSENSUS_NODE_ID = process.env.CONSENSUS_NODE_ID ?? '0.0.3'; + +// How many keys must sign: 1 = DFNS alone is enough, 2 = both must sign. +const THRESHOLD = 1; + +async function main(): Promise { + if (!DEPLOYER_ACCOUNT_ID || !DEPLOYER_PRIVATE_KEY) { + throw new Error('MY_ACCOUNT_ID and MY_PRIVATE_KEY_ECDSA must be set in .env'); + } + if (!DFNS_WALLET_PUBLIC_KEY) { + throw new Error('DFNS_WALLET_PUBLIC_KEY must be set in .env'); + } + + const deployerPrivKey = PrivateKey.fromStringECDSA(DEPLOYER_PRIVATE_KEY); + const dfnsPublicKey = PublicKey.fromString(DFNS_WALLET_PUBLIC_KEY); + + // 1-of-2 KeyList: DFNS key + deployer key (threshold can be adjusted above) + const keyList = new KeyList([dfnsPublicKey, deployerPrivKey.publicKey], THRESHOLD); + + const client = Client.forNetwork( + Object.fromEntries([[CONSENSUS_NODE_URL, CONSENSUS_NODE_ID]]), + ).setOperator(AccountId.fromString(DEPLOYER_ACCOUNT_ID), deployerPrivKey); + + console.log(`Creating ${THRESHOLD}-of-2 multisig account...`); + console.log(` Key 1 (DFNS): ${dfnsPublicKey.toString()}`); + console.log(` Key 2 (deployer): ${deployerPrivKey.publicKey.toString()}`); + + const tx = await new AccountCreateTransaction() + .setKeyWithoutAlias(keyList) + .setInitialBalance(new Hbar(0)) + .execute(client); + + const receipt = await tx.getReceipt(client); + const newAccountId = receipt.accountId; + + if (!newAccountId) { + throw new Error('Account creation failed — no accountId in receipt'); + } + + console.log(`\nMultisig account created: ${newAccountId.toString()}`); + console.log(`\nAdd this to your .env:`); + console.log(` DFNS_MULTISIG_ACCOUNT_ID=${newAccountId.toString()}`); + console.log(`\nThen update multisigFreezeDFNS.ts to use DFNS_MULTISIG_ACCOUNT_ID`); + console.log(`as the targetId/accountId for association, role grant, and freeze.`); +} + +main() + .then(() => process.exit(0)) + .catch((err) => { + console.error(err); + process.exit(1); + }); diff --git a/packages/sdk/example/ts/multisigFreeze.ts b/packages/sdk/example/ts/multisigFreeze.ts new file mode 100644 index 000000000..0652e3dbb --- /dev/null +++ b/packages/sdk/example/ts/multisigFreeze.ts @@ -0,0 +1,212 @@ +import { + Network, + InitializationRequest, + ConnectRequest, + SupportedWallets, + StableCoin, + Role, + StableCoinRole, + CreateRequest, + TokenSupplyType, + GrantRoleRequest, + FreezeAccountRequest, + SignTransactionRequest, +} from '@hashgraph/stablecoin-npm-sdk'; +import { + AccountId, + Client, + PrivateKey, + Status, + TokenAssociateTransaction, + TokenId, +} from '@hiero-ledger/sdk'; + +require('dotenv').config({ path: __dirname + '/../../.env' }); + +// === Deployer & signing account (CLIENT wallet) === +const DEPLOYER_ACCOUNT_ID = process.env.MY_ACCOUNT_ID!; +const DEPLOYER_PRIVATE_KEY = process.env.MY_PRIVATE_KEY_ECDSA!; + +// === Multisig account (MULTISIG wallet — only for the freeze) === +const MULTISIG_ACCOUNT_ID = process.env.MULTISIG_ACCOUNT_ID!; + +// === Infrastructure === +const FACTORY_ADDRESS = process.env.FACTORY_ADDRESS!; +const RESOLVER_ADDRESS = process.env.RESOLVER_ADDRESS!; +const BACKEND_URL = process.env.BACKEND_URL ?? 'http://127.0.0.1:3001/api/transactions/'; +const CONSENSUS_NODE_URL = process.env.CONSENSUS_NODE_URL ?? '34.94.106.61:50211'; +const CONSENSUS_NODE_ID = process.env.CONSENSUS_NODE_ID ?? '0.0.3'; + +const consensusNodes = [{ url: CONSENSUS_NODE_URL, nodeId: CONSENSUS_NODE_ID }]; + +const mirrorNodeConfig = { + name: 'Testnet Mirror Node', + network: 'testnet', + baseUrl: 'https://testnet.mirrornode.hedera.com/api/v1/', + apiKey: '', + headerName: '', + selected: true, +}; + +const RPCNodeConfig = { + name: 'HashIO', + network: 'testnet', + baseUrl: 'https://testnet.hashio.io/api', + apiKey: '', + headerName: '', + selected: true, +}; + +const wait = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +const retry = async ( + fn: () => Promise, + label: string, + intervalMs = 5000, + maxAttempts = 12, +): Promise => { + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + return await fn(); + } catch (err: any) { + if (attempt === maxAttempts) throw err; + console.log(` [${label}] attempt ${attempt} failed, retrying in ${intervalMs / 1000}s...`); + await wait(intervalMs); + } + } + throw new Error(`${label} exhausted all retries`); +}; + +const connectDeployer = () => + Network.connect( + new ConnectRequest({ + account: { + accountId: DEPLOYER_ACCOUNT_ID, + privateKey: { key: DEPLOYER_PRIVATE_KEY, type: 'ECDSA' }, + }, + network: 'custom', + mirrorNode: mirrorNodeConfig, + rpcNode: RPCNodeConfig, + wallet: SupportedWallets.CLIENT, + consensusNodes, + }), + ); + +const connectMultisig = () => + Network.connect( + new ConnectRequest({ + account: { accountId: MULTISIG_ACCOUNT_ID }, + network: 'custom', + mirrorNode: mirrorNodeConfig, + rpcNode: RPCNodeConfig, + wallet: SupportedWallets.MULTISIG, + consensusNodes, + }), + ); + +const main = async () => { + // ── Init ──────────────────────────────────────────────────────────────── + await Network.init( + new InitializationRequest({ + network: 'custom', + mirrorNode: mirrorNodeConfig, + rpcNode: RPCNodeConfig, + configuration: { factoryAddress: FACTORY_ADDRESS, resolverAddress: RESOLVER_ADDRESS }, + consensusNodes, + backend: { url: BACKEND_URL }, + }), + ); + + // ── Phase 1: Deploy stablecoin ────────────────────────────────────────── + console.log('\n[1/4] Deploying stablecoin...'); + await connectDeployer(); + + const stableCoin = (await StableCoin.create( + new CreateRequest({ + name: 'MultisigFreezeTest', + symbol: 'MFT', + decimals: 6, + initialSupply: '1000', + freezeKey: { key: 'null', type: 'null' }, + kycKey: { key: 'null', type: 'null' }, + wipeKey: { key: 'null', type: 'null' }, + pauseKey: { key: 'null', type: 'null' }, + feeScheduleKey: { key: 'null', type: 'null' }, + supplyType: TokenSupplyType.INFINITE, + createReserve: false, + grantKYCToOriginalSender: true, + burnRoleAccount: DEPLOYER_ACCOUNT_ID, + wipeRoleAccount: DEPLOYER_ACCOUNT_ID, + rescueRoleAccount: DEPLOYER_ACCOUNT_ID, + pauseRoleAccount: DEPLOYER_ACCOUNT_ID, + freezeRoleAccount: DEPLOYER_ACCOUNT_ID, + deleteRoleAccount: DEPLOYER_ACCOUNT_ID, + kycRoleAccount: DEPLOYER_ACCOUNT_ID, + cashInRoleAccount: DEPLOYER_ACCOUNT_ID, + feeRoleAccount: DEPLOYER_ACCOUNT_ID, + cashInRoleAllowance: '0', + proxyOwnerAccount: DEPLOYER_ACCOUNT_ID, + configId: '0x0000000000000000000000000000000000000000000000000000000000000002', + configVersion: 1, + }), + )) as { coin: any; reserve: any }; + + const tokenId: string = stableCoin.coin.tokenId.toString(); + console.log(` Stablecoin deployed: ${tokenId}`); + await wait(5000); + + // ── Phase 2: Associate multisig account to token (direct HTS tx) ──────── + console.log('\n[2/4] Associating multisig account to token...'); + const hederaClient = Client.forNetwork( + Object.fromEntries(consensusNodes.map((n) => [n.url, n.nodeId])), + ).setOperator(DEPLOYER_ACCOUNT_ID, PrivateKey.fromStringECDSA(DEPLOYER_PRIVATE_KEY)); + + const associateTx = await new TokenAssociateTransaction() + .setAccountId(AccountId.fromString(MULTISIG_ACCOUNT_ID)) + .setTokenIds([TokenId.fromString(tokenId)]) + .freezeWith(hederaClient) + .sign(PrivateKey.fromStringECDSA(DEPLOYER_PRIVATE_KEY)); + + const associateResponse = await associateTx.execute(hederaClient); + const receipt = await associateResponse.getReceipt(hederaClient); + if (receipt.status !== Status.Success) { + throw new Error(`Association failed: ${receipt.status.toString()}`); + } + console.log(' Association done.'); + await wait(5000); + + // ── Phase 3: Grant freeze role to multisig account (CLIENT/deployer) ──── + console.log('\n[3/4] Granting freeze role to multisig account...'); + await Role.grantRole( + new GrantRoleRequest({ + targetId: MULTISIG_ACCOUNT_ID, + tokenId, + role: StableCoinRole.FREEZE_ROLE, + }), + ); + console.log(' Freeze role granted.'); + await wait(5000); + + // ── Phase 4: Freeze via multisig (MULTISIG → sign → autoSubmit) ───────── + console.log('\n[4/4] Submitting freeze via multisig...'); + await connectMultisig(); + + const startDate = new Date(Date.now() + 1 * 60 * 1000).toISOString(); + const freezeResult = await retry( + () => StableCoin.freeze(new FreezeAccountRequest({ tokenId, targetId: MULTISIG_ACCOUNT_ID, startDate })), + 'freeze', + ); + const freezeTxId = freezeResult.transactionId!; + console.log(` Backend tx: ${freezeTxId}`); + + await connectDeployer(); + await StableCoin.signTransaction(new SignTransactionRequest({ transactionId: freezeTxId })); + console.log(' Signature stored. Waiting for autoSubmit...'); + + process.exit(0); +}; + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/packages/sdk/example/ts/multisigFreezeDFNS.ts b/packages/sdk/example/ts/multisigFreezeDFNS.ts new file mode 100644 index 000000000..9b3c687ee --- /dev/null +++ b/packages/sdk/example/ts/multisigFreezeDFNS.ts @@ -0,0 +1,293 @@ +import { + Network, + InitializationRequest, + ConnectRequest, + SupportedWallets, + StableCoin, + Role, + StableCoinRole, + CreateRequest, + TokenSupplyType, + GrantRoleRequest, + FreezeAccountRequest, + SignTransactionRequest, +} from '@hashgraph/stablecoin-npm-sdk'; +import type { DFNSConfigRequest } from '@hashgraph/stablecoin-npm-sdk'; +import { + AccountId, + Client, + PrivateKey, + Status, + TokenAssociateTransaction, + TokenId, +} from '@hiero-ledger/sdk'; +import * as fs from 'fs'; + +require('dotenv').config({ path: __dirname + '/../../.env' }); + +// Resolves a value that can be either a raw key string or a path to a key file. +const resolveKeyOrPath = (value: string | undefined): string | undefined => { + if (!value) return undefined; + if (fs.existsSync(value)) return fs.readFileSync(value, 'utf8').trim(); + return value; +}; + +// === Deployer & signing account (CLIENT wallet) === +const DEPLOYER_ACCOUNT_ID = process.env.MY_ACCOUNT_ID!; +const DEPLOYER_PRIVATE_KEY = process.env.MY_PRIVATE_KEY_ECDSA!; + +// === Infrastructure === +const FACTORY_ADDRESS = process.env.FACTORY_ADDRESS!; +const RESOLVER_ADDRESS = process.env.RESOLVER_ADDRESS!; +const BACKEND_URL = process.env.BACKEND_URL ?? 'http://127.0.0.1:3001/api/transactions/'; +const CONSENSUS_NODE_URL = process.env.CONSENSUS_NODE_URL ?? '34.94.106.61:50211'; +const CONSENSUS_NODE_ID = process.env.CONSENSUS_NODE_ID ?? '0.0.3'; + +// === DFNS custodial wallet settings === +const DFNS_AUTH_TOKEN = process.env.DFNS_SERVICE_ACCOUNT_AUTHORIZATION_TOKEN!; +const DFNS_CREDENTIAL_ID = process.env.DFNS_SERVICE_ACCOUNT_CREDENTIAL_ID!; +const DFNS_PRIVATE_KEY = resolveKeyOrPath( + process.env.DFNS_SERVICE_ACCOUNT_PRIVATE_KEY_OR_PATH ?? + process.env.DFNS_SERVICE_ACCOUNT_PRIVATE_KEY_PATH ?? + process.env.DFNS_SERVICE_ACCOUNT_PRIVATE_KEY, +)!; +const DFNS_APP_ORIGIN = process.env.DFNS_APP_ORIGIN!; +const DFNS_APP_ID = process.env.DFNS_APP_ID!; +const DFNS_BASE_URL = process.env.DFNS_BASE_URL ?? process.env.DFNS_TEST_URL!; +const DFNS_WALLET_ID = process.env.DFNS_WALLET_ID!; +const DFNS_WALLET_PUBLIC_KEY = process.env.DFNS_WALLET_PUBLIC_KEY!; +// The Hedera account ID that DFNS controls (used as the connected wallet identity) +const DFNS_HEDERA_ACCOUNT_ID = process.env.DFNS_HEDERA_ACCOUNT_ID!; +// The multisig account created by createDFNSMultisigAccount.ts (KeyList includes DFNS key) +const DFNS_MULTISIG_ACCOUNT_ID = process.env.DFNS_MULTISIG_ACCOUNT_ID!; + +// === Validate required env vars before doing anything === +const REQUIRED_ENV: Record = { + MY_ACCOUNT_ID: DEPLOYER_ACCOUNT_ID, + MY_PRIVATE_KEY_ECDSA: DEPLOYER_PRIVATE_KEY, + FACTORY_ADDRESS, + RESOLVER_ADDRESS, + DFNS_SERVICE_ACCOUNT_AUTHORIZATION_TOKEN: DFNS_AUTH_TOKEN, + DFNS_SERVICE_ACCOUNT_CREDENTIAL_ID: DFNS_CREDENTIAL_ID, + 'DFNS_SERVICE_ACCOUNT_PRIVATE_KEY(_OR_PATH)': DFNS_PRIVATE_KEY, + DFNS_APP_ORIGIN, + DFNS_APP_ID, + 'DFNS_BASE_URL or DFNS_TEST_URL': DFNS_BASE_URL, + DFNS_WALLET_ID, + DFNS_WALLET_PUBLIC_KEY, + DFNS_HEDERA_ACCOUNT_ID, + DFNS_MULTISIG_ACCOUNT_ID, +}; +const missing = Object.entries(REQUIRED_ENV) + .filter(([, v]) => !v) + .map(([k]) => k); +if (missing.length) { + console.error(`Missing required env vars:\n ${missing.join('\n ')}`); + process.exit(1); +} + +const consensusNodes = [{ url: CONSENSUS_NODE_URL, nodeId: CONSENSUS_NODE_ID }]; + +const mirrorNodeConfig = { + name: 'Testnet Mirror Node', + network: 'testnet', + baseUrl: 'https://testnet.mirrornode.hedera.com/api/v1/', + apiKey: '', + headerName: '', + selected: true, +}; + +const RPCNodeConfig = { + name: 'HashIO', + network: 'testnet', + baseUrl: 'https://testnet.hashio.io/api', + apiKey: '', + headerName: '', + selected: true, +}; + +const dfnsCustodialSettings: DFNSConfigRequest = { + authorizationToken: DFNS_AUTH_TOKEN, + credentialId: DFNS_CREDENTIAL_ID, + serviceAccountPrivateKey: DFNS_PRIVATE_KEY, + urlApplicationOrigin: DFNS_APP_ORIGIN, + applicationId: DFNS_APP_ID, + baseUrl: DFNS_BASE_URL, + walletId: DFNS_WALLET_ID, + hederaAccountId: DFNS_HEDERA_ACCOUNT_ID, + publicKey: DFNS_WALLET_PUBLIC_KEY, +}; + +const wait = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +const retry = async ( + fn: () => Promise, + label: string, + intervalMs = 5000, + maxAttempts = 12, +): Promise => { + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + return await fn(); + } catch (err: any) { + if (attempt === maxAttempts) throw err; + console.log(` [${label}] attempt ${attempt} failed, retrying in ${intervalMs / 1000}s...`); + await wait(intervalMs); + } + } + throw new Error(`${label} exhausted all retries`); +}; + +const connectDeployer = () => + Network.connect( + new ConnectRequest({ + account: { + accountId: DEPLOYER_ACCOUNT_ID, + privateKey: { key: DEPLOYER_PRIVATE_KEY, type: 'ECDSA' }, + }, + network: 'custom', + mirrorNode: mirrorNodeConfig, + rpcNode: RPCNodeConfig, + wallet: SupportedWallets.CLIENT, + consensusNodes, + }), + ); + +// Connect as the DFNS-controlled multisig account to initiate the freeze (creates backend tx) +const connectDfnsMultisig = () => + Network.connect( + new ConnectRequest({ + account: { accountId: DFNS_MULTISIG_ACCOUNT_ID }, + network: 'custom', + mirrorNode: mirrorNodeConfig, + rpcNode: RPCNodeConfig, + wallet: SupportedWallets.MULTISIG, + consensusNodes, + }), + ); + +// Connect as DFNS custodial wallet to sign the pending backend tx +const connectDfns = () => + Network.connect( + new ConnectRequest({ + network: 'custom', + mirrorNode: mirrorNodeConfig, + rpcNode: RPCNodeConfig, + wallet: SupportedWallets.DFNS, + custodialWalletSettings: dfnsCustodialSettings, + consensusNodes, + }), + ); + +const main = async () => { + // ── Init ──────────────────────────────────────────────────────────────── + await Network.init( + new InitializationRequest({ + network: 'custom', + mirrorNode: mirrorNodeConfig, + rpcNode: RPCNodeConfig, + configuration: { factoryAddress: FACTORY_ADDRESS, resolverAddress: RESOLVER_ADDRESS }, + consensusNodes, + backend: { url: BACKEND_URL }, + }), + ); + + // ── Phase 1: Deploy stablecoin ────────────────────────────────────────── + console.log('\n[1/4] Deploying stablecoin...'); + await connectDeployer(); + + const stableCoin = (await StableCoin.create( + new CreateRequest({ + name: 'DFNSMultisigFreezeTest', + symbol: 'DMFT', + decimals: 6, + initialSupply: '1000', + freezeKey: { key: 'null', type: 'null' }, + kycKey: { key: 'null', type: 'null' }, + wipeKey: { key: 'null', type: 'null' }, + pauseKey: { key: 'null', type: 'null' }, + feeScheduleKey: { key: 'null', type: 'null' }, + supplyType: TokenSupplyType.INFINITE, + createReserve: false, + grantKYCToOriginalSender: true, + burnRoleAccount: DEPLOYER_ACCOUNT_ID, + wipeRoleAccount: DEPLOYER_ACCOUNT_ID, + rescueRoleAccount: DEPLOYER_ACCOUNT_ID, + pauseRoleAccount: DEPLOYER_ACCOUNT_ID, + freezeRoleAccount: DEPLOYER_ACCOUNT_ID, + deleteRoleAccount: DEPLOYER_ACCOUNT_ID, + kycRoleAccount: DEPLOYER_ACCOUNT_ID, + cashInRoleAccount: DEPLOYER_ACCOUNT_ID, + feeRoleAccount: DEPLOYER_ACCOUNT_ID, + cashInRoleAllowance: '0', + proxyOwnerAccount: DEPLOYER_ACCOUNT_ID, + configId: '0x0000000000000000000000000000000000000000000000000000000000000002', + configVersion: 1, + }), + )) as { coin: any; reserve: any }; + + const tokenId: string = stableCoin.coin.tokenId.toString(); + console.log(` Stablecoin deployed: ${tokenId}`); + await wait(5000); + + // ── Phase 2: Associate multisig account to token (deployer pays, deployer signs) ── + // The multisig account is 1-of-2, so the deployer key alone satisfies the threshold. + console.log('\n[2/4] Associating DFNS multisig account to token...'); + const hederaClient = Client.forNetwork( + Object.fromEntries(consensusNodes.map((n) => [n.url, n.nodeId])), + ).setOperator(DEPLOYER_ACCOUNT_ID, PrivateKey.fromStringECDSA(DEPLOYER_PRIVATE_KEY)); + + const associateTx = await new TokenAssociateTransaction() + .setAccountId(AccountId.fromString(DFNS_MULTISIG_ACCOUNT_ID)) + .setTokenIds([TokenId.fromString(tokenId)]) + .freezeWith(hederaClient) + .sign(PrivateKey.fromStringECDSA(DEPLOYER_PRIVATE_KEY)); + + const associateResponse = await associateTx.execute(hederaClient); + const receipt = await associateResponse.getReceipt(hederaClient); + if (receipt.status !== Status.Success) { + throw new Error(`Association failed: ${receipt.status.toString()}`); + } + console.log(' Association done.'); + await wait(5000); + + // ── Phase 3: Grant freeze role to the DFNS multisig account (the freeze caller) ── + console.log('\n[3/4] Granting freeze role to DFNS multisig account...'); + await Role.grantRole( + new GrantRoleRequest({ + targetId: DFNS_MULTISIG_ACCOUNT_ID, + tokenId, + role: StableCoinRole.FREEZE_ROLE, + }), + ); + console.log(' Freeze role granted.'); + await wait(5000); + + // ── Phase 4: Freeze via multisig → DFNS signs ──────────────────────────── + // Step 4a: connect as the DFNS multisig account to create the pending backend tx + console.log('\n[4/4] Submitting freeze via DFNS multisig...'); + await connectDfnsMultisig(); + + const startDate = new Date(Date.now() + 1 * 60 * 1000).toISOString(); + const freezeResult = await retry<{ transactionId?: string }>( + () => + StableCoin.freeze( + new FreezeAccountRequest({ tokenId, targetId: DFNS_MULTISIG_ACCOUNT_ID, startDate }), + ) as Promise<{ transactionId?: string }>, + 'freeze', + ); + const freezeTxId = freezeResult.transactionId!; + console.log(` Backend tx: ${freezeTxId}`); + + // Step 4b: connect as DFNS custodial wallet to sign the pending tx + await connectDfns(); + await StableCoin.signTransaction(new SignTransactionRequest({ transactionId: freezeTxId })); + console.log(' Signature stored by DFNS. Waiting for autoSubmit...'); + + process.exit(0); +}; + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/packages/sdk/example/ts/package.json b/packages/sdk/example/ts/package.json index d45434adb..be97bf108 100644 --- a/packages/sdk/example/ts/package.json +++ b/packages/sdk/example/ts/package.json @@ -15,6 +15,9 @@ "roles": "npm run build && node build/role.js", "test-external-evm": "npm run build && node build/testExternalEVM.js", "test-external-hedera": "npm run build && node build/testExternalHedera.js", + "multisig-freeze": "npm run build && node build/multisigFreeze.js", + "multisig-freeze-dfns": "npm run build && node build/multisigFreezeDFNS.js", + "create-dfns-multisig-account": "npm run build && node build/createDFNSMultisigAccount.js", "test": "echo \"Error: no test specified\" && exit 1" }, "keywords": [], diff --git a/packages/sdk/jest.config.js b/packages/sdk/jest.config.js index 2cdc6cf59..0bc40d82d 100644 --- a/packages/sdk/jest.config.js +++ b/packages/sdk/jest.config.js @@ -10,6 +10,7 @@ module.exports = { '^(\\.{1,2}/.*)\\.(m)?js$': '$1', '@hashgraph/hedera-wallet-connect': '/__mocks__/hedera-wallet-connect.js', + 'fireblocks-sdk': '/__mocks__/fireblocks-sdk.js', '^uuid$': 'uuid', }, testMatch: ['**/__tests__/**/*.(test|spec).[jt]s?(x)'], diff --git a/packages/sdk/src/app/usecase/command/stablecoin/backend/sign/SignCommandHandler.ts b/packages/sdk/src/app/usecase/command/stablecoin/backend/sign/SignCommandHandler.ts index 5087bddca..b181a4d1b 100644 --- a/packages/sdk/src/app/usecase/command/stablecoin/backend/sign/SignCommandHandler.ts +++ b/packages/sdk/src/app/usecase/command/stablecoin/backend/sign/SignCommandHandler.ts @@ -63,9 +63,23 @@ export class SignCommandHandler implements ICommandHandler { ); // extracts bytes to sign + const signClient = + transaction.network === 'custom' && + transaction.consensus_nodes?.length + ? Client.forNetwork( + Object.fromEntries( + transaction.consensus_nodes.map( + (n: { url: string; nodeId: string }) => [ + n.url, + n.nodeId, + ], + ), + ), + ) + : Client.forName(transaction.network); const deserializedTransaction = Transaction.fromBytes( Hex.toUint8Array(transaction.transaction_message), - ).freezeWith(Client.forName(transaction.network)); + ).freezeWith(signClient); if ( !deserializedTransaction || !deserializedTransaction._signedTransactions diff --git a/packages/sdk/src/domain/context/network/Environment.ts b/packages/sdk/src/domain/context/network/Environment.ts index 035b0a335..4d31688ee 100644 --- a/packages/sdk/src/domain/context/network/Environment.ts +++ b/packages/sdk/src/domain/context/network/Environment.ts @@ -22,6 +22,7 @@ export const testnet = 'testnet'; export const previewnet = 'previewnet'; export const mainnet = 'mainnet'; export const local = 'local'; +export const custom = 'custom'; export const unrecognized = 'unrecognized'; export type Environment = @@ -29,6 +30,7 @@ export type Environment = | 'previewnet' | 'mainnet' | 'local' + | 'custom' | 'unrecognized' | string; diff --git a/packages/sdk/src/domain/context/transaction/MultiSigTransaction.ts b/packages/sdk/src/domain/context/transaction/MultiSigTransaction.ts index 42cffa365..108a490a4 100644 --- a/packages/sdk/src/domain/context/transaction/MultiSigTransaction.ts +++ b/packages/sdk/src/domain/context/transaction/MultiSigTransaction.ts @@ -40,6 +40,7 @@ export class MultiSigTransaction { network: string; hedera_account_id: string; start_date: string; + consensus_nodes?: { url: string; nodeId: string }[]; constructor( id: string, @@ -53,6 +54,7 @@ export class MultiSigTransaction { network: string, hedera_account_id: string, start_date: string, + consensus_nodes?: { url: string; nodeId: string }[], ) { this.id = id; this.transaction_message = transaction_message; @@ -65,6 +67,7 @@ export class MultiSigTransaction { this.network = network; this.hedera_account_id = hedera_account_id; this.start_date = start_date; + this.consensus_nodes = consensus_nodes; } } diff --git a/packages/sdk/src/port/out/backend/BackendAdapter.ts b/packages/sdk/src/port/out/backend/BackendAdapter.ts index 4de1a6bbf..e2caabde8 100644 --- a/packages/sdk/src/port/out/backend/BackendAdapter.ts +++ b/packages/sdk/src/port/out/backend/BackendAdapter.ts @@ -53,6 +53,7 @@ export class BackendAdapter { threshold: number, network: Environment, startDate: Date, + consensusNodes?: { url: string; nodeId: string }[], ): Promise { try { const body = { @@ -63,6 +64,7 @@ export class BackendAdapter { threshold: threshold, network: network, start_date: startDate, + consensus_nodes: consensusNodes ?? null, }; //TODO: error because url is not defined diff --git a/packages/sdk/src/port/out/hs/client/ClientTransactionAdapter.ts b/packages/sdk/src/port/out/hs/client/ClientTransactionAdapter.ts index a718c2b93..0a6539c28 100644 --- a/packages/sdk/src/port/out/hs/client/ClientTransactionAdapter.ts +++ b/packages/sdk/src/port/out/hs/client/ClientTransactionAdapter.ts @@ -84,7 +84,21 @@ export class ClientTransactionAdapter extends BaseHederaTransactionAdapter { this.account = account; this.account.publicKey = accountMirror.publicKey; this.network = this.networkService.environment; - this._client = Client.forName(this.networkService.environment); + if ( + this.networkService.environment === 'custom' && + this.networkService.consensusNodes?.length + ) { + this._client = Client.forNetwork( + Object.fromEntries( + this.networkService.consensusNodes.map((n) => [ + n.url, + n.nodeId, + ]), + ), + ); + } else { + this._client = Client.forName(this.networkService.environment); + } const id = this.account.id?.value ?? ''; if (!account.privateKey) throw new WalletConnectError( @@ -170,10 +184,16 @@ export class ClientTransactionAdapter extends BaseHederaTransactionAdapter { try { const privateKey = this.account.privateKey.toHashgraphKey(); - const signedTx = await message.sign(privateKey); // firma y retorna Transaction - - const bytes = signedTx.toBytes(); // Uint8Array - return Hex.fromUint8Array(bytes); + // Sign only the body bytes and return the raw signature — matching HWC behavior. + // SubmitCommandHandler uses addSignature(publicKey, rawSig) so it expects raw bytes. + const bodyBytes = + message._signedTransactions.get(0)?.bodyBytes; + if (!bodyBytes) + throw new SigningError( + 'No body bytes found in frozen transaction', + ); + const rawSignature = privateKey.sign(bodyBytes); + return Hex.fromUint8Array(rawSignature); } catch (error) { LogService.logError(error); throw new SigningError(error); diff --git a/packages/sdk/src/port/out/hs/custodial/CustodialTransactionAdapter.ts b/packages/sdk/src/port/out/hs/custodial/CustodialTransactionAdapter.ts index e7f2d83d1..872bf7568 100644 --- a/packages/sdk/src/port/out/hs/custodial/CustodialTransactionAdapter.ts +++ b/packages/sdk/src/port/out/hs/custodial/CustodialTransactionAdapter.ts @@ -79,6 +79,21 @@ export abstract class CustodialTransactionAdapter extends BaseHederaTransactionA case 'previewnet': this.client = Client.forPreviewnet(); break; + case 'custom': + if (!this.networkService.consensusNodes?.length) { + throw new Error( + 'Custom network requires at least one consensus node', + ); + } + this.client = Client.forNetwork( + Object.fromEntries( + this.networkService.consensusNodes.map((n) => [ + n.url, + n.nodeId, + ]), + ), + ); + break; default: throw new Error('Network not supported'); } diff --git a/packages/sdk/src/port/out/hs/multiSig/MultiSigTransactionAdapter.ts b/packages/sdk/src/port/out/hs/multiSig/MultiSigTransactionAdapter.ts index 09392609b..d5b5dd33e 100644 --- a/packages/sdk/src/port/out/hs/multiSig/MultiSigTransactionAdapter.ts +++ b/packages/sdk/src/port/out/hs/multiSig/MultiSigTransactionAdapter.ts @@ -34,11 +34,7 @@ import NetworkService from '../../../../app/service/NetworkService.js'; import { MirrorNodeAdapter } from '../../mirror/MirrorNodeAdapter.js'; import { BackendAdapter } from '../../backend/BackendAdapter.js'; import { SupportedWallets } from '../../../../domain/context/network/Wallet.js'; -import { - Environment, - previewnet, - mainnet, -} from '../../../../domain/context/network/Environment.js'; +import { Environment } from '../../../../domain/context/network/Environment.js'; import Injectable from '../../../../core/Injectable.js'; import { InitializationData } from '../../TransactionAdapter.js'; import LogService from '../../../../app/service/LogService.js'; @@ -93,14 +89,6 @@ export class MultiSigTransactionAdapter extends BaseHederaTransactionAdapter { t.setTransactionValidDuration(180); t._freezeWithAccountId(accountId); - let client: Client = Client.forTestnet(); - - if (this.networkService.environment == previewnet) { - client = Client.forPreviewnet(); - } else if (this.networkService.environment == mainnet) { - client = Client.forMainnet(); - } - if ( !this.networkService.consensusNodes || this.networkService.consensusNodes.length == 0 @@ -110,10 +98,11 @@ export class MultiSigTransactionAdapter extends BaseHederaTransactionAdapter { ); } - client.setNetwork({ - [this.networkService.consensusNodes[0].url]: - this.networkService.consensusNodes[0].nodeId, - }); + const client = Client.forNetwork( + Object.fromEntries( + this.networkService.consensusNodes.map((n) => [n.url, n.nodeId]), + ), + ); if (!this.account.multiKey) { throw new Error('MultiKey not found in the account'); @@ -134,6 +123,7 @@ export class MultiSigTransactionAdapter extends BaseHederaTransactionAdapter { this.account.multiKey.threshold, this.networkService.environment, new Date(dateStr), + this.networkService.consensusNodes, ); return new TransactionResponse(transactionId);