Skip to content
Open
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
22 changes: 0 additions & 22 deletions .github/workflows/_docker-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,28 +45,6 @@ jobs:
- name: Set IMAGE env
run: echo "IMAGE=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}/manifest-app" >> "$GITHUB_ENV"

- name: Create .env from environment vars/secrets
run: |
touch .env
echo "NEXT_PUBLIC_CHAIN=${{ vars.NEXT_PUBLIC_CHAIN }}" >> .env
echo "NEXT_PUBLIC_CHAIN_ID=${{ vars.NEXT_PUBLIC_CHAIN_ID }}" >> .env
echo "NEXT_PUBLIC_CHAIN_TIER=${{ vars.NEXT_PUBLIC_CHAIN_TIER }}" >> .env
echo "NEXT_PUBLIC_RPC_URL=${{ vars.NEXT_PUBLIC_RPC_URL }}" >> .env
echo "NEXT_PUBLIC_API_URL=${{ vars.NEXT_PUBLIC_API_URL }}" >> .env
echo "NEXT_PUBLIC_WALLETCONNECT_KEY=${{ secrets.NEXT_PUBLIC_WALLETCONNECT_KEY }}" >> .env
echo "NEXT_PUBLIC_WEB3AUTH_NETWORK=${{ vars.NEXT_PUBLIC_WEB3AUTH_NETWORK }}" >> .env
echo "NEXT_PUBLIC_WEB3AUTH_CLIENT_ID=${{ secrets.NEXT_PUBLIC_WEB3AUTH_CLIENT_ID }}" >> .env
echo "NEXT_PUBLIC_EXPLORER_URL=${{ vars.NEXT_PUBLIC_EXPLORER_URL }}" >> .env
echo "NEXT_PUBLIC_INDEXER_URL=${{ vars.NEXT_PUBLIC_INDEXER_URL }}" >> .env
echo "NEXT_PUBLIC_OSMOSIS_CHAIN=${{ vars.NEXT_PUBLIC_OSMOSIS_CHAIN }}" >> .env
echo "NEXT_PUBLIC_OSMOSIS_CHAIN_ID=${{ vars.NEXT_PUBLIC_OSMOSIS_CHAIN_ID }}" >> .env
echo "NEXT_PUBLIC_OSMOSIS_RPC_URL=${{ vars.NEXT_PUBLIC_OSMOSIS_RPC_URL }}" >> .env
echo "NEXT_PUBLIC_OSMOSIS_API_URL=${{ vars.NEXT_PUBLIC_OSMOSIS_API_URL }}" >> .env
echo "NEXT_PUBLIC_OSMOSIS_EXPLORER_URL=${{ vars.NEXT_PUBLIC_OSMOSIS_EXPLORER_URL }}" >> .env
echo "NEXT_PUBLIC_LEAP_DEEPLINK=${{ vars.NEXT_PUBLIC_LEAP_DEEPLINK }}" >> .env
echo "NEXT_PUBLIC_UPGRADE_MIN_BLOCK_OFFSET=${{ vars.NEXT_PUBLIC_UPGRADE_MIN_BLOCK_OFFSET }}" >> .env
echo "NEXT_PUBLIC_MFX_TO_PWR_CONVERSION_CONTRACT_ADDRESS=${{ vars.NEXT_PUBLIC_MFX_TO_PWR_CONVERSION_CONTRACT_ADDRESS }}" >> .env

- name: Extract metadata (release tag)
id: meta_tag
if: inputs.release_tag != ''
Expand Down
90 changes: 90 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Project Overview

Manifest Wallet ("Alberto") — a Next.js 15 web app for interacting with the Manifest Network blockchain and its modules (groups, factory, bank, admins). Built with the Pages Router, Cosmos SDK tooling, and multi-chain support (Manifest + Osmosis).

## Commands

```bash
bun install # Install dependencies
bun run dev # Dev server with Turbopack (localhost:3000)
bun run build # Production build
bun run lint # ESLint
bun run format # Prettier (write mode)
bun test # Run all unit tests (Bun test runner)
bun test path/to/file # Run a single test file
bun run test:coverage # Tests with coverage report
```

