diff --git a/package-lock.json b/package-lock.json index b059fb747..2e0b9df7c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1505,6 +1505,18 @@ "resolved": "packages/jellyfish-transaction", "link": true }, + "node_modules/@defichain/jellyfish-wallet": { + "resolved": "packages/jellyfish-wallet", + "link": true + }, + "node_modules/@defichain/jellyfish-wallet-mnemonic": { + "resolved": "packages/jellyfish-wallet-mnemonic", + "link": true + }, + "node_modules/@defichain/jellyfish-wallet-ocean": { + "resolved": "packages/jellyfish-wallet-ocean", + "link": true + }, "node_modules/@defichain/testcontainers": { "resolved": "packages/testcontainers", "link": true @@ -14185,6 +14197,44 @@ "file-uri-to-path": "1.0.0" } }, + "node_modules/bip32": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/bip32/-/bip32-2.0.6.tgz", + "integrity": "sha512-HpV5OMLLGTjSVblmrtYRfFFKuQB+GArM0+XP8HGWfJ5vxYBqo+DesvJwOdC2WJ3bCkZShGf0QIfoIpeomVzVdA==", + "dependencies": { + "@types/node": "10.12.18", + "bs58check": "^2.1.1", + "create-hash": "^1.2.0", + "create-hmac": "^1.1.7", + "tiny-secp256k1": "^1.1.3", + "typeforce": "^1.11.5", + "wif": "^2.0.6" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bip32/node_modules/@types/node": { + "version": "10.12.18", + "resolved": "https://registry.npmjs.org/@types/node/-/node-10.12.18.tgz", + "integrity": "sha512-fh+pAqt4xRzPfqA6eh3Z2y6fyZavRIumvjhaCL753+TVkGKGhpPeyrJG2JftD0T9q4GF00KjefsQ+PQNDdWQaQ==" + }, + "node_modules/bip39": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bip39/-/bip39-3.0.3.tgz", + "integrity": "sha512-P0dKrz4g0V0BjXfx7d9QNkJ/Txcz/k+hM9TnjqjUaXtuOfAvxXSw2rJw8DX0e3ZPwnK/IgDxoRqf0bvoVCqbMg==", + "dependencies": { + "@types/node": "11.11.6", + "create-hash": "^1.1.0", + "pbkdf2": "^3.0.9", + "randombytes": "^2.0.1" + } + }, + "node_modules/bip39/node_modules/@types/node": { + "version": "11.11.6", + "resolved": "https://registry.npmjs.org/@types/node/-/node-11.11.6.tgz", + "integrity": "sha512-Exw4yUWMBXM3X+8oqzJNRqZSwUAaS4+7NdvHqQuFi/d+synz++xmX3QIf+BFqneW8N31R8Ky+sikfZUXq07ggQ==" + }, "node_modules/bip66": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/bip66/-/bip66-1.1.5.tgz", @@ -23653,7 +23703,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/pbkdf2/-/pbkdf2-3.1.1.tgz", "integrity": "sha512-4Ejy1OPxi9f2tt1rRV7Go7zmfDQ+ZectEQz3VGUQhgq62HtIRPDyG/JtnwIxs6x3uNMwo2V7q1fMvKjb+Tnpqg==", - "dev": true, "dependencies": { "create-hash": "^1.1.2", "create-hmac": "^1.1.4", @@ -25201,7 +25250,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", - "dev": true, "dependencies": { "safe-buffer": "^5.1.0" } @@ -28085,6 +28133,11 @@ "is-typedarray": "^1.0.0" } }, + "node_modules/typeforce": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/typeforce/-/typeforce-1.18.0.tgz", + "integrity": "sha512-7uc1O8h1M1g0rArakJdf0uLRSSgFcYexrVoKo+bzJd32gd4gDy2L/Z+8/FjPnU9ydY3pEnVPtr9FyscYY60K1g==" + }, "node_modules/typescript": { "version": "4.2.3", "dev": true, @@ -29334,6 +29387,48 @@ "bignumber.js": "^9.0.1" } }, + "packages/jellyfish-wallet": { + "name": "@defichain/jellyfish-wallet", + "version": "0.0.0", + "license": "MIT", + "dependencies": { + "@defichain/jellyfish-crypto": "0.0.0", + "@defichain/jellyfish-transaction": "0.0.0" + }, + "devDependencies": { + "typescript": ">=4.2.0" + }, + "peerDependencies": { + "bignumber.js": "^9.0.1" + } + }, + "packages/jellyfish-wallet-mnemonic": { + "name": "@defichain/jellyfish-wallet-mnemonic", + "version": "0.0.0", + "license": "MIT", + "dependencies": { + "@defichain/jellyfish-transaction": "0.0.0", + "@defichain/jellyfish-wallet": "0.0.0", + "bip32": "^2.0.6", + "bip39": "^3.0.3" + }, + "devDependencies": { + "typescript": ">=4.2.0" + } + }, + "packages/jellyfish-wallet-ocean": { + "name": "@defichain/jellyfish-wallet-ocean", + "version": "0.0.0", + "license": "MIT", + "dependencies": { + "@defichain/jellyfish-crypto": "0.0.0", + "@defichain/jellyfish-network": "0.0.0", + "@defichain/jellyfish-wallet": "0.0.0" + }, + "devDependencies": { + "typescript": ">=4.2.0" + } + }, "packages/testcontainers": { "name": "@defichain/testcontainers", "version": "0.0.0", @@ -30560,6 +30655,33 @@ "typescript": ">=4.2.0" } }, + "@defichain/jellyfish-wallet": { + "version": "file:packages/jellyfish-wallet", + "requires": { + "@defichain/jellyfish-crypto": "0.0.0", + "@defichain/jellyfish-transaction": "0.0.0", + "typescript": ">=4.2.0" + } + }, + "@defichain/jellyfish-wallet-mnemonic": { + "version": "file:packages/jellyfish-wallet-mnemonic", + "requires": { + "@defichain/jellyfish-transaction": "0.0.0", + "@defichain/jellyfish-wallet": "0.0.0", + "bip32": "^2.0.6", + "bip39": "^3.0.3", + "typescript": ">=4.2.0" + } + }, + "@defichain/jellyfish-wallet-ocean": { + "version": "file:packages/jellyfish-wallet-ocean", + "requires": { + "@defichain/jellyfish-crypto": "0.0.0", + "@defichain/jellyfish-network": "0.0.0", + "@defichain/jellyfish-wallet": "0.0.0", + "typescript": ">=4.2.0" + } + }, "@defichain/testcontainers": { "version": "file:packages/testcontainers", "requires": { @@ -40558,6 +40680,45 @@ "file-uri-to-path": "1.0.0" } }, + "bip32": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/bip32/-/bip32-2.0.6.tgz", + "integrity": "sha512-HpV5OMLLGTjSVblmrtYRfFFKuQB+GArM0+XP8HGWfJ5vxYBqo+DesvJwOdC2WJ3bCkZShGf0QIfoIpeomVzVdA==", + "requires": { + "@types/node": "10.12.18", + "bs58check": "^2.1.1", + "create-hash": "^1.2.0", + "create-hmac": "^1.1.7", + "tiny-secp256k1": "^1.1.3", + "typeforce": "^1.11.5", + "wif": "^2.0.6" + }, + "dependencies": { + "@types/node": { + "version": "10.12.18", + "resolved": "https://registry.npmjs.org/@types/node/-/node-10.12.18.tgz", + "integrity": "sha512-fh+pAqt4xRzPfqA6eh3Z2y6fyZavRIumvjhaCL753+TVkGKGhpPeyrJG2JftD0T9q4GF00KjefsQ+PQNDdWQaQ==" + } + } + }, + "bip39": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bip39/-/bip39-3.0.3.tgz", + "integrity": "sha512-P0dKrz4g0V0BjXfx7d9QNkJ/Txcz/k+hM9TnjqjUaXtuOfAvxXSw2rJw8DX0e3ZPwnK/IgDxoRqf0bvoVCqbMg==", + "requires": { + "@types/node": "11.11.6", + "create-hash": "^1.1.0", + "pbkdf2": "^3.0.9", + "randombytes": "^2.0.1" + }, + "dependencies": { + "@types/node": { + "version": "11.11.6", + "resolved": "https://registry.npmjs.org/@types/node/-/node-11.11.6.tgz", + "integrity": "sha512-Exw4yUWMBXM3X+8oqzJNRqZSwUAaS4+7NdvHqQuFi/d+synz++xmX3QIf+BFqneW8N31R8Ky+sikfZUXq07ggQ==" + } + } + }, "bip66": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/bip66/-/bip66-1.1.5.tgz", @@ -47330,7 +47491,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/pbkdf2/-/pbkdf2-3.1.1.tgz", "integrity": "sha512-4Ejy1OPxi9f2tt1rRV7Go7zmfDQ+ZectEQz3VGUQhgq62HtIRPDyG/JtnwIxs6x3uNMwo2V7q1fMvKjb+Tnpqg==", - "dev": true, "requires": { "create-hash": "^1.1.2", "create-hmac": "^1.1.4", @@ -48537,7 +48697,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", - "dev": true, "requires": { "safe-buffer": "^5.1.0" } @@ -50560,6 +50719,11 @@ "is-typedarray": "^1.0.0" } }, + "typeforce": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/typeforce/-/typeforce-1.18.0.tgz", + "integrity": "sha512-7uc1O8h1M1g0rArakJdf0uLRSSgFcYexrVoKo+bzJd32gd4gDy2L/Z+8/FjPnU9ydY3pEnVPtr9FyscYY60K1g==" + }, "typescript": { "version": "4.2.3", "dev": true diff --git a/packages/jellyfish-wallet-mnemonic/README.md b/packages/jellyfish-wallet-mnemonic/README.md new file mode 100644 index 000000000..fc7bc8ed7 --- /dev/null +++ b/packages/jellyfish-wallet-mnemonic/README.md @@ -0,0 +1,12 @@ +[![npm](https://img.shields.io/npm/v/@defichain/jellyfish-wallet-mnemonic)](https://www.npmjs.com/package/@defichain/jellyfish-wallet-mnemonic/v/latest) +[![npm@next](https://img.shields.io/npm/v/@defichain/jellyfish-wallet-mnemonic/next)](https://www.npmjs.com/package/@defichain/jellyfish-wallet-mnemonic/v/next) + +# @defichain/jellyfish-wallet-mnemonic + +MnemonicHdNode implements the WalletHdNode from jellyfish-wallet; a CoinType-agnostic HD Wallet for noncustodial DeFi. + +Purpose [44'] / CoinType-agnostic [n] / Account [n] / Chain (ignored for now) [0] / Addresses [n] + +- BIP32 Hierarchical Deterministic Wallets +- BIP39 Mnemonic code for generating deterministic keys +- BIP44 Multi-Account Hierarchy for Deterministic Wallets diff --git a/packages/jellyfish-wallet-mnemonic/__tests__/mnemonic/bip32.test.ts b/packages/jellyfish-wallet-mnemonic/__tests__/mnemonic/bip32.test.ts new file mode 100644 index 000000000..c50dd493d --- /dev/null +++ b/packages/jellyfish-wallet-mnemonic/__tests__/mnemonic/bip32.test.ts @@ -0,0 +1,180 @@ +import { MnemonicHdNode, MnemonicHdNodeProvider, mnemonicToSeed, generateMnemonic } from '../../src' +import BigNumber from 'bignumber.js' +import { Transaction, Vout } from '@defichain/jellyfish-transaction' +import { OP_CODES } from '@defichain/jellyfish-transaction/dist/script' + +const regTestBip32Options = { + bip32: { + public: 0x043587cf, + private: 0x04358394 + }, + wif: 0xef +} + +const transaction: Transaction = { + version: 0x00000004, + lockTime: 0x00000000, + vin: [{ + index: 0, + script: { stack: [] }, + sequence: 4294967278, + txid: 'fff7f7881a8099afa6940d42d1e7f6362bec38171ea3edf433541db4e4ad969f' + }], + vout: [{ + script: { + stack: [ + OP_CODES.OP_0, + OP_CODES.OP_PUSHDATA(Buffer.from('1d0f172a0ecb48aee1be1f2687d2963ae33f71a1', 'hex'), 'little') + ] + }, + value: new BigNumber('5.98'), + dct_id: 0x00 + }] +} + +const prevout: Vout = { + script: { + stack: [ + OP_CODES.OP_0, + OP_CODES.OP_PUSHDATA(Buffer.from('1d0f172a0ecb48aee1be1f2687d2963ae33f71a1', 'hex'), 'little') + ] + }, + value: new BigNumber('6'), + dct_id: 0x00 +} + +describe('24 words: random', () => { + let provider: MnemonicHdNodeProvider + + beforeAll(() => { + const words = generateMnemonic(12) + const seed = mnemonicToSeed(words) + provider = MnemonicHdNodeProvider.fromSeed(seed, regTestBip32Options) + }) + + describe("44'/1129'/0'/0/0", () => { + let node: MnemonicHdNode + + beforeEach(() => { + node = provider.derive("44'/1129'/0'/0/0") + }) + + it('should drive pub key', async () => { + const derivedPubKey = await node.publicKey() + expect(derivedPubKey.length).toBe(33) + }) + + it('should drive priv key', async () => { + const derivedPrivKey = await node.privateKey() + expect(derivedPrivKey.length).toBe(32) + }) + + it('should sign and verify', async () => { + const hash = Buffer.from('e9071e75e25b8a1e298a72f0d2e9f4f95a0f5cdf86a533cda597eb402ed13b3a', 'hex') + + const signature = await node.sign(hash) + expect(signature.length).toBe(70) + + const valid = await node.verify(hash, signature) + expect(valid).toBe(true) + }) + + it('should sign tx', async () => { + const signed = await node.signTx(transaction, [prevout]) + + expect(signed.witness.length).toBe(1) + expect(signed.witness[0].scripts.length).toBe(2) + + expect(signed.witness[0].scripts[0].hex.length).toBe(142) + expect(signed.witness[0].scripts[1].hex.length).toBe(66) + }) + }) +}) + +describe('24 words: abandon x23 art', () => { + let provider: MnemonicHdNodeProvider + + beforeAll(() => { + const words = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art'.split(' ') + const seed = mnemonicToSeed(words) + provider = MnemonicHdNodeProvider.fromSeed(seed, regTestBip32Options) + }) + + describe("44'/1129'/0'/0/0", () => { + let node: MnemonicHdNode + const pubKey = '037cf033b3c773dae3ce704e85fabef1702b25ad897533fe65b5c3f85912adebc1' + + beforeEach(() => { + node = provider.derive("44'/1129'/0'/0/0") + }) + + it('should drive pub key', async () => { + const derivedPubKey = await node.publicKey() + expect(derivedPubKey.toString('hex')).toBe(pubKey) + }) + + it('should drive priv key', async () => { + const privKey = await node.privateKey() + expect(privKey.toString('hex')).toBe('3e1f9339b4685c35d590fd1a6801967a9f95dbedf3e5733efa6451dc771a2d18') + }) + + it('should sign and verify', async () => { + const hash = Buffer.from('e9071e75e25b8a1e298a72f0d2e9f4f95a0f5cdf86a533cda597eb402ed13b3a', 'hex') + + const signature = await node.sign(hash) + expect(signature.toString('hex')).toBe('3044022070454813f8ff8e7a13f2ef9be18c89a3768d846647559798c147cd2ae284d1b1022058584df9e77efd620c7657f8d63eb7a2cd8c5753e3d29bc50bcb4c8c5c95ce49') + + const valid = await node.verify(hash, signature) + expect(valid).toBe(true) + }) + + it('should sign tx', async () => { + const signed = await node.signTx(transaction, [prevout]) + + expect(signed.witness.length).toBe(1) + expect(signed.witness[0].scripts.length).toBe(2) + + expect(signed.witness[0].scripts[0].hex).toBe('3044022039c1dd0ccc95e188bd997f955221f37d97eb135d3060d79aa584f0d6361e4083022012481d8505adecea60cd02b4a2c381c0323a5ff0eb780bea1a9d11485c2d6e6f01') + expect(signed.witness[0].scripts[1].hex).toBe(pubKey) + }) + }) + + describe("44'/1129'/1'/0/0", () => { + let node: MnemonicHdNode + const pubKey = '03548dfc620bcd01f774ba24512b594040693898b49ca08ec4ea9fc99f319be34f' + + beforeEach(() => { + node = provider.derive("44'/1129'/1'/0/0") + }) + + it('should drive pub key', async () => { + const derivedPubKey = await node.publicKey() + expect(derivedPubKey.toString('hex')).toBe(pubKey) + }) + + it('should drive priv key', async () => { + const privKey = await node.privateKey() + expect(privKey.toString('hex')).toBe('be7b3f86469900fc9302cea6bcf3b05c165a6461f8a0e7796305c350fc1f7357') + }) + + it('should sign and verify', async () => { + const hash = Buffer.from('e9071e75e25b8a1e298a72f0d2e9f4f95a0f5cdf86a533cda597eb402ed13b3a', 'hex') + + const signature = await node.sign(hash) + expect(signature.toString('hex')).toBe('304402201866354d84fb7b576c3a3248adb55582aa9a1c61b8d27dc355c4d9d07aa16b480220311133b0a69ab54a63406b1fce001c91d8a65ef665016d9792850edbe34a7598') + + const valid = await node.verify(hash, signature) + expect(valid).toBe(true) + }) + + it('should sign tx', async () => { + const signed = await node.signTx(transaction, [prevout]) + + expect(signed.witness.length).toBe(1) + expect(signed.witness[0].scripts.length).toBe(2) + + expect(signed.witness[0].scripts[0].hex).toBe('30440220403d4733c626866ba4117cbf725cc7f6d547cc8bc012786345cb1e58a2693426022039597dd1c39c1a528b884b97a246dd24b6fc7a103ce29a15ef8402ca691b5b0901') + expect(signed.witness[0].scripts[1].hex).toBe(pubKey) + }) + }) +}) diff --git a/packages/jellyfish-wallet-mnemonic/__tests__/mnemonic/bip39.test.ts b/packages/jellyfish-wallet-mnemonic/__tests__/mnemonic/bip39.test.ts new file mode 100644 index 000000000..30498a209 --- /dev/null +++ b/packages/jellyfish-wallet-mnemonic/__tests__/mnemonic/bip39.test.ts @@ -0,0 +1,58 @@ +import { generateMnemonic, mnemonicToSeed, validateMnemonic } from '../../src' + +it('should generate 12-15-18-21-24 and validate', () => { + function shouldGenerateAndValidate (length: 12 | 15 | 18 | 21 | 24): void { + const words = generateMnemonic(length) + + expect(words.length).toBe(length) + expect(validateMnemonic(words)).toBe(true) + expect(mnemonicToSeed(words).length).toBe(64) + } + + shouldGenerateAndValidate(12) + shouldGenerateAndValidate(15) + shouldGenerateAndValidate(18) + shouldGenerateAndValidate(21) + shouldGenerateAndValidate(24) +}) + +it('should generate 24 words as default', () => { + const words = generateMnemonic() + + expect(words.length).toBe(24) + expect(validateMnemonic(words)).toBe(true) + expect(mnemonicToSeed(words).length).toBe(64) +}) + +it('should always generate a fixed mnemonic with the same rng', () => { + const words = generateMnemonic(24, (numOfBytes) => { + return Buffer.alloc(numOfBytes, 0) + }) + + expect(validateMnemonic(words)).toBe(true) + expect(mnemonicToSeed(words).toString('hex')).toBe( + '408b285c123836004f4b8842c89324c1f01382450c0d439af345ba7fc49acf705489c6fc77dbd4e3dc1dd8cc6bc9f043db8ada1e243c4a0eafb290d399480840' + ) + expect(words.join(' ')).toBe( + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art' + ) +}) + +it('should validate a fixed mnemonic', () => { + const fixtures = [ + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about', + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon agent', + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art', + 'legal winner thank year wave sausage worth useful legal winner thank yellow', + 'letter advice cage absurd amount doctor acoustic avoid letter advice cage above', + 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong', + 'void come effort suffer camp survey warrior heavy shoot primary clutch crush open amazing screen patrol group space point ten exist slush involve unfold' + ] + + for (const fixture of fixtures) { + expect(validateMnemonic(fixture)).toBe(true) + + const words = fixture.split(' ') + expect(validateMnemonic(words)).toBe(true) + } +}) diff --git a/packages/jellyfish-wallet-mnemonic/jest.config.js b/packages/jellyfish-wallet-mnemonic/jest.config.js new file mode 100644 index 000000000..11d9802ff --- /dev/null +++ b/packages/jellyfish-wallet-mnemonic/jest.config.js @@ -0,0 +1,12 @@ +module.exports = { + testEnvironment: 'node', + testMatch: [ + '**/__tests__/**/*.test.ts' + ], + transform: { + '^.+\\.ts$': 'ts-jest' + }, + verbose: true, + clearMocks: true, + testTimeout: 120000 +} diff --git a/packages/jellyfish-wallet-mnemonic/package.json b/packages/jellyfish-wallet-mnemonic/package.json new file mode 100644 index 000000000..0911fbb2f --- /dev/null +++ b/packages/jellyfish-wallet-mnemonic/package.json @@ -0,0 +1,51 @@ +{ + "private": false, + "name": "@defichain/jellyfish-wallet-mnemonic", + "version": "0.0.0", + "description": "A collection of TypeScript + JavaScript tools and libraries for DeFi Blockchain developers to build decentralized finance on Bitcoin", + "keywords": [ + "DeFiChain", + "DeFi", + "Blockchain", + "API", + "Bitcoin" + ], + "repository": "DeFiCh/jellyfish", + "bugs": "https://github.com/DeFiCh/jellyfish/issues", + "license": "MIT", + "publishConfig": { + "access": "public" + }, + "contributors": [ + { + "name": "DeFiChain Foundation", + "email": "engineering@defichain.com", + "url": "https://defichain.com/" + }, + { + "name": "DeFi Blockchain Contributors" + }, + { + "name": "DeFi Jellyfish Contributors" + } + ], + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "tsc", + "publish:next": "npm publish --tag next --access public", + "publish:latest": "npm publish --tag latest --access public" + }, + "dependencies": { + "@defichain/jellyfish-wallet": "0.0.0", + "@defichain/jellyfish-transaction": "0.0.0", + "bip32": "^2.0.6", + "bip39": "^3.0.3" + }, + "devDependencies": { + "typescript": ">=4.2.0" + } +} diff --git a/packages/jellyfish-wallet-mnemonic/src/index.ts b/packages/jellyfish-wallet-mnemonic/src/index.ts new file mode 100644 index 000000000..be0e3b7f2 --- /dev/null +++ b/packages/jellyfish-wallet-mnemonic/src/index.ts @@ -0,0 +1 @@ +export * from './mnemonic' diff --git a/packages/jellyfish-wallet-mnemonic/src/mnemonic.ts b/packages/jellyfish-wallet-mnemonic/src/mnemonic.ts new file mode 100644 index 000000000..cee977b26 --- /dev/null +++ b/packages/jellyfish-wallet-mnemonic/src/mnemonic.ts @@ -0,0 +1,176 @@ +import { WalletHdNode, WalletHdNodeProvider } from '@defichain/jellyfish-wallet' +import { DERSignature } from '@defichain/jellyfish-crypto' +import { + Transaction, + Vout, + TransactionSegWit, + TransactionSigner, + SignInputOption, SIGHASH +} from '@defichain/jellyfish-transaction' +import * as bip32 from 'bip32' +import * as bip39 from 'bip39' + +/** + * Bip32 Options, version bytes and WIF format. Unique to each chain. + * + * @see https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki#serialization-format + */ +export interface Bip32Options { + bip32: { + // base58Prefixes.EXT_PUBLIC_KEY + public: number + // base58Prefixes.EXT_SECRET_KEY + private: number + } + // base58Prefixes.SECRET_KEY + wif: number +} + +/** + * @param {string} mnemonic sentence to validate + * @return {boolean} validity + */ +export function validateMnemonic (mnemonic: string | string[]): boolean { + if (Array.isArray(mnemonic)) { + return bip39.validateMnemonic(mnemonic.join(' ')) + } + return bip39.validateMnemonic(mnemonic) +} + +/** + * Generate a random mnemonic code of length, uses crypto.randomBytes under the hood. + * Defaults to 256-bits of entropy. + * https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki + * + * | ENT | CS | ENT+CS | MS | + * +-------+----+--------+------+ + * | 128 | 4 | 132 | 12 | + * | 160 | 5 | 165 | 15 | + * | 192 | 6 | 198 | 18 | + * | 224 | 7 | 231 | 21 | + * | 256 | 8 | 264 | 24 | + * + * @param {number} length the sentence length of the mnemonic code + * @param {(number) => Buffer} rng random number generation, generate random num of bytes buffer + * @return {string[]} generated mnemonic word list, (COLD STORAGE) + */ +export function generateMnemonic (length: 12 | 15 | 18 | 21 | 24 = 24, rng?: (numOfBytes: number) => Buffer): string[] { + const entropy = length / 3 * 32 + const sentence = bip39.generateMnemonic(entropy, rng) + return sentence.split(' ') +} + +/** + * @param {string[]} mnemonic words, (COLD) + * @return {Buffer} HD seed, (HOT) but ideally should not be kept at rest + */ +export function mnemonicToSeed (mnemonic: string[]): Buffer { + return bip39.mnemonicToSeedSync(mnemonic.join(' ')) +} + +/** + * MnemonicHdNode implements the WalletHdNode from jellyfish-wallet; a CoinType-agnostic HD Wallet for noncustodial DeFi. + * Purpose [44'] / CoinType-agnostic [n] / Account [n] / Chain (ignored for now) [0] / Addresses [n] + * + * - BIP32 Hierarchical Deterministic Wallets + * - BIP39 Mnemonic code for generating deterministic keys + * - BIP44 Multi-Account Hierarchy for Deterministic Wallets + */ +export class MnemonicHdNode implements WalletHdNode { + private readonly root: bip32.BIP32Interface + private readonly path: string + + constructor (root: bip32.BIP32Interface, path: string) { + this.root = root + this.path = path + } + + /** + * @private derive current code BIP32Interface, internal + */ + private derive (): bip32.BIP32Interface { + return this.root.derivePath(this.path) + } + + /** + * @return Promise compressed public key + */ + async publicKey (): Promise { + return this.derive().publicKey + } + + /** + * @return Promise privateKey of the WalletHdNode, allowed to fail if neutered. + */ + async privateKey (): Promise { + const node = this.derive() + + if (node.privateKey != null) { + return node.privateKey + } + throw new Error('neutered hd node') + } + + /** + * Sign a transaction with all prevout belong to this HdNode with SIGHASH.ALL + * This implementation can only sign a P2WPKH, hence the implementing WalletAccount should only + * recognize P2WPKH addresses encoded in bech32 format. + * + * @param {Transaction} transaction to sign + * @param {Vout[]} prevouts of transaction to sign, ellipticPair will be mapped to current node + * @return TransactionSegWit signed transaction ready to broadcast + */ + async signTx (transaction: Transaction, prevouts: Vout[]): Promise { + const inputs: SignInputOption[] = prevouts.map(prevout => { + return { prevout: prevout, ellipticPair: this } + }) + return TransactionSigner.sign(transaction, inputs, { + sigHashType: SIGHASH.ALL + }) + } + + /** + * @param {Buffer} hash to sign + * @return {Buffer} signature in DER format, SIGHASHTYPE not included + */ + async sign (hash: Buffer): Promise { + const node = this.derive() + const signature = node.sign(hash, true) + return DERSignature.encode(signature) + } + + /** + * @param {Buffer} hash to verify with signature + * @param {Buffer} derSignature of the hash in encoded with DER, SIGHASHTYPE must not be included + * @return Promise validity of signature of the hash + */ + async verify (hash: Buffer, derSignature: Buffer): Promise { + const node = this.derive() + const signature = DERSignature.decode(derSignature) + return node.verify(hash, signature) + } +} + +/** + * Provider that derive MnemonicHdNode from root. Uses a lite on demand derivation. + */ +export class MnemonicHdNodeProvider implements WalletHdNodeProvider { + /** + * @param {Buffer} seed of the hd node + * @param {Bip32Options} options for chain agnostic generation of public/private keys + */ + static fromSeed (seed: Buffer, options: Bip32Options): MnemonicHdNodeProvider { + const node = bip32.fromSeed(seed, options) + return new MnemonicHdNodeProvider(node) + } + + private readonly root: bip32.BIP32Interface + + private constructor (root: bip32.BIP32Interface) { + this.root = root + } + + derive (path: string): MnemonicHdNode { + return new MnemonicHdNode(this.root, path) + } +} diff --git a/packages/jellyfish-wallet-mnemonic/tsconfig.json b/packages/jellyfish-wallet-mnemonic/tsconfig.json new file mode 100644 index 000000000..9670bde91 --- /dev/null +++ b/packages/jellyfish-wallet-mnemonic/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "include": [ + "./src/**/*" + ], + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + } +} diff --git a/packages/jellyfish-wallet-ocean/README.md b/packages/jellyfish-wallet-ocean/README.md new file mode 100644 index 000000000..d67985498 --- /dev/null +++ b/packages/jellyfish-wallet-ocean/README.md @@ -0,0 +1,7 @@ +[![npm](https://img.shields.io/npm/v/@defichain/jellyfish-wallet-ocean)](https://www.npmjs.com/package/@defichain/jellyfish-wallet-ocean/v/latest) +[![npm@next](https://img.shields.io/npm/v/@defichain/jellyfish-wallet-ocean/next)](https://www.npmjs.com/package/@defichain/jellyfish-wallet-ocean/v/next) + +# @defichain/jellyfish-wallet-ocean + +OceanWalletAccount implements the WalletAccount from jellyfish-wallet; a stateless account service for DeFi. +All stateless and stateful node service is provided by DeFi ocean service. diff --git a/packages/jellyfish-wallet-ocean/jest.config.js b/packages/jellyfish-wallet-ocean/jest.config.js new file mode 100644 index 000000000..11d9802ff --- /dev/null +++ b/packages/jellyfish-wallet-ocean/jest.config.js @@ -0,0 +1,12 @@ +module.exports = { + testEnvironment: 'node', + testMatch: [ + '**/__tests__/**/*.test.ts' + ], + transform: { + '^.+\\.ts$': 'ts-jest' + }, + verbose: true, + clearMocks: true, + testTimeout: 120000 +} diff --git a/packages/jellyfish-wallet-ocean/package.json b/packages/jellyfish-wallet-ocean/package.json new file mode 100644 index 000000000..fa59c52c2 --- /dev/null +++ b/packages/jellyfish-wallet-ocean/package.json @@ -0,0 +1,50 @@ +{ + "private": false, + "name": "@defichain/jellyfish-wallet-ocean", + "version": "0.0.0", + "description": "A collection of TypeScript + JavaScript tools and libraries for DeFi Blockchain developers to build decentralized finance on Bitcoin", + "keywords": [ + "DeFiChain", + "DeFi", + "Blockchain", + "API", + "Bitcoin" + ], + "repository": "DeFiCh/jellyfish", + "bugs": "https://github.com/DeFiCh/jellyfish/issues", + "license": "MIT", + "publishConfig": { + "access": "public" + }, + "contributors": [ + { + "name": "DeFiChain Foundation", + "email": "engineering@defichain.com", + "url": "https://defichain.com/" + }, + { + "name": "DeFi Blockchain Contributors" + }, + { + "name": "DeFi Jellyfish Contributors" + } + ], + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "tsc", + "publish:next": "npm publish --tag next --access public", + "publish:latest": "npm publish --tag latest --access public" + }, + "dependencies": { + "@defichain/jellyfish-crypto": "0.0.0", + "@defichain/jellyfish-network": "0.0.0", + "@defichain/jellyfish-wallet": "0.0.0" + }, + "devDependencies": { + "typescript": ">=4.2.0" + } +} diff --git a/packages/jellyfish-wallet-ocean/src/index.ts b/packages/jellyfish-wallet-ocean/src/index.ts new file mode 100644 index 000000000..3a99b4054 --- /dev/null +++ b/packages/jellyfish-wallet-ocean/src/index.ts @@ -0,0 +1,46 @@ +import { WalletAccount, WalletAccountProvider, WalletHdNode } from '@defichain/jellyfish-wallet' +import { Network } from '@defichain/jellyfish-network' +import { HRP, toBech32 } from '@defichain/jellyfish-crypto' + +/** + * jellyfish-api-ocean implementation of WalletAccount. + * All stateless and stateful node service is provided by an ocean instance. + */ +export class OceanWalletAccount implements WalletAccount { + private readonly hdNode: WalletHdNode + private readonly network: Network + + constructor (hdNode: WalletHdNode, network: Network) { + this.hdNode = hdNode + this.network = network + } + + /** + * @return {string} bech32 address + */ + async getAddress (): Promise { + const pubKey = await this.hdNode.publicKey() + return toBech32(pubKey, this.network.bech32.hrp as HRP) + } + + async isActive (): Promise { + throw new Error('to be implemented') + } +} + +/** + * Provide OceanWalletAccount with upstream to DeFi ocean services. + */ +export class OceanWalletAccountProvider implements WalletAccountProvider { + private readonly network: Network + + // TODO(fuxingloh): to implement after 'jellyfish-api-ocean' + + constructor (network: Network) { + this.network = network + } + + provide (hdNode: WalletHdNode): OceanWalletAccount { + return new OceanWalletAccount(hdNode, this.network) + } +} diff --git a/packages/jellyfish-wallet-ocean/tsconfig.json b/packages/jellyfish-wallet-ocean/tsconfig.json new file mode 100644 index 000000000..9670bde91 --- /dev/null +++ b/packages/jellyfish-wallet-ocean/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "include": [ + "./src/**/*" + ], + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + } +} diff --git a/packages/jellyfish-wallet/README.md b/packages/jellyfish-wallet/README.md new file mode 100644 index 000000000..e1cfff8c3 --- /dev/null +++ b/packages/jellyfish-wallet/README.md @@ -0,0 +1,33 @@ +[![npm](https://img.shields.io/npm/v/@defichain/jellyfish-wallet)](https://www.npmjs.com/package/@defichain/jellyfish-wallet/v/latest) +[![npm@next](https://img.shields.io/npm/v/@defichain/jellyfish-wallet/next)](https://www.npmjs.com/package/@defichain/jellyfish-wallet/v/next) + +# @defichain/jellyfish-wallet + +> If you want to use multiple/change address please use defid directly. +> This is created for better UX, your daily average users. + +Jellyfish wallet is a managed wallet, where account can get discovered from an HD seed. Accounts in jellyfish-wallet, +has only one address for simplicity. Accounts path are derived from seed with path: `44'/1129'/{ACCOUNT}/0/0`. It uses a +provider model where the node and account is agnostic and provided on demand to the managed wallet. + +Being a managed wallet design it uses must use conventional defaults and options must be kept to none. Address must stay +consistent hence `bech32` must be used and, etc. + +### Wallet Hd Node + +> `WalletHdNode` & `WalletHdNodeProvider` + +A bip32 path based hierarchical deterministic node, using a provider model where you can derive any HdNode. +`WalletHdNode` extends `EllipticPair` in the jellyfish-crypto package with the additional interface `signTx()` for +signing transaction. + +Due to the agnostic and promise based nature of WalletHdNode, it allows any implementation from hardware to network +based crypto operations. + +### Wallet Account + +> `WalletAccount` & `WalletAccountProvider` + +Account in `jellyfish-wallet` provides an interface for all features of DeFi blockchain. This pushes the implementation +design to WalletAccount implementor. This also allows for upstream agnostic implementation. It could be full node, super +node, or a networked API. diff --git a/packages/jellyfish-wallet/__tests__/account.mock.ts b/packages/jellyfish-wallet/__tests__/account.mock.ts new file mode 100644 index 000000000..21c515057 --- /dev/null +++ b/packages/jellyfish-wallet/__tests__/account.mock.ts @@ -0,0 +1,54 @@ +import { WalletAccount, WalletAccountProvider, WalletHdNode } from '../src' + +/** + * This is for testing only, please don't use this for anything else. + * Address is the pubkey + * + * The first 5 keys are: + * 028b147a7939b8e510defb56a1fccb80d2557d1fa7b5023a704ad4cfcfc651af2f + * 0337f21a6b2a6be26086ab0e7509fdb1316ef2a428b2571d1e20cb8886f6e2f9f1 + * 023bf78af6546c9d957d0fa0d3066f3d7fa735196662e2cce753305926712945d7 + * 02a9b7278229a9a4cb20a7852bf540559dc844faba558338d221cd0d26b795fdbb + * 02acf1d65943ce391c5c7a6319050c71ece26f5815f1a69445edd35b8d8dac13be + */ +export class TestAccount implements WalletAccount { + public hdNode: WalletHdNode + public provider: TestAccountProvider + + constructor (hdNode: WalletHdNode, provider: TestAccountProvider) { + this.hdNode = hdNode + this.provider = provider + } + + /** + * @return {string} address is the pubkey + */ + async getAddress (): Promise { + const pubKey = await this.hdNode.publicKey() + return pubKey.toString('hex') + } + + async isActive (): Promise { + const address = await this.getAddress() + return this.provider.mappings[address] !== undefined + } +} + +export interface MockTestAccountData { + utxo?: {} + tokens?: {} +} + +export class TestAccountProvider implements WalletAccountProvider { + public readonly mappings: { + [pubKey: string]: MockTestAccountData + } + + constructor (mappings: { [pubKey: string]: MockTestAccountData }) { + this.mappings = mappings + } + + provide (hdNode: WalletHdNode): TestAccount { + return new TestAccount(hdNode, this) + } +} diff --git a/packages/jellyfish-wallet/__tests__/node.mock.ts b/packages/jellyfish-wallet/__tests__/node.mock.ts new file mode 100644 index 000000000..357b11238 --- /dev/null +++ b/packages/jellyfish-wallet/__tests__/node.mock.ts @@ -0,0 +1,53 @@ +import { WalletHdNode, WalletHdNodeProvider } from '../src' +import { + SignInputOption, + Transaction, + TransactionSegWit, + TransactionSigner, + Vout +} from '@defichain/jellyfish-transaction' +import { EllipticPair, getEllipticPairFromPrivateKey } from '@defichain/jellyfish-crypto' + +/** + * This is for testing only, please don't use this for anything else. + */ +export class TestNode implements WalletHdNode { + public readonly path: string + public readonly ellipticPair: EllipticPair + + constructor (path: string) { + this.path = path + this.ellipticPair = getEllipticPairFromPrivateKey( + Buffer.alloc(32, path, 'ascii') + ) + } + + async publicKey (): Promise { + return await this.ellipticPair.publicKey() + } + + async privateKey (): Promise { + return await this.ellipticPair.privateKey() + } + + async sign (hash: Buffer): Promise { + return await this.ellipticPair.sign(hash) + } + + async verify (hash: Buffer, derSignature: Buffer): Promise { + return await this.ellipticPair.verify(hash, derSignature) + } + + async signTx (transaction: Transaction, prevouts: Vout[]): Promise { + const inputs: SignInputOption[] = prevouts.map(prevout => { + return { prevout: prevout, ellipticPair: this } + }) + return TransactionSigner.sign(transaction, inputs) + } +} + +export class TestNodeProvider implements WalletHdNodeProvider { + derive (path: string): TestNode { + return new TestNode(path) + } +} diff --git a/packages/jellyfish-wallet/__tests__/wallet.test.ts b/packages/jellyfish-wallet/__tests__/wallet.test.ts new file mode 100644 index 000000000..dac5bba79 --- /dev/null +++ b/packages/jellyfish-wallet/__tests__/wallet.test.ts @@ -0,0 +1,135 @@ +import { TestNodeProvider } from './node.mock' +import { MockTestAccountData, TestAccountProvider } from './account.mock' +import { JellyfishWallet } from '../src' + +const nodeProvider = new TestNodeProvider() +const mockData: MockTestAccountData = {} + +describe('discover accounts', () => { + it('should discover [] account when empty', async () => { + const accountProvider = new TestAccountProvider({}) + const wallet = new JellyfishWallet(nodeProvider, accountProvider) + + const accounts = await wallet.discover() + expect(accounts.length).toBe(0) + }) + + it('should discover [0] when [0] has activity', async () => { + const accountProvider = new TestAccountProvider({ + '028b147a7939b8e510defb56a1fccb80d2557d1fa7b5023a704ad4cfcfc651af2f': mockData + }) + const wallet = new JellyfishWallet(nodeProvider, accountProvider) + + const accounts = await wallet.discover() + expect(accounts.length).toBe(1) + }) + + it('should discover [] when [1] has activity', async () => { + const accountProvider = new TestAccountProvider({ + '0337f21a6b2a6be26086ab0e7509fdb1316ef2a428b2571d1e20cb8886f6e2f9f1': mockData + }) + const wallet = new JellyfishWallet(nodeProvider, accountProvider) + + const accounts = await wallet.discover() + expect(accounts.length).toBe(0) + }) + + it('should discover [0,1] when [0,1] has activity', async () => { + const accountProvider = new TestAccountProvider({ + '028b147a7939b8e510defb56a1fccb80d2557d1fa7b5023a704ad4cfcfc651af2f': mockData, + '0337f21a6b2a6be26086ab0e7509fdb1316ef2a428b2571d1e20cb8886f6e2f9f1': mockData + }) + const wallet = new JellyfishWallet(nodeProvider, accountProvider) + + const accounts = await wallet.discover() + expect(accounts.length).toBe(2) + }) + + it('should discover [0,1,2] when [0,1,2] has activity', async () => { + const accountProvider = new TestAccountProvider({ + '028b147a7939b8e510defb56a1fccb80d2557d1fa7b5023a704ad4cfcfc651af2f': mockData, + '0337f21a6b2a6be26086ab0e7509fdb1316ef2a428b2571d1e20cb8886f6e2f9f1': mockData, + '023bf78af6546c9d957d0fa0d3066f3d7fa735196662e2cce753305926712945d7': mockData + }) + const wallet = new JellyfishWallet(nodeProvider, accountProvider) + + const accounts = await wallet.discover() + expect(accounts.length).toBe(3) + }) + + it('should discover [0,1] when [0,1,3,4] has activity', async () => { + const accountProvider = new TestAccountProvider({ + '028b147a7939b8e510defb56a1fccb80d2557d1fa7b5023a704ad4cfcfc651af2f': mockData, + '0337f21a6b2a6be26086ab0e7509fdb1316ef2a428b2571d1e20cb8886f6e2f9f1': mockData, + '02a9b7278229a9a4cb20a7852bf540559dc844faba558338d221cd0d26b795fdbb': mockData, + '02acf1d65943ce391c5c7a6319050c71ece26f5815f1a69445edd35b8d8dac13be': mockData + }) + const wallet = new JellyfishWallet(nodeProvider, accountProvider) + + const accounts = await wallet.discover() + expect(accounts.length).toBe(2) + }) + + it('should discover [0] when [0,1] has activity as max account is set to 1', async () => { + const accountProvider = new TestAccountProvider({ + '028b147a7939b8e510defb56a1fccb80d2557d1fa7b5023a704ad4cfcfc651af2f': mockData, + '0337f21a6b2a6be26086ab0e7509fdb1316ef2a428b2571d1e20cb8886f6e2f9f1': mockData + }) + const wallet = new JellyfishWallet(nodeProvider, accountProvider) + + const accounts = await wallet.discover(1) + expect(accounts.length).toBe(1) + }) +}) + +describe('is usable', () => { + it('[0] should be usable regardless', async () => { + const accountProvider = new TestAccountProvider({}) + const wallet = new JellyfishWallet(nodeProvider, accountProvider) + expect(await wallet.isUsable(0)).toBe(true) + expect(await wallet.isUsable(1)).toBe(false) + }) + + it('[0,1] should be usable when [0] has activity', async () => { + const accountProvider = new TestAccountProvider({ + '028b147a7939b8e510defb56a1fccb80d2557d1fa7b5023a704ad4cfcfc651af2f': mockData + }) + const wallet = new JellyfishWallet(nodeProvider, accountProvider) + expect(await wallet.isUsable(0)).toBe(true) + expect(await wallet.isUsable(1)).toBe(true) + }) + + it('[0,1,2] should be usable when [0,1] has activity', async () => { + const accountProvider = new TestAccountProvider({ + '028b147a7939b8e510defb56a1fccb80d2557d1fa7b5023a704ad4cfcfc651af2f': mockData, + '0337f21a6b2a6be26086ab0e7509fdb1316ef2a428b2571d1e20cb8886f6e2f9f1': mockData + }) + const wallet = new JellyfishWallet(nodeProvider, accountProvider) + expect(await wallet.isUsable(0)).toBe(true) + expect(await wallet.isUsable(1)).toBe(true) + expect(await wallet.isUsable(2)).toBe(true) + }) + + it('[2] should be usable when [0,1] has no activity', async () => { + const accountProvider = new TestAccountProvider({}) + const wallet = new JellyfishWallet(nodeProvider, accountProvider) + expect(await wallet.isUsable(2)).toBe(false) + }) +}) + +describe('get accounts', () => { + const accountProvider = new TestAccountProvider({}) + const wallet = new JellyfishWallet(nodeProvider, accountProvider) + + it('should get account 0', async () => { + const account = wallet.get(0) + const address = await account.getAddress() + expect(address).toBe('028b147a7939b8e510defb56a1fccb80d2557d1fa7b5023a704ad4cfcfc651af2f') + }) + + it('should get account 1', async () => { + const account = wallet.get(1) + const address = await account.getAddress() + expect(address).toBe('0337f21a6b2a6be26086ab0e7509fdb1316ef2a428b2571d1e20cb8886f6e2f9f1') + }) +}) diff --git a/packages/jellyfish-wallet/__tests__/wallet_account.test.ts b/packages/jellyfish-wallet/__tests__/wallet_account.test.ts new file mode 100644 index 000000000..4693410a0 --- /dev/null +++ b/packages/jellyfish-wallet/__tests__/wallet_account.test.ts @@ -0,0 +1,44 @@ +import { TestAccountProvider } from './account.mock' +import { TestNodeProvider } from './node.mock' +import { WalletAccount } from '../src' + +describe('provide different account', () => { + const nodeProvider = new TestNodeProvider() + const accountProvider = new TestAccountProvider({}) + + it('should provide for 0', () => { + const node = nodeProvider.derive('0') + const account = accountProvider.provide(node) + expect(account).toBeTruthy() + }) + + it('should provide for 0/0', () => { + const node = nodeProvider.derive('0/0') + const account = accountProvider.provide(node) + expect(account).toBeTruthy() + }) +}) + +describe('WalletAccount: 0/0/0', () => { + const nodeProvider = new TestNodeProvider() + const accountProvider = new TestAccountProvider({ + '027f776dd7175558946aeda9e09f49e8690d811f302d232596cdcf2f87cc73f929': { + } + }) + let account: WalletAccount + + beforeAll(() => { + const node = nodeProvider.derive('0/0/0') + account = accountProvider.provide(node) + }) + + it('getAddress should be 027f...', async () => { + const address = await account.getAddress() + expect(address).toBe('027f776dd7175558946aeda9e09f49e8690d811f302d232596cdcf2f87cc73f929') + }) + + it('isActive should be active', async () => { + const active = await account.isActive() + expect(active).toBe(true) + }) +}) diff --git a/packages/jellyfish-wallet/__tests__/wallet_hd_node.test.ts b/packages/jellyfish-wallet/__tests__/wallet_hd_node.test.ts new file mode 100644 index 000000000..31174ae33 --- /dev/null +++ b/packages/jellyfish-wallet/__tests__/wallet_hd_node.test.ts @@ -0,0 +1,50 @@ +import { TestNodeProvider } from './node.mock' +import { WalletHdNode } from '../src' + +it('should derive', () => { + const provider = new TestNodeProvider() + const node = provider.derive("44'/1129'/0'/0/0") + + expect(node).toBeTruthy() +}) + +describe("WalletHdNode: 44'/1129'/0'", () => { + const provider = new TestNodeProvider() + let node: WalletHdNode + + beforeAll(() => { + node = provider.derive("44'/1129'/0'") + }) + + it('should derive public key', async () => { + const pubKey = await node.publicKey() + expect(pubKey.length).toBe(33) + expect(pubKey.toString('hex')).toBe('03331bde25ae763c3872effa39a7b104dfd072d43a956d1c40c0aff7ede5fb1578') + }) + + it('should derive private key', async () => { + const privKey = await node.privateKey() + expect(privKey.length).toBe(32) + expect(privKey.toString('hex')).toBe('3434272f31313239272f30273434272f31313239272f30273434272f31313239') + }) + + it('should derive sign and verify', async () => { + const hash = Buffer.from('e9071e75e25b8a1e298a72f0d2e9f4f95a0f5cdf86a533cda597eb402ed13b3a', 'hex') + + const signature = await node.sign(hash) + expect(signature.toString('hex')).toBe('3044022015707827c1bdfabfdbabb8d82f35c2cbe7b8d2fc1388a692469fe04879e4938802202eaf2033fb1b2cd69b5039457c283b9c80f082a6732f5460b983eb6947ca21bd') + + const valid = await node.verify(hash, signature) + expect(valid).toBe(true) + }) + + it('should signed transaction and fail because invalid', async () => { + return await expect(async () => await node.signTx({ + version: 0, + vin: [], + vout: [], + lockTime: 0 + }, []) + ).rejects.toThrow('option.validate.version = true - trying to sign a txn 0 different from 4 is not supported') + }) +}) diff --git a/packages/jellyfish-wallet/jest.config.js b/packages/jellyfish-wallet/jest.config.js new file mode 100644 index 000000000..11d9802ff --- /dev/null +++ b/packages/jellyfish-wallet/jest.config.js @@ -0,0 +1,12 @@ +module.exports = { + testEnvironment: 'node', + testMatch: [ + '**/__tests__/**/*.test.ts' + ], + transform: { + '^.+\\.ts$': 'ts-jest' + }, + verbose: true, + clearMocks: true, + testTimeout: 120000 +} diff --git a/packages/jellyfish-wallet/package.json b/packages/jellyfish-wallet/package.json new file mode 100644 index 000000000..2e44a8736 --- /dev/null +++ b/packages/jellyfish-wallet/package.json @@ -0,0 +1,52 @@ +{ + "private": false, + "name": "@defichain/jellyfish-wallet", + "version": "0.0.0", + "description": "A collection of TypeScript + JavaScript tools and libraries for DeFi Blockchain developers to build decentralized finance on Bitcoin", + "keywords": [ + "DeFiChain", + "DeFi", + "Blockchain", + "API", + "Bitcoin" + ], + "repository": "DeFiCh/jellyfish", + "bugs": "https://github.com/DeFiCh/jellyfish/issues", + "license": "MIT", + "publishConfig": { + "access": "public" + }, + "contributors": [ + { + "name": "DeFiChain Foundation", + "email": "engineering@defichain.com", + "url": "https://defichain.com/" + }, + { + "name": "DeFi Blockchain Contributors" + }, + { + "name": "DeFi Jellyfish Contributors" + } + ], + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "tsc", + "publish:next": "npm publish --tag next --access public", + "publish:latest": "npm publish --tag latest --access public" + }, + "peerDependencies": { + "bignumber.js": "^9.0.1" + }, + "dependencies": { + "@defichain/jellyfish-crypto": "0.0.0", + "@defichain/jellyfish-transaction": "0.0.0" + }, + "devDependencies": { + "typescript": ">=4.2.0" + } +} diff --git a/packages/jellyfish-wallet/src/index.ts b/packages/jellyfish-wallet/src/index.ts new file mode 100644 index 000000000..f004b3aa2 --- /dev/null +++ b/packages/jellyfish-wallet/src/index.ts @@ -0,0 +1,3 @@ +export * from './wallet' +export * from './wallet_account' +export * from './wallet_hd_node' diff --git a/packages/jellyfish-wallet/src/wallet.ts b/packages/jellyfish-wallet/src/wallet.ts new file mode 100644 index 000000000..449c6b4f9 --- /dev/null +++ b/packages/jellyfish-wallet/src/wallet.ts @@ -0,0 +1,81 @@ +import { WalletAccount, WalletAccountProvider } from './wallet_account' +import { WalletHdNode, WalletHdNodeProvider } from './wallet_hd_node' + +/** + * DFI CoinType + */ +const COIN_TYPE: number = 1129 + +/** + * Jellyfish managed wallet. + * WalletHdNode instance is provided by WalletHdNodeProvider. + * WalletAccount instance for interfacing layer/upstream to service will be provided by WalletAccountProvider. + */ +export class JellyfishWallet { + private readonly nodeProvider: WalletHdNodeProvider + private readonly accountProvider: WalletAccountProvider + + /** + * @param {WalletHdNodeProvider} nodeProvider + * @param {WalletAccountProvider} accountProvider + */ + constructor (nodeProvider: WalletHdNodeProvider, accountProvider: WalletAccountProvider) { + this.nodeProvider = nodeProvider + this.accountProvider = accountProvider + } + + /** + * @param {number} account number to get + * @return Promise + */ + get (account: number): Account { + const path = `44'/${COIN_TYPE}'/${account}'/0/0` + const node = this.nodeProvider.derive(path) + return this.accountProvider.provide(node) + } + + /** + * Check if account in the wallet is usable. + * An usable account in wallet is a account that has no activity gap. + * Account 0 (default) is always valid. + * + * @example 0 is the default account and usable regardless + * @example 0,1 is usable when [0] has activity + * @example 0,1,2 is usable when [0,1] has activity + * @example 0,1,2,3 is usable when [0,1,2] has activity + * @example 0,1 is usable when [0,1,3] has activity (3 should never ever has transaction in the first place) + * + * @param {number} account number to check if valid + * @return Promise usability of account + */ + async isUsable (account: number): Promise { + if (account === 0) { + return true + } + + return await this.get(account - 1).isActive() + } + + /** + * Discover accounts that are active in managed JellyfishWallet. + * Account are considered active if the address contains any transaction activity. + * Default account, the first account will always get discovered regardless. + * + * @param {number} maxAccounts to discover + * @return WalletAccount[] discovered + */ + async discover (maxAccounts: number = 100): Promise { + const wallets: Account[] = [] + + for (let i = 0; i < maxAccounts; i++) { + const account = await this.get(i) + if (!await account.isActive()) { + break + } + + wallets[i] = account + } + + return wallets + } +} diff --git a/packages/jellyfish-wallet/src/wallet_account.ts b/packages/jellyfish-wallet/src/wallet_account.ts new file mode 100644 index 000000000..8a6bb0cb1 --- /dev/null +++ b/packages/jellyfish-wallet/src/wallet_account.ts @@ -0,0 +1,47 @@ +import { WalletHdNode } from './wallet_hd_node' + +/** + * An HDW is organized as several 'accounts'. + * Accounts are numbered, the default account ("") being number 0. + * Account are derived from root and the pubkey to be used is `44'/1129'/${account}'/0/0` + */ +export interface WalletAccount { + /** + * @return {Promise} whether the current account is active and has txn activity + */ + isActive: () => Promise + + /** + * @return {Promise} address of the wallet, for consistency sake only one address format should be used. + */ + getAddress: () => Promise + + // TODO(fuxingloh): stateless features + // - getUtxoBalance + // - listUtxo + // - listTransaction + // - listTokenBalance + // - getTokenBalance + + // TODO(fuxingloh): stateful features + // - sendUtxo + // - sendToken + // - addLiquidity + // - removeLiquidity + // - swap + // - fromUtxoToAccount + // - fromAccountToUtxo +} + +/** + * WalletAccount uses a provider model to allow jellyfish-wallet provide an account interface from any upstream + * provider. This keep WalletAccount implementation free from a single implementation constraint. + */ +export interface WalletAccountProvider { + + /** + * @param {WalletHdNode} hdNode of this wallet account + * @return WalletAccount + */ + provide: (hdNode: WalletHdNode) => T +} diff --git a/packages/jellyfish-wallet/src/wallet_hd_node.ts b/packages/jellyfish-wallet/src/wallet_hd_node.ts new file mode 100644 index 000000000..115079243 --- /dev/null +++ b/packages/jellyfish-wallet/src/wallet_hd_node.ts @@ -0,0 +1,35 @@ +import { Transaction, TransactionSegWit, Vout } from '@defichain/jellyfish-transaction' +import { EllipticPair } from '@defichain/jellyfish-crypto' + +/** + * WalletHdNode extends EllipticPair with additional interface to sign transaction. + * + * WalletHdNode uses a managed wallet design where defaults are decided by the implementation. + * Keeping the WalletHdNode to conventional defaults and options to none. + */ +export interface WalletHdNode extends EllipticPair { + + /** + * WalletHdNode transaction signing. + * + * @param {Transaction} transaction to sign + * @param {Vout[]} prevouts of the transaction to fund this transaction + * @return {TransactionSegWit} a signed transaction + */ + signTx: (transaction: Transaction, prevouts: Vout[]) => Promise + +} + +/** + * WalletHdNode uses the provider model to allow jellyfish-wallet to derive/provide a WalletHdNode from any sources. + * This design keep WalletHdNode derivation agnostic of any implementation, allowing a lite + * implementation where WalletHdNode are derived on demand. + */ +export interface WalletHdNodeProvider { + + /** + * @param {string} path to derive + * @return WalletHdNode + */ + derive: (path: string) => T +} diff --git a/packages/jellyfish-wallet/tsconfig.json b/packages/jellyfish-wallet/tsconfig.json new file mode 100644 index 000000000..9670bde91 --- /dev/null +++ b/packages/jellyfish-wallet/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "include": [ + "./src/**/*" + ], + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + } +}