ImpulsewhitepaperBack to site
Contents
810 / 12

Security and custody

The section that should be checkable rather than believed.

A wallet asks for more trust than any other kind of software. The response should not be a promise, it should be a set of properties anyone can verify.

Key generation

A wallet begins with entropy from the browser's cryptographic random source, and nothing else. No server is contacted, no identifier is created, and the recovery phrase exists only on the device that made it. It is shown to you once, and never leaves.

src/lib/keystore.tsts
1export async function createWallet(password: string) {
2 const mnemonic = newMnemonic(); // BIP-39, browser CSPRNG
3 const { address } = deriveFromMnemonic(mnemonic, 0);
4 
5 await setItem(VAULT_KEY, {
6 seed: await encrypt(password, mnemonic), // never stored in the clear
7 accounts: [{ address, kind: "hd", hdIndex: 0 }],
8 });
9 
10 return { mnemonic }; // shown once, to you only
11}

Neither the keystore nor the storage layer contains a single fetch call or URL. There is no code path from key material to the network.

Derivation: standard paths, so you can leave

Keys are derived at the paths every major wallet uses: m/44'/60'/0'/0/n for EVM chains and m/44'/501'/n'/0' for Solana. That matters more than it sounds: the same recovery phrase produces the same addresses in MetaMask and Phantom, which means your funds stay reachable without Impulse existing at all.

Solana uses a different curve, and this is where wallets quietly get it wrong. ed25519 requires SLIP-0010 rather than BIP-32, with every level hardened and no public derivation. Get it wrong and you produce a plausible-looking address that no other wallet agrees with. Impulse's implementation is checked against all four official SLIP-0010 test vectors, and the resulting addresses match what Phantom and Solflare show for the same phrase.

Encryption at rest

The phrase is sealed with a key derived from your password, using a fresh random salt and initialisation vector on every single write. Nothing is reused between encryptions, which is the property that makes AES-GCM safe to use at all.

src/lib/keystore.tsts
1const PBKDF2_ITERS = 310_000; // OWASP floor for SHA-256
2 
3const salt = getRandomValues(new Uint8Array(16)); // fresh, every write
4const iv = getRandomValues(new Uint8Array(12)); // never reused
5 
6const key = await deriveKey(password, salt); // PBKDF2 -> AES-256
7const ct = await subtle.encrypt({ name: "AES-GCM", iv }, key, data);

The unlocked key lives in session memory only. It is cleared on lock, on browser close, and by an idle timeout enforced where signing happens rather than only in the interface.

The network boundary

A browser extension can only contact hosts declared in its manifest. The browser enforces this, not us. That list is the complete boundary of where anything could possibly go, and every entry is a public third-party API: chain RPCs, DexScreener, GeckoTerminal, GMGN, Jupiter, PumpPortal, Bubblemaps.

manifest.json · host_permissionsenforced by the browser
rpc.mainnet.chain.robinhood.combalances, broadcast
mainnet.base.orgbalances, broadcast
api.dexscreener.comprices, pairs
api.geckoterminal.comchart candles
quote-api.jup.agSolana routing
openapi.gmgn.aisafety, holders
No impulse.* host, and no server of ours, appears on this list. It is the complete set of places anything can go.
An excerpt of the manifest boundary. Sixteen hosts, every one a public third-party API.
Not one of them is a server we operate. There is nowhere for your keys to go.

Verify it yourself

  • Read the boundary. Open the extension's manifest and look at host_permissions. It is short, and it is the whole answer.
  • Watch the network. Open devtools, create a wallet, and count the requests. There are none.
  • Disconnect entirely. Turn off your network and create a wallet. It works, because the keys are made on your machine. A custodial wallet cannot do this.
  • Take your keys elsewhere. Import the phrase into MetaMask or Phantom. Same addresses. Your funds do not depend on us.