Package manager is **Bun** (not npm/yarn). Use `bun install`, `bun run`, `bun test`.

## Architecture

### Routing & Pages

Next.js **Pages Router** (`pages/` directory). Dynamic routes: `pages/factory/[id].tsx`, `pages/groups/[id].tsx`.

### Provider Stack

`ManifestAppProviders` in `contexts/manifestAppProviders.tsx` composes the full provider tree:
`QueryClientProvider` → `ContactsProvider` → `Web3AuthProvider` → `ChainProvider` (cosmos-kit) → `ThemeProvider` → `ToastProvider`

### State Management

- **Server state**: TanStack React Query v5 — query hooks in `hooks/useQueries.ts`
- **Blockchain transactions**: `hooks/useTx.tsx`
- **LCD queries**: `hooks/useLcdQueryClient.ts`
- **Client state**: React Context (`contexts/`) for theme, toasts, advanced mode, web3auth
- **Form state**: Formik + Yup (schemas in `schemas/`)
- **Local persistence**: `hooks/useLocalStorage.ts`

### Component Organization

Feature-based directories under `components/`:

- `admins/`, `bank/`, `factory/`, `groups/`, `tokens/` — domain features
- `react/` — shared layout and UI components (Nav, Sidebar, etc.)
- `3js/` — Three.js visualizations (dynamically imported to avoid SSR issues)
- `icons/` — custom SVG icon components

### Blockchain Integration

- **@manifest-network/manifestjs** — proto-generated types, registries, amino converters
- **cosmos-kit** — wallet management (Keplr, Cosmostation, Leap, Web3Auth social login)
- **@cosmjs** — signing, stargate client, proto-signing
- Multi-chain: Manifest (primary) + Osmosis (IBC/swaps)

### Configuration & Runtime Environment Variables

- `config/env.ts` — centralized env var access via `getEnvVar(key)` helper. All consumer files import `env` from here.
- `config/manifestChain.ts` / `config/osmosisChain.ts` — chain registry, selected by `NEXT_PUBLIC_CHAIN_TIER` (qa/testnet/mainnet)
- `.env.test` for test environment variables

**Runtime env vars**: `NEXT_PUBLIC_*` variables are **not** inlined at build time. Instead:

- `config/env.ts` uses dynamic `process.env[key]` access (server) and `window.__ENV__[key]` (client) to avoid Next.js build-time inlining.
- `pages/_document.tsx` injects `<script src="/env-config.js" />` synchronously before React hydrates.
- `public/env-config.js` is a committed empty placeholder (`window.__ENV__ = {}`). In production Docker containers, `docker-entrypoint.mjs` overwrites it at container start with actual `NEXT_PUBLIC_*` values from the environment.
- During `bun dev`, Next.js reads `.env.local` and provides values via `process.env` server-side as usual.

This allows **one Docker image** to be built and configured at runtime for any environment (qa/testnet/mainnet) by passing env vars at `docker run` time.

### Styling

Tailwind CSS v4 + DaisyUI v5. Dark/light theme via `data-theme` attribute. Custom breakpoints (`xxs: 320px`, `3xl: 2560px`).

## Code Conventions

- TypeScript strict mode
- Path alias: `@/*` maps to project root (e.g., `@/components`, `@/hooks`)
- Prettier: 100 char width, single quotes, es5 trailing commas, sorted imports (`@trivago/prettier-plugin-sort-imports`)
- Import order: `@/` prefixed paths first, then relative `.` paths, separated by blank line
- Tests co-located in `__tests__/` directories next to source files, using `*.test.tsx` pattern
- Test setup: happy-dom for DOM simulation, `@testing-library/react` + `@testing-library/jest-dom` matchers

## CI

GitHub Actions runs on push and PR: build check, test coverage (uploaded to Codecov), and Prettier formatting check. Docker builds trigger on `release/*` branches and version tags, deploying a single environment-agnostic image to GHCR. Environment-specific `NEXT_PUBLIC_*` vars are passed at `docker run` time, not at build time.
15 changes: 9 additions & 6 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,9 @@ COPY . .

