diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 93775b8..d371f07 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,13 +1,25 @@ -name: CI Workflow +name: CI + +# Feedback for pull requests and for every push to the mainline. The +# build-and-test job is the one that matters: it is the same set of checks the +# deploy workflow gates on, so a green tick here means the same commit can ship. +# Lint runs alongside but is advisory (see the job) because the tree carries a +# large backlog of pre-existing findings that a feature branch should not have +# to clear before it can merge. on: push: - branches: [ "master", "main" ] + branches: ["master", "main"] pull_request: - branches: [ "master", "main" ] + branches: ["master", "main"] + +# A newer push to the same branch makes an in-flight run obsolete. +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true jobs: - build-and-check: + build-and-test: runs-on: ubuntu-latest steps: - name: Checkout repository @@ -16,21 +28,45 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '18' - cache: 'npm' + # Node 20, not 18: vitest 4 requires ^20 || ^22 || >=24, so the test + # step below will not even start on the old pin. + node-version: "20" + cache: "npm" - name: Install dependencies run: npm ci - - name: Type check (Shared) - run: npm run build -w @sui-cli-web/shared - - - name: Build (All) + - name: Build (all workspaces) + # This is also the typecheck that counts - tsup and vite fail the build + # on a real type error in shared, server or web. run: npm run build - # Advisory for now: the repo carries a large pre-existing biome backlog - # (formatting, import order, a11y) unrelated to most PRs. Keep the report - # visible in the logs without failing the build until it's cleaned up. - - name: Lint and Format Code + - name: Test (server - unit + e2e) + # No `sui` binary is installed on the runner on purpose: a present-but- + # unconfigured CLI blocks on an interactive prompt, while an absent one + # fails fast. The e2e suite is written to assert only the shape of the + # "sui missing" state, so it is deterministic here. + run: npm test -w sui-cli-web-server + + lint: + runs-on: ubuntu-latest + # Advisory only: the tree has a standing biome backlog, so a red result here + # must not wall off a branch. It stays in the pipeline so new violations are + # visible in the log rather than invisible. + continue-on-error: true + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + + - name: Install dependencies + run: npm ci + + - name: Lint and format check run: npx @biomejs/biome ci . continue-on-error: true diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 2046c24..74039fe 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -5,10 +5,18 @@ name: Deploy to Railway # building with no error anywhere. This workflow uploads the source itself with # a project token, which does not depend on the App at all. # +# The deploy is gated: the `test` job below builds every workspace and runs the +# server suite, and `deploy` declares `needs: test`, so a commit that fails to +# build or fails a test never reaches `railway up`. Keeping the gate as a job in +# THIS workflow - rather than a separate CI workflow chained by workflow_run - +# means it runs on exactly this commit and ref, with no skipped-vs-failed +# ambiguity and no stale-ref race. +# # Requires a repository secret RAILWAY_TOKEN: a *project* token from # https://railway.com/account/tokens scoped to sui-cli-web / production. -# Without it the job skips instead of failing, so a fork or a clone without the -# secret does not report a red build for something it was never meant to do. +# Without it the deploy step skips instead of failing, so a fork or a clone +# without the secret does not report a red build for something it was never +# meant to do. on: push: @@ -22,8 +30,35 @@ concurrency: cancel-in-progress: true jobs: + test: + runs-on: ubuntu-latest + if: ${{ github.repository == 'harrymove-ctrl/sui-cli-web' }} + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + # Must be >= 20 for vitest 4; a bad pin here would skip the very tests + # this gate exists to run and let a broken commit through as "passed". + node-version: "20" + cache: "npm" + + - name: Install dependencies + run: npm ci + + - name: Build (all workspaces) + run: npm run build + + - name: Test (server - unit + e2e) + # No `sui` binary on the runner by design - see ci.yml for why. + run: npm test -w sui-cli-web-server + deploy: runs-on: ubuntu-latest + # The gate: this is what stops a red build or a failing test from shipping. + needs: test if: ${{ github.repository == 'harrymove-ctrl/sui-cli-web' }} steps: - name: Checkout repository diff --git a/.gitignore b/.gitignore index 33f239e..3367974 100644 --- a/.gitignore +++ b/.gitignore @@ -40,7 +40,6 @@ PLAN.md # Keep README.md and packages/server/README.md for npm IMPLEMENTATION_PLAN.md FEATURES.md -docs/ plans/ contracts/community_registry/CONTRACT_INFO.md diff --git a/apps/marketing/.astro/content-modules.mjs b/apps/marketing/.astro/content-modules.mjs index 78d83aa..ac12a5b 100644 --- a/apps/marketing/.astro/content-modules.mjs +++ b/apps/marketing/.astro/content-modules.mjs @@ -1,10 +1,11 @@ export default new Map([ -["src/content/posts/21-day-sui-challenge-day-5.mdx", () => import("astro:content-layer-deferred-module?astro%3Acontent-layer-deferred-module=&fileName=src%2Fcontent%2Fposts%2F21-day-sui-challenge-day-5.mdx&astroContentModuleFlag=true")], ["src/content/posts/21-day-sui-challenge-day-2.mdx", () => import("astro:content-layer-deferred-module?astro%3Acontent-layer-deferred-module=&fileName=src%2Fcontent%2Fposts%2F21-day-sui-challenge-day-2.mdx&astroContentModuleFlag=true")], -["src/content/posts/21-day-sui-challenge-day-4.mdx", () => import("astro:content-layer-deferred-module?astro%3Acontent-layer-deferred-module=&fileName=src%2Fcontent%2Fposts%2F21-day-sui-challenge-day-4.mdx&astroContentModuleFlag=true")], ["src/content/posts/21-day-sui-challenge-day-3.mdx", () => import("astro:content-layer-deferred-module?astro%3Acontent-layer-deferred-module=&fileName=src%2Fcontent%2Fposts%2F21-day-sui-challenge-day-3.mdx&astroContentModuleFlag=true")], +["src/content/posts/21-day-sui-challenge-day-4.mdx", () => import("astro:content-layer-deferred-module?astro%3Acontent-layer-deferred-module=&fileName=src%2Fcontent%2Fposts%2F21-day-sui-challenge-day-4.mdx&astroContentModuleFlag=true")], +["src/content/posts/21-day-sui-challenge-day-5.mdx", () => import("astro:content-layer-deferred-module?astro%3Acontent-layer-deferred-module=&fileName=src%2Fcontent%2Fposts%2F21-day-sui-challenge-day-5.mdx&astroContentModuleFlag=true")], ["src/content/posts/21-day-sui-challenge-is-live.mdx", () => import("astro:content-layer-deferred-module?astro%3Acontent-layer-deferred-module=&fileName=src%2Fcontent%2Fposts%2F21-day-sui-challenge-is-live.mdx&astroContentModuleFlag=true")], ["src/content/posts/getting-started-with-sui-cli-web.mdx", () => import("astro:content-layer-deferred-module?astro%3Acontent-layer-deferred-module=&fileName=src%2Fcontent%2Fposts%2Fgetting-started-with-sui-cli-web.mdx&astroContentModuleFlag=true")], +["src/content/posts/copy-for-ai-and-responsive-objects.mdx", () => import("astro:content-layer-deferred-module?astro%3Acontent-layer-deferred-module=&fileName=src%2Fcontent%2Fposts%2Fcopy-for-ai-and-responsive-objects.mdx&astroContentModuleFlag=true")], ["src/content/posts/introducing-sui-cli-web.mdx", () => import("astro:content-layer-deferred-module?astro%3Acontent-layer-deferred-module=&fileName=src%2Fcontent%2Fposts%2Fintroducing-sui-cli-web.mdx&astroContentModuleFlag=true")]]); \ No newline at end of file diff --git a/apps/marketing/src/components/react/BrandedImage.tsx b/apps/marketing/src/components/react/BrandedImage.tsx index 8ac78ce..e6367f3 100644 --- a/apps/marketing/src/components/react/BrandedImage.tsx +++ b/apps/marketing/src/components/react/BrandedImage.tsx @@ -21,7 +21,7 @@ interface BrandedImageProps { * - SVG displacement filter for interactive "decay" effect on hover * - 3D perspective tilt effect (rotateX/rotateY based on mouse position) * - Smooth mouse-tracking distortion - * - Branded watermark overlay (default: cli.firstmovers.io) + * - Branded watermark overlay (default: sui-cli-web-production.up.railway.app) * - Works with both online URLs and local images */ export function BrandedImage({ @@ -29,7 +29,7 @@ export function BrandedImage({ alt = 'Image', width = 400, height = 300, - watermark = 'cli.firstmovers.io', + watermark = 'sui-cli-web-production.up.railway.app', className = '', enableTilt = true, maxTilt = 15, diff --git a/apps/marketing/src/components/react/TwitterEmbed.tsx b/apps/marketing/src/components/react/TwitterEmbed.tsx index 1fd2bfe..c824541 100644 --- a/apps/marketing/src/components/react/TwitterEmbed.tsx +++ b/apps/marketing/src/components/react/TwitterEmbed.tsx @@ -32,7 +32,7 @@ declare global { export function TwitterEmbed({ tweetUrl, theme = 'dark', - watermark = 'cli.firstmovers.io', + watermark = 'sui-cli-web-production.up.railway.app', maxTilt = 12, }: TwitterEmbedProps) { const containerRef = useRef(null); diff --git a/apps/marketing/src/content/posts/copy-for-ai-and-responsive-objects.mdx b/apps/marketing/src/content/posts/copy-for-ai-and-responsive-objects.mdx new file mode 100644 index 0000000..3c24921 --- /dev/null +++ b/apps/marketing/src/content/posts/copy-for-ai-and-responsive-objects.mdx @@ -0,0 +1,50 @@ +--- +title: "What's New: Copy for AI Everywhere + a Responsive Object Explorer" +description: "Every page in Sui CLI Web can now hand its state to an AI agent, and the object list fits any screen - table on desktop, compact list on mobile." +publishDate: 2026-07-24 +category: wrapup +tags: ["changelog", "sui", "ai", "developer-experience", "responsive", "objects"] +author: "Harry Phan" +sections: + - id: copy-for-ai + title: "Copy for AI, on every page" + - id: responsive-objects + title: "An object explorer that fits your screen" + - id: whats-next + title: "What's next" +--- + +A short wrap-up of what shipped today. Two changes, both about the same thing: getting out of your way when you want to move from *looking* at on-chain state to *doing* something with it. + +## Copy for AI, on every page + +Sui CLI Web already turns raw JSON into something you can read. But the moment you want a second opinion - "what is this object?", "explain this PTB", "is this multi-sig set up right?" - you were back to copying fields by hand into a chat window. + +Not anymore. A **Copy for AI** button now lives in the header of every major page: objects, coins, transfers, the transaction inspector, gas analysis, key management, Move deploy, security tools, and the rest. Each one understands the page you're on and gives you three ready-made options: + +- **Copy prompt** - a natural-language description of exactly what you're looking at, ready to paste. +- **Copy as JSON** - the underlying structured data, capped so a wallet with thousands of objects doesn't blow up your clipboard. +- **Copy page as markdown** - a clean, human-readable rendering. + +There are also one-click **Open in ChatGPT** and **Open in Claude** shortcuts that carry the prompt straight into a fresh chat. + +The context is tailored per page. On the transaction inspector it hands over the full programmable transaction - commands, arguments, gas budget, and the latest dry-run result - so an agent can actually reason about it. On the object detail view it describes the type, owner, and Display metadata. + +One deliberate boundary: on the key-management and address pages, **only public data ever leaves the app** - addresses, public keys, key schemes, multi-sig thresholds. Private keys, mnemonics, and seed phrases are never included in any payload. Everything still runs locally; nothing is sent anywhere until *you* paste it. + +## An object explorer that fits your screen + +The **My Objects** view is a proper data table - sortable, resizable, reorderable columns that stay smooth even at thousands of objects. But a fixed-width table has a bad habit: on a narrow window it either overflows into a horizontal scroll or leaves a big dead gap where short values don't fill their column. + +Two fixes: + +- **Fit-to-width** - the Object ID column now stretches to fill whatever space is available, so the table uses the full panel instead of stranding empty pixels on the right. When there genuinely isn't room, it falls back to a horizontal scroll as before. +- **Collapse on small screens** - below a phone-sized width, the multi-column table swaps for a compact one-line-per-object list: type icon, name, a shortened ID, and version. Tap through to the same detail view. + +And yes - the object list got its own **Copy for AI** button too, so you can hand your whole filtered set of objects to an agent in one click. + +## What's next + +More of the same philosophy: less friction between reading state and acting on it. If there's a page where you wish the AI hand-off were smarter, or a view that still feels cramped, that's exactly the kind of feedback that shapes the next update. + +Happy building. diff --git a/apps/server/package.json b/apps/server/package.json index 0e22890..c9d6f2b 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,6 +1,6 @@ { "name": "sui-cli-web-server", - "version": "1.5.0", + "version": "1.6.0", "description": "Local server that bridges your browser to Sui CLI. Manage addresses, transfer SUI, deploy Move contracts - all from a beautiful web UI. Private keys never leave your machine.", "type": "module", "main": "./dist/index.js", diff --git a/apps/server/src/__tests__/DevstackExecutor.test.ts b/apps/server/src/__tests__/DevstackExecutor.test.ts new file mode 100644 index 0000000..ef293ea --- /dev/null +++ b/apps/server/src/__tests__/DevstackExecutor.test.ts @@ -0,0 +1,102 @@ +/** + * Tests for the devstack CLI executor. + * + * Two things here are load-bearing and both are easy to get quietly wrong. + * First, envelope parsing: the CLI is invoked through a Node that may print + * npm engine warnings to stdout before the JSON, so a naive JSON.parse of the + * whole stream fails on exactly the machines devstack targets. Second, the + * read-only verb allowlist: this executor is reachable from an unauthenticated + * HTTP server, and `up` boots Docker containers. + * + * Kept in its own file because the service suite mocks this module wholesale. + */ + +import { describe, expect, it } from 'vitest'; +import { DevstackExecutor, DevstackNotInstalledError } from '../cli/DevstackExecutor'; + +const executor = DevstackExecutor.getInstance(); + +/** parseEnvelope is private; call it the way the class does internally. */ +const parse = (stdout: string) => + (executor as unknown as { parseEnvelope: (s: string) => unknown }).parseEnvelope(stdout); + +describe('DevstackExecutor', () => { + describe('envelope parsing', () => { + it('reads a clean envelope', () => { + const out = + '{"schemaVersion":1,"ok":true,"command":"status","elapsedMs":3,"data":{"present":false}}'; + + expect(parse(out)).toEqual({ + schemaVersion: 1, + ok: true, + command: 'status', + elapsedMs: 3, + data: { present: false }, + }); + }); + + it('ignores npm engine warnings printed before the envelope', () => { + // Verbatim shape of what npm prints when running devstack under a Node + // its transitive deps dislike - observed on Node 25 with ini@7. + const noisy = [ + 'npm warn EBADENGINE Unsupported engine {', + "npm warn EBADENGINE package: 'ini@7.0.0',", + 'npm warn EBADENGINE }', + '{"schemaVersion":1,"ok":true,"command":"doctor","elapsedMs":317,"data":{"reports":[]}}', + ].join('\n'); + + expect(parse(noisy)).toMatchObject({ ok: true, command: 'doctor' }); + }); + + it('keeps ok:false envelopes rather than treating them as failures to parse', () => { + const failed = '{"schemaVersion":1,"ok":false,"command":"config","elapsedMs":1,"data":null}'; + + expect(parse(failed)).toMatchObject({ ok: false, command: 'config' }); + }); + + it('defaults a missing schemaVersion instead of rejecting the envelope', () => { + expect(parse('{"ok":true,"command":"status","data":{}}')).toMatchObject({ + schemaVersion: 1, + elapsedMs: 0, + }); + }); + + it('rejects output that is not JSON at all', () => { + expect(() => parse('devstack: command not found')).toThrow(/no JSON output/); + }); + + it('rejects malformed JSON', () => { + expect(() => parse('{"ok":true,')).toThrow(/not valid JSON/); + }); + + it('rejects JSON that is not a devstack envelope', () => { + // A different tool answering on the same name would otherwise be trusted. + expect(() => parse('{"hello":"world"}')).toThrow(/not a recognised envelope/); + }); + }); + + describe('verb allowlist', () => { + it('refuses a mutating verb even when it is forced past the type', async () => { + // `up` requires Docker and starts containers; an unauthenticated POST + // must never reach it. + await expect(executor.run('up' as never, [], { cwd: process.cwd() })).rejects.toThrow( + /non-read-only verb/ + ); + }); + + it('checks the allowlist before it checks for the binary', async () => { + // Order matters: if absence were checked first, the refusal would leak + // as "not installed" and look like a fixable environment problem. + await expect( + executor.run('wipe' as never, [], { cwd: '/nonexistent-project-dir' }) + ).rejects.not.toBeInstanceOf(DevstackNotInstalledError); + }); + }); + + describe('binary resolution', () => { + it('reports absence rather than throwing', () => { + expect(executor.resolveBinary('/nonexistent-project-dir')).toBeNull(); + expect(executor.isInstalled('/nonexistent-project-dir')).toBe(false); + }); + }); +}); diff --git a/apps/server/src/__tests__/DevstackService.test.ts b/apps/server/src/__tests__/DevstackService.test.ts new file mode 100644 index 0000000..7e69f15 --- /dev/null +++ b/apps/server/src/__tests__/DevstackService.test.ts @@ -0,0 +1,342 @@ +/** + * Tests for the devstack bridge. + * + * The interesting behaviour is not "does it call the CLI" - it is what happens + * in the three states almost every user is actually in: devstack absent, + * devstack present but blocked (wrong Node, no Docker), and a project that has + * a config but has never been booted. All three have to be ordinary answers, + * not exceptions, or the UI shows an error toast to a user who did nothing + * wrong. The security guard on the caller-supplied path is tested here too, + * because that path arrives from an unauthenticated HTTP request. + */ + +import { homedir } from 'os'; +import { join } from 'path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const run = vi.fn(); +const resolveBinary = vi.fn(); +const version = vi.fn(); + +vi.mock('../cli/DevstackExecutor', async () => { + const actual = + await vi.importActual('../cli/DevstackExecutor'); + return { + ...actual, + DevstackExecutor: { + getInstance: () => ({ run, resolveBinary, version }), + }, + }; +}); + +const getEnvironments = vi.fn(); +const addEnvironment = vi.fn(); +const removeEnvironment = vi.fn(); +const switchEnvironment = vi.fn(); + +vi.mock('../services/EnvironmentService', () => ({ + EnvironmentService: class { + getEnvironments = getEnvironments; + addEnvironment = addEnvironment; + removeEnvironment = removeEnvironment; + switchEnvironment = switchEnvironment; + }, +})); + +const statMock = vi.fn(); +const readFileMock = vi.fn(); + +vi.mock('fs/promises', () => ({ + stat: (...args: unknown[]) => statMock(...args), + readFile: (...args: unknown[]) => readFileMock(...args), +})); + +import { DevstackCommandError } from '../cli/DevstackExecutor'; +import { DevstackService } from '../services/DevstackService'; + +const PROJECT = join(homedir(), 'projects', 'my-dapp'); + +const DEPLOYMENT = { + defaultNetwork: 'localnet', + networks: { + localnet: { + network: 'localnet', + rpc: 'http://rpc.my-dapp.localhost:9000', + chainId: 'E1QW5Dax', + faucet: 'http://faucet.my-dapp.localhost:9123', + graphql: 'http://graphql.my-dapp.localhost:9125', + local: true, + packages: { hello: '0xabc' }, + }, + }, + accounts: { alice: '0xf175', publisher: '0x0e0e' }, +}; + +function mockConfigOk() { + run.mockImplementation(async (verb: string) => { + if (verb === 'config') { + return { + schemaVersion: 1, + ok: true, + command: 'config', + elapsedMs: 0, + data: { + resolvedConfigPath: join(PROJECT, 'devstack.config.ts'), + app: 'my-dapp', + stack: 'main', + stateDir: join(PROJECT, '.devstack'), + network: null, + }, + }; + } + throw new Error(`unexpected verb ${verb}`); + }); +} + +describe('DevstackService', () => { + let service: DevstackService; + + beforeEach(() => { + vi.clearAllMocks(); + service = DevstackService.getInstance(); + service.clearCaches(); + resolveBinary.mockReturnValue({ path: '/usr/local/bin/devstack', source: 'path' }); + version.mockResolvedValue('0.7.2'); + }); + + afterEach(() => { + service.clearCaches(); + }); + + describe('path authorisation', () => { + it('refuses a directory outside the home tree', async () => { + await expect(service.getCapabilities('/etc')).rejects.toThrow(/outside the allowed/); + }); + + it('refuses a traversal that escapes home', async () => { + await expect(service.getCapabilities(join(homedir(), '..', '..', 'etc'))).rejects.toThrow( + /outside the allowed/ + ); + }); + + it('allows a project inside home', async () => { + resolveBinary.mockReturnValue(null); + await expect(service.getCapabilities(PROJECT)).resolves.toMatchObject({ installed: false }); + }); + }); + + describe('capabilities', () => { + it('reports absence as a normal answer, not an error', async () => { + resolveBinary.mockReturnValue(null); + + const caps = await service.getCapabilities(PROJECT); + + expect(caps.installed).toBe(false); + expect(caps.ready).toBe(false); + expect(caps.blockers[0]).toMatch(/not installed/); + // No CLI should have been invoked to learn this. + expect(run).not.toHaveBeenCalled(); + }); + + it('turns a failing required doctor check into a blocker', async () => { + run.mockResolvedValue({ + schemaVersion: 1, + ok: true, + command: 'doctor', + elapsedMs: 12, + data: { + reports: [ + { + name: 'docker', + description: 'Docker daemon reachable', + required: true, + outcome: { status: 'error', detail: 'daemon not running' }, + }, + { + name: 'sui-cli', + description: '`sui` CLI on PATH', + required: false, + outcome: { status: 'error', detail: 'absent' }, + }, + ], + }, + }); + + const caps = await service.getCapabilities(PROJECT); + + expect(caps.installed).toBe(true); + expect(caps.ready).toBe(false); + expect(caps.blockers).toContain('Docker daemon reachable: daemon not running'); + // required:false must not block - a missing sui CLI degrades, not stops. + expect(caps.blockers.some((b) => b.includes('sui` CLI'))).toBe(false); + expect(caps.reports).toHaveLength(2); + }); + + it('is ready when every required check passes', async () => { + run.mockResolvedValue({ + schemaVersion: 1, + ok: true, + command: 'doctor', + elapsedMs: 8, + data: { + reports: [ + { + name: 'docker', + description: 'Docker daemon reachable', + required: true, + outcome: { status: 'ok', detail: 'server 29.2.1' }, + }, + ], + }, + }); + + const caps = await service.getCapabilities(PROJECT); + + // This suite runs on whatever Node the developer has; only assert the + // doctor half, and assert the Node half through blockers below. + const dockerBlocked = caps.blockers.some((b) => b.includes('Docker')); + expect(dockerBlocked).toBe(false); + expect(caps.version).toBe('0.7.2'); + }); + + it('reports a doctor crash as a blocker instead of throwing', async () => { + run.mockRejectedValue(new DevstackCommandError('boom', 78, 'config error')); + + const caps = await service.getCapabilities(PROJECT); + + expect(caps.installed).toBe(true); + expect(caps.ready).toBe(false); + expect(caps.blockers.join(' ')).toMatch(/doctor failed.*config error/); + }); + + it('caches the probe so a polling UI does not spawn a process per second', async () => { + run.mockResolvedValue({ + schemaVersion: 1, + ok: true, + command: 'doctor', + elapsedMs: 1, + data: { reports: [] }, + }); + + await service.getCapabilities(PROJECT); + await service.getCapabilities(PROJECT); + + expect(run).toHaveBeenCalledTimes(1); + }); + }); + + describe('deployment', () => { + it('returns null when the stack has never been booted', async () => { + mockConfigOk(); + statMock.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); + + await expect(service.getDeployment(PROJECT)).resolves.toBeNull(); + }); + + it('maps endpoints, packages and funded accounts', async () => { + mockConfigOk(); + statMock.mockResolvedValue({ mtimeMs: 111 }); + readFileMock.mockResolvedValue(JSON.stringify(DEPLOYMENT)); + + const deployment = await service.getDeployment(PROJECT); + + expect(deployment).not.toBeNull(); + expect(deployment?.stack).toBe('main'); + expect(deployment?.defaultNetwork).toBe('localnet'); + expect(deployment?.networks.localnet.rpc).toBe('http://rpc.my-dapp.localhost:9000'); + expect(deployment?.networks.localnet.faucet).toBe('http://faucet.my-dapp.localhost:9123'); + expect(deployment?.networks.localnet.packages).toEqual({ hello: '0xabc' }); + expect(deployment?.accounts).toEqual({ alice: '0xf175', publisher: '0x0e0e' }); + }); + + it('drops a network with no rpc rather than surfacing an unusable entry', async () => { + mockConfigOk(); + statMock.mockResolvedValue({ mtimeMs: 222 }); + readFileMock.mockResolvedValue( + JSON.stringify({ + networks: { broken: { network: 'broken' }, localnet: DEPLOYMENT.networks.localnet }, + }) + ); + + const deployment = await service.getDeployment(PROJECT); + + expect(Object.keys(deployment?.networks ?? {})).toEqual(['localnet']); + }); + + it('re-reads the file when it changes and not before', async () => { + mockConfigOk(); + statMock.mockResolvedValue({ mtimeMs: 100 }); + readFileMock.mockResolvedValue(JSON.stringify(DEPLOYMENT)); + + await service.getDeployment(PROJECT); + await service.getDeployment(PROJECT); + expect(readFileMock).toHaveBeenCalledTimes(1); + + // A reboot rewrites deployment.json with new ports; mtime is what tells us. + statMock.mockResolvedValue({ mtimeMs: 200 }); + await service.getDeployment(PROJECT); + expect(readFileMock).toHaveBeenCalledTimes(2); + }); + }); + + describe('attach', () => { + beforeEach(() => { + mockConfigOk(); + statMock.mockResolvedValue({ mtimeMs: 1 }); + readFileMock.mockResolvedValue(JSON.stringify(DEPLOYMENT)); + }); + + it('registers the stack rpc as a sui env and switches to it', async () => { + getEnvironments.mockResolvedValue([{ alias: 'testnet', rpc: 'https://x' }]); + + const result = await service.attach(PROJECT); + + expect(addEnvironment).toHaveBeenCalledWith( + 'devstack-main', + 'http://rpc.my-dapp.localhost:9000' + ); + expect(switchEnvironment).toHaveBeenCalledWith('devstack-main'); + expect(result).toMatchObject({ alias: 'devstack-main', network: 'localnet', reused: false }); + }); + + it('reuses an unchanged alias instead of recreating it', async () => { + getEnvironments.mockResolvedValue([ + { alias: 'devstack-main', rpc: 'http://rpc.my-dapp.localhost:9000' }, + ]); + + const result = await service.attach(PROJECT); + + expect(result.reused).toBe(true); + expect(addEnvironment).not.toHaveBeenCalled(); + expect(removeEnvironment).not.toHaveBeenCalled(); + expect(switchEnvironment).toHaveBeenCalledWith('devstack-main'); + }); + + it('replaces an alias left pointing at a dead port by an earlier boot', async () => { + getEnvironments.mockResolvedValue([ + { alias: 'devstack-main', rpc: 'http://rpc.my-dapp.localhost:9001' }, + ]); + + await service.attach(PROJECT); + + expect(removeEnvironment).toHaveBeenCalledWith('devstack-main'); + expect(addEnvironment).toHaveBeenCalledWith( + 'devstack-main', + 'http://rpc.my-dapp.localhost:9000' + ); + }); + + it('refuses a network the stack does not have, naming what it does have', async () => { + getEnvironments.mockResolvedValue([]); + + await expect(service.attach(PROJECT, 'mainnet')).rejects.toThrow(/localnet/); + expect(addEnvironment).not.toHaveBeenCalled(); + }); + + it('refuses to attach a project that was never booted', async () => { + statMock.mockRejectedValue(new Error('ENOENT')); + + await expect(service.attach(PROJECT)).rejects.toThrow(/devstack up/); + }); + }); +}); diff --git a/apps/server/src/__tests__/server.e2e.test.ts b/apps/server/src/__tests__/server.e2e.test.ts new file mode 100644 index 0000000..4d86439 --- /dev/null +++ b/apps/server/src/__tests__/server.e2e.test.ts @@ -0,0 +1,136 @@ +/** + * End-to-end server smoke test. + * + * This boots the *real*, fully-assembled server - the same CORS config, the + * same rate-limit hooks, every one of the ~25 route plugins, in the order + * production registers them - via buildServer() + fastify.inject(). No port is + * bound, so it cannot flake on a busy socket or a ready-poll race, and it runs + * under the vitest the repo already has rather than dragging in a browser. + * + * What it is here to catch: the assembled server failing to boot at all (a bad + * route plugin, a static-serving collision), a route silently unregistered, the + * CORS allowlist regressing (which once turned every hosted asset into a 500), + * and the devstack routes I added answering the wrong way on bad input or on a + * hosted deployment. + * + * What it deliberately does NOT assert: anything that depends on a `sui` binary + * or ~/.sui/sui_config. A CI runner has neither, a dev machine has both, so the + * only stable contract is the *shape* of the answer - status 200 and a boolean + * field - never its value. Asserting `suiInstalled: false` would pass in CI and + * fail on every developer's machine. + */ + +import type { FastifyInstance } from 'fastify'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { buildServer } from '../index'; + +let app: FastifyInstance; + +beforeAll(async () => { + app = await buildServer(); + await app.ready(); +}); + +afterAll(async () => { + await app.close(); +}); + +describe('server boots and serves the liveness contract', () => { + it('answers /api/health with 200 - the string the web app polls for', async () => { + const res = await app.inject({ method: 'GET', url: '/api/health' }); + + expect(res.statusCode).toBe(200); + expect(res.json()).toMatchObject({ status: 'ok' }); + }); + + it('answers /api/status with a boolean install flag, whatever its value', async () => { + const res = await app.inject({ method: 'GET', url: '/api/status' }); + + expect(res.statusCode).toBe(200); + // Value differs by environment (true on a dev box, false in CI); only the + // shape is a contract. + expect(typeof res.json().suiInstalled).toBe('boolean'); + }); + + it('returns a structured JSON 404 for an unknown /api route', async () => { + const res = await app.inject({ method: 'GET', url: '/api/does-not-exist' }); + + expect(res.statusCode).toBe(404); + expect(res.json()).toMatchObject({ error: 'API route not found' }); + }); +}); + +describe('CORS allowlist', () => { + it('echoes a localhost origin rather than rejecting it', async () => { + // The regression that once made every hosted asset a 500 was a dropped + // allowlist entry; a loopback origin must always be allowed back. + const res = await app.inject({ + method: 'GET', + url: '/api/health', + headers: { origin: 'http://localhost:5174' }, + }); + + expect(res.statusCode).toBe(200); + expect(res.headers['access-control-allow-origin']).toBe('http://localhost:5174'); + }); + + it('denies an unknown origin without turning it into a 500', async () => { + // An access decision is not a server fault: the request still succeeds, it + // just gets no allow-origin header back for the browser to honour. + const res = await app.inject({ + method: 'GET', + url: '/api/health', + headers: { origin: 'https://evil.example.com' }, + }); + + expect(res.statusCode).toBe(200); + expect(res.headers['access-control-allow-origin']).toBeUndefined(); + }); +}); + +describe('devstack routes are wired through the real stack', () => { + it('rejects a capabilities request with no dir', async () => { + const res = await app.inject({ method: 'GET', url: '/api/devstack/capabilities' }); + + expect(res.statusCode).toBe(400); + expect(res.json()).toMatchObject({ success: false }); + }); + + it('rejects an attach request with no dir', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/devstack/attach', + payload: {}, + }); + + expect(res.statusCode).toBe(400); + expect(res.json()).toMatchObject({ success: false }); + }); + + it('refuses a path outside the home tree with a structured error, not a crash', async () => { + const res = await app.inject({ + method: 'GET', + url: '/api/devstack/capabilities?dir=/etc', + }); + + // The point is that the process is still answering: a rejected path is a + // handled error, never an unhandled throw that would take the server down. + expect(res.statusCode).toBeGreaterThanOrEqual(400); + expect(res.json().success).toBe(false); + }); + + it('answers 503 on a hosted deployment instead of inspecting a shared container', async () => { + // isHostedDeployment() reads this per-request, so setting it here exercises + // the guard without rebuilding the server. + process.env.RAILWAY_SERVICE_ID = 'test-service'; + try { + const res = await app.inject({ + method: 'GET', + url: '/api/devstack/capabilities?dir=/tmp/whatever', + }); + expect(res.statusCode).toBe(503); + } finally { + delete process.env.RAILWAY_SERVICE_ID; + } + }); +}); diff --git a/apps/server/src/cli/DevstackExecutor.ts b/apps/server/src/cli/DevstackExecutor.ts new file mode 100644 index 0000000..ab4fe54 --- /dev/null +++ b/apps/server/src/cli/DevstackExecutor.ts @@ -0,0 +1,263 @@ +/** + * Devstack CLI executor. + * + * Same shape as SuiCliExecutor - execFile with an argv array, never a shell - + * but with three differences that come from what devstack is: + * + * 1. We never install it. Devstack needs Node >= 24, a Docker daemon and + * @mysten/sui v2 (we are on v1), and its own README says the API "can break + * freely" with "no deprecation cycles". Depending on it would push all of + * that onto every user of `npx sui-cli-web-server`. So the binary is + * resolved from whatever the user already has, and absence is a normal + * answer rather than an error. + * + * 2. Only read-only verbs are allowed. `up`, `apply`, `wipe` and `snapshot + * restore` mutate a developer's environment and, per the CLI's own schema, + * require Docker; exposing them over HTTP would let a page in a browser + * start containers. Reading is enough to be useful. + * + * 3. Output is a JSON envelope, not free text, and exit codes are meaningful. + */ + +import type { DevstackEnvelope } from '@sui-cli-web/shared'; +import { execFile } from 'child_process'; +import { existsSync } from 'fs'; +import { dirname, join, parse } from 'path'; +import { promisify } from 'util'; +import { Platform, PlatformConfig } from '../utils/platform'; + +const execFileAsync = promisify(execFile); + +/** + * Verbs this executor will run. Everything here is documented as + * `lifecycle: offline` or otherwise side-effect free in `devstack schema + * --json`, so none of them boot containers or write to a stack. + */ +const READ_ONLY_VERBS = ['doctor', 'status', 'config', 'schema'] as const; +export type DevstackVerb = (typeof READ_ONLY_VERBS)[number]; + +/** Documented devstack exit codes worth telling the user apart. */ +export const DEVSTACK_EXIT = { + OK: 0, + SUPERVISOR_LIVE: 40, + SNAPSHOT_NOT_FOUND: 41, + USAGE: 64, + CONFIG: 78, +} as const; + +export class DevstackNotInstalledError extends Error { + constructor() { + super('devstack is not installed'); + this.name = 'DevstackNotInstalledError'; + } +} + +export class DevstackCommandError extends Error { + constructor( + message: string, + public readonly exitCode: number | undefined, + public readonly stderr: string + ) { + super(message); + this.name = 'DevstackCommandError'; + } +} + +export interface DevstackRunOptions { + /** Directory to run in - a devstack project root. */ + cwd: string; + timeoutMs?: number; +} + +export interface ResolvedBinary { + path: string; + /** workspace = found in a node_modules/.bin above cwd; path = found on PATH. */ + source: 'workspace' | 'path'; +} + +export class DevstackExecutor { + private static instance: DevstackExecutor; + + public static getInstance(): DevstackExecutor { + if (!DevstackExecutor.instance) { + DevstackExecutor.instance = new DevstackExecutor(); + } + return DevstackExecutor.instance; + } + + private binaryName(): string { + return Platform.isWindows() ? 'devstack.cmd' : 'devstack'; + } + + /** + * Find the binary for a project. A devstack project installs it locally, so + * the workspace copy is checked first and wins: it is the version that + * matches the project's config, whereas a global install may be older. + * Walks up from cwd because pnpm/npm workspaces hoist .bin to the repo root. + */ + public resolveBinary(cwd: string): ResolvedBinary | null { + const name = this.binaryName(); + + let dir = cwd; + const { root } = parse(cwd); + while (true) { + const candidate = join(dir, 'node_modules', '.bin', name); + if (existsSync(candidate)) { + return { path: candidate, source: 'workspace' }; + } + if (dir === root) break; + const parent = dirname(dir); + if (parent === dir) break; + dir = parent; + } + + for (const searchPath of PlatformConfig.getBinarySearchPaths()) { + const candidate = join(searchPath, name); + if (existsSync(candidate)) { + return { path: candidate, source: 'path' }; + } + } + + return null; + } + + public isInstalled(cwd: string): boolean { + return this.resolveBinary(cwd) !== null; + } + + /** + * Run a read-only verb and return its parsed envelope. + * + * Devstack prints the same envelope on failure as on success - `ok: false` + * with a non-zero exit - so a thrown execFile error still carries usable + * JSON on stdout, and we parse it before deciding to throw. + */ + public async run( + verb: DevstackVerb, + args: string[], + options: DevstackRunOptions + ): Promise> { + if (!READ_ONLY_VERBS.includes(verb)) { + // Defence in depth: the type already forbids this, but the verb can + // arrive from a request body that lied about its type. + throw new DevstackCommandError(`refusing to run non-read-only verb: ${verb}`, undefined, ''); + } + + const binary = this.resolveBinary(options.cwd); + if (!binary) { + throw new DevstackNotInstalledError(); + } + + const finalArgs = [verb, ...args, '--json']; + + try { + const { stdout } = await execFileAsync(binary.path, finalArgs, { + cwd: options.cwd, + timeout: options.timeoutMs ?? 20000, + maxBuffer: 8 * 1024 * 1024, + env: process.env, + }); + return this.parseEnvelope(stdout); + } catch (error) { + const err = error as NodeJS.ErrnoException & { + stdout?: string; + stderr?: string; + code?: number | string; + }; + + // A failing check still prints an envelope; prefer it over the raw error. + if (err.stdout) { + try { + return this.parseEnvelope(err.stdout); + } catch { + // Not JSON after all - fall through to the error path. + } + } + + const exitCode = typeof err.code === 'number' ? err.code : undefined; + throw new DevstackCommandError( + `devstack ${verb} failed${exitCode !== undefined ? ` (exit ${exitCode})` : ''}`, + exitCode, + (err.stderr || err.message || '').trim() + ); + } + } + + /** Read the version without going through the envelope - `-V` prints bare text. */ + public async version(cwd: string): Promise { + const binary = this.resolveBinary(cwd); + if (!binary) return null; + try { + const { stdout } = await execFileAsync(binary.path, ['--version'], { + cwd, + timeout: 10000, + env: process.env, + }); + return stdout.trim() || null; + } catch { + return null; + } + } + + private parseEnvelope(stdout: string): DevstackEnvelope { + const trimmed = stdout.trim(); + if (!trimmed.includes('{')) { + throw new DevstackCommandError( + 'devstack produced no JSON output', + undefined, + trimmed.slice(0, 500) + ); + } + + // The CLI can emit warnings before the envelope - npm prints EBADENGINE + // lines that themselves end in `{`, so seeking the *first* brace lands + // inside the warning. Take the whole stream when it parses, otherwise walk + // lines from the bottom and parse the last block that starts one: that is + // the envelope whether it is one line or pretty-printed. + const parsed = this.parseJsonSuffix(trimmed); + if (parsed === undefined) { + throw new DevstackCommandError( + 'devstack output was not valid JSON', + undefined, + trimmed.slice(0, 500) + ); + } + + const envelope = parsed as Partial>; + if (typeof envelope?.ok !== 'boolean' || typeof envelope?.command !== 'string') { + throw new DevstackCommandError( + 'devstack output was not a recognised envelope', + undefined, + trimmed.slice(0, 500) + ); + } + + return { + schemaVersion: envelope.schemaVersion ?? 1, + ok: envelope.ok, + command: envelope.command, + elapsedMs: envelope.elapsedMs ?? 0, + data: envelope.data as T, + }; + } + + /** Parse the largest trailing block of `text` that is valid JSON. */ + private parseJsonSuffix(text: string): unknown | undefined { + try { + return JSON.parse(text); + } catch { + // Fall through to the line scan. + } + + const lines = text.split('\n'); + for (let i = lines.length - 1; i >= 0; i--) { + if (!lines[i].trimStart().startsWith('{')) continue; + try { + return JSON.parse(lines.slice(i).join('\n')); + } catch { + // Not the start of the envelope - keep walking up. + } + } + return undefined; + } +} diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index f0d1d1c..dd3ec53 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -4,10 +4,12 @@ import Fastify from 'fastify'; import fs from 'fs'; import { createRequire } from 'module'; import path from 'path'; +import { pathToFileURL } from 'url'; import { SuiCliExecutor } from './cli/SuiCliExecutor'; import { addressRoutes } from './routes/address'; import { coinRoutes } from './routes/coin'; import { derivedObjectsRoutes } from './routes/derivedObjects'; +import { devstackRoutes } from './routes/devstack'; import { devtoolsRoutes } from './routes/devtools'; import { dynamicFieldsRoutes } from './routes/dynamic-fields'; import { environmentRoutes } from './routes/environment'; @@ -123,7 +125,15 @@ ${c.yellow}${c.bold} ⚡ Continuing with outdated version... Consider updating `); } -async function main() { +/** + * Build the fully-assembled Fastify app - CORS, rate-limit hooks, every route + * plugin, and static serving - and return it *without* listening. + * + * Split out from main() so tests can boot the real, complete server in-process + * with fastify.inject(): no port to bind, no ready-poll to race, and the same + * plugin graph production runs. main() is the only caller that then listens. + */ +export async function buildServer() { const fastify = Fastify({ logger: { level: 'info', @@ -506,6 +516,23 @@ async function main() { { prefix: '/api' } ); + // Devstack bridge - read-only inspection of a local devstack project, plus + // attaching its RPC as a sui client env. Writes go through the sui CLI, so + // the write rate limit is the right one for the attach endpoint. + await fastify.register( + async (instance) => { + instance.addHook('onRequest', async (request, reply) => { + if (request.method === 'GET') { + await readRateLimit(request, reply); + } else { + await writeRateLimit(request, reply); + } + }); + await instance.register(devstackRoutes); + }, + { prefix: '/api' } + ); + // Pay routes - Multi-recipient payments await fastify.register( async (instance) => { @@ -567,10 +594,38 @@ async function main() { if (webDistPath) { console.log(`[Static] Serving UI & Blog static files from: ${webDistPath}`); + // Two registrations, because the two kinds of file want opposite caching. + // + // Everything under /assets carries a content hash in its filename, so a + // given URL can never change contents - it is safe to cache for a year and + // never revalidate. @fastify/static defaults to `public, max-age=0`, which + // made the browser re-check every one of ~40 chunks on every reload. + await fastify.register(fastifyStatic, { + root: path.join(webDistPath, 'assets'), + prefix: '/assets/', + wildcard: false, + decorateReply: false, + maxAge: '1y', + immutable: true, + }); + + // index.html and the rest keep the conservative default: their URLs are + // stable, so caching them is how a deploy goes unnoticed. + // + // `wildcard: true` here (not `false`) is load-bearing: with `false`, + // @fastify/static eagerly pre-registers one exact route per file it finds + // under `root` at plugin-registration time - and `webDistPath` recursively + // includes everything already registered a few lines up under `/assets/`, + // so the server crashed on every cold start ("Method 'HEAD' already + // declared for route '/assets/...'") the moment the web build actually had + // assets in it. `wildcard: true` uses a single catch-all route instead, + // which find-my-way only falls through to when the more specific + // `/assets/*` route (registered above) doesn't match - no collision, and + // still serves index.html/everything else correctly. await fastify.register(fastifyStatic, { root: webDistPath, prefix: '/', - wildcard: false, + wildcard: true, }); // The Astro marketing site is built separately and copied under /blog, so @@ -604,6 +659,12 @@ async function main() { console.log('[Static] Warning: No static web dist folder found in candidates:', candidatePaths); } + return fastify; +} + +async function main() { + const fastify = await buildServer(); + // Start server try { await fastify.listen({ port: PORT, host: HOST }); @@ -656,4 +717,10 @@ async function main() { } } -main(); +// Self-start only when run as the entrypoint (`tsx src/index.ts`, `node +// dist/index.js`). When a test imports this module for buildServer(), argv[1] +// is the test runner, so main() - and its listen() - stays dormant. +const invokedPath = process.argv[1] ? pathToFileURL(process.argv[1]).href : ''; +if (import.meta.url === invokedPath) { + main(); +} diff --git a/apps/server/src/routes/address.ts b/apps/server/src/routes/address.ts index c5f4417..810de65 100644 --- a/apps/server/src/routes/address.ts +++ b/apps/server/src/routes/address.ts @@ -151,14 +151,19 @@ export async function addressRoutes(fastify: FastifyInstance) { }>('/addresses/:address/summary', async (request, reply) => { try { const address = validateAddress(request.params.address); - const [rawObjects, coinGroups] = await Promise.all([ - addressService.getRawObjects(address), + const [summary, coinGroups] = await Promise.all([ + addressService.getObjectSummary(address), coinService.getCoinsGrouped(address), ]); - const packages = packageService.extractPublishedPackages(rawObjects); + // rawObjects is only non-null on the CLI fallback path, where packages + // still have to be extracted from the raw BCS shape. The gRPC path has + // already resolved them. + const packages = summary.rawObjects + ? packageService.extractPublishedPackages(summary.rawObjects) + : summary.packages; return { success: true, - data: { objectCount: rawObjects.length, packages, coinGroups }, + data: { objectCount: summary.objectCount, packages, coinGroups }, }; } catch (error) { return handleError(error, reply); @@ -303,6 +308,23 @@ export async function addressRoutes(fastify: FastifyInstance) { } }); + // Batch-fetch richer per-object attributes (owner, storage rebate, digest, + // has-public-transfer, previous tx, best-effort Display) for the "My Objects" + // expandable rows. Same batching approach as nft-metadata above. + fastify.post<{ + Body: { objectIds: string[] }; + Reply: ApiResponse; + }>('/objects/attributes', async (request, reply) => { + try { + const rawIds = Array.isArray(request.body?.objectIds) ? request.body.objectIds : []; + const objectIds = rawIds.slice(0, 500).map((id) => validateObjectId(id)); + const attributes = await addressService.getObjectsAttributes(objectIds); + return { success: true, data: attributes }; + } catch (error) { + return handleError(error, reply); + } + }); + // Get transaction block by digest fastify.get<{ Params: { digest: string }; diff --git a/apps/server/src/routes/devstack.ts b/apps/server/src/routes/devstack.ts new file mode 100644 index 0000000..33f1fff --- /dev/null +++ b/apps/server/src/routes/devstack.ts @@ -0,0 +1,112 @@ +/** + * Devstack bridge routes. + * + * Read-only by construction: this exposes what a devstack project on *this + * machine* looks like, plus one write that goes through the `sui` CLI's own + * env list. There is deliberately no boot/stop endpoint - `devstack up` + * requires Docker and runs containers, and this server has no authentication, + * so an unauthenticated POST must never be able to start one. + * + * On a hosted deployment every route here answers 503: the filesystem it would + * inspect belongs to a shared container, not to the person asking. + */ + +import type { + ApiResponse, + DevstackAttachResult, + DevstackCapabilities, + DevstackDeployment, +} from '@sui-cli-web/shared'; +import type { FastifyInstance, FastifyReply } from 'fastify'; +import { DevstackService } from '../services/DevstackService'; +import { handleRouteError } from '../utils/errorHandler'; +import { isHostedDeployment } from '../utils/pathSafety'; + +/** Shared guard: hosted instances have no user machine to inspect. */ +function refuseIfHosted(reply: FastifyReply): boolean { + if (!isHostedDeployment()) return false; + reply.status(503); + return true; +} + +export async function devstackRoutes(fastify: FastifyInstance) { + const service = DevstackService.getInstance(); + + /** + * GET /api/devstack/capabilities?dir= + * + * Answers "can this machine use devstack, and why not". `installed: false` + * is the expected answer for most users and is still a 200 - the UI needs to + * render an explanation, not an error toast. + */ + fastify.get<{ + Querystring: { dir?: string }; + Reply: ApiResponse; + }>('/devstack/capabilities', async (request, reply) => { + if (refuseIfHosted(reply)) { + return { success: false, error: 'Devstack inspection is only available on a local server' }; + } + try { + const dir = request.query.dir?.trim(); + if (!dir) { + reply.status(400); + return { success: false, error: 'dir is required' }; + } + return { success: true, data: await service.getCapabilities(dir) }; + } catch (error) { + return handleRouteError(error, reply); + } + }); + + /** + * GET /api/devstack/deployment?dir= + * + * The stack's endpoints, packages and funded accounts. `data: null` means + * the project has a config but has never been booted, which is a normal + * state and not a 404. + */ + fastify.get<{ + Querystring: { dir?: string }; + Reply: ApiResponse; + }>('/devstack/deployment', async (request, reply) => { + if (refuseIfHosted(reply)) { + return { success: false, error: 'Devstack inspection is only available on a local server' }; + } + try { + const dir = request.query.dir?.trim(); + if (!dir) { + reply.status(400); + return { success: false, error: 'dir is required' }; + } + return { success: true, data: await service.getDeployment(dir) }; + } catch (error) { + return handleRouteError(error, reply); + } + }); + + /** + * POST /api/devstack/attach { dir, network? } + * + * Registers the stack's RPC as a `sui client` env and switches to it, so + * every other feature in the product follows without knowing devstack + * exists. Idempotent: re-attaching an unchanged stack reuses the alias. + */ + fastify.post<{ + Body: { dir?: string; network?: string }; + Reply: ApiResponse; + }>('/devstack/attach', async (request, reply) => { + if (refuseIfHosted(reply)) { + return { success: false, error: 'Devstack inspection is only available on a local server' }; + } + try { + const { dir, network } = request.body ?? {}; + if (!dir?.trim()) { + reply.status(400); + return { success: false, error: 'dir is required' }; + } + return { success: true, data: await service.attach(dir.trim(), network) }; + } catch (error) { + return handleRouteError(error, reply); + } + }); +} diff --git a/apps/server/src/routes/package.ts b/apps/server/src/routes/package.ts index 6f1142a..6a1ba3c 100644 --- a/apps/server/src/routes/package.ts +++ b/apps/server/src/routes/package.ts @@ -10,8 +10,25 @@ import { validateTypeArgs, } from '../utils/validation'; import { handleRouteError } from '../utils/errorHandler'; +import { ConfigParser } from '../cli/ConfigParser'; +import { + getPackageModulesViaGrpc, + type PackageModulesViaGrpc, +} from '../utils/suiGrpcClient'; const packageService = new PackageService(); +const configParser = ConfigParser.getInstance(); + +/** Resolve the active environment's fullnode URL for gRPC introspection. */ +async function getActiveRpcUrl(): Promise { + try { + const config = await configParser.getConfig(); + const activeEnv = config?.envs.find((e) => e.alias === config.active_env); + return activeEnv?.rpc || null; + } catch { + return null; + } +} export async function packageRoutes(fastify: FastifyInstance) { // Get user's published packages (via UpgradeCap objects) @@ -39,6 +56,32 @@ export async function packageRoutes(fastify: FastifyInstance) { return handleRouteError(error, reply); } }); + // Explore a package: normalized modules (functions + datatypes) via gRPC. + fastify.get<{ + Params: { id: string }; + Reply: ApiResponse; + }>('/packages/:id/explore', async (request, reply) => { + try { + const packageId = validateObjectId(request.params.id, 'packageId'); + + const rpcUrl = await getActiveRpcUrl(); + if (!rpcUrl) { + reply.status(503); + return { success: false, error: 'No active Sui environment configured' }; + } + + const result = await getPackageModulesViaGrpc(packageId, rpcUrl); + if (!result) { + reply.status(404); + return { success: false, error: 'Package not found' }; + } + + return { success: true, data: result }; + } catch (error) { + return handleRouteError(error, reply); + } + }); + // Publish a Move package fastify.post<{ Body: { diff --git a/apps/server/src/services/AddressService.ts b/apps/server/src/services/AddressService.ts index fe71d00..ebba8f3 100644 --- a/apps/server/src/services/AddressService.ts +++ b/apps/server/src/services/AddressService.ts @@ -1,14 +1,18 @@ import type { GasCoin, SuiAddress } from '@sui-cli-web/shared'; import { ConfigParser } from '../cli/ConfigParser'; import { SuiCliExecutor } from '../cli/SuiCliExecutor'; +import type { PublishedPackageInfo } from '@sui-cli-web/shared'; import type { TransactionBalanceEffect } from '../utils/suiGrpcClient'; import { getBalanceViaGrpc, + getObjectFullViaGrpc, + getObjectsAttributesViaGrpc, getObjectsJsonViaGrpc, getOwnedObjectsViaGrpc, getTransactionBalanceEffectsViaGrpc, getTransactionTimestampsViaGrpc, } from '../utils/suiGrpcClient'; +import { normalizeCliObjectShape } from '../utils/normalizeSuiObject'; import { getAddressBalanceEffects, getObjectVersionHistory, @@ -86,39 +90,6 @@ async function fetchWithTimeout( } } -// Batch RPC helper - executes multiple RPC calls in single batch request -async function batchRpcCall( - rpcUrl: string, - calls: Array<{ method: string; params: unknown[] }> -): Promise { - const batchRequest = calls.map((call, idx) => ({ - jsonrpc: '2.0', - id: idx + 1, - method: call.method, - params: call.params, - })); - - const response = await fetchWithTimeout(rpcUrl, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(batchRequest), - }); - - if (!response.ok) throw new Error(response.statusText); - - const results = (await response.json()) as - | Array<{ id: number; result?: unknown }> - | { result?: unknown }; - - // Results may come back in any order, sort by id - if (Array.isArray(results)) { - results.sort((a, b) => a.id - b.id); - return results.map((r) => r.result); - } - - return [(results as { result?: unknown }).result]; -} - export class AddressService { private executor: SuiCliExecutor; private configParser: ConfigParser; @@ -581,10 +552,71 @@ export class AddressService { return Array.isArray(data) ? data : []; } + /** Object count + published packages in one shot, gRPC-first. + * + * `/addresses/:address/summary` used to call getRawObjects - the same + * `sui client objects` invocation that getObjects() above deliberately + * abandoned, for the reason stated in its own comment: it serializes one slow + * multi-object RPC call that borderline-exceeds the executor timeout on a + * large wallet. The Dashboard fires this route once per wallet, so that cost + * was multiplied by the number of wallets and then serialized again by the + * browser's per-origin connection cap. + * + * gRPC's listOwnedObjects returns the full type string for every object, which + * is all that is needed to *find* the UpgradeCaps. Only those few objects then + * need their decoded fields, in one batched call. + * + * Falls back to the CLI when there is no active RPC endpoint (localnet) or + * gRPC fails, so behaviour is unchanged wherever gRPC is unavailable. + */ + public async getObjectSummary( + address: string + ): Promise<{ objectCount: number; packages: PublishedPackageInfo[]; rawObjects: any[] | null }> { + const rpcUrl = await this.getActiveRpcUrl(); + if (rpcUrl) { + try { + const owned = await getOwnedObjectsViaGrpc(address, rpcUrl); + + // Match on the module::name suffix, not the whole type string: gRPC + // returns system addresses zero-padded (0x0000…0002::package::UpgradeCap) + // where the CLI shape uses the short 0x2 form. + const capIds = owned + .filter((o) => typeof o.type === 'string' && o.type.endsWith('::package::UpgradeCap')) + .map((o) => o.objectId) + .filter((id): id is string => Boolean(id)); + + const packages: PublishedPackageInfo[] = []; + if (capIds.length > 0) { + const decoded = await getObjectsJsonViaGrpc(capIds, rpcUrl); + for (const d of decoded) { + const json = d.json as Record | null; + const fields = (json?.fields ?? json) as Record | undefined; + if (fields?.package) { + packages.push({ + packageId: String(fields.package), + upgradeCapId: d.objectId, + version: String(fields.version ?? '1'), + policy: Number(fields.policy ?? 0), + }); + } + } + } + + return { objectCount: owned.length, packages, rawObjects: null }; + } catch { + // gRPC unavailable for this endpoint - fall through to the CLI. + } + } + + const rawObjects = await this.getRawObjects(address); + return { objectCount: rawObjects.length, packages: [], rawObjects }; + } + public async getObject(objectId: string): Promise { - // RPC first - only path that can return Display-standard metadata (`display.data`), - // which the CLI's `client object` output never includes. Falls back to the CLI when - // there's no active RPC URL (e.g. localnet) or the RPC call fails outright. + // JSON-RPC first - the only path that can return Display-standard metadata + // (`display.data`), which neither gRPC nor the CLI ever includes. Still alive on + // mainnet; Mysten's public testnet/devnet fullnodes now 404 every legacy JSON-RPC + // method, so this is *expected* to fail there and fall through to gRPC below. const rpcUrl = await this.getActiveRpcUrl(); if (rpcUrl) { try { @@ -612,11 +644,27 @@ export class AddressService { const result = (await response.json()) as { result?: { data?: unknown } }; if (result.result?.data) return result.result.data; } + } catch { + // fall through to gRPC + } + + // gRPC - the modern replacement for the JSON-RPC call above, and the only + // reliable path on testnet/devnet today. Same canonical shape (`type`, + // `previousTransaction`, `content.fields`), just without Display metadata - + // `sui.rpc.v2.Object` has no such field (see getObjectFullViaGrpc's own doc). + try { + const full = await getObjectFullViaGrpc(objectId, rpcUrl); + if (full) return full; } catch { // fall through to CLI } } + // CLI - last resort (e.g. localnet with no RPC/gRPC reachable at all). Its JSON + // output uses different field names entirely (`objType`/`prevTx`, and flattens + // some object kinds directly onto `content` instead of nesting under + // `content.fields`) from both paths above - normalize so callers never see a + // third shape depending on which transport happened to answer. const output = await this.executor.execute(['client', 'object', objectId], { json: true }); // Handle "Object does not exist" case - CLI returns plain text instead of JSON @@ -624,7 +672,7 @@ export class AddressService { throw new Error(`Object ${objectId} does not exist`); } - return JSON.parse(output); + return normalizeCliObjectShape(JSON.parse(output)); } /** @@ -965,6 +1013,40 @@ export class AddressService { }; }); } + + /** + * Batch-fetch the richer attributes the "My Objects" expandable rows surface: + * owner, version, digest, previous transaction, storage rebate, whether the + * type is freely transferable, and a best-effort Display (name/image derived + * from the decoded content fields). One gRPC batch call, like getNftMetadata. + */ + public async getObjectsAttributes(objectIds: string[]): Promise { + if (objectIds.length === 0) return []; + + const rpcUrl = await this.getActiveRpcUrl(); + if (!rpcUrl) { + throw new Error('No active RPC URL found'); + } + + const objects = await getObjectsAttributesViaGrpc(objectIds, rpcUrl); + + return objects.map((obj) => { + const fields = (obj.json ?? {}) as Record; + const imageUrl = extractImageUrl(fields); + const name = extractName(fields); + return { + objectId: obj.objectId, + version: obj.version, + digest: obj.digest, + type: obj.type, + owner: obj.owner, + previousTransaction: obj.previousTransaction, + storageRebate: obj.storageRebate, + hasPublicTransfer: obj.hasPublicTransfer, + display: name || imageUrl ? { name, imageUrl } : null, + }; + }); + } } export interface NftMetadata { @@ -976,6 +1058,18 @@ export interface NftMetadata { attributes: { label: string; value: string }[]; } +export interface ObjectAttributes { + objectId: string; + version: string | null; + digest: string | null; + type: string | null; + owner: unknown; + previousTransaction: string | null; + storageRebate: string | null; + hasPublicTransfer: boolean | null; + display: { name: string | null; imageUrl: string | null } | null; +} + const IMAGE_FIELD_KEYS = [ 'image_url', 'image', diff --git a/apps/server/src/services/CoinService.ts b/apps/server/src/services/CoinService.ts index ab88d93..c6c1938 100644 --- a/apps/server/src/services/CoinService.ts +++ b/apps/server/src/services/CoinService.ts @@ -1,15 +1,16 @@ +import { SuiCliExecutor } from '../cli/SuiCliExecutor'; +import { ConfigParser } from '../cli/ConfigParser'; import type { + CoinInfo, CoinGroup, CoinGroupedResponse, - CoinInfo, CoinMetadata, CoinOperationResult, } from '@sui-cli-web/shared'; import { getShortSymbol } from '@sui-cli-web/shared'; -import { ConfigParser } from '../cli/ConfigParser'; -import { SuiCliExecutor } from '../cli/SuiCliExecutor'; import { getKnownToken, getTokenPriority, isVerifiedToken } from '../utils/knownTokens'; import { getAllBalancesViaGrpc, getOwnedCoinsViaGrpc } from '../utils/suiGrpcClient'; +import { getCoinMetadataViaGraphQL } from './GraphQLService'; // Constants const MIST_PER_SUI = 1_000_000_000; @@ -239,7 +240,24 @@ export class CoinService { throw new Error('No active RPC URL configured'); } - return this.fetchCoinsByType(address, coinType, rpcUrl); + // JSON-RPC first, gRPC fallback - same reasoning as `getCoinsGrouped` above: + // `suix_getCoins` 404s on Mysten's public testnet/devnet fullnodes, and unlike + // `getCoinsGrouped` this method previously had no fallback at all, so it threw + // outright on those networks instead of degrading. + try { + return await this.fetchCoinsByType(address, coinType, rpcUrl); + } catch { + const owned = await getOwnedCoinsViaGrpc(address, rpcUrl); + return owned + .filter((c) => c.coinType === coinType) + .map((c) => ({ + coinObjectId: c.coinObjectId, + coinType: c.coinType, + balance: c.balance, + version: c.version, + digest: c.digest, + })); + } } /** @@ -318,7 +336,19 @@ export class CoinService { metadataCache.set(coinType, { data: metadata, timestamp: Date.now() }); return metadata; - } catch (error) { + } catch { + // JSON-RPC unreachable (`suix_getCoinMetadata` 404s on Mysten's public + // testnet/devnet fullnodes, same as every other legacy JSON-RPC method) - + // there's no gRPC equivalent (a coin's on-chain `CoinMetadata` object + // isn't looked up by type over gRPC), so GraphQL is the fallback here + // instead, before giving up to a synthesized default. + const viaGraphQL = await getCoinMetadataViaGraphQL(coinType, rpcUrl).catch(() => null); + if (viaGraphQL) { + const metadata: CoinMetadata = { coinType, ...viaGraphQL }; + metadataCache.set(coinType, { data: metadata, timestamp: Date.now() }); + return metadata; + } + // Return default for SUI on error if (coinType === SUI_COIN_TYPE) { return { @@ -399,7 +429,9 @@ export class CoinService { public async mergeCoins( primaryCoinId: string, coinIdsToMerge: string[], - coinType: string, + // Kept in the signature for call-site symmetry with splitCoin; merging + // needs no type argument because the coins carry it. + _coinType: string, gasBudget: string = '50000000' ): Promise { try { @@ -608,7 +640,9 @@ export class CoinService { public async dryRunMerge( primaryCoinId: string, coinIdsToMerge: string[], - coinType: string, + // Kept in the signature for call-site symmetry with splitCoin; merging + // needs no type argument because the coins carry it. + _coinType: string, gasBudget: string = '50000000' ): Promise { try { diff --git a/apps/server/src/services/DevstackService.ts b/apps/server/src/services/DevstackService.ts new file mode 100644 index 0000000..4070f8e --- /dev/null +++ b/apps/server/src/services/DevstackService.ts @@ -0,0 +1,320 @@ +/** + * Reads a devstack project the user already runs and makes it usable from + * Sui CLI Web. + * + * The unit of truth is `.devstack/stacks//deployment.json`, which + * devstack writes on every `up`/`apply`: RPC, faucet and GraphQL endpoints, + * the chain id, published package ids, and the addresses of the named accounts + * it funded. That file is a stable, documented artifact - `devstack status` + * by contrast reports the *live supervisor's* projection and reads `present: + * false` after a one-shot `apply`, which would look like "no stack" for a + * stack that exists on disk. So the file leads and status only annotates. + */ + +import type { + DevstackCapabilities, + DevstackDeployment, + DevstackDoctorReport, + DevstackNetwork, +} from '@sui-cli-web/shared'; +import { readFile, stat } from 'fs/promises'; +import { join, normalize, resolve } from 'path'; +import { + DevstackCommandError, + DevstackExecutor, + DevstackNotInstalledError, +} from '../cli/DevstackExecutor'; +import { isProjectPathAllowed } from '../utils/pathSafety'; +import { EnvironmentService } from './EnvironmentService'; + +/** Devstack declares engines.node >= 24; this server only requires >= 18. */ +const REQUIRED_NODE_MAJOR = 24; + +interface DoctorData { + reports: Array<{ + name: string; + description: string; + required: boolean; + outcome: { status: string; detail: string }; + }>; +} + +interface ConfigData { + resolvedConfigPath: string; + app: string; + stack: string; + stateDir: string; + network: string | null; +} + +/** Raw shape of deployment.json - only the fields we rely on. */ +interface RawDeployment { + defaultNetwork?: string; + networks?: Record< + string, + { + network?: string; + rpc?: string; + chainId?: string; + faucet?: string; + graphql?: string; + local?: boolean; + packages?: Record; + } + >; + accounts?: Record; +} + +interface CacheEntry { + value: T; + /** Wall-clock expiry for capability probes. */ + expiresAt?: number; + /** mtime of the source file, for deployment reads. */ + mtimeMs?: number; +} + +const CAPABILITIES_TTL_MS = 30_000; + +export class DevstackService { + private static instance: DevstackService; + + private readonly executor = DevstackExecutor.getInstance(); + private readonly environments = new EnvironmentService(); + + /** Keyed by project dir: probing spawns two processes, so it is worth a TTL. */ + private capabilitiesCache = new Map>(); + /** Keyed by deployment.json path and invalidated by mtime, not by clock. */ + private deploymentCache = new Map>(); + + public static getInstance(): DevstackService { + if (!DevstackService.instance) { + DevstackService.instance = new DevstackService(); + } + return DevstackService.instance; + } + + /** + * Resolve and authorise a caller-supplied project directory. + * + * The path arrives from an HTTP request, so it is normalised and then checked + * against the same home-scoped allowlist the filesystem browser uses. Without + * this, `?dir=/` would let a page walk the disk looking for deployment.json. + */ + private safeDir(dir: string): string { + const resolved = normalize(resolve(dir)); + if (!isProjectPathAllowed(resolved)) { + throw new Error(`Path is outside the allowed directories: ${resolved}`); + } + return resolved; + } + + private nodeMeetsRequirement(): boolean { + const major = Number.parseInt(process.versions.node.split('.')[0], 10); + return Number.isFinite(major) && major >= REQUIRED_NODE_MAJOR; + } + + /** + * What can this machine do with devstack right now. + * + * Never throws for the ordinary case of "not installed" - that is the answer + * for almost every user of this product, and it is not a failure. + */ + public async getCapabilities(dir: string): Promise { + const cwd = this.safeDir(dir); + + const cached = this.capabilitiesCache.get(cwd); + if (cached?.expiresAt && cached.expiresAt > Date.now()) { + return cached.value; + } + + const node = { + current: process.versions.node, + meetsDevstackRequirement: this.nodeMeetsRequirement(), + }; + + const binary = this.executor.resolveBinary(cwd); + if (!binary) { + const value: DevstackCapabilities = { + installed: false, + node, + reports: [], + ready: false, + blockers: ['devstack is not installed in this project or on PATH'], + }; + this.cacheCapabilities(cwd, value); + return value; + } + + const blockers: string[] = []; + if (!node.meetsDevstackRequirement) { + blockers.push( + `devstack requires Node >= ${REQUIRED_NODE_MAJOR}, this server runs Node ${node.current}` + ); + } + + let reports: DevstackDoctorReport[] = []; + try { + const envelope = await this.executor.run('doctor', [], { cwd }); + reports = (envelope.data?.reports ?? []).map((r) => ({ + name: r.name, + description: r.description, + required: r.required, + status: r.outcome?.status ?? 'unknown', + detail: r.outcome?.detail ?? '', + })); + for (const report of reports) { + if (report.required && report.status !== 'ok') { + blockers.push(`${report.description}: ${report.detail || report.status}`); + } + } + } catch (error) { + // doctor itself failing is a blocker, not a crash - report it as one. + blockers.push( + error instanceof DevstackCommandError + ? `devstack doctor failed: ${error.stderr || error.message}` + : `devstack doctor failed: ${(error as Error).message}` + ); + } + + const value: DevstackCapabilities = { + installed: true, + binaryPath: binary.path, + source: binary.source, + version: (await this.executor.version(cwd)) ?? undefined, + node, + reports, + ready: blockers.length === 0, + blockers, + }; + this.cacheCapabilities(cwd, value); + return value; + } + + private cacheCapabilities(cwd: string, value: DevstackCapabilities): void { + this.capabilitiesCache.set(cwd, { value, expiresAt: Date.now() + CAPABILITIES_TTL_MS }); + } + + /** + * Where devstack would put this project's state. Asking the CLI beats + * guessing `/.devstack`: app name, stack name and state dir are all + * overridable by config or flags. + */ + private async resolveConfig(cwd: string): Promise { + const envelope = await this.executor.run('config', [], { cwd }); + const data = envelope.data; + if (!data?.stateDir || !data?.stack) { + throw new Error('devstack config did not report a state directory'); + } + return data; + } + + /** + * Read the deployment for a project, or null when the stack has never been + * booted. A missing file is the normal "you have a config but have not run + * `devstack up` yet" state, so it is not an error. + */ + public async getDeployment(dir: string): Promise { + const cwd = this.safeDir(dir); + const config = await this.resolveConfig(cwd); + const deploymentPath = join(config.stateDir, 'stacks', config.stack, 'deployment.json'); + + let mtimeMs: number; + try { + mtimeMs = (await stat(deploymentPath)).mtimeMs; + } catch { + return null; + } + + const cached = this.deploymentCache.get(deploymentPath); + if (cached && cached.mtimeMs === mtimeMs) { + return cached.value; + } + + const raw = JSON.parse(await readFile(deploymentPath, 'utf-8')) as RawDeployment; + + const networks: Record = {}; + for (const [name, net] of Object.entries(raw.networks ?? {})) { + if (!net?.rpc) continue; + networks[name] = { + network: net.network ?? name, + rpc: net.rpc, + chainId: net.chainId, + faucet: net.faucet, + graphql: net.graphql, + local: net.local, + packages: net.packages ?? {}, + }; + } + + const value: DevstackDeployment = { + projectDir: cwd, + app: config.app, + stack: config.stack, + stateDir: config.stateDir, + defaultNetwork: raw.defaultNetwork ?? Object.keys(networks)[0] ?? 'localnet', + networks, + accounts: raw.accounts ?? {}, + }; + + this.deploymentCache.set(deploymentPath, { value, mtimeMs }); + return value; + } + + /** + * Point Sui CLI Web at a devstack network. + * + * This writes through `sui client new-env` rather than holding the endpoint + * in server memory, so every other feature - objects, coins, transfers, the + * MCP server - follows automatically, and the choice survives a restart. The + * alias is derived from the stack so re-attaching is idempotent. + */ + public async attach( + dir: string, + networkName?: string + ): Promise<{ + alias: string; + rpc: string; + network: string; + reused: boolean; + }> { + const deployment = await this.getDeployment(dir); + if (!deployment) { + throw new Error('This project has no booted stack - run `devstack up` first'); + } + + const name = networkName ?? deployment.defaultNetwork; + const network = deployment.networks[name]; + if (!network) { + throw new Error( + `Stack has no network "${name}" (available: ${Object.keys(deployment.networks).join(', ') || 'none'})` + ); + } + + const alias = `devstack-${deployment.stack}`; + const existing = await this.environments.getEnvironments(); + const match = existing.find((e: { alias: string; rpc: string }) => e.alias === alias); + + if (match && match.rpc === network.rpc) { + await this.environments.switchEnvironment(alias); + return { alias, rpc: network.rpc, network: name, reused: true }; + } + + // A stale alias points at a dead port from a previous boot; devstack + // reassigns ports, so replace rather than leave the user on a dead RPC. + if (match) { + await this.environments.removeEnvironment(alias); + } + + await this.environments.addEnvironment(alias, network.rpc); + await this.environments.switchEnvironment(alias); + return { alias, rpc: network.rpc, network: name, reused: false }; + } + + /** Exposed for tests: drop every cached probe. */ + public clearCaches(): void { + this.capabilitiesCache.clear(); + this.deploymentCache.clear(); + } +} + +export { DevstackNotInstalledError }; diff --git a/apps/server/src/services/GraphQLService.ts b/apps/server/src/services/GraphQLService.ts index 2b0514d..7736086 100644 --- a/apps/server/src/services/GraphQLService.ts +++ b/apps/server/src/services/GraphQLService.ts @@ -408,3 +408,40 @@ export async function getObjectVersionHistory( return hops; } + +/** + * GraphQL replacement for the JSON-RPC `suix_getCoinMetadata` call, which + * 404s on Mysten's public testnet/devnet fullnodes along with every other + * legacy JSON-RPC method. There is no gRPC equivalent - `sui.rpc.v2.Object` + * carries a coin's `balance` but nothing about the `CoinMetadata` object + * itself (name/symbol/decimals/icon), so GraphQL is the only live source for + * this on those networks. Returns `null` on any failure (network down, coin + * type has no registered metadata, etc.) so callers can fall back to a + * synthesized default the way a thrown RPC error already triggered. + */ +export async function getCoinMetadataViaGraphQL( + coinType: string, + rpcUrl: string +): Promise<{ decimals: number; name: string; symbol: string; description?: string; iconUrl?: string } | null> { + const graphqlUrl = getGraphqlUrl(rpcUrl); + if (!graphqlUrl) return null; + + const data = await runQuery( + graphqlUrl, + `query CoinMetadata($coinType: String!) { + coinMetadata(coinType: $coinType) { decimals name symbol description iconUrl } + }`, + { coinType } + ); + + const metadata = data?.coinMetadata; + if (!metadata) return null; + + return { + decimals: metadata.decimals, + name: metadata.name, + symbol: metadata.symbol, + description: metadata.description || undefined, + iconUrl: metadata.iconUrl || undefined, + }; +} diff --git a/apps/server/src/services/ParameterHelperService.ts b/apps/server/src/services/ParameterHelperService.ts index e4657c8..4d960a0 100644 --- a/apps/server/src/services/ParameterHelperService.ts +++ b/apps/server/src/services/ParameterHelperService.ts @@ -1,6 +1,8 @@ import { SuiCliExecutor } from '../cli/SuiCliExecutor'; import { ConfigParser } from '../cli/ConfigParser'; import { InspectorService, FunctionInfo, ParameterInfo } from './dev/InspectorService'; +import { getObjectFullViaGrpc, getOwnedObjectsViaGrpc } from '../utils/suiGrpcClient'; +import { normalizeCliObjectShape } from '../utils/normalizeSuiObject'; // Type categories for parameter classification export type ParameterCategory = @@ -581,11 +583,27 @@ export class ParameterHelperService { try { return await this.fetchObjectsViaRpc(address, rpcUrl); } catch { - // Fall back to CLI + // Fall through to gRPC + } + + // gRPC - the modern replacement for the JSON-RPC call above, and the only + // reliable path on Mysten's public testnet/devnet fullnodes (every legacy + // JSON-RPC method 404s there). Already returns a decoded `type` string per + // object, unlike the raw-BCS CLI fallback below. + try { + const owned = await getOwnedObjectsViaGrpc(address, rpcUrl); + if (owned.length > 0) return owned; + } catch { + // Fall through to CLI } } - // CLI fallback + // CLI fallback - last resort (e.g. localnet with no RPC/gRPC reachable at + // all). `sui client objects --json` returns raw, undecoded BCS content + // (`data.Move.type_`/`contents: [byte, ...]`, not a decoded `type` string), + // so `getObjectsByType`'s type-matching filter below effectively can't work + // against this path - acceptable since it should only ever be reached when + // both RPC and gRPC are unavailable. const output = await this.executor.execute(['client', 'objects', address], { json: true }); const data = JSON.parse(output); return Array.isArray(data) ? data : []; @@ -692,14 +710,28 @@ export class ParameterHelperService { const result = await response.json() as { result?: { data?: any } }; return result.result?.data; } + } catch { + // Fall through to gRPC + } + + // gRPC - the modern replacement for the JSON-RPC call above, and the only + // reliable path on testnet/devnet (every legacy JSON-RPC method 404s + // there). Same canonical shape (`type`, `content.fields`), just without + // Display metadata - gRPC has no equivalent field for that. + try { + const full = await getObjectFullViaGrpc(objectId, rpcUrl); + if (full) return full; } catch { // Fall back to CLI } } - // CLI fallback + // CLI fallback - its JSON uses different field names entirely + // (`objType`/`prevTx`) and flattens some object kinds (e.g. `Coin`) + // directly onto `content` instead of nesting under `content.fields` - + // normalize so this never returns a third shape callers have to guess at. const output = await this.executor.execute(['client', 'object', objectId], { json: true }); - return JSON.parse(output); + return normalizeCliObjectShape(JSON.parse(output)); } catch (error) { console.error('[ParameterHelperService] Failed to get object metadata:', error); return null; diff --git a/apps/server/src/utils/normalizeSuiObject.ts b/apps/server/src/utils/normalizeSuiObject.ts new file mode 100644 index 0000000..bc1843e --- /dev/null +++ b/apps/server/src/utils/normalizeSuiObject.ts @@ -0,0 +1,33 @@ +/** + * `sui client object --json` and `sui client objects --json` use + * different field names entirely from the JSON-RPC/gRPC shapes the rest of + * this codebase (and the web client) expect - `objType` instead of `type`, + * `prevTx` instead of `previousTransaction`. It also always flattens a Move + * struct's own fields directly onto `content` rather than nesting them under + * `content.fields` the way RPC/gRPC do - confirmed for both `Coin` + * (`content: { balance, id }`) and `Display` (`content: { fields, id, + * version }`, where "fields" there is just Display's own Move field of that + * name, not a wrapper around the others - `content.fields` never exists as a + * generic "every field lives here" container in the CLI's own JSON). Every + * key on `content` is therefore a genuine Move field and gets wrapped + * verbatim, with no exclusions - there is no reliable way to tell "CLI + * bookkeeping" apart from "a struct that really does have a field named + * `version`" (Display is exactly that case), so keeping everything is the + * only safe choice. + * + * Normalizing once here means the CLI fallback (only ever reached when + * neither JSON-RPC nor gRPC succeeded - e.g. localnet with no reachable + * endpoint) returns the same shape as every other source, instead of + * silently rendering blank fields downstream. + */ +export function normalizeCliObjectShape(raw: Record): Record { + const type = (raw.objType as string | undefined) ?? (raw.type as string | undefined) ?? null; + const previousTransaction = + (raw.prevTx as string | undefined) ?? (raw.previousTransaction as string | undefined) ?? null; + + const rawContent = raw.content as Record | undefined; + const content = + rawContent && typeof rawContent === 'object' ? { dataType: 'moveObject', type, fields: rawContent } : null; + + return { ...raw, type, previousTransaction, content }; +} diff --git a/apps/server/src/utils/pathSafety.ts b/apps/server/src/utils/pathSafety.ts new file mode 100644 index 0000000..64a5b26 --- /dev/null +++ b/apps/server/src/utils/pathSafety.ts @@ -0,0 +1,58 @@ +/** + * Path authorisation for endpoints that take a directory from the caller. + * + * The server has no authentication and the same build is deployed publicly on + * Railway, so any endpoint that accepts a filesystem path is reachable by + * anyone who can reach the process. Confining reads to the user's own home + * directory is what keeps `?dir=/etc` from being a question the server answers. + * + * routes/filesystem.ts carries an older copy of this logic with a wider + * allowlist (it also permits /Users, /home and drive roots, because it is a + * file *browser*). New code should use this one: it is deliberately narrower. + */ + +import { realpathSync } from 'fs'; +import { homedir } from 'os'; +import { normalize, resolve, sep } from 'path'; + +/** + * True when `targetPath` resolves inside the current user's home directory. + * + * Symlinks are resolved first: without realpath, `~/link-to-root/etc/passwd` + * passes a prefix check while pointing outside home. A path that does not + * exist yet is normalised instead, since a caller may legitimately name a + * project directory that has not been created. + */ +export function isProjectPathAllowed(targetPath: string): boolean { + let canonical: string; + try { + canonical = realpathSync(targetPath); + } catch { + canonical = normalize(resolve(targetPath)); + } + + let home: string; + try { + home = realpathSync(homedir()); + } catch { + home = normalize(resolve(homedir())); + } + + if (canonical === home) return true; + // The separator matters: without it, `/home/harry-evil` passes as `/home/harry`. + return canonical.startsWith(home.endsWith(sep) ? home : home + sep); +} + +/** + * True when this process is a hosted deployment rather than a user's own + * machine. Features that inspect the local filesystem or local processes are + * meaningless there and should refuse rather than answer, because "there" is a + * shared container that no user owns. + */ +export function isHostedDeployment(): boolean { + return !!( + process.env.RAILWAY_SERVICE_ID || + process.env.RAILWAY_STATIC_URL || + process.env.RAILWAY_PUBLIC_DOMAIN + ); +} diff --git a/apps/server/src/utils/suiGrpcClient.ts b/apps/server/src/utils/suiGrpcClient.ts index acfda6f..c29f241 100644 --- a/apps/server/src/utils/suiGrpcClient.ts +++ b/apps/server/src/utils/suiGrpcClient.ts @@ -143,18 +143,12 @@ export interface OwnedObjectSummary { function mapProtoOwner(o: any): unknown { if (!o) return null; switch (o.kind) { - case 1: - return { AddressOwner: o.address }; - case 2: - return { ObjectOwner: o.address }; - case 3: - return { Shared: { initial_shared_version: o.version?.toString() } }; - case 4: - return 'Immutable'; - case 5: - return { AddressOwner: o.address }; - default: - return null; + case 1: return { AddressOwner: o.address }; + case 2: return { ObjectOwner: o.address }; + case 3: return { Shared: { initial_shared_version: o.version?.toString() } }; + case 4: return 'Immutable'; + case 5: return { AddressOwner: o.address }; + default: return null; } } @@ -194,10 +188,7 @@ export async function getOwnedObjectsViaGrpc( previousTransaction: o.previousTransaction ?? null, }); } - pageToken = - response.nextPageToken && response.nextPageToken.length > 0 - ? response.nextPageToken - : undefined; + pageToken = response.nextPageToken && response.nextPageToken.length > 0 ? response.nextPageToken : undefined; } while (pageToken); return out; } @@ -273,9 +264,7 @@ export async function getOwnedCoinsViaGrpc( }); } pageToken = - response.nextPageToken && response.nextPageToken.length > 0 - ? response.nextPageToken - : undefined; + response.nextPageToken && response.nextPageToken.length > 0 ? response.nextPageToken : undefined; } while (pageToken); return out; } @@ -288,6 +277,88 @@ export interface ObjectJsonSummary { json: unknown; } +/** Same canonical shape `AddressService.getObject`'s JSON-RPC path already + * returns, so callers don't need to know which transport served a given + * object. `content` is `null` for Move packages (they have no Move struct + * fields; `package` metadata lives on the raw gRPC object we don't surface + * here since nothing currently reads it). */ +export interface FullObjectViaGrpc { + objectId: string; + version: string; + digest: string; + /** `null` for packages - `sui.rpc.v2.Object.object_type` is literally the + * string "package" for those, which is not a real Move type to show. */ + type: string | null; + owner: unknown; + previousTransaction: string | null; + storageRebate: string; + content: { + dataType: 'moveObject'; + type: string; + hasPublicTransfer: boolean; + fields: Record; + } | null; +} + +/** + * Single-object equivalent of `getObjectsJsonViaGrpc`, used as the primary + * data source for "object detail" fetches now that Mysten's public + * testnet/devnet fullnodes 404 every legacy JSON-RPC method (mainnet still + * serves `sui_getObject` - this path works there too, gRPC is just faster). + * Returns `null` for a missing/deleted/pruned object rather than throwing, + * so callers can fall back to the CLI the same way a thrown RPC error would + * have triggered fallback before. + * + * Deliberately does NOT attempt Display-standard metadata (`display.data`) - + * that is a JSON-RPC/GraphQL-only computation with no gRPC equivalent + * (`sui.rpc.v2.Object` has no `display` field at all). Callers that need it + * layer a GraphQL lookup on top of this instead of expecting it here. + */ +export async function getObjectFullViaGrpc( + objectId: string, + rpcUrl: string +): Promise { + const client = getGrpcClient(rpcUrl); + const { response } = await client.ledgerService.getObject({ + objectId, + readMask: { + paths: [ + 'object_id', + 'version', + 'digest', + 'owner', + 'object_type', + 'has_public_transfer', + 'previous_transaction', + 'storage_rebate', + 'json', + ], + }, + }); + const obj = response.object; + if (!obj?.objectId) return null; + + const isPackage = obj.objectType === 'package'; + return { + objectId: obj.objectId, + version: obj.version != null ? obj.version.toString() : '0', + digest: obj.digest ?? '', + type: isPackage ? null : (obj.objectType ?? null), + owner: mapProtoOwner(obj.owner), + previousTransaction: obj.previousTransaction ?? null, + storageRebate: obj.storageRebate != null ? obj.storageRebate.toString() : '0', + content: + isPackage || !obj.objectType + ? null + : { + dataType: 'moveObject', + type: obj.objectType, + hasPublicTransfer: obj.hasPublicTransfer ?? false, + fields: obj.json ? (protobufValueToJs(obj.json) as Record) : {}, + }, + }; +} + /** * Batch-fetch decoded Move struct content (as JSON) plus `previousTransaction` * for a set of object IDs, in concurrent batches. Used to enrich list views @@ -337,6 +408,89 @@ export async function getObjectsJsonViaGrpc( return results.flat(); } +export interface ObjectAttributesViaGrpc { + objectId: string; + version: string | null; + digest: string | null; + /** `null` for Move packages (their object_type is literally "package"). */ + type: string | null; + /** CLI-style owner shape ({ AddressOwner } / { Shared } / "Immutable" / ...). */ + owner: unknown; + previousTransaction: string | null; + /** MIST. */ + storageRebate: string | null; + /** True iff the type has Move's `store` ability (freely transferable). */ + hasPublicTransfer: boolean | null; + /** Decoded Move content fields, kept so callers can derive Display (name/image). */ + json: unknown | null; +} + +/** + * Batch-fetch the richer per-object attributes the "My Objects" expandable rows + * show (owner, storage rebate, digest, has-public-transfer, previous tx) plus the + * decoded content json (for a best-effort Display name/image). One + * `batchGetObjects` per chunk, same shape as {@link getObjectsJsonViaGrpc} but + * with a fuller read mask. Missing/pruned objects are simply omitted. + */ +export async function getObjectsAttributesViaGrpc( + objectIds: string[], + rpcUrl: string +): Promise { + const client = getGrpcClient(rpcUrl); + const unique = [...new Set(objectIds)].filter(Boolean); + + const chunks: string[][] = []; + for (let i = 0; i < unique.length; i += BATCH_SIZE) { + chunks.push(unique.slice(i, i + BATCH_SIZE)); + } + + const results = await Promise.all( + chunks.map(async (chunk) => { + try { + const { response } = await client.ledgerService.batchGetObjects({ + requests: chunk.map((objectId) => ({ objectId })), + readMask: { + paths: [ + 'object_id', + 'object_type', + 'version', + 'digest', + 'owner', + 'previous_transaction', + 'storage_rebate', + 'has_public_transfer', + 'json', + ], + }, + }); + const out: ObjectAttributesViaGrpc[] = []; + for (const result of response.objects) { + if (result.result.oneofKind !== 'object') continue; + const obj = result.result.object; + if (!obj.objectId) continue; + const isPackage = obj.objectType === 'package'; + out.push({ + objectId: obj.objectId, + version: obj.version != null ? obj.version.toString() : null, + digest: obj.digest ?? null, + type: isPackage ? null : (obj.objectType ?? null), + owner: mapProtoOwner(obj.owner), + previousTransaction: obj.previousTransaction ?? null, + storageRebate: obj.storageRebate != null ? obj.storageRebate.toString() : null, + hasPublicTransfer: obj.hasPublicTransfer ?? null, + json: obj.json ? protobufValueToJs(obj.json) : null, + }); + } + return out; + } catch { + return [] as ObjectAttributesViaGrpc[]; + } + }) + ); + + return results.flat(); +} + /** * Batch-fetch just the checkpoint timestamp for a set of transaction digests - * a cheaper sibling of {@link getTransactionBalanceEffectsViaGrpc} for callers @@ -378,6 +532,187 @@ export async function getTransactionTimestampsViaGrpc( return Object.fromEntries(results.flat()); } +// --------------------------------------------------------------------------- +// Move package introspection (Package Explorer) +// +// `MovePackageService.GetPackage` returns a fully normalized package: modules +// with their functions and datatypes (structs/enums), each already decoded from +// bytecode. This is why the Package Explorer prefers it over regex-scraping the +// CLI's `disassembled` output - the shapes below are the proto enums, compared +// by their numeric wire values so we don't have to import the generated enums. +// --------------------------------------------------------------------------- + +const ABILITY_NAMES: Record = { 1: 'copy', 2: 'drop', 3: 'store', 4: 'key' }; +const VISIBILITY_NAMES: Record = { 1: 'private', 2: 'public', 3: 'public(friend)' }; +const DATATYPE_KIND_NAMES: Record = { 1: 'struct', 2: 'enum' }; +// OpenSignatureBody.Type wire values -> primitive Move type name. +const PRIMITIVE_TYPE_NAMES: Record = { + 1: 'address', + 2: 'bool', + 3: 'u8', + 4: 'u16', + 5: 'u32', + 6: 'u64', + 7: 'u128', + 8: 'u256', +}; +const TYPE_VECTOR = 9; +const TYPE_DATATYPE = 10; +const TYPE_PARAMETER = 11; +const REFERENCE_IMMUTABLE = 1; +const REFERENCE_MUTABLE = 2; + +export interface MoveField { + name: string; + type: string; +} + +export interface MoveDatatype { + name: string; + kind: string; // 'struct' | 'enum' + abilities: string[]; + typeParameters: string[]; // e.g. ['T0', 'phantom T1'] + fields: MoveField[]; // struct fields, or flattened for single-variant enums + variants?: { name: string; fields: MoveField[] }[]; +} + +export interface MoveFunction { + name: string; + visibility: string; // 'public' | 'private' | 'public(friend)' + isEntry: boolean; + typeParameters: string[]; + parameters: string[]; + returns: string[]; +} + +export interface MoveModule { + name: string; + functions: MoveFunction[]; + datatypes: MoveDatatype[]; +} + +export interface PackageModulesViaGrpc { + storageId: string; + originalId: string; + version: string; + modules: MoveModule[]; +} + +/** Shorten a fully-qualified `::module::Name` so the leading package + * address is truncated but the module + type name stay readable. */ +function shortenTypeName(typeName: string): string { + const [addr, ...rest] = typeName.split('::'); + if (rest.length === 0) return typeName; + const shortAddr = addr && addr.length > 12 ? `${addr.slice(0, 6)}...${addr.slice(-4)}` : addr; + return [shortAddr, ...rest].join('::'); +} + +/** Render an `OpenSignatureBody` (a field/param type without the reference + * prefix) into a readable Move type string. `typeParams` names the enclosing + * datatype/function's generic parameters so `TYPE_PARAMETER` renders as `T0`. */ +function renderSignatureBody(body: any, typeParams: string[]): string { + if (!body) return 'unknown'; + const type = body.type ?? 0; + if (PRIMITIVE_TYPE_NAMES[type]) return PRIMITIVE_TYPE_NAMES[type]; + if (type === TYPE_VECTOR) { + const inner = body.typeParameterInstantiation?.[0]; + return `vector<${inner ? renderSignatureBody(inner, typeParams) : 'unknown'}>`; + } + if (type === TYPE_DATATYPE) { + const base = shortenTypeName(body.typeName ?? 'unknown'); + const args = body.typeParameterInstantiation ?? []; + if (args.length === 0) return base; + return `${base}<${args.map((a: any) => renderSignatureBody(a, typeParams)).join(', ')}>`; + } + if (type === TYPE_PARAMETER) { + const idx = body.typeParameter ?? 0; + return typeParams[idx] ?? `T${idx}`; + } + return 'unknown'; +} + +/** Render an `OpenSignature` (function param/return: a body plus an optional + * `&`/`&mut` reference prefix). */ +function renderSignature(sig: any, typeParams: string[]): string { + const body = renderSignatureBody(sig?.body, typeParams); + if (sig?.reference === REFERENCE_MUTABLE) return `&mut ${body}`; + if (sig?.reference === REFERENCE_IMMUTABLE) return `&${body}`; + return body; +} + +/** Name a datatype's generic parameters `T0`, `T1`, ... prefixing `phantom` + * where declared, so they read the way they appear in Move source. */ +function renderTypeParameters(typeParameters: any[]): string[] { + return (typeParameters ?? []).map((tp: any, i: number) => + tp?.isPhantom ? `phantom T${i}` : `T${i}` + ); +} + +function mapDatatype(dt: any): MoveDatatype { + const typeParamNames = renderTypeParameters(dt.typeParameters).map((n) => n.replace('phantom ', '')); + const mapFields = (fields: any[]): MoveField[] => + (fields ?? []).map((f: any) => ({ + name: f.name ?? '', + type: renderSignatureBody(f.type, typeParamNames), + })); + return { + name: dt.name ?? '', + kind: DATATYPE_KIND_NAMES[dt.kind ?? 0] ?? 'struct', + abilities: (dt.abilities ?? []).map((a: number) => ABILITY_NAMES[a]).filter(Boolean), + typeParameters: renderTypeParameters(dt.typeParameters), + fields: mapFields(dt.fields), + variants: + dt.variants && dt.variants.length > 0 + ? dt.variants.map((v: any) => ({ name: v.name ?? '', fields: mapFields(v.fields) })) + : undefined, + }; +} + +function mapFunction(fn: any): MoveFunction { + const typeParamNames = renderTypeParameters(fn.typeParameters).map((n) => n.replace('phantom ', '')); + return { + name: fn.name ?? '', + visibility: VISIBILITY_NAMES[fn.visibility ?? 0] ?? 'private', + isEntry: fn.isEntry ?? false, + typeParameters: renderTypeParameters(fn.typeParameters), + parameters: (fn.parameters ?? []).map((p: any) => renderSignature(p, typeParamNames)), + returns: (fn.returns ?? []).map((r: any) => renderSignature(r, typeParamNames)), + }; +} + +/** + * Fetch a package's normalized modules (functions + datatypes) over gRPC. + * Returns `null` when the package is not found, so the route can surface a 404 + * instead of a 500 the same way the object-detail path does. + */ +export async function getPackageModulesViaGrpc( + packageId: string, + rpcUrl: string +): Promise { + const client = getGrpcClient(rpcUrl); + const { response } = await client.movePackageService.getPackage({ packageId }); + const pkg = response.package; + if (!pkg?.storageId) return null; + + const byName = (a: { name: string }, b: { name: string }) => a.name.localeCompare(b.name); + const modules: MoveModule[] = (pkg.modules ?? []) + .map( + (m: any): MoveModule => ({ + name: m.name ?? '', + functions: (m.functions ?? []).map(mapFunction).sort(byName), + datatypes: (m.datatypes ?? []).map(mapDatatype).sort(byName), + }) + ) + .sort(byName); + + return { + storageId: pkg.storageId, + originalId: pkg.originalId ?? pkg.storageId, + version: pkg.version != null ? pkg.version.toString() : '1', + modules, + }; +} + /** `google.protobuf.Value` (protobuf-ts `Value` message) -> plain JS value. */ function protobufValueToJs(value: any): unknown { const kind = value?.kind; diff --git a/apps/web/index.html b/apps/web/index.html index bb9a099..c08c3a1 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -16,7 +16,7 @@ - + diff --git a/apps/web/package.json b/apps/web/package.json index 4cf038a..f342fe7 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -49,6 +49,7 @@ "ogl": "^1.0.11", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-grid-layout": "^1.5.3", "react-hot-toast": "^2.4.1", "react-resizable-panels": "^4.12.1", "react-router-dom": "^7.10.0", @@ -62,6 +63,7 @@ "@types/d3-shape": "^3.1.8", "@types/react": "^18.2.66", "@types/react-dom": "^18.2.22", + "@types/react-grid-layout": "^1.3.6", "@vitejs/plugin-react": "^4.2.1", "autoprefixer": "^10.4.19", "postcss": "^8.4.38", diff --git a/apps/web/public/apple-touch-icon.png b/apps/web/public/apple-touch-icon.png new file mode 100644 index 0000000..915e334 Binary files /dev/null and b/apps/web/public/apple-touch-icon.png differ diff --git a/apps/web/public/favicon-16x16.png b/apps/web/public/favicon-16x16.png new file mode 100644 index 0000000..3e7cbf1 Binary files /dev/null and b/apps/web/public/favicon-16x16.png differ diff --git a/apps/web/public/favicon-32x32.png b/apps/web/public/favicon-32x32.png new file mode 100644 index 0000000..bdf8538 Binary files /dev/null and b/apps/web/public/favicon-32x32.png differ diff --git a/apps/web/public/icon-192x192.png b/apps/web/public/icon-192x192.png new file mode 100644 index 0000000..b88a7b1 Binary files /dev/null and b/apps/web/public/icon-192x192.png differ diff --git a/apps/web/public/icon-512x512.png b/apps/web/public/icon-512x512.png new file mode 100644 index 0000000..673691a Binary files /dev/null and b/apps/web/public/icon-512x512.png differ diff --git a/apps/web/public/manifest.json b/apps/web/public/manifest.json index a20b8ab..dd8a7da 100644 --- a/apps/web/public/manifest.json +++ b/apps/web/public/manifest.json @@ -35,9 +35,9 @@ "purpose": "maskable" }, { - "src": "/sui-logo.svg", - "sizes": "any", - "type": "image/svg+xml" + "src": "/sui-logo.png", + "sizes": "512x512", + "type": "image/png" } ], "screenshots": [], @@ -47,7 +47,7 @@ "short_name": "Setup", "description": "Installation guide", "url": "/setup", - "icons": [{ "src": "/sui-logo.svg", "sizes": "any" }] + "icons": [{ "src": "/sui-logo.png", "sizes": "512x512", "type": "image/png" }] } ] } diff --git a/apps/web/public/sui-logo.png b/apps/web/public/sui-logo.png new file mode 100644 index 0000000..931d752 Binary files /dev/null and b/apps/web/public/sui-logo.png differ diff --git a/apps/web/public/sui-logo.svg b/apps/web/public/sui-logo.svg deleted file mode 100644 index 3d7919e..0000000 --- a/apps/web/public/sui-logo.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index f142f28..7977ad8 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,11 +1,13 @@ import { lazy, Suspense, useEffect } from 'react'; import { Toaster } from 'react-hot-toast'; import { Navigate, Route, Routes, useLocation } from 'react-router-dom'; -import { AppGuard } from './components/guards/AppGuard'; import { useTheme } from './contexts/ThemeContext'; import { trackPageView } from './lib/analytics'; // Lazy load ALL route components for better initial load and code splitting +const AppGuard = lazy(() => + import('./components/guards/AppGuard').then((m) => ({ default: m.AppGuard })) +); const HomePage = lazy(() => import('./components/HomePage').then((m) => ({ default: m.HomePage }))); const SetupPage = lazy(() => import('./components/SetupPage').then((m) => ({ default: m.SetupPage })) @@ -38,12 +40,23 @@ const TransactionBuilder = lazy(() => const DynamicFieldExplorer = lazy(() => import('./components/DynamicFieldExplorer').then((m) => ({ default: m.DynamicFieldExplorer })) ); +// Package Explorer is its own Assets page again - packages are a different kind +// of thing from owned objects, and burying them in an Objects tab made the two +// hard to tell apart. +const PackageExplorer = lazy(() => + import('./components/PackageExplorer').then((m) => ({ default: m.PackageExplorer })) +); +// Anyone still on the old `/app/objects?tab=packages` URL is redirected from +// inside ObjectList, which is where that query param is read. const DevTools = lazy(() => import('./components/DevTools').then((m) => ({ default: m.DevTools }))); const DerivedObjectCalculator = lazy(() => import('./components/DerivedObjectCalculator').then((m) => ({ default: m.DerivedObjectCalculator, })) ); +const DevstackBridge = lazy(() => + import('./components/DevstackBridge').then((m) => ({ default: m.DevstackBridge })) +); const SecurityTools = lazy(() => import('./components/SecurityTools').then((m) => ({ default: m.SecurityTools })) ); @@ -184,6 +197,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> } /> @@ -194,6 +208,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/apps/web/src/api/services/devstack.ts b/apps/web/src/api/services/devstack.ts new file mode 100644 index 0000000..6f7f6d1 --- /dev/null +++ b/apps/web/src/api/services/devstack.ts @@ -0,0 +1,34 @@ +/** + * Devstack bridge - read a local devstack project and point the app at it. + * @module api/services/devstack + */ + +import type { + DevstackAttachResult, + DevstackCapabilities, + DevstackDeployment, +} from '@sui-cli-web/shared'; +import { fetchApi } from '../core/request'; + +/** + * Whether devstack can be used from `dir`, and if not, why. `installed: false` + * is the expected answer for most people and is not an error. + */ +export async function getDevstackCapabilities(dir: string) { + return fetchApi(`/devstack/capabilities?dir=${encodeURIComponent(dir)}`); +} + +/** The stack's endpoints, packages and funded accounts; null when never booted. */ +export async function getDevstackDeployment(dir: string) { + return fetchApi( + `/devstack/deployment?dir=${encodeURIComponent(dir)}` + ); +} + +/** Register the stack's RPC as a `sui client` env and switch to it. */ +export async function attachDevstack(dir: string, network?: string) { + return fetchApi('/devstack/attach', { + method: 'POST', + body: JSON.stringify({ dir, network }), + }); +} diff --git a/apps/web/src/api/services/index.ts b/apps/web/src/api/services/index.ts index 17d6feb..58d9fd1 100644 --- a/apps/web/src/api/services/index.ts +++ b/apps/web/src/api/services/index.ts @@ -9,6 +9,7 @@ export * from './derivedObjects'; export * from './devtools'; export * from './environments'; export * from './faucet'; +export * from './devstack'; export * from './filesystem'; export * from './inspector'; export * from './keytool'; diff --git a/apps/web/src/api/services/objects.ts b/apps/web/src/api/services/objects.ts index 0d56ee2..217dd19 100644 --- a/apps/web/src/api/services/objects.ts +++ b/apps/web/src/api/services/objects.ts @@ -13,17 +13,84 @@ export async function getTransactionBlock(digest: string) { return fetchApi>(`/tx/${digest}`); } -export async function getDynamicFields(objectId: string, cursor?: string, limit?: number) { +export interface DynamicFieldItem { + kind?: 'FIELD' | 'OBJECT' | string; + fieldId?: string; + name?: + | { + type?: string; + bcs?: string; + value?: any; + json?: any; + } + | any; + value?: + | { + type?: string; + json?: any; + } + | any; + valueType?: string; + fieldObject?: { + objectId?: string; + version?: string; + digest?: string; + owner?: any; + objectType?: string; + hasPublicTransfer?: boolean; + contents?: any; + previousTransaction?: string; + storageRebate?: string; + bcs?: string; + json?: any; + }; + // Fallbacks for legacy/alternative payload keys + objectId?: string; + objectType?: string; + type?: string; + version?: string; + digest?: string; + bcsName?: string; +} + +export interface DynamicFieldsResult { + objectId: string; + data: DynamicFieldItem[]; + nextCursor: string | null; + hasNextPage: boolean; +} + +export async function getDynamicFields( + objectId: string, + cursor?: string, + limit?: number +): Promise { const params = new URLSearchParams(); if (cursor) params.set('cursor', cursor); if (limit) params.set('limit', String(limit)); const query = params.toString() ? `?${params.toString()}` : ''; - return fetchApi<{ - objectId: string; - data: any[]; - nextCursor: string | null; - hasNextPage: boolean; - }>(`/dynamic-fields/${objectId}${query}`); + const raw = await fetchApi(`/dynamic-fields/${objectId}${query}`); + + // Safely extract field array regardless of client/server wrapping shape + let list: DynamicFieldItem[] = []; + if (Array.isArray(raw?.data)) { + list = raw.data; + } else if (Array.isArray(raw?.data?.dynamicFields)) { + list = raw.data.dynamicFields; + } else if (Array.isArray(raw?.data?.nodes)) { + list = raw.data.nodes; + } else if (Array.isArray(raw?.dynamicFields)) { + list = raw.dynamicFields; + } else if (Array.isArray(raw)) { + list = raw; + } + + return { + objectId: raw?.objectId || objectId, + data: list, + nextCursor: raw?.nextCursor || raw?.data?.nextCursor || null, + hasNextPage: Boolean(raw?.hasNextPage || raw?.data?.hasNextPage || false), + }; } export async function getObjectMetadata(objectId: string) { @@ -84,3 +151,26 @@ export async function getNftMetadata(objectIds: string[]) { body: JSON.stringify({ objectIds }), }); } + +export interface ObjectAttributes { + objectId: string; + version: string | null; + digest: string | null; + type: string | null; + /** CLI-style owner shape: { AddressOwner } / { ObjectOwner } / { Shared } / "Immutable". */ + owner: unknown; + previousTransaction: string | null; + /** MIST. */ + storageRebate: string | null; + /** True iff the type is freely transferable (has Move's `store` ability). */ + hasPublicTransfer: boolean | null; + display: { name: string | null; imageUrl: string | null } | null; +} + +/** Batch-fetch richer per-object attributes for the "My Objects" expandable rows. */ +export async function getObjectsAttributes(objectIds: string[]) { + return fetchApi('/objects/attributes', { + method: 'POST', + body: JSON.stringify({ objectIds }), + }); +} diff --git a/apps/web/src/api/services/packages.ts b/apps/web/src/api/services/packages.ts index e3f9f58..2e8500e 100644 --- a/apps/web/src/api/services/packages.ts +++ b/apps/web/src/api/services/packages.ts @@ -13,6 +13,47 @@ export interface PublishedPackageInfo { policy: number; } +export interface MoveField { + name: string; + type: string; +} + +export interface MoveDatatype { + name: string; + kind: string; // 'struct' | 'enum' + abilities: string[]; + typeParameters: string[]; + fields: MoveField[]; + variants?: { name: string; fields: MoveField[] }[]; +} + +export interface MoveFunction { + name: string; + visibility: string; // 'public' | 'private' | 'public(friend)' + isEntry: boolean; + typeParameters: string[]; + parameters: string[]; + returns: string[]; +} + +export interface MoveModule { + name: string; + functions: MoveFunction[]; + datatypes: MoveDatatype[]; +} + +export interface PackageModules { + storageId: string; + originalId: string; + version: string; + modules: MoveModule[]; +} + +/** Fetch a package's normalized modules (functions + datatypes) for the explorer. */ +export async function explorePackage(packageId: string) { + return fetchApi(`/packages/${encodeURIComponent(packageId)}/explore`); +} + // API functions export async function getPackageSummary(packageId: string) { return fetchApi>(`/packages/${packageId}/summary`); diff --git a/apps/web/src/components/AddressList/index.tsx b/apps/web/src/components/AddressList/index.tsx index 9e9baff..59782a1 100644 --- a/apps/web/src/components/AddressList/index.tsx +++ b/apps/web/src/components/AddressList/index.tsx @@ -3,10 +3,11 @@ import { Archive, Copy, ExternalLink, KeyRound, Package, Plus, Trash2 } from 'lu import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import toast from 'react-hot-toast'; import { useNavigate, useSearchParams } from 'react-router-dom'; +import { UserGlassIcon } from '@/components/icons/UserGlassIcon'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { ConfirmDialog } from '@/components/ui/confirm-dialog'; -import { UserGlassIcon } from '@/components/icons/UserGlassIcon'; +import { CopyForAiMenu } from '@/components/ui/copy-for-ai'; import { Tooltip } from '@/components/ui/tooltip'; import { buildExplorerUrl, @@ -186,11 +187,8 @@ const AddressCard = memo( {/* Balance */}
-
- {formatBalance(addr.balance)}{' '} - SUI +
+ {formatBalance(addr.balance)} SUI
@@ -327,7 +325,9 @@ export function AddressList() { ); const [deleteError, setDeleteError] = useState(null); const [isDeleting, setIsDeleting] = useState(false); - const [exportTarget, setExportTarget] = useState<{ address: string; alias?: string } | null>(null); + const [exportTarget, setExportTarget] = useState<{ address: string; alias?: string } | null>( + null + ); // Initial load useEffect(() => { @@ -399,6 +399,50 @@ export function AddressList() { }); }, [addresses, debouncedSearchQuery, metadata]); + // Copy-for-AI export. Public only: address, alias, active flag, balance - + // never private keys, recovery phrases, or local notes/labels. + const copyToClipboard = useCallback((text: string, label: string) => { + navigator.clipboard.writeText(text); + toast.success(`${label} copied`); + }, []); + + const aiExport = useMemo(() => { + const publicAddresses = sortedAddresses.map((a) => ({ + address: a.address, + alias: a.alias ?? null, + active: a.isActive, + balanceSui: a.balance ?? null, + })); + const active = publicAddresses.find((a) => a.active); + const prompt = [ + `I'm working with a Sui wallet that has ${publicAddresses.length} address${publicAddresses.length !== 1 ? 'es' : ''} on the ${currentNetwork} network.`, + active + ? `The active address is ${active.address}${active.alias ? ` (alias "${active.alias}")` : ''}.` + : '', + 'Addresses:', + ...publicAddresses.map( + (a) => + `- ${a.address}${a.alias ? ` (${a.alias})` : ''}${a.active ? ' [active]' : ''} - ${a.balanceSui ?? '0'} SUI` + ), + '', + 'Help me understand and work with these addresses.', + ] + .filter(Boolean) + .join('\n'); + const json = JSON.stringify({ network: currentNetwork, addresses: publicAddresses }, null, 2); + const markdown = [ + `# Sui Wallet Addresses (${currentNetwork})`, + '', + '| Address | Alias | Active | Balance (SUI) |', + '| --- | --- | --- | --- |', + ...publicAddresses.map( + (a) => + `| \`${a.address}\` | ${a.alias ?? '-'} | ${a.active ? 'yes' : 'no'} | ${a.balanceSui ?? '0'} |` + ), + ].join('\n'); + return { prompt, json, markdown }; + }, [sortedAddresses, currentNetwork]); + // Memoize handlers to prevent re-renders const handleSwitch = useCallback( async (address: string) => { @@ -605,6 +649,14 @@ export function AddressList() { {isSearching && searching...}
+ {sortedAddresses.length > 0 && ( + + )}
- - {coinData.totalCoinTypes} type{coinData.totalCoinTypes !== 1 ? 's' : ''} - +
+ + {coinData.totalCoinTypes} type{coinData.totalCoinTypes !== 1 ? 's' : ''} + + {coinData && ( + + )} +
{/* Coin Groups */} diff --git a/apps/web/src/components/CoinMerge/index.tsx b/apps/web/src/components/CoinMerge/index.tsx index 5edc4a0..87f9373 100644 --- a/apps/web/src/components/CoinMerge/index.tsx +++ b/apps/web/src/components/CoinMerge/index.tsx @@ -1,3 +1,4 @@ +import type { CoinInfo, CoinMetadata, CoinOperationResult } from '@sui-cli-web/shared'; import { AnimatePresence, motion } from 'framer-motion'; import { AlertCircle, @@ -15,9 +16,9 @@ import { useEffect, useMemo, useState } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; import * as api from '@/api/client'; import { Button } from '@/components/ui/button'; +import { CopyForAiMenu } from '@/components/ui/copy-for-ai'; import { showErrorToast, showSuccessToast } from '@/lib/toast'; import { useAppStore } from '@/stores/useAppStore'; -import type { CoinInfo, CoinMetadata, CoinOperationResult } from '@sui-cli-web/shared'; // Format balance with proper decimals function formatBalance(balance: string, decimals: number): string { @@ -213,10 +214,64 @@ export function CoinMerge() { const isAnyLoading = isLoading || isEstimating || isMerging; + const copyToClipboard = (text: string, label: string) => { + navigator.clipboard.writeText(text); + showSuccessToast({ message: `${label} copied` }); + }; + + const aiJson = JSON.stringify( + { + operation: 'merge', + coinType: coinTypeParam, + symbol, + decimals, + totalCoins: coins.length, + primaryCoin: primaryCoin + ? { + coinObjectId: primaryCoin.coinObjectId, + balance: formatBalance(primaryCoin.balance, decimals), + } + : null, + selectedCoins: selectedCoins.map((c) => ({ + coinObjectId: c.coinObjectId, + balance: formatBalance(c.balance, decimals), + })), + selectedBalance: formatBalance(selectedBalance.toString(), decimals), + totalAfterMerge: formatBalance(totalAfterMerge.toString(), decimals), + }, + null, + 2 + ); + + const aiMarkdown = [ + '# Sui coin merge', + '', + `- **Coin type:** ${coinTypeParam}`, + `- **Symbol:** ${symbol}`, + primaryCoin + ? `- **Primary coin:** ${primaryCoin.coinObjectId} (${formatBalance(primaryCoin.balance, decimals)} ${symbol})` + : '', + `- **Coins selected to merge:** ${selectedCoinIds.size} of ${coins.length}`, + `- **Selected balance:** ${formatBalance(selectedBalance.toString(), decimals)} ${symbol}`, + `- **Balance after merge:** ${formatBalance(totalAfterMerge.toString(), decimals)} ${symbol}`, + `- **Coins reduced:** ${coins.length} → ${coins.length - selectedCoinIds.size}`, + '', + '## Coins to merge', + ...selectedCoins.map( + (c, i) => `${i + 1}. ${c.coinObjectId} — ${formatBalance(c.balance, decimals)} ${symbol}` + ), + ] + .filter(Boolean) + .join('\n'); + + const aiPrompt = `I'm merging ${symbol} coins on Sui.\n\n${aiMarkdown}\n\nConfirm this merge makes sense (consolidating dust into the primary coin) and flag anything worth attention.`; + if (!coinTypeParam) { return (
-

Invalid parameters. Please select a coin type first.

+

+ Invalid parameters. Please select a coin type first. +

@@ -240,7 +295,17 @@ export function CoinMerge() {

Merge Coins

- {symbol} +
+ {symbol} + {coins.length > 0 && ( + + )} +
{isLoading ? ( @@ -265,7 +330,9 @@ export function CoinMerge() { animate={{ opacity: 1, y: 0 }} className="bg-card border border-border rounded-xl p-4" > -
Primary coin (receives merged balance)
+
+ Primary coin (receives merged balance) +
@@ -482,7 +550,9 @@ export function CoinMerge() { {mergeResult.success ? ( <> - Merge successful + + Merge successful + ) : ( <> @@ -548,7 +618,11 @@ export function CoinMerge() { {/* Back to Coins */} - diff --git a/apps/web/src/components/CoinSplit/index.tsx b/apps/web/src/components/CoinSplit/index.tsx index 0c7288d..d820678 100644 --- a/apps/web/src/components/CoinSplit/index.tsx +++ b/apps/web/src/components/CoinSplit/index.tsx @@ -1,3 +1,4 @@ +import type { CoinInfo, CoinMetadata, CoinOperationResult } from '@sui-cli-web/shared'; import { AnimatePresence, motion } from 'framer-motion'; import { AlertCircle, @@ -16,9 +17,9 @@ import { useEffect, useMemo, useState } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; import * as api from '@/api/client'; import { Button } from '@/components/ui/button'; +import { CopyForAiMenu } from '@/components/ui/copy-for-ai'; import { showErrorToast, showSuccessToast } from '@/lib/toast'; import { useAppStore } from '@/stores/useAppStore'; -import type { CoinInfo, CoinMetadata, CoinOperationResult } from '@sui-cli-web/shared'; // Format balance with proper decimals function formatBalance(balance: string, decimals: number): string { @@ -157,9 +158,7 @@ export function CoinSplit() { if (value && !/^\d*\.?\d*$/.test(value)) return; const rawValue = value ? toRawAmount(value, decimals) : ''; - setSplitAmounts( - splitAmounts.map((a) => (a.id === id ? { ...a, value, rawValue } : a)) - ); + setSplitAmounts(splitAmounts.map((a) => (a.id === id ? { ...a, value, rawValue } : a))); setShowPreview(false); setDryRunResult(null); }; @@ -258,6 +257,52 @@ export function CoinSplit() { const isAnyLoading = isLoading || isEstimating || isSplitting; + const copyToClipboard = (text: string, label: string) => { + navigator.clipboard.writeText(text); + showSuccessToast({ message: `${label} copied` }); + }; + + const aiJson = JSON.stringify( + { + operation: 'split', + coinType: coinTypeParam, + symbol, + decimals, + sourceCoin: coin + ? { + coinObjectId: coin.coinObjectId, + version: coin.version, + balance: formatBalance(coin.balance, decimals), + } + : null, + splitAmounts: splitAmounts + .filter((a) => a.rawValue) + .map((a) => ({ value: a.value, raw: a.rawValue })), + splitTotal: formatBalance(totalSplitAmount.toString(), decimals), + remaining: formatBalance(remainingBalance.toString(), decimals), + }, + null, + 2 + ); + + const aiMarkdown = [ + '# Sui coin split', + '', + `- **Coin type:** ${coinTypeParam}`, + `- **Symbol:** ${symbol}`, + coin ? `- **Source coin:** ${coin.coinObjectId}` : '', + coin ? `- **Source balance:** ${formatBalance(coin.balance, decimals)} ${symbol}` : '', + `- **Split total:** ${formatBalance(totalSplitAmount.toString(), decimals)} ${symbol}`, + `- **Remaining:** ${formatBalance(remainingBalance.toString(), decimals)} ${symbol}`, + '', + '## Split into', + ...splitAmounts.filter((a) => a.value).map((a, i) => `${i + 1}. ${a.value} ${symbol}`), + ] + .filter(Boolean) + .join('\n'); + + const aiPrompt = `I'm splitting a ${symbol} coin on Sui.\n\n${aiMarkdown}\n\nCheck that these split amounts make sense, don't exceed the source balance, and leave enough behind for gas.`; + if (!coinIdParam || !coinTypeParam) { return (
@@ -285,7 +330,17 @@ export function CoinSplit() {

Split Coin

- {symbol} +
+ {symbol} + {coin && ( + + )} +
{isLoading ? ( @@ -293,9 +348,7 @@ export function CoinSplit() {
) : !coin ? ( -
- Coin not found -
+
Coin not found
) : ( <> {/* Source Coin Card */} @@ -354,7 +407,12 @@ export function CoinSplit() { { key: '5equal', label: '5 Equal' }, { key: '10equal', label: '10 Equal' }, ].map(({ key, label }) => ( - ))} @@ -501,7 +559,9 @@ export function CoinSplit() { {splitResult.success ? ( <> - Split successful + + Split successful + ) : ( <> @@ -579,7 +639,11 @@ export function CoinSplit() { {/* Back to Coins */} - diff --git a/apps/web/src/components/CoinTransfer/index.tsx b/apps/web/src/components/CoinTransfer/index.tsx index 3c24dc1..8e43394 100644 --- a/apps/web/src/components/CoinTransfer/index.tsx +++ b/apps/web/src/components/CoinTransfer/index.tsx @@ -1,13 +1,14 @@ -import { useState, useEffect, useRef } from 'react'; -import { useSearchParams, useNavigate } from 'react-router-dom'; -import { useAppStore } from '@/stores/useAppStore'; -import { getApiBaseUrl } from '@/api/client'; -import { Spinner } from '../shared/Spinner'; +import { AlertCircle, ArrowLeft, CheckCircle, ChevronDown, Send, Wallet } from 'lucide-react'; +import { useEffect, useRef, useState } from 'react'; import toast from 'react-hot-toast'; -import { ArrowLeft, Send, AlertCircle, CheckCircle, ChevronDown, Wallet } from 'lucide-react'; +import { useNavigate, useSearchParams } from 'react-router-dom'; +import { getApiBaseUrl } from '@/api/client'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; +import { CopyForAiMenu } from '@/components/ui/copy-for-ai'; +import { useAppStore } from '@/stores/useAppStore'; +import { Spinner } from '../shared/Spinner'; interface CoinMetadata { coinType: string; @@ -231,6 +232,47 @@ export function CoinTransfer() { } }; + const copyToClipboard = (text: string, label: string) => { + navigator.clipboard.writeText(text); + toast.success(`${label} copied`); + }; + + const decimals = metadata?.decimals ?? 9; + const symbol = metadata?.symbol ?? 'Coin'; + + const aiJson = JSON.stringify( + { + operation: 'transfer', + coinType: coinTypeParam, + symbol, + coinObjectId: coinIdParam, + balance: formatBalance(coinBalance, decimals), + from: activeAddress?.address ?? null, + to: toAddress || null, + amount: amount || null, + estimatedGas: estimatedGas || null, + }, + null, + 2 + ); + + const aiMarkdown = [ + '# Sui coin transfer', + '', + `- **Coin type:** ${coinTypeParam}`, + `- **Symbol:** ${symbol}`, + `- **Coin object:** ${coinIdParam}`, + `- **Balance:** ${formatBalance(coinBalance, decimals)} ${symbol}`, + `- **From:** ${activeAddress?.alias || activeAddress?.address || 'not connected'}`, + `- **To:** ${toAddress || '(not set)'}`, + `- **Amount:** ${amount ? `${amount} ${symbol}` : '(not set)'}`, + estimatedGas ? `- **Estimated gas:** ${estimatedGas} SUI` : '', + ] + .filter(Boolean) + .join('\n'); + + const aiPrompt = `I'm transferring ${symbol} on Sui.\n\n${aiMarkdown}\n\nSanity-check the recipient address format and that the amount doesn't exceed the coin balance (leaving room for gas).`; + if (!coinIdParam || !coinTypeParam) { return (
@@ -253,19 +295,22 @@ export function CoinTransfer() { return (
{/* Header */} -
- -
-

Transfer {metadata?.symbol || 'Coin'}

-

Send to any address

+
+
+ +
+

Transfer {metadata?.symbol || 'Coin'}

+

Send to any address

+
+
{/* Coin Info */} diff --git a/apps/web/src/components/Dashboard/ActivityHeatmap.tsx b/apps/web/src/components/Dashboard/ActivityHeatmap.tsx deleted file mode 100644 index ad8523d..0000000 --- a/apps/web/src/components/Dashboard/ActivityHeatmap.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import { selectHistory, useMoveDevStore } from '@/components/MoveDeploy/hooks/state/useMoveDevState'; -import { cn } from '@/lib/utils'; - -const WEEKS = 12; -const DAYS = 7; - -// 0 = empty, 1-4 = increasing intensity, thresholds scale to the busiest day seen -// so a handful of local runs still reads as visibly "some activity." -const INTENSITY_OPACITY = [0, 0.25, 0.45, 0.65, 0.9]; - -function intensityFor(count: number, max: number): number { - if (count === 0) return 0; - if (max <= 1) return count > 0 ? 4 : 0; - const ratio = count / max; - if (ratio <= 0.25) return 1; - if (ratio <= 0.5) return 2; - if (ratio <= 0.75) return 3; - return 4; -} - -interface ActivityHeatmapProps { - /** Unix ms timestamps of real on-chain transactions for the active wallet (already deduped - * of the synthetic "current balance" anchor point by the caller). */ - onChainTimestamps?: number[]; -} - -export function ActivityHeatmap({ onChainTimestamps = [] }: ActivityHeatmapProps) { - const history = useMoveDevStore(selectHistory); - - const today = new Date(); - today.setHours(0, 0, 0, 0); - const totalDays = WEEKS * DAYS; - const startDate = new Date(today); - // Align the grid so the last column ends on today, Sunday-first rows. - startDate.setDate(today.getDate() - totalDays + 1 + today.getDay()); - - const localByDate = new Map(); - for (const entry of history) { - const key = new Date(entry.timestamp).toISOString().slice(0, 10); - localByDate.set(key, (localByDate.get(key) ?? 0) + 1); - } - - const onChainByDate = new Map(); - for (const ts of onChainTimestamps) { - const key = new Date(ts).toISOString().slice(0, 10); - onChainByDate.set(key, (onChainByDate.get(key) ?? 0) + 1); - } - - const totalsByDate = new Map(); - for (const key of new Set([...localByDate.keys(), ...onChainByDate.keys()])) { - totalsByDate.set(key, (localByDate.get(key) ?? 0) + (onChainByDate.get(key) ?? 0)); - } - - const maxCount = Math.max(0, ...totalsByDate.values()); - - const cells: { date: string; local: number; onChain: number; count: number }[] = []; - for (let i = 0; i < totalDays; i++) { - const d = new Date(startDate); - d.setDate(startDate.getDate() + i); - const key = d.toISOString().slice(0, 10); - cells.push({ - date: key, - local: localByDate.get(key) ?? 0, - onChain: onChainByDate.get(key) ?? 0, - count: totalsByDate.get(key) ?? 0, - }); - } - - const hasAnyActivity = maxCount > 0; - - return ( -
-
- {cells.map((cell) => { - const level = intensityFor(cell.count, maxCount); - const parts = [ - cell.local > 0 ? `${cell.local} local` : null, - cell.onChain > 0 ? `${cell.onChain} on-chain` : null, - ].filter(Boolean); - const label = parts.length > 0 ? parts.join(' · ') : 'no activity'; - return ( -
- ); - })} -
- {!hasAnyActivity && ( -

- No local or on-chain activity in this window yet — build/test/publish/upgrade runs from - Move Studio, and transactions from the active wallet, will fill this in. -

- )} -
- ); -} diff --git a/apps/web/src/components/Dashboard/AddComponentMenu.tsx b/apps/web/src/components/Dashboard/AddComponentMenu.tsx new file mode 100644 index 0000000..62892bc --- /dev/null +++ b/apps/web/src/components/Dashboard/AddComponentMenu.tsx @@ -0,0 +1,120 @@ +import { Plus, Check, Minus } from 'lucide-react'; +import { DitherButton } from '@/components/dither-kit/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { cn } from '@/lib/utils'; + +export interface AddableWidget { + id: string; + title: string; + category: string; + description: string; +} + +/** + * Header action that replaces the old "Request Faucet" button: opens a menu of + * every dashboard widget, grouped by category, and toggles the chosen one on or + * off the grid. Placed widgets read as checked and remove on click, so the menu + * is the single place to manage the dashboard - no need to enter Customize mode + * just to drop a chart. + */ +export function AddComponentMenu({ + widgets, + activeIds, + onAdd, + onRemove, +}: { + widgets: AddableWidget[]; + activeIds: string[]; + onAdd: (id: string) => void; + onRemove: (id: string) => void; +}) { + const active = new Set(activeIds); + // Removing the last widget would leave activeIds empty, and the config + // reconciler reads "empty" as "unconfigured" and restores the whole default + // set - so the final card has to stay put. + const isLast = active.size <= 1; + const categories = [...new Set(widgets.map((w) => w.category))]; + + return ( + + + + + {/* Icon-only on narrow screens: the dashboard content column is ~240px + on a phone, where four labelled pills stack into three rows. */} + Add component + + + + {categories.map((category, ci) => ( +
+ {ci > 0 && } + {category} + {widgets + .filter((w) => w.category === category) + .map((w) => { + const added = active.has(w.id); + const pinned = added && isLast; + return ( + { + // Stay open so several widgets can be toggled in one pass. + e.preventDefault(); + if (pinned) return; + if (added) onRemove(w.id); + else onAdd(w.id); + }} + className="group flex cursor-pointer flex-col items-start gap-0.5 py-2" + > +
+ {w.title} + {added ? ( + // Check at rest, minus on hover: the icon previews what the + // click does rather than just restating the current state. + <> + + {!pinned && ( + + )} + + ) : ( + + )} +
+ {w.description} +
+ ); + })} +
+ ))} +
+
+ ); +} diff --git a/apps/web/src/components/Dashboard/DashboardGrid.tsx b/apps/web/src/components/Dashboard/DashboardGrid.tsx new file mode 100644 index 0000000..bd3d6f0 --- /dev/null +++ b/apps/web/src/components/Dashboard/DashboardGrid.tsx @@ -0,0 +1,136 @@ +import { GripVertical, X } from 'lucide-react'; +import { useEffect, useRef, useState, type ReactNode } from 'react'; +import { Responsive, type Layout, type Layouts } from 'react-grid-layout'; +import { cn } from '@/lib/utils'; +import './grid.css'; + +// 12 cols on desktop, collapsing to fewer as the viewport narrows. RGL clamps any +// item wider than the current cols, so a 12-wide item just spans the full row on +// small screens instead of overflowing. +export const GRID_BREAKPOINTS = { lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }; +export const GRID_COLS = { lg: 12, md: 12, sm: 6, xs: 4, xxs: 2 }; +export const GRID_ROW_HEIGHT = 30; + +export interface DashboardGridItem { + /** Stable id - must match the `i` used in the layout objects. */ + i: string; + node: ReactNode; +} + +export function DashboardGrid({ + items, + layouts, + editing, + onLayoutChange, + onRemove, +}: { + items: DashboardGridItem[]; + layouts: Layouts; + editing: boolean; + /** Omitted (undefined) outside edit mode so auto/breakpoint reflows are never persisted. */ + onLayoutChange?: (current: Layout[], all: Layouts) => void; + /** Remove a widget from the dashboard (shown as an × on each card in edit mode). */ + onRemove?: (id: string) => void; +}) { + const containerRef = useRef(null); + const [width, setWidth] = useState(0); + // True while a card is actively being dragged/resized. During that window we do + // NOT push the layout back into RGL: onLayoutChange fires on every mouse-move, + // and feeding a freshly-rebuilt layout array back mid-drag makes RGL re-sync + // from the prop and snap the card back - which read as "drag doesn't work". We + // let RGL own the layout during the gesture and only persist once it stops. + const interactingRef = useRef(false); + + // Measure the container ourselves instead of react-grid-layout's WidthProvider. + // WidthProvider only re-measures on `window` resize, so when it takes its one + // measurement too early - before Lenis smooth-scroll and the framer-motion page + // transition have settled the layout - it locks in a wrong/tiny width and never + // corrects, collapsing every card. A ResizeObserver re-measures on any container + // size change (mount settle, sidebar collapse, window resize) and self-heals. + useEffect(() => { + const el = containerRef.current; + if (!el) return; + const measure = () => setWidth(el.clientWidth); + measure(); + const ro = new ResizeObserver(measure); + ro.observe(el); + return () => ro.disconnect(); + }, []); + + return ( +
+ {/* Render the grid only once a real width is known - a 0-width first paint is + exactly what produced the collapsed layout, so we skip it entirely. */} + {width > 0 && ( + { + interactingRef.current = true; + }} + onResizeStart={() => { + interactingRef.current = true; + }} + onDragStop={() => { + interactingRef.current = false; + }} + onResizeStop={() => { + interactingRef.current = false; + }} + // RGL fires onLayoutChange during the gesture (skipped) and once more + // right after onDragStop/onResizeStop clears the flag (persisted). + onLayoutChange={(current, all) => { + if (!interactingRef.current) onLayoutChange?.(current, all); + }} + useCSSTransforms + > + {items.map((item) => ( +
+
+ {editing && ( + // Visual affordance only - the whole card is draggable now, so + // this just signals "edit mode / grab me" and never blocks the card. +
+ +
+ )} + {editing && onRemove && ( + + )} + {/* Push content below the handle strip only while editing so the handle + never overlaps the card's own header. */} +
{item.node}
+
+
+ ))} +
+ )} +
+ ); +} diff --git a/apps/web/src/components/Dashboard/DitherBarList.tsx b/apps/web/src/components/Dashboard/DitherBarList.tsx new file mode 100644 index 0000000..6513846 --- /dev/null +++ b/apps/web/src/components/Dashboard/DitherBarList.tsx @@ -0,0 +1,72 @@ +import { DitherGradient } from '@/components/dither-kit/gradient'; +import type { DitherColor } from '@/components/dither-kit/palette'; + +// Same fixed order the coin/wallet charts use so a given series keeps its colour. +const BAR_COLORS: DitherColor[] = ['blue', 'green', 'purple', 'orange', 'pink', 'red', 'grey']; + +export interface BarDatum { + label: string; + value: number; + /** Pre-formatted value shown on the right (falls back to the raw number). */ + formatted?: string; + /** Optional per-row colour override; otherwise cycles the palette by index. */ + color?: DitherColor; +} + +/** + * Horizontal dither bar chart - the on-brand way to compare a handful of + * labelled values (coin balances, per-wallet object/package counts, "top N" + * lists). Bars are widthed against the largest value in the set. Built from the + * same DitherGradient fill the wallets table uses, so it reads as one system. + */ +export function DitherBarList({ + data, + emptyMessage = 'Nothing to show yet', + maxRows, +}: { + data: BarDatum[]; + emptyMessage?: string; + maxRows?: number; +}) { + const sorted = [...data].sort((a, b) => b.value - a.value); + const rows = maxRows ? sorted.slice(0, maxRows) : sorted; + const max = rows.reduce((m, d) => Math.max(m, d.value), 0); + + if (rows.length === 0 || max <= 0) { + return ( +
+ {emptyMessage} +
+ ); + } + + return ( +
+ {rows.map((d, i) => { + // Floor visible bars at 4% so a tiny-but-nonzero value still registers. + const pct = d.value > 0 ? Math.max((d.value / max) * 100, 4) : 0; + const color = d.color ?? BAR_COLORS[i % BAR_COLORS.length]; + return ( +
+
+ {d.label} + + {d.formatted ?? d.value.toLocaleString()} + +
+
+ {pct > 0 && ( +
+ +
+ )} +
+
+ ); + })} +
+ ); +} diff --git a/apps/web/src/components/Dashboard/RecentActivity.tsx b/apps/web/src/components/Dashboard/RecentActivity.tsx index 06aa035..9168daf 100644 --- a/apps/web/src/components/Dashboard/RecentActivity.tsx +++ b/apps/web/src/components/Dashboard/RecentActivity.tsx @@ -63,7 +63,11 @@ export function RecentActivity({ onChainHistory = [], activeWalletAlias }: Recen

No local build/deploy activity yet — actions in Move Studio will show up here.

-
@@ -73,8 +77,10 @@ export function RecentActivity({ onChainHistory = [], activeWalletAlias }: Recen const Icon = OPERATION_ICONS[entry.type]; const packageName = entry.packagePath.split('/').filter(Boolean).pop() || entry.packagePath; return ( -
- +
+
+ +
{OPERATION_LABELS[entry.type]} · {packageName} @@ -117,8 +123,10 @@ export function RecentActivity({ onChainHistory = [], activeWalletAlias }: Recen ) : (
{onChainHistory.map((entry) => ( -
- +
+
+ +
Balance changed to {entry.balance.toFixed(4)} SUI diff --git a/apps/web/src/components/Dashboard/grid.css b/apps/web/src/components/Dashboard/grid.css new file mode 100644 index 0000000..f350a33 --- /dev/null +++ b/apps/web/src/components/Dashboard/grid.css @@ -0,0 +1,52 @@ +/* react-grid-layout theming for the customizable dashboard. + Vendor stylesheets first, then our overrides so the drag/resize affordances + match the app's tokens and only appear while the grid is in edit mode. */ +@import 'react-grid-layout/css/styles.css'; +@import 'react-resizable/css/styles.css'; + +/* Smooth position/size transitions when items reflow, but never while the user + is actively dragging/resizing the item itself (RGL adds .react-draggable-dragging / + .resizing on that node) - transitioning then makes the drag feel laggy. */ +.dashboard-grid .react-grid-item { + transition: transform 200ms ease, width 200ms ease, height 200ms ease; +} +.dashboard-grid .react-grid-item.react-draggable-dragging, +.dashboard-grid .react-grid-item.resizing { + transition: none; + z-index: 30; + cursor: grabbing; +} + +/* The drop target preview shown under a dragged item. */ +.dashboard-grid .react-grid-item.react-grid-placeholder { + background: hsl(var(--primary) / 0.18); + border: 1.5px dashed hsl(var(--primary) / 0.5); + border-radius: 0.75rem; + opacity: 1; +} + +/* Edit-mode affordances - hidden entirely when not editing so normal viewing is + clean and nothing is accidentally draggable. */ +.dashboard-grid:not(.is-editing) .react-resizable-handle { + display: none; +} +.dashboard-grid.is-editing .react-grid-item { + cursor: grab; + outline: 1.5px dashed hsl(var(--border)); + outline-offset: 2px; + border-radius: 0.75rem; +} +.dashboard-grid.is-editing .react-grid-item:hover { + outline-color: hsl(var(--primary) / 0.6); +} + +/* Recolor the default SE resize handle (a background-image arrow) to a theme dot. */ +.dashboard-grid.is-editing .react-resizable-handle::after { + border-right-color: hsl(var(--primary) / 0.7); + border-bottom-color: hsl(var(--primary) / 0.7); + width: 8px; + height: 8px; +} +.dashboard-grid .react-resizable-handle { + background-image: none; +} diff --git a/apps/web/src/components/Dashboard/index.tsx b/apps/web/src/components/Dashboard/index.tsx index 0a82afa..da84872 100644 --- a/apps/web/src/components/Dashboard/index.tsx +++ b/apps/web/src/components/Dashboard/index.tsx @@ -1,24 +1,179 @@ -import { GradientSpin } from 'gradient-spin'; -import { Coins, Package, RefreshCw } from 'lucide-react'; +import { Check, Coins, LayoutGrid, Package, RefreshCw, RotateCcw } from 'lucide-react'; import { useEffect, useMemo, useState } from 'react'; -import { useNavigate } from 'react-router-dom'; +import type { Layout, Layouts } from 'react-grid-layout'; +import toast from 'react-hot-toast'; import { getHistoricalBalance, getWalletSummary } from '@/api/services/addresses'; import { DitherButton } from '@/components/dither-kit/button'; import { DitherGradient } from '@/components/dither-kit/gradient'; +import { Sparkline } from '@/components/dither-kit/sparkline'; +import { formatInteger } from '@/components/formater'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { PixelCard } from '@/components/ui/pixel-card'; -import { useTheme } from '@/contexts/ThemeContext'; -import { cn } from '@/lib/utils'; +import { CopyForAiMenu } from '@/components/ui/copy-for-ai'; +import { Loader } from '@/components/ui/loader'; import { useApiOnMount } from '@/hooks/api/useApi'; +import { buildAiContext } from '@/lib/ai-context'; +import { cn } from '@/lib/utils'; import { useAppStore } from '@/stores/useAppStore'; import { useBalanceHistoryStore } from '@/stores/useBalanceHistoryStore'; import { formatRelativeTime } from '@/utils/format'; -import { ActivityHeatmap } from './ActivityHeatmap'; +import { type AddableWidget, AddComponentMenu } from './AddComponentMenu'; import { BalanceHistoryChart } from './BalanceHistoryChart'; import { CoinDistributionChart } from './CoinDistributionChart'; +import { DashboardGrid, type DashboardGridItem } from './DashboardGrid'; +import { DitherBarList } from './DitherBarList'; import { RecentActivity } from './RecentActivity'; +import { useDashboardConfig, type WidgetSize } from './useDashboardConfig'; +import { useLenisScrollable } from './useLenisScrollable'; import { WalletsTable } from './WalletsTable'; +// The catalogue of every dashboard widget: what it's called, which group it +// shows under in the "Add component" menu, and its default grid footprint. The +// render logic lives in the component (renderWidget) because it needs live data; +// this static half is what the add-menu, persistence, and defaults key off. +type WidgetCategory = 'Stats' | 'Active wallet' | 'Portfolio'; +interface WidgetMeta { + title: string; + category: WidgetCategory; + description: string; + size: WidgetSize; +} + +const WIDGET_META: Record = { + 'stat-balance': { + title: 'Total Balance', + category: 'Stats', + description: 'Portfolio SUI across all wallets', + size: { w: 3, h: 4, minW: 2, minH: 3 }, + }, + 'stat-objects': { + title: 'Total Objects', + category: 'Stats', + description: 'Owned objects across all wallets', + size: { w: 3, h: 4, minW: 2, minH: 3 }, + }, + 'stat-packages': { + title: 'Total Packages', + category: 'Stats', + description: 'Published packages across all wallets', + size: { w: 3, h: 4, minW: 2, minH: 3 }, + }, + 'stat-wallets': { + title: 'Wallets & Coins', + category: 'Stats', + description: 'Wallet count and active coin types', + size: { w: 3, h: 4, minW: 2, minH: 3 }, + }, + 'balance-trend': { + title: 'Balance Trend', + category: 'Active wallet', + description: 'Sparkline of the active wallet balance', + size: { w: 3, h: 5, minW: 2, minH: 4 }, + }, + wallets: { + title: 'Wallets Table', + category: 'Portfolio', + description: 'Every wallet with balance / objects / packages bars', + size: { w: 12, h: 9, minW: 4, minH: 5 }, + }, + 'wallet-balances': { + title: 'Wallets by Balance', + category: 'Portfolio', + description: 'Bar chart comparing SUI across wallets', + size: { w: 4, h: 9, minW: 3, minH: 5 }, + }, + 'wallet-objects': { + title: 'Wallets by Objects', + category: 'Portfolio', + description: 'Top wallets by owned-object count', + size: { w: 4, h: 9, minW: 3, minH: 5 }, + }, + 'wallet-packages': { + title: 'Wallets by Packages', + category: 'Portfolio', + description: 'Top wallets by published packages', + size: { w: 4, h: 9, minW: 3, minH: 5 }, + }, + 'balance-history': { + title: 'Balance History', + category: 'Active wallet', + description: 'Full on-chain SUI balance over time', + size: { w: 8, h: 11, minW: 4, minH: 6 }, + }, + 'coin-distribution': { + title: 'Coin Distribution', + category: 'Active wallet', + description: 'Ring chart of the active wallet coin mix', + size: { w: 6, h: 10, minW: 3, minH: 6 }, + }, + 'coin-balances': { + title: 'Coin Balances', + category: 'Active wallet', + description: 'Bar chart of each coin type you hold', + size: { w: 4, h: 9, minW: 3, minH: 5 }, + }, + 'recent-activity': { + title: 'Recent Activity', + category: 'Active wallet', + description: 'Latest local & on-chain events', + size: { w: 6, h: 10, minW: 3, minH: 6 }, + }, +}; + +// The dashboard as it ships before any customization (order == render order). +const DEFAULT_WIDGET_IDS = [ + 'stat-balance', + 'stat-objects', + 'stat-packages', + 'stat-wallets', + 'wallets', + 'balance-history', + 'coin-distribution', + 'recent-activity', +]; + +const sizeOf = (id: string): WidgetSize | undefined => WIDGET_META[id]?.size; + +// The full catalogue passed to the add-menu. +const ADDABLE_WIDGETS: AddableWidget[] = Object.entries(WIDGET_META).map(([id, m]) => ({ + id, + title: m.title, + category: m.category, + description: m.description, +})); + +// Base 12-col desktop arrangement. Heights are in grid rows (rowHeight 30 + 16px +// gaps): stat tiles ~4, charts ~10-11, the wide wallets table ~9. +const BASE_LAYOUT: Layout[] = [ + { i: 'stat-balance', x: 0, y: 0, w: 3, h: 4, minW: 2, minH: 3 }, + { i: 'stat-objects', x: 3, y: 0, w: 3, h: 4, minW: 2, minH: 3 }, + { i: 'stat-packages', x: 6, y: 0, w: 3, h: 4, minW: 2, minH: 3 }, + { i: 'stat-wallets', x: 9, y: 0, w: 3, h: 4, minW: 2, minH: 3 }, + { i: 'wallets', x: 0, y: 4, w: 12, h: 9, minW: 4, minH: 5 }, + { i: 'balance-history', x: 0, y: 13, w: 12, h: 11, minW: 4, minH: 6 }, + { i: 'coin-distribution', x: 0, y: 24, w: 6, h: 10, minW: 3, minH: 6 }, + { i: 'recent-activity', x: 6, y: 24, w: 6, h: 10, minW: 3, minH: 6 }, +]; + +// On narrow breakpoints, stack every card full-width so nothing can be squeezed +// into an unreadable column. Explicit per-breakpoint layouts (rather than letting +// RGL synthesize them) keep the arrangement predictable at every width. +function stackForCols(base: Layout[], cols: number): Layout[] { + let y = 0; + return base.map((item) => { + const laid: Layout = { ...item, x: 0, y, w: cols, minW: 1 }; + y += item.h; + return laid; + }); +} + +const DEFAULT_LAYOUTS: Layouts = { + lg: BASE_LAYOUT.map((l) => ({ ...l })), + md: BASE_LAYOUT.map((l) => ({ ...l })), + sm: stackForCols(BASE_LAYOUT, 6), + xs: stackForCols(BASE_LAYOUT, 4), + xxs: stackForCols(BASE_LAYOUT, 2), +}; + /* Card anatomy borrowed from the @efferd/dashboard-3 block (stats.tsx / chart cards): muted xs CardTitle, big tabular-nums value, xs footnote - all in one unified grid. */ function ChartCard({ @@ -34,8 +189,12 @@ function ChartCard({ className?: string; children: React.ReactNode; }) { + // min-h-0 lets this flex child shrink so its own overflow-auto engages when the + // card is resized smaller than its content; the hook enables native wheel + // scrolling inside it despite the page's Lenis smooth-scroll. + const bodyRef = useLenisScrollable(); return ( - + {title} {total ? ( @@ -43,31 +202,27 @@ function ChartCard({ ) : null} {subtitle ? {subtitle} : null} - {children} + + {children} + ); } -/* Labeled section wrapper - the dashboard grid used to be one flat 4-col block mixing - portfolio-wide, active-wallet-only, and local-only-to-this-browser numbers with no visual - separation. Each section says up front what scope its numbers are. */ -function DashboardSection({ - title, - scope, +// Grid item whose body scrolls natively even under the page's Lenis smooth-scroll +// (used for cards that render their own container, e.g. RecentActivity). +function ScrollableItem({ children, + className, }: { - title: string; - scope: string; children: React.ReactNode; + className?: string; }) { + const ref = useLenisScrollable(); return ( -
-
-

{title}

- {scope} -
-
{children}
-
+
+ {children} +
); } @@ -85,7 +240,7 @@ function StatCard({ footnote: string; }) { return ( - + @@ -107,8 +262,6 @@ function StatCard({ } export function Dashboard() { - const navigate = useNavigate(); - const { theme } = useTheme(); const { addresses, environments, isLoading } = useAppStore(); const activeAddress = addresses.find((a) => a.isActive); const activeEnv = environments.find((e) => e.isActive); @@ -133,31 +286,28 @@ export function Dashboard() { data: walletSummaries, loading: walletSummariesLoading, refetch: refetchWalletSummaries, - } = useApiOnMount( - () => { - const failed = new Set(); - return Promise.all( - addresses.map(async (addr) => { - try { - const summary = await getWalletSummary(addr.address); - return { address: addr.address, ...summary }; - } catch { - failed.add(addr.address); - return { - address: addr.address, - objectCount: 0, - packages: [], - coinGroups: { groups: [], totalCoinTypes: 0, totalCoins: 0 }, - }; - } - }) - ).then((results) => { - setFailedAddresses(failed); - return results; - }); - }, - [addresses.map((a) => a.address).join(',')] - ); + } = useApiOnMount(() => { + const failed = new Set(); + return Promise.all( + addresses.map(async (addr) => { + try { + const summary = await getWalletSummary(addr.address); + return { address: addr.address, ...summary }; + } catch { + failed.add(addr.address); + return { + address: addr.address, + objectCount: 0, + packages: [], + coinGroups: { groups: [], totalCoinTypes: 0, totalCoins: 0 }, + }; + } + }) + ).then((results) => { + setFailedAddresses(failed); + return results; + }); + }, [addresses.map((a) => a.address).join(',')]); const [lastUpdatedAt, setLastUpdatedAt] = useState(null); useEffect(() => { @@ -217,6 +367,75 @@ export function Dashboard() { const totalPackages = packageData.reduce((sum, w) => sum + w.value, 0); const topCoin = [...coinData].sort((a, b) => b.value - a.value)[0]; + const handleCopy = (text: string, label: string) => { + navigator.clipboard.writeText(text); + toast.success(`${label} copied`); + }; + + const aiJson = JSON.stringify( + { + network: activeEnv?.alias ?? null, + totalBalance, + totalObjects, + totalPackages, + wallets: walletMetrics, + }, + null, + 2 + ); + + const aiMarkdown = [ + '# Sui wallet portfolio', + '', + `- **Network:** ${activeEnv?.alias ?? 'not connected'}`, + `- **Total balance:** ${totalBalance.toFixed(2)} SUI across ${addresses.length} wallets`, + `- **Total objects:** ${totalObjects}`, + `- **Total packages:** ${totalPackages}`, + '', + '## Wallets', + '| Wallet | Balance (SUI) | Objects | Packages |', + '|---|---|---|---|', + ...walletMetrics.map( + (w) => + `| ${w.name}${w.isActive ? ' (active)' : ''} | ${w.balance.toFixed(2)} | ${w.objects} | ${w.packages} |` + ), + ].join('\n'); + + const aiPrompt = buildAiContext({ + title: 'Sui wallet portfolio', + intro: [ + 'A portfolio dashboard across every address in this local Sui CLI keystore,', + 'scoped to the active network. Nothing here mutates anything.', + ], + stateJson: aiJson, + endpoints: [ + { method: 'GET', path: '/addresses', effect: 'every address in the keystore' }, + { + method: 'GET', + path: '/addresses/:address/summary', + effect: 'balance, object and package counts for one address', + }, + { + method: 'GET', + path: '/addresses/:address/history', + effect: 'balance over time (per-transaction points)', + }, + { method: 'GET', path: '/coins/:address', effect: 'coin objects held, by type' }, + { method: 'GET', path: '/health', effect: 'confirm the server is up' }, + ], + rules: [ + 'Balances are SUI in the UI but MIST over the RPC (1 SUI = 1e9 MIST)', + 'Totals here span ALL addresses; the "active wallet" figures are one address', + 'Every figure is scoped to the active network - testnet and mainnet balances are unrelated', + 'An address needs its own SUI coin for gas; a large non-SUI balance does not make it spendable', + ], + examples: [ + 'summarise what this account holds', + 'find wallets with objects but no gas', + 'chart the balance history', + ], + }); + // Active wallet's profile across the 4 real per-wallet metrics, each normalized // to 0-100 against the max held by any of the user's wallets - a genuine // comparison, not a fabricated score. @@ -236,15 +455,8 @@ export function Dashboard() { ); // getHistoricalBalance prepends a synthetic "current balance right now" anchor point - // (timestamp = the moment of the fetch) that isn't a real transaction. It always sorts - // to the newest slot, so dropping the last entry leaves only genuine on-chain tx timestamps. - const onChainActivityTimestamps = useMemo(() => { - const points = activeWalletHistory ?? []; - return points.length > 1 ? points.slice(0, -1).map((p) => p.timestamp) : []; - }, [activeWalletHistory]); - - // Same real on-chain points as above, kept as {timestamp, balance} (not just timestamps) so - // RecentActivity can show what actually happened, not just when - most recent first. + // (timestamp = the moment of the fetch) that isn't a real transaction; dropping the + // last entry leaves genuine on-chain points, newest first, for RecentActivity. const recentOnChainActivity = useMemo(() => { const points = activeWalletHistory ?? []; return points.length > 1 @@ -282,77 +494,61 @@ export function Dashboard() { return [...older, ...onChain].sort((a, b) => a.timestamp - b.timestamp); }, [activeWalletHistory, snapshotsByAddress, activeAddress?.address]); + // Customizable-grid state: `editing` gates the drag/resize handles so normal + // viewing stays clean; `layouts` is persisted to localStorage (this browser). + const [editing, setEditing] = useState(false); + const { activeIds, layouts, onLayoutChange, add, remove, reset } = useDashboardConfig({ + defaultIds: DEFAULT_WIDGET_IDS, + defaultLayouts: DEFAULT_LAYOUTS, + sizeOf, + }); + + // Pin the action toolbar once the hero row scrolls out of view, so + // Add component / Customize / Done stay reachable while arranging cards + // far down the page. The hero keeps its own inline copy; the pinned pill + // only fades in when that copy is off-screen, so both are never visible + // at once. + // + // Held as state (callback ref), not a useRef: the hero unmounts behind the + // `dashboardNotReady` loader and remounts on HMR edits, and an observer + // bound once via [] deps would keep watching the detached node - reporting + // "not intersecting" forever and wedging the pill permanently on (or, on a + // cold load where the loader renders first, never attaching at all). + const [heroEl, setHeroEl] = useState(null); + const [toolbarPinned, setToolbarPinned] = useState(false); + useEffect(() => { + if (!heroEl || typeof IntersectionObserver === 'undefined') { + setToolbarPinned(false); + return; + } + const observer = new IntersectionObserver( + ([entry]) => setToolbarPinned(!entry.isIntersecting), + // Count the hero as "gone" slightly early so the pill is already there + // by the time the buttons disappear under the app's top bar. + { rootMargin: '-72px 0px 0px 0px' } + ); + observer.observe(heroEl); + return () => observer.disconnect(); + }, [heroEl]); + if (dashboardNotReady) { return (
- +

Loading your wallets…

); } - return ( -
- {/* Pixel background - sits behind the whole dashboard content, auto-animating (no hover needed). - Canvas fillStyle can't resolve CSS custom properties, so pick literal colors per theme. */} - -
- -
- {/* Welcome hero */} -
-
-

- Welcome back{activeAddress?.alias ? `, ${activeAddress.alias}` : ''}! 👋 -

-

- {activeEnv ? `Connected to ${activeEnv.alias}` : 'No network connected'} - {' · '} - {lastUpdatedAt ? `Updated ${formatRelativeTime(lastUpdatedAt)}` : 'Loading…'} -

-
-
- refetchWalletSummaries()} - variant="dotted" - disabled={walletSummariesLoading} - title="Refresh wallet data" - className="rounded-full h-11 w-11 p-0 flex items-center justify-center" - > - - - navigate('/app/faucet')} - variant="gradient" - bloom="low" - className="rounded-full h-11 px-6 text-sm" - > - + Request Faucet - -
-
+ // Point series for the Balance Trend sparkline widget. + const balanceSpark = realBalanceHistory.map((p) => p.balance); - {/* Every number below is scoped to activeEnv - split into labeled sections so it's - clear at a glance what's portfolio-wide vs. active-wallet-only vs. local-to-this- - browser, instead of one flat undifferentiated grid. */} - + // Render a widget by id. Only ids present in WIDGET_META reach here (the config + // hook filters unknown ids on load), so `default` is just a safety net. + const renderWidget = (id: string): React.ReactNode => { + switch (id) { + case 'stat-balance': + return ( } label="Total Balance" @@ -360,18 +556,27 @@ export function Dashboard() { unit="SUI" footnote={`Active wallet: ${activeBalance.toFixed(2)} SUI`} /> + ); + case 'stat-objects': + return ( } label="Total Objects" value={String(totalObjects)} footnote={`Active wallet: ${activeObjects}`} /> + ); + case 'stat-packages': + return ( } label="Total Packages" value={String(totalPackages)} footnote={`Active wallet: ${activePackages}`} /> + ); + case 'stat-wallets': + return ( } label="Wallets & Coins" @@ -379,59 +584,289 @@ export function Dashboard() { unit="wallets" footnote={`Active wallet coin types: ${activeCoinTypes}`} /> - + ); + case 'balance-trend': + return ( + + {balanceSpark.length >= 2 ? ( +
+ +
+ ) : ( +
+ Not enough history yet +
+ )} +
+ ); + case 'wallets': + return ( - + -
- - + ); + case 'wallet-balances': + return ( + + ({ + label: w.name, + value: w.balance, + formatted: `${w.balance.toFixed(2)} SUI`, + }))} + emptyMessage="No wallet balances yet" + /> + + ); + case 'wallet-objects': + return ( + + ({ + label: w.name, + value: w.objects, + formatted: formatInteger(w.objects), + }))} + emptyMessage="No objects found" + /> + + ); + case 'wallet-packages': + return ( + + ({ + label: w.name, + value: w.packages, + formatted: formatInteger(w.packages), + }))} + emptyMessage="No packages published yet" + /> + + ); + case 'balance-history': + return ( -
+ {/* h-full (not a fixed 260px) so the chart grows/shrinks with the card. */} +
+ ); + case 'coin-distribution': + return ( 0 && topCoin + ? `${topCoin.formattedBalance} ${topCoin.symbol}` + : undefined + } + subtitle={ + coinData.length > 0 + ? `${coinData.length} coin type${coinData.length === 1 ? '' : 's'} held${topCoin ? ` · ${topCoin.symbol} leads` : ''}` + : 'No coins held in this wallet' + } > - - - - {coinData.length > 0 && ( - + {coinData.length > 0 ? ( - - )} - - - {/* Local build/test/publish/upgrade history - this browser only, never synced. */} - -
+ ) : ( +
+ No coins to display +
+ )} + + ); + case 'coin-balances': + return ( + + ({ + label: c.symbol || 'Unknown', + value: c.value, + formatted: c.formattedBalance, + }))} + emptyMessage="No coins held in this wallet" + /> + + ); + case 'recent-activity': + // RecentActivity renders its own bordered card; ScrollableItem makes the + // grid cell scroll natively (past Lenis) when the activity list overflows. + return ( + + + ); + default: + return null; + } + }; + + const gridItems: DashboardGridItem[] = activeIds.map((id) => ({ i: id, node: renderWidget(id) })); + + // Rendered twice: inline in the hero row, and inside the pinned pill that + // follows the viewport once the hero scrolls away. One shared control + // height (h-9) so every pill sits flush with its neighbours. + const actionToolbar = ( + <> + + refetchWalletSummaries()} + variant="dotted" + disabled={walletSummariesLoading} + title="Refresh wallet data" + className="rounded-full h-9 w-9 p-0 flex items-center justify-center" + > + + + + {editing ? ( + <> + + + Reset + + setEditing(false)} + variant="gradient" + bloom="low" + aria-label="Done customizing" + className="rounded-full h-9 px-3 lg:px-4" + > + + Done + + + ) : ( + setEditing(true)} + variant="dotted" + title="Customize layout — drag & resize cards" + aria-label="Customize layout" + className="rounded-full h-9 px-3 lg:px-4" + > + + Customize + + )} + + ); + + return ( +
+ {/* Welcome hero. Stacked until `lg`, not `sm`: the sidebar eats the + viewport, and sharing a row with the (variable-length) greeting leaves + the toolbar ~150px at tablet widths - enough to force every pill onto + its own line. */} +
+
+

+ Welcome back{activeAddress?.alias ? `, ${activeAddress.alias}` : ''}! 👋 +

+

+ {activeEnv ? `Connected to ${activeEnv.alias}` : 'No network connected'} + {' · '} + {lastUpdatedAt ? `Updated ${formatRelativeTime(lastUpdatedAt)}` : 'Loading…'} +

- -
+ {/* Fades out as the pill fades in. Both copies live in the DOM at once, + so without this they overlap for the length of the transition - and + a screen reader would read every control twice. */} +
+ {actionToolbar} +
+
+ + {/* Pinned toolbar - a zero-height sticky rail: once the hero copy of the + buttons scrolls out of view, this glass pill fades in at the top of + the scroll area and follows the user down the page. */} +
+
+ {/* `max-w-full` + `flex-wrap`: the row is wider than a phone viewport, + and a non-wrapping pill inside a `justify-end` flex parent gets + pushed off the left edge rather than clipped on the right. */} +
+ {actionToolbar} +
+
+
+ + {/* One free-form grid: every card can be dragged/resized in edit mode and + the arrangement is remembered per browser. Toggle via "Customize". */} + {editing && ( +
+ Drag any card to move it, drag its bottom-right corner to resize, or click the + × to remove it. Use + Add component to add charts. + Everything saves to this browser — hit{' '} + Reset to restore the default. +
+ )} +
); } diff --git a/apps/web/src/components/Dashboard/useDashboardConfig.ts b/apps/web/src/components/Dashboard/useDashboardConfig.ts new file mode 100644 index 0000000..6176040 --- /dev/null +++ b/apps/web/src/components/Dashboard/useDashboardConfig.ts @@ -0,0 +1,193 @@ +import { useCallback, useState } from 'react'; +import type { Layout, Layouts } from 'react-grid-layout'; +import { GRID_COLS } from './DashboardGrid'; + +// Persists BOTH which widgets are on the dashboard and how they're arranged. +// Bumped past the layout-only keys (v1-v4) now that the shape includes activeIds. +const STORAGE_KEY = 'dashboard-config-v5'; + +export interface WidgetSize { + w: number; + h: number; + minW: number; + minH: number; +} + +interface DashboardConfig { + activeIds: string[]; + layouts: Layouts; +} + +const BREAKPOINTS = ['lg', 'md', 'sm', 'xs', 'xxs'] as const; + +/** Bottom edge (in grid rows) of a layout, so a newly-added widget can be + * dropped just below everything else instead of overlapping. */ +function bottomOf(layout: Layout[]): number { + return layout.reduce((max, l) => Math.max(max, l.y + l.h), 0); +} + +function widthForBreakpoint(bp: string, size: WidgetSize): number { + const cols = GRID_COLS[bp as keyof typeof GRID_COLS] ?? 12; + // On the narrow breakpoints every card spans the full width (matches the + // stacked defaults); on lg/md keep the widget's intended width. + return cols < 12 ? cols : Math.min(size.w, cols); +} + +function makeEntry(bp: string, id: string, size: WidgetSize, y: number): Layout { + const cols = GRID_COLS[bp as keyof typeof GRID_COLS] ?? 12; + return { + i: id, + x: 0, + y, + w: widthForBreakpoint(bp, size), + h: size.h, + minW: Math.min(size.minW, cols), + minH: size.minH, + }; +} + +/** + * Reconcile a (possibly partial/stale) config against the current widget set: + * - drop active ids the registry no longer knows about, + * - guarantee every active widget has a layout entry at every breakpoint + * (appending missing ones at the bottom), and + * - drop layout entries for widgets that are no longer active. + * This is what stops an async-loaded or newly-added widget from rendering with + * no/garbage geometry. + */ +function reconcile( + config: Partial | null, + defaults: DashboardConfig, + sizeOf: (id: string) => WidgetSize | undefined +): DashboardConfig { + const rawIds = config?.activeIds && config.activeIds.length > 0 ? config.activeIds : defaults.activeIds; + const activeIds = rawIds.filter((id) => sizeOf(id)); + const finalIds = activeIds.length > 0 ? activeIds : defaults.activeIds; + + const layouts: Layouts = {}; + for (const bp of BREAKPOINTS) { + const saved = config?.layouts?.[bp] ?? defaults.layouts[bp] ?? []; + const savedById = new Map(saved.map((l) => [l.i, l])); + const out: Layout[] = []; + for (const id of finalIds) { + const size = sizeOf(id); + if (!size) continue; + const existing = savedById.get(id); + if (existing) { + out.push({ + ...existing, + w: Math.max(existing.w, Math.min(size.minW, GRID_COLS[bp] ?? 12)), + h: Math.max(existing.h, size.minH), + minW: Math.min(size.minW, GRID_COLS[bp] ?? 12), + minH: size.minH, + }); + } else { + out.push(makeEntry(bp, id, size, bottomOf(out))); + } + } + layouts[bp] = out; + } + return { activeIds: finalIds, layouts }; +} + +export function useDashboardConfig(opts: { + defaultIds: string[]; + defaultLayouts: Layouts; + sizeOf: (id: string) => WidgetSize | undefined; +}) { + const { defaultIds, defaultLayouts, sizeOf } = opts; + const defaults: DashboardConfig = { activeIds: defaultIds, layouts: defaultLayouts }; + + const [config, setConfig] = useState(() => { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (raw) return reconcile(JSON.parse(raw), defaults, sizeOf); + } catch { + // ignore + } + return reconcile(null, defaults, sizeOf); + }); + + // Persist layout edits (called from the grid on drag/resize stop). + const onLayoutChange = useCallback( + (_current: Layout[], allLayouts: Layouts) => { + setConfig((prev) => { + const next = reconcile({ activeIds: prev.activeIds, layouts: allLayouts }, defaults, sizeOf); + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); + } catch { + // ignore + } + return next; + }); + }, + // defaults/sizeOf are stable enough (module-level data); intentionally omitted. + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + + const add = useCallback( + (id: string) => { + setConfig((prev) => { + if (prev.activeIds.includes(id)) return prev; + const size = sizeOf(id); + if (!size) return prev; + const layouts: Layouts = {}; + for (const bp of BREAKPOINTS) { + const arr = prev.layouts[bp] ?? []; + layouts[bp] = [...arr, makeEntry(bp, id, size, bottomOf(arr))]; + } + const next = { activeIds: [...prev.activeIds, id], layouts }; + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); + } catch { + /* ignore */ + } + return next; + }); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + + const remove = useCallback( + (id: string) => { + setConfig((prev) => { + if (!prev.activeIds.includes(id)) return prev; + const layouts: Layouts = {}; + for (const bp of BREAKPOINTS) { + layouts[bp] = (prev.layouts[bp] ?? []).filter((l) => l.i !== id); + } + const next = { activeIds: prev.activeIds.filter((x) => x !== id), layouts }; + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); + } catch { + /* ignore */ + } + return next; + }); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + + const reset = useCallback(() => { + const fresh = reconcile(null, defaults, sizeOf); + setConfig(fresh); + try { + localStorage.removeItem(STORAGE_KEY); + } catch { + /* ignore */ + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return { + activeIds: config.activeIds, + layouts: config.layouts, + onLayoutChange, + add, + remove, + reset, + }; +} diff --git a/apps/web/src/components/Dashboard/useLenisScrollable.ts b/apps/web/src/components/Dashboard/useLenisScrollable.ts new file mode 100644 index 0000000..cb19136 --- /dev/null +++ b/apps/web/src/components/Dashboard/useLenisScrollable.ts @@ -0,0 +1,42 @@ +import { useEffect, useRef } from 'react'; + +/** + * Ref for a scrollable container living inside the Lenis smooth-scroll page. + * + * Lenis intercepts wheel events globally, so a nested `overflow-auto` box can't + * scroll on its own - which is why the dashboard cards (recent activity, tables, + * a resized-small chart) felt "stuck". Lenis honors a `data-lenis-prevent` + * attribute and leaves native scrolling alone on that element. + * + * We toggle the attribute ONLY while the element actually overflows: if it were + * always present, wheel events over a card that fits would be swallowed and the + * page itself would refuse to scroll while the cursor sat over that card. + */ +export function useLenisScrollable() { + const ref = useRef(null); + + useEffect(() => { + const el = ref.current; + if (!el) return; + + const sync = () => { + const overflowing = el.scrollHeight - el.clientHeight > 1; + el.toggleAttribute('data-lenis-prevent', overflowing); + }; + + sync(); + // Re-check when the card is resized (grid drag/resize, window) or its content + // changes (data loads in, sections expand). + const ro = new ResizeObserver(sync); + ro.observe(el); + const mo = new MutationObserver(sync); + mo.observe(el, { childList: true, subtree: true, characterData: true }); + + return () => { + ro.disconnect(); + mo.disconnect(); + }; + }, []); + + return ref; +} diff --git a/apps/web/src/components/DerivedObjectCalculator/index.tsx b/apps/web/src/components/DerivedObjectCalculator/index.tsx index 4ec24b8..3315ecb 100644 --- a/apps/web/src/components/DerivedObjectCalculator/index.tsx +++ b/apps/web/src/components/DerivedObjectCalculator/index.tsx @@ -4,6 +4,7 @@ import toast from 'react-hot-toast'; import { useNavigate } from 'react-router-dom'; import { type DerivedObjectKeyType, deriveObjectAddress } from '@/api/services/derivedObjects'; import { Button } from '@/components/ui/button'; +import { CopyForAiMenu } from '@/components/ui/copy-for-ai'; import { Input } from '@/components/ui/input'; import { Select, @@ -55,12 +56,47 @@ export function DerivedObjectCalculator() { const canCompute = parentId.trim().startsWith('0x') && keyValue.trim().length > 0 && !isComputing; + const copyToClipboard = (text: string, label: string) => { + navigator.clipboard.writeText(text); + toast.success(`${label} copied`); + }; + + // Copy-for-AI export of the derivation inputs and computed result. + const aiExport = address + ? { + prompt: [ + `On Sui, I derived an object address from a parent object and a typed key.`, + `- Parent object ID: ${parentId.trim()}`, + `- Key type: ${keyType}`, + `- Key value: ${keyValue.trim()}`, + `- Derived object address: ${address}`, + '', + "This uses @mysten/sui's derived_object::derive_address. Explain how this address is computed and how I can use it.", + ].join('\n'), + json: JSON.stringify( + { + parentId: parentId.trim(), + keyType, + keyValue: keyValue.trim(), + derivedAddress: address, + }, + null, + 2 + ), + } + : null; + return (
-
- - Derived Address Calculator +
+
+ + Derived Address Calculator +
+ {aiExport && ( + + )}

Computes a derived object's deterministic address from its parent object ID and key - the diff --git a/apps/web/src/components/DevTools/index.tsx b/apps/web/src/components/DevTools/index.tsx index 7353d7c..8fbc32c 100644 --- a/apps/web/src/components/DevTools/index.tsx +++ b/apps/web/src/components/DevTools/index.tsx @@ -1,35 +1,36 @@ -import { useState, useMemo, useEffect, useCallback } from 'react'; -import { useSearchParams } from 'react-router-dom'; -import { motion, AnimatePresence } from 'framer-motion'; -import { Button } from '@/components/ui/button'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { Label } from '@/components/ui/label'; +import { AnimatePresence, motion } from 'framer-motion'; import { - Code, - FileCode, Activity, - Loader2, - FolderOpen, - PlayCircle, - FileText, - Settings, AlertCircle, - CheckCircle2, AlertTriangle, + CheckCircle2, ChevronDown, + Code, + FileCode, + FileText, + FolderOpen, Lightbulb, + Loader2, + PlayCircle, + Settings, } from 'lucide-react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import toast from 'react-hot-toast'; -import { FileBrowser } from '@/components/MoveDeploy/FileBrowser'; +import { useSearchParams } from 'react-router-dom'; import { - runCoverage, disassembleModule, generatePackageSummary, getPackageModules, getPublishedPackages, type PublishedPackageInfo, + runCoverage, } from '@/api/client'; +import { FileBrowser } from '@/components/MoveDeploy/FileBrowser'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { CopyForAiMenu } from '@/components/ui/copy-for-ai'; +import { Label } from '@/components/ui/label'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; // Helper to parse CLI output and separate warnings from actual content interface ParsedOutput { @@ -42,34 +43,39 @@ interface ParsedOutput { // Simplified warning for user-friendly display interface SimplifiedWarning { - id: string; // e.g., "W99001" - title: string; // User-friendly title - description: string; // Simple explanation - suggestion: string; // What to do about it - location?: string; // File:line (simplified) - rawOutput: string; // Original output for advanced users + id: string; // e.g., "W99001" + title: string; // User-friendly title + description: string; // Simple explanation + suggestion: string; // What to do about it + location?: string; // File:line (simplified) + rawOutput: string; // Original output for advanced users } // Map warning codes to user-friendly explanations -const WARNING_EXPLANATIONS: Record = { - 'W99001': { +const WARNING_EXPLANATIONS: Record< + string, + { title: string; description: string; suggestion: string } +> = { + W99001: { title: 'Better return pattern available', - description: 'Your function sends an object directly to the caller. This works, but returning the object instead makes your code more flexible.', - suggestion: 'Consider using "public fun" that returns the object, so callers can decide what to do with it.', + description: + 'Your function sends an object directly to the caller. This works, but returning the object instead makes your code more flexible.', + suggestion: + 'Consider using "public fun" that returns the object, so callers can decide what to do with it.', }, - 'W09001': { + W09001: { title: 'Unused variable', description: 'You declared a variable but never used it.', suggestion: 'Remove the variable or prefix with underscore (_) if intentional.', }, - 'W09002': { + W09002: { title: 'Unused import', description: 'You imported something but never used it.', suggestion: 'Remove the unused import to keep code clean.', }, - 'W09003': { + W09003: { title: 'Unused function', - description: 'You defined a function but it\'s never called.', + description: "You defined a function but it's never called.", suggestion: 'Remove if not needed, or add "public" if it should be accessible.', }, }; @@ -83,7 +89,9 @@ function parseWarningToSimplified(rawWarning: string): SimplifiedWarning { // Extract location (file:line) const locationMatch = rawWarning.match(/┌─\s+([^:]+):(\d+):\d+/); - const location = locationMatch ? `${locationMatch[1].split('/').pop()}:${locationMatch[2]}` : undefined; + const location = locationMatch + ? `${locationMatch[1].split('/').pop()}:${locationMatch[2]}` + : undefined; // Get explanation from our map, or create generic one const explanation = WARNING_EXPLANATIONS[warningCode] || { @@ -122,16 +130,18 @@ function parseCliOutput(output: string): ParsedOutput { // Box drawing characters: ┌ ─ │ └ ├ ╭ ╮ ╯ ╰ const boxChars = /^[\s┌─│└├╭╮╯╰]/; // Also match lines starting with spaces, =, or containing "This warning" - return boxChars.test(line) || - line.startsWith(' ') || - line.startsWith(' ') || - line.startsWith('=') || - line.includes('This warning can be suppressed') || - line.includes('Returning an object') || - line.includes('Transaction sender') || - line.includes('Transfer of an object') || - line.includes('^^^^') || - line.trim() === ''; + return ( + boxChars.test(line) || + line.startsWith(' ') || + line.startsWith(' ') || + line.startsWith('=') || + line.includes('This warning can be suppressed') || + line.includes('Returning an object') || + line.includes('Transaction sender') || + line.includes('Transfer of an object') || + line.includes('^^^^') || + line.trim() === '' + ); }; for (const line of lines) { @@ -253,7 +263,9 @@ export function DevTools() { // File Browser State const [showBrowser, setShowBrowser] = useState(false); - const [browserTarget, setBrowserTarget] = useState<'coverage' | 'disassembly' | 'summary'>('coverage'); + const [browserTarget, setBrowserTarget] = useState<'coverage' | 'disassembly' | 'summary'>( + 'coverage' + ); // Check if module name is required for current coverage mode const moduleNameRequired = coverageMode === 'source' || coverageMode === 'bytecode'; @@ -348,11 +360,7 @@ export function DevTools() { setDisassemblyOutput(''); try { - const data = await disassembleModule( - modulePath.trim(), - showDebug, - showBytecodeMap - ); + const data = await disassembleModule(modulePath.trim(), showDebug, showBytecodeMap); setDisassemblyOutput(data.output); toast.success('Disassembly complete!'); } catch (error: any) { @@ -381,9 +389,7 @@ export function DevTools() { summaryFormat ); setSummaryOutput( - summaryFormat === 'json' - ? JSON.stringify(data.summary, null, 2) - : String(data.summary) + summaryFormat === 'json' ? JSON.stringify(data.summary, null, 2) : String(data.summary) ); toast.success('Summary generated!'); } catch (error: any) { @@ -395,6 +401,89 @@ export function DevTools() { } }; + // Copy-for-AI: assemble the active tool's inputs/outputs into shareable context + const copyToClipboard = (text: string, label: string) => { + navigator.clipboard.writeText(text); + toast.success(`${label} copied`); + }; + + const tabLabels: Record = { + coverage: 'Test Coverage', + disassemble: 'Bytecode Disassembly', + summary: 'Package Summary', + }; + + const aiState: Record = + activeTab === 'coverage' + ? { + tool: 'coverage', + packagePath: packagePath.trim() || null, + coverageMode, + moduleName: coverageModuleName.trim() || null, + detectedModules, + output: coverageOutput ? coverageOutput.slice(0, 6000) : null, + } + : activeTab === 'disassemble' + ? { + tool: 'disassemble', + modulePath: modulePath.trim() || null, + showDebug, + showBytecodeMap, + output: disassemblyOutput ? disassemblyOutput.slice(0, 6000) : null, + } + : { + tool: 'summary', + packagePath: summaryPackagePath.trim() || null, + packageId: summaryPackageId.trim() || null, + format: summaryFormat, + output: summaryOutput ? summaryOutput.slice(0, 6000) : null, + }; + + const hasExportable = + activeTab === 'coverage' + ? !!(packagePath.trim() || coverageOutput) + : activeTab === 'disassemble' + ? !!(modulePath.trim() || disassemblyOutput) + : !!(summaryPackagePath.trim() || summaryPackageId.trim() || summaryOutput); + + const aiActiveOutput = + activeTab === 'coverage' + ? coverageOutput + : activeTab === 'disassemble' + ? disassemblyOutput + : summaryOutput; + + const aiJson = JSON.stringify(aiState, null, 2); + + const aiMarkdown = [ + `# Sui Move Dev Tools — ${tabLabels[activeTab]}`, + '', + ...(activeTab === 'coverage' + ? [ + `- **Package path:** ${packagePath.trim() || 'not set'}`, + `- **Mode:** ${coverageMode}`, + coverageModuleName.trim() ? `- **Module:** ${coverageModuleName.trim()}` : null, + ] + : activeTab === 'disassemble' + ? [ + `- **Module path:** ${modulePath.trim() || 'not set'}`, + `- **Show debug:** ${showDebug ? 'yes' : 'no'}`, + `- **Show bytecode map:** ${showBytecodeMap ? 'yes' : 'no'}`, + ] + : [ + `- **Package path:** ${summaryPackagePath.trim() || 'not set'}`, + `- **Package ID:** ${summaryPackageId.trim() || 'not set'}`, + `- **Format:** ${summaryFormat}`, + ]), + aiActiveOutput ? '' : null, + aiActiveOutput ? '## Output' : null, + aiActiveOutput ? '```\n' + aiActiveOutput.slice(0, 6000) + '\n```' : null, + ] + .filter((line) => line !== null) + .join('\n'); + + const aiPrompt = `Here's the output from the Sui Move "${tabLabels[activeTab]}" dev tool:\n\n${aiMarkdown}\n\nHelp me interpret this and suggest concrete next steps (e.g. improving test coverage, understanding the bytecode, or reviewing the package structure).`; + return ( <>

@@ -405,9 +494,14 @@ export function DevTools() {

Dev Tools

- - Coverage · Disassemble · Summary - + {hasExportable && ( + + )}
{/* Main Content */} @@ -417,20 +511,22 @@ export function DevTools() { transition={{ type: 'spring', stiffness: 300, damping: 25, delay: 0.1 }} > - - - + + } className="flex-1"> Coverage - - + } className="flex-1"> Disassemble - - + } className="flex-1"> Summary +

