diff --git a/README.md b/README.md index b1c94b2..1009431 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # @1001-digital/wagmi-in-app-wallet -A [wagmi](https://wagmi.sh) connector that turns a BIP39 mnemonic into a fully functional in-browser wallet. Keys are derived locally and stored in `localStorage` — no external signers or extensions required. +A wagmi connector for a client-encrypted, host-synchronized EVM wallet. +Mnemonic and private-key material stay in a browser worker while the host stores +only a versioned AES-GCM vault. ## Install @@ -13,48 +15,52 @@ pnpm add @1001-digital/wagmi-in-app-wallet ## Usage ```ts -import { inAppWallet, prepareInAppWallet } from '@1001-digital/wagmi-in-app-wallet' -import { createConfig, http } from '@wagmi/core' -import { mainnet } from 'viem/chains' - -const config = createConfig({ - chains: [mainnet], - connectors: [inAppWallet()], - transports: { [mainnet.id]: http() }, -}) - -// Derive and store the private key from a mnemonic -await prepareInAppWallet('your twelve word mnemonic ...') - -// Then connect -await config.connectors[0].connect() -``` +import { + EncryptedWalletKeyring, + inAppWallet, + type WalletVaultStore, +} from '@1001-digital/wagmi-in-app-wallet' + +const store: WalletVaultStore = { + load: () => api.get('/me/vaults/evm-in-app-wallet'), + put: (document, expectedRevision) => + api.put('/me/vaults/evm-in-app-wallet', { + document, + expectedRevision, + }), +} -### Custom storage key +const keyring = new EncryptedWalletKeyring({ store }) +await keyring.load() -```ts -inAppWallet({ storageKey: 'my-app:wallet-pk' }) +const connector = inAppWallet({ + keyring, + requestUnlock: async () => { + await openUnlockDialog(keyring) + }, +}) ``` -## API - -### `inAppWallet(parameters?)` - -Creates a wagmi connector. Accepts an optional `InAppWalletParameters` object: - -- `storageKey` — localStorage key for the private key (default: `evm:in-app-wallet-pk`) - -### `prepareInAppWallet(mnemonic)` - -Derives a private key from a BIP39 mnemonic, stores it in localStorage, and returns the wallet address. Call this before connecting. - -### `InAppWalletParameters` - -```ts -type InAppWalletParameters = { - storageKey?: string -} -``` +Use `keyring.create({ passphrase, scope })` for a new 12-word wallet or +`keyring.restore({ mnemonic, passphrase, scope })` for recovery. A passphrase is +always retained as the portable recovery wrapper; supported WebAuthn passkeys can +be added as additional PRF-based wrappers. + +Calling `disconnect()` or `keyring.lock()` destroys the in-memory signer. It does +not delete the synchronized encrypted vault. + +## Security model + +- The host API receives ciphertext, public address, salts, and wrapping metadata. + It never receives a mnemonic, private key, passphrase, or WebAuthn PRF output. +- Passphrase keys use Argon2id. Vault and wrapped-key ciphertext use AES-256-GCM + with domain-separated authenticated data. +- Browser reload, tab close, explicit lock, and sign-out require another unlock. +- A recovery phrase remains the user escape path if the host API or passkey + provider becomes unavailable. +- JavaScript running in the page can still influence requests while the wallet is + unlocked. Host applications must use a restrictive CSP and treat third-party + scripts as part of the wallet trust boundary. ## License diff --git a/package.json b/package.json index 58076ce..f3068c8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@1001-digital/wagmi-in-app-wallet", - "version": "0.1.0", + "version": "1.0.0", "type": "module", "exports": { ".": "./src/index.ts" @@ -12,9 +12,18 @@ "@wagmi/core": ">=3.0.0", "viem": ">=2.0.0" }, + "dependencies": { + "@noble/hashes": "^2.2.0" + }, "devDependencies": { - "@wagmi/core": "^3.4.0", + "@wagmi/core": "3.4.6", + "@types/node": "^24.0.0", "typescript": "^5.8.0", - "viem": "^2.45.0" + "viem": "2.48.4", + "vitest": "^3.2.4" + }, + "scripts": { + "test": "vitest run", + "typecheck": "tsc --noEmit" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b4584ab..e6da3a6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,22 +7,191 @@ settings: importers: .: + dependencies: + '@noble/hashes': + specifier: ^2.2.0 + version: 2.2.0 devDependencies: + '@types/node': + specifier: ^24.0.0 + version: 24.13.3 '@wagmi/core': - specifier: ^3.4.0 - version: 3.4.0(ox@0.12.4(typescript@5.9.3))(typescript@5.9.3)(viem@2.46.3(typescript@5.9.3)) + specifier: 3.4.6 + version: 3.4.6(typescript@5.9.3)(viem@2.48.4(typescript@5.9.3)) typescript: specifier: ^5.8.0 version: 5.9.3 viem: - specifier: ^2.45.0 - version: 2.46.3(typescript@5.9.3) + specifier: 2.48.4 + version: 2.48.4(typescript@5.9.3) + vitest: + specifier: ^3.2.4 + version: 3.2.7(@types/node@24.13.3) packages: '@adraffy/ens-normalize@1.11.1': resolution: {integrity: sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==} + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@noble/ciphers@1.3.0': resolution: {integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==} engines: {node: ^14.21.3 || >=16} @@ -35,6 +204,148 @@ packages: resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} engines: {node: ^14.21.3 || >=16} + '@noble/hashes@2.2.0': + resolution: {integrity: sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==} + engines: {node: '>= 20.19.0'} + + '@rollup/rollup-android-arm-eabi@4.62.3': + resolution: {integrity: sha512-c0wdcekXtQvvn5Tsrk/+op/gUArrbWaFduBnTLP2l1cKLSQs4diMWjJw3m6A0DdzT8dAAX95KpkJ3qynCePbmw==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.62.3': + resolution: {integrity: sha512-3YjElDdWN+qXAFbJ/CzPV+0wspLqh54k/I6GfdYtEJRqg7buSgc1yPM3B+93j1M4neobtkATHZTmxK2AMVGfnA==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.62.3': + resolution: {integrity: sha512-Pch2pFNOxxz1hTjypIdPyRTR6riiwRl84+VcN9djS680fw+Co1nAJINrdpqp7KV0NvyuU8ilZXZCjd7ykJl1GQ==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.62.3': + resolution: {integrity: sha512-LEuncFUHFiF8t4yZVZvvZA1wk0pjAscRnsrn1EfTEmN4HXotBi2YtcnLRyaK6UbuczW7xZS5ES+81Rdz8Z0T6g==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.62.3': + resolution: {integrity: sha512-zvBUvsQUpOWALdDsk6qbS8bXf2VxmPisuudNDrY7x0p0jBdsoZl8HsHczIOgkQiZldmcacMKtBzpoGVNeIe2bQ==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.62.3': + resolution: {integrity: sha512-C2KmNrcSem/AMg984H/dev+si0lieQGdXdR/lYGJnuumXnFb9Y7QdiI62obFdLlxRYLBv4P0eUVIDbD4c1vVvw==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.62.3': + resolution: {integrity: sha512-ggXnsTAEzNQx74XpunRsiZ9aBZDsI7XIa0hm2nzR9f4WzH5/f/d73ZSDaC5ejJ8YLY4NW+V3wr0tjOaeCq8hqA==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.62.3': + resolution: {integrity: sha512-2vng+FlzNUhKZxtej3IUqJgbZoQk2M/dwQM20+ULV0R/E/8tr9/P6uEf2iiGIk4HL0zMKh5Jry7mUHdUOvyGgA==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.62.3': + resolution: {integrity: sha512-LLLFZKt4/Nraf9rxDkhiU8QVgLF4WmCkfr0L4fj0fPfIZFBib0DeiFk1hhaYKd03LFAFJcxHslhDFlNJLylf5Q==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.62.3': + resolution: {integrity: sha512-WJkdQCvS9sWNOUBJZfQRKpZGFBztRzcowI+nndmflKgU4XY+3a420FgTOSKTsVqJbnzSxeT4vaJalpOaPo2YCQ==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.62.3': + resolution: {integrity: sha512-PwHXCCS2n64/1Ot6rP1YEYA02MGYBcQlr8CSZZyrUG2O7NH6NklYmvr9v3Jy+5e/eDeNchc/ukmKJi9LuflMIQ==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.62.3': + resolution: {integrity: sha512-vUjxINQu3RC8NZS3ykk1gN65gIz8pAopOq2HXuZhiIxHdx7TFvDG+jgrdSgInu1Eza4/Rfi2VzZgyIgEH4WOaw==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.62.3': + resolution: {integrity: sha512-wzko4aJ13+0G3kGnviCg5gnXFKd40izKsrf2uOw12US4XqprkDrmwOpeW14aSNa37V8bfPcz5Fkob6LZ3BAPmA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.62.3': + resolution: {integrity: sha512-8120ue0JUMSwy11stlwnfdX3pPd+WZYGCDBwEHWtIHi6pOpZmsEF5QKB7a/UN+XFdqvobxz98kv8RTqikyCEBw==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.62.3': + resolution: {integrity: sha512-XLFHnR3tXMjbOCh2vtVJHmxt+995uJsTERQyseFDRA0xxMxyTZPLa3OIUlyFaO4mF/Lu0FjmWHCuPXJT1n/IOg==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.62.3': + resolution: {integrity: sha512-se6yXvNGMIl0f+RQzyh7XAmia8/9kplQx424wnG2w0C1oi6XgO6Y8otKhdXFHbHs88Ihavzmvh1NWjuovE76BQ==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.62.3': + resolution: {integrity: sha512-gNoxRefktVIiGflpONuxWWXZAzIQG++z9qHO3xKwk4WdDMuQja3JHGfE1u0i3PfPDyvhypdk+WrgIJqLhGG7sg==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.62.3': + resolution: {integrity: sha512-V4KtWtQfAFMU7+9/A/VDps/VI8CHd3cYz0L8sgJzz8qK7eY7wI4ruFD82UYIYvW9Z4DtlTfhQcsl4XyPHW5uSg==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.62.3': + resolution: {integrity: sha512-LBx9LYXvj2CBkMkjLdNAWLwH0MLMin7do2VcVo9kVPibGLkY0BQQut2fv7NVqkXqZ/CrAu9LqDHVV1xHCMpCPw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.62.3': + resolution: {integrity: sha512-ABVf3Q0RCu7NcyCCOZQI0pJ3GuSdfSl8EXcy88QtdceIMIoCUdfhsJChZ64L9zVM2aJHjde1Bhn5uqSRcX9ySA==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.62.3': + resolution: {integrity: sha512-+2Cy/ldweGBLlPIKsQLF8U5N44a0KDdbrk1rAjHOM9M2K+kGdIVjHLmmrZIcx+9Ny3ke/1JomCsDI1ocb11+sg==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.62.3': + resolution: {integrity: sha512-dtZvzc8BedpSaFNy75x6uiWwAGTH+aZHDtdrqP6qk+WcLJrfti6sGje1ZJ9UxyzDLF23d/mV+PaMwuC0hL7UVA==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.62.3': + resolution: {integrity: sha512-Rj8Ra4noo+aYy7sKBggCx0407mws34kAb1ySyWuq5DAtFBQdkSwnsjCgPrhPe9cvgBKZIukpE+CVHvORCS93kQ==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.62.3': + resolution: {integrity: sha512-vp7N084ew/odXn2gi/mzm9mUkQu9l6AiN6dt4IeUM2Uvm9o+cVmP+YkqbMOteLbiGgqBBlJZjIMYVCfOOIVbVQ==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.62.3': + resolution: {integrity: sha512-MOG/3gTOn4Fwf574RVOaY61I5o6P90legkFADiTyn1hyjNydT+cerU2rLUwPdZkKKyJ+iT+K9p7WXK4LM1Ka6g==} + cpu: [x64] + os: [win32] + '@scure/base@1.2.6': resolution: {integrity: sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==} @@ -44,17 +355,58 @@ packages: '@scure/bip39@1.6.0': resolution: {integrity: sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==} - '@wagmi/core@3.4.0': - resolution: {integrity: sha512-EU5gDsUp5t7+cuLv12/L8hfyWfCIKsBNiiBqpOqxZJxvAcAiQk4xFe2jMgaQPqApc3Omvxrk032M8AQ4N0cQeg==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/node@24.13.3': + resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==} + + '@vitest/expect@3.2.7': + resolution: {integrity: sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==} + + '@vitest/mocker@3.2.7': + resolution: {integrity: sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.2.7': + resolution: {integrity: sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==} + + '@vitest/runner@3.2.7': + resolution: {integrity: sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==} + + '@vitest/snapshot@3.2.7': + resolution: {integrity: sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==} + + '@vitest/spy@3.2.7': + resolution: {integrity: sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==} + + '@vitest/utils@3.2.7': + resolution: {integrity: sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==} + + '@wagmi/core@3.4.6': + resolution: {integrity: sha512-wDZpRfzQo6NJj770mt23HdeU9O0MDO3cnxVP7tP/1HL7DLqOGMN3hADIc0wEF51ejrpnJlGLf8hS1qb2ZAzqJA==} peerDependencies: '@tanstack/query-core': '>=5.0.0' - ox: '>=0.11.1' + accounts: ~0.8.1 typescript: '>=5.7.3' viem: 2.x peerDependenciesMeta: '@tanstack/query-core': optional: true - ox: + accounts: optional: true typescript: optional: true @@ -70,14 +422,81 @@ packages: zod: optional: true + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + eventemitter3@5.0.1: resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + isows@1.0.7: resolution: {integrity: sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg==} peerDependencies: ws: '*' + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + mipd@0.0.7: resolution: {integrity: sha512-aAPZPNDQ3uMTdKbuO2YmAw2TxLHO0moa4YKAyETM/DTj5FloZo+a+8tU+iv4GmW+sOxKLSRwcSFuczk+Cpt6fg==} peerDependencies: @@ -86,27 +505,177 @@ packages: typescript: optional: true - ox@0.12.4: - resolution: {integrity: sha512-+P+C7QzuwPV8lu79dOwjBKfB2CbnbEXe/hfyyrff1drrO1nOOj3Hc87svHfcW1yneRr3WXaKr6nz11nq+/DF9Q==} + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.16: + resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + ox@0.14.20: + resolution: {integrity: sha512-rby38C3nDn8eQkf29Zgw4hkCZJ64Qqi0zRPWL8ENUQ7JVuoITqrVtwWQgM/He19SCMUEc7hS/Sjw0jIOSLJhOw==} peerDependencies: typescript: '>=5.4.0' peerDependenciesMeta: typescript: optional: true + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + postcss@8.5.24: + resolution: {integrity: sha512-8RyVklq0owXUTa4xlpzu4l9AaVKIdQvAcOHZWaMh98HgySsUtxRVf/chRe3dsSLqb6i40BzGRzEUddRaI+9TSw==} + engines: {node: ^10 || ^12 || >=14} + + rollup@4.62.3: + resolution: {integrity: sha512-Gu0c0iH9FzgX1L1t7ByIbbS3Vmdz+6KHm/EsqmmC71gUQ82yvZRkTK6XzrFObSka91WUVdynqp6nsfilzr5k6Q==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@4.0.4: + resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} + engines: {node: '>=14.0.0'} + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} hasBin: true - viem@2.46.3: - resolution: {integrity: sha512-2LJS+Hyh2sYjHXQtzfv1kU9pZx9dxFzvoU/ZKIcn0FNtOU0HQuIICuYdWtUDFHaGXbAdVo8J1eCvmjkL9JVGwg==} + undici-types@7.18.2: + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + + viem@2.48.4: + resolution: {integrity: sha512-mReP/rgY2P+WeeRSG4sUvccCLKfyAW1C73Y3KkobAqgzYmVna9qyUMNE44xIUkDtfvRuC33r24UhF4baBYovsg==} peerDependencies: typescript: '>=5.0.4' peerDependenciesMeta: typescript: optional: true + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + + vite@7.3.6: + resolution: {integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@3.2.7: + resolution: {integrity: sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.7 + '@vitest/ui': 3.2.7 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + ws@8.18.3: resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==} engines: {node: '>=10.0.0'} @@ -141,6 +710,86 @@ snapshots: '@adraffy/ens-normalize@1.11.1': {} + '@esbuild/aix-ppc64@0.28.1': + optional: true + + '@esbuild/android-arm64@0.28.1': + optional: true + + '@esbuild/android-arm@0.28.1': + optional: true + + '@esbuild/android-x64@0.28.1': + optional: true + + '@esbuild/darwin-arm64@0.28.1': + optional: true + + '@esbuild/darwin-x64@0.28.1': + optional: true + + '@esbuild/freebsd-arm64@0.28.1': + optional: true + + '@esbuild/freebsd-x64@0.28.1': + optional: true + + '@esbuild/linux-arm64@0.28.1': + optional: true + + '@esbuild/linux-arm@0.28.1': + optional: true + + '@esbuild/linux-ia32@0.28.1': + optional: true + + '@esbuild/linux-loong64@0.28.1': + optional: true + + '@esbuild/linux-mips64el@0.28.1': + optional: true + + '@esbuild/linux-ppc64@0.28.1': + optional: true + + '@esbuild/linux-riscv64@0.28.1': + optional: true + + '@esbuild/linux-s390x@0.28.1': + optional: true + + '@esbuild/linux-x64@0.28.1': + optional: true + + '@esbuild/netbsd-arm64@0.28.1': + optional: true + + '@esbuild/netbsd-x64@0.28.1': + optional: true + + '@esbuild/openbsd-arm64@0.28.1': + optional: true + + '@esbuild/openbsd-x64@0.28.1': + optional: true + + '@esbuild/openharmony-arm64@0.28.1': + optional: true + + '@esbuild/sunos-x64@0.28.1': + optional: true + + '@esbuild/win32-arm64@0.28.1': + optional: true + + '@esbuild/win32-ia32@0.28.1': + optional: true + + '@esbuild/win32-x64@0.28.1': + optional: true + + '@jridgewell/sourcemap-codec@1.5.5': {} + '@noble/ciphers@1.3.0': {} '@noble/curves@1.9.1': @@ -149,6 +798,83 @@ snapshots: '@noble/hashes@1.8.0': {} + '@noble/hashes@2.2.0': {} + + '@rollup/rollup-android-arm-eabi@4.62.3': + optional: true + + '@rollup/rollup-android-arm64@4.62.3': + optional: true + + '@rollup/rollup-darwin-arm64@4.62.3': + optional: true + + '@rollup/rollup-darwin-x64@4.62.3': + optional: true + + '@rollup/rollup-freebsd-arm64@4.62.3': + optional: true + + '@rollup/rollup-freebsd-x64@4.62.3': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.62.3': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.62.3': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.62.3': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.62.3': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.62.3': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.62.3': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.62.3': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.62.3': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.62.3': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.62.3': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.62.3': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.62.3': + optional: true + + '@rollup/rollup-linux-x64-musl@4.62.3': + optional: true + + '@rollup/rollup-openbsd-x64@4.62.3': + optional: true + + '@rollup/rollup-openharmony-arm64@4.62.3': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.62.3': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.62.3': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.62.3': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.62.3': + optional: true + '@scure/base@1.2.6': {} '@scure/bip32@1.7.0': @@ -162,14 +888,68 @@ snapshots: '@noble/hashes': 1.8.0 '@scure/base': 1.2.6 - '@wagmi/core@3.4.0(ox@0.12.4(typescript@5.9.3))(typescript@5.9.3)(viem@2.46.3(typescript@5.9.3))': + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + + '@types/node@24.13.3': + dependencies: + undici-types: 7.18.2 + + '@vitest/expect@3.2.7': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 + chai: 5.3.3 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.2.7(vite@7.3.6(@types/node@24.13.3))': + dependencies: + '@vitest/spy': 3.2.7 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.6(@types/node@24.13.3) + + '@vitest/pretty-format@3.2.7': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/runner@3.2.7': + dependencies: + '@vitest/utils': 3.2.7 + pathe: 2.0.3 + strip-literal: 3.1.0 + + '@vitest/snapshot@3.2.7': + dependencies: + '@vitest/pretty-format': 3.2.7 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@3.2.7': + dependencies: + tinyspy: 4.0.4 + + '@vitest/utils@3.2.7': + dependencies: + '@vitest/pretty-format': 3.2.7 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + + '@wagmi/core@3.4.6(typescript@5.9.3)(viem@2.48.4(typescript@5.9.3))': dependencies: eventemitter3: 5.0.1 mipd: 0.0.7(typescript@5.9.3) - viem: 2.46.3(typescript@5.9.3) + viem: 2.48.4(typescript@5.9.3) zustand: 5.0.0 optionalDependencies: - ox: 0.12.4(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - '@types/react' @@ -181,17 +961,93 @@ snapshots: optionalDependencies: typescript: 5.9.3 + assertion-error@2.0.1: {} + + cac@6.7.14: {} + + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + check-error@2.1.3: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-eql@5.0.2: {} + + es-module-lexer@1.7.0: {} + + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + eventemitter3@5.0.1: {} + expect-type@1.4.0: {} + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + fsevents@2.3.3: + optional: true + isows@1.0.7(ws@8.18.3): dependencies: ws: 8.18.3 + js-tokens@9.0.1: {} + + loupe@3.2.1: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + mipd@0.0.7(typescript@5.9.3): optionalDependencies: typescript: 5.9.3 - ox@0.12.4(typescript@5.9.3): + ms@2.1.3: {} + + nanoid@3.3.16: {} + + ox@0.14.20(typescript@5.9.3): dependencies: '@adraffy/ens-normalize': 1.11.1 '@noble/ciphers': 1.3.0 @@ -206,9 +1062,83 @@ snapshots: transitivePeerDependencies: - zod + pathe@2.0.3: {} + + pathval@2.0.1: {} + + picocolors@1.1.1: {} + + picomatch@4.0.5: {} + + postcss@8.5.24: + dependencies: + nanoid: 3.3.16 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + rollup@4.62.3: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.62.3 + '@rollup/rollup-android-arm64': 4.62.3 + '@rollup/rollup-darwin-arm64': 4.62.3 + '@rollup/rollup-darwin-x64': 4.62.3 + '@rollup/rollup-freebsd-arm64': 4.62.3 + '@rollup/rollup-freebsd-x64': 4.62.3 + '@rollup/rollup-linux-arm-gnueabihf': 4.62.3 + '@rollup/rollup-linux-arm-musleabihf': 4.62.3 + '@rollup/rollup-linux-arm64-gnu': 4.62.3 + '@rollup/rollup-linux-arm64-musl': 4.62.3 + '@rollup/rollup-linux-loong64-gnu': 4.62.3 + '@rollup/rollup-linux-loong64-musl': 4.62.3 + '@rollup/rollup-linux-ppc64-gnu': 4.62.3 + '@rollup/rollup-linux-ppc64-musl': 4.62.3 + '@rollup/rollup-linux-riscv64-gnu': 4.62.3 + '@rollup/rollup-linux-riscv64-musl': 4.62.3 + '@rollup/rollup-linux-s390x-gnu': 4.62.3 + '@rollup/rollup-linux-x64-gnu': 4.62.3 + '@rollup/rollup-linux-x64-musl': 4.62.3 + '@rollup/rollup-openbsd-x64': 4.62.3 + '@rollup/rollup-openharmony-arm64': 4.62.3 + '@rollup/rollup-win32-arm64-msvc': 4.62.3 + '@rollup/rollup-win32-ia32-msvc': 4.62.3 + '@rollup/rollup-win32-x64-gnu': 4.62.3 + '@rollup/rollup-win32-x64-msvc': 4.62.3 + fsevents: 2.3.3 + + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@3.10.0: {} + + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + + tinyspy@4.0.4: {} + typescript@5.9.3: {} - viem@2.46.3(typescript@5.9.3): + undici-types@7.18.2: {} + + viem@2.48.4(typescript@5.9.3): dependencies: '@noble/curves': 1.9.1 '@noble/hashes': 1.8.0 @@ -216,7 +1146,7 @@ snapshots: '@scure/bip39': 1.6.0 abitype: 1.2.3(typescript@5.9.3) isows: 1.0.7(ws@8.18.3) - ox: 0.12.4(typescript@5.9.3) + ox: 0.14.20(typescript@5.9.3) ws: 8.18.3 optionalDependencies: typescript: 5.9.3 @@ -225,6 +1155,85 @@ snapshots: - utf-8-validate - zod + vite-node@3.2.4(@types/node@24.13.3): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.3.6(@types/node@24.13.3) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vite@7.3.6(@types/node@24.13.3): + dependencies: + esbuild: 0.28.1 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + postcss: 8.5.24 + rollup: 4.62.3 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 24.13.3 + fsevents: 2.3.3 + + vitest@3.2.7(@types/node@24.13.3): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.7 + '@vitest/mocker': 3.2.7(vite@7.3.6(@types/node@24.13.3)) + '@vitest/pretty-format': 3.2.7 + '@vitest/runner': 3.2.7 + '@vitest/snapshot': 3.2.7 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.4.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.17 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.3.6(@types/node@24.13.3) + vite-node: 3.2.4(@types/node@24.13.3) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.13.3 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + ws@8.18.3: {} zustand@5.0.0: {} diff --git a/src/connector.ts b/src/connector.ts new file mode 100644 index 0000000..881ad24 --- /dev/null +++ b/src/connector.ts @@ -0,0 +1,236 @@ +import { createConnector } from '@wagmi/core' +import { + type Address, + type Hex, + createPublicClient, + createWalletClient, + custom, + getAddress, + hexToBigInt, + hexToNumber, + http, + numberToHex, +} from 'viem' +import type { EncryptedWalletKeyring } from './keyring.js' +import type { RequestUnlock, UnlockReason } from './types.js' +import { WalletLockedError } from './errors.js' + +export type InAppWalletParameters = { + keyring: EncryptedWalletKeyring + requestUnlock?: RequestUnlock + name?: string +} + +inAppWallet.type = 'inAppWallet' as const + +export function inAppWallet(parameters: InAppWalletParameters) { + type Provider = + ReturnType extends (...args: infer A) => infer R ? R : never + + // @ts-expect-error wagmi withCapabilities conditional return type + return createConnector((config) => { + let currentChainId: number = config.chains[0].id + + function getChain(chainId?: number) { + return ( + config.chains.find((chain) => chain.id === (chainId ?? currentChainId)) ?? + config.chains[0] + ) + } + + async function ensureUnlocked(reason: UnlockReason): Promise { + if (parameters.keyring.isUnlocked) return + if (!parameters.requestUnlock) throw new WalletLockedError() + await parameters.requestUnlock(reason) + if (!parameters.keyring.isUnlocked) throw new WalletLockedError() + } + + function assertRequestedAccount(address: string | undefined): void { + const walletAddress = parameters.keyring.address + if ( + !walletAddress || + !address || + getAddress(address) !== getAddress(walletAddress) + ) { + throw new Error('Requested account does not match the in-app wallet') + } + } + + return { + id: 'inAppWallet', + name: parameters.name ?? 'In App', + type: inAppWallet.type, + + async connect({ chainId } = {}) { + if (!parameters.keyring.address) { + await parameters.keyring.load() + } + if (!parameters.keyring.address) throw new Error('No in-app wallet vault found') + if (chainId) { + const chain = config.chains.find((candidate) => candidate.id === chainId) + if (!chain) throw new Error('Chain not configured') + currentChainId = chain.id + } + return { + accounts: [getAddress(parameters.keyring.address)], + chainId: currentChainId, + } + }, + + async disconnect() { + parameters.keyring.lock() + }, + + async getAccounts() { + return parameters.keyring.address + ? [getAddress(parameters.keyring.address)] + : [] + }, + + async getChainId() { + return currentChainId + }, + + async getProvider() { + const chain = getChain() + const transport = config.transports?.[chain.id] ?? http() + + const request = async ({ + method, + params, + }: { + method: string + params?: unknown[] + }): Promise => { + if (method === 'eth_accounts' || method === 'eth_requestAccounts') { + return parameters.keyring.address + ? [parameters.keyring.address] + : [] + } + if (method === 'eth_chainId') return numberToHex(currentChainId) + + if (method === 'personal_sign') { + const [data, account] = params as [Hex, Address] + assertRequestedAccount(account) + const message = { raw: data } as const + await ensureUnlocked({ method, message }) + return parameters.keyring.signMessage({ message }) + } + if (method === 'eth_signTypedData_v4') { + const [account, typedDataJson] = params as [Address, string] + assertRequestedAccount(account) + const typedData = JSON.parse(typedDataJson) + await ensureUnlocked({ method, typedData }) + return parameters.keyring.signTypedData(typedData) + } + if (method === 'eth_sign') { + const [account, hash] = params as [Address, Hex] + assertRequestedAccount(account) + await ensureUnlocked({ method, hash }) + return parameters.keyring.sign({ hash }) + } + + if (method === 'eth_sendTransaction') { + const [transaction] = params as [Record] + assertRequestedAccount(transaction.from) + if ( + transaction.chainId && + hexToNumber(transaction.chainId as Hex) !== currentChainId + ) { + throw new Error('Transaction chain does not match the active chain') + } + await ensureUnlocked({ method, transaction }) + const walletClient = createWalletClient({ + account: parameters.keyring.asAccount(), + chain, + transport, + }) + const feeParameters = transaction.maxFeePerGas + ? { + maxFeePerGas: hexToBigInt(transaction.maxFeePerGas as Hex), + maxPriorityFeePerGas: transaction.maxPriorityFeePerGas + ? hexToBigInt(transaction.maxPriorityFeePerGas as Hex) + : undefined, + } + : transaction.gasPrice + ? { gasPrice: hexToBigInt(transaction.gasPrice as Hex) } + : {} + return walletClient.sendTransaction({ + chain, + to: transaction.to as Address | undefined, + data: transaction.data as Hex | undefined, + value: transaction.value + ? hexToBigInt(transaction.value as Hex) + : undefined, + gas: transaction.gas + ? hexToBigInt(transaction.gas as Hex) + : undefined, + nonce: + transaction.nonce != null + ? hexToNumber(transaction.nonce as Hex) + : undefined, + ...feeParameters, + } as never) + } + + if (method === 'wallet_switchEthereumChain') { + const [{ chainId: hexChainId }] = params as [ + { chainId: `0x${string}` }, + ] + const newChainId = hexToNumber(hexChainId) + const configured = config.chains.find( + (candidate) => candidate.id === newChainId, + ) + if (!configured) throw new Error('Chain not configured') + currentChainId = newChainId + config.emitter.emit('change', { chainId: newChainId }) + return null + } + + const publicClient = createPublicClient({ chain, transport }) + return ( + publicClient as unknown as { + request: (args: { + method: string + params?: unknown[] + }) => Promise + } + ).request({ method, params: params as unknown[] }) + } + + return custom({ request })({ retryCount: 0 }) + }, + + async isAuthorized() { + if (!parameters.keyring.address) await parameters.keyring.load() + return Boolean(parameters.keyring.address) + }, + + async switchChain({ chainId }) { + const chain = config.chains.find((candidate) => candidate.id === chainId) + if (!chain) throw new Error('Chain not configured') + currentChainId = chainId + config.emitter.emit('change', { chainId }) + return chain + }, + + onAccountsChanged(accounts) { + if (accounts.length === 0) this.onDisconnect() + else { + config.emitter.emit('change', { + accounts: accounts.map((account) => getAddress(account)), + }) + } + }, + + onChainChanged(chain) { + config.emitter.emit('change', { chainId: Number(chain) }) + }, + + onDisconnect() { + parameters.keyring.lock() + config.emitter.emit('disconnect') + }, + } + }) +} diff --git a/src/crypto.ts b/src/crypto.ts new file mode 100644 index 0000000..dc162a1 --- /dev/null +++ b/src/crypto.ts @@ -0,0 +1,344 @@ +import { argon2idAsync } from '@noble/hashes/argon2.js' +import { getAddress } from 'viem' +import { mnemonicToAccount } from 'viem/accounts' +import { + DEFAULT_DERIVATION_PATH, + WALLET_VAULT_VERSION, + type Argon2idParameters, + type EncryptedWalletVaultV1, + type PasskeyKeySlot, + type PassphraseKeySlot, + type RuntimeAccount, + type VaultKeySlot, + type WrappedVaultKey, +} from './types.js' +import { + asArrayBuffer, + base64UrlToBytes, + bytesToBase64Url, + clearBytes, + randomBytes, +} from './encoding.js' +import { VaultIntegrityError, WalletLockedError } from './errors.js' + +export const DEFAULT_ARGON2ID_PARAMETERS: Argon2idParameters = { + algorithm: 'argon2id', + memoryKiB: 64 * 1024, + iterations: 3, + parallelism: 1, + outputLength: 32, +} + +const encoder = new TextEncoder() +const decoder = new TextDecoder() + +function payloadAad(document: { + id: string + scope: string + address: string + derivationPath: string +}): Uint8Array { + return encoder.encode( + [ + 'networked-wallet-vault', + WALLET_VAULT_VERSION, + document.id, + document.scope, + document.address, + document.derivationPath, + ].join(':'), + ) +} + +function slotAad( + document: Pick, + slot: Pick, +): Uint8Array { + return encoder.encode( + ['networked-wallet-key-slot', document.id, document.scope, slot.type, slot.id].join( + ':', + ), + ) +} + +async function importAesKey(bytes: Uint8Array): Promise { + return crypto.subtle.importKey('raw', asArrayBuffer(bytes), 'AES-GCM', false, [ + 'encrypt', + 'decrypt', + ]) +} + +async function encrypt( + plaintext: Uint8Array, + keyBytes: Uint8Array, + aad: Uint8Array, +): Promise<{ iv: string; ciphertext: string }> { + const iv = randomBytes(12) + const key = await importAesKey(keyBytes) + const ciphertext = await crypto.subtle.encrypt( + { + name: 'AES-GCM', + iv: asArrayBuffer(iv), + additionalData: asArrayBuffer(aad), + tagLength: 128, + }, + key, + asArrayBuffer(plaintext), + ) + return { + iv: bytesToBase64Url(iv), + ciphertext: bytesToBase64Url(new Uint8Array(ciphertext)), + } +} + +async function decrypt( + encrypted: { iv: string; ciphertext: string }, + keyBytes: Uint8Array, + aad: Uint8Array, +): Promise { + try { + const key = await importAesKey(keyBytes) + const plaintext = await crypto.subtle.decrypt( + { + name: 'AES-GCM', + iv: asArrayBuffer(base64UrlToBytes(encrypted.iv)), + additionalData: asArrayBuffer(aad), + tagLength: 128, + }, + key, + asArrayBuffer(base64UrlToBytes(encrypted.ciphertext)), + ) + return new Uint8Array(plaintext) + } catch { + throw new VaultIntegrityError() + } +} + +export function normalizeMnemonic(mnemonic: string): string { + return mnemonic.trim().toLowerCase().replace(/\s+/gu, ' ') +} + +export function deriveRuntimeAccount( + mnemonic: string, + derivationPath: `m/44'/60'/${string}` = DEFAULT_DERIVATION_PATH, +): RuntimeAccount { + const account = mnemonicToAccount(normalizeMnemonic(mnemonic), { + path: derivationPath, + }) + return { + address: getAddress(account.address), + publicKey: account.publicKey, + } +} + +export async function derivePassphraseWrappingKey( + passphrase: string, + salt: Uint8Array, + parameters: Argon2idParameters, +): Promise { + if ( + parameters.algorithm !== DEFAULT_ARGON2ID_PARAMETERS.algorithm || + parameters.memoryKiB !== DEFAULT_ARGON2ID_PARAMETERS.memoryKiB || + parameters.iterations !== DEFAULT_ARGON2ID_PARAMETERS.iterations || + parameters.parallelism !== DEFAULT_ARGON2ID_PARAMETERS.parallelism || + parameters.outputLength !== DEFAULT_ARGON2ID_PARAMETERS.outputLength || + salt.length !== 16 + ) { + throw new VaultIntegrityError('Unsupported wallet vault KDF parameters') + } + const password = encoder.encode(passphrase) + try { + return await argon2idAsync(password, salt, { + m: parameters.memoryKiB, + t: parameters.iterations, + p: parameters.parallelism, + dkLen: parameters.outputLength, + }) + } finally { + clearBytes(password) + } +} + +export async function wrapVaultKey( + document: Pick, + slot: Pick, + vaultKey: Uint8Array, + wrappingKey: Uint8Array, +): Promise { + const wrapped = await encrypt(vaultKey, wrappingKey, slotAad(document, slot)) + return { algorithm: 'AES-256-GCM', ...wrapped } +} + +export async function unwrapVaultKey( + document: Pick, + slot: VaultKeySlot, + wrappingKey: Uint8Array, +): Promise { + return decrypt(slot.wrappedKey, wrappingKey, slotAad(document, slot)) +} + +export async function createEncryptedVault(input: { + id: string + scope: string + mnemonic: string + passphrase: string + derivationPath?: `m/44'/60'/${string}` +}): Promise<{ + document: EncryptedWalletVaultV1 + vaultKey: Uint8Array + mnemonic: string + account: RuntimeAccount +}> { + const mnemonic = normalizeMnemonic(input.mnemonic) + const derivationPath = input.derivationPath ?? DEFAULT_DERIVATION_PATH + const account = deriveRuntimeAccount(mnemonic, derivationPath) + const vaultKey = randomBytes(32) + const salt = randomBytes(16) + const slot: Omit = { + id: crypto.randomUUID(), + type: 'passphrase', + salt: bytesToBase64Url(salt), + kdf: DEFAULT_ARGON2ID_PARAMETERS, + } + const documentBase = { + version: WALLET_VAULT_VERSION, + id: input.id, + scope: input.scope, + address: account.address, + derivationPath, + } satisfies Omit + const wrappingKey = await derivePassphraseWrappingKey( + input.passphrase, + salt, + slot.kdf, + ) + try { + const wrappedKey = await wrapVaultKey(documentBase, slot, vaultKey, wrappingKey) + const plaintext = encoder.encode(JSON.stringify({ mnemonic })) + try { + const payload = await encrypt(plaintext, vaultKey, payloadAad(documentBase)) + return { + document: { + ...documentBase, + payload: { algorithm: 'AES-256-GCM', ...payload }, + keySlots: [{ ...slot, wrappedKey }], + }, + vaultKey, + mnemonic, + account, + } + } finally { + clearBytes(plaintext) + } + } finally { + clearBytes(wrappingKey) + clearBytes(salt) + } +} + +export async function unlockEncryptedVaultWithPassphrase( + document: EncryptedWalletVaultV1, + passphrase: string, +): Promise<{ vaultKey: Uint8Array; mnemonic: string; account: RuntimeAccount }> { + const slot = document.keySlots.find( + (candidate): candidate is PassphraseKeySlot => + candidate.type === 'passphrase', + ) + if (!slot) throw new VaultIntegrityError('The vault has no passphrase key slot') + const wrappingKey = await derivePassphraseWrappingKey( + passphrase, + base64UrlToBytes(slot.salt), + slot.kdf, + ) + try { + return unlockEncryptedVaultWithKey(document, slot, wrappingKey) + } finally { + clearBytes(wrappingKey) + } +} + +export async function unlockEncryptedVaultWithKey( + document: EncryptedWalletVaultV1, + slot: VaultKeySlot, + wrappingKey: Uint8Array, +): Promise<{ vaultKey: Uint8Array; mnemonic: string; account: RuntimeAccount }> { + if (document.version !== WALLET_VAULT_VERSION) { + throw new VaultIntegrityError('Unsupported wallet vault version') + } + const vaultKey = await unwrapVaultKey(document, slot, wrappingKey) + try { + const plaintext = await decrypt( + document.payload, + vaultKey, + payloadAad(document), + ) + try { + const parsed = JSON.parse(decoder.decode(plaintext)) as { mnemonic?: unknown } + if (typeof parsed.mnemonic !== 'string') throw new VaultIntegrityError() + const mnemonic = normalizeMnemonic(parsed.mnemonic) + const account = deriveRuntimeAccount(mnemonic, document.derivationPath) + if (getAddress(account.address) !== getAddress(document.address)) { + throw new VaultIntegrityError('The encrypted seed does not match the vault address') + } + return { vaultKey, mnemonic, account } + } finally { + clearBytes(plaintext) + } + } catch (error) { + clearBytes(vaultKey) + throw error + } +} + +export async function replacePassphraseSlot( + document: EncryptedWalletVaultV1, + vaultKey: Uint8Array, + passphrase: string, +): Promise { + const salt = randomBytes(16) + const slot: Omit = { + id: crypto.randomUUID(), + type: 'passphrase', + salt: bytesToBase64Url(salt), + kdf: DEFAULT_ARGON2ID_PARAMETERS, + } + const wrappingKey = await derivePassphraseWrappingKey( + passphrase, + salt, + slot.kdf, + ) + try { + const wrappedKey = await wrapVaultKey(document, slot, vaultKey, wrappingKey) + return { + ...document, + keySlots: [ + { ...slot, wrappedKey }, + ...document.keySlots.filter((candidate) => candidate.type !== 'passphrase'), + ], + } + } finally { + clearBytes(wrappingKey) + clearBytes(salt) + } +} + +export async function addWrappedPasskeySlot( + document: EncryptedWalletVaultV1, + vaultKey: Uint8Array, + slot: Omit, + wrappingKey: Uint8Array, +): Promise { + const wrappedKey = await wrapVaultKey(document, slot, vaultKey, wrappingKey) + return { + ...document, + keySlots: [ + ...document.keySlots.filter((candidate) => candidate.id !== slot.id), + { ...slot, wrappedKey }, + ], + } +} + +export function requireUnlocked(value: T | null): T { + if (value === null) throw new WalletLockedError() + return value +} diff --git a/src/encoding.ts b/src/encoding.ts new file mode 100644 index 0000000..2450f48 --- /dev/null +++ b/src/encoding.ts @@ -0,0 +1,36 @@ +export function bytesToBase64Url(bytes: Uint8Array): string { + let binary = '' + for (const byte of bytes) binary += String.fromCharCode(byte) + return btoa(binary) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/u, '') +} + +export function base64UrlToBytes(value: string): Uint8Array { + const normalized = value.replace(/-/g, '+').replace(/_/g, '/') + const padded = normalized.padEnd( + normalized.length + ((4 - (normalized.length % 4)) % 4), + '=', + ) + const binary = atob(padded) + const bytes = new Uint8Array(binary.length) + for (let index = 0; index < binary.length; index++) { + bytes[index] = binary.charCodeAt(index) + } + return bytes +} + +export function randomBytes(length: number): Uint8Array { + return crypto.getRandomValues(new Uint8Array(length)) +} + +export function clearBytes(bytes: Uint8Array | null | undefined): void { + bytes?.fill(0) +} + +export function asArrayBuffer(bytes: Uint8Array): ArrayBuffer { + const copy = new Uint8Array(bytes.byteLength) + copy.set(bytes) + return copy.buffer +} diff --git a/src/errors.ts b/src/errors.ts new file mode 100644 index 0000000..faea32f --- /dev/null +++ b/src/errors.ts @@ -0,0 +1,31 @@ +export class WalletLockedError extends Error { + override name = 'WalletLockedError' + + constructor(message = 'The in-app wallet is locked') { + super(message) + } +} + +export class VaultConflictError extends Error { + override name = 'VaultConflictError' + + constructor(message = 'The wallet vault changed on another device') { + super(message) + } +} + +export class VaultIntegrityError extends Error { + override name = 'VaultIntegrityError' + + constructor(message = 'The encrypted wallet vault could not be verified') { + super(message) + } +} + +export class PasskeyPrfUnavailableError extends Error { + override name = 'PasskeyPrfUnavailableError' + + constructor() { + super('This browser or passkey does not support encrypted vault unlock') + } +} diff --git a/src/index.ts b/src/index.ts index 392046d..90987a8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,221 +1,5 @@ -import { createConnector } from '@wagmi/core' -import { - type Address, - type Hex, - createPublicClient, - createWalletClient, - custom, - getAddress, - hexToBigInt, - hexToNumber, - http, - numberToHex, -} from 'viem' -import { privateKeyToAccount, type PrivateKeyAccount } from 'viem/accounts' - -const STORAGE_KEY = 'evm:in-app-wallet-pk' - -/** - * Derive a private key from a BIP39 mnemonic and store it in localStorage. - * Call this before `connectAsync({ connector })`. - */ -export async function prepareInAppWallet(mnemonic: string): Promise
{ - const { mnemonicToAccount } = await import('viem/accounts') - const { bytesToHex } = await import('viem') - - const normalized = mnemonic.trim().toLowerCase().replace(/\s+/g, ' ') - const hdAccount = mnemonicToAccount(normalized) - const hdKey = hdAccount.getHdKey() - const pk = bytesToHex(hdKey.privateKey!) as `0x${string}` - - localStorage.setItem(STORAGE_KEY, pk) - return hdAccount.address -} - -export type InAppWalletParameters = { - storageKey?: string -} - -inAppWallet.type = 'inAppWallet' as const - -export function inAppWallet(parameters: InAppWalletParameters = {}) { - const key = parameters.storageKey ?? STORAGE_KEY - - type Provider = - ReturnType extends (...args: infer A) => infer R ? R : never - - // @ts-expect-error wagmi 0.4.x withCapabilities conditional return type - return createConnector((config) => { - let account: PrivateKeyAccount | null = null - let currentChainId: number = config.chains[0].id - - function loadAccount(): PrivateKeyAccount | null { - if (typeof window === 'undefined') return null - try { - const stored = localStorage.getItem(key) - if (stored?.startsWith('0x')) { - account = privateKeyToAccount(stored as `0x${string}`) - return account - } - } catch {} - return null - } - - function getChain(chainId?: number) { - return ( - config.chains.find((c) => c.id === (chainId ?? currentChainId)) ?? - config.chains[0] - ) - } - - return { - id: 'inAppWallet', - name: 'In App', - type: inAppWallet.type, - - async connect({ chainId } = {}) { - const acct = account ?? loadAccount() - if (!acct) throw new Error('No in-app wallet key found in storage') - - if (chainId) currentChainId = chainId - - return { - accounts: [getAddress(acct.address)], - chainId: currentChainId, - } - }, - - async disconnect() { - account = null - if (typeof window !== 'undefined') { - localStorage.removeItem(key) - } - }, - - async getAccounts() { - const acct = account ?? loadAccount() - return acct ? [getAddress(acct.address)] : [] - }, - - async getChainId() { - return currentChainId - }, - - async getProvider() { - const chain = getChain() - const transport = config.transports?.[chain.id] ?? http() - - const request = async ({ - method, - params, - }: { - method: string - params?: unknown[] - }): Promise => { - // Account methods - if (method === 'eth_accounts' || method === 'eth_requestAccounts') { - return account ? [account.address] : [] - } - if (method === 'eth_chainId') { - return numberToHex(currentChainId) - } - - // Signing methods — handled locally - if (method === 'personal_sign') { - if (!account) throw new Error('Not connected') - const [data] = params as [Hex, Address] - return account.signMessage({ message: { raw: data } }) - } - if (method === 'eth_signTypedData_v4') { - if (!account) throw new Error('Not connected') - const [, typedDataJson] = params as [Address, string] - const typedData = JSON.parse(typedDataJson) - return account.signTypedData(typedData) - } - if (method === 'eth_sign') { - if (!account) throw new Error('Not connected') - const [, data] = params as [Address, Hex] - return account.sign!({ hash: data }) - } - - // Send transaction — sign locally, broadcast via RPC - if (method === 'eth_sendTransaction') { - if (!account) throw new Error('Not connected') - const [tx] = params as [Record] - const walletClient = createWalletClient({ - account, - chain, - transport, - }) - return walletClient.sendTransaction({ - chain, - to: tx.to as Address, - data: tx.data as Hex | undefined, - value: tx.value ? hexToBigInt(tx.value as Hex) : undefined, - gas: tx.gas ? hexToBigInt(tx.gas as Hex) : undefined, - nonce: - tx.nonce != null ? hexToNumber(tx.nonce as Hex) : undefined, - }) - } - - // Chain switching - if (method === 'wallet_switchEthereumChain') { - const [{ chainId: hexChainId }] = params as [ - { chainId: `0x${string}` }, - ] - const newChainId = hexToNumber(hexChainId) - const chain = config.chains.find((c) => c.id === newChainId) - if (!chain) throw new Error('Chain not configured') - currentChainId = newChainId - config.emitter.emit('change', { chainId: newChainId }) - return null - } - - // Everything else — forward to RPC - const publicClient = createPublicClient({ chain, transport }) - return ( - publicClient as unknown as { - request: (args: { - method: string - params?: unknown[] - }) => Promise - } - ).request({ method, params: params as unknown[] }) - } - - return custom({ request })({ retryCount: 0 }) - }, - - async isAuthorized() { - const acct = account ?? loadAccount() - return !!acct - }, - - async switchChain({ chainId }) { - const chain = config.chains.find((c) => c.id === chainId) - if (!chain) throw new Error('Chain not configured') - currentChainId = chainId - config.emitter.emit('change', { chainId }) - return chain - }, - - onAccountsChanged(accounts) { - if (accounts.length === 0) this.onDisconnect() - else - config.emitter.emit('change', { - accounts: accounts.map((a) => getAddress(a)), - }) - }, - - onChainChanged(chain) { - const chainId = Number(chain) - config.emitter.emit('change', { chainId }) - }, - - onDisconnect() { - config.emitter.emit('disconnect') - account = null - }, - } - }) -} +export * from './connector.js' +export * from './errors.js' +export * from './keyring.js' +export * from './store.js' +export * from './types.js' diff --git a/src/keyring.ts b/src/keyring.ts new file mode 100644 index 0000000..955c240 --- /dev/null +++ b/src/keyring.ts @@ -0,0 +1,321 @@ +import type { + Hash, + Hex, + LocalAccount, + SignableMessage, + TransactionSerializable, +} from 'viem' +import { toAccount, english, generateMnemonic } from 'viem/accounts' +import { clearBytes } from './encoding.js' +import { VaultConflictError, WalletLockedError } from './errors.js' +import { evaluatePasskey, registerPasskey } from './passkeys.js' +import { createWalletRuntimeClient } from './runtime-client.js' +import { + DEFAULT_DERIVATION_PATH, + type EncryptedWalletVaultV1, + type PasskeyKeySlot, + type PasskeyRegistrationOptions, + type RuntimeAccount, + type SyncedWalletVault, + type WalletKeyringSnapshot, + type WalletKeyringStatus, + type WalletRuntimeClient, + type WalletVaultStore, +} from './types.js' + +type Listener = (snapshot: WalletKeyringSnapshot) => void + +export class EncryptedWalletKeyring { + readonly #store: WalletVaultStore + #runtime: WalletRuntimeClient + #synced: SyncedWalletVault | null = null + #runtimeAccount: RuntimeAccount | null = null + #status: WalletKeyringStatus = 'empty' + readonly #listeners = new Set() + + constructor(options: { + store: WalletVaultStore + runtime?: WalletRuntimeClient + }) { + this.#store = options.store + this.#runtime = options.runtime ?? createWalletRuntimeClient() + } + + get document(): EncryptedWalletVaultV1 | null { + return this.#synced?.document ?? null + } + + get address() { + return this.#synced?.document.address ?? null + } + + get status(): WalletKeyringStatus { + return this.#status + } + + get isUnlocked(): boolean { + return this.#status === 'unlocked' + } + + snapshot(): WalletKeyringSnapshot { + return { + status: this.#status, + address: this.address, + revision: this.#synced?.revision ?? null, + passkeys: + this.#synced?.document.keySlots + .filter( + (slot): slot is PasskeyKeySlot => slot.type === 'passkey', + ) + .map((slot) => ({ id: slot.id, label: slot.label })) ?? [], + } + } + + subscribe(listener: Listener): () => void { + this.#listeners.add(listener) + listener(this.snapshot()) + return () => this.#listeners.delete(listener) + } + + async load(): Promise { + this.lock() + this.#synced = await this.#store.load() + this.#status = this.#synced ? 'locked' : 'empty' + this.#emit() + return this.#synced + } + + async create(input: { + passphrase: string + scope: string + }): Promise<{ mnemonic: string; vault: SyncedWalletVault }> { + const mnemonic = generateMnemonic(english, 128) + const vault = await this.#createOrReplace({ + mnemonic, + passphrase: input.passphrase, + scope: input.scope, + }) + return { mnemonic, vault } + } + + async restore(input: { + mnemonic: string + passphrase: string + scope: string + }): Promise { + return this.#createOrReplace(input) + } + + async #createOrReplace(input: { + mnemonic: string + passphrase: string + scope: string + }): Promise { + assertVaultPassphrase(input.passphrase) + const current = this.#synced + const id = current?.document.id ?? crypto.randomUUID() + const result = await this.#runtime.request('createVault', { + id, + scope: input.scope, + mnemonic: input.mnemonic, + passphrase: input.passphrase, + derivationPath: DEFAULT_DERIVATION_PATH, + }) + const synced = await this.#put(result.document, current?.revision ?? null) + this.#runtimeAccount = result.account + this.#status = 'unlocked' + this.#emit() + return synced + } + + async unlockWithPassphrase(passphrase: string): Promise { + const document = this.#requireDocument() + this.#status = 'unlocking' + this.#emit() + try { + this.#runtimeAccount = await this.#runtime.request( + 'unlockWithPassphrase', + { document, passphrase }, + ) + this.#status = 'unlocked' + } catch (error) { + this.#status = 'locked' + throw error + } finally { + this.#emit() + } + } + + async unlockWithPasskey(slotId?: string): Promise { + const document = this.#requireDocument() + const slots = document.keySlots.filter( + (slot): slot is PasskeyKeySlot => slot.type === 'passkey', + ) + const slot = slotId + ? slots.find((candidate) => candidate.id === slotId) + : slots[0] + if (!slot) throw new Error('Passkey slot not found') + this.#status = 'unlocking' + this.#emit() + const wrappingKey = await evaluatePasskey(document, slot) + try { + this.#runtimeAccount = await this.#runtime.request( + 'unlockWithWrappingKey', + { document, slotId: slot.id, wrappingKey }, + ) + this.#status = 'unlocked' + } catch (error) { + this.#status = 'locked' + throw error + } finally { + clearBytes(wrappingKey) + this.#emit() + } + } + + async addPasskey( + options: PasskeyRegistrationOptions, + ): Promise { + this.#requireUnlocked() + const document = this.#requireDocument() + const registered = await registerPasskey(document, options) + try { + const updated = await this.#runtime.request('addPasskeySlot', { + document, + slot: registered.slot, + wrappingKey: registered.wrappingKey, + }) + return this.#put(updated, this.#synced!.revision) + } finally { + clearBytes(registered.wrappingKey) + } + } + + async removePasskey(slotId: string): Promise { + this.#requireUnlocked() + const document = this.#requireDocument() + const slot = document.keySlots.find( + (candidate) => candidate.id === slotId && candidate.type === 'passkey', + ) + if (!slot) throw new Error('Passkey slot not found') + return this.#put( + { + ...document, + keySlots: document.keySlots.filter( + (candidate) => candidate.id !== slot.id, + ), + }, + this.#synced!.revision, + ) + } + + async changePassphrase(passphrase: string): Promise { + this.#requireUnlocked() + assertVaultPassphrase(passphrase) + const document = this.#requireDocument() + const updated = await this.#runtime.request('changePassphrase', { + document, + passphrase, + }) + return this.#put(updated, this.#synced!.revision) + } + + async exportMnemonic(): Promise { + this.#requireUnlocked() + return this.#runtime.request('exportMnemonic', undefined) + } + + asAccount(): LocalAccount { + const runtimeAccount = this.#runtimeAccount + if (!runtimeAccount || !this.isUnlocked) throw new WalletLockedError() + return toAccount({ + address: runtimeAccount.address, + sign: ({ hash }) => this.sign({ hash }), + signMessage: ({ message }) => this.signMessage({ message }), + signTransaction: (transaction) => this.signTransaction(transaction), + signTypedData: (typedData) => + this.signTypedData(typedData as unknown as Record), + }) + } + + sign(input: { hash: Hash }): Promise { + this.#requireUnlocked() + return this.#runtime.request('sign', input) + } + + signMessage(input: { message: SignableMessage }): Promise { + this.#requireUnlocked() + return this.#runtime.request('signMessage', input) + } + + signTransaction( + transaction: TransactionSerializable, + ): Promise { + this.#requireUnlocked() + return this.#runtime.request('signTransaction', { transaction }) + } + + signTypedData( + typedData: Record, + ): Promise { + this.#requireUnlocked() + return this.#runtime.request('signTypedData', { typedData }) + } + + lock(): void { + void this.#runtime.request('lock', undefined).catch(() => undefined) + this.#runtimeAccount = null + this.#status = this.#synced ? 'locked' : 'empty' + this.#emit() + } + + destroy(): void { + this.#runtime.destroy() + this.#runtimeAccount = null + this.#status = this.#synced ? 'locked' : 'empty' + this.#emit() + } + + async #put( + document: EncryptedWalletVaultV1, + expectedRevision: number | null, + ): Promise { + try { + this.#synced = await this.#store.put(document, expectedRevision) + this.#emit() + return this.#synced + } catch (error) { + this.lock() + if ( + error instanceof VaultConflictError || + (typeof error === 'object' && + error !== null && + 'status' in error && + error.status === 409) + ) { + throw new VaultConflictError() + } + throw error + } + } + + #requireDocument(): EncryptedWalletVaultV1 { + if (!this.#synced) throw new Error('No synchronized wallet vault is loaded') + return this.#synced.document + } + + #requireUnlocked(): void { + if (!this.isUnlocked) throw new WalletLockedError() + } + + #emit(): void { + const snapshot = this.snapshot() + for (const listener of this.#listeners) listener(snapshot) + } +} + +export function assertVaultPassphrase(passphrase: string): void { + if (passphrase.length < 12) { + throw new Error('The wallet passphrase must contain at least 12 characters') + } +} diff --git a/src/passkeys.ts b/src/passkeys.ts new file mode 100644 index 0000000..f52b4fe --- /dev/null +++ b/src/passkeys.ts @@ -0,0 +1,143 @@ +import { hkdf } from '@noble/hashes/hkdf.js' +import { sha256 } from '@noble/hashes/sha2.js' +import { + asArrayBuffer, + base64UrlToBytes, + bytesToBase64Url, + randomBytes, +} from './encoding.js' +import { PasskeyPrfUnavailableError } from './errors.js' +import type { + EncryptedWalletVaultV1, + PasskeyKeySlot, + PasskeyRegistrationOptions, +} from './types.js' + +type PrfExtensionResults = { + prf?: { + enabled?: boolean + results?: { first?: ArrayBuffer } + } +} + +function extensionResults( + credential: PublicKeyCredential, +): PrfExtensionResults { + return credential.getClientExtensionResults() as PrfExtensionResults +} + +function deriveWrappingKey( + prfOutput: ArrayBuffer, + document: Pick, + slotId: string, +): Uint8Array { + return hkdf( + sha256, + new Uint8Array(prfOutput), + new TextEncoder().encode(document.id), + new TextEncoder().encode( + `networked-wallet-passkey:${document.scope}:${slotId}`, + ), + 32, + ) +} + +async function requestPrfOutput(input: { + credentialId: Uint8Array + rpId: string + prfSalt: Uint8Array +}): Promise { + const credential = (await navigator.credentials.get({ + publicKey: { + challenge: asArrayBuffer(randomBytes(32)), + rpId: input.rpId, + allowCredentials: [ + { + type: 'public-key', + id: asArrayBuffer(input.credentialId), + }, + ], + userVerification: 'required', + timeout: 60_000, + extensions: { + prf: { + eval: { first: asArrayBuffer(input.prfSalt) }, + }, + } as AuthenticationExtensionsClientInputs, + }, + })) as PublicKeyCredential | null + if (!credential) throw new Error('Passkey unlock was cancelled') + const output = extensionResults(credential).prf?.results?.first + if (!output) throw new PasskeyPrfUnavailableError() + return output +} + +export async function registerPasskey( + document: EncryptedWalletVaultV1, + options: PasskeyRegistrationOptions, +): Promise<{ + slot: Omit + wrappingKey: Uint8Array +}> { + if (!navigator.credentials) throw new PasskeyPrfUnavailableError() + const slotId = crypto.randomUUID() + const prfSalt = randomBytes(32) + const challenge = randomBytes(32) + const rpId = options.rpId ?? window.location.hostname + const credential = (await navigator.credentials.create({ + publicKey: { + challenge: asArrayBuffer(challenge), + rp: { name: options.rpName, id: rpId }, + user: { + id: asArrayBuffer(new TextEncoder().encode(document.id)), + name: options.userName ?? document.address, + displayName: options.userName ?? 'In-app wallet', + }, + pubKeyCredParams: [ + { type: 'public-key', alg: -7 }, + { type: 'public-key', alg: -257 }, + ], + authenticatorSelection: { + residentKey: 'required', + userVerification: 'required', + }, + timeout: 60_000, + attestation: 'none', + extensions: { + prf: { eval: { first: asArrayBuffer(prfSalt) } }, + } as AuthenticationExtensionsClientInputs, + }, + })) as PublicKeyCredential | null + if (!credential) throw new Error('Passkey registration was cancelled') + const prfOutput = + extensionResults(credential).prf?.results?.first ?? + (await requestPrfOutput({ + credentialId: new Uint8Array(credential.rawId), + rpId, + prfSalt, + })) + return { + slot: { + id: slotId, + type: 'passkey', + credentialId: bytesToBase64Url(new Uint8Array(credential.rawId)), + prfSalt: bytesToBase64Url(prfSalt), + rpId, + label: options.label, + }, + wrappingKey: deriveWrappingKey(prfOutput, document, slotId), + } +} + +export async function evaluatePasskey( + document: EncryptedWalletVaultV1, + slot: PasskeyKeySlot, +): Promise { + if (!navigator.credentials) throw new PasskeyPrfUnavailableError() + const prfOutput = await requestPrfOutput({ + credentialId: base64UrlToBytes(slot.credentialId), + rpId: slot.rpId, + prfSalt: base64UrlToBytes(slot.prfSalt), + }) + return deriveWrappingKey(prfOutput, document, slot.id) +} diff --git a/src/runtime-client.ts b/src/runtime-client.ts new file mode 100644 index 0000000..421e7b7 --- /dev/null +++ b/src/runtime-client.ts @@ -0,0 +1,90 @@ +import { WalletRuntime } from './runtime.js' +import type { + WalletRuntimeClient, + WalletRuntimeMethod, + WalletRuntimeRequestMap, +} from './types.js' + +class InlineRuntimeClient implements WalletRuntimeClient { + readonly #runtime = new WalletRuntime() + + request( + method: M, + input: WalletRuntimeRequestMap[M]['input'], + ): Promise { + return this.#runtime.request(method, input) + } + + destroy(): void { + this.#runtime.lock() + } +} + +class WorkerRuntimeClient implements WalletRuntimeClient { + readonly #worker: Worker + #nextId = 1 + readonly #pending = new Map< + number, + { resolve(value: unknown): void; reject(error: unknown): void } + >() + #failure: Error | null = null + + constructor() { + this.#worker = new Worker(new URL('./wallet.worker.ts', import.meta.url), { + type: 'module', + name: 'in-app-wallet', + }) + this.#worker.onmessage = ( + event: MessageEvent< + | { id: number; ok: true; output: unknown } + | { id: number; ok: false; error: { name: string; message: string } } + >, + ) => { + const pending = this.#pending.get(event.data.id) + if (!pending) return + this.#pending.delete(event.data.id) + if (event.data.ok) { + pending.resolve(event.data.output) + return + } + const error = new Error(event.data.error.message) + error.name = event.data.error.name + pending.reject(error) + } + this.#worker.onerror = () => { + this.#failure = new Error('Wallet worker failed') + for (const pending of this.#pending.values()) { + pending.reject(this.#failure) + } + this.#pending.clear() + } + } + + request( + method: M, + input: WalletRuntimeRequestMap[M]['input'], + ): Promise { + if (this.#failure) return Promise.reject(this.#failure) + const id = this.#nextId++ + return new Promise((resolve, reject) => { + this.#pending.set(id, { resolve, reject }) + this.#worker.postMessage({ id, method, input }) + }) + } + + destroy(): void { + this.#worker.terminate() + for (const pending of this.#pending.values()) { + pending.reject(new Error('Wallet worker terminated')) + } + this.#pending.clear() + } +} + +export function createWalletRuntimeClient(): WalletRuntimeClient { + if (typeof window === 'undefined') return new InlineRuntimeClient() + if (typeof Worker === 'undefined') { + throw new Error('A module Worker is required for the in-app wallet') + } + return new WorkerRuntimeClient() +} diff --git a/src/runtime.ts b/src/runtime.ts new file mode 100644 index 0000000..ed56818 --- /dev/null +++ b/src/runtime.ts @@ -0,0 +1,196 @@ +import type { + Hash, + Hex, + SignableMessage, + TransactionSerializable, +} from 'viem' +import { mnemonicToAccount } from 'viem/accounts' +import { + addWrappedPasskeySlot, + createEncryptedVault, + replacePassphraseSlot, + requireUnlocked, + unlockEncryptedVaultWithKey, + unlockEncryptedVaultWithPassphrase, +} from './crypto.js' +import { clearBytes } from './encoding.js' +import type { + EncryptedWalletVaultV1, + RuntimeAccount, + WalletRuntimeMethod, + WalletRuntimeRequestMap, +} from './types.js' + +export class WalletRuntime { + #mnemonic: string | null = null + #vaultKey: Uint8Array | null = null + #account: ReturnType | null = null + #runtimeAccount: RuntimeAccount | null = null + + async request( + method: M, + input: WalletRuntimeRequestMap[M]['input'], + ): Promise { + switch (method) { + case 'createVault': + return this.#createVault( + input as WalletRuntimeRequestMap['createVault']['input'], + ) as Promise + case 'unlockWithPassphrase': + return this.#unlockWithPassphrase( + input as WalletRuntimeRequestMap['unlockWithPassphrase']['input'], + ) as Promise + case 'unlockWithWrappingKey': + return this.#unlockWithWrappingKey( + input as WalletRuntimeRequestMap['unlockWithWrappingKey']['input'], + ) as Promise + case 'addPasskeySlot': + return this.#addPasskeySlot( + input as WalletRuntimeRequestMap['addPasskeySlot']['input'], + ) as Promise + case 'changePassphrase': + return this.#changePassphrase( + input as WalletRuntimeRequestMap['changePassphrase']['input'], + ) as Promise + case 'exportMnemonic': + return Promise.resolve( + requireUnlocked(this.#mnemonic), + ) as Promise + case 'sign': + return this.#sign( + input as WalletRuntimeRequestMap['sign']['input'], + ) as Promise + case 'signMessage': + return this.#signMessage( + input as WalletRuntimeRequestMap['signMessage']['input'], + ) as Promise + case 'signTransaction': + return this.#signTransaction( + input as WalletRuntimeRequestMap['signTransaction']['input'], + ) as Promise + case 'signTypedData': + return this.#signTypedData( + input as WalletRuntimeRequestMap['signTypedData']['input'], + ) as Promise + case 'lock': + this.lock() + return Promise.resolve() as Promise + } + } + + async #createVault( + input: WalletRuntimeRequestMap['createVault']['input'], + ): Promise { + const result = await createEncryptedVault(input) + this.#setUnlocked( + result.mnemonic, + result.vaultKey, + result.account, + input.derivationPath, + ) + return { document: result.document, account: result.account } + } + + async #unlockWithPassphrase( + input: WalletRuntimeRequestMap['unlockWithPassphrase']['input'], + ): Promise { + const result = await unlockEncryptedVaultWithPassphrase( + input.document, + input.passphrase, + ) + this.#setUnlocked( + result.mnemonic, + result.vaultKey, + result.account, + input.document.derivationPath, + ) + return result.account + } + + async #unlockWithWrappingKey( + input: WalletRuntimeRequestMap['unlockWithWrappingKey']['input'], + ): Promise { + const slot = input.document.keySlots.find( + (candidate) => candidate.id === input.slotId, + ) + if (!slot) throw new Error('Passkey slot not found') + const result = await unlockEncryptedVaultWithKey( + input.document, + slot, + input.wrappingKey, + ) + this.#setUnlocked( + result.mnemonic, + result.vaultKey, + result.account, + input.document.derivationPath, + ) + return result.account + } + + async #addPasskeySlot( + input: WalletRuntimeRequestMap['addPasskeySlot']['input'], + ): Promise { + return addWrappedPasskeySlot( + input.document, + requireUnlocked(this.#vaultKey), + input.slot, + input.wrappingKey, + ) + } + + async #changePassphrase( + input: WalletRuntimeRequestMap['changePassphrase']['input'], + ): Promise { + return replacePassphraseSlot( + input.document, + requireUnlocked(this.#vaultKey), + input.passphrase, + ) + } + + async #sign(input: { hash: Hash }): Promise { + return requireUnlocked(this.#account).sign({ hash: input.hash }) + } + + async #signMessage(input: { message: SignableMessage }): Promise { + return requireUnlocked(this.#account).signMessage({ message: input.message }) + } + + async #signTransaction(input: { + transaction: TransactionSerializable + }): Promise { + return requireUnlocked(this.#account).signTransaction(input.transaction) + } + + async #signTypedData(input: { + typedData: Record + }): Promise { + return requireUnlocked(this.#account).signTypedData( + input.typedData as never, + ) + } + + #setUnlocked( + mnemonic: string, + vaultKey: Uint8Array, + account: RuntimeAccount, + derivationPath: `m/44'/60'/${string}`, + ): void { + this.lock() + this.#mnemonic = mnemonic + this.#vaultKey = vaultKey + this.#runtimeAccount = account + this.#account = mnemonicToAccount(mnemonic, { + path: derivationPath, + }) + } + + lock(): void { + clearBytes(this.#vaultKey) + this.#vaultKey = null + this.#mnemonic = null + this.#account = null + this.#runtimeAccount = null + } +} diff --git a/src/store.ts b/src/store.ts new file mode 100644 index 0000000..633f9b6 --- /dev/null +++ b/src/store.ts @@ -0,0 +1,33 @@ +import type { + EncryptedWalletVaultV1, + SyncedWalletVault, + WalletVaultStore, +} from './types.js' +import { VaultConflictError } from './errors.js' + +export function createLocalEncryptedVaultStore( + storageKey = 'evm:encrypted-in-app-wallet', +): WalletVaultStore { + return { + async load() { + const stored = localStorage.getItem(storageKey) + if (!stored) return null + return JSON.parse(stored) as SyncedWalletVault + }, + async put( + document: EncryptedWalletVaultV1, + expectedRevision: number | null, + ) { + const current = await this.load() + if ((current?.revision ?? null) !== expectedRevision) { + throw new VaultConflictError() + } + const synced = { + document, + revision: (current?.revision ?? 0) + 1, + } + localStorage.setItem(storageKey, JSON.stringify(synced)) + return synced + }, + } +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..b372f8f --- /dev/null +++ b/src/types.ts @@ -0,0 +1,167 @@ +import type { + Address, + Hash, + Hex, + SignableMessage, + TransactionSerializable, +} from 'viem' + +export const WALLET_VAULT_VERSION = 1 as const +export const DEFAULT_DERIVATION_PATH = "m/44'/60'/0'/0/0" as const + +export type Argon2idParameters = { + algorithm: 'argon2id' + memoryKiB: number + iterations: number + parallelism: number + outputLength: number +} + +export type WrappedVaultKey = { + algorithm: 'AES-256-GCM' + iv: string + ciphertext: string +} + +export type PassphraseKeySlot = { + id: string + type: 'passphrase' + salt: string + kdf: Argon2idParameters + wrappedKey: WrappedVaultKey +} + +export type PasskeyKeySlot = { + id: string + type: 'passkey' + credentialId: string + prfSalt: string + rpId: string + label?: string + wrappedKey: WrappedVaultKey +} + +export type VaultKeySlot = PassphraseKeySlot | PasskeyKeySlot + +export type EncryptedWalletVaultV1 = { + version: typeof WALLET_VAULT_VERSION + id: string + scope: string + address: Address + derivationPath: `m/44'/60'/${string}` + payload: { + algorithm: 'AES-256-GCM' + iv: string + ciphertext: string + } + keySlots: VaultKeySlot[] +} + +export type SyncedWalletVault = { + document: EncryptedWalletVaultV1 + revision: number +} + +export type WalletVaultStore = { + load(): Promise + put( + document: EncryptedWalletVaultV1, + expectedRevision: number | null, + ): Promise +} + +export type UnlockReason = + | { method: 'connect' } + | { method: 'personal_sign'; message: SignableMessage } + | { method: 'eth_sign'; hash: Hash } + | { + method: 'eth_signTypedData_v4' + typedData: Record + } + | { method: 'eth_sendTransaction'; transaction: Record } + +export type RequestUnlock = (reason: UnlockReason) => Promise + +export type RuntimeAccount = { + address: Address + publicKey: Hex +} + +export type WalletRuntimeRequestMap = { + createVault: { + input: { + id: string + scope: string + mnemonic: string + passphrase: string + derivationPath: `m/44'/60'/${string}` + } + output: { document: EncryptedWalletVaultV1; account: RuntimeAccount } + } + unlockWithPassphrase: { + input: { document: EncryptedWalletVaultV1; passphrase: string } + output: RuntimeAccount + } + unlockWithWrappingKey: { + input: { + document: EncryptedWalletVaultV1 + slotId: string + wrappingKey: Uint8Array + } + output: RuntimeAccount + } + addPasskeySlot: { + input: { + document: EncryptedWalletVaultV1 + slot: Omit + wrappingKey: Uint8Array + } + output: EncryptedWalletVaultV1 + } + changePassphrase: { + input: { document: EncryptedWalletVaultV1; passphrase: string } + output: EncryptedWalletVaultV1 + } + exportMnemonic: { input: undefined; output: string } + sign: { input: { hash: Hash }; output: Hex } + signMessage: { input: { message: SignableMessage }; output: Hex } + signTransaction: { + input: { transaction: TransactionSerializable } + output: Hex + } + signTypedData: { + input: { typedData: Record } + output: Hex + } + lock: { input: undefined; output: void } +} + +export type WalletRuntimeMethod = keyof WalletRuntimeRequestMap + +export type WalletRuntimeClient = { + request( + method: M, + input: WalletRuntimeRequestMap[M]['input'], + ): Promise + destroy(): void +} + +export type PasskeyRegistrationOptions = { + rpName: string + rpId?: string + userName?: string + label?: string +} + +export type WalletKeyringStatus = + | 'empty' + | 'locked' + | 'unlocking' + | 'unlocked' + +export type WalletKeyringSnapshot = { + status: WalletKeyringStatus + address: Address | null + revision: number | null + passkeys: Array<{ id: string; label?: string }> +} diff --git a/src/wallet.worker.ts b/src/wallet.worker.ts new file mode 100644 index 0000000..dfb7538 --- /dev/null +++ b/src/wallet.worker.ts @@ -0,0 +1,40 @@ +import { WalletRuntime } from './runtime.js' +import type { + WalletRuntimeMethod, + WalletRuntimeRequestMap, +} from './types.js' + +type WorkerRequest = { + id: number + method: M + input: WalletRuntimeRequestMap[M]['input'] +} + +type WorkerResponse = + | { id: number; ok: true; output: unknown } + | { id: number; ok: false; error: { name: string; message: string } } + +const runtime = new WalletRuntime() +const workerScope = self as unknown as { + onmessage: ((event: MessageEvent) => void) | null + postMessage(message: WorkerResponse): void +} + +workerScope.onmessage = async ({ data }) => { + try { + const output = await runtime.request( + data.method, + data.input as never, + ) + workerScope.postMessage({ id: data.id, ok: true, output }) + } catch (error) { + workerScope.postMessage({ + id: data.id, + ok: false, + error: { + name: error instanceof Error ? error.name : 'Error', + message: error instanceof Error ? error.message : 'Wallet operation failed', + }, + }) + } +} diff --git a/tests/keyring.test.ts b/tests/keyring.test.ts new file mode 100644 index 0000000..d99efb4 --- /dev/null +++ b/tests/keyring.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from 'vitest' +import { recoverMessageAddress } from 'viem' +import { + EncryptedWalletKeyring, + VaultConflictError, + WalletLockedError, + type EncryptedWalletVaultV1, + type SyncedWalletVault, + type WalletVaultStore, +} from '../src/index.js' + +const MNEMONIC = + 'test test test test test test test test test test test junk' +const PASSPHRASE = 'correct horse battery staple' + +class MemoryStore implements WalletVaultStore { + current: SyncedWalletVault | null = null + + async load() { + return structuredClone(this.current) + } + + async put( + document: EncryptedWalletVaultV1, + expectedRevision: number | null, + ) { + if ((this.current?.revision ?? null) !== expectedRevision) { + throw new VaultConflictError() + } + this.current = { + document: structuredClone(document), + revision: (this.current?.revision ?? 0) + 1, + } + return structuredClone(this.current) + } +} + +describe('EncryptedWalletKeyring', () => { + it( + 'syncs only ciphertext, locks, unlocks, and signs with the restored account', + async () => { + const store = new MemoryStore() + const keyring = new EncryptedWalletKeyring({ store }) + + await keyring.restore({ + mnemonic: MNEMONIC, + passphrase: PASSPHRASE, + scope: 'networked.art', + }) + + const address = keyring.address + expect(address).toBeTruthy() + expect(JSON.stringify(store.current)).not.toContain(MNEMONIC) + expect(store.current?.document.keySlots).toHaveLength(1) + + keyring.lock() + expect(keyring.status).toBe('locked') + await expect(keyring.exportMnemonic()).rejects.toBeInstanceOf( + WalletLockedError, + ) + + await keyring.unlockWithPassphrase(PASSPHRASE) + expect(await keyring.exportMnemonic()).toBe(MNEMONIC) + + const message = 'networked wallet test' + const signature = await keyring.signMessage({ message }) + expect( + await recoverMessageAddress({ message, signature }), + ).toBe(address) + + keyring.destroy() + }, + 30_000, + ) + + it( + 'fails closed when the encrypted payload is modified', + async () => { + const store = new MemoryStore() + const keyring = new EncryptedWalletKeyring({ store }) + await keyring.restore({ + mnemonic: MNEMONIC, + passphrase: PASSPHRASE, + scope: 'networked.art', + }) + keyring.lock() + + const current = store.current! + current.document.payload.ciphertext = + `${current.document.payload.ciphertext.slice(0, -1)}A` + await keyring.load() + + await expect( + keyring.unlockWithPassphrase(PASSPHRASE), + ).rejects.toThrow() + expect(keyring.status).toBe('locked') + keyring.destroy() + }, + 30_000, + ) + + it('enforces a portable passphrase floor', async () => { + const keyring = new EncryptedWalletKeyring({ + store: new MemoryStore(), + }) + await expect( + keyring.restore({ + mnemonic: MNEMONIC, + passphrase: 'too short', + scope: 'networked.art', + }), + ).rejects.toThrow('at least 12 characters') + keyring.destroy() + }) + + it( + 'rejects attacker-controlled KDF costs before deriving a key', + async () => { + const store = new MemoryStore() + const keyring = new EncryptedWalletKeyring({ store }) + await keyring.restore({ + mnemonic: MNEMONIC, + passphrase: PASSPHRASE, + scope: 'networked.art', + }) + keyring.lock() + const slot = store.current!.document.keySlots[0] + if (slot.type !== 'passphrase') throw new Error('Missing passphrase slot') + slot.kdf.memoryKiB = 2 ** 31 + await keyring.load() + + await expect( + keyring.unlockWithPassphrase(PASSPHRASE), + ).rejects.toThrow('Unsupported wallet vault KDF parameters') + keyring.destroy() + }, + 30_000, + ) +}) diff --git a/tsconfig.json b/tsconfig.json index c66908d..6da93c0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,7 +6,9 @@ "moduleResolution": "bundler", "esModuleInterop": true, "skipLibCheck": true, - "noEmit": true + "noEmit": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": ["node", "vitest/globals"] }, - "include": ["src"] + "include": ["src", "tests"] }