Skip to content
Draft
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
3 changes: 3 additions & 0 deletions src/consts/intervals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,6 @@ export const TRENDING_TOKENS_FAILED_UPDATE_INTERVAL = 60 * 1000 // 1 minute
export const ESTIMATE_UPDATE_INTERVAL = 30000
export const GAS_PRICE_UPDATE_INTERVAL = 12000
export const FETCH_SAFE_TXNS = 3 * 60 * 1000 // 3 minutes
// Shielded balances change far less often than public portfolio activity, and each
// sync re-scans UTXO commitments, so a longer interval keeps RPC/Subsquid load low.
export const RAILGUN_BALANCE_REFRESH_INTERVAL = 3 * 60 * 1000 // 3 minutes
62 changes: 62 additions & 0 deletions src/consts/railgun.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/**
* Chains the Railgun SDK can actually run on.
*
* The Railgun protocol itself is deployed on more networks (Arbitrum, Polygon, BSC), but
* `@kohaku-eth/railgun`'s `chainConfig(chainId)` only resolves a ChainConfig for these two -
* everything else returns null and `createRailgunPlugin` throws `Unsupported chain ID`. This
* list is therefore a mirror of the SDK's capability, not of the protocol's, and
* `RailgunController` asserts each entry against `chainConfig()` at init time so a version
* bump that drops (or adds) a chain fails loudly instead of silently.
*
* Note that these two are mutually exclusive in practice: Sepolia only exists in
* `predefinedTestnetNetworks`, so a wallet in mainnet mode sees Ethereum and a wallet in
* testnet mode sees Sepolia (unless the user added the other one as a custom network).
*/
export const RAILGUN_SUPPORTED_CHAIN_IDS = [1n, 11155111n]

/**
* Railgun derives its spending and viewing keys at `m/44'/1984'/0'/0'/<index>'` and
* `m/420'/1984'/0'/0'/<index>'` (1984 is Railgun's BIP-44 coin type). The exact paths come
* from the SDK at runtime (`RailgunSigner.spendingKeyPath`/`viewingKeyPath`); these prefixes
* exist so `KeystoreController.deriveRailgunKey` can refuse anything outside them.
*
* That refusal is the security boundary: the Railgun plugin is an unaudited alpha SDK that
* asks the host keystore to derive arbitrary BIP-32 paths, and without this whitelist a bug
* (or a compromised release) could ask for `m/44'/60'/...` - the user's actual EVM keys.
*/
export const RAILGUN_DERIVATION_PATH_PREFIXES = ["m/44'/1984'/", "m/420'/1984'/"]

/**
* Which Railgun key pair to derive from the seed. Fixed at 0 on purpose: it makes the 0zk
* address a pure function of the recovery phrase, so it is recoverable from the seed alone
* with no extra persisted state. The consequence is that every account derived from the same
* recovery phrase shares one Railgun identity (and one shielded balance) - the Privacy UI
* states this explicitly.
*
* If per-account (or multiple) privacy identities are ever wanted, the index must be chosen
* by the user and persisted - never inferred from the account's HD index, since an inference
* that comes back different points the wallet at an empty identity and the user's shielded
* funds look gone.
*/
export const RAILGUN_KEY_INDEX = 0