RUN apt update && apt install -y git

RUN \
if [ -f .env ]; then echo ".env file found, continuing..."; else echo ".env file not found, exiting..."; exit 1; fi
# Create a dummy .env so next build succeeds without real env values.
# Runtime env vars are injected at container start via docker-entrypoint.mjs.
RUN touch .env

# Next.js collects completely anonymous telemetry data about general usage.
# Learn more here: https://nextjs.org/telemetry
Expand All @@ -43,8 +44,6 @@ RUN \
else echo "Lockfile not found." && exit 1; \
fi

RUN rm -rf .env

# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app
Expand All @@ -56,7 +55,11 @@ ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs

COPY --from=builder /app/public ./public
# public/ must be writable so docker-entrypoint.mjs can generate env-config.js
COPY --from=builder --chown=nextjs:nodejs /app/public ./public

# Copy the entrypoint script
COPY --from=builder --chown=nextjs:nodejs /app/docker-entrypoint.mjs ./docker-entrypoint.mjs

# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
Expand All @@ -72,4 +75,4 @@ ENV PORT=3000
# server.js is created by next build from the standalone output
# https://nextjs.org/docs/pages/api-reference/next-config-js/output
ENV HOSTNAME="0.0.0.0"
CMD ["bun", "server.js"]
CMD ["bun", "docker-entrypoint.mjs"]
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,3 +86,29 @@ where
3. Navigate to `http://localhost:3000` in your browser

4. Enjoy!

### Docker

The Docker image is environment-agnostic — it is built **once** and configured at runtime via environment variables. No `.env` file is needed at build time.

#### Build

```bash
docker build -t manifest-app .
```

#### Run

Pass `NEXT_PUBLIC_*` variables at container start:

```bash
docker run -p 3000:3000 \
-e NEXT_PUBLIC_CHAIN=manifest \
-e NEXT_PUBLIC_CHAIN_ID=manifest-1 \
-e NEXT_PUBLIC_CHAIN_TIER=mainnet \
-e NEXT_PUBLIC_RPC_URL=https://rpc.manifest.example.com \
-e NEXT_PUBLIC_API_URL=https://api.manifest.example.com \
manifest-app
```

At container start, `docker-entrypoint.mjs` writes all `NEXT_PUBLIC_*` environment variables into `public/env-config.js` as `window.__ENV__`, which is loaded by the browser before React hydrates. This allows the same image to serve any environment (qa, testnet, mainnet).
12 changes: 10 additions & 2 deletions components/admins/modals/__tests__/validatorModal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,10 +85,18 @@ describe('ValidatorDetailsModal Component', () => {
const input = screen.getByPlaceholderText('1000');
fireEvent.change(input, { target: { value: '-1' } });
fireEvent.blur(input);
// Wait for Formik validation to complete by checking for the error tooltip
await waitFor(() => {
const updateButton = screen.getByText('Update');
expect(updateButton).toBeDisabled();
expect(
screen.getByText(
(_content, element) =>
(element as HTMLElement)?.dataset?.tip === 'Power must be greater than 0'
)
).toBeInTheDocument();
});
// Validation is done — isValid should now be false
const updateButton = screen.getByText('Update');
expect(updateButton).toBeDisabled();
},
TIMEOUT
);
Expand Down
25 changes: 14 additions & 11 deletions components/groups/forms/groups/__tests__/GroupDetailsForm.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,11 +73,6 @@ describe('GroupDetails Component', () => {
});

