Repository: zama-ai/fhevm-js
Pull request #230: feat: fix API errors, add ACL and signatures check for public decrypt…
Author: login: jatZama
Labels: name: cla-signed
Created: 2025-05-02T16:36:27Z | Merged: 2025-05-05T13:01:43Z

… endpoint
ex usage: 
```js  
it("test HTTPPublicDecrypt mixed", async function () {
    const handleBool = await this.contract.xBool();
    const handle32 = await this.contract.xUint32();
    const handleAddress = await this.contract.xAddress();
    const handleBytes128 = await this.contract.xBytes128();
    const res = await this.instances.alice.publicDecrypt([
      handleBool,
      handleBytes128,
      handle32,
      handleAddress,
    ]);
    console.log(res);
  });
  ```
  
  Which will return a dictionary of (handles, decrypted cleartext) values such as: 
  `{
  '0x826feb7d3125d43475f3710144322d5eeabe22df63ff00000000000030390000': true,
  '0x48c1b3df0b6e96d395c1d4f0dc88f7688261c26eb0ff00000000000030390a00': '0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000d3f1e794f90b63477d50293f0ff0d232ca3f485213a1',
  '0xf94fd2cead277005511f811497a185db1b81598f2aff00000000000030390400': 242n,
  '0x207db7f48ef83342828ff2084e891be48f9db07691ff00000000000030390700': '0xfC4382C084fCA3f4fB07c3BCDA906C01797595a8'
}`.
Question: I guess the userDecrypt should also return such a dictionary instead of a list, since we now support also batch user decryption. Also probably it would make sense to add the 2048 encrypted bits check for user Decrypt, wdyt? @immortal-tofu 


