From eb928333d92c1b2422181c071811da9244bd7358 Mon Sep 17 00:00:00 2001 From: "Petros G. Sideris" Date: Tue, 10 Mar 2026 14:28:27 +0000 Subject: [PATCH 01/11] More indicators --- dapp/src/hooks/useDataRoom.ts | 1 + dapp/src/lib/fhe.ts | 27 +++++++------------ dapp/src/pages/Dashboard/index.tsx | 7 +++++ .../src/pages/Room/components/FolderPanel.tsx | 16 ++++++++++- dapp/src/pages/Room/index.tsx | 7 +++++ 5 files changed, 40 insertions(+), 18 deletions(-) diff --git a/dapp/src/hooks/useDataRoom.ts b/dapp/src/hooks/useDataRoom.ts index ff3d291..bfcfb78 100644 --- a/dapp/src/hooks/useDataRoom.ts +++ b/dapp/src/hooks/useDataRoom.ts @@ -90,6 +90,7 @@ export function useRoomKey(roomId: bigint | undefined, enabled = true) { }, enabled: !!signerPromise && roomId !== undefined && enabled, staleTime: Infinity, + retry: false, structuralSharing: false, }); } diff --git a/dapp/src/lib/fhe.ts b/dapp/src/lib/fhe.ts index 7c53780..8af8604 100644 --- a/dapp/src/lib/fhe.ts +++ b/dapp/src/lib/fhe.ts @@ -1,5 +1,3 @@ -import { createInstance, SepoliaConfig, MainnetConfig } from "@zama-fhe/relayer-sdk/web"; -import type { FhevmInstance } from "@zama-fhe/relayer-sdk/web"; import type { JsonRpcSigner } from "ethers"; import { CHAIN_ID, DATAROOM_ADDRESS } from "@/contracts"; @@ -12,26 +10,26 @@ const rpcUrls: Record = { [1]: import.meta.env.VITE_RPC_URL || "https://eth.llamarpc.com", }; -const networkConfigs: Record = { - [11155111]: SepoliaConfig, - [1]: MainnetConfig, -}; - -let instancePromise: Promise | null = null; +// Lazy-loaded FhEVM instance. +let instancePromise: Promise | null = null; -function getInstance(): Promise { +async function getInstance() { if (!instancePromise) { - const config = networkConfigs[CHAIN_ID]; + const { createInstance, SepoliaConfig, MainnetConfig } = await import("@zama-fhe/relayer-sdk/web"); + const configs: Record = { + [11155111]: SepoliaConfig, + [1]: MainnetConfig, + }; + const config = configs[CHAIN_ID]; const network = rpcUrls[CHAIN_ID]; if (!config || !network) { throw new Error(`No FHE config for chain ${CHAIN_ID}. Supported: Sepolia (11155111), Mainnet (1).`); } instancePromise = createInstance({ ...config, network }); } - return instancePromise; + return instancePromise as Promise; } - const CACHE_PREFIX = "fhe:"; function getCached(handle: string): string | null { @@ -97,11 +95,6 @@ export async function decryptEuint256( return hex; } -/** - * Convenience: decrypt the room key for a folder. - * On mock chains (Anvil), this is a no-op — the handle IS the key. - * On FHE chains, performs the full user decryption flow. - */ export async function decryptRoomKey( handle: string, signer: JsonRpcSigner, diff --git a/dapp/src/pages/Dashboard/index.tsx b/dapp/src/pages/Dashboard/index.tsx index d9fdf6c..4d65895 100644 --- a/dapp/src/pages/Dashboard/index.tsx +++ b/dapp/src/pages/Dashboard/index.tsx @@ -74,6 +74,13 @@ export default function Dashboard() { )} + {(isPending || isConfirming) && count > 0 && ( +
+ + {isPending ? "Waiting for signature..." : "Confirming transaction..."} +
+ )} + {(isPending || isConfirming) && count === 0 ? (
diff --git a/dapp/src/pages/Room/components/FolderPanel.tsx b/dapp/src/pages/Room/components/FolderPanel.tsx index 1f2157f..0e9896d 100644 --- a/dapp/src/pages/Room/components/FolderPanel.tsx +++ b/dapp/src/pages/Room/components/FolderPanel.tsx @@ -67,7 +67,7 @@ export function FolderPanel({ folderId }: { folderId: bigint }) { isReady: storachaReady, } = useStoracha(); - const { data: roomKeyData } = useRoomKey(folderId); + const { data: roomKeyData, error: roomKeyError, isLoading: isLoadingKey } = useRoomKey(folderId); const [newMember, setNewMember] = useState(""); const [selectedFiles, setSelectedFiles] = useState([]); @@ -239,6 +239,20 @@ export function FolderPanel({ folderId }: { folderId: bigint }) { )} + {isLoadingKey && ( +
+ + Decrypting folder key... +
+ )} + + {roomKeyError && ( +
+ + Failed to decrypt folder key: {roomKeyError.message} +
+ )} + {uploadError && (
diff --git a/dapp/src/pages/Room/index.tsx b/dapp/src/pages/Room/index.tsx index 09ec152..41629c0 100644 --- a/dapp/src/pages/Room/index.tsx +++ b/dapp/src/pages/Room/index.tsx @@ -150,6 +150,13 @@ export default function Room() {
)} + {(isCreatingFolder || isConfirmingFolder) && folders.length > 0 && ( +
+ + {isCreatingFolder ? "Waiting for signature..." : "Confirming transaction..."} +
+ )} + {(isCreatingFolder || isConfirmingFolder) && folders.length === 0 ? (
From 5bd43beac11f122c9a13bfa650bb55b483ce031c Mon Sep 17 00:00:00 2001 From: "Petros G. Sideris" Date: Tue, 10 Mar 2026 14:36:32 +0000 Subject: [PATCH 02/11] update vite --- dapp/vite.config.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/dapp/vite.config.ts b/dapp/vite.config.ts index 8f33a9c..a89e277 100644 --- a/dapp/vite.config.ts +++ b/dapp/vite.config.ts @@ -21,4 +21,10 @@ export default defineConfig({ "@": path.resolve(__dirname, "./src"), }, }, + optimizeDeps: { + exclude: ["@zama-fhe/relayer-sdk"], + }, + build: { + target: "esnext", + }, }); From d599082e0169d4bb673a5a7eadfcbfa44daca7bf Mon Sep 17 00:00:00 2001 From: "Petros G. Sideris" Date: Tue, 10 Mar 2026 15:05:57 +0000 Subject: [PATCH 03/11] Try loading wasm from zama relayer directly' --- dapp/src/lib/fhe.ts | 50 ++++++++++++++++++++++++++++++++------------- 1 file changed, 36 insertions(+), 14 deletions(-) diff --git a/dapp/src/lib/fhe.ts b/dapp/src/lib/fhe.ts index 8af8604..ad5ac6e 100644 --- a/dapp/src/lib/fhe.ts +++ b/dapp/src/lib/fhe.ts @@ -5,29 +5,51 @@ export function isFheChain(): boolean { return CHAIN_ID !== 31337; } +// Load the relayer SDK UMD bundle from Zama's CDN. This handles WASM init internally +const SDK_CDN_URL = "https://cdn.zama.org/relayer-sdk-js/0.4.2/relayer-sdk-js.umd.cjs"; + const rpcUrls: Record = { [11155111]: import.meta.env.VITE_RPC_URL || "https://ethereum-sepolia-rpc.publicnode.com", [1]: import.meta.env.VITE_RPC_URL || "https://eth.llamarpc.com", }; -// Lazy-loaded FhEVM instance. -let instancePromise: Promise | null = null; +function loadSDKScript(): Promise { + return new Promise((resolve, reject) => { + const w = window as unknown as Record; + if (w.relayerSDK) { resolve(); return; } + const script = document.createElement("script"); + script.src = SDK_CDN_URL; + script.onload = () => resolve(); + script.onerror = () => reject(new Error(`Failed to load relayer SDK from ${SDK_CDN_URL}`)); + document.head.appendChild(script); + }); +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let instancePromise: Promise | null = null; async function getInstance() { if (!instancePromise) { - const { createInstance, SepoliaConfig, MainnetConfig } = await import("@zama-fhe/relayer-sdk/web"); - const configs: Record = { - [11155111]: SepoliaConfig, - [1]: MainnetConfig, - }; - const config = configs[CHAIN_ID]; - const network = rpcUrls[CHAIN_ID]; - if (!config || !network) { - throw new Error(`No FHE config for chain ${CHAIN_ID}. Supported: Sepolia (11155111), Mainnet (1).`); - } - instancePromise = createInstance({ ...config, network }); + instancePromise = (async () => { + await loadSDKScript(); + const { initSDK, createInstance, SepoliaConfig, MainnetConfig } = + await import("@zama-fhe/relayer-sdk/bundle"); + + const presets: Record = { + [11155111]: SepoliaConfig, + [1]: MainnetConfig, + }; + const preset = presets[CHAIN_ID]; + const network = rpcUrls[CHAIN_ID]; + if (!preset || !network) { + throw new Error(`No FHE config for chain ${CHAIN_ID}. Supported: Sepolia (11155111), Mainnet (1).`); + } + + await initSDK(); + return createInstance({ ...preset, network }); + })(); } - return instancePromise as Promise; + return instancePromise; } const CACHE_PREFIX = "fhe:"; From c6fd596fdefc07429bd64ac7a0f6b071a1cef283 Mon Sep 17 00:00:00 2001 From: "Petros G. Sideris" Date: Tue, 10 Mar 2026 15:22:23 +0000 Subject: [PATCH 04/11] Add UX pattern for decrypting --- dapp/src/assets/31337.contracts.json | 2 +- dapp/src/hooks/useDataRoom.ts | 16 ++-- .../src/pages/Room/components/FolderPanel.tsx | 86 +++++++++++++++---- dapp/src/pages/Room/index.tsx | 9 +- 4 files changed, 83 insertions(+), 30 deletions(-) diff --git a/dapp/src/assets/31337.contracts.json b/dapp/src/assets/31337.contracts.json index 58203fe..57f41d9 100644 --- a/dapp/src/assets/31337.contracts.json +++ b/dapp/src/assets/31337.contracts.json @@ -1,3 +1,3 @@ { - "EncryptedDataRoom": "0xf4b146fba71f41e0592668ffbf264f1d186b2ca8" + "EncryptedDataRoom": "0xd84379ceae14aa33c123af12424a37803f885889" } \ No newline at end of file diff --git a/dapp/src/hooks/useDataRoom.ts b/dapp/src/hooks/useDataRoom.ts index bfcfb78..edc1f47 100644 --- a/dapp/src/hooks/useDataRoom.ts +++ b/dapp/src/hooks/useDataRoom.ts @@ -45,12 +45,18 @@ export function useRoom(roomId: bigint | undefined) { }); } -export function useFolders(parentId: bigint | undefined) { - const contract = usePublicContract(); +export function useAccessibleFolders(parentId: bigint | undefined, isOwner: boolean) { + const signerPromise = useEthersSigner({ chainId: CHAIN_ID }); return useQuery({ - queryKey: ["dataroom", "folders", parentId?.toString()], - queryFn: () => contract!.getFolders(parentId!), - enabled: !!contract && parentId !== undefined, + queryKey: ["dataroom", "accessibleFolders", parentId?.toString(), isOwner], + queryFn: async () => { + const contract = await getSignerContract(signerPromise!); + const folderIds = await contract.getFolders(parentId!); + if (isOwner || folderIds.length === 0) return folderIds; + const flags = await Promise.all(folderIds.map((fId) => contract.validateAccess(fId))); + return folderIds.filter((_, i) => flags[i] !== ZERO_BYTES32); + }, + enabled: !!signerPromise && parentId !== undefined, structuralSharing: false, }); } diff --git a/dapp/src/pages/Room/components/FolderPanel.tsx b/dapp/src/pages/Room/components/FolderPanel.tsx index 0e9896d..4c93829 100644 --- a/dapp/src/pages/Room/components/FolderPanel.tsx +++ b/dapp/src/pages/Room/components/FolderPanel.tsx @@ -1,5 +1,6 @@ import { useState } from "react"; import { useAccount } from "wagmi"; +import { useQueryClient } from "@tanstack/react-query"; import { Upload, UserPlus, @@ -25,6 +26,7 @@ import { ZERO_BYTES32, RekeyPhase, } from "@/hooks/useDataRoom"; +import { isFheChain } from "@/lib/fhe"; import { useStoracha } from "@/hooks/useStoracha"; import { CopyableAddress } from "@/components/CopyableAddress"; import { DocumentRow } from "./DocumentRow"; @@ -47,6 +49,7 @@ function getUploadButtonText( export function FolderPanel({ folderId }: { folderId: bigint }) { const { address } = useAccount(); + const queryClient = useQueryClient(); const { data: folderData } = useRoom(folderId); const isOwner = @@ -55,7 +58,7 @@ export function FolderPanel({ folderId }: { folderId: bigint }) { const { addDocuments, isPending: isAddingDoc, isConfirming: isConfirmingDoc } = useAddDocuments(); const { grantAccess, isPending: isGranting, isConfirming: isConfirmingGrant } = useGrantAccess(); - const { revokeAccess, isPending: isRevoking } = useRevokeAccess(); + const { revokeAccess, isPending: isRevoking, isConfirming: isConfirmingRevoke } = useRevokeAccess(); const { rekeyAndRewrap, progress: rekeyProgress } = useRekeyAndRewrap(); const { @@ -67,7 +70,8 @@ export function FolderPanel({ folderId }: { folderId: bigint }) { isReady: storachaReady, } = useStoracha(); - const { data: roomKeyData, error: roomKeyError, isLoading: isLoadingKey } = useRoomKey(folderId); + const [keyRequested, setKeyRequested] = useState(!isFheChain()); + const { data: roomKeyData, error: roomKeyError, isLoading: isLoadingKey } = useRoomKey(folderId, keyRequested); const [newMember, setNewMember] = useState(""); const [selectedFiles, setSelectedFiles] = useState([]); @@ -79,6 +83,8 @@ export function FolderPanel({ folderId }: { folderId: bigint }) { const roomKeyHex = !!roomKeyData && roomKeyData !== ZERO_BYTES32 ? roomKeyData : null; const hasAccess = !!roomKeyHex || isOwner; + const showDecryptPrompt = isFheChain() && !keyRequested; + const showDecrypting = isFheChain() && keyRequested && isLoadingKey && !roomKeyData; if (!folderData) { return

Loading folder...

; @@ -169,7 +175,53 @@ export function FolderPanel({ folderId }: { folderId: bigint }) {
- {!hasAccess && ( + {showDecryptPrompt && ( +
+ +

Folder is Encrypted

+

+ Sign a message with your wallet to decrypt the folder and access documents. +

+ +
+ )} + + {showDecrypting && ( +
+ +

Decrypting Folder

+

+ Please sign the message in your wallet... +

+
+ )} + + {!showDecryptPrompt && !showDecrypting && roomKeyError && ( +
+ +

Decryption Failed

+

+ {roomKeyError.message} +

+ +
+ )} + + {!showDecryptPrompt && !showDecrypting && !roomKeyError && !hasAccess && (

Access Restricted

@@ -179,7 +231,7 @@ export function FolderPanel({ folderId }: { folderId: bigint }) {
)} - {hasAccess && ( + {!showDecryptPrompt && !showDecrypting && hasAccess && (
{/* Documents */}
@@ -239,20 +291,6 @@ export function FolderPanel({ folderId }: { folderId: bigint }) { )} - {isLoadingKey && ( -
- - Decrypting folder key... -
- )} - - {roomKeyError && ( -
- - Failed to decrypt folder key: {roomKeyError.message} -
- )} - {uploadError && (
@@ -308,6 +346,16 @@ export function FolderPanel({ folderId }: { folderId: bigint }) {
+ {(isGranting || isConfirmingGrant || isRevoking || isConfirmingRevoke) && ( +
+ + {isGranting && "Granting access..."} + {!isGranting && isConfirmingGrant && "Confirming grant..."} + {isRevoking && "Revoking access..."} + {!isRevoking && isConfirmingRevoke && "Confirming revoke..."} +
+ )} +
{members.map((member: string) => (
@@ -320,7 +368,7 @@ export function FolderPanel({ folderId }: { folderId: bigint }) { {member.toLowerCase() !== folderData.owner.toLowerCase() && (
)} @@ -233,7 +238,6 @@ export function FolderPanel({ folderId }: { folderId: bigint }) { {!showDecryptPrompt && !showDecrypting && hasAccess && (
- {/* Documents */}

@@ -319,7 +323,6 @@ export function FolderPanel({ folderId }: { folderId: bigint }) { )}

- {/* Access Group */}

diff --git a/dapp/src/pages/Room/index.tsx b/dapp/src/pages/Room/index.tsx index b06f8fb..8649c50 100644 --- a/dapp/src/pages/Room/index.tsx +++ b/dapp/src/pages/Room/index.tsx @@ -15,7 +15,7 @@ import { useCreateFolder, useGrantAccessToAllFolders, useRevokeAccessFromAllFolders, -} from "@/hooks/useDataRoom"; +} from "@/hooks/dataroom"; import { CopyableAddress } from "@/components/CopyableAddress"; import { SafeIcon } from "./components/SafeIcon"; import { FolderCard } from "./components/FolderCard"; From e581a6717e573098e8f6d0cd1ca7f1787962bbea Mon Sep 17 00:00:00 2001 From: "Petros G. Sideris" Date: Tue, 10 Mar 2026 16:05:39 +0000 Subject: [PATCH 06/11] Update contracts with helpful view function --- contracts/src/EncryptedDataRoom.sol | 18 +- contracts/src/MockEncryptedDataRoom.sol | 18 +- dapp/src/components/Layout.tsx | 2 +- dapp/src/contracts/EncryptedDataRoom.json | 658 ++++++++++++++++++ .../hooks/dataroom/useAccessibleFolders.ts | 6 +- .../hooks/dataroom/useHasAccessToAnyFolder.ts | 6 +- .../src/pages/Room/components/FolderPanel.tsx | 7 +- dapp/src/types/EncryptedDataRoom.ts | 25 + .../factories/EncryptedDataRoom__factory.ts | 41 +- 9 files changed, 764 insertions(+), 17 deletions(-) create mode 100644 dapp/src/contracts/EncryptedDataRoom.json diff --git a/contracts/src/EncryptedDataRoom.sol b/contracts/src/EncryptedDataRoom.sol index 48b2e67..07557ec 100644 --- a/contracts/src/EncryptedDataRoom.sol +++ b/contracts/src/EncryptedDataRoom.sol @@ -302,6 +302,12 @@ contract EncryptedDataRoom is ZamaEthereumConfig { // Views + /// @notice Check if the caller has access to a folder + /// @param roomId The folder to check access for. + function hasAccess(uint256 roomId) external view returns (bool) { + return _isMember[roomId][msg.sender]; + } + /// @notice Get your encrypted access flag for a folder. /// @param roomId The folder to check access for. function validateAccess(uint256 roomId) external view returns (ebool) { @@ -327,7 +333,7 @@ contract EncryptedDataRoom is ZamaEthereumConfig { return (doc.cid, doc.name, doc.createdAt, documentKeyVersion[roomId][docIndex], doc.wrappedKey); } - /// @notice Get room/folder info. + /// @notice Get room/folder info. memberCount reflects only active (non-revoked) members. /// @param roomId The room or folder to query. function getRoom(uint256 roomId) external @@ -343,8 +349,14 @@ contract EncryptedDataRoom is ZamaEthereumConfig { ) { Room storage room = rooms[roomId]; - return - (room.owner, room.name, room.documentCount, room.memberCount, room.isParent, room.parentId, room.childCount); + uint256 active = 0; + uint256 total = room.memberCount; + for (uint256 i = 0; i < total; i++) { + if (_isMember[roomId][_members[roomId][i]]) { + active++; + } + } + return (room.owner, room.name, room.documentCount, active, room.isParent, room.parentId, room.childCount); } /// @notice Get all folder IDs under a parent room. diff --git a/contracts/src/MockEncryptedDataRoom.sol b/contracts/src/MockEncryptedDataRoom.sol index 31e8a03..3a076dd 100644 --- a/contracts/src/MockEncryptedDataRoom.sol +++ b/contracts/src/MockEncryptedDataRoom.sol @@ -257,6 +257,12 @@ contract MockEncryptedDataRoom { // ─── Views + /// @notice Check if the caller has access to a folder + /// @param roomId The folder to check access for. + function hasAccess(uint256 roomId) external view returns (bool) { + return _isMember[roomId][msg.sender]; + } + /// @notice Check your access flag for a folder. /// @param roomId The folder to check access for. function validateAccess(uint256 roomId) external view returns (bytes32) { @@ -282,7 +288,7 @@ contract MockEncryptedDataRoom { return (doc.cid, doc.name, doc.createdAt, documentKeyVersion[roomId][docIndex], doc.wrappedKey); } - /// @notice Get room/folder info. + /// @notice Get room/folder info. memberCount reflects only active (non-revoked) members. /// @param roomId The room or folder to query. function getRoom(uint256 roomId) external @@ -298,8 +304,14 @@ contract MockEncryptedDataRoom { ) { Room storage room = rooms[roomId]; - return - (room.owner, room.name, room.documentCount, room.memberCount, room.isParent, room.parentId, room.childCount); + uint256 active = 0; + uint256 total = room.memberCount; + for (uint256 i = 0; i < total; i++) { + if (_isMember[roomId][_members[roomId][i]]) { + active++; + } + } + return (room.owner, room.name, room.documentCount, active, room.isParent, room.parentId, room.childCount); } /// @notice Get all folder IDs under a parent room. diff --git a/dapp/src/components/Layout.tsx b/dapp/src/components/Layout.tsx index 829750a..0de890d 100644 --- a/dapp/src/components/Layout.tsx +++ b/dapp/src/components/Layout.tsx @@ -22,7 +22,7 @@ export default function Layout() { to="/investor" className="text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)]" > - Investor View + Shared with me diff --git a/dapp/src/contracts/EncryptedDataRoom.json b/dapp/src/contracts/EncryptedDataRoom.json new file mode 100644 index 0000000..e58dfd5 --- /dev/null +++ b/dapp/src/contracts/EncryptedDataRoom.json @@ -0,0 +1,658 @@ +[ + { + "type": "function", + "name": "NO_PARENT", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "addDocuments", + "inputs": [ + { + "name": "roomId", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "cids", + "type": "string[]", + "internalType": "string[]" + }, + { + "name": "names", + "type": "string[]", + "internalType": "string[]" + }, + { + "name": "wrappedKeys", + "type": "bytes[]", + "internalType": "bytes[]" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "confidentialProtocolId", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "createFolder", + "inputs": [ + { + "name": "parentId", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "name", + "type": "string", + "internalType": "string" + } + ], + "outputs": [ + { + "name": "roomId", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "createRoom", + "inputs": [ + { + "name": "name", + "type": "string", + "internalType": "string" + } + ], + "outputs": [ + { + "name": "roomId", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "documentKeyVersion", + "inputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getDocument", + "inputs": [ + { + "name": "roomId", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "docIndex", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "cid", + "type": "string", + "internalType": "string" + }, + { + "name": "name", + "type": "string", + "internalType": "string" + }, + { + "name": "createdAt", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "keyVersion", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "wrappedKey", + "type": "bytes", + "internalType": "bytes" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getFolders", + "inputs": [ + { + "name": "parentId", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "", + "type": "uint256[]", + "internalType": "uint256[]" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getMembers", + "inputs": [ + { + "name": "roomId", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "", + "type": "address[]", + "internalType": "address[]" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getParentRoom", + "inputs": [ + { + "name": "roomId", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getRoom", + "inputs": [ + { + "name": "roomId", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "owner", + "type": "address", + "internalType": "address" + }, + { + "name": "name", + "type": "string", + "internalType": "string" + }, + { + "name": "documentCount", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "memberCount", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "isParent", + "type": "bool", + "internalType": "bool" + }, + { + "name": "parentId", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "childCount", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getRoomKey", + "inputs": [ + { + "name": "roomId", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "euint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "grantAccess", + "inputs": [ + { + "name": "roomId", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "users", + "type": "address[]", + "internalType": "address[]" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "grantAccessToAllFolders", + "inputs": [ + { + "name": "parentId", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "user", + "type": "address", + "internalType": "address" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "hasAccess", + "inputs": [ + { + "name": "roomId", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "rekeyRoom", + "inputs": [ + { + "name": "roomId", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "revokeAccess", + "inputs": [ + { + "name": "roomId", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "users", + "type": "address[]", + "internalType": "address[]" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "revokeAccessFromAllFolders", + "inputs": [ + { + "name": "parentId", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "user", + "type": "address", + "internalType": "address" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "roomCount", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "roomKeyVersion", + "inputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "rooms", + "inputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "owner", + "type": "address", + "internalType": "address" + }, + { + "name": "name", + "type": "string", + "internalType": "string" + }, + { + "name": "documentCount", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "memberCount", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "isParent", + "type": "bool", + "internalType": "bool" + }, + { + "name": "parentId", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "childCount", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "updateDocumentKeys", + "inputs": [ + { + "name": "roomId", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "docIndices", + "type": "uint256[]", + "internalType": "uint256[]" + }, + { + "name": "newWrappedKeys", + "type": "bytes[]", + "internalType": "bytes[]" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "validateAccess", + "inputs": [ + { + "name": "roomId", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "ebool" + } + ], + "stateMutability": "view" + }, + { + "type": "event", + "name": "DocumentAdded", + "inputs": [ + { + "name": "roomId", + "type": "uint256", + "indexed": true, + "internalType": "uint256" + }, + { + "name": "docIndex", + "type": "uint256", + "indexed": true, + "internalType": "uint256" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "FolderCreated", + "inputs": [ + { + "name": "parentId", + "type": "uint256", + "indexed": true, + "internalType": "uint256" + }, + { + "name": "roomId", + "type": "uint256", + "indexed": true, + "internalType": "uint256" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "MembershipChanged", + "inputs": [ + { + "name": "roomId", + "type": "uint256", + "indexed": true, + "internalType": "uint256" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "RoomCreated", + "inputs": [ + { + "name": "roomId", + "type": "uint256", + "indexed": true, + "internalType": "uint256" + }, + { + "name": "owner", + "type": "address", + "indexed": true, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "RoomRekeyed", + "inputs": [ + { + "name": "roomId", + "type": "uint256", + "indexed": true, + "internalType": "uint256" + }, + { + "name": "newVersion", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + } + ], + "anonymous": false + }, + { + "type": "error", + "name": "AlreadyMember", + "inputs": [] + }, + { + "type": "error", + "name": "CannotNestDeeper", + "inputs": [] + }, + { + "type": "error", + "name": "IsParentRoom", + "inputs": [] + }, + { + "type": "error", + "name": "LengthMismatch", + "inputs": [] + }, + { + "type": "error", + "name": "NotMember", + "inputs": [] + }, + { + "type": "error", + "name": "NotParentRoom", + "inputs": [] + }, + { + "type": "error", + "name": "NotRoomOwner", + "inputs": [] + }, + { + "type": "error", + "name": "RoomNotFound", + "inputs": [] + }, + { + "type": "error", + "name": "Unauthorized", + "inputs": [] + }, + { + "type": "error", + "name": "ZamaProtocolUnsupported", + "inputs": [] + } +] \ No newline at end of file diff --git a/dapp/src/hooks/dataroom/useAccessibleFolders.ts b/dapp/src/hooks/dataroom/useAccessibleFolders.ts index 0e04f2f..bd46f00 100644 --- a/dapp/src/hooks/dataroom/useAccessibleFolders.ts +++ b/dapp/src/hooks/dataroom/useAccessibleFolders.ts @@ -1,5 +1,5 @@ import { useQuery } from "@tanstack/react-query"; -import { getSignerContract, useEthersSigner, CHAIN_ID, ZERO_BYTES32 } from "./shared"; +import { getSignerContract, useEthersSigner, CHAIN_ID } from "./shared"; export function useAccessibleFolders(parentId: bigint | undefined, isOwner: boolean) { const signerPromise = useEthersSigner({ chainId: CHAIN_ID }); @@ -9,8 +9,8 @@ export function useAccessibleFolders(parentId: bigint | undefined, isOwner: bool const contract = await getSignerContract(signerPromise!); const folderIds = await contract.getFolders(parentId!); if (isOwner || folderIds.length === 0) return folderIds; - const flags = await Promise.all(folderIds.map((fId) => contract.validateAccess(fId))); - return folderIds.filter((_, i) => flags[i] !== ZERO_BYTES32); + const flags = await Promise.all(folderIds.map((fId) => contract.hasAccess(fId))); + return folderIds.filter((_, i) => flags[i]); }, enabled: !!signerPromise && parentId !== undefined, structuralSharing: false, diff --git a/dapp/src/hooks/dataroom/useHasAccessToAnyFolder.ts b/dapp/src/hooks/dataroom/useHasAccessToAnyFolder.ts index 75d2e92..419826f 100644 --- a/dapp/src/hooks/dataroom/useHasAccessToAnyFolder.ts +++ b/dapp/src/hooks/dataroom/useHasAccessToAnyFolder.ts @@ -1,5 +1,5 @@ import { useQuery } from "@tanstack/react-query"; -import { getSignerContract, useEthersSigner, CHAIN_ID, ZERO_BYTES32 } from "./shared"; +import { getSignerContract, useEthersSigner, CHAIN_ID } from "./shared"; export function useHasAccessToAnyFolder(parentId: bigint | undefined) { const signerPromise = useEthersSigner({ chainId: CHAIN_ID }); @@ -9,8 +9,8 @@ export function useHasAccessToAnyFolder(parentId: bigint | undefined) { const contract = await getSignerContract(signerPromise!); const folderIds = await contract.getFolders(parentId!); if (folderIds.length === 0) return false; - const flags = await Promise.all(folderIds.map((fId) => contract.validateAccess(fId))); - return flags.some((flag) => flag !== ZERO_BYTES32); + const flags = await Promise.all(folderIds.map((fId) => contract.hasAccess(fId))); + return flags.some((flag) => flag); }, enabled: !!signerPromise && parentId !== undefined, }); diff --git a/dapp/src/pages/Room/components/FolderPanel.tsx b/dapp/src/pages/Room/components/FolderPanel.tsx index 56c3a40..0f0068d 100644 --- a/dapp/src/pages/Room/components/FolderPanel.tsx +++ b/dapp/src/pages/Room/components/FolderPanel.tsx @@ -212,10 +212,13 @@ export function FolderPanel({ folderId }: { folderId: bigint }) {

Decryption Failed

- {roomKeyError.message} + {roomKeyError.message?.includes("user rejected") || roomKeyError.message?.includes("ACTION_REJECTED") + ? "Signature request was rejected. Please try again to access this folder." + : "Could not decrypt the folder key. You may not have access, or the network may be unavailable."}

)} - {!showDecryptPrompt && !showDecrypting && hasAccess && ( + {!showDecryptPrompt && !showDecrypting && !roomKeyError && hasAccess && (
diff --git a/dapp/src/types/EncryptedDataRoom.ts b/dapp/src/types/EncryptedDataRoom.ts index 3380074..55a6517 100644 --- a/dapp/src/types/EncryptedDataRoom.ts +++ b/dapp/src/types/EncryptedDataRoom.ts @@ -28,6 +28,7 @@ export interface EncryptedDataRoomInterface extends Interface { nameOrSignature: | "NO_PARENT" | "addDocuments" + | "confidentialProtocolId" | "createFolder" | "createRoom" | "documentKeyVersion" @@ -39,6 +40,7 @@ export interface EncryptedDataRoomInterface extends Interface { | "getRoomKey" | "grantAccess" | "grantAccessToAllFolders" + | "hasAccess" | "rekeyRoom" | "revokeAccess" | "revokeAccessFromAllFolders" @@ -63,6 +65,10 @@ export interface EncryptedDataRoomInterface extends Interface { functionFragment: "addDocuments", values: [BigNumberish, string[], string[], BytesLike[]] ): string; + encodeFunctionData( + functionFragment: "confidentialProtocolId", + values?: undefined + ): string; encodeFunctionData( functionFragment: "createFolder", values: [BigNumberish, string] @@ -104,6 +110,10 @@ export interface EncryptedDataRoomInterface extends Interface { functionFragment: "grantAccessToAllFolders", values: [BigNumberish, AddressLike] ): string; + encodeFunctionData( + functionFragment: "hasAccess", + values: [BigNumberish] + ): string; encodeFunctionData( functionFragment: "rekeyRoom", values: [BigNumberish] @@ -136,6 +146,10 @@ export interface EncryptedDataRoomInterface extends Interface { functionFragment: "addDocuments", data: BytesLike ): Result; + decodeFunctionResult( + functionFragment: "confidentialProtocolId", + data: BytesLike + ): Result; decodeFunctionResult( functionFragment: "createFolder", data: BytesLike @@ -165,6 +179,7 @@ export interface EncryptedDataRoomInterface extends Interface { functionFragment: "grantAccessToAllFolders", data: BytesLike ): Result; + decodeFunctionResult(functionFragment: "hasAccess", data: BytesLike): Result; decodeFunctionResult(functionFragment: "rekeyRoom", data: BytesLike): Result; decodeFunctionResult( functionFragment: "revokeAccess", @@ -310,6 +325,8 @@ export interface EncryptedDataRoom extends BaseContract { "nonpayable" >; + confidentialProtocolId: TypedContractMethod<[], [bigint], "view">; + createFolder: TypedContractMethod< [parentId: BigNumberish, name: string], [bigint], @@ -374,6 +391,8 @@ export interface EncryptedDataRoom extends BaseContract { "nonpayable" >; + hasAccess: TypedContractMethod<[roomId: BigNumberish], [boolean], "view">; + rekeyRoom: TypedContractMethod<[roomId: BigNumberish], [void], "nonpayable">; revokeAccess: TypedContractMethod< @@ -439,6 +458,9 @@ export interface EncryptedDataRoom extends BaseContract { [void], "nonpayable" >; + getFunction( + nameOrSignature: "confidentialProtocolId" + ): TypedContractMethod<[], [bigint], "view">; getFunction( nameOrSignature: "createFolder" ): TypedContractMethod< @@ -514,6 +536,9 @@ export interface EncryptedDataRoom extends BaseContract { [void], "nonpayable" >; + getFunction( + nameOrSignature: "hasAccess" + ): TypedContractMethod<[roomId: BigNumberish], [boolean], "view">; getFunction( nameOrSignature: "rekeyRoom" ): TypedContractMethod<[roomId: BigNumberish], [void], "nonpayable">; diff --git a/dapp/src/types/factories/EncryptedDataRoom__factory.ts b/dapp/src/types/factories/EncryptedDataRoom__factory.ts index 543d22c..88f6928 100644 --- a/dapp/src/types/factories/EncryptedDataRoom__factory.ts +++ b/dapp/src/types/factories/EncryptedDataRoom__factory.ts @@ -50,6 +50,19 @@ const _abi = [ outputs: [], stateMutability: "nonpayable", }, + { + type: "function", + name: "confidentialProtocolId", + inputs: [], + outputs: [ + { + name: "", + type: "uint256", + internalType: "uint256", + }, + ], + stateMutability: "view", + }, { type: "function", name: "createFolder", @@ -281,7 +294,7 @@ const _abi = [ { name: "", type: "bytes32", - internalType: "bytes32", + internalType: "euint256", }, ], stateMutability: "view", @@ -322,6 +335,25 @@ const _abi = [ outputs: [], stateMutability: "nonpayable", }, + { + type: "function", + name: "hasAccess", + inputs: [ + { + name: "roomId", + type: "uint256", + internalType: "uint256", + }, + ], + outputs: [ + { + name: "", + type: "bool", + internalType: "bool", + }, + ], + stateMutability: "view", + }, { type: "function", name: "rekeyRoom", @@ -489,7 +521,7 @@ const _abi = [ { name: "", type: "bytes32", - internalType: "bytes32", + internalType: "ebool", }, ], stateMutability: "view", @@ -628,6 +660,11 @@ const _abi = [ name: "Unauthorized", inputs: [], }, + { + type: "error", + name: "ZamaProtocolUnsupported", + inputs: [], + }, ] as const; export class EncryptedDataRoom__factory { From 4a22a3bcb6b60cb5bce0b3065a8d65315828e3f6 Mon Sep 17 00:00:00 2001 From: "Petros G. Sideris" Date: Tue, 10 Mar 2026 20:22:12 +0000 Subject: [PATCH 07/11] Add Faucet and update views --- contracts/src/EncryptedDataRoom.sol | 23 +++- contracts/src/MockEncryptedDataRoom.sol | 22 +++- contracts/test/EncryptedDataRoom.fhe.ts | 114 ++++++++++++++++++- contracts/test/MockEncryptedDataRoom.t.sol | 124 +++++++++++++++++++++ dapp/src/components/Layout.tsx | 47 ++++++++ 5 files changed, 316 insertions(+), 14 deletions(-) diff --git a/contracts/src/EncryptedDataRoom.sol b/contracts/src/EncryptedDataRoom.sol index 07557ec..abc4457 100644 --- a/contracts/src/EncryptedDataRoom.sol +++ b/contracts/src/EncryptedDataRoom.sol @@ -194,8 +194,16 @@ contract EncryptedDataRoom is ZamaEthereumConfig { address user = users[i]; if (_isMember[roomId][user]) revert AlreadyMember(); - uint256 idx = rooms[roomId].memberCount++; - _members[roomId][idx] = user; + // Only allocate a new slot if user was never in this folder before + bool existingSlot = false; + uint256 count = rooms[roomId].memberCount; + for (uint256 j = 0; j < count; j++) { + if (_members[roomId][j] == user) existingSlot = true; + break; + } + if (!existingSlot) { + _members[roomId][rooms[roomId].memberCount++] = user; + } _isMember[roomId][user] = true; FHE.allow(_roomKey[roomId], user); @@ -241,8 +249,15 @@ contract EncryptedDataRoom is ZamaEthereumConfig { uint256 roomId = _children[parentId][i]; if (_isMember[roomId][user]) continue; - uint256 idx = rooms[roomId].memberCount++; - _members[roomId][idx] = user; + bool existingSlot = false; + uint256 count = rooms[roomId].memberCount; + for (uint256 j = 0; j < count; j++) { + if (_members[roomId][j] == user) existingSlot = true; + break; + } + if (!existingSlot) { + _members[roomId][rooms[roomId].memberCount++] = user; + } _isMember[roomId][user] = true; FHE.allow(_roomKey[roomId], user); diff --git a/contracts/src/MockEncryptedDataRoom.sol b/contracts/src/MockEncryptedDataRoom.sol index 3a076dd..25712a1 100644 --- a/contracts/src/MockEncryptedDataRoom.sol +++ b/contracts/src/MockEncryptedDataRoom.sol @@ -180,8 +180,15 @@ contract MockEncryptedDataRoom { address user = users[i]; if (_isMember[roomId][user]) revert AlreadyMember(); - uint256 idx = rooms[roomId].memberCount++; - _members[roomId][idx] = user; + bool existingSlot = false; + uint256 count = rooms[roomId].memberCount; + for (uint256 j = 0; j < count; j++) { + if (_members[roomId][j] == user) existingSlot = true; + break; + } + if (!existingSlot) { + _members[roomId][rooms[roomId].memberCount++] = user; + } _isMember[roomId][user] = true; _access[roomId][user] = true; } @@ -217,8 +224,15 @@ contract MockEncryptedDataRoom { uint256 roomId = _children[parentId][i]; if (_isMember[roomId][user]) continue; - uint256 idx = rooms[roomId].memberCount++; - _members[roomId][idx] = user; + bool existingSlot = false; + uint256 count = rooms[roomId].memberCount; + for (uint256 j = 0; j < count; j++) { + if (_members[roomId][j] == user) existingSlot = true; + break; + } + if (!existingSlot) { + _members[roomId][rooms[roomId].memberCount++] = user; + } _isMember[roomId][user] = true; _access[roomId][user] = true; diff --git a/contracts/test/EncryptedDataRoom.fhe.ts b/contracts/test/EncryptedDataRoom.fhe.ts index fee509f..2b0c842 100644 --- a/contracts/test/EncryptedDataRoom.fhe.ts +++ b/contracts/test/EncryptedDataRoom.fhe.ts @@ -4,7 +4,6 @@ import hre from "hardhat"; const { ethers, fhevm } = hre; -/** Mock wrapped key (60 bytes: 12 IV + 32 ciphertext + 16 tag). */ function mockWrappedKey(seed: number): string { return ethers.hexlify(ethers.randomBytes(60)); } @@ -188,12 +187,12 @@ describe("contract EncryptedDataRoom", function () { .to.emit(room, "MembershipChanged") .withArgs(folderId); - // member can decrypt access flag + // can decrypt access flag const encryptedAccess = await room.connect(member).validateAccess(folderId); const decryptedAccess = await fhevm.userDecryptEbool(encryptedAccess, roomAddress, member); expect(decryptedAccess).to.equal(true); - // member can decrypt folder key + // can decrypt folder key const encryptedKey = await room.connect(member).getRoomKey(folderId); const decryptedKey = await fhevm.userDecryptEuint( FhevmType.euint256, @@ -268,7 +267,6 @@ describe("contract EncryptedDataRoom", function () { const { owner, member, outsider, room, folderId } = await roomWithFolderFixture(); await room.connect(owner).grantAccess(folderId, [member.address]); - // outsider is valid, member is duplicate : should revert await expect(room.connect(owner).grantAccess(folderId, [outsider.address, member.address])) .to.be.revertedWithCustomError(room, "AlreadyMember"); @@ -299,7 +297,6 @@ describe("contract EncryptedDataRoom", function () { await room.connect(owner).grantAccessToAllFolders(parentId, member.address); - // Check member is in all 3 folders for (const fId of [1n, 2n, 3n]) { const members = await room.connect(owner).getMembers(fId); expect(members.length).to.equal(2); @@ -525,7 +522,6 @@ describe("contract EncryptedDataRoom", function () { const doc1 = await room.getDocument(folderId, 1n); expect(doc1.keyVersion).to.equal(1n); - // first doc still has old version const doc0After = await room.getDocument(folderId, 0n); expect(doc0After.keyVersion).to.equal(0n); }); @@ -614,6 +610,112 @@ describe("contract EncryptedDataRoom", function () { }); }); + describe("hasAccess()", function () { + it("returns true for members and false for non-members", async function () { + const { owner, member, outsider, room, folderId } = await roomWithFolderFixture(); + + // owner has access + expect(await room.connect(owner).hasAccess(folderId)).to.equal(true); + + // non-member does not + expect(await room.connect(outsider).hasAccess(folderId)).to.equal(false); + + // grant + await room.connect(owner).grantAccess(folderId, [member.address]); + expect(await room.connect(member).hasAccess(folderId)).to.equal(true); + + // revoke + await room.connect(owner).revokeAccess(folderId, [member.address]); + expect(await room.connect(member).hasAccess(folderId)).to.equal(false); + }); + }); + + describe("re-grant after revoke (H-1 fix)", function () { + it("re-granting a revoked member does not create duplicate entries", async function () { + const { owner, member, room, folderId } = await roomWithFolderFixture(); + const roomAddress = await room.getAddress(); + + // Grant + await room.connect(owner).grantAccess(folderId, [member.address]); + expect((await room.connect(owner).getMembers(folderId)).length).to.equal(2); + + // Revoke + await room.connect(owner).revokeAccess(folderId, [member.address]); + expect((await room.connect(owner).getMembers(folderId)).length).to.equal(1); + + // Re-grant: reuse existing slot + await room.connect(owner).grantAccess(folderId, [member.address]); + + const members = await room.connect(owner).getMembers(folderId); + expect(members.length).to.equal(2); // owner + member, no duplicate + + const info = await room.getRoom(folderId); + expect(info.memberCount).to.equal(2n); + + expect(await room.connect(member).hasAccess(folderId)).to.equal(true); + + // member can still decrypt folder key + const encryptedKey = await room.connect(member).getRoomKey(folderId); + const decryptedKey = await fhevm.userDecryptEuint( + FhevmType.euint256, + encryptedKey, + roomAddress, + member, + ); + expect(decryptedKey).to.not.equal(0n); + }); + + it("multiple revoke/re-grant cycles produce correct counts", async function () { + const { owner, member, room, folderId } = await roomWithFolderFixture(); + + for (let cycle = 0; cycle < 3; cycle++) { + await room.connect(owner).grantAccess(folderId, [member.address]); + + const members = await room.connect(owner).getMembers(folderId); + expect(members.length).to.equal(2, `cycle ${cycle}: members after grant`); + + const info = await room.getRoom(folderId); + expect(info.memberCount).to.equal(2n, `cycle ${cycle}: memberCount after grant`); + + expect(await room.connect(member).hasAccess(folderId)).to.equal(true); + + await room.connect(owner).revokeAccess(folderId, [member.address]); + + const membersAfter = await room.connect(owner).getMembers(folderId); + expect(membersAfter.length).to.equal(1, `cycle ${cycle}: members after revoke`); + + expect(await room.connect(member).hasAccess(folderId)).to.equal(false); + } + }); + + it("grantAccessToAllFolders re-grant after revoke produces no duplicates", async function () { + const { owner, member, room, parentId } = await roomWithFolderFixture(); + await room.connect(owner).createFolder(parentId, "Financials"); + + await room.connect(owner).grantAccessToAllFolders(parentId, member.address); + for (const fId of [1n, 2n]) { + expect((await room.connect(owner).getMembers(fId)).length).to.equal(2); + } + + await room.connect(owner).revokeAccessFromAllFolders(parentId, member.address); + for (const fId of [1n, 2n]) { + expect((await room.connect(owner).getMembers(fId)).length).to.equal(1); + } + + // Re-grant == no duplicates + await room.connect(owner).grantAccessToAllFolders(parentId, member.address); + for (const fId of [1n, 2n]) { + const members = await room.connect(owner).getMembers(fId); + expect(members.length).to.equal(2); + + const info = await room.getRoom(fId); + expect(info.memberCount).to.equal(2n); + + expect(await room.connect(member).hasAccess(fId)).to.equal(true); + } + }); + }); + describe("CRITICAL PATH: Key Isolation", function () { it("each folder gets a different FHE key", async function () { const { owner, room, parentId } = await roomWithFolderFixture(); diff --git a/contracts/test/MockEncryptedDataRoom.t.sol b/contracts/test/MockEncryptedDataRoom.t.sol index 1ec613c..6d17348 100644 --- a/contracts/test/MockEncryptedDataRoom.t.sol +++ b/contracts/test/MockEncryptedDataRoom.t.sol @@ -568,4 +568,128 @@ contract MockEncryptedDataRoomTest is Test { assertEq(kv0, 1); // bumped to current version assertEq(kv1, 1); } + + // ─── hasAccess view ─────────────────────────────────────── + + function test_hasAccess() public { + dr.createRoom("P"); + uint256 fId = dr.createFolder(0, "HasAccess"); + + // owner has access + assertTrue(dr.hasAccess(fId)); + + // non-member has no access + vm.prank(user); + assertFalse(dr.hasAccess(fId)); + + // grant then check + dr.grantAccess(fId, _toArray(user)); + vm.prank(user); + assertTrue(dr.hasAccess(fId)); + + // revoke then check + dr.revokeAccess(fId, _toArray(user)); + vm.prank(user); + assertFalse(dr.hasAccess(fId)); + } + + // ─── Re-grant after revoke (H-1 fix) ───────────────────── + + function test_grantAccess_reGrantAfterRevoke_noDuplicate() public { + dr.createRoom("P"); + uint256 fId = dr.createFolder(0, "ReGrant"); + + // Grant user + dr.grantAccess(fId, _toArray(user)); + assertEq(dr.getMembers(fId).length, 2); // owner + user + + // Revoke user + dr.revokeAccess(fId, _toArray(user)); + assertEq(dr.getMembers(fId).length, 1); // owner only + + // Re-grant user — should reuse existing slot, not create a duplicate + dr.grantAccess(fId, _toArray(user)); + + address[] memory members = dr.getMembers(fId); + assertEq(members.length, 2); // owner + user (no duplicate) + assertEq(members[0], owner); + assertEq(members[1], user); + + // getRoom memberCount should also be 2 + (,,, uint256 memberCount,,,) = dr.getRoom(fId); + assertEq(memberCount, 2); + + // hasAccess should be true + vm.prank(user); + assertTrue(dr.hasAccess(fId)); + + // user can get key + vm.prank(user); + bytes32 key = dr.getRoomKey(fId); + assertNotEq(key, bytes32(0)); + } + + function test_grantAccess_multipleRevokeCycles() public { + dr.createRoom("P"); + uint256 fId = dr.createFolder(0, "Cycles"); + + // 3 cycles of grant/revoke/re-grant + for (uint256 cycle = 0; cycle < 3; cycle++) { + dr.grantAccess(fId, _toArray(user)); + + address[] memory members = dr.getMembers(fId); + assertEq(members.length, 2, "members should be 2 after grant"); + + (,,, uint256 memberCount,,,) = dr.getRoom(fId); + assertEq(memberCount, 2, "getRoom memberCount should be 2"); + + vm.prank(user); + assertTrue(dr.hasAccess(fId)); + + dr.revokeAccess(fId, _toArray(user)); + + members = dr.getMembers(fId); + assertEq(members.length, 1, "members should be 1 after revoke"); + + (,,, memberCount,,,) = dr.getRoom(fId); + assertEq(memberCount, 1, "getRoom memberCount should be 1"); + + vm.prank(user); + assertFalse(dr.hasAccess(fId)); + } + } + + function test_grantAccessToAllFolders_reGrantAfterRevoke_noDuplicate() public { + dr.createRoom("P"); + uint256 f1 = dr.createFolder(0, "Legal"); + uint256 f2 = dr.createFolder(0, "Fin"); + + // Grant to all folders + dr.grantAccessToAllFolders(0, user); + assertEq(dr.getMembers(f1).length, 2); + assertEq(dr.getMembers(f2).length, 2); + + // Revoke from all folders + dr.revokeAccessFromAllFolders(0, user); + assertEq(dr.getMembers(f1).length, 1); + assertEq(dr.getMembers(f2).length, 1); + + // Re-grant to all folders — no duplicates + dr.grantAccessToAllFolders(0, user); + + assertEq(dr.getMembers(f1).length, 2); + assertEq(dr.getMembers(f2).length, 2); + + // Verify getRoom memberCount is correct + (,,, uint256 mc1,,,) = dr.getRoom(f1); + (,,, uint256 mc2,,,) = dr.getRoom(f2); + assertEq(mc1, 2); + assertEq(mc2, 2); + + // User has access to both + vm.prank(user); + assertTrue(dr.hasAccess(f1)); + vm.prank(user); + assertTrue(dr.hasAccess(f2)); + } } diff --git a/dapp/src/components/Layout.tsx b/dapp/src/components/Layout.tsx index 0de890d..b6e18c0 100644 --- a/dapp/src/components/Layout.tsx +++ b/dapp/src/components/Layout.tsx @@ -1,8 +1,54 @@ +import { useState } from "react"; import { Outlet, Link } from "react-router-dom"; import { ConnectButton } from "@rainbow-me/rainbowkit"; +import { useChainId } from "wagmi"; +import { sepolia } from "wagmi/chains"; import ThemeToggle from "./ThemeToggle"; import { BrandMark } from "./BrandMark"; +const FAUCETS = [ + { name: "Google Cloud", url: "https://cloud.google.com/application/web3/faucet/ethereum/sepolia" }, + { name: "Alchemy", url: "https://www.alchemy.com/faucets/ethereum-sepolia" }, + { name: "Infura", url: "https://www.infura.io/faucet/sepolia" }, +]; + +function SepoliaFaucetBanner() { + const chainId = useChainId(); + const [dismissed, setDismissed] = useState(false); + + if (chainId !== sepolia.id || dismissed) return null; + + return ( +
+

+ Sepolia testnet + + Need gas? + {FAUCETS.map((f, i) => ( + + {i > 0 && · } + + {f.name} + + + ))} +

+ +
+ ); +} + export default function Layout() { return (
@@ -78,6 +124,7 @@ export default function Layout() { +
From 084bf00b7bd7f5c95402eb61219aa253059ac925 Mon Sep 17 00:00:00 2001 From: "Petros G. Sideris" Date: Tue, 10 Mar 2026 21:17:12 +0000 Subject: [PATCH 08/11] Add documentation --- README.md | 17 ++-- contracts/src/EncryptedDataRoom.sol | 27 ++++- contracts/src/MockEncryptedDataRoom.sol | 27 ++++- contracts/test/EncryptedDataRoom.fhe.ts | 74 ++++++++++++++ contracts/test/MockEncryptedDataRoom.t.sol | 60 ++++++++++++ dapp/src/contracts/EncryptedDataRoom.json | 10 ++ dapp/src/lib/crypto.ts | 1 + dapp/src/lib/fhe.ts | 5 +- docs/ADRs/001-fhe-access-control.md | 20 ++++ docs/ADRs/002-per-document-cek-wrapping.md | 27 +++++ docs/ADRs/003-room-folder-hierarchy.md | 29 ++++++ docs/ADRs/004-skip-hkdf-key-derivation.md | 35 +++++++ docs/ADRs/005-relayer-sdk-cdn-loading.md | 26 +++++ docs/ADRs/006-single-owner-model.md | 20 ++++ docs/ARCHITECTURE.md | 109 +++++++++++++++++++++ docs/FEATURES.md | 54 +++++++++- 16 files changed, 520 insertions(+), 21 deletions(-) create mode 100644 docs/ADRs/001-fhe-access-control.md create mode 100644 docs/ADRs/002-per-document-cek-wrapping.md create mode 100644 docs/ADRs/003-room-folder-hierarchy.md create mode 100644 docs/ADRs/004-skip-hkdf-key-derivation.md create mode 100644 docs/ADRs/005-relayer-sdk-cdn-loading.md create mode 100644 docs/ADRs/006-single-owner-model.md create mode 100644 docs/ARCHITECTURE.md diff --git a/README.md b/README.md index baba345..d843faf 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,14 @@ # Encrypted Dataroom Management (eDRM) -A trustless, encrypted data room where access is enforced on-chain via FHE (Fully Homomorphic Encryption). Nobody can see who has access to what. Documents live on Filecoin via Storacha. +Trustless, encrypted filesharing where access is enforced via FHE (Fully Homomorphic Encryption). Nobody can see who has access to what. Documents live on Filecoin via Storacha. ## The Problem Data rooms are essential to every investment deal, yet the market (\$3-4B TAM, led by Intralinks and Datasite) charges \$15-50k+ per deal for what is fundamentally access control on a file share. -Often we need to rely on trust assumptions of the vendors and the members that operate on these data rooms. +Often we need to rely on trust assumptions of the vendors and the members that operate on these data rooms. + +**Alternatively, it's a way to securely share documents with your friends that no-one else can access!** ## The Solution @@ -37,7 +39,9 @@ For the full encryption flow, key hierarchy, and contract interface, see [Techni ## Documentation - [Technical Architecture](docs/ARCHITECTURE.md): encryption flow, key hierarchy, storage model, privacy guarantees +- [Design Notes](DESIGN_NOTES.md): current gaps, proposed features, and open questions - [Feature Set](docs/FEATURES.md): planned and shipped features +- [Architecture Decision Records](docs/ADRs/): technical decisions and trade-offs ## Getting Started @@ -80,12 +84,12 @@ This runs: 3. **TypeChain**: pulls ABIs from the build artifacts and generates typed bindings 4. **Vite**: starts the dapp dev server -#### Storacha setup (file storage) -If it's too much effort to connect storacha ask for keys from: petros@obolos.io +#### Storacha setup +If it's too much effort to setup Storacha ask for keys from: petros@obolos.io The dapp encrypts files client-side and uploads them to Filecoin via [Storacha](https://storacha.network). -You need an agent key and a delegation proof: +Make an agent key and a delegation proof: ```bash # 1. Install the CLI @@ -119,8 +123,7 @@ VITE_STORACHA_PROOF=mAY… #### Chain / RPC (optional) ```env -# 31337 = Anvil (default), 11155111 = Sepolia, 1 = Mainnet -VITE_CHAIN_ID=31337 +VITE_CHAIN_ID=31337 # anvil, 11155111 = Sepolia VITE_RPC_URL=http://127.0.0.1:8545 ``` diff --git a/contracts/src/EncryptedDataRoom.sol b/contracts/src/EncryptedDataRoom.sol index abc4457..deefc64 100644 --- a/contracts/src/EncryptedDataRoom.sol +++ b/contracts/src/EncryptedDataRoom.sol @@ -19,6 +19,8 @@ contract EncryptedDataRoom is ZamaEthereumConfig { error NotParentRoom(); error IsParentRoom(); error CannotNestDeeper(); + error BatchTooLarge(); + error InvalidAddress(); // Types struct Room { @@ -39,6 +41,7 @@ contract EncryptedDataRoom is ZamaEthereumConfig { } uint256 public constant NO_PARENT = type(uint256).max; + uint256 public constant MAX_BATCH_SIZE = 100; uint256 public roomCount; mapping(uint256 => Room) public rooms; mapping(uint256 => mapping(uint256 => Document)) internal _documents; @@ -74,6 +77,11 @@ contract EncryptedDataRoom is ZamaEthereumConfig { _; } + modifier roomExists(uint256 roomId) { + if (rooms[roomId].owner == address(0)) revert RoomNotFound(); + _; + } + // Room Management /// @notice Create a new parent room @@ -153,6 +161,7 @@ contract EncryptedDataRoom is ZamaEthereumConfig { notParentRoom(roomId) { if (cids.length != names.length || cids.length != wrappedKeys.length) revert LengthMismatch(); + if (cids.length > MAX_BATCH_SIZE) revert BatchTooLarge(); for (uint256 i = 0; i < cids.length; i++) { uint256 docIndex = rooms[roomId].documentCount++; _documents[roomId][docIndex] = @@ -174,6 +183,7 @@ contract EncryptedDataRoom is ZamaEthereumConfig { notParentRoom(roomId) { if (docIndices.length != newWrappedKeys.length) revert LengthMismatch(); + if (docIndices.length > MAX_BATCH_SIZE) revert BatchTooLarge(); for (uint256 i = 0; i < docIndices.length; i++) { _documents[roomId][docIndices[i]].wrappedKey = newWrappedKeys[i]; documentKeyVersion[roomId][docIndices[i]] = roomKeyVersion[roomId]; @@ -190,8 +200,10 @@ contract EncryptedDataRoom is ZamaEthereumConfig { onlyRoomOwner(roomId) notParentRoom(roomId) { + if (users.length > MAX_BATCH_SIZE) revert BatchTooLarge(); for (uint256 i = 0; i < users.length; i++) { address user = users[i]; + if (user == address(0)) revert InvalidAddress(); if (_isMember[roomId][user]) revert AlreadyMember(); // Only allocate a new slot if user was never in this folder before @@ -224,6 +236,7 @@ contract EncryptedDataRoom is ZamaEthereumConfig { onlyRoomOwner(roomId) notParentRoom(roomId) { + if (users.length > MAX_BATCH_SIZE) revert BatchTooLarge(); for (uint256 i = 0; i < users.length; i++) { address user = users[i]; if (!_isMember[roomId][user]) revert NotMember(); @@ -242,6 +255,7 @@ contract EncryptedDataRoom is ZamaEthereumConfig { /// @param parentId The parent room ID. /// @param user Address of the user to grant access. function grantAccessToAllFolders(uint256 parentId, address user) external onlyRoomOwner(parentId) { + if (user == address(0)) revert InvalidAddress(); Room storage parent = rooms[parentId]; if (!parent.isParent) revert NotParentRoom(); @@ -319,19 +333,19 @@ contract EncryptedDataRoom is ZamaEthereumConfig { /// @notice Check if the caller has access to a folder /// @param roomId The folder to check access for. - function hasAccess(uint256 roomId) external view returns (bool) { + function hasAccess(uint256 roomId) external view roomExists(roomId) returns (bool) { return _isMember[roomId][msg.sender]; } /// @notice Get your encrypted access flag for a folder. /// @param roomId The folder to check access for. - function validateAccess(uint256 roomId) external view returns (ebool) { + function validateAccess(uint256 roomId) external view roomExists(roomId) returns (ebool) { return _access[roomId][msg.sender]; } /// @notice Get the encrypted folder key handle. Only decryptable if granted FHE access. /// @param roomId The folder to get the key for. - function getRoomKey(uint256 roomId) external view notParentRoom(roomId) returns (euint256) { + function getRoomKey(uint256 roomId) external view roomExists(roomId) notParentRoom(roomId) returns (euint256) { if (!_isMember[roomId][msg.sender]) revert Unauthorized(); return _roomKey[roomId]; } @@ -344,6 +358,7 @@ contract EncryptedDataRoom is ZamaEthereumConfig { view returns (string memory cid, string memory name, uint256 createdAt, uint256 keyVersion, bytes memory wrappedKey) { + if (rooms[roomId].owner == address(0)) revert RoomNotFound(); Document storage doc = _documents[roomId][docIndex]; return (doc.cid, doc.name, doc.createdAt, documentKeyVersion[roomId][docIndex], doc.wrappedKey); } @@ -363,6 +378,7 @@ contract EncryptedDataRoom is ZamaEthereumConfig { uint256 childCount ) { + if (rooms[roomId].owner == address(0)) revert RoomNotFound(); Room storage room = rooms[roomId]; uint256 active = 0; uint256 total = room.memberCount; @@ -376,7 +392,7 @@ contract EncryptedDataRoom is ZamaEthereumConfig { /// @notice Get all folder IDs under a parent room. /// @param parentId The parent room ID. - function getFolders(uint256 parentId) external view returns (uint256[] memory) { + function getFolders(uint256 parentId) external view roomExists(parentId) returns (uint256[] memory) { Room storage parent = rooms[parentId]; if (!parent.isParent) revert NotParentRoom(); @@ -389,13 +405,14 @@ contract EncryptedDataRoom is ZamaEthereumConfig { /// @notice Get the parent room ID for a folder. /// @param roomId The folder ID. - function getParentRoom(uint256 roomId) external view returns (uint256) { + function getParentRoom(uint256 roomId) external view roomExists(roomId) returns (uint256) { return rooms[roomId].parentId; } /// @notice Get active members of a folder. /// @param roomId The folder to query. function getMembers(uint256 roomId) external view returns (address[] memory) { + if (rooms[roomId].owner == address(0)) revert RoomNotFound(); if (rooms[roomId].owner != msg.sender) revert Unauthorized(); uint256 count = rooms[roomId].memberCount; diff --git a/contracts/src/MockEncryptedDataRoom.sol b/contracts/src/MockEncryptedDataRoom.sol index 25712a1..4c51589 100644 --- a/contracts/src/MockEncryptedDataRoom.sol +++ b/contracts/src/MockEncryptedDataRoom.sol @@ -14,6 +14,8 @@ contract MockEncryptedDataRoom { error NotParentRoom(); error IsParentRoom(); error CannotNestDeeper(); + error BatchTooLarge(); + error InvalidAddress(); struct Room { address owner; @@ -33,6 +35,7 @@ contract MockEncryptedDataRoom { } uint256 public constant NO_PARENT = type(uint256).max; + uint256 public constant MAX_BATCH_SIZE = 100; uint256 public roomCount; @@ -67,6 +70,11 @@ contract MockEncryptedDataRoom { _; } + modifier roomExists(uint256 roomId) { + if (rooms[roomId].owner == address(0)) revert RoomNotFound(); + _; + } + // ─── Room Management /// @notice Create a new parent room (deal-level container). No key, no members, no documents. @@ -139,6 +147,7 @@ contract MockEncryptedDataRoom { notParentRoom(roomId) { if (cids.length != names.length || cids.length != wrappedKeys.length) revert LengthMismatch(); + if (cids.length > MAX_BATCH_SIZE) revert BatchTooLarge(); for (uint256 i = 0; i < cids.length; i++) { uint256 docIndex = rooms[roomId].documentCount++; _documents[roomId][docIndex] = @@ -160,6 +169,7 @@ contract MockEncryptedDataRoom { notParentRoom(roomId) { if (docIndices.length != newWrappedKeys.length) revert LengthMismatch(); + if (docIndices.length > MAX_BATCH_SIZE) revert BatchTooLarge(); for (uint256 i = 0; i < docIndices.length; i++) { _documents[roomId][docIndices[i]].wrappedKey = newWrappedKeys[i]; documentKeyVersion[roomId][docIndices[i]] = roomKeyVersion[roomId]; @@ -176,8 +186,10 @@ contract MockEncryptedDataRoom { onlyRoomOwner(roomId) notParentRoom(roomId) { + if (users.length > MAX_BATCH_SIZE) revert BatchTooLarge(); for (uint256 i = 0; i < users.length; i++) { address user = users[i]; + if (user == address(0)) revert InvalidAddress(); if (_isMember[roomId][user]) revert AlreadyMember(); bool existingSlot = false; @@ -203,6 +215,7 @@ contract MockEncryptedDataRoom { onlyRoomOwner(roomId) notParentRoom(roomId) { + if (users.length > MAX_BATCH_SIZE) revert BatchTooLarge(); for (uint256 i = 0; i < users.length; i++) { address user = users[i]; if (!_isMember[roomId][user]) revert NotMember(); @@ -217,6 +230,7 @@ contract MockEncryptedDataRoom { /// @param parentId The parent room ID. /// @param user Address of the user to grant access. function grantAccessToAllFolders(uint256 parentId, address user) external onlyRoomOwner(parentId) { + if (user == address(0)) revert InvalidAddress(); Room storage parent = rooms[parentId]; if (!parent.isParent) revert NotParentRoom(); @@ -273,19 +287,19 @@ contract MockEncryptedDataRoom { /// @notice Check if the caller has access to a folder /// @param roomId The folder to check access for. - function hasAccess(uint256 roomId) external view returns (bool) { + function hasAccess(uint256 roomId) external view roomExists(roomId) returns (bool) { return _isMember[roomId][msg.sender]; } /// @notice Check your access flag for a folder. /// @param roomId The folder to check access for. - function validateAccess(uint256 roomId) external view returns (bytes32) { + function validateAccess(uint256 roomId) external view roomExists(roomId) returns (bytes32) { return _access[roomId][msg.sender] ? bytes32(uint256(1)) : bytes32(0); } /// @notice Get the folder key. Only accessible to members. /// @param roomId The folder to get the key for. - function getRoomKey(uint256 roomId) external view notParentRoom(roomId) returns (bytes32) { + function getRoomKey(uint256 roomId) external view roomExists(roomId) notParentRoom(roomId) returns (bytes32) { if (!_access[roomId][msg.sender]) revert Unauthorized(); return _roomKey[roomId]; } @@ -298,6 +312,7 @@ contract MockEncryptedDataRoom { view returns (string memory cid, string memory name, uint256 createdAt, uint256 keyVersion, bytes memory wrappedKey) { + if (rooms[roomId].owner == address(0)) revert RoomNotFound(); Document storage doc = _documents[roomId][docIndex]; return (doc.cid, doc.name, doc.createdAt, documentKeyVersion[roomId][docIndex], doc.wrappedKey); } @@ -317,6 +332,7 @@ contract MockEncryptedDataRoom { uint256 childCount ) { + if (rooms[roomId].owner == address(0)) revert RoomNotFound(); Room storage room = rooms[roomId]; uint256 active = 0; uint256 total = room.memberCount; @@ -330,7 +346,7 @@ contract MockEncryptedDataRoom { /// @notice Get all folder IDs under a parent room. /// @param parentId The parent room ID. - function getFolders(uint256 parentId) external view returns (uint256[] memory) { + function getFolders(uint256 parentId) external view roomExists(parentId) returns (uint256[] memory) { Room storage parent = rooms[parentId]; if (!parent.isParent) revert NotParentRoom(); @@ -343,13 +359,14 @@ contract MockEncryptedDataRoom { /// @notice Get the parent room ID for a folder. /// @param roomId The folder ID. - function getParentRoom(uint256 roomId) external view returns (uint256) { + function getParentRoom(uint256 roomId) external view roomExists(roomId) returns (uint256) { return rooms[roomId].parentId; } /// @notice Get active members of a folder. /// @param roomId The folder to query. function getMembers(uint256 roomId) external view returns (address[] memory) { + if (rooms[roomId].owner == address(0)) revert RoomNotFound(); if (rooms[roomId].owner != msg.sender) revert Unauthorized(); uint256 count = rooms[roomId].memberCount; diff --git a/contracts/test/EncryptedDataRoom.fhe.ts b/contracts/test/EncryptedDataRoom.fhe.ts index 2b0c842..89cf34b 100644 --- a/contracts/test/EncryptedDataRoom.fhe.ts +++ b/contracts/test/EncryptedDataRoom.fhe.ts @@ -738,4 +738,78 @@ describe("contract EncryptedDataRoom", function () { expect(key1).to.not.equal(key2); }); }); + + describe("M-1: room existence checks", function () { + it("getRoom reverts for non-existent room", async function () { + const { room } = await deployFixture(); + + await expect(room.getRoom(999n)) + .to.be.revertedWithCustomError(room, "RoomNotFound"); + }); + + it("getDocument reverts for non-existent room", async function () { + const { room } = await deployFixture(); + + await expect(room.getDocument(999n, 0n)) + .to.be.revertedWithCustomError(room, "RoomNotFound"); + }); + + it("hasAccess reverts for non-existent room", async function () { + const { room } = await deployFixture(); + + await expect(room.hasAccess(999n)) + .to.be.revertedWithCustomError(room, "RoomNotFound"); + }); + + it("validateAccess reverts for non-existent room", async function () { + const { room } = await deployFixture(); + + await expect(room.validateAccess(999n)) + .to.be.revertedWithCustomError(room, "RoomNotFound"); + }); + + it("getRoomKey reverts for non-existent room", async function () { + const { room } = await deployFixture(); + + await expect(room.getRoomKey(999n)) + .to.be.revertedWithCustomError(room, "RoomNotFound"); + }); + + it("getFolders reverts for non-existent room", async function () { + const { room } = await deployFixture(); + + await expect(room.getFolders(999n)) + .to.be.revertedWithCustomError(room, "RoomNotFound"); + }); + + it("getParentRoom reverts for non-existent room", async function () { + const { room } = await deployFixture(); + + await expect(room.getParentRoom(999n)) + .to.be.revertedWithCustomError(room, "RoomNotFound"); + }); + + it("getMembers reverts for non-existent room", async function () { + const { room } = await deployFixture(); + + await expect(room.getMembers(999n)) + .to.be.revertedWithCustomError(room, "RoomNotFound"); + }); + }); + + describe("M-3: address(0) rejected", function () { + it("grantAccess rejects address(0)", async function () { + const { owner, room, folderId } = await roomWithFolderFixture(); + + await expect(room.connect(owner).grantAccess(folderId, [ethers.ZeroAddress])) + .to.be.revertedWithCustomError(room, "InvalidAddress"); + }); + + it("grantAccessToAllFolders rejects address(0)", async function () { + const { owner, room, parentId } = await roomWithFolderFixture(); + + await expect(room.connect(owner).grantAccessToAllFolders(parentId, ethers.ZeroAddress)) + .to.be.revertedWithCustomError(room, "InvalidAddress"); + }); + }); }); diff --git a/contracts/test/MockEncryptedDataRoom.t.sol b/contracts/test/MockEncryptedDataRoom.t.sol index 6d17348..bbcbbbb 100644 --- a/contracts/test/MockEncryptedDataRoom.t.sol +++ b/contracts/test/MockEncryptedDataRoom.t.sol @@ -692,4 +692,64 @@ contract MockEncryptedDataRoomTest is Test { vm.prank(user); assertTrue(dr.hasAccess(f2)); } + + // ─── M-1: Room existence checks ────────────────────────── + + function test_getRoom_nonExistent_reverts() public { + vm.expectRevert(MockEncryptedDataRoom.RoomNotFound.selector); + dr.getRoom(999); + } + + function test_getDocument_nonExistent_reverts() public { + vm.expectRevert(MockEncryptedDataRoom.RoomNotFound.selector); + dr.getDocument(999, 0); + } + + function test_hasAccess_nonExistent_reverts() public { + vm.expectRevert(MockEncryptedDataRoom.RoomNotFound.selector); + dr.hasAccess(999); + } + + function test_validateAccess_nonExistent_reverts() public { + vm.expectRevert(MockEncryptedDataRoom.RoomNotFound.selector); + dr.validateAccess(999); + } + + function test_getRoomKey_nonExistent_reverts() public { + vm.expectRevert(MockEncryptedDataRoom.RoomNotFound.selector); + dr.getRoomKey(999); + } + + function test_getFolders_nonExistent_reverts() public { + vm.expectRevert(MockEncryptedDataRoom.RoomNotFound.selector); + dr.getFolders(999); + } + + function test_getParentRoom_nonExistent_reverts() public { + vm.expectRevert(MockEncryptedDataRoom.RoomNotFound.selector); + dr.getParentRoom(999); + } + + function test_getMembers_nonExistent_reverts() public { + vm.expectRevert(MockEncryptedDataRoom.RoomNotFound.selector); + dr.getMembers(999); + } + + // ─── M-3: address(0) rejected ──────────────────────────── + + function test_grantAccess_addressZero_reverts() public { + dr.createRoom("P"); + dr.createFolder(0, "F"); + + vm.expectRevert(MockEncryptedDataRoom.InvalidAddress.selector); + dr.grantAccess(1, _toArray(address(0))); + } + + function test_grantAccessToAllFolders_addressZero_reverts() public { + dr.createRoom("P"); + dr.createFolder(0, "F"); + + vm.expectRevert(MockEncryptedDataRoom.InvalidAddress.selector); + dr.grantAccessToAllFolders(0, address(0)); + } } diff --git a/dapp/src/contracts/EncryptedDataRoom.json b/dapp/src/contracts/EncryptedDataRoom.json index e58dfd5..d28f514 100644 --- a/dapp/src/contracts/EncryptedDataRoom.json +++ b/dapp/src/contracts/EncryptedDataRoom.json @@ -610,11 +610,21 @@ "name": "AlreadyMember", "inputs": [] }, + { + "type": "error", + "name": "BatchTooLarge", + "inputs": [] + }, { "type": "error", "name": "CannotNestDeeper", "inputs": [] }, + { + "type": "error", + "name": "InvalidAddress", + "inputs": [] + }, { "type": "error", "name": "IsParentRoom", diff --git a/dapp/src/lib/crypto.ts b/dapp/src/lib/crypto.ts index f2ffc30..4ba6760 100644 --- a/dapp/src/lib/crypto.ts +++ b/dapp/src/lib/crypto.ts @@ -88,6 +88,7 @@ export async function wrapKey(cek: Uint8Array, wrappingKey: CryptoKey): Promise< /** Unwrap (decrypt) a wrapped CEK. Input is IV‖ciphertext‖tag. */ export async function unwrapKey(wrapped: Uint8Array, wrappingKey: CryptoKey): Promise { + if (wrapped.length < IV_BYTES + 16) throw new Error("Wrapped key too short"); const iv = wrapped.slice(0, IV_BYTES); const ciphertext = wrapped.slice(IV_BYTES); const plaintext = await crypto.subtle.decrypt({ name: ALGO, iv }, wrappingKey, ciphertext); diff --git a/dapp/src/lib/fhe.ts b/dapp/src/lib/fhe.ts index ada1a0f..bd4360e 100644 --- a/dapp/src/lib/fhe.ts +++ b/dapp/src/lib/fhe.ts @@ -5,14 +5,13 @@ export function isFheChain(): boolean { return CHAIN_ID !== 31337; } -// Load the relayer SDK UMD bundle from Zama's CDN. This handles WASM init internally -const SDK_CDN_URL = "https://cdn.zama.org/relayer-sdk-js/0.4.2/relayer-sdk-js.umd.cjs"; - const rpcUrls: Record = { [11155111]: import.meta.env.VITE_RPC_URL || "https://ethereum-sepolia-rpc.publicnode.com", [1]: import.meta.env.VITE_RPC_URL || "https://eth.llamarpc.com", }; +// Load the relayer SDK UMD bundle from Zama's CDN. This handles WASM init internally +const SDK_CDN_URL = "https://cdn.zama.org/relayer-sdk-js/0.4.2/relayer-sdk-js.umd.cjs"; function loadSDKScript(): Promise { return new Promise((resolve, reject) => { const w = window as unknown as Record; diff --git a/docs/ADRs/001-fhe-access-control.md b/docs/ADRs/001-fhe-access-control.md new file mode 100644 index 0000000..7093f27 --- /dev/null +++ b/docs/ADRs/001-fhe-access-control.md @@ -0,0 +1,20 @@ +# ADR-001: Use FHE for On-Chain Access Control + +**Date**: 2026-03-08 + +**Status**: Accepted + +## Context + +We need a mechanism to control who can decrypt documents in the data room. The key management must be trustless — no centralized server or intermediary should be able to grant or revoke access. + +Options considered: + +1. **Zama (FHE)**: Room key stored as `euint256` in contract storage. Only addresses granted `FHE.allow()` can decrypt. The key never exists in plaintext on-chain. +2. **Lit Protocol**: Skip. clashes with our current solution. One more integration concern. +3. **MPC / Shamir**: Off-chain key sharing among participants. Requires coordination infrastructure. +4. **Pure client-side**: Walkaway test. + +## Decision + +Use Zama. The room key lives as an FHE-encrypted `euint256` in the smart contract. `FHE.allow(key, user)` grants decryption rights at the EVM level. Users decrypt via the Zama Relayer using EIP-712 signed requests. diff --git a/docs/ADRs/002-per-document-cek-wrapping.md b/docs/ADRs/002-per-document-cek-wrapping.md new file mode 100644 index 0000000..78ac573 --- /dev/null +++ b/docs/ADRs/002-per-document-cek-wrapping.md @@ -0,0 +1,27 @@ +# ADR-002: Per-Document CEK Wrapping + +**Date**: 2026-03-08 + +**Status**: Accepted + +## Context + +When a member is revoked, the room key must rotate so the revoked user cannot decrypt future content. The question is how to handle existing encrypted documents during key rotation. + +**Option A: Re-encrypt blobs** — Download each encrypted document from Storacha, decrypt with the old key, re-encrypt with the new key, re-upload. O(n) Storacha round-trips. + +## Decision +Each document is encrypted with its own random Content Encryption Key (CEK). The CEK is then wrapped (encrypted) with the room key and stored on-chain. On rekey, only the wrapped CEKs need re-wrapping. Blobs never change. + +Upload flow: +``` +file -> [AES-GCM + random CEK] -> encrypted blob -> Storacha (CID) +CEK -> [AES-GCM + room key] -> wrappedKey -> on-chain +``` + +Rekey flow: +``` +for each doc: unwrap CEK (old key) -> re-wrap CEK (new key) +batch updateDocumentKeys() on-chain +blobs on Storacha are never touched +``` diff --git a/docs/ADRs/003-room-folder-hierarchy.md b/docs/ADRs/003-room-folder-hierarchy.md new file mode 100644 index 0000000..dda1106 --- /dev/null +++ b/docs/ADRs/003-room-folder-hierarchy.md @@ -0,0 +1,29 @@ +# ADR-003: Room/Folder Two-Level Hierarchy + +**Date**: 2026-03-08 + +**Status**: Accepted + +## Context + +Data rooms in due diligence typically have organizational structure: a deal (room) contains folders like "Legal", "Financials", "IP", each with different access permissions. Some investors see legal docs but not financials. + + + A "parent room" is a deal-level container with no key/members/docs. "Folders" are children of a parent room, each with their own key, members, and documents. Max one level of nesting. + +## Decision + +Two-level hierarchy. Parent rooms are organizational containers. Folders hold the actual keys, members, and documents. + +- `createRoom("Series A")` -> parent (id 0) +- `createFolder(0, "Legal")` -> folder (id 1) with its own FHE key +- `createFolder(0, "Financials")` -> folder (id 2) with its own FHE key +- `grantAccessToAllFolders(0, investor)` -> bulk grant across all folders + +## Consequences + +- Simple model that maps well to real-world data room structure +- Per-folder key isolation: revoking from "Legal" doesn't affect "Financials" +- `grantAccessToAllFolders` / `revokeAccessFromAllFolders` for convenience +- No deep nesting complexity (max 1 level enforced by `CannotNestDeeper`) +- Owner is always the parent room creator — no per-folder ownership delegation diff --git a/docs/ADRs/004-skip-hkdf-key-derivation.md b/docs/ADRs/004-skip-hkdf-key-derivation.md new file mode 100644 index 0000000..a9961d1 --- /dev/null +++ b/docs/ADRs/004-skip-hkdf-key-derivation.md @@ -0,0 +1,35 @@ +# ADR-004: Skip HKDF Key Derivation (Accept Raw AES Key Import) + +**Date**: 2026-03-09 +**Status**: Accepted (known limitation) + +## Context + +The 32-byte FHE-decrypted room key is imported directly as an AES-256-GCM key via `crypto.subtle.importKey('raw', ...)`. Cryptographic best practice says raw key material should go through a Key Derivation Function (HKDF) with domain-separated info strings before use. + +The proper approach: +```typescript +const baseKey = await crypto.subtle.importKey('raw', roomKeyBytes, 'HKDF', false, ['deriveKey']); +return crypto.subtle.deriveKey( + { name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(32), info: encode(purpose) }, + baseKey, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt'] +); +``` + +## Decision + +Skip HKDF for the hackathon. Use the raw FHE-decrypted bytes directly as the AES key. + +## Rationale + +- The room key is only used for one purpose: wrapping per-document CEKs via AES-GCM +- Each wrapping operation uses a unique random 12-byte IV from `crypto.getRandomValues()` +- AES-256-GCM with random IVs is secure for up to ~2^32 operations per key (we'll never approach this) +- Adding HKDF would require a migration path for existing wrapped keys on-chain +- Practical attack surface is zero given our usage pattern + +## Consequences + +- Violates crypto hygiene in principle but not in practice +- Documented in security audit as H-2 (low practical risk) +- If the product goes to production, HKDF should be added with a versioned key derivation scheme diff --git a/docs/ADRs/005-relayer-sdk-cdn-loading.md b/docs/ADRs/005-relayer-sdk-cdn-loading.md new file mode 100644 index 0000000..3cf2a4c --- /dev/null +++ b/docs/ADRs/005-relayer-sdk-cdn-loading.md @@ -0,0 +1,26 @@ +# ADR-005: Load Zama Relayer SDK via CDN Script Injection + +**Date**: 2026-03-09 + +**Status**: Accepted as a workaround + +## Context + +The `@zama-fhe/relayer-sdk` npm package (v0.4.2) does not work as a standard ESM import in Vite/React apps: + +1. The package bundles WASM internally and is distributed as a UMD module only +2. Vite's `optimizeDeps` fails to pre-bundle it — requires `exclude: ["@zama-fhe/relayer-sdk"]` +3. Direct `import` fails at build time even with the exclusion +4. No TypeScript type declarations (`.d.ts`) are shipped — everything is `any` + +## Decision + +Load the UMD bundle at runtime via `