/**
* The upper bound stated to the user before an initial sync, and the timeout the controller gives it
* - deliberately the same number, so the promise and the behaviour cannot disagree.
*
* One figure for every chain rather than a table per chain, because the variance between runs is
* larger than the difference between the cases. Measured 2026-08-11 against a mainnet pool of ~254k
* commitments / ~126k POI operations, with the phases read off the SDK's own log timestamps:
*
* - First initialization of a chain: 671s on Ethereum, 14s on Sepolia. Of the Ethereum figure, 33%
* is downloading commitments, 34% building the POI/TXID tree, 11% trial-decrypting for the
* identity, the rest tree inserts and nullifiers.
* - First initialization for a *further* identity on the same chain: 336s. The POI/TXID half is
* reused, but the SDK still re-downloads and re-processes every commitment - so it is half the
* work, not a tenth, and observed runs have gone well past that when the indexer is slow.
* - A sync for an identity that is already initialized: 6s. Only the tail since the last one.
*
* The pool grows - roughly 22%/year at the observed rate - so this figure has to grow with it. When
* it does, the timeout follows automatically.
*/
export const RAILGUN_INITIAL_SYNC_MAX_MINUTES = 20
1 change: 1 addition & 0 deletions src/controllers/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ ALWAYS update this list when creating a new controller, and provide a one-senten
- **ProvidersController** – Initializes and manages JSON-RPC providers for each configured network.
- **PhishingController** – Maintains and updates a list of phishing domains and addresses to protect users.
- **PortfolioController** – Fetches and caches token balances, DeFi positions, and price data per account.
- **RailgunController** – Derives the account's Railgun (0zk) identity deterministically from its recovery phrase, syncs POI-aware shielded balances for every supported chain at once with no network selection (Ethereum and Sepolia are the only ones the SDK supports), and builds shield/unshield/private-transfer operations against the chain each token belongs to.
- **RequestsController** – Handles all requests (e.g., signing, connecting to an app, etc.), which come from the app UI and dApps.
- **SafeController** – Integrates with Safe (Gnosis Safe) multisig wallets for transaction and message fetching.
- **SelectedAccountController** – Tracks the currently selected account and derives its data (e.g., portfolio and auto login policies)
Expand Down
43 changes: 43 additions & 0 deletions src/controllers/continuousUpdates/continuousUpdates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
ACTIVE_EXTENSION_PORTFOLIO_UPDATE_INTERVAL,
ACTIVITY_REFRESH_INTERVAL,
INACTIVE_EXTENSION_PORTFOLIO_UPDATE_INTERVAL,
RAILGUN_BALANCE_REFRESH_INTERVAL,
TRENDING_TOKENS_ACTIVE_UPDATE_INTERVAL,
TRENDING_TOKENS_FAILED_UPDATE_INTERVAL,
TRENDING_TOKENS_INACTIVE_UPDATE_INTERVAL
Expand Down Expand Up @@ -74,6 +75,12 @@ export class ContinuousUpdatesController extends EventEmitter {

#safeGlobalMessageInterval: IRecurringTimeout

#railgunBalancesInterval: IRecurringTimeout

get railgunBalancesInterval() {
return this.#railgunBalancesInterval
}

#updateTrendingTokensInterval: IRecurringTimeout

get updateTrendingTokensInterval() {
Expand Down Expand Up @@ -173,6 +180,32 @@ export class ContinuousUpdatesController extends EventEmitter {
'resolveConfirmedSafeMessages'
)

this.#railgunBalancesInterval = new RecurringTimeout(
this.#updateRailgunBalances.bind(this),
RAILGUN_BALANCE_REFRESH_INTERVAL,
this.emitError.bind(this),
'railgunBalancesInterval'
)

// Railgun requires explicit user opt-in (key derivation + WASM init + a shielded pool
// sync), so the refresh interval only runs once initialized, and stops if that ever
// reverts (e.g. the background context restarted, or the keystore locked, and Railgun
// hasn't been re-initialized yet).
//
// With POI enabled this interval is not just a balance refresh: the SDK generates and
// submits the POI proofs for notes it has pending during a sync, so a wallet that never
// syncs after sending leaves those notes without an innocence proof - and unspendable.
this.#main.railgun.onUpdate(() => {
// Gated on a completed scan, not merely on an initialized plugin: opening the Privacy screen
// derives the identity (RailgunController.initIdentity), and starting a scan off the back of
// that would turn a screen visit into the minutes-long first walk nobody asked for.
if (this.#main.railgun.isInitialized && this.#main.railgun.hasSyncedAnyChain) {
this.#railgunBalancesInterval.start({ runImmediately: true })
} else {
this.#railgunBalancesInterval.stop()
}
}, 'continuous-update')

