diff --git a/README.md b/README.md
index baba345..e6883e9 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
@@ -38,6 +40,7 @@ For the full encryption flow, key hierarchy, and contract interface, see [Techni
- [Technical Architecture](docs/ARCHITECTURE.md): encryption flow, key hierarchy, storage model, privacy guarantees
- [Feature Set](docs/FEATURES.md): planned and shipped features
+- [Architecture Decision Records](docs/ADRs/): technical decisions and trade-offs
## Getting Started
@@ -80,12 +83,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 +122,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 48b2e67..1cbc4e7 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,12 +200,24 @@ 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();
- 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);
@@ -216,6 +238,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();
@@ -234,6 +257,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();
@@ -241,8 +265,17 @@ 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);
@@ -302,15 +335,21 @@ 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 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];
}
@@ -323,11 +362,12 @@ 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);
}
- /// @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
@@ -342,14 +382,21 @@ contract EncryptedDataRoom is ZamaEthereumConfig {
uint256 childCount
)
{
+ if (rooms[roomId].owner == address(0)) revert RoomNotFound();
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.
/// @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();
@@ -362,13 +409,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 31e8a03..d8a853a 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,12 +186,23 @@ 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();
- 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;
}
@@ -196,6 +217,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();
@@ -210,6 +232,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();
@@ -217,8 +240,17 @@ 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;
@@ -257,15 +289,21 @@ 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 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];
}
@@ -278,11 +316,12 @@ 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);
}
- /// @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
@@ -297,14 +336,21 @@ contract MockEncryptedDataRoom {
uint256 childCount
)
{
+ if (rooms[roomId].owner == address(0)) revert RoomNotFound();
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.
/// @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();
@@ -317,13 +363,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 fee509f..89cf34b 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();
@@ -636,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 1ec613c..bbcbbbb 100644
--- a/contracts/test/MockEncryptedDataRoom.t.sol
+++ b/contracts/test/MockEncryptedDataRoom.t.sol
@@ -568,4 +568,188 @@ 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));
+ }
+
+ // ─── 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/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/components/Layout.tsx b/dapp/src/components/Layout.tsx
index 829750a..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}
+
+
+ ))}
+
+
setDismissed(true)}
+ className="ml-4 text-[var(--muted-foreground)] hover:text-[var(--foreground)] text-lg leading-none"
+ aria-label="Dismiss"
+ >
+ ×
+
+
+ );
+}
+
export default function Layout() {
return (
@@ -22,7 +68,7 @@ export default function Layout() {
to="/investor"
className="text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
>
- Investor View
+ Shared with me
@@ -78,6 +124,7 @@ export default function Layout() {
+
diff --git a/dapp/src/contracts/EncryptedDataRoom.json b/dapp/src/contracts/EncryptedDataRoom.json
new file mode 100644
index 0000000..d28f514
--- /dev/null
+++ b/dapp/src/contracts/EncryptedDataRoom.json
@@ -0,0 +1,668 @@
+[
+ {
+ "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": "BatchTooLarge",
+ "inputs": []
+ },
+ {
+ "type": "error",
+ "name": "CannotNestDeeper",
+ "inputs": []
+ },
+ {
+ "type": "error",
+ "name": "InvalidAddress",
+ "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/index.ts b/dapp/src/hooks/dataroom/index.ts
new file mode 100644
index 0000000..b6dbbf5
--- /dev/null
+++ b/dapp/src/hooks/dataroom/index.ts
@@ -0,0 +1,17 @@
+export { ZERO_BYTES32 } from "./shared";
+export { useRoomCount } from "./useRoomCount";
+export { useRoom } from "./useRoom";
+export { useAccessibleFolders } from "./useAccessibleFolders";
+export { useDocument } from "./useDocument";
+export { useRoomMembers } from "./useRoomMembers";
+export { useRoomKey, useRoomKeyHandle } from "./useRoomKey";
+export { useHasAccessToAnyFolder } from "./useHasAccessToAnyFolder";
+export { useCreateRoom } from "./useCreateRoom";
+export { useCreateFolder } from "./useCreateFolder";
+export { useAddDocuments } from "./useAddDocuments";
+export { useGrantAccess } from "./useGrantAccess";
+export { useRevokeAccess } from "./useRevokeAccess";
+export { useGrantAccessToAllFolders } from "./useGrantAccessToAllFolders";
+export { useRevokeAccessFromAllFolders } from "./useRevokeAccessFromAllFolders";
+export { useRekeyAndRewrap, RekeyPhase } from "./useRekeyAndRewrap";
+export type { RekeyProgress } from "./useRekeyAndRewrap";
diff --git a/dapp/src/hooks/dataroom/shared.ts b/dapp/src/hooks/dataroom/shared.ts
new file mode 100644
index 0000000..08b1e3d
--- /dev/null
+++ b/dapp/src/hooks/dataroom/shared.ts
@@ -0,0 +1,28 @@
+import { useMemo } from "react";
+import type { JsonRpcSigner } from "ethers";
+import { DATAROOM_ADDRESS, CHAIN_ID } from "@/contracts";
+import { EncryptedDataRoom__factory } from "@/types";
+import type { EncryptedDataRoom } from "@/types";
+import { useEthersProvider, useEthersSigner } from "@/lib/ethers-adapter";
+
+export { CHAIN_ID, DATAROOM_ADDRESS };
+export { EncryptedDataRoom__factory };
+export type { EncryptedDataRoom };
+export { useEthersSigner };
+
+export const ZERO_BYTES32 = "0x0000000000000000000000000000000000000000000000000000000000000000";
+
+/** Read-only contract (no wallet needed). */
+export function usePublicContract(): EncryptedDataRoom | null {
+ const provider = useEthersProvider({ chainId: CHAIN_ID });
+ return useMemo(() => {
+ if (!provider) return null;
+ return EncryptedDataRoom__factory.connect(DATAROOM_ADDRESS, provider);
+ }, [provider]);
+}
+
+/** Signer-backed typed contract (needs connected wallet). */
+export async function getSignerContract(signerPromise: Promise
) {
+ const signer = await signerPromise;
+ return EncryptedDataRoom__factory.connect(DATAROOM_ADDRESS, signer);
+}
diff --git a/dapp/src/hooks/dataroom/useAccessibleFolders.ts b/dapp/src/hooks/dataroom/useAccessibleFolders.ts
new file mode 100644
index 0000000..bd46f00
--- /dev/null
+++ b/dapp/src/hooks/dataroom/useAccessibleFolders.ts
@@ -0,0 +1,18 @@
+import { useQuery } from "@tanstack/react-query";
+import { getSignerContract, useEthersSigner, CHAIN_ID } from "./shared";
+
+export function useAccessibleFolders(parentId: bigint | undefined, isOwner: boolean) {
+ const signerPromise = useEthersSigner({ chainId: CHAIN_ID });
+ return useQuery({
+ 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.hasAccess(fId)));
+ return folderIds.filter((_, i) => flags[i]);
+ },
+ enabled: !!signerPromise && parentId !== undefined,
+ structuralSharing: false,
+ });
+}
diff --git a/dapp/src/hooks/dataroom/useAddDocuments.ts b/dapp/src/hooks/dataroom/useAddDocuments.ts
new file mode 100644
index 0000000..f323d11
--- /dev/null
+++ b/dapp/src/hooks/dataroom/useAddDocuments.ts
@@ -0,0 +1,32 @@
+import { useState, useCallback } from "react";
+import { useQueryClient } from "@tanstack/react-query";
+import { getSignerContract, useEthersSigner, CHAIN_ID } from "./shared";
+
+export function useAddDocuments() {
+ const signerPromise = useEthersSigner({ chainId: CHAIN_ID });
+ const queryClient = useQueryClient();
+ const [isPending, setIsPending] = useState(false);
+ const [isConfirming, setIsConfirming] = useState(false);
+ const [error, setError] = useState(null);
+
+ const addDocuments = useCallback(async (roomId: bigint, cids: string[], names: string[], wrappedKeys: string[]) => {
+ if (!signerPromise) return;
+ setIsPending(true);
+ setError(null);
+ try {
+ const contract = await getSignerContract(signerPromise);
+ const tx = await contract.addDocuments(roomId, cids, names, wrappedKeys);
+ setIsPending(false);
+ setIsConfirming(true);
+ await tx.wait();
+ setIsConfirming(false);
+ queryClient.invalidateQueries({ refetchType: "all" });
+ } catch (e) {
+ setIsPending(false);
+ setIsConfirming(false);
+ setError(e as Error);
+ }
+ }, [signerPromise, queryClient]);
+
+ return { addDocuments, isPending, isConfirming, error };
+}
diff --git a/dapp/src/hooks/dataroom/useCreateFolder.ts b/dapp/src/hooks/dataroom/useCreateFolder.ts
new file mode 100644
index 0000000..3c38a5a
--- /dev/null
+++ b/dapp/src/hooks/dataroom/useCreateFolder.ts
@@ -0,0 +1,32 @@
+import { useState, useCallback } from "react";
+import { useQueryClient } from "@tanstack/react-query";
+import { getSignerContract, useEthersSigner, CHAIN_ID } from "./shared";
+
+export function useCreateFolder() {
+ const signerPromise = useEthersSigner({ chainId: CHAIN_ID });
+ const queryClient = useQueryClient();
+ const [isPending, setIsPending] = useState(false);
+ const [isConfirming, setIsConfirming] = useState(false);
+ const [error, setError] = useState(null);
+
+ const createFolder = useCallback(async (parentId: bigint, name: string) => {
+ if (!signerPromise) return;
+ setIsPending(true);
+ setError(null);
+ try {
+ const contract = await getSignerContract(signerPromise);
+ const tx = await contract.createFolder(parentId, name);
+ setIsPending(false);
+ setIsConfirming(true);
+ await tx.wait();
+ setIsConfirming(false);
+ queryClient.invalidateQueries({ refetchType: "all" });
+ } catch (e) {
+ setIsPending(false);
+ setIsConfirming(false);
+ setError(e as Error);
+ }
+ }, [signerPromise, queryClient]);
+
+ return { createFolder, isPending, isConfirming, error };
+}
diff --git a/dapp/src/hooks/dataroom/useCreateRoom.ts b/dapp/src/hooks/dataroom/useCreateRoom.ts
new file mode 100644
index 0000000..0df93ef
--- /dev/null
+++ b/dapp/src/hooks/dataroom/useCreateRoom.ts
@@ -0,0 +1,32 @@
+import { useState, useCallback } from "react";
+import { useQueryClient } from "@tanstack/react-query";
+import { getSignerContract, useEthersSigner, CHAIN_ID } from "./shared";
+
+export function useCreateRoom() {
+ const signerPromise = useEthersSigner({ chainId: CHAIN_ID });
+ const queryClient = useQueryClient();
+ const [isPending, setIsPending] = useState(false);
+ const [isConfirming, setIsConfirming] = useState(false);
+ const [error, setError] = useState(null);
+
+ const createRoom = useCallback(async (name: string) => {
+ if (!signerPromise) return;
+ setIsPending(true);
+ setError(null);
+ try {
+ const contract = await getSignerContract(signerPromise);
+ const tx = await contract.createRoom(name);
+ setIsPending(false);
+ setIsConfirming(true);
+ await tx.wait();
+ setIsConfirming(false);
+ queryClient.invalidateQueries({ refetchType: "all" });
+ } catch (e) {
+ setIsPending(false);
+ setIsConfirming(false);
+ setError(e as Error);
+ }
+ }, [signerPromise, queryClient]);
+
+ return { createRoom, isPending, isConfirming, error };
+}
diff --git a/dapp/src/hooks/dataroom/useDocument.ts b/dapp/src/hooks/dataroom/useDocument.ts
new file mode 100644
index 0000000..33714dd
--- /dev/null
+++ b/dapp/src/hooks/dataroom/useDocument.ts
@@ -0,0 +1,12 @@
+import { useQuery } from "@tanstack/react-query";
+import { usePublicContract } from "./shared";
+
+export function useDocument(roomId: bigint | undefined, docIndex: bigint | undefined) {
+ const contract = usePublicContract();
+ return useQuery({
+ queryKey: ["dataroom", "document", roomId?.toString(), docIndex?.toString()],
+ queryFn: () => contract!.getDocument(roomId!, docIndex!),
+ enabled: !!contract && roomId !== undefined && docIndex !== undefined,
+ structuralSharing: false,
+ });
+}
diff --git a/dapp/src/hooks/dataroom/useGrantAccess.ts b/dapp/src/hooks/dataroom/useGrantAccess.ts
new file mode 100644
index 0000000..2e53aff
--- /dev/null
+++ b/dapp/src/hooks/dataroom/useGrantAccess.ts
@@ -0,0 +1,32 @@
+import { useState, useCallback } from "react";
+import { useQueryClient } from "@tanstack/react-query";
+import { getSignerContract, useEthersSigner, CHAIN_ID } from "./shared";
+
+export function useGrantAccess() {
+ const signerPromise = useEthersSigner({ chainId: CHAIN_ID });
+ const queryClient = useQueryClient();
+ const [isPending, setIsPending] = useState(false);
+ const [isConfirming, setIsConfirming] = useState(false);
+ const [error, setError] = useState(null);
+
+ const grantAccess = useCallback(async (roomId: bigint, user: string) => {
+ if (!signerPromise) return;
+ setIsPending(true);
+ setError(null);
+ try {
+ const contract = await getSignerContract(signerPromise);
+ const tx = await contract.grantAccess(roomId, [user]);
+ setIsPending(false);
+ setIsConfirming(true);
+ await tx.wait();
+ setIsConfirming(false);
+ queryClient.invalidateQueries({ refetchType: "all" });
+ } catch (e) {
+ setIsPending(false);
+ setIsConfirming(false);
+ setError(e as Error);
+ }
+ }, [signerPromise, queryClient]);
+
+ return { grantAccess, isPending, isConfirming, error };
+}
diff --git a/dapp/src/hooks/dataroom/useGrantAccessToAllFolders.ts b/dapp/src/hooks/dataroom/useGrantAccessToAllFolders.ts
new file mode 100644
index 0000000..443362a
--- /dev/null
+++ b/dapp/src/hooks/dataroom/useGrantAccessToAllFolders.ts
@@ -0,0 +1,32 @@
+import { useState, useCallback } from "react";
+import { useQueryClient } from "@tanstack/react-query";
+import { getSignerContract, useEthersSigner, CHAIN_ID } from "./shared";
+
+export function useGrantAccessToAllFolders() {
+ const signerPromise = useEthersSigner({ chainId: CHAIN_ID });
+ const queryClient = useQueryClient();
+ const [isPending, setIsPending] = useState(false);
+ const [isConfirming, setIsConfirming] = useState(false);
+ const [error, setError] = useState(null);
+
+ const grantAccessToAllFolders = useCallback(async (parentId: bigint, user: string) => {
+ if (!signerPromise) return;
+ setIsPending(true);
+ setError(null);
+ try {
+ const contract = await getSignerContract(signerPromise);
+ const tx = await contract.grantAccessToAllFolders(parentId, user);
+ setIsPending(false);
+ setIsConfirming(true);
+ await tx.wait();
+ setIsConfirming(false);
+ queryClient.invalidateQueries({ refetchType: "all" });
+ } catch (e) {
+ setIsPending(false);
+ setIsConfirming(false);
+ setError(e as Error);
+ }
+ }, [signerPromise, queryClient]);
+
+ return { grantAccessToAllFolders, isPending, isConfirming, error };
+}
diff --git a/dapp/src/hooks/dataroom/useHasAccessToAnyFolder.ts b/dapp/src/hooks/dataroom/useHasAccessToAnyFolder.ts
new file mode 100644
index 0000000..419826f
--- /dev/null
+++ b/dapp/src/hooks/dataroom/useHasAccessToAnyFolder.ts
@@ -0,0 +1,17 @@
+import { useQuery } from "@tanstack/react-query";
+import { getSignerContract, useEthersSigner, CHAIN_ID } from "./shared";
+
+export function useHasAccessToAnyFolder(parentId: bigint | undefined) {
+ const signerPromise = useEthersSigner({ chainId: CHAIN_ID });
+ return useQuery({
+ queryKey: ["dataroom", "hasAccessToAnyFolder", parentId?.toString()],
+ queryFn: async () => {
+ 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.hasAccess(fId)));
+ return flags.some((flag) => flag);
+ },
+ enabled: !!signerPromise && parentId !== undefined,
+ });
+}
diff --git a/dapp/src/hooks/dataroom/useRekeyAndRewrap.ts b/dapp/src/hooks/dataroom/useRekeyAndRewrap.ts
new file mode 100644
index 0000000..c3e69b6
--- /dev/null
+++ b/dapp/src/hooks/dataroom/useRekeyAndRewrap.ts
@@ -0,0 +1,98 @@
+import { useState, useCallback } from "react";
+import { useQueryClient } from "@tanstack/react-query";
+import { getSignerContract, useEthersSigner, CHAIN_ID } from "./shared";
+import { deriveAesKey, hexToBytes, unwrapKey, wrapKey, bytesToHex } from "@/lib/crypto";
+import { decryptRoomKey } from "@/lib/fhe";
+
+export const RekeyPhase = {
+ Idle: "idle",
+ Rekeying: "rekeying",
+ Rewrapping: "rewrapping",
+ Updating: "updating",
+ Done: "done",
+ Error: "error",
+} as const;
+
+export type RekeyPhase = (typeof RekeyPhase)[keyof typeof RekeyPhase];
+
+export type RekeyProgress = {
+ phase: RekeyPhase;
+ current: number;
+ total: number;
+ error?: string;
+};
+
+export function useRekeyAndRewrap() {
+ const signerPromise = useEthersSigner({ chainId: CHAIN_ID });
+ const queryClient = useQueryClient();
+ const [progress, setProgress] = useState({
+ phase: RekeyPhase.Idle,
+ current: 0,
+ total: 0,
+ });
+
+ const rekeyAndRewrap = useCallback(
+ async (
+ folderId: bigint,
+ documentCount: number,
+ oldRoomKeyHex: string,
+ ) => {
+ if (!signerPromise) return;
+
+ try {
+ const contract = await getSignerContract(signerPromise);
+
+ // Phase 1: Rekey on-chain
+ setProgress({ phase: RekeyPhase.Rekeying, current: 0, total: 0 });
+ const tx = await contract.rekeyRoom(folderId);
+ await tx.wait();
+
+ // If no documents, we're done
+ if (documentCount === 0) {
+ setProgress({ phase: RekeyPhase.Done, current: 0, total: 0 });
+ queryClient.invalidateQueries({ refetchType: "all" });
+ return;
+ }
+
+ const signer = await signerPromise!;
+ const newKeyHandle = await contract.getRoomKey(folderId);
+ const newKeyHex = await decryptRoomKey(newKeyHandle, signer);
+
+ // Phase 2: Re-wrap all document CEKs locally (no Storacha I/O)
+ setProgress({ phase: RekeyPhase.Rewrapping, current: 0, total: documentCount });
+ const oldWrappingKey = await deriveAesKey(hexToBytes(oldRoomKeyHex));
+ const newWrappingKey = await deriveAesKey(hexToBytes(newKeyHex));
+ const newWrappedKeys: string[] = [];
+
+ for (let i = 0; i < documentCount; i++) {
+ setProgress({ phase: RekeyPhase.Rewrapping, current: i + 1, total: documentCount });
+ const doc = await contract.getDocument(folderId, BigInt(i));
+ const wrappedCek = hexToBytes(doc.wrappedKey);
+ const cek = await unwrapKey(wrappedCek, oldWrappingKey);
+ const newWrapped = await wrapKey(cek, newWrappingKey);
+ newWrappedKeys.push(bytesToHex(newWrapped));
+ }
+
+ // Phase 3: Update all wrapped keys on-chain in a single transaction
+ setProgress({ phase: RekeyPhase.Updating, current: 0, total: documentCount });
+ const docIndices = Array.from({ length: documentCount }, (_, i) => BigInt(i));
+ const updateTx = await contract.updateDocumentKeys(folderId, docIndices, newWrappedKeys);
+ await updateTx.wait();
+ setProgress({ phase: RekeyPhase.Updating, current: documentCount, total: documentCount });
+
+ setProgress({ phase: RekeyPhase.Done, current: documentCount, total: documentCount });
+ queryClient.invalidateQueries({ refetchType: "all" });
+ } catch (e) {
+ const message = e instanceof Error ? e.message : "Unknown error";
+ setProgress((prev) => ({ ...prev, phase: RekeyPhase.Error, error: message }));
+ }
+ },
+ [signerPromise, queryClient]
+ );
+
+ const reset = useCallback(() => {
+ setProgress({ phase: RekeyPhase.Idle, current: 0, total: 0 });
+ }, []);
+
+ return { rekeyAndRewrap, progress, reset };
+}
diff --git a/dapp/src/hooks/dataroom/useRevokeAccess.ts b/dapp/src/hooks/dataroom/useRevokeAccess.ts
new file mode 100644
index 0000000..c3c306d
--- /dev/null
+++ b/dapp/src/hooks/dataroom/useRevokeAccess.ts
@@ -0,0 +1,32 @@
+import { useState, useCallback } from "react";
+import { useQueryClient } from "@tanstack/react-query";
+import { getSignerContract, useEthersSigner, CHAIN_ID } from "./shared";
+
+export function useRevokeAccess() {
+ const signerPromise = useEthersSigner({ chainId: CHAIN_ID });
+ const queryClient = useQueryClient();
+ const [isPending, setIsPending] = useState(false);
+ const [isConfirming, setIsConfirming] = useState(false);
+ const [error, setError] = useState(null);
+
+ const revokeAccess = useCallback(async (roomId: bigint, user: string) => {
+ if (!signerPromise) return;
+ setIsPending(true);
+ setError(null);
+ try {
+ const contract = await getSignerContract(signerPromise);
+ const tx = await contract.revokeAccess(roomId, [user]);
+ setIsPending(false);
+ setIsConfirming(true);
+ await tx.wait();
+ setIsConfirming(false);
+ queryClient.invalidateQueries({ refetchType: "all" });
+ } catch (e) {
+ setIsPending(false);
+ setIsConfirming(false);
+ setError(e as Error);
+ }
+ }, [signerPromise, queryClient]);
+
+ return { revokeAccess, isPending, isConfirming, error };
+}
diff --git a/dapp/src/hooks/dataroom/useRevokeAccessFromAllFolders.ts b/dapp/src/hooks/dataroom/useRevokeAccessFromAllFolders.ts
new file mode 100644
index 0000000..b7860bc
--- /dev/null
+++ b/dapp/src/hooks/dataroom/useRevokeAccessFromAllFolders.ts
@@ -0,0 +1,32 @@
+import { useState, useCallback } from "react";
+import { useQueryClient } from "@tanstack/react-query";
+import { getSignerContract, useEthersSigner, CHAIN_ID } from "./shared";
+
+export function useRevokeAccessFromAllFolders() {
+ const signerPromise = useEthersSigner({ chainId: CHAIN_ID });
+ const queryClient = useQueryClient();
+ const [isPending, setIsPending] = useState(false);
+ const [isConfirming, setIsConfirming] = useState(false);
+ const [error, setError] = useState(null);
+
+ const revokeAccessFromAllFolders = useCallback(async (parentId: bigint, user: string) => {
+ if (!signerPromise) return;
+ setIsPending(true);
+ setError(null);
+ try {
+ const contract = await getSignerContract(signerPromise);
+ const tx = await contract.revokeAccessFromAllFolders(parentId, user);
+ setIsPending(false);
+ setIsConfirming(true);
+ await tx.wait();
+ setIsConfirming(false);
+ queryClient.invalidateQueries({ refetchType: "all" });
+ } catch (e) {
+ setIsPending(false);
+ setIsConfirming(false);
+ setError(e as Error);
+ }
+ }, [signerPromise, queryClient]);
+
+ return { revokeAccessFromAllFolders, isPending, isConfirming, error };
+}
diff --git a/dapp/src/hooks/dataroom/useRoom.ts b/dapp/src/hooks/dataroom/useRoom.ts
new file mode 100644
index 0000000..d97f2d2
--- /dev/null
+++ b/dapp/src/hooks/dataroom/useRoom.ts
@@ -0,0 +1,12 @@
+import { useQuery } from "@tanstack/react-query";
+import { usePublicContract } from "./shared";
+
+export function useRoom(roomId: bigint | undefined) {
+ const contract = usePublicContract();
+ return useQuery({
+ queryKey: ["dataroom", "room", roomId?.toString()],
+ queryFn: () => contract!.getRoom(roomId!),
+ enabled: !!contract && roomId !== undefined,
+ structuralSharing: false,
+ });
+}
diff --git a/dapp/src/hooks/dataroom/useRoomCount.ts b/dapp/src/hooks/dataroom/useRoomCount.ts
new file mode 100644
index 0000000..60f245b
--- /dev/null
+++ b/dapp/src/hooks/dataroom/useRoomCount.ts
@@ -0,0 +1,12 @@
+import { useQuery } from "@tanstack/react-query";
+import { usePublicContract } from "./shared";
+
+export function useRoomCount() {
+ const contract = usePublicContract();
+ return useQuery({
+ queryKey: ["dataroom", "roomCount"],
+ queryFn: () => contract!.roomCount(),
+ enabled: !!contract,
+ structuralSharing: false,
+ });
+}
diff --git a/dapp/src/hooks/dataroom/useRoomKey.ts b/dapp/src/hooks/dataroom/useRoomKey.ts
new file mode 100644
index 0000000..1aaf643
--- /dev/null
+++ b/dapp/src/hooks/dataroom/useRoomKey.ts
@@ -0,0 +1,36 @@
+import { useQuery } from "@tanstack/react-query";
+import { EncryptedDataRoom__factory, DATAROOM_ADDRESS, useEthersSigner, CHAIN_ID } from "./shared";
+import { decryptRoomKey } from "@/lib/fhe";
+
+export function useRoomKey(roomId: bigint | undefined, enabled = true) {
+ const signerPromise = useEthersSigner({ chainId: CHAIN_ID });
+ return useQuery({
+ queryKey: ["dataroom", "roomKey", roomId?.toString()],
+ queryFn: async () => {
+ const signer = await signerPromise!;
+ const contract = EncryptedDataRoom__factory.connect(DATAROOM_ADDRESS, signer);
+ const handle = await contract.getRoomKey(roomId!);
+ return decryptRoomKey(handle, signer);
+ },
+ enabled: !!signerPromise && roomId !== undefined && enabled,
+ staleTime: Infinity,
+ retry: false,
+ structuralSharing: false,
+ });
+}
+
+export function useRoomKeyHandle(roomId: bigint | undefined) {
+ const signerPromise = useEthersSigner({ chainId: CHAIN_ID });
+ return useQuery({
+ queryKey: ["dataroom", "roomKeyHandle", roomId?.toString()],
+ queryFn: async () => {
+ const signer = await signerPromise!;
+ const contract = EncryptedDataRoom__factory.connect(DATAROOM_ADDRESS, signer);
+ return contract.getRoomKey(roomId!);
+ },
+ enabled: !!signerPromise && roomId !== undefined,
+ staleTime: Infinity,
+ retry: false,
+ structuralSharing: false,
+ });
+}
diff --git a/dapp/src/hooks/dataroom/useRoomMembers.ts b/dapp/src/hooks/dataroom/useRoomMembers.ts
new file mode 100644
index 0000000..b18214e
--- /dev/null
+++ b/dapp/src/hooks/dataroom/useRoomMembers.ts
@@ -0,0 +1,15 @@
+import { useQuery } from "@tanstack/react-query";
+import { getSignerContract, useEthersSigner, CHAIN_ID } from "./shared";
+
+export function useRoomMembers(roomId: bigint | undefined, enabled = true) {
+ const signerPromise = useEthersSigner({ chainId: CHAIN_ID });
+ return useQuery({
+ queryKey: ["dataroom", "members", roomId?.toString()],
+ queryFn: async () => {
+ const contract = await getSignerContract(signerPromise!);
+ return contract.getMembers(roomId!);
+ },
+ enabled: !!signerPromise && roomId !== undefined && enabled,
+ structuralSharing: false,
+ });
+}
diff --git a/dapp/src/hooks/useDataRoom.ts b/dapp/src/hooks/useDataRoom.ts
deleted file mode 100644
index ff3d291..0000000
--- a/dapp/src/hooks/useDataRoom.ts
+++ /dev/null
@@ -1,407 +0,0 @@
-import { useMemo, useState, useCallback } from "react";
-import { useQuery, useQueryClient } from "@tanstack/react-query";
-import type { JsonRpcSigner } from "ethers";
-import { DATAROOM_ADDRESS, CHAIN_ID } from "@/contracts";
-import { EncryptedDataRoom__factory } from "@/types";
-import type { EncryptedDataRoom } from "@/types";
-import { useEthersProvider, useEthersSigner } from "@/lib/ethers-adapter";
-import { deriveAesKey, hexToBytes, unwrapKey, wrapKey, bytesToHex } from "@/lib/crypto";
-import { decryptRoomKey } from "@/lib/fhe";
-
-export const ZERO_BYTES32 = "0x0000000000000000000000000000000000000000000000000000000000000000";
-
-/** Read-only contract (no wallet needed). */
-function usePublicContract(): EncryptedDataRoom | null {
- const provider = useEthersProvider({ chainId: CHAIN_ID });
- return useMemo(() => {
- if (!provider) return null;
- return EncryptedDataRoom__factory.connect(DATAROOM_ADDRESS, provider);
- }, [provider]);
-}
-
-/** Signer-backed typed contract (needs connected wallet). */
-async function getSignerContract(signerPromise: Promise) {
- const signer = await signerPromise;
- return EncryptedDataRoom__factory.connect(DATAROOM_ADDRESS, signer);
-}
-
-export function useRoomCount() {
- const contract = usePublicContract();
- return useQuery({
- queryKey: ["dataroom", "roomCount"],
- queryFn: () => contract!.roomCount(),
- enabled: !!contract,
- structuralSharing: false,
- });
-}
-
-export function useRoom(roomId: bigint | undefined) {
- const contract = usePublicContract();
- return useQuery({
- queryKey: ["dataroom", "room", roomId?.toString()],
- queryFn: () => contract!.getRoom(roomId!),
- enabled: !!contract && roomId !== undefined,
- structuralSharing: false,
- });
-}
-
-export function useFolders(parentId: bigint | undefined) {
- const contract = usePublicContract();
- return useQuery({
- queryKey: ["dataroom", "folders", parentId?.toString()],
- queryFn: () => contract!.getFolders(parentId!),
- enabled: !!contract && parentId !== undefined,
- structuralSharing: false,
- });
-}
-
-export function useDocument(roomId: bigint | undefined, docIndex: bigint | undefined) {
- const contract = usePublicContract();
- return useQuery({
- queryKey: ["dataroom", "document", roomId?.toString(), docIndex?.toString()],
- queryFn: () => contract!.getDocument(roomId!, docIndex!),
- enabled: !!contract && roomId !== undefined && docIndex !== undefined,
- structuralSharing: false,
- });
-}
-
-export function useRoomMembers(roomId: bigint | undefined, enabled = true) {
- const signerPromise = useEthersSigner({ chainId: CHAIN_ID });
- return useQuery({
- queryKey: ["dataroom", "members", roomId?.toString()],
- queryFn: async () => {
- const contract = await getSignerContract(signerPromise!);
- return contract.getMembers(roomId!);
- },
- enabled: !!signerPromise && roomId !== undefined && enabled,
- structuralSharing: false,
- });
-}
-
-export function useRoomKey(roomId: bigint | undefined, enabled = true) {
- const signerPromise = useEthersSigner({ chainId: CHAIN_ID });
- return useQuery({
- queryKey: ["dataroom", "roomKey", roomId?.toString()],
- queryFn: async () => {
- const signer = await signerPromise!;
- const contract = EncryptedDataRoom__factory.connect(DATAROOM_ADDRESS, signer);
- const handle = await contract.getRoomKey(roomId!);
- return decryptRoomKey(handle, signer);
- },
- enabled: !!signerPromise && roomId !== undefined && enabled,
- staleTime: Infinity,
- structuralSharing: false,
- });
-}
-
-
-export function useHasAccessToAnyFolder(parentId: bigint | undefined) {
- const signerPromise = useEthersSigner({ chainId: CHAIN_ID });
- return useQuery({
- queryKey: ["dataroom", "hasAccessToAnyFolder", parentId?.toString()],
- queryFn: async () => {
- 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);
- },
- enabled: !!signerPromise && parentId !== undefined,
- });
-}
-
-export function useCreateRoom() {
- const signerPromise = useEthersSigner({ chainId: CHAIN_ID });
- const queryClient = useQueryClient();
- const [isPending, setIsPending] = useState(false);
- const [isConfirming, setIsConfirming] = useState(false);
- const [error, setError] = useState(null);
-
- const createRoom = useCallback(async (name: string) => {
- if (!signerPromise) return;
- setIsPending(true);
- setError(null);
- try {
- const contract = await getSignerContract(signerPromise);
- const tx = await contract.createRoom(name);
- setIsPending(false);
- setIsConfirming(true);
- await tx.wait();
- setIsConfirming(false);
- queryClient.invalidateQueries({ refetchType: "all" });
- } catch (e) {
- setIsPending(false);
- setIsConfirming(false);
- setError(e as Error);
- }
- }, [signerPromise, queryClient]);
-
- return { createRoom, isPending, isConfirming, error };
-}
-
-export function useCreateFolder() {
- const signerPromise = useEthersSigner({ chainId: CHAIN_ID });
- const queryClient = useQueryClient();
- const [isPending, setIsPending] = useState(false);
- const [isConfirming, setIsConfirming] = useState(false);
- const [error, setError] = useState(null);
-
- const createFolder = useCallback(async (parentId: bigint, name: string) => {
- if (!signerPromise) return;
- setIsPending(true);
- setError(null);
- try {
- const contract = await getSignerContract(signerPromise);
- const tx = await contract.createFolder(parentId, name);
- setIsPending(false);
- setIsConfirming(true);
- await tx.wait();
- setIsConfirming(false);
- queryClient.invalidateQueries({ refetchType: "all" });
- } catch (e) {
- setIsPending(false);
- setIsConfirming(false);
- setError(e as Error);
- }
- }, [signerPromise, queryClient]);
-
- return { createFolder, isPending, isConfirming, error };
-}
-
-export function useAddDocuments() {
- const signerPromise = useEthersSigner({ chainId: CHAIN_ID });
- const queryClient = useQueryClient();
- const [isPending, setIsPending] = useState(false);
- const [isConfirming, setIsConfirming] = useState(false);
- const [error, setError] = useState(null);
-
- const addDocuments = useCallback(async (roomId: bigint, cids: string[], names: string[], wrappedKeys: string[]) => {
- if (!signerPromise) return;
- setIsPending(true);
- setError(null);
- try {
- const contract = await getSignerContract(signerPromise);
- const tx = await contract.addDocuments(roomId, cids, names, wrappedKeys);
- setIsPending(false);
- setIsConfirming(true);
- await tx.wait();
- setIsConfirming(false);
- queryClient.invalidateQueries({ refetchType: "all" });
- } catch (e) {
- setIsPending(false);
- setIsConfirming(false);
- setError(e as Error);
- }
- }, [signerPromise, queryClient]);
-
- return { addDocuments, isPending, isConfirming, error };
-}
-
-export function useGrantAccess() {
- const signerPromise = useEthersSigner({ chainId: CHAIN_ID });
- const queryClient = useQueryClient();
- const [isPending, setIsPending] = useState(false);
- const [isConfirming, setIsConfirming] = useState(false);
- const [error, setError] = useState(null);
-
- const grantAccess = useCallback(async (roomId: bigint, user: string) => {
- if (!signerPromise) return;
- setIsPending(true);
- setError(null);
- try {
- const contract = await getSignerContract(signerPromise);
- const tx = await contract.grantAccess(roomId, [user]);
- setIsPending(false);
- setIsConfirming(true);
- await tx.wait();
- setIsConfirming(false);
- queryClient.invalidateQueries({ refetchType: "all" });
- } catch (e) {
- setIsPending(false);
- setIsConfirming(false);
- setError(e as Error);
- }
- }, [signerPromise, queryClient]);
-
- return { grantAccess, isPending, isConfirming, error };
-}
-
-export function useRevokeAccess() {
- const signerPromise = useEthersSigner({ chainId: CHAIN_ID });
- const queryClient = useQueryClient();
- const [isPending, setIsPending] = useState(false);
- const [isConfirming, setIsConfirming] = useState(false);
- const [error, setError] = useState(null);
-
- const revokeAccess = useCallback(async (roomId: bigint, user: string) => {
- if (!signerPromise) return;
- setIsPending(true);
- setError(null);
- try {
- const contract = await getSignerContract(signerPromise);
- const tx = await contract.revokeAccess(roomId, [user]);
- setIsPending(false);
- setIsConfirming(true);
- await tx.wait();
- setIsConfirming(false);
- queryClient.invalidateQueries({ refetchType: "all" });
- } catch (e) {
- setIsPending(false);
- setIsConfirming(false);
- setError(e as Error);
- }
- }, [signerPromise, queryClient]);
-
- return { revokeAccess, isPending, isConfirming, error };
-}
-
-export function useGrantAccessToAllFolders() {
- const signerPromise = useEthersSigner({ chainId: CHAIN_ID });
- const queryClient = useQueryClient();
- const [isPending, setIsPending] = useState(false);
- const [isConfirming, setIsConfirming] = useState(false);
- const [error, setError] = useState(null);
-
- const grantAccessToAllFolders = useCallback(async (parentId: bigint, user: string) => {
- if (!signerPromise) return;
- setIsPending(true);
- setError(null);
- try {
- const contract = await getSignerContract(signerPromise);
- const tx = await contract.grantAccessToAllFolders(parentId, user);
- setIsPending(false);
- setIsConfirming(true);
- await tx.wait();
- setIsConfirming(false);
- queryClient.invalidateQueries({ refetchType: "all" });
- } catch (e) {
- setIsPending(false);
- setIsConfirming(false);
- setError(e as Error);
- }
- }, [signerPromise, queryClient]);
-
- return { grantAccessToAllFolders, isPending, isConfirming, error };
-}
-
-export function useRevokeAccessFromAllFolders() {
- const signerPromise = useEthersSigner({ chainId: CHAIN_ID });
- const queryClient = useQueryClient();
- const [isPending, setIsPending] = useState(false);
- const [isConfirming, setIsConfirming] = useState(false);
- const [error, setError] = useState(null);
-
- const revokeAccessFromAllFolders = useCallback(async (parentId: bigint, user: string) => {
- if (!signerPromise) return;
- setIsPending(true);
- setError(null);
- try {
- const contract = await getSignerContract(signerPromise);
- const tx = await contract.revokeAccessFromAllFolders(parentId, user);
- setIsPending(false);
- setIsConfirming(true);
- await tx.wait();
- setIsConfirming(false);
- queryClient.invalidateQueries({ refetchType: "all" });
- } catch (e) {
- setIsPending(false);
- setIsConfirming(false);
- setError(e as Error);
- }
- }, [signerPromise, queryClient]);
-
- return { revokeAccessFromAllFolders, isPending, isConfirming, error };
-}
-
-export const RekeyPhase = {
- Idle: "idle",
- Rekeying: "rekeying",
- Rewrapping: "rewrapping",
- Updating: "updating",
- Done: "done",
- Error: "error",
-} as const;
-
-export type RekeyPhase = (typeof RekeyPhase)[keyof typeof RekeyPhase];
-
-export type RekeyProgress = {
- phase: RekeyPhase;
- current: number;
- total: number;
- error?: string;
-};
-
-export function useRekeyAndRewrap() {
- const signerPromise = useEthersSigner({ chainId: CHAIN_ID });
- const queryClient = useQueryClient();
- const [progress, setProgress] = useState({
- phase: RekeyPhase.Idle,
- current: 0,
- total: 0,
- });
-
- const rekeyAndRewrap = useCallback(
- async (
- folderId: bigint,
- documentCount: number,
- oldRoomKeyHex: string,
- ) => {
- if (!signerPromise) return;
-
- try {
- const contract = await getSignerContract(signerPromise);
-
- // Phase 1: Rekey on-chain
- setProgress({ phase: RekeyPhase.Rekeying, current: 0, total: 0 });
- const tx = await contract.rekeyRoom(folderId);
- await tx.wait();
-
- // If no documents, we're done
- if (documentCount === 0) {
- setProgress({ phase: RekeyPhase.Done, current: 0, total: 0 });
- queryClient.invalidateQueries({ refetchType: "all" });
- return;
- }
-
- const signer = await signerPromise!;
- const newKeyHandle = await contract.getRoomKey(folderId);
- const newKeyHex = await decryptRoomKey(newKeyHandle, signer);
-
- // Phase 2: Re-wrap all document CEKs locally (no Storacha I/O)
- setProgress({ phase: RekeyPhase.Rewrapping, current: 0, total: documentCount });
- const oldWrappingKey = await deriveAesKey(hexToBytes(oldRoomKeyHex));
- const newWrappingKey = await deriveAesKey(hexToBytes(newKeyHex));
- const newWrappedKeys: string[] = [];
-
- for (let i = 0; i < documentCount; i++) {
- setProgress({ phase: RekeyPhase.Rewrapping, current: i + 1, total: documentCount });
- const doc = await contract.getDocument(folderId, BigInt(i));
- const wrappedCek = hexToBytes(doc.wrappedKey);
- const cek = await unwrapKey(wrappedCek, oldWrappingKey);
- const newWrapped = await wrapKey(cek, newWrappingKey);
- newWrappedKeys.push(bytesToHex(newWrapped));
- }
-
- // Phase 3: Update all wrapped keys on-chain in a single transaction
- setProgress({ phase: RekeyPhase.Updating, current: 0, total: documentCount });
- const docIndices = Array.from({ length: documentCount }, (_, i) => BigInt(i));
- const updateTx = await contract.updateDocumentKeys(folderId, docIndices, newWrappedKeys);
- await updateTx.wait();
- setProgress({ phase: RekeyPhase.Updating, current: documentCount, total: documentCount });
-
- setProgress({ phase: RekeyPhase.Done, current: documentCount, total: documentCount });
- queryClient.invalidateQueries({ refetchType: "all" });
- } catch (e) {
- const message = e instanceof Error ? e.message : "Unknown error";
- setProgress((prev) => ({ ...prev, phase: RekeyPhase.Error, error: message }));
- }
- },
- [signerPromise, queryClient]
- );
-
- const reset = useCallback(() => {
- setProgress({ phase: RekeyPhase.Idle, current: 0, total: 0 });
- }, []);
-
- return { rekeyAndRewrap, progress, reset };
-}
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 7c53780..bd4360e 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,47 @@ const rpcUrls: Record = {
[1]: import.meta.env.VITE_RPC_URL || "https://eth.llamarpc.com",
};
-const networkConfigs: Record = {
- [11155111]: SepoliaConfig,
- [1]: MainnetConfig,
-};
+// 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;
+ 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);
+ });
+}
-let instancePromise: Promise | null = null;
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+let instancePromise: Promise | null = null;
-function getInstance(): Promise {
+async function getInstance() {
if (!instancePromise) {
- const config = networkConfigs[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;
}
-
const CACHE_PREFIX = "fhe:";
function getCached(handle: string): string | null {
@@ -42,6 +61,10 @@ function getCached(handle: string): string | null {
}
}
+export function hasCachedKey(handle: string): boolean {
+ return getCached(handle) !== null;
+}
+
function setCached(handle: string, hex: string): void {
try {
sessionStorage.setItem(CACHE_PREFIX + handle.toLowerCase(), hex);
@@ -97,11 +120,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/components/RoomCard.tsx b/dapp/src/pages/Dashboard/components/RoomCard.tsx
index c0d9e43..5175f85 100644
--- a/dapp/src/pages/Dashboard/components/RoomCard.tsx
+++ b/dapp/src/pages/Dashboard/components/RoomCard.tsx
@@ -1,6 +1,6 @@
import { Link } from "react-router-dom";
import { ChevronRight, FolderOpen } from "lucide-react";
-import { useRoom } from "@/hooks/useDataRoom";
+import { useRoom } from "@/hooks/dataroom";
import { CopyableAddress } from "@/components/CopyableAddress";
export function RoomCard({ roomId }: { roomId: bigint }) {
diff --git a/dapp/src/pages/Dashboard/index.tsx b/dapp/src/pages/Dashboard/index.tsx
index d9fdf6c..b1f4313 100644
--- a/dapp/src/pages/Dashboard/index.tsx
+++ b/dapp/src/pages/Dashboard/index.tsx
@@ -1,7 +1,7 @@
import { useState } from "react";
import { useAccount } from "wagmi";
import { Plus, FolderLock, Loader2 } from "lucide-react";
-import { useRoomCount, useCreateRoom } from "@/hooks/useDataRoom";
+import { useRoomCount, useCreateRoom } from "@/hooks/dataroom";
import { RoomCard } from "./components/RoomCard";
export default function Dashboard() {
@@ -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/InvestorView/components/InvestorRoomCard.tsx b/dapp/src/pages/InvestorView/components/InvestorRoomCard.tsx
index e45e2db..3ef1408 100644
--- a/dapp/src/pages/InvestorView/components/InvestorRoomCard.tsx
+++ b/dapp/src/pages/InvestorView/components/InvestorRoomCard.tsx
@@ -1,6 +1,6 @@
import { Link } from "react-router-dom";
import { ChevronRight, FolderOpen } from "lucide-react";
-import { useRoom, useHasAccessToAnyFolder } from "@/hooks/useDataRoom";
+import { useRoom, useHasAccessToAnyFolder } from "@/hooks/dataroom";
export function InvestorRoomCard({ roomId }: { roomId: bigint }) {
const { data: roomData } = useRoom(roomId);
diff --git a/dapp/src/pages/InvestorView/index.tsx b/dapp/src/pages/InvestorView/index.tsx
index b322db5..16f75a3 100644
--- a/dapp/src/pages/InvestorView/index.tsx
+++ b/dapp/src/pages/InvestorView/index.tsx
@@ -1,6 +1,6 @@
import { useAccount } from "wagmi";
import { Eye, FolderLock } from "lucide-react";
-import { useRoomCount } from "@/hooks/useDataRoom";
+import { useRoomCount } from "@/hooks/dataroom";
import { InvestorRoomCard } from "./components/InvestorRoomCard";
export default function InvestorView() {
diff --git a/dapp/src/pages/Room/components/DocumentRow.tsx b/dapp/src/pages/Room/components/DocumentRow.tsx
index eef9511..4e41cb8 100644
--- a/dapp/src/pages/Room/components/DocumentRow.tsx
+++ b/dapp/src/pages/Room/components/DocumentRow.tsx
@@ -1,6 +1,6 @@
import { useState } from "react";
import { FileText, Download, Loader2 } from "lucide-react";
-import { useDocument } from "@/hooks/useDataRoom";
+import { useDocument } from "@/hooks/dataroom";
export function DocumentRow({
roomId,
diff --git a/dapp/src/pages/Room/components/FolderCard.tsx b/dapp/src/pages/Room/components/FolderCard.tsx
index 17717cb..d0ba617 100644
--- a/dapp/src/pages/Room/components/FolderCard.tsx
+++ b/dapp/src/pages/Room/components/FolderCard.tsx
@@ -1,5 +1,5 @@
import { FolderOpen, FileText, Users } from "lucide-react";
-import { useRoom, useRoomMembers } from "@/hooks/useDataRoom";
+import { useRoom, useRoomMembers } from "@/hooks/dataroom";
export function FolderCard({ folderId, isOwner, onSelect }: { folderId: bigint; isOwner: boolean; onSelect: () => void }) {
const { data } = useRoom(folderId);
diff --git a/dapp/src/pages/Room/components/FolderPanel.tsx b/dapp/src/pages/Room/components/FolderPanel.tsx
index 1f2157f..0f0068d 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,
@@ -22,9 +23,11 @@ import {
useRevokeAccess,
useRekeyAndRewrap,
useRoomKey,
+ useRoomKeyHandle,
ZERO_BYTES32,
RekeyPhase,
-} from "@/hooks/useDataRoom";
+} from "@/hooks/dataroom";
+import { isFheChain, hasCachedKey } from "@/lib/fhe";
import { useStoracha } from "@/hooks/useStoracha";
import { CopyableAddress } from "@/components/CopyableAddress";
import { DocumentRow } from "./DocumentRow";
@@ -47,15 +50,17 @@ function getUploadButtonText(
export function FolderPanel({ folderId }: { folderId: bigint }) {
const { address } = useAccount();
+ const queryClient = useQueryClient();
const { data: folderData } = useRoom(folderId);
- const isOwner =
- !!folderData && !!address && folderData.owner.toLowerCase() === address.toLowerCase();
+ const isOwner = !!folderData
+ && !!address
+ && folderData.owner.toLowerCase() === address.toLowerCase();
const { data: membersData } = useRoomMembers(folderId, isOwner);
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 +72,11 @@ export function FolderPanel({ folderId }: { folderId: bigint }) {
isReady: storachaReady,
} = useStoracha();
- const { data: roomKeyData } = useRoomKey(folderId);
+ const { data: roomKeyHandle } = useRoomKeyHandle(folderId);
+ const alreadyCached = !!roomKeyHandle && hasCachedKey(roomKeyHandle);
+ const [userRequestedKey, setUserRequestedKey] = useState
(false);
+ const keyRequested = !isFheChain() || userRequestedKey || alreadyCached;
+ const { data: roomKeyData, error: roomKeyError, isLoading: isLoadingKey } = useRoomKey(folderId, keyRequested);
const [newMember, setNewMember] = useState("");
const [selectedFiles, setSelectedFiles] = useState([]);
@@ -79,6 +88,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 +180,56 @@ export function FolderPanel({ folderId }: { folderId: bigint }) {
- {!hasAccess && (
+ {showDecryptPrompt && (
+
+
+
Folder is Encrypted
+
+ Sign a message to decrypt the folder and access documents.
+
+
setUserRequestedKey(true)}
+ className="dr-btn dr-btn--primary"
+ >
+
+ Decrypt Folder
+
+
+ )}
+
+ {showDecrypting && (
+
+
+
Decrypting Folder
+
+ Please sign the message to decrypt folder...
+
+
+ )}
+
+ {!showDecryptPrompt && !showDecrypting && roomKeyError && (
+
+
+
Decryption Failed
+
+ {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."}
+
+
{
+ setUserRequestedKey(false);
+ queryClient.resetQueries({ queryKey: ["dataroom", "roomKey", folderId.toString()] });
+ }}
+ className="dr-btn dr-btn--primary"
+ >
+
+ Retry
+
+
+ )}
+
+ {!showDecryptPrompt && !showDecrypting && !roomKeyError && !hasAccess && (
Access Restricted
@@ -179,9 +239,8 @@ export function FolderPanel({ folderId }: { folderId: bigint }) {
)}
- {hasAccess && (
+ {!showDecryptPrompt && !showDecrypting && !roomKeyError && hasAccess && (
- {/* Documents */}
@@ -267,7 +326,6 @@ export function FolderPanel({ folderId }: { folderId: bigint }) {
)}
- {/* Access Group */}
@@ -294,6 +352,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) => (
@@ -306,7 +374,7 @@ export function FolderPanel({ folderId }: { folderId: bigint }) {
{member.toLowerCase() !== folderData.owner.toLowerCase() && (
revokeAccess(folderId, member)}
- disabled={isRevoking}
+ disabled={isRevoking || isConfirmingRevoke}
className="dr-btn dr-btn--danger dr-btn--icon"
>
diff --git a/dapp/src/pages/Room/index.tsx b/dapp/src/pages/Room/index.tsx
index 09ec152..8649c50 100644
--- a/dapp/src/pages/Room/index.tsx
+++ b/dapp/src/pages/Room/index.tsx
@@ -11,11 +11,11 @@ import {
} from "lucide-react";
import {
useRoom,
- useFolders,
+ useAccessibleFolders,
useCreateFolder,
useGrantAccessToAllFolders,
useRevokeAccessFromAllFolders,
-} from "@/hooks/useDataRoom";
+} from "@/hooks/dataroom";
import { CopyableAddress } from "@/components/CopyableAddress";
import { SafeIcon } from "./components/SafeIcon";
import { FolderCard } from "./components/FolderCard";
@@ -27,7 +27,9 @@ export default function Room() {
const { address } = useAccount();
const { data: roomData } = useRoom(roomId);
- const { data: folderIds } = useFolders(roomId);
+ const isOwner =
+ !!roomData && !!address && roomData.owner.toLowerCase() === address.toLowerCase();
+ const { data: folderIds } = useAccessibleFolders(roomId, isOwner);
const { createFolder, isPending: isCreatingFolder, isConfirming: isConfirmingFolder } = useCreateFolder();
const {
grantAccessToAllFolders,
@@ -46,9 +48,6 @@ export default function Room() {
const [folderName, setFolderName] = useState("");
const [bulkMember, setBulkMember] = useState("");
- const isOwner =
- !!roomData && !!address && roomData.owner.toLowerCase() === address.toLowerCase();
-
if (!roomData || roomId === undefined) {
return Loading room...
;
}
@@ -150,6 +149,13 @@ export default function Room() {
)}
+ {(isCreatingFolder || isConfirmingFolder) && folders.length > 0 && (
+
+
+ {isCreatingFolder ? "Waiting for signature..." : "Confirming transaction..."}
+
+ )}
+
{(isCreatingFolder || isConfirmingFolder) && folders.length === 0 ? (
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 {
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",
+ },
});
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..4f6623e
--- /dev/null
+++ b/docs/ADRs/004-skip-hkdf-key-derivation.md
@@ -0,0 +1,28 @@
+# ADR-004: Skip HKDF Key Derivation
+
+**Date**: 2026-03-09
+
+**Status**: Accepted as out of scope.
+
+## 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 as an accepted drawback. 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
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 `