+ {activeTab === 'coverage' && 'Move test coverage report for your package'} + {activeTab === 'disassemble' && 'View compiled bytecode as Move disassembly'} + {activeTab === 'summary' && 'Package structure and module summary'} +

{/* Coverage Tab */} @@ -450,13 +546,18 @@ export function DevTools() { Test Coverage Analysis - Run coverage on Move packages + + Run coverage on Move packages + {/* Package Path */}
-
+ ); +} + +export default DevstackBridge; diff --git a/apps/web/src/components/DynamicFieldExplorer/index.tsx b/apps/web/src/components/DynamicFieldExplorer/index.tsx index c0cdc73..9d4db87 100644 --- a/apps/web/src/components/DynamicFieldExplorer/index.tsx +++ b/apps/web/src/components/DynamicFieldExplorer/index.tsx @@ -1,85 +1,221 @@ -import { useState, useEffect } from 'react'; -import { useSearchParams, useNavigate } from 'react-router-dom'; -import { getDynamicFields } from '@/api/client'; +import { + AlertTriangle, + ChevronDown, + Copy, + ExternalLink, + Eye, + Filter, + History, + Info, + Layers, + Link2, + Network, + Package, + Plus, + RefreshCw, + Search, + Sparkles, + Zap, +} from 'lucide-react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import toast from 'react-hot-toast'; +import { useNavigate, useSearchParams } from 'react-router-dom'; +import { type DynamicFieldItem, getDynamicFields } from '@/api/services/objects'; +import type { ChartConfig } from '@/components/dither-kit/chart-context'; +import { Pie } from '@/components/dither-kit/pie'; +import { PieChart } from '@/components/dither-kit/pie-chart'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; +import { CopyForAiMenu } from '@/components/ui/copy-for-ai'; +import { Tooltip } from '@/components/ui/tooltip'; +import { buildAiContext } from '@/lib/ai-context'; +import { formatBalance, truncateAddress } from '@/utils/format'; import { Spinner } from '../shared/Spinner'; -import toast from 'react-hot-toast'; -import { Link2, Eye, Search, Copy, ExternalLink, ChevronDown, RefreshCw, ArrowRight } from 'lucide-react'; -interface DynamicField { - name: any; - bcsName: string; - type: string; - objectType: string; - objectId: string; - version: string; - digest: string; +// Helper to extract short type name (e.g., 0x2::coin::Coin<0x2::sui::SUI> -> Coin) +function getShortTypeName(fullType: string): string { + if (!fullType) return 'unknown'; + return ( + fullType + .replace(/0x[a-fA-F0-9]{1,64}::/g, '') + .split('::') + .pop() || fullType + ); } -// Parse field key from name object -function parseFieldKey(name: any): { type: string; value: string; displayType: string; displayValue: string } { - if (typeof name === 'object' && name !== null) { - const type = name.type || 'unknown'; - const value = name.value || JSON.stringify(name); - - // Human-readable type (remove package prefix) - const displayType = type.split('::').pop() || type; +// Parse field key/name info from GraphQL/JSON payload +function parseFieldKey(nameRaw: any): { + type: string; + value: string; + displayType: string; + displayValue: string; +} { + if (nameRaw && typeof nameRaw === 'object') { + const type = nameRaw.type || 'unknown'; + let value = nameRaw.value; + if (value === undefined && nameRaw.json !== undefined) { + value = nameRaw.json; + } + if (typeof value === 'object' && value !== null) { + value = JSON.stringify(value); + } else if (value === undefined) { + value = JSON.stringify(nameRaw); + } else { + value = String(value); + } - // Shortened value for display - const displayValue = typeof value === 'string' && value.length > 20 - ? `${value.slice(0, 10)}...${value.slice(-8)}` - : String(value); + const displayType = getShortTypeName(type); + const displayValue = + typeof value === 'string' && value.length > 24 + ? `${value.slice(0, 12)}...${value.slice(-8)}` + : String(value); - return { type, value, displayType, displayValue }; + return { type, value: String(value), displayType, displayValue }; } return { type: 'string', - value: String(name), + value: String(nameRaw ?? 'unknown'), displayType: 'string', - displayValue: String(name) + displayValue: String(nameRaw ?? 'unknown'), }; } -// Get short type name from full type path -function getShortTypeName(fullType: string): string { - const parts = fullType.split('::'); - return parts[parts.length - 1] || fullType; +// Normalized internal field format +interface NormalizedField { + raw: DynamicFieldItem; + index: number; + kind: 'FIELD' | 'OBJECT'; + fieldId: string; + nameType: string; + shortNameType: string; + nameValue: string; + shortNameValue: string; + valueType: string; + shortValueType: string; + childObjectId: string; + version: string; + previousTx: string; + storageRebate: string; + storageRebateSui: string; + jsonContent: any; } -// Get package ID from full type -function getPackageId(fullType: string): string { - const parts = fullType.split('::'); - if (parts[0] && parts[0].startsWith('0x')) { - return parts[0].length > 16 ? `${parts[0].slice(0, 8)}...${parts[0].slice(-6)}` : parts[0]; - } - return ''; -} +function normalizeFieldItem(item: DynamicFieldItem, index: number): NormalizedField { + const kind: 'FIELD' | 'OBJECT' = + item.kind === 'OBJECT' || item.kind === 'dynamic_object_field' ? 'OBJECT' : 'FIELD'; + + const keyInfo = parseFieldKey(item.name); + const valueType = + item.valueType || + item.fieldObject?.objectType || + item.value?.type || + item.objectType || + 'unknown'; + const childObjectId = item.fieldObject?.objectId || item.objectId || item.fieldId || ''; + const version = item.fieldObject?.version || item.version || '1'; + const previousTx = item.fieldObject?.previousTransaction || item.digest || ''; + const storageRebate = item.fieldObject?.storageRebate || '0'; + const storageRebateSui = formatBalance(storageRebate); -// Get type-based styling -function getTypeStyle(objectType: string): { icon: string; colorClass: string; bgClass: string; borderClass: string } { - const type = objectType.toLowerCase(); - if (type.includes('table')) return { icon: '📊', colorClass: 'text-cyan-400', bgClass: 'bg-cyan-500/20', borderClass: 'border-cyan-500/30' }; - if (type.includes('bag')) return { icon: '🎒', colorClass: 'text-purple-400', bgClass: 'bg-purple-500/20', borderClass: 'border-purple-500/30' }; - if (type.includes('vec') || type.includes('vector')) return { icon: '📋', colorClass: 'text-green-400', bgClass: 'bg-green-500/20', borderClass: 'border-green-500/30' }; - if (type.includes('item') || type.includes('nft') || type.includes('character')) return { icon: '🎮', colorClass: 'text-orange-400', bgClass: 'bg-orange-500/20', borderClass: 'border-orange-500/30' }; - if (type.includes('coin')) return { icon: '🪙', colorClass: 'text-yellow-400', bgClass: 'bg-yellow-500/20', borderClass: 'border-yellow-500/30' }; - return { icon: '📦', colorClass: 'text-blue-400', bgClass: 'bg-blue-500/20', borderClass: 'border-blue-500/30' }; + const jsonContent = + item.fieldObject?.json || + item.fieldObject?.contents || + item.value?.json || + (typeof item.value === 'object' ? item.value : null) || + item.name?.json || + null; + + return { + raw: item, + index, + kind, + fieldId: item.fieldId || childObjectId, + nameType: keyInfo.type, + shortNameType: keyInfo.displayType, + nameValue: keyInfo.value, + shortNameValue: keyInfo.displayValue, + valueType, + shortValueType: getShortTypeName(valueType), + childObjectId, + version, + previousTx, + storageRebate, + storageRebateSui, + jsonContent, + }; } export function DynamicFieldExplorer() { const navigate = useNavigate(); const [searchParams] = useSearchParams(); const [objectId, setObjectId] = useState(''); - const [fields, setFields] = useState([]); + const [queriedObjectId, setQueriedObjectId] = useState(''); + const [rawFields, setRawFields] = useState([]); const [isLoading, setIsLoading] = useState(false); const [hasNextPage, setHasNextPage] = useState(false); const [nextCursor, setNextCursor] = useState(null); - const [queriedObjectId, setQueriedObjectId] = useState(''); - const [expandedFields, setExpandedFields] = useState>(new Set()); + + // Tabs & filters + const [activeTab, setActiveTab] = useState<'table' | 'graph' | 'activity'>('table'); + const [kindFilter, setKindFilter] = useState<'ALL' | 'FIELD' | 'OBJECT'>('ALL'); + const [selectedNameTypes, setSelectedNameTypes] = useState>(new Set()); + const [selectedValueTypes, setSelectedValueTypes] = useState>(new Set()); + const [searchQuery, setSearchQuery] = useState(''); + const [expandedRows, setExpandedRows] = useState>(new Set()); + const [showOrphanWarning, setShowOrphanWarning] = useState(true); const [autoQueried, setAutoQueried] = useState(false); - // Auto-fill and query from URL param + // Graph state for lazy expansion: parentObjectId -> child fields + const [graphData, setGraphData] = useState>(new Map()); + const [loadingGraphNodes, setLoadingGraphNodes] = useState>(new Set()); + + const handleQueryWithId = useCallback( + async (id: string, cursor?: string) => { + const cleanId = id.trim(); + if (!cleanId && !cursor) { + toast.error('Please enter a valid Sui object ID'); + return; + } + + setIsLoading(true); + try { + const result = await getDynamicFields(cursor ? queriedObjectId : cleanId, cursor, 50); + + if (!cursor) { + setRawFields(result.data); + setQueriedObjectId(cleanId); + setExpandedRows(new Set()); + setSelectedNameTypes(new Set()); + setSelectedValueTypes(new Set()); + setGraphData(new Map()); + } else { + setRawFields((prev) => [...prev, ...result.data]); + } + + setHasNextPage(result.hasNextPage); + setNextCursor(result.nextCursor); + + if (!cursor) { + toast.success( + `Loaded ${result.data.length} dynamic field${result.data.length !== 1 ? 's' : ''}` + ); + } + } catch (error) { + const msg = error instanceof Error ? error.message : 'Failed to fetch dynamic fields'; + toast.error(msg); + if (!cursor) { + setRawFields([]); + setHasNextPage(false); + setNextCursor(null); + } + } finally { + setIsLoading(false); + } + }, + [queriedObjectId] + ); + + // Auto-fill from URL search params useEffect(() => { const urlObjectId = searchParams.get('objectId'); if (urlObjectId && !autoQueried) { @@ -89,413 +225,1133 @@ export function DynamicFieldExplorer() { handleQueryWithId(urlObjectId); }, 100); } - }, [searchParams, autoQueried]); - - const handleQueryWithId = async (id: string, cursor?: string) => { - if (!id.trim() && !cursor) { - toast.error('Please enter an object ID'); - return; - } + }, [searchParams, autoQueried, handleQueryWithId]); - setIsLoading(true); - try { - const result = await getDynamicFields( - cursor ? queriedObjectId : id, - cursor, - 50 - ); - - if (!cursor) { - setFields(result.data); - setQueriedObjectId(id); - setExpandedFields(new Set()); // Collapse all on new query - } else { - setFields(prev => [...prev, ...result.data]); - } + const handleQuery = (cursor?: string) => { + handleQueryWithId(objectId, cursor); + }; - setHasNextPage(result.hasNextPage); - setNextCursor(result.nextCursor); + // Expand graph child dynamic fields + const handleExpandGraphChild = async (childId: string) => { + if (graphData.has(childId) || loadingGraphNodes.has(childId)) return; - if (result.data.length === 0 && !cursor) { - // Don't show toast, we have a nice empty state - } else if (!cursor) { - toast.success(`Found ${result.data.length} field${result.data.length !== 1 ? 's' : ''}`); - } - } catch (error) { - const message = error instanceof Error ? error.message : 'Failed to query dynamic fields'; - toast.error(message); - if (!cursor) { - setFields([]); - setHasNextPage(false); - setNextCursor(null); - } + setLoadingGraphNodes((prev) => new Set(prev).add(childId)); + try { + const res = await getDynamicFields(childId, undefined, 20); + setGraphData((prev) => new Map(prev).set(childId, res.data)); + toast.success(`Expanded ${res.data.length} child fields for ${truncateAddress(childId)}`); + } catch { + toast.error(`Could not fetch dynamic fields for child object ${truncateAddress(childId)}`); + setGraphData((prev) => new Map(prev).set(childId, [])); } finally { - setIsLoading(false); + setLoadingGraphNodes((prev) => { + const next = new Set(prev); + next.delete(childId); + return next; + }); } }; - const handleQuery = async (cursor?: string) => { - await handleQueryWithId(objectId, cursor); - }; + // Normalize all items + const normalizedFields = useMemo(() => { + return rawFields.map((f, i) => normalizeFieldItem(f, i)); + }, [rawFields]); - const toggleExpanded = (index: number) => { - const newExpanded = new Set(expandedFields); - if (newExpanded.has(index)) { - newExpanded.delete(index); - } else { - newExpanded.add(index); - } - setExpandedFields(newExpanded); - }; + // Aggregations for composition summary (§2.1) + const stats = useMemo(() => { + const total = normalizedFields.length; + let fieldCount = 0; + let objectCount = 0; + const nameTypesMap = new Map(); + const valueTypesMap = new Map(); + + normalizedFields.forEach((f) => { + if (f.kind === 'OBJECT') objectCount++; + else fieldCount++; + + nameTypesMap.set(f.nameType, (nameTypesMap.get(f.nameType) || 0) + 1); + valueTypesMap.set(f.valueType, (valueTypesMap.get(f.valueType) || 0) + 1); + }); + + const distinctNameTypes = Array.from(nameTypesMap.keys()); + const distinctValueTypes = Array.from(valueTypesMap.keys()); + + // Donut chart dataset + const donutData = [ + { name: 'wrapped dynamic_field', count: fieldCount }, + { name: 'child dynamic_object_field', count: objectCount }, + ].filter((d) => d.count > 0); + + const donutConfig: ChartConfig = { + 'wrapped dynamic_field': { label: 'FIELD (wrapped)', color: 'blue' }, + 'child dynamic_object_field': { label: 'OBJECT (child)', color: 'green' }, + }; + + return { + total, + fieldCount, + objectCount, + distinctNameTypes, + distinctValueTypes, + donutData, + donutConfig, + nameTypesMap, + valueTypesMap, + }; + }, [normalizedFields]); + + // Filtering + const filteredFields = useMemo(() => { + return normalizedFields.filter((f) => { + if (kindFilter !== 'ALL' && f.kind !== kindFilter) return false; + + if (selectedNameTypes.size > 0 && !selectedNameTypes.has(f.nameType)) return false; + if (selectedValueTypes.size > 0 && !selectedValueTypes.has(f.valueType)) return false; + + if (searchQuery.trim()) { + const q = searchQuery.toLowerCase(); + const matchesKey = + f.nameValue.toLowerCase().includes(q) || f.nameType.toLowerCase().includes(q); + const matchesVal = + f.valueType.toLowerCase().includes(q) || f.childObjectId.toLowerCase().includes(q); + if (!matchesKey && !matchesVal) return false; + } + + return true; + }); + }, [normalizedFields, kindFilter, selectedNameTypes, selectedValueTypes, searchQuery]); + + // Recency sorted fields for Activity tab (§2.4) + const recencyFields = useMemo(() => { + return [...filteredFields].sort((a, b) => { + const vA = parseInt(a.version, 10) || 0; + const vB = parseInt(b.version, 10) || 0; + return vB - vA; + }); + }, [filteredFields]); + + // Copy for AI export (§4) + const aiExportContext = useMemo(() => { + if (!queriedObjectId || normalizedFields.length === 0) return null; + + const CAP = 200; + const capped = normalizedFields.slice(0, CAP); + const statePayload = { + parentObjectId: queriedObjectId, + totalFields: normalizedFields.length, + fieldKindSplit: { + wrapped_dynamic_field: stats.fieldCount, + child_dynamic_object_field: stats.objectCount, + }, + distinctNameTypesCount: stats.distinctNameTypes.length, + distinctValueTypesCount: stats.distinctValueTypes.length, + fields: capped.map((f) => ({ + kind: f.kind, + nameType: f.nameType, + nameValue: f.nameValue, + childObjectId: f.childObjectId, + valueType: f.valueType, + version: f.version, + storageRebate: f.storageRebate, + previousTransaction: f.previousTx, + isAddressableByObjectId: f.kind === 'OBJECT', + })), + }; + + const rules = [ + 'dynamic_field (FIELD): Value is wrapped in a Field object. It does NOT have its own key ability and CANNOT be queried independently by object ID.', + 'dynamic_object_field (OBJECT): Value stays its own object with key+store abilities. It IS independently addressable and viewable in object explorers / wallets.', + 'Lazy loading: Dynamic fields cost gas only when accessed in Move (unlike standard struct fields which are loaded together).', + 'Orphaned fields warning: Deleting a parent object UID without deleting attached dynamic fields renders those fields permanently inaccessible and storage rebate unrecoverable.', + 'Limit: Maximum 1000 dynamic fields can be touched in a single transaction.', + ]; + + const markdownTable = [ + `# Dynamic Fields of \`${queriedObjectId}\``, + '', + `Total fields: **${stats.total}** (${stats.fieldCount} FIELD wrapped, ${stats.objectCount} OBJECT child)`, + '', + '| Kind | Key Type | Key Value | Child Object ID | Value Type | Addressable? | Version |', + '| --- | --- | --- | --- | --- | --- | --- |', + ...capped.map( + (f) => + `| **${f.kind}** | \`${f.shortNameType}\` | \`${f.shortNameValue}\` | \`${f.childObjectId}\` | \`${f.shortValueType}\` | ${ + f.kind === 'OBJECT' ? 'Yes (DOF child)' : 'No (Wrapped DF)' + } | ${f.version} |` + ), + ].join('\n'); + + const promptText = buildAiContext({ + title: `Sui Dynamic Fields - ${queriedObjectId}`, + intro: [ + `Analysis of dynamic fields attached to Sui parent object \`${queriedObjectId}\`.`, + `Contains ${stats.total} attached fields (${stats.fieldCount} wrapped \`dynamic_field\`s and ${stats.objectCount} child \`dynamic_object_field\`s).`, + ], + stateJson: JSON.stringify(statePayload, null, 2), + endpoints: [ + { + method: 'GET', + path: `/api/dynamic-fields/${queriedObjectId}`, + effect: 'Query attached dynamic fields list for object ID', + }, + ], + rules, + examples: [ + 'Explain why I cannot find a wrapped dynamic_field value by its object ID', + 'Identify which dynamic object fields are child objects that can be transferred or inspected', + 'Analyze gas rebate unrecoverable if parent UID is deleted', + ], + extra: markdownTable, + }); + + return { + prompt: promptText, + json: JSON.stringify(statePayload, null, 2), + markdown: markdownTable, + }; + }, [queriedObjectId, normalizedFields, stats]); const copyToClipboard = (text: string, label: string) => { navigator.clipboard.writeText(text); toast.success(`${label} copied`); }; - const handleViewObject = (objectId: string) => { - navigate(`/app/objects/${objectId}`); + const toggleNameTypeFilter = (type: string) => { + setSelectedNameTypes((prev) => { + const next = new Set(prev); + if (next.has(type)) next.delete(type); + else next.add(type); + return next; + }); }; - const handleExploreFields = (newObjectId: string) => { - setObjectId(newObjectId); - handleQueryWithId(newObjectId); + const toggleValueTypeFilter = (type: string) => { + setSelectedValueTypes((prev) => { + const next = new Set(prev); + if (next.has(type)) next.delete(type); + else next.add(type); + return next; + }); }; - const handleOpenExplorer = (objectId: string) => { - // Default to testnet, could be made dynamic based on active env - window.open(`https://suiscan.xyz/testnet/object/${objectId}`, '_blank'); + const toggleRowExpanded = (idx: number) => { + setExpandedRows((prev) => { + const next = new Set(prev); + if (next.has(idx)) next.delete(idx); + else next.add(idx); + return next; + }); }; return ( -
- {/* Header */} -
- -