// Trending tokens poll frequently only while the extension is active and back off to a long
// cadence otherwise. On becoming active we refresh immediately, but the freshness guard in
// #updateTrendingTokens skips the fetch when the last update is still recent.
Expand Down Expand Up @@ -274,6 +307,16 @@ export class ContinuousUpdatesController extends EventEmitter {
})
}

async #updateRailgunBalances() {
await this.initialLoadPromise

if (!this.#main.railgun.isInitialized) return

// Flagged as a background update so a transient failure on this timer doesn't toast the
// user (and report to Sentry) on every tick - see RailgunController.sync.
await this.#main.railgun.sync({ isBackgroundUpdate: true })
}

async #updateTrendingTokens() {
await this.initialLoadPromise
await this.#main.dapps.initialLoadPromise
Expand Down
59 changes: 55 additions & 4 deletions src/controllers/keystore/keystore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,16 @@ import {
encryptWithPublicKey,
publicKeyByPrivateKey
} from 'eth-crypto'
import { computeAddress, concat, getBytes, hexlify, keccak256, Mnemonic, Wallet } from 'ethers'
import {
computeAddress,
concat,
getBytes,
HDNodeWallet,
hexlify,
keccak256,
Mnemonic,
Wallet
} from 'ethers'

import {
CIPHER,
Expand All @@ -27,8 +36,10 @@ import {
DERIVATION_OPTIONS,
HD_PATH_TEMPLATE_TYPE
} from '../../consts/derivation'
import { RAILGUN_DERIVATION_PATH_PREFIXES } from '../../consts/railgun'
import { Account } from '../../interfaces/account'
import { IEventEmitterRegistryController, Statuses } from '../../interfaces/eventEmitter'
import { Hex } from '../../interfaces/hex'
import { KeyIterator } from '../../interfaces/keyIterator'
import {
ExternalKey,
Expand Down Expand Up @@ -715,11 +726,13 @@ export class KeystoreController extends EventEmitter implements IKeystoreControl
}

async persistTempSeed() {
if (!this.#tempSeed) return
if (!this.#tempSeed) return undefined

await this.#addSeed(this.#tempSeed)
const persistedSeed = await this.#addSeed(this.#tempSeed)
this.#tempSeed = null
this.emitUpdate()

return persistedSeed
}

async #addSeed({ seed, seedPassphrase, hdPathTemplate, notBackedUp }: KeystoreTempSeed) {
Expand All @@ -742,7 +755,13 @@ export class KeystoreController extends EventEmitter implements IKeystoreControl
}

const existingEntry = await this.#findStoredSeed(seed, seedPassphrase)
if (existingEntry) return
if (existingEntry)
return {
id: existingEntry.id,
label: existingEntry.label,
hdPathTemplate: existingEntry.hdPathTemplate,
withPassphrase: !!existingEntry.seedPassphrase
}

const entropy = extractEntropyFromSeed(seed)

Expand All @@ -764,6 +783,13 @@ export class KeystoreController extends EventEmitter implements IKeystoreControl
await this.#storage.set('keystoreSeeds', this.#keystoreSeeds)

this.emitUpdate()

return {
id: newEntry.id,
label: newEntry.label,
hdPathTemplate: newEntry.hdPathTemplate,
withPassphrase: !!newEntry.seedPassphrase
}
}

async addSeed(keystoreSeed: KeystoreTempSeed) {
Expand Down Expand Up @@ -1238,6 +1264,31 @@ export class KeystoreController extends EventEmitter implements IKeystoreControl
}
}

