diff --git a/.changeset/serialize-wallet-mutations.md b/.changeset/serialize-wallet-mutations.md new file mode 100644 index 00000000..81bb4d72 --- /dev/null +++ b/.changeset/serialize-wallet-mutations.md @@ -0,0 +1,5 @@ +--- +"nansen-cli": patch +--- + +Serialize wallet create, default, and delete mutations across processes so concurrent commands cannot strand wallets or leave stale configuration. diff --git a/src/__tests__/fixtures/wallet-concurrency-worker.mjs b/src/__tests__/fixtures/wallet-concurrency-worker.mjs new file mode 100644 index 00000000..c3d61784 --- /dev/null +++ b/src/__tests__/fixtures/wallet-concurrency-worker.mjs @@ -0,0 +1,61 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +const [repo, home, markerDir, name, password] = process.argv.slice(2); +process.env.HOME = home; +process.env.USERPROFILE = home; + +const walletsDir = path.join(home, '.nansen', 'wallets'); +const configPath = path.join(walletsDir, 'config.json'); +const lockPath = path.join(walletsDir, '.wallet-mutation.lock'); +const originalReadFileSync = fs.readFileSync.bind(fs); +const originalReaddirSync = fs.readdirSync.bind(fs); +let synchronizedConfigRead = false; +let synchronizedWalletScan = false; + +function rendezvous(stage) { + fs.writeFileSync(path.join(markerDir, `${stage}-${process.pid}`), name); + const deadline = Date.now() + 10_000; + while (originalReaddirSync(markerDir).filter(file => file.startsWith(`${stage}-`)).length < 2) { + if (Date.now() > deadline) throw new Error(`Timed out at ${stage} rendezvous`); + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10); + } +} + +function mutationLockExists() { + try { + return fs.lstatSync(lockPath).isDirectory(); + } catch (error) { + if (error.code === 'ENOENT') return false; + throw error; + } +} + +fs.readFileSync = function patchedReadFileSync(file, ...args) { + const value = originalReadFileSync(file, ...args); + if (!synchronizedConfigRead && path.resolve(String(file)) === path.resolve(configPath)) { + synchronizedConfigRead = true; + if (!mutationLockExists()) rendezvous('config-read'); + } + return value; +}; + +fs.readdirSync = function patchedReaddirSync(dir, ...args) { + const value = originalReaddirSync(dir, ...args); + if (!synchronizedWalletScan && path.resolve(String(dir)) === path.resolve(walletsDir)) { + synchronizedWalletScan = true; + if (!mutationLockExists()) rendezvous('wallet-scan'); + } + return value; +}; + +try { + const walletUrl = pathToFileURL(path.join(repo, 'src', 'wallet.js')).href; + const { createWallet } = await import(`${walletUrl}?worker=${process.pid}`); + const result = createWallet(name, password); + process.stdout.write(JSON.stringify({ ok: true, result })); +} catch (error) { + process.stdout.write(JSON.stringify({ ok: false, error: error.message })); + process.exitCode = 1; +} diff --git a/src/__tests__/fixtures/wallet-lock-contender.mjs b/src/__tests__/fixtures/wallet-lock-contender.mjs new file mode 100644 index 00000000..d322519a --- /dev/null +++ b/src/__tests__/fixtures/wallet-lock-contender.mjs @@ -0,0 +1,20 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +const [repo, home, markerPath] = process.argv.slice(2); +process.env.HOME = home; +process.env.USERPROFILE = home; + +try { + const walletUrl = pathToFileURL(path.join(repo, 'src', 'wallet.js')).href; + const { withWalletMutationLock } = await import(`${walletUrl}?contender=${process.pid}`); + const result = withWalletMutationLock(() => { + fs.writeFileSync(markerPath, String(process.pid)); + return path.basename(markerPath); + }); + process.stdout.write(JSON.stringify({ ok: true, result })); +} catch (error) { + process.stdout.write(JSON.stringify({ ok: false, error: error.message, code: error.code })); + process.exitCode = 1; +} diff --git a/src/__tests__/fixtures/wallet-lock-holder.mjs b/src/__tests__/fixtures/wallet-lock-holder.mjs new file mode 100644 index 00000000..d08dd1cf --- /dev/null +++ b/src/__tests__/fixtures/wallet-lock-holder.mjs @@ -0,0 +1,16 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +const [repo, home, enteredPath, releasePath] = process.argv.slice(2); +process.env.HOME = home; +process.env.USERPROFILE = home; + +const walletUrl = pathToFileURL(path.join(repo, 'src', 'wallet.js')).href; +const { withWalletMutationLock } = await import(`${walletUrl}?holder=${process.pid}`); + +withWalletMutationLock(() => { + fs.writeFileSync(enteredPath, String(process.pid)); + const cell = new Int32Array(new SharedArrayBuffer(4)); + while (!fs.existsSync(releasePath)) Atomics.wait(cell, 0, 0, 10); +}); diff --git a/src/__tests__/fixtures/wallet-mutation-worker.mjs b/src/__tests__/fixtures/wallet-mutation-worker.mjs new file mode 100644 index 00000000..a4dd9b43 --- /dev/null +++ b/src/__tests__/fixtures/wallet-mutation-worker.mjs @@ -0,0 +1,57 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +const [repo, home, markerDir, operation, name, password] = process.argv.slice(2); +process.env.HOME = home; +process.env.USERPROFILE = home; + +const walletsDir = path.join(home, '.nansen', 'wallets'); +const configPath = path.join(walletsDir, 'config.json'); +const lockPath = path.join(walletsDir, '.wallet-mutation.lock'); +const originalReadFileSync = fs.readFileSync.bind(fs); +const originalReaddirSync = fs.readdirSync.bind(fs); +const originalRenameSync = fs.renameSync.bind(fs); +let synchronizedConfigRead = false; + +function lockExists() { + try { return fs.lstatSync(lockPath).isDirectory(); } + catch (error) { if (error.code === 'ENOENT') return false; throw error; } +} + +function rendezvous() { + fs.writeFileSync(path.join(markerDir, `config-read-${operation}-${process.pid}`), 'ready'); + const deadline = Date.now() + 10_000; + while (originalReaddirSync(markerDir).filter(file => file.startsWith('config-read-')).length < 2) { + if (Date.now() > deadline) throw new Error('Timed out at config-read rendezvous'); + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10); + } +} + +fs.readFileSync = function patchedReadFileSync(file, ...args) { + const value = originalReadFileSync(file, ...args); + if (!synchronizedConfigRead && path.resolve(String(file)) === path.resolve(configPath)) { + synchronizedConfigRead = true; + if (!lockExists()) rendezvous(); + } + return value; +}; + +fs.renameSync = function patchedRenameSync(from, to) { + if (operation === 'default' && path.resolve(String(to)) === path.resolve(configPath) && !lockExists()) { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 500); + } + return originalRenameSync(from, to); +}; + +try { + const walletUrl = pathToFileURL(path.join(repo, 'src', 'wallet.js')).href; + const { deleteWallet, setDefaultWallet } = await import(`${walletUrl}?mutation=${process.pid}`); + const result = operation === 'default' + ? setDefaultWallet(name) + : await deleteWallet(name, password); + process.stdout.write(JSON.stringify({ ok: true, result })); +} catch (error) { + process.stdout.write(JSON.stringify({ ok: false, error: error.message })); + process.exitCode = 1; +} diff --git a/src/__tests__/wallet-concurrency.test.js b/src/__tests__/wallet-concurrency.test.js new file mode 100644 index 00000000..bccb4999 --- /dev/null +++ b/src/__tests__/wallet-concurrency.test.js @@ -0,0 +1,662 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { spawn } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const repo = fileURLToPath(new URL('../..', import.meta.url)); +const worker = fileURLToPath(new URL('./fixtures/wallet-concurrency-worker.mjs', import.meta.url)); +const holder = fileURLToPath(new URL('./fixtures/wallet-lock-holder.mjs', import.meta.url)); +const mutationWorker = fileURLToPath(new URL('./fixtures/wallet-mutation-worker.mjs', import.meta.url)); +const lockContender = fileURLToPath(new URL('./fixtures/wallet-lock-contender.mjs', import.meta.url)); +const roots = []; +const originalHome = process.env.HOME; +const originalUserProfile = process.env.USERPROFILE; + +function runWorker(home, markerDir, name, password) { + return new Promise(resolve => { + const child = spawn(process.execPath, [worker, repo, home, markerDir, name, password], { + env: { ...process.env, HOME: home, USERPROFILE: home }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', chunk => { stdout += chunk; }); + child.stderr.on('data', chunk => { stderr += chunk; }); + child.on('close', code => resolve({ code, stdout, stderr, body: JSON.parse(stdout) })); + }); +} + +function runMutationWorker(home, markerDir, operation, name, password) { + return new Promise(resolve => { + const child = spawn(process.execPath, [mutationWorker, repo, home, markerDir, operation, name, password], { + env: { ...process.env, HOME: home, USERPROFILE: home }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', chunk => { stdout += chunk; }); + child.stderr.on('data', chunk => { stderr += chunk; }); + child.on('close', code => resolve({ code, stderr, body: JSON.parse(stdout) })); + }); +} + +function runLockContender(home, markerPath) { + return new Promise(resolve => { + const child = spawn(process.execPath, [lockContender, repo, home, markerPath], { + env: { ...process.env, HOME: home, USERPROFILE: home }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', chunk => { stdout += chunk; }); + child.stderr.on('data', chunk => { stderr += chunk; }); + child.on('close', code => resolve({ code, stderr, body: JSON.parse(stdout) })); + }); +} + +function getDeadPid() { + return new Promise(resolve => { + const child = spawn(process.execPath, ['-e', 'process.exit(0)']); + const pid = child.pid; + child.once('close', () => resolve(pid)); + }); +} + +function syntheticIdentity() { + return process.platform === 'linux' + ? 'linux:00000000-0000-0000-0000-000000000000:0' + : undefined; +} + +function currentIdentity() { + if (process.platform !== 'linux') return undefined; + const stat = fs.readFileSync(`/proc/${process.pid}/stat`, 'utf8'); + const fields = stat.slice(stat.lastIndexOf(')') + 2).trim().split(/\s+/); + const boot = fs.readFileSync('/proc/sys/kernel/random/boot_id', 'utf8').trim(); + return `linux:${boot}:${fields[19]}`; +} + +function fixture() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'nansen-wallet-concurrency-')); + roots.push(root); + const home = path.join(root, 'home'); + const markerDir = path.join(root, 'markers'); + const walletsDir = path.join(home, '.nansen', 'wallets'); + fs.mkdirSync(walletsDir, { recursive: true, mode: 0o700 }); + fs.mkdirSync(markerDir); + fs.writeFileSync( + path.join(walletsDir, 'config.json'), + JSON.stringify({ defaultWallet: null, passwordHash: null }), + { mode: 0o600 }, + ); + return { home, markerDir, walletsDir }; +} + +function waitForFile(filePath) { + return new Promise((resolve, reject) => { + const deadline = Date.now() + 5000; + const check = () => { + if (fs.existsSync(filePath)) return resolve(); + if (Date.now() >= deadline) return reject(new Error(`Timed out waiting for ${filePath}`)); + setTimeout(check, 10); + }; + check(); + }); +} + +afterEach(() => { + process.env.HOME = originalHome; + process.env.USERPROFILE = originalUserProfile; + for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); +}); + +describe('wallet mutation concurrency', () => { + it('does not report success for a first encrypted wallet stranded by a competing password', async () => { + const { home, markerDir, walletsDir } = fixture(); + const entries = [ + { name: 'alpha', password: 'alpha-password-1234' }, + { name: 'beta', password: 'beta-password-5678' }, + ]; + + const results = await Promise.all(entries.map(entry => runWorker(home, markerDir, entry.name, entry.password))); + const succeeded = results.filter(result => result.code === 0); + const rejected = results.filter(result => result.code !== 0); + + expect(succeeded).toHaveLength(1); + expect(rejected).toHaveLength(1); + expect(rejected[0].body.error).toMatch(/Incorrect password/); + + const winner = entries.find(entry => entry.name === succeeded[0].body.result.name); + const loser = entries.find(entry => entry.name !== winner.name); + expect(fs.existsSync(path.join(walletsDir, `${winner.name}.json`))).toBe(true); + expect(fs.existsSync(path.join(walletsDir, `${loser.name}.json`))).toBe(false); + + process.env.HOME = home; + process.env.USERPROFILE = home; + const walletUrl = pathToFileURL(path.join(repo, 'src', 'wallet.js')).href; + const { exportWallet } = await import(`${walletUrl}?verify=${Date.now()}`); + expect(() => exportWallet(winner.name, winner.password)).not.toThrow(); + }); + + it('allows concurrent creates that use the same password', async () => { + const { home, markerDir, walletsDir } = fixture(); + const password = 'shared-password-1234'; + const entries = [{ name: 'alpha', password }, { name: 'beta', password }]; + + const results = await Promise.all(entries.map(entry => runWorker(home, markerDir, entry.name, entry.password))); + + expect(results.map(result => result.code)).toEqual([0, 0]); + expect(entries.every(entry => fs.existsSync(path.join(walletsDir, `${entry.name}.json`)))).toBe(true); + process.env.HOME = home; + process.env.USERPROFILE = home; + const walletUrl = pathToFileURL(path.join(repo, 'src', 'wallet.js')).href; + const { exportWallet } = await import(`${walletUrl}?verify=${Date.now()}`); + for (const entry of entries) expect(() => exportWallet(entry.name, password)).not.toThrow(); + }); + + it('reports only one success for concurrent duplicate names', async () => { + const { home, markerDir, walletsDir } = fixture(); + + const results = await Promise.all([ + runWorker(home, markerDir, 'duplicate', 'shared-password-1234'), + runWorker(home, markerDir, 'duplicate', 'shared-password-1234'), + ]); + + expect(results.filter(result => result.code === 0)).toHaveLength(1); + const rejected = results.find(result => result.code !== 0); + expect(rejected.body.error).toMatch(/already exists/); + expect(fs.existsSync(path.join(walletsDir, 'duplicate.json'))).toBe(true); + }); + + it('serializes default selection against deletion without leaving a missing default', async () => { + const { home, markerDir, walletsDir } = fixture(); + process.env.HOME = home; + process.env.USERPROFILE = home; + const walletUrl = pathToFileURL(path.join(repo, 'src', 'wallet.js')).href; + const { createWallet } = await import(`${walletUrl}?setup=${Date.now()}`); + createWallet('alpha', 'shared-password-1234'); + createWallet('beta', 'shared-password-1234'); + + const results = await Promise.all([ + runMutationWorker(home, markerDir, 'default', 'alpha', 'shared-password-1234'), + runMutationWorker(home, markerDir, 'delete', 'alpha', 'shared-password-1234'), + ]); + + expect(results.find(result => result.body.result?.deleted === 'alpha')?.code).toBe(0); + expect(fs.existsSync(path.join(walletsDir, 'alpha.json'))).toBe(false); + expect(fs.existsSync(path.join(walletsDir, 'beta.json'))).toBe(true); + expect(JSON.parse(fs.readFileSync(path.join(walletsDir, 'config.json'), 'utf8')).defaultWallet).toBe('beta'); + }); + + it('releases the lock when a mutation throws', async () => { + const { home, walletsDir } = fixture(); + process.env.HOME = home; + process.env.USERPROFILE = home; + const walletUrl = pathToFileURL(path.join(repo, 'src', 'wallet.js')).href; + const { createWallet, withWalletMutationLock } = await import(`${walletUrl}?release=${Date.now()}`); + + expect(() => withWalletMutationLock(() => { throw new Error('injected failure'); })).toThrow('injected failure'); + expect(fs.existsSync(path.join(walletsDir, '.wallet-mutation.lock'))).toBe(false); + expect(() => createWallet('after-failure', 'password-after-failure')).not.toThrow(); + }); + + it('preserves a callback exception when releasing the lock also fails', async () => { + const { home, walletsDir } = fixture(); + process.env.HOME = home; + process.env.USERPROFILE = home; + const walletUrl = pathToFileURL(path.join(repo, 'src', 'wallet.js')).href; + const { withWalletMutationLock } = await import(`${walletUrl}?primary=${Date.now()}`); + const originalRenameSync = fs.renameSync; + const primary = new Error('callback failed'); + fs.renameSync = (from, to) => { + if (String(from).endsWith('.wallet-mutation.lock') && String(to).includes('.released-')) { + const error = new Error('release failed'); + error.code = 'EIO'; + throw error; + } + return originalRenameSync(from, to); + }; + try { + let thrown; + try { withWalletMutationLock(() => { throw primary; }); } catch (error) { thrown = error; } + expect(thrown).toBe(primary); + expect(thrown.releaseError.message).toBe('release failed'); + expect(fs.existsSync(path.join(walletsDir, '.wallet-mutation.lock'))).toBe(true); + } finally { + fs.renameSync = originalRenameSync; + } + }); + + it('returns normally when a transient release failure succeeds on retry', async () => { + const { home, walletsDir } = fixture(); + process.env.HOME = home; + process.env.USERPROFILE = home; + const walletUrl = pathToFileURL(path.join(repo, 'src', 'wallet.js')).href; + const { withWalletMutationLock } = await import(`${walletUrl}?release-retry=${Date.now()}`); + const originalRenameSync = fs.renameSync; + let releaseAttempts = 0; + fs.renameSync = (from, to) => { + if (String(from).endsWith('.wallet-mutation.lock') && String(to).includes('.released-')) { + releaseAttempts++; + if (releaseAttempts === 1) { + const error = new Error('transient release failure'); + error.code = 'EIO'; + throw error; + } + } + return originalRenameSync(from, to); + }; + try { + expect(withWalletMutationLock(() => 'committed')).toBe('committed'); + expect(releaseAttempts).toBe(2); + expect(fs.existsSync(path.join(walletsDir, '.wallet-mutation.lock'))).toBe(false); + } finally { + fs.renameSync = originalRenameSync; + } + }); + + it('rejects Promise-returning callbacks after releasing the lock', async () => { + const { home, walletsDir } = fixture(); + process.env.HOME = home; + process.env.USERPROFILE = home; + const walletUrl = pathToFileURL(path.join(repo, 'src', 'wallet.js')).href; + const { withWalletMutationLock } = await import(`${walletUrl}?async=${Date.now()}`); + + expect(() => withWalletMutationLock(() => Promise.resolve('too late'))) + .toThrow(/callbacks must be synchronous.*Promise/i); + expect(fs.existsSync(path.join(walletsDir, '.wallet-mutation.lock'))).toBe(false); + }); + + it('rejects same-process nested locking immediately', async () => { + const { home } = fixture(); + process.env.HOME = home; + process.env.USERPROFILE = home; + const walletUrl = pathToFileURL(path.join(repo, 'src', 'wallet.js')).href; + const { withWalletMutationLock } = await import(`${walletUrl}?nested=${Date.now()}`); + const started = Date.now(); + + expect(() => withWalletMutationLock(() => withWalletMutationLock(() => {}))) + .toThrow(/not reentrant.*nested/i); + expect(Date.now() - started).toBeLessThan(250); + }); + + it('reports an actionable busy error for a live lock owner', async () => { + const { home } = fixture(); + const root = path.dirname(home); + const enteredPath = path.join(root, 'entered'); + const releasePath = path.join(root, 'release'); + const child = spawn(process.execPath, [holder, repo, home, enteredPath, releasePath], { + env: { ...process.env, HOME: home, USERPROFILE: home }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + try { + await waitForFile(enteredPath); + process.env.HOME = home; + process.env.USERPROFILE = home; + const walletUrl = pathToFileURL(path.join(repo, 'src', 'wallet.js')).href; + const { withWalletMutationLock } = await import(`${walletUrl}?busy=${Date.now()}`); + expect(() => withWalletMutationLock(() => {}, { waitMs: 100, pollMs: 10 })) + .toThrow(new RegExp(`busy .*process ${child.pid}`, 'i')); + } finally { + fs.writeFileSync(releasePath, 'release'); + await new Promise(resolve => child.once('close', resolve)); + } + }); + + it('fails closed when a previous stale-lock recovery was interrupted', async () => { + const { home, walletsDir } = fixture(); + const deadPid = await getDeadPid(); + const lockPath = path.join(walletsDir, '.wallet-mutation.lock'); + fs.mkdirSync(lockPath); + const ownerToken = 'a'.repeat(32); + fs.writeFileSync(path.join(lockPath, 'owner.json'), JSON.stringify({ + pid: deadPid, + token: ownerToken, + identity: syntheticIdentity(), + createdAt: new Date().toISOString(), + })); + fs.writeFileSync(path.join(lockPath, 'recovery.json'), JSON.stringify({ + pid: deadPid, + token: 'b'.repeat(32), + ownerToken, + identity: syntheticIdentity(), + createdAt: new Date().toISOString(), + })); + + process.env.HOME = home; + process.env.USERPROFILE = home; + const walletUrl = pathToFileURL(path.join(repo, 'src', 'wallet.js')).href; + const { withWalletMutationLock } = await import(`${walletUrl}?stale=${Date.now()}`); + expect(() => withWalletMutationLock(() => 'must not run', { waitMs: 40, pollMs: 5 })) + .toThrow(/recovery was interrupted.*remove .*wallet-mutation\.lock/i); + expect(fs.existsSync(lockPath)).toBe(true); + expect(fs.existsSync(path.join(lockPath, 'recovery.json'))).toBe(true); + }); + + it.skipIf(process.platform !== 'linux')('recovers a stale lock when its PID has been reused', async () => { + const { home, walletsDir } = fixture(); + const lockPath = path.join(walletsDir, '.wallet-mutation.lock'); + fs.mkdirSync(lockPath); + fs.writeFileSync(path.join(lockPath, 'owner.json'), JSON.stringify({ + pid: process.pid, + token: 'd'.repeat(32), + identity: 'linux:00000000-0000-0000-0000-000000000000:0', + createdAt: new Date().toISOString(), + })); + + process.env.HOME = home; + process.env.USERPROFILE = home; + const walletUrl = pathToFileURL(path.join(repo, 'src', 'wallet.js')).href; + const { withWalletMutationLock } = await import(`${walletUrl}?pid-reuse=${Date.now()}`); + expect(withWalletMutationLock(() => 'recovered')).toBe('recovered'); + expect(fs.existsSync(lockPath)).toBe(false); + }); + + it.skipIf(process.platform !== 'linux')('rejects a stable lock record without process identity', async () => { + const { home, walletsDir } = fixture(); + const lockPath = path.join(walletsDir, '.wallet-mutation.lock'); + fs.mkdirSync(lockPath); + fs.writeFileSync(path.join(lockPath, 'owner.json'), JSON.stringify({ + pid: process.pid, + token: 'e'.repeat(32), + createdAt: new Date().toISOString(), + })); + process.env.HOME = home; + process.env.USERPROFILE = home; + const walletUrl = pathToFileURL(path.join(repo, 'src', 'wallet.js')).href; + const { withWalletMutationLock } = await import(`${walletUrl}?missing-identity=${Date.now()}`); + + expect(() => withWalletMutationLock(() => {}, { waitMs: 40, pollMs: 5 })) + .toThrow(/malformed owner metadata/i); + }); + + it.skipIf(process.platform !== 'linux')('rejects a recovery record without process identity', async () => { + const { home, walletsDir } = fixture(); + const deadPid = await getDeadPid(); + const lockPath = path.join(walletsDir, '.wallet-mutation.lock'); + const ownerToken = 'a'.repeat(32); + fs.mkdirSync(lockPath); + fs.writeFileSync(path.join(lockPath, 'owner.json'), JSON.stringify({ + pid: deadPid, + token: ownerToken, + identity: syntheticIdentity(), + createdAt: new Date().toISOString(), + })); + fs.writeFileSync(path.join(lockPath, 'recovery.json'), JSON.stringify({ + pid: process.pid, + token: 'b'.repeat(32), + ownerToken, + createdAt: new Date().toISOString(), + })); + process.env.HOME = home; + process.env.USERPROFILE = home; + const walletUrl = pathToFileURL(path.join(repo, 'src', 'wallet.js')).href; + const { withWalletMutationLock } = await import(`${walletUrl}?missing-recovery-identity=${Date.now()}`); + + expect(() => withWalletMutationLock(() => {}, { waitMs: 40, pollMs: 5 })) + .toThrow(/malformed recovery marker/i); + }); + + it('surfaces a stable lock directory whose owner metadata is missing', async () => { + const { home, walletsDir } = fixture(); + const lockPath = path.join(walletsDir, '.wallet-mutation.lock'); + fs.mkdirSync(lockPath); + fs.writeFileSync(path.join(lockPath, 'unexpected-entry'), 'preserve'); + process.env.HOME = home; + process.env.USERPROFILE = home; + const walletUrl = pathToFileURL(path.join(repo, 'src', 'wallet.js')).href; + const { withWalletMutationLock } = await import(`${walletUrl}?missing-owner=${Date.now()}`); + const started = Date.now(); + + expect(() => withWalletMutationLock(() => {}, { waitMs: 40, pollMs: 5 })) + .toThrow(/invalid: missing owner metadata/i); + expect(Date.now() - started).toBeLessThan(500); + }); + + it('retries when missing owner metadata belongs to a replaced lock inode', async () => { + const { home, walletsDir } = fixture(); + const lockPath = path.join(walletsDir, '.wallet-mutation.lock'); + const ownerPath = path.join(lockPath, 'owner.json'); + fs.mkdirSync(lockPath); + fs.writeFileSync(ownerPath, JSON.stringify({ + pid: process.pid, + token: 'a'.repeat(32), + identity: currentIdentity(), + createdAt: new Date().toISOString(), + })); + process.env.HOME = home; + process.env.USERPROFILE = home; + const originalLstatSync = fs.lstatSync; + let handedOff = false; + fs.lstatSync = (file, ...args) => { + if (!handedOff && path.resolve(String(file)) === path.resolve(ownerPath)) { + handedOff = true; + fs.rmSync(lockPath, { recursive: true, force: true }); + fs.mkdirSync(lockPath); + fs.writeFileSync(ownerPath, JSON.stringify({ + pid: process.pid, + token: 'c'.repeat(32), + identity: currentIdentity(), + createdAt: new Date().toISOString(), + })); + const error = new Error('old owner disappeared'); + error.code = 'ENOENT'; + throw error; + } + return originalLstatSync(file, ...args); + }; + try { + const walletUrl = pathToFileURL(path.join(repo, 'src', 'wallet.js')).href; + const { withWalletMutationLock } = await import(`${walletUrl}?owner-inode-handoff=${Date.now()}`); + expect(() => withWalletMutationLock(() => {}, { waitMs: 40, pollMs: 5 })) + .toThrow(/busy .*process/i); + expect(handedOff).toBe(true); + } finally { + fs.lstatSync = originalLstatSync; + } + }); + + it('lets many contenders recover the same dead owner', async () => { + const { home, markerDir, walletsDir } = fixture(); + const deadPid = await getDeadPid(); + const lockPath = path.join(walletsDir, '.wallet-mutation.lock'); + const ownerToken = 'a'.repeat(32); + fs.mkdirSync(lockPath); + fs.writeFileSync(path.join(lockPath, 'owner.json'), JSON.stringify({ + pid: deadPid, token: ownerToken, identity: syntheticIdentity(), createdAt: new Date().toISOString(), + })); + + const results = await Promise.all(Array.from({ length: 12 }, (_, index) => + runLockContender(home, path.join(markerDir, `mutation-${index}`)))); + + expect(results.map(result => result.code), JSON.stringify(results)).toEqual(Array(12).fill(0)); + expect(fs.readdirSync(markerDir).filter(file => file.startsWith('mutation-'))).toHaveLength(12); + expect(fs.existsSync(lockPath)).toBe(false); + expect(fs.readdirSync(walletsDir).filter(file => file.includes('.stale-'))).toEqual([]); + }); + + it('retries a candidate rename when the observed lock disappears', async () => { + const { home, walletsDir } = fixture(); + process.env.HOME = home; + process.env.USERPROFILE = home; + const lockPath = path.join(walletsDir, '.wallet-mutation.lock'); + const originalRenameSync = fs.renameSync; + let injected = false; + fs.renameSync = (from, to) => { + if (!injected && path.resolve(String(to)) === path.resolve(lockPath) && String(from).includes('.candidate-')) { + injected = true; + const error = new Error('destination disappeared after contention'); + error.code = 'ENOTEMPTY'; + throw error; + } + return originalRenameSync(from, to); + }; + try { + const walletUrl = pathToFileURL(path.join(repo, 'src', 'wallet.js')).href; + const { withWalletMutationLock } = await import(`${walletUrl}?rename-race=${Date.now()}`); + expect(withWalletMutationLock(() => 'retried')).toBe('retried'); + expect(injected).toBe(true); + expect(fs.existsSync(lockPath)).toBe(false); + } finally { + fs.renameSync = originalRenameSync; + } + }); + + for (const code of ['EPERM', 'EACCES']) { + it(`bounds persistent ${code} candidate rename failures when the destination is absent`, async () => { + const { home, walletsDir } = fixture(); + process.env.HOME = home; + process.env.USERPROFILE = home; + const lockPath = path.join(walletsDir, '.wallet-mutation.lock'); + const originalRenameSync = fs.renameSync; + let attempts = 0; + fs.renameSync = (from, to) => { + if (path.resolve(String(to)) === path.resolve(lockPath) && String(from).includes('.candidate-')) { + attempts++; + const error = new Error(`persistent ${code}`); + error.code = code; + throw error; + } + return originalRenameSync(from, to); + }; + try { + const walletUrl = pathToFileURL(path.join(repo, 'src', 'wallet.js')).href; + const { withWalletMutationLock } = await import(`${walletUrl}?persistent-${code}=${Date.now()}`); + const started = Date.now(); + let thrown; + try { withWalletMutationLock(() => {}, { waitMs: 40, pollMs: 5 }); } catch (error) { thrown = error; } + expect(thrown?.code).toBe(code); + expect(Date.now() - started).toBeLessThan(500); + expect(attempts).toBeGreaterThan(1); + } finally { + fs.renameSync = originalRenameSync; + } + }); + } + + for (const code of ['EIO', 'ENOENT']) { + it(`throws unexpected ${code} candidate rename failures when the destination is absent`, async () => { + const { home, walletsDir } = fixture(); + process.env.HOME = home; + process.env.USERPROFILE = home; + const lockPath = path.join(walletsDir, '.wallet-mutation.lock'); + const originalRenameSync = fs.renameSync; + let attempts = 0; + fs.renameSync = (from, to) => { + if (path.resolve(String(to)) === path.resolve(lockPath) && String(from).includes('.candidate-')) { + attempts++; + const error = new Error(`unexpected ${code}`); + error.code = code; + throw error; + } + return originalRenameSync(from, to); + }; + try { + const walletUrl = pathToFileURL(path.join(repo, 'src', 'wallet.js')).href; + const { withWalletMutationLock } = await import(`${walletUrl}?unexpected-${code}=${Date.now()}`); + let thrown; + try { withWalletMutationLock(() => {}, { waitMs: 100, pollMs: 5 }); } catch (error) { thrown = error; } + expect(thrown?.code).toBe(code); + expect(attempts).toBe(1); + } finally { + fs.renameSync = originalRenameSync; + } + }); + } + + + it('preserves a live recovery claim published during an owner handoff', async () => { + const { home, walletsDir } = fixture(); + const deadPid = await getDeadPid(); + const lockPath = path.join(walletsDir, '.wallet-mutation.lock'); + const ownerPath = path.join(lockPath, 'owner.json'); + const recoveryPath = path.join(lockPath, 'recovery.json'); + fs.mkdirSync(lockPath); + fs.writeFileSync(ownerPath, JSON.stringify({ + pid: deadPid, token: 'a'.repeat(32), identity: syntheticIdentity(), createdAt: new Date().toISOString(), + })); + process.env.HOME = home; + process.env.USERPROFILE = home; + const originalLinkSync = fs.linkSync; + let handedOff = false; + fs.linkSync = (from, to) => { + if (!handedOff && path.resolve(String(to)) === path.resolve(recoveryPath)) { + handedOff = true; + fs.rmSync(lockPath, { recursive: true, force: true }); + fs.mkdirSync(lockPath); + fs.writeFileSync(ownerPath, JSON.stringify({ + pid: deadPid, token: 'c'.repeat(32), identity: syntheticIdentity(), createdAt: new Date().toISOString(), + })); + fs.writeFileSync(recoveryPath, JSON.stringify({ + pid: process.pid, + token: 'd'.repeat(32), + ownerToken: 'c'.repeat(32), + identity: currentIdentity(), + createdAt: new Date().toISOString(), + })); + } + return originalLinkSync(from, to); + }; + try { + const walletUrl = pathToFileURL(path.join(repo, 'src', 'wallet.js')).href; + const { withWalletMutationLock } = await import(`${walletUrl}?live-handoff=${Date.now()}`); + expect(() => withWalletMutationLock(() => {}, { waitMs: 40, pollMs: 5 })) + .toThrow(/busy while another process recovers/i); + expect(JSON.parse(fs.readFileSync(recoveryPath, 'utf8')).token).toBe('d'.repeat(32)); + } finally { + fs.linkSync = originalLinkSync; + } + }); + + it('retries when stale-owner inspection observes a successor token handoff', async () => { + const { home, walletsDir } = fixture(); + const deadPid = await getDeadPid(); + const lockPath = path.join(walletsDir, '.wallet-mutation.lock'); + const ownerPath = path.join(lockPath, 'owner.json'); + fs.mkdirSync(lockPath); + fs.writeFileSync(ownerPath, JSON.stringify({ + pid: deadPid, token: 'a'.repeat(32), identity: syntheticIdentity(), createdAt: new Date().toISOString(), + })); + process.env.HOME = home; + process.env.USERPROFILE = home; + const originalReadFileSync = fs.readFileSync; + let ownerReads = 0; + let handedOff = false; + fs.readFileSync = (file, ...args) => { + if (path.resolve(String(file)) === path.resolve(ownerPath) && ++ownerReads === 2) { + fs.rmSync(lockPath, { recursive: true, force: true }); + fs.mkdirSync(lockPath); + fs.writeFileSync(ownerPath, JSON.stringify({ + pid: deadPid, token: 'c'.repeat(32), identity: syntheticIdentity(), createdAt: new Date().toISOString(), + })); + handedOff = true; + } + return originalReadFileSync(file, ...args); + }; + try { + const walletUrl = pathToFileURL(path.join(repo, 'src', 'wallet.js')).href; + const { withWalletMutationLock } = await import(`${walletUrl}?handoff=${Date.now()}`); + expect(withWalletMutationLock(() => 'successor recovered')).toBe('successor recovered'); + expect(handedOff).toBe(true); + } finally { + fs.readFileSync = originalReadFileSync; + } + }); + + it.skipIf(process.platform === 'win32')('rejects a symlink lock without touching its target', async () => { + const { home, walletsDir } = fixture(); + const outside = path.join(path.dirname(home), 'outside-lock'); + fs.mkdirSync(outside); + fs.symlinkSync(outside, path.join(walletsDir, '.wallet-mutation.lock')); + process.env.HOME = home; + process.env.USERPROFILE = home; + const walletUrl = pathToFileURL(path.join(repo, 'src', 'wallet.js')).href; + const { createWallet } = await import(`${walletUrl}?symlink=${Date.now()}`); + + expect(() => createWallet('blocked', 'blocked-password')).toThrow(/lock is invalid.*real directory/i); + expect(fs.existsSync(outside)).toBe(true); + expect(fs.readdirSync(outside)).toEqual([]); + }); +}); diff --git a/src/__tests__/wallet.test.js b/src/__tests__/wallet.test.js index c7b3a0c2..4a03e70d 100644 --- a/src/__tests__/wallet.test.js +++ b/src/__tests__/wallet.test.js @@ -720,6 +720,133 @@ describe('Privy wallet create via unified flow', () => { expect(wallet.evm).toBe('0xEvmAddr'); expect(wallet.solana).toBe('SolAddr'); }); + + it('does not hold the local lock during Privy calls and cleans up a raced duplicate', async () => { + const { createPrivyWalletPair } = await import('../privy.js'); + const lockPath = path.join(tempDir, '.nansen', 'wallets', '.wallet-mutation.lock'); + const deleteCalls = []; + let createCalls = 0; + vi.stubGlobal('fetch', vi.fn().mockImplementation((url, opts) => { + if (opts?.method === 'DELETE') { + deleteCalls.push(url); + return Promise.resolve({ ok: true, json: () => Promise.resolve({}) }); + } + + expect(fs.existsSync(lockPath)).toBe(false); + createCalls++; + if (createCalls === 1) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ id: 'wl_evm_raced', address: '0xRaced', chain_type: 'ethereum' }), + }); + } + + createWallet('raced-name', 'competing-password'); + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ id: 'wl_sol_raced', address: 'SolRaced', chain_type: 'solana' }), + }); + })); + + await expect(createPrivyWalletPair('raced-name')).rejects.toThrow('already exists'); + expect(deleteCalls).toHaveLength(2); + expect(deleteCalls.some(url => url.includes('wl_evm_raced'))).toBe(true); + expect(deleteCalls.some(url => url.includes('wl_sol_raced'))).toBe(true); + expect(showWallet('raced-name').provider).toBe('local'); + }); + + it('does not delete remote wallets when lock release fails after local commit', async () => { + const { createPrivyWalletPair } = await import('../privy.js'); + const deleteCalls = []; + let createCalls = 0; + vi.stubGlobal('fetch', vi.fn().mockImplementation((url, opts) => { + if (opts?.method === 'DELETE') { + deleteCalls.push(url); + return Promise.resolve({ ok: true, json: () => Promise.resolve({}) }); + } + createCalls++; + return Promise.resolve({ + ok: true, + json: () => Promise.resolve(createCalls === 1 + ? { id: 'wl_evm_committed', address: '0xCommitted', chain_type: 'ethereum' } + : { id: 'wl_sol_committed', address: 'SolCommitted', chain_type: 'solana' }), + }); + })); + + const originalRenameSync = fs.renameSync; + fs.renameSync = (from, to) => { + if (String(from).endsWith('.wallet-mutation.lock') && String(to).includes('.released-')) { + const error = new Error('injected release failure'); + error.code = 'EIO'; + throw error; + } + return originalRenameSync(from, to); + }; + try { + const error = await createPrivyWalletPair('committed-privy').catch(err => err); + expect(error.code).toBe('WALLET_MUTATION_COMMITTED_LOCK_RELEASE_FAILED'); + expect(error.committed).toBe(true); + expect(error.message).toMatch(/mutation committed.*release/i); + expect(deleteCalls).toEqual([]); + expect(showWallet('committed-privy').provider).toBe('privy'); + } finally { + fs.renameSync = originalRenameSync; + } + }); + + it('preserves remote wallets when config write and wallet rollback both fail', async () => { + const { createPrivyWalletPair } = await import('../privy.js'); + const walletsDir = path.join(tempDir, '.nansen', 'wallets'); + const walletFile = path.join(walletsDir, 'partial-privy.json'); + const configPath = path.join(walletsDir, 'config.json'); + const deleteCalls = []; + let createCalls = 0; + vi.stubGlobal('fetch', vi.fn().mockImplementation((url, opts) => { + if (opts?.method === 'DELETE') { + deleteCalls.push(url); + return Promise.resolve({ ok: true, json: () => Promise.resolve({}) }); + } + createCalls++; + return Promise.resolve({ + ok: true, + json: () => Promise.resolve(createCalls === 1 + ? { id: 'wl_evm_partial', address: '0xPartial', chain_type: 'ethereum' } + : { id: 'wl_sol_partial', address: 'SolPartial', chain_type: 'solana' }), + }); + })); + + const originalRenameSync = fs.renameSync; + const originalUnlinkSync = fs.unlinkSync; + fs.renameSync = (from, to) => { + if (path.resolve(String(to)) === path.resolve(configPath) && String(from).endsWith('.tmp')) { + const error = new Error('injected config write failure'); + error.code = 'EIO'; + throw error; + } + return originalRenameSync(from, to); + }; + fs.unlinkSync = file => { + if (path.resolve(String(file)) === path.resolve(walletFile)) { + const error = new Error('injected wallet rollback failure'); + error.code = 'EPERM'; + throw error; + } + return originalUnlinkSync(file); + }; + try { + const error = await createPrivyWalletPair('partial-privy').catch(err => err); + expect(error.code).toBe('PRIVY_WALLET_LOCAL_COMMIT_PARTIAL'); + expect(error.committed).toBe(true); + expect(error.partial).toBe(true); + expect(error.configError.message).toBe('injected config write failure'); + expect(error.rollbackError.message).toBe('injected wallet rollback failure'); + expect(deleteCalls).toEqual([]); + expect(fs.existsSync(walletFile)).toBe(true); + } finally { + fs.renameSync = originalRenameSync; + fs.unlinkSync = originalUnlinkSync; + } + }); }); describe('Privy wallet delete and export', () => { diff --git a/src/privy.js b/src/privy.js index 1218050c..83ca7b0a 100644 --- a/src/privy.js +++ b/src/privy.js @@ -11,7 +11,7 @@ import path from "path"; import { parsePaymentRequirements } from "./x402.js"; import { hasSufficientEvmPaymentBalance, hasSufficientSvmPaymentBalance } from "./x402-payment-balance.js"; import { isEvmNetwork } from "./x402-evm.js"; -import { mkdirPrivateSync, readWalletJson, writeWalletJsonAtomic } from "./wallet.js"; +import { mkdirPrivateSync, readWalletJson, withWalletMutationLock, writeWalletJsonAtomic } from "./wallet.js"; import { evaluatePaymentRequirement, resolvePaymentAmount, resolvePayTo, signWithSessionCharge } from "./x402-policy.js"; import { isSvmNetwork, @@ -29,6 +29,18 @@ import { trace } from "./debug.js"; const PRIVY_BASE_URL = "https://api.privy.io/v1"; +export class PrivyWalletLocalCommitError extends Error { + constructor(configError, rollbackError) { + super(`Privy wallet reference was written, but config persistence failed and the local reference could not be rolled back: ${configError.message}`, { cause: configError }); + this.name = 'PrivyWalletLocalCommitError'; + this.code = 'PRIVY_WALLET_LOCAL_COMMIT_PARTIAL'; + this.committed = true; + this.partial = true; + this.configError = configError; + this.rollbackError = rollbackError; + } +} + // ============= PrivyClient ============= export class PrivyClient { @@ -193,20 +205,44 @@ export async function createPrivyWalletPair(name) { createdAt: new Date().toISOString(), }; - mkdirPrivateSync(walletsDir); + try { + // Do not hold the filesystem lock during Privy network calls. Re-check and + // serialize only the local persistence boundary, cleaning up the newly + // created remote pair if another process claimed the name meanwhile. + withWalletMutationLock(() => { + mkdirPrivateSync(walletsDir); + if (fs.existsSync(walletFile)) { + throw new Error(`Wallet "${name}" already exists`); + } - // Write config before wallet file so a crash doesn't leave an orphan without a default entry - const configPath = path.join(walletsDir, "config.json"); - let config = { defaultWallet: null, passwordHash: null }; - if (fs.existsSync(configPath)) { - config = readWalletJson(configPath); - } - if (!config.defaultWallet) { - config.defaultWallet = name; - writeWalletJsonAtomic(configPath, config); - } + const configPath = path.join(walletsDir, "config.json"); + let config = { defaultWallet: null, passwordHash: null }; + if (fs.existsSync(configPath)) config = readWalletJson(configPath); - writeWalletJsonAtomic(walletFile, walletData); + writeWalletJsonAtomic(walletFile, walletData); + try { + if (!config.defaultWallet) { + config.defaultWallet = name; + writeWalletJsonAtomic(configPath, config); + } + } catch (configError) { + try { + fs.unlinkSync(walletFile); + } catch (rollbackError) { + throw new PrivyWalletLocalCommitError(configError, rollbackError); + } + throw configError; + } + }); + } catch (err) { + // The local reference is already durable. A lock-release failure must be + // reported as committed, never misclassified as a persistence failure that + // deletes the matching remote wallets. + if (err?.committed === true) throw err; + try { await client.deleteWallet(solanaResult.id); } catch { /* best effort */ } + try { await client.deleteWallet(evmResult.id); } catch { /* best effort */ } + throw err; + } return walletData; } diff --git a/src/wallet.js b/src/wallet.js index 9b67d04e..c5f61c5d 100644 --- a/src/wallet.js +++ b/src/wallet.js @@ -30,6 +30,11 @@ const SCRYPT_KEYLEN = 32; const SALT_LEN = 16; const IV_LEN = 12; const _AUTH_TAG_LEN = 16; +const WALLET_MUTATION_LOCK = '.wallet-mutation.lock'; +const WALLET_MUTATION_LOCK_WAIT_MS = 15000; +const WALLET_MUTATION_LOCK_POLL_MS = 25; +const LOCK_SLEEP_CELL = new Int32Array(new SharedArrayBuffer(4)); +const HELD_WALLET_MUTATION_LOCKS = new Set(); import { keccak256 } from './crypto.js'; @@ -235,6 +240,401 @@ function ensureWalletsDir() { mkdirPrivateSync(getWalletsDir()); } +function sleepSync(ms) { + Atomics.wait(LOCK_SLEEP_CELL, 0, 0, ms); +} + +function linuxProcessIdentity(pid) { + if (process.platform !== 'linux') return null; + // pid is validated as a positive safe integer before this fixed /proc path + // is used. Field 22 is the process start time in kernel clock ticks; pairing + // it with the boot ID distinguishes a reused PID from the original owner. + try { + const stat = fs.readFileSync(`/proc/${pid}/stat`, 'utf8'); + const end = stat.lastIndexOf(')'); + const fields = end === -1 ? [] : stat.slice(end + 2).trim().split(/\s+/); + const started = fields[19]; + const boot = fs.readFileSync('/proc/sys/kernel/random/boot_id', 'utf8').trim(); + return started && boot ? `linux:${boot}:${started}` : null; + } catch (err) { + if (err.code === 'ENOENT' || err.code === 'ESRCH') return null; + throw err; + } +} + +function processIsAlive(owner) { + const { pid } = owner; + try { + process.kill(pid, 0); + } catch (err) { + if (err.code === 'ESRCH') return false; + if (err.code !== 'EPERM') throw err; + } + if (!owner.identity) return true; + const currentIdentity = linuxProcessIdentity(pid); + return currentIdentity !== null && currentIdentity === owner.identity; +} + +function readLockJson(filePath, label) { + let stat; + try { + stat = fs.lstatSync(filePath); + } catch (err) { + if (err.code === 'ENOENT') { + const wrapped = new Error(`Wallet mutation lock is invalid: missing ${label}. Stop other wallet commands, remove ${path.dirname(filePath)}, and retry.`, { cause: err }); + wrapped.code = err.code; + throw wrapped; + } + throw err; + } + if (stat.isSymbolicLink() || !stat.isFile()) { + throw new Error(`Wallet mutation lock is invalid: ${label} must be a regular file. Stop other wallet commands, remove ${path.dirname(filePath)}, and retry.`); + } + try { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); + } catch (err) { + const wrapped = new Error(`Wallet mutation lock is invalid: ${label} is not valid JSON. Stop other wallet commands, remove ${path.dirname(filePath)}, and retry.`, { cause: err }); + if (err.code) wrapped.code = err.code; + throw wrapped; + } +} + +function validateLockRecord(record, label, { ownerToken = null } = {}) { + const validIdentity = typeof record?.identity === 'string' + && /^[A-Za-z0-9:._-]{1,200}$/.test(record.identity); + const valid = record + && Number.isSafeInteger(record.pid) + && record.pid > 0 + && typeof record.token === 'string' + && /^[a-f0-9]{32}$/.test(record.token) + && typeof record.createdAt === 'string' + && !Number.isNaN(Date.parse(record.createdAt)) + && (process.platform === 'linux' + ? validIdentity + : (record.identity === undefined || record.identity === null || validIdentity)); + if (!valid || (ownerToken !== null && record.ownerToken !== ownerToken)) { + throw new Error(`Wallet mutation lock is invalid: malformed ${label}. Stop other wallet commands, remove the lock directory, and retry.`); + } + return record; +} + +function currentLockIdentity() { + const identity = linuxProcessIdentity(process.pid); + if (process.platform === 'linux' && !identity) { + throw new Error('Wallet mutation lock cannot establish the current Linux process identity; refusing unsafe PID-only locking.'); + } + return identity; +} + +function lockDirectoryChanged(lockPath, expectedStat) { + try { + const current = fs.lstatSync(lockPath, { bigint: true }); + return !expectedStat || String(current.dev) !== expectedStat.dev || + String(current.ino) !== expectedStat.ino || String(current.ctimeNs) !== expectedStat.ctimeNs; + } catch (err) { + if (err.code === 'ENOENT') return true; + throw err; + } +} + +function removeOwnRecoveryClaim(expectedClaim, recoveryPath) { + if (!expectedClaim) return; + try { + const publishedStat = fs.lstatSync(recoveryPath); + if (expectedClaim.dev === publishedStat.dev && expectedClaim.ino === publishedStat.ino) { + fs.unlinkSync(recoveryPath); + } + } catch (err) { + if (err.code !== 'ENOENT') throw err; + } +} + +function inspectWalletMutationLock(lockPath) { + const stat = fs.lstatSync(lockPath, { bigint: true }); + if (stat.isSymbolicLink() || !stat.isDirectory()) { + throw new Error(`Wallet mutation lock is invalid: ${lockPath} must be a real directory. Stop other wallet commands, remove it, and retry.`); + } + try { + const owner = validateLockRecord(readLockJson(path.join(lockPath, 'owner.json'), 'owner metadata'), 'owner metadata'); + Object.defineProperty(owner, 'lockDirectoryStat', { + value: { dev: String(stat.dev), ino: String(stat.ino), ctimeNs: String(stat.ctimeNs) }, + }); + return owner; + } catch (err) { + if (err && (typeof err === 'object' || typeof err === 'function')) { + try { + err.lockDirectoryStat = { dev: String(stat.dev), ino: String(stat.ino), ctimeNs: String(stat.ctimeNs) }; + } catch { /* preserve immutable errors */ } + } + throw err; + } +} + +function recoverDeadWalletMutationLock(lockPath, owner) { + const recoveryPath = path.join(lockPath, 'recovery.json'); + const recoveryToken = crypto.randomBytes(16).toString('hex'); + const recoveryCandidatePath = path.join(lockPath, `.recovery-${process.pid}-${recoveryToken}.candidate`); + let publishedClaim; + const recovery = { + pid: process.pid, + token: recoveryToken, + ownerToken: owner.token, + identity: currentLockIdentity(), + createdAt: new Date().toISOString(), + }; + + try { + // Publish complete recovery metadata with an exclusive hard link. Opening + // recovery.json directly with "wx" exposes an empty/partial file to other + // contenders between open and write. + writePrivateJson(recoveryCandidatePath, recovery); + fs.linkSync(recoveryCandidatePath, recoveryPath); + publishedClaim = fs.lstatSync(recoveryCandidatePath); + } catch (err) { + if (err.code === 'ENOENT') return false; + if (err.code !== 'EEXIST') throw err; + let existing; + try { + existing = validateLockRecord(readLockJson(recoveryPath, 'recovery marker'), 'recovery marker'); + } catch (readErr) { + if (readErr.code === 'ENOENT') return false; + throw readErr; + } + let canonicalOwner; + try { + canonicalOwner = inspectWalletMutationLock(lockPath); + } catch (inspectErr) { + if (inspectErr.code === 'ENOENT' && lockDirectoryChanged(lockPath, inspectErr.lockDirectoryStat)) return false; + throw inspectErr; + } + // The caller's owner snapshot may predate a handoff. Never remove a marker + // merely because it names a different owner: it may be the successor's live + // recovery claim. + if (canonicalOwner.token !== owner.token) return false; + if (existing.ownerToken !== canonicalOwner.token) { + throw new Error('Wallet mutation lock is invalid: recovery marker does not match its owner. Stop other wallet commands, remove the lock directory, and retry.', { cause: err }); + } + if (processIsAlive(existing)) return false; + throw new Error(`Wallet mutation recovery was interrupted by process ${existing.pid}. Stop other wallet commands, remove ${lockPath}, and retry.`, { cause: err }); + } finally { + try { fs.unlinkSync(recoveryCandidatePath); } catch { /* lock may have been quarantined */ } + } + + let currentOwner; + try { + currentOwner = inspectWalletMutationLock(lockPath); + } catch (err) { + if (err.code === 'ENOENT') return false; + throw err; + } + if (currentOwner.token !== owner.token) { + removeOwnRecoveryClaim(publishedClaim, recoveryPath); + return false; + } + + const stalePath = path.join( + getWalletsDir(), + `${WALLET_MUTATION_LOCK}.stale-${process.pid}-${recoveryToken}`, + ); + try { + fs.renameSync(lockPath, stalePath); + } catch (err) { + if (err.code === 'ENOENT') return false; + throw err; + } + + const staleOwner = validateLockRecord( + readLockJson(path.join(stalePath, 'owner.json'), 'stale owner metadata'), + 'stale owner metadata', + ); + const staleRecovery = validateLockRecord( + readLockJson(path.join(stalePath, 'recovery.json'), 'stale recovery marker'), + 'stale recovery marker', + { ownerToken: owner.token }, + ); + if (staleOwner.token !== owner.token || staleRecovery.token !== recoveryToken) { + throw new Error('Wallet mutation lock ownership changed during stale-lock recovery; refusing to delete the quarantined directory.'); + } + fs.rmSync(stalePath, { recursive: true, force: true }); + return true; +} + +function releaseWalletMutationLock(lockPath, token) { + const owner = inspectWalletMutationLock(lockPath); + if (owner.token !== token) { + throw new Error('Wallet mutation lock ownership changed before release; refusing to remove another process lock.'); + } + + const releasedPath = path.join(getWalletsDir(), `${WALLET_MUTATION_LOCK}.released-${process.pid}-${token}`); + fs.renameSync(lockPath, releasedPath); + const releasedOwner = validateLockRecord( + readLockJson(path.join(releasedPath, 'owner.json'), 'released owner metadata'), + 'released owner metadata', + ); + if (releasedOwner.token !== token) { + try { fs.renameSync(releasedPath, lockPath); } catch { /* preserve the unexpected directory */ } + throw new Error('Wallet mutation lock ownership changed during release; refusing to remove another process lock.'); + } + fs.rmSync(releasedPath, { recursive: true, force: true }); +} + +export class WalletMutationCommittedError extends Error { + constructor(releaseError) { + super(`Wallet mutation committed, but releasing the mutation lock failed: ${releaseError.message}`, { cause: releaseError }); + this.name = 'WalletMutationCommittedError'; + this.code = 'WALLET_MUTATION_COMMITTED_LOCK_RELEASE_FAILED'; + this.committed = true; + this.releaseError = releaseError; + } +} + +/** + * Serialize complete wallet read-check-write mutations across processes. + * The prepared directory is atomically renamed into place, so readers never + * observe partial owner metadata. Dead owners are quarantined before cleanup; + * cleanup therefore cannot remove a successor's newly acquired lock. + * The callback must be synchronous; returning a Promise is rejected so the + * lock cannot be released while an asynchronous mutation is still running. + */ +export function withWalletMutationLock(fn, { + waitMs = WALLET_MUTATION_LOCK_WAIT_MS, + pollMs = WALLET_MUTATION_LOCK_POLL_MS, +} = {}) { + ensureWalletsDir(); + const walletsDir = getWalletsDir(); + const lockPath = path.join(walletsDir, WALLET_MUTATION_LOCK); + if (HELD_WALLET_MUTATION_LOCKS.has(lockPath)) { + throw new Error('Wallet mutation lock is not reentrant; nested wallet mutations in the same process are not allowed.'); + } + + const token = crypto.randomBytes(16).toString('hex'); + const identity = currentLockIdentity(); + const candidatePath = path.join(walletsDir, `${WALLET_MUTATION_LOCK}.candidate-${process.pid}-${token}`); + mkdirPrivateSync(candidatePath); + writePrivateJson(path.join(candidatePath, 'owner.json'), { + pid: process.pid, + token, + identity, + createdAt: new Date().toISOString(), + }); + + const deadline = Date.now() + waitMs; + let acquired = false; + try { + while (!acquired) { + try { + fs.renameSync(candidatePath, lockPath); + acquired = true; + break; + } catch (renameError) { + const retryableRename = ['EEXIST', 'ENOTEMPTY', 'EPERM', 'EACCES'].includes(renameError.code); + let lockExists = false; + try { + fs.lstatSync(lockPath); + lockExists = true; + } catch (statErr) { + if (statErr.code !== 'ENOENT') throw statErr; + } + if (!lockExists && !retryableRename) throw renameError; + // Another releaser/recoverer may remove the destination after a + // retryable handoff error. Every retry is still deadline-bounded. + if (!lockExists) { + if (Date.now() >= deadline) throw renameError; + sleepSync(Math.min(pollMs, Math.max(1, deadline - Date.now()))); + continue; + } + } + + let owner; + try { + owner = inspectWalletMutationLock(lockPath); + } catch (err) { + // Retry ENOENT only when the canonical directory itself disappeared. + // A stable directory missing owner.json is an invalid lock, not + // transient contention. + if (err.code === 'ENOENT' && lockDirectoryChanged(lockPath, err.lockDirectoryStat)) { + if (Date.now() >= deadline) { + throw new Error('Wallet mutation lock changed continuously while waiting to acquire it. Retry the wallet command.', { cause: err }); + } + sleepSync(Math.min(pollMs, Math.max(1, deadline - Date.now()))); + continue; + } + throw err; + } + if (!processIsAlive(owner)) { + const recovered = recoverDeadWalletMutationLock(lockPath, owner); + if (recovered) continue; + if (Date.now() >= deadline) { + throw new Error('Wallet mutation lock is busy while another process recovers a dead owner. Wait for recovery to finish and retry.'); + } + sleepSync(Math.min(pollMs, Math.max(1, deadline - Date.now()))); + continue; + } + if (Date.now() >= deadline) { + throw new Error(`Wallet mutation lock is busy (held by process ${owner.pid}). Wait for the other wallet command to finish and retry.`); + } + sleepSync(Math.min(pollMs, Math.max(1, deadline - Date.now()))); + } + + HELD_WALLET_MUTATION_LOCKS.add(lockPath); + let result; + let callbackError; + let callbackFailed = false; + try { + result = fn(); + if (result && typeof result.then === 'function') { + if (typeof result.catch === 'function') result.catch(() => {}); + throw new TypeError('Wallet mutation lock callbacks must be synchronous and must not return a Promise.'); + } + } catch (err) { + callbackFailed = true; + callbackError = err; + } + + let releaseError; + let firstReleaseError; + for (let attempt = 0; attempt < 2 && acquired; attempt++) { + try { + releaseWalletMutationLock(lockPath, token); + acquired = false; + } catch (err) { + if (attempt === 0) firstReleaseError = err; + else { + releaseError = err; + if (releaseError && (typeof releaseError === 'object' || typeof releaseError === 'function')) { + try { releaseError.firstReleaseError = firstReleaseError; } catch { /* preserve immutable errors */ } + } + } + } + } + HELD_WALLET_MUTATION_LOCKS.delete(lockPath); + + if (callbackFailed) { + if (releaseError && callbackError && (typeof callbackError === 'object' || typeof callbackError === 'function')) { + try { callbackError.releaseError = releaseError; } catch { /* preserve immutable primary errors */ } + } + throw callbackError; + } + if (releaseError) throw new WalletMutationCommittedError(releaseError); + return result; + } finally { + if (!acquired) { + fs.rmSync(candidatePath, { recursive: true, force: true }); + } + HELD_WALLET_MUTATION_LOCKS.delete(lockPath); + } +} + +function writePrivateJson(filePath, value) { + const fd = fs.openSync(filePath, 'wx', 0o600); + try { + fs.fchmodSync(fd, 0o600); + fs.writeFileSync(fd, JSON.stringify(value)); + } finally { + fs.closeSync(fd); + } +} + /** * Write a wallet-subsystem JSON file (config.json or a .json key file) * so that a concurrent reader always sees either the previous complete file @@ -497,6 +897,10 @@ export function listWallets() { * Create a new wallet pair (EVM + Solana). */ export function createWallet(name, password) { + return withWalletMutationLock(() => createWalletUnlocked(name, password)); +} + +function createWalletUnlocked(name, password) { ensureWalletsDir(); const config = getWalletConfig(); const walletFile = getWalletFile(name); @@ -624,48 +1028,52 @@ export function exportWallet(name, password) { * Set the default wallet. */ export function setDefaultWallet(name) { - requireWalletFile(name); + return withWalletMutationLock(() => { + requireWalletFile(name); - const config = getWalletConfig(); - config.defaultWallet = name; - saveWalletConfig(config); + const config = getWalletConfig(); + config.defaultWallet = name; + saveWalletConfig(config); - return { defaultWallet: name }; + return { defaultWallet: name }; + }); } /** * Delete a wallet. */ export async function deleteWallet(name, password) { - const walletFile = requireWalletFile(name); + return withWalletMutationLock(() => { + const walletFile = requireWalletFile(name); - const data = readWalletJson(walletFile); - const config = getWalletConfig(); + const data = readWalletJson(walletFile); + const config = getWalletConfig(); - if (data.provider && data.provider !== 'local') { - // Non-local wallets: just remove local reference, no password needed - } else { - if (config.passwordHash && !verifyPassword(password, config)) { - throw new Error('Incorrect password'); + if (data.provider && data.provider !== 'local') { + // Non-local wallets: just remove local reference, no password needed + } else { + if (config.passwordHash && !verifyPassword(password, config)) { + throw new Error('Incorrect password'); + } } - } - fs.unlinkSync(walletFile); + fs.unlinkSync(walletFile); - const remaining = fs.readdirSync(getWalletsDir()) - .filter(f => f.endsWith('.json') && f !== 'config.json') - .filter(f => { try { readWalletJson(path.join(getWalletsDir(), f)); return true; } catch { return false; } }); + const remaining = fs.readdirSync(getWalletsDir()) + .filter(f => f.endsWith('.json') && f !== 'config.json') + .filter(f => { try { readWalletJson(path.join(getWalletsDir(), f)); return true; } catch { return false; } }); - if (remaining.length === 0) { - config.defaultWallet = null; - config.passwordHash = null; - deletePassword(); - } else if (config.defaultWallet === name) { - config.defaultWallet = remaining[0].replace('.json', ''); - } - saveWalletConfig(config); + if (remaining.length === 0) { + config.defaultWallet = null; + config.passwordHash = null; + deletePassword(); + } else if (config.defaultWallet === name) { + config.defaultWallet = remaining[0].replace('.json', ''); + } + saveWalletConfig(config); - return { deleted: name, newDefault: config.defaultWallet }; + return { deleted: name, newDefault: config.defaultWallet }; + }); } // ============= CLI Command Builder =============