diff --git a/CLAUDE.md b/CLAUDE.md index 5dee138..d526445 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ pnpm + turborepo monorepo. Node 24, pnpm only (`preinstall` blocks npm/yarn). - `packages/contracts` — Solidity contracts (Foundry), deploy scripts, wagmi-generated TS bindings in `generated/`. Exports `@hollab-io/contracts/actions`. - `packages/dao-contracts` — DAO layer contract extensions (ERC20Votes governor, timelock, treasury). -- `packages/hollab-sdk` (`@hollab-io/hollab-sdk`) — Private data layer SDK: key management, encrypted 0G storage, event indexer. `tsc` build. +- `packages/hollab-sdk` (`@hollab-io/hollab-sdk`) — Private data layer SDK: key management, encrypted storage, event indexer. `tsc` build. - `packages/agent-sdk` (`@hollab-io/agent-sdk`) — SDK for agents to integrate with hollab.eth governance. `tsup` build. Depends on `contracts` + `indexing-client`. - `packages/indexing-client` — Typed client for the Ponder indexer. `tsup` build. - `packages/viem-extension` — Viem client extensions. @@ -39,7 +39,7 @@ forge build forge test -vvv forge coverage pnpm generate # wagmi generate → generated/ TS bindings -pnpm deploy:infra:sepolia # DeployInfrastructure.s.sol (also :mainnet, :0g-testnet, :0g-mainnet, :local) +pnpm deploy:infra:sepolia # DeployInfrastructure.s.sol (also :mainnet, :local) pnpm deploy:org:sepolia # CreateOrganization.s.sol ``` @@ -109,9 +109,11 @@ on-chain events └─ agent-sdk ``` -Frontend reads go through `useQuery` hooks wrapping the indexing-client; writes go through `useMutation` wrapping wagmi/viem calls. Multi-chain config (Sepolia default, Mainnet, 0G) lives in `apps/hola-modern/src/config/chains.ts`. +Frontend reads go through `useQuery` hooks wrapping the indexing-client; writes go through `useMutation` wrapping wagmi/viem calls. Multi-chain config (Sepolia default, Mainnet coming soon) lives in `apps/hola-modern/src/config/chains.ts`. -**Private data layer** (`packages/hollab-sdk`): ContentRef hashes on-chain point to encrypted blobs stored on 0G. SDK handles key management and encrypted storage so proposal content / meeting transcripts stay off-chain but verifiably committed. +**Private data layer** (`packages/hollab-sdk`): ContentRef hashes on-chain point to encrypted blobs stored on IPFS. SDK handles key management and encrypted storage so proposal content / meeting transcripts stay off-chain but verifiably committed. + +**IPFS pin proxy** (`apps/hollab-indexing`): the indexer's Hono API exposes `POST /storage/pin` and (in local mode) `GET /ipfs/:cid`. When `PINATA_JWT` is set the proxy forwards uploads to Pinata; otherwise it falls back to a local filesystem store under `LOCAL_IPFS_DIR` (default `.ponder/local-ipfs`). Both branches return CIDv0; the frontend extracts the 32-byte sha2-256 multihash digest as `bytes32` for on-chain `ContentRef.contentHash`. Reads in production hit `VITE_IPFS_GATEWAY/ipfs/`; in local dev, point that env at the indexer URL. ## Conventions diff --git a/README.md b/README.md index 2d3feff..03f5bf3 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ This is the full loop: **create** an org, **structure** it through governance, * ## Status (What Works Today) - **Proposal lifecycle end-to-end on-chain** — `createProposal` → `raiseObjection` / `resolveObjection` → `adopt` / `discard`, indexed and queryable. `executeGovernance` has been removed; the lifecycle primitives are the only path. -- **Readable tensions on-chain** — `createProposalWithTension(...)` publishes plaintext via a `ProposalTensionPublished` event; the on-chain `tensionHash` is derived (`keccak256`) so the content address stays canonical. Long or sensitive content can still commit only a hash and resolve via IPFS / 0G / encrypted backends. +- **Readable tensions on-chain** — `createProposalWithTension(...)` publishes plaintext via a `ProposalTensionPublished` event; the on-chain `tensionHash` is derived (`keccak256`) so the content address stays canonical. Long or sensitive content can still commit only a hash and resolve via IPFS-pinned encrypted blobs. - **Agent-native governance** — attribution-only proposer model (propose as a member or as a role you lead), per-org `MAX_PROPOSAL_AGE` (default 7 days, bounds [1 hour, 30 days]), and async proposal queues instead of mandatory synchronous meetings. See [`specs/99`](./specs/99-agent-native-divergence.md). - **ERC-8004 agent identity** — members can bind an agent NFT to their org identity via `OrganizationInstance.linkAgentIdentity(...)`; ownership is verified on-chain, enabling an agent-native surface on top of the same governance primitives. - **Officer bootstrap + election lock** — admins can set a circle's Facilitator / Secretary to any address (human or autonomous agent) pre-election; `isFacilitatorElected` / `isSecretaryElected` flip once a corresponding election adopts, after which the bootstrap setters are locked and governance owns the role. @@ -93,7 +93,7 @@ The design principle is simple: **on-chain for commitments, off-chain for coordi Meeting coordination events (IDM steps, agenda items, nominations) are emitted as **events only** — the indexer reconstructs the full meeting state, but the chain only stores what matters: who ended up in which role, and which governance changes were adopted. -Proposals may include a short plaintext tension inline — published via the `ProposalTensionPublished` event log — or a **ContentRef** hash pointing to off-chain encrypted content (IPFS / 0G). The ledger proves _that_ a proposal with specific content was adopted: short tensions stay readable without any off-chain retrieval, longer or private content stays addressable but off-chain. +Proposals may include a short plaintext tension inline — published via the `ProposalTensionPublished` event log — or a **ContentRef** hash pointing to off-chain encrypted content (IPFS). The ledger proves _that_ a proposal with specific content was adopted: short tensions stay readable without any off-chain retrieval, longer or private content stays addressable but off-chain. ## Architecture diff --git a/apps/docs/docs/pages/contracts/meeting-factory.mdx b/apps/docs/docs/pages/contracts/meeting-factory.mdx index 93ea142..37a0d09 100644 --- a/apps/docs/docs/pages/contracts/meeting-factory.mdx +++ b/apps/docs/docs/pages/contracts/meeting-factory.mdx @@ -76,7 +76,7 @@ Any org member can create a proposal or raise an objection. Only org admins can | `circleId` | Circle the change targets (anchor = 0) | | `proposer` | `msg.sender` at create time | | `proposerRoleId` | 0 if the proposer is not currently a role lead | -| `tensionHash` | `bytes32` — CIDv1 / 0G root / keccak256 of off-chain tension text | +| `tensionHash` | `bytes32` — sha2-256 digest of an IPFS CIDv0, or keccak256 inline | | `changeType` | `HolacracyTypes.ChangeType` (CreateRole, AmendRole, Election, …) | | `changeData` | ABI-encoded payload, decoded by `_applyChange` on adopt | | `status` | `Draft` → `Adopted` or `Discarded` (MVP uses these three) | diff --git a/apps/docs/docs/pages/contracts/role-registry.mdx b/apps/docs/docs/pages/contracts/role-registry.mdx index d9f9dcc..34881f7 100644 --- a/apps/docs/docs/pages/contracts/role-registry.mdx +++ b/apps/docs/docs/pages/contracts/role-registry.mdx @@ -85,7 +85,7 @@ function createRoleWithRefs( ) external returns (uint256 roleId) ``` -This supports the hybrid on-chain/off-chain model — role names and structure live on-chain, while detailed descriptions can be stored encrypted on 0G Network or IPFS. +This supports the hybrid on-chain/off-chain model — role names and structure live on-chain, while detailed descriptions can be stored encrypted on IPFS (pinned via the indexer pin-proxy). ## Circle Tracking diff --git a/apps/docs/docs/pages/introduction.mdx b/apps/docs/docs/pages/introduction.mdx index 243a756..730f8ba 100644 --- a/apps/docs/docs/pages/introduction.mdx +++ b/apps/docs/docs/pages/introduction.mdx @@ -19,12 +19,12 @@ hollab.eth replaces top-down hierarchy with a structured system of **roles**, ** The system separates **onchain commitments** from **offchain coordination**: -| Onchain | Offchain | -| --------------------------------------- | --------------------------------------- | -| Organization structure (roles, teams) | Sync facilitation | -| Governance outcomes (adopted proposals) | Proposal content & discussions | -| Membership & voting power | Encrypted private data (via 0G Network) | -| Token balances & delegation | Calendar scheduling | +| Onchain | Offchain | +| --------------------------------------- | --------------------------------- | +| Organization structure (roles, teams) | Sync facilitation | +| Governance outcomes (adopted proposals) | Proposal content & discussions | +| Membership & voting power | Encrypted private data (via IPFS) | +| Token balances & delegation | Calendar scheduling | Smart contracts enforce the rules — only proposal reviews can modify org structure, voting follows token-weighted power, and membership is tracked transparently. @@ -34,15 +34,15 @@ hollab.eth is designed for both humans and AI agents. The agent SDK allows progr ## Stack -| Layer | Technology | -| ---------- | ------------------------------------------- | -| Contracts | Solidity, Foundry, OpenZeppelin | -| Indexer | Ponder (event indexing → GraphQL) | -| Frontend | React 19, Vite, Wagmi, RainbowKit | -| SDK | TypeScript, HKDF key derivation, 0G Network | -| Agent SDK | TypeScript, viem, framework-agnostic | -| Identity | ENS subdomains (`myorg.hollab.eth`) | -| Deployment | IPFS via omnipin, ENS contenthash | +| Layer | Technology | +| ---------- | ------------------------------------- | +| Contracts | Solidity, Foundry, OpenZeppelin | +| Indexer | Ponder (event indexing → GraphQL) | +| Frontend | React 19, Vite, Wagmi, RainbowKit | +| SDK | TypeScript, HKDF key derivation, IPFS | +| Agent SDK | TypeScript, viem, framework-agnostic | +| Identity | ENS subdomains (`myorg.hollab.eth`) | +| Deployment | IPFS via omnipin, ENS contenthash | ## Chains diff --git a/apps/docs/docs/pages/operations/docker.mdx b/apps/docs/docs/pages/operations/docker.mdx index 8400231..6e45758 100644 --- a/apps/docs/docs/pages/operations/docker.mdx +++ b/apps/docs/docs/pages/operations/docker.mdx @@ -52,17 +52,18 @@ Concurrency groups cancel in-progress runs on the same branch. ### IPFS Deployment -On push to `release` branch (or manual trigger), `deploy-ipfs.yml`: +Frontend deploys are **not** run in CI — they go through a local script, [`scripts/deploy-ipfs.sh`](https://github.com/hollab-io/hollab-cannes-26/blob/dev/scripts/deploy-ipfs.sh), so the deployer key never lives in CI and every production push is multisig-approved. The script: -1. Builds the frontend with production env vars -2. Deploys to IPFS via **omnipin** (Filecoin pinning) -3. Updates the `hollab.eth` ENS **contenthash** through a Safe multisig transaction +1. Builds the frontend with production env vars (`pnpm --filter hola-modern build`) +2. Pins `dist/` to IPFS via **omnipin** (Filecoin pinning) +3. Emits the new CID plus the encoded `setContenthash` calldata for a human-signed **Safe** transaction that updates the `hollab.eth` ENS **contenthash** -Required secrets: +Required env (local shell, not CI secrets): - `OMNIPIN_FILECOIN_TOKEN` — Filecoin pinning API token -- `OMNIPIN_PK` — Deployer private key -- `SAFE_ADDRESS` — Safe multisig address for ENS updates +- production `VITE_*` build vars (gateway, indexer URLs, WalletConnect project id) — typically in `apps/hola-modern/.env.production` + +See [IPFS Deployment](/operations/ipfs) for the full flow. ### Shared Setup diff --git a/apps/docs/docs/pages/operations/ipfs.mdx b/apps/docs/docs/pages/operations/ipfs.mdx index ee983a7..da7f061 100644 --- a/apps/docs/docs/pages/operations/ipfs.mdx +++ b/apps/docs/docs/pages/operations/ipfs.mdx @@ -37,14 +37,20 @@ The frontend uses hash-based routing (`#/org/:id/tactical`) instead of path-base - There is no server to handle path-based routing fallbacks - Hash fragments (`#/...`) are handled entirely client-side -## Manual Deployment +## Deployment -```bash -# Build the frontend -pnpm --filter hola-modern build +Deployment is driven by a single local script, [`scripts/deploy-ipfs.sh`](https://github.com/hollab-io/hollab-cannes-26/blob/dev/scripts/deploy-ipfs.sh): -# The dist/ folder is ready for IPFS upload -ls apps/hola-modern/dist/ +```bash +# Pin token in env; production VITE_* vars in apps/hola-modern/.env.production +OMNIPIN_FILECOIN_TOKEN=… ./scripts/deploy-ipfs.sh ``` -The actual IPFS upload and ENS update are handled by the `deploy-ipfs.yml` GitHub Action, which uses omnipin CLI tools and Safe SDK for the multisig transaction. +The script: + +1. **Preflights** the required env (pin token, production `VITE_*` vars) and the `omnipin` CLI +2. **Builds** the frontend (`pnpm --filter hola-modern build`, including the `dist/index.html` → `dist/404.html` copy) +3. **Pins** `apps/hola-modern/dist/` to IPFS/Filecoin via omnipin and captures the CID +4. **Emits** the resulting CID, a gateway preview URL, and the encoded `setContenthash` calldata to paste into the **Safe Transaction Builder** + +The script never submits the multisig transaction — the `hollab.eth` contenthash update is a human-signed Safe step (see [ENS Contenthash](#ens-contenthash) above). This keeps the deployer key out of CI and ensures every production push is multisig-approved. diff --git a/apps/docs/docs/pages/sdk/agent-sdk.mdx b/apps/docs/docs/pages/sdk/agent-sdk.mdx index 80a8b9b..16f181d 100644 --- a/apps/docs/docs/pages/sdk/agent-sdk.mdx +++ b/apps/docs/docs/pages/sdk/agent-sdk.mdx @@ -114,7 +114,7 @@ import { ChangeType, encodeCreateRole } from "@hollab-io/agent-sdk"; // 1. Any member creates a Draft proposal. tensionText is hashed client-side // with keccak256 — only the hash is committed on-chain. Pass tensionHash -// directly if the full text is already pinned to IPFS / 0G. +// directly if the full text is already pinned to IPFS. const { proposalId } = await agent.governance.createProposal(meetingFactoryAddr, { orgId, circleId: 1n, diff --git a/apps/docs/docs/pages/sdk/key-management.mdx b/apps/docs/docs/pages/sdk/key-management.mdx index 8afbf19..9233771 100644 --- a/apps/docs/docs/pages/sdk/key-management.mdx +++ b/apps/docs/docs/pages/sdk/key-management.mdx @@ -53,7 +53,7 @@ This maps directly to the `ContentRef` struct used in RoleRegistry for on-chain ## Stream IDs -Data is addressed on the 0G Network using stream IDs built from org/circle/role identifiers: +Data is addressed by content hash on IPFS; logical stream IDs built from org/circle/role identifiers name each blob and resolve to its latest CID: ```ts import { buildCircleMetaKey, buildOrgMetaKey, buildStreamId } from "@hollab-io/hollab-sdk"; diff --git a/apps/docs/docs/pages/sdk/overview.mdx b/apps/docs/docs/pages/sdk/overview.mdx index 3149db4..6916de7 100644 --- a/apps/docs/docs/pages/sdk/overview.mdx +++ b/apps/docs/docs/pages/sdk/overview.mdx @@ -4,7 +4,7 @@ The `hollab-sdk` package (`packages/hollab-sdk/`) provides cryptographic key man ## Purpose -While smart contracts handle on-chain commitments (org structure, votes, membership), some governance data is better kept private — internal issues, draft proposals, role descriptions, and meeting notes. The SDK provides end-to-end encryption for this data using the 0G Network for storage. +While smart contracts handle on-chain commitments (org structure, votes, membership), some governance data is better kept private — internal issues, draft proposals, role descriptions, and meeting notes. The SDK provides end-to-end encryption for this data using IPFS for storage (encrypted blobs pinned via the indexer pin-proxy). For programmatic access by AI agents and automated systems, see the [Agent SDK](/sdk/agent-sdk). @@ -31,14 +31,21 @@ See [Key Management](/sdk/key-management) for details. ### StorageClient -Interacts with 0G Network for encrypted blob storage: +Interacts with IPFS (via the indexer pin-proxy) for encrypted blob storage: ```ts import { StorageClient } from "@hollab-io/hollab-sdk"; -const storage = new StorageClient(config); +const storage = new StorageClient(keyManager, { + pinUrl: "http://localhost:42069/storage/pin", + gatewayUrl: "http://localhost:42069", +}); ``` +Writes encrypt and pin a blob, returning its bytes32 `contentHash`; reads take +that hash, fetch from the gateway, and decrypt. The logical-key → `contentHash` +mapping lives on-chain (`ContentRef`). + ### OrgClient High-level client for reading and writing organization data: diff --git a/apps/docs/vocs.config.ts.timestamp-1776324288003-c20653bf328678.mjs b/apps/docs/vocs.config.ts.timestamp-1776324288003-c20653bf328678.mjs deleted file mode 100644 index 304a7aa..0000000 --- a/apps/docs/vocs.config.ts.timestamp-1776324288003-c20653bf328678.mjs +++ /dev/null @@ -1,73 +0,0 @@ -// vocs.config.ts -import { defineConfig } from "file:///Users/skas/Documents/GitHub/hollab-cannes-26/node_modules/.pnpm/vocs@1.0.0-alpha.62_@types+node@24.12.2_@types+react-dom@19.2.3_@types+react@19.2.14__@types+_fsx26vbbj3odmcmqybrctfi7g4/node_modules/vocs/_lib/index.js"; - -var vocs_config_default = defineConfig({ - title: "hollab.eth", - description: "The onchain operating system for autonomous organizations", - vite: { - server: { - port: 1243, - }, - }, - sidebar: [ - { - text: "Introduction", - items: [ - { text: "What is hollab.eth", link: "/introduction" }, - { text: "Architecture", link: "/architecture" }, - ], - }, - { - text: "Getting Started", - items: [ - { text: "Installation", link: "/getting-started/installation" }, - { text: "Local Development", link: "/getting-started/local-dev" }, - ], - }, - { - text: "Contracts", - items: [ - { text: "Overview", link: "/contracts/overview" }, - { text: "OrganizationFactory", link: "/contracts/organization-factory" }, - { text: "RoleRegistry", link: "/contracts/role-registry" }, - { text: "MeetingFactory", link: "/contracts/meeting-factory" }, - { text: "ActionVoting", link: "/contracts/action-voting" }, - { text: "GovToken", link: "/contracts/gov-token" }, - { text: "ENS Integration", link: "/contracts/ens" }, - { text: "Deployment", link: "/contracts/deployment" }, - ], - }, - { - text: "Indexer", - items: [ - { text: "Ponder Indexer", link: "/indexer/ponder" }, - { text: "Indexing Client", link: "/indexer/client" }, - ], - }, - { - text: "SDK", - items: [ - { text: "Overview", link: "/sdk/overview" }, - { text: "Key Management", link: "/sdk/key-management" }, - { text: "Viem Extension", link: "/sdk/viem-extension" }, - { text: "Agent SDK", link: "/sdk/agent-sdk" }, - ], - }, - { - text: "Frontend", - items: [ - { text: "App Structure", link: "/frontend/structure" }, - { text: "User Journey", link: "/frontend/user-journey" }, - ], - }, - { - text: "Operations", - items: [ - { text: "Docker & CI/CD", link: "/operations/docker" }, - { text: "IPFS Deployment", link: "/operations/ipfs" }, - ], - }, - ], -}); -export { vocs_config_default as default }; -//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsidm9jcy5jb25maWcudHMiXSwKICAic291cmNlc0NvbnRlbnQiOiBbImNvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9kaXJuYW1lID0gXCIvVXNlcnMvc2thcy9Eb2N1bWVudHMvR2l0SHViL2hvbGxhYi1jYW5uZXMtMjYvYXBwcy9kb2NzXCI7Y29uc3QgX192aXRlX2luamVjdGVkX29yaWdpbmFsX2ZpbGVuYW1lID0gXCIvVXNlcnMvc2thcy9Eb2N1bWVudHMvR2l0SHViL2hvbGxhYi1jYW5uZXMtMjYvYXBwcy9kb2NzL3ZvY3MuY29uZmlnLnRzXCI7Y29uc3QgX192aXRlX2luamVjdGVkX29yaWdpbmFsX2ltcG9ydF9tZXRhX3VybCA9IFwiZmlsZTovLy9Vc2Vycy9za2FzL0RvY3VtZW50cy9HaXRIdWIvaG9sbGFiLWNhbm5lcy0yNi9hcHBzL2RvY3Mvdm9jcy5jb25maWcudHNcIjtpbXBvcnQgeyBkZWZpbmVDb25maWcgfSBmcm9tIFwidm9jc1wiO1xuXG5leHBvcnQgZGVmYXVsdCBkZWZpbmVDb25maWcoe1xuICAgIHRpdGxlOiBcImhvbGxhYi5ldGhcIixcbiAgICBkZXNjcmlwdGlvbjogXCJUaGUgb25jaGFpbiBvcGVyYXRpbmcgc3lzdGVtIGZvciBhdXRvbm9tb3VzIG9yZ2FuaXphdGlvbnNcIixcbiAgICB2aXRlOiB7XG4gICAgICAgIHNlcnZlcjoge1xuICAgICAgICAgICAgcG9ydDogMTI0MyxcbiAgICAgICAgfSxcbiAgICB9LFxuICAgIHNpZGViYXI6IFtcbiAgICAgICAge1xuICAgICAgICAgICAgdGV4dDogXCJJbnRyb2R1Y3Rpb25cIixcbiAgICAgICAgICAgIGl0ZW1zOiBbXG4gICAgICAgICAgICAgICAgeyB0ZXh0OiBcIldoYXQgaXMgaG9sbGFiLmV0aFwiLCBsaW5rOiBcIi9pbnRyb2R1Y3Rpb25cIiB9LFxuICAgICAgICAgICAgICAgIHsgdGV4dDogXCJBcmNoaXRlY3R1cmVcIiwgbGluazogXCIvYXJjaGl0ZWN0dXJlXCIgfSxcbiAgICAgICAgICAgIF0sXG4gICAgICAgIH0sXG4gICAgICAgIHtcbiAgICAgICAgICAgIHRleHQ6IFwiR2V0dGluZyBTdGFydGVkXCIsXG4gICAgICAgICAgICBpdGVtczogW1xuICAgICAgICAgICAgICAgIHsgdGV4dDogXCJJbnN0YWxsYXRpb25cIiwgbGluazogXCIvZ2V0dGluZy1zdGFydGVkL2luc3RhbGxhdGlvblwiIH0sXG4gICAgICAgICAgICAgICAgeyB0ZXh0OiBcIkxvY2FsIERldmVsb3BtZW50XCIsIGxpbms6IFwiL2dldHRpbmctc3RhcnRlZC9sb2NhbC1kZXZcIiB9LFxuICAgICAgICAgICAgXSxcbiAgICAgICAgfSxcbiAgICAgICAge1xuICAgICAgICAgICAgdGV4dDogXCJDb250cmFjdHNcIixcbiAgICAgICAgICAgIGl0ZW1zOiBbXG4gICAgICAgICAgICAgICAgeyB0ZXh0OiBcIk92ZXJ2aWV3XCIsIGxpbms6IFwiL2NvbnRyYWN0cy9vdmVydmlld1wiIH0sXG4gICAgICAgICAgICAgICAgeyB0ZXh0OiBcIk9yZ2FuaXphdGlvbkZhY3RvcnlcIiwgbGluazogXCIvY29udHJhY3RzL29yZ2FuaXphdGlvbi1mYWN0b3J5XCIgfSxcbiAgICAgICAgICAgICAgICB7IHRleHQ6IFwiUm9sZVJlZ2lzdHJ5XCIsIGxpbms6IFwiL2NvbnRyYWN0cy9yb2xlLXJlZ2lzdHJ5XCIgfSxcbiAgICAgICAgICAgICAgICB7IHRleHQ6IFwiTWVldGluZ0ZhY3RvcnlcIiwgbGluazogXCIvY29udHJhY3RzL21lZXRpbmctZmFjdG9yeVwiIH0sXG4gICAgICAgICAgICAgICAgeyB0ZXh0OiBcIkFjdGlvblZvdGluZ1wiLCBsaW5rOiBcIi9jb250cmFjdHMvYWN0aW9uLXZvdGluZ1wiIH0sXG4gICAgICAgICAgICAgICAgeyB0ZXh0OiBcIkdvdlRva2VuXCIsIGxpbms6IFwiL2NvbnRyYWN0cy9nb3YtdG9rZW5cIiB9LFxuICAgICAgICAgICAgICAgIHsgdGV4dDogXCJFTlMgSW50ZWdyYXRpb25cIiwgbGluazogXCIvY29udHJhY3RzL2Vuc1wiIH0sXG4gICAgICAgICAgICAgICAgeyB0ZXh0OiBcIkRlcGxveW1lbnRcIiwgbGluazogXCIvY29udHJhY3RzL2RlcGxveW1lbnRcIiB9LFxuICAgICAgICAgICAgXSxcbiAgICAgICAgfSxcbiAgICAgICAge1xuICAgICAgICAgICAgdGV4dDogXCJJbmRleGVyXCIsXG4gICAgICAgICAgICBpdGVtczogW1xuICAgICAgICAgICAgICAgIHsgdGV4dDogXCJQb25kZXIgSW5kZXhlclwiLCBsaW5rOiBcIi9pbmRleGVyL3BvbmRlclwiIH0sXG4gICAgICAgICAgICAgICAgeyB0ZXh0OiBcIkluZGV4aW5nIENsaWVudFwiLCBsaW5rOiBcIi9pbmRleGVyL2NsaWVudFwiIH0sXG4gICAgICAgICAgICBdLFxuICAgICAgICB9LFxuICAgICAgICB7XG4gICAgICAgICAgICB0ZXh0OiBcIlNES1wiLFxuICAgICAgICAgICAgaXRlbXM6IFtcbiAgICAgICAgICAgICAgICB7IHRleHQ6IFwiT3ZlcnZpZXdcIiwgbGluazogXCIvc2RrL292ZXJ2aWV3XCIgfSxcbiAgICAgICAgICAgICAgICB7IHRleHQ6IFwiS2V5IE1hbmFnZW1lbnRcIiwgbGluazogXCIvc2RrL2tleS1tYW5hZ2VtZW50XCIgfSxcbiAgICAgICAgICAgICAgICB7IHRleHQ6IFwiVmllbSBFeHRlbnNpb25cIiwgbGluazogXCIvc2RrL3ZpZW0tZXh0ZW5zaW9uXCIgfSxcbiAgICAgICAgICAgICAgICB7IHRleHQ6IFwiQWdlbnQgU0RLXCIsIGxpbms6IFwiL3Nkay9hZ2VudC1zZGtcIiB9LFxuICAgICAgICAgICAgXSxcbiAgICAgICAgfSxcbiAgICAgICAge1xuICAgICAgICAgICAgdGV4dDogXCJGcm9udGVuZFwiLFxuICAgICAgICAgICAgaXRlbXM6IFtcbiAgICAgICAgICAgICAgICB7IHRleHQ6IFwiQXBwIFN0cnVjdHVyZVwiLCBsaW5rOiBcIi9mcm9udGVuZC9zdHJ1Y3R1cmVcIiB9LFxuICAgICAgICAgICAgICAgIHsgdGV4dDogXCJVc2VyIEpvdXJuZXlcIiwgbGluazogXCIvZnJvbnRlbmQvdXNlci1qb3VybmV5XCIgfSxcbiAgICAgICAgICAgIF0sXG4gICAgICAgIH0sXG4gICAgICAgIHtcbiAgICAgICAgICAgIHRleHQ6IFwiT3BlcmF0aW9uc1wiLFxuICAgICAgICAgICAgaXRlbXM6IFtcbiAgICAgICAgICAgICAgICB7IHRleHQ6IFwiRG9ja2VyICYgQ0kvQ0RcIiwgbGluazogXCIvb3BlcmF0aW9ucy9kb2NrZXJcIiB9LFxuICAgICAgICAgICAgICAgIHsgdGV4dDogXCJJUEZTIERlcGxveW1lbnRcIiwgbGluazogXCIvb3BlcmF0aW9ucy9pcGZzXCIgfSxcbiAgICAgICAgICAgIF0sXG4gICAgICAgIH0sXG4gICAgXSxcbn0pO1xuIl0sCiAgIm1hcHBpbmdzIjogIjtBQUF1VixTQUFTLG9CQUFvQjtBQUVwWCxJQUFPLHNCQUFRLGFBQWE7QUFBQSxFQUN4QixPQUFPO0FBQUEsRUFDUCxhQUFhO0FBQUEsRUFDYixNQUFNO0FBQUEsSUFDRixRQUFRO0FBQUEsTUFDSixNQUFNO0FBQUEsSUFDVjtBQUFBLEVBQ0o7QUFBQSxFQUNBLFNBQVM7QUFBQSxJQUNMO0FBQUEsTUFDSSxNQUFNO0FBQUEsTUFDTixPQUFPO0FBQUEsUUFDSCxFQUFFLE1BQU0sc0JBQXNCLE1BQU0sZ0JBQWdCO0FBQUEsUUFDcEQsRUFBRSxNQUFNLGdCQUFnQixNQUFNLGdCQUFnQjtBQUFBLE1BQ2xEO0FBQUEsSUFDSjtBQUFBLElBQ0E7QUFBQSxNQUNJLE1BQU07QUFBQSxNQUNOLE9BQU87QUFBQSxRQUNILEVBQUUsTUFBTSxnQkFBZ0IsTUFBTSxnQ0FBZ0M7QUFBQSxRQUM5RCxFQUFFLE1BQU0scUJBQXFCLE1BQU0sNkJBQTZCO0FBQUEsTUFDcEU7QUFBQSxJQUNKO0FBQUEsSUFDQTtBQUFBLE1BQ0ksTUFBTTtBQUFBLE1BQ04sT0FBTztBQUFBLFFBQ0gsRUFBRSxNQUFNLFlBQVksTUFBTSxzQkFBc0I7QUFBQSxRQUNoRCxFQUFFLE1BQU0sdUJBQXVCLE1BQU0sa0NBQWtDO0FBQUEsUUFDdkUsRUFBRSxNQUFNLGdCQUFnQixNQUFNLDJCQUEyQjtBQUFBLFFBQ3pELEVBQUUsTUFBTSxrQkFBa0IsTUFBTSw2QkFBNkI7QUFBQSxRQUM3RCxFQUFFLE1BQU0sZ0JBQWdCLE1BQU0sMkJBQTJCO0FBQUEsUUFDekQsRUFBRSxNQUFNLFlBQVksTUFBTSx1QkFBdUI7QUFBQSxRQUNqRCxFQUFFLE1BQU0sbUJBQW1CLE1BQU0saUJBQWlCO0FBQUEsUUFDbEQsRUFBRSxNQUFNLGNBQWMsTUFBTSx3QkFBd0I7QUFBQSxNQUN4RDtBQUFBLElBQ0o7QUFBQSxJQUNBO0FBQUEsTUFDSSxNQUFNO0FBQUEsTUFDTixPQUFPO0FBQUEsUUFDSCxFQUFFLE1BQU0sa0JBQWtCLE1BQU0sa0JBQWtCO0FBQUEsUUFDbEQsRUFBRSxNQUFNLG1CQUFtQixNQUFNLGtCQUFrQjtBQUFBLE1BQ3ZEO0FBQUEsSUFDSjtBQUFBLElBQ0E7QUFBQSxNQUNJLE1BQU07QUFBQSxNQUNOLE9BQU87QUFBQSxRQUNILEVBQUUsTUFBTSxZQUFZLE1BQU0sZ0JBQWdCO0FBQUEsUUFDMUMsRUFBRSxNQUFNLGtCQUFrQixNQUFNLHNCQUFzQjtBQUFBLFFBQ3RELEVBQUUsTUFBTSxrQkFBa0IsTUFBTSxzQkFBc0I7QUFBQSxRQUN0RCxFQUFFLE1BQU0sYUFBYSxNQUFNLGlCQUFpQjtBQUFBLE1BQ2hEO0FBQUEsSUFDSjtBQUFBLElBQ0E7QUFBQSxNQUNJLE1BQU07QUFBQSxNQUNOLE9BQU87QUFBQSxRQUNILEVBQUUsTUFBTSxpQkFBaUIsTUFBTSxzQkFBc0I7QUFBQSxRQUNyRCxFQUFFLE1BQU0sZ0JBQWdCLE1BQU0seUJBQXlCO0FBQUEsTUFDM0Q7QUFBQSxJQUNKO0FBQUEsSUFDQTtBQUFBLE1BQ0ksTUFBTTtBQUFBLE1BQ04sT0FBTztBQUFBLFFBQ0gsRUFBRSxNQUFNLGtCQUFrQixNQUFNLHFCQUFxQjtBQUFBLFFBQ3JELEVBQUUsTUFBTSxtQkFBbUIsTUFBTSxtQkFBbUI7QUFBQSxNQUN4RDtBQUFBLElBQ0o7QUFBQSxFQUNKO0FBQ0osQ0FBQzsiLAogICJuYW1lcyI6IFtdCn0K diff --git a/apps/hola-modern/.env.example b/apps/hola-modern/.env.example index 6e4f795..1b1e998 100644 --- a/apps/hola-modern/.env.example +++ b/apps/hola-modern/.env.example @@ -1 +1,18 @@ +# WalletConnect — get a project ID at https://cloud.reown.com VITE_WALLETCONNECT_PROJECT_ID= + +# Indexer URL fallback (used when chain-specific override below is empty) +VITE_INDEXER_URL= + +# Per-chain indexer overrides +VITE_INDEXER_URL_LOCAL=http://localhost:42069 +VITE_INDEXER_URL_SEPOLIA= +VITE_INDEXER_URL_MAINNET= +VITE_INDEXER_URL_0G= + +# IPFS gateway used to fetch ContentRef blobs. +# - Production: a public gateway (e.g. https://gateway.pinata.cloud) or +# your dedicated gateway. Reads go to ${VITE_IPFS_GATEWAY}/ipfs/. +# - Local dev (no PINATA_JWT on the indexer): point this at the indexer URL, +# which serves blobs from its local mock under GET /ipfs/:cid. +VITE_IPFS_GATEWAY=http://localhost:42069 diff --git a/apps/hola-modern/index.html b/apps/hola-modern/index.html index f4bd9b4..0558d1e 100644 --- a/apps/hola-modern/index.html +++ b/apps/hola-modern/index.html @@ -29,6 +29,22 @@ hollab.eth + ); case "structure": + if (!activeOrg) { + return ( +
+ Loading organization… +
+ ); + } return ( setAutoOpenInvite(false)} diff --git a/apps/hola-modern/src/config/chains.ts b/apps/hola-modern/src/config/chains.ts index a2b8b0b..7bc124f 100644 --- a/apps/hola-modern/src/config/chains.ts +++ b/apps/hola-modern/src/config/chains.ts @@ -3,25 +3,10 @@ import { meetingComponentsFactoryAddress, organizationFactoryAddress, } from "@hollab-io/contracts/actions"; -import { defineChain } from "viem"; import { foundry, mainnet, sepolia } from "viem/chains"; const ZERO: Address = "0x0000000000000000000000000000000000000000"; -/** 0G Galileo Testnet chain definition */ -export const zgTestnet = defineChain({ - id: 16602, - name: "0G Galileo Testnet", - nativeCurrency: { name: "A0GI", symbol: "A0GI", decimals: 18 }, - rpcUrls: { - default: { http: ["https://evmrpc-testnet.0g.ai"] }, - }, - blockExplorers: { - default: { name: "0G Explorer", url: "https://chainscan-galileo.0g.ai" }, - }, - testnet: true, -}); - /** Look up a chain-keyed address map, falling back to zero. */ function addr(map: Record, chainId: number): Address { return (map[chainId] as Address) ?? ZERO; @@ -67,22 +52,11 @@ const mainnetConfig: ChainConfig = { indexerUrl: import.meta.env.VITE_INDEXER_URL_MAINNET ?? import.meta.env.VITE_INDEXER_URL ?? "", }; -// ── 0G Testnet (Galileo) ──────────────────────────────────────────────────── -const zgTestnetConfig: ChainConfig = { - chain: zgTestnet, - label: "0G Testnet", - enabled: true, - orgFactoryAddress: addr(organizationFactoryAddress, zgTestnet.id), - meetingFactoryAddress: addr(meetingComponentsFactoryAddress, zgTestnet.id), - indexerUrl: import.meta.env.VITE_INDEXER_URL_0G ?? "", -}; - // ── Registry ───────────────────────────────────────────────────────────────── const configs: Record = { [foundry.id]: localhostConfig, [sepolia.id]: sepoliaConfig, [mainnet.id]: mainnetConfig, - [zgTestnet.id]: zgTestnetConfig, }; // Default to localhost when local addresses are available, otherwise Sepolia diff --git a/apps/hola-modern/src/config/zeroG.ts b/apps/hola-modern/src/config/zeroG.ts deleted file mode 100644 index c6cf024..0000000 --- a/apps/hola-modern/src/config/zeroG.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** 0G Storage network configuration */ - -export const ZG_STORAGE_CONFIG = { - testnet: { - rpcUrl: "https://evmrpc-testnet.0g.ai", - indexerRpc: "https://indexer-storage-testnet-turbo.0g.ai", - chainId: 16602, - }, - mainnet: { - rpcUrl: "https://evmrpc.0g.ai", - indexerRpc: "https://indexer-storage-turbo.0g.ai", - chainId: 16661, - }, -} as const; - -/** Active network — override via VITE_ZG_NETWORK env var */ -const network = (import.meta.env.VITE_ZG_NETWORK ?? "testnet") as keyof typeof ZG_STORAGE_CONFIG; - -export const zgConfig = ZG_STORAGE_CONFIG[network]; diff --git a/apps/hola-modern/src/hooks/useEncryptedStorage.ts b/apps/hola-modern/src/hooks/useEncryptedStorage.ts index 4582ddb..60586dc 100644 --- a/apps/hola-modern/src/hooks/useEncryptedStorage.ts +++ b/apps/hola-modern/src/hooks/useEncryptedStorage.ts @@ -1,13 +1,16 @@ /** - * useEncryptedStorage — encrypt + upload to 0G / fetch + decrypt from 0G. + * useEncryptedStorage — encrypt + upload to IPFS / fetch + decrypt from IPFS. * * Composition hook combining: - * - useKeyManager (wallet-backed AES key derivation) - * - useZgStorage (0G blob upload/download) - * - KeyManager (AES encrypt/decrypt) + * - useKeyManager (wallet-backed AES key derivation) + * - useIpfsStorage (pin proxy upload + gateway 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). + * Upload returns the 32-byte sha2-256 multihash digest extracted from the + * resulting CIDv0, formatted as bytes32 — matches ContentRef.contentHash on + * chain directly. The wire format for encrypted payloads is a 2-byte + * nonce-length prefix followed by the nonce and ciphertext (mirrors + * hollab-sdk StorageClient). */ import type { EncryptedPayload } from "@hollab-io/hollab-sdk"; import { KeyManager } from "@hollab-io/hollab-sdk"; @@ -15,8 +18,8 @@ import { useMutation } from "@tanstack/react-query"; import type { DataVisibilityValue } from "./useContentRef"; import { DataVisibility } from "./useContentRef"; +import { downloadBytes, uploadBytes } from "./useIpfsStorage"; import { useKeyManager } from "./useKeyManager"; -import { downloadBytes, uploadBytes } from "./useZgStorage"; // ── Serialization (matches hollab-sdk StorageClient wire format) ───────────── @@ -46,13 +49,13 @@ export type EncryptAndUploadParams = { }; export type EncryptAndUploadResult = { - /** 0G Merkle root hash — stored on-chain as ContentRef.contentHash */ - rootHash: string; + /** bytes32 content hash — stored on-chain as ContentRef.contentHash */ + contentHash: `0x${string}`; }; export type FetchAndDecryptParams = { - /** 0G root hash (from on-chain ContentRef.contentHash) */ - rootHash: string; + /** bytes32 content hash (from on-chain ContentRef.contentHash) */ + contentHash: `0x${string}`; visibility: DataVisibilityValue; orgId: bigint; circleId: bigint; @@ -71,8 +74,7 @@ export function useEncryptedStorage() { const textBytes = new TextEncoder().encode(params.text); if (params.visibility === DataVisibility.Public) { - const { rootHash } = await uploadBytes(textBytes); - return { rootHash }; + return uploadBytes(textBytes); } // Encrypted path @@ -88,13 +90,12 @@ export function useEncryptedStorage() { const payload = km.encrypt(key, textBytes); const serialized = serializePayload(payload); - const { rootHash } = await uploadBytes(serialized); - return { rootHash }; + return uploadBytes(serialized); }, }); async function fetchAndDecrypt(params: FetchAndDecryptParams): Promise { - const bytes = await downloadBytes(params.rootHash); + const bytes = await downloadBytes(params.contentHash); if (params.visibility === DataVisibility.Public) { return new TextDecoder().decode(bytes); diff --git a/apps/hola-modern/src/hooks/useIpfsStorage.test.ts b/apps/hola-modern/src/hooks/useIpfsStorage.test.ts new file mode 100644 index 0000000..56cfa5b --- /dev/null +++ b/apps/hola-modern/src/hooks/useIpfsStorage.test.ts @@ -0,0 +1,91 @@ +/** + * useIpfsStorage round-trip tests. + * + * The load-bearing invariant of the 0G→IPFS migration: the frontend derives a + * bytes32 `contentHash` from the CID the indexer pin-proxy returns, then must + * reconstruct the *exact same CID* to read the blob back. We simulate the + * indexer's CID derivation with the same multiformats primitives it uses + * (sha2-256 multihash → CIDv0), so this test pins down the cross-component + * contract without any network. + */ +import { CID } from "multiformats/cid"; +import { sha256 } from "multiformats/hashes/sha2"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { downloadBytes, uploadBytes } from "./useIpfsStorage"; + +// Isolate the storage logic from chain config / localStorage / env. +vi.mock("../config/chains", () => ({ + DEFAULT_CHAIN_ID: 31337, + getChainConfig: () => ({ indexerUrl: "http://indexer.test" }), +})); + +/** Mirror the indexer pin-proxy's `localCidFor`: sha2-256 digest → CIDv0. */ +async function indexerCidFor(bytes: Uint8Array): Promise { + const mh = await sha256.digest(bytes); + return CID.createV0(mh).toString(); +} + +function hex(bytes: Uint8Array): string { + let s = ""; + for (const b of bytes) s += b.toString(16).padStart(2, "0"); + return s; +} + +const originalFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = originalFetch; + vi.restoreAllMocks(); +}); + +describe("useIpfsStorage round-trip", () => { + const payload = new TextEncoder().encode("a hollab tension worth resolving"); + + it("derives contentHash = the sha2-256 digest the indexer hashed into its CID", async () => { + const cid = await indexerCidFor(payload); + const digestHex = hex((await sha256.digest(payload)).digest); + + globalThis.fetch = vi.fn(async () => Response.json({ cid })) as typeof fetch; + + const { contentHash } = await uploadBytes(payload); + + // contentHash is exactly the 32-byte sha2-256 digest of the content. + expect(contentHash).toBe(`0x${digestHex}`); + expect(contentHash).toHaveLength(2 + 64); + }); + + it("reconstructs the indexer's exact CID when reading back (the round-trip)", async () => { + const cid = await indexerCidFor(payload); + + // 1. Upload: proxy returns the indexer-derived CID. + globalThis.fetch = vi.fn(async () => Response.json({ cid })) as typeof fetch; + const { contentHash } = await uploadBytes(payload); + + // 2. Download: capture the gateway URL the hook builds from contentHash. + let requestedUrl = ""; + globalThis.fetch = vi.fn(async (url: string | URL | Request) => { + requestedUrl = String(url); + const ab = new ArrayBuffer(payload.byteLength); + new Uint8Array(ab).set(payload); + return new Response(ab); + }) as typeof fetch; + + const out = await downloadBytes(contentHash); + + // The reconstructed CID must equal the CID the indexer originally stored. + expect(requestedUrl).toContain(`/ipfs/${cid}`); + expect(out).toEqual(payload); + }); + + it("rejects a contentHash that is not 32 bytes", async () => { + await expect(downloadBytes("0xdeadbeef" as `0x${string}`)).rejects.toThrow(/32-byte/); + }); + + it("surfaces a pin-proxy error instead of producing a bogus hash", async () => { + globalThis.fetch = vi.fn( + async () => new Response("payload too large", { status: 413 }), + ) as typeof fetch; + await expect(uploadBytes(payload)).rejects.toThrow(/413/); + }); +}); diff --git a/apps/hola-modern/src/hooks/useIpfsStorage.ts b/apps/hola-modern/src/hooks/useIpfsStorage.ts new file mode 100644 index 0000000..9390e13 --- /dev/null +++ b/apps/hola-modern/src/hooks/useIpfsStorage.ts @@ -0,0 +1,132 @@ +/** + * useIpfsStorage — browser-side IPFS uploads via a Pinata proxy. + * + * Uploads stream raw bytes to the indexer's POST /storage/pin endpoint + * (which holds the Pinata JWT). The proxy returns a CIDv0; this hook + * extracts the 32-byte sha2-256 multihash digest as a bytes32 hex string + * so the on-chain ContentRef.contentHash layout stays unchanged. + * + * Reads fetch from VITE_IPFS_GATEWAY by reconstructing the CIDv0 from the + * on-chain digest. + */ +import { useMutation } from "@tanstack/react-query"; +import { CID } from "multiformats/cid"; +import * as Digest from "multiformats/hashes/digest"; + +import { DEFAULT_CHAIN_ID, getChainConfig } from "../config/chains"; + +const SHA2_256_CODE = 0x12; +const DIGEST_BYTES = 32; +const PIN_PATH = "/storage/pin"; + +const DEFAULT_GATEWAY = "https://gateway.pinata.cloud"; + +export type UploadResult = { + /** 32-byte sha2-256 digest extracted from the CIDv0, formatted as bytes32. */ + contentHash: `0x${string}`; +}; + +function activeChainId(): number { + const stored = + typeof localStorage !== "undefined" ? localStorage.getItem("hollab:activeChainId") : null; + return stored ? Number(stored) : DEFAULT_CHAIN_ID; +} + +function pinUrl(): string { + const base = getChainConfig(activeChainId()).indexerUrl; + if (!base) { + throw new Error("Indexer URL not configured for the active chain — cannot upload to IPFS"); + } + return `${base.replace(/\/$/, "")}${PIN_PATH}`; +} + +function gatewayBase(): string { + return (import.meta.env.VITE_IPFS_GATEWAY ?? DEFAULT_GATEWAY).replace(/\/$/, ""); +} + +function bytesToHex(bytes: Uint8Array): `0x${string}` { + let hex = ""; + for (const b of bytes) hex += b.toString(16).padStart(2, "0"); + return `0x${hex}` as `0x${string}`; +} + +function hexToBytes(hex: string): Uint8Array { + const stripped = hex.startsWith("0x") ? hex.slice(2) : hex; + if (stripped.length !== DIGEST_BYTES * 2) { + throw new Error(`Expected ${DIGEST_BYTES}-byte hex, got ${stripped.length / 2} bytes`); + } + const out = new Uint8Array(DIGEST_BYTES); + for (let i = 0; i < DIGEST_BYTES; i++) { + out[i] = parseInt(stripped.slice(i * 2, i * 2 + 2), 16); + } + return out; +} + +function cidToContentHash(cid: string): `0x${string}` { + const parsed = CID.parse(cid); + const mh = parsed.multihash; + if (mh.code !== SHA2_256_CODE) { + throw new Error(`Unexpected multihash code ${mh.code}, want sha2-256 (0x12)`); + } + if (mh.digest.length !== DIGEST_BYTES) { + throw new Error(`Unexpected digest length ${mh.digest.length}, want ${DIGEST_BYTES}`); + } + return bytesToHex(mh.digest); +} + +function contentHashToCid(contentHash: `0x${string}`): string { + const digest = hexToBytes(contentHash); + const mh = Digest.create(SHA2_256_CODE, digest); + return CID.createV0(mh).toString(); +} + +/** Upload raw bytes to IPFS via the indexer pin proxy. */ +export async function uploadBytes(data: Uint8Array): Promise { + // Copy into a fresh ArrayBuffer so the Blob body type narrows correctly + // (lib.dom rejects Uint8Array as a BlobPart in TS 5.7+). + const ab = new ArrayBuffer(data.byteLength); + new Uint8Array(ab).set(data); + + const res = await fetch(pinUrl(), { + method: "POST", + headers: { "content-type": "application/octet-stream" }, + body: new Blob([ab]), + }); + if (!res.ok) { + const detail = await res.text().catch(() => ""); + throw new Error(`Pin proxy upload failed (${res.status}): ${detail}`); + } + const json = (await res.json()) as { cid?: string; error?: string }; + if (!json.cid) { + throw new Error(`Pin proxy returned no CID: ${json.error ?? "unknown error"}`); + } + return { contentHash: cidToContentHash(json.cid) }; +} + +/** Download raw bytes from IPFS by the on-chain bytes32 content hash. */ +export async function downloadBytes(contentHash: `0x${string}`): Promise { + const cid = contentHashToCid(contentHash); + const url = `${gatewayBase()}/ipfs/${cid}`; + const res = await fetch(url); + if (!res.ok) { + throw new Error(`IPFS gateway fetch failed (${res.status}) for ${cid}`); + } + return new Uint8Array(await res.arrayBuffer()); +} + +export function useIpfsStorage() { + const uploadText = useMutation({ + mutationFn: async (text: string): Promise => { + return uploadBytes(new TextEncoder().encode(text)); + }, + }); + + const uploadFile = useMutation({ + mutationFn: async (file: File): Promise => { + const buffer = await file.arrayBuffer(); + return uploadBytes(new Uint8Array(buffer)); + }, + }); + + return { uploadText, uploadFile }; +} diff --git a/apps/hola-modern/src/hooks/useOkrObjectives.ts b/apps/hola-modern/src/hooks/useOkrObjectives.ts index ac93ad4..0aea74b 100644 --- a/apps/hola-modern/src/hooks/useOkrObjectives.ts +++ b/apps/hola-modern/src/hooks/useOkrObjectives.ts @@ -2,7 +2,7 @@ * useOkrObjectives — reads the aggregate OKR blob for a (role, quarter) pair. * * Flow: read contentRef on-chain for fieldName = keccak256("okr:{quarter}") → - * download 0G blob by rootHash → decrypt with the role key → + * download IPFS blob by contentHash → decrypt with the role key → * parse JSON into OkrObjective[]. * * Writes go through governance via useCreateOkrObjective (AmendRoleWithRefs @@ -62,7 +62,7 @@ export function useOkrObjectives(params: { } const visibility = contentRef.visibility as DataVisibilityValue; const text = await fetchAndDecrypt({ - rootHash: contentRef.contentHash, + contentHash: contentRef.contentHash as `0x${string}`, visibility: visibility ?? DataVisibility.Public, orgId, circleId, diff --git a/apps/hola-modern/src/hooks/useOrgOkrs.ts b/apps/hola-modern/src/hooks/useOrgOkrs.ts index 69baedd..bac851b 100644 --- a/apps/hola-modern/src/hooks/useOrgOkrs.ts +++ b/apps/hola-modern/src/hooks/useOrgOkrs.ts @@ -3,7 +3,7 @@ * * Queries the indexer for every content ref across the org's RoleRegistry * matching fieldName = keccak256("okr:{quarter}"), then fetches + decrypts - * each 0G blob to produce a flat list of `OkrObjective` with their owning role. + * each IPFS blob to produce a flat list of `OkrObjective` with their owning role. */ import type { OkrObjective } from "@hollab-io/hollab-sdk"; import type { ContentRef } from "@hollab-io/indexing-client"; @@ -46,7 +46,7 @@ export function useOrgOkrs(params: { const visibility = ref.visibility as DataVisibilityValue; try { const text = await fetchAndDecrypt({ - rootHash: ref.contentHash, + contentHash: ref.contentHash as `0x${string}`, visibility: visibility ?? DataVisibility.Public, orgId, circleId: circleId ?? BigInt(0), diff --git a/apps/hola-modern/src/hooks/useRoleFieldContent.ts b/apps/hola-modern/src/hooks/useRoleFieldContent.ts index 0568a44..318b109 100644 --- a/apps/hola-modern/src/hooks/useRoleFieldContent.ts +++ b/apps/hola-modern/src/hooks/useRoleFieldContent.ts @@ -1,9 +1,9 @@ /** - * useRoleFieldContent — read a role field's content from 0G via on-chain ContentRef. + * useRoleFieldContent — read a role field's content from IPFS 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 + * 3. Downloads from IPFS + 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. @@ -50,11 +50,8 @@ export function useRoleFieldContent(params: { 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, + contentHash: contentRef.contentHash as `0x${string}`, visibility: contentRef.visibility as 0 | 1 | 2, orgId: params.orgId, circleId: params.circleId, diff --git a/apps/hola-modern/src/hooks/useSetOkrs.ts b/apps/hola-modern/src/hooks/useSetOkrs.ts index 6625f66..e74da28 100644 --- a/apps/hola-modern/src/hooks/useSetOkrs.ts +++ b/apps/hola-modern/src/hooks/useSetOkrs.ts @@ -2,7 +2,7 @@ * useSetOkrs — propose updating the OKR blob for a (role, quarter) pair. * * OKR changes go through governance: the caller uploads the new objectives - * to 0G and raises an AmendRoleWithRefs proposal that re-points the role's + * to IPFS and raises an AmendRoleWithRefs proposal that re-points the role's * on-chain content ref for fieldName=keccak256("okr:{quarter}") to the new * blob. Proposal adoption requires zero open objections (standard IDM). * @@ -39,7 +39,7 @@ export type SetOkrsParams = { export type SetOkrsResult = { txHash: `0x${string}`; - rootHash: string; + contentHash: `0x${string}`; }; export function useSetOkrs() { @@ -71,7 +71,7 @@ export function useSetOkrs() { // 2. Serialize + upload + encrypt. const text = JSON.stringify(params.objectives); - const { rootHash } = await encryptAndUpload.mutateAsync({ + const { contentHash } = await encryptAndUpload.mutateAsync({ text, visibility, orgId: params.orgId, @@ -79,9 +79,6 @@ export function useSetOkrs() { roleId: params.roleId, }); - const contentHash = - `0x${rootHash.replace(/^0x/, "").padStart(64, "0")}` as `0x${string}`; - // 3. Build AmendRoleWithRefs payload with existing role fields + new OKR ref. const encodedData = encodeAmendRoleWithRefs({ roleId: params.roleId, @@ -118,7 +115,7 @@ export function useSetOkrs() { address, ); - return { txHash, rootHash }; + return { txHash, contentHash }; }, onSuccess: (_result, params) => { queryClient.invalidateQueries({ diff --git a/apps/hola-modern/src/hooks/useZgStorage.ts b/apps/hola-modern/src/hooks/useZgStorage.ts deleted file mode 100644 index 65d902e..0000000 --- a/apps/hola-modern/src/hooks/useZgStorage.ts +++ /dev/null @@ -1,108 +0,0 @@ -/** - * useZgStorage — browser-side 0G Storage uploads. - * - * Uses the 0G SDK's MemData (for text) and ZgBlob (for File objects) - * which work in the browser without filesystem access. - * - * Requires ethers BrowserProvider to bridge the wallet signer to the - * 0G SDK's ethers-based upload API. - */ -// Use the SDK's /browser entry to avoid pulling `node:fs/promises` via ZgFile, -// which Rollup cannot bundle for the browser. The /browser subpath ships the -// same public API (Indexer, MemData) compiled for the browser target. -import { Indexer, MemData } from "@0gfoundation/0g-ts-sdk/browser"; -import { useMutation } from "@tanstack/react-query"; -import { BrowserProvider } from "ethers"; - -import { zgConfig } from "../config/zeroG"; - -async function getEthersSigner() { - if (!window.ethereum) { - throw new Error("No wallet provider found — connect a wallet first"); - } - const provider = new BrowserProvider(window.ethereum); - return provider.getSigner(); -} - -export type UploadResult = { - /** 0G Merkle root hash — needed for retrieval */ - rootHash: string; -}; - -/** - * Upload raw bytes to 0G Storage from the browser. - * Handles Indexer init, signer bridging, merkle tree generation, and upload. - */ -export async function uploadBytes(data: Uint8Array): Promise { - const signer = await getEthersSigner(); - const indexer = new Indexer(zgConfig.indexerRpc); - - const memData = new MemData(data); - const [tree, treeErr] = await memData.merkleTree(); - if (treeErr !== null) { - throw new Error(`Merkle tree generation failed: ${treeErr}`); - } - - const rootHash = tree!.rootHash(); - - const [, uploadErr] = await indexer.upload(memData, zgConfig.rpcUrl, signer); - if (uploadErr) { - throw new Error(`0G upload failed: ${(uploadErr as Error).message}`); - } - - 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({ - mutationFn: async (text: string): Promise => { - const bytes = new TextEncoder().encode(text); - return uploadBytes(bytes); - }, - }); - - /** Upload a File object to 0G Storage */ - const uploadFile = useMutation({ - mutationFn: async (file: File): Promise => { - const buffer = await file.arrayBuffer(); - return uploadBytes(new Uint8Array(buffer)); - }, - }); - - return { uploadText, uploadFile }; -} diff --git a/apps/hola-modern/src/shims/node-fs-promises.ts b/apps/hola-modern/src/shims/node-fs-promises.ts deleted file mode 100644 index deef33d..0000000 --- a/apps/hola-modern/src/shims/node-fs-promises.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * Browser shim for `node:fs/promises`. - * - * @0gfoundation/0g-ts-sdk's browser bundle still references fs/promises from - * its Node-only ZgFile class. Our hola-modern code never reaches ZgFile — we - * use MemData (bytes in memory) and ZgBlob (File objects). This module exists - * only to give Rollup a specifier to resolve. Anything that actually imports - * these at runtime throws a clear error so we notice immediately. - */ -function unsupported(name: string): () => never { - return () => { - throw new Error( - `[node:fs/promises shim] '${name}' was called in the browser. ` + - `Browser code paths must not touch the filesystem.`, - ); - }; -} - -export const open = unsupported("open"); -export const readFile = unsupported("readFile"); -export const writeFile = unsupported("writeFile"); -export const stat = unsupported("stat"); -export const unlink = unsupported("unlink"); -export const mkdir = unsupported("mkdir"); - -export default { - open, - readFile, - writeFile, - stat, - unlink, - mkdir, -}; diff --git a/apps/hola-modern/src/views/GovernanceMeetingRoom.tsx b/apps/hola-modern/src/views/GovernanceMeetingRoom.tsx index 7ac25c2..2dba9cf 100644 --- a/apps/hola-modern/src/views/GovernanceMeetingRoom.tsx +++ b/apps/hola-modern/src/views/GovernanceMeetingRoom.tsx @@ -94,7 +94,7 @@ type PendingGovernanceAction = { destinationCircleId?: string; // Encrypted storage visibility?: DataVisibilityValue; - contentRefs?: { fieldName: string; rootHash: string; visibility: number }[]; + contentRefs?: { fieldName: string; contentHash: `0x${string}`; visibility: number }[]; }; type ProposalDraft = { @@ -307,8 +307,7 @@ function buildGovernanceCalls( ...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}`, + contentHash: r.contentHash, visibility: r.visibility, })), }); @@ -336,8 +335,7 @@ function buildGovernanceCalls( ...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}`, + contentHash: r.contentHash, visibility: r.visibility, })), }); @@ -1813,7 +1811,7 @@ export default function GovernanceMeetingRoom({ const [isTxPending, setIsTxPending] = useState(false); const [txError, setTxError] = useState(null); - // Encrypted storage (0G + key management) + // Encrypted storage (IPFS + key management) const { encryptAndUpload, unlockKeys, isKeyUnlocked } = useEncryptedStorage(); // Proposal wizard state @@ -2099,8 +2097,10 @@ 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; + // Encrypt + upload to IPFS when visibility is non-public and this is a role action + let contentRefs: + | { fieldName: string; contentHash: `0x${string}`; visibility: number }[] + | undefined; const vis = proposalDraft.visibility; if ( isRoleAction && @@ -2128,14 +2128,14 @@ export default function GovernanceMeetingRoom({ : undefined; const results = await Promise.all( fields.map(async (f) => { - const { rootHash } = await encryptAndUpload.mutateAsync({ + const { contentHash } = await encryptAndUpload.mutateAsync({ text: f.text, visibility: vis, orgId: BigInt(orgId ?? "0"), circleId: cId, roleId: rId, }); - return { fieldName: f.name, rootHash, visibility: vis }; + return { fieldName: f.name, contentHash, visibility: vis }; }), ); contentRefs = results; diff --git a/apps/hola-modern/src/views/OkrProposalComposer.tsx b/apps/hola-modern/src/views/OkrProposalComposer.tsx index 6ac07ca..bc9f420 100644 --- a/apps/hola-modern/src/views/OkrProposalComposer.tsx +++ b/apps/hola-modern/src/views/OkrProposalComposer.tsx @@ -3,7 +3,7 @@ * * The caller must be a role lead of the receiving role (so the proposal is * raised under the representation rule). On submit, the aggregate objectives - * list is uploaded to 0G and an AmendRoleWithRefs proposal is created that + * list is uploaded to IPFS and an AmendRoleWithRefs proposal is created that * re-points the role's `keccak256("okr:{quarter}")` content ref. */ import type { OkrKeyResult, OkrObjective } from "@hollab-io/hollab-sdk"; diff --git a/apps/hola-modern/vite.config.ts b/apps/hola-modern/vite.config.ts index 991cab8..6e4e668 100644 --- a/apps/hola-modern/vite.config.ts +++ b/apps/hola-modern/vite.config.ts @@ -1,12 +1,7 @@ -import { fileURLToPath } from "node:url"; import react from "@vitejs/plugin-react"; import { defineConfig } from "vite"; import { nodePolyfills } from "vite-plugin-node-polyfills"; -const nodeFsPromisesStub = fileURLToPath( - new URL("./src/shims/node-fs-promises.ts", import.meta.url), -); - // https://vite.dev/config/ export default defineConfig({ plugins: [ @@ -16,16 +11,6 @@ export default defineConfig({ globals: { Buffer: true, global: true, process: true }, }), ], - resolve: { - alias: [ - // @0gfoundation/0g-ts-sdk's browser bundle still references - // node:fs/promises via ZgFile (unused in our browser code paths: - // useZgStorage only touches MemData + ZgBlob). Alias to a stub - // so Rollup can resolve the specifier without pulling Node APIs. - { find: "node:fs/promises", replacement: nodeFsPromisesStub }, - { find: /^fs\/promises$/, replacement: nodeFsPromisesStub }, - ], - }, // Use relative paths so the build works on IPFS (hollab.eth.limo) // where there's no server to rewrite absolute /assets/ paths. base: "./", diff --git a/apps/hollab-indexing/.env.example b/apps/hollab-indexing/.env.example new file mode 100644 index 0000000..4e4e1ca --- /dev/null +++ b/apps/hollab-indexing/.env.example @@ -0,0 +1,9 @@ +# Pinata JWT for IPFS pinning. +# Required in production. When unset, POST /storage/pin falls back to a +# local filesystem mock under ${LOCAL_IPFS_DIR:-.ponder/local-ipfs} and +# GET /ipfs/:cid serves blobs back from that dir. Use the local mode for +# dev so you do not need a Pinata account. +PINATA_JWT= + +# Optional override for the local-mock storage dir. +# LOCAL_IPFS_DIR=.ponder/local-ipfs diff --git a/apps/hollab-indexing/package.json b/apps/hollab-indexing/package.json index e760acd..a576a47 100644 --- a/apps/hollab-indexing/package.json +++ b/apps/hollab-indexing/package.json @@ -15,6 +15,7 @@ "dependencies": { "@hollab-io/contracts": "workspace:*", "hono": "^4.5.0", + "multiformats": "^13.3.7", "ponder": "^0.16.6", "viem": "^2.21.3" }, diff --git a/apps/hollab-indexing/src/api/index.ts b/apps/hollab-indexing/src/api/index.ts index 777075e..44e9eaa 100644 --- a/apps/hollab-indexing/src/api/index.ts +++ b/apps/hollab-indexing/src/api/index.ts @@ -5,6 +5,7 @@ import { db } from "ponder:api"; import schema from "ponder:schema"; import { assembleOrgIndex, assembleOrgManifest } from "./manifest.js"; +import storageApp from "./storage.js"; const app = new Hono(); @@ -14,6 +15,12 @@ app.use("/sql/*", client({ db, schema })); app.use("/", graphql({ db, schema })); app.use("/graphql", graphql({ db, schema })); +// ── IPFS pin proxy ────────────────────────────────────────────────────────── +// POST /storage/pin + GET /ipfs/:cid, extracted to ./storage.ts so the routes +// stay Ponder-free and unit-testable. See that module for the Pinata vs local +// filesystem behaviour. +app.route("/", storageApp); + // ── Agent manifest endpoints ──────────────────────────────────────────────── // Runtime-assembled `agent.json` per org. Schema v1 lives in // packages/agent-sdk/docs/agent-manifest-v1.md. Sprint spec: diff --git a/apps/hollab-indexing/src/api/storage.ts b/apps/hollab-indexing/src/api/storage.ts new file mode 100644 index 0000000..d2ec5f0 --- /dev/null +++ b/apps/hollab-indexing/src/api/storage.ts @@ -0,0 +1,116 @@ +/** + * IPFS pin proxy — a standalone Hono sub-app, mounted by the main api in + * index.ts. Kept Ponder-free (no `ponder:*` virtual-module imports) so it can + * be exercised in isolation with `storage.request(...)` in tests. + * + * POST /storage/pin streams a raw byte body to either Pinata (when PINATA_JWT + * is set) or a local filesystem mock (when it isn't — used in dev). Both + * branches return CIDv0; the frontend extracts the 32-byte multihash digest + * for on-chain ContentRef.contentHash so the layout matches in both modes. + * + * Local mode also exposes GET /ipfs/:cid as a stand-in gateway so the frontend + * can fetch blobs by pointing VITE_IPFS_GATEWAY at the indexer URL. + */ +import { createHash } from "node:crypto"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { Hono } from "hono"; +import { CID } from "multiformats/cid"; +import * as Digest from "multiformats/hashes/digest"; + +const PINATA_PIN_URL = "https://api.pinata.cloud/pinning/pinFileToIPFS"; +const MAX_PIN_BYTES = 5 * 1024 * 1024; // 5 MiB upload cap +const SHA2_256 = 0x12; + +// Read env at call time (not import time) so tests can point LOCAL_IPFS_DIR at a +// temp dir and toggle PINATA_JWT per case. +function localStorageDir(): string { + return process.env.LOCAL_IPFS_DIR ?? ".ponder/local-ipfs"; +} + +export function isLocalMode(): boolean { + return !process.env.PINATA_JWT; +} + +/** Deterministic CIDv0 for a buffer: sha2-256 multihash, dag-pb codec. */ +export function localCidFor(buffer: Uint8Array): string { + const digest = createHash("sha256").update(buffer).digest(); + const mh = Digest.create(SHA2_256, new Uint8Array(digest)); + return CID.createV0(mh).toString(); +} + +async function localStore(buffer: Uint8Array): Promise { + const cid = localCidFor(buffer); + const dir = localStorageDir(); + await mkdir(dir, { recursive: true }); + await writeFile(path.join(dir, cid), buffer); + return cid; +} + +async function pinataStore(buffer: ArrayBuffer): Promise { + const jwt = process.env.PINATA_JWT!; + const form = new FormData(); + form.append("file", new Blob([buffer]), "blob"); + form.append("pinataOptions", JSON.stringify({ cidVersion: 0 })); + + const res = await fetch(PINATA_PIN_URL, { + method: "POST", + headers: { Authorization: `Bearer ${jwt}` }, + body: form, + }); + if (!res.ok) { + const detail = await res.text().catch(() => ""); + throw new Error(`pinata upload failed (${res.status}): ${detail}`); + } + const result = (await res.json()) as { IpfsHash?: string }; + if (!result.IpfsHash) throw new Error("pinata returned no CID"); + return result.IpfsHash; +} + +const storage = new Hono(); + +storage.post("/storage/pin", async (c) => { + const buffer = await c.req.arrayBuffer(); + if (buffer.byteLength === 0) { + return c.json({ error: "empty body" }, 400); + } + if (buffer.byteLength > MAX_PIN_BYTES) { + return c.json({ error: "payload too large", maxBytes: MAX_PIN_BYTES }, 413); + } + + try { + const cid = isLocalMode() + ? await localStore(new Uint8Array(buffer)) + : await pinataStore(buffer); + return c.json({ cid }); + } catch (err) { + return c.json({ error: (err as Error).message }, 502); + } +}); + +// Local gateway — only serves blobs written by the local mock. In production +// (with PINATA_JWT set), reads should go to a real IPFS gateway, not here. +storage.get("/ipfs/:cid", async (c) => { + if (!isLocalMode()) { + return c.json({ error: "local gateway disabled when PINATA_JWT is set" }, 404); + } + const cid = c.req.param("cid"); + try { + // Validate the CID before touching the filesystem so a path like ".." + // can't escape the local storage dir. + CID.parse(cid); + } catch { + return c.json({ error: "invalid CID" }, 400); + } + try { + const bytes = await readFile(path.join(localStorageDir(), cid)); + return new Response(bytes, { + status: 200, + headers: { "content-type": "application/octet-stream" }, + }); + } catch { + return c.json({ error: "not found", cid }, 404); + } +}); + +export default storage; diff --git a/apps/hollab-indexing/test/storage.spec.ts b/apps/hollab-indexing/test/storage.spec.ts new file mode 100644 index 0000000..58e273b --- /dev/null +++ b/apps/hollab-indexing/test/storage.spec.ts @@ -0,0 +1,103 @@ +/** + * In-process tests for the IPFS pin proxy (src/api/storage.ts). + * + * Exercises the real Hono routes via `storage.request(...)` against a temp + * filesystem — no Ponder, no network, no Pinata. This is the server half of + * the 0G→IPFS round-trip the frontend's useIpfsStorage relies on. + */ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import storage, { localCidFor } from "../src/api/storage.js"; + +let dir: string; +const savedJwt = process.env.PINATA_JWT; + +beforeEach(async () => { + delete process.env.PINATA_JWT; // force local mode + dir = await mkdtemp(path.join(tmpdir(), "hollab-ipfs-")); + process.env.LOCAL_IPFS_DIR = dir; +}); + +afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + delete process.env.LOCAL_IPFS_DIR; + if (savedJwt === undefined) delete process.env.PINATA_JWT; + else process.env.PINATA_JWT = savedJwt; +}); + +async function pin(body: Uint8Array): Promise { + return storage.request("/storage/pin", { + method: "POST", + body, + headers: { "content-type": "application/octet-stream" }, + }); +} + +describe("pin proxy — local mode round-trip", () => { + it("pins bytes, returns the deterministic CIDv0, and serves them back", async () => { + const body = new TextEncoder().encode("hello pin proxy"); + + const pinRes = await pin(body); + expect(pinRes.status).toBe(200); + const { cid } = (await pinRes.json()) as { cid: string }; + + // CID is the pure sha2-256→CIDv0 of the content (matches the frontend). + expect(cid).toBe(localCidFor(body)); + + const getRes = await storage.request(`/ipfs/${cid}`); + expect(getRes.status).toBe(200); + const out = new Uint8Array(await getRes.arrayBuffer()); + expect(out).toEqual(body); + }); + + it("is content-addressed: identical bytes pin to the same CID", async () => { + const body = new TextEncoder().encode("dedupe me"); + const a = (await (await pin(body)).json()) as { cid: string }; + const b = (await (await pin(body)).json()) as { cid: string }; + expect(a.cid).toBe(b.cid); + }); +}); + +describe("pin proxy — guards", () => { + it("rejects an empty body (400)", async () => { + const res = await pin(new Uint8Array(0)); + expect(res.status).toBe(400); + }); + + it("rejects a payload over the 5 MiB cap (413)", async () => { + const tooBig = new Uint8Array(5 * 1024 * 1024 + 1); + const res = await pin(tooBig); + expect(res.status).toBe(413); + const body = (await res.json()) as { error: string; maxBytes: number }; + expect(body.maxBytes).toBe(5 * 1024 * 1024); + }); + + it("rejects a malformed CID on the gateway (400)", async () => { + const res = await storage.request("/ipfs/not-a-valid-cid"); + expect(res.status).toBe(400); + }); + + it("returns 404 for a well-formed but unknown CID", async () => { + const cid = localCidFor(new TextEncoder().encode("never pinned")); + const res = await storage.request(`/ipfs/${cid}`); + expect(res.status).toBe(404); + }); +}); + +describe("pin proxy — pinata mode", () => { + it("disables the local gateway when PINATA_JWT is set", async () => { + // Pin a blob in local mode first so the file genuinely exists on disk… + const body = new TextEncoder().encode("guarded"); + const { cid } = (await (await pin(body)).json()) as { cid: string }; + + // …then flip to pinata mode and confirm the gateway refuses to serve it. + process.env.PINATA_JWT = "test-jwt"; + const res = await storage.request(`/ipfs/${cid}`); + expect(res.status).toBe(404); + const json = (await res.json()) as { error: string }; + expect(json.error).toMatch(/disabled/); + }); +}); diff --git a/docs/prds/public-private-tensions.md b/docs/prds/public-private-tensions.md index 039ea7d..dfee85f 100644 --- a/docs/prds/public-private-tensions.md +++ b/docs/prds/public-private-tensions.md @@ -17,7 +17,7 @@ encoded in a small off-chain envelope stored under the existing `tensionHash`. Today `PublicProposalView` renders a hex hash where the tension text should be. The MVP permalink is agent-legible but not human-legible, so the share loop breaks: nobody lands on the page and understands why the proposal -exists. We also have a v2 privacy narrative (0G + encryption via +exists. We also have a v2 privacy narrative (IPFS + encryption via `hollab-sdk`) that is built but unused on the write path. A single always-public model is too blunt — some orgs will want to use @@ -105,9 +105,9 @@ Rules: ### Storage -- Public envelopes: stored on 0G as-is (or whatever the existing - `hollab-sdk` storage backend is). The envelope is public, but storage can - still go through 0G for consistency — no reason to fork transports. +- Public envelopes: pinned to IPFS as-is (via the `hollab-sdk` storage + backend / indexer pin-proxy). The envelope is public, but storage still + goes through the same transport — no reason to fork it. - Private envelopes: same storage, with the `body` field pre-encrypted by `hollab-sdk` before upload. @@ -222,7 +222,7 @@ metadata. This is the whole point. - **Envelope hash binding means you can't re-upload to "fix a typo" without breaking the on-chain hash.** This is correct behavior but will surprise authors. Surface "tensions are immutable" in the propose UI. -- **0G availability.** If the envelope is unreachable, the permalink +- **IPFS availability.** If the envelope is unreachable, the permalink degrades to the hex-hash state we have today. Acceptable fallback; document it. - **Private + public in the same org = UX complexity.** Readers will see diff --git a/docs/sprint-agent-native-mvp.md b/docs/sprint-agent-native-mvp.md index 33f3bd7..aee45cc 100644 --- a/docs/sprint-agent-native-mvp.md +++ b/docs/sprint-agent-native-mvp.md @@ -395,7 +395,7 @@ The Day-2 finding ("no proposal lifecycle on-chain, manifest `openProposals: []` ### What is still intentionally missing (do not re-enter without a call) -- **ContentRef → text resolution.** Proposals carry `tensionHash` but the public view does not yet resolve it to human-readable content. The 0G private-data path exists in `hollab-sdk` but is not wired into `PublicProposalView`. This is the next highest-leverage gap (see "Next bets" below). +- **ContentRef → text resolution.** Proposals carry `tensionHash` but the public view does not yet resolve it to human-readable content. The IPFS private-data path exists in `hollab-sdk` (and the frontend `useIpfsStorage` hook) but is not wired into `PublicProposalView`. This is the next highest-leverage gap (see "Next bets" below). - **Objection integration flow.** `raiseObjection` + `resolveObjection` exist on-chain; there is no UX for a facilitator-style integration loop. Only terminal states render well. - **Agent-authored proposal discovery for humans.** The `🤖` chip is rendered, but there is no "agents proposing in this org right now" surface — which is the clearest paperclip-ish hook we have. - **Meeting lifecycle on-chain.** Proposals exist outside meetings currently; the `GovernanceMeeting` surface is still the pre-lifecycle shape. Not blocking. @@ -417,3 +417,14 @@ Bets 1 and 3 above have been shaped into product specs. Bet 1 was refined — vi - [docs/prds/public-objection-flow.md](./prds/public-objection-flow.md) — progressive wallet-connect + `raiseObjection` from `PublicProposalView`, reusing the same envelope for concerns. Key product decision: **org-member advisory gate** (contract remains authoritative). Bet 2 (agent proposal firehose) is still on the roadmap but not yet shaped. + +--- + +## Storage substrate migration: 0G → IPFS (2026-06-09) + +The off-chain storage backend referenced throughout the earlier (dated) sections — "0G storage", "the 0G upload", "the 0G path" — has been **replaced by IPFS**. Those historical references are left intact as a record of what the sprint decided at the time; this note supersedes them for current state. + +- **Frontend** now uploads encrypted blobs through the indexer pin-proxy (`POST /storage/pin`) and reads them back from `VITE_IPFS_GATEWAY`. The hook is `apps/hola-modern/src/hooks/useIpfsStorage.ts` (replaces the deleted `useZgStorage.ts`); `useEncryptedStorage` and the OKR/role-content hooks now thread a `bytes32` `contentHash` (sha2-256 multihash digest of a CIDv0) instead of a 0G `rootHash`. +- **Indexer** exposes the pin-proxy: Pinata when `PINATA_JWT` is set, otherwise a local filesystem mock under `LOCAL_IPFS_DIR` (`.ponder/local-ipfs`) with a `GET /ipfs/:cid` gateway for dev. +- **0G fully removed (2026-06-10).** Storage moved to IPFS (above); the 0G _chain_ target was then dropped too (the 0G relationship ended) — the repo now targets Sepolia + Mainnet only. +- `specs/07` has been neutralized to a provider-neutral verifiable-compute layer + ERC-8004 agent identity (was 0G Compute + ERC-7857 INFTs); it remains v2/descoped. diff --git a/packages/contracts/deployments/16602-infrastructure.json b/packages/contracts/deployments/16602-infrastructure.json deleted file mode 100644 index aa338b4..0000000 --- a/packages/contracts/deployments/16602-infrastructure.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "actionVotingImpl": "0x3aF5899C921B75BA7f920EbC0e0302B00D772A32", - "chainId": 16602, - "ensRegistrar": "0xAa70f74A1912AADACD660720bb46C619F954e582", - "meetingFactory": "0xc7808AE1Bd393cc1d193eB48332Ab46bD01Ecf6C", - "meetingImpl": "0x228173911B6a4E33762E851465D9FCfCDA03f913", - "orgFactory": "0x27127651B812604eff832cD726B241D4E2D20EE0", - "roleRegistryImpl": "0x0165aAAabA0685a0cD55476f640b5925AC3A14Cc" -} \ No newline at end of file diff --git a/packages/contracts/foundry.toml b/packages/contracts/foundry.toml index db3a1fd..5920d9d 100644 --- a/packages/contracts/foundry.toml +++ b/packages/contracts/foundry.toml @@ -33,12 +33,8 @@ fs_permissions = [{ access = "read-write", path = "./deployments" }] localhost = "http://127.0.0.1:8545" mainnet = "${ETH_MAINNET_RPC_URL}" sepolia = "${ETH_SEPOLIA_RPC_URL}" -0g-testnet = "https://evmrpc-testnet.0g.ai" -0g-mainnet = "https://evmrpc.0g.ai" # ── Etherscan verification ─────────────────────────────────────────────────── [etherscan] mainnet = { key = "${ETHERSCAN_API_KEY}", chain = 1 } sepolia = { key = "${ETHERSCAN_API_KEY}", chain = 11155111 } -0g-testnet = { key = "PLACEHOLDER", chain = 16602, url = "https://chainscan-galileo.0g.ai/open/api" } -0g-mainnet = { key = "PLACEHOLDER", chain = 16661, url = "https://chainscan.0g.ai/open/api" } diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 8079c15..248373f 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -16,12 +16,9 @@ "scripts": { "build": "command -v forge >/dev/null 2>&1 && forge build || echo 'forge not found, skipping contracts build'", "clean": "rm -rf out && rm -rf cache", - "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 --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 --legacy -vvvv", "generate": "wagmi generate", diff --git a/packages/contracts/script/DeployInfrastructure.s.sol b/packages/contracts/script/DeployInfrastructure.s.sol index 13b13a8..61c36ac 100644 --- a/packages/contracts/script/DeployInfrastructure.s.sol +++ b/packages/contracts/script/DeployInfrastructure.s.sol @@ -43,9 +43,6 @@ interface IENS { * Production broadcast (Sepolia): * pnpm deploy:infra:sepolia * - * Production broadcast (0G testnet): - * pnpm deploy:infra:0g-testnet - * * Resume a failed broadcast: * forge script script/DeployInfrastructure.s.sol \ * --rpc-url sepolia \ diff --git a/packages/hollab-sdk/package.json b/packages/hollab-sdk/package.json index 3f921bc..98ebbb4 100644 --- a/packages/hollab-sdk/package.json +++ b/packages/hollab-sdk/package.json @@ -2,7 +2,7 @@ "name": "@hollab-io/hollab-sdk", "version": "0.0.1", "private": true, - "description": "HolLab private data layer SDK — key management, encrypted 0G storage, org client, event indexer", + "description": "HolLab private data layer SDK — key management, encrypted IPFS storage, org client, event indexer", "license": "MIT", "author": "Wonderland", "type": "module", @@ -30,6 +30,7 @@ "dependencies": { "@noble/ciphers": "^1.2.1", "@noble/hashes": "^1.7.1", + "multiformats": "^13.3.7", "viem": "2.21.4" } } diff --git a/packages/hollab-sdk/src/external.ts b/packages/hollab-sdk/src/external.ts index f7f3e32..ac06727 100644 --- a/packages/hollab-sdk/src/external.ts +++ b/packages/hollab-sdk/src/external.ts @@ -2,9 +2,9 @@ export type { EncryptedPayload, KeyShare, + ContentHash, StreamId, - LogEntry, - StorageConfig, + IpfsStorageConfig, OrgMeta, CircleMeta, Tension, @@ -24,9 +24,6 @@ export type { // Interfaces export type { IKeyManager, IStorageClient, IOrgClient, IEventIndexer } from "./internal.js"; -// 0G client interfaces (for DI) -export type { ZeroGKvClient, ZeroGLogClient } from "./internal.js"; - // Exceptions export { HollabSdkError, EncryptionError, StorageError, KeyDerivationError } from "./internal.js"; diff --git a/packages/hollab-sdk/src/interfaces/eventIndexer.interface.ts b/packages/hollab-sdk/src/interfaces/eventIndexer.interface.ts index 13ba00a..1676262 100644 --- a/packages/hollab-sdk/src/interfaces/eventIndexer.interface.ts +++ b/packages/hollab-sdk/src/interfaces/eventIndexer.interface.ts @@ -1,7 +1,9 @@ import type { ContentRefEvent, ProposalEvent, RoleChange } from "../types/events.types.js"; /** - * Watches on-chain governance events and mirrors them to 0G Storage. + * Watches on-chain governance events and dispatches them to registered + * handlers. (The Ponder indexer is the queryable audit trail; this no longer + * mirrors events to off-chain storage.) */ export interface IEventIndexer { /** Start watching for new events, optionally from a specific block. */ diff --git a/packages/hollab-sdk/src/interfaces/orgClient.interface.ts b/packages/hollab-sdk/src/interfaces/orgClient.interface.ts index eb49b46..733832e 100644 --- a/packages/hollab-sdk/src/interfaces/orgClient.interface.ts +++ b/packages/hollab-sdk/src/interfaces/orgClient.interface.ts @@ -6,45 +6,40 @@ import type { RoleConfig, Tension, } from "../types/org.types.js"; +import type { ContentHash } from "../types/storage.types.js"; /** - * High-level interface for reading and writing org private data - * in 0G Storage with appropriate key scope. + * High-level interface for reading and writing an org's private data on IPFS + * with the appropriate key scope. + * + * Storage is content-addressed: `set*` returns the bytes32 `contentHash` of the + * pinned blob (which the caller persists, e.g. in an on-chain `ContentRef`), and + * `get*` takes that hash to fetch and decrypt. */ export interface IOrgClient { - getOrgMeta(): Promise; - setOrgMeta(meta: OrgMeta): Promise; + getOrgMeta(contentHash: ContentHash): Promise; + setOrgMeta(meta: OrgMeta): Promise; - getCircleMeta(circleId: bigint): Promise; - setCircleMeta(circleId: bigint, meta: CircleMeta): Promise; + getCircleMeta(circleId: bigint, contentHash: ContentHash): Promise; + setCircleMeta(circleId: bigint, meta: CircleMeta): Promise; - getTension(circleId: bigint, tensionId: string): Promise; - setTension(circleId: bigint, tension: Tension): Promise; + getTension(circleId: bigint, contentHash: ContentHash): Promise; + setTension(circleId: bigint, tension: Tension): Promise; - getProposal(circleId: bigint, proposalId: bigint): Promise; - setProposal(circleId: bigint, proposal: Proposal): Promise; + getProposal(circleId: bigint, contentHash: ContentHash): Promise; + setProposal(circleId: bigint, proposal: Proposal): Promise; - getRoleConfig(circleId: bigint, roleId: bigint): Promise; - setRoleConfig(circleId: bigint, roleId: bigint, config: RoleConfig): Promise; + getRoleConfig(circleId: bigint, roleId: bigint, contentHash: ContentHash): Promise; + setRoleConfig(circleId: bigint, roleId: bigint, config: RoleConfig): Promise; - getOkrs(circleId: bigint, roleId: bigint, quarter: string): Promise; - setOkrs( - circleId: bigint, - roleId: bigint, - quarter: string, - objectives: OkrObjective[], - ): Promise; + getOkrs(circleId: bigint, roleId: bigint, contentHash: ContentHash): Promise; + setOkrs(circleId: bigint, roleId: bigint, objectives: OkrObjective[]): Promise; - shareCircleKey( - circleId: bigint, - recipientAddress: `0x${string}`, - recipientPublicKey: Uint8Array, - ): Promise; + shareCircleKey(circleId: bigint, recipientPublicKey: Uint8Array): Promise; shareRoleKey( circleId: bigint, roleId: bigint, - recipientAddress: `0x${string}`, recipientPublicKey: Uint8Array, - ): Promise; + ): Promise; } diff --git a/packages/hollab-sdk/src/interfaces/storageClient.interface.ts b/packages/hollab-sdk/src/interfaces/storageClient.interface.ts index 7d091eb..cf9a338 100644 --- a/packages/hollab-sdk/src/interfaces/storageClient.interface.ts +++ b/packages/hollab-sdk/src/interfaces/storageClient.interface.ts @@ -1,30 +1,19 @@ -import type { LogEntry, StreamId } from "../types/storage.types.js"; +import type { ContentHash } from "../types/storage.types.js"; /** - * Wrapper around 0G Storage (KV + Log) with client-side encryption. + * Content-addressed encrypted storage over IPFS (via the indexer pin-proxy). + * + * Writes encrypt a value, pin the ciphertext, and return its bytes32 content + * hash; reads take that hash, fetch the blob from a gateway, and decrypt it. + * The logical-key → contentHash mapping lives on-chain (`ContentRef`) or in the + * caller — IPFS is immutable and content-addressed, so there is no mutable + * (streamId, key) lookup and no append-only log here (the Ponder indexer is the + * queryable audit trail). */ export interface IStorageClient { - /** Encrypts value with the given key and stores it in KV. */ - putEncrypted( - streamId: StreamId, - key: string, - value: Uint8Array, - encryptionKey: Uint8Array, - ): Promise; + /** Encrypts a value and pins it to IPFS; returns the bytes32 content hash. */ + putEncrypted(value: Uint8Array, encryptionKey: Uint8Array): Promise; - /** Fetches from KV and decrypts with the given key. Returns null if not found. */ - getDecrypted( - streamId: StreamId, - key: string, - encryptionKey: Uint8Array, - ): Promise; - - /** Appends a plaintext entry to the 0G Storage Log. */ - appendLog(streamId: StreamId, entry: LogEntry): Promise; - - /** Reads entries from the 0G Storage Log. */ - readLog(streamId: StreamId, fromIndex: number, count: number): Promise; - - /** Checks whether a stream exists. */ - streamExists(streamId: StreamId): Promise; + /** Fetches the blob by content hash and decrypts it. Throws if not found. */ + getDecrypted(contentHash: ContentHash, encryptionKey: Uint8Array): Promise; } diff --git a/packages/hollab-sdk/src/lib/storage/cid.ts b/packages/hollab-sdk/src/lib/storage/cid.ts new file mode 100644 index 0000000..74ee105 --- /dev/null +++ b/packages/hollab-sdk/src/lib/storage/cid.ts @@ -0,0 +1,51 @@ +/** + * CIDv0 ⇄ bytes32 content-hash conversion. + * + * The pin-proxy returns a CIDv0; on-chain `ContentRef.contentHash` stores only + * the 32-byte sha2-256 multihash digest. These helpers move between the two so + * the SDK and the on-chain layout agree (and match the frontend's + * useIpfsStorage hook). + */ +import { CID } from "multiformats/cid"; +import * as Digest from "multiformats/hashes/digest"; + +import type { ContentHash } from "../../types/storage.types.js"; + +const SHA2_256_CODE = 0x12; +const DIGEST_BYTES = 32; + +function bytesToHex(bytes: Uint8Array): ContentHash { + let hex = ""; + for (const b of bytes) hex += b.toString(16).padStart(2, "0"); + return `0x${hex}` as ContentHash; +} + +function hexToBytes(hex: string): Uint8Array { + const stripped = hex.startsWith("0x") ? hex.slice(2) : hex; + if (stripped.length !== DIGEST_BYTES * 2) { + throw new Error(`Expected ${DIGEST_BYTES}-byte hex, got ${stripped.length / 2} bytes`); + } + const out = new Uint8Array(DIGEST_BYTES); + for (let i = 0; i < DIGEST_BYTES; i++) { + out[i] = parseInt(stripped.slice(i * 2, i * 2 + 2), 16); + } + return out; +} + +/** CIDv0 string → bytes32 sha2-256 digest. */ +export function cidToContentHash(cid: string): ContentHash { + const mh = CID.parse(cid).multihash; + if (mh.code !== SHA2_256_CODE) { + throw new Error(`Unexpected multihash code ${mh.code}, want sha2-256 (0x12)`); + } + if (mh.digest.length !== DIGEST_BYTES) { + throw new Error(`Unexpected digest length ${mh.digest.length}, want ${DIGEST_BYTES}`); + } + return bytesToHex(mh.digest); +} + +/** bytes32 sha2-256 digest → CIDv0 string. */ +export function contentHashToCid(contentHash: ContentHash): string { + const mh = Digest.create(SHA2_256_CODE, hexToBytes(contentHash)); + return CID.createV0(mh).toString(); +} diff --git a/packages/hollab-sdk/src/providers/eventIndexer.ts b/packages/hollab-sdk/src/providers/eventIndexer.ts index 913a4c0..561d1a3 100644 --- a/packages/hollab-sdk/src/providers/eventIndexer.ts +++ b/packages/hollab-sdk/src/providers/eventIndexer.ts @@ -1,7 +1,6 @@ import type { PublicClient } from "viem"; import type { IEventIndexer } from "../interfaces/eventIndexer.interface.js"; -import type { IStorageClient } from "../interfaces/storageClient.interface.js"; import type { ContentRefEvent, EventIndexerConfig, @@ -10,7 +9,6 @@ import type { } from "../types/events.types.js"; import { contentRefEvents, roleRegistryEvents } from "../lib/chain/abis.js"; import { decodeContentRefEvent, decodeRoleRegistryEvent } from "../lib/chain/eventDecoder.js"; -import { buildStreamId } from "../lib/storage/schema.js"; const DEFAULT_CHUNK_SIZE = 2000; @@ -19,8 +17,6 @@ type UnwatchFn = () => void; export class EventIndexer implements IEventIndexer { private readonly config: EventIndexerConfig; private readonly publicClient: PublicClient; - private readonly storageClient: IStorageClient; - private readonly streamId: string; private readonly chunkSize: number; private unwatchFns: UnwatchFn[] = []; @@ -29,15 +25,9 @@ export class EventIndexer implements IEventIndexer { private roleHandlers: ((event: RoleChange) => void)[] = []; private contentRefHandlers: ((event: ContentRefEvent) => void)[] = []; - constructor( - config: EventIndexerConfig, - publicClient: PublicClient, - storageClient: IStorageClient, - ) { + constructor(config: EventIndexerConfig, publicClient: PublicClient) { this.config = config; this.publicClient = publicClient; - this.storageClient = storageClient; - this.streamId = buildStreamId(config.orgId); this.chunkSize = config.chunkSize ?? DEFAULT_CHUNK_SIZE; } @@ -159,13 +149,6 @@ export class EventIndexer implements IEventIndexer { for (const handler of this.contentRefHandlers) { handler(event); } - - void this.storageClient.appendLog(this.streamId, { - type: `contentref:${event.type}`, - data: event as unknown as Record, - timestamp: event.timestamp, - txHash: event.transactionHash, - }); } // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -177,12 +160,5 @@ export class EventIndexer implements IEventIndexer { for (const handler of this.roleHandlers) { handler(event); } - - void this.storageClient.appendLog(this.streamId, { - type: `role:${event.type}`, - data: event as unknown as Record, - timestamp: event.timestamp, - txHash: event.transactionHash, - }); } } diff --git a/packages/hollab-sdk/src/providers/index.ts b/packages/hollab-sdk/src/providers/index.ts index 0a75c00..b4d57a3 100644 --- a/packages/hollab-sdk/src/providers/index.ts +++ b/packages/hollab-sdk/src/providers/index.ts @@ -1,5 +1,4 @@ export { KeyManager } from "./keyManager.js"; export { StorageClient } from "./storageClient.js"; -export type { ZeroGKvClient, ZeroGLogClient } from "./storageClient.js"; export { OrgClient } from "./orgClient.js"; export { EventIndexer } from "./eventIndexer.js"; diff --git a/packages/hollab-sdk/src/providers/orgClient.ts b/packages/hollab-sdk/src/providers/orgClient.ts index 72944be..1df65ac 100644 --- a/packages/hollab-sdk/src/providers/orgClient.ts +++ b/packages/hollab-sdk/src/providers/orgClient.ts @@ -12,17 +12,7 @@ import type { RoleConfig, Tension, } from "../types/org.types.js"; -import { - buildCircleKeyShareKey, - buildCircleMetaKey, - buildOkrKey, - buildOrgMetaKey, - buildProposalKey, - buildRoleConfigKey, - buildRoleKeyShareKey, - buildStreamId, - buildTensionKey, -} from "../lib/storage/schema.js"; +import type { ContentHash } from "../types/storage.types.js"; const encoder = new TextEncoder(); const decoder = new TextDecoder(); @@ -46,9 +36,17 @@ function jsonDecode(data: Uint8Array): T { return JSON.parse(decoder.decode(data), bigintReviver) as T; } +/** + * High-level reader/writer for an org's private data. + * + * Storage is content-addressed: each `set*` encrypts the value with the + * appropriate scope key, pins it, and returns the bytes32 `contentHash`. Each + * `get*` takes the `contentHash` (resolved by the caller from on-chain + * `ContentRef`s) and decrypts the blob. The key scope (org / circle / role) + * determines who can decrypt; the content hash determines what is fetched. + */ export class OrgClient implements IOrgClient { private readonly orgId: bigint; - private readonly streamId: string; private readonly keyManager: IKeyManager; private readonly storageClient: IStorageClient; private readonly wallet: WalletClient; @@ -61,7 +59,6 @@ export class OrgClient implements IOrgClient { storageClient: IStorageClient, ) { this.orgId = config.orgId; - this.streamId = buildStreamId(config.orgId); this.keyManager = keyManager; this.storageClient = storageClient; this.wallet = wallet; @@ -90,170 +87,110 @@ export class OrgClient implements IOrgClient { } /** @inheritdoc */ - async getOrgMeta(): Promise { + async getOrgMeta(contentHash: ContentHash): Promise { const key = await this.getOrgKey(); - const data = await this.storageClient.getDecrypted(this.streamId, buildOrgMetaKey(), key); - return data ? jsonDecode(data) : null; + return jsonDecode(await this.storageClient.getDecrypted(contentHash, key)); } /** @inheritdoc */ - async setOrgMeta(meta: OrgMeta): Promise { + async setOrgMeta(meta: OrgMeta): Promise { const key = await this.getOrgKey(); - await this.storageClient.putEncrypted( - this.streamId, - buildOrgMetaKey(), - jsonEncode(meta), - key, - ); + return this.storageClient.putEncrypted(jsonEncode(meta), key); } /** @inheritdoc */ - async getCircleMeta(circleId: bigint): Promise { + async getCircleMeta(circleId: bigint, contentHash: ContentHash): Promise { const key = await this.getCircleKey(circleId); - const data = await this.storageClient.getDecrypted( - this.streamId, - buildCircleMetaKey(circleId), - key, - ); - return data ? jsonDecode(data) : null; + return jsonDecode(await this.storageClient.getDecrypted(contentHash, key)); } /** @inheritdoc */ - async setCircleMeta(circleId: bigint, meta: CircleMeta): Promise { + async setCircleMeta(circleId: bigint, meta: CircleMeta): Promise { const key = await this.getCircleKey(circleId); - await this.storageClient.putEncrypted( - this.streamId, - buildCircleMetaKey(circleId), - jsonEncode(meta), - key, - ); + return this.storageClient.putEncrypted(jsonEncode(meta), key); } /** @inheritdoc */ - async getTension(circleId: bigint, tensionId: string): Promise { + async getTension(circleId: bigint, contentHash: ContentHash): Promise { const key = await this.getCircleKey(circleId); - const data = await this.storageClient.getDecrypted( - this.streamId, - buildTensionKey(circleId, tensionId), - key, - ); - return data ? jsonDecode(data) : null; + return jsonDecode(await this.storageClient.getDecrypted(contentHash, key)); } /** @inheritdoc */ - async setTension(circleId: bigint, tension: Tension): Promise { + async setTension(circleId: bigint, tension: Tension): Promise { const key = await this.getCircleKey(circleId); - await this.storageClient.putEncrypted( - this.streamId, - buildTensionKey(circleId, tension.id), - jsonEncode(tension), - key, - ); + return this.storageClient.putEncrypted(jsonEncode(tension), key); } /** @inheritdoc */ - async getProposal(circleId: bigint, proposalId: bigint): Promise { + async getProposal(circleId: bigint, contentHash: ContentHash): Promise { const key = await this.getCircleKey(circleId); - const data = await this.storageClient.getDecrypted( - this.streamId, - buildProposalKey(circleId, proposalId), - key, - ); - return data ? jsonDecode(data) : null; + return jsonDecode(await this.storageClient.getDecrypted(contentHash, key)); } /** @inheritdoc */ - async setProposal(circleId: bigint, proposal: Proposal): Promise { + async setProposal(circleId: bigint, proposal: Proposal): Promise { const key = await this.getCircleKey(circleId); - await this.storageClient.putEncrypted( - this.streamId, - buildProposalKey(circleId, proposal.id), - jsonEncode(proposal), - key, - ); + return this.storageClient.putEncrypted(jsonEncode(proposal), key); } /** @inheritdoc */ - async getRoleConfig(circleId: bigint, roleId: bigint): Promise { + async getRoleConfig( + circleId: bigint, + roleId: bigint, + contentHash: ContentHash, + ): Promise { const key = await this.getRoleKey(circleId, roleId); - const data = await this.storageClient.getDecrypted( - this.streamId, - buildRoleConfigKey(roleId), - key, - ); - return data ? jsonDecode(data) : null; + return jsonDecode(await this.storageClient.getDecrypted(contentHash, key)); } /** @inheritdoc */ - async setRoleConfig(circleId: bigint, roleId: bigint, config: RoleConfig): Promise { + async setRoleConfig( + circleId: bigint, + roleId: bigint, + config: RoleConfig, + ): Promise { const key = await this.getRoleKey(circleId, roleId); - await this.storageClient.putEncrypted( - this.streamId, - buildRoleConfigKey(roleId), - jsonEncode(config), - key, - ); + return this.storageClient.putEncrypted(jsonEncode(config), key); } /** @inheritdoc */ - async getOkrs(circleId: bigint, roleId: bigint, quarter: string): Promise { + async getOkrs( + circleId: bigint, + roleId: bigint, + contentHash: ContentHash, + ): Promise { const key = await this.getRoleKey(circleId, roleId); - const data = await this.storageClient.getDecrypted( - this.streamId, - buildOkrKey(roleId, quarter), - key, - ); - return data ? jsonDecode(data) : []; + return jsonDecode(await this.storageClient.getDecrypted(contentHash, key)); } /** @inheritdoc */ async setOkrs( circleId: bigint, roleId: bigint, - quarter: string, objectives: OkrObjective[], - ): Promise { + ): Promise { const key = await this.getRoleKey(circleId, roleId); - await this.storageClient.putEncrypted( - this.streamId, - buildOkrKey(roleId, quarter), - jsonEncode(objectives), - key, - ); + return this.storageClient.putEncrypted(jsonEncode(objectives), key); } /** @inheritdoc */ - async shareCircleKey( - circleId: bigint, - recipientAddress: `0x${string}`, - recipientPublicKey: Uint8Array, - ): Promise { + async shareCircleKey(circleId: bigint, recipientPublicKey: Uint8Array): Promise { const circleKey = await this.getCircleKey(circleId); const share = this.keyManager.createKeyShare(circleKey, recipientPublicKey); const orgKey = await this.getOrgKey(); - await this.storageClient.putEncrypted( - this.streamId, - buildCircleKeyShareKey(circleId, recipientAddress), - jsonEncode(share), - orgKey, - ); + return this.storageClient.putEncrypted(jsonEncode(share), orgKey); } /** @inheritdoc */ async shareRoleKey( circleId: bigint, roleId: bigint, - recipientAddress: `0x${string}`, recipientPublicKey: Uint8Array, - ): Promise { + ): Promise { const roleKey = await this.getRoleKey(circleId, roleId); const share = this.keyManager.createKeyShare(roleKey, recipientPublicKey); const orgKey = await this.getOrgKey(); - await this.storageClient.putEncrypted( - this.streamId, - buildRoleKeyShareKey(roleId, recipientAddress), - jsonEncode(share), - orgKey, - ); + return this.storageClient.putEncrypted(jsonEncode(share), orgKey); } } diff --git a/packages/hollab-sdk/src/providers/storageClient.ts b/packages/hollab-sdk/src/providers/storageClient.ts index 13ac39e..4b3b867 100644 --- a/packages/hollab-sdk/src/providers/storageClient.ts +++ b/packages/hollab-sdk/src/providers/storageClient.ts @@ -1,6 +1,14 @@ import type { IKeyManager } from "../interfaces/keyManager.interface.js"; import type { IStorageClient } from "../interfaces/storageClient.interface.js"; -import type { EncryptedPayload, LogEntry, StreamId } from "../internal.js"; +import type { EncryptedPayload } from "../types/crypto.types.js"; +import type { + ContentHash, + FetchLike, + FetchResponse, + IpfsStorageConfig, +} from "../types/storage.types.js"; +import { StorageError } from "../exceptions/storageError.exception.js"; +import { cidToContentHash, contentHashToCid } from "../lib/storage/cid.js"; /** * Serializes an EncryptedPayload to a Uint8Array for storage. @@ -26,70 +34,66 @@ function deserializePayload(data: Uint8Array): EncryptedPayload { return { nonce, ciphertext }; } -/** Interface for the 0G KV client operations we use */ -export interface ZeroGKvClient { - put(streamId: string, key: string, value: Uint8Array): Promise; - get(streamId: string, key: string): Promise; - exists(streamId: string): Promise; -} - -/** Interface for the 0G Log client operations we use */ -export interface ZeroGLogClient { - append(streamId: string, data: Uint8Array): Promise; - read(streamId: string, fromIndex: number, count: number): Promise; -} - +/** + * Content-addressed encrypted storage backed by IPFS. + * + * Uploads encrypt → serialize → POST to the indexer pin-proxy, returning the + * blob's bytes32 content hash. Downloads reconstruct the CID from that hash, + * fetch from an IPFS gateway, deserialize, and decrypt. Mirrors the frontend's + * useIpfsStorage hook so both produce/consume identical `ContentRef` hashes. + */ export class StorageClient implements IStorageClient { private readonly keyManager: IKeyManager; - private readonly kvClient: ZeroGKvClient; - private readonly logClient: ZeroGLogClient; + private readonly pinUrl: string; + private readonly gatewayUrl: string; + private readonly fetchImpl?: FetchLike; - constructor(keyManager: IKeyManager, kvClient: ZeroGKvClient, logClient: ZeroGLogClient) { + constructor(keyManager: IKeyManager, config: IpfsStorageConfig) { this.keyManager = keyManager; - this.kvClient = kvClient; - this.logClient = logClient; + this.pinUrl = config.pinUrl; + this.gatewayUrl = config.gatewayUrl.replace(/\/$/, ""); + this.fetchImpl = config.fetchImpl; } - /** @inheritdoc */ - async putEncrypted( - streamId: StreamId, - key: string, - value: Uint8Array, - encryptionKey: Uint8Array, - ): Promise { - const payload = this.keyManager.encrypt(encryptionKey, value); - const serialized = serializePayload(payload); - await this.kvClient.put(streamId, key, serialized); + private fetch(input: string, init?: Parameters[1]): Promise { + const impl = this.fetchImpl ?? (globalThis as unknown as { fetch: FetchLike }).fetch; + return impl(input, init); } /** @inheritdoc */ - async getDecrypted( - streamId: StreamId, - key: string, - encryptionKey: Uint8Array, - ): Promise { - const data = await this.kvClient.get(streamId, key); - if (!data) return null; + async putEncrypted(value: Uint8Array, encryptionKey: Uint8Array): Promise { + const payload = this.keyManager.encrypt(encryptionKey, value); + const serialized = serializePayload(payload); - const payload = deserializePayload(data); - return this.keyManager.decrypt(encryptionKey, payload); - } + // Copy into a fresh ArrayBuffer so the body type narrows correctly. + const body = new ArrayBuffer(serialized.byteLength); + new Uint8Array(body).set(serialized); - /** @inheritdoc */ - async appendLog(streamId: StreamId, entry: LogEntry): Promise { - const encoded = new TextEncoder().encode(JSON.stringify(entry)); - await this.logClient.append(streamId, encoded); + const res = await this.fetch(this.pinUrl, { + method: "POST", + headers: { "content-type": "application/octet-stream" }, + body, + }); + if (!res.ok) { + const detail = await res.text().catch(() => ""); + throw new StorageError(`pin proxy upload failed (${res.status}): ${detail}`); + } + const json = (await res.json()) as { cid?: string; error?: string }; + if (!json.cid) { + throw new StorageError(`pin proxy returned no CID: ${json.error ?? "unknown error"}`); + } + return cidToContentHash(json.cid); } /** @inheritdoc */ - async readLog(streamId: StreamId, fromIndex: number, count: number): Promise { - const rawEntries = await this.logClient.read(streamId, fromIndex, count); - const decoder = new TextDecoder(); - return rawEntries.map((raw) => JSON.parse(decoder.decode(raw)) as LogEntry); - } - - /** @inheritdoc */ - async streamExists(streamId: StreamId): Promise { - return this.kvClient.exists(streamId); + async getDecrypted(contentHash: ContentHash, encryptionKey: Uint8Array): Promise { + const cid = contentHashToCid(contentHash); + const res = await this.fetch(`${this.gatewayUrl}/ipfs/${cid}`); + if (!res.ok) { + throw new StorageError(`IPFS gateway fetch failed (${res.status}) for ${cid}`); + } + const bytes = new Uint8Array(await res.arrayBuffer()); + const payload = deserializePayload(bytes); + return this.keyManager.decrypt(encryptionKey, payload); } } diff --git a/packages/hollab-sdk/src/types/index.ts b/packages/hollab-sdk/src/types/index.ts index ccb78e1..982e264 100644 --- a/packages/hollab-sdk/src/types/index.ts +++ b/packages/hollab-sdk/src/types/index.ts @@ -1,5 +1,5 @@ export type { EncryptedPayload, KeyShare } from "./crypto.types.js"; -export type { StreamId, LogEntry, StorageConfig } from "./storage.types.js"; +export type { ContentHash, StreamId, IpfsStorageConfig } from "./storage.types.js"; export type { OrgMeta, CircleMeta, diff --git a/packages/hollab-sdk/src/types/org.types.ts b/packages/hollab-sdk/src/types/org.types.ts index 47d4ee7..0bbc3f2 100644 --- a/packages/hollab-sdk/src/types/org.types.ts +++ b/packages/hollab-sdk/src/types/org.types.ts @@ -1,4 +1,4 @@ -/** Organization-level metadata stored in 0G KV */ +/** Organization-level metadata stored off-chain (encrypted, on IPFS) */ export interface OrgMeta { name: string; purpose: string; @@ -86,5 +86,4 @@ export interface OkrObjective { /** Configuration for OrgClient */ export interface OrgClientConfig { orgId: bigint; - storageConfig: import("./storage.types.js").StorageConfig; } diff --git a/packages/hollab-sdk/src/types/storage.types.ts b/packages/hollab-sdk/src/types/storage.types.ts index 63dbe26..1f86fa8 100644 --- a/packages/hollab-sdk/src/types/storage.types.ts +++ b/packages/hollab-sdk/src/types/storage.types.ts @@ -1,26 +1,44 @@ -/** Unique identifier for a 0G Storage stream */ +/** + * A bytes32 content hash — the 32-byte sha2-256 multihash digest of a CIDv0, + * formatted as a `0x`-prefixed hex string. This is the on-chain + * `ContentRef.contentHash` value; given it, the StorageClient reconstructs the + * CID to fetch the blob from an IPFS gateway. + */ +export type ContentHash = `0x${string}`; + +/** + * Logical key namespace label for off-chain blobs (e.g. "org:{orgId}", + * "circle:{circleId}:meta"). With content-addressed IPFS the label no longer + * locates the blob — it's a human-meaningful name whose latest CID is tracked + * on-chain (`ContentRef`) or by the caller. Retained for the schema helpers. + */ export type StreamId = string; -/** A single entry in a 0G Storage Log */ -export interface LogEntry { - /** Entry type identifier (e.g. "governance:proposal-adopted") */ - type: string; - /** Arbitrary JSON-serializable data */ - data: Record; - /** Unix timestamp in seconds */ - timestamp: number; - /** Optional transaction hash for on-chain events */ - txHash?: string; +/** + * Minimal Response shape the StorageClient consumes — a structural subset of + * the WHATWG `Response`, declared locally so the SDK does not require the DOM + * lib. A real global `Response` satisfies it. + */ +export interface FetchResponse { + ok: boolean; + status: number; + json(): Promise; + text(): Promise; + arrayBuffer(): Promise; } -/** Configuration for connecting to 0G Storage */ -export interface StorageConfig { - /** 0G Storage KV endpoint URL */ - kvEndpoint: string; - /** 0G Storage Log endpoint URL */ - logEndpoint: string; - /** 0G Storage node endpoint URL */ - nodeEndpoint: string; - /** Private key for 0G Storage authentication */ - privateKey: string; +/** Minimal fetch signature the StorageClient needs (compatible with global fetch). */ +export type FetchLike = ( + input: string, + init?: { method?: string; headers?: Record; body?: ArrayBuffer }, +) => Promise; + +/** Configuration for the IPFS pin-proxy the StorageClient talks to. */ +export interface IpfsStorageConfig { + /** Full URL of the indexer pin endpoint, e.g. http://localhost:42069/storage/pin */ + pinUrl: string; + /** Base IPFS gateway URL; the client appends `/ipfs/` on reads. */ + gatewayUrl: string; + /** Optional fetch implementation (defaults to the global fetch). */ + fetchImpl?: FetchLike; } diff --git a/packages/hollab-sdk/test/unit/eventIndexer.spec.ts b/packages/hollab-sdk/test/unit/eventIndexer.spec.ts index 9fd78ba..d4a2ff7 100644 --- a/packages/hollab-sdk/test/unit/eventIndexer.spec.ts +++ b/packages/hollab-sdk/test/unit/eventIndexer.spec.ts @@ -1,6 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { IStorageClient } from "../../src/interfaces/storageClient.interface.js"; import type { EventIndexerConfig } from "../../src/types/events.types.js"; import { EventIndexer } from "../../src/internal.js"; @@ -18,16 +17,6 @@ function createMockPublicClient(): MockPublicClient { }; } -function createMockStorageClient(): IStorageClient { - return { - putEncrypted: vi.fn(), - getDecrypted: vi.fn(), - appendLog: vi.fn(), - readLog: vi.fn(), - streamExists: vi.fn(), - }; -} - const config: EventIndexerConfig = { rpcUrl: "http://localhost:8545", contracts: { @@ -40,18 +29,14 @@ const config: EventIndexerConfig = { describe("EventIndexer", () => { let indexer: EventIndexer; let mockPublicClient: MockPublicClient; - let mockStorage: IStorageClient; beforeEach(() => { mockPublicClient = createMockPublicClient(); - mockStorage = createMockStorageClient(); // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any - indexer = new EventIndexer(config, mockPublicClient as any, mockStorage); + indexer = new EventIndexer(config, mockPublicClient as any); }); - afterEach(() => { - vi.clearAllMocks(); - }); + afterEach(() => vi.clearAllMocks()); describe("start/stop lifecycle", () => { it("starts watching events", async () => { @@ -139,33 +124,4 @@ describe("EventIndexer", () => { ); }); }); - - describe("log storage", () => { - it("appends decoded events to 0G log", async () => { - mockPublicClient.getContractEvents.mockImplementation( - (params: { abi: readonly { name: string }[] }) => { - if (params.abi[0]?.name === "RoleCreated") { - return [ - { - eventName: "RoleLeadAssigned", - args: { _roleId: 1n, _lead: "0xAlice" }, - blockNumber: 50n, - transactionHash: "0xabc", - logIndex: 0, - }, - ]; - } - return []; - }, - ); - - mockPublicClient.getBlockNumber.mockResolvedValue(100n); - await indexer.syncOrg(1n, 0n); - - expect(mockStorage.appendLog).toHaveBeenCalledWith( - "org:1", - expect.objectContaining({ type: "role:RoleLeadAssigned" }), - ); - }); - }); }); diff --git a/packages/hollab-sdk/test/unit/orgClient.spec.ts b/packages/hollab-sdk/test/unit/orgClient.spec.ts index 111fa4b..d45a9c5 100644 --- a/packages/hollab-sdk/test/unit/orgClient.spec.ts +++ b/packages/hollab-sdk/test/unit/orgClient.spec.ts @@ -3,20 +3,21 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { IKeyManager } from "../../src/interfaces/keyManager.interface.js"; import type { IStorageClient } from "../../src/interfaces/storageClient.interface.js"; import type { OrgMeta, RoleConfig, Tension } from "../../src/types/org.types.js"; +import type { ContentHash } from "../../src/types/storage.types.js"; import { OrgClient } from "../../src/internal.js"; const encoder = new TextEncoder(); +const ORG_KEY = new Uint8Array(32).fill(0x01); +const CIRCLE_KEY = new Uint8Array(32).fill(0x02); +const ROLE_KEY = new Uint8Array(32).fill(0x03); +const STORED_HASH = `0x${"cd".repeat(32)}` as ContentHash; function createMockKeyManager(): IKeyManager { - const orgKey = new Uint8Array(32).fill(0x01); - const circleKey = new Uint8Array(32).fill(0x02); - const roleKey = new Uint8Array(32).fill(0x03); - return { deriveMasterKey: vi.fn().mockResolvedValue(new Uint8Array(32).fill(0x00)), - deriveOrgKey: vi.fn().mockReturnValue(orgKey), - deriveCircleKey: vi.fn().mockReturnValue(circleKey), - deriveRoleKey: vi.fn().mockReturnValue(roleKey), + deriveOrgKey: vi.fn().mockReturnValue(ORG_KEY), + deriveCircleKey: vi.fn().mockReturnValue(CIRCLE_KEY), + deriveRoleKey: vi.fn().mockReturnValue(ROLE_KEY), encrypt: vi.fn(), decrypt: vi.fn(), createKeyShare: vi @@ -29,11 +30,8 @@ function createMockKeyManager(): IKeyManager { function createMockStorageClient(): IStorageClient { return { - putEncrypted: vi.fn(), + putEncrypted: vi.fn().mockResolvedValue(STORED_HASH), getDecrypted: vi.fn(), - appendLog: vi.fn(), - readLog: vi.fn(), - streamExists: vi.fn(), }; } @@ -41,7 +39,7 @@ function createMockWallet(): { account: { address: `0x${string}` } } { return { account: { address: "0xAlice" } }; } -describe("OrgClient", () => { +describe("OrgClient (content-addressed)", () => { let orgClient: OrgClient; let mockKm: IKeyManager; let mockStorage: IStorageClient; @@ -50,15 +48,7 @@ describe("OrgClient", () => { mockKm = createMockKeyManager(); mockStorage = createMockStorageClient(); orgClient = new OrgClient( - { - orgId: 1n, - storageConfig: { - kvEndpoint: "", - logEndpoint: "", - nodeEndpoint: "", - privateKey: "", - }, - }, + { orgId: 1n }, // eslint-disable-next-line @typescript-eslint/no-explicit-any createMockWallet() as any, mockKm, @@ -66,61 +56,45 @@ describe("OrgClient", () => { ); }); - afterEach(() => { - vi.clearAllMocks(); - }); + afterEach(() => vi.clearAllMocks()); describe("getOrgMeta / setOrgMeta", () => { - it("uses org key scope for metadata", async () => { + it("encrypts with the org key and returns the pinned content hash", async () => { const meta: OrgMeta = { name: "Test Org", purpose: "Testing", config: {} }; - await orgClient.setOrgMeta(meta); + const hash = await orgClient.setOrgMeta(meta); expect(mockKm.deriveOrgKey).toHaveBeenCalled(); - expect(mockStorage.putEncrypted).toHaveBeenCalledWith( - "org:1", - "meta", - expect.any(Uint8Array), - expect.any(Uint8Array), - ); + expect(mockStorage.putEncrypted).toHaveBeenCalledWith(expect.any(Uint8Array), ORG_KEY); + expect(hash).toBe(STORED_HASH); }); - it("returns null when no meta exists", async () => { - vi.mocked(mockStorage.getDecrypted).mockResolvedValue(null); - const result = await orgClient.getOrgMeta(); - expect(result).toBeNull(); - }); - - it("deserializes stored meta", async () => { + it("fetches by content hash and deserializes", async () => { const meta: OrgMeta = { name: "Test", purpose: "Test", config: {} }; vi.mocked(mockStorage.getDecrypted).mockResolvedValue( encoder.encode(JSON.stringify(meta)), ); - const result = await orgClient.getOrgMeta(); + const result = await orgClient.getOrgMeta(STORED_HASH); + expect(mockStorage.getDecrypted).toHaveBeenCalledWith(STORED_HASH, ORG_KEY); expect(result).toEqual(meta); }); }); - describe("getCircleMeta / setCircleMeta", () => { - it("uses circle key scope", async () => { + describe("scope keys", () => { + it("setCircleMeta uses the circle key", async () => { await orgClient.setCircleMeta(5n, { name: "Circle", purpose: "Test", parentCircleId: null, config: {}, }); - - expect(mockKm.deriveCircleKey).toHaveBeenCalled(); + expect(mockKm.deriveCircleKey).toHaveBeenCalledWith(expect.any(Uint8Array), 5n); expect(mockStorage.putEncrypted).toHaveBeenCalledWith( - "org:1", - "circle:5:meta", - expect.any(Uint8Array), expect.any(Uint8Array), + CIRCLE_KEY, ); }); - }); - describe("getTension / setTension", () => { - it("uses circle key scope and correct key path", async () => { + it("setTension uses the circle key", async () => { const tension: Tension = { id: "abc", circleId: 5n, @@ -131,41 +105,13 @@ describe("OrgClient", () => { resolvedAt: null, }; await orgClient.setTension(5n, tension); - - expect(mockKm.deriveCircleKey).toHaveBeenCalled(); expect(mockStorage.putEncrypted).toHaveBeenCalledWith( - "org:1", - "circle:5:tension:abc", - expect.any(Uint8Array), - expect.any(Uint8Array), - ); - }); - }); - - describe("getProposal / setProposal", () => { - it("uses circle key scope", async () => { - await orgClient.setProposal(3n, { - id: 10n, - circleId: 3n, - proposer: "0xBob", - tension: "t", - explanation: "e", - rationale: "r", - discussion: [], - createdAt: 1000, - }); - - expect(mockStorage.putEncrypted).toHaveBeenCalledWith( - "org:1", - "circle:3:proposal:10", - expect.any(Uint8Array), expect.any(Uint8Array), + CIRCLE_KEY, ); }); - }); - describe("getRoleConfig / setRoleConfig", () => { - it("uses role key scope (circle key → role key)", async () => { + it("setRoleConfig derives the role key (circle key → role key)", async () => { const config: RoleConfig = { name: "R", purpose: "P", @@ -173,53 +119,43 @@ describe("OrgClient", () => { agentConfig: null, }; await orgClient.setRoleConfig(3n, 7n, config); - - expect(mockKm.deriveCircleKey).toHaveBeenCalled(); - expect(mockKm.deriveRoleKey).toHaveBeenCalled(); - expect(mockStorage.putEncrypted).toHaveBeenCalledWith( - "org:1", - "role:7:config", - expect.any(Uint8Array), - expect.any(Uint8Array), - ); + expect(mockKm.deriveCircleKey).toHaveBeenCalledWith(expect.any(Uint8Array), 3n); + expect(mockKm.deriveRoleKey).toHaveBeenCalledWith(CIRCLE_KEY, 7n); + expect(mockStorage.putEncrypted).toHaveBeenCalledWith(expect.any(Uint8Array), ROLE_KEY); }); }); - describe("shareCircleKey", () => { - it("creates key share and stores at keyshare path", async () => { - await orgClient.shareCircleKey(5n, "0xBob", new Uint8Array(32)); - - expect(mockKm.createKeyShare).toHaveBeenCalled(); - expect(mockStorage.putEncrypted).toHaveBeenCalledWith( - "org:1", - "keyshare:circle:5:0xbob", - expect.any(Uint8Array), - expect.any(Uint8Array), - ); + describe("key sharing", () => { + it("shareCircleKey wraps the circle key and pins it under the org key", async () => { + const hash = await orgClient.shareCircleKey(5n, new Uint8Array(32)); + expect(mockKm.createKeyShare).toHaveBeenCalledWith(CIRCLE_KEY, expect.any(Uint8Array)); + // The key-share envelope itself is encrypted with the org key. + expect(mockStorage.putEncrypted).toHaveBeenCalledWith(expect.any(Uint8Array), ORG_KEY); + expect(hash).toBe(STORED_HASH); }); - }); - describe("shareRoleKey", () => { - it("creates key share and stores at role keyshare path", async () => { - await orgClient.shareRoleKey(3n, 7n, "0xBob", new Uint8Array(32)); - - expect(mockKm.createKeyShare).toHaveBeenCalled(); - expect(mockStorage.putEncrypted).toHaveBeenCalledWith( - "org:1", - "keyshare:role:7:0xbob", - expect.any(Uint8Array), - expect.any(Uint8Array), - ); + it("shareRoleKey wraps the role key and pins it under the org key", async () => { + await orgClient.shareRoleKey(3n, 7n, new Uint8Array(32)); + expect(mockKm.createKeyShare).toHaveBeenCalledWith(ROLE_KEY, expect.any(Uint8Array)); + expect(mockStorage.putEncrypted).toHaveBeenCalledWith(expect.any(Uint8Array), ORG_KEY); }); }); describe("master key caching", () => { - it("only derives master key once across multiple calls", async () => { - vi.mocked(mockStorage.getDecrypted).mockResolvedValue(null); - await orgClient.getOrgMeta(); - await orgClient.getCircleMeta(1n); - await orgClient.getTension(1n, "x"); - + it("only derives the master key once across multiple writes", async () => { + await orgClient.setOrgMeta({ name: "a", purpose: "b", config: {} }); + await orgClient.setCircleMeta(1n, { + name: "c", + purpose: "d", + parentCircleId: null, + config: {}, + }); + await orgClient.setRoleConfig(1n, 2n, { + name: "r", + purpose: "p", + instructions: "i", + agentConfig: null, + }); expect(mockKm.deriveMasterKey).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/hollab-sdk/test/unit/storageClient.spec.ts b/packages/hollab-sdk/test/unit/storageClient.spec.ts index c5c531c..a425e71 100644 --- a/packages/hollab-sdk/test/unit/storageClient.spec.ts +++ b/packages/hollab-sdk/test/unit/storageClient.spec.ts @@ -1,147 +1,133 @@ +import { CID } from "multiformats/cid"; +import { sha256 } from "multiformats/hashes/sha2"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { IKeyManager } from "../../src/interfaces/keyManager.interface.js"; -import type { ZeroGKvClient, ZeroGLogClient } from "../../src/providers/storageClient.js"; import { StorageClient } from "../../src/internal.js"; +const NONCE = new Uint8Array(16).fill(0x01); +const encKey = new Uint8Array(32).fill(0xaa); + function createMockKeyManager(): IKeyManager { return { deriveMasterKey: vi.fn(), deriveOrgKey: vi.fn(), deriveCircleKey: vi.fn(), deriveRoleKey: vi.fn(), - encrypt: vi.fn().mockImplementation((_key: Uint8Array, plaintext: Uint8Array) => ({ - nonce: new Uint8Array(16).fill(0x01), - ciphertext: plaintext, // passthrough for testing + // Passthrough cipher with a fixed nonce, so we can predict the bytes + // that get pinned and therefore the resulting CID. + encrypt: vi.fn().mockImplementation((_k: Uint8Array, plaintext: Uint8Array) => ({ + nonce: NONCE, + ciphertext: plaintext, })), decrypt: vi .fn() - .mockImplementation( - (_key: Uint8Array, payload: { ciphertext: Uint8Array }) => payload.ciphertext, - ), + .mockImplementation((_k: Uint8Array, p: { ciphertext: Uint8Array }) => p.ciphertext), createKeyShare: vi.fn(), decryptKeyShare: vi.fn(), deriveSharedSecret: vi.fn(), }; } -function createMockKvClient(): ZeroGKvClient { - return { - put: vi.fn(), - get: vi.fn(), - exists: vi.fn(), - }; +/** Mirror StorageClient's wire format: [2-byte nonceLen][nonce][ciphertext]. */ +function serialize(nonce: Uint8Array, ciphertext: Uint8Array): Uint8Array { + const out = new Uint8Array(2 + nonce.length + ciphertext.length); + out[0] = (nonce.length >> 8) & 0xff; + out[1] = nonce.length & 0xff; + out.set(nonce, 2); + out.set(ciphertext, 2 + nonce.length); + return out; } -function createMockLogClient(): ZeroGLogClient { - return { - append: vi.fn(), - read: vi.fn(), - }; +/** Mirror the indexer pin-proxy: sha2-256 → CIDv0. */ +async function cidForBytes(bytes: Uint8Array): Promise { + return CID.createV0(await sha256.digest(bytes)).toString(); } -describe("StorageClient", () => { - let storageClient: StorageClient; - let mockKm: IKeyManager; - let mockKv: ZeroGKvClient; - let mockLog: ZeroGLogClient; +async function digestHex(bytes: Uint8Array): Promise { + const d = (await sha256.digest(bytes)).digest; + let hex = ""; + for (const b of d) hex += b.toString(16).padStart(2, "0"); + return hex; +} - const streamId = "org:1"; - const encKey = new Uint8Array(32).fill(0xaa); +describe("StorageClient (IPFS pin-proxy)", () => { + let km: IKeyManager; + let fetchMock: ReturnType; + let client: StorageClient; beforeEach(() => { - mockKm = createMockKeyManager(); - mockKv = createMockKvClient(); - mockLog = createMockLogClient(); - storageClient = new StorageClient(mockKm, mockKv, mockLog); + km = createMockKeyManager(); + fetchMock = vi.fn(); + client = new StorageClient(km, { + pinUrl: "http://indexer.test/storage/pin", + gatewayUrl: "http://indexer.test/", + fetchImpl: fetchMock as unknown as typeof fetch, + }); }); - afterEach(() => { - vi.clearAllMocks(); - }); + afterEach(() => vi.clearAllMocks()); describe("putEncrypted", () => { - it("encrypts and stores value", async () => { - const value = new TextEncoder().encode("test data"); - await storageClient.putEncrypted(streamId, "mykey", value, encKey); - - expect(mockKm.encrypt).toHaveBeenCalledWith(encKey, value); - expect(mockKv.put).toHaveBeenCalledWith(streamId, "mykey", expect.any(Uint8Array)); - }); - }); + it("encrypts, POSTs to the pin endpoint, and returns the CID's bytes32 digest", async () => { + const value = new TextEncoder().encode("secret"); + const pinned = serialize(NONCE, value); // passthrough cipher + const cid = await cidForBytes(pinned); + fetchMock.mockResolvedValue( + new Response(JSON.stringify({ cid }), { + headers: { "content-type": "application/json" }, + }), + ); - describe("getDecrypted", () => { - it("returns null when key not found", async () => { - vi.mocked(mockKv.get).mockResolvedValue(null); + const contentHash = await client.putEncrypted(value, encKey); - const result = await storageClient.getDecrypted(streamId, "missing", encKey); - expect(result).toBeNull(); + expect(km.encrypt).toHaveBeenCalledWith(encKey, value); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe("http://indexer.test/storage/pin"); + expect(init.method).toBe("POST"); + // contentHash is exactly the sha2-256 digest the proxy hashed into the CID. + expect(contentHash).toBe(`0x${await digestHex(pinned)}`); }); - it("fetches and decrypts value", async () => { - const plaintext = new TextEncoder().encode("hello"); - // Simulate stored data: [0, 16, ...nonce(16 bytes), ...ciphertext] - const nonce = new Uint8Array(16).fill(0x01); - const stored = new Uint8Array(2 + 16 + plaintext.length); - stored[0] = 0; - stored[1] = 16; - stored.set(nonce, 2); - stored.set(plaintext, 18); - - vi.mocked(mockKv.get).mockResolvedValue(stored); - - const result = await storageClient.getDecrypted(streamId, "mykey", encKey); - expect(mockKm.decrypt).toHaveBeenCalled(); - expect(result).toEqual(plaintext); + it("throws a StorageError when the proxy rejects the upload", async () => { + fetchMock.mockResolvedValue(new Response("payload too large", { status: 413 })); + await expect( + client.putEncrypted(new TextEncoder().encode("x"), encKey), + ).rejects.toThrow(/413/); }); - }); - - describe("appendLog", () => { - it("serializes entry and appends to log", async () => { - const entry = { type: "test", data: { foo: "bar" }, timestamp: 1234567890 }; - await storageClient.appendLog(streamId, entry); - expect(mockLog.append).toHaveBeenCalledWith(streamId, expect.any(Uint8Array)); - const call = vi.mocked(mockLog.append).mock.calls[0]!; - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const decoded = JSON.parse(new TextDecoder().decode(call[1])); - expect(decoded).toEqual(entry); + it("throws when the proxy returns no CID", async () => { + fetchMock.mockResolvedValue(new Response(JSON.stringify({ error: "boom" }))); + await expect( + client.putEncrypted(new TextEncoder().encode("x"), encKey), + ).rejects.toThrow(/no CID/); }); }); - describe("readLog", () => { - it("reads and parses log entries", async () => { - const entries = [ - { type: "a", data: {}, timestamp: 1 }, - { type: "b", data: { x: 1 }, timestamp: 2 }, - ]; - vi.mocked(mockLog.read).mockResolvedValue( - entries.map((e) => new TextEncoder().encode(JSON.stringify(e))), - ); + describe("getDecrypted", () => { + it("reconstructs the CID, fetches from the gateway, and decrypts", async () => { + const plaintext = new TextEncoder().encode("hello"); + const stored = serialize(NONCE, plaintext); + const cid = await cidForBytes(stored); + const contentHash = `0x${await digestHex(stored)}` as `0x${string}`; - const result = await storageClient.readLog(streamId, 0, 2); - expect(result).toEqual(entries); - expect(mockLog.read).toHaveBeenCalledWith(streamId, 0, 2); - }); + const ab = new ArrayBuffer(stored.byteLength); + new Uint8Array(ab).set(stored); + fetchMock.mockResolvedValue(new Response(ab)); - it("returns empty array for no entries", async () => { - vi.mocked(mockLog.read).mockResolvedValue([]); - const result = await storageClient.readLog(streamId, 0, 10); - expect(result).toEqual([]); - }); - }); + const out = await client.getDecrypted(contentHash, encKey); - describe("streamExists", () => { - it("delegates to kv client", async () => { - vi.mocked(mockKv.exists).mockResolvedValue(true); - const result = await storageClient.streamExists(streamId); - expect(result).toBe(true); + const [url] = fetchMock.mock.calls[0] as [string]; + // Gateway URL is normalized (trailing slash stripped) + /ipfs/. + expect(url).toBe(`http://indexer.test/ipfs/${cid}`); + expect(km.decrypt).toHaveBeenCalled(); + expect(out).toEqual(plaintext); }); - it("returns false when stream does not exist", async () => { - vi.mocked(mockKv.exists).mockResolvedValue(false); - const result = await storageClient.streamExists(streamId); - expect(result).toBe(false); + it("throws a StorageError when the gateway 404s", async () => { + const contentHash = `0x${"ab".repeat(32)}` as `0x${string}`; + fetchMock.mockResolvedValue(new Response("nope", { status: 404 })); + await expect(client.getDecrypted(contentHash, encKey)).rejects.toThrow(/404/); }); }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a9a5e3f..bec8847 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -80,9 +80,6 @@ importers: apps/hola-modern: dependencies: - "@0gfoundation/0g-ts-sdk": - specifier: 1.2.1 - version: 1.2.1(bufferutil@4.1.0)(ethers@6.13.1(bufferutil@4.1.0)(utf-8-validate@5.0.10))(utf-8-validate@5.0.10) "@fullcalendar/core": specifier: ^6.1.20 version: 6.1.20 @@ -134,6 +131,9 @@ importers: lucide-react: specifier: ^1.7.0 version: 1.7.0(react@19.2.4) + multiformats: + specifier: ^13.3.7 + version: 13.4.2 postcss: specifier: ^8.5.8 version: 8.5.8 @@ -213,6 +213,9 @@ importers: hono: specifier: ^4.5.0 version: 4.12.10 + multiformats: + specifier: ^13.3.7 + version: 13.4.2 ponder: specifier: ^0.16.6 version: 0.16.6(@opentelemetry/api@1.9.1)(@types/node@20.19.39)(bufferutil@4.1.0)(hono@4.12.10)(lightningcss@1.32.0)(typescript@5.9.3)(utf-8-validate@6.0.6)(viem@2.21.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)(zod@3.25.76))(zod@3.25.76) @@ -273,6 +276,9 @@ importers: "@noble/hashes": specifier: ^1.7.1 version: 1.8.0 + multiformats: + specifier: ^13.3.7 + version: 13.4.2 viem: specifier: 2.21.4 version: 2.21.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)(zod@3.25.76) @@ -301,14 +307,6 @@ importers: version: 8.5.1(jiti@2.4.2)(postcss@8.5.8)(typescript@5.9.3)(yaml@2.5.1) packages: - "@0gfoundation/0g-ts-sdk@1.2.1": - resolution: - { - integrity: sha512-1PBq+aug6p3OYh9UbEDxn+mroQ4XCjd7355wwStfd8ykyKMXHTdrSuNrfranr5TnF/uksKUWnN3HJ73gYKKDpw==, - } - peerDependencies: - ethers: 6.13.1 - "@adraffy/ens-normalize@1.10.0": resolution: { @@ -1844,24 +1842,6 @@ packages: } engines: { node: ">=14" } - "@ethersproject/bytes@5.8.0": - resolution: - { - integrity: sha512-vTkeohgJVCPVHu5c25XWaWQOZ4v+DkGoC42/TS2ond+PARCxTJvgTFUNDZovyQ/uAQ4EcpqqowKydcdmRKjg7A==, - } - - "@ethersproject/keccak256@5.8.0": - resolution: - { - integrity: sha512-A1pkKLZSz8pDaQ1ftutZoaN46I6+jvuqugx5KYNeQOPqq+JZ0Txm7dlWesCHB5cndJSu5vP2VKptKf7cksERng==, - } - - "@ethersproject/logger@5.8.0": - resolution: - { - integrity: sha512-Qe6knGmY+zPPWTC+wQrpitodgBfH7XoceCGL5bJVejmH+yCS3R8jJm8iiWuvWbG76RUmyEG53oqv6GMVWqunjA==, - } - "@fastify/busboy@3.2.0": resolution: { @@ -4992,12 +4972,6 @@ packages: peerDependencies: axios: 0.x || 1.x - axios@0.27.2: - resolution: - { - integrity: sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ==, - } - axios@1.13.6: resolution: { @@ -5857,13 +5831,6 @@ packages: } engines: { node: ">=12" } - d@1.0.2: - resolution: - { - integrity: sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==, - } - engines: { node: ">=0.12" } - dargs@8.1.0: resolution: { @@ -6485,26 +6452,6 @@ packages: integrity: sha512-X13Q/ZSc+vsO1q600bvNK4bxgXMkHcf//RxCmYDaRY5DAcT+eoXjY5hoAPGMdRnWQjvyLEcyauG3b6hz76LNqg==, } - es5-ext@0.10.64: - resolution: - { - integrity: sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==, - } - engines: { node: ">=0.10" } - - es6-iterator@2.0.3: - resolution: - { - integrity: sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==, - } - - es6-symbol@3.1.4: - resolution: - { - integrity: sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg==, - } - engines: { node: ">=0.12" } - esast-util-from-estree@2.0.0: resolution: { @@ -6695,13 +6642,6 @@ packages: jiti: optional: true - esniff@2.0.1: - resolution: - { - integrity: sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==, - } - engines: { node: ">=0.10" } - espree@10.4.0: resolution: { @@ -6851,12 +6791,6 @@ packages: } engines: { node: ">= 0.8" } - event-emitter@0.3.5: - resolution: - { - integrity: sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==, - } - event-target-shim@5.0.1: resolution: { @@ -6910,12 +6844,6 @@ packages: } engines: { node: ">=12.0.0" } - ext@1.7.0: - resolution: - { - integrity: sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==, - } - extend@3.0.2: resolution: { @@ -7943,12 +7871,6 @@ packages: } engines: { node: ">= 0.4" } - is-typedarray@1.0.0: - resolution: - { - integrity: sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==, - } - is-unicode-supported@1.3.0: resolution: { @@ -8079,12 +8001,6 @@ packages: } engines: { node: ">=10" } - js-sha3@0.8.0: - resolution: - { - integrity: sha512-gF1cRrHhIzNfToc802P800N8PpXS+evLLXfsVpowqmAFR9uwbi89WvXg2QspOmXL8QL86J4T1EpFu+yUkwJY3Q==, - } - js-tokens@4.0.0: resolution: { @@ -9115,6 +9031,12 @@ packages: integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==, } + multiformats@13.4.2: + resolution: + { + integrity: sha512-eh6eHCrRi1+POZ3dA+Dq1C6jhP1GNtr9CRINMb67OKzqW9I5DUuZM/3jLPlzhgpGeiNUlEGEbkCYChXMCc/8DQ==, + } + multiformats@9.9.0: resolution: { @@ -9154,12 +9076,6 @@ packages: } engines: { node: ">= 0.6" } - next-tick@1.1.0: - resolution: - { - integrity: sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==, - } - node-addon-api@2.0.2: resolution: { @@ -9363,12 +9279,6 @@ packages: integrity: sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g==, } - open-jsonrpc-provider@0.2.1: - resolution: - { - integrity: sha512-b+pRxakRwAqp+4OTh2TeH+z2zwVGa0C4fbcwIn6JsZdjd4VBmsp7KfCdmmV3XH8diecwXa5UILCw4IwAtk1DTQ==, - } - openapi-fetch@0.13.8: resolution: { @@ -10500,12 +10410,6 @@ packages: integrity: sha512-cjwII1MdIIVloKvC9ErQ+OgAtwHBmcZ0Bg4ciz78FtbT8In39aAYbaA7zvxQ61xVMSPE8WxhLwLbhif4Js2C+g==, } - reconnecting-websocket@4.4.0: - resolution: - { - integrity: sha512-D2E33ceRPga0NvTDhJmphEgJ7FUYF0v4lr1ki0csq06OdlxKfugGzN0dSkxM/NfqCxYELK4KcaTOUOjTV6Dcng==, - } - regex-recursion@5.1.1: resolution: { @@ -11599,12 +11503,6 @@ packages: } engines: { node: ">=14.16" } - type@2.7.3: - resolution: - { - integrity: sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==, - } - typed-array-buffer@1.0.3: resolution: { @@ -11612,12 +11510,6 @@ packages: } engines: { node: ">= 0.4" } - typedarray-to-buffer@3.1.5: - resolution: - { - integrity: sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==, - } - typescript-eslint@8.58.0: resolution: { @@ -12176,13 +12068,6 @@ packages: integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==, } - websocket@1.0.35: - resolution: - { - integrity: sha512-/REy6amwPZl44DDzvRCkaI1q1bIiQB0mEFQLUrhz3z2EK91cp3n72rAjUlrTP0zV22HJIUOVHQGPxhFRjxjt+Q==, - } - engines: { node: ">=4.0.0" } - whatwg-url@5.0.0: resolution: { @@ -12367,14 +12252,6 @@ packages: } engines: { node: ">=10" } - yaeti@0.0.6: - resolution: - { - integrity: sha512-MvQa//+KcZCUkBTIC9blM+CU9J2GzuTytsOUwf2lidtvkx/6gnEp1QvJv34t9vdjhFmha/mUiNDbN0D0mJWdug==, - } - engines: { node: ">=0.10.32" } - deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. - yallist@3.1.1: resolution: { @@ -12507,18 +12384,6 @@ packages: } snapshots: - "@0gfoundation/0g-ts-sdk@1.2.1(bufferutil@4.1.0)(ethers@6.13.1(bufferutil@4.1.0)(utf-8-validate@5.0.10))(utf-8-validate@5.0.10)": - dependencies: - "@ethersproject/bytes": 5.8.0 - "@ethersproject/keccak256": 5.8.0 - ethers: 6.13.1(bufferutil@4.1.0)(utf-8-validate@5.0.10) - open-jsonrpc-provider: 0.2.1(bufferutil@4.1.0)(utf-8-validate@5.0.10) - transitivePeerDependencies: - - bufferutil - - debug - - supports-color - - utf-8-validate - "@adraffy/ens-normalize@1.10.0": {} "@adraffy/ens-normalize@1.10.1": {} @@ -12881,7 +12746,7 @@ snapshots: "@commitlint/is-ignored@19.8.1": dependencies: "@commitlint/types": 19.8.1 - semver: 7.7.2 + semver: 7.7.4 "@commitlint/lint@19.8.1": dependencies: @@ -13400,17 +13265,6 @@ snapshots: ethereum-cryptography: 2.2.1 micro-ftch: 0.3.1 - "@ethersproject/bytes@5.8.0": - dependencies: - "@ethersproject/logger": 5.8.0 - - "@ethersproject/keccak256@5.8.0": - dependencies: - "@ethersproject/bytes": 5.8.0 - js-sha3: 0.8.0 - - "@ethersproject/logger@5.8.0": {} - "@fastify/busboy@3.2.0": {} "@floating-ui/core@1.7.5": @@ -16547,13 +16401,6 @@ snapshots: axios: 1.13.6 is-retry-allowed: 2.2.0 - axios@0.27.2: - dependencies: - follow-redirects: 1.15.11 - form-data: 4.0.5 - transitivePeerDependencies: - - debug - axios@1.13.6: dependencies: follow-redirects: 1.15.11 @@ -17071,11 +16918,6 @@ snapshots: d3-timer@3.0.1: {} - d@1.0.2: - dependencies: - es5-ext: 0.10.64 - type: 2.7.3 - dargs@8.1.0: {} dataloader@2.2.3: {} @@ -17324,24 +17166,6 @@ snapshots: es-toolkit@1.33.0: {} - es5-ext@0.10.64: - dependencies: - es6-iterator: 2.0.3 - es6-symbol: 3.1.4 - esniff: 2.0.1 - next-tick: 1.1.0 - - es6-iterator@2.0.3: - dependencies: - d: 1.0.2 - es5-ext: 0.10.64 - es6-symbol: 3.1.4 - - es6-symbol@3.1.4: - dependencies: - d: 1.0.2 - ext: 1.7.0 - esast-util-from-estree@2.0.0: dependencies: "@types/estree-jsx": 1.0.5 @@ -17651,13 +17475,6 @@ snapshots: transitivePeerDependencies: - supports-color - esniff@2.0.1: - dependencies: - d: 1.0.2 - es5-ext: 0.10.64 - event-emitter: 0.3.5 - type: 2.7.3 - espree@10.4.0: dependencies: acorn: 8.15.0 @@ -17775,11 +17592,6 @@ snapshots: "@types/node": 24.12.2 require-like: 0.1.2 - event-emitter@0.3.5: - dependencies: - d: 1.0.2 - es5-ext: 0.10.64 - event-target-shim@5.0.1: {} eventemitter2@6.4.9: {} @@ -17819,10 +17631,6 @@ snapshots: expect-type@1.2.2: {} - ext@1.7.0: - dependencies: - type: 2.7.3 - extend@3.0.2: {} extension-port-stream@3.0.0: @@ -18446,8 +18254,6 @@ snapshots: dependencies: which-typed-array: 1.1.20 - is-typedarray@1.0.0: {} - is-unicode-supported@1.3.0: {} is-what@5.5.0: {} @@ -18509,8 +18315,6 @@ snapshots: joycon@3.1.1: {} - js-sha3@0.8.0: {} - js-tokens@4.0.0: {} js-yaml@4.1.0: @@ -19328,6 +19132,8 @@ snapshots: ms@2.1.3: {} + multiformats@13.4.2: {} + multiformats@9.9.0: {} mz@2.7.0: @@ -19346,8 +19152,6 @@ snapshots: negotiator@0.6.4: {} - next-tick@1.1.0: {} - node-addon-api@2.0.2: {} node-fetch-native@1.6.7: {} @@ -19476,18 +19280,6 @@ snapshots: regex: 5.1.1 regex-recursion: 5.1.1 - open-jsonrpc-provider@0.2.1(bufferutil@4.1.0)(utf-8-validate@5.0.10): - dependencies: - axios: 0.27.2 - reconnecting-websocket: 4.4.0 - websocket: 1.0.35 - ws: 8.20.0(bufferutil@4.1.0)(utf-8-validate@5.0.10) - transitivePeerDependencies: - - bufferutil - - debug - - supports-color - - utf-8-validate - openapi-fetch@0.13.8: dependencies: openapi-typescript-helpers: 0.0.15 @@ -20289,8 +20081,6 @@ snapshots: unified: 11.0.5 vfile: 6.0.3 - reconnecting-websocket@4.4.0: {} - regex-recursion@5.1.1: dependencies: regex: 5.1.1 @@ -21037,18 +20827,12 @@ snapshots: type-fest@3.13.1: {} - type@2.7.3: {} - typed-array-buffer@1.0.3: dependencies: call-bound: 1.0.4 es-errors: 1.3.0 is-typed-array: 1.1.15 - typedarray-to-buffer@3.1.5: - dependencies: - is-typedarray: 1.0.0 - typescript-eslint@8.58.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3): dependencies: "@typescript-eslint/eslint-plugin": 8.58.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) @@ -21648,17 +21432,6 @@ snapshots: webidl-conversions@3.0.1: {} - websocket@1.0.35: - dependencies: - bufferutil: 4.1.0 - debug: 2.6.9 - es5-ext: 0.10.64 - typedarray-to-buffer: 3.1.5 - utf-8-validate: 5.0.10 - yaeti: 0.0.6 - transitivePeerDependencies: - - supports-color - whatwg-url@5.0.0: dependencies: tr46: 0.0.3 @@ -21758,8 +21531,6 @@ snapshots: y18n@5.0.8: {} - yaeti@0.0.6: {} - yallist@3.1.1: {} yaml@2.5.1: {} diff --git a/scripts/deploy-ipfs.sh b/scripts/deploy-ipfs.sh new file mode 100755 index 0000000..b11f046 --- /dev/null +++ b/scripts/deploy-ipfs.sh @@ -0,0 +1,178 @@ +#!/usr/bin/env bash +# ────────────────────────────────────────────────────────────────────────────── +# deploy-ipfs.sh — Build the hola-modern SPA, pin it to IPFS, and emit the Safe +# transaction needed to point hollab.eth at the new CID. +# +# This script intentionally STOPS before submitting anything on-chain. The +# `hollab.eth` ENS contenthash is updated through a Safe multisig — a human +# pastes the emitted calldata into the Safe Transaction Builder and collects +# signatures. The deployer key never lives in CI or in this script. +# +# Pipeline: +# 1. Preflight — required env + tools (omnipin, node, pnpm) +# 2. Build — pnpm --filter hola-modern build (+ dist/404.html copy) +# 3. Pin — omnipin uploads dist/ to IPFS/Filecoin, returns a CID +# 4. Emit — print CID, gateway preview, and setContenthash(node, hash) +# calldata for the Safe Transaction Builder +# +# Usage: +# OMNIPIN_FILECOIN_TOKEN=… ./scripts/deploy-ipfs.sh +# +# Required env: +# OMNIPIN_FILECOIN_TOKEN — Filecoin/IPFS pinning API token for omnipin +# +# Optional env: +# ENS_NAME — ENS name to update (default: hollab.eth) +# ENS_RESOLVER — resolver address the Safe tx targets. If unset the +# script still emits calldata and tells you to send it +# to the name's current Public Resolver. +# OMNIPIN_BIN — override the omnipin invocation (default: omnipin, +# falling back to `npx --yes omnipin`) +# SKIP_BUILD=1 — reuse an existing apps/hola-modern/dist/ +# +# Production build vars (read from apps/hola-modern/.env.production if present, +# otherwise must be exported in your shell): +# VITE_IPFS_GATEWAY, VITE_INDEXER_URL_SEPOLIA, VITE_INDEXER_URL_MAINNET, +# VITE_WALLETCONNECT_PROJECT_ID, … +# ────────────────────────────────────────────────────────────────────────────── +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +FRONTEND="$ROOT/apps/hola-modern" +DIST="$FRONTEND/dist" +ENS_NAME="${ENS_NAME:-hollab.eth}" + +# ── tiny color helpers ─────────────────────────────────────────────────────── +if [ -t 1 ]; then + BOLD=$'\033[1m'; DIM=$'\033[2m'; GREEN=$'\033[32m'; YELLOW=$'\033[33m'; RED=$'\033[31m'; RESET=$'\033[0m' +else + BOLD=""; DIM=""; GREEN=""; YELLOW=""; RED=""; RESET="" +fi +step() { echo ""; echo "${BOLD}▶ $*${RESET}"; } +ok() { echo "${GREEN}✓${RESET} $*"; } +warn() { echo "${YELLOW}!${RESET} $*"; } +die() { echo "${RED}✗ $*${RESET}" >&2; exit 1; } + +# ── 1. Preflight ───────────────────────────────────────────────────────────── +step "Preflight" + +for cmd in node pnpm; do + command -v "$cmd" &>/dev/null || die "$cmd is required but not found." +done +ok "node $(node --version), pnpm $(pnpm --version)" + +: "${OMNIPIN_FILECOIN_TOKEN:?OMNIPIN_FILECOIN_TOKEN is required (Filecoin/IPFS pinning token)}" +ok "OMNIPIN_FILECOIN_TOKEN present" + +# Resolve the omnipin binary (global, else npx fallback). +if [ -n "${OMNIPIN_BIN:-}" ]; then + read -r -a OMNIPIN <<<"$OMNIPIN_BIN" +elif command -v omnipin &>/dev/null; then + OMNIPIN=(omnipin) +else + warn "omnipin not on PATH — falling back to 'npx --yes omnipin'" + OMNIPIN=(npx --yes omnipin) +fi +ok "omnipin: ${OMNIPIN[*]}" + +# Load production build vars if a dotenv file exists (does not override the shell). +ENV_PROD="$FRONTEND/.env.production" +if [ -f "$ENV_PROD" ]; then + set -a; # shellcheck disable=SC1090 + source "$ENV_PROD"; set +a + ok "loaded build vars from $ENV_PROD" +else + warn "no $ENV_PROD — relying on VITE_* already exported in the shell" +fi +[ -n "${VITE_IPFS_GATEWAY:-}" ] || warn "VITE_IPFS_GATEWAY is empty — production reads may fall back to the default gateway" + +# ── 2. Build ───────────────────────────────────────────────────────────────── +if [ "${SKIP_BUILD:-}" = "1" ] && [ -f "$DIST/index.html" ]; then + step "Build (skipped — reusing $DIST)" +else + step "Build" + ( cd "$ROOT" && pnpm --filter hola-modern build ) +fi +[ -f "$DIST/index.html" ] || die "build did not produce $DIST/index.html" +[ -f "$DIST/404.html" ] || die "missing $DIST/404.html — the hash-routing fallback copy did not run" +ok "dist/ built (index.html + 404.html present)" + +# ── 3. Pin to IPFS ─────────────────────────────────────────────────────────── +step "Pin dist/ to IPFS via omnipin" +warn "omnipin CLI shape is assumed: ' upload ' printing a CID on stdout." +warn "If this fails, run the upload manually and re-run with CID= to skip pinning." + +if [ -n "${CID:-}" ]; then + ok "using provided CID=$CID (pinning skipped)" +else + PIN_OUT="$("${OMNIPIN[@]}" upload "$DIST" 2>&1)" || { + echo "$PIN_OUT" >&2 + die "omnipin upload failed. Confirm the CLI flags (\`${OMNIPIN[*]} --help\`) and OMNIPIN_FILECOIN_TOKEN, or pass CID= to skip." + } + echo "${DIM}${PIN_OUT}${RESET}" + # Extract the first CIDv0 (Qm…) or CIDv1 (bafy…/bafk…) token from omnipin output. + CID="$(printf '%s\n' "$PIN_OUT" | grep -oE '\b(Qm[1-9A-HJ-NP-Za-km-z]{44}|ba[a-z2-7]{57,})\b' | head -n1 || true)" + [ -n "$CID" ] || die "could not parse a CID from omnipin output (see above). Re-run with CID=." + ok "pinned → $CID" +fi + +# ── 4. Emit Safe contenthash transaction ───────────────────────────────────── +step "Encode ENS contenthash + Safe calldata" + +# Encode using the frontend's installed viem + multiformats (run from $FRONTEND +# so the deps resolve). Emits a single line: +ENC="$( + cd "$FRONTEND" && CID="$CID" ENS_NAME="$ENS_NAME" node --input-type=module <<'NODE' +import { CID } from "multiformats/cid"; +import { namehash, encodeFunctionData } from "viem"; + +const cidStr = process.env.CID; +const name = process.env.ENS_NAME; + +// Normalize to CIDv1 dag-pb so the contenthash carries the standard 0x0170 prefix. +const v1 = CID.parse(cidStr).toV1(); +const cidHex = Buffer.from(v1.bytes).toString("hex"); +// EIP-1577 ipfs contenthash = varint(ipfs-ns = 0xe3) ++ CIDv1 bytes → 0xe301 ++ cid +const contenthash = `0x e3 01 ${cidHex}`.replace(/\s+/g, ""); + +const node = namehash(name); +const calldata = encodeFunctionData({ + abi: [{ + type: "function", + name: "setContenthash", + stateMutability: "nonpayable", + inputs: [{ name: "node", type: "bytes32" }, { name: "hash", type: "bytes" }], + outputs: [], + }], + functionName: "setContenthash", + args: [node, contenthash], +}); + +process.stdout.write(`${node} ${contenthash} ${calldata}\n`); +NODE +)" || die "contenthash encoding failed (is hola-modern installed? run 'pnpm install')." + +read -r ENS_NODE CONTENTHASH CALLDATA <<<"$ENC" +GATEWAY="${VITE_IPFS_GATEWAY:-https://gateway.pinata.cloud}" + +echo "" +echo "${BOLD}════════════════════════════════════════════════════════════════════${RESET}" +echo "${BOLD} Pinned build ready — Safe transaction below (NOT submitted)${RESET}" +echo "${BOLD}════════════════════════════════════════════════════════════════════${RESET}" +echo " ENS name : ${ENS_NAME}" +echo " CID : ${CID}" +echo " Preview : ${GATEWAY%/}/ipfs/${CID}" +echo " eth.limo : https://${ENS_NAME}.limo (after the record propagates)" +echo "" +echo " ${BOLD}Safe Transaction Builder${RESET} — new transaction:" +echo " To (resolver) : ${ENS_RESOLVER:-}" +echo " Value : 0" +echo " Method : setContenthash(bytes32 node, bytes hash)" +echo " node : ${ENS_NODE}" +echo " hash : ${CONTENTHASH}" +echo "" +echo " ${BOLD}Raw calldata${RESET} (paste into Safe's 'Custom data' / Transaction Builder):" +echo " ${CALLDATA}" +echo "" +warn "Collect Safe signatures and execute to finish the deploy. Nothing was submitted on-chain." +echo "${BOLD}════════════════════════════════════════════════════════════════════${RESET}" diff --git a/specs/05-governance-process.md b/specs/05-governance-process.md index 265e960..45c04fa 100644 --- a/specs/05-governance-process.md +++ b/specs/05-governance-process.md @@ -471,8 +471,7 @@ are `bytes32` content-addresses of the off-chain text. The contract is indifferent to the addressing scheme — it works equally with: - `keccak256(utf8(rawText))` when the content is inline and small -- CIDv1 sha256-truncated to 32 bytes when stored on IPFS / Filecoin -- 0G Merkle root when stored on 0G Storage +- the 32-byte sha2-256 multihash digest of a CIDv0 when the encrypted blob is pinned on IPFS (the MVP path — uploaded via the indexer pin-proxy, reconstructed to a full CID on read) This keeps `ContentRef` polymorphic across storage backends. See `CLAUDE.md` and `hollab-sdk` for the encryption layer that sits between the diff --git a/specs/07-private-data-and-ai-agents.md b/specs/07-private-data-and-ai-agents.md index 9c1f969..836be02 100644 --- a/specs/07-private-data-and-ai-agents.md +++ b/specs/07-private-data-and-ai-agents.md @@ -2,7 +2,9 @@ > **MVP status (2026-04-13): descoped.** The Aztec-flavored privacy posture described here is **not** in the MVP scope. The MVP ships the agent-native public org surface (see `docs/sprint-agent-native-mvp.md`); private data and agent execution are held as the v2 direction. This spec remains **authoritative for the v2 design** — treat it as the target architecture, not the current implementation. -> Extends the HolLab on-chain governance system with off-chain private storage (0G Network), encryption key management, and an AI agent execution layer. +> **0G fully removed (2026-06-10).** This spec originally targeted the 0G stack (Storage + Compute + ERC-7857 INFTs). The 0G relationship ended, so every 0G specific has been neutralized: storage → **IPFS** (encrypted blobs via the indexer pin-proxy, addressed on-chain by `ContentRef`); inference → a provider-neutral **verifiable-compute** layer (§5.5); agent identity → **ERC-8004** (the shipped standard) instead of ERC-7857. The design intent is unchanged — only the vendor is gone. +> +> Extends the HolLab on-chain governance system with off-chain private storage (IPFS), encryption key management, and an AI agent execution layer. --- @@ -12,10 +14,10 @@ HolLab's on-chain contracts (RoleRegistry, CircleRegistry, GovernanceProcess, Ci This spec defines: -- A **private data layer** built on 0G Storage (KV + Log) with client-side AES-256-CTR encryption +- A **private data layer** built on IPFS (content-addressed encrypted blobs, pinned via the indexer pin-proxy) with client-side AES-256-CTR encryption - A **hierarchical key management** scheme tied to on-chain roles -- An **AI agent adapter** that bridges on-chain role authority with off-chain agent execution via 0G Compute -- An **event indexer** that mirrors on-chain governance events to 0G Storage for agent consumption +- An **AI agent adapter** that bridges on-chain role authority with off-chain agent execution via a verifiable-compute provider +- An **event indexer** that mirrors on-chain governance events to IPFS for agent consumption --- @@ -28,21 +30,21 @@ This spec defines: │ ├── AccessManager (per org) │ │ ├── CircleRegistry / RoleRegistry / GovernanceProcess│ │ ├── CircleTreasury (TimelockController per circle) │ -│ └── AgentRegistry (ERC-7857 INFTs) [new] │ +│ └── AgentRegistry (ERC-8004 identity) [new] │ └──────────────┬───────────────────────────────────────┘ │ events + role checks ┌──────────────▼───────────────────────────────────────┐ │ HolLab SDK (TypeScript) [new] │ │ ├── OrgClient — org-level read/write │ │ ├── KeyManager — hierarchical key derivation │ -│ ├── EventIndexer — chain → 0G Storage sync │ +│ ├── EventIndexer — chain → IPFS sync │ │ └── AgentAdapter — agent ↔ governance bridge │ └──────────────┬───────────────────────────────────────┘ │ encrypted read/write │ inference ┌──────────────▼────────────┐ ┌───────────────▼───────┐ -│ 0G Storage │ │ 0G Compute │ -│ ├── KV: private org data │ │ ├── LLM inference │ -│ └── Log: audit trail │ │ └── TEE verification │ +│ IPFS (pinned blobs) │ │ Verifiable Compute │ +│ ├── private org data │ │ ├── LLM inference │ +│ └── public audit log │ │ └── TEE verification │ └───────────────────────────┘ └───────────────────────┘ ``` @@ -52,7 +54,7 @@ This spec defines: ### 3.1 Storage Schema -All private org data is stored in 0G Storage KV, organized by stream ID and key prefix. +All private org data is stored as encrypted blobs on IPFS, pinned via the indexer pin-proxy and addressed by content hash. The logical keys below name each blob; because IPFS is immutable and content-addressed, the latest CID for a key is tracked on-chain (`ContentRef`) or by the indexer rather than mutated in place. | Stream ID | Key Pattern | Value | Encryption | Description | | ------------- | --------------------------------- | -------------------------- | ---------- | -------------------------- | @@ -67,7 +69,7 @@ All private org data is stored in 0G Storage KV, organized by stream ID and key ### 3.2 Audit Trail (Public) -Governance actions are mirrored to 0G Storage Log (append-only, plaintext) for public verifiability: +Governance actions are mirrored to a plaintext IPFS audit log (append-only) for public verifiability: | Entry Type | Content | | ------------------------------ | ------------------------------------------------------- | @@ -145,9 +147,9 @@ Rotation is an off-chain operation. The SDK handles re-encryption and key share Each AI agent in an org is represented by: -1. **On-chain**: An INFT (ERC-7857) token held by the role it fills. The INFT metadata (encrypted) contains the agent's model config, system prompt, and capabilities. +1. **On-chain**: An ERC-8004 agent identity bound to the role it fills. Its (off-chain, encrypted) metadata contains the agent's model config, system prompt, and capabilities. 2. **On-chain**: A role assignment in RoleRegistry — the agent's address is a role lead. -3. **Off-chain**: Agent state in 0G Storage KV — persistent memory, current tasks, heartbeat timestamp. +3. **Off-chain**: Agent state pinned on IPFS — persistent memory, current tasks, heartbeat timestamp. ### 5.2 AgentRegistry Contract @@ -160,15 +162,15 @@ AgentRegistry └── agentHeartbeat(agentId) — updates last-seen timestamp ``` -| Property | Type | Description | -| --------------- | ------- | ------------------------------------------------------- | -| `id` | uint256 | Auto-incrementing agent ID | -| `roleId` | uint256 | The role this agent fills | -| `account` | address | The agent's wallet address (EOA or smart account) | -| `modelUri` | string | 0G Storage root hash pointing to encrypted model config | -| `registeredAt` | uint256 | Registration timestamp | -| `lastHeartbeat` | uint256 | Last heartbeat timestamp | -| `active` | bool | Whether the agent is currently active | +| Property | Type | Description | +| --------------- | ------- | ---------------------------------------------------------- | +| `id` | uint256 | Auto-incrementing agent ID | +| `roleId` | uint256 | The role this agent fills | +| `account` | address | The agent's wallet address (EOA or smart account) | +| `modelUri` | string | IPFS content hash (CID) pointing to encrypted model config | +| `registeredAt` | uint256 | Registration timestamp | +| `lastHeartbeat` | uint256 | Last heartbeat timestamp | +| `active` | bool | Whether the agent is currently active | ### 5.3 Agent Heartbeat Protocol @@ -177,15 +179,15 @@ Inspired by Paperclip's heartbeat model, adapted for on-chain authority: ``` 1. WAKE — Agent runtime starts (cron or event-triggered) 2. IDENTIFY — Read role assignment from RoleRegistry -3. AUTHORIZE — Derive role key, decrypt agent config from 0G Storage -4. CHECK — Read pending governance proposals, treasury operations, tensions from 0G KV +3. AUTHORIZE — Derive role key, decrypt agent config from IPFS +4. CHECK — Read pending governance proposals, treasury operations, tensions from IPFS 5. PRIORITIZE— Select highest-priority work item 6. ACT — Execute action: a. Submit governance proposal (on-chain via GovernanceProcess) b. Schedule treasury operation (on-chain via CircleTreasury) - c. Write analysis/recommendation (off-chain to 0G KV) + c. Write analysis/recommendation (off-chain to IPFS) d. Delegate sub-task to another agent (on-chain role assignment) -7. REPORT — Write status update to 0G KV (encrypted with circle key) +7. REPORT — Write status update to IPFS (encrypted with circle key) 8. HEARTBEAT — Call AgentRegistry.agentHeartbeat(agentId) on-chain 9. SLEEP — Agent runtime stops until next trigger ``` @@ -217,17 +219,17 @@ interface AgentAdapter { } ``` -### 5.5 0G Compute Integration +### 5.5 Verifiable Compute Integration -Agents use 0G Compute for inference: +Agents use a verifiable-compute provider for inference: -| Feature | Usage | -| -------------------- | -------------------------------------------------------------------------------------------------------------------- | -| **Chat completions** | Agent reasoning about proposals, tensions, and role assignments. OpenAI-compatible API via 0G Compute. | -| **TEE verification** | Verifiable inference — proofs that the agent's reasoning matches the claimed model + input. | -| **Fine-tuning** | Org-specific model fine-tuning on governance history. Dataset = historical proposals + outcomes from 0G Storage Log. | +| Feature | Usage | +| -------------------- | ------------------------------------------------------------------------------------------------------------------------ | +| **Chat completions** | Agent reasoning about proposals, tensions, and role assignments. OpenAI-compatible API via the compute provider. | +| **TEE verification** | Verifiable inference — proofs that the agent's reasoning matches the claimed model + input. | +| **Fine-tuning** | Org-specific model fine-tuning on governance history. Dataset = historical proposals + outcomes from the IPFS audit log. | -The SDK wraps 0G Compute's OpenAI-compatible endpoint: +The SDK wraps the provider's OpenAI-compatible endpoint: ```typescript interface AgentInference { @@ -238,7 +240,7 @@ interface AgentInference { // Config setModel(modelId: string): void; - setProvider(endpoint: string): void; // 0G Compute endpoint + setProvider(endpoint: string): void; // inference provider endpoint } ``` @@ -248,7 +250,7 @@ interface AgentInference { ### 6.1 Purpose -The EventIndexer watches on-chain events and mirrors them to 0G Storage for agent consumption. This decouples agents from direct chain RPC access and provides a queryable data layer. +The EventIndexer watches on-chain events and mirrors them to IPFS for agent consumption. This decouples agents from direct chain RPC access and provides a queryable data layer. ### 6.2 Indexed Events @@ -269,10 +271,10 @@ The EventIndexer watches on-chain events and mirrors them to 0G Storage for agen ### 6.3 Storage Format -Events are written to 0G Storage in two places: +Events are written to IPFS in two forms: -1. **KV (queryable, encrypted)**: Latest state snapshot per entity. Agents read this for current context. -2. **Log (append-only, plaintext)**: Full event history. Used for audit and AI fine-tuning datasets. +1. **State snapshots (queryable, encrypted)**: Latest snapshot blob per entity, its CID tracked by the indexer. Agents read this for current context. +2. **Audit log (append-only, plaintext)**: Full event history pinned as plaintext blobs. Used for audit and AI fine-tuning datasets. ### 6.4 SDK Interface @@ -301,18 +303,18 @@ packages/hollab-sdk/ ├── src/ │ ├── client/ │ │ ├── OrgClient.ts — high-level org read/write -│ │ ├── StorageClient.ts — 0G Storage KV + Log wrapper +│ │ ├── StorageClient.ts — IPFS pin/fetch wrapper (encrypted blobs) │ │ └── ChainClient.ts — on-chain contract interactions │ ├── crypto/ │ │ ├── KeyManager.ts — HKDF key derivation + key shares │ │ ├── encrypt.ts — AES-256-CTR encrypt/decrypt │ │ └── keyshare.ts — public-key encrypted key distribution │ ├── indexer/ -│ │ ├── EventIndexer.ts — chain event → 0G Storage sync +│ │ ├── EventIndexer.ts — chain event → IPFS sync │ │ └── handlers.ts — per-event-type handlers │ ├── agent/ │ │ ├── AgentAdapter.ts — agent ↔ governance bridge -│ │ ├── AgentInference.ts — 0G Compute wrapper +│ │ ├── AgentInference.ts — verifiable-compute wrapper │ │ └── heartbeat.ts — heartbeat protocol implementation │ ├── types/ │ │ └── index.ts — shared TypeScript types @@ -324,12 +326,12 @@ packages/hollab-sdk/ ### 7.1 Dependencies -| Dependency | Purpose | -| ------------------------- | ---------------------------------- | -| `@0gfoundation/0g-ts-sdk` | 0G Storage KV + Log operations | -| `ethers` | Chain interactions, wallet signing | -| `@noble/hashes` | HKDF-SHA256 key derivation | -| `@noble/ciphers` | AES-256-CTR encryption | +| Dependency | Purpose | +| -------------------------- | --------------------------------------- | +| `multiformats` + pin-proxy | CID/multihash handling + IPFS pin/fetch | +| `ethers` | Chain interactions, wallet signing | +| `@noble/hashes` | HKDF-SHA256 key derivation | +| `@noble/ciphers` | AES-256-CTR encryption | --- @@ -337,19 +339,19 @@ packages/hollab-sdk/ ### 8.1 Threat Model -| Threat | Mitigation | -| --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | -| 0G node operator reads ciphertext | Client-side AES-256-CTR encryption. Operator sees only ciphertext. | -| Compromised circle lead key | Key rotation: new circle key, re-encrypt all data, revoke old key shares. | -| Agent wallet compromised | Deregister agent on-chain, rotate role key, revoke proposer/canceller roles. | -| Replay of old encrypted data | Each encryption uses a random nonce (0G SDK standard). KV updates overwrite old values. | -| Unauthorized treasury spending | TimelockController enforces delay. Facilitator can cancel. All ops are on-chain and auditable. | -| Agent acts outside authority | Agent's on-chain transactions are gated by AccessManager / circle lead checks. Agent can only act within its role's permissions. | +| Threat | Mitigation | +| -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| IPFS node / pinning service reads ciphertext | Client-side AES-256-CTR encryption. The pinning service sees only ciphertext. | +| Compromised circle lead key | Key rotation: new circle key, re-encrypt all data, revoke old key shares. | +| Agent wallet compromised | Deregister agent on-chain, rotate role key, revoke proposer/canceller roles. | +| Replay of old encrypted data | Each encryption uses a random IV/nonce. New versions pin as new CIDs and the on-chain `ContentRef` advances, so stale CIDs fall out of reference. | +| Unauthorized treasury spending | TimelockController enforces delay. Facilitator can cancel. All ops are on-chain and auditable. | +| Agent acts outside authority | Agent's on-chain transactions are gated by AccessManager / circle lead checks. Agent can only act within its role's permissions. | ### 8.2 Trust Assumptions -1. **0G Storage** — trusted for availability, NOT for confidentiality. All sensitive data is encrypted client-side. -2. **0G Compute TEE** — trusted for inference integrity. The TEE attestation proves the model and input match the output. +1. **IPFS / pinning service** — trusted for availability, NOT for confidentiality. All sensitive data is encrypted client-side. +2. **Verifiable-compute TEE** — trusted for inference integrity. The TEE attestation proves the model and input match the output. 3. **On-chain contracts** — trusted as the source of truth for role assignments, circle membership, and treasury state. 4. **Wallet security** — the master key derives from a wallet signature. Wallet compromise = org compromise. @@ -360,10 +362,10 @@ packages/hollab-sdk/ | Phase | Deliverable | Depends On | | ----------- | ----------------------------------------------------------------------------------- | ---------- | | **Phase 1** | `KeyManager` — HKDF derivation, AES-256-CTR encrypt/decrypt, key share distribution | — | -| **Phase 2** | `StorageClient` — 0G KV + Log wrapper with encryption integration | Phase 1 | +| **Phase 2** | `StorageClient` — IPFS pin/fetch wrapper with encryption integration | Phase 1 | | **Phase 3** | `OrgClient` — high-level org data read/write (roles, proposals, tensions) | Phase 2 | -| **Phase 4** | `EventIndexer` — chain events → 0G Storage sync | Phase 3 | +| **Phase 4** | `EventIndexer` — chain events → IPFS sync | Phase 3 | | **Phase 5** | `AgentRegistry` contract — on-chain agent identity + heartbeat | — | | **Phase 6** | `AgentAdapter` — agent ↔ governance bridge | Phase 3, 5 | -| **Phase 7** | `AgentInference` — 0G Compute integration for reasoning | Phase 6 | -| **Phase 8** | INFT (ERC-7857) integration — tokenized agent identities | Phase 5 | +| **Phase 7** | `AgentInference` — verifiable-compute integration for reasoning | Phase 6 | +| **Phase 8** | ERC-8004 agent-identity integration | Phase 5 |