Skip to content

Commit

Permalink
Initial commit
Browse files Browse the repository at this point in the history
  • Loading branch information
James Calfee committed May 19, 2017
0 parents commit 0a8a780
Show file tree
Hide file tree
Showing 10 changed files with 6,210 additions and 0 deletions.
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
node_modules
coverage
test
npm-debug.log
4 changes: 4 additions & 0 deletions .travis.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
language: node_js
node_js:
- "6"
after_success: npm run coveralls
38 changes: 38 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
[![Build Status](https://travis-ci.org/jcalfee/bip39-checker.svg?branch=master)](https://travis-ci.org/jcalfee/bip39-checker)
[![Coverage Status](https://coveralls.io/repos/github/jcalfee/bip39-checker/badge.svg?branch=master)](https://coveralls.io/github/jcalfee/bip39-checker?branch=master)

# BIP-0039 Checker

Normalize and spell check BIP-0039 brain seed phrases.

## API

```javascript
const assert = require('assert')
const {normalize, checkWords, suggest} = require('bip39-checker')

// Normalizes
assert.equal('double spaces', normalize('double spaces'), 'removes extra spaces')
assert.equal('lowercase', normalize('Lowercase'), 'lowercase')
assert.equal('trim', normalize(' trim '), 'trim')

// Checks each word in the seed
assert(checkWords('lazy dog', 'english'))
assert(!checkWords('lazy ogday', 'english'))

// Auto-correct suggestions
assert(suggest('quality') === true)
assert(suggest('ágil', {language: 'spanish'}) === true)
assert.equal(suggest('quality1')[0], 'quality')

// BIP-0039 dictionaries are exported
const {languages, wordlist} = require('bip39-checker')

console.log(languages)
assert(wordlist(languages[0]))

// Word list array for a given language
assert(wordlist('english').length, 2048)
assert(wordlist('spanish').length, 2048)
// etc.. (all languages must be 2048 words)
```
143 changes: 143 additions & 0 deletions index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
const LevensteinDistance = require('./src/levenstein_distance')

module.exports = {
suggest,
languages: [
'chinese_simplified',
'chinese_traditional',
'japanese',
'english',
'italian',
'french',
'spanish'
],
wordlist: language => language ? wordlists[language] : wordlists,
normalize,
checkWords,
validWordlist,
bip39: require('bip39')
}

const wordlists = {
chinese_simplified: require('./src/wordlist_chinese_simplified'),
chinese_traditional: require('./src/wordlist_chinese_traditional'),
english: require('bip39/wordlists/english.json'),
french: require('bip39/wordlists/french.json'),
italian: require('bip39/wordlists/italian.json'),
japanese: require('bip39/wordlists/japanese.json'),
spanish: require('bip39/wordlists/spanish.json')
}

/**
@summary Character cleansing: printable characters, all lowercase, trim.
@description Filter and remove invalid characters or extraneous spaces from BIP-0039 word phrases. Future implementation can assume that this method will not change any word in the language files (@see index.test.js).
@retrun {string} normalized seed
*/
function normalize (seed) {
if (typeof seed !== 'string') {
throw new TypeError('seed string required')
}

// TODO? use unorm module until String.prototype.normalize gets better browser support
seed = seed.normalize('NFKD')// Normalization Form: Compatibility Decomposition
seed = seed.replace(/\s+/g, ' ') // Remove multiple spaces in a row
seed = seed.toLowerCase()
seed = seed.trim()
return seed
}

/**
Find the best matching word or words in the list.
@return {Array|boolean} 0 or more suggestions, true when perfect match
*/
function suggest (word = '', {maxSuggestions = 15, language = 'english'} = {}) {
word = word.trim().toLowerCase()
const nword = normalize(word)
const wordlist = validWordlist(language)

if (word === '') { return [] }

// Words that begin the same, also handles perfect match
let match = false
const matches = wordlist.reduce((arr, w) => {
if (w === word || match) {
match = true
return
}
if (w.indexOf(nword) === 0 && arr.length < 10) { arr.push(w) }

return arr
}, [])
if (match) {
return true
}

// Levenshtein distance
if (!/chinese/.test(language)) {
const levenstein = LevensteinDistance(wordlist)
const lwords = levenstein(nword, {threshold: 0.5, language})
lwords.forEach(w => { matches.push(w) })
}

if (language === 'english') {
// Vowels are almost useless
const nvword = novowels(nword)
if (nvword !== '') {
wordlist.reduce((arr, w) => {
const score = novowels(w).indexOf(nvword)
if (score !== -1) { arr.push([score, w]) }
return arr
}, [])
.sort((a, b) => Math.sign(a[0], b[0]))
.map(a => a[1])
.forEach(w => { matches.push(w) })
}
}

const dedupe = {}
const finalMatches = matches.filter(item =>
dedupe[item] ? false : dedupe[item] = true
)

// console.log('suggest finalMatches', word, finalMatches)
return finalMatches.slice(0, maxSuggestions)
}

/**
@arg {string} seed - single word or combination of words from the wordlist
@arg {string} [language = 'english'] - Language dictionary to test seed against
@return {boolean} true if seed contains no words or all valid words
@throws {Error} 'Missing wordlist for ${language}'
*/
function checkWords (seed = '', language = 'english') {
const words = seed.split(' ')
const wordlist = validWordlist(language)
let word
while ((word = words.pop()) != null) {
const idx = wordlist.findIndex(w => w === word)
if (idx === -1) {
return false
}
}
return true
}

// private follows

/**
@throws {Error} 'Missing wordlist for ${language}'
*/
function validWordlist (language) {
const wordlist = wordlists[language]
if (!wordlist) {
throw new Error(`Missing wordlist for language ${language}`)
}
return wordlist
}

const vowelRe = /[aeiou]/g
const novowels = word => word.replace(vowelRe, '')
68 changes: 68 additions & 0 deletions index.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/* eslint-env mocha */
const assert = require('assert')
const LevensteinDistance = require('./src/levenstein_distance')
const {suggest, wordlist, languages, checkWords, validWordlist, normalize} = require('.')

describe('Seed', () => {
it('Normalize', () => {
throws(() => normalize(), /^seed string required$/)
// Update README if these change:
assert.equal('double spaces', normalize('double spaces'), 'removes extra spaces')
assert.equal('lowercase', normalize('Lowercase'), 'lowercase')
assert.equal('trim', normalize(' trim '), 'trim')
})

it('Suggests', () => {
assert(suggest('quality') === true)
assert(suggest('ágil', {language: 'spanish'}) === true)
assert.equal(suggest('quality1')[0], 'quality')
assert(suggest('').length === 0)
assert(suggest('qua').length > 0)
assert(suggest('seeder').length > 0)
assert(suggest('aeiou').length === 0)
assert(suggest('qlty').length === 1)
LevensteinDistance.distance('', '')
})

it('Length', () => {
for (let lang of languages) {
assertLen(wordlist(lang))
}
})

it('Check Words', () => {
assert(checkWords('lazy dog', 'english'))
assert(!checkWords('lazy dogma', 'english'))
throws(() => validWordlist('pig_latin'), /^Missing wordlist for language pig_latin$/)
for (let lang of languages) {
assertNormalized(lang)
}
})
})

const assertNormalized = lang => {
for (let word of wordlist(lang)) {
assert(word === normalize(word), `word ${word} in wordlist ${lang} did not normalize`)
}
}
const assertLen = wordlist => {
assert.equal(2048, wordlist.length, `Expecting 2048 words, got ${wordlist.length}`)
}

/* istanbul ignore next */
function throws (fn, match) {
try {
fn()
assert(false, 'Expecting error')
} catch (error) {
if (!match.test(error.message)) {
error.message = `Error did not match ${match}\n${error.message}`
throw error
}
}
}
try {
throws(() => { throw '1' }, /2/)
} catch (err) {
// for code-coverage
}
25 changes: 25 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"name": "bip39-checker",
"description": "The BIP-0039 wordlist spell check module.",
"version": "1.0.0",
"main": "index.js",
"license": "MIT",
"scripts": {
"test": "mocha index.test.js",
"coverage": "istanbul cover _mocha -- -R spec index.test.js",
"coveralls": "npm run coverage && cat ./coverage/lcov.info | ./node_modules/.bin/coveralls"
},
"repository": {
"type": "git",
"url": "git://github.com/eosjs/bip39-checker.git"
},
"dependencies": {
"bip39": "^2.3.0"
},
"devDependencies": {
"coveralls": "^2.13.1",
"istanbul": "^0.4.5",
"mocha": "^3.4.1",
"standard": "^10.0.2"
}
}
42 changes: 42 additions & 0 deletions src/levenstein_distance.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@

module.exports = suggestDictionary

// Modified npm 'suggest-it' package to return an array (instead of single words)

function suggestDictionary (dict, opts) {
opts = opts || {}
var threshold = opts.threshold || 0.5
return function suggest (word) {
var length = word.length
return dict.reduce(function (result, dictEntry) {
var score = distance(dictEntry, word)
if (score / length < threshold) {
// console.log('suggestDictionary', word, (score / length).toFixed(3), dictEntry)
result.push([score, dictEntry])
}
return result
}, [])
.sort((a, b) => Math.sign(a[0], b[0]))
.map(a => a[1])
}
}

function distance (a, b) {
var table = [], diag = 0, left, top
if (a.length === 0 || b.length === 0) return Math.max(a.length, b.length)
for (var ii = 0, ilen = a.length + 1; ii !== ilen; ++ii) {
for (var jj = 0, jlen = b.length + 1; jj !== jlen; ++jj) {
if (ii === 0 || jj === 0) table[jj] = Math.max(ii, jj)
else {
diag += Number(a[ii - 1] !== b[jj - 1])
left = table[jj - 1] + 1
top = table[jj] + 1
table[jj] = Math.min(left, top, diag)
diag = top - 1
}
}
}
return table[b.length]
}

suggestDictionary.distance = distance
Loading

0 comments on commit 0a8a780

Please sign in to comment.