Diff:
diff --git a/package-lock.json b/package-lock.json
index e15f7e22..c24810e7 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
 {
   "name": "@fhevm/sdk",
-  "version": "0.7.0-28",
+  "version": "0.7.0-29",
   "lockfileVersion": 2,
   "requires": true,
   "packages": {
     "": {
       "name": "@fhevm/sdk",
-      "version": "0.7.0-28",
+      "version": "0.7.0-29",
       "license": "BSD-3-Clause-Clear",
       "dependencies": {
         "commander": "^11.0.0",
diff --git a/package.json b/package.json
index e482f13d..57c0c411 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@fhevm/sdk",
-  "version": "0.7.0-28",
+  "version": "0.7.0-29",
   "description": "fhevm SDK",
   "main": "lib/node.js",
   "types": "lib/node/node.d.ts",
diff --git a/src/config.ts b/src/config.ts
index d3d14887..2e6b2422 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -14,7 +14,10 @@ import {
 } from './utils';
 import { CompactPkeCrs, TfheCompactPublicKey } from 'node-tfhe';
 
-const abiKmsVerifier = ['function getKmsSigners() view returns (address[])'];
+const abiKmsVerifier = [
+  'function getKmsSigners() view returns (address[])',
+  'function getThreshold() view returns (uint256)',
+];
 
 export type FhevmInstanceConfig = {
   verifyingContractAddress: string;
@@ -122,3 +125,16 @@ export const getKMSSigners = async (
   const signers: string[] = await kmsContract.getKmsSigners();
   return signers;
 };
+
+export const getKMSSignersThreshold = async (
+  provider: Provider,
+  config: FhevmInstanceConfig,
+): Promise<number> => {
+  const kmsContract = new Contract(
+    config.kmsContractAddress,
+    abiKmsVerifier,
+    provider,
+  );
+  const threshold: bigint = await kmsContract.getThreshold();
+  return Number(threshold); // threshold is always supposed to fit in a number
+};
diff --git a/src/index.test.ts b/src/index.test.ts
index f0af7815..6d83ef6a 100644
--- a/src/index.test.ts
+++ b/src/index.test.ts
@@ -9,6 +9,7 @@ jest.mock('ethers', () => ({
   isAddress: () => true,
   Contract: () => ({
     getKmsSigners: () => ['0x4c102C7cA99d3079fEFF08114d3bad888b9794d9'],
+    getThreshold: () => BigInt(1),
   }),
 }));
 
diff --git a/src/index.ts b/src/index.ts
index 3566b0c0..8872d200 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -3,6 +3,7 @@ import {
   FhevmInstanceConfig,
   getChainId,
   getKMSSigners,
+  getKMSSignersThreshold,
   getProvider,
   getPublicParams,
   getTfheCompactPublicKey,
@@ -18,7 +19,10 @@ import {
   createRelayerEncryptedInput,
   RelayerEncryptedInput,
 } from './relayer/sendEncryption';
-import { publicDecryptRequest } from './relayer/publicDecrypt';
+import {
+  publicDecryptRequest,
+  DecryptedResults,
+} from './relayer/publicDecrypt';
 
 import { PublicParams } from './sdk/encrypt';
 import { generateKeypair, createEIP712, EIP712 } from './sdk/keypair';
@@ -49,7 +53,9 @@ export type FhevmInstance = {
     startTimestamp: string | number,
     durationDays: string | number,
   ) => EIP712;
-  publicDecrypt: (handle: string | Uint8Array) => Promise<bigint>;
+  publicDecrypt: (
+    handles: (string | Uint8Array)[],
+  ) => Promise<DecryptedResults>;
   userDecrypt: (
     handles: HandleContractPair[],
     privateKey: string,
@@ -107,6 +113,8 @@ export const createInstance = async (
 
   const kmsSigners = await getKMSSigners(provider, config);
 
+  const thresholdSigners = await getKMSSignersThreshold(provider, config);
+
   return {
     createEncryptedInput: createRelayerEncryptedInput(
       aclContractAddress,
@@ -123,8 +131,8 @@ export const createInstance = async (
     ),
     publicDecrypt: publicDecryptRequest(
       kmsSigners,
+      thresholdSigners,
       gatewayChainId,
-      chainId,
       verifyingContractAddress,
       aclContractAddress,
       cleanURL(config.relayerUrl),
diff --git a/src/relayer/publicDecrypt.test.ts b/src/relayer/publicDecrypt.test.ts
index 51353a29..942d6f90 100644
--- a/src/relayer/publicDecrypt.test.ts
+++ b/src/relayer/publicDecrypt.test.ts
@@ -11,8 +11,8 @@ describe('publicDecrypt', () => {
   it('get public decryption for handle', async () => {
     const public_decrypt = publicDecryptRequest(
       [],
+      1,
       54321,
-      9000,
       '0x8ba1f109551bd432803012645ac136ddd64dba72',
       '0xa5e1defb98EFe38EBb2D958CEe052410247F4c80',
       'https://test-relayer.net/',
diff --git a/src/relayer/publicDecrypt.ts b/src/relayer/publicDecrypt.ts
index 264326d7..0dc495a9 100644
--- a/src/relayer/publicDecrypt.ts
+++ b/src/relayer/publicDecrypt.ts
@@ -1,28 +1,188 @@
 import { fromHexString, toHexString } from '../utils';
-import { ethers } from 'ethers';
+import { ethers, AbiCoder } from 'ethers';
+
+export type DecryptedResults = Record<string, bigint | boolean | string>;
 
 const aclABI = [
-  'function persistAllowed(bytes32 handle, address account) view returns (bool)',
+  'function isAllowedForDecryption(bytes32 handle) view returns (bool)',
 ];
 
+function isThresholdReached(
+  kmsSigners: string[],
+  recoveredAddresses: string[],
+  threshold: number,
+): boolean {
+  const addressMap = new Map<string, number>();
+  recoveredAddresses.forEach((address, index) => {
+    if (addressMap.has(address)) {
+      const duplicateValue = address;
+      throw new Error(
+        `Duplicate signer address found: ${duplicateValue} appears multiple times in recovered addresses`,
+      );
+    }
+    addressMap.set(address, index);
+  });
+
+  for (const address of recoveredAddresses) {
+    if (!kmsSigners.includes(address)) {
+      throw new Error(
+        `Invalid address found: ${address} is not in the list of KMS signers`,
+      );
+    }
+  }
+
+  return recoveredAddresses.length >= threshold;
+}
+
+const NumEncryptedBits: Record<number, number> = {
+  0: 2, // ebool
+  2: 8, // euint8
+  3: 16, // euint16
+  4: 32, // euint32
+  5: 64, // euint64
+  6: 128, // euint128
+  7: 160, // eaddress
+  8: 256, // euint256
+  9: 512, // ebytes64
+  10: 1024, // ebytes128
+  11: 2048, // ebytes256
+} as const;
+
+function checkEncryptedBits(handles: string[]) {
+  let total = 0;
+
+  for (const handle of handles) {
+    if (handle.length !== 66) {
+      throw new Error(`Handle ${handle} is not of valid length`);
+    }
+
+    const hexPair = handle.slice(-4, -2).toLowerCase();
+    const typeDiscriminant = parseInt(hexPair, 16);
+
+    if (!(typeDiscriminant in NumEncryptedBits)) {
+      throw new Error(`Handle ${handle} is not of valid type`);
+    }
+
+    total +=
+      NumEncryptedBits[typeDiscriminant as keyof typeof NumEncryptedBits];
+
+    // enforce 2048‑bit limit
+    if (total > 2048) {
+      throw new Error(
+        'Cannot decrypt more than 2048 encrypted bits in a single request',
+      );
+    }
+  }
+  return total;
+}
+
+const CiphertextType: Record<number, 'bool' | 'uint256' | 'address' | 'bytes'> =
+  {
+    0: 'bool',
+    2: 'uint256',
+    3: 'uint256',
+    4: 'uint256',
+    5: 'uint256',
+    6: 'uint256',
+    7: 'address',
+    8: 'uint256',
+    9: 'bytes',
+    10: 'bytes',
+    11: 'bytes',
+  };
+
+function deserializeDecryptedResult(
+  handles: string[],
+  decryptedResult: string,
+): DecryptedResults {
+  let typesList: number[] = [];
+  for (const handle of handles) {
+    const hexPair = handle.slice(-4, -2).toLowerCase();
+    const typeDiscriminant = parseInt(hexPair, 16);
+    typesList.push(typeDiscriminant);
+  }
+
+  const restoredEncoded =
+    '0x' +
+    '00'.repeat(32) + // dummy requestID (ignored)
+    decryptedResult.slice(2) +
+    '00'.repeat(32); // dummy empty bytes[] length (ignored)
+
+  const abiTypes = typesList.map((t) => {
+    const abiType = CiphertextType[t]; // all types are valid because this was supposedly checked already inside the `checkEncryptedBits` function
+    return abiType;
+  });
+
+  const coder = new AbiCoder();
+  const decoded = coder.decode(
+    ['uint256', ...abiTypes, 'bytes[]'],
+    restoredEncoded,
+  );
+
+  // strip dummy first/last element
+  const rawValues = decoded.slice(1, 1 + typesList.length);
+
+  let results: DecryptedResults = {};
+  handles.forEach((handle, idx) => (results[handle] = rawValues[idx]));
+
+  return results;
+}
+
 export const publicDecryptRequest =
   (
-    kmsSignatures: string[],
+    kmsSigners: string[],
+    thresholdSigners: number,
     gatewayChainId: number,
-    chainId: number,
     verifyingContractAddress: string,
     aclContractAddress: string,
     relayerUrl: string,
     provider: ethers.JsonRpcProvider | ethers.BrowserProvider,
   ) =>
-  async (_handle: Uint8Array | string) => {
-    const handle =
-      typeof _handle === 'string'
-        ? toHexString(fromHexString(_handle), true)
-        : toHexString(_handle, true);
+  async (_handles: (Uint8Array | string)[]) => {
+    const acl = new ethers.Contract(aclContractAddress, aclABI, provider);
+
+    let handles: string[];
+    try {
+      handles = await Promise.all(
+        _handles.map(async (_handle) => {
+          const handle =
+            typeof _handle === 'string'
+              ? toHexString(fromHexString(_handle), true)
+              : toHexString(_handle, true);
+
+          const isAllowedForDecryption = await acl.isAllowedForDecryption(
+            handle,
+          );
+          if (!isAllowedForDecryption) {
+            throw new Error(
+              `Handle ${handle} is not allowed for public decryption!`,
+            );
+          }
+          return handle;
+        }),
+      );
+    } catch (e) {
+      throw e;
+    }
+
+    const verifications = handles.map(async (ctHandle) => {
+      const isAllowedForDecryption = await acl.isAllowedForDecryption(ctHandle);
+      if (!isAllowedForDecryption) {
+        throw new Error(
+          `Handle ${ctHandle} is not allowed for public decryption!`,
+        );
+      }
+    });
+
+    await Promise.all(verifications).catch((e) => {
+      throw e;
+    });
+
+    // check 2048 bits limit
+    checkEncryptedBits(handles);
 
     const payloadForRequest = {
-      ciphertext_handle: handle,
+      ciphertextHandles: handles,
     };
     const options = {
       method: 'POST',
@@ -38,7 +198,7 @@ export const publicDecryptRequest =
       response = await fetch(`${relayerUrl}/v1/public-decrypt`, options);
       if (!response.ok) {
         throw new Error(
-          `User decrypt failed: relayer respond with HTTP code ${response.status}`,
+          `Public decrypt failed: relayer respond with HTTP code ${response.status}`,
         );
       }
     } catch (e) {
@@ -62,7 +222,47 @@ export const publicDecryptRequest =
       );
     }
 
-    // TODO verify signature on decryption
+    // verify signatures on decryption:
+    const domain = {
+      name: 'Decryption',
+      version: '1',
+      chainId: gatewayChainId,
+      verifyingContract: verifyingContractAddress,
+    };
+    const types = {
+      PublicDecryptVerification: [
+        { name: 'ctHandles', type: 'bytes32[]' },
+        { name: 'decryptedResult', type: 'bytes' },
+      ],
+    };
+    const result = json.response[0];
+    const decryptedResult = result.decrypted_value.startsWith('0x')
+      ? result.decrypted_value
+      : `0x${result.decrypted_value}`;
+    const signatures = result.signatures;
+
+    const recoveredAddresses = signatures.map((signature: string) => {
+      const sig = signature.startsWith('0x') ? signature : `0x${signature}`;
+      const recoveredAddress = ethers.verifyTypedData(
+        domain,
+        types,
+        { ctHandles: handles, decryptedResult },
+        sig,
+      );
+      return recoveredAddress;
+    });
+
+    const thresholdReached = isThresholdReached(
+      kmsSigners,
+      recoveredAddresses,
+      thresholdSigners,
+    );
+
+    if (!thresholdReached) {
+      throw Error('KMS signers threshold is not reached');
+    }
+
+    const results = deserializeDecryptedResult(handles, decryptedResult);
 
-    return json;
+    return results;
   };
diff --git a/src/relayer/userDecrypt.ts b/src/relayer/userDecrypt.ts
index 972c104e..6482b845 100644
--- a/src/relayer/userDecrypt.ts
+++ b/src/relayer/userDecrypt.ts
@@ -63,16 +63,18 @@ export const userDecryptRequest =
         contractAddress,
       );
       if (!userAllowed) {
-        throw new Error('User is not authorized to user decrypt this handle!');
+        throw new Error(
+          `User ${userAddress} is not authorized to user decrypt handle ${ctHandle}!`,
+        );
       }
       if (!contractAllowed) {
         throw new Error(
-          'dApp contract is not authorized to user decrypt this handle!',
+          `dapp contract ${contractAddress} is not authorized to user decrypt handle ${ctHandle}!`,
         );
       }
       if (userAddress === contractAddress) {
         throw new Error(
-          'userAddress should not be equal to contractAddress when requesting user decryption!',
+          `userAddress ${userAddress} should not be equal to contractAddress when requesting user decryption!`,
         );
       }
     });


Reviews:
user: login: immortal-tofu
state: CHANGES_REQUESTED
author_association: COLLABORATOR
submitted_at: 2025-05-05T11:49:18Z
user: login: jatZama
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-05T12:16:24Z
user: login: jatZama
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-05T12:16:32Z
user: login: jatZama
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-05T12:16:48Z
user: login: immortal-tofu
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-05T12:35:21Z
user: login: jatZama
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-05T12:44:04Z
user: login: jatZama
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-05T12:44:16Z
user: login: jatZama
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-05T12:47:49Z
user: login: immortal-tofu
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-05-05T12:57:07Z

Review comments:
diff_hunk: @@ -49,7 +50,7 @@ export type FhevmInstance = {
     startTimestamp: string | number,
     durationDays: string | number,
   ) => EIP712;
-  publicDecrypt: (handle: string | Uint8Array) => Promise<bigint>;
+  publicDecrypt: (handles: (string | Uint8Array)[]) => Promise<any>;
path: src/index.ts
user: login: immortal-tofu
body: Don't use `any` type
created_at: 2025-05-05T11:43:16Z
author_association: COLLABORATOR
diff_hunk: @@ -1,28 +1,168 @@
 import { fromHexString, toHexString } from '../utils';
-import { ethers } from 'ethers';
+import { ethers, AbiCoder } from 'ethers';
 
 const aclABI = [
-  'function persistAllowed(bytes32 handle, address account) view returns (bool)',
+  'function isAllowedForDecryption(bytes32 handle) view returns (bool)',
 ];
 
+function isThresholdReached(
+  kmsSigners: string[],
+  recoveredAddresses: string[],
+  threshold: number,
+): boolean {
+  const addressMap = new Map<string, number>();
+  recoveredAddresses.forEach((address, index) => {
+    if (addressMap.has(address)) {
+      const duplicateValue = address;
+      throw new Error(
+        `Duplicate signer address found: ${duplicateValue} appears multiple times in recovered addresses`,
+      );
+    }
+    addressMap.set(address, index);
+  });
+
+  for (const address of recoveredAddresses) {
+    if (!kmsSigners.includes(address)) {
+      throw new Error(
+        `Invalid address found: ${address} is not in the list of KMS signers`,
+      );
+    }
+  }
+
+  return recoveredAddresses.length >= threshold;
+}
+
+const NumEncryptedBits: Record<number, number> = {
+  0: 2, // ebool
+  2: 8, // euint8
+  3: 16, // euint16
+  4: 32, // euint32
+  5: 64, // euint64
+  6: 128, // euint128
+  7: 160, // eaddress
+  8: 256, // euint256
+  9: 512, // ebytes64
+  10: 1024, // ebytes128
+  11: 2048, // ebytes256
+} as const;
+
+function checkEncryptedBits(handles: string[]) {
+  let total = 0;
+
+  for (const handle of handles) {
+    if (handle.length !== 66) {
+      throw new Error(`Handle ${handle} is not of valid length`);
+    }
+
+    const hexPair = handle.slice(-4, -2).toLowerCase();
+    const typeDiscriminant = parseInt(hexPair, 16);
+
+    if (!(typeDiscriminant in NumEncryptedBits)) {
+      throw new Error(`Handle ${handle} is not of valid type`);
+    }
+
+    total +=
+      NumEncryptedBits[typeDiscriminant as keyof typeof NumEncryptedBits];
+
+    // enforce 2048‑bit limit
+    if (total > 2048) {
+      throw new Error(
+        'Cannot decrypt more than 2048 encrypted bits in a single request',
+      );
+    }
+  }
+  return total;
+}
+
+const CiphertextType: Record<number, 'bool' | 'uint256' | 'address' | 'bytes'> =
+  {
+    0: 'bool',
+    2: 'uint256',
+    3: 'uint256',
+    4: 'uint256',
+    5: 'uint256',
+    6: 'uint256',
+    7: 'address',
+    8: 'uint256',
+    9: 'bytes',
+    10: 'bytes',
+    11: 'bytes',
+  };
+
+function deserializeDecryptedResult(
+  handles: string[],
+  decryptedResult: string,
+): Record<string, any> {
path: src/relayer/publicDecrypt.ts
user: login: immortal-tofu
body: Don't use `any` type. Create a type `DecryptedResult`.

Maybe `type DecryptedResult = Record<string, bigint | bool>`?
created_at: 2025-05-05T11:43:28Z
author_association: COLLABORATOR
diff_hunk: @@ -1,28 +1,168 @@
 import { fromHexString, toHexString } from '../utils';
-import { ethers } from 'ethers';
+import { ethers, AbiCoder } from 'ethers';
 
 const aclABI = [
-  'function persistAllowed(bytes32 handle, address account) view returns (bool)',
+  'function isAllowedForDecryption(bytes32 handle) view returns (bool)',
 ];
 
+function isThresholdReached(
+  kmsSigners: string[],
+  recoveredAddresses: string[],
+  threshold: number,
+): boolean {
+  const addressMap = new Map<string, number>();
+  recoveredAddresses.forEach((address, index) => {
+    if (addressMap.has(address)) {
+      const duplicateValue = address;
+      throw new Error(
+        `Duplicate signer address found: ${duplicateValue} appears multiple times in recovered addresses`,
+      );
+    }
+    addressMap.set(address, index);
+  });
+
+  for (const address of recoveredAddresses) {
+    if (!kmsSigners.includes(address)) {
+      throw new Error(
+        `Invalid address found: ${address} is not in the list of KMS signers`,
+      );
+    }
+  }
+
+  return recoveredAddresses.length >= threshold;
+}
+
+const NumEncryptedBits: Record<number, number> = {
+  0: 2, // ebool
+  2: 8, // euint8
+  3: 16, // euint16
+  4: 32, // euint32
+  5: 64, // euint64
+  6: 128, // euint128
+  7: 160, // eaddress
+  8: 256, // euint256
+  9: 512, // ebytes64
+  10: 1024, // ebytes128
+  11: 2048, // ebytes256
+} as const;
+
+function checkEncryptedBits(handles: string[]) {
+  let total = 0;
+
+  for (const handle of handles) {
+    if (handle.length !== 66) {
+      throw new Error(`Handle ${handle} is not of valid length`);
+    }
+
+    const hexPair = handle.slice(-4, -2).toLowerCase();
+    const typeDiscriminant = parseInt(hexPair, 16);
+
+    if (!(typeDiscriminant in NumEncryptedBits)) {
+      throw new Error(`Handle ${handle} is not of valid type`);
+    }
+
+    total +=
+      NumEncryptedBits[typeDiscriminant as keyof typeof NumEncryptedBits];
+
+    // enforce 2048‑bit limit
+    if (total > 2048) {
+      throw new Error(
+        'Cannot decrypt more than 2048 encrypted bits in a single request',
+      );
+    }
+  }
+  return total;
+}
+
+const CiphertextType: Record<number, 'bool' | 'uint256' | 'address' | 'bytes'> =
+  {
+    0: 'bool',
+    2: 'uint256',
+    3: 'uint256',
+    4: 'uint256',
+    5: 'uint256',
+    6: 'uint256',
+    7: 'address',
+    8: 'uint256',
+    9: 'bytes',
+    10: 'bytes',
+    11: 'bytes',
+  };
+
+function deserializeDecryptedResult(
+  handles: string[],
+  decryptedResult: string,
+): Record<string, any> {
+  let typesList: number[] = [];
+  for (const handle of handles) {
+    const hexPair = handle.slice(-4, -2).toLowerCase();
+    const typeDiscriminant = parseInt(hexPair, 16);
+    typesList.push(typeDiscriminant);
+  }
+
+  const restoredEncoded =
+    '0x' +
+    '00'.repeat(32) + // dummy requestID (ignored)
+    decryptedResult.slice(2) +
+    '00'.repeat(32); // dummy empty bytes[] length (ignored)
+
+  const abiTypes = typesList.map((t) => {
+    const abiType = CiphertextType[t]; // all types are valid because this was supposedly checked already inside the `checkEncryptedBits` function
+    return abiType;
+  });
+
+  const coder = new AbiCoder();
+  const decoded = coder.decode(
+    ['uint256', ...abiTypes, 'bytes[]'],
+    restoredEncoded,
+  );
+
+  // strip dummy first/last element
+  const rawValues = decoded.slice(1, 1 + typesList.length);
+
+  let results: Record<string, any> = {};
path: src/relayer/publicDecrypt.ts
user: login: immortal-tofu
body: Use `DecryptedResult`
created_at: 2025-05-05T11:43:40Z
author_association: COLLABORATOR
diff_hunk: @@ -1,28 +1,168 @@
 import { fromHexString, toHexString } from '../utils';
-import { ethers } from 'ethers';
+import { ethers, AbiCoder } from 'ethers';
 
 const aclABI = [
-  'function persistAllowed(bytes32 handle, address account) view returns (bool)',
+  'function isAllowedForDecryption(bytes32 handle) view returns (bool)',
 ];
 
+function isThresholdReached(
+  kmsSigners: string[],
+  recoveredAddresses: string[],
+  threshold: number,
+): boolean {
+  const addressMap = new Map<string, number>();
+  recoveredAddresses.forEach((address, index) => {
+    if (addressMap.has(address)) {
+      const duplicateValue = address;
+      throw new Error(
+        `Duplicate signer address found: ${duplicateValue} appears multiple times in recovered addresses`,
+      );
+    }
+    addressMap.set(address, index);
+  });
+
+  for (const address of recoveredAddresses) {
+    if (!kmsSigners.includes(address)) {
+      throw new Error(
+        `Invalid address found: ${address} is not in the list of KMS signers`,
+      );
+    }
+  }
+
+  return recoveredAddresses.length >= threshold;
+}
+
+const NumEncryptedBits: Record<number, number> = {
+  0: 2, // ebool
+  2: 8, // euint8
+  3: 16, // euint16
+  4: 32, // euint32
+  5: 64, // euint64
+  6: 128, // euint128
+  7: 160, // eaddress
+  8: 256, // euint256
+  9: 512, // ebytes64
+  10: 1024, // ebytes128
+  11: 2048, // ebytes256
+} as const;
+
+function checkEncryptedBits(handles: string[]) {
+  let total = 0;
+
+  for (const handle of handles) {
+    if (handle.length !== 66) {
+      throw new Error(`Handle ${handle} is not of valid length`);
+    }
+
+    const hexPair = handle.slice(-4, -2).toLowerCase();
+    const typeDiscriminant = parseInt(hexPair, 16);
+
+    if (!(typeDiscriminant in NumEncryptedBits)) {
+      throw new Error(`Handle ${handle} is not of valid type`);
+    }
+
+    total +=
+      NumEncryptedBits[typeDiscriminant as keyof typeof NumEncryptedBits];
+
+    // enforce 2048‑bit limit
+    if (total > 2048) {
+      throw new Error(
+        'Cannot decrypt more than 2048 encrypted bits in a single request',
+      );
+    }
+  }
+  return total;
+}
+
+const CiphertextType: Record<number, 'bool' | 'uint256' | 'address' | 'bytes'> =
+  {
+    0: 'bool',
+    2: 'uint256',
+    3: 'uint256',
+    4: 'uint256',
+    5: 'uint256',
+    6: 'uint256',
+    7: 'address',
+    8: 'uint256',
+    9: 'bytes',
+    10: 'bytes',
+    11: 'bytes',
+  };
+
+function deserializeDecryptedResult(
+  handles: string[],
+  decryptedResult: string,
+): Record<string, any> {
path: src/relayer/publicDecrypt.ts
user: login: jatZama
body: done with `DecryptedResults = Record<string, bigint | boolean | string>;`
created_at: 2025-05-05T12:16:24Z
author_association: MEMBER
diff_hunk: @@ -1,28 +1,168 @@
 import { fromHexString, toHexString } from '../utils';
-import { ethers } from 'ethers';
+import { ethers, AbiCoder } from 'ethers';
 
 const aclABI = [
-  'function persistAllowed(bytes32 handle, address account) view returns (bool)',
+  'function isAllowedForDecryption(bytes32 handle) view returns (bool)',
 ];
 
+function isThresholdReached(
+  kmsSigners: string[],
+  recoveredAddresses: string[],
+  threshold: number,
+): boolean {
+  const addressMap = new Map<string, number>();
+  recoveredAddresses.forEach((address, index) => {
+    if (addressMap.has(address)) {
+      const duplicateValue = address;
+      throw new Error(
+        `Duplicate signer address found: ${duplicateValue} appears multiple times in recovered addresses`,
+      );
+    }
+    addressMap.set(address, index);
+  });
+
+  for (const address of recoveredAddresses) {
+    if (!kmsSigners.includes(address)) {
+      throw new Error(
+        `Invalid address found: ${address} is not in the list of KMS signers`,
+      );
+    }
+  }
+
+  return recoveredAddresses.length >= threshold;
+}
+
+const NumEncryptedBits: Record<number, number> = {
+  0: 2, // ebool
+  2: 8, // euint8
+  3: 16, // euint16
+  4: 32, // euint32
+  5: 64, // euint64
+  6: 128, // euint128
+  7: 160, // eaddress
+  8: 256, // euint256
+  9: 512, // ebytes64
+  10: 1024, // ebytes128
+  11: 2048, // ebytes256
+} as const;
+
+function checkEncryptedBits(handles: string[]) {
+  let total = 0;
+
+  for (const handle of handles) {
+    if (handle.length !== 66) {
+      throw new Error(`Handle ${handle} is not of valid length`);
+    }
+
+    const hexPair = handle.slice(-4, -2).toLowerCase();
+    const typeDiscriminant = parseInt(hexPair, 16);
+
+    if (!(typeDiscriminant in NumEncryptedBits)) {
+      throw new Error(`Handle ${handle} is not of valid type`);
+    }
+
+    total +=
+      NumEncryptedBits[typeDiscriminant as keyof typeof NumEncryptedBits];
+
+    // enforce 2048‑bit limit
+    if (total > 2048) {
+      throw new Error(
+        'Cannot decrypt more than 2048 encrypted bits in a single request',
+      );
+    }
+  }
+  return total;
+}
+
+const CiphertextType: Record<number, 'bool' | 'uint256' | 'address' | 'bytes'> =
+  {
+    0: 'bool',
+    2: 'uint256',
+    3: 'uint256',
+    4: 'uint256',
+    5: 'uint256',
+    6: 'uint256',
+    7: 'address',
+    8: 'uint256',
+    9: 'bytes',
+    10: 'bytes',
+    11: 'bytes',
+  };
+
+function deserializeDecryptedResult(
+  handles: string[],
+  decryptedResult: string,
+): Record<string, any> {
+  let typesList: number[] = [];
+  for (const handle of handles) {
+    const hexPair = handle.slice(-4, -2).toLowerCase();
+    const typeDiscriminant = parseInt(hexPair, 16);
+    typesList.push(typeDiscriminant);
+  }
+
+  const restoredEncoded =
+    '0x' +
+    '00'.repeat(32) + // dummy requestID (ignored)
+    decryptedResult.slice(2) +
+    '00'.repeat(32); // dummy empty bytes[] length (ignored)
+
+  const abiTypes = typesList.map((t) => {
+    const abiType = CiphertextType[t]; // all types are valid because this was supposedly checked already inside the `checkEncryptedBits` function
+    return abiType;
+  });
+
+  const coder = new AbiCoder();
+  const decoded = coder.decode(
+    ['uint256', ...abiTypes, 'bytes[]'],
+    restoredEncoded,
+  );
+
+  // strip dummy first/last element
+  const rawValues = decoded.slice(1, 1 + typesList.length);
+
+  let results: Record<string, any> = {};
path: src/relayer/publicDecrypt.ts
user: login: jatZama
body: done
created_at: 2025-05-05T12:16:32Z
author_association: MEMBER
diff_hunk: @@ -49,7 +50,7 @@ export type FhevmInstance = {
     startTimestamp: string | number,
     durationDays: string | number,
   ) => EIP712;
-  publicDecrypt: (handle: string | Uint8Array) => Promise<bigint>;
+  publicDecrypt: (handles: (string | Uint8Array)[]) => Promise<any>;
path: src/index.ts
user: login: jatZama
body: done with `DecryptedResults`
created_at: 2025-05-05T12:16:48Z
author_association: MEMBER
diff_hunk: @@ -1,28 +1,170 @@
 import { fromHexString, toHexString } from '../utils';
-import { ethers } from 'ethers';
+import { ethers, AbiCoder } from 'ethers';
+
+export type DecryptedResults = Record<string, bigint | boolean | string>;
 
 const aclABI = [
-  'function persistAllowed(bytes32 handle, address account) view returns (bool)',
+  'function isAllowedForDecryption(bytes32 handle) view returns (bool)',
 ];
 
+function isThresholdReached(
+  kmsSigners: string[],
+  recoveredAddresses: string[],
+  threshold: number,
+): boolean {
+  const addressMap = new Map<string, number>();
+  recoveredAddresses.forEach((address, index) => {
+    if (addressMap.has(address)) {
+      const duplicateValue = address;
+      throw new Error(
+        `Duplicate signer address found: ${duplicateValue} appears multiple times in recovered addresses`,
+      );
+    }
+    addressMap.set(address, index);
+  });
+
+  for (const address of recoveredAddresses) {
+    if (!kmsSigners.includes(address)) {
+      throw new Error(
+        `Invalid address found: ${address} is not in the list of KMS signers`,
+      );
+    }
+  }
+
+  return recoveredAddresses.length >= threshold;
+}
+
+const NumEncryptedBits: Record<number, number> = {
+  0: 2, // ebool
+  2: 8, // euint8
+  3: 16, // euint16
+  4: 32, // euint32
+  5: 64, // euint64
+  6: 128, // euint128
+  7: 160, // eaddress
+  8: 256, // euint256
+  9: 512, // ebytes64
+  10: 1024, // ebytes128
+  11: 2048, // ebytes256
+} as const;
+
+function checkEncryptedBits(handles: string[]) {
+  let total = 0;
+
+  for (const handle of handles) {
+    if (handle.length !== 66) {
+      throw new Error(`Handle ${handle} is not of valid length`);
+    }
+
+    const hexPair = handle.slice(-4, -2).toLowerCase();
+    const typeDiscriminant = parseInt(hexPair, 16);
+
+    if (!(typeDiscriminant in NumEncryptedBits)) {
+      throw new Error(`Handle ${handle} is not of valid type`);
+    }
+
+    total +=
+      NumEncryptedBits[typeDiscriminant as keyof typeof NumEncryptedBits];
+
+    // enforce 2048‑bit limit
+    if (total > 2048) {
+      throw new Error(
+        'Cannot decrypt more than 2048 encrypted bits in a single request',
+      );
+    }
+  }
+  return total;
+}
+
+const CiphertextType: Record<number, 'bool' | 'uint256' | 'address' | 'bytes'> =
+  {
+    0: 'bool',
+    2: 'uint256',
+    3: 'uint256',
+    4: 'uint256',
+    5: 'uint256',
+    6: 'uint256',
+    7: 'address',
+    8: 'uint256',
+    9: 'bytes',
+    10: 'bytes',
+    11: 'bytes',
+  };
+
+function deserializeDecryptedResult(
+  handles: string[],
+  decryptedResult: string,
+): Record<string, DecryptedResults> {
path: src/relayer/publicDecrypt.ts
user: login: immortal-tofu
body: You should return `DecryptedResults` instead of `Record<string, DecryptedResults>`
created_at: 2025-05-05T12:24:54Z
author_association: COLLABORATOR
diff_hunk: @@ -1,28 +1,170 @@
 import { fromHexString, toHexString } from '../utils';
-import { ethers } from 'ethers';
+import { ethers, AbiCoder } from 'ethers';
+
+export type DecryptedResults = Record<string, bigint | boolean | string>;
 
 const aclABI = [
-  'function persistAllowed(bytes32 handle, address account) view returns (bool)',
+  'function isAllowedForDecryption(bytes32 handle) view returns (bool)',
 ];
 
+function isThresholdReached(
+  kmsSigners: string[],
+  recoveredAddresses: string[],
+  threshold: number,
+): boolean {
+  const addressMap = new Map<string, number>();
+  recoveredAddresses.forEach((address, index) => {
+    if (addressMap.has(address)) {
+      const duplicateValue = address;
+      throw new Error(
+        `Duplicate signer address found: ${duplicateValue} appears multiple times in recovered addresses`,
+      );
+    }
+    addressMap.set(address, index);
+  });
+
+  for (const address of recoveredAddresses) {
+    if (!kmsSigners.includes(address)) {
+      throw new Error(
+        `Invalid address found: ${address} is not in the list of KMS signers`,
+      );
+    }
+  }
+
+  return recoveredAddresses.length >= threshold;
+}
+
+const NumEncryptedBits: Record<number, number> = {
+  0: 2, // ebool
+  2: 8, // euint8
+  3: 16, // euint16
+  4: 32, // euint32
+  5: 64, // euint64
+  6: 128, // euint128
+  7: 160, // eaddress
+  8: 256, // euint256
+  9: 512, // ebytes64
+  10: 1024, // ebytes128
+  11: 2048, // ebytes256
+} as const;
+
+function checkEncryptedBits(handles: string[]) {
+  let total = 0;
+
+  for (const handle of handles) {
+    if (handle.length !== 66) {
+      throw new Error(`Handle ${handle} is not of valid length`);
+    }
+
+    const hexPair = handle.slice(-4, -2).toLowerCase();
+    const typeDiscriminant = parseInt(hexPair, 16);
+
+    if (!(typeDiscriminant in NumEncryptedBits)) {
+      throw new Error(`Handle ${handle} is not of valid type`);
+    }
+
+    total +=
+      NumEncryptedBits[typeDiscriminant as keyof typeof NumEncryptedBits];
+
+    // enforce 2048‑bit limit
+    if (total > 2048) {
+      throw new Error(
+        'Cannot decrypt more than 2048 encrypted bits in a single request',
+      );
+    }
+  }
+  return total;
+}
+
+const CiphertextType: Record<number, 'bool' | 'uint256' | 'address' | 'bytes'> =
+  {
+    0: 'bool',
+    2: 'uint256',
+    3: 'uint256',
+    4: 'uint256',
+    5: 'uint256',
+    6: 'uint256',
+    7: 'address',
+    8: 'uint256',
+    9: 'bytes',
+    10: 'bytes',
+    11: 'bytes',
+  };
+
+function deserializeDecryptedResult(
+  handles: string[],
+  decryptedResult: string,
+): Record<string, DecryptedResults> {
+  let typesList: number[] = [];
+  for (const handle of handles) {
+    const hexPair = handle.slice(-4, -2).toLowerCase();
+    const typeDiscriminant = parseInt(hexPair, 16);
+    typesList.push(typeDiscriminant);
+  }
+
+  const restoredEncoded =
+    '0x' +
+    '00'.repeat(32) + // dummy requestID (ignored)
+    decryptedResult.slice(2) +
+    '00'.repeat(32); // dummy empty bytes[] length (ignored)
+
+  const abiTypes = typesList.map((t) => {
+    const abiType = CiphertextType[t]; // all types are valid because this was supposedly checked already inside the `checkEncryptedBits` function
+    return abiType;
+  });
+
+  const coder = new AbiCoder();
+  const decoded = coder.decode(
+    ['uint256', ...abiTypes, 'bytes[]'],
+    restoredEncoded,
+  );
+
+  // strip dummy first/last element
+  const rawValues = decoded.slice(1, 1 + typesList.length);
+
+  let results: Record<string, DecryptedResults> = {};
path: src/relayer/publicDecrypt.ts
user: login: immortal-tofu
body: Type is `DecryptedResults` instead of `Record<string, DecryptedResults>`
created_at: 2025-05-05T12:25:06Z
author_association: COLLABORATOR
diff_hunk: @@ -62,7 +204,47 @@ export const publicDecryptRequest =
       );
     }
 
-    // TODO verify signature on decryption
+    // verify signatures on decryption:
+    const domain = {
+      name: 'Decryption',
+      version: '1',
+      chainId: gatewayChainId,
+      verifyingContract: verifyingContractAddress,
+    };
+    const types = {
+      PublicDecryptVerification: [
+        { name: 'ctHandles', type: 'bytes32[]' },
+        { name: 'decryptedResult', type: 'bytes' },
+      ],
+    };
+    const result = json.response[0];
+    const decryptedResult = result.decrypted_value.startsWith('0x')
+      ? result.decrypted_value
+      : `0x${result.decrypted_value}`;
+    const signatures = result.signatures;
+
+    const recoveredAddresses = signatures.map((signature: string) => {
+      const sig = signature.startsWith('0x') ? signature : `0x${signature}`;
+      const recoveredAddress = ethers.verifyTypedData(
+        domain,
+        types,
+        { ctHandles: handles, decryptedResult: decryptedResult },
path: src/relayer/publicDecrypt.ts
user: login: immortal-tofu
body: `{ ctHandles: handles, decryptedResult },`
created_at: 2025-05-05T12:26:52Z
author_association: COLLABORATOR
diff_hunk: @@ -1,28 +1,170 @@
 import { fromHexString, toHexString } from '../utils';
-import { ethers } from 'ethers';
+import { ethers, AbiCoder } from 'ethers';
+
+export type DecryptedResults = Record<string, bigint | boolean | string>;
 
 const aclABI = [
-  'function persistAllowed(bytes32 handle, address account) view returns (bool)',
+  'function isAllowedForDecryption(bytes32 handle) view returns (bool)',
 ];
 
+function isThresholdReached(
+  kmsSigners: string[],
+  recoveredAddresses: string[],
+  threshold: number,
+): boolean {
+  const addressMap = new Map<string, number>();
+  recoveredAddresses.forEach((address, index) => {
+    if (addressMap.has(address)) {
+      const duplicateValue = address;
+      throw new Error(
+        `Duplicate signer address found: ${duplicateValue} appears multiple times in recovered addresses`,
+      );
+    }
+    addressMap.set(address, index);
+  });
+
+  for (const address of recoveredAddresses) {
+    if (!kmsSigners.includes(address)) {
+      throw new Error(
+        `Invalid address found: ${address} is not in the list of KMS signers`,
+      );
+    }
+  }
+
+  return recoveredAddresses.length >= threshold;
+}
+
+const NumEncryptedBits: Record<number, number> = {
+  0: 2, // ebool
+  2: 8, // euint8
+  3: 16, // euint16
+  4: 32, // euint32
+  5: 64, // euint64
+  6: 128, // euint128
+  7: 160, // eaddress
+  8: 256, // euint256
+  9: 512, // ebytes64
+  10: 1024, // ebytes128
+  11: 2048, // ebytes256
+} as const;
+
+function checkEncryptedBits(handles: string[]) {
+  let total = 0;
+
+  for (const handle of handles) {
+    if (handle.length !== 66) {
+      throw new Error(`Handle ${handle} is not of valid length`);
+    }
+
+    const hexPair = handle.slice(-4, -2).toLowerCase();
+    const typeDiscriminant = parseInt(hexPair, 16);
+
+    if (!(typeDiscriminant in NumEncryptedBits)) {
+      throw new Error(`Handle ${handle} is not of valid type`);
+    }
+
+    total +=
+      NumEncryptedBits[typeDiscriminant as keyof typeof NumEncryptedBits];
+
+    // enforce 2048‑bit limit
+    if (total > 2048) {
+      throw new Error(
+        'Cannot decrypt more than 2048 encrypted bits in a single request',
+      );
+    }
+  }
+  return total;
+}
+
+const CiphertextType: Record<number, 'bool' | 'uint256' | 'address' | 'bytes'> =
+  {
+    0: 'bool',
+    2: 'uint256',
+    3: 'uint256',
+    4: 'uint256',
+    5: 'uint256',
+    6: 'uint256',
+    7: 'address',
+    8: 'uint256',
+    9: 'bytes',
+    10: 'bytes',
+    11: 'bytes',
+  };
+
+function deserializeDecryptedResult(
+  handles: string[],
+  decryptedResult: string,
+): Record<string, DecryptedResults> {
+  let typesList: number[] = [];
+  for (const handle of handles) {
+    const hexPair = handle.slice(-4, -2).toLowerCase();
+    const typeDiscriminant = parseInt(hexPair, 16);
+    typesList.push(typeDiscriminant);
+  }
+
+  const restoredEncoded =
+    '0x' +
+    '00'.repeat(32) + // dummy requestID (ignored)
+    decryptedResult.slice(2) +
+    '00'.repeat(32); // dummy empty bytes[] length (ignored)
+
+  const abiTypes = typesList.map((t) => {
+    const abiType = CiphertextType[t]; // all types are valid because this was supposedly checked already inside the `checkEncryptedBits` function
+    return abiType;
+  });
+
+  const coder = new AbiCoder();
+  const decoded = coder.decode(
+    ['uint256', ...abiTypes, 'bytes[]'],
+    restoredEncoded,
+  );
+
+  // strip dummy first/last element
+  const rawValues = decoded.slice(1, 1 + typesList.length);
+
+  let results: Record<string, DecryptedResults> = {};
+  handles.forEach((handle, idx) => (results[handle] = rawValues[idx]));
+
+  return results;
+}
+
 export const publicDecryptRequest =
   (
-    kmsSignatures: string[],
+    kmsSigners: string[],
+    thresholdSigners: number,
     gatewayChainId: number,
-    chainId: number,
     verifyingContractAddress: string,
     aclContractAddress: string,
     relayerUrl: string,
     provider: ethers.JsonRpcProvider | ethers.BrowserProvider,
   ) =>
-  async (_handle: Uint8Array | string) => {
-    const handle =
-      typeof _handle === 'string'
-        ? toHexString(fromHexString(_handle), true)
-        : toHexString(_handle, true);
+  async (_handles: (Uint8Array | string)[]) => {
+    const handles = _handles.map((handle) =>
path: src/relayer/publicDecrypt.ts
user: login: immortal-tofu
body: You can avoid having 2 loops:

```
    const acl = new ethers.Contract(aclContractAddress, aclABI, provider);
    
    let handles: string[];
    try {
      handles = await _handles.map(async (_handle) => {
        const handle = typeof _handle === 'string'
          ? toHexString(fromHexString(handle), true)
          : toHexString(handle, true);
        
        const isAllowedForDecryption = await acl.isAllowedForDecryption(handle);
        if (!isAllowedForDecryption) {
            throw new Error(
            `Handle ${handle} is not allowed for public decryption!`,
          );
        }
        return handle;
      });
    } catch(e) {
      throw(e);
    }
created_at: 2025-05-05T12:33:17Z
author_association: COLLABORATOR
diff_hunk: @@ -1,28 +1,170 @@
 import { fromHexString, toHexString } from '../utils';
-import { ethers } from 'ethers';
+import { ethers, AbiCoder } from 'ethers';
+
+export type DecryptedResults = Record<string, bigint | boolean | string>;
 
 const aclABI = [
-  'function persistAllowed(bytes32 handle, address account) view returns (bool)',
+  'function isAllowedForDecryption(bytes32 handle) view returns (bool)',
 ];
 
+function isThresholdReached(
+  kmsSigners: string[],
+  recoveredAddresses: string[],
+  threshold: number,
+): boolean {
+  const addressMap = new Map<string, number>();
+  recoveredAddresses.forEach((address, index) => {
+    if (addressMap.has(address)) {
+      const duplicateValue = address;
+      throw new Error(
+        `Duplicate signer address found: ${duplicateValue} appears multiple times in recovered addresses`,
+      );
+    }
+    addressMap.set(address, index);
+  });
+
+  for (const address of recoveredAddresses) {
+    if (!kmsSigners.includes(address)) {
+      throw new Error(
+        `Invalid address found: ${address} is not in the list of KMS signers`,
+      );
+    }
+  }
+
+  return recoveredAddresses.length >= threshold;
+}
+
+const NumEncryptedBits: Record<number, number> = {
+  0: 2, // ebool
+  2: 8, // euint8
+  3: 16, // euint16
+  4: 32, // euint32
+  5: 64, // euint64
+  6: 128, // euint128
+  7: 160, // eaddress
+  8: 256, // euint256
+  9: 512, // ebytes64
+  10: 1024, // ebytes128
+  11: 2048, // ebytes256
+} as const;
+
+function checkEncryptedBits(handles: string[]) {
+  let total = 0;
+
+  for (const handle of handles) {
+    if (handle.length !== 66) {
+      throw new Error(`Handle ${handle} is not of valid length`);
+    }
+
+    const hexPair = handle.slice(-4, -2).toLowerCase();
+    const typeDiscriminant = parseInt(hexPair, 16);
+
+    if (!(typeDiscriminant in NumEncryptedBits)) {
+      throw new Error(`Handle ${handle} is not of valid type`);
+    }
+
+    total +=
+      NumEncryptedBits[typeDiscriminant as keyof typeof NumEncryptedBits];
+
+    // enforce 2048‑bit limit
+    if (total > 2048) {
+      throw new Error(
+        'Cannot decrypt more than 2048 encrypted bits in a single request',
+      );
+    }
+  }
+  return total;
+}
+
+const CiphertextType: Record<number, 'bool' | 'uint256' | 'address' | 'bytes'> =
+  {
+    0: 'bool',
+    2: 'uint256',
+    3: 'uint256',
+    4: 'uint256',
+    5: 'uint256',
+    6: 'uint256',
+    7: 'address',
+    8: 'uint256',
+    9: 'bytes',
+    10: 'bytes',
+    11: 'bytes',
+  };
+
+function deserializeDecryptedResult(
+  handles: string[],
+  decryptedResult: string,
+): Record<string, DecryptedResults> {
+  let typesList: number[] = [];
+  for (const handle of handles) {
+    const hexPair = handle.slice(-4, -2).toLowerCase();
+    const typeDiscriminant = parseInt(hexPair, 16);
+    typesList.push(typeDiscriminant);
+  }
+
+  const restoredEncoded =
+    '0x' +
+    '00'.repeat(32) + // dummy requestID (ignored)
+    decryptedResult.slice(2) +
+    '00'.repeat(32); // dummy empty bytes[] length (ignored)
+
+  const abiTypes = typesList.map((t) => {
+    const abiType = CiphertextType[t]; // all types are valid because this was supposedly checked already inside the `checkEncryptedBits` function
+    return abiType;
+  });
+
+  const coder = new AbiCoder();
+  const decoded = coder.decode(
+    ['uint256', ...abiTypes, 'bytes[]'],
+    restoredEncoded,
+  );
+
+  // strip dummy first/last element
+  const rawValues = decoded.slice(1, 1 + typesList.length);
+
+  let results: Record<string, DecryptedResults> = {};
+  handles.forEach((handle, idx) => (results[handle] = rawValues[idx]));
+
+  return results;
+}
+
 export const publicDecryptRequest =
   (
-    kmsSignatures: string[],
+    kmsSigners: string[],
+    thresholdSigners: number,
     gatewayChainId: number,
-    chainId: number,
     verifyingContractAddress: string,
     aclContractAddress: string,
     relayerUrl: string,
     provider: ethers.JsonRpcProvider | ethers.BrowserProvider,
   ) =>
-  async (_handle: Uint8Array | string) => {
-    const handle =
-      typeof _handle === 'string'
-        ? toHexString(fromHexString(_handle), true)
-        : toHexString(_handle, true);
+  async (_handles: (Uint8Array | string)[]) => {
+    const handles = _handles.map((handle) =>
path: src/relayer/publicDecrypt.ts
user: login: jatZama
body: this code you propose does not compile
created_at: 2025-05-05T12:44:04Z
author_association: MEMBER
diff_hunk: @@ -1,28 +1,170 @@
 import { fromHexString, toHexString } from '../utils';
-import { ethers } from 'ethers';
+import { ethers, AbiCoder } from 'ethers';
+
+export type DecryptedResults = Record<string, bigint | boolean | string>;
 
 const aclABI = [
-  'function persistAllowed(bytes32 handle, address account) view returns (bool)',
+  'function isAllowedForDecryption(bytes32 handle) view returns (bool)',
 ];
 
+function isThresholdReached(
+  kmsSigners: string[],
+  recoveredAddresses: string[],
+  threshold: number,
+): boolean {
+  const addressMap = new Map<string, number>();
+  recoveredAddresses.forEach((address, index) => {
+    if (addressMap.has(address)) {
+      const duplicateValue = address;
+      throw new Error(
+        `Duplicate signer address found: ${duplicateValue} appears multiple times in recovered addresses`,
+      );
+    }
+    addressMap.set(address, index);
+  });
+
+  for (const address of recoveredAddresses) {
+    if (!kmsSigners.includes(address)) {
+      throw new Error(
+        `Invalid address found: ${address} is not in the list of KMS signers`,
+      );
+    }
+  }
+
+  return recoveredAddresses.length >= threshold;
+}
+
+const NumEncryptedBits: Record<number, number> = {
+  0: 2, // ebool
+  2: 8, // euint8
+  3: 16, // euint16
+  4: 32, // euint32
+  5: 64, // euint64
+  6: 128, // euint128
+  7: 160, // eaddress
+  8: 256, // euint256
+  9: 512, // ebytes64
+  10: 1024, // ebytes128
+  11: 2048, // ebytes256
+} as const;
+
+function checkEncryptedBits(handles: string[]) {
+  let total = 0;
+
+  for (const handle of handles) {
+    if (handle.length !== 66) {
+      throw new Error(`Handle ${handle} is not of valid length`);
+    }
+
+    const hexPair = handle.slice(-4, -2).toLowerCase();
+    const typeDiscriminant = parseInt(hexPair, 16);
+
+    if (!(typeDiscriminant in NumEncryptedBits)) {
+      throw new Error(`Handle ${handle} is not of valid type`);
+    }
+
+    total +=
+      NumEncryptedBits[typeDiscriminant as keyof typeof NumEncryptedBits];
+
+    // enforce 2048‑bit limit
+    if (total > 2048) {
+      throw new Error(
+        'Cannot decrypt more than 2048 encrypted bits in a single request',
+      );
+    }
+  }
+  return total;
+}
+
+const CiphertextType: Record<number, 'bool' | 'uint256' | 'address' | 'bytes'> =
+  {
+    0: 'bool',
+    2: 'uint256',
+    3: 'uint256',
+    4: 'uint256',
+    5: 'uint256',
+    6: 'uint256',
+    7: 'address',
+    8: 'uint256',
+    9: 'bytes',
+    10: 'bytes',
+    11: 'bytes',
+  };
+
+function deserializeDecryptedResult(
+  handles: string[],
+  decryptedResult: string,
+): Record<string, DecryptedResults> {
+  let typesList: number[] = [];
+  for (const handle of handles) {
+    const hexPair = handle.slice(-4, -2).toLowerCase();
+    const typeDiscriminant = parseInt(hexPair, 16);
+    typesList.push(typeDiscriminant);
+  }
+
+  const restoredEncoded =
+    '0x' +
+    '00'.repeat(32) + // dummy requestID (ignored)
+    decryptedResult.slice(2) +
+    '00'.repeat(32); // dummy empty bytes[] length (ignored)
+
+  const abiTypes = typesList.map((t) => {
+    const abiType = CiphertextType[t]; // all types are valid because this was supposedly checked already inside the `checkEncryptedBits` function
+    return abiType;
+  });
+
+  const coder = new AbiCoder();
+  const decoded = coder.decode(
+    ['uint256', ...abiTypes, 'bytes[]'],
+    restoredEncoded,
+  );
+
+  // strip dummy first/last element
+  const rawValues = decoded.slice(1, 1 + typesList.length);
+
+  let results: Record<string, DecryptedResults> = {};
+  handles.forEach((handle, idx) => (results[handle] = rawValues[idx]));
+
+  return results;
+}
+
 export const publicDecryptRequest =
   (
-    kmsSignatures: string[],
+    kmsSigners: string[],
+    thresholdSigners: number,
     gatewayChainId: number,
-    chainId: number,
     verifyingContractAddress: string,
     aclContractAddress: string,
     relayerUrl: string,
     provider: ethers.JsonRpcProvider | ethers.BrowserProvider,
   ) =>
-  async (_handle: Uint8Array | string) => {
-    const handle =
-      typeof _handle === 'string'
-        ? toHexString(fromHexString(_handle), true)
-        : toHexString(_handle, true);
+  async (_handles: (Uint8Array | string)[]) => {
+    const handles = _handles.map((handle) =>
path: src/relayer/publicDecrypt.ts
user: login: jatZama
body: Type 'Promise<any>[]' is not assignable to type 'string[]'.
  Type 'Promise<any>' is not assignable to type 'string'.
created_at: 2025-05-05T12:44:16Z
author_association: MEMBER
diff_hunk: @@ -1,28 +1,170 @@
 import { fromHexString, toHexString } from '../utils';
-import { ethers } from 'ethers';
+import { ethers, AbiCoder } from 'ethers';
+
+export type DecryptedResults = Record<string, bigint | boolean | string>;
 
 const aclABI = [
-  'function persistAllowed(bytes32 handle, address account) view returns (bool)',
+  'function isAllowedForDecryption(bytes32 handle) view returns (bool)',
 ];
 
+function isThresholdReached(
+  kmsSigners: string[],
+  recoveredAddresses: string[],
+  threshold: number,
+): boolean {
+  const addressMap = new Map<string, number>();
+  recoveredAddresses.forEach((address, index) => {
+    if (addressMap.has(address)) {
+      const duplicateValue = address;
+      throw new Error(
+        `Duplicate signer address found: ${duplicateValue} appears multiple times in recovered addresses`,
+      );
+    }
+    addressMap.set(address, index);
+  });
+
+  for (const address of recoveredAddresses) {
+    if (!kmsSigners.includes(address)) {
+      throw new Error(
+        `Invalid address found: ${address} is not in the list of KMS signers`,
+      );
+    }
+  }
+
+  return recoveredAddresses.length >= threshold;
+}
+
+const NumEncryptedBits: Record<number, number> = {
+  0: 2, // ebool
+  2: 8, // euint8
+  3: 16, // euint16
+  4: 32, // euint32
+  5: 64, // euint64
+  6: 128, // euint128
+  7: 160, // eaddress
+  8: 256, // euint256
+  9: 512, // ebytes64
+  10: 1024, // ebytes128
+  11: 2048, // ebytes256
+} as const;
+
+function checkEncryptedBits(handles: string[]) {
+  let total = 0;
+
+  for (const handle of handles) {
+    if (handle.length !== 66) {
+      throw new Error(`Handle ${handle} is not of valid length`);
+    }
+
+    const hexPair = handle.slice(-4, -2).toLowerCase();
+    const typeDiscriminant = parseInt(hexPair, 16);
+
+    if (!(typeDiscriminant in NumEncryptedBits)) {
+      throw new Error(`Handle ${handle} is not of valid type`);
+    }
+
+    total +=
+      NumEncryptedBits[typeDiscriminant as keyof typeof NumEncryptedBits];
+
+    // enforce 2048‑bit limit
+    if (total > 2048) {
+      throw new Error(
+        'Cannot decrypt more than 2048 encrypted bits in a single request',
+      );
+    }
+  }
+  return total;
+}
+
+const CiphertextType: Record<number, 'bool' | 'uint256' | 'address' | 'bytes'> =
+  {
+    0: 'bool',
+    2: 'uint256',
+    3: 'uint256',
+    4: 'uint256',
+    5: 'uint256',
+    6: 'uint256',
+    7: 'address',
+    8: 'uint256',
+    9: 'bytes',
+    10: 'bytes',
+    11: 'bytes',
+  };
+
+function deserializeDecryptedResult(
+  handles: string[],
+  decryptedResult: string,
+): Record<string, DecryptedResults> {
+  let typesList: number[] = [];
+  for (const handle of handles) {
+    const hexPair = handle.slice(-4, -2).toLowerCase();
+    const typeDiscriminant = parseInt(hexPair, 16);
+    typesList.push(typeDiscriminant);
+  }
+
+  const restoredEncoded =
+    '0x' +
+    '00'.repeat(32) + // dummy requestID (ignored)
+    decryptedResult.slice(2) +
+    '00'.repeat(32); // dummy empty bytes[] length (ignored)
+
+  const abiTypes = typesList.map((t) => {
+    const abiType = CiphertextType[t]; // all types are valid because this was supposedly checked already inside the `checkEncryptedBits` function
+    return abiType;
+  });
+
+  const coder = new AbiCoder();
+  const decoded = coder.decode(
+    ['uint256', ...abiTypes, 'bytes[]'],
+    restoredEncoded,
+  );
+
+  // strip dummy first/last element
+  const rawValues = decoded.slice(1, 1 + typesList.length);
+
+  let results: Record<string, DecryptedResults> = {};
+  handles.forEach((handle, idx) => (results[handle] = rawValues[idx]));
+
+  return results;
+}
+
 export const publicDecryptRequest =
   (
-    kmsSignatures: string[],
+    kmsSigners: string[],
+    thresholdSigners: number,
     gatewayChainId: number,
-    chainId: number,
     verifyingContractAddress: string,
     aclContractAddress: string,
     relayerUrl: string,
     provider: ethers.JsonRpcProvider | ethers.BrowserProvider,
   ) =>
-  async (_handle: Uint8Array | string) => {
-    const handle =
-      typeof _handle === 'string'
-        ? toHexString(fromHexString(_handle), true)
-        : toHexString(_handle, true);
+  async (_handles: (Uint8Array | string)[]) => {
+    const handles = _handles.map((handle) =>
path: src/relayer/publicDecrypt.ts
user: login: jatZama
body: Using Promise.all fixed the issue fyi
created_at: 2025-05-05T12:47:49Z
author_association: MEMBER

PR comments:
user: login: 0xalexbel
created_at: 2025-05-04T04:46:41Z
body: While you are adjusting the public user messages, maybe this one also could be modified: 

https://github.com/zama-ai/fhevm-js/blob/c379625f82632bdacb2fef1dc687daa7d42a585f/src/relayer/userDecrypt.ts#L77

From:
```ts
userAddress ${userAddress} should not be equal to contractAddress when requesting reencryption!
```

To 
```ts
userAddress ${userAddress} should not be equal to contractAddress when requesting decryption!
```
author_association: CONTRIBUTOR
user: login: jatZama
created_at: 2025-05-04T20:08:37Z
body: > While you are adjusting the public user messages, maybe this one also could be modified:
> 
> https://github.com/zama-ai/fhevm-js/blob/c379625f82632bdacb2fef1dc687daa7d42a585f/src/relayer/userDecrypt.ts#L77
> 
> From:
> 
> ```ts
> userAddress ${userAddress} should not be equal to contractAddress when requesting reencryption!
> ```
> 
> To
> 
> ```ts
> userAddress ${userAddress} should not be equal to contractAddress when requesting decryption!
> ```

This was a typo good catch, I will keep it as `*user* decryption`.
author_association: MEMBER
user: login: immortal-tofu
created_at: 2025-05-05T12:57:21Z
body: I approve but we should add tests for these new functions
author_association: COLLABORATOR
user: login: jatZama
created_at: 2025-05-05T13:03:28Z
body: Thanks @immortal-tofu I agree, I noticed coverage became very low even before implementing this, since we added many new checks and features already, so I opened this issue to not forget about it. https://github.com/zama-ai/fhevm-js/issues/232
author_association: MEMBER