diff --git a/packages/utils/compression.ts b/packages/utils/compression.ts
index 25d957a..24c3d04 100644
--- a/packages/utils/compression.ts
+++ b/packages/utils/compression.ts
@@ -18,6 +18,10 @@ export const processRawContent = (raw: string) => {
if (raw.startsWith("br:")) {
version = "br";
decompressed = decompressBrotli(raw.slice(3));
+ } else if (raw.startsWith("enc:v3:br:")) {
+ version = "enc:v3:br";
+ } else if (raw.startsWith("enc:v2:br:")) {
+ version = "enc:v2:br";
} else if (raw.startsWith("enc:br:")) {
version = "enc:br";
}
diff --git a/packages/web/public/DOCS.md b/packages/web/public/DOCS.md
index b252261..9fbd79e 100644
--- a/packages/web/public/DOCS.md
+++ b/packages/web/public/DOCS.md
@@ -427,3 +427,77 @@ Get user data for an address.
| `address` | `address` | User wallet address |
**Response:** `{ user: User }`
+
+---
+
+## Content Encoding
+
+Entry content stored onchain goes through a multi-step encoding pipeline before being passed to the API. The `content` / `chunkContent` fields in create and update requests contain the final encoded string — not raw markdown.
+
+### Pipeline
+
+```
+Markdown → Compress (Brotli) → Encrypt (optional) → Prefix → Store
+```
+
+1. **Write** — Author composes content in markdown
+2. **Compress** — Content is Brotli-compressed (quality 11) and Base64-encoded
+3. **Encrypt** (private entries only) — Compressed content is encrypted with AES-GCM and Base64-encoded
+4. **Prefix** — A version prefix is prepended to indicate the encoding format
+5. **Store** — The prefixed string is signed (EIP-712) and stored onchain as the entry content
+
+### Format Prefixes
+
+The version prefix at the start of the stored content string indicates how to decode it:
+
+| Prefix | Encryption | Compression | Description |
+|--------|------------|-------------|-------------|
+| `br:` | None | Brotli | Public entry, compressed only |
+| `enc:v3:br:` | AES-GCM (v3 key) | Brotli | Private entry, current format |
+| `enc:v2:br:` | AES-GCM (v2 key) | Brotli | Deprecated |
+| `enc:br:` | AES-GCM (v1 key) | Brotli | Deprecated |
+
+**Examples:**
+- Public: `br:GxoAAI2pVgqN...` (Brotli-compressed, Base64-encoded markdown)
+- Private: `enc:v3:br:A7f3kQ9x...` (encrypted + compressed)
+
+### Compression
+
+All content is compressed with [Brotli](https://github.com/nicolo-ribaudo/brotli-wasm) at quality level 11 (maximum), then Base64-encoded. This reduces onchain storage costs.
+
+```
+markdown → UTF-8 encode → brotli compress → base64 encode
+```
+
+### Encryption
+
+Private entries are encrypted **after** compression using [AES-GCM](https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/encrypt#aes-gcm) (128-bit key, 12-byte random IV).
+
+```
+compressed content → AES-GCM encrypt → prepend IV → Base64 encode
+```
+
+The encryption key is deterministically derived from a wallet signature:
+
+1. User signs a fixed message with `personal_sign`
+2. Signature is hashed with Keccak-256
+3. First 16 bytes of the hash become the AES key
+
+The key never leaves the client. Only the entry author can decrypt their private entries — the server and contract store opaque ciphertext.
+
+**V1, V2, and V3 keys** differ only in the message signed during key derivation. V3 is the current default and includes a security warning to only sign on writer.place. V1 and V2 are supported for backward compatibility with older entries. A migration tool is available in the app to re-encrypt legacy entries with the V3 key.
+
+**Important:** Because the encryption key is derived from signing a specific message, anyone who tricks you into signing that same message on a different site could derive the same key and decrypt your private entries. The V3 message explicitly states to only sign on `https://writer.place`. Always verify the requesting site before signing.
+
+### Decoding
+
+To read an entry, reverse the pipeline based on the prefix:
+
+| Prefix | Steps |
+|--------|-------|
+| `br:` | Strip prefix → Base64 decode → Brotli decompress |
+| `enc:v3:br:` | Strip prefix → Base64 decode → AES-GCM decrypt (v3 key) → Brotli decompress |
+| `enc:v2:br:` | Strip prefix → Base64 decode → AES-GCM decrypt (v2 key) → Brotli decompress |
+| `enc:br:` | Strip prefix → Base64 decode → AES-GCM decrypt (v1 key) → Brotli decompress |
+
+Public entries are decoded server-side and returned as plaintext in the `decompressed` field. Private entries are returned as the raw encoded string and decrypted client-side using the author's wallet.
diff --git a/packages/web/src/app/docs/page.tsx b/packages/web/src/app/docs/page.tsx
index a264243..9322430 100644
--- a/packages/web/src/app/docs/page.tsx
+++ b/packages/web/src/app/docs/page.tsx
@@ -1,6 +1,9 @@
"use client";
+import { Check } from "@/components/icons/Check";
+import { Copy } from "@/components/icons/Copy";
import Image from "next/image";
+import Link from "next/link";
import { useEffect, useRef, useState } from "react";
import { MAX_CONTENT_LENGTH, MAX_TITLE_LENGTH } from "utils/constants";
@@ -33,38 +36,9 @@ function CopyButton({ value }: { value: string }) {
className="text-neutral-500 hover:text-primary transition-colors align-middle"
>
{copied ? (
-
+
{description}
} +{path}
-
- §
-
+ {path}
+
+ §
+
+ {description}
+ {auth &&Auth: {auth}
}{description}
- {auth &&Auth: {auth}
} {params && params.length > 0 && ( -Parameters
-Request Body
-+
Response
{response}
@@ -267,21 +245,23 @@ function ContractFunction({
return (
{description}
- {access &&Access: {access}
} +{description}
+ {access &&Access: {access}
} +Parameters
-Returns
@@ -308,11 +288,11 @@ function ContractFunction({ {events && events.length > 0 && (Events
-
{e}
@@ -339,6 +319,11 @@ const tocItems = [
{ id: "entries", label: "Entries", depth: 1 },
{ id: "color", label: "Color", depth: 1 },
{ id: "user", label: "User", depth: 1 },
+ { id: "content-encoding", label: "Content Encoding", depth: 0 },
+ { id: "format-prefixes", label: "Format Prefixes", depth: 1 },
+ { id: "compression", label: "Compression", depth: 1 },
+ { id: "encryption", label: "Encryption", depth: 1 },
+ { id: "decoding", label: "Decoding", depth: 1 },
];
function TableOfContents() {
@@ -417,7 +402,7 @@ export default function DocsPage() {
return (
-
+
Writer is an onchain writing platform. Content is permanently stored
on Optimism through smart contracts.
@@ -426,47 +411,45 @@ export default function DocsPage() {
Smart Contracts
-
+
WriterFactory:
-
0x28c7721ECff2246a9277CAd46ab2124f69Efd88E
-
+
ColorRegistry:
-
0x7Bf5B616f5431725bCE61E397173cd6FbFaAC6F1
-
+
-
-
- Factory contract that deploys Writer + WriterStorage pairs using
- CREATE2 for deterministic addresses.
-
-
+
-
-
- Main logic contract for managing entries with role-based access
- control.
-
-
+
Reading
@@ -600,7 +581,7 @@ export default function DocsPage() {
Writing
@@ -795,7 +776,7 @@ export default function DocsPage() {
Administration
@@ -844,12 +825,10 @@ export default function DocsPage() {
/>
-
-
- Storage contract that holds all entry data. Only the Writer logic
- contract can modify state, enforced by the onlyLogic modifier.
-
-
+
-
-
- Simple registry mapping user addresses to their chosen hex color.
-
-
+
{/* API */}
-
+
API
@@ -954,7 +932,7 @@ export default function DocsPage() {
Base URL:
-
+
https://api.writer.place
@@ -1192,6 +1170,170 @@ export default function DocsPage() {
/>
+
+ {/* CONTENT ENCODING */}
+
+
+ Content Encoding
+
+
+ Entry content goes through a multi-step encoding pipeline before
+ being stored onchain. The content{" "}
+ & chunkContent fields in API
+ requests contain the final encoded string, not raw markdown.
+
+
+
+
+ markdown → compress → encrypt (optional) → prefix
+ → store
+
+
+
+
+
+
+ br:
+
+ Public entry. Brotli compressed, Base64 encoded. No
+ encryption.
+
+
+ br:GxoAAI2pVgqN...
+
+
+
+
+
+ enc:v3:br:
+
+
+ Private entry, current format. AES-GCM encrypted with v3 key,
+ Brotli compressed.
+
+
+ enc:v3:br:A7f3kQ9x...
+
+
+
+
+ enc:v2:br:
+ Deprecated
+
+
+
+ enc:br:
+ Deprecated
+
+
+
+
+
+
+
+ markdown → UTF-8 encode → brotli compress (quality 11)
+ → base64 encode
+
+
+
+
+
+
+
+ compressed content → AES-GCM encrypt → prepend IV
+ → base64 encode
+
+
+
+
+ The encryption key is deterministically derived from a wallet
+ signature:
+
+
+
+ -
+ User signs a fixed message with{" "}
+
personal_sign
+
+ - Signature is hashed with Keccak-256
+ - First 16 bytes of the hash become the AES key
+
+
+
+ The key never leaves the client. Only the entry author can decrypt
+ their private entries — the server and contract store opaque
+ ciphertext.
+
+
+
+ V1, V2, and V3 keys differ only in the message signed during key
+ derivation. V3 is the current default and includes a security
+ warning to only sign on writer.place. V1 and V2 are supported for
+ backward compatibility with older entries. A migration tool is
+ available in the app to re-encrypt legacy entries with the V3 key.
+
+
+
+
+
+
+ br:
+
+ strip prefix → base64 decode → brotli decompress
+
+
+
+
+
+ enc:v3:br:
+
+
+ strip prefix → base64 decode → AES-GCM decrypt (v3
+ key) → brotli decompress
+
+
+
+
+ enc:v2:br:
+ Deprecated
+
+
+
+ enc:br:
+ Deprecated
+
+
+
+
+ Public entries are decoded server-side and returned as plaintext
+ in the{" "}
+ decompressed{" "}
+ field. Private entries are returned as the raw encoded string and
+ decrypted client-side using the author's wallet.
+
+
+
diff --git a/packages/web/src/app/fund/page.tsx b/packages/web/src/app/fund/page.tsx
index 4075fde..96b87c6 100644
--- a/packages/web/src/app/fund/page.tsx
+++ b/packages/web/src/app/fund/page.tsx
@@ -1,5 +1,7 @@
"use client";
+import { Check } from "@/components/icons/Check";
+import { Copy } from "@/components/icons/Copy";
import { type RelayWallet, getRelayWallets } from "@/utils/api";
import { useQuery } from "@tanstack/react-query";
import Image from "next/image";
@@ -40,39 +42,7 @@ function WalletRow({ wallet }: { wallet: RelayWallet }) {
aria-label="Copy address"
className="text-neutral-500 hover:text-primary cursor-pointer"
>
- {copied ? (
-
- ) : (
-
- )}
+ {copied ? : }
{displayBalance} ETH
diff --git a/packages/web/src/app/globals.css b/packages/web/src/app/globals.css
index 691a741..734619f 100644
--- a/packages/web/src/app/globals.css
+++ b/packages/web/src/app/globals.css
@@ -49,6 +49,10 @@
color: var(--color-white);
background-color: var(--color-background);
}
+
+ .font-mono {
+ font-size: 15px;
+ }
}
::-webkit-scrollbar {
diff --git a/packages/web/src/app/layout.tsx b/packages/web/src/app/layout.tsx
index 3c7ae59..5db8321 100644
--- a/packages/web/src/app/layout.tsx
+++ b/packages/web/src/app/layout.tsx
@@ -69,20 +69,20 @@ export default async function RootLayout({
className="min-h-dvh"
suppressHydrationWarning
>
-
+
-
+
{children}
diff --git a/packages/web/src/app/writer/[address]/page.tsx b/packages/web/src/app/writer/[address]/page.tsx
index adfb381..a025dba 100644
--- a/packages/web/src/app/writer/[address]/page.tsx
+++ b/packages/web/src/app/writer/[address]/page.tsx
@@ -69,7 +69,7 @@ export default function WriterPage() {
setUnlockError("Signature request was rejected.");
}, []);
- const { processedEntries, hasLockedPrivateEntries } = useProcessedEntries(
+ const { processedEntries, hasLockedPrivateEntries, processedOnce } = useProcessedEntries(
writer?.entries,
address,
{
@@ -80,20 +80,26 @@ export default function WriterPage() {
const hasPrivateEntries =
writer?.entries?.some((entry) => isEntryPrivate(entry)) ?? false;
+ const allEntriesPrivate =
+ (writer?.entries?.length ?? 0) > 0 &&
+ writer?.entries?.every((entry) => isEntryPrivate(entry));
const showUnlockBanner = hasPrivateEntries && hasLockedPrivateEntries;
const showLockedEntries = !allowDecryption || Boolean(unlockError);
useEffect(() => {
if (!wallet || !hasPrivateEntries) return;
// Auto-unlock only if the key is already cached (no signature prompt).
- if (hasCachedDerivedKey(wallet, "v2")) {
+ if (
+ hasCachedDerivedKey(wallet, "v3") ||
+ hasCachedDerivedKey(wallet, "v2")
+ ) {
setAllowDecryption(true);
}
}, [wallet, hasPrivateEntries]);
// Show loading state during the gap where entries exist but processedEntries hasn't been populated yet
const isEntriesProcessing =
- writer?.entries?.length && processedEntries.length === 0;
+ writer?.entries?.length && processedEntries.length === 0 && !processedOnce;
const walletAddress = wallet?.address?.toLowerCase();
const { data: savedData } = useQuery({
queryKey: ["saved", walletAddress],
@@ -169,6 +175,7 @@ export default function WriterPage() {
isUnlocking={allowDecryption && !unlockError}
unlockError={unlockError}
showLockedEntries={showLockedEntries}
+ emptyMessage={allEntriesPrivate && !canCreateEntries ? "no public entries" : "no entries yet"}
onUnlock={() => {
setUnlockError(null);
setAllowDecryption(true);
diff --git a/packages/web/src/components/CreateInput.tsx b/packages/web/src/components/CreateInput.tsx
index 301b951..85e382e 100644
--- a/packages/web/src/components/CreateInput.tsx
+++ b/packages/web/src/components/CreateInput.tsx
@@ -114,14 +114,19 @@ export default function CreateInput({
if (markdown.trim() === "") return;
setLoadingContent(markdown);
setIsSubmitting(true);
- await onSubmit({ markdown, encrypted });
- editorRef.current?.setMarkdown("");
- setMarkdown("");
- setIsSubmitting(false);
- setHasFocus(false);
- setIsExpanded(false);
- setEncrypted(false);
- onExpand?.(false);
+ try {
+ await onSubmit({ markdown, encrypted });
+ editorRef.current?.setMarkdown("");
+ setMarkdown("");
+ setHasFocus(false);
+ setIsExpanded(false);
+ setEncrypted(false);
+ onExpand?.(false);
+ } catch (error) {
+ console.error("Submit failed:", error);
+ } finally {
+ setIsSubmitting(false);
+ }
};
const isLoadingOrSubmitting = isLoading || isSubmitting;
diff --git a/packages/web/src/components/Entry.tsx b/packages/web/src/components/Entry.tsx
index 86f225d..f9e465e 100644
--- a/packages/web/src/components/Entry.tsx
+++ b/packages/web/src/components/Entry.tsx
@@ -178,8 +178,12 @@ export default function Entry({
if (isEntryPrivate(initialEntry)) {
setEncrypted(true);
if (wallet && isWalletAuthor(wallet, initialEntry)) {
+ const needsV3 = initialEntry.raw?.startsWith("enc:v3:br:");
const needsV2 = initialEntry.raw?.startsWith("enc:v2:br:");
const needsV1 = initialEntry.raw?.startsWith("enc:br:");
+ const keyV3 = needsV3
+ ? await getCachedDerivedKey(wallet, "v3")
+ : undefined;
const keyV2 = needsV2
? await getCachedDerivedKey(wallet, "v2")
: undefined;
@@ -190,6 +194,7 @@ export default function Entry({
keyV2,
initialEntry,
keyV1,
+ keyV3,
);
setProcessedEntry(processed);
setEditedContent(processed.decompressed ?? "");
@@ -286,9 +291,9 @@ export default function Entry({
const compressedContent = await compress(editedContent);
let versionedCompressedContent = `br:${compressedContent}`;
if (encrypted) {
- const key = await getCachedDerivedKey(wallet, "v2");
+ const key = await getCachedDerivedKey(wallet, "v3");
const encryptedContent = await encrypt(key, compressedContent);
- versionedCompressedContent = `enc:v2:br:${encryptedContent}`;
+ versionedCompressedContent = `enc:v3:br:${encryptedContent}`;
}
// Store expected raw content for polling comparison
expectedRawContentRef.current = versionedCompressedContent;
diff --git a/packages/web/src/components/EntryList.tsx b/packages/web/src/components/EntryList.tsx
index 66797e2..684e0c4 100644
--- a/packages/web/src/components/EntryList.tsx
+++ b/packages/web/src/components/EntryList.tsx
@@ -93,11 +93,11 @@ export default function EntryList({
{isUnlocking ? "Unlocking..." : "Unlock to view"}
- {unlockError && (
+ {/* {unlockError && (
Signature rejected
- )}
+ )} */}
{createdAt}
diff --git a/packages/web/src/components/EntryListWithCreateInput.tsx b/packages/web/src/components/EntryListWithCreateInput.tsx
index 179a321..c750ed7 100644
--- a/packages/web/src/components/EntryListWithCreateInput.tsx
+++ b/packages/web/src/components/EntryListWithCreateInput.tsx
@@ -24,6 +24,7 @@ export default function EntryListWithCreateInput({
unlockError,
onUnlock,
showLockedEntries = false,
+ emptyMessage = "no entries yet",
}: {
writerTitle: string;
writerAddress: string;
@@ -34,6 +35,7 @@ export default function EntryListWithCreateInput({
unlockError?: string | null;
onUnlock?: () => void;
showLockedEntries?: boolean;
+ emptyMessage?: string;
}) {
const [isExpanded, setIsExpanded] = useState(false);
const [wallet] = useOPWallet();
@@ -64,9 +66,9 @@ export default function EntryListWithCreateInput({
const compressedContent = await compress(markdown);
let versionedCompressedContent = `br:${compressedContent}`;
if (encrypted) {
- const key = await getCachedDerivedKey(wallet, "v2");
+ const key = await getCachedDerivedKey(wallet, "v3");
const encryptedContent = await encrypt(key, compressedContent);
- versionedCompressedContent = `enc:v2:br:${encryptedContent}`;
+ versionedCompressedContent = `enc:v3:br:${encryptedContent}`;
}
const { signature, nonce, chunkCount, chunkContent } =
@@ -116,7 +118,7 @@ export default function EntryListWithCreateInput({
)}
{!isExpanded && processedEntries.length === 0 && (
- no entries yet
+ {emptyMessage}
)}
diff --git a/packages/web/src/components/MigrateModal.tsx b/packages/web/src/components/MigrateModal.tsx
new file mode 100644
index 0000000..7a89738
--- /dev/null
+++ b/packages/web/src/components/MigrateModal.tsx
@@ -0,0 +1,220 @@
+"use client";
+
+import { type Entry, editEntry } from "@/utils/api";
+import { useOPWallet } from "@/utils/hooks";
+import { getCachedDerivedKey } from "@/utils/keyCache";
+import { signUpdate } from "@/utils/signer";
+import { compress, decompress, decrypt, encrypt } from "@/utils/utils";
+import { useQueryClient } from "@tanstack/react-query";
+import { VisuallyHidden } from "radix-ui";
+import { useCallback, useState } from "react";
+import type { Hex } from "viem";
+import { LoadingRelic } from "./LoadingRelic";
+import { Modal, ModalDescription, ModalTitle } from "./dsl/Modal";
+
+export interface MigrateEntry {
+ entry: Entry;
+ writerAddress: string;
+ writerTitle: string;
+}
+
+type MigrationStatus = "idle" | "migrating" | "done" | "error";
+
+export function MigrateModal({
+ open,
+ onClose,
+ entriesToMigrate,
+}: {
+ open: boolean;
+ onClose: () => void;
+ entriesToMigrate: MigrateEntry[];
+}) {
+ const [wallet] = useOPWallet();
+ const queryClient = useQueryClient();
+ const [status, setStatus] = useState("idle");
+ const [migratedCount, setMigratedCount] = useState(0);
+ const [errorMessage, setErrorMessage] = useState(null);
+ const [migratedIds, setMigratedIds] = useState>(new Set());
+
+ const handleClose = useCallback(() => {
+ setStatus("idle");
+ setMigratedCount(0);
+ setErrorMessage(null);
+ setMigratedIds(new Set());
+ onClose();
+ }, [onClose]);
+
+ const migrate = useCallback(async () => {
+ if (!wallet || entriesToMigrate.length === 0) return;
+ setStatus("migrating");
+ setMigratedCount(0);
+ setErrorMessage(null);
+
+ try {
+ const keyV3 = await getCachedDerivedKey(wallet, "v3");
+
+ for (const { entry, writerAddress } of entriesToMigrate) {
+ // Decrypt with old key
+ let decrypted: string;
+ if (entry.raw?.startsWith("enc:v2:br:")) {
+ const keyV2 = await getCachedDerivedKey(wallet, "v2");
+ decrypted = await decrypt(keyV2, entry.raw.slice(10));
+ } else if (entry.raw?.startsWith("enc:br:")) {
+ const keyV1 = await getCachedDerivedKey(wallet, "v1");
+ decrypted = await decrypt(keyV1, entry.raw.slice(7));
+ } else {
+ continue;
+ }
+
+ // Decompress to get original markdown, then re-compress and re-encrypt with v3
+ const markdown = await decompress(decrypted);
+ const compressed = await compress(markdown);
+ const encrypted = await encrypt(keyV3, compressed);
+ const newContent = `enc:v3:br:${encrypted}`;
+
+ const { signature, nonce, totalChunks, content } = await signUpdate(
+ wallet,
+ {
+ entryId: Number(entry.onChainId),
+ address: writerAddress as Hex,
+ content: newContent,
+ },
+ );
+
+ await editEntry({
+ address: writerAddress,
+ id: Number(entry.onChainId),
+ signature,
+ nonce,
+ totalChunks,
+ content,
+ });
+
+ setMigratedCount((c) => c + 1);
+ setMigratedIds((prev) => new Set(prev).add(entry.id));
+ }
+
+ queryClient.invalidateQueries({ queryKey: ["writer"] });
+ queryClient.invalidateQueries({ queryKey: ["get-writers"] });
+ setStatus("done");
+ } catch (error) {
+ console.error("Migration failed", error);
+ setErrorMessage(
+ "Migration was interrupted. Already migrated entries are safe.",
+ );
+ setStatus("error");
+ }
+ }, [wallet, entriesToMigrate, queryClient]);
+
+ // Group entries by writer for display
+ const entriesByWriter = entriesToMigrate.reduce<
+ Record
+ >((acc, item) => {
+ if (!acc[item.writerAddress]) {
+ acc[item.writerAddress] = { title: item.writerTitle, entries: [] };
+ }
+ acc[item.writerAddress].entries.push(item);
+ return acc;
+ }, {});
+
+ return (
+
+
+ Migrate Private Entries
+
+ Re-encrypt private entries with the new signing key
+
+
+
+
+ Migrate Private Entries
+
+ You have {entriesToMigrate.length} private entries that currently use
+ old signing keys. You will always have read access to these entries,
+ but we recommend migrating them to the new signing key for improved
+ security.
+
+
+ {/* Entry list */}
+
+ {Object.entries(entriesByWriter).map(
+ ([writerAddress, { title, entries }]) => (
+
+
+ {title}
+
+
+ {entries.map(({ entry }) => {
+ const isMigrated = migratedIds.has(entry.id);
+ const version = entry.raw?.startsWith("enc:v2:br:")
+ ? "v2"
+ : "v1";
+ return (
+
+
+ entry #{entry.onChainId}
+
+
+ {isMigrated ? "v3" : version}
+
+
+ );
+ })}
+
+
+ ),
+ )}
+
+
+ {status === "idle" && (
+
+ )}
+
+ {status === "migrating" && (
+
+
+
+ Migrating {migratedCount}/{entriesToMigrate.length}...
+
+
+ )}
+
+ {status === "done" && (
+
+ Migrated {migratedCount} {migratedCount === 1 ? "entry" : "entries"}{" "}
+ successfully.
+
+ )}
+
+ {status === "error" && errorMessage && (
+
+
+ {errorMessage}
+
+
+
+ )}
+
+
+ );
+}
diff --git a/packages/web/src/components/NavDropdown.tsx b/packages/web/src/components/NavDropdown.tsx
index 4c15905..f1bb905 100644
--- a/packages/web/src/components/NavDropdown.tsx
+++ b/packages/web/src/components/NavDropdown.tsx
@@ -1,5 +1,7 @@
"use client";
+import type { Writer } from "@/utils/api";
+import { useOPWallet } from "@/utils/hooks";
import { clearAllCachedKeys } from "@/utils/keyCache";
import {
type ThemeMode,
@@ -8,12 +10,15 @@ import {
setStoredThemeMode,
subscribeSystemThemeChange,
} from "@/utils/theme";
+import { isEntryPrivate, isWalletAuthor } from "@/utils/utils";
import { usePrivy } from "@privy-io/react-auth";
+import { useQueryClient } from "@tanstack/react-query";
import Image from "next/image";
import { usePathname, useRouter } from "next/navigation";
-import { useEffect, useState } from "react";
+import { useEffect, useMemo, useState } from "react";
import { ColorModal } from "./ColorModal";
-import { queryClient } from "./Providers";
+import { type MigrateEntry, MigrateModal } from "./MigrateModal";
+import { queryClient as globalQueryClient } from "./Providers";
import { Dropdown, DropdownItem } from "./dsl/Dropdown";
function ThemeButton({
@@ -48,10 +53,17 @@ function ThemeButton({
);
}
+function isLegacyEncrypted(raw: string | undefined | null): boolean {
+ if (!raw) return false;
+ return raw.startsWith("enc:v2:br:") || raw.startsWith("enc:br:");
+}
+
export function NavDropdown() {
- const { logout, authenticated, login } = usePrivy();
+ const { logout, authenticated, login, user } = usePrivy();
+ const [wallet] = useOPWallet();
const router = useRouter();
const pathname = usePathname();
+ const queryClient = useQueryClient();
const navItems = [
{ label: "Home", href: "/home" },
@@ -59,9 +71,41 @@ export function NavDropdown() {
...(authenticated ? [{ label: "Saved", href: "/saved" }] : []),
].filter((item) => item.href !== pathname);
const [open, setOpen] = useState(false);
+ const [migrateOpen, setMigrateOpen] = useState(false);
const [dropdownOpen, setDropdownOpen] = useState(false);
const [themeMode, setThemeMode] = useState("system");
+ // Derive legacy entries from the cached get-writers query
+ const address = user?.wallet?.address;
+ const writersData = queryClient.getQueryData([
+ "get-writers",
+ address,
+ ]);
+
+ const entriesToMigrate = useMemo(() => {
+ if (!writersData || !wallet) return [];
+ const result: MigrateEntry[] = [];
+ for (const writer of writersData) {
+ for (const entry of writer.entries) {
+ if (
+ isEntryPrivate(entry) &&
+ isWalletAuthor(wallet, entry) &&
+ isLegacyEncrypted(entry.raw) &&
+ entry.onChainId
+ ) {
+ result.push({
+ entry,
+ writerAddress: writer.address,
+ writerTitle: writer.title,
+ });
+ }
+ }
+ }
+ return result;
+ }, [writersData, wallet]);
+
+ const hasLegacyEntries = entriesToMigrate.length > 0;
+
useEffect(() => {
if (typeof window === "undefined") return;
const initialMode = getStoredThemeMode();
@@ -121,12 +165,22 @@ export function NavDropdown() {
)}
+ {authenticated && hasLegacyEntries && (
+ setMigrateOpen(true)}>
+
+ Migrate
+
+ {entriesToMigrate.length}
+
+
+
+ )}
{authenticated ? (
logout().then(() => {
clearAllCachedKeys();
- queryClient.clear();
+ globalQueryClient.clear();
})
}
>
@@ -157,6 +211,11 @@ export function NavDropdown() {
setOpen(false)} />
+ setMigrateOpen(false)}
+ entriesToMigrate={entriesToMigrate}
+ />
>
);
}
diff --git a/packages/web/src/components/SavedView.tsx b/packages/web/src/components/SavedView.tsx
index 3ff3b0d..212262f 100644
--- a/packages/web/src/components/SavedView.tsx
+++ b/packages/web/src/components/SavedView.tsx
@@ -162,10 +162,16 @@ function MixedSavedGrid({
);
if (needs.length === 0) return;
+ const needsV3 = needs.some((entry) =>
+ entry.raw?.startsWith("enc:v3:br:"),
+ );
const needsV2 = needs.some((entry) =>
entry.raw?.startsWith("enc:v2:br:"),
);
const needsV1 = needs.some((entry) => entry.raw?.startsWith("enc:br:"));
+ const keyV3 = needsV3
+ ? await getCachedDerivedKey(activeWallet, "v3")
+ : undefined;
const keyV2 = needsV2
? await getCachedDerivedKey(activeWallet, "v2")
: undefined;
@@ -177,7 +183,7 @@ function MixedSavedGrid({
async (entry) =>
[
entry.id,
- await processPrivateEntry(keyV2, entry, keyV1),
+ await processPrivateEntry(keyV2, entry, keyV1, keyV3),
] as const,
),
);
@@ -299,11 +305,11 @@ function MixedSavedGrid({
{canUnlock ? "Unlock to view" : "Private"}
- {unlockError && canUnlock && (
+ {/* {unlockError && canUnlock && (
Signature rejected
- )}
+ )} */}
) : (
diff --git a/packages/web/src/components/dsl/Modal.tsx b/packages/web/src/components/dsl/Modal.tsx
index 458b6a1..0e3f140 100644
--- a/packages/web/src/components/dsl/Modal.tsx
+++ b/packages/web/src/components/dsl/Modal.tsx
@@ -18,7 +18,7 @@ export function Modal({ open, onClose, children, className }: ModalProps) {
// }}
className={cn(
"DialogContent",
- "fixed top-1/2 left-1/2 z-[101] transform -translate-x-1/2 -translate-y-1/2 w-80vw max-w-450px max-h-85vh p-[25px] bg-neutral-200 dark:bg-neutral-800 rounded-lg",
+ "fixed top-1/2 left-1/2 z-[101] transform -translate-x-1/2 -translate-y-1/2 w-80vw max-w-[450px] max-h-85vh p-[25px] bg-white dark:bg-neutral-800 rounded-lg",
className,
)}
>
diff --git a/packages/web/src/components/icons/Check.tsx b/packages/web/src/components/icons/Check.tsx
new file mode 100644
index 0000000..8c165f5
--- /dev/null
+++ b/packages/web/src/components/icons/Check.tsx
@@ -0,0 +1,22 @@
+import type { SvgProps } from ".";
+
+export function Check({ title, ...props }: SvgProps) {
+ return (
+
+ );
+}
diff --git a/packages/web/src/components/icons/Copy.tsx b/packages/web/src/components/icons/Copy.tsx
new file mode 100644
index 0000000..4fc21df
--- /dev/null
+++ b/packages/web/src/components/icons/Copy.tsx
@@ -0,0 +1,23 @@
+import type { SvgProps } from ".";
+
+export function Copy({ title, ...props }: SvgProps) {
+ return (
+
+ );
+}
diff --git a/packages/web/src/utils/hooks.ts b/packages/web/src/utils/hooks.ts
index c4bfc08..7d6bc0a 100644
--- a/packages/web/src/utils/hooks.ts
+++ b/packages/web/src/utils/hooks.ts
@@ -79,6 +79,7 @@ export function useProcessedEntries(
) {
const [wallet, walletReady] = useOPWallet();
const [processedEntries, setProcessedEntries] = useState([]);
+ const [processedOnce, setProcessedOnce] = useState(false);
const allowDecryption = options?.allowDecryption ?? false;
const onDecryptError = options?.onDecryptError;
@@ -96,6 +97,7 @@ export function useProcessedEntries(
return true;
});
setProcessedEntries(visibleEntries);
+ setProcessedOnce(true);
// Process private entries in background (non-blocking)
async function processPrivateEntriesInBackground() {
@@ -117,12 +119,18 @@ export function useProcessedEntries(
try {
// Get derived keys (cached to avoid multiple signature requests)
+ const needsV3 = privateEntriesToProcess.some((entry) =>
+ entry.raw?.startsWith("enc:v3:br:"),
+ );
const needsV2 = privateEntriesToProcess.some((entry) =>
entry.raw?.startsWith("enc:v2:br:"),
);
const needsV1 = privateEntriesToProcess.some((entry) =>
entry.raw?.startsWith("enc:br:"),
);
+ const derivedKeyV3 = needsV3
+ ? await getCachedDerivedKey(wallet!, "v3")
+ : undefined;
const derivedKeyV2 = needsV2
? await getCachedDerivedKey(wallet!, "v2")
: undefined;
@@ -151,6 +159,7 @@ export function useProcessedEntries(
derivedKeyV2,
entry,
derivedKeyV1,
+ derivedKeyV3,
);
await setCachedEntry(writerAddress, entryId, processed, {
walletAddress,
@@ -185,5 +194,5 @@ export function useProcessedEntries(
(entry) => isEntryPrivate(entry) && !entry.decompressed,
);
- return { processedEntries, hasLockedPrivateEntries };
+ return { processedEntries, hasLockedPrivateEntries, processedOnce };
}
diff --git a/packages/web/src/utils/keyCache.ts b/packages/web/src/utils/keyCache.ts
index 3b4fd9a..745c3db 100644
--- a/packages/web/src/utils/keyCache.ts
+++ b/packages/web/src/utils/keyCache.ts
@@ -1,17 +1,23 @@
import type { ConnectedWallet } from "@privy-io/react-auth";
-import { getDerivedSigningKeyV1, getDerivedSigningKeyV2 } from "./signer";
+import {
+ getDerivedSigningKeyV1,
+ getDerivedSigningKeyV2,
+ getDerivedSigningKeyV3,
+} from "./signer";
+
+export type KeyVersion = "v1" | "v2" | "v3";
// In-memory cache for derived keys (session-scoped for security)
// Key format: "walletAddress:version"
const keyCache = new Map();
-function keyCacheKey(walletAddress: string, version: "v1" | "v2"): string {
+function keyCacheKey(walletAddress: string, version: KeyVersion): string {
return `${walletAddress.toLowerCase()}:${version}`;
}
export async function getCachedDerivedKey(
wallet: ConnectedWallet,
- version: "v1" | "v2",
+ version: KeyVersion,
): Promise {
const cacheKey = keyCacheKey(wallet.address, version);
@@ -23,9 +29,11 @@ export async function getCachedDerivedKey(
// Derive key and cache it
const key =
- version === "v2"
- ? await getDerivedSigningKeyV2(wallet)
- : await getDerivedSigningKeyV1(wallet);
+ version === "v3"
+ ? await getDerivedSigningKeyV3(wallet)
+ : version === "v2"
+ ? await getDerivedSigningKeyV2(wallet)
+ : await getDerivedSigningKeyV1(wallet);
keyCache.set(cacheKey, key);
return key;
@@ -33,19 +41,21 @@ export async function getCachedDerivedKey(
export function hasCachedDerivedKey(
wallet: ConnectedWallet,
- version: "v1" | "v2",
+ version: KeyVersion,
): boolean {
const cacheKey = keyCacheKey(wallet.address, version);
return keyCache.has(cacheKey);
}
export async function getCachedDerivedKeys(wallet: ConnectedWallet): Promise<{
+ keyV3: Uint8Array;
keyV2: Uint8Array;
keyV1: Uint8Array;
}> {
+ const keyV3 = await getCachedDerivedKey(wallet, "v3");
const keyV2 = await getCachedDerivedKey(wallet, "v2");
const keyV1 = await getCachedDerivedKey(wallet, "v1");
- return { keyV2, keyV1 };
+ return { keyV3, keyV2, keyV1 };
}
export function clearCachedKeysForWallet(walletAddress: string): void {
diff --git a/packages/web/src/utils/signer.ts b/packages/web/src/utils/signer.ts
index 59cfc9b..cdf0753 100644
--- a/packages/web/src/utils/signer.ts
+++ b/packages/web/src/utils/signer.ts
@@ -255,5 +255,25 @@ export async function getDerivedSigningKeyV2(
return key.slice(0, 16); // Use the first 16 bytes for a 128-bit key
}
-// Default export uses v2 for new encryptions
-export const getDerivedSigningKey = getDerivedSigningKeyV2;
+// V3 key with security warning message
+export async function getDerivedSigningKeyV3(
+ wallet: ConnectedWallet,
+): Promise {
+ const message =
+ "Writer: write (privately) today, forever.\n\nNOTE: Only sign this message on https://writer.place.";
+ const encodedMessage = `0x${Buffer.from(message, "utf8").toString("hex")}`;
+ const provider = await wallet.getEthereumProvider();
+ const method = "personal_sign";
+
+ const signature = await provider.request({
+ method,
+ params: [encodedMessage, wallet.address],
+ });
+
+ const hash = keccak256(signature);
+ const key = Uint8Array.from(Buffer.from(hash.slice(2), "hex"));
+ return key.slice(0, 16);
+}
+
+// Default export uses v3 for new encryptions
+export const getDerivedSigningKey = getDerivedSigningKeyV3;
diff --git a/packages/web/src/utils/utils.ts b/packages/web/src/utils/utils.ts
index e0f82f4..00801ea 100644
--- a/packages/web/src/utils/utils.ts
+++ b/packages/web/src/utils/utils.ts
@@ -248,12 +248,23 @@ export async function processPrivateEntry(
keyV2: Uint8Array | undefined,
entry: Entry,
keyV1?: Uint8Array,
+ keyV3?: Uint8Array,
): Promise {
+ if (entry.raw?.startsWith("enc:v3:br:")) {
+ if (!keyV3) {
+ throw new Error("V3 key required to decrypt entry");
+ }
+ const decrypted = await decrypt(keyV3, entry.raw.slice(10)); // "enc:v3:br:" = 10 chars
+ const decompressed = await decompress(decrypted);
+ return {
+ ...entry,
+ decompressed,
+ };
+ }
if (entry.raw?.startsWith("enc:v2:br:")) {
if (!keyV2) {
throw new Error("V2 key required to decrypt entry");
}
- // New format - use v2 key
const decrypted = await decrypt(keyV2, entry.raw.slice(10)); // "enc:v2:br:" = 10 chars
const decompressed = await decompress(decrypted);
return {
@@ -280,7 +291,8 @@ export function isEntryPrivate(entry: Entry) {
return (
entry.version?.startsWith("enc:") ||
entry.raw?.startsWith("enc:br:") ||
- entry.raw?.startsWith("enc:v2:br:")
+ entry.raw?.startsWith("enc:v2:br:") ||
+ entry.raw?.startsWith("enc:v3:br:")
);
}