From 682d3f4d9391fd23021c729cca80562d322f91e4 Mon Sep 17 00:00:00 2001 From: Luis Rojas Date: Mon, 24 Jun 2019 09:44:25 -0600 Subject: [PATCH] CT-479 - Add EOS address methods to SDK --- modules/core/package.json | 1 + modules/core/src/v2/coinFactory.ts | 4 + modules/core/src/v2/coins/eos.ts | 220 +++++++++++++++++++++++++ modules/core/src/v2/coins/teos.ts | 17 ++ modules/core/test/v2/unit/coins/eos.ts | 98 +++++++++++ yarn.lock | 34 +++- 6 files changed, 370 insertions(+), 4 deletions(-) create mode 100644 modules/core/src/v2/coins/eos.ts create mode 100644 modules/core/src/v2/coins/teos.ts create mode 100644 modules/core/test/v2/unit/coins/eos.ts diff --git a/modules/core/package.json b/modules/core/package.json index 8371df18f3..ef4f5d344d 100644 --- a/modules/core/package.json +++ b/modules/core/package.json @@ -55,6 +55,7 @@ "debug": "^3.1.0", "ecurve": "^1.0.6", "eol": "^0.5.0", + "eosjs-ecc": "4.0.4", "lodash": "^4.17.11", "moment": "^2.20.1", "prova-lib": "^0.2.10", diff --git a/modules/core/src/v2/coinFactory.ts b/modules/core/src/v2/coinFactory.ts index d728fa4c0b..fdc6473881 100644 --- a/modules/core/src/v2/coinFactory.ts +++ b/modules/core/src/v2/coinFactory.ts @@ -9,6 +9,7 @@ import { Bsv } from './coins/bsv'; import { Btc } from './coins/btc'; import { Btg } from './coins/btg'; import { Dash } from './coins/dash'; +import { Eos } from './coins/eos'; import { Eth } from './coins/eth'; import { Ltc } from './coins/ltc'; import { Ofc } from './coins/ofc'; @@ -20,6 +21,7 @@ import { Tbsv } from './coins/tbsv'; import { Tbtc } from './coins/tbtc'; import { Tbtg } from './coins/tbtg'; import { Tdash } from './coins/tdash'; +import { Teos } from './coins/teos'; import { Teth } from './coins/teth'; import { Tltc } from './coins/tltc'; import { Trmg } from './coins/trmg'; @@ -104,6 +106,8 @@ GlobalCoinFactory.registerCoinConstructor('btg', Btg.createInstance); GlobalCoinFactory.registerCoinConstructor('tbtg', Tbtg.createInstance); GlobalCoinFactory.registerCoinConstructor('ltc', Ltc.createInstance); GlobalCoinFactory.registerCoinConstructor('tltc', Tltc.createInstance); +GlobalCoinFactory.registerCoinConstructor('eos', Eos.createInstance); +GlobalCoinFactory.registerCoinConstructor('teos', Teos.createInstance); GlobalCoinFactory.registerCoinConstructor('eth', Eth.createInstance); GlobalCoinFactory.registerCoinConstructor('teth', Teth.createInstance); GlobalCoinFactory.registerCoinConstructor('rmg', Rmg.createInstance); diff --git a/modules/core/src/v2/coins/eos.ts b/modules/core/src/v2/coins/eos.ts new file mode 100644 index 0000000000..dc7c2adfd6 --- /dev/null +++ b/modules/core/src/v2/coins/eos.ts @@ -0,0 +1,220 @@ +/** + * @prettier + */ +import { BaseCoin } from '../baseCoin'; +import { BigNumber } from 'bignumber.js'; +import * as crypto from 'crypto'; +import * as utxoLib from 'bitgo-utxo-lib'; +import * as url from 'url'; +import * as querystring from 'querystring'; +import * as _ from 'lodash'; +import { InvalidAddressError, UnexpectedAddressError } from '../../errors'; + +const EOS_ADDRESS_LENGTH = 12; + +interface KeyPair { + pub: string; + prv: string; +} + +interface AddressDetails { + address: string; + memoId: string; +} + +export class Eos extends BaseCoin { + static createInstance(bitgo: any): BaseCoin { + return new Eos(bitgo); + } + + getChain(): string { + return 'eos'; + } + + getFamily(): string { + return 'eos'; + } + + getFullName(): string { + return 'EOS'; + } + + getBaseFactor(): number { + return 1e4; + } + + /** + * Flag for sending value of 0 + * @returns {boolean} True if okay to send 0 value, false otherwise + */ + valuelessTransferAllowed(): boolean { + return true; + } + + /** + * Generate secp256k1 key pair + * + * @param seed - Seed from which the new keypair should be generated, otherwise a random seed is used + */ + generateKeyPair(seed?: Buffer): KeyPair { + if (!seed) { + // An extended private key has both a normal 256 bit private key and a 256 + // bit chain code, both of which must be random. 512 bits is therefore the + // maximum entropy and gives us maximum security against cracking. + seed = crypto.randomBytes(512 / 8); + } + const extendedKey = utxoLib.HDNode.fromSeedBuffer(seed); + const xpub = extendedKey.neutered().toBase58(); + return { + pub: xpub, + prv: extendedKey.toBase58(), + }; + } + + /** + * Return boolean indicating whether input is valid public key for the coin. + * + * @param pub - the pub to be checked + */ + isValidPub(pub: string): boolean { + try { + utxoLib.HDNode.fromBase58(pub); + return true; + } catch (e) { + return false; + } + } + + /** + * Return boolean indicating whether input is valid seed for the coin + * + * @param prv - the prv to be checked + */ + isValidPrv(prv: string): boolean { + try { + utxoLib.HDNode.fromBase58(prv); + return true; + } catch (e) { + return false; + } + } + + /** + * Evaluates whether a memo is valid + * + * @param memo - the memo to be checked + */ + isValidMemo(memo: string): boolean { + return memo && memo.length <= 256; + } + + /** + * Return boolean indicating whether a memo id is valid + * + * @param memoId - the memo id to be checked + */ + isValidMemoId(memoId: string): boolean { + if (!this.isValidMemo(memoId)) { + return false; + } + + let memoIdNumber; + try { + memoIdNumber = new BigNumber(memoId); + } catch (e) { + return false; + } + + return memoIdNumber.gte(0); + } + + /** + * Process address into address and memo id + * @param address - the address + */ + getAddressDetails(address: string): AddressDetails { + const destinationDetails = url.parse(address); + const queryDetails = querystring.parse(destinationDetails.query); + const destinationAddress = destinationDetails.pathname; + + // EOS addresses have to be "human readable", which means start with a letter, up to 12 characters and only a-z1-5., i.e.mtoda1.bitgo + // source: https://developers.eos.io/eosio-cpp/docs/naming-conventions + if (!/^[a-z][a-z1-5.]*$/.test(destinationAddress) || destinationAddress.length > EOS_ADDRESS_LENGTH) { + throw new InvalidAddressError(`invalid address: ${address}`); + } + + // address doesn't have a memo id + if (destinationDetails.pathname === address) { + return { + address: address, + memoId: null, + }; + } + + if (!queryDetails.memoId) { + // if there are more properties, the query details need to contain the memoId property + throw new InvalidAddressError(`invalid property in address: ${address}`); + } + + if (Array.isArray(queryDetails.memoId) && queryDetails.memoId.length !== 1) { + // valid addresses can only contain one memo id + throw new InvalidAddressError(`invalid address '${address}', must contain exactly one memoId`); + } + + const [memoId] = _.castArray(queryDetails.memoId); + if (!this.isValidMemoId(memoId)) { + throw new InvalidAddressError(`invalid address: '${address}', memoId is not valid`); + } + + return { + address: destinationAddress, + memoId, + }; + } + + /** + * Validate and return address with appended memo id + * + * @param address + * @param memoId + */ + normalizeAddress({ address, memoId }: AddressDetails): string { + if (this.isValidMemoId(memoId)) { + return `${address}?memoId=${memoId}`; + } + return address; + } + + /** + * Return boolean indicating whether input is valid public key for the coin + * + * @param address - the address to be checked + */ + isValidAddress(address: string): boolean { + try { + const addressDetails = this.getAddressDetails(address); + return address === this.normalizeAddress(addressDetails); + } catch (e) { + return false; + } + } + + /** + * Check if address is a valid EOS address, then verify it matches the root address. + * + * @param address - the address to verify + * @param rootAddress - the wallet's root address + */ + verifyAddress({ address, rootAddress }: { address: string; rootAddress: string }): void { + if (!this.isValidAddress(address)) { + throw new InvalidAddressError(`invalid address: ${address}`); + } + + const addressDetails = this.getAddressDetails(address); + const rootAddressDetails = this.getAddressDetails(rootAddress); + + if (addressDetails.address !== rootAddressDetails.address) { + throw new Error(`address validation failure: ${addressDetails.address} vs ${rootAddressDetails.address}`); + } + } +} diff --git a/modules/core/src/v2/coins/teos.ts b/modules/core/src/v2/coins/teos.ts new file mode 100644 index 0000000000..86ff7efb18 --- /dev/null +++ b/modules/core/src/v2/coins/teos.ts @@ -0,0 +1,17 @@ +import { BaseCoin } from '../baseCoin'; +import { Eos } from './eos'; + +export class Teos extends Eos { + + static createInstance(bitgo: any): BaseCoin { + return new Teos(bitgo); + } + + getChain() { + return 'teos'; + } + + getFullName() { + return 'Testnet EOS'; + } +} diff --git a/modules/core/test/v2/unit/coins/eos.ts b/modules/core/test/v2/unit/coins/eos.ts new file mode 100644 index 0000000000..e36ba0ac31 --- /dev/null +++ b/modules/core/test/v2/unit/coins/eos.ts @@ -0,0 +1,98 @@ +import * as should from 'should'; +import * as crypto from 'crypto'; + +const TestV2BitGo = require('../../../lib/test_bitgo'); + +describe('EOS:', function() { + let bitgo; + let basecoin; + + before(function() { + bitgo = new TestV2BitGo({ env: 'test' }); + bitgo.initializeTestVars(); + basecoin = bitgo.coin('teos'); + }); + + it('should get address details', function() { + let addressDetails = basecoin.getAddressDetails('i1skda3kso43'); + addressDetails.address.should.equal('i1skda3kso43'); + should.not.exist(addressDetails.memoId); + + addressDetails = basecoin.getAddressDetails('ks13k3hdui24?memoId=1'); + addressDetails.address.should.equal('ks13k3hdui24'); + addressDetails.memoId.should.equal('1'); + + (() => { basecoin.getAddressDetails('ks13k3hdui24?memoId=x'); }).should.throw(); + (() => { basecoin.getAddressDetails('ks13k3hdui24?memoId=1&memoId=2'); }).should.throw(); + }); + + it('should validate address', function() { + basecoin.isValidAddress('i1skda3kso43').should.equal(true); + basecoin.isValidAddress('ks13kdh245ls').should.equal(true); + basecoin.isValidAddress('ks13k3hdui24?memoId=1').should.equal(true); + basecoin.isValidAddress('ks13k3hdui24?memoId=x').should.equal(false); + }); + + it('verifyAddress should work', function() { + basecoin.verifyAddress({ + address: 'i1skda3kso43', + rootAddress: 'i1skda3kso43', + }); + basecoin.verifyAddress({ + address: 'ks13kdh245ls?memoId=1', + rootAddress: 'ks13kdh245ls', + }); + + (() => { + basecoin.verifyAddress({ + address: 'i1skda3kso43?memoId=243432', + rootAddress: 'ks13kdh245ls', + }); + }).should.throw(); + + (() => { + basecoin.verifyAddress({ + address: 'i1skda3kso43=x', + rootAddress: 'i1skda3kso43', + }); + }).should.throw(); + + (() => { + basecoin.verifyAddress({ + address: 'i1skda3kso43', + }); + }).should.throw(); + }); + + it('isValidMemoId should work', function() { + basecoin.isValidMemo('1').should.equal(true); + basecoin.isValidMemo('uno').should.equal(true); + const string257CharsLong = '4WMNlu0fFU8N94AwukfpfPPQn2Myo80JdmLNF5rgeKAab9XLD93KUQipcT6US0LRwWWIGbUt89fjmdwpg3CBklNi8QIeBI2i8UDJCEuQKYobR5m4ismm1RooTXUnw5OPjmfLuuajYV4e5cS1jpC6hez5X43PZ5SsGaHNYX2YYXY03ir54cWWx5QW5VCPKPKUzfq2UYK5fjAG2Fe3xCUOzqgoR6KaAiuOOnDSyhZygLJyaoJpOXZM9olblNtAW75Ed'; + basecoin.isValidMemo(string257CharsLong).should.equal(false); + }); + + it('should validate pub key', () => { + const { pub } = basecoin.keychains().create(); + basecoin.isValidPub(pub).should.equal(true); + }); + + describe('Keypairs:', () => { + it('should generate a keypair from random seed', function() { + const keyPair = basecoin.generateKeyPair(); + keyPair.should.have.property('pub'); + keyPair.should.have.property('prv'); + + basecoin.isValidPub(keyPair.pub).should.equal(true); + basecoin.isValidPrv(keyPair.prv).should.equal(true); + }); + + it('should generate a keypair from seed', function() { + const seed = Buffer.from('c3b09c24731be2851b641d9d5b3f60fa129695c24071768d15654bea207b7bb6', 'hex'); + const keyPair = basecoin.generateKeyPair(seed); + + keyPair.pub.should.equal('xpub661MyMwAqRbcF2SUqUMiqxWGwaVX6sH4okTtX8jxJ1A14wfL8W7jZEoNE537JqSESXFpTcXCZahPz7RKQLpAEGsVp233dc5CffLSecpU13X'); + keyPair.prv.should.equal('xprv9s21ZrQH143K2YN1jSpiUpZYPYf2hQZDSXYHikLLjfd2C9LBaxoV1SUtNnZGnXeyJ6uFWMbQTfjXqVfgNqRBw5yyaCtBK1AM8PF3XZtKjQp'); + }); + }); +}); + diff --git a/yarn.lock b/yarn.lock index 89d1eba69d..cadd06d01e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2071,7 +2071,7 @@ bs58@^3.1.0: dependencies: base-x "^1.1.0" -bs58@^4.0.0: +bs58@^4.0.0, bs58@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/bs58/-/bs58-4.0.1.tgz#be161e76c354f6f788ae4071f63f34e8c4f0a42a" integrity sha1-vhYedsNU9veIrkBx9j806MTwpCo= @@ -2187,6 +2187,13 @@ byte-size@^4.0.3: resolved "https://registry.yarnpkg.com/byte-size/-/byte-size-4.0.4.tgz#29d381709f41aae0d89c631f1c81aec88cd40b23" integrity sha512-82RPeneC6nqCdSwCX2hZUz3JPOvN5at/nTEw/CMf05Smu3Hrpo9Psb7LjN+k+XndNArG1EY8L4+BM3aTM4BCvw== +bytebuffer@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/bytebuffer/-/bytebuffer-5.0.1.tgz#582eea4b1a873b6d020a48d58df85f0bba6cfddd" + integrity sha1-WC7qSxqHO20CCkjVjfhfC7ps/d0= + dependencies: + long "~3" + bytes@3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.0.0.tgz#d32815404d689699f85a4ea4fa8755dd13a96048" @@ -2826,7 +2833,7 @@ create-ecdh@^4.0.0: bn.js "^4.1.0" elliptic "^6.0.0" -create-hash@^1.1.0, create-hash@^1.1.2, create-hash@^1.2.0: +create-hash@^1.1.0, create-hash@^1.1.2, create-hash@^1.1.3, create-hash@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/create-hash/-/create-hash-1.2.0.tgz#889078af11a63756bcfb59bd221996be3a9ef196" integrity sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg== @@ -2837,7 +2844,7 @@ create-hash@^1.1.0, create-hash@^1.1.2, create-hash@^1.2.0: ripemd160 "^2.0.1" sha.js "^2.4.0" -create-hmac@^1.1.0, create-hmac@^1.1.2, create-hmac@^1.1.3, create-hmac@^1.1.4, create-hmac@^1.1.7: +create-hmac@^1.1.0, create-hmac@^1.1.2, create-hmac@^1.1.3, create-hmac@^1.1.4, create-hmac@^1.1.6, create-hmac@^1.1.7: version "1.1.7" resolved "https://registry.yarnpkg.com/create-hmac/-/create-hmac-1.1.7.tgz#69170c78b3ab957147b2b8b04572e47ead2243ff" integrity sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg== @@ -3355,7 +3362,7 @@ ecc-jsbn@~0.1.1: jsbn "~0.1.0" safer-buffer "^2.1.0" -ecurve@^1.0.0, ecurve@^1.0.6, ecurve@~1.0.5: +ecurve@^1.0.0, ecurve@^1.0.5, ecurve@^1.0.6, ecurve@~1.0.5: version "1.0.6" resolved "https://registry.yarnpkg.com/ecurve/-/ecurve-1.0.6.tgz#dfdabbb7149f8d8b78816be5a7d5b83fcf6de797" integrity sha512-/BzEjNfiSuB7jIWKcS/z8FK9jNjmEWvUV2YZ4RLSmcDtP7Lq0m6FvDuSnJpBlDpGRpfRQeTLGLBI8H+kEv0r+w== @@ -3452,6 +3459,20 @@ eol@^0.5.0: resolved "https://registry.yarnpkg.com/eol/-/eol-0.5.1.tgz#a2f15f9be38ac160f27c4e394fde02a9731a410c" integrity sha1-ovFfm+OKwWDyfE45T94CqXMaQQw= +eosjs-ecc@4.0.4: + version "4.0.4" + resolved "https://registry.yarnpkg.com/eosjs-ecc/-/eosjs-ecc-4.0.4.tgz#431450f30a6f73088ff5d7ba1ebdfe967a5ca4ab" + integrity sha512-9wAYefts4TidHOu+eN9nAisZdWpUzlUimZrB63oP7+/s4xRNJEn2Vvep2ICRODpxpidbshM1L7WaSYW9oiV5gA== + dependencies: + bigi "^1.4.2" + browserify-aes "^1.0.6" + bs58 "^4.0.1" + bytebuffer "^5.0.1" + create-hash "^1.1.3" + create-hmac "^1.1.6" + ecurve "^1.0.5" + randombytes "^2.0.5" + err-code@^1.0.0: version "1.1.2" resolved "https://registry.yarnpkg.com/err-code/-/err-code-1.1.2.tgz#06e0116d3028f6aef4806849eb0ea6a748ae6960" @@ -5917,6 +5938,11 @@ long@^2.2.3: resolved "https://registry.yarnpkg.com/long/-/long-2.4.0.tgz#9fa180bb1d9500cdc29c4156766a1995e1f4524f" integrity sha1-n6GAux2VAM3CnEFWdmoZleH0Uk8= +long@~3: + version "3.2.0" + resolved "https://registry.yarnpkg.com/long/-/long-3.2.0.tgz#d821b7138ca1cb581c172990ef14db200b5c474b" + integrity sha1-2CG3E4yhy1gcFymQ7xTbIAtcR0s= + loose-envify@^1.1.0, loose-envify@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf"