Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,18 @@ and this project adheres to

## [Unreleased]

### Added

- A one-time popup and a persistent top banner encouraging users to move to
[Ar.io Console](https://console.ar.io/), which covers ArNS plus file uploads
and page/website deploys. This app will stop being supported in the coming
months.
- The popup is skipped on checkout, register, transaction, and extend/upgrade
pages so it never interrupts a purchase.
- Dismissal is stored under a versioned `localStorage` key
(`CONSOLE_MIGRATION_NOTICE_KEY`); bump it to show the popup to everyone
again.

## [2.3.6] - 2026-08-12

### Fixed
Expand Down
80 changes: 65 additions & 15 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ logo images, GraphQL), but not for contract state.

## Development Commands

Node 24 (`.nvmrc`), Yarn 1.

```bash
yarn # Install
yarn dev # Dev server (NODE_ENV=prod, VITE_GITHUB_HASH=local)
Expand Down Expand Up @@ -43,7 +45,16 @@ npx cross-env NODE_ENV=test jest src/utils/searchUtils/searchUtils.test.ts

`NODE_ENV=test` is required — Jest uses `tsconfig.test.json` and a custom
`import.meta` AST transformer (`tests/common/import-meta-transformer.js`) to make
Vite's `import.meta.env` work under CommonJS.
Vite's `import.meta.env` work under CommonJS. Jest collects `*.test.ts(x)` from
anywhere except `tests/common/` and `tests/playwright/` (Playwright's `testDir`).

Playwright's `globalSetup` (`tests/playwright/setup.ts`) spawns `yarn preview`
itself, so `yarn build` first. Specs hit `process.env.URL`, defaulting to
`http://localhost:4173/`. After sign-in, navigate with `navigate()` from
`tests/playwright/helpers.ts`, never `page.goto` — a hard reload wipes the
in-memory devtools keypair, which is deliberately not persisted. Call
`skipConsoleNotice(page)` before the first navigation, or the Console
migration popup covers the page.

Biome is the source of truth for lint/format. `.eslintrc` and `.prettierrc` are
vestigial — don't wire new tooling to them.
Expand Down Expand Up @@ -86,6 +97,15 @@ scan against `MPL_CORE_PROGRAM_ID`, filtered by the `ANT Program` Metaplex
attribute. If names appear missing or stale in Manage, this is the first place to
look.

Fresh purchases drift too. With no `processId`, the SDK's `buyRecord` spawns the
ANT and buys the name in one transaction, then records the owner's ACL entries
in a *separate*, best-effort transaction whose failure is only logged. A rejected
or failed follow-up leaves a just-bought name flagged `needsOwnerSync`, which
surfaces as the "Sync Ownership" button in Manage.

The ACL is not an authorization source: the ANT program checks writes against the
live Metaplex Core owner and the controller list.

### Wallets — Solana only

`WALLET_TYPES` has a single member: `SOLANA`. Wallet discovery goes through
Expand All @@ -104,6 +124,13 @@ app's `ArNSWalletConnector` interface over the adapter. Its important output is
`PrivateKeySolanaWalletConnector` backs the devtools-only private-key login at
`/settings/devtools`.

`solanaSigner` is a kit `TransactionModifyingSigner`, not a partial signer:
Phantom rewrites transactions on real origins (priority fee, Lighthouse guard
instructions), so the bridge returns the wallet's rewritten message together with
its signature. Transaction assembly, compute-budget and priority-fee pinning, and
multi-signer ordering all live in the SDK's `sendAndConfirm`
(`@ar.io/sdk`, `solana/send`), not in this repo.

`ArNSWalletConnector` still carries `contractSigner` / `turboSigner` fields from
the multi-chain era; the Solana connector leaves them `undefined`.

Expand Down Expand Up @@ -147,10 +174,11 @@ React Context + reducer per domain, all nested in `main.tsx` (order matters —
`solanaConfig` changes, persists `walletType`.

Write flows go through `src/state/actions/` — `dispatchANTInteraction`,
`dispatchArIOInteraction`, `dispatchArNSUpdate`, `dispatchArIOContract`. These
require a connected Solana wallet with a signer and throw otherwise. Interaction
names are the `ANT_INTERACTION_TYPES` / `ARNS_INTERACTION_TYPES` enums in
`src/types.ts`.
`dispatchArIOInteraction`, `dispatchArIOContract`. These require a connected
Solana wallet with a signer and throw otherwise. `dispatchArNSUpdate` is the read
side: it reloads the wallet's names and ANT states (merging in ACL drift) and
resets their query caches. Interaction names are the `ANT_INTERACTION_TYPES` /
`ARNS_INTERACTION_TYPES` enums in `src/types.ts`.

### Data fetching

Expand All @@ -159,6 +187,14 @@ React Query for all server state. `queryClient` in `src/utils/network.ts`
tuned to stop refetch storms). An IndexedDB persister is implemented
(`createIDBPersister`) but not currently wired up in `main.tsx`.

Per-name queries override that default: `useDomainInfo` and the ANT state
queries (`['ant', processId, …]`) use `staleTime: Infinity`, so a write that
doesn't bust them leaves the UI showing old values. Post-write invalidation
happens when `TransactionState.interactionResult` changes (`TransactionState`
and `DomainSettings` both react to it) and in `dispatchArNSUpdate`. A new write
flow must set `interactionResult` the way the dispatch actions do, or invalidate
explicitly.

Domain logic lives in `src/hooks/use<Feature>.tsx`. Arweave data retrieval goes
through `ArweaveCompositeDataProvider` / `SimpleArweaveDataProvider` in
`src/services/arweave/`.
Expand Down Expand Up @@ -197,8 +233,10 @@ Motion for animation. Per-component `styles.css`.

### File organization

- **Components**: one folder each, containing `<ComponentName>.tsx`, `styles.css`,
and `__tests__/`. Test files named `<component-name>.test.ts(x)`.
- **Components**: one folder each, containing `<ComponentName>.tsx` and
`styles.css`. The README asks for tests in `__tests__/` named
`<component-name>.test.ts(x)`, but most existing tests sit beside the source
file instead — either is picked up.
- **Utils**: `src/utils/`, with colocated or sibling tests.
- **Types for external libs that don't export what we need**: `src/types/`.
- **Images**: `assets/images/{dark,light,common}/`.
Expand Down Expand Up @@ -240,17 +278,22 @@ Flows on *existing* ANTs are unaffected: `EXTEND_LEASE`, `INCREASE_UNDERNAMES`.

### Environment variables

Vite only exposes an explicit allowlist — never widen the `define` block in
`vite.config.ts` to the whole `process.env`.
Every `VITE_`-prefixed variable set at build time is inlined into the public
bundle through `import.meta.env`, so never give a secret that prefix. Separately,
the `define` block in `vite.config.ts` shims `process.env` with `URL` only —
never widen it to the whole `process.env`, or CI secrets leak into the bundle.

- Solana: `VITE_SOLANA_NETWORK`, `VITE_SOLANA_RPC_URL`,
`VITE_ARIO_CORE_PROGRAM_ID`, `VITE_ARIO_GAR_PROGRAM_ID`,
`VITE_ARIO_ARNS_PROGRAM_ID`, `VITE_ARIO_ANT_PROGRAM_ID`,
`VITE_ARIO_MINT_ADDRESS`
- Arweave/Turbo: `VITE_ARWEAVE_HOST`, `VITE_ARWEAVE_GRAPHQL_URL`,
`VITE_HYPERBEAM_URL`, `VITE_ARNS_NAME`
- Arweave: `VITE_ARWEAVE_HOST`, `VITE_ARWEAVE_GRAPHQL_URL`, `VITE_HYPERBEAM_URL`
- Build: `VITE_ENVIRONMENT` (production/develop), `VITE_NODE_ENV`,
`VITE_GITHUB_HASH`
- Legacy AO, still read in `constants.ts` and still passed by CI:
`VITE_ARIO_PROCESS_ID`, `VITE_ARIO_AO_CU_URL`, `VITE_ANT_AO_CU_URL`
- `VITE_ARNS_NAME` is a shell variable for `publish:arweave`; app code doesn't
read it.

`VITE_SOLANA_RPC_URL` is read with `||`, not `??`, on purpose — CI injects `""`
when the secret is unset.
Expand All @@ -271,10 +314,10 @@ and `vite.config.ts`; Jest mirrors them in `moduleNameMapper`).
### Jest specifics

