crumb-alpha-cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +4 -0
- package/.turbo/turbo-test.log +38 -0
- package/dist/commands/balance.d.ts +2 -0
- package/dist/commands/balance.d.ts.map +1 -0
- package/dist/commands/balance.js +43 -0
- package/dist/commands/balance.js.map +1 -0
- package/dist/commands/deposit.d.ts +2 -0
- package/dist/commands/deposit.d.ts.map +1 -0
- package/dist/commands/deposit.js +52 -0
- package/dist/commands/deposit.js.map +1 -0
- package/dist/commands/history.d.ts +4 -0
- package/dist/commands/history.d.ts.map +1 -0
- package/dist/commands/history.js +10 -0
- package/dist/commands/history.js.map +1 -0
- package/dist/commands/init.d.ts +2 -0
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/commands/init.js +76 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/pay.d.ts +4 -0
- package/dist/commands/pay.d.ts.map +1 -0
- package/dist/commands/pay.js +50 -0
- package/dist/commands/pay.js.map +1 -0
- package/dist/commands/serve.d.ts +5 -0
- package/dist/commands/serve.d.ts.map +1 -0
- package/dist/commands/serve.js +43 -0
- package/dist/commands/serve.js.map +1 -0
- package/dist/commands/wallet.d.ts +4 -0
- package/dist/commands/wallet.d.ts.map +1 -0
- package/dist/commands/wallet.js +69 -0
- package/dist/commands/wallet.js.map +1 -0
- package/dist/commands/withdraw.d.ts +4 -0
- package/dist/commands/withdraw.d.ts.map +1 -0
- package/dist/commands/withdraw.js +54 -0
- package/dist/commands/withdraw.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +69 -0
- package/dist/index.js.map +1 -0
- package/dist/utils/config.d.ts +11 -0
- package/dist/utils/config.d.ts.map +1 -0
- package/dist/utils/config.js +30 -0
- package/dist/utils/config.js.map +1 -0
- package/dist/utils/keystore.d.ts +5 -0
- package/dist/utils/keystore.d.ts.map +1 -0
- package/dist/utils/keystore.js +51 -0
- package/dist/utils/keystore.js.map +1 -0
- package/dist/utils/output.d.ts +8 -0
- package/dist/utils/output.d.ts.map +1 -0
- package/dist/utils/output.js +35 -0
- package/dist/utils/output.js.map +1 -0
- package/package.json +39 -0
- package/src/commands/balance.ts +48 -0
- package/src/commands/deposit.ts +57 -0
- package/src/commands/history.ts +11 -0
- package/src/commands/init.ts +86 -0
- package/src/commands/pay.ts +55 -0
- package/src/commands/serve.ts +58 -0
- package/src/commands/wallet.ts +81 -0
- package/src/commands/withdraw.ts +62 -0
- package/src/index.ts +83 -0
- package/src/utils/config.ts +41 -0
- package/src/utils/keystore.ts +65 -0
- package/src/utils/output.ts +40 -0
- package/test/config.test.ts +109 -0
- package/test/keystore.test.ts +118 -0
- package/tsconfig.json +8 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { GatewayClient } from 'crumb-alpha-core'
|
|
2
|
+
import type { SupportedChainName } from 'crumb-alpha-core'
|
|
3
|
+
import { readConfig } from '../utils/config.js'
|
|
4
|
+
import { loadPrivateKey, getPassword } from '../utils/keystore.js'
|
|
5
|
+
import { heading, success, error, info, keyValue } from '../utils/output.js'
|
|
6
|
+
import ora from 'ora'
|
|
7
|
+
|
|
8
|
+
export async function withdrawCommand(
|
|
9
|
+
amount: string,
|
|
10
|
+
options: { chain?: string },
|
|
11
|
+
): Promise<void> {
|
|
12
|
+
heading('Withdraw')
|
|
13
|
+
|
|
14
|
+
const config = readConfig()
|
|
15
|
+
if (!config.activeWallet) {
|
|
16
|
+
error('No wallet configured. Run `crumb init` first.')
|
|
17
|
+
return
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
let password: string
|
|
21
|
+
try {
|
|
22
|
+
password = getPassword()
|
|
23
|
+
} catch {
|
|
24
|
+
error('No password provided. Set CRUMB_KEYSTORE_PASSWORD env var.')
|
|
25
|
+
return
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
let privateKey: string
|
|
29
|
+
try {
|
|
30
|
+
privateKey = loadPrivateKey(password)
|
|
31
|
+
} catch (err: any) {
|
|
32
|
+
error(`Failed to decrypt keystore: ${err.message}`)
|
|
33
|
+
return
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const chain = (config.chain ?? 'arcTestnet') as SupportedChainName
|
|
37
|
+
const gateway = new GatewayClient({
|
|
38
|
+
chain,
|
|
39
|
+
privateKey: privateKey as `0x${string}`,
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
const destChain = options.chain as SupportedChainName | undefined
|
|
43
|
+
info(`Withdrawing ${amount} USDC from Gateway${destChain ? ` to ${destChain}` : ''}...`)
|
|
44
|
+
const spinner = ora(' Processing withdrawal...').start()
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
const result = await gateway.withdraw(amount, {
|
|
48
|
+
chain: destChain,
|
|
49
|
+
})
|
|
50
|
+
spinner.stop()
|
|
51
|
+
|
|
52
|
+
console.log()
|
|
53
|
+
success('Withdrawn')
|
|
54
|
+
keyValue('Amount:', `${result.formattedAmount} USDC`)
|
|
55
|
+
keyValue('Tx:', result.mintTxHash)
|
|
56
|
+
keyValue('From:', result.sourceChain)
|
|
57
|
+
keyValue('To:', result.destinationChain)
|
|
58
|
+
} catch (err: any) {
|
|
59
|
+
spinner.stop()
|
|
60
|
+
error(`Withdrawal failed: ${err.message}`)
|
|
61
|
+
}
|
|
62
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { Command } from 'commander'
|
|
4
|
+
import { initCommand } from './commands/init.js'
|
|
5
|
+
import { walletCreateCommand, walletShowCommand, walletFundCommand } from './commands/wallet.js'
|
|
6
|
+
import { balanceCommand } from './commands/balance.js'
|
|
7
|
+
import { depositCommand } from './commands/deposit.js'
|
|
8
|
+
import { withdrawCommand } from './commands/withdraw.js'
|
|
9
|
+
import { payCommand } from './commands/pay.js'
|
|
10
|
+
import { historyCommand } from './commands/history.js'
|
|
11
|
+
import { serveCommand } from './commands/serve.js'
|
|
12
|
+
|
|
13
|
+
const program = new Command()
|
|
14
|
+
|
|
15
|
+
program
|
|
16
|
+
.name('crumb')
|
|
17
|
+
.description('CLI for Crumb agentic payments via Circle Gateway')
|
|
18
|
+
.version('0.1.0')
|
|
19
|
+
|
|
20
|
+
program
|
|
21
|
+
.command('init')
|
|
22
|
+
.description('Initialize Crumb — create config and wallet')
|
|
23
|
+
.action(initCommand)
|
|
24
|
+
|
|
25
|
+
const wallet = program
|
|
26
|
+
.command('wallet')
|
|
27
|
+
.description('Wallet management')
|
|
28
|
+
|
|
29
|
+
wallet
|
|
30
|
+
.command('create')
|
|
31
|
+
.description('Create a new wallet')
|
|
32
|
+
.action(walletCreateCommand)
|
|
33
|
+
|
|
34
|
+
wallet
|
|
35
|
+
.command('show')
|
|
36
|
+
.description('Show active wallet info')
|
|
37
|
+
.action(walletShowCommand)
|
|
38
|
+
|
|
39
|
+
wallet
|
|
40
|
+
.command('fund')
|
|
41
|
+
.description('Instructions to fund your wallet')
|
|
42
|
+
.action(walletFundCommand)
|
|
43
|
+
|
|
44
|
+
program
|
|
45
|
+
.command('balance')
|
|
46
|
+
.description('Check USDC balance (wallet + gateway)')
|
|
47
|
+
.action(balanceCommand)
|
|
48
|
+
|
|
49
|
+
program
|
|
50
|
+
.command('deposit')
|
|
51
|
+
.description('Deposit USDC into Gateway for gasless payments')
|
|
52
|
+
.argument('<amount>', 'Amount in USDC (e.g. 10.0)')
|
|
53
|
+
.action(depositCommand)
|
|
54
|
+
|
|
55
|
+
program
|
|
56
|
+
.command('withdraw')
|
|
57
|
+
.description('Withdraw USDC from Gateway')
|
|
58
|
+
.argument('<amount>', 'Amount in USDC (e.g. 10.0)')
|
|
59
|
+
.option('--chain <chain>', 'Destination chain (for cross-chain withdrawal)')
|
|
60
|
+
.action(withdrawCommand)
|
|
61
|
+
|
|
62
|
+
program
|
|
63
|
+
.command('pay')
|
|
64
|
+
.description('Pay for an x402-protected resource')
|
|
65
|
+
.argument('<url>', 'URL of the x402-protected resource')
|
|
66
|
+
.option('--max-payment <amount>', 'Maximum USDC willing to pay')
|
|
67
|
+
.action(payCommand)
|
|
68
|
+
|
|
69
|
+
program
|
|
70
|
+
.command('history')
|
|
71
|
+
.description('View transaction history')
|
|
72
|
+
.option('--limit <n>', 'Number of transactions to show', '20')
|
|
73
|
+
.action((options) => historyCommand({ limit: parseInt(options.limit, 10) }))
|
|
74
|
+
|
|
75
|
+
program
|
|
76
|
+
.command('serve')
|
|
77
|
+
.description('Run a local test provider')
|
|
78
|
+
.argument('<file>', 'Path to handler file')
|
|
79
|
+
.option('--port <port>', 'Port to listen on', '3000')
|
|
80
|
+
.option('--price <price>', 'USDC per call (e.g. $0.01)', '$0.01')
|
|
81
|
+
.action(serveCommand)
|
|
82
|
+
|
|
83
|
+
program.parse()
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { homedir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
const CRUMB_DIR = join(homedir(), '.crumb')
|
|
6
|
+
const CONFIG_PATH = join(CRUMB_DIR, 'config.json')
|
|
7
|
+
|
|
8
|
+
export interface CrumbConfig {
|
|
9
|
+
activeWallet?: string
|
|
10
|
+
chain: string // e.g. 'arcTestnet'
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function ensureCrumbDir(): void {
|
|
14
|
+
if (!existsSync(CRUMB_DIR)) {
|
|
15
|
+
mkdirSync(CRUMB_DIR, { recursive: true })
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function getCrumbDir(): string {
|
|
20
|
+
return CRUMB_DIR
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function getConfigPath(): string {
|
|
24
|
+
return CONFIG_PATH
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function readConfig(): CrumbConfig {
|
|
28
|
+
if (!existsSync(CONFIG_PATH)) {
|
|
29
|
+
return { chain: 'arcTestnet' }
|
|
30
|
+
}
|
|
31
|
+
return JSON.parse(readFileSync(CONFIG_PATH, 'utf-8'))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function writeConfig(config: CrumbConfig): void {
|
|
35
|
+
ensureCrumbDir()
|
|
36
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2))
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function configExists(): boolean {
|
|
40
|
+
return existsSync(CONFIG_PATH)
|
|
41
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { randomBytes, createCipheriv, createDecipheriv, scryptSync } from 'node:crypto'
|
|
4
|
+
import { getCrumbDir, ensureCrumbDir } from './config.js'
|
|
5
|
+
|
|
6
|
+
const KEYSTORE_FILE = 'keystore.enc'
|
|
7
|
+
|
|
8
|
+
function getKeystorePath(): string {
|
|
9
|
+
return join(getCrumbDir(), KEYSTORE_FILE)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function keystoreExists(): boolean {
|
|
13
|
+
return existsSync(getKeystorePath())
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function encryptAndSave(privateKey: string, password: string): void {
|
|
17
|
+
ensureCrumbDir()
|
|
18
|
+
|
|
19
|
+
const salt = randomBytes(32)
|
|
20
|
+
const key = scryptSync(password, salt, 32)
|
|
21
|
+
const iv = randomBytes(16)
|
|
22
|
+
const cipher = createCipheriv('aes-256-gcm', key, iv)
|
|
23
|
+
|
|
24
|
+
let encrypted = cipher.update(privateKey, 'utf-8', 'hex')
|
|
25
|
+
encrypted += cipher.final('hex')
|
|
26
|
+
const authTag = cipher.getAuthTag()
|
|
27
|
+
|
|
28
|
+
const payload = {
|
|
29
|
+
salt: salt.toString('hex'),
|
|
30
|
+
iv: iv.toString('hex'),
|
|
31
|
+
authTag: authTag.toString('hex'),
|
|
32
|
+
encrypted,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
writeFileSync(getKeystorePath(), JSON.stringify(payload, null, 2))
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function loadPrivateKey(password: string): string {
|
|
39
|
+
const path = getKeystorePath()
|
|
40
|
+
if (!existsSync(path)) {
|
|
41
|
+
throw new Error('No keystore found. Run `crumb init` first.')
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const payload = JSON.parse(readFileSync(path, 'utf-8'))
|
|
45
|
+
const salt = Buffer.from(payload.salt, 'hex')
|
|
46
|
+
const iv = Buffer.from(payload.iv, 'hex')
|
|
47
|
+
const authTag = Buffer.from(payload.authTag, 'hex')
|
|
48
|
+
const key = scryptSync(password, salt, 32)
|
|
49
|
+
|
|
50
|
+
const decipher = createDecipheriv('aes-256-gcm', key, iv)
|
|
51
|
+
decipher.setAuthTag(authTag)
|
|
52
|
+
|
|
53
|
+
let decrypted = decipher.update(payload.encrypted, 'hex', 'utf-8')
|
|
54
|
+
decrypted += decipher.final('utf-8')
|
|
55
|
+
|
|
56
|
+
return decrypted
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function getPassword(): string {
|
|
60
|
+
const envPassword = process.env.CRUMB_KEYSTORE_PASSWORD
|
|
61
|
+
if (envPassword) return envPassword
|
|
62
|
+
throw new Error(
|
|
63
|
+
'No password provided. Set CRUMB_KEYSTORE_PASSWORD env var or pass --password flag.',
|
|
64
|
+
)
|
|
65
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import chalk from 'chalk'
|
|
2
|
+
|
|
3
|
+
export function heading(text: string): void {
|
|
4
|
+
console.log()
|
|
5
|
+
console.log(chalk.bold.cyan(` ◆ ${text}`))
|
|
6
|
+
console.log()
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function success(text: string): void {
|
|
10
|
+
console.log(chalk.green(` ✔ ${text}`))
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function error(text: string): void {
|
|
14
|
+
console.log(chalk.red(` ✖ ${text}`))
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function info(text: string): void {
|
|
18
|
+
console.log(chalk.gray(` ${text}`))
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function keyValue(key: string, value: string): void {
|
|
22
|
+
console.log(` ${chalk.dim(key.padEnd(12))} ${value}`)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function box(lines: string[]): void {
|
|
26
|
+
const maxLen = Math.max(...lines.map((l) => l.length))
|
|
27
|
+
const border = '─'.repeat(maxLen + 4)
|
|
28
|
+
console.log()
|
|
29
|
+
console.log(` ┌${border}┐`)
|
|
30
|
+
for (const line of lines) {
|
|
31
|
+
console.log(` │ ${line.padEnd(maxLen + 2)}│`)
|
|
32
|
+
}
|
|
33
|
+
console.log(` └${border}┘`)
|
|
34
|
+
console.log()
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function truncateAddress(address: string): string {
|
|
38
|
+
if (address.length <= 12) return address
|
|
39
|
+
return `${address.slice(0, 6)}...${address.slice(-4)}`
|
|
40
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
2
|
+
import { mkdtempSync, rmSync, existsSync } from 'node:fs'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
|
|
6
|
+
// We need to mock the paths to use a temp directory
|
|
7
|
+
let tempDir: string
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
tempDir = mkdtempSync(join(tmpdir(), 'crumb-test-'))
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
rmSync(tempDir, { recursive: true, force: true })
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
// Mock the config module paths
|
|
18
|
+
vi.mock('../src/utils/config.js', async () => {
|
|
19
|
+
const fs = await import('node:fs')
|
|
20
|
+
const path = await import('node:path')
|
|
21
|
+
|
|
22
|
+
// We use a getter for tempDir so each test gets the right dir
|
|
23
|
+
const getDir = () => tempDir
|
|
24
|
+
const getConfigPath = () => path.join(getDir(), 'config.json')
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
ensureCrumbDir: () => {
|
|
28
|
+
const dir = getDir()
|
|
29
|
+
if (!fs.existsSync(dir)) {
|
|
30
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
getCrumbDir: getDir,
|
|
34
|
+
getConfigPath,
|
|
35
|
+
readConfig: () => {
|
|
36
|
+
const configPath = getConfigPath()
|
|
37
|
+
if (!fs.existsSync(configPath)) {
|
|
38
|
+
return { chain: 'arcTestnet' }
|
|
39
|
+
}
|
|
40
|
+
return JSON.parse(fs.readFileSync(configPath, 'utf-8'))
|
|
41
|
+
},
|
|
42
|
+
writeConfig: (config: any) => {
|
|
43
|
+
const dir = getDir()
|
|
44
|
+
if (!fs.existsSync(dir)) {
|
|
45
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
46
|
+
}
|
|
47
|
+
fs.writeFileSync(getConfigPath(), JSON.stringify(config, null, 2))
|
|
48
|
+
},
|
|
49
|
+
configExists: () => {
|
|
50
|
+
return fs.existsSync(getConfigPath())
|
|
51
|
+
},
|
|
52
|
+
}
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
const { readConfig, writeConfig, configExists, getCrumbDir, ensureCrumbDir } = await import(
|
|
56
|
+
'../src/utils/config.js'
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
describe('config', () => {
|
|
60
|
+
describe('readConfig', () => {
|
|
61
|
+
it('returns default config when no file exists', () => {
|
|
62
|
+
const config = readConfig()
|
|
63
|
+
expect(config).toEqual({ chain: 'arcTestnet' })
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('reads existing config', () => {
|
|
67
|
+
writeConfig({ chain: 'arcTestnet', activeWallet: '0xabc' })
|
|
68
|
+
const config = readConfig()
|
|
69
|
+
expect(config.chain).toBe('arcTestnet')
|
|
70
|
+
expect(config.activeWallet).toBe('0xabc')
|
|
71
|
+
})
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
describe('writeConfig', () => {
|
|
75
|
+
it('writes config to disk', () => {
|
|
76
|
+
writeConfig({ chain: 'arcTestnet', activeWallet: '0x123' })
|
|
77
|
+
const config = readConfig()
|
|
78
|
+
expect(config.chain).toBe('arcTestnet')
|
|
79
|
+
expect(config.activeWallet).toBe('0x123')
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('overwrites existing config', () => {
|
|
83
|
+
writeConfig({ chain: 'arcTestnet' })
|
|
84
|
+
writeConfig({ chain: 'arcTestnet', activeWallet: '0xnew' })
|
|
85
|
+
const config = readConfig()
|
|
86
|
+
expect(config.chain).toBe('arcTestnet')
|
|
87
|
+
})
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
describe('configExists', () => {
|
|
91
|
+
it('returns false when no config file', () => {
|
|
92
|
+
expect(configExists()).toBe(false)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('returns true after writing config', () => {
|
|
96
|
+
writeConfig({ chain: 'arcTestnet' })
|
|
97
|
+
expect(configExists()).toBe(true)
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
describe('ensureCrumbDir', () => {
|
|
102
|
+
it('creates directory if not exists', () => {
|
|
103
|
+
const dir = getCrumbDir()
|
|
104
|
+
// tempDir already exists, but test the function doesn't throw
|
|
105
|
+
ensureCrumbDir()
|
|
106
|
+
expect(existsSync(dir)).toBe(true)
|
|
107
|
+
})
|
|
108
|
+
})
|
|
109
|
+
})
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
2
|
+
import { mkdtempSync, rmSync, existsSync, readFileSync } from 'node:fs'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
|
|
6
|
+
let tempDir: string
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
tempDir = mkdtempSync(join(tmpdir(), 'crumb-keystore-test-'))
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
rmSync(tempDir, { recursive: true, force: true })
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
vi.mock('../src/utils/config.js', () => {
|
|
17
|
+
const fs = require('node:fs') // eslint-disable-line
|
|
18
|
+
return {
|
|
19
|
+
getCrumbDir: () => tempDir,
|
|
20
|
+
ensureCrumbDir: () => {
|
|
21
|
+
if (!fs.existsSync(tempDir)) {
|
|
22
|
+
fs.mkdirSync(tempDir, { recursive: true })
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
}
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
const { encryptAndSave, loadPrivateKey, keystoreExists } = await import(
|
|
29
|
+
'../src/utils/keystore.js'
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
describe('keystore', () => {
|
|
33
|
+
const testPrivateKey = '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80'
|
|
34
|
+
const testPassword = 'test-password-123'
|
|
35
|
+
|
|
36
|
+
describe('encryptAndSave', () => {
|
|
37
|
+
it('creates an encrypted keystore file', () => {
|
|
38
|
+
encryptAndSave(testPrivateKey, testPassword)
|
|
39
|
+
expect(existsSync(join(tempDir, 'keystore.enc'))).toBe(true)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('stores encrypted data as JSON', () => {
|
|
43
|
+
encryptAndSave(testPrivateKey, testPassword)
|
|
44
|
+
const raw = readFileSync(join(tempDir, 'keystore.enc'), 'utf-8')
|
|
45
|
+
const data = JSON.parse(raw)
|
|
46
|
+
expect(data).toHaveProperty('salt')
|
|
47
|
+
expect(data).toHaveProperty('iv')
|
|
48
|
+
expect(data).toHaveProperty('authTag')
|
|
49
|
+
expect(data).toHaveProperty('encrypted')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('does not store the private key in plaintext', () => {
|
|
53
|
+
encryptAndSave(testPrivateKey, testPassword)
|
|
54
|
+
const raw = readFileSync(join(tempDir, 'keystore.enc'), 'utf-8')
|
|
55
|
+
expect(raw).not.toContain(testPrivateKey)
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
describe('loadPrivateKey', () => {
|
|
60
|
+
it('decrypts and returns the original private key', () => {
|
|
61
|
+
encryptAndSave(testPrivateKey, testPassword)
|
|
62
|
+
const result = loadPrivateKey(testPassword)
|
|
63
|
+
expect(result).toBe(testPrivateKey)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('throws with wrong password', () => {
|
|
67
|
+
encryptAndSave(testPrivateKey, testPassword)
|
|
68
|
+
expect(() => loadPrivateKey('wrong-password')).toThrow()
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('throws when no keystore exists', () => {
|
|
72
|
+
expect(() => loadPrivateKey(testPassword)).toThrow('No keystore found')
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
describe('keystoreExists', () => {
|
|
77
|
+
it('returns false when no keystore', () => {
|
|
78
|
+
expect(keystoreExists()).toBe(false)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('returns true after saving', () => {
|
|
82
|
+
encryptAndSave(testPrivateKey, testPassword)
|
|
83
|
+
expect(keystoreExists()).toBe(true)
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
describe('roundtrip', () => {
|
|
88
|
+
it('handles various private key formats', () => {
|
|
89
|
+
const keys = [
|
|
90
|
+
'0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80',
|
|
91
|
+
'0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d',
|
|
92
|
+
'0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a',
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
for (const key of keys) {
|
|
96
|
+
const keystorePath = join(tempDir, 'keystore.enc')
|
|
97
|
+
if (existsSync(keystorePath)) {
|
|
98
|
+
rmSync(keystorePath)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
encryptAndSave(key, testPassword)
|
|
102
|
+
expect(loadPrivateKey(testPassword)).toBe(key)
|
|
103
|
+
}
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('uses different salt/iv each time', () => {
|
|
107
|
+
encryptAndSave(testPrivateKey, testPassword)
|
|
108
|
+
const raw1 = JSON.parse(readFileSync(join(tempDir, 'keystore.enc'), 'utf-8'))
|
|
109
|
+
|
|
110
|
+
encryptAndSave(testPrivateKey, testPassword)
|
|
111
|
+
const raw2 = JSON.parse(readFileSync(join(tempDir, 'keystore.enc'), 'utf-8'))
|
|
112
|
+
|
|
113
|
+
expect(raw1.salt).not.toBe(raw2.salt)
|
|
114
|
+
expect(raw1.iv).not.toBe(raw2.iv)
|
|
115
|
+
expect(raw1.encrypted).not.toBe(raw2.encrypted)
|
|
116
|
+
})
|
|
117
|
+
})
|
|
118
|
+
})
|