diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..5f6fb98
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,21 @@
+root = true
+
+[*]
+charset = utf-8
+end_of_line = lf
+insert_final_newline = true
+trim_trailing_whitespace = true
+
+[*.{ts,tsx,js,jsx,cjs,mjs,json,yaml,yml,md}]
+indent_style = space
+indent_size = 2
+
+[*.rs]
+indent_style = space
+indent_size = 4
+
+[Makefile]
+indent_style = tab
+
+[*.md]
+trim_trailing_whitespace = false
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 819311e..028b430 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -8,6 +8,33 @@ on:
- staging
jobs:
+ format:
+ name: Formatting checks
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup Node
+ uses: actions/setup-node@v4
+ with:
+ node-version: 22
+
+ - name: Install dashboard dependencies
+ working-directory: dashboard
+ run: npm ci
+
+ - name: Check dashboard formatting (Prettier)
+ working-directory: dashboard
+ run: npm run format:check
+
+ - name: Install listener dependencies
+ working-directory: listener
+ run: npm ci
+
+ - name: Check listener formatting (Prettier)
+ working-directory: listener
+ run: npm run format:check
+
frontend:
name: Frontend (lint, typecheck, test)
runs-on: ubuntu-latest
diff --git a/.prettierignore b/.prettierignore
new file mode 100644
index 0000000..332bffd
--- /dev/null
+++ b/.prettierignore
@@ -0,0 +1,26 @@
+# Dependencies
+node_modules/
+dashboard/node_modules/
+listener/node_modules/
+
+# Build output
+dist/
+dashboard/dist/
+listener/dist/
+target/
+
+# Lock files (managed by package managers, not formatted)
+package-lock.json
+dashboard/package-lock.json
+listener/package-lock.json
+Cargo.lock
+
+# Generated / vendored
+*.wasm
+*.optimized.wasm
+reports/
+dashboard/reports/
+
+# Environment files
+.env
+.env.*
diff --git a/.prettierrc b/.prettierrc
new file mode 100644
index 0000000..4d2523a
--- /dev/null
+++ b/.prettierrc
@@ -0,0 +1,10 @@
+{
+ "semi": true,
+ "singleQuote": true,
+ "trailingComma": "all",
+ "printWidth": 100,
+ "tabWidth": 2,
+ "useTabs": false,
+ "arrowParens": "always",
+ "endOfLine": "lf"
+}
diff --git a/CODE_FORMATTING.md b/CODE_FORMATTING.md
new file mode 100644
index 0000000..ac2edfd
--- /dev/null
+++ b/CODE_FORMATTING.md
@@ -0,0 +1,103 @@
+# Code Formatting
+
+NotifyChain enforces consistent formatting across all languages via automated checks that run on every pull request.
+
+---
+
+## Tools
+
+| Language | Tool | Config |
+|---|---|---|
+| TypeScript / TSX (dashboard) | [Prettier](https://prettier.io) 3.x | `.prettierrc` (root) |
+| TypeScript (listener) | [Prettier](https://prettier.io) 3.x | `.prettierrc` (root) |
+| Rust (contracts) | `rustfmt` (stable) | default `rustfmt` rules |
+| All files | EditorConfig | `.editorconfig` (root) |
+
+---
+
+## Prettier rules (TypeScript / TSX)
+
+Defined in `.prettierrc` at the repository root.
+
+| Rule | Value |
+|---|---|
+| `semi` | `true` — semicolons required |
+| `singleQuote` | `true` — single quotes for strings |
+| `trailingComma` | `"all"` — trailing commas wherever valid |
+| `printWidth` | `100` — wrap lines at 100 characters |
+| `tabWidth` | `2` — two-space indentation |
+| `useTabs` | `false` — spaces, not tabs |
+| `arrowParens` | `"always"` — parentheses around arrow function parameters |
+| `endOfLine` | `"lf"` — Unix line endings |
+
+---
+
+## Rust formatting
+
+The `rust` CI job runs `cargo fmt --all -- --check`. This uses the default `rustfmt` rules (stable channel). No custom `rustfmt.toml` is required.
+
+---
+
+## EditorConfig
+
+`.editorconfig` enforces baseline rules in supported editors (VS Code, JetBrains, Vim, etc.) independently of any formatter:
+
+- UTF-8 encoding everywhere
+- LF line endings everywhere
+- Final newline on every file
+- Trailing whitespace trimmed (except Markdown)
+- 2-space indentation for TS/JS/JSON/YAML/Markdown
+- 4-space indentation for Rust
+
+---
+
+## CI enforcement
+
+The `format` job in `.github/workflows/ci.yml` runs on every pull request and push to `main` / `staging`:
+
+```
+format job
+ ├── dashboard: npm run format:check (Prettier --check)
+ └── listener: npm run format:check (Prettier --check)
+```
+
+The `rust` job also runs `cargo fmt --all -- --check`.
+
+A pull request **cannot be merged** if either check exits non-zero.
+
+---
+
+## Fixing formatting locally
+
+### TypeScript / TSX
+
+```bash
+# Fix dashboard
+cd dashboard
+npx prettier --write "src/**/*.{ts,tsx}" --config ../.prettierrc
+
+# Fix listener
+cd listener
+npx prettier --write "src/**/*.ts" --config ../.prettierrc
+```
+
+### Rust
+
+```bash
+cd contract
+cargo fmt --all
+```
+
+### VS Code auto-format on save
+
+Install the [Prettier - Code formatter](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode) extension and add to `.vscode/settings.json`:
+
+```json
+{
+ "editor.formatOnSave": true,
+ "editor.defaultFormatter": "esbenp.prettier-vscode",
+ "[rust]": {
+ "editor.defaultFormatter": "rust-lang.rust-analyzer"
+ }
+}
+```
diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json
index ca3faa6..14ee216 100644
--- a/dashboard/package-lock.json
+++ b/dashboard/package-lock.json
@@ -30,6 +30,7 @@
"jest-axe": "^10.0.0",
"jest-environment-jsdom": "^29.7.0",
"jsdom": "^26.1.0",
+ "prettier": "3.3.3",
"ts-jest": "^29.2.5",
"typescript": "^5.8.3",
"vite": "^6.3.5"
@@ -16202,6 +16203,22 @@
"node": ">= 0.8.0"
}
},
+ "node_modules/prettier": {
+ "version": "3.3.3",
+ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz",
+ "integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "prettier": "bin/prettier.cjs"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/prettier/prettier?sponsor=1"
+ }
+ },
"node_modules/pretty-format": {
"version": "27.5.1",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
diff --git a/dashboard/package.json b/dashboard/package.json
index b0433d9..ac5d8c7 100644
--- a/dashboard/package.json
+++ b/dashboard/package.json
@@ -8,6 +8,7 @@
"preview": "node ./node_modules/vite/bin/vite.js preview",
"dev": "node ./node_modules/vite/bin/vite.js",
"lint": "node ./node_modules/eslint/bin/eslint.js \"src/**/*.{ts,tsx}\" --max-warnings=0",
+ "format:check": "node ./node_modules/prettier/bin/prettier.cjs --check \"src/**/*.{ts,tsx}\" --config ../.prettierrc",
"test": "node ./node_modules/jest/bin/jest.js",
"test:wallet": "node ./node_modules/jest/bin/jest.js src/__tests__/wallet-integration.test.tsx",
"benchmark": "node ./node_modules/jest/bin/jest.js src/benchmark"
@@ -19,13 +20,6 @@
"zustand": "^5.0.6"
},
"devDependencies": {
- "@typescript-eslint/eslint-plugin": "^6.10.0",
- "@typescript-eslint/parser": "^6.10.0",
- "eslint": "^8.46.0",
- "eslint-plugin-react": "^7.33.0",
- "@testing-library/react": "^16.3.0",
- "@testing-library/user-event": "^14.6.1",
- "@types/jest": "^29.5.14",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
@@ -33,11 +27,16 @@
"@types/jest-axe": "^3.5.9",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
+ "@typescript-eslint/eslint-plugin": "^6.10.0",
+ "@typescript-eslint/parser": "^6.10.0",
"@vitejs/plugin-react": "^4.7.0",
+ "eslint": "^8.46.0",
+ "eslint-plugin-react": "^7.33.0",
"jest": "^29.7.0",
"jest-axe": "^10.0.0",
"jest-environment-jsdom": "^29.7.0",
"jsdom": "^26.1.0",
+ "prettier": "3.3.3",
"ts-jest": "^29.2.5",
"typescript": "^5.8.3",
"vite": "^6.3.5"
diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx
index 792f057..8e2dd1d 100644
--- a/dashboard/src/App.tsx
+++ b/dashboard/src/App.tsx
@@ -129,6 +129,82 @@ export function App() {
+
+
+ {tab === 'explorer' && (
export function App() {
const [tab, setTab] = useState('explorer');
const [drawerOpen, setDrawerOpen] = useState(false);
diff --git a/dashboard/src/__tests__/wallet-integration.test.tsx b/dashboard/src/__tests__/wallet-integration.test.tsx
index 18ea4fe..dcccf6f 100644
--- a/dashboard/src/__tests__/wallet-integration.test.tsx
+++ b/dashboard/src/__tests__/wallet-integration.test.tsx
@@ -10,7 +10,6 @@ import * as fs from 'fs';
import * as path from 'path';
const WALLET_ID_KEY = 'notify-chain:wallet-id';
-const WALLET_ADDRESS_KEY = 'notify-chain:wallet-address';
const REPORT_PATH = path.join(process.cwd(), 'reports', 'wallet-integration.json');
type KitMock = typeof import('../test/stellarWalletsKitMock');
@@ -239,7 +238,7 @@ describe('Notification feed clears on wallet switch (issue #175)', () => {
await wallet.disconnectWallet();
expect(store.useWalletStore.getState().address).toBeNull();
- expect(localStorage.getItem(WALLET_ADDRESS_KEY)).toBeNull();
+ expect(localStorage.getItem('notify-chain:wallet-address')).toBeNull();
});
it('no stale address remains in localStorage after switching wallets', async () => {
@@ -258,8 +257,8 @@ describe('Notification feed clears on wallet switch (issue #175)', () => {
await wallet.connectWallet();
// localStorage must reflect the new account only
- expect(localStorage.getItem(WALLET_ADDRESS_KEY)).toBe(SUPPORTED_WALLETS[2].address);
- expect(localStorage.getItem(WALLET_ADDRESS_KEY)).not.toBe(SUPPORTED_WALLETS[0].address);
+ expect(localStorage.getItem('notify-chain:wallet-address')).toBe(SUPPORTED_WALLETS[2].address);
+ expect(localStorage.getItem('notify-chain:wallet-address')).not.toBe(SUPPORTED_WALLETS[0].address);
expect(store.useWalletStore.getState().address).toBe(SUPPORTED_WALLETS[2].address);
});
@@ -283,7 +282,7 @@ describe('Notification feed clears on wallet switch (issue #175)', () => {
await wallet.connectWallet();
expect(store.useWalletStore.getState().address).toBe(provider.address);
- expect(localStorage.getItem(WALLET_ADDRESS_KEY)).toBe(provider.address);
+ expect(localStorage.getItem('notify-chain:wallet-address')).toBe(provider.address);
expect(localStorage.getItem(WALLET_ID_KEY)).toBe(provider.id);
localStorage.clear();
diff --git a/dashboard/src/hooks/useWalletAccountSync.ts b/dashboard/src/hooks/useWalletAccountSync.ts
index fe339cb..170621b 100644
--- a/dashboard/src/hooks/useWalletAccountSync.ts
+++ b/dashboard/src/hooks/useWalletAccountSync.ts
@@ -9,7 +9,7 @@ import { useWalletStore } from '../store/walletStore';
* its initial value) — it fires only for subsequent transitions, i.e. a real
* wallet switch or disconnect while the page is open.
*/
-export function useWalletAccountSync(onAccountChange: (address: string | null) => void): void {
+export function useWalletAccountSync(onAccountChange: () => void): void {
const address = useWalletStore((state) => state.address);
// Track whether this is the very first render so we can skip it.
@@ -24,6 +24,6 @@ export function useWalletAccountSync(onAccountChange: (address: string | null) =
isFirstRender.current = false;
return;
}
- callbackRef.current(address);
+ callbackRef.current();
}, [address]);
}
diff --git a/dashboard/src/pages/NotificationSearchPage.test.tsx b/dashboard/src/pages/NotificationSearchPage.test.tsx
index 1221df4..01be2bd 100644
--- a/dashboard/src/pages/NotificationSearchPage.test.tsx
+++ b/dashboard/src/pages/NotificationSearchPage.test.tsx
@@ -1,9 +1,16 @@
import '@testing-library/jest-dom';
+import { render, screen, waitFor, fireEvent, act } from '@testing-library/react';
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
import { NotificationSearchPage } from './NotificationSearchPage';
import { searchNotifications } from '../services/eventsApi';
import type { NotificationSearchResponse } from '../services/eventsApi';
+jest.mock('../services/eventsApi', () => ({
+ searchNotifications: jest.fn(),
+}));
+
+const mockedSearch = searchNotifications as jest.MockedFunction;
+
jest.mock('../services/eventsApi', () => {
const actual = jest.requireActual('../services/eventsApi') as typeof import('../services/eventsApi');
return {
@@ -47,6 +54,18 @@ const mockResult: NotificationSearchResponse = {
totalPages: 1,
};
+function emptyResponse(): NotificationSearchResponse {
+ return {
+ results: [],
+ total: 0,
+ limit: 20,
+ offset: 0,
+ itemCount: 0,
+ totalPages: 0,
+ };
+}
+
+describe('NotificationSearchPage loading skeletons', () => {
describe('NotificationSearchPage filters', () => {
beforeEach(() => {
jest.useFakeTimers();
@@ -166,7 +185,6 @@ describe('NotificationSearchPage filters', () => {
expect(screen.getByLabelText(/filter from date/i)).toHaveValue('');
expect(screen.queryByRole('button', { name: /clear all filters/i })).not.toBeInTheDocument();
});
-});
describe('searchNotifications query params', () => {
const originalFetch = global.fetch;
@@ -271,6 +289,13 @@ describe('NotificationSearchPage loading skeletons', () => {
});
});
+describe('searchNotifications query params', () => {
+ const originalFetch = global.fetch;
+
+ beforeEach(() => {
+ global.fetch = jest.fn().mockResolvedValue({
+ ok: true,
+ json: async () => emptyResponse(),
describe('NotificationResultCard copy notification ID', () => {
beforeEach(() => {
mockedSearch.mockReset();
@@ -283,6 +308,28 @@ describe('NotificationResultCard copy notification ID', () => {
});
afterEach(() => {
+ global.fetch = originalFetch;
+ });
+
+ it('appends type, status, startDate, and endDate to the URL', async () => {
+ const { searchNotifications: realSearch } = jest.requireActual(
+ '../services/eventsApi'
+ ) as typeof import('../services/eventsApi');
+
+ await realSearch('http://localhost:8787', {
+ type: 'webhook',
+ status: 'COMPLETED',
+ startDate: '2026-01-01',
+ endDate: '2026-01-31',
+ });
+
+ expect(global.fetch).toHaveBeenCalledWith(
+ expect.stringContaining('type=webhook')
+ );
+ const calledUrl = (global.fetch as jest.Mock).mock.calls[0][0] as string;
+ expect(calledUrl).toContain('status=COMPLETED');
+ expect(calledUrl).toContain('startDate=2026-01-01');
+ expect(calledUrl).toContain('endDate=2026-01-31');
jest.useRealTimers();
});
diff --git a/listener/package-lock.json b/listener/package-lock.json
index efacc89..19bf08d 100644
--- a/listener/package-lock.json
+++ b/listener/package-lock.json
@@ -27,6 +27,7 @@
"@typescript-eslint/parser": "^6.10.0",
"eslint": "^8.46.0",
"jest": "^29.7.0",
+ "prettier": "3.3.3",
"ts-jest": "^29.4.11",
"ts-node": "^10.9.2",
"typescript": "~5.4.5"
@@ -2841,6 +2842,9 @@
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
+ "version": "2.11.5",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.5.tgz",
+ "integrity": "sha512-xJo6a6YZnwZfnyGmQKWMbVOcii7XRibjOskRh+WJ9UHQoX16xrQrcIgAMQOzfvs8XiLMx6ih/fsLPF73iY2D1A==",
"version": "2.11.4",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.4.tgz",
"integrity": "sha512-s4+sLr9mZ/CyqeRritFeYV/Zx73OAtmaHn6kkBS1XRoJn1hrg3xIDUcpicAEX68tkcIN0iBCgti31C8zxtkhsQ==",
@@ -7301,6 +7305,22 @@
"node": ">= 0.8.0"
}
},
+ "node_modules/prettier": {
+ "version": "3.3.3",
+ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz",
+ "integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "prettier": "bin/prettier.cjs"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/prettier/prettier?sponsor=1"
+ }
+ },
"node_modules/pretty-format": {
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz",
diff --git a/listener/package.json b/listener/package.json
index 498caf2..683d2a1 100644
--- a/listener/package.json
+++ b/listener/package.json
@@ -8,6 +8,7 @@
"start": "node dist/index.js",
"typecheck": "node ./node_modules/typescript/bin/tsc --noEmit",
"lint": "node ./node_modules/typescript/bin/tsc --noEmit",
+ "format:check": "node ./node_modules/prettier/bin/prettier.cjs --check \"src/**/*.ts\" --config ../.prettierrc",
"test": "node ./node_modules/jest/bin/jest.js",
"migrate": "ts-node src/scripts/migrate-db.ts",
"migrate:templates": "ts-node src/scripts/migrate-templates.ts",
@@ -37,6 +38,7 @@
"@typescript-eslint/parser": "^6.10.0",
"eslint": "^8.46.0",
"jest": "^29.7.0",
+ "prettier": "3.3.3",
"ts-jest": "^29.4.11",
"ts-node": "^10.9.2",
"typescript": "~5.4.5"