test('next button is disabled when form is dirty and invalid', async () => {
function updateField(field: string, validValue: string) {
const input = screen.getByLabelText(field);
fireEvent.change(input, { target: { value: validValue } });
}

const invalidProps = {
...mockProps,
formData: {
Expand All @@ -93,13 +88,21 @@ describe('GroupDetails Component', () => {

renderWithChainProvider(<GroupDetails {...invalidProps} />);
const nextButton = screen.getByText('Next: Group Members');
await waitFor(() => expect(nextButton).toBeDisabled());
// Initially disabled because authors is [''] (empty string)
expect(nextButton).toBeDisabled();

// Fill all fields to valid values
fireEvent.change(screen.getByLabelText('Group Title'), {
target: { value: 'New Group Title' },
});
fireEvent.change(screen.getByLabelText('Author name or address'), {
target: { value: manifestAddr1 },
});
fireEvent.change(screen.getByLabelText('Description'), {
target: { value: 'New Long Description is Long Enough well well well...' },
});

updateField('Group Title', 'New Group Title');
await waitFor(() => expect(nextButton).toBeDisabled());
updateField('Author name or address', manifestAddr1);
await waitFor(() => expect(nextButton).toBeDisabled());
updateField('Description', 'New Long Description is Long Enough well well well...');
// Wait for Formik validation to settle and button to become enabled
await waitFor(() => expect(nextButton).toBeEnabled());
});

Expand Down
47 changes: 28 additions & 19 deletions config/env.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
import parse from 'parse-duration';

function getEnvVar(key: string): string | undefined {
if (typeof window !== 'undefined' && window.__ENV__ !== undefined) {
// In the browser, window.__ENV__ is the source of truth.
return window.__ENV__[key];
}
// Dynamic key access prevents Next.js from inlining at build time.
return process.env[key];
}

function parseDuration(duration: string | undefined, defaultValue: number): number {
const d = parse(duration ?? '');
if (d === null) {
Expand All @@ -13,50 +22,50 @@ const env = {
production: process.env.NODE_ENV === 'production',

// Wallet
walletConnectKey: process.env.NEXT_PUBLIC_WALLETCONNECT_KEY ?? '',
web3AuthNetwork: process.env.NEXT_PUBLIC_WEB3AUTH_NETWORK ?? '',
web3AuthClientId: process.env.NEXT_PUBLIC_WEB3AUTH_CLIENT_ID ?? '',
walletConnectKey: getEnvVar('NEXT_PUBLIC_WALLETCONNECT_KEY') ?? '',
web3AuthNetwork: getEnvVar('NEXT_PUBLIC_WEB3AUTH_NETWORK') ?? '',
web3AuthClientId: getEnvVar('NEXT_PUBLIC_WEB3AUTH_CLIENT_ID') ?? '',

// Chains
chain: process.env.NEXT_PUBLIC_CHAIN ?? '',
osmosisChain: process.env.NEXT_PUBLIC_OSMOSIS_CHAIN ?? '',
chainId: process.env.NEXT_PUBLIC_CHAIN_ID ?? '',
osmosisChainId: process.env.NEXT_PUBLIC_OSMOSIS_CHAIN_ID ?? '',
chain: getEnvVar('NEXT_PUBLIC_CHAIN') ?? '',
osmosisChain: getEnvVar('NEXT_PUBLIC_OSMOSIS_CHAIN') ?? '',
chainId: getEnvVar('NEXT_PUBLIC_CHAIN_ID') ?? '',
osmosisChainId: getEnvVar('NEXT_PUBLIC_OSMOSIS_CHAIN_ID') ?? '',

// Leap Deeplink
leapDeeplink: process.env.NEXT_PUBLIC_LEAP_DEEPLINK ?? '',
leapDeeplink: getEnvVar('NEXT_PUBLIC_LEAP_DEEPLINK') ?? '',

// Ops
chainTier: process.env.NEXT_PUBLIC_CHAIN_TIER ?? '',
chainTier: getEnvVar('NEXT_PUBLIC_CHAIN_TIER') ?? '',

// Explorer URLs
explorerUrl: process.env.NEXT_PUBLIC_EXPLORER_URL ?? '',
osmosisExplorerUrl: process.env.NEXT_PUBLIC_OSMOSIS_EXPLORER_URL ?? '',
explorerUrl: getEnvVar('NEXT_PUBLIC_EXPLORER_URL') ?? '',
osmosisExplorerUrl: getEnvVar('NEXT_PUBLIC_OSMOSIS_EXPLORER_URL') ?? '',
// RPC and API URLs
rpcUrl: process.env.NEXT_PUBLIC_RPC_URL ?? '',
apiUrl: process.env.NEXT_PUBLIC_API_URL ?? '',
indexerUrl: process.env.NEXT_PUBLIC_INDEXER_URL ?? '',
rpcUrl: getEnvVar('NEXT_PUBLIC_RPC_URL') ?? '',
apiUrl: getEnvVar('NEXT_PUBLIC_API_URL') ?? '',
indexerUrl: getEnvVar('NEXT_PUBLIC_INDEXER_URL') ?? '',

// Osmosis RPC URLs
osmosisApiUrl: process.env.NEXT_PUBLIC_OSMOSIS_API_URL ?? '',
osmosisRpcUrl: process.env.NEXT_PUBLIC_OSMOSIS_RPC_URL ?? '',
osmosisApiUrl: getEnvVar('NEXT_PUBLIC_OSMOSIS_API_URL') ?? '',
osmosisRpcUrl: getEnvVar('NEXT_PUBLIC_OSMOSIS_RPC_URL') ?? '',

// Frontend development specific variables.

/**
* Minimum allowed voting period for proposals. This is a number of seconds.
* By default, it is set to 30 minutes.
*/
minimumVotingPeriod: parseDuration(process.env.NEXT_PUBLIC_MINIMUM_VOTING_PERIOD, 1800),
minimumVotingPeriod: parseDuration(getEnvVar('NEXT_PUBLIC_MINIMUM_VOTING_PERIOD'), 1800),

/**
* Minimum block offset required when submitting a chain upgrade proposal. The chosen upgrade height must be at least this many blocks greater than the current block height at the time of proposal submission.
* By default, it is set to 1000 blocks.
*/
upgradeMinBlockOffset: parseInt(process.env.NEXT_PUBLIC_UPGRADE_MIN_BLOCK_OFFSET ?? '1000', 10),
upgradeMinBlockOffset: parseInt(getEnvVar('NEXT_PUBLIC_UPGRADE_MIN_BLOCK_OFFSET') ?? '1000', 10),

mfxToPwrConversionContractAddress:
process.env.NEXT_PUBLIC_MFX_TO_PWR_CONVERSION_CONTRACT_ADDRESS ?? '',
getEnvVar('NEXT_PUBLIC_MFX_TO_PWR_CONVERSION_CONTRACT_ADDRESS') ?? '',

pwrTokenDenom: 'factory/manifest1afk9zr2hn2jsac63h4hm60vl9z3e5u69gndzf7c99cqge3vzwjzsfmy9qj/upwr',
};
Expand Down
40 changes: 40 additions & 0 deletions docker-entrypoint.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';

const __dirname = dirname(fileURLToPath(import.meta.url));

// Collect all NEXT_PUBLIC_* environment variables.
const runtimeEnv = {};
for (const [key, value] of Object.entries(process.env)) {
if (key.startsWith('NEXT_PUBLIC_')) {
runtimeEnv[key] = value;
}
}

const keys = Object.keys(runtimeEnv);
if (keys.length === 0) {
console.warn(
'[entrypoint] WARNING: No NEXT_PUBLIC_* environment variables found. ' +
'The app will start with empty configuration.'
);
}

// Escape '<' to prevent </script> injection if values are ever served inline.
const json = JSON.stringify(runtimeEnv).replace(/</g, '\\u003c');
const script = `window.__ENV__ = ${json};`;

const outputPath = join(__dirname, 'public', 'env-config.js');
try {
writeFileSync(outputPath, script);
console.log(`[entrypoint] Wrote env-config.js with keys: ${keys.join(', ')}`);
} catch (err) {
console.error(
`[entrypoint] FATAL: Failed to write ${outputPath}: ${err.message}\n` +
'Ensure the public/ directory exists and is writable by the container user.'
);
process.exit(1);
}

// Start the Next.js server.
await import('./server.js');
1 change: 1 addition & 0 deletions global.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ declare global {
}

interface Window {
__ENV__?: Record<string, string | undefined>;
keplr?: any;
ethereum?: any;
leap?: any;
Expand Down
Loading