From 9017885bbd1df8da815203e028f74f44beed12a0 Mon Sep 17 00:00:00 2001 From: James Lawton Date: Fri, 6 Feb 2026 10:36:08 +0000 Subject: [PATCH 1/2] added trails funding flow and encryption --- README.md | 27 ++++++++++-- src/commands/create-wallet.ts | 25 ++++++++--- src/commands/login.ts | 23 ++++++++-- src/commands/projects.ts | 29 +++++++++++-- src/commands/transfer.ts | 8 ++-- src/commands/wallet-info.ts | 16 +++++-- src/lib/config.ts | 82 +++++++++++++++++++++++++++++++++++ src/lib/crypto.ts | 46 ++++++++++++++++++++ 8 files changed, 235 insertions(+), 21 deletions(-) create mode 100644 src/lib/crypto.ts diff --git a/README.md b/README.md index 615e6d7..d3d23e6 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,13 @@ npm install -g @0xsequence/builder-cli sequence-builder --help ``` -## Quick Start for Agents +## Private Key Encryption + +Set `SEQUENCE_PASSPHRASE` in your environment to auto-encrypt and store the private key. No need to pass `-k` after wallet creation. + +```bash +export SEQUENCE_PASSPHRASE="your-strong-secret" +``` ```bash # 1. Generate a wallet @@ -34,7 +40,7 @@ npx @0xsequence/builder-cli projects create "My Project" # 5. Get your Sequence wallet address (where to send tokens) npx @0xsequence/builder-cli wallet-info -k -a -# 6. Fund the Sequence wallet with tokens +# 6. Fund the Sequence wallet via the Trails link from step 5 # 7. Send an ERC20 transfer (gas paid with same token!) npx @0xsequence/builder-cli transfer \ @@ -117,6 +123,11 @@ Sequence Wallet: 0xA715064b5601Aebf197aC84A469b72Bb7Dc6A646 Important: Send tokens to the Sequence Wallet address for use with the transfer command. The Sequence Wallet can pay gas fees with ERC20 tokens (no ETH needed). + +Fund your wallet: + Click the link below to fund your Sequence Wallet via Trails: + +https://demo.trails.build/?mode=swap&toAddress=0xA715064b5601Aebf197aC84A469b72Bb7Dc6A646&toChainId=137&toToken=0x3c499c542cef5e3811e1192ce70d8cc03d5c3359&apiKey=AQAAAAAAAKhGHJc3N5V2AWqfJ1v9xZ2u0nA&theme=light ``` ## Login @@ -288,8 +299,18 @@ Configuration is stored in `~/.sequence-builder/config.json`: - JWT token for authentication - Environment settings +- Encrypted private key (if `SEQUENCE_PASSPHRASE` is set) + +### Encrypted Key Storage + +When `SEQUENCE_PASSPHRASE` is set as an environment variable, the CLI will: + +1. **On `create-wallet` / `login`**: Encrypt the private key with AES-256-GCM and store it in config +2. **On all other commands**: Automatically decrypt and use the stored key (no `-k` flag needed) + +The private key is encrypted using a key derived from `SEQUENCE_PASSPHRASE` via scrypt. Only the encrypted ciphertext, salt, and IV are stored -- never the raw key. -**Note**: Private keys are NOT stored. You must provide them with each command that requires signing. +To disable encrypted storage, simply unset the env var. You can always override with an explicit `-k` flag. ## Environment Support diff --git a/src/commands/create-wallet.ts b/src/commands/create-wallet.ts index 8f1d882..c01a861 100644 --- a/src/commands/create-wallet.ts +++ b/src/commands/create-wallet.ts @@ -1,6 +1,7 @@ import { Command } from 'commander' import chalk from 'chalk' import { generateWallet } from '../lib/wallet.js' +import { storeEncryptedKey } from '../lib/config.js' export const createWalletCommand = new Command('create-wallet') .description('Generate a new EOA keypair for use with Sequence Builder') @@ -9,12 +10,16 @@ export const createWalletCommand = new Command('create-wallet') try { const wallet = generateWallet() + // Auto-encrypt and store if SEQUENCE_PASSPHRASE is set + const stored = storeEncryptedKey(wallet.privateKey) + if (options.json) { console.log( JSON.stringify( { privateKey: wallet.privateKey, address: wallet.address, + keyStored: stored, }, null, 2 @@ -29,14 +34,24 @@ export const createWalletCommand = new Command('create-wallet') console.log(chalk.white('Private Key:'), chalk.yellow(wallet.privateKey)) console.log(chalk.white('Address: '), chalk.cyan(wallet.address)) console.log('') - console.log( - chalk.red.bold('IMPORTANT:'), - chalk.white('Store these credentials securely. They will not be shown again.') - ) + if (stored) { + console.log( + chalk.green('✓ Private key encrypted and stored.'), + chalk.gray('You won\'t need to pass -k for future commands.') + ) + } else { + console.log( + chalk.red.bold('IMPORTANT:'), + chalk.white('Store these credentials securely. They will not be shown again.') + ) + console.log( + chalk.gray('Tip: Set SEQUENCE_PASSPHRASE env var to auto-encrypt and store the key.') + ) + } console.log('') console.log(chalk.gray('To use this wallet:')) console.log(chalk.gray(' 1. Fund it with native token for gas fees')) - console.log(chalk.gray(' 2. Run: sequence-builder login -k ')) + console.log(chalk.gray(` 2. Run: sequence-builder login${stored ? '' : ' -k '}`)) console.log('') } catch (error) { console.error( diff --git a/src/commands/login.ts b/src/commands/login.ts index edefbfa..d7eb694 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -2,19 +2,27 @@ import { Command } from 'commander' import chalk from 'chalk' import { generateEthAuthProof } from '../lib/ethauth.js' import { getAuthToken } from '../lib/api.js' -import { updateConfig, EXIT_CODES, isLoggedIn, getValidJwtToken } from '../lib/config.js' +import { + updateConfig, + EXIT_CODES, + isLoggedIn, + getValidJwtToken, + getPrivateKey, + storeEncryptedKey, +} from '../lib/config.js' import { isValidPrivateKey, getAddressFromPrivateKey } from '../lib/wallet.js' export const loginCommand = new Command('login') .description('Authenticate with Sequence Builder using your private key') - .requiredOption('-k, --private-key ', 'Your wallet private key') + .option('-k, --private-key ', 'Your wallet private key (or use stored encrypted key)') .option('-e, --email ', 'Email address to associate with your account') .option('--json', 'Output in JSON format') .option('--env ', 'Environment to use (prod, dev)', 'prod') .option('--api-url ', 'Custom API URL') .action(async (options) => { try { - const { privateKey, email, json, env, apiUrl } = options + const { email, json, env, apiUrl } = options + const privateKey = getPrivateKey(options) // Validate private key format if (!isValidPrivateKey(privateKey)) { @@ -73,6 +81,9 @@ export const loginCommand = new Command('login') apiUrl: apiUrl, }) + // Auto-encrypt and store private key if SEQUENCE_PASSPHRASE is set + const stored = storeEncryptedKey(privateKey) + if (json) { console.log( JSON.stringify( @@ -93,6 +104,12 @@ export const loginCommand = new Command('login') console.log('') console.log(chalk.white('Address: '), chalk.cyan(address)) console.log(chalk.white('Expires: '), chalk.gray(response.auth.expiresAt)) + if (stored) { + console.log( + chalk.green('Key: '), + chalk.green('Encrypted and stored (no need to pass -k again)') + ) + } console.log('') console.log(chalk.gray('You can now use commands like:')) console.log(chalk.gray(' sequence-builder projects')) diff --git a/src/commands/projects.ts b/src/commands/projects.ts index 14a6dba..68258ff 100644 --- a/src/commands/projects.ts +++ b/src/commands/projects.ts @@ -2,7 +2,7 @@ import { Command } from 'commander' import chalk from 'chalk' import { Session } from '@0xsequence/auth' import { listProjects, createProject, getProject, getDefaultAccessKey } from '../lib/api.js' -import { isLoggedIn, EXIT_CODES } from '../lib/config.js' +import { isLoggedIn, EXIT_CODES, loadConfig } from '../lib/config.js' import { isValidPrivateKey } from '../lib/wallet.js' export const projectsCommand = new Command('projects') @@ -160,11 +160,32 @@ async function createProjectAction( // Access key fetch failed, but project was created } - // Derive Sequence wallet address if private key is provided + // Derive Sequence wallet address if private key is available (explicit or stored) + let resolvedPrivateKey = privateKey + if (!resolvedPrivateKey) { + // Try to get stored key without exiting -- it's optional for project creation + try { + const config = loadConfig() + const passphrase = process.env.SEQUENCE_PASSPHRASE + if (config.encryptedPrivateKey && config.encryptionSalt && config.encryptionIv && passphrase) { + const { decryptPrivateKey } = await import('../lib/crypto.js') + resolvedPrivateKey = decryptPrivateKey( + { + encrypted: config.encryptedPrivateKey, + salt: config.encryptionSalt, + iv: config.encryptionIv, + }, + passphrase + ) + } + } catch { + // No stored key available, that's fine for project creation + } + } let sequenceWalletAddress: string | undefined - if (privateKey && accessKey && isValidPrivateKey(privateKey)) { + if (resolvedPrivateKey && accessKey && isValidPrivateKey(resolvedPrivateKey)) { try { - const normalizedKey = privateKey.startsWith('0x') ? privateKey : '0x' + privateKey + const normalizedKey = resolvedPrivateKey.startsWith('0x') ? resolvedPrivateKey : '0x' + resolvedPrivateKey const session = await Session.singleSigner({ signer: normalizedKey, projectAccessKey: accessKey, diff --git a/src/commands/transfer.ts b/src/commands/transfer.ts index 5a806b4..a08b4f3 100644 --- a/src/commands/transfer.ts +++ b/src/commands/transfer.ts @@ -2,7 +2,7 @@ import { Command } from 'commander' import chalk from 'chalk' import { ethers } from 'ethers' import { Session } from '@0xsequence/auth' -import { EXIT_CODES } from '../lib/config.js' +import { EXIT_CODES, getPrivateKey } from '../lib/config.js' import { isValidPrivateKey } from '../lib/wallet.js' // ERC20 ABI for transfer function @@ -15,7 +15,7 @@ const ERC20_ABI = [ export const transferCommand = new Command('transfer') .description('Send an ERC20 token transfer using Sequence smart wallet') - .requiredOption('-k, --private-key ', 'Your wallet private key') + .option('-k, --private-key ', 'Your wallet private key (or use stored encrypted key)') .requiredOption('-a, --access-key ', 'Project access key') .requiredOption('-t, --token
', 'ERC20 token contract address') .requiredOption('-r, --recipient
', 'Recipient address') @@ -23,12 +23,14 @@ export const transferCommand = new Command('transfer') .requiredOption('-c, --chain-id ', 'Chain ID (e.g., 137 for Polygon)') .option('--json', 'Output in JSON format') .action(async (options) => { - const { privateKey, accessKey, token, recipient, amount, chainId: chainIdStr, json } = options + const { accessKey, token, recipient, amount, chainId: chainIdStr, json } = options // Track wallet address for error reporting let walletAddress: string | undefined try { + const privateKey = getPrivateKey(options) + // Validate private key format if (!isValidPrivateKey(privateKey)) { if (json) { diff --git a/src/commands/wallet-info.ts b/src/commands/wallet-info.ts index 12d03d4..425af86 100644 --- a/src/commands/wallet-info.ts +++ b/src/commands/wallet-info.ts @@ -1,18 +1,20 @@ import { Command } from 'commander' import chalk from 'chalk' import { Session } from '@0xsequence/auth' -import { EXIT_CODES } from '../lib/config.js' +import { EXIT_CODES, getPrivateKey } from '../lib/config.js' import { isValidPrivateKey, getAddressFromPrivateKey } from '../lib/wallet.js' export const walletInfoCommand = new Command('wallet-info') .description('Show wallet addresses (EOA and Sequence smart wallet)') - .requiredOption('-k, --private-key ', 'Your wallet private key') + .option('-k, --private-key ', 'Your wallet private key (or use stored encrypted key)') .requiredOption('-a, --access-key ', 'Project access key') .option('--json', 'Output in JSON format') .action(async (options) => { - const { privateKey, accessKey, json } = options + const { accessKey, json } = options try { + const privateKey = getPrivateKey(options) + // Validate private key format if (!isValidPrivateKey(privateKey)) { if (json) { @@ -46,12 +48,15 @@ export const walletInfoCommand = new Command('wallet-info') const sequenceWalletAddress = session.account.address + const fundingUrl = `https://demo.trails.build/?mode=swap&toAddress=${sequenceWalletAddress}&toChainId=137&toToken=0x3c499c542cef5e3811e1192ce70d8cc03d5c3359&apiKey=AQAAAAAAAKhGHJc3N5V2AWqfJ1v9xZ2u0nA&theme=light` + if (json) { console.log( JSON.stringify( { eoaAddress, sequenceWalletAddress, + fundingUrl, }, null, 2 @@ -76,6 +81,11 @@ export const walletInfoCommand = new Command('wallet-info') chalk.yellow(' The Sequence Wallet can pay gas fees with ERC20 tokens (no ETH needed).') ) console.log('') + console.log(chalk.green.bold('Fund your wallet:')) + console.log(chalk.green(' Click the link below to fund your Sequence Wallet via Trails:')) + console.log('') + console.log(chalk.cyan.underline(fundingUrl)) + console.log('') } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error) if (json) { diff --git a/src/lib/config.ts b/src/lib/config.ts index 5cf41ad..f6d535b 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -1,12 +1,17 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs' import { homedir } from 'os' import { join } from 'path' +import chalk from 'chalk' +import { encryptPrivateKey, decryptPrivateKey, type EncryptedData } from './crypto.js' export interface Config { jwtToken?: string jwtExpiresAt?: string environment: 'prod' | 'dev' apiUrl?: string + encryptedPrivateKey?: string + encryptionSalt?: string + encryptionIv?: string } const CONFIG_DIR = join(homedir(), '.sequence-builder') @@ -112,6 +117,83 @@ export function getApiUrl(options?: { env?: string; apiUrl?: string }): string { } } +/** + * Encrypt and store a private key in config using SEQUENCE_PASSPHRASE env var. + * Returns true if stored, false if SEQUENCE_PASSPHRASE is not set. + */ +export function storeEncryptedKey(privateKey: string): boolean { + const passphrase = process.env.SEQUENCE_PASSPHRASE + if (!passphrase) { + return false + } + + const data = encryptPrivateKey(privateKey, passphrase) + updateConfig({ + encryptedPrivateKey: data.encrypted, + encryptionSalt: data.salt, + encryptionIv: data.iv, + }) + return true +} + +/** + * Resolve the private key from CLI options or stored encrypted config. + * Priority: 1) explicit --private-key flag, 2) stored encrypted key via SEQUENCE_PASSPHRASE. + * Exits with error if neither is available. + */ +export function getPrivateKey(options: { privateKey?: string; json?: boolean }): string { + const { privateKey, json } = options + + // 1. Explicit CLI flag takes precedence + if (privateKey) { + return privateKey + } + + // 2. Try to decrypt stored key + const passphrase = process.env.SEQUENCE_PASSPHRASE + const config = loadConfig() + + if (config.encryptedPrivateKey && config.encryptionSalt && config.encryptionIv && passphrase) { + try { + const data: EncryptedData = { + encrypted: config.encryptedPrivateKey, + salt: config.encryptionSalt, + iv: config.encryptionIv, + } + return decryptPrivateKey(data, passphrase) + } catch { + if (json) { + console.log( + JSON.stringify({ + error: 'Failed to decrypt stored key -- check SEQUENCE_PASSPHRASE', + code: EXIT_CODES.INVALID_PRIVATE_KEY, + }) + ) + } else { + console.error(chalk.red('✖ Failed to decrypt stored private key')) + console.error(chalk.gray('Check that SEQUENCE_PASSPHRASE is correct')) + } + process.exit(EXIT_CODES.INVALID_PRIVATE_KEY) + } + } + + // 3. Neither available + if (json) { + console.log( + JSON.stringify({ + error: 'No private key provided. Use --private-key or set SEQUENCE_PASSPHRASE env var', + code: EXIT_CODES.INVALID_PRIVATE_KEY, + }) + ) + } else { + console.error(chalk.red('✖ No private key available')) + console.error( + chalk.gray('Provide --private-key or set SEQUENCE_PASSPHRASE env var with a stored key') + ) + } + process.exit(EXIT_CODES.INVALID_PRIVATE_KEY) +} + // Exit codes export const EXIT_CODES = { SUCCESS: 0, diff --git a/src/lib/crypto.ts b/src/lib/crypto.ts new file mode 100644 index 0000000..7b25235 --- /dev/null +++ b/src/lib/crypto.ts @@ -0,0 +1,46 @@ +import { randomBytes, scryptSync, createCipheriv, createDecipheriv } from 'crypto' + +export interface EncryptedData { + encrypted: string + salt: string + iv: string +} + +/** + * Encrypt a private key using AES-256-GCM with a passphrase. + * Key is derived from passphrase using scrypt. + */ +export function encryptPrivateKey(privateKey: string, passphrase: string): EncryptedData { + const salt = randomBytes(32) + const iv = randomBytes(16) + const key = scryptSync(passphrase, salt, 32) + + const cipher = createCipheriv('aes-256-gcm', key, iv) + let encrypted = cipher.update(privateKey, 'utf8', 'hex') + encrypted += cipher.final('hex') + const authTag = cipher.getAuthTag().toString('hex') + + return { + encrypted: encrypted + ':' + authTag, + salt: salt.toString('hex'), + iv: iv.toString('hex'), + } +} + +/** + * Decrypt a private key using AES-256-GCM with a passphrase. + */ +export function decryptPrivateKey(data: EncryptedData, passphrase: string): string { + const salt = Buffer.from(data.salt, 'hex') + const iv = Buffer.from(data.iv, 'hex') + const key = scryptSync(passphrase, salt, 32) + + const [encryptedHex, authTagHex] = data.encrypted.split(':') + const decipher = createDecipheriv('aes-256-gcm', key, iv) + decipher.setAuthTag(Buffer.from(authTagHex, 'hex')) + + let decrypted = decipher.update(encryptedHex, 'hex', 'utf8') + decrypted += decipher.final('utf8') + + return decrypted +} From 2e6e126394a527f4b1f211620fc4d9194ef642eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ahmet=20Bu=C4=9Fra=20Yi=C4=9Fiter?= Date: Fri, 6 Feb 2026 13:41:24 +0300 Subject: [PATCH 2/2] Fix test and format --- src/__tests__/cli.test.ts | 3 ++- src/commands/create-wallet.ts | 6 ++++-- src/commands/projects.ts | 11 +++++++++-- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/__tests__/cli.test.ts b/src/__tests__/cli.test.ts index 83582ad..0206e1f 100644 --- a/src/__tests__/cli.test.ts +++ b/src/__tests__/cli.test.ts @@ -44,7 +44,8 @@ describe('CLI commands', () => { it('should output JSON format with --json flag', () => { const result = runCli('create-wallet --json') - const output = result.stdout.trim().split('\n').slice(-4).join('\n') // Get JSON part + const raw = result.stdout.trim() + const output = raw.substring(raw.indexOf('{'), raw.lastIndexOf('}') + 1) // Get JSON part expect(() => JSON.parse(output)).not.toThrow() const json = JSON.parse(output) expect(json).toHaveProperty('privateKey') diff --git a/src/commands/create-wallet.ts b/src/commands/create-wallet.ts index c01a861..100931a 100644 --- a/src/commands/create-wallet.ts +++ b/src/commands/create-wallet.ts @@ -37,7 +37,7 @@ export const createWalletCommand = new Command('create-wallet') if (stored) { console.log( chalk.green('✓ Private key encrypted and stored.'), - chalk.gray('You won\'t need to pass -k for future commands.') + chalk.gray("You won't need to pass -k for future commands.") ) } else { console.log( @@ -51,7 +51,9 @@ export const createWalletCommand = new Command('create-wallet') console.log('') console.log(chalk.gray('To use this wallet:')) console.log(chalk.gray(' 1. Fund it with native token for gas fees')) - console.log(chalk.gray(` 2. Run: sequence-builder login${stored ? '' : ' -k '}`)) + console.log( + chalk.gray(` 2. Run: sequence-builder login${stored ? '' : ' -k '}`) + ) console.log('') } catch (error) { console.error( diff --git a/src/commands/projects.ts b/src/commands/projects.ts index 68258ff..d36ef99 100644 --- a/src/commands/projects.ts +++ b/src/commands/projects.ts @@ -167,7 +167,12 @@ async function createProjectAction( try { const config = loadConfig() const passphrase = process.env.SEQUENCE_PASSPHRASE - if (config.encryptedPrivateKey && config.encryptionSalt && config.encryptionIv && passphrase) { + if ( + config.encryptedPrivateKey && + config.encryptionSalt && + config.encryptionIv && + passphrase + ) { const { decryptPrivateKey } = await import('../lib/crypto.js') resolvedPrivateKey = decryptPrivateKey( { @@ -185,7 +190,9 @@ async function createProjectAction( let sequenceWalletAddress: string | undefined if (resolvedPrivateKey && accessKey && isValidPrivateKey(resolvedPrivateKey)) { try { - const normalizedKey = resolvedPrivateKey.startsWith('0x') ? resolvedPrivateKey : '0x' + resolvedPrivateKey + const normalizedKey = resolvedPrivateKey.startsWith('0x') + ? resolvedPrivateKey + : '0x' + resolvedPrivateKey const session = await Session.singleSigner({ signer: normalizedKey, projectAccessKey: accessKey,