diff --git a/.changeset/smart-parents-refuse.md b/.changeset/smart-parents-refuse.md
new file mode 100644
index 0000000000..bf160770f5
--- /dev/null
+++ b/.changeset/smart-parents-refuse.md
@@ -0,0 +1,33 @@
+---
+"@latticexyz/explorer": patch
+---
+
+World Explorer package now exports an `observer` Viem decorator that can be used to get visibility into contract writes initiated from your app. You can watch these writes stream in on the new "Observe" tab of the World Explorer.
+
+```ts
+import { createClient, publicActions, walletActions } from "viem";
+import { observer } from "@latticexyz/explorer/observer";
+
+const client = createClient({ ... })
+  .extend(publicActions)
+  .extend(walletActions)
+  .extend(observer());
+```
+
+By default, the `observer` action assumes the World Explorer is running at `http://localhost:13690`, but this can be customized with the `explorerUrl` option.
+
+```ts
+observer({
+  explorerUrl: "http://localhost:4444",
+});
+```
+
+If you want to measure the timing of transaction-to-state-change, you can also pass in a `waitForStateChange` function that takes a transaction hash and returns a partial [`TransactionReceipt`](https://viem.sh/docs/glossary/types#transactionreceipt) with `blockNumber`, `status`, and `transactionHash`. This mirrors the `waitForTransaction` function signature returned by `syncTo...` helper in `@latticexyz/store-sync`.
+
+```ts
+observer({
+  async waitForStateChange(hash) {
+    return await waitForTransaction(hash);
+  },
+});
+```
diff --git a/examples/local-explorer/packages/client/package.json b/examples/local-explorer/packages/client/package.json
index cb8f88f0b0..8dbe1b0f80 100644
--- a/examples/local-explorer/packages/client/package.json
+++ b/examples/local-explorer/packages/client/package.json
@@ -12,7 +12,7 @@
   },
   "dependencies": {
     "@latticexyz/common": "link:../../../../packages/common",
-    "@latticexyz/dev-tools": "link:../../../../packages/dev-tools",
+    "@latticexyz/explorer": "link:../../../../packages/explorer",
     "@latticexyz/react": "link:../../../../packages/react",
     "@latticexyz/schema-type": "link:../../../../packages/schema-type",
     "@latticexyz/store-sync": "link:../../../../packages/store-sync",
diff --git a/examples/local-explorer/packages/client/src/index.tsx b/examples/local-explorer/packages/client/src/index.tsx
index c9b662c9f0..5362fcf0a4 100644
--- a/examples/local-explorer/packages/client/src/index.tsx
+++ b/examples/local-explorer/packages/client/src/index.tsx
@@ -2,7 +2,6 @@ import ReactDOM from "react-dom/client";
 import { App } from "./App";
 import { setup } from "./mud/setup";
 import { MUDProvider } from "./MUDContext";
-import mudConfig from "contracts/mud.config";
 
 const rootElement = document.getElementById("react-root");
 if (!rootElement) throw new Error("React root not found");
@@ -15,20 +14,4 @@ setup().then(async (result) => {
       <App />
     </MUDProvider>,
   );
-
-  // https://vitejs.dev/guide/env-and-mode.html
-  if (import.meta.env.DEV) {
-    const { mount: mountDevTools } = await import("@latticexyz/dev-tools");
-    mountDevTools({
-      config: mudConfig,
-      publicClient: result.network.publicClient,
-      walletClient: result.network.walletClient,
-      latestBlock$: result.network.latestBlock$,
-      storedBlockLogs$: result.network.storedBlockLogs$,
-      worldAddress: result.network.worldContract.address,
-      worldAbi: result.network.worldContract.abi,
-      write$: result.network.write$,
-      useStore: result.network.useStore,
-    });
-  }
 });
