From 6c22eac5bfe604fbd679b86891fae477aa0bc4f4 Mon Sep 17 00:00:00 2001 From: Skas Merkushin Date: Thu, 16 Apr 2026 21:56:22 +0200 Subject: [PATCH 01/12] feat(hola-modern, contracts): encrypted role content refs + sepolia deploy tooling Adds the write-side plumbing for encrypted proposal/role content: - useKeyManager / useEncryptedStorage / useRoleFieldContent hooks - 0G storage refactor and hollab-sdk wiring - GovernanceMeetingRoom now emits CreateRoleWithRefs / AmendRoleWithRefs when visibility-scoped content refs are attached Infra: wagmi.config auto-reads Sepolia deployment artifact, Sepolia deploys use --legacy, dev:sepolia helper script, refreshed 11155111 addresses. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/hola-modern/package.json | 1 + .../src/hooks/useEncryptedStorage.ts | 130 ++++++++++++++ apps/hola-modern/src/hooks/useKeyManager.ts | 83 +++++++++ .../src/hooks/useRoleFieldContent.ts | 74 ++++++++ apps/hola-modern/src/hooks/useZgStorage.ts | 36 +++- .../src/views/GovernanceMeetingRoom.tsx | 158 +++++++++++++++++- package.json | 1 + .../deployments/11155111-infrastructure.json | 17 +- packages/contracts/package.json | 4 +- packages/contracts/wagmi.config.ts | 14 +- pnpm-lock.yaml | 3 + scripts/dev-sepolia.sh | 145 ++++++++++++++++ 12 files changed, 639 insertions(+), 27 deletions(-) create mode 100644 apps/hola-modern/src/hooks/useEncryptedStorage.ts create mode 100644 apps/hola-modern/src/hooks/useKeyManager.ts create mode 100644 apps/hola-modern/src/hooks/useRoleFieldContent.ts create mode 100755 scripts/dev-sepolia.sh diff --git a/apps/hola-modern/package.json b/apps/hola-modern/package.json index 36278e9..9d2bcfc 100644 --- a/apps/hola-modern/package.json +++ b/apps/hola-modern/package.json @@ -19,6 +19,7 @@ "@fullcalendar/react": "^6.1.20", "@fullcalendar/timegrid": "^6.1.20", "@hollab-io/contracts": "workspace:*", + "@hollab-io/hollab-sdk": "workspace:*", "@hollab-io/indexing-client": "workspace:*", "@hollab-io/viem-extension": "workspace:*", "@rainbow-me/rainbowkit": "2.2.10", diff --git a/apps/hola-modern/src/hooks/useEncryptedStorage.ts b/apps/hola-modern/src/hooks/useEncryptedStorage.ts new file mode 100644 index 0000000..4582ddb --- /dev/null +++ b/apps/hola-modern/src/hooks/useEncryptedStorage.ts @@ -0,0 +1,130 @@ +/** + * useEncryptedStorage — encrypt + upload to 0G / fetch + decrypt from 0G. + * + * Composition hook combining: + * - useKeyManager (wallet-backed AES key derivation) + * - useZgStorage (0G blob upload/download) + * - KeyManager (AES encrypt/decrypt) + * + * The upload path serializes EncryptedPayload using a 2-byte nonce-length + * prefix format (matching the hollab-sdk StorageClient wire format). + */ +import type { EncryptedPayload } from "@hollab-io/hollab-sdk"; +import { KeyManager } from "@hollab-io/hollab-sdk"; +import { useMutation } from "@tanstack/react-query"; + +import type { DataVisibilityValue } from "./useContentRef"; +import { DataVisibility } from "./useContentRef"; +import { useKeyManager } from "./useKeyManager"; +import { downloadBytes, uploadBytes } from "./useZgStorage"; + +// ── Serialization (matches hollab-sdk StorageClient wire format) ───────────── + +function serializePayload(p: EncryptedPayload): Uint8Array { + const buf = new Uint8Array(2 + p.nonce.length + p.ciphertext.length); + new DataView(buf.buffer).setUint16(0, p.nonce.length); + buf.set(p.nonce, 2); + buf.set(p.ciphertext, 2 + p.nonce.length); + return buf; +} + +function deserializePayload(buf: Uint8Array): EncryptedPayload { + const nonceLen = new DataView(buf.buffer, buf.byteOffset).getUint16(0); + const nonce = buf.slice(2, 2 + nonceLen); + const ciphertext = buf.slice(2 + nonceLen); + return { nonce, ciphertext }; +} + +// ── Types ──────────────────────────────────────────────────────────────────── + +export type EncryptAndUploadParams = { + text: string; + visibility: DataVisibilityValue; + orgId: bigint; + circleId: bigint; + roleId?: bigint; +}; + +export type EncryptAndUploadResult = { + /** 0G Merkle root hash — stored on-chain as ContentRef.contentHash */ + rootHash: string; +}; + +export type FetchAndDecryptParams = { + /** 0G root hash (from on-chain ContentRef.contentHash) */ + rootHash: string; + visibility: DataVisibilityValue; + orgId: bigint; + circleId: bigint; + roleId?: bigint; +}; + +// ── Hook ───────────────────────────────────────────────────────────────────── + +const km = new KeyManager(); + +export function useEncryptedStorage() { + const { isUnlocked, unlock, getEncryptionKey, lock, error: keyError } = useKeyManager(); + + const encryptAndUpload = useMutation({ + mutationFn: async (params: EncryptAndUploadParams): Promise => { + const textBytes = new TextEncoder().encode(params.text); + + if (params.visibility === DataVisibility.Public) { + const { rootHash } = await uploadBytes(textBytes); + return { rootHash }; + } + + // Encrypted path + const key = getEncryptionKey({ + visibility: params.visibility, + orgId: params.orgId, + circleId: params.circleId, + roleId: params.roleId, + }); + if (!key) { + throw new Error("Expected encryption key for non-public visibility"); + } + + const payload = km.encrypt(key, textBytes); + const serialized = serializePayload(payload); + const { rootHash } = await uploadBytes(serialized); + return { rootHash }; + }, + }); + + async function fetchAndDecrypt(params: FetchAndDecryptParams): Promise { + const bytes = await downloadBytes(params.rootHash); + + if (params.visibility === DataVisibility.Public) { + return new TextDecoder().decode(bytes); + } + + // Encrypted path + const key = getEncryptionKey({ + visibility: params.visibility, + orgId: params.orgId, + circleId: params.circleId, + roleId: params.roleId, + }); + if (!key) { + throw new Error("Expected encryption key for non-public visibility"); + } + + const payload = deserializePayload(bytes); + const plaintext = km.decrypt(key, payload); + return new TextDecoder().decode(plaintext); + } + + return { + encryptAndUpload, + fetchAndDecrypt, + isKeyUnlocked: isUnlocked, + unlockKeys: unlock, + lockKeys: lock, + keyError, + }; +} + +// Re-export for tests +export { serializePayload, deserializePayload }; diff --git a/apps/hola-modern/src/hooks/useKeyManager.ts b/apps/hola-modern/src/hooks/useKeyManager.ts new file mode 100644 index 0000000..9bb9e5f --- /dev/null +++ b/apps/hola-modern/src/hooks/useKeyManager.ts @@ -0,0 +1,83 @@ +/** + * useKeyManager — wallet-backed encryption key derivation. + * + * Bridges the hollab-sdk KeyManager (which takes a viem WalletClient) with + * wagmi's useWalletClient. Caches the derived master key in a ref so the + * wallet signature prompt fires only once per session. + */ +import { KeyManager } from "@hollab-io/hollab-sdk"; +import { useCallback, useState } from "react"; +import { useWalletClient } from "wagmi"; + +import type { DataVisibilityValue } from "./useContentRef"; +import { DataVisibility } from "./useContentRef"; + +const km = new KeyManager(); + +// Module-level key cache — survives re-renders without useRef (avoids +// the "cannot access refs during render" lint rule). +let cachedMasterKey: Uint8Array | null = null; + +export type GetEncryptionKeyParams = { + visibility: DataVisibilityValue; + orgId: bigint; + circleId: bigint; + roleId?: bigint; +}; + +export function useKeyManager() { + const { data: walletClient } = useWalletClient(); + const [unlocked, setUnlocked] = useState(!!cachedMasterKey); + const [error, setError] = useState(null); + + // Derive isUnlocked: cached key exists AND wallet is still connected + const isUnlocked = unlocked && !!walletClient; + + const unlock = useCallback( + async (orgId: bigint) => { + if (cachedMasterKey) return; + if (!walletClient) { + throw new Error("Connect a wallet before unlocking encryption keys"); + } + setError(null); + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + cachedMasterKey = await km.deriveMasterKey(walletClient as any, orgId); + setUnlocked(true); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + setError(msg); + throw err; + } + }, + [walletClient], + ); + + const lock = useCallback(() => { + cachedMasterKey = null; + setUnlocked(false); + setError(null); + }, []); + + const getEncryptionKey = useCallback((params: GetEncryptionKeyParams): Uint8Array | null => { + if (params.visibility === DataVisibility.Public) return null; + + if (!cachedMasterKey) { + throw new Error("Encryption keys not unlocked. Call unlock() first."); + } + + const orgKey = km.deriveOrgKey(cachedMasterKey, params.orgId); + + if (params.visibility === DataVisibility.OrgEncrypted) { + return orgKey; + } + + if (params.roleId === undefined) { + throw new Error("roleId is required for RoleEncrypted visibility"); + } + const circleKey = km.deriveCircleKey(orgKey, params.circleId); + return km.deriveRoleKey(circleKey, params.roleId); + }, []); + + return { isUnlocked, unlock, getEncryptionKey, lock, error }; +} diff --git a/apps/hola-modern/src/hooks/useRoleFieldContent.ts b/apps/hola-modern/src/hooks/useRoleFieldContent.ts new file mode 100644 index 0000000..0568a44 --- /dev/null +++ b/apps/hola-modern/src/hooks/useRoleFieldContent.ts @@ -0,0 +1,74 @@ +/** + * useRoleFieldContent — read a role field's content from 0G via on-chain ContentRef. + * + * 1. Reads ContentRef from RoleRegistry (on-chain) + * 2. If zero hash → returns undefined (backward compat with non-Refs roles) + * 3. Downloads from 0G + decrypts based on visibility tier + * + * For encrypted fields, returns { needsUnlock: true } when keys are not yet + * derived, so the UI can show a lock icon + "Unlock to view" button. + */ +import type { Address } from "viem"; +import { useQuery } from "@tanstack/react-query"; + +import { DataVisibility, useReadContentRef } from "./useContentRef"; +import { useEncryptedStorage } from "./useEncryptedStorage"; + +const ZERO_HASH = "0x0000000000000000000000000000000000000000000000000000000000000000"; + +export function useRoleFieldContent(params: { + roleRegistryAddress: Address | undefined; + roleId: bigint | undefined; + fieldName: string | undefined; + orgId: bigint; + circleId: bigint; + roleId_forKey?: bigint; +}) { + const { fetchAndDecrypt, isKeyUnlocked } = useEncryptedStorage(); + + const { + data: contentRef, + isLoading: isRefLoading, + error: refError, + } = useReadContentRef(params.roleRegistryAddress, params.roleId, params.fieldName); + + const hasContent = !!contentRef && contentRef.contentHash !== ZERO_HASH; + const isEncrypted = hasContent && contentRef.visibility !== DataVisibility.Public; + const needsUnlock = isEncrypted && !isKeyUnlocked; + + const { + data, + isLoading: isContentLoading, + error: contentError, + } = useQuery({ + queryKey: [ + "roleFieldContent", + contentRef?.contentHash, + contentRef?.visibility, + isKeyUnlocked, + ], + queryFn: async () => { + if (!contentRef || contentRef.contentHash === ZERO_HASH) return undefined; + + // Convert bytes32 contentHash to 0G root hash string + const rootHash = contentRef.contentHash; + + return fetchAndDecrypt({ + rootHash, + visibility: contentRef.visibility as 0 | 1 | 2, + orgId: params.orgId, + circleId: params.circleId, + roleId: params.roleId_forKey, + }); + }, + enabled: hasContent && !needsUnlock, + }); + + return { + data, + isLoading: isRefLoading || isContentLoading, + error: refError ?? contentError ?? null, + needsUnlock, + hasContent, + }; +} diff --git a/apps/hola-modern/src/hooks/useZgStorage.ts b/apps/hola-modern/src/hooks/useZgStorage.ts index 24584dd..6aaaa5f 100644 --- a/apps/hola-modern/src/hooks/useZgStorage.ts +++ b/apps/hola-modern/src/hooks/useZgStorage.ts @@ -30,7 +30,7 @@ export type UploadResult = { * Upload raw bytes to 0G Storage from the browser. * Handles Indexer init, signer bridging, merkle tree generation, and upload. */ -async function uploadBytes(data: Uint8Array): Promise { +export async function uploadBytes(data: Uint8Array): Promise { const signer = await getEthersSigner(); const indexer = new Indexer(zgConfig.indexerRpc); @@ -50,6 +50,40 @@ async function uploadBytes(data: Uint8Array): Promise { return { rootHash: rootHash! }; } +/** + * Download raw bytes from 0G Storage by Merkle root hash. + * Standalone async function (not a hook) so composition hooks can call it directly. + */ +/** + * Download raw bytes from 0G Storage by Merkle root hash. + * + * The 0G SDK's Indexer.download writes to a file path (Node-only), so in the + * browser we locate the shard nodes via the indexer and fetch the file data + * over HTTP from the first available node. + */ +export async function downloadBytes(rootHash: string): Promise { + const indexer = new Indexer(zgConfig.indexerRpc); + const locations = await indexer.getFileLocations(rootHash); + + if (!locations.length) { + throw new Error(`0G: no shard nodes found for root hash ${rootHash}`); + } + + // Try each shard node until one succeeds + for (const node of locations) { + try { + const url = `${node.url}/file?root=${rootHash}`; + const response = await fetch(url); + if (!response.ok) continue; + return new Uint8Array(await response.arrayBuffer()); + } catch { + continue; + } + } + + throw new Error(`0G: all shard nodes failed to serve root hash ${rootHash}`); +} + export function useZgStorage() { /** Upload a text string to 0G Storage */ const uploadText = useMutation({ diff --git a/apps/hola-modern/src/views/GovernanceMeetingRoom.tsx b/apps/hola-modern/src/views/GovernanceMeetingRoom.tsx index 5794999..af1fc4b 100644 --- a/apps/hola-modern/src/views/GovernanceMeetingRoom.tsx +++ b/apps/hola-modern/src/views/GovernanceMeetingRoom.tsx @@ -9,6 +9,7 @@ import { CircleDot, FileText, Loader2, + Lock, MinusCircle, MoveRight, Plus, @@ -23,10 +24,15 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { encodeFunctionData, zeroHash } from "viem"; import { usePublicClient, useWalletClient } from "wagmi"; +import type { DataVisibilityValue } from "../hooks/useContentRef"; import type { GovernanceMeeting } from "../hooks/useGovernanceMeetingsFromIndexer"; +import { DataVisibility, fieldNameHash } from "../hooks/useContentRef"; +import { useEncryptedStorage } from "../hooks/useEncryptedStorage"; import { encodeAmendRole, + encodeAmendRoleWithRefs, encodeCreateRole, + encodeCreateRoleWithRefs, encodeExpandRoleToCircle, encodeRemoveRole, ChangeType as OnChainChangeType, @@ -67,6 +73,9 @@ type PendingGovernanceAction = { // Target existingTargetId?: string; destinationCircleId?: string; + // Encrypted storage + visibility?: DataVisibilityValue; + contentRefs?: { fieldName: string; rootHash: string; visibility: number }[]; }; type ProposalDraft = { @@ -84,6 +93,8 @@ type ProposalDraft = { // Target fields existingTargetId: string; destinationCircleId: string; + // Encrypted storage + visibility: DataVisibilityValue; }; // ─── Constants ─────────────────────────────────────────────────────────────── @@ -256,9 +267,8 @@ function buildGovernanceCalls( let circleId: bigint; if (action.changeType === "create-role") { - changeType = OnChainChangeType.CreateRole; circleId = BigInt(action.circleId || "0"); - encodedData = encodeCreateRole({ + const roleParams = { circleId, name: action.roleName ?? "", purpose: action.roleDescription ?? "", @@ -269,11 +279,25 @@ function buildGovernanceCalls( .map((s) => s.trim()) .filter(Boolean) : [], - }); + }; + if (action.contentRefs?.length) { + changeType = OnChainChangeType.CreateRoleWithRefs; + encodedData = encodeCreateRoleWithRefs({ + ...roleParams, + fieldNames: action.contentRefs.map((r) => fieldNameHash(r.fieldName)), + refs: action.contentRefs.map((r) => ({ + contentHash: + `0x${r.rootHash.replace("0x", "").padStart(64, "0")}` as `0x${string}`, + visibility: r.visibility, + })), + }); + } else { + changeType = OnChainChangeType.CreateRole; + encodedData = encodeCreateRole(roleParams); + } } else if (action.changeType === "amend-role" && action.existingTargetId) { - changeType = OnChainChangeType.AmendRole; circleId = BigInt(action.circleId || "0"); - encodedData = encodeAmendRole({ + const roleParams = { roleId: BigInt(action.existingTargetId), name: action.roleName ?? "", purpose: action.roleDescription ?? "", @@ -284,7 +308,22 @@ function buildGovernanceCalls( .map((s) => s.trim()) .filter(Boolean) : [], - }); + }; + if (action.contentRefs?.length) { + changeType = OnChainChangeType.AmendRoleWithRefs; + encodedData = encodeAmendRoleWithRefs({ + ...roleParams, + fieldNames: action.contentRefs.map((r) => fieldNameHash(r.fieldName)), + refs: action.contentRefs.map((r) => ({ + contentHash: + `0x${r.rootHash.replace("0x", "").padStart(64, "0")}` as `0x${string}`, + visibility: r.visibility, + })), + }); + } else { + changeType = OnChainChangeType.AmendRole; + encodedData = encodeAmendRole(roleParams); + } } else if (action.changeType === "remove-role" && action.existingTargetId) { changeType = OnChainChangeType.RemoveRole; circleId = BigInt(action.circleId || "0"); @@ -334,6 +373,7 @@ function buildEmptyDraft(circleId: string, roleId: string): ProposalDraft { policyBody: "", existingTargetId: "", destinationCircleId: "", + visibility: DataVisibility.Public, }; } @@ -810,6 +850,58 @@ function ProposalWizard({ )} + {/* Data visibility selector — only for create/amend role */} + {isRoleAction && (isCreate || draft.changeType === "amend-role") && ( +
+ +
+ {( + [ + { + value: DataVisibility.Public, + label: "Public", + desc: "Readable by anyone", + }, + { + value: DataVisibility.OrgEncrypted, + label: "Org-encrypted", + desc: "Org members only", + }, + { + value: DataVisibility.RoleEncrypted, + label: "Role-encrypted", + desc: "Role holders only", + }, + ] as const + ).map((opt) => ( + + ))} +
+
+ )} + {/* Create / amend policy fields */} {isPolicyAction && (isCreate || draft.changeType === "amend-policy") && ( <> @@ -1573,6 +1665,9 @@ export default function GovernanceMeetingRoom({ const [isTxPending, setIsTxPending] = useState(false); const [txError, setTxError] = useState(null); + // Encrypted storage (0G + key management) + const { encryptAndUpload, unlockKeys, isKeyUnlocked } = useEncryptedStorage(); + // Proposal wizard state const [showWizard, setShowWizard] = useState(false); const [wizardStep, setWizardStep] = useState("action"); @@ -1785,7 +1880,7 @@ export default function GovernanceMeetingRoom({ ], ); - const handleSubmitProposal = useCallback(() => { + const handleSubmitProposal = useCallback(async () => { if (!activeGovernanceMeeting) return; const isRoleAction = proposalDraft.changeType.includes("role"); @@ -1856,6 +1951,49 @@ export default function GovernanceMeetingRoom({ // Queue the on-chain action for batched execution at meeting completion const actionLabel = `${changeLabel}: ${title}`; + // Encrypt + upload to 0G when visibility is non-public and this is a role action + let contentRefs: { fieldName: string; rootHash: string; visibility: number }[] | undefined; + const vis = proposalDraft.visibility; + if ( + isRoleAction && + (isCreate || proposalDraft.changeType === "amend-role") && + vis !== undefined + ) { + // Unlock encryption keys if needed + if (vis !== DataVisibility.Public && !isKeyUnlocked && orgId) { + await unlockKeys(BigInt(orgId)); + } + + const fields: { name: string; text: string }[] = []; + if (proposalDraft.roleName) fields.push({ name: "name", text: proposalDraft.roleName }); + if (proposalDraft.roleDescription) + fields.push({ name: "purpose", text: proposalDraft.roleDescription }); + if (proposalDraft.roleDomain) + fields.push({ name: "domains", text: proposalDraft.roleDomain }); + if (proposalDraft.roleAccountabilities) + fields.push({ name: "accountabilities", text: proposalDraft.roleAccountabilities }); + + if (fields.length > 0) { + const cId = BigInt(proposalDraft.circleId || "0"); + const rId = proposalDraft.existingTargetId + ? BigInt(proposalDraft.existingTargetId) + : undefined; + const results = await Promise.all( + fields.map(async (f) => { + const { rootHash } = await encryptAndUpload.mutateAsync({ + text: f.text, + visibility: vis, + orgId: BigInt(orgId ?? "0"), + circleId: cId, + roleId: rId, + }); + return { fieldName: f.name, rootHash, visibility: vis }; + }), + ); + contentRefs = results; + } + } + setPendingActions((prev) => [ ...prev, { @@ -1871,6 +2009,8 @@ export default function GovernanceMeetingRoom({ policyBody: proposalDraft.policyBody || undefined, existingTargetId: proposalDraft.existingTargetId || undefined, destinationCircleId: proposalDraft.destinationCircleId || undefined, + visibility: vis, + contentRefs, }, ]); @@ -1880,11 +2020,15 @@ export default function GovernanceMeetingRoom({ }, [ activeGovernanceMeeting, createGovernanceProposal, + encryptAndUpload, initialCircleId, initialRoleId, + isKeyUnlocked, + orgId, proposalDraft, snapshot.policies, snapshot.roles, + unlockKeys, ]); const agendaItems = useMemo( diff --git a/package.json b/package.json index 91c3323..4f855aa 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "create-package": "./create-package.sh", "dev": "turbo run dev", "dev:local": "./scripts/dev-local.sh", + "dev:sepolia": "./scripts/dev-sepolia.sh", "env:prebuild": "turbo run env:prebuild", "format": "turbo run format", "format:fix": "turbo run format:fix", diff --git a/packages/contracts/deployments/11155111-infrastructure.json b/packages/contracts/deployments/11155111-infrastructure.json index 46d5f76..7791dbd 100644 --- a/packages/contracts/deployments/11155111-infrastructure.json +++ b/packages/contracts/deployments/11155111-infrastructure.json @@ -1,14 +1,9 @@ { - "actionVotingImpl": "0x33932d11D8EB83306cB23AE8EAF5Ed1b0b86e028", + "actionVotingImpl": "0xd413acc56781b69184E825b205cE8e77EF409ddE", "chainId": 11155111, - "circleRegistryImpl": "0x25a70F737B81351eFE8F05aFFfbCf7753934E9da", - "dataProvider": "0x95aaADF936770cdc6579F7fAC574B7e270e6132E", - "ensRegistrar": "0x2C4a4F3c9Eb1238f04fC2F9263e098ba0FC521F6", - "govFactory": "0x461E0a26818D70fb28E6223d1A86EDF0eb9b1683", - "governanceMeetingImpl": "0xB756D098B190043539C1Ee2138E7d34449762ad9", - "governanceProcessImpl": "0x5215fD32a647854B22365Ac84c97E67c9c3a5E42", - "meetingFactory": "0x18a1Dc3b2ad282E7376AFC2Ff9214d2544aDf27F", - "orgFactory": "0xda7029ef38fDCF3bFb79f113801b5b77Be55f0b3", - "roleRegistryImpl": "0x5D4930b5701e68C31921089119a445B4a2e7C903", - "tacticalMeetingImpl": "0x4A6f8E80484656e8Cedc687a43DEaC9eaA44F96c" + "ensRegistrar": "0xfedB58637c58B2dCA89d862b396222748D8c43A6", + "meetingFactory": "0xAd8223B6e9da5Cf0b4aE03d325CC385Fd1f6a825", + "meetingImpl": "0x6E6EA6A2ac2Ba4eCFb0463f8dDb130Ec1bd583BE", + "orgFactory": "0xEdB4Da78b5C759a651a72F8E4cEF07E606FfF051", + "roleRegistryImpl": "0x5ADc218266920016E34af999595A4c2AB46e6174" } \ No newline at end of file diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 1914ed0..8079c15 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -19,11 +19,11 @@ "deploy:infra:0g-mainnet": "forge script script/DeployInfrastructure.s.sol --tc DeployInfrastructure --rpc-url 0g-mainnet --browser --broadcast --verify --slow --legacy --with-gas-price 10gwei -vvvv", "deploy:infra:0g-testnet": "forge script script/DeployInfrastructure.s.sol --tc DeployInfrastructure --rpc-url 0g-testnet --browser --broadcast --verify --slow --legacy --with-gas-price 10gwei -vvvv", "deploy:infra:mainnet": "forge script script/DeployInfrastructure.s.sol --tc DeployInfrastructure --rpc-url mainnet --browser --broadcast --verify --slow -vvvv", - "deploy:infra:sepolia": "forge script script/DeployInfrastructure.s.sol --tc DeployInfrastructure --rpc-url sepolia --browser --broadcast --verify --slow -vvvv", + "deploy:infra:sepolia": "forge script script/DeployInfrastructure.s.sol --tc DeployInfrastructure --rpc-url sepolia --browser --broadcast --verify --slow --legacy -vvvv", "deploy:local": "forge script script/DeployLocal.s.sol --tc DeployLocal --rpc-url localhost --broadcast -vvv", "deploy:org:0g-testnet": "forge script script/CreateOrganization.s.sol --rpc-url 0g-testnet --browser --broadcast --verify --slow --legacy --with-gas-price 10gwei -vvvv", "deploy:org:mainnet": "forge script script/CreateOrganization.s.sol --rpc-url mainnet --browser --broadcast --verify --slow -vvvv", - "deploy:org:sepolia": "forge script script/CreateOrganization.s.sol --rpc-url sepolia --browser --broadcast --verify --slow -vvvv", + "deploy:org:sepolia": "forge script script/CreateOrganization.s.sol --rpc-url sepolia --browser --broadcast --verify --slow --legacy -vvvv", "generate": "wagmi generate", "lint": "forge fmt", "lint:fix": "forge fmt && pnpm lint:sol-tests --fix && pnpm lint:sol-logic --fix", diff --git a/packages/contracts/wagmi.config.ts b/packages/contracts/wagmi.config.ts index 081d905..9b94f7e 100644 --- a/packages/contracts/wagmi.config.ts +++ b/packages/contracts/wagmi.config.ts @@ -2,9 +2,10 @@ import { existsSync, readFileSync } from "fs"; import { defineConfig } from "@wagmi/cli"; import { foundry } from "@wagmi/cli/plugins"; -// Read local deployment addresses from artifact (changes on every `forge script --broadcast`) -function readLocalDeployment(): { orgFactory?: string; meetingFactory?: string } { - const path = "./deployments/31337-local.json"; +// Read deployment addresses from artifact (changes on every `forge script --broadcast`) +function readDeployment(chainId: number): { orgFactory?: string; meetingFactory?: string } { + const suffix = chainId === 31337 ? "local" : "infrastructure"; + const path = `./deployments/${chainId}-${suffix}.json`; if (!existsSync(path)) return {}; try { const data = JSON.parse(readFileSync(path, "utf-8")); @@ -14,7 +15,8 @@ function readLocalDeployment(): { orgFactory?: string; meetingFactory?: string } } } -const local = readLocalDeployment(); +const local = readDeployment(31337); +const sepolia = readDeployment(11155111); export default defineConfig({ out: "generated/index.ts", @@ -34,13 +36,13 @@ export default defineConfig({ deployments: { OrganizationFactory: { ...(local.orgFactory ? { 31337: local.orgFactory } : {}), + ...(sepolia.orgFactory ? { 11155111: sepolia.orgFactory } : {}), 1: "0xC0252342923238CF5509cfBd2fa46A45ADeDc921", - 11155111: "0xda7029ef38fDCF3bFb79f113801b5b77Be55f0b3", }, MeetingComponentsFactory: { ...(local.meetingFactory ? { 31337: local.meetingFactory } : {}), + ...(sepolia.meetingFactory ? { 11155111: sepolia.meetingFactory } : {}), 1: "0x876C1eDF90e1BcdFC3488a53Ce3EFf1759D27D25", - 11155111: "0x18a1Dc3b2ad282E7376AFC2Ff9214d2544aDf27F", }, }, }), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 87a911c..a9a5e3f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -101,6 +101,9 @@ importers: "@hollab-io/contracts": specifier: workspace:* version: link:../../packages/contracts + "@hollab-io/hollab-sdk": + specifier: workspace:* + version: link:../../packages/hollab-sdk "@hollab-io/indexing-client": specifier: workspace:* version: link:../../packages/indexing-client diff --git a/scripts/dev-sepolia.sh b/scripts/dev-sepolia.sh new file mode 100755 index 0000000..6665222 --- /dev/null +++ b/scripts/dev-sepolia.sh @@ -0,0 +1,145 @@ +#!/usr/bin/env bash +# ────────────────────────────────────────────────────────────────────────────── +# dev-sepolia.sh — Run ponder + frontend locally against live Sepolia +# +# No anvil. No redeploy. Reads infrastructure addresses from +# packages/contracts/deployments/11155111-infrastructure.json, writes +# .env.local for both the indexer and the frontend, then launches ponder +# (pointed at Sepolia) and vite (pointed at local ponder) in parallel. +# +# Reads and writes happen on real Sepolia; only the indexer + UI are local. +# +# Usage: +# ./scripts/dev-sepolia.sh +# +# Required env (in your shell or packages/contracts/.env): +# ETH_SEPOLIA_RPC_URL — production RPC (e.g. DRPC) +# +# Optional env: +# START_BLOCK — override ponder backfill start (default: 10665310) +# VITE_WALLETCONNECT_PROJECT_ID +# ────────────────────────────────────────────────────────────────────────────── +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +CONTRACTS="$ROOT/packages/contracts" +FRONTEND="$ROOT/apps/hola-modern" +INDEXER="$ROOT/apps/hollab-indexing" +ARTIFACT="$CONTRACTS/deployments/11155111-infrastructure.json" +DEFAULT_START_BLOCK=10665310 +PIDS=() + +# ── Cleanup on exit ────────────────────────────────────────────────────────── +cleanup() { + echo "" + echo "Shutting down..." + for pid in "${PIDS[@]}"; do + kill "$pid" 2>/dev/null || true + done + wait 2>/dev/null || true + echo "Done." +} +trap cleanup EXIT INT TERM + +# ── Prereqs ────────────────────────────────────────────────────────────────── +for cmd in jq pnpm; do + if ! command -v "$cmd" &>/dev/null; then + echo "Error: $cmd is required but not found." + exit 1 + fi +done + +# ── Load contracts .env (for ETH_SEPOLIA_RPC_URL) ──────────────────────────── +if [ -f "$CONTRACTS/.env" ]; then + set -a + # shellcheck disable=SC1091 + source "$CONTRACTS/.env" + set +a +fi + +: "${ETH_SEPOLIA_RPC_URL:?ETH_SEPOLIA_RPC_URL is required (set in packages/contracts/.env or export in shell)}" + +if [ ! -f "$ARTIFACT" ]; then + echo "Error: Sepolia deployment artifact not found at $ARTIFACT" + echo "Run 'pnpm --filter @hollab-io/contracts deploy:infra:sepolia' first." + exit 1 +fi + +# ── Extract addresses ──────────────────────────────────────────────────────── +ORG_FACTORY=$(jq -r '.orgFactory' "$ARTIFACT") +MEETING_FACTORY=$(jq -r '.meetingFactory' "$ARTIFACT") +ENS_REGISTRAR=$(jq -r '.ensRegistrar' "$ARTIFACT") + +if [ "$ORG_FACTORY" = "null" ] || [ -z "$ORG_FACTORY" ]; then + echo "Error: .orgFactory missing from $ARTIFACT" + exit 1 +fi +if [ "$MEETING_FACTORY" = "null" ] || [ -z "$MEETING_FACTORY" ]; then + echo "Error: .meetingFactory missing from $ARTIFACT" + exit 1 +fi + +START_BLOCK="${START_BLOCK:-$DEFAULT_START_BLOCK}" + +echo "Sepolia infrastructure:" +echo " OrganizationFactory: $ORG_FACTORY" +echo " MeetingComponentsFactory: $MEETING_FACTORY" +echo " ENSSubdomainRegistrar: $ENS_REGISTRAR" +echo " START_BLOCK: $START_BLOCK" + +# ── Write indexer .env.local ───────────────────────────────────────────────── +cat > "$INDEXER/.env.local" < "$FRONTEND_ENV" <> "$FRONTEND_ENV" +fi +echo "Wrote $FRONTEND_ENV" + +# ── Clear stale ponder cache (old addresses) ───────────────────────────────── +if [ -d "$INDEXER/.ponder" ]; then + echo "Clearing stale ponder cache..." + rm -rf "$INDEXER/.ponder" +fi + +# ── Start Ponder (live-synced against Sepolia) ─────────────────────────────── +echo "" +echo "Starting Ponder against Sepolia (backfill from block $START_BLOCK)..." +(cd "$INDEXER" && pnpm dev) & +PIDS+=($!) + +sleep 3 + +# ── Start Vite frontend ────────────────────────────────────────────────────── +echo "Starting Vite dev server..." +(cd "$FRONTEND" && pnpm dev) & +PIDS+=($!) + +echo "" +echo "=========================================" +echo " Sepolia dev environment ready!" +echo "" +echo " Chain: Sepolia (11155111)" +echo " RPC: $ETH_SEPOLIA_RPC_URL" +echo " Indexer: http://localhost:42069 (local → Sepolia)" +echo " Frontend: http://localhost:5173 (reads → local indexer)" +echo "" +echo " Wallet writes go to real Sepolia." +echo " Press Ctrl+C to stop all services." +echo "=========================================" +echo "" + +wait From fe5be8f1fff4105a821061b8dbb7b2c78617c310 Mon Sep 17 00:00:00 2001 From: Skas Merkushin Date: Thu, 16 Apr 2026 21:57:28 +0200 Subject: [PATCH 02/12] =?UTF-8?q?fix(hola-modern):=20ux=20audit=20batch=20?= =?UTF-8?q?1=20=E2=80=94=202=20critical=20+=205=20polish=20findings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UX-AUDIT.md adds a severity-ranked frontend audit; this commit addresses 7 findings leaving UX-C-1 (objection lifecycle) as the next focused task. - UX-C-2: drop auto switchChain loop; new WrongNetworkBanner with explicit switch button, shown only on authed views - UX-H-1: live .hollab.eth preview in create-workspace modal with editable override, 3–32 char validation; deploy hook accepts subname param - UX-M-6: deploy-tx link resolves via chainConfig.blockExplorers instead of hardcoded etherscan.io - UX-M-1: "browse public organizations" secondary CTA on Welcome → #/explore - UX-M-2: delete unreferenced OrganizationOnboarding.tsx - UX-M-3: drop vestigial isOnboarding/onboardedOrgIds state; fresh orgs land on the structure tab with invite panel pre-opened - UX-H-5: migrate MembersView, JoinOrganizationPanel, TacticalMeetingRoom legacy useEffect+setState reads to useQuery per CLAUDE.md convention Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/hola-modern/src/App.tsx | 43 +- .../src/components/WalletWorkspaceSync.tsx | 14 +- .../src/components/WrongNetworkBanner.tsx | 50 ++ .../src/hooks/useOrganizationFactory.ts | 21 +- .../src/views/JoinOrganizationPanel.tsx | 79 ++-- apps/hola-modern/src/views/MembersView.tsx | 29 +- .../src/views/OrganizationOnboarding.tsx | 436 ------------------ .../src/views/OrganizationsHome.tsx | 117 ++++- .../src/views/TacticalMeetingRoom.tsx | 25 +- apps/hola-modern/src/views/Welcome.tsx | 44 +- audits/UX-AUDIT.md | 394 ++++++++++++++++ 11 files changed, 694 insertions(+), 558 deletions(-) create mode 100644 apps/hola-modern/src/components/WrongNetworkBanner.tsx delete mode 100644 apps/hola-modern/src/views/OrganizationOnboarding.tsx create mode 100644 audits/UX-AUDIT.md diff --git a/apps/hola-modern/src/App.tsx b/apps/hola-modern/src/App.tsx index ec98f2b..8f056fd 100644 --- a/apps/hola-modern/src/App.tsx +++ b/apps/hola-modern/src/App.tsx @@ -6,6 +6,7 @@ import type { AppTabId } from "./config/navigation"; import ChainSwitcher from "./components/ChainSwitcher"; import ThemeToggle from "./components/ThemeToggle"; import WalletAuthControl from "./components/WalletAuthControl"; +import WrongNetworkBanner from "./components/WrongNetworkBanner"; import { useChain } from "./context/ChainContext"; import { useTheme } from "./context/ThemeContext"; import { useCirclesFromIndexer } from "./hooks/useCirclesFromIndexer"; @@ -91,23 +92,9 @@ function App() { const { meetings: indexedGovernanceMeetings, pollForNewMeeting: pollForNewGovernanceMeeting } = useGovernanceMeetingsFromIndexer(governanceMeetingAddress); - // When true, StructureView should auto-open the add-members panel + // When true, StructureView should auto-open the add-members panel. + // Set by fresh org creation so the new org creator lands on "add your first members". const [autoOpenInvite, setAutoOpenInvite] = useState(false); - // Org IDs that have completed (or skipped) member onboarding this session - const [onboardedOrgIds, setOnboardedOrgIds] = useState>(() => new Set()); - - // Skip invite onboarding if the org already has more than 1 member (creator + others) - const isOnboarding = Boolean( - routeOrgId && - !onboardedOrgIds.has(routeOrgId) && - route.page !== "join" && - (activeOrg ? Number(activeOrg.memberCount) <= 1 : true), - ); - const completeOnboarding = () => { - if (routeOrgId) { - setOnboardedOrgIds((prev) => new Set([...prev, routeOrgId])); - } - }; const isGuest = route.page === "join"; const [showGuestJoin, setShowGuestJoin] = useState(false); @@ -133,13 +120,6 @@ function App() { } }, [indexedRoles, syncIndexedRoles]); - // Skip onboarding screen — just mark it complete - useEffect(() => { - if (isOnboarding && activeOrg) { - completeOnboarding(); - } - }, [isOnboarding, activeOrg]); // eslint-disable-line react-hooks/exhaustive-deps - // ── Public (wallet-less) org surface ───────────────────────────────────── // Must render BEFORE the auth gate so incognito / no-wallet visitors work. @@ -230,21 +210,30 @@ function App() { ); } - return navigate({ page: "constitution" })} />; + return ( + navigate({ page: "constitution" })} + onBrowsePublic={() => navigate({ page: "explore" })} + /> + ); } // ── Org list ───────────────────────────────────────────────────────────── if (!routeOrgId) { return ( -
+