Dynamic Fields

+
+ {/* Top Header */} +
+
+
+
+ +
+

+ Dynamic Fields Explorer +

+
+

+ Inspect heterogeneous runtime key-value storage attached to Move objects + (`sui::dynamic_field` & `sui::dynamic_object_field`). +

+
+ + {aiExportContext && ( + + )}
- {/* Search Input */} -
-
- -
-
- - setObjectId(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') { - handleQuery(); - } - }} - placeholder="0x..." - className="w-full pl-10 pr-3 py-2 bg-secondary border border-border rounded-lg text-sm font-mono text-foreground placeholder:text-tertiary focus:outline-none focus:border-[#4da2ff]/50 transition-colors" - disabled={isLoading} - /> -
- + {/* Query Search Bar */} +
+ +
+
+ + setObjectId(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') handleQuery(); + }} + placeholder="Enter Sui object ID (e.g., 0x5 or 0x...)" + className="w-full pl-10 pr-4 py-2.5 bg-secondary border border-border rounded-lg text-sm font-mono text-foreground placeholder:text-tertiary focus:outline-none focus:border-primary/50 transition-colors" + disabled={isLoading} + />
+
- {/* Results */} - {isLoading && fields.length === 0 ? ( -
- + {/* Main Content Area */} + {isLoading && normalizedFields.length === 0 ? ( +
+ +

+ Loading dynamic fields from Sui chain... +

- ) : fields.length > 0 ? ( -
- {/* Parent Object Card */} -
-
+ ) : queriedObjectId && normalizedFields.length > 0 ? ( +
+ {/* Section 2.1: Header Composition Summary Strip */} +
+ {/* Donut Chart: DF vs DOF Split */} +
- Parent object - + + Storage Composition + + + {stats.total} total +
-
copyToClipboard(queriedObjectId, 'Object ID')} - > - {queriedObjectId} - + +
+
+ + + +
+
+
+
+
+
{stats.fieldCount} FIELD
+
+ Wrapped `dynamic_field` +
+
+
+
+
+
+
+ {stats.objectCount} OBJECT +
+
+ Child `dynamic_object_field` +
+
+
+
-
- - {fields.length} field{fields.length !== 1 ? 's' : ''} - -
+ + {/* Heterogeneous Types Summary */} +
+
+
+ + Heterogeneous Storage + + +
+
+
+
+ {stats.distinctValueTypes.length} +
+
Distinct Value Types
+
+
+
+ {stats.distinctNameTypes.length} +
+
Distinct Name Types
+
+
+
+
+ Top value type:{' '} + + {getShortTypeName(stats.distinctValueTypes[0] || '')} + +
+
+ + {/* Parent Object Context Card */} +
+
+
+ + Parent Object Context + + +
+ +
+ +
+ + + SuiScan +
- {/* Fields List */} -
- {fields.map((field, index) => { - const isExpanded = expandedFields.has(index); - const keyInfo = parseFieldKey(field.name); - const typeStyle = getTypeStyle(field.objectType); - const shortType = getShortTypeName(field.objectType); - - return ( -
+
+
+ + Important Concept: Dynamic Fields & Orphaned Storage +
+ +
+

+ • FIELD (`dynamic_field`) values are wrapped in a `Field` + struct and carry `store`. They are not addressable by object ID. +
OBJECT (`dynamic_object_field`) values retain their + `key+store` abilities as child objects and can be viewed by object + ID. +
Orphaned Fields Footgun: Deleting a parent object's + `UID` without explicitly removing attached fields makes those fields permanently + inaccessible, locking their storage rebate forever. +

+
+ )} + + {/* View Mode Tabs & Filter Controls */} +
+
+ {/* Tabs */} +
+ + + +
+ + {/* Kind Filter Buttons */} +
+ Kind: + {(['ALL', 'FIELD', 'OBJECT'] as const).map((k) => ( + + ))} +
+
+ + {/* Filter Bar: Chips & Search */} +
+
+
+ + setSearchQuery(e.target.value)} + placeholder="Filter by key name, value, or object ID..." + className="w-full pl-9 pr-3 py-1.5 bg-secondary border border-border rounded-lg text-xs font-mono text-foreground placeholder:text-tertiary focus:outline-none focus:border-primary/50" + /> +
+ + {(selectedNameTypes.size > 0 || selectedValueTypes.size > 0 || searchQuery) && ( + + )} +
+ + {/* Filter Chips: Name Types */} + {stats.distinctNameTypes.length > 0 && ( +
+ Name Types: + {stats.distinctNameTypes.map((t) => { + const active = selectedNameTypes.has(t); + const short = getShortTypeName(t); + return ( + + ); + })} +
+ )} + + {/* Filter Chips: Value Types */} + {stats.distinctValueTypes.length > 0 && ( +
+ Value Types: + {stats.distinctValueTypes.map((t) => { + const active = selectedValueTypes.has(t); + const short = getShortTypeName(t); + return ( + + ); + })} +
+ )} +
+
+ + {/* Render Tab Views */} + {filteredFields.length === 0 ? ( +
+ No dynamic fields match the active filter criteria. +
+ ) : activeTab === 'table' ? ( + /* ================= VIEW 2.2: TABLE VIEW ================= */ +
+ {filteredFields.map((f) => { + const isExpanded = expandedRows.has(f.index); + const isObjectKind = f.kind === 'OBJECT'; + + return (
toggleExpanded(index)} + key={`${f.childObjectId}-${f.index}`} + className="rounded-xl bg-card border border-border overflow-hidden transition-colors" > -
- {typeStyle.icon} -
-
-
- - {shortType} - - - - Field {index + 1} + {/* Item Row Bar */} +
+ {/* Kind Badge */} + + + {f.kind} -
-
- {field.objectId.slice(0, 12)}...{field.objectId.slice(-8)} -
-
- -
+ - {/* Expanded Details */} - {isExpanded && ( -
- {/* Key Section */} -
-
- 🔑 - Key + {/* Key & Value Brief */} +
+ {/* Name (Key) */} +
+ + Key ({f.shortNameType}) + + + {f.shortNameValue} +
-
-
- Type - {keyInfo.displayType} -
-
- Value -
+ + Value Type + + + {f.shortValueType} + +
+ + {/* Child Object ID */} +
+ + Child ID + + {isObjectKind ? ( +
-
+ {truncateAddress(f.childObjectId)} + + + ) : ( + + + {truncateAddress(f.childObjectId)} (wrapped) + + + )}
- {/* Arrow */} -
- -
+ +
- {/* Value Section */} -
-
- {typeStyle.icon} - - Value (stored object) - -
-
-
- Object ID -
{ - e.stopPropagation(); - copyToClipboard(field.objectId, 'Object ID'); - }} - > - {field.objectId.slice(0, 10)}...{field.objectId.slice(-8)} - + {/* Expanded Row Detail */} + {isExpanded && ( +
+
+ {/* Name (Key) Detail Box */} +
+
+ 🔑 Key Specification +
+
+
+ Type: + + {f.nameType} + +
+
+ Value: + + {f.nameValue} + +
-
- Type - {shortType} + + {/* Value / Child Object Detail Box */} +
+
+ 📦 Stored Field Object +
+
+
+ Kind: + + {f.kind === 'OBJECT' ? 'dynamic_object_field' : 'dynamic_field'} + +
+
+ Object ID: + + {f.childObjectId} + +
+
+ Version: + {f.version} +
+
+ Storage Rebate: + + {f.storageRebateSui} SUI ({f.storageRebate} MIST) + +
+
-
- Version - {field.version} +
+ + {/* Actions Row */} +
+ {isObjectKind ? ( + + ) : ( + + + + )} + + + + {f.previousTx && ( + + )} +
+ + {/* Decoded JSON Content */} + {f.jsonContent && ( +
+ + Decoded Field Contents (JSON) + +
+                              {JSON.stringify(f.jsonContent, null, 2)}
+                            
- {getPackageId(field.objectType) && ( -
- Package - {getPackageId(field.objectType)} -
+ )} +
+ )} +
+ ); + })} +
+ ) : activeTab === 'graph' ? ( + /* ================= VIEW 2.3: RELATIONSHIPS GRAPH VIEW ================= */ +
+
+
+

+ + Dynamic Field Hierarchy Tree +

+

+ Parent → Children node tree. Solid borders represent addressable `OBJECT` + children; dashed borders represent wrapped `FIELD` values. +

+
+
+ + {/* Tree Container */} +
+ {/* Root Parent Node */} +
+ + Parent Object + + ({truncateAddress(queriedObjectId)}) + + + {filteredFields.length} fields + +
+ + {/* Level 1 Children List */} +
+ {filteredFields.slice(0, 50).map((f) => { + const isObj = f.kind === 'OBJECT'; + const childFields = graphData.get(f.childObjectId); + const isGraphLoading = loadingGraphNodes.has(f.childObjectId); + + return ( +
+ {/* Node Card */} +
+ + {f.kind} + + + + Key: {f.shortNameValue} + + + {f.shortValueType} + + + ({truncateAddress(f.childObjectId)}) + + + {isObj && ( + )}
+ + {/* Level 2 Sub-Children Graph */} + {childFields && childFields.length > 0 && ( +
+ {childFields.slice(0, 20).map((cf, cidx) => { + const norm = normalizeFieldItem(cf, cidx); + return ( +
+ {norm.kind} + {norm.shortNameValue} + + {norm.shortValueType} + + ({truncateAddress(norm.childObjectId)}) + +
+ ); + })} + {childFields.length > 20 && ( +
+ + {childFields.length - 20} more nested child fields hidden +
+ )} +
+ )} + {childFields && childFields.length === 0 && ( +
+ No nested dynamic fields. +
+ )}
+ ); + })} - {/* Actions */} -
-
+
+
+ ) : ( + /* ================= VIEW 2.4: ACTIVITY & RECENCY VIEW ================= */ +
+
+
+ + + Sorted by version descending as a proxy for update recency. + +
+ + {recencyFields.length} fields + +
+ +
+ {recencyFields.map((f) => ( +
+
+
+ - - View object - + {f.kind} + + + Key: {f.shortNameValue} + + + {f.shortValueType} +
+
+ Child ID: {f.childObjectId} +
+
+ +
+
+
Version
+
{f.version}
+
+
+
Storage Rebate
+
{f.storageRebateSui} SUI
+
+ {f.previousTx ? ( - -
+ ) : ( + - + )}
- )} -
- ); - })} -
+
+ ))} +
+
+ )} {/* Load More Button */} {hasNextPage && ( -
+
)} - - {/* Footer Stats */} -
- - {fields.length} field{fields.length !== 1 ? 's' : ''} loaded - {hasNextPage && ' • more available'} - -
) : queriedObjectId ? ( - /* Empty State with Education */ -
-
-
📭
-
No dynamic fields
-
This object doesn't have any dynamic fields attached.
- -
-
- 💡 - What are dynamic fields? -
-
-

Dynamic fields let you attach key-value data to objects at runtime without declaring them in the Move struct definition.

-
-
- 🎮 - Game inventory (items on a character) -
-
- 🖼️ - NFT metadata extensions -
-
- ⚙️ - Dynamic configuration storage -
-
-
-
- - -
+ /* Empty State */ +
+
📭
+

No Dynamic Fields Found

+

+ The object{' '} + + {queriedObjectId} + {' '} + exists but has no attached dynamic fields (`sui::dynamic_field` or + `sui::dynamic_object_field`). +

+
) : ( - /* Initial State */ -
-
-
🔍
-
Explore dynamic fields
-
- Enter an object ID to see its attached dynamic fields -
+ /* Initial Ready State */ +
+
+ 🔍 +
+
+

Explore Dynamic Fields

+

+ Enter any Sui object ID above to inspect its heterogeneous runtime storage, + relationship tree, and ability rules. +

+
-
-
- What you'll see +
+
+
1. FIELD (`dynamic_field`)
+
+ Wrapped value with `store` ability. Not independently addressable by object ID.
-
-
- 🔑 - Key type and value for each field -
-
- 📦 - Stored object details (type, version) -
-
- 🔗 - Navigation to view or explore nested objects -
+
+
+
2. OBJECT (`dynamic_object_field`)
+
+ Child object with `key+store` abilities. Addressable & viewable by object ID. +
+
+
+
3. Hierarchy Tree
+
+ Interactive graph view with lazy node branch expansion.
diff --git a/apps/web/src/components/EnvironmentList/index.tsx b/apps/web/src/components/EnvironmentList/index.tsx index 8250e05..a065273 100644 --- a/apps/web/src/components/EnvironmentList/index.tsx +++ b/apps/web/src/components/EnvironmentList/index.tsx @@ -1,10 +1,11 @@ import { clsx } from 'clsx'; import { Plus, Trash2 } from 'lucide-react'; -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import toast from 'react-hot-toast'; import { getChainIdentifier } from '@/api/client'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; +import { CopyForAiMenu } from '@/components/ui/copy-for-ai'; import { useAppStore } from '@/stores/useAppStore'; import { Spinner } from '../shared/Spinner'; @@ -59,6 +60,44 @@ export function EnvironmentList() { return env.alias.toLowerCase().includes(query) || env.rpc.toLowerCase().includes(query); }); + const copyToClipboard = (text: string, label: string) => { + navigator.clipboard.writeText(text); + toast.success(`${label} copied`); + }; + + // Copy-for-AI export. Public only: env name, rpc url, active flag - all of + // which are plain network config, no secrets. + const aiExport = useMemo(() => { + const envs = filteredEnvs.map((e) => ({ + name: e.alias, + rpcUrl: e.rpc, + active: e.isActive, + })); + const active = envs.find((e) => e.active); + const prompt = [ + `I'm using the Sui CLI with ${envs.length} configured environment${envs.length !== 1 ? 's' : ''}.`, + active ? `The active environment is "${active.name}" (${active.rpcUrl}).` : '', + chainId + ? `Active chain identifier: ${chainId}${chainNetwork ? ` (${chainNetwork})` : ''}.` + : '', + 'Environments:', + ...envs.map((e) => `- ${e.name}: ${e.rpcUrl}${e.active ? ' [active]' : ''}`), + '', + 'Help me work with these Sui network environments.', + ] + .filter(Boolean) + .join('\n'); + const json = JSON.stringify({ chainId, chainNetwork, environments: envs }, null, 2); + const markdown = [ + '# Sui CLI Environments', + '', + '| Name | RPC URL | Active |', + '| --- | --- | --- |', + ...envs.map((e) => `| ${e.name} | \`${e.rpcUrl}\` | ${e.active ? 'yes' : 'no'} |`), + ].join('\n'); + return { prompt, json, markdown }; + }, [filteredEnvs, chainId, chainNetwork]); + const handleSwitch = async (alias: string) => { try { await switchEnvironment(alias); @@ -113,6 +152,21 @@ export function EnvironmentList() { return (
+ {/* Header */} + {filteredEnvs.length > 0 && ( +
+ + {filteredEnvs.length} environment{filteredEnvs.length !== 1 ? 's' : ''} + + +
+ )} + {/* Chain Identifier Display */} {chainId && (
@@ -177,7 +231,11 @@ export function EnvironmentList() {
) : ( - diff --git a/apps/web/src/components/EventExplorer/index.tsx b/apps/web/src/components/EventExplorer/index.tsx index b5ee44c..b6d5578 100644 --- a/apps/web/src/components/EventExplorer/index.tsx +++ b/apps/web/src/components/EventExplorer/index.tsx @@ -2,33 +2,34 @@ * EventExplorer - Decode and understand Sui events */ -import React, { useState, useCallback, useMemo } from 'react'; -import { motion, AnimatePresence } from 'framer-motion'; +import { AnimatePresence, motion } from 'framer-motion'; import { - Search, - Zap, + Activity, + ArrowRightLeft, + BarChart3, + Check, ChevronDown, ChevronRight, - Copy, - Check, - Filter, - Activity, Coins, - ArrowRightLeft, - FileCode, + Copy, Database, - TrendingUp, - Users, - Shield, - Sparkles, ExternalLink, + FileCode, + Filter, Info, Loader2, - BarChart3, + Search, + Shield, + Sparkles, + TrendingUp, + Users, + Zap, } from 'lucide-react'; +import React, { useCallback, useMemo, useState } from 'react'; import toast from 'react-hot-toast'; import { apiClient } from '@/api/client'; import { Button } from '@/components/ui/button'; +import { CopyForAiMenu } from '@/components/ui/copy-for-ai'; interface ParsedEvent { id: string; @@ -45,10 +46,26 @@ interface ParsedEvent { // Known protocol detection const KNOWN_PROTOCOLS: Record = { - '0x8d97f1cd6ac663735be08d1d2b6d02a159e711586461306ce60a2b7a6a565a9e': { name: 'Pyth Oracle', color: 'text-purple-400', icon: }, - '0x5d4b302506645c37ff133b98c4b50a5ae14841659738d6d733d59d0d217a93bf': { name: 'Wormhole', color: 'text-blue-400', icon: }, - '0xa0eba10b173538c8fecca1dff298e488402cc9ff374f8a12ca7758eebe830b66': { name: 'Cetus DEX', color: 'text-cyan-400', icon: }, - '0x91bfbc386a41afcfd9b2533058d7e915a1d3829089cc268ff4333d54d6339ca1': { name: 'Turbos DEX', color: 'text-green-400', icon: }, + '0x8d97f1cd6ac663735be08d1d2b6d02a159e711586461306ce60a2b7a6a565a9e': { + name: 'Pyth Oracle', + color: 'text-purple-400', + icon: , + }, + '0x5d4b302506645c37ff133b98c4b50a5ae14841659738d6d733d59d0d217a93bf': { + name: 'Wormhole', + color: 'text-blue-400', + icon: , + }, + '0xa0eba10b173538c8fecca1dff298e488402cc9ff374f8a12ca7758eebe830b66': { + name: 'Cetus DEX', + color: 'text-cyan-400', + icon: , + }, + '0x91bfbc386a41afcfd9b2533058d7e915a1d3829089cc268ff4333d54d6339ca1': { + name: 'Turbos DEX', + color: 'text-green-400', + icon: , + }, '0xdee9': { name: 'DeepBook', color: 'text-yellow-400', icon: }, '0x2': { name: 'Sui Framework', color: 'text-blue-400', icon: }, '0x3': { name: 'Sui System', color: 'text-blue-400', icon: }, @@ -57,28 +74,28 @@ const KNOWN_PROTOCOLS: Record = { // DeFi - 'SwapEvent': 'Token swap executed', - 'AddLiquidityEvent': 'Liquidity added to pool', - 'RemoveLiquidityEvent': 'Liquidity removed from pool', - 'FlashLoanEvent': 'Flash loan executed', - 'BorrowEvent': 'Asset borrowed', - 'RepayEvent': 'Loan repaid', - 'LiquidateEvent': 'Position liquidated', + SwapEvent: 'Token swap executed', + AddLiquidityEvent: 'Liquidity added to pool', + RemoveLiquidityEvent: 'Liquidity removed from pool', + FlashLoanEvent: 'Flash loan executed', + BorrowEvent: 'Asset borrowed', + RepayEvent: 'Loan repaid', + LiquidateEvent: 'Position liquidated', // Oracle - 'PriceFeedUpdateEvent': 'Price feed updated', - 'TemporalNumericValueFeedUpdateEvent': 'Oracle price update', - 'PriceInfoObject': 'Price information stored', + PriceFeedUpdateEvent: 'Price feed updated', + TemporalNumericValueFeedUpdateEvent: 'Oracle price update', + PriceInfoObject: 'Price information stored', // NFT - 'MintEvent': 'NFT minted', - 'TransferEvent': 'Asset transferred', - 'BurnEvent': 'Asset burned', + MintEvent: 'NFT minted', + TransferEvent: 'Asset transferred', + BurnEvent: 'Asset burned', // Staking - 'StakeEvent': 'Tokens staked', - 'UnstakeEvent': 'Tokens unstaked', - 'ClaimRewardsEvent': 'Rewards claimed', + StakeEvent: 'Tokens staked', + UnstakeEvent: 'Tokens unstaked', + ClaimRewardsEvent: 'Rewards claimed', // General - 'PackagePublish': 'Contract deployed', - 'Upgrade': 'Contract upgraded', + PackagePublish: 'Contract deployed', + Upgrade: 'Contract upgraded', }; function truncateAddress(address: string, chars = 6): string { @@ -109,7 +126,10 @@ function getEventDescription(eventName: string): string { } // Generate from name - const words = eventName.replace(/([A-Z])/g, ' $1').trim().split(' '); + const words = eventName + .replace(/([A-Z])/g, ' $1') + .trim() + .split(' '); return words.join(' ').toLowerCase(); } @@ -118,13 +138,18 @@ function getEventIcon(eventName: string): React.ReactNode { if (name.includes('swap')) return ; if (name.includes('transfer')) return ; if (name.includes('mint')) return ; - if (name.includes('price') || name.includes('oracle') || name.includes('feed')) return ; + if (name.includes('price') || name.includes('oracle') || name.includes('feed')) + return ; if (name.includes('stake')) return ; if (name.includes('liquidity')) return ; return ; } -function EventCard({ event, isExpanded, onToggle }: { +function EventCard({ + event, + isExpanded, + onToggle, +}: { event: ParsedEvent; isExpanded: boolean; onToggle: () => void; @@ -172,9 +197,7 @@ function EventCard({ event, isExpanded, onToggle }: {
{description}
-
- {event.module} -
+
{event.module}
{/* Expanded Content */} @@ -192,9 +215,18 @@ function EventCard({ event, isExpanded, onToggle }: {
Package
- {truncateAddress(event.packageId, 8)} -
@@ -202,9 +234,18 @@ function EventCard({ event, isExpanded, onToggle }: {
Sender
- {truncateAddress(event.sender, 8)} -
@@ -214,11 +255,20 @@ function EventCard({ event, isExpanded, onToggle }: {
Event Type -
- {event.type} + + {event.type} +
{/* Event Data */} @@ -227,7 +277,9 @@ function EventCard({ event, isExpanded, onToggle }: {
Event Data
diff --git a/apps/web/src/components/FaucetForm/index.tsx b/apps/web/src/components/FaucetForm/index.tsx index 355d129..91c9749 100644 --- a/apps/web/src/components/FaucetForm/index.tsx +++ b/apps/web/src/components/FaucetForm/index.tsx @@ -1,9 +1,19 @@ import { clsx } from 'clsx'; -import { AlertTriangle, CheckCircle2, Copy, Droplet, ExternalLink, MessageCircle, X, XCircle } from 'lucide-react'; +import { + AlertTriangle, + CheckCircle2, + Copy, + Droplet, + ExternalLink, + MessageCircle, + X, + XCircle, +} from 'lucide-react'; import { useEffect, useState } from 'react'; import toast from 'react-hot-toast'; import { useSearchParams } from 'react-router-dom'; import { Button } from '@/components/ui/button'; +import { CopyForAiMenu } from '@/components/ui/copy-for-ai'; import { useAppStore } from '@/stores/useAppStore'; import { Spinner } from '../shared/Spinner'; @@ -21,16 +31,6 @@ interface FaucetSource { } const FAUCET_SOURCES: FaucetSource[] = [ - { - id: 'fm-faucet', - name: 'FM Faucet', - description: 'Contact @rongmauhong (Telegram) or @222tee (X) - no captcha', - networks: ['testnet'], - type: 'web', - url: 'https://fmfaucet.xyz', - dailyLimit: '2 requests/day', - perRequestAmount: '1 SUI', - }, { id: 'sui-web-faucet', name: 'Sui Web Faucet', @@ -41,16 +41,6 @@ const FAUCET_SOURCES: FaucetSource[] = [ dailyLimit: 'Rate limited', perRequestAmount: '1 SUI', }, - { - id: 'blockbolt-faucet', - name: 'Blockbolt Faucet', - description: 'Community faucet - no captcha', - networks: ['devnet', 'testnet'], - type: 'web', - url: 'https://faucet.blockbolt.io/', - dailyLimit: 'Limited', - perRequestAmount: '1 SUI', - }, { id: 'n1stake-faucet', name: 'n1stake Faucet', @@ -81,6 +71,17 @@ const FAUCET_SOURCES: FaucetSource[] = [ dailyLimit: '1 request/day', perRequestAmount: '0.5 SUI', }, + { + id: 'sui-http-api', + name: 'Official HTTP API', + description: + 'POST {"FixedAmountRequest":{"recipient":"
"}} - what the button above uses', + networks: ['devnet', 'testnet'], + type: 'web', + url: 'https://docs.sui.io/getting-started/onboarding/get-coins', + dailyLimit: 'Rate limited', + perRequestAmount: '1 SUI', + }, { id: 'sui-discord', name: 'Sui Discord Faucet', @@ -212,6 +213,39 @@ export function FaucetForm() { setCustomAddress(''); }; + const copyToClipboard = (text: string, label: string) => { + navigator.clipboard.writeText(text); + toast.success(`${label} copied`); + }; + + const aiJson = JSON.stringify( + { + targetAddress: targetAddress ?? null, + network: selectedNetwork, + isExternalAddress, + activeEnvironment: activeEnv?.alias ?? null, + lastResult, + }, + null, + 2 + ); + + const aiMarkdown = [ + '# Sui faucet request', + '', + `- **Address:** ${targetAddress ?? '(none)'}`, + `- **Network:** ${selectedNetwork}`, + `- **External address:** ${isExternalAddress ? 'yes' : 'no'}`, + activeEnv ? `- **Active environment:** ${activeEnv.alias}` : null, + lastResult + ? `- **Last request:** ${lastResult.success ? 'success' : 'failed'} — ${lastResult.message}` + : null, + ] + .filter(Boolean) + .join('\n'); + + const aiPrompt = `Explain how to fund this Sui address on ${selectedNetwork}: ${targetAddress ?? '(no address)'}.\n\n${aiMarkdown}\n\nWalk me through requesting test tokens and list the best faucet options for this network.`; + if (!targetAddress && !activeAddress) { return
No address selected
; } @@ -231,8 +265,15 @@ export function FaucetForm() { )}
+ {isExternalAddress && (
-
{availableSources.map((source) => ( -
source.url && openExternalFaucet(source.url)} > @@ -423,7 +464,7 @@ export function FaucetForm() { {source.perRequestAmount} {source.dailyLimit} -
+ ))}
diff --git a/apps/web/src/components/GasAnalysis/index.tsx b/apps/web/src/components/GasAnalysis/index.tsx index 8758d2f..c09e52d 100644 --- a/apps/web/src/components/GasAnalysis/index.tsx +++ b/apps/web/src/components/GasAnalysis/index.tsx @@ -2,35 +2,36 @@ * GasAnalysis - Comprehensive gas analysis with transaction insights */ -import React, { useState, useCallback } from 'react'; -import { motion, AnimatePresence } from 'framer-motion'; +import { AnimatePresence, motion } from 'framer-motion'; import { - Search, - Fuel, - Zap, - Database, + Activity, + AlertTriangle, ArrowDownRight, - HelpCircle, + ArrowRightLeft, CheckCircle2, - AlertTriangle, - Lightbulb, - Loader2, - Copy, ChevronDown, ChevronRight, - Activity, - TrendingUp, Clock, - Package, Coins, - ArrowRightLeft, + Copy, + Database, FileCode, - Sparkles, + Fuel, + HelpCircle, Info, + Lightbulb, + Loader2, + Package, + Search, + Sparkles, + TrendingUp, + Zap, } from 'lucide-react'; +import React, { useCallback, useState } from 'react'; import toast from 'react-hot-toast'; import { apiClient } from '@/api/client'; import { Button } from '@/components/ui/button'; +import { CopyForAiMenu } from '@/components/ui/copy-for-ai'; interface GasBreakdown { computationCost: string; @@ -95,7 +96,11 @@ function analyzeGasDistribution(breakdown: GasBreakdown) { } // Estimate transaction type from gas pattern -function estimateTransactionType(breakdown: GasBreakdown): { type: string; icon: React.ReactNode; description: string } { +function estimateTransactionType(breakdown: GasBreakdown): { + type: string; + icon: React.ReactNode; + description: string; +} { const computation = Number(breakdown.computationCost); const storage = Number(breakdown.storageCost); const rebate = Number(breakdown.storageRebate); @@ -106,7 +111,7 @@ function estimateTransactionType(breakdown: GasBreakdown): { type: string; icon: return { type: 'Cleanup/Delete', icon: , - description: 'Objects were deleted, freeing storage' + description: 'Objects were deleted, freeing storage', }; } @@ -115,7 +120,7 @@ function estimateTransactionType(breakdown: GasBreakdown): { type: string; icon: return { type: 'Object Creation', icon: , - description: 'New objects created (NFT mint, deploy, etc.)' + description: 'New objects created (NFT mint, deploy, etc.)', }; } @@ -124,7 +129,7 @@ function estimateTransactionType(breakdown: GasBreakdown): { type: string; icon: return { type: 'DeFi/Swap', icon: , - description: 'Complex computation (swap, stake, etc.)' + description: 'Complex computation (swap, stake, etc.)', }; } @@ -133,7 +138,7 @@ function estimateTransactionType(breakdown: GasBreakdown): { type: string; icon: return { type: 'Simple Transfer', icon: , - description: 'Basic SUI or token transfer' + description: 'Basic SUI or token transfer', }; } @@ -142,14 +147,14 @@ function estimateTransactionType(breakdown: GasBreakdown): { type: string; icon: return { type: 'Contract Deploy', icon: , - description: 'Smart contract deployment' + description: 'Smart contract deployment', }; } return { type: 'General Transaction', icon: , - description: 'Standard blockchain operation' + description: 'Standard blockchain operation', }; } @@ -166,7 +171,11 @@ function Tooltip({ children, content }: { children: React.ReactNode; content: st const [show, setShow] = useState(false); return (
-
setShow(true)} onMouseLeave={() => setShow(false)} className="cursor-help"> +
setShow(true)} + onMouseLeave={() => setShow(false)} + className="cursor-help" + > {children}
@@ -222,10 +231,22 @@ export function GasAnalysis() { toast.success('Copied!'); }; + const copyForAi = (text: string, label: string) => { + navigator.clipboard.writeText(text); + toast.success(`${label} copied`); + }; + const getEfficiencyInfo = (eff: number) => { - if (eff >= 70) return { color: 'text-green-400', bg: 'bg-green-500', label: 'Excellent', emoji: '🎯' }; + if (eff >= 70) + return { color: 'text-green-400', bg: 'bg-green-500', label: 'Excellent', emoji: '🎯' }; if (eff >= 40) return { color: 'text-blue-400', bg: 'bg-blue-500', label: 'Good', emoji: '👍' }; - if (eff >= 20) return { color: 'text-yellow-400', bg: 'bg-yellow-500', label: 'Room to optimize', emoji: '💡' }; + if (eff >= 20) + return { + color: 'text-yellow-400', + bg: 'bg-yellow-500', + label: 'Room to optimize', + emoji: '💡', + }; return { color: 'text-orange-400', bg: 'bg-orange-500', label: 'Over-budgeted', emoji: '⚠️' }; }; @@ -234,16 +255,53 @@ export function GasAnalysis() { const gasDistribution = breakdown ? analyzeGasDistribution(breakdown) : null; const efficiencyInfo = breakdown ? getEfficiencyInfo(breakdown.efficiency) : null; + // AI export of the current gas analysis, only meaningful once a tx is analyzed. + const aiJson = breakdown + ? JSON.stringify( + { + digest: digest.trim(), + transactionType: txType?.type, + breakdown, + gasDistribution, + optimizations: result?.optimizations ?? [], + }, + null, + 2 + ) + : undefined; + + const aiPrompt = breakdown + ? [ + `Explain this Sui transaction's gas usage and how to optimize it.`, + '', + `- Digest: ${digest.trim()}`, + `- Detected type: ${txType?.type ?? 'unknown'} (${txType?.description ?? ''})`, + `- Total gas used: ${formatSui(breakdown.totalGasUsed)} SUI (${formatNumber(breakdown.totalGasUsed)} MIST)`, + `- Gas budget: ${formatSui(breakdown.totalGasBudget)} SUI, efficiency ${breakdown.efficiency}% used`, + `- Computation: ${formatSui(breakdown.computationCost)} SUI (${gasDistribution?.computationPercent ?? 0}%)`, + `- Storage: ${formatSui(breakdown.storageCost)} SUI (${gasDistribution?.storagePercent ?? 0}%)`, + `- Storage rebate: ${formatSui(breakdown.storageRebate)} SUI`, + result && result.optimizations.length > 0 + ? `\nExisting insights:\n${result.optimizations.map((o) => `- [${o.type}] ${o.message}`).join('\n')}` + : '', + '', + `Break down where the gas went and suggest concrete ways to reduce it.`, + ].join('\n') + : ''; + return (
{/* Header */}
-
- -

Gas Analysis

- - - +
+
+ +

Gas Analysis

+ + + +
+ {breakdown && }
@@ -258,7 +316,11 @@ export function GasAnalysis() { className="w-full pl-9 pr-4 py-2.5 bg-secondary border border-border rounded-lg text-sm text-foreground placeholder:text-tertiary focus:outline-none focus:border-orange-500/50 font-mono" />
-
@@ -274,7 +336,6 @@ export function GasAnalysis() { {breakdown && txType && gasDistribution && efficiencyInfo && ( - {/* Transaction Summary Card */}
@@ -306,13 +367,19 @@ export function GasAnalysis() {
-
~${(Number(breakdown.totalGasUsed) / 1_000_000_000 * 3.5).toFixed(4)} USD
-
{formatNumber(breakdown.totalGasUsed)} MIST
+
+ ~${((Number(breakdown.totalGasUsed) / 1_000_000_000) * 3.5).toFixed(4)} USD +
+
+ {formatNumber(breakdown.totalGasUsed)} MIST +
{/* Efficiency Badge */} -
+
{efficiencyInfo.emoji} {breakdown.efficiency}% budget used • {efficiencyInfo.label} @@ -333,7 +400,9 @@ export function GasAnalysis() { className="bg-blue-500 flex items-center justify-center" > {gasDistribution.computationPercent > 15 && ( - {gasDistribution.computationPercent}% + + {gasDistribution.computationPercent}% + )} {gasDistribution.storagePercent > 15 && ( - {gasDistribution.storagePercent}% + + {gasDistribution.storagePercent}% + )}
@@ -370,7 +441,9 @@ export function GasAnalysis() {
-
{formatSui(breakdown.computationCost)} SUI
+
+ {formatSui(breakdown.computationCost)} SUI +
@@ -383,7 +456,9 @@ export function GasAnalysis() {
-
{formatSui(breakdown.storageCost)} SUI
+
+ {formatSui(breakdown.storageCost)} SUI +
@@ -397,7 +472,9 @@ export function GasAnalysis() {
-
-{formatSui(breakdown.storageRebate)} SUI
+
+ -{formatSui(breakdown.storageRebate)} SUI +
)} @@ -411,16 +488,23 @@ export function GasAnalysis() {
Gas Budget Set - {formatSui(breakdown.totalGasBudget)} SUI + + {formatSui(breakdown.totalGasBudget)} SUI +
Actually Used - {formatSui(breakdown.totalGasUsed)} SUI + + {formatSui(breakdown.totalGasUsed)} SUI +
Unused (Returned) - {formatSui((Number(breakdown.totalGasBudget) - Number(breakdown.totalGasUsed)).toString())} SUI + {formatSui( + (Number(breakdown.totalGasBudget) - Number(breakdown.totalGasUsed)).toString() + )}{' '} + SUI
@@ -450,8 +534,11 @@ export function GasAnalysis() {
{opt.type === 'warning' ? ( @@ -462,14 +549,23 @@ export function GasAnalysis() { )}
-
+
{opt.message}
- {opt.details &&
{opt.details}
} - {opt.potentialSavings &&
{opt.potentialSavings}
} + {opt.details && ( +
{opt.details}
+ )} + {opt.potentialSavings && ( +
{opt.potentialSavings}
+ )}
))} @@ -486,11 +582,15 @@ export function GasAnalysis() {
Computation -

Processing power for executing code (loops, calculations, function calls)

+

+ Processing power for executing code (loops, calculations, function calls) +

Storage -

Cost to store data on-chain (creating objects, modifying state)

+

+ Cost to store data on-chain (creating objects, modifying state) +

Rebate @@ -498,7 +598,9 @@ export function GasAnalysis() {
Budget -

Max gas you're willing to pay. Unused gas is returned to you.

+

+ Max gas you're willing to pay. Unused gas is returned to you. +

@@ -513,13 +615,20 @@ export function GasAnalysis() {

Analyze Transaction Gas

- Understand how much gas was used, where it went, and how to optimize future transactions + Understand how much gas was used, where it went, and how to optimize future + transactions

Try these examples:
{[ - { digest: '7SZsZ8RzL7JcteKbcJh4D5xXjz6vGkuxNzj6wJtB73Dv', label: 'Oracle Update (11 events)' }, - { digest: '95iEUzhvYWZoceBtgq7LkMsZxhrtfK3iJQk7AFV6Xgnk', label: 'DeFi Transaction (29 events)' }, + { + digest: '7SZsZ8RzL7JcteKbcJh4D5xXjz6vGkuxNzj6wJtB73Dv', + label: 'Oracle Update (11 events)', + }, + { + digest: '95iEUzhvYWZoceBtgq7LkMsZxhrtfK3iJQk7AFV6Xgnk', + label: 'DeFi Transaction (29 events)', + }, ].map((example) => ( - ))} +
+ handleTabChange(v as Tab)} className="w-full"> + + {tabs.map((tab) => ( + {tab.icon}} + badge={tab.badge} + > + {tab.label} + + ))} + + +

+ {activeTab === 'keys' && 'List keys in your local keystore'} + {activeTab === 'generate' && 'Create a new keypair'} + {activeTab === 'sign' && 'Sign a message or transaction with a local key'} + {activeTab === 'multisig' && 'Build a multisig address from public keys'} + {activeTab === 'execute' && 'Execute a signed transaction'} + {activeTab === 'decode' && "Decode a signed transaction's contents"} +

{/* Tab Content */} @@ -943,8 +1038,18 @@ export function KeytoolManager() { className="p-1 hover:bg-background-active rounded transition-colors" title="Copy address" > - - + +
@@ -965,8 +1070,18 @@ export function KeytoolManager() { className="p-1 hover:bg-background-active rounded transition-colors" title="Copy public key" > - - + +
@@ -994,21 +1109,29 @@ export function KeytoolManager() { {!generatedKey || mnemonicAcknowledged ? ( <>
- + -

Ed25519 is recommended for most use cases

+

+ Ed25519 is recommended for most use cases +

- + - @@ -1160,7 +1372,10 @@ export function KeytoolManager() { className="w-full px-3 py-2.5 bg-secondary/50 border border-border/50 rounded-lg text-sm text-foreground placeholder:text-muted-foreground/60 focus:outline-none focus:border-accent/50 transition-colors font-mono" /> {keys.length > 0 && ( - )} @@ -1171,7 +1386,9 @@ export function KeytoolManager() {
Selected: - {keys[selectedSignKeyIndex].suiAddress} + + {keys[selectedSignKeyIndex].suiAddress} +
)} @@ -1180,7 +1397,9 @@ export function KeytoolManager() { {/* Sample Transaction Generator */}
- ⚡ Generate Sample Transaction + + ⚡ Generate Sample Transaction + (for testing)
@@ -1225,10 +1444,15 @@ export function KeytoolManager() { {/* Transaction Bytes */}
- +