diff --git a/examples/local-explorer/packages/client/src/mud/setupNetwork.ts b/examples/local-explorer/packages/client/src/mud/setupNetwork.ts
index dba19e3f4c..2ff379e41e 100644
--- a/examples/local-explorer/packages/client/src/mud/setupNetwork.ts
+++ b/examples/local-explorer/packages/client/src/mud/setupNetwork.ts
@@ -16,9 +16,9 @@ import {
 import { syncToZustand } from "@latticexyz/store-sync/zustand";
 import { getNetworkConfig } from "./getNetworkConfig";
 import IWorldAbi from "contracts/out/IWorld.sol/IWorld.abi.json";
-import { createBurnerAccount, transportObserver, ContractWrite } from "@latticexyz/common";
-import { transactionQueue, writeObserver } from "@latticexyz/common/actions";
-import { Subject, share } from "rxjs";
+import { createBurnerAccount, transportObserver } from "@latticexyz/common";
+import { transactionQueue } from "@latticexyz/common/actions";
+import { observer, type WaitForStateChange } from "@latticexyz/explorer/observer";
 
 /*
  * Import our MUD config, which includes strong types for
@@ -34,6 +34,7 @@ export type SetupNetworkResult = Awaited<ReturnType<typeof setupNetwork>>;
 
 export async function setupNetwork() {
   const networkConfig = await getNetworkConfig();
+  const waitForStateChange = Promise.withResolvers<WaitForStateChange>();
 
   /*
    * Create a viem public (read only) client
@@ -47,12 +48,6 @@ export async function setupNetwork() {
 
   const publicClient = createPublicClient(clientOptions);
 
-  /*
-   * Create an observable for contract writes that we can
-   * pass into MUD dev tools for transaction observability.
-   */
-  const write$ = new Subject<ContractWrite>();
-
   /*
    * Create a temporary wallet and a viem client for it
    * (see https://viem.sh/docs/clients/wallet.html).
@@ -63,7 +58,11 @@ export async function setupNetwork() {
     account: burnerAccount,
   })
     .extend(transactionQueue())
-    .extend(writeObserver({ onWrite: (write) => write$.next(write) }));
+    .extend(
+      observer({
+        waitForStateChange: (hash) => waitForStateChange.promise.then((fn) => fn(hash)),
+      }),
+    );
 
   /*
    * Create an object for communicating with the deployed World.
@@ -86,6 +85,7 @@ export async function setupNetwork() {
     publicClient,
     startBlock: BigInt(networkConfig.initialBlockNumber),
   });
+  waitForStateChange.resolve(waitForTransaction);
 
   return {
     tables,
@@ -96,6 +96,5 @@ export async function setupNetwork() {
     storedBlockLogs$,
     waitForTransaction,
     worldContract,
-    write$: write$.asObservable().pipe(share()),
   };
 }
diff --git a/examples/local-explorer/pnpm-lock.yaml b/examples/local-explorer/pnpm-lock.yaml
index 35e2931c00..db7ba2d842 100644
--- a/examples/local-explorer/pnpm-lock.yaml
+++ b/examples/local-explorer/pnpm-lock.yaml
@@ -47,9 +47,9 @@ importers:
       '@latticexyz/common':
         specifier: link:../../../../packages/common
         version: link:../../../../packages/common
-      '@latticexyz/dev-tools':
-        specifier: link:../../../../packages/dev-tools
-        version: link:../../../../packages/dev-tools
+      '@latticexyz/explorer':
+        specifier: link:../../../../packages/explorer
+        version: link:../../../../packages/explorer
       '@latticexyz/react':
         specifier: link:../../../../packages/react
         version: link:../../../../packages/react
diff --git a/package.json b/package.json
index ff3e249430..425b16aca4 100644
--- a/package.json
+++ b/package.json
@@ -12,7 +12,7 @@
     "build": "turbo run build",
     "changelog:generate": "tsx scripts/changelog.ts",
     "clean": "turbo run clean",
-    "dev": "TSUP_SKIP_DTS=true turbo run dev --concurrency 100 --filter=!@latticexyz/explorer",
+    "dev": "TSUP_SKIP_DTS=true turbo run dev --concurrency 100",
     "dist-tag-rm": "pnpm recursive exec -- sh -c 'npm dist-tag rm $(cat package.json | jq -r \".name\") $TAG || true'",
     "docs:generate:api": "tsx scripts/render-api-docs.ts",
     "foundryup": "curl -L https://foundry.paradigm.xyz | bash && bash ~/.foundry/bin/foundryup",
diff --git a/packages/explorer/bin/explorer.js b/packages/explorer/bin/explorer.js
new file mode 100755
index 0000000000..6d4f0839cf
--- /dev/null
+++ b/packages/explorer/bin/explorer.js
@@ -0,0 +1,3 @@
+#!/usr/bin/env node
+// workaround for https://github.com/pnpm/pnpm/issues/1801
+import "../dist/bin/explorer.js";
diff --git a/packages/explorer/package.json b/packages/explorer/package.json
index 53f98600e5..806b76ee5d 100644
--- a/packages/explorer/package.json
+++ b/packages/explorer/package.json
@@ -3,23 +3,35 @@
   "version": "2.2.3",
   "description": "World Explorer is a tool for visually exploring and manipulating the state of worlds",
   "type": "module",
+  "exports": {
+    "./observer": "./dist/exports/observer.js"
+  },
+  "typesVersions": {
+    "*": {
+      "observer": [
+        "./dist/exports/observer.d.ts"
+      ]
+    }
+  },
   "bin": {
-    "explorer": "./dist/explorer.js"
+    "explorer": "./bin/explorer.js"
   },
   "files": [
+    "bin",
     "dist",
     ".next/standalone/packages/explorer"
   ],
   "scripts": {
-    "build": "pnpm run build:explorer && pnpm run build:bin",
-    "build:bin": "tsup",
+    "build": "pnpm run build:js && pnpm run build:explorer",
     "build:explorer": "next build && shx cp -r .next/static .next/standalone/packages/explorer/.next",
-    "clean": "pnpm run clean:explorer && pnpm run clean:bin",
-    "clean:bin": "shx rm -rf dist",
+    "build:js": "tsup",
+    "clean": "pnpm run clean:js && pnpm run clean:explorer",
     "clean:explorer": "shx rm -rf .next .turbo",
-    "dev": "next dev --port 13690",
-    "lint": "next lint",
-    "start": "node .next/standalone/packages/explorer/server.js"
+    "clean:js": "shx rm -rf dist",
+    "dev": "tsup --watch",
+    "explorer:dev": "next dev --port 13690",
+    "explorer:start": "node .next/standalone/packages/explorer/server.js",
+    "lint": "next lint"
   },
   "dependencies": {
     "@hookform/resolvers": "^3.9.0",
@@ -44,6 +56,7 @@
     "better-sqlite3": "^8.6.0",
     "class-variance-authority": "^0.7.0",
     "clsx": "^2.1.1",
+    "debug": "^4.3.4",
     "lucide-react": "^0.408.0",
     "next": "14.2.5",
     "query-string": "^9.1.0",
@@ -62,6 +75,7 @@
   "devDependencies": {
     "@trivago/prettier-plugin-sort-imports": "^4.3.0",
     "@types/better-sqlite3": "^7.6.4",
+    "@types/debug": "^4.1.7",
     "@types/minimist": "^1.2.5",
     "@types/node": "^18.15.11",
     "@types/react": "18.2.22",
diff --git a/packages/explorer/src/app/(explorer)/error.tsx b/packages/explorer/src/app/(explorer)/error.tsx
index 2c1604616b..4cfe8b5711 100644
--- a/packages/explorer/src/app/(explorer)/error.tsx
+++ b/packages/explorer/src/app/(explorer)/error.tsx
@@ -25,7 +25,7 @@ export default function Error({ reset, error }: Props) {
 
       <div className="mt-10 flex items-center justify-center gap-x-6">
         <Button asChild>
-          <Link href={getUrl("explorer")}>Go back to explorer</Link>
+          <Link href={getUrl("explore")}>Go back to explorer</Link>
         </Button>
 
         <Button variant="secondary" onClick={reset}>
diff --git a/packages/explorer/src/app/(explorer)/not-found.tsx b/packages/explorer/src/app/(explorer)/not-found.tsx
index dc83993eea..44059b412c 100644
--- a/packages/explorer/src/app/(explorer)/not-found.tsx
+++ b/packages/explorer/src/app/(explorer)/not-found.tsx
@@ -14,7 +14,7 @@ export default function NotFound() {
       <p className="mt-6 text-base leading-7 text-white/70">Sorry, we couldn’t find the page you’re looking for.</p>
       <div className="mt-10 flex items-center justify-center gap-x-6">
         <Button asChild>
-          <Link href={getUrl("explorer")}>Go back to explorer</Link>
+          <Link href={getUrl("explore")}>Go back to explorer</Link>
         </Button>
 
         <Button variant="secondary" asChild>
diff --git a/packages/explorer/src/app/(explorer)/worlds/[worldAddress]/explorer/DataExplorer.tsx b/packages/explorer/src/app/(explorer)/worlds/[worldAddress]/explore/DataExplorer.tsx
similarity index 100%
rename from packages/explorer/src/app/(explorer)/worlds/[worldAddress]/explorer/DataExplorer.tsx
rename to packages/explorer/src/app/(explorer)/worlds/[worldAddress]/explore/DataExplorer.tsx
diff --git a/packages/explorer/src/app/(explorer)/worlds/[worldAddress]/explorer/EditableTableCell.tsx b/packages/explorer/src/app/(explorer)/worlds/[worldAddress]/explore/EditableTableCell.tsx
similarity index 100%
rename from packages/explorer/src/app/(explorer)/worlds/[worldAddress]/explorer/EditableTableCell.tsx
rename to packages/explorer/src/app/(explorer)/worlds/[worldAddress]/explore/EditableTableCell.tsx
diff --git a/packages/explorer/src/app/(explorer)/worlds/[worldAddress]/explorer/TableSelector.tsx b/packages/explorer/src/app/(explorer)/worlds/[worldAddress]/explore/TableSelector.tsx
similarity index 100%
rename from packages/explorer/src/app/(explorer)/worlds/[worldAddress]/explorer/TableSelector.tsx
rename to packages/explorer/src/app/(explorer)/worlds/[worldAddress]/explore/TableSelector.tsx
diff --git a/packages/explorer/src/app/(explorer)/worlds/[worldAddress]/explorer/TablesViewer.tsx b/packages/explorer/src/app/(explorer)/worlds/[worldAddress]/explore/TablesViewer.tsx
similarity index 100%
rename from packages/explorer/src/app/(explorer)/worlds/[worldAddress]/explorer/TablesViewer.tsx
rename to packages/explorer/src/app/(explorer)/worlds/[worldAddress]/explore/TablesViewer.tsx
diff --git a/packages/explorer/src/app/(explorer)/worlds/[worldAddress]/explorer/page.tsx b/packages/explorer/src/app/(explorer)/worlds/[worldAddress]/explore/page.tsx
similarity index 100%
rename from packages/explorer/src/app/(explorer)/worlds/[worldAddress]/explorer/page.tsx
rename to packages/explorer/src/app/(explorer)/worlds/[worldAddress]/explore/page.tsx
diff --git a/packages/explorer/src/app/(explorer)/worlds/[worldAddress]/observe/Write.tsx b/packages/explorer/src/app/(explorer)/worlds/[worldAddress]/observe/Write.tsx
new file mode 100644
index 0000000000..b61825e5ce
--- /dev/null
+++ b/packages/explorer/src/app/(explorer)/worlds/[worldAddress]/observe/Write.tsx
@@ -0,0 +1,31 @@
+"use client";
+
+import { type Write } from "../../../../../observer/store";
+import { msPerViewportWidth } from "./common";
+
+export type Props = Write;
+
+export function Write({ functionSignature, time: start, events }: Props) {
+  return (
+    <div className="pr-[10vw]">
+      <div className="font-bold opacity-40 group-hover/write:opacity-100">
+        {functionSignature} <span className="opacity-50">{new Date(start).toLocaleTimeString()}</span>
+      </div>
+      <div className="inline-grid bg-cyan-400/10 text-cyan-400">
+        {events.map((event) => (
+          <div key={event.type} className="col-start-1 flex">
+            <div
+              className="pointer-events-none shrink-0"
+              style={{ width: `${((event.time - start) / msPerViewportWidth) * 100}vw` }}
+            />
+            <div className="h-[1.25em] shrink-0 whitespace-nowrap border-l-2 border-current">
+              <div className="pointer-events-none invisible absolute px-1 leading-[1.25em] opacity-70 group-hover/write:visible">
+                {event.type} <span className="text-white/60">{event.time - start}ms</span>
+              </div>
+            </div>
+          </div>
+        ))}
+      </div>
+    </div>
+  );
+}
diff --git a/packages/explorer/src/app/(explorer)/worlds/[worldAddress]/observe/Writes.tsx b/packages/explorer/src/app/(explorer)/worlds/[worldAddress]/observe/Writes.tsx
new file mode 100644
index 0000000000..053b509e28
--- /dev/null
+++ b/packages/explorer/src/app/(explorer)/worlds/[worldAddress]/observe/Writes.tsx
@@ -0,0 +1,26 @@
+"use client";
+
+import { useStore } from "zustand";
+import { KeepInView } from "../../../../../components/KeepInView";
+import { store } from "../../../../../observer/store";
+import { Write } from "./Write";
+
+export function Writes() {
+  const writes = useStore(store, (state) => Object.values(state.writes));
+
+  return (
+    // TODO: replace with h-full once container is stretched to full height
+    <div className="relative h-[80vh] overflow-auto">
+      <KeepInView>
+        <div className="flex flex-col gap-4 pb-10 text-xs leading-tight">
+          {writes.length === 0 ? <>Waiting for transactions…</> : null}
+          {writes.map((write) => (
+            <div key={write.writeId} className="group/write flex gap-2">
+              <Write {...write} />
+            </div>
+          ))}
+        </div>
+      </KeepInView>
+    </div>
+  );
+}
diff --git a/packages/explorer/src/app/(explorer)/worlds/[worldAddress]/observe/common.ts b/packages/explorer/src/app/(explorer)/worlds/[worldAddress]/observe/common.ts
new file mode 100644
index 0000000000..fa7dd9d0b1
--- /dev/null
+++ b/packages/explorer/src/app/(explorer)/worlds/[worldAddress]/observe/common.ts
@@ -0,0 +1 @@
+export const msPerViewportWidth = 1000 * 60 * 1;
diff --git a/packages/explorer/src/app/(explorer)/worlds/[worldAddress]/observe/page.tsx b/packages/explorer/src/app/(explorer)/worlds/[worldAddress]/observe/page.tsx
new file mode 100644
index 0000000000..1cd2e007d7
--- /dev/null
+++ b/packages/explorer/src/app/(explorer)/worlds/[worldAddress]/observe/page.tsx
@@ -0,0 +1,5 @@
+import { Writes } from "./Writes";
+
+export default function ObservePage() {
+  return <Writes />;
+}
diff --git a/packages/explorer/src/app/(explorer)/worlds/[worldAddress]/page.tsx b/packages/explorer/src/app/(explorer)/worlds/[worldAddress]/page.tsx
index bd72da2c84..2fedbf5643 100644
--- a/packages/explorer/src/app/(explorer)/worlds/[worldAddress]/page.tsx
+++ b/packages/explorer/src/app/(explorer)/worlds/[worldAddress]/page.tsx
@@ -1,5 +1,5 @@
 import { redirect } from "next/navigation";
 
 export default async function WorldPage({ params }: { params: { worldAddress: string } }) {
-  return redirect(`/worlds/${params.worldAddress}/explorer`);
+  return redirect(`/worlds/${params.worldAddress}/explore`);
 }
diff --git a/packages/explorer/src/app/internal/layout.tsx b/packages/explorer/src/app/internal/layout.tsx
new file mode 100644
index 0000000000..c8f9cee0b7
--- /dev/null
+++ b/packages/explorer/src/app/internal/layout.tsx
@@ -0,0 +1,7 @@
+export default function Layout({ children }: { children: React.ReactNode }) {
+  return (
+    <html lang="en">
+      <body>{children}</body>
+    </html>
+  );
+}
diff --git a/packages/explorer/src/app/internal/observer-relay/Relay.tsx b/packages/explorer/src/app/internal/observer-relay/Relay.tsx
new file mode 100644
index 0000000000..62a66b8741
--- /dev/null
+++ b/packages/explorer/src/app/internal/observer-relay/Relay.tsx
@@ -0,0 +1,9 @@
+"use client";
+
+import { useEffect } from "react";
+import { createRelay } from "../../../observer/relay";
+
+export function Relay() {
+  useEffect(createRelay, []);
+  return null;
+}
diff --git a/packages/explorer/src/app/internal/observer-relay/page.tsx b/packages/explorer/src/app/internal/observer-relay/page.tsx
new file mode 100644
index 0000000000..85fc8fc574
--- /dev/null
+++ b/packages/explorer/src/app/internal/observer-relay/page.tsx
@@ -0,0 +1,5 @@
+import { Relay } from "./Relay";
+
+export default function ObserverRelayPage() {
+  return <Relay />;
+}
diff --git a/packages/explorer/bin/explorer.ts b/packages/explorer/src/bin/explorer.ts
similarity index 97%
rename from packages/explorer/bin/explorer.ts
rename to packages/explorer/src/bin/explorer.ts
index e7b3bcc06f..88f887ba32 100755
--- a/packages/explorer/bin/explorer.ts
+++ b/packages/explorer/src/bin/explorer.ts
@@ -9,6 +9,7 @@ import { ChildProcess, spawn } from "child_process";
 
 const __filename = fileURLToPath(import.meta.url);
 const __dirname = path.dirname(__filename);
+const packageRoot = path.join(__dirname, "..", "..");
 
 const argv = yargs(process.argv.slice(2))
   .options({
@@ -72,14 +73,14 @@ async function startExplorer() {
       "node_modules/.bin/next",
       ["dev", "--port", port.toString(), ...(hostname ? ["--hostname", hostname] : [])],
       {
-        cwd: path.join(__dirname, ".."),
+        cwd: packageRoot,
         stdio: "inherit",
         env,
       },
     );
   } else {
     explorerProcess = spawn("node", [".next/standalone/packages/explorer/server.js"], {
-      cwd: path.join(__dirname, ".."),
+      cwd: packageRoot,
       stdio: "inherit",
       env: {
         ...env,
diff --git a/packages/explorer/src/components/KeepInView.tsx b/packages/explorer/src/components/KeepInView.tsx
new file mode 100644
index 0000000000..83161a584f
--- /dev/null
+++ b/packages/explorer/src/components/KeepInView.tsx
@@ -0,0 +1,38 @@
+import { ReactNode, useRef } from "react";
+
+export type Props = {
+  className?: string;
+  children: ReactNode;
+  enabled?: boolean;
+};
+
+export function KeepInView({ className, children, enabled = true }: Props) {
+  const containerRef = useRef<HTMLDivElement>(null);
+  const hoveredRef = useRef(false);
+  const scrollBehaviorRef = useRef<ScrollBehavior>("auto");
+
+  // Intentionally not in a `useEffect` so this triggers on every render.
+  if (!hoveredRef.current && enabled) {
+    containerRef.current?.scrollIntoView({
+      behavior: scrollBehaviorRef.current,
+      block: "end",
+      inline: "end",
+    });
+  }
+  scrollBehaviorRef.current = "smooth";
+
+  return (
+    <div
+      ref={containerRef}
+      onMouseEnter={() => {
+        hoveredRef.current = true;
+      }}
+      onMouseLeave={() => {
+        hoveredRef.current = false;
+      }}
+      className={className}
+    >
+      {children}
+    </div>
+  );
+}
diff --git a/packages/explorer/src/components/Navigation.tsx b/packages/explorer/src/components/Navigation.tsx
index 1429946851..e4d0d7c2b3 100644
--- a/packages/explorer/src/components/Navigation.tsx
+++ b/packages/explorer/src/components/Navigation.tsx
@@ -20,12 +20,12 @@ export function Navigation() {
       <div className="flex items-center justify-between">
         <div className="flex gap-x-6 py-4">
           <Link
-            href={getLinkUrl("explorer")}
+            href={getLinkUrl("explore")}
             className={cn("text-sm uppercase underline-offset-[16px]", {
-              "font-semibold underline decoration-orange-500 decoration-4": pathname === getLinkUrl("explorer"),
+              "font-semibold underline decoration-orange-500 decoration-4": pathname === getLinkUrl("explore"),
             })}
           >
-            Data explorer
+            Explore
           </Link>
 
           <Link
@@ -36,6 +36,15 @@ export function Navigation() {
           >
             Interact
           </Link>
+
+          <Link
+            href={getLinkUrl("observe")}
+            className={cn("text-sm uppercase underline-offset-[16px]", {
+              "font-semibold underline decoration-orange-500 decoration-4": pathname === getLinkUrl("observe"),
+            })}
+          >
+            Observe
+          </Link>
         </div>
 
         {isFetched && !data?.isWorldDeployed && (
diff --git a/packages/explorer/src/debug.ts b/packages/explorer/src/debug.ts
new file mode 100644
index 0000000000..7cab931243
--- /dev/null
+++ b/packages/explorer/src/debug.ts
@@ -0,0 +1,3 @@
+import createDebug from "debug";
+
+export const debug = createDebug("mud:explorer");
diff --git a/packages/explorer/src/exports/observer.ts b/packages/explorer/src/exports/observer.ts
new file mode 100644
index 0000000000..84c6797b40
--- /dev/null
+++ b/packages/explorer/src/exports/observer.ts
@@ -0,0 +1,3 @@
+export { createBridge, type CreateBridgeOpts } from "../observer/bridge";
+export type { Messages, MessageType, EmitMessage } from "../observer/messages";
+export { observer, type ObserverOptions, type WaitForStateChange } from "../observer/decorator";
diff --git a/packages/explorer/src/observer/README.md b/packages/explorer/src/observer/README.md
new file mode 100644
index 0000000000..71510e61a2
--- /dev/null
+++ b/packages/explorer/src/observer/README.md
@@ -0,0 +1,23 @@
+```
+┌─app────────────────────────┐ ┌─explorer───────────────────┐
+│                            │ │                            │
+│                            │ │                            │
+│                            │ │                            │
+│                            │ │                            │
+│                            │ │                            │
+│                            │ │                            │
+│                            │ │                            │
+│                 ┌─bridge─┐ │ │                            │
+│                 │        │ │ │                            │
+│                 │     ───┼relay──►                        │
+│                 │        │ │ │                            │
+│                 └────────┘ │ │                            │
+└────────────────────────────┘ └────────────────────────────┘
+```
+
+<!-- diagram via https://asciiflow.com/ -->
+
+## TODO
+
+- [ ] figure out why 127.0.0.1 wasn't relaying messages on BroadcastChannel but localhost does
+- [ ] Explorer/Next.js app seems to be clearing localStorage.debug on reload?
diff --git a/packages/explorer/src/observer/bridge.ts b/packages/explorer/src/observer/bridge.ts
new file mode 100644
index 0000000000..7be2ebb926
--- /dev/null
+++ b/packages/explorer/src/observer/bridge.ts
@@ -0,0 +1,81 @@
+"use client";
+
+import { wait } from "@latticexyz/common/utils";
+import { debug } from "./debug";
+import { EmitMessage } from "./messages";
+
+export type BridgeEnvelope = { mud: "explorer/observer"; data: unknown };
+
+export function isBridgeEnvelope(input: unknown): input is BridgeEnvelope {
+  return (
+    typeof input === "object" &&
+    input !== null &&
+    "mud" in input &&
+    input.mud === "explorer/observer" &&
+    "data" in input
+  );
+}
+
+export function wrapMessage(data: unknown): BridgeEnvelope {
+  return { mud: "explorer/observer", data };
+}
+
+export type CreateBridgeOpts = {
+  url: string;
+  timeout?: number;
+};
+
+export function createBridge({ url, timeout = 10_000 }: CreateBridgeOpts): EmitMessage {
+  const emit = Promise.withResolvers<EmitMessage>();
+  const iframe = document.createElement("iframe");
+  iframe.tabIndex = -1;
+  iframe.ariaHidden = "true";
+  iframe.style.position = "absolute";
+  iframe.style.border = "0";
+  iframe.style.width = "0";
+  iframe.style.height = "0";
+
+  iframe.addEventListener(
+    "load",
+    () => {
+      debug("observer iframe ready", iframe.src);
+      // TODO: throw if `iframe.contentWindow` is `null`?
+      emit.resolve((type, data) => {
+        const message = wrapMessage({ ...data, type, time: Date.now() });
+        debug("posting message to bridge", message);
+        iframe.contentWindow!.postMessage(message, "*");
+      });
+    },
+    { once: true },
+  );
+
+  iframe.addEventListener(
+    "error",
+    (error) => {
+      debug("observer iframe error", error);
+      emit.reject(error);
+    },
+    { once: true },
+  );
+
+  // TODO: should we let the caller handle this with their own promise timeout or race?
+  wait(timeout).then(() => {
+    emit.reject(new Error("Timed out waiting for observer iframe to load."));
+  });
+
+  debug("mounting observer iframe", url);
+  iframe.src = url;
+  parent.document.body.appendChild(iframe);
+
+  emit.promise.catch(() => {
+    iframe.remove();
+  });
+
+  return (messageType, message) => {
+    debug("got message for bridge", messageType, message);
+    emit.promise.then(
+      (fn) => fn(messageType, message),
+      (error) => debug("could not deliver message", message, error),
+    );
+  };
+}
diff --git a/packages/explorer/src/observer/common.ts b/packages/explorer/src/observer/common.ts
new file mode 100644
index 0000000000..04f2bd63cd
--- /dev/null
+++ b/packages/explorer/src/observer/common.ts
@@ -0,0 +1,5 @@
+import { TransactionReceipt } from "viem";
+
+export const relayChannelName = "explorer/observer";
+
+export type ReceiptSummary = Pick<TransactionReceipt, "blockNumber" | "status" | "transactionHash">;
diff --git a/packages/explorer/src/observer/debug.ts b/packages/explorer/src/observer/debug.ts
new file mode 100644
index 0000000000..8e1becd45c
--- /dev/null
+++ b/packages/explorer/src/observer/debug.ts
@@ -0,0 +1,3 @@
+import { debug as parentDebug } from "../debug";
+
+export const debug = parentDebug.extend("observer");
diff --git a/packages/explorer/src/observer/decorator.ts b/packages/explorer/src/observer/decorator.ts
new file mode 100644
index 0000000000..cee3a22c2b
--- /dev/null
+++ b/packages/explorer/src/observer/decorator.ts
@@ -0,0 +1,72 @@
+import { Account, Chain, Client, Hex, Transport, WalletActions, getAbiItem } from "viem";
+import { waitForTransactionReceipt, writeContract } from "viem/actions";
+import { formatAbiItem, getAction } from "viem/utils";
+import { createBridge } from "./bridge";
+import { ReceiptSummary } from "./common";
+
+export type WaitForStateChange = (hash: Hex) => Promise<ReceiptSummary>;
+
+export type ObserverOptions = {
+  explorerUrl?: string;
+  waitForStateChange?: WaitForStateChange;
+};
+
+export function observer<transport extends Transport, chain extends Chain, account extends Account>({
+  explorerUrl = "http://localhost:13690",
+  waitForStateChange,
+}: ObserverOptions): (
+  client: Client<transport, chain, account>,
+) => Pick<WalletActions<chain, account>, "writeContract"> {
+  const emit = createBridge({ url: `${explorerUrl}/internal/observer-relay` });
+
+  setInterval(() => {
+    emit("ping", {});
+  }, 2000);
+
+  return (client) => {
+    let counter = 0;
+    return {
+      async writeContract(args) {
+        const writeId = `${client.uid}-${++counter}`;
+        const write = getAction(client, writeContract, "writeContract")(args);
+
+        // `writeContract` above will throw if this isn't present
+        const functionAbiItem = getAbiItem({
+          abi: args.abi,
+          name: args.functionName,
+          args: args.args,
+        } as never)!;
+
+        emit("write", {
+          writeId,
+          address: args.address,
+          functionSignature: formatAbiItem(functionAbiItem),
+          args: (args.args ?? []) as never,
+        });
+        Promise.allSettled([write]).then(([result]) => {
+          emit("write:result", { ...result, writeId });
+        });
+
+        write.then((hash) => {
+          const receipt = getAction(client, waitForTransactionReceipt, "waitForTransactionReceipt")({ hash });
+          emit("waitForTransactionReceipt", { writeId });
+          Promise.allSettled([receipt]).then(([result]) => {
+            emit("waitForTransactionReceipt:result", { ...result, writeId });
+          });
+        });
+
+        if (waitForStateChange) {
+          write.then((hash) => {
+            const receipt = waitForStateChange(hash);
+            emit("waitForStateChange", { writeId });
+            Promise.allSettled([receipt]).then(([result]) => {
+              emit("waitForStateChange:result", { ...result, writeId });
+            });
+          });
+        }
+
+        return write;
+      },
+    };
+  };
+}
diff --git a/packages/explorer/src/observer/messages.ts b/packages/explorer/src/observer/messages.ts
new file mode 100644
index 0000000000..4c37d7c32d
--- /dev/null
+++ b/packages/explorer/src/observer/messages.ts
@@ -0,0 +1,37 @@
+import { Address, Hash } from "viem";
+import { ReceiptSummary } from "./common";
+
+export type Messages = {
+  ping: {};
+  write: {
+    writeId: string;
+    address: Address;
+    functionSignature: string;
+    args: unknown[];
+  };
+  "write:result": PromiseSettledResult<Hash> & {
+    writeId: string;
+  };
+  waitForTransactionReceipt: {
+    writeId: string;
+  };
+  "waitForTransactionReceipt:result": PromiseSettledResult<ReceiptSummary> & {
+    writeId: string;
+  };
+  waitForStateChange: {
+    writeId: string;
+  };
+  "waitForStateChange:result": PromiseSettledResult<ReceiptSummary> & {
+    writeId: string;
+  };
+};
+
+export type MessageType = keyof Messages;
+export type Message<messageType extends MessageType = MessageType> = {
+  [k in MessageType]: Omit<Messages[k], "type" | "time"> & { type: k; time: number };
+}[messageType];
+
+export type EmitMessage = <const messageType extends MessageType>(
+  type: messageType,
+  data: Messages[messageType],
+) => void;
diff --git a/packages/explorer/src/observer/relay.ts b/packages/explorer/src/observer/relay.ts
new file mode 100644
index 0000000000..6c64292170
--- /dev/null
+++ b/packages/explorer/src/observer/relay.ts
@@ -0,0 +1,20 @@
+"use client";
+
+import debug from "debug";
+import { isBridgeEnvelope } from "./bridge";
+import { relayChannelName } from "./common";
+
+export function createRelay(): () => void {
+  const channel = new BroadcastChannel(relayChannelName);
+  function relay(event: MessageEvent) {
+    if (isBridgeEnvelope(event.data)) {
+      debug("relaying message from bridge");
+      channel.postMessage(event.data.data);
+    }
+  }
+  window.addEventListener("message", relay);
+  return () => {
+    window.removeEventListener("message", relay);
+    channel.close();
+  };
+}
diff --git a/packages/explorer/src/observer/store.ts b/packages/explorer/src/observer/store.ts
new file mode 100644
index 0000000000..b0f065ff74
--- /dev/null
+++ b/packages/explorer/src/observer/store.ts
@@ -0,0 +1,44 @@
+"use client";
+
+import { Address } from "viem";
+import { createStore } from "zustand/vanilla";
+import { relayChannelName } from "./common";
+import { debug } from "./debug";
+import { Message, MessageType } from "./messages";
+
+export type Write = {
+  writeId: string;
+  address: Address;
+  functionSignature: string;
+  args: unknown[];
+  time: number;
+  events: Message<Exclude<MessageType, "ping">>[];
+};
+
+export type State = {
+  writes: {
+    [id: string]: Write;
+  };
+};
+
+export const store = createStore<State>(() => ({
+  writes: {},
+}));
+
+debug("listening for relayed messages", relayChannelName);
+const channel = new BroadcastChannel(relayChannelName);
+channel.addEventListener("message", ({ data }: MessageEvent<Message>) => {
+  if (data.type === "ping") return;
+  store.setState((state) => {
+    const write = data.type === "write" ? ({ ...data, events: [] } satisfies Write) : state.writes[data.writeId];
+    return {
+      writes: {
+        ...state.writes,
+        [data.writeId]: {
+          ...write,
+          events: [...write.events, data],
+        },
+      },
+    };
+  });
+});
diff --git a/packages/explorer/tsconfig.tsup.json b/packages/explorer/tsconfig.tsup.json
new file mode 100644
index 0000000000..dc787c60b2
--- /dev/null
+++ b/packages/explorer/tsconfig.tsup.json
@@ -0,0 +1,3 @@
+{
+  "extends": ["../../tsconfig.json"]
+}
diff --git a/packages/explorer/tsup.config.ts b/packages/explorer/tsup.config.ts
index 6a842f457b..5a229b1e41 100644
--- a/packages/explorer/tsup.config.ts
+++ b/packages/explorer/tsup.config.ts
@@ -1,10 +1,12 @@
 import { defineConfig } from "tsup";
 
 export default defineConfig({
-  entry: ["bin/explorer.ts"],
+  tsconfig: "tsconfig.tsup.json",
+  entry: ["src/bin/explorer.ts", "src/exports/observer.ts"],
   target: "esnext",
   format: ["esm"],
+  dts: !process.env.TSUP_SKIP_DTS,
   sourcemap: true,
   clean: true,
-  minify: true,
+  minify: false,
 });
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 3d349fb7f4..324b8fbe3f 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -494,7 +494,7 @@ importers:
         version: 3.1.3(@types/react-dom@18.2.7)(@types/react@18.2.22)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
       '@rainbow-me/rainbowkit':
         specifier: ^2.1.5
-        version: 2.1.5(@tanstack/react-query@5.52.0(react@18.2.0))(@types/react@18.2.22)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(viem@2.19.8(bufferutil@4.0.8)(typescript@5.4.2)(utf-8-validate@5.0.10)(zod@3.23.8))(wagmi@2.12.7(@tanstack/query-core@5.52.0)(@tanstack/react-query@5.52.0(react@18.2.0))(@types/react@18.2.22)(bufferutil@4.0.8)(encoding@0.1.13)(react-dom@18.2.0(react@18.2.0))(react-native@0.75.2(@babel/core@7.21.4)(@babel/preset-env@7.25.3(@babel/core@7.21.4))(@types/react@18.2.22)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.2.0)(typescript@5.4.2)(utf-8-validate@5.0.10))(react@18.2.0)(rollup@3.21.8)(typescript@5.4.2)(utf-8-validate@5.0.10)(viem@2.19.8(bufferutil@4.0.8)(typescript@5.4.2)(utf-8-validate@5.0.10)(zod@3.23.8))(zod@3.23.8))
+        version: 2.1.6(@tanstack/react-query@5.52.0(react@18.2.0))(@types/react@18.2.22)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(viem@2.19.8(bufferutil@4.0.8)(typescript@5.4.2)(utf-8-validate@5.0.10)(zod@3.23.8))(wagmi@2.12.7(@tanstack/query-core@5.52.0)(@tanstack/react-query@5.52.0(react@18.2.0))(@types/react@18.2.22)(bufferutil@4.0.8)(encoding@0.1.13)(react-dom@18.2.0(react@18.2.0))(react-native@0.75.2(@babel/core@7.21.4)(@babel/preset-env@7.25.3(@babel/core@7.21.4))(@types/react@18.2.22)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.2.0)(typescript@5.4.2)(utf-8-validate@5.0.10))(react@18.2.0)(rollup@3.21.8)(typescript@5.4.2)(utf-8-validate@5.0.10)(viem@2.19.8(bufferutil@4.0.8)(typescript@5.4.2)(utf-8-validate@5.0.10)(zod@3.23.8))(zod@3.23.8))
       '@tanstack/react-query':
         specifier: ^5.51.3
         version: 5.52.0(react@18.2.0)
@@ -513,6 +513,9 @@ importers:
       clsx:
         specifier: ^2.1.1
         version: 2.1.1
+      debug:
+        specifier: ^4.3.4
+        version: 4.3.4
       lucide-react:
         specifier: ^0.408.0
         version: 0.408.0(react@18.2.0)
@@ -562,6 +565,9 @@ importers:
       '@types/better-sqlite3':
         specifier: ^7.6.4
         version: 7.6.4
+      '@types/debug':
+        specifier: ^4.1.7
+        version: 4.1.7
       '@types/minimist':
         specifier: ^1.2.5
         version: 1.2.5
@@ -4036,8 +4042,8 @@ packages:
       '@types/react-dom':
         optional: true
 
-  '@rainbow-me/rainbowkit@2.1.5':
-    resolution: {integrity: sha512-Kdef0zu0bUlIOlbyyi3ukmQl7k8s3w0jTcWZxYTicZ/N4L35yX0vEzYgiG4u6OSXlbAQaC7VrkPKugPbSohnLQ==}
+  '@rainbow-me/rainbowkit@2.1.6':
+    resolution: {integrity: sha512-DCt6VYuPPxcPY6veuSOa784mHHHN0uSdDBTivdUBssmjTwHMmOrEs6kuKSYTPRu8EAwA1AvIc+ulSVnS022nbg==}
     engines: {node: '>=12.4'}
     peerDependencies:
       '@tanstack/react-query': '>=5.0.0'
@@ -4823,17 +4829,17 @@ packages:
   '@ungap/structured-clone@1.2.0':
     resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==}
 
-  '@vanilla-extract/css@1.14.0':
-    resolution: {integrity: sha512-rYfm7JciWZ8PFzBM/HDiE2GLnKI3xJ6/vdmVJ5BSgcCZ5CxRlM9Cjqclni9lGzF3eMOijnUhCd/KV8TOzyzbMA==}
+  '@vanilla-extract/css@1.15.5':
+    resolution: {integrity: sha512-N1nQebRWnXvlcmu9fXKVUs145EVwmWtMD95bpiEKtvehHDpUhmO1l2bauS7FGYKbi3dU1IurJbGpQhBclTr1ng==}
 
-  '@vanilla-extract/dynamic@2.1.0':
-    resolution: {integrity: sha512-8zl0IgBYRtgD1h+56Zu13wHTiMTJSVEa4F7RWX9vTB/5Xe2KtjoiqApy/szHPVFA56c+ex6A4GpCQjT1bKXbYw==}
+  '@vanilla-extract/dynamic@2.1.2':
+    resolution: {integrity: sha512-9BGMciD8rO1hdSPIAh1ntsG4LPD3IYKhywR7VOmmz9OO4Lx1hlwkSg3E6X07ujFx7YuBfx0GDQnApG9ESHvB2A==}
 
   '@vanilla-extract/private@1.0.6':
     resolution: {integrity: sha512-ytsG/JLweEjw7DBuZ/0JCN4WAQgM9erfSTdS1NQY778hFQSZ6cfCDEZZ0sgVm4k54uNz6ImKB33AYvSR//fjxw==}
 
-  '@vanilla-extract/sprinkles@1.6.1':
-    resolution: {integrity: sha512-N/RGKwGAAidBupZ436RpuweRQHEFGU+mvAqBo8PRMAjJEmHoPDttV8RObaMLrJHWLqvX+XUMinHUnD0hFRQISw==}
+  '@vanilla-extract/sprinkles@1.6.3':
+    resolution: {integrity: sha512-oCHlQeYOBIJIA2yWy2GnY5wE2A7hGHDyJplJo4lb+KEIBcJWRnDJDg8ywDwQS5VfWJrBBO3drzYZPFpWQjAMiQ==}
     peerDependencies:
       '@vanilla-extract/css': ^1.0.0
 
@@ -5650,10 +5656,6 @@ packages:
     resolution: {integrity: sha512-rQ1+kcj+ttHG0MKVGBUXwayCCF1oh39BF5COIpRzuCEv8Mwjv0XucrI2ExNTOn9IlLifGClWQcU9BrZORvtw6Q==}
     engines: {node: '>=6'}
 
-  clsx@2.1.0:
-    resolution: {integrity: sha512-m3iNNWpd9rl3jvvcBnu70ylMdrXt8Vlq4HYadnU5fwcOtvkSQWPmj7amUcDT2qYI7risszBjI5AUIUox9D16pg==}
-    engines: {node: '>=6'}
-
   clsx@2.1.1:
     resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
     engines: {node: '>=6'}
@@ -5929,6 +5931,14 @@ packages:
   dedent@0.7.0:
     resolution: {integrity: sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA==}
 
+  dedent@1.5.3:
+    resolution: {integrity: sha512-NHQtfOOW68WD8lgypbLA5oT+Bt0xXJhiYvoR6SmmNXZfpzOGXwdKWmcwG8N7PwVVWV3eF/68nmD9BaJSsTBhyQ==}
+    peerDependencies:
+      babel-plugin-macros: ^3.1.0
+    peerDependenciesMeta:
+      babel-plugin-macros:
+        optional: true
+
   deep-eql@4.1.3:
     resolution: {integrity: sha512-WaEtAOpRA1MQ0eohqZjpGD8zdI0Ovsm8mmFhaDN8dvDZzyoUMcYDnf5Y6iu7HTXxf8JDS23qWa4a+hKCDyOPzw==}
     engines: {node: '>=6'}
@@ -7966,6 +7976,9 @@ packages:
     resolution: {integrity: sha512-CQl19J/g+Hbjbv4Y3mFNNXFEL/5t/KCg8POCuUqd4rMKjGG+j1ybER83hxV58zL+dFI1PTkt3GNFSHRt+d8qEQ==}
     engines: {node: 14 || >=16.14}
 
+  lru-cache@10.4.3:
+    resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
+
   lru-cache@4.1.5:
     resolution: {integrity: sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==}
 
@@ -8577,9 +8590,6 @@ packages:
   outdent@0.5.0:
     resolution: {integrity: sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q==}
 
-  outdent@0.8.0:
-    resolution: {integrity: sha512-KiOAIsdpUTcAXuykya5fnVVT+/5uS0Q1mrkRHcF89tpieSmY33O/tmc54CqwA+bfhbtEfZUNLHaPUiB9X3jt1A==}
-
   p-filter@2.1.0:
     resolution: {integrity: sha512-ZBxxZ5sL2HghephhpGAQdoskxplTwr7ICaehZwLIlfL6acuVgZPm8yBNuRAFBGEqtD/hmUeq9eqLg2ys9Xr/yw==}
     engines: {node: '>=8'}
@@ -9071,6 +9081,11 @@ packages:
     engines: {node: '>=10.13.0'}
     hasBin: true
 
+  qrcode@1.5.4:
+    resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
+    engines: {node: '>=10.13.0'}
+    hasBin: true
+
   query-string@7.1.3:
     resolution: {integrity: sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==}
     engines: {node: '>=6'}
@@ -9187,6 +9202,16 @@ packages:
       '@types/react':
         optional: true
 
+  react-remove-scroll@2.6.0:
+    resolution: {integrity: sha512-I2U4JVEsQenxDAKaVa3VZ/JeJZe0/2DxPWL8Tj8yLKctQJQiZM52pn/GWFpSp8dftjM3pSAHVJZscAnC/y+ySQ==}
+    engines: {node: '>=10'}
+    peerDependencies:
+      '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0
+      react: ^16.8.0 || ^17.0.0 || ^18.0.0
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+
   react-router-dom@6.11.0:
     resolution: {integrity: sha512-Q3mK1c/CYoF++J6ZINz7EZzwlgSOZK/kc7lxIA7PhtWhKju4KfF1WHqlx0kVCIFJAWztuYVpXZeljEbds8z4Og==}
     engines: {node: '>=14'}
@@ -14183,22 +14208,23 @@ snapshots:
       '@types/react': 18.2.22
       '@types/react-dom': 18.2.7
 
-  '@rainbow-me/rainbowkit@2.1.5(@tanstack/react-query@5.52.0(react@18.2.0))(@types/react@18.2.22)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(viem@2.19.8(bufferutil@4.0.8)(typescript@5.4.2)(utf-8-validate@5.0.10)(zod@3.23.8))(wagmi@2.12.7(@tanstack/query-core@5.52.0)(@tanstack/react-query@5.52.0(react@18.2.0))(@types/react@18.2.22)(bufferutil@4.0.8)(encoding@0.1.13)(react-dom@18.2.0(react@18.2.0))(react-native@0.75.2(@babel/core@7.21.4)(@babel/preset-env@7.25.3(@babel/core@7.21.4))(@types/react@18.2.22)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.2.0)(typescript@5.4.2)(utf-8-validate@5.0.10))(react@18.2.0)(rollup@3.21.8)(typescript@5.4.2)(utf-8-validate@5.0.10)(viem@2.19.8(bufferutil@4.0.8)(typescript@5.4.2)(utf-8-validate@5.0.10)(zod@3.23.8))(zod@3.23.8))':
+  '@rainbow-me/rainbowkit@2.1.6(@tanstack/react-query@5.52.0(react@18.2.0))(@types/react@18.2.22)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(viem@2.19.8(bufferutil@4.0.8)(typescript@5.4.2)(utf-8-validate@5.0.10)(zod@3.23.8))(wagmi@2.12.7(@tanstack/query-core@5.52.0)(@tanstack/react-query@5.52.0(react@18.2.0))(@types/react@18.2.22)(bufferutil@4.0.8)(encoding@0.1.13)(react-dom@18.2.0(react@18.2.0))(react-native@0.75.2(@babel/core@7.21.4)(@babel/preset-env@7.25.3(@babel/core@7.21.4))(@types/react@18.2.22)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.2.0)(typescript@5.4.2)(utf-8-validate@5.0.10))(react@18.2.0)(rollup@3.21.8)(typescript@5.4.2)(utf-8-validate@5.0.10)(viem@2.19.8(bufferutil@4.0.8)(typescript@5.4.2)(utf-8-validate@5.0.10)(zod@3.23.8))(zod@3.23.8))':
     dependencies:
       '@tanstack/react-query': 5.52.0(react@18.2.0)
-      '@vanilla-extract/css': 1.14.0
-      '@vanilla-extract/dynamic': 2.1.0
-      '@vanilla-extract/sprinkles': 1.6.1(@vanilla-extract/css@1.14.0)
-      clsx: 2.1.0
-      qrcode: 1.5.3
+      '@vanilla-extract/css': 1.15.5
+      '@vanilla-extract/dynamic': 2.1.2
+      '@vanilla-extract/sprinkles': 1.6.3(@vanilla-extract/css@1.15.5)
+      clsx: 2.1.1
+      qrcode: 1.5.4
       react: 18.2.0
       react-dom: 18.2.0(react@18.2.0)
-      react-remove-scroll: 2.5.7(@types/react@18.2.22)(react@18.2.0)
+      react-remove-scroll: 2.6.0(@types/react@18.2.22)(react@18.2.0)
       ua-parser-js: 1.0.38
       viem: 2.19.8(bufferutil@4.0.8)(typescript@5.4.2)(utf-8-validate@5.0.10)(zod@3.23.8)
       wagmi: 2.12.7(@tanstack/query-core@5.52.0)(@tanstack/react-query@5.52.0(react@18.2.0))(@types/react@18.2.22)(bufferutil@4.0.8)(encoding@0.1.13)(react-dom@18.2.0(react@18.2.0))(react-native@0.75.2(@babel/core@7.21.4)(@babel/preset-env@7.25.3(@babel/core@7.21.4))(@types/react@18.2.22)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.2.0)(typescript@5.4.2)(utf-8-validate@5.0.10))(react@18.2.0)(rollup@3.21.8)(typescript@5.4.2)(utf-8-validate@5.0.10)(viem@2.19.8(bufferutil@4.0.8)(typescript@5.4.2)(utf-8-validate@5.0.10)(zod@3.23.8))(zod@3.23.8)
     transitivePeerDependencies:
       - '@types/react'
+      - babel-plugin-macros
 
   '@react-native-community/cli-clean@14.0.0':
     dependencies:
@@ -15401,29 +15427,32 @@ snapshots:
 
   '@ungap/structured-clone@1.2.0': {}
 
-  '@vanilla-extract/css@1.14.0':
+  '@vanilla-extract/css@1.15.5':
     dependencies:
       '@emotion/hash': 0.9.2
       '@vanilla-extract/private': 1.0.6
-      chalk: 4.1.2
       css-what: 6.1.0
       cssesc: 3.0.0
       csstype: 3.1.2
+      dedent: 1.5.3
       deep-object-diff: 1.1.9
       deepmerge: 4.3.1
+      lru-cache: 10.4.3
       media-query-parser: 2.0.2
       modern-ahocorasick: 1.0.1
-      outdent: 0.8.0
+      picocolors: 1.0.1
+    transitivePeerDependencies:
+      - babel-plugin-macros
 
-  '@vanilla-extract/dynamic@2.1.0':
+  '@vanilla-extract/dynamic@2.1.2':
     dependencies:
       '@vanilla-extract/private': 1.0.6
 
   '@vanilla-extract/private@1.0.6': {}
 
-  '@vanilla-extract/sprinkles@1.6.1(@vanilla-extract/css@1.14.0)':
+  '@vanilla-extract/sprinkles@1.6.3(@vanilla-extract/css@1.15.5)':
     dependencies:
-      '@vanilla-extract/css': 1.14.0
+      '@vanilla-extract/css': 1.15.5
 
   '@viem/anvil@0.0.7(bufferutil@4.0.8)(debug@4.3.4)(utf-8-validate@5.0.10)':
     dependencies:
@@ -16622,8 +16651,6 @@ snapshots:
 
   clsx@2.0.0: {}
 
-  clsx@2.1.0: {}
-
   clsx@2.1.1: {}
 
   co@4.6.0: {}
@@ -16893,6 +16920,8 @@ snapshots:
 
   dedent@0.7.0: {}
 
+  dedent@1.5.3: {}
+
   deep-eql@4.1.3:
     dependencies:
       type-detect: 4.0.8
@@ -19634,6 +19663,8 @@ snapshots:
 
   lru-cache@10.3.0: {}
 
+  lru-cache@10.4.3: {}
+
   lru-cache@4.1.5:
     dependencies:
       pseudomap: 1.0.2
@@ -20375,8 +20406,6 @@ snapshots:
 
   outdent@0.5.0: {}
 
-  outdent@0.8.0: {}
-
   p-filter@2.1.0:
     dependencies:
       p-map: 2.1.0
@@ -20801,6 +20830,12 @@ snapshots:
       pngjs: 5.0.0
       yargs: 15.4.1
 
+  qrcode@1.5.4:
+    dependencies:
+      dijkstrajs: 1.0.3
+      pngjs: 5.0.0
+      yargs: 15.4.1
+
   query-string@7.1.3:
     dependencies:
       decode-uri-component: 0.2.2
@@ -20954,6 +20989,17 @@ snapshots:
     optionalDependencies:
       '@types/react': 18.2.22
 
+  react-remove-scroll@2.6.0(@types/react@18.2.22)(react@18.2.0):
+    dependencies:
+      react: 18.2.0
+      react-remove-scroll-bar: 2.3.6(@types/react@18.2.22)(react@18.2.0)
+      react-style-singleton: 2.2.1(@types/react@18.2.22)(react@18.2.0)
+      tslib: 2.6.2
+      use-callback-ref: 1.3.2(@types/react@18.2.22)(react@18.2.0)
+      use-sidecar: 1.1.2(@types/react@18.2.22)(react@18.2.0)
+    optionalDependencies:
+      '@types/react': 18.2.22
+
   react-router-dom@6.11.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0):
     dependencies:
       '@remix-run/router': 1.6.0