diff --git a/src/adapters/index.js b/src/adapters/index.js index 5d434c6..7a6e0ce 100644 --- a/src/adapters/index.js +++ b/src/adapters/index.js @@ -17,6 +17,7 @@ import Nodemailer from './nodemailer.js' import JSONFiles from './json-files.js' import FullStackJWT from './fullstack-jwt.js' import config from '../../config/index.js' +import Wallet from './wallet.adapter.js' class Adapters { constructor (localConfig = {}) { @@ -29,6 +30,7 @@ class Adapters { this.jsonFiles = new JSONFiles() this.bchjs = new BCHJS() this.config = config + this.wallet = new Wallet(localConfig) // Get a valid JWT API key and instance bch-js. this.fullStackJwt = new FullStackJWT(config) @@ -36,14 +38,22 @@ class Adapters { async start () { try { + let apiToken if (this.config.getJwtAtStartup) { // Get a JWT token and instantiate bch-js with it. Then pass that instance // to all the rest of the apps controllers and adapters. - await this.fullStackJwt.getJWT() + apiToken = await this.fullStackJwt.getJWT() // Instantiate bch-js with the JWT token, and overwrite the placeholder for bch-js. this.bchjs = await this.fullStackJwt.instanceBchjs() } + // Create a default instance of minimal-slp-wallet without initializing it + // (without retrieving the wallets UTXOs). This instance will be overwritten + // if the operator has configured BCH payments. + console.log('\nCreating default startup wallet. This wallet may be overwritten.') + await this.wallet.instanceWalletWithoutInitialization({}, { apiToken }) + this.bchjs = this.wallet.bchWallet.bchjs + // Start the IPFS node. // Do not start these adapters if this is an e2e test. if (this.config.env !== 'test') { diff --git a/src/adapters/wallet.adapter.js b/src/adapters/wallet.adapter.js new file mode 100644 index 0000000..57d3fb2 --- /dev/null +++ b/src/adapters/wallet.adapter.js @@ -0,0 +1,129 @@ +/* + Wallet adapter. This library instantiates minimal-slp-wallet based on different + configuration settings. That single instance of minimal-slp-wallet is then + easily passed around the rest of this application. +*/ + +// Global npm libraries +import BchWallet from 'minimal-slp-wallet' + +// Local libraries +import config from '../../config/index.js' +import JsonFiles from './json-files.js' + +class Wallet { + constructor (localConfig = {}) { + // Encapsulate dependencies + this.config = config + this.BchWallet = BchWallet + this.jsonFiles = new JsonFiles() + + // Bind 'this' object to all subfunctions + this.instanceWalletWithoutInitialization = this.instanceWalletWithoutInitialization.bind(this) + } + + // This is used for initializing the wallet, without waiting to update the wallet + // UTXOs from the blockchain. + // This is useful when the wallet is simply needed to make calls to the blockchain, + // and there is no need to hydrate it with UTXO data. + async instanceWalletWithoutInitialization (walletData = {}, advancedConfig = {}) { + try { + // Use the apiToken from the config settings, if one is not passed-in at + // run-time. + if (!advancedConfig.apiToken) { + advancedConfig.apiToken = this.config.apiToken + } + + // If an authentication password is passed in the config, add it. + if (this.config.authPass) { + advancedConfig.authPass = this.config.authPass + } + + // Detect and configure different blockchain infrastructure settings. + if (this.config.useFullStackCash) { + advancedConfig.interface = 'rest-api' + advancedConfig.restURL = this.config.apiServer + } else { + // By default use the web3 Cash Stack (https://cashstack.info) + advancedConfig.interface = 'consumer-api' + advancedConfig.restURL = this.config.consumerUrl + } + + console.log('advancedConfig setting when creating wallet: ', advancedConfig) + + // Instantiate minimal-slp-wallet. + if (walletData.mnemonic) { + // Instance the wallet using the mnemonic passed in to this function. + console.log('Mnemonic provided to wallet library from calling function.') + this.bchWallet = await this._instanceWallet(walletData.mnemonic, advancedConfig) + } else if (this.config.mnemonic) { + // Otherwise use the mnemonic in the config setting or passed as an environment variable. + console.log('Mnemonic retrieved from config file or environment variable.') + this.bchWallet = await this._instanceWallet(this.config.mnemonic, advancedConfig) + } else { + // If no mnemonic is provided, then generate a new mnemonic to create the wallet. + console.log('New mnemonic generated.') + this.bchWallet = await this._instanceWallet(undefined, advancedConfig) + } + + // Wait for wallet to initialize. + await this.bchWallet.walletInfoPromise + console.log('BCH wallet initialized.') + console.log(`Wallet address: ${this.bchWallet.walletInfo.cashAddress}`) + console.log(`Wallet mnemonic: ${this.bchWallet.walletInfo.mnemonic}`) + + return this.bchWallet + } catch (err) { + console.error('Error in instanceWalletWithoutInitialization()') + throw err + } + } + + // This function is used for easier mocking of unit tests. + async _instanceWallet (mnemonic, config) { + const wallet = new this.BchWallet(mnemonic, config) + await wallet.walletInfoPromise + + console.log('_instanceWallet() wallet.bchjs.restURL: ', wallet.bchjs.restURL) + + return wallet + } + + // Open the wallet file, or create one if the file doesn't exist. + // Does not instance the wallet. The output of this function is expected to + // be passed to instanceWallet(). + async openWallet (advancedConfig = {}) { + try { + let walletData + + // Try to open the wallet.json file. + try { + // console.log('this.WALLET_FILE: ', this.WALLET_FILE) + walletData = await this.jsonFiles.readJSON(this.WALLET_FILE) + } catch (err) { + // Create a new wallet file if one does not already exist. + console.log('Wallet file not found. Creating new wallet.json file.') + + // Create a new wallet. + // No-Update flag creates wallet without making any network calls. + const walletInstance = await this.instanceWalletWithoutInitialization({}, advancedConfig) + + walletData = walletInstance.walletInfo + + // Add the nextAddress property + walletData.nextAddress = 1 + + // Write the wallet data to the JSON file. + await this.jsonFiles.writeJSON(walletData, this.WALLET_FILE) + } + + // console.log('walletData: ', walletData) + return walletData + } catch (err) { + console.error('Error in openWallet()') + throw err + } + } +} + +export default Wallet diff --git a/test/unit/adapters/test-wallet.json b/test/unit/adapters/test-wallet.json new file mode 100644 index 0000000..7ff3a31 --- /dev/null +++ b/test/unit/adapters/test-wallet.json @@ -0,0 +1,11 @@ +{ + "privateKey": "KwftZ1XUKMP9cLwTjXM3Axk5dnjy28nZ7b54PMushFEYs71tXQZu", + "publicKey": "038ac0965dc75b9b98a8e1fee01d8e4bcb23770ad00d8b41f76ef3c07519e76ded", + "mnemonic": "blue unfair eye isolate capital devote horse lumber thrive second text flame", + "cashAddress": "bitcoincash:qprt28ydgfgx8da26y35us57ur5pexwjns0uvyn3qj", + "address": "bitcoincash:qprt28ydgfgx8da26y35us57ur5pexwjns0uvyn3qj", + "legacyAddress": "17SsGH7mb4nQEVh7hwTPYbYbgmWTnyWf3y", + "hdPath": "m/44'/245'/0'/0/0", + "slpAddress": "simpleledger:qprt28ydgfgx8da26y35us57ur5pexwjnsr88lx37v", + "nextAddress": 1 +} \ No newline at end of file diff --git a/test/unit/adapters/wallet.adapter.unit.js b/test/unit/adapters/wallet.adapter.unit.js new file mode 100644 index 0000000..7f8551e --- /dev/null +++ b/test/unit/adapters/wallet.adapter.unit.js @@ -0,0 +1,198 @@ +/* + Unit tests for the Wallet Adapter library. +*/ + +// Global npm libraries +import { assert } from 'chai' +import sinon from 'sinon' +import BchWallet from 'minimal-slp-wallet' +import fs from 'fs' + +// Local libraries +import WalletAdapter from '../../../src/adapters/wallet.adapter.js' +import { MockBchWallet } from '../mocks/adapters/wallet.js' + +// Hack to get __dirname back. +// https://blog.logrocket.com/alternatives-dirname-node-js-es-modules/ +import * as url from 'url' +const __dirname = url.fileURLToPath(new URL('.', import.meta.url)) + +// Global constants +const testWalletFile = `${__dirname.toString()}/test-wallet.json` + +describe('#wallet', () => { + let uut + let sandbox + + before(() => { + // Delete the test file if it exists. + try { + deleteFile(testWalletFile) + } catch (err) { } + }) + + beforeEach(() => { + uut = new WalletAdapter() + sandbox = sinon.createSandbox() + }) + + afterEach(() => sandbox.restore()) + + after(() => { + // Delete the test file if it exists. + try { + deleteFile(testWalletFile) + } catch (err) { } + }) + + describe('#_instanceWallet', () => { + it('should create a wallet given a mnemonic', async () => { + const mnemonic = 'wagon tray learn flat erase laugh lonely rug check captain jacket morning' + const result = await uut._instanceWallet(mnemonic) + // console.log('result: ', result) + assert.equal(result.walletInfo.mnemonic, mnemonic) + }) + }) + + describe('#openWallet', () => { + it('should create a new wallet when wallet file does not exist', async () => { + // Mock dependencies + uut.BchWallet = MockBchWallet + // Ensure we open the test file, not the production wallet file. + uut.WALLET_FILE = testWalletFile + const result = await uut.openWallet() + // console.log('result: ', result) + assert.property(result, 'mnemonic') + assert.property(result, 'privateKey') + assert.property(result, 'publicKey') + assert.property(result, 'cashAddress') + assert.property(result, 'address') + assert.property(result, 'slpAddress') + assert.property(result, 'legacyAddress') + assert.property(result, 'hdPath') + }) + + it('should open existing wallet file', async () => { + // This test case uses the file created in the previous test case. + // Ensure we open the test file, not the production wallet file. + uut.WALLET_FILE = testWalletFile + const result = await uut.openWallet() + // console.log('result: ', result) + assert.property(result, 'mnemonic') + assert.property(result, 'privateKey') + assert.property(result, 'publicKey') + assert.property(result, 'cashAddress') + assert.property(result, 'address') + assert.property(result, 'slpAddress') + assert.property(result, 'legacyAddress') + assert.property(result, 'hdPath') + }) + + it('should catch and throw an error', async () => { + try { + // Force an error + uut.WALLET_FILE = '' + uut.BchWallet = () => { + } + await uut.openWallet() + // console.log('result: ', result) + assert.fail('Unexpected code path') + } catch (err) { + // console.log('err: ', err) + assert.include(err.message, 'this.BchWallet is not a constructor') + } + }) + }) + + describe('#instanceWalletWithoutInitialization', () => { + it('should create an instance of BchWallet', async () => { + // Create a mock wallet. + const mockWallet = new BchWallet() + await mockWallet.walletInfoPromise + sandbox.stub(mockWallet, 'initialize').resolves() + + // Mock dependencies + sandbox.stub(uut, '_instanceWallet').resolves(mockWallet) + uut.config.authPass = 'fake-auth-pass' + + // Ensure we open the test file, not the production wallet file. + uut.WALLET_FILE = testWalletFile + const walletData = await uut.openWallet() + + // console.log('walletData: ', walletData) + const result = await uut.instanceWalletWithoutInitialization(walletData) + // console.log('result: ', result) + + assert.property(result, 'walletInfoPromise') + assert.property(result, 'walletInfo') + }) + + it('should catch and throw an error', async () => { + try { + // Force an error + sandbox.stub(uut, '_instanceWallet').rejects(new Error('test error')) + + await uut.instanceWalletWithoutInitialization() + + assert.fail('Unexpected code path') + } catch (err) { + // console.log('err: ', err) + assert.include(err.message, 'test error') + } + }) + + it('should create an instance of BchWallet using web2 infra', async () => { + // Create a mock wallet. + const mockWallet = new BchWallet() + await mockWallet.walletInfoPromise + sandbox.stub(mockWallet, 'initialize').resolves() + // Mock dependencies + sandbox.stub(uut, '_instanceWallet').resolves(mockWallet) + // Ensure we open the test file, not the production wallet file. + uut.WALLET_FILE = testWalletFile + const walletData = await uut.openWallet() + // console.log('walletData: ', walletData) + // Force desired code path + uut.config.useFullStackCash = true + const result = await uut.instanceWalletWithoutInitialization(walletData) + // console.log('result: ', result) + assert.property(result, 'walletInfoPromise') + assert.property(result, 'walletInfo') + }) + + it('should generate wallet from mnemonic in config', async () => { + // Create a mock wallet. + const mockWallet = new BchWallet() + await mockWallet.walletInfoPromise + sandbox.stub(mockWallet, 'initialize').resolves() + + // Mock dependencies + sandbox.stub(uut, '_instanceWallet').resolves(mockWallet) + + // Ensure we open the test file, not the production wallet file. + uut.WALLET_FILE = testWalletFile + const walletData = await uut.openWallet() + // console.log('walletData: ', walletData) + + const originalConfig = uut.config.mnemonic + uut.config.mnemonic = walletData.mnemonic + + const result = await uut.instanceWalletWithoutInitialization({}) + // console.log('result: ', result) + + uut.config.mnemonic = originalConfig + + assert.property(result, 'walletInfoPromise') + assert.property(result, 'walletInfo') + }) + }) +}) + +const deleteFile = (filepath) => { + try { + // Delete state if exist + fs.unlinkSync(filepath) + } catch (err) { + // console.error('Error trying to delete file: ', err) + } +} diff --git a/test/unit/mocks/adapters/wallet.js b/test/unit/mocks/adapters/wallet.js new file mode 100644 index 0000000..09e8378 --- /dev/null +++ b/test/unit/mocks/adapters/wallet.js @@ -0,0 +1,149 @@ +import BCHJS from "@psf/bch-js"; + +const mockWallet = { + mnemonic: 'course abstract aerobic deer try switch turtle diet fence affair butter top', + privateKey: 'L5D2UAam8tvo3uii5kpgaGyjvVMimdrXu8nWGQSQjuuAix6ji1YQ', + publicKey: '0379433ffc401483ade310469953c1cba77c71af904f07c15bde330d7198b4d6dc', + cashAddress: 'bitcoincash:qzl0d3gcqeypv4cy7gh8rgdszxa9vvm2acv7fqtd00', + address: 'bitcoincash:qzl0d3gcqeypv4cy7gh8rgdszxa9vvm2acv7fqtd00', + slpAddress: 'simpleledger:qzl0d3gcqeypv4cy7gh8rgdszxa9vvm2acq9zm7d33', + legacyAddress: '1JQj1KcQL7GPKzc1D2PvdUSgw3MbDtrHzi', + hdPath: "m/44'/245'/0'/0/0", + nextAddress: 1 +}; + +class MockBchWallet { + constructor() { + this.walletInfoPromise = true; + this.walletInfo = mockWallet; + this.initialize = async () => {} + this.bchjs = new BCHJS(); + this.burnTokens = async () => { + return { success: true, txid: 'txid' }; + }; + this.sendTokens = async () => { + return 'fakeTxid'; + }; + this.getUtxos = async () => { }; + this.getBalance = async () => { }; + this.listTokens = async () => { }; + this.getTxData = async () => { + return [{ + tokenTicker: 'TROUT' + }]; + }; + this.optimize = async () => { }; + // Environment variable is used by wallet-balance.unit.js to force an error. + if (process.env.NO_UTXO) { + this.utxos = {}; + } + else { + this.utxos = { + utxoStore: { + address: 'bitcoincash:qqetvdnlt0p8g27dr44cx7h057kpzly9xse9huc97z', + bchUtxos: [ + { + height: 700685, + tx_hash: '1fc577caaff5626a8477162581e57bae1b19dc6aa6c10638013c2b1ba14dc654', + tx_pos: 0, + value: 1000, + txid: '1fc577caaff5626a8477162581e57bae1b19dc6aa6c10638013c2b1ba14dc654', + vout: 0, + isValid: false + }, + { + height: 700685, + tx_hash: '1fc577caaff5626a8477162581e57bae1b19dc6aa6c10638013c2b1ba14dc654', + tx_pos: 2, + value: 19406, + txid: '1fc577caaff5626a8477162581e57bae1b19dc6aa6c10638013c2b1ba14dc654', + vout: 2, + isValid: false + } + ], + nullUtxos: [], + slpUtxos: { + type1: { + mintBatons: [], + tokens: [ + { + 'height': 717331, + 'tx_hash': '74889580bb1a5f8c026aa2f55118ac9917df3332f7abae72a70343daa1c29621', + 'tx_pos': 1, + 'value': 546, + 'txid': '74889580bb1a5f8c026aa2f55118ac9917df3332f7abae72a70343daa1c29621', + 'vout': 1, + 'isSlp': true, + 'type': 'token', + 'qty': '10', + 'tokenId': '600ee24d0f208aebc2bdd2c4ee1b9acb6d57343561442e8676b5bbea311d5a0f', + 'address': 'bitcoincash:qqraj35x6l2qyqhjm5l7qlt7z2245ez8l5z3dwkeq5', + 'ticker': 'FLIPS', + 'name': 'FLIPS', + 'documentUri': '', + 'documentHash': '', + 'decimals': 1, + 'qtyStr': '1' + }, + { + 'height': 730597, + 'tx_hash': '52520faddfafc46b8f8c9548b097f3a3b82a5bf363b5095047b9c5f83247fe36', + 'tx_pos': 1, + 'value': 546, + 'txid': '52520faddfafc46b8f8c9548b097f3a3b82a5bf363b5095047b9c5f83247fe36', + 'vout': 1, + 'isSlp': true, + 'type': 'token', + 'qty': '34999991', + 'tokenId': '38e97c5d7d3585a2cbf3f9580c82ca33985f9cb0845d4dcce220cb709f9538b0', + 'address': 'bitcoincash:qqraj35x6l2qyqhjm5l7qlt7z2245ez8l5z3dwkeq5', + 'ticker': 'PSF', + 'name': 'Permissionless Software Foundation', + 'documentUri': 'psfoundation.cash', + 'documentHash': '', + 'decimals': 8, + 'qtyStr': '0.34999991' + }, + { + 'height': 730597, + 'tx_hash': '5dc7e7c91382aed1666a51212dfb74050261e12c3c4f62b6b1e57f42d6c51ee1', + 'tx_pos': 2, + 'value': 546, + 'txid': '5dc7e7c91382aed1666a51212dfb74050261e12c3c4f62b6b1e57f42d6c51ee1', + 'vout': 2, + 'isSlp': true, + 'type': 'token', + 'qty': '18898', + 'tokenId': 'a4fb5c2da1aa064e25018a43f9165040071d9e984ba190c222a7f59053af84b2', + 'address': 'bitcoincash:qqraj35x6l2qyqhjm5l7qlt7z2245ez8l5z3dwkeq5', + 'ticker': 'TROUT', + 'name': "Trout's test token", + 'documentUri': 'troutsblog.com', + 'documentHash': '', + 'decimals': 2, + 'qtyStr': '188.98' + } + ] + }, + nft: { + tokens: [] + }, + group: { + tokens: [], + mintBatons: [] + } + } + } + }; + } + } +} + +export { MockBchWallet }; + +export { mockWallet }; + +export default { + MockBchWallet, + mockWallet +};