This document describes how NoM Wallet is structured internally. It is written against the current source; file paths are given so each claim can be checked against the code.
NoM Wallet is a Vue 3 + TypeScript application that ships as two build targets from one codebase:
- a standalone web app, and
- a Chrome/Edge browser extension (Manifest V3).
Both targets share the same application code under src/ and the same shared component library under packages/ui/ (@nom/ui). All blockchain and wallet logic lives in a platform-agnostic service layer; the differences between web and extension are confined to the storage backend and the build configuration.
The repository is an npm-workspaces monorepo (workspaces: ["packages/*"] in package.json). The only workspace package today is packages/ui (@nom/ui).
Two Vite configs drive the two targets:
| Target | Config | Output | Notes |
|---|---|---|---|
| Web | vite.config.web.ts |
dist/ |
Vue + Tailwind + node polyfills; custom plugin copies the SDK's PoW assets (see below) |
| Extension | vite.config.extension.ts |
dist-extension/ |
Uses @crxjs/vite-plugin with manifest.json |
The @ alias maps to src/, and @nom/ui maps to packages/ui/src (defined in the web config's resolve.alias).
znn-typescript-sdk loads pow.js and pow.wasm from the web root at runtime. Vite doesn't know about these files, so vite.config.web.ts defines a copyPowFiles plugin that serves them from the SDK's dist/browser directory during dev and copies them into the build output during production. The SDK itself is excluded from dependency pre-bundling (optimizeDeps.exclude).
Components / Pages (src/components, src/pages)
│ import only from @/core and @nom/ui
▼
Composables (src/core/composables) ← reactive Vue wrappers
│ call Service.getInstance()
▼
Services (src/core/*-service.ts) ← business / blockchain logic
│
├── ZenonService → znn-typescript-sdk (network + PoW)
├── StorageService → StorageAdapter (localStorage / chrome.storage)
└── SessionManager → in-memory unlocked KeyStores
The boundary between layers is deliberate and enforced by the barrel file. src/core/index.ts re-exports composables, selected types, and formatters, but not the service classes, storage adapters, or session manager. Its header comment states this explicitly: components are expected to consume functionality only through composables, never by importing a service directly.
Services hold all business logic and are the only code that talks to the SDK. Each service in src/core/ follows the same shape: a getInstance() singleton accessor and an ensureInitialized() method that guarantees the underlying ZenonService connection is ready before use.
| Service | File | Responsibility (public surface) |
|---|---|---|
WalletService |
wallet-service.ts |
Create (KeyStore.newRandom) / import (KeyStore.fromMnemonic) wallets, encrypt & persist keystores, unlock/lock, derive accounts, rename, hide/show, delete, export mnemonic, sign data |
AccountService |
account-service.ts |
getAccountInfo, getPlasmaInfo, getPlasmaLevel, getUnreceivedBlocks, getDelegatedPillar |
TransactionService |
transaction-service.ts |
sendTransaction, receiveTransaction, sendEmbeddedContractBlock |
PlasmaService |
plasma-service.ts |
getFusionEntries, createFuseBlock, createCancelBlock |
StakeService |
stake-service.ts |
getStakeEntries, createStakeBlock, createCancelStakeBlock |
RewardsService |
rewards-service.ts |
getAllUncollectedRewards, getUncollectedReward, createCollectRewardBlock (pillar, sentinel, stake, liquidity) |
PillarService |
pillar-service.ts |
getAllPillars (paged), createDelegateBlock, createUndelegateBlock, getTotalDelegatedZnn |
TokenService |
token-service.ts |
getTokenByZts |
ZenonService |
zenon-service.ts |
Singleton SDK connection; network + PoW configuration |
State-changing operations are split in two:
- The domain service constructs an
AccountBlockTemplate— e.g.PlasmaService.createFuseBlock,StakeService.createStakeBlock,PillarService.createDelegateBlock,RewardsService.createCollectRewardBlock. These methods are synchronous and do no signing. TransactionService.sendEmbeddedContractBlock(block, keyPair)signs and broadcasts the template using aKeyPairderived from an unlocked wallet.
Plain value transfers go through TransactionService.sendTransaction, and receiving an unreceived block goes through TransactionService.receiveTransaction(blockHash, keyPair).
Zenon requires Proof-of-Work to produce a block's nonce when the sending account lacks the plasma to cover it. The SDK computes this in the browser via a WebAssembly module; the asset-serving side (pow.js / pow.wasm, setPowBasePath('/'), the copyPowFiles plugin) is covered under Build targets above. How the work is scheduled is decided in ZenonService (src/core/zenon-service.ts):
- Configuration is one-time and idempotent. Static flags (
powConfigured,powWorkerEnabled) guard setup so it runs once across the singleton's lifetime. - Web app → off-thread worker. When not in an extension context and
isPowWorkerSupported()is true, the service callsZenon.usePowWorker()and registers it viaZenon.setPowProvider(...). Running PoW off the main thread keeps the UI responsive and stops the long computation from starving the node WebSocket heartbeat (which previously dropped the connection mid-send). - Extension → main-thread fallback.
isExtensionContext()(detected viachrome.runtime?.id) returns true inside the MV3 popup/worker, where the CSPscript-src 'self'forbids the SDK'sblob:-based worker. The service skips worker setup and lets the SDK use its synchronous main-thread WASM generator. - Defensive fallback. Worker creation is wrapped in try/catch; if it throws (e.g. a strict CSP elsewhere), the failure is logged and the SDK transparently falls back to main-thread PoW rather than breaking the send.
src/core/pow-status.ts exposes a reactive isGeneratingPow flag for the UI. It is driven by trackPow, a wrapper conforming to the SDK's PowProvider signature that increments/decrements an in-flight counter around each generation. Because only the off-thread worker path is wrapped with trackPow, this reactive flag reflects PoW activity in the web app; in the extension (main-thread generator, no pluggable provider) operation-level toasts cover the feedback instead.
src/core/composables/ contains one composable per service plus a few helpers. The exported set (src/core/composables/index.ts): useWallet, useAccount, useNetwork, useTransaction, usePlasma, useStake, useRewards, usePillar, useToken, useStorage, and runActivity (from useActivity), along with the formatter utilities.
The composables use a module-level singleton pattern. Reactive state (ref/computed) is declared at module scope — outside the exported function — so every component that calls e.g. useWallet() shares the same state rather than getting its own copy. The function body wires up the methods and returns them. useWallet also caches a one-time loadPromise so the router guard and App.vue don't each trigger a separate initial load on startup.
Inside the composable, the corresponding service is obtained via Service.getInstance() (e.g. WalletService.getInstance()). State changes are mediated through window CustomEvents where cross-cutting notification is needed — for example, lock/unlock dispatches a wallet-status-changed event that Home.vue listens for to reload.
All persistence goes through the StorageAdapter interface (src/types/wallet.ts):
interface StorageAdapter {
get<T>(key: string): Promise<T | null>
set<T>(key: string, value: T): Promise<void>
remove(key: string): Promise<void>
}Two implementations exist in src/core/storage/:
LocalStorageAdapter— browserlocalStorage(web app)ChromeStorageAdapter—chrome.storage.local(extension)
The adapter is not selected in main.ts. Instead, StorageService (src/core/storage/storage-service.ts) auto-detects the environment in its constructor: if chrome.storage.local is present and accessible it uses ChromeStorageAdapter, otherwise it falls back to LocalStorageAdapter. A module-level singleton storageService is exported and consumed by WalletService.
The persisted shape is WalletStorage ({ wallets, activeWalletAddress, activeAccountAddress }); each Wallet carries its encryptedKeyFile, accounts, name, baseAddress, and createdAt.
Key material is handled by the SDK's KeyStore / KeyFile types:
- Create:
KeyStore.newRandom(). - Import:
KeyStore.fromMnemonic(mnemonic). - Persist:
WalletService.saveWalletcallsKeyFile.setPassword(password)thenkeyFile.encrypt(keyStore), storing only the encrypted result asencryptedKeyFile. The encrypted structure (KeyFileEncryptedDatainsrc/types/wallet.ts) records an Argon2-based KDF and cipher parameters. Private keys are never persisted in plaintext. - Unlock:
KeyFile.setPassword(password)+keyFile.decrypt(encryptedKeyFile)yields aKeyStore, which is handed to the session manager. - Sign:
WalletService.signDataresolves aKeyPairfrom an unlockedKeyStoreand signs.
SessionManager (src/core/session-manager.ts) holds unlocked KeyStores in an in-memory Map, keyed by base address, each stamped with an unlockedAt time. isUnlocked enforces a 30-minute timeout (sessionTimeout = 30 * 60 * 1000) and evicts expired sessions on access. Nothing here is persisted, so locking — or reloading the app — discards the unlocked keys. A module-level singleton sessionManager is exported (note: this is a plain exported instance, not a getInstance() accessor like the other services).
Routes are defined in src/router.ts using createWebHistory:
| Path | Page | Meta |
|---|---|---|
/ |
Home.vue |
requiresWallet |
/setup |
Setup.vue |
— |
/send |
Send.vue |
requiresWallet, requiresUnlock |
/receive |
Receive.vue |
requiresWallet |
/token/:tokenStandard |
TokenDetails.vue |
requiresWallet |
/:pathMatch(.*)* |
→ redirect / |
— |
A global beforeEach guard enforces wallet state. It first calls wallet.ensureLoaded() (the guard can run before App.vue's onMounted on a hard refresh), then:
requiresWalletroute with no wallets → redirect to/setup./setupwhile wallets already exist → redirect to/.requiresUnlockroute while the active wallet is locked → redirect to/with anunlockquery holding the original target.App.vuereads that query, opens the unlock dialog, and navigates to the target on success.
src/main.ts— web/extension app entry. InstallsBufferonglobalThisfor the SDK, registers a global VueerrorHandlerthat surfaces uncaught errors via a toast, installs the router, and mountsApp.vue.src/background.ts— the extension's MV3 service worker. It is currently a minimal stub: anonInstalledlistener and a no-oponMessagehandler. Wallet storage in the extension is accessed directly viachrome.storage.localfrom the popup context, not proxied through the worker.manifest.json— MV3 manifest. Requests only thestoragepermission; the popup isindex.html; the backgroundservice_workerissrc/background.ts(module type).
Shared constants live in src/config.ts — the default node URL (wss://node.zenonhub.io:35998) and the built-in node list, momentum/block timing, plasma fusion minimums and revoke lock, staking minimums and the 30-day "month" duration options, and the minimum password length. Prefer importing from here over hardcoding values in components.
- No semicolons, single quotes, 100-char width (
.prettierrc.json). - Strict TypeScript is the primary correctness check; there is no automated test suite (
npm run typecheck+npm run lint). - PascalCase for components, camelCase for services and functions.
- No Pinia/Vuex — shared state is the module-level reactive state inside composables.