88import { CoinPayClient } from '../src/client.js' ;
99import { PaymentStatus , Blockchain , FiatCurrency } from '../src/payments.js' ;
1010import { readFileSync , writeFileSync , existsSync } from 'fs' ;
11+ import { execSync } from 'child_process' ;
12+ import { createInterface } from 'readline' ;
1113import { homedir } from 'os' ;
1214import { join } from 'path' ;
15+ import { tmpdir } from 'os' ;
1316
1417const VERSION = '0.3.3' ;
1518const CONFIG_FILE = join ( homedir ( ) , '.coinpay.json' ) ;
@@ -95,8 +98,19 @@ function parseArgs(args) {
9598 const arg = args [ i ] ;
9699
97100 if ( arg . startsWith ( '--' ) ) {
98- const [ key , value ] = arg . slice ( 2 ) . split ( '=' ) ;
99- result . flags [ key ] = value ?? true ;
101+ const eqIdx = arg . indexOf ( '=' ) ;
102+ if ( eqIdx !== - 1 ) {
103+ result . flags [ arg . slice ( 2 , eqIdx ) ] = arg . slice ( eqIdx + 1 ) ;
104+ } else {
105+ // Peek ahead: if next arg doesn't start with '-', it's the value
106+ const next = args [ i + 1 ] ;
107+ if ( next && ! next . startsWith ( '-' ) ) {
108+ result . flags [ arg . slice ( 2 ) ] = next ;
109+ i ++ ;
110+ } else {
111+ result . flags [ arg . slice ( 2 ) ] = true ;
112+ }
113+ }
100114 } else if ( arg . startsWith ( '-' ) ) {
101115 result . flags [ arg . slice ( 1 ) ] = args [ ++ i ] ?? true ;
102116 } else if ( ! result . command ) {
@@ -143,6 +157,10 @@ ${colors.cyan}Commands:${colors.reset}
143157 get <crypto> Get exchange rate
144158 list Get all exchange rates
145159
160+ ${ colors . bright } wallet${ colors . reset }
161+ backup-seed Encrypt seed phrase to GPG file
162+ decrypt-backup <file> Decrypt a GPG backup file
163+
146164 ${ colors . bright } webhook${ colors . reset }
147165 logs <business-id> Get webhook logs
148166 test <business-id> Send test webhook
@@ -156,6 +174,10 @@ ${colors.cyan}Options:${colors.reset}
156174 --currency <code> Fiat currency (USD, EUR, etc.) - default: USD
157175 --blockchain <code> Blockchain (BTC, ETH, SOL, POL, BCH, USDC_ETH, USDC_POL, USDC_SOL)
158176 --description <text> Payment description
177+ --seed <phrase> Seed phrase (or reads from stdin)
178+ --password <pass> GPG passphrase (or prompts interactively)
179+ --wallet-id <id> Wallet ID for backup filename
180+ --output <path> Output file path (default: wallet_<id>_seedphrase.txt.gpg)
159181
160182${ colors . cyan } Examples:${ colors . reset }
161183 # Configure your API key (get it from your CoinPay dashboard)
@@ -179,6 +201,18 @@ ${colors.cyan}Examples:${colors.reset}
179201 # List your businesses
180202 coinpay business list
181203
204+ # Encrypt seed phrase to GPG backup file
205+ coinpay wallet backup-seed --seed "word1 word2 ..." --password "mypass" --wallet-id "wid-abc"
206+
207+ # Encrypt seed phrase (interactive password prompt)
208+ coinpay wallet backup-seed --seed "word1 word2 ..." --wallet-id "wid-abc"
209+
210+ # Pipe seed phrase from stdin
211+ echo "word1 word2 ..." | coinpay wallet backup-seed --wallet-id "wid-abc" --password "mypass"
212+
213+ # Decrypt a backup file
214+ coinpay wallet decrypt-backup wallet_wid-abc_seedphrase.txt.gpg --password "mypass"
215+
182216${ colors . cyan } Environment Variables:${ colors . reset }
183217 COINPAY_API_KEY API key (overrides config)
184218 COINPAY_BASE_URL Custom API URL
@@ -453,6 +487,235 @@ async function handleWebhook(subcommand, args, flags) {
453487 }
454488}
455489
490+ /**
491+ * Prompt for password interactively (hides input)
492+ */
493+ function promptPassword ( prompt = 'Password: ' ) {
494+ return new Promise ( ( resolve ) => {
495+ process . stdout . write ( prompt ) ;
496+
497+ // If stdin is a TTY, read with hidden input
498+ if ( process . stdin . isTTY ) {
499+ const rl = createInterface ( { input : process . stdin , output : process . stdout } ) ;
500+ // Temporarily override output to hide password chars
501+ const origWrite = process . stdout . write . bind ( process . stdout ) ;
502+ process . stdout . write = ( chunk ) => {
503+ // Only suppress characters that are the user's input
504+ if ( typeof chunk === 'string' && chunk !== prompt && chunk !== '\n' && chunk !== '\r\n' ) {
505+ return true ;
506+ }
507+ return origWrite ( chunk ) ;
508+ } ;
509+
510+ rl . question ( '' , ( answer ) => {
511+ process . stdout . write = origWrite ;
512+ process . stdout . write ( '\n' ) ;
513+ rl . close ( ) ;
514+ resolve ( answer ) ;
515+ } ) ;
516+ } else {
517+ // Pipe mode — read from stdin
518+ const chunks = [ ] ;
519+ process . stdin . on ( 'data' , ( chunk ) => chunks . push ( chunk ) ) ;
520+ process . stdin . on ( 'end' , ( ) => resolve ( Buffer . concat ( chunks ) . toString ( ) . trim ( ) ) ) ;
521+ process . stdin . resume ( ) ;
522+ }
523+ } ) ;
524+ }
525+
526+ /**
527+ * Check if gpg is available
528+ */
529+ function hasGpg ( ) {
530+ try {
531+ execSync ( 'gpg --version' , { stdio : 'pipe' } ) ;
532+ return true ;
533+ } catch {
534+ return false ;
535+ }
536+ }
537+
538+ /**
539+ * Wallet commands
540+ */
541+ async function handleWallet ( subcommand , args , flags ) {
542+ switch ( subcommand ) {
543+ case 'backup-seed' : {
544+ if ( ! hasGpg ( ) ) {
545+ print . error ( 'gpg is required but not found. Install it with:' ) ;
546+ print . info ( ' Ubuntu/Debian: sudo apt install gnupg' ) ;
547+ print . info ( ' macOS: brew install gnupg' ) ;
548+ print . info ( ' Windows: https://www.gnupg.org/download/' ) ;
549+ process . exit ( 1 ) ;
550+ }
551+
552+ const walletId = flags [ 'wallet-id' ] ;
553+ if ( ! walletId ) {
554+ print . error ( 'Required: --wallet-id <id>' ) ;
555+ return ;
556+ }
557+
558+ // Get seed phrase from --seed flag or stdin
559+ let seed = flags . seed ;
560+ if ( ! seed ) {
561+ if ( process . stdin . isTTY ) {
562+ print . error ( 'Required: --seed <phrase> (or pipe via stdin)' ) ;
563+ print . info ( 'Example: coinpay wallet backup-seed --seed "word1 word2 ..." --wallet-id "wid-abc"' ) ;
564+ return ;
565+ }
566+ // Read from stdin
567+ const chunks = [ ] ;
568+ for await ( const chunk of process . stdin ) {
569+ chunks . push ( chunk ) ;
570+ }
571+ seed = Buffer . concat ( chunks ) . toString ( ) . trim ( ) ;
572+ }
573+
574+ if ( ! seed ) {
575+ print . error ( 'Seed phrase is empty' ) ;
576+ return ;
577+ }
578+
579+ // Get password from --password flag or prompt
580+ let password = flags . password ;
581+ if ( ! password ) {
582+ if ( ! process . stdin . isTTY ) {
583+ print . error ( 'Required: --password <pass> (cannot prompt in pipe mode)' ) ;
584+ return ;
585+ }
586+ password = await promptPassword ( 'Encryption password: ' ) ;
587+ const confirm = await promptPassword ( 'Confirm password: ' ) ;
588+ if ( password !== confirm ) {
589+ print . error ( 'Passwords do not match' ) ;
590+ return ;
591+ }
592+ }
593+
594+ if ( ! password ) {
595+ print . error ( 'Password is empty' ) ;
596+ return ;
597+ }
598+
599+ // Build the plaintext content
600+ const filename = `wallet_${ walletId } _seedphrase.txt` ;
601+ const content = [
602+ '# CoinPayPortal Wallet Seed Phrase Backup' ,
603+ `# Wallet ID: ${ walletId } ` ,
604+ `# Created: ${ new Date ( ) . toISOString ( ) } ` ,
605+ '#' ,
606+ '# KEEP THIS FILE SAFE. Anyone with this phrase can access your funds.' ,
607+ `# Decrypt with: gpg --decrypt ${ filename } .gpg` ,
608+ '' ,
609+ seed ,
610+ '' ,
611+ ] . join ( '\n' ) ;
612+
613+ // Determine output path
614+ const outputPath = flags . output || `${ filename } .gpg` ;
615+
616+ // Write plaintext to temp file, encrypt with gpg, remove temp
617+ const tmpFile = join ( tmpdir ( ) , `coinpay-backup-${ Date . now ( ) } .txt` ) ;
618+ try {
619+ writeFileSync ( tmpFile , content , { mode : 0o600 } ) ;
620+
621+ // Write passphrase to a temp file for gpg (avoids shell escaping issues)
622+ const passFile = join ( tmpdir ( ) , `coinpay-pass-${ Date . now ( ) } ` ) ;
623+ writeFileSync ( passFile , password , { mode : 0o600 } ) ;
624+ try {
625+ execSync (
626+ `gpg --batch --yes --passphrase-file "${ passFile } " --pinentry-mode loopback --symmetric --cipher-algo AES256 --output "${ outputPath } " "${ tmpFile } "` ,
627+ { stdio : [ 'pipe' , 'pipe' , 'pipe' ] }
628+ ) ;
629+ } finally {
630+ try { writeFileSync ( passFile , Buffer . alloc ( password . length , 0 ) ) ; } catch { }
631+ try { const { unlinkSync : u } = await import ( 'fs' ) ; u ( passFile ) ; } catch { }
632+ }
633+
634+ print . success ( `Encrypted backup saved to: ${ outputPath } ` ) ;
635+ print . info ( `Decrypt with: gpg --decrypt ${ outputPath } ` ) ;
636+ } finally {
637+ // Securely delete temp file
638+ try {
639+ writeFileSync ( tmpFile , Buffer . alloc ( content . length , 0 ) ) ;
640+ const { unlinkSync } = await import ( 'fs' ) ;
641+ unlinkSync ( tmpFile ) ;
642+ } catch {
643+ // Best effort cleanup
644+ }
645+ }
646+ break ;
647+ }
648+
649+ case 'decrypt-backup' : {
650+ if ( ! hasGpg ( ) ) {
651+ print . error ( 'gpg is required but not found.' ) ;
652+ process . exit ( 1 ) ;
653+ }
654+
655+ const filePath = args [ 0 ] ;
656+ if ( ! filePath ) {
657+ print . error ( 'Backup file path required' ) ;
658+ print . info ( 'Example: coinpay wallet decrypt-backup wallet_wid-abc_seedphrase.txt.gpg' ) ;
659+ return ;
660+ }
661+
662+ if ( ! existsSync ( filePath ) ) {
663+ print . error ( `File not found: ${ filePath } ` ) ;
664+ return ;
665+ }
666+
667+ // Get password
668+ let password = flags . password ;
669+ if ( ! password ) {
670+ if ( ! process . stdin . isTTY ) {
671+ print . error ( 'Required: --password <pass> (cannot prompt in pipe mode)' ) ;
672+ return ;
673+ }
674+ password = await promptPassword ( 'Decryption password: ' ) ;
675+ }
676+
677+ try {
678+ const passFile = join ( tmpdir ( ) , `coinpay-pass-${ Date . now ( ) } ` ) ;
679+ writeFileSync ( passFile , password , { mode : 0o600 } ) ;
680+ let result ;
681+ try {
682+ result = execSync (
683+ `gpg --batch --yes --passphrase-file "${ passFile } " --pinentry-mode loopback --decrypt "${ filePath } "` ,
684+ { stdio : [ 'pipe' , 'pipe' , 'pipe' ] }
685+ ) ;
686+ } finally {
687+ try { writeFileSync ( passFile , Buffer . alloc ( password . length , 0 ) ) ; } catch { }
688+ try { const { unlinkSync : u } = await import ( 'fs' ) ; u ( passFile ) ; } catch { }
689+ }
690+
691+ const output = result . toString ( ) ;
692+ // Extract just the mnemonic (skip comments)
693+ const lines = output . split ( '\n' ) ;
694+ const mnemonic = lines
695+ . filter ( ( l ) => ! l . startsWith ( '#' ) && l . trim ( ) . length > 0 )
696+ . join ( ' ' )
697+ . trim ( ) ;
698+
699+ if ( flags . json ) {
700+ print . json ( { mnemonic, raw : output } ) ;
701+ } else {
702+ print . success ( 'Backup decrypted successfully' ) ;
703+ console . log ( `\n${ colors . bright } Seed Phrase:${ colors . reset } ` ) ;
704+ console . log ( `${ colors . yellow } ${ mnemonic } ${ colors . reset } \n` ) ;
705+ print . warn ( 'This is sensitive data — do not share it with anyone.' ) ;
706+ }
707+ } catch ( err ) {
708+ print . error ( 'Decryption failed — wrong password or corrupted file' ) ;
709+ }
710+ break ;
711+ }
712+
713+ default :
714+ print . error ( `Unknown wallet command: ${ subcommand } ` ) ;
715+ print . info ( 'Available: backup-seed, decrypt-backup' ) ;
716+ }
717+ }
718+
456719/**
457720 * Main entry point
458721 */
@@ -491,6 +754,10 @@ async function main() {
491754 case 'webhook' :
492755 await handleWebhook ( subcommand , args , flags ) ;
493756 break ;
757+
758+ case 'wallet' :
759+ await handleWallet ( subcommand , args , flags ) ;
760+ break ;
494761
495762 default :
496763 print . error ( `Unknown command: ${ command } ` ) ;
0 commit comments