`transformIgnorePatterns` explicitly un-ignores `@ar.io`, `@permaweb`,
`arbundles`, `@dha-team/arbundles`, `arweave-wallet-connector`, and `wagmi` —
these ship ESM that must be transformed. Add new ESM-only deps here when they
break tests. `@ar.io/solana-contracts` subpaths are remapped to their built
`lib/*/index.js`.
`arbundles`, `@dha-team/arbundles`, `arweave-wallet-connector`, `@wagmi`, and
`wagmi` — these ship ESM that must be transformed. Add new ESM-only deps here
when they break tests. `@ar.io/solana-contracts` subpaths are remapped to their
built `lib/*/index.js`.

## Git hooks

Expand All @@ -286,3 +329,10 @@ break tests. `@ar.io/solana-contracts` subpaths are remapped to their built

`.github/workflows/`: `build_and_test.yml`, `pr-preview.yaml`,
`staging_deploy.yml`, `production.yml`.

- PRs gate on `lint:check` and `build` only. The Playwright job has
`if: github.ref_name == 'main'`, so it's skipped on every PR, and `yarn test`
is commented out in `build_and_test.yml` and `production.yml`. Neither suite
blocks a merge — run both locally.
- `pr-preview.yaml` passes no `VITE_SOLANA_*` or program-ID variables, so preview
builds fall back to the defaults in `src/utils/solana.ts`.
4 changes: 4 additions & 0 deletions src/components/layout/Layout/Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@ import { useArNSState } from '@src/state';
import { Progress } from 'antd';
import { Outlet } from 'react-router-dom';

import ConsoleMigrationModal from '../../modals/ConsoleMigrationModal/ConsoleMigrationModal';
import Footer from '../Footer/Footer';
import NavBar from '../Navbar/Navbar';
import Notifications from '../Notifications/Notifications';
import TopBanner from './TopBanner';
import './styles.css';

function Layout() {
Expand All @@ -20,6 +22,7 @@ function Layout() {
height: '100vh',
}}
>
<TopBanner />
<div
id="layout"
className="flex flex-row"
Expand Down Expand Up @@ -55,6 +58,7 @@ function Layout() {
<Notifications />
</div>
<Footer />
<ConsoleMigrationModal />
</div>
);
}
Expand Down
39 changes: 10 additions & 29 deletions src/components/layout/Layout/TopBanner.tsx
Original file line number Diff line number Diff line change
@@ -1,41 +1,22 @@
import { useIsMobile } from '@src/hooks';
import { SOLANA_MIGRATION_LINK } from '@src/utils/constants';
import { getConsoleLink } from '@src/utils/constants';
import { ExternalLinkIcon } from 'lucide-react';
import { Link } from 'react-router-dom';

const TopBanner = () => {
const isMobile = useIsMobile();

return (
<div
style={{
textAlign: 'center',
backgroundColor: 'var(--accent)',
color: 'var(--text-black)',
padding: isMobile ? '12px 15px' : '12px 18px',
fontSize: '14px',
}}
data-testid="console-migration-banner"
className="w-full bg-primary px-4 py-2.5 text-center text-sm leading-snug text-black"
>
<strong>Ar.io is migrating to Solana!</strong> Purchases are paused and
will resume shortly. Register before the June 1, 2026 snapshot!{' '}
<Link
to={SOLANA_MIGRATION_LINK}
The ArNS app is moving to Ar.io Console.{' '}
<a
href={getConsoleLink('banner')}
target="_blank"
rel="noreferrer"
className="link hover"
style={{
display: 'inline',
color: 'var(--text-black)',
textDecoration: 'underline',
fontWeight: 600,
}}
className="inline-flex items-center gap-1 whitespace-nowrap font-bold underline underline-offset-2"
>
Learn More{' '}
<ExternalLinkIcon
size={14}
style={{ display: 'inline', verticalAlign: 'middle' }}
/>
</Link>
Try Console
<ExternalLinkIcon size={14} />
</a>
</div>
);
};
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import {
CONSOLE_MIGRATION_NOTICE_KEY,
getConsoleLink,
} from '@src/utils/constants';
import { ExternalLinkIcon, XIcon } from 'lucide-react';
import { Dialog } from 'radix-ui';
import { useEffect, useState } from 'react';
import { useLocation } from 'react-router-dom';

