diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 6735ba9b7..52d767b73 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -15,6 +15,10 @@ "./typechain-types/index.js": { "import": "./build/typechain-types/index.js", "require": "./build/typechain-types/index.js" + }, + "./typechain-types/*": { + "import": "./build/typechain-types/*", + "require": "./build/typechain-types/*" } }, "scripts": { diff --git a/packages/sdk/__tests__/app/service/TransactionService.test.ts b/packages/sdk/__tests__/app/service/TransactionService.test.ts new file mode 100644 index 000000000..b9e3585b4 --- /dev/null +++ b/packages/sdk/__tests__/app/service/TransactionService.test.ts @@ -0,0 +1,285 @@ +/* + * + * 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 'reflect-metadata'; + +// Mock tsyringe and Injectable before any other imports to prevent DI +// circular dependency issues when loading TransactionService. +jest.mock('tsyringe', () => ({ + singleton: () => (cls: unknown): unknown => cls, + registry: () => (cls: unknown): unknown => cls, + injectable: () => (cls: unknown): unknown => cls, + inject: () => (): undefined => undefined, + delay: (fn: () => unknown): unknown => fn(), + container: { + register: jest.fn(), + resolve: jest.fn(), + resolveAll: jest.fn(), + }, +})); + +jest.mock('../../../src/core/Injectable', () => ({ + __esModule: true, + default: { + resolve: (): Record => ({}), + lazyResolve: (): Record => ({}), + TOKENS: { COMMAND_HANDLER: Symbol(), QUERY_HANDLER: Symbol(), TRANSACTION_HANDLER: 'TransactionHandler' }, + register: jest.fn(), + registerCommandHandler: jest.fn(), + registerTransactionHandler: jest.fn(), + resolveTransactionHandler: jest.fn(), + registerTransactionAdapterInstances: (): unknown[] => [], + getQueryHandlers: (): unknown[] => [], + getCommandHandlers: (): unknown[] => [], + isWeb: (): boolean => false, + }, +})); + +import { ethers } from 'ethers'; +import { + CashInFacet__factory, + BurnableFacet__factory, + WipeableFacet__factory, + FreezableFacet__factory, + KYCFacet__factory, + PausableFacet__factory, + RescuableFacet__factory, + HoldManagementFacet__factory, + SupplierAdminFacet__factory, + RoleManagementFacet__factory, + CustomFeesFacet__factory, + HederaTokenManagerFacet__factory, +} from '@hashgraph/stablecoin-npm-contracts'; +import TransactionService from '../../../src/app/service/TransactionService'; +import Hex from '../../../src/core/Hex'; + +// Helper: encodes a function call and returns it as a Uint8Array +function encode( + iface: ethers.Interface, + fn: string, + params: unknown[], +): Uint8Array { + const hex = iface.encodeFunctionData(fn, params); + return Hex.toUint8Array(hex.slice(2)); // strip '0x' prefix +} + +const DUMMY_ADDRESS = '0x0000000000000000000000000000000000000001'; + +describe('TransactionService.decodeFunctionCall', () => { + describe('HederaTokenManagerFacet — regression', () => { + it('decodes an existing function from HederaTokenManagerFacet', () => { + const iface = new ethers.Interface( + HederaTokenManagerFacet__factory.abi, + ); + const fragment = iface.fragments.find( + (f) => f.type === 'function', + ); + if (!fragment || fragment.type !== 'function') return; + const fn = fragment as ethers.FunctionFragment; + const params = fn.inputs.map((input) => + input.type === 'bytes32' + ? ethers.ZeroHash + : input.type === 'uint256' + ? BigInt(0) + : DUMMY_ADDRESS, + ); + const bytes = encode(iface, fn.name, params); + const result = TransactionService.decodeFunctionCall(bytes); + expect(result).not.toBeNull(); + expect(result?.name).toBe(fn.name); + }); + }); + + describe('CashInFacet', () => { + it('decodes mint(address, uint256)', () => { + const iface = new ethers.Interface(CashInFacet__factory.abi); + const bytes = encode(iface, 'mint', [DUMMY_ADDRESS, BigInt(1000)]); + const result = TransactionService.decodeFunctionCall(bytes); + expect(result).not.toBeNull(); + expect(result?.name).toBe('mint'); + }); + }); + + describe('BurnableFacet', () => { + it('decodes burn(uint256)', () => { + const iface = new ethers.Interface(BurnableFacet__factory.abi); + const bytes = encode(iface, 'burn', [BigInt(500)]); + const result = TransactionService.decodeFunctionCall(bytes); + expect(result).not.toBeNull(); + expect(result?.name).toBe('burn'); + }); + }); + + describe('WipeableFacet', () => { + it('decodes wipe(address, uint256)', () => { + const iface = new ethers.Interface(WipeableFacet__factory.abi); + const bytes = encode(iface, 'wipe', [DUMMY_ADDRESS, BigInt(200)]); + const result = TransactionService.decodeFunctionCall(bytes); + expect(result).not.toBeNull(); + expect(result?.name).toBe('wipe'); + }); + }); + + describe('FreezableFacet', () => { + it('decodes freeze(address)', () => { + const iface = new ethers.Interface(FreezableFacet__factory.abi); + const bytes = encode(iface, 'freeze', [DUMMY_ADDRESS]); + const result = TransactionService.decodeFunctionCall(bytes); + expect(result).not.toBeNull(); + expect(result?.name).toBe('freeze'); + }); + + it('decodes unfreeze(address)', () => { + const iface = new ethers.Interface(FreezableFacet__factory.abi); + const bytes = encode(iface, 'unfreeze', [DUMMY_ADDRESS]); + const result = TransactionService.decodeFunctionCall(bytes); + expect(result).not.toBeNull(); + expect(result?.name).toBe('unfreeze'); + }); + }); + + describe('KYCFacet', () => { + it('decodes grantKyc(address)', () => { + const iface = new ethers.Interface(KYCFacet__factory.abi); + const bytes = encode(iface, 'grantKyc', [DUMMY_ADDRESS]); + const result = TransactionService.decodeFunctionCall(bytes); + expect(result).not.toBeNull(); + expect(result?.name).toBe('grantKyc'); + }); + + it('decodes revokeKyc(address)', () => { + const iface = new ethers.Interface(KYCFacet__factory.abi); + const bytes = encode(iface, 'revokeKyc', [DUMMY_ADDRESS]); + const result = TransactionService.decodeFunctionCall(bytes); + expect(result).not.toBeNull(); + expect(result?.name).toBe('revokeKyc'); + }); + }); + + describe('PausableFacet', () => { + it('decodes pause()', () => { + const iface = new ethers.Interface(PausableFacet__factory.abi); + const bytes = encode(iface, 'pause', []); + const result = TransactionService.decodeFunctionCall(bytes); + expect(result).not.toBeNull(); + expect(result?.name).toBe('pause'); + }); + + it('decodes unpause()', () => { + const iface = new ethers.Interface(PausableFacet__factory.abi); + const bytes = encode(iface, 'unpause', []); + const result = TransactionService.decodeFunctionCall(bytes); + expect(result).not.toBeNull(); + expect(result?.name).toBe('unpause'); + }); + }); + + describe('RescuableFacet', () => { + it('decodes rescue(int64)', () => { + const iface = new ethers.Interface(RescuableFacet__factory.abi); + const bytes = encode(iface, 'rescue', [BigInt(100)]); + const result = TransactionService.decodeFunctionCall(bytes); + expect(result).not.toBeNull(); + expect(result?.name).toBe('rescue'); + }); + }); + + describe('HoldManagementFacet', () => { + it('decodes createHold(tuple)', () => { + const iface = new ethers.Interface(HoldManagementFacet__factory.abi); + const hold = { + amount: BigInt(100), + expirationTimestamp: BigInt(9999999999), + escrow: DUMMY_ADDRESS, + to: DUMMY_ADDRESS, + data: '0x', + }; + const bytes = encode(iface, 'createHold', [hold]); + const result = TransactionService.decodeFunctionCall(bytes); + expect(result).not.toBeNull(); + expect(result?.name).toBe('createHold'); + }); + + it('decodes executeHold(_holdIdentifier, _to, _amount)', () => { + const iface = new ethers.Interface(HoldManagementFacet__factory.abi); + const bytes = encode(iface, 'executeHold', [ + { tokenHolder: DUMMY_ADDRESS, holdId: BigInt(1) }, + DUMMY_ADDRESS, + BigInt(100), + ]); + const result = TransactionService.decodeFunctionCall(bytes); + expect(result).not.toBeNull(); + expect(result?.name).toBe('executeHold'); + }); + }); + + describe('SupplierAdminFacet', () => { + it('decodes the first public function in the ABI', () => { + const iface = new ethers.Interface(SupplierAdminFacet__factory.abi); + const fn = iface.fragments.find( + (f) => f.type === 'function', + ) as ethers.FunctionFragment; + const params = fn.inputs.map((input) => + input.type === 'uint256' ? BigInt(100) : DUMMY_ADDRESS, + ); + const bytes = encode(iface, fn.name, params); + const result = TransactionService.decodeFunctionCall(bytes); + expect(result).not.toBeNull(); + expect(result?.name).toBe(fn.name); + }); + }); + + describe('RoleManagementFacet', () => { + it('decodes grantRoles(bytes32[], address[], uint256[])', () => { + const iface = new ethers.Interface(RoleManagementFacet__factory.abi); + const bytes = encode(iface, 'grantRoles', [ + [ethers.ZeroHash], + [DUMMY_ADDRESS], + [BigInt(0)], + ]); + const result = TransactionService.decodeFunctionCall(bytes); + expect(result).not.toBeNull(); + expect(result?.name).toBe('grantRoles'); + }); + }); + + describe('CustomFeesFacet', () => { + it('decodes updateTokenCustomFees(tuple[], tuple[])', () => { + const iface = new ethers.Interface(CustomFeesFacet__factory.abi); + const bytes = encode(iface, 'updateTokenCustomFees', [[], []]); + const result = TransactionService.decodeFunctionCall(bytes); + expect(result).not.toBeNull(); + expect(result?.name).toBe('updateTokenCustomFees'); + }); + }); + + describe('Edge cases', () => { + it('returns null when the selector does not exist in any ABI', () => { + const unknown = new Uint8Array([0xde, 0xad, 0xbe, 0xef, 0, 0, 0, 0]); + const result = TransactionService.decodeFunctionCall(unknown); + expect(result).toBeNull(); + }); + + it('returns null for an empty Uint8Array', () => { + const result = TransactionService.decodeFunctionCall(new Uint8Array(0)); + expect(result).toBeNull(); + }); + }); +}); diff --git a/packages/sdk/jest.unit.config.js b/packages/sdk/jest.unit.config.js new file mode 100644 index 000000000..a20f62cc6 --- /dev/null +++ b/packages/sdk/jest.unit.config.js @@ -0,0 +1,19 @@ +module.exports = { + testEnvironment: 'node', + preset: 'ts-jest', + moduleNameMapper: { + '^(\\.{1,2}/.*)\\.(m)?js$': '$1', + '@hashgraph/hedera-wallet-connect': + '/__mocks__/hedera-wallet-connect.js', + '^uuid$': 'uuid', + }, + testMatch: ['**/__tests__/app/**/*.(test|spec).[jt]s?(x)'], + testPathIgnorePatterns: ['/build/', '/src_old/', '/example/js/'], + modulePathIgnorePatterns: ['/example/js/'], + transform: { + '^.+\\.ts?$': 'ts-jest', + '^.+\\.[t|j]sx?$': 'babel-jest', + }, + transformIgnorePatterns: ['node_modules/(?!@ngrx|(?!deck.gl)|ng-dynamic)'], + testTimeout: 10_000, +}; diff --git a/packages/sdk/package.json b/packages/sdk/package.json index c17ee6867..d0931b99a 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -47,6 +47,7 @@ }, "scripts": { "execute:createMultisig": "node build/cjs/scripts/CreateMultisigAccount.js", + "execute:associateToken": "node build/cjs/scripts/AssociateToken.js", "start": "node build/src/index.js", "clean": "rimraf coverage build", "clean:modules": "rimraf node_modules", @@ -58,6 +59,7 @@ "build:release": "npm run clean && tsc -p tsconfig.release.json", "lint": "eslint . --ext .ts --ext .mts", "test": "NODE_OPTIONS=--max-old-space-size=8192 npx jest --forceExit --maxWorkers=50%", + "test:unit": "NODE_OPTIONS=--max-old-space-size=8192 npx jest --config jest.unit.config.js --forceExit --maxWorkers=50%", "test:watch": "concurrently --kill-others \"npm run build:watch\" \"npm run test:jest:watch\"", "test:jest:watch": "NODE_OPTIONS=--experimental-vm-modules npx jest --watch", "test:ci": "NODE_OPTIONS=--max-old-space-size=8192 npx jest --ci --forceExit --runInBand", diff --git a/packages/sdk/scripts/AssociateToken.ts b/packages/sdk/scripts/AssociateToken.ts new file mode 100644 index 000000000..8adc3c171 --- /dev/null +++ b/packages/sdk/scripts/AssociateToken.ts @@ -0,0 +1,87 @@ +/* + * + * 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. + * + */ + +/** + * DESCRIPTION + * Associates token 0.0.7981724 with the multisig account 0.0.8201011. + * The multisig account has a 2-of-2 KeyList (ED25519 + ECDSA), so both + * keys must sign the TokenAssociateTransaction. + * The fee payer is the ECDSA account 0.0.1653 (which has funds). + * + * HOW TO RUN IT + * 1- npm run build (inside packages/sdk) + * 2- node build/cjs/src/scripts/AssociateToken.js + * or: npx ts-node scripts/AssociateToken.ts + */ + +import { + TokenAssociateTransaction, + TokenId, + AccountId, + Client, + PrivateKey, +} from '@hiero-ledger/sdk'; + +// Multisig account keys +const ED25519_PRIVATE_KEY = ''; +const ECDSA_PRIVATE_KEY = ''; + +// The multisig account to associate the token to +const MULTISIG_ACCOUNT_ID = ''; + +// Token to associate +const TOKEN_ID = ''; + +// Fee payer — single ECDSA account with funds +const FEE_PAYER = { + id: '', + privateKey: ECDSA_PRIVATE_KEY, +}; + +async function associateToken(): Promise { + const ed25519Key = PrivateKey.fromStringED25519(ED25519_PRIVATE_KEY); + const ecdsaKey = PrivateKey.fromStringECDSA(ECDSA_PRIVATE_KEY); + + const client = Client.forTestnet().setOperator( + AccountId.fromString(FEE_PAYER.id), + PrivateKey.fromStringECDSA(FEE_PAYER.privateKey), + ); + + const tx = await new TokenAssociateTransaction() + .setAccountId(AccountId.fromString(MULTISIG_ACCOUNT_ID)) + .setTokenIds([TokenId.fromString(TOKEN_ID)]) + .freezeWith(client); + + // Both keys of the KeyList must sign + const signedTx = await (await tx.sign(ed25519Key)).sign(ecdsaKey); + + const response = await signedTx.execute(client); + const receipt = await response.getReceipt(client); + + console.log(`Token ${TOKEN_ID} associated with ${MULTISIG_ACCOUNT_ID}`); + console.log(`Status: ${receipt.status.toString()}`); +} + +associateToken() + .then(() => process.exit(0)) + .catch((err) => { + console.error(err); + process.exit(1); + }); diff --git a/packages/sdk/scripts/CreateMultisigAccount.ts b/packages/sdk/scripts/CreateMultisigAccount.ts index 819cb05fd..97fa89784 100644 --- a/packages/sdk/scripts/CreateMultisigAccount.ts +++ b/packages/sdk/scripts/CreateMultisigAccount.ts @@ -20,81 +20,68 @@ /** * DESCRIPTION - * This script will deploy a multi-key account with a key list made of 2 keys: - * - one ED25519 key - * - one EDCSA key + * Creates a multisig account with a 2-of-2 KeyList (ED25519 + ECDSA). + * The resulting account requires both keys to sign every transaction. + * The fee payer is the ECDSA account 0.0.1653 (which has funds). * * HOW TO RUN IT - * 1- Fill in the following constants : Multisig_ED25519_privateKey, Multisig_ECDSA_privateKey, deployingAccount - * 2- Compile the script : npm run build (this command will actually build all the typescript files in the module) - * 3- Run the compiled script : npm run execute:createMultisig - * 4- the multisig account id will be displayed in the console. + * 1- npm run build (inside packages/sdk) + * 2- npm run execute:createMultisig */ import { AccountCreateTransaction, - KeyList, + AccountId, Client, + Hbar, + KeyList, PrivateKey, - AccountId, } from '@hiero-ledger/sdk'; -// Hex encoded private key of the ED25519 key that will be added to the multisig account's key -const Multisig_ED25519_privateKey = ''; +// Clave privada ED25519 de la cuenta 0.0.1579 +const Multisig_ED25519_privateKey = 'e8e993b064ba3d8209a40526513574f043d5af0f900764f35fc92c9a9198deb2'; -// Hex encoded private key of the ECDSA key that will be added to the multisig account's key -const Multisig_ECDSA_privateKey = ''; +// Clave privada ECDSA de la cuenta 0.0.1653 +const Multisig_ECDSA_privateKey = '3bb707249245cfb5090cfa49d598ad43eee5a266a91585f5cddb063ed525e491'; -// Account Id and Hex encoded ECDSA private key of the single key account that will be used to deploy the multisig account. -// MAKE SURE this account has funds as it will pay for the account creation fees !!!!!!!!!!! +// Cuenta pagadora (fee payer): debe tener fondos en testnet const deployingAccount = { - id: '', - ECDSA_privateKey: '', + id: '0.0.1653', + ECDSA_privateKey: '3bb707249245cfb5090cfa49d598ad43eee5a266a91585f5cddb063ed525e491', }; -const delay = async (seconds = 5): Promise => { - seconds = seconds * 1000; - await new Promise((r) => setTimeout(r, seconds)); -}; +async function createMultisigAccount(): Promise { + const ed25519Key = PrivateKey.fromStringED25519(Multisig_ED25519_privateKey); + const ecdsaKey = PrivateKey.fromStringECDSA(Multisig_ECDSA_privateKey); + const feePayerKey = PrivateKey.fromStringECDSA(deployingAccount.ECDSA_privateKey); -async function createMultisigAccount(): Promise { - const signerKeys = [ - PrivateKey.fromStringED25519(Multisig_ED25519_privateKey), - PrivateKey.fromStringECDSA(Multisig_ECDSA_privateKey), - ]; - - const keyList = KeyList.of( - signerKeys[0].publicKey, - signerKeys[1].publicKey, - ); - - const newAccountTx = new AccountCreateTransaction().setKey(keyList); + // 2-of-2 KeyList: both ED25519 and ECDSA must sign + const keyList = new KeyList([ed25519Key.publicKey, ecdsaKey.publicKey], 2); const client = Client.forTestnet().setOperator( AccountId.fromString(deployingAccount.id), - PrivateKey.fromStringECDSA(deployingAccount.ECDSA_privateKey), + feePayerKey, ); - const newAccountResponse = await newAccountTx.execute(client); + const tx = await new AccountCreateTransaction() + .setKeyWithoutAlias(keyList) + .setInitialBalance(new Hbar(0)) + .execute(client); - await delay(); + const receipt = await tx.getReceipt(client); + const newAccountId = receipt.accountId; - const newAccountReceipt = await newAccountResponse.getReceipt(client); - const newAccountId = newAccountReceipt.accountId; - if (newAccountId === null) { - throw new Error('newAccountId is null'); + if (!newAccountId) { + throw new Error('Error creating multisig account'); } - const multisigAccountId = newAccountId.toString(); - - return multisigAccountId; + console.log(`Multisig account created: ${newAccountId.toString()}`); + console.log(`KeyList: ED25519 (0.0.1579) + ECDSA (0.0.1653), threshold 2-of-2`); } -// Main createMultisigAccount() - .then((events) => { - console.log(events); - }) - .catch((error) => { - console.error(error); + .then(() => process.exit(0)) + .catch((err) => { + console.error(err); + process.exit(1); }); diff --git a/packages/sdk/src/app/service/TransactionService.ts b/packages/sdk/src/app/service/TransactionService.ts index 8014574c3..d5b6f4b36 100644 --- a/packages/sdk/src/app/service/TransactionService.ts +++ b/packages/sdk/src/app/service/TransactionService.ts @@ -50,7 +50,7 @@ import { TransferTransaction, } from '@hiero-ledger/sdk'; import { MirrorNodeAdapter } from '../../port/out/mirror/MirrorNodeAdapter.js'; -import { HederaTokenManagerFacet__factory } from '@hashgraph/stablecoin-npm-contracts'; +import * as Factories from '@hashgraph/stablecoin-npm-contracts/typechain-types/factories/contracts'; import { ethers } from 'ethers'; import Hex from '../../core/Hex.js'; import { AWSKMSTransactionAdapter } from '../../port/out/hs/custodial/AWSKMSTransactionAdapter'; @@ -319,17 +319,40 @@ export default class TransactionService extends Service { } } - static decodeFunctionCall( - parameters: Uint8Array, - ): ethers.TransactionDescription | null { - const inputData = '0x' + Hex.fromUint8Array(parameters); - try { - const iface_tokenManager = new ethers.Interface( - HederaTokenManagerFacet__factory.abi, + + + + private static readonly COMBINED_INTERFACE: ethers.Interface = (() => { + const seen = new Set(); + const fragments: any[] = []; + + function flattenFactories(obj: Record): any[] { + return Object.values(obj).flatMap(v => + Array.isArray(v?.abi) ? [v] : v && typeof v === 'object' ? flattenFactories(v) : [] ); - return iface_tokenManager.parseTransaction({ data: inputData }); - } catch (e) { + } + const factories = flattenFactories(Factories as Record); + + for (const factory of Object.values(factories)) { + if (!Array.isArray(factory?.abi)) continue; + for (const fragment of factory.abi) { + if (fragment.type !== 'function') continue; + const sig = `${fragment.name}(${(fragment.inputs ?? []).map((i: any) => i.type).join(',')})`; + if (!seen.has(sig)) { + seen.add(sig); + fragments.push(fragment); + } + } + } + return new ethers.Interface(fragments); + })(); + + static decodeFunctionCall(parameters: Uint8Array): ethers.TransactionDescription | null { + const inputData = '0x' + Hex.fromUint8Array(parameters); + try { + return TransactionService.COMBINED_INTERFACE.parseTransaction({ data: inputData }); + } catch (_) { return null; } }