Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,13 @@ paytaca wallet create # Generate a new 12-word seed phrase
paytaca wallet create --chipnet # Create on chipnet (testnet)
paytaca wallet import # Import an existing seed phrase
paytaca wallet info # Show wallet hash, address, and balance
paytaca wallet export # Display the stored seed phrase
paytaca wallet export # Display the stored seed phrase (interactive terminal + biometrics)
```

> `wallet export` requires an interactive terminal and prompts for biometric
> authentication (Touch ID, fingerprint, or Windows Hello) when available. It
> cannot be used from scripts, pipes, or other non-interactive processes.

### Balance

```bash
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "paytaca-cli",
"version": "0.5.2",
"version": "0.6.0",
"description": "Command-line interface for the Paytaca Bitcoin Cash wallet",
"type": "module",
"main": "dist/index.js",
Expand Down
99 changes: 99 additions & 0 deletions src/commands/wallet.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { describe, it, expect } from 'vitest'
import {
assertInteractiveTerminal,
NonInteractiveTerminalError,
classifyBiometricOutput,
generateChallenge,
isChallengeAnswerCorrect,
} from './wallet.js'

describe('assertInteractiveTerminal', () => {
it('allows execution when both stdin and stdout are TTYs', () => {
expect(() => assertInteractiveTerminal(true, true)).not.toThrow()
})

it('refuses when stdin is not a TTY', () => {
expect(() => assertInteractiveTerminal(undefined, true)).toThrow(
NonInteractiveTerminalError
)
})

it('refuses when stdout is not a TTY', () => {
expect(() => assertInteractiveTerminal(true, undefined)).toThrow(
NonInteractiveTerminalError
)
})

it('refuses when neither stream is a TTY', () => {
expect(() => assertInteractiveTerminal(false, false)).toThrow(
NonInteractiveTerminalError
)
})
})

describe('classifyBiometricOutput', () => {
it('maps the unavailable token', () => {
expect(classifyBiometricOutput('PAYTACA_BIO_UNAVAILABLE')).toBe('unavailable')
})

it('maps the success token', () => {
expect(classifyBiometricOutput('PAYTACA_BIO_OK')).toBe('ok')
})

it('maps the failure token', () => {
expect(classifyBiometricOutput('PAYTACA_BIO_FAIL')).toBe('denied')
})

it('treats unrecognized output as an error', () => {
expect(classifyBiometricOutput('')).toBe('error')
expect(classifyBiometricOutput('some unrelated output')).toBe('error')
})

it('lets a failure token win if both fail and ok appear', () => {
expect(
classifyBiometricOutput('PAYTACA_BIO_FAIL\nPAYTACA_BIO_OK')
).toBe('denied')
})

it('does not confuse the unavailable token with success', () => {
expect(classifyBiometricOutput('PAYTACA_BIO_UNAVAILABLE')).not.toBe('ok')
})
})

describe('generateChallenge', () => {
it('produces two 4-character groups from an unambiguous alphabet', () => {
for (let i = 0; i < 50; i++) {
const challenge = generateChallenge()
expect(challenge).toMatch(/^[A-HJ-NP-Z2-9]{4}-[A-HJ-NP-Z2-9]{4}$/)
}
})

it('generates different codes across calls', () => {
const codes = new Set(Array.from({ length: 50 }, () => generateChallenge()))
expect(codes.size).toBeGreaterThan(1)
})
})

describe('isChallengeAnswerCorrect', () => {
const challenge = 'ABCD-2345'

it('accepts an exact match', () => {
expect(isChallengeAnswerCorrect(challenge, challenge)).toBe(true)
})

it('is case-insensitive and trims surrounding whitespace', () => {
expect(isChallengeAnswerCorrect(challenge, ' abcd-2345 ')).toBe(true)
})

it('rejects a wrong answer', () => {
expect(isChallengeAnswerCorrect(challenge, 'WXYZ-9876')).toBe(false)
})

it('rejects a missing answer (timeout)', () => {
expect(isChallengeAnswerCorrect(challenge, null)).toBe(false)
})

it('rejects an empty answer', () => {
expect(isChallengeAnswerCorrect(challenge, '')).toBe(false)
})
})
Loading
Loading