// Routes where a popup would interrupt a purchase or on-chain action. The
// top banner still shows there; the popup waits until the user lands on a
// browsing page.
const SUPPRESSED_ROUTES = [
/^\/transaction/,
/^\/register\//,
/^\/checkout/,
/^\/connect/,
/^\/manage\/names\/[^/]+\/(extend|upgrade-undernames)/,
];

// localStorage can throw (private mode, blocked site data). Failing open
// means the popup may show again, which is better than breaking the page.
function isDismissed(): boolean {
try {
return localStorage.getItem(CONSOLE_MIGRATION_NOTICE_KEY) !== null;
} catch {
return false;
}
}

function markDismissed() {
try {
localStorage.setItem(CONSOLE_MIGRATION_NOTICE_KEY, `${Date.now()}`);
} catch {
// ignore — see isDismissed
}
}

const CONSOLE_FEATURES = [
'Search, buy, and manage ArNS names',
'Upload files to Arweave',
'Deploy pages and websites',
];

function ConsoleMigrationModal() {
const { pathname } = useLocation();
const [open, setOpen] = useState(false);

useEffect(() => {
if (isDismissed()) return;
// Close without recording a dismissal (e.g. Back button into checkout),
// so the popup can return on the next browsing page.
if (SUPPRESSED_ROUTES.some((route) => route.test(pathname))) {
setOpen(false);
return;
}
setOpen(true);
}, [pathname]);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

function handleOpenChange(next: boolean) {
if (!next) markDismissed();
setOpen(next);
}

return (
<Dialog.Root open={open} onOpenChange={handleOpenChange}>
<Dialog.Portal>
{/* z-[2100] sits above antd notifications (2050) so error toasts
can't cover the buttons on small screens. */}
<Dialog.Overlay className="fixed inset-0 z-[2100] bg-black/60 backdrop-blur-[2px]" />
<Dialog.Content
data-testid="console-migration-modal"
// Focus the dialog itself rather than Radix's default (the close
// button), which would paint a focus ring before any interaction.
onOpenAutoFocus={(e) => {
e.preventDefault();
(e.currentTarget as HTMLElement).focus();
}}
className="fixed left-1/2 top-1/2 z-[2100] flex max-h-[calc(100dvh-32px)] w-[calc(100%-32px)] max-w-[440px] -translate-x-1/2 -translate-y-1/2 flex-col gap-5 overflow-y-auto rounded-lg outline-none border border-dark-grey bg-metallic-grey p-6 text-white shadow-one sm:p-8"
>
<Dialog.Close
aria-label="Close"
className="absolute right-4 top-4 rounded p-1 text-grey transition-colors hover:text-white focus-visible:outline focus-visible:outline-2 focus-visible:outline-primary"
>
<XIcon size={20} />
</Dialog.Close>

<div className="flex flex-col gap-2 pr-6">
<span className="text-xs font-medium uppercase tracking-wider text-primary">
New home for ArNS
</span>
<Dialog.Title className="text-xl font-bold leading-snug sm:text-2xl">
Try Ar.io Console
</Dialog.Title>
</div>

<Dialog.Description className="text-sm leading-relaxed text-light-grey">
Everything you do here now lives in Ar.io Console, along with more
tools for building on Ar.io.
</Dialog.Description>

<ul className="flex flex-col gap-2 text-sm text-white">
{CONSOLE_FEATURES.map((feature) => (
<li key={feature} className="flex items-center gap-3">
<span className="size-1.5 shrink-0 rounded-full bg-primary" />
{feature}
</li>
))}
</ul>

<p className="text-sm leading-relaxed text-light-grey">
Your names come with you. Just connect the same wallet.
</p>

<p className="rounded-md border border-dark-grey bg-foreground px-4 py-3 text-sm leading-relaxed text-light-grey">
This app still works, but we'll stop supporting it in the coming
months.
</p>

<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<Dialog.Close className="rounded-md border border-dark-grey px-5 py-3 text-sm font-medium text-light-grey transition-colors hover:border-grey hover:text-white">
Not now
</Dialog.Close>
<a
href={getConsoleLink('popup')}
target="_blank"
rel="noreferrer"
onClick={() => handleOpenChange(false)}
className="flex items-center justify-center gap-2 rounded-md bg-primary px-5 py-3 text-sm font-bold text-black transition-opacity hover:opacity-90"
>
Go to Console
<ExternalLinkIcon size={16} />
</a>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}

export default ConsoleMigrationModal;
Loading
Loading