/**
* Derives a Railgun spending or viewing key from a stored recovery phrase, so the Railgun
* plugin can get the keys it needs without the recovery phrase itself ever leaving the
* keystore. Deterministic by construction: the same (seedId, path) always yields the same
* key, which is what makes the resulting 0zk address recoverable from the seed alone.
*
* Only Railgun's own derivation paths are allowed (see
* RAILGUN_DERIVATION_PATH_PREFIXES for why this whitelist is the point of the method, not
* a formality).
*/
async deriveRailgunKey(seedId: KeystoreSeed['id'], path: string): Promise<Hex> {
await this.initialLoadPromise

if (!this.isUnlocked) throw new Error('keystore: not unlocked')

const isRailgunPath = RAILGUN_DERIVATION_PATH_PREFIXES.some((prefix) => path.startsWith(prefix))
if (!isRailgunPath)
throw new Error(`keystore: refusing to derive a key outside Railgun's paths (${path})`)

const { seed, seedPassphrase } = await this.getSavedSeed(seedId)

return HDNodeWallet.fromMnemonic(Mnemonic.fromPhrase(seed, seedPassphrase), path)
.privateKey as Hex
}

async #changeKeystorePassword(newSecret: string, oldSecret?: string, extraEntropy?: string) {
await this.initialLoadPromise

Expand Down
33 changes: 32 additions & 1 deletion src/controllers/main/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { NetworksController } from '@/controllers/networks/networks'
import { PhishingController } from '@/controllers/phishing/phishing'
import { PortfolioController } from '@/controllers/portfolio/portfolio'
import { ProvidersController } from '@/controllers/providers/providers'
import { RailgunController } from '@/controllers/railgun/railgun'
import { RequestsController } from '@/controllers/requests/requests'
import { SafeController } from '@/controllers/safe/safe'
import { SelectedAccountController } from '@/controllers/selectedAccount/selectedAccount'
Expand Down Expand Up @@ -76,6 +77,7 @@ import { IPhishingController } from '@/interfaces/phishing'
import { Platform } from '@/interfaces/platform'
import { IPortfolioController } from '@/interfaces/portfolio'
import { IProvidersController } from '@/interfaces/provider'
import { IRailgunController } from '@/interfaces/railgun'
import { IRequestsController } from '@/interfaces/requests'
import { ISafeController } from '@/interfaces/safe'
import { ISelectedAccountController } from '@/interfaces/selectedAccount'
Expand Down Expand Up @@ -163,6 +165,8 @@ export class MainController extends EventEmitter implements IMainController {

portfolio: IPortfolioController

railgun: IRailgunController

dapps: IDappsController

phishing: IPhishingController
Expand Down Expand Up @@ -238,7 +242,9 @@ export class MainController extends EventEmitter implements IMainController {
featureFlags,
keystoreSigners,
externalSignerControllers,
uiManager
uiManager,
loadRailgunWasm,
pimlicoApiKey
}: {
eventEmitterRegistry?: IEventEmitterRegistryController
appVersion: string
Expand All @@ -255,6 +261,13 @@ export class MainController extends EventEmitter implements IMainController {
keystoreSigners: Partial<{ [key in Key['type']]: KeystoreSignerType }>
externalSignerControllers: ExternalSignerControllers
uiManager: UiManager
// Railgun (privacy pool) is currently web-only - the WASM asset loader is
// platform-specific, so this is optional for other environments (mobile, benzin, legends)
// that don't construct it.
loadRailgunWasm?: () => Promise<Response | BufferSource>
// Pimlico ERC-4337 bundler API key, used only for Railgun unshield/private-transfer
// broadcasting. Optional - that flow simply isn't available without it.
pimlicoApiKey?: string
}) {
super(eventEmitterRegistry)
this.#storageAPI = storageAPI
Expand Down Expand Up @@ -416,6 +429,24 @@ export class MainController extends EventEmitter implements IMainController {
eventEmitterRegistry,
this.verification
)
this.railgun = new RailgunController({
keystore: this.keystore,
networks: this.networks,
providers: this.providers,
selectedAccount: this.selectedAccount,
storage: this.storage,
fetch: this.fetch,
loadWasm:
loadRailgunWasm ||
(() => {
throw new Error(
'railgun: no WASM loader was provided for this environment - Railgun is currently web-only'
)
}),
sendUiMessage: this.ui.message.sendUiMessage,
pimlicoApiKey,
eventEmitterRegistry
})
if (this.featureFlags.isFeatureEnabled('withEmailVaultController')) {
this.emailVault = new EmailVaultController(
this.storage,
Expand Down
Loading