Browse Source

Init

master
Ezerous 4 years ago
commit
907be539f7
  1. 8
      .babelrc
  2. 15
      .eslintrc.js
  3. 2
      .gitattributes
  4. 5
      .gitignore
  5. 10
      .npmignore
  6. 5
      .travis.yml
  7. 13
      CHANGELOG.md
  8. 120
      CONTRIBUTING.md
  9. 21
      LICENSE.txt
  10. 378
      README.md
  11. 42
      index.d.ts
  12. 13
      jest.config.js
  13. 59
      package.json
  14. BIN
      readme/drizzle-logomark.png
  15. BIN
      readme/drizzle-sync1.png
  16. BIN
      readme/drizzle-sync2.png
  17. BIN
      readme/drizzle-sync3.png
  18. BIN
      readme/drizzle-sync4.png
  19. 145
      src/Drizzle.js
  20. 159
      src/DrizzleContract.js
  21. 17
      src/accountBalances/accountBalancesActions.js
  22. 16
      src/accountBalances/accountBalancesReducer.js
  23. 34
      src/accountBalances/accountBalancesSaga.js
  24. 4
      src/accountBalances/constants.js
  25. 22
      src/accounts/accountsActions.js
  26. 17
      src/accounts/accountsReducer.js
  27. 75
      src/accounts/accountsSaga.js
  28. 5
      src/accounts/constants.js
  29. 13
      src/blocks/blocksReducer.js
  30. 191
      src/blocks/blocksSaga.js
  31. 7
      src/blocks/constants.js
  32. 33
      src/contractStateUtils.js
  33. 16
      src/contracts/constants.js
  34. 35
      src/contracts/contractsActions.js
  35. 128
      src/contracts/contractsReducer.js
  36. 282
      src/contracts/contractsSaga.js
  37. 20
      src/defaultOptions.js
  38. 51
      src/drizzle-middleware.js
  39. 3
      src/drizzleStatus/constants.js
  40. 21
      src/drizzleStatus/drizzleStatusReducer.js
  41. 90
      src/drizzleStatus/drizzleStatusSaga.js
  42. 69
      src/generateStore.js
  43. 51
      src/index.js
  44. 8
      src/mergeOptions.js
  45. 20
      src/reducer.js
  46. 13
      src/rootSaga.js
  47. 6
      src/transactions/constants.js
  48. 25
      src/transactions/transactionStackReducer.js
  49. 56
      src/transactions/transactionsReducer.js
  50. 17
      src/web3/constants.js
  51. 59
      src/web3/web3Reducer.js
  52. 89
      src/web3/web3Saga.js
  53. 53
      test/accountBalances.test.js
  54. 38
      test/accounts.test.js
  55. 91
      test/blocks.test.js
  56. 122
      test/contractStateUtils.test.js
  57. 167
      test/drizzle/api.test.js
  58. 48
      test/drizzle/getOrCreateWeb3.test.js
  59. 184
      test/drizzle/middleware.test.js
  60. 75
      test/drizzle/options.test.js
  61. 45
      test/environments/ganache-environment.js
  62. 67
      test/generateStore.test.js
  63. 42
      test/utils/data/TestContract-abi.json
  64. 6
      test/utils/data/TestContract-byteCode.json
  65. 12
      test/utils/data/TestContract.sol
  66. 60
      test/utils/helpers.js
  67. 176
      test/web3.test.js
  68. 36
      types/Drizzle.d.ts
  69. 83
      types/IContract.d.ts
  70. 25
      types/contractStateUtils.d.ts
  71. 13
      types/generateStore.d.ts
  72. 3
      types/index.d.ts
  73. 30
      webpack/base.config.js
  74. 6
      webpack/pure.config.js
  75. 14
      webpack/release.config.js

8
.babelrc

@ -0,0 +1,8 @@
{
"presets": ["@babel/preset-env"],
"plugins": [
"@babel/plugin-transform-runtime",
"@babel/plugin-transform-arrow-functions",
"@babel/plugin-proposal-object-rest-spread"
]
}

15
.eslintrc.js

@ -0,0 +1,15 @@
module.exports = {
"extends": ["standard", "plugin:jest/recommended"],
"plugins": ["jest"],
"rules": {
"jest/prefer-to-have-length": "warn",
"space-before-function-paren": ["error", {
"anonymous": "ignore",
"asyncArrow": "always",
"named": "ignore"
}]
},
"env" : {
"jest/globals": true
}
}

2
.gitattributes

@ -0,0 +1,2 @@
# Set the default behavior, in case people don't have core.autocrlf set.
* text=auto eol=lf

5
.gitignore

@ -0,0 +1,5 @@
.DS_Store
.tern-project
dist
node_modules
yarn*

10
.npmignore

@ -0,0 +1,10 @@
.DS_Store
jest.config.js
.travis.yml
.eslintrc.js
.babelrc
node_modules
test
webpack
.github

5
.travis.yml

@ -0,0 +1,5 @@
language: node_js
node_js:
- "11.10.1"
- "--lts"
cache: npm

13
CHANGELOG.md

@ -0,0 +1,13 @@
# Change Log
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## 1.5.1 (2019-10-29)
### Bug Fixes
* handle customProvider option correctly ([be2488f](https://github.com/trufflesuite/drizzle/commit/be2488f))
* readme.md ([d0da8b5](https://github.com/trufflesuite/drizzle/commit/d0da8b5))
* readme.md ([ac187f5](https://github.com/trufflesuite/drizzle/commit/ac187f5))

120
CONTRIBUTING.md

@ -0,0 +1,120 @@
Contributing to Drizzle
=======================
_Thanks for taking the time to help out and improve Drizzle! :tada:_
The following is a set of guidelines for Drizzle contributions and may change over time. Feel free to suggest improvements to this document in a pull request!
Contents
--------
[How Can I Contribute?](#how-can-i-contribute)
[Development](#development)
- [Overview](#overview)
- [Development Requirements](#development-requirements)
- [Getting Started](#getting-started)
- [Forks, Branches, and Pull Requests](#forks-branches-and-pull-requests)
- [Branching Model](#branching-model)
- [Working on a Branch](#working-on-a-branch)
[Additional Notes](#additional-notes)
How Can I Contribute?
---------------------
All contributions are welcome!
If you run into an issue, the first step is to reach out in [our community Gitter channel](https://gitter.im/ConsenSys/truffle), in case others have run into the problem or know how to help.
To report a problem or to suggest a new feature, [open a GitHub Issue](https://github.com/trufflesuite/drizzle/issues/new). This will help the Drizzle maintainers become aware of the problem and prioritize a fix.
For code contributions, for either new features or bug fixes, see [Development](#development).
If you're looking to make a substantial change, you may want to reach out first to give us a heads up.
Development
-----------
### Overview
Drizzle has two companion libraries ([`drizzle-react`](https://github.com/trufflesuite/drizzle-react) and [`drizzle-react-components`](https://github.com/trufflesuite/drizzle-react-components)), each with their own NPM packages.
The content of this guide applies to those companion libraries as well.
This repository ([trufflesuite/drizzle](https://github.com/trufflesuite/drizzle)) contains the core logic for storing and updating chaindata in a [Redux](https://github.com/reduxjs/redux) store.
### Development Requirements
In order to develop Drizzle, you'll need:
- [Git](https://git-scm.com/)
- [Node.js](https://nodejs.org)
### Getting Started
First clone this repository and install NPM dependencies:
$ git clone git@github.com:trufflesuite/drizzle.git
$ cd drizzle
$ npm install
$ npm test
If all is good, then run the build command :
$ npm run build
Your local Drizzle copy is contained in the `dist/` directory.
To use this in a project, use `npm link`:
$ cd dist
$ npm link // may require sudo
$ cd my-project-root
$ npm link drizzle
You're ready to use your local development version of Drizzle in your project.
### Forks, Branches, and Pull Requests
Community contributions to Drizzle require that you first fork each repository you wish to modify. After your modifications, push changes to your fork(s) and submit a pull request upstream to `trufflesuite`'s fork(s).
See GitHub documentation about [Collaborating with issues and pull requests](https://help.github.com/categories/collaborating-with-issues-and-pull-requests/) for more information.
> :exclamation: **Note:** _Drizzle development uses a long-lived `develop` branch for new (non-hotfix) development. Pull Requests should be opened against `develop` in all repositories._
#### Branching Model
Drizzle projects adhere to Gitflow, a Git workflow designed around a strict branching model to more easily track feature development vs. releases. [For more information on Gitflow, check out Atlassian's helpful guide](https://www.atlassian.com/git/tutorials/comparing-workflows/gitflow-workflow).
We can separate our branches into long-lived and purposeful branches. We have two long-lived branches:
- **`master`**, checkout for hotfix development; in sync with the latest `release` (synced after the release has gone out publicly).
- **`develop`**, checkout for feature development; latest unstable releases and work targeting the next major or minor release.
All development is done on branches with a `prefix/title` style naming convention. These are later merged into `develop` and finally a `release` branch before final release. These are the two prefixes we use:
- **`feature/`**, for new feature development; later merged with `develop` and `release`.
- **`fix/`**, for hotfix development; later merged with `master` and `develop`.
For example, a fix for a contract fetching error might look like: `fix/contract-fetching`.
#### Working on a Branch
Use a branch for your modifications, tracking it on your fork:
$ git checkout -b feature/sweet-feature // or "fix/" prefix if a hotfix
$ git push -u ChocolateLover feature/sweet-feature
Then, make changes and commit as usual.
Additional Notes
----------------
Join the chat in [our community Gitter channel](https://gitter.im/ConsenSys/truffle). If anything about this process is unclear, or for helpful feedback of any kind, we'd love to hear from you!
**Thanks again for all your support, encouragement, and effort! Drizzle would not be possible without contributors like you. :bow:**

21
LICENSE.txt

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2017 Joshua Quintal
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

378
README.md

@ -0,0 +1,378 @@
<img src="https://truffleframework.com/img/drizzle-logo-dark.svg" width="200">
# @drizzle/store
`npm install --save @drizzle/store`
Drizzle is a collection of front-end libraries that make writing dapp frontends easier and more predictable. The core of Drizzle is based on a Redux store, so you have access to the spectacular development tools around Redux. We take care of synchronizing your contract data, transaction data and more. Things stay fast because you declare what to keep in sync.
- Fully reactive contract data, including state, events and transactions.
- Declarative, so you're not wasting valuable cycles on uneeded data.
- Maintains access to underlying functionality. Web3 and your contract's methods are still there, untouched.
**Using React?**: The easiest way to get started with Drizzle is to use our [official `@drizzle/react-plugin` package](https://github.com/trufflesuite/drizzle/tree/master/packages/react-plugin) and (optionally) its companion [`@drizzle/react-components`](https://github.com/trufflesuite/drizzle/tree/master/packages/react-components).
## Getting Started
**Note**: Since Drizzle uses web3 1.0 and web sockets, be sure your development environment can support these.
1. Import the provider.
```javascript
import { Drizzle } from "@drizzle/store";
```
1. Create an `options` object and pass in the desired contract artifacts for Drizzle to instantiate. Other options are available, see [the Options section](#options) below.
```javascript
// Import contracts
import SimpleStorage from "./../build/contracts/SimpleStorage.json";
import TutorialToken from "./../build/contracts/TutorialToken.json";
const options = {
contracts: [SimpleStorage]
};
const drizzle = new Drizzle(options);
```
**Note**: The above assumes you have no existing redux store and will generate a new one. If you need something more sophisticated, consult our documentation [Using Drizzle's Redux Store](https://www.truffleframework.com/docs/drizzle/getting-started/using-drizzles-redux-store)
1. Get contract data. Calling the `cacheCall()` function on a contract will
execute the desired call and return a corresponding key so the data can be
retrieved from the store. When a new block is received, Drizzle will refresh
the store automatically _if_ any transactions in the block touched our
contract. For more information on how this works, see [How Data Stays
Fresh](#how-data-stays-fresh).
**Note:** We have to check that Drizzle is initialized before fetching data. A simple if statement such as below is fine for displaying a few pieces of data, but a better approach for larger dapps is to use a [loading component](https://github.com/trufflesuite/drizzle-react#recipe-loading-component). We've already built one for you in our [`@drizzle/react-components` library](https://github.com/trufflesuite/drizzle/tree/master/packages/react-components) as well.
```javascript
// Assuming we're observing the store for changes.
const state = drizzle.store.getState();
// If Drizzle is initialized (and therefore web3, accounts and contracts), continue.
if (state.drizzleStatus.initialized) {
// Declare this call to be cached and synchronized. We'll receive the store key for recall.
const dataKey = drizzle.contracts.SimpleStorage.methods.storedData.cacheCall();
// Use the dataKey to display data from the store.
return state.contracts.SimpleStorage.storedData[dataKey].value;
}
// If Drizzle isn't initialized, display some loading indication.
return "Loading...";
```
The contract instance has all of its standard web3 properties and methods. For example, you could still call as normal if you don't want something in the store:
```javascript
drizzle.contracts.SimpleStorage.methods.storedData().call();
```
1. Send a contract transaction. Calling the `cacheSend()` function on a contract will send the desired transaction and return a corresponding transaction hash so the status can be retrieved from the store. The last argument can optionally be an options object with the typical from, gas and gasPrice keys. Drizzle will update the transaction's state in the store (pending, success, error) and store the transaction receipt. For more information on how this works, see [How Data Stays Fresh](#how-data-stays-fresh).
**Note:** We have to check that Drizzle is initialized before fetching data. A simple if statement such as below is fine for displaying a few pieces of data, but a better approach for larger dapps is to use a [loading component](https://github.com/trufflesuite/drizzle/tree/master/packages/react-plugin#recipe-loading-component). We've already built one for you in our [`@drizzle/react-components` library](https://github.com/trufflesuite/drizzle/tree/master/packages/react-components) as well.
```javascript
// Assuming we're observing the store for changes.
const state = drizzle.store.getState();
// If Drizzle is initialized (and therefore web3, accounts and contracts), continue.
if (state.drizzleStatus.initialized) {
// Declare this transaction to be observed. We'll receive the stackId for reference.
const stackId = drizzle.contracts.SimpleStorage.methods.set.cacheSend(2, {
from: "0x3f..."
});
// Use the dataKey to display the transaction status.
if (state.transactionStack[stackId]) {
const txHash = state.transactionStack[stackId];
return state.transactions[txHash].status;
}
}
// If Drizzle isn't initialized, display some loading indication.
return "Loading...";
```
For more information on what's contained in transaction state, see [Drizzle State](#drizzle-state).
The contract instance has all of its standard web3 properties and methods. For example, you could still send as normal if you don't want a tx in the store:
```javascript
drizzle.contracts.SimpleStorage.methods.set(2).send({ from: "0x3f..." });
```
## Adding contracts dynamically
You can programmatically add contracts to Drizzle using either `drizzle.addContract()` or the `ADD_CONTRACT` action.
```javascript
const contractConfig = {
contractName: "0x066408929e8d5Ed161e9cAA1876b60e1fBB5DB75",
web3Contract: new web3.eth.Contract(/* ... */)
};
events = ["Mint"];
// Using an action
dispatch({ type: "ADD_CONTRACT", drizzle, contractConfig, events, web3 });
// Or using the Drizzle context object
this.context.drizzle.addContract(contractConfig, events);
```
## Deleting contracts
You can also delete contracts using either `drizzle.deleteContract()` or the `DELETE_CONTRACT` action.
```javascript
const contractName = "MyContract";
// Using an action
dispatch({ type: "DELETE_CONTRACT", drizzle, contractName });
// Or using the Drizzle context object
this.context.drizzle.deleteContract(contractName);
```
## Options
Drizzle has a number of configuration options so it only keeps track of exactly the data you need. Here's the full list of options along with their default values.
```javascript
{
contracts,
events: {
contractName: [
eventName,
{
eventName,
eventOptions
}
]
},
polls: {
accounts: interval,
blocks: interval
},
syncAlways,
web3: {
customProvider,
fallback: {
type
url
}
},
networkWhitelist
}
```
### `contracts` (array)
An array of either contract artifact files or Web3 contract objects. The objects have a `contractName` and `web3Contract` key.
i.e.
```
contracts: [
truffleArtifact, // A regular Truffle contract artifact
{
contractName: 'RegisteredContract',
web3Contract: new web3.eth.Contract(abi, address, {data: 'deployedBytecode' }) // An instance of a Web3 contract
}
]
```
### `events` (object)
An object consisting of contract names each containing an array of strings of the event names we'd like to listen for and sync with the store. Furthermore, event names may be replaced with an object containing both `eventName` and `eventOptions`, where `eventOptions` field corresponds to the [web3 Contract.events options](https://web3js.readthedocs.io/en/v1.2.0/web3-eth-contract.html#contract-events).
### `polls` (object)
An object containing key/value pairs denoting what is being polled and the interval (in ms). Possible polls are accounts and blocks. Accounts will poll for addresses and balances, blocks for new blocks. **Default**: `{ blocks: 3000 }`
### `syncAlways` (boolean)
If `true`, will replay all contract calls at every block. This is useful if your dapp uses a proxy contract which obfuscates your primary contract's address. By default Drizzle checks blocks to see if a transaction interacting with your contracts has occured. If so, it syncs that contract. **Default**: `false`
### `web3` (object)
Options regarding `web3` instantiation.
#### `customProvider` (object)
A valid web3 `provider` object. For example, you may wish to programatically create a Ganache provider for testing:
```
// Create a Ganache provider.
const testingProvider = Ganache.provider({
gasLimit: 7000000
})
const options = {
web3: {
customProvider: testingProvider
}
}
const drizzle = new Drizzle(options)
```
#### `fallback` (object)
An object consisting of the type and url of a fallback web3 provider. This is used if no injected provider, such as MetaMask or Mist, is detected.
`type` (string): The type of the fallback web3 provider. Currently the only possibility is `'ws'` (web socket). **Default**: `'ws'`
`url` (string): The full fallback web3 provider url. **Default**: `'ws://127.0.0.1:8545'`
### `networkWhitelist` (array)
An array of valid network ids for your project. Your smart contracts might only be deployed on particular networks, or you might want to restrict access on networks that are under development.
Allows all networks by default. Ganache bypasses this check and is never restricted.
```
// Allows the listed networks, plus Ganache
const options = {
networkWhitelist: [
1, // Mainnet
3, // Ropsten
4, // Rinkeby
5, // Goerli
42 // Kovan
]
}
```
## Drizzle State
```javascript
{
accounts,
accountBalances: {
address
}
contracts: {
contractName: {
initialized,
synced,
events,
callerFunctionName: {
argsHash: {
args,
value
}
}
}
},
currentBlock,
drizzleStatus: {
initialized
},
transactions: {
txHash: {
confirmations,
error,
receipt,
status
}
},
transactionStack,
web3: {
status
}
}
```
## `accounts` (array)
An array of account addresses from `web3`.
## `accountBalances` (object)
An object whose keys are account addresses and values are account balances (in Wei).
## `contracts` (object)
A series of contract state objects, indexed by the contract name as declared in its ABI.
### `contractName` (object)
`initialized` (boolean): `true` once contract is fully instantiated.
`synced` (boolean): `false` if contract state changes have occurred in a block and Drizzle is re-running its calls.
`events` (array): An array of event objects. Drizzle will only listen for the events we declared in options.
The contract's state also includes the state of each constant function called on the contract (`callerFunctionName`). The functions are indexed by name, and contain the outputs indexed by a hash of the arguments passed during the call (`argsHash`). If no arguments were passed, the hash is `0x0`. Drizzle reads from the store for you, so it should be unnecessary to touch this data cache manually.
`args` (array): Arguments passed to function call.
`value` (mixed): Value returned from function call.
### `currentBlock` (object)
An object the latest block as an object resulting from [`web3.getBlock()`](https://web3js.readthedocs.io/en/v1.2.0/web3-eth.html#getblock). This is updated once the block is received from a subscription or fetched via polling, but before any processing takes place.
## `drizzleStatus` (object)
An object containing information about the status of Drizzle.
`initialized` (boolean): `true` once:
- `web3` is found or instantiated
- Account addresses are stored in state
- All contracts are instantiated
### `initialized` (boolean)
`false` by default, becomes true once a `web3` instance is found and the accounts and contracts are fetched.
## `transactions` (object)
A series of transaction objects, indexed by transaction hash.
### `txHash` (object)
`confirmations` (array): After the initial receipt, further confirmation receipts (up to the 24th).
`error` (object): contains the returned error if any.
`receipt` (object): contains the first transaction receipt received from a transaction's `success` event.
`status` (string): `true` or `false` depending on transaction status
- `pending` when the transaction has broadcasted successfully, but is not yet mined
- `success` when a transaction receipt has been received (you may also wish to check for further confirmations)
- `error` if any errors occurred after broadcasting
For more in-depth information on the Ethereum transaction lifecycle, [check out this great blog post](https://medium.com/blockchannel/life-cycle-of-an-ethereum-transaction-e5c66bae0f6e).
## `transactionStack` (array)
In cases where a user cancels a transaction or the transaction is malformed and unable to be broadcasted, it won't receive a hash. To keep track of these cases, a temporary ID will be added to this array and replaced with the transaction hash once broadcasted. The `cacheSend()` method will return a `stackId`, which will allow you get the temporary ID to observe this process for your own transaction status indicator UI.
## `web3` (object)
`status` (string): `initializing`, `initialized` and `failed` are possible options. Useful for triggering warnings if `web3` fails to instantiate.
## How Data Stays Fresh
1. Once initialized, Drizzle instantiates `web3` and our desired contracts, then observes the chain by subscribing to new block headers.
![Drizzle Sync Step 1](https://github.com/trufflesuite/drizzle/blob/master/packages/store/readme/drizzle-sync1.png?raw=true)
1. Drizzle keeps track of contract calls so it knows what to synchronize.
![Drizzle Sync Step 2](https://github.com/trufflesuite/drizzle/blob/master/packages/store/readme/drizzle-sync2.png?raw=true)
1. When a new block header comes in, Drizzle checks that the block isn't pending, then goes through the transactions looking to see if any of them touched our contracts.
![Drizzle Sync Step 3](https://github.com/trufflesuite/drizzle/blob/master/packages/store/readme/drizzle-sync3.png?raw=true)
1. If they did, we replay the calls already in the store to refresh any potentially altered data. If they didn't we continue with the store data.
![Drizzle Sync Step 4](https://github.com/trufflesuite/drizzle/blob/master/packages/store/readme/drizzle-sync4.png?raw=true)
## License
[MIT](https://github.com/trufflesuite/drizzle/blob/master/packages/store/LICENSE.txt)

42
index.d.ts

@ -0,0 +1,42 @@
import {
Drizzle,
IDrizzleOptions,
generateStore,
IStoreConfig,
generateContractsInitialState,
} from './types';
type drizzleSagas = any[];
export {
Drizzle,
IDrizzleOptions,
generateStore,
IStoreConfig,
generateContractsInitialState,
drizzleSagas,
};
export enum EventActions {
EVENT_FIRED = 'EVENT_FIRED',
EVENT_CHANGED = 'EVENT_CHANGED',
EVENT_ERROR = 'EVENT_ERROR',
}
export namespace drizzleReducers {
type state = any | undefined | null;
export interface IAction {
[key: string]: any;
type: string;
}
export function accounts(state: state, action: IAction): any;
export function accountBalances(state: state, action: IAction): any;
export function contracts(state: state, action: IAction): any;
export function currentBlock(state: state, action: IAction): any;
export function drizzleStatus(state: state, action: IAction): any;
export function transactions(state: state, action: IAction): any;
export function transactionStack(state: state, action: IAction): any;
export function web3(state: state, action: IAction): any;
}

13
jest.config.js

@ -0,0 +1,13 @@
// For a detailed explanation regarding each configuration property, visit:
// https://jestjs.io/docs/en/configuration.html
module.exports = {
// Automatically clear mock calls and instances between every test
clearMocks: true,
// An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
modulePathIgnorePatterns: ['<rootDir>[/\\\\](dist|node_modules)[/\\\\]'],
// The test environment that will be used for testing
testEnvironment: '<rootDir>/test/environments/ganache-environment.js'
}

59
package.json

@ -0,0 +1,59 @@
{
"name": "@drizzle/store",
"version": "1.5.1",
"description": "A reactive data-store for web3 and smart contracts.",
"types": "./index.d.ts",
"main": "./dist/drizzle-store.js",
"react-native": "./src/index.js",
"repository": "https://github.com/trufflesuite/drizzle",
"scripts": {
"prepare": "webpack --config webpack/release.config.js",
"build:pure": "webpack --config webpack/pure.config.js",
"dev": "webpack --config webpack/release.config.js --watch",
"format": "prettier-standard 'src/**/*.js' 'test/**/*.js'",
"lint": "eslint 'src/**/*.js'",
"lint:fix": "eslint 'src/**/*.js' --fix",
"test": "jest --notify --silent",
"webpack-report": "webpack-bundle-analyzer --log-level debug --port 4200 dist/stats.json"
},
"keywords": [
"ethereum",
"redux",
"redux-saga"
],
"author": {
"name": "Josh Quintal",
"email": "josh@trufflesuite.com",
"url": "http://truffleframework.com/docs/drizzle/getting-started"
},
"license": "ISC",
"publishConfig": {
"access": "public"
},
"dependencies": {
"deepmerge": "^3.2.0",
"is-plain-object": "^2.0.4",
"redux": "^4.0.1",
"redux-saga": "^0.16.0",
"web3": "^1.2.1"
},
"devDependencies": {
"@babel/core": "^7.4.4",
"@babel/preset-env": "^7.4.4",
"eslint-config-standard": "^13.0.1",
"eslint-plugin-import": "^2.18.2",
"eslint-plugin-jest": "^22.14.1",
"eslint-plugin-node": "^9.1.0",
"eslint-plugin-promise": "^4.2.1",
"eslint-plugin-standard": "^4.0.0",
"eth-block-tracker-es5": "^2.3.2",
"ganache-core": "^2.5.5",
"jest": "^24.7.1",
"prettier-standard": "^9.1.1",
"redux-mock-store": "^1.5.3",
"standard": "^13.1.0",
"webpack": "^4.30.0",
"webpack-cli": "^3.3.1",
"webpack-merge": "^4.2.1"
}
}

BIN
readme/drizzle-logomark.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

BIN
readme/drizzle-sync1.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

BIN
readme/drizzle-sync2.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
readme/drizzle-sync3.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
readme/drizzle-sync4.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

145
src/Drizzle.js

@ -0,0 +1,145 @@
import { generateStore } from './generateStore'
import defaultOptions from './defaultOptions'
import merge from './mergeOptions'
import DrizzleContract from './DrizzleContract'
import * as ContractActions from './contracts/constants'
import * as DrizzleActions from './drizzleStatus/constants'
// Load as promise so that async Drizzle initialization can still resolve
var isEnvReadyPromise = new Promise((resolve, reject) => {
const hasNavigator = typeof navigator !== 'undefined'
const hasWindow = typeof window !== 'undefined'
const hasDocument = typeof document !== 'undefined'
if (hasNavigator && navigator.product === 'ReactNative') {
return resolve()
}
if (hasWindow) {
return window.addEventListener('load', resolve)
}
// resolve in any case if we missed the load event and the document is already loaded
if (hasDocument && document.readyState === `complete`) {
return resolve()
}
})
export const getOrCreateWeb3Contract = (store, contractConfig, web3) => {
if (contractConfig.web3Contract) {
return contractConfig.web3Contract
}
const state = store.getState()
const networkId = state.web3 && state.web3.networkId
const selectedAccount = state.accounts[0]
const { abi, networks, deployedBytecode } = contractConfig
return (
new web3.eth.Contract(abi, networks[networkId].address, {
from: selectedAccount,
data: deployedBytecode
})
)
}
class Drizzle {
constructor (givenOptions, store) {
const options = merge(defaultOptions, givenOptions)
// Variables
this.contracts = {}
this.contractList = []
this.options = options
this.store = store || this.generateStore(options)
this.web3 = {}
this.loadingContract = {}
// Wait for window load event in case of injected web3.
isEnvReadyPromise.then(() => {
// Begin Drizzle initialization.
this.store.dispatch({
type: DrizzleActions.DRIZZLE_INITIALIZING,
drizzle: this,
options
})
})
}
addContract (contractConfig, events = []) {
const web3Contract = getOrCreateWeb3Contract(
this.store,
contractConfig,
this.web3
)
const drizzleContract = new DrizzleContract(
web3Contract,
this.web3,
contractConfig.contractName,
this.store,
events
)
if (this.contracts[drizzleContract.contractName]) {
throw new Error(
`Contract already exists: ${drizzleContract.contractName}`
)
}
this.store.dispatch({ type: ContractActions.CONTRACT_INITIALIZING, contractConfig })
this.contracts[drizzleContract.contractName] = drizzleContract
this.contractList.push(drizzleContract)
this.store.dispatch({
type: ContractActions.CONTRACT_INITIALIZED,
name: contractConfig.contractName
})
}
deleteContract (contractName) {
// Deleting a contract means removing it from this instance's
// `contractList`, `contracts`, and `loadingContract`
if (!this.contracts[contractName]) {
throw new Error(`Contract does not exist: ${contractName}`)
}
this.contractList = this.contractList.filter(
contract => contract.contractName !== contractName
)
const { [contractName]: omittedContract, ...restContracts } = this.contracts
this.contracts = restContracts
const {
[contractName]: omittedLoading,
...restLoadingContract
} = this.loadingContract
this.loadingContract = restLoadingContract
this.store.dispatch({
type: ContractActions.DELETE_CONTRACT,
contractName
})
}
findContractByAddress (address) {
return this.contractList.find(contract => {
return contract.address.toLowerCase() === address.toLowerCase()
})
}
/*
* NOTE
* This strangeness is for backward compatibility with < v1.2.4
* Future versions will have generateStore's contents here
*/
generateStore (options) {
return generateStore(options)
}
}
export default Drizzle

159
src/DrizzleContract.js

@ -0,0 +1,159 @@
import * as ContractActions from './contracts/constants'
import * as TransactionsActions from './transactions/constants'
import { isGetterFunction, isSetterFunction } from './contractStateUtils'
class DrizzleContract {
constructor (
web3Contract,
web3,
name,
store,
events = [],
contractArtifact = {}
) {
this.abi = web3Contract.options.jsonInterface
this.address = web3Contract.options.address
this.web3 = web3
this.contractName = name
this.contractArtifact = contractArtifact
this.store = store
// Merge web3 contract instance into DrizzleContract instance.
Object.assign(this, web3Contract)
for (var i = 0; i < this.abi.length; i++) {
var item = this.abi[i]
if (isGetterFunction(item)) {
this.methods[item.name].cacheCall = this.cacheCallFunction(item.name, i)
}
if (isSetterFunction(item)) {
this.methods[item.name].cacheSend = this.cacheSendFunction(item.name, i)
}
}
// Register event listeners if any events.
if (events.length > 0) {
for (i = 0; i < events.length; i++) {
const event = events[i]
if (typeof event === 'object') {
store.dispatch({
type: ContractActions.LISTEN_FOR_EVENT,
contract: this,
eventName: event.eventName,
eventOptions: event.eventOptions
})
} else {
store.dispatch({
type: ContractActions.LISTEN_FOR_EVENT,
contract: this,
eventName: event
})
}
}
}
}
cacheCallFunction (fnName, fnIndex, fn) {
var contract = this
return function () {
// Collect args and hash to use as key, 0x0 if no args
var argsHash = '0x0'
var args = arguments
if (args.length > 0) {
argsHash = contract.generateArgsHash(args)
}
const contractName = contract.contractName
const functionState = contract.store.getState().contracts[contractName][
fnName
]
// If call result is in state and fresh, return value instead of calling
if (argsHash in functionState) {
if (contract.store.getState().contracts[contractName].synced === true) {
return argsHash
}
}
// Otherwise, call function and update store
contract.store.dispatch({
type: ContractActions.CALL_CONTRACT_FN,
contract,
fnName,
fnIndex,
args,
argsHash
})
// Return nothing because state is currently empty.
return argsHash
}
}
cacheSendFunction (fnName, fnIndex, fn) {
// NOTE: May not need fn index
var contract = this
return function () {
var args = arguments
// Generate temporary ID
const transactionStack = contract.store.getState().transactionStack
const stackId = transactionStack.length
const stackTempKey = `TEMP_${new Date().getTime()}`
// Add ID to "transactionStack" with temp value, will be overwritten on TX_BROADCASTED
contract.store.dispatch({ type: TransactionsActions.PUSH_TO_TXSTACK, stackTempKey })
// Dispatch tx to saga
// When txhash received, will be value of stack ID
contract.store.dispatch({
type: ContractActions.SEND_CONTRACT_TX,
contract,
fnName,
fnIndex,
args,
stackId,
stackTempKey
})
// return stack ID
return stackId
}
}
generateArgsHash (args) {
var web3 = this.web3
var hashString = ''
for (var i = 0; i < args.length; i++) {
if (typeof args[i] !== 'function') {
var argToHash = args[i]
// Stringify objects to allow hashing
if (typeof argToHash === 'object') {
argToHash = JSON.stringify(argToHash)
}
// Convert number to strong to allow hashing
if (typeof argToHash === 'number') {
argToHash = argToHash.toString()
}
// This check is in place for web3 v0.x
if ('utils' in web3) {
var hashPiece = web3.utils.sha3(argToHash)
} else {
var hashPiece = web3.sha3(argToHash)
}
hashString += hashPiece
}
}
return web3.utils.sha3(hashString)
}
}
export default DrizzleContract

17
src/accountBalances/accountBalancesActions.js

@ -0,0 +1,17 @@
const ACCOUNTS_FETCHING = 'ACCOUNTS_FETCHING'
export function accountsFetching (results) {
return {
type: ACCOUNTS_FETCHING,
payload: results
}
}
const ACCOUNTS_FETCHED = 'ACCOUNTS_FETCHED'
export function accountsFetched (results) {
return {
type: ACCOUNTS_FETCHED,
payload: results
}
}

16
src/accountBalances/accountBalancesReducer.js

@ -0,0 +1,16 @@
import * as AccountBalancesActions from './constants'
const initialState = {}
const accountBalancesReducer = (state = initialState, action) => {
if (action.type === AccountBalancesActions.ACCOUNT_BALANCE_FETCHED) {
return {
...state,
[action.account]: action.accountBalance
}
}
return state
}
export default accountBalancesReducer

34
src/accountBalances/accountBalancesSaga.js

@ -0,0 +1,34 @@
import { call, put, select, takeLatest } from 'redux-saga/effects'
import * as AccountBalancesActions from './constants'
export function * getAccountBalances (action) {
const accounts = yield select(getAccountsState)
const web3 = action.web3
if (!accounts) {
console.error('No accounts found while attempting to fetch balances!')
}
try {
for (var i in accounts) {
var account = accounts[i]
var accountBalance = yield call(web3.eth.getBalance, account)
yield put({ type: AccountBalancesActions.ACCOUNT_BALANCE_FETCHED, account, accountBalance })
}
} catch (error) {
yield put({ type: AccountBalancesActions.ACCOUNT_BALANCE_FAILED, error })
console.error('Error fetching account ' + account + ' balance:')
console.error(error)
}
yield put({ type: AccountBalancesActions.ACCOUNT_BALANCES_FETCHED })
}
export const getAccountsState = state => state.accounts
function * accountBalancesSaga () {
yield takeLatest(AccountBalancesActions.ACCOUNT_BALANCES_FETCHING, getAccountBalances)
}
export default accountBalancesSaga

4
src/accountBalances/constants.js

@ -0,0 +1,4 @@
export const ACCOUNT_BALANCE_FETCHED = 'ACCOUNT_BALANCE_FETCHED'
export const ACCOUNT_BALANCE_FAILED = 'ACCOUNT_BALANCE_FAILED'
export const ACCOUNT_BALANCES_FETCHED = 'ACCOUNT_BALANCES_FETCHED'
export const ACCOUNT_BALANCES_FETCHING = 'ACCOUNT_BALANCES_FETCHING'

22
src/accounts/accountsActions.js

@ -0,0 +1,22 @@
import * as AccountsActions from './constants'
export function accountsFetching (results) {
return {
type: AccountsActions.ACCOUNTS_FETCHING,
payload: results
}
}
export function accountsFetched (results) {
return {
type: AccountsActions.ACCOUNTS_FETCHED,
payload: results
}
}
export function accountsFailed (error) {
return {
type: AccountsActions.ACCOUNTS_FAILED,
payload: error
}
}

17
src/accounts/accountsReducer.js

@ -0,0 +1,17 @@
import * as AccountsActions from './constants'
const initialState = {}
const accountsReducer = (state = initialState, action) => {
if (action.type === AccountsActions.ACCOUNTS_FETCHING) {
return state
}
if (action.type === AccountsActions.ACCOUNTS_FETCHED) {
return Object.assign({}, state, action.accounts)
}
return state
}
export default accountsReducer

75
src/accounts/accountsSaga.js

@ -0,0 +1,75 @@
import { END, eventChannel } from 'redux-saga'
import { call, put, take, takeLatest } from 'redux-saga/effects'
import { getAccountBalances } from '../accountBalances/accountBalancesSaga'
import * as AccountsActions from './constants'
/*
* Fetch Accounts List
*/
export function * getAccounts (action) {
const web3 = action.web3
try {
const accounts = yield call(web3.eth.getAccounts)
if (!accounts) {
throw 'No accounts found!'
}
yield put({ type: AccountsActions.ACCOUNTS_FETCHED, accounts })
} catch (error) {
yield put({ type: AccountsActions.ACCOUNTS_FAILED, error })
console.error('Error fetching accounts:')
console.error(error)
}
}
/*
* Poll for Account Changes
*/
function * createAccountsPollChannel ({ interval, web3 }) {
return eventChannel(emit => {
const persistedWeb3 = web3
const accountsPoller = setInterval(() => {
emit({ type: AccountsActions.SYNCING_ACCOUNTS, persistedWeb3 })
}, interval) // options.polls.accounts
const unsubscribe = () => {
clearInterval(accountsPoller)
}
return unsubscribe
})
}
function * callCreateAccountsPollChannel ({ interval, web3 }) {
const accountsChannel = yield call(createAccountsPollChannel, {
interval,
web3
})
try {
while (true) {
var event = yield take(accountsChannel)
if (event.type === AccountsActions.SYNCING_ACCOUNTS) {
yield call(getAccounts, { web3: event.persistedWeb3 })
yield call(getAccountBalances, { web3: event.persistedWeb3 })
}
yield put(event)
}
} finally {
accountsChannel.close()
}
}
function * accountsSaga () {
yield takeLatest(AccountsActions.ACCOUNTS_FETCHING, getAccounts)
yield takeLatest(AccountsActions.ACCOUNTS_POLLING, callCreateAccountsPollChannel)
}
export default accountsSaga

5
src/accounts/constants.js

@ -0,0 +1,5 @@
export const ACCOUNTS_FETCHING = 'ACCOUNTS_FETCHING'
export const ACCOUNTS_FETCHED = 'ACCOUNTS_FETCHED'
export const ACCOUNTS_FAILED = 'ACCOUNTS_FAILED'
export const SYNCING_ACCOUNTS = 'SYNCING_ACCOUNTS'
export const ACCOUNTS_POLLING = 'ACCOUNTS_POLLING'

13
src/blocks/blocksReducer.js

@ -0,0 +1,13 @@
import * as BlocksActions from './constants'
const initialState = {}
const blocksReducer = (state = initialState, action) => {
if (action.type === BlocksActions.BLOCK_PROCESSING) {
return action.block
}
return state
}
export default blocksReducer

191
src/blocks/blocksSaga.js

@ -0,0 +1,191 @@
import { END, eventChannel } from 'redux-saga'
import { call, put, take, takeEvery, takeLatest, all } from 'redux-saga/effects'
import BlockTracker from 'eth-block-tracker-es5'
import * as BlocksActions from './constants'
import * as ContractActions from '../contracts/constants'
/*
* Listen for Blocks
*/
export function createBlockChannel ({ drizzle, web3, syncAlways }) {
return eventChannel(emit => {
const blockEvents = web3.eth
.subscribe('newBlockHeaders', (error, result) => {
if (error) {
emit({ type: BlocksActions.BLOCKS_FAILED, error })
console.error('Error in block header subscription:')
console.error(error)
emit(END)
}
})
.on('data', blockHeader => {
emit({ type: BlocksActions.BLOCK_RECEIVED, blockHeader, drizzle, web3, syncAlways })
})
.on('error', error => {
emit({ type: BlocksActions.BLOCKS_FAILED, error })
emit(END)
})
const unsubscribe = () => {
blockEvents.off()
}
return unsubscribe
})
}
function * callCreateBlockChannel ({ drizzle, web3, syncAlways }) {
const blockChannel = yield call(createBlockChannel, {
drizzle,
web3,
syncAlways
})
try {
while (true) {
var event = yield take(blockChannel)
yield put(event)
}
} finally {
blockChannel.close()
}
}
/*
* Poll for Blocks
*/
export function createBlockPollChannel ({
drizzle,
interval,
web3,
syncAlways
}) {
return eventChannel(emit => {
const blockTracker = new BlockTracker({
provider: web3.currentProvider,
pollingInterval: interval
})
blockTracker.on('block', block => {
emit({ type: BlocksActions.BLOCK_FOUND, block, drizzle, web3, syncAlways })
})
blockTracker.start().catch(error => {
emit({ type: BlocksActions.BLOCKS_FAILED, error })
emit(END)
})
const unsubscribe = () => {
blockTracker.stop().catch(_ => {
// BlockTracker assumes there is an outstanding event subscription.
// However for our tests we start and stop a BlockTracker in succession
// that triggers an error.
})
}
return unsubscribe
})
}
function * callCreateBlockPollChannel ({
drizzle,
interval,
web3,
syncAlways
}) {
const blockChannel = yield call(createBlockPollChannel, {
drizzle,
interval,
web3,
syncAlways
})
try {
while (true) {
var event = yield take(blockChannel)
yield put(event)
}
} finally {
blockChannel.close()
}
}
/*
* Process Blocks
*/
function * processBlockHeader ({ blockHeader, drizzle, web3, syncAlways }) {
const blockNumber = blockHeader.number
try {
const block = yield call(web3.eth.getBlock, blockNumber, true)
yield call(processBlock, { block, drizzle, web3, syncAlways })
} catch (error) {
console.error('Error in block processing:')
console.error(error)
yield put({ type: BlocksActions.BLOCK_FAILED, error })
}
}
function * processBlock ({ block, drizzle, web3, syncAlways }) {
try {
// Emit block for addition to store.
// Regardless of syncing success/failure, this is still the latest block.
yield put({ type: BlocksActions.BLOCK_PROCESSING, block })
if (syncAlways) {
yield all(
Object.keys(drizzle.contracts).map(key => {
return put({
type: ContractActions.CONTRACT_SYNCING,
contract: drizzle.contracts[key]
})
})
)
return
}
const txs = block.transactions
if (txs.length > 0) {
// Loop through txs looking for any contract address of interest
for (var i = 0; i < txs.length; i++) {
var from = txs[i].from || ''
var fromContract = drizzle.findContractByAddress(from.toLowerCase())
if (fromContract) {
yield put({ type: ContractActions.CONTRACT_SYNCING, contract: fromContract })
}
var to = txs[i].to || ''
var toContract = drizzle.findContractByAddress(to.toLowerCase())
if (toContract) {
yield put({ type: ContractActions.CONTRACT_SYNCING, contract: toContract })
}
}
}
} catch (error) {
console.error('Error in block processing:')
console.error(error)
yield put({ type: BlocksActions.BLOCK_FAILED, error })
}
}
function * blocksSaga () {
// Block Subscriptions
yield takeLatest(BlocksActions.BLOCKS_LISTENING, callCreateBlockChannel)
yield takeEvery(BlocksActions.BLOCK_RECEIVED, processBlockHeader)
// Block Polling
yield takeLatest(BlocksActions.BLOCKS_POLLING, callCreateBlockPollChannel)
yield takeEvery(BlocksActions.BLOCK_FOUND, processBlock)
}
export default blocksSaga

7
src/blocks/constants.js

@ -0,0 +1,7 @@
export const BLOCK_PROCESSING = 'BLOCK_PROCESSING'
export const BLOCKS_FAILED = 'BLOCKS_FAILED'
export const BLOCK_FAILED = 'BLOCK_FAILED'
export const BLOCK_RECEIVED = 'BLOCK_RECEIVED'
export const BLOCK_FOUND = 'BLOCK_FOUND'
export const BLOCKS_LISTENING = 'BLOCKS_LISTENING'
export const BLOCKS_POLLING = 'BLOCKS_POLLING'

33
src/contractStateUtils.js

@ -0,0 +1,33 @@
export const getAbi = contractEntry =>
contractEntry.web3Contract
? contractEntry.web3Contract.options.jsonInterface
: contractEntry.abi
export const isGetterFunction = (abiItem) => {
// must be func type, then either .constant for pre solc 0.6.0, or 'pure'/'view' for solc 0.6.0+
return abiItem.type === 'function' && (['pure', 'view'].includes(abiItem.stateMutability) || abiItem.constant === true)
}
export const isSetterFunction = (abiItem) => {
// must be func type, then either .constant is false for pre solc 0.6.0, or 'payable'/'nonpayable' for solc 0.6.0+
return abiItem.type === 'function' && (['payable', 'nonpayable'].includes(abiItem.stateMutability) || abiItem.constant === false)
}
export const generateContractInitialState = contractConfig => {
const constants = getAbi(contractConfig).filter(isGetterFunction)
const objectOfConstants = constants.reduce(
(acc, x) => ({ ...acc, [x.name]: {} }),
{}
)
return {
initialized: false,
synced: false,
...objectOfConstants
}
}
export const generateContractsInitialState = options =>
(options.contracts || []).reduce((state, contract) => {
state[contract.contractName] = generateContractInitialState(contract)
return state
}, {})

16
src/contracts/constants.js

@ -0,0 +1,16 @@
export const EVENT_FIRED = 'EVENT_FIRED'
export const EVENT_CHANGED = 'EVENT_CHANGED'
export const EVENT_ERROR = 'EVENT_ERROR'
export const LISTEN_FOR_EVENT = 'LISTEN_FOR_EVENT'
export const CONTRACT_INITIALIZING = 'CONTRACT_INITIALIZING'
export const CONTRACT_INITIALIZED = 'CONTRACT_INITIALIZED'
export const GOT_CONTRACT_VAR = 'GOT_CONTRACT_VAR'
export const DELETE_CONTRACT = 'DELETE_CONTRACT'
export const CONTRACT_SYNCING = 'CONTRACT_SYNCING'
export const CONTRACT_SYNCED = 'CONTRACT_SYNCED'
export const CONTRACT_SYNC_IND = 'CONTRACT_SYNC_IND'
export const ERROR_CONTRACT_VAR = 'ERROR_CONTRACT_VAR'
export const CALL_CONTRACT_FN = 'CALL_CONTRACT_FN'
export const SEND_CONTRACT_TX = 'SEND_CONTRACT_TX'
export const ADD_CONTRACT = 'ADD_CONTRACT'
export const ERROR_ADD_CONTRACT = 'ERROR_ADD_CONTRACT'

35
src/contracts/contractsActions.js

@ -0,0 +1,35 @@
import * as ContractActions from './constants'
const INITIALIZING_CONTRACT = 'INITIALIZING_CONTRACT'
export function initializingContract (results) {
return {
type: INITIALIZING_CONTRACT,
payload: results
}
}
const INITIALIZED_CONTRACT = 'INITIALIZED_CONTRACT'
export function initializedContract (results) {
return {
type: INITIALIZED_CONTRACT,
payload: results
}
}
const GETTING_CONTRACT_VAR = 'GETTING_CONTRACT_VAR'
export function gettingContractVar (results) {
return {
type: GETTING_CONTRACT_VAR,
payload: results
}
}
export function gotContractVar (results) {
return {
type: ContractActions.GOT_CONTRACT_VAR,
payload: results
}
}

128
src/contracts/contractsReducer.js

@ -0,0 +1,128 @@
import { generateContractInitialState } from '../contractStateUtils'
import * as ContractActions from './constants'
const initialState = {}
const contractsReducer = (state = initialState, action) => {
/*
* Contract Status
*/
if (action.type === ContractActions.CONTRACT_INITIALIZING) {
return {
...state,
[action.contractConfig.contractName]: generateContractInitialState(
action.contractConfig
)
}
}
if (action.type === ContractActions.CONTRACT_INITIALIZED) {
return {
...state,
[action.name]: {
...state[action.name],
initialized: true,
synced: true,
events: []
}
}
}
if (action.type === ContractActions.DELETE_CONTRACT) {
const { [action.contractName]: omitted, ...rest } = state
return rest
}
if (action.type === ContractActions.CONTRACT_SYNCING) {
const contractName = action.contract.contractName
return {
...state,
[contractName]: {
...state[contractName],
synced: false
}
}
}
if (action.type === ContractActions.CONTRACT_SYNCED) {
return {
...state,
[action.contractName]: {
...state[action.contractName],
synced: true
}
}
}
if (action.type === ContractActions.CONTRACT_SYNC_IND) {
return {
...state,
[action.contractName]: {
...state[action.contractName],
synced: false
}
}
}
/*
* Contract Functions
*/
if (action.type === ContractActions.GOT_CONTRACT_VAR) {
return {
...state,
[action.name]: {
...state[action.name],
[action.variable]: {
...state[action.name][action.variable],
[action.argsHash]: {
...state[action.name][action.variable][action.argsHash],
args: action.args,
fnIndex: action.fnIndex,
value: action.value,
error: null
}
}
}
}
}
if (action.type === ContractActions.ERROR_CONTRACT_VAR) {
return {
...state,
[action.name]: {
...state[action.name],
[action.variable]: {
...state[action.name][action.variable],
[action.argsHash]: {
...state[action.name][action.variable][action.argsHash],
args: action.args,
fnIndex: action.fnIndex,
value: null,
error: action.error
}
}
}
}
}
/*
* Contract Events
*/
if (action.type === ContractActions.EVENT_FIRED) {
return {
...state,
[action.name]: {
...state[action.name],
events: [...state[action.name].events, action.event]
}
}
}
return state
}
export default contractsReducer

282
src/contracts/contractsSaga.js

@ -0,0 +1,282 @@
import { END, eventChannel } from 'redux-saga'
import { call, put, select, take, takeEvery } from 'redux-saga/effects'
import * as ContractActions from './constants'
import * as TransactionsActions from '../transactions/constants'
/*
* Events
*/
export function createContractEventChannel ({
contract,
eventName,
eventOptions
}) {
const name = contract.contractName
return eventChannel(emit => {
const eventListener = contract.events[eventName](eventOptions)
.on('data', event => {
emit({ type: ContractActions.EVENT_FIRED, name, event })
})
.on('changed', event => {
emit({ type: ContractActions.EVENT_CHANGED, name, event })
})
.on('error', error => {
emit({ type: ContractActions.EVENT_ERROR, name, error })
emit(END)
})
const unsubscribe = () => {
eventListener.removeListener(eventName)
}
return unsubscribe
})
}
function * callListenForContractEvent ({ contract, eventName, eventOptions }) {
const contractEventChannel = yield call(createContractEventChannel, {
contract,
eventName,
eventOptions
})
while (true) {
var event = yield take(contractEventChannel)
yield put(event)
}
}
/*
* Send and Cache
*/
function createTxChannel ({
txObject,
stackId,
sendArgs = {},
contractName,
stackTempKey
}) {
var persistTxHash
return eventChannel(emit => {
const txPromiEvent = txObject
.send(sendArgs)
.on('transactionHash', txHash => {
persistTxHash = txHash
emit({ type: TransactionsActions.TX_BROADCASTED, txHash, stackId })
emit({ type: ContractActions.CONTRACT_SYNC_IND, contractName })
})
.on('confirmation', (confirmationNumber, receipt) => {
emit({
type: TransactionsActions.TX_CONFIRMATION,
confirmationReceipt: receipt,
txHash: persistTxHash
})
})
.on('receipt', receipt => {
emit({ type: TransactionsActions.TX_SUCCESSFUL, receipt: receipt, txHash: persistTxHash })
emit(END)
})
.on('error', (error, receipt) => {
console.error(error)
console.error(receipt)
emit({ type: TransactionsActions.TX_ERROR, error: error, stackTempKey })
emit(END)
})
const unsubscribe = () => {
txPromiEvent.off()
}
return unsubscribe
})
}
function * callSendContractTx ({
contract,
fnName,
fnIndex,
args,
stackId,
stackTempKey
}) {
// Check for type of object and properties indicative of call/send options.
if (args.length) {
const finalArg = args.length > 1 ? args[args.length - 1] : args[0]
var sendArgs = {}
var finalArgTest = false
if (typeof finalArg === 'object') {
var finalArgTest = yield call(isSendOrCallOptions, finalArg)
}
if (finalArgTest) {
sendArgs = finalArg
args.length > 1 ? delete args[args.length - 1] : delete args[0]
args.length = args.length - 1
}
}
// Get name to mark as desynchronized on tx creation
const contractName = contract.contractName
// Create the transaction object and execute the tx.
const txObject = yield call(contract.methods[fnName], ...args)
const txChannel = yield call(createTxChannel, {
txObject,
stackId,
sendArgs,
contractName,
stackTempKey
})
try {
while (true) {
var event = yield take(txChannel)
yield put(event)
}
} finally {
txChannel.close()
}
}
/*
* Call and Cache
*/
function * callCallContractFn ({
contract,
fnName,
fnIndex,
args,
argsHash,
sync = false
}) {
// keeping for pre-v1.1.5 compatibility with CALL_CONTRACT_FN event.
if (sync) {
return
}
// Check for type of object and properties indicative of call/send options.
if (args.length) {
const finalArg = args.length > 1 ? args[args.length - 1] : args[0]
var callArgs = {}
var finalArgTest = false
if (typeof finalArg === 'object') {
var finalArgTest = yield call(isSendOrCallOptions, finalArg)
}
if (finalArgTest) {
callArgs = finalArg
args.length > 1 ? delete args[args.length - 1] : delete args[0]
args.length = args.length - 1
}
}
// Create the transaction object and execute the call.
const txObject = yield call(contract.methods[fnName], ...args)
try {
const callResult = yield call(txObject.call, callArgs)
var dispatchArgs = {
name: contract.contractName,
variable: contract.abi[fnIndex].name,
argsHash: argsHash,
args: args,
value: callResult,
fnIndex: fnIndex
}
yield put({ type: ContractActions.GOT_CONTRACT_VAR, ...dispatchArgs })
} catch (error) {
console.error(error)
var errorArgs = {
name: contract.contractName,
variable: contract.abi[fnIndex].name,
argsHash: argsHash,
args: args,
error: error,
fnIndex: fnIndex
}
yield put({ type: ContractActions.ERROR_CONTRACT_VAR, ...errorArgs })
}
}
/*
* Sync Contract
*/
function * callSyncContract (action) {
// Get contract state from store
const contract = action.contract
const contractName = contract.contractName
const contractsState = yield select(getContractsState)
var contractFnsState = Object.assign({}, contractsState[contractName])
// Remove unnecessary keys
delete contractFnsState.initialized
delete contractFnsState.synced
delete contractFnsState.events
// Iterate over functions and hashes
for (var fnName in contractFnsState) {
for (var argsHash in contractFnsState[fnName]) {
const fnIndex = contractFnsState[fnName][argsHash].fnIndex
const args = contractFnsState[fnName][argsHash].args
// Pull args and call fn for each given function
// keeping for pre-v1.1.5 compatibility with CALL_CONTRACT_FN event.
yield put({
type: ContractActions.CALL_CONTRACT_FN,
contract,
fnName,
fnIndex,
args,
argsHash,
sync: true
})
yield call(callCallContractFn, {
contract,
fnName,
fnIndex,
args,
argsHash
})
}
}
// When complete, dispatch CONTRACT_SYNCED
yield put({ type: ContractActions.CONTRACT_SYNCED, contractName })
}
const getContractsState = state => state.contracts
function isSendOrCallOptions (options) {
if ('from' in options) return true
if ('gas' in options) return true
if ('gasPrice' in options) return true
if ('value' in options) return true
return false
}
function * contractsSaga () {
yield takeEvery(ContractActions.SEND_CONTRACT_TX, callSendContractTx)
yield takeEvery(ContractActions.CALL_CONTRACT_FN, callCallContractFn)
yield takeEvery(ContractActions.CONTRACT_SYNCING, callSyncContract)
yield takeEvery(ContractActions.LISTEN_FOR_EVENT, callListenForContractEvent)
}
export default contractsSaga

20
src/defaultOptions.js

@ -0,0 +1,20 @@
const defaultOptions = {
web3: {
// `block` no longer needed;
// keeping for pre-v1.1.1 compatibility with drizzle-react.
block: false,
fallback: {
type: 'ws',
url: 'ws://127.0.0.1:8545'
}
},
contracts: [],
events: {},
polls: {
blocks: 3000
},
syncAlways: false,
networkWhitelist: []
}
export default defaultOptions

51
src/drizzle-middleware.js

@ -0,0 +1,51 @@
import * as DrizzleActions from './drizzleStatus/constants'
import * as AccountsActions from './accounts/constants'
import * as ContractActions from './contracts/constants'
export const drizzleMiddleware = drizzleInstance => store => next => action => {
const { type } = action
if (type === DrizzleActions.DRIZZLE_INITIALIZING) {
drizzleInstance = action.drizzle
}
if (
type === AccountsActions.ACCOUNTS_FETCHED &&
drizzleInstance &&
drizzleInstance.contractList.length
) {
const newAccount = action.accounts[0]
const oldAccount = drizzleInstance.contractList[0].options.from
// Update `from` fields with newAccount
if (oldAccount !== newAccount) {
drizzleInstance.contractList.forEach(contract => {
contract.options.from = newAccount
})
}
}
if (type === ContractActions.ADD_CONTRACT && drizzleInstance) {
try {
const { contractConfig, events } = action
drizzleInstance.addContract(contractConfig, events)
} catch (error) {
console.error('Attempt to add a duplicate contract.\n', error)
// Notify user via
const notificationAction = {
type: ContractActions.ERROR_ADD_CONTRACT,
error,
attemptedAction: action
}
store.dispatch(notificationAction)
// Don't propogate current action
return
}
}
return next(action)
}
const initializedMiddleware = drizzleMiddleware(undefined)
export default initializedMiddleware

3
src/drizzleStatus/constants.js

@ -0,0 +1,3 @@
export const DRIZZLE_INITIALIZED = 'DRIZZLE_INITIALIZED'
export const DRIZZLE_INITIALIZING = 'DRIZZLE_INITIALIZING'
export const DRIZZLE_FAILED = 'DRIZZLE_FAILED'

21
src/drizzleStatus/drizzleStatusReducer.js

@ -0,0 +1,21 @@
import * as DrizzleActions from './constants'
const initialState = {
initialized: false
}
const drizzleStatusReducer = (state = initialState, action) => {
/*
* Drizzle Status
*/
if (action.type === DrizzleActions.DRIZZLE_INITIALIZED) {
return {
...state,
initialized: true
}
}
return state
}
export default drizzleStatusReducer

90
src/drizzleStatus/drizzleStatusSaga.js

@ -0,0 +1,90 @@
import { call, put, select, takeLatest } from 'redux-saga/effects'
// Initialization Functions
import { initializeWeb3, getNetworkId } from '../web3/web3Saga'
import { getAccounts } from '../accounts/accountsSaga'
import { getAccountBalances } from '../accountBalances/accountBalancesSaga'
import * as DrizzleActions from './constants'
import * as BlocksActions from '../blocks/constants'
import * as AccountsActions from '../accounts/constants'
import { NETWORK_IDS, NETWORK_MISMATCH } from '../web3/constants'
export function * initializeDrizzle (action) {
try {
const { drizzle, options } = action
// Initialize web3 and get the current network ID.
const web3 = yield call(initializeWeb3, options.web3)
drizzle.web3 = web3
// Client may opt out of connecting their account to the dapp Guard against
// further web3 interaction, and note web3 will be undefined
//
if (web3) {
const networkId = yield call(getNetworkId, { web3 })
// Check whether network is allowed
const networkWhitelist = options.networkWhitelist
if (networkWhitelist.length &&
networkId !== NETWORK_IDS.ganache &&
!networkWhitelist.includes(networkId)) {
yield put({ type: NETWORK_MISMATCH, networkId })
} else {
// Get initial accounts list and balances.
yield call(getAccounts, { web3 })
yield call(getAccountBalances, { web3 })
// Instantiate contracts passed through via options.
for (var i = 0; i < options.contracts.length; i++) {
var contractConfig = options.contracts[i]
var events = []
var contractName = contractConfig.contractName
if (contractName in options.events) {
events = options.events[contractName]
}
yield call([drizzle, drizzle.addContract], contractConfig, events)
}
const syncAlways = options.syncAlways
// Protect server-side environments by ensuring ethereum access is
// guarded by isMetaMask which should only be in browser environment.
//
if (web3.currentProvider.isMetaMask && !window.ethereum) {
// Using old MetaMask, attempt block polling.
const interval = options.polls.blocks
yield put({ type: BlocksActions.BLOCKS_POLLING, drizzle, interval, web3, syncAlways })
} else {
// Not using old MetaMask, attempt subscription block listening.
yield put({ type: BlocksActions.BLOCKS_LISTENING, drizzle, web3, syncAlways })
}
// Accounts Polling
if ('accounts' in options.polls) {
yield put({
type: AccountsActions.ACCOUNTS_POLLING,
interval: options.polls.accounts,
web3
})
}
}
}
} catch (error) {
yield put({ type: DrizzleActions.DRIZZLE_FAILED, error })
console.error('Error initializing Drizzle:')
console.error(error)
return
}
yield put({ type: DrizzleActions.DRIZZLE_INITIALIZED })
}
function * drizzleStatusSaga () {
yield takeLatest(DrizzleActions.DRIZZLE_INITIALIZING, initializeDrizzle)
}
export default drizzleStatusSaga

69
src/generateStore.js

@ -0,0 +1,69 @@
import { all, fork } from 'redux-saga/effects'
import { createStore, applyMiddleware, compose, combineReducers } from 'redux'
import createSagaMiddleware from 'redux-saga'
import drizzleSagas from './rootSaga'
import drizzleReducers from './reducer'
import { generateContractsInitialState } from './contractStateUtils'
import drizzleMW from './drizzle-middleware'
const composeSagas = sagas =>
function * () {
yield all(sagas.map(fork))
}
/**
* Generate the redux store by combining drizzleOptions, application reducers,
* middleware and initial app state.
*
* @param {object} config - The configuration object
* @param {object} config.drizzleOptions - drizzle configuration object
* @param {object} config.reducers={} - application level reducers to include in drizzle's redux store
* @param {object[]} config.appSagas=[] - application sagas to be managed by drizzle's saga middleware
* @param {object[]} config.appMiddlewares=[] - application middlewares to be managed by drizzle's saga middleware
* @param {boolean} config.disableReduxDevTools=false - disable redux devtools hook
* @returns {object} Redux store
*
*/
export function generateStore ({
drizzleOptions,
appReducers = {},
appSagas = [],
appMiddlewares = [],
disableReduxDevTools = false,
...options
}) {
// Note: Preserve backwards compatibility for passing options to
// `generateStore`. in drizzle v1.3.3 and prior of generate had a signature
// of `generateStore(options)`.
//
// The updated signature looks for `drizzleOptions`, `appReducers`,
// `appSagas`, `initialAppStore` and `disableReduxDevTools` while
// {...options} captures the previous release's signature.
//
// Resolve drizzleOptions. If called by dapps written to previous API, then
// drizzleOptions will be `undefined` and will resolve to rest constructed
// options.
//
drizzleOptions = drizzleOptions || options
const composeEnhancers = !disableReduxDevTools
? global.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose
: compose
let initialContractsState = {
contracts: generateContractsInitialState(drizzleOptions)
}
const sagaMiddleware = createSagaMiddleware()
const allMiddlewares = [...appMiddlewares, sagaMiddleware, drizzleMW]
const allReducers = { ...drizzleReducers, ...appReducers }
const store = createStore(
combineReducers(allReducers),
initialContractsState,
composeEnhancers(applyMiddleware(...allMiddlewares))
)
sagaMiddleware.run(composeSagas([...drizzleSagas, ...appSagas]))
return store
}

51
src/index.js

@ -0,0 +1,51 @@
import Drizzle from './Drizzle.js'
import { generateStore } from './generateStore'
import { generateContractsInitialState } from './contractStateUtils'
// Events
import * as EventActions from './contracts/constants'
// Reducers
import accountsReducer from './accounts/accountsReducer'
import accountBalancesReducer from './accountBalances/accountBalancesReducer'
import blocksReducer from './blocks/blocksReducer'
import contractsReducer from './contracts/contractsReducer'
import drizzleStatusReducer from './drizzleStatus/drizzleStatusReducer'
import transactionsReducer from './transactions/transactionsReducer'
import transactionStackReducer from './transactions/transactionStackReducer'
import web3Reducer from './web3/web3Reducer'
const drizzleReducers = {
accounts: accountsReducer,
accountBalances: accountBalancesReducer,
contracts: contractsReducer,
currentBlock: blocksReducer,
drizzleStatus: drizzleStatusReducer,
transactions: transactionsReducer,
transactionStack: transactionStackReducer,
web3: web3Reducer
}
// Sagas
import accountsSaga from './accounts/accountsSaga'
import accountBalancesSaga from './accountBalances/accountBalancesSaga'
import blocksSaga from './blocks/blocksSaga'
import contractsSaga from './contracts/contractsSaga'
import drizzleStatusSaga from './drizzleStatus/drizzleStatusSaga'
const drizzleSagas = [
accountsSaga,
accountBalancesSaga,
blocksSaga,
contractsSaga,
drizzleStatusSaga
]
export {
Drizzle,
generateContractsInitialState,
generateStore,
drizzleReducers,
drizzleSagas,
EventActions
}

8
src/mergeOptions.js

@ -0,0 +1,8 @@
import merge from 'deepmerge'
const isPlainObject = require('is-plain-object')
export default function (defaultOptions, newOptions) {
return merge(defaultOptions, newOptions, {
isMergeableObject: isPlainObject
})
}

20
src/reducer.js

@ -0,0 +1,20 @@
import accountsReducer from './accounts/accountsReducer'
import accountBalancesReducer from './accountBalances/accountBalancesReducer'
import blocksReducer from './blocks/blocksReducer'
import contractsReducer from './contracts/contractsReducer'
import drizzleStatusReducer from './drizzleStatus/drizzleStatusReducer'
import transactionsReducer from './transactions/transactionsReducer'
import transactionStackReducer from './transactions/transactionStackReducer'
import web3Reducer from './web3/web3Reducer'
// All our reducers
export default {
accounts: accountsReducer,
accountBalances: accountBalancesReducer,
contracts: contractsReducer,
currentBlock: blocksReducer,
drizzleStatus: drizzleStatusReducer,
transactions: transactionsReducer,
transactionStack: transactionStackReducer,
web3: web3Reducer
}

13
src/rootSaga.js

@ -0,0 +1,13 @@
import accountsSaga from './accounts/accountsSaga'
import accountBalancesSaga from './accountBalances/accountBalancesSaga'
import blocksSaga from './blocks/blocksSaga'
import contractsSaga from './contracts/contractsSaga'
import drizzleStatusSaga from './drizzleStatus/drizzleStatusSaga'
export default [
accountsSaga,
accountBalancesSaga,
blocksSaga,
contractsSaga,
drizzleStatusSaga
]

6
src/transactions/constants.js

@ -0,0 +1,6 @@
export const TX_BROADCASTED = 'TX_BROADCASTED'
export const TX_CONFIRMATION = 'TX_CONFIRMATION'
export const TX_SUCCESSFUL = 'TX_SUCCESSFUL'
export const TX_ERROR = 'TX_ERROR'
export const PUSH_TO_TXSTACK = 'PUSH_TO_TXSTACK'
export const POP_FROM_TXSTACK = 'POP_FROM_TXSTACK'

25
src/transactions/transactionStackReducer.js

@ -0,0 +1,25 @@
import * as TransactionsActions from './constants'
const initialState = []
const transactionStackReducer = (state = initialState, action) => {
if (action.type === TransactionsActions.PUSH_TO_TXSTACK) {
return [...state, action.stackTempKey]
}
if (action.type === TransactionsActions.POP_FROM_TXSTACK) {
state.pop()
return [...state]
}
if (action.type === TransactionsActions.TX_BROADCASTED) {
state[action.stackId] = action.txHash
return [...state]
}
return state
}
export default transactionStackReducer

56
src/transactions/transactionsReducer.js

@ -0,0 +1,56 @@
import * as TransactionsActions from './constants'
const initialState = {}
const transactionsReducer = (state = initialState, action) => {
if (action.type === TransactionsActions.TX_BROADCASTED) {
return {
...state,
[action.txHash]: {
status: 'pending',
confirmations: []
}
}
}
if (action.type === TransactionsActions.TX_CONFIRMATION) {
return {
...state,
[action.txHash]: {
...state[action.txHash],
confirmations: [
...state[action.txHash].confirmations,
action.confirmationReceipt
]
}
}
}
if (action.type === TransactionsActions.TX_SUCCESSFUL) {
return {
...state,
[action.txHash]: {
...state[action.txHash],
status: 'success',
receipt: action.receipt
}
}
}
if (action.type === TransactionsActions.TX_ERROR) {
return {
...state,
[action.stackTempKey]: {
...state[action.stackTempKey],
status: 'error',
error: {
message: action.error && action.error.message
}
}
}
}
return state
}
export default transactionsReducer

17
src/web3/constants.js

@ -0,0 +1,17 @@
export const WEB3_INITIALIZING = 'WEB3_INITIALIZING'
export const WEB3_INITIALIZED = 'WEB3_INITIALIZED'
export const WEB3_FAILED = 'WEB3_FAILED'
export const WEB3_USER_DENIED = 'WEB3_USER_DENIED'
export const NETWORK_ID_FETCHED = 'NETWORK_ID_FETCHED'
export const NETWORK_ID_FAILED = 'NETWORK_ID_FAILED'
export const NETWORK_MISMATCH = 'NETWORK_MISMATCH'
export const NETWORK_IDS = {
mainnet: 1,
ropsten: 3,
rinkeby: 4,
goerli: 5,
kovan: 42,
ganache: 5777
}

59
src/web3/web3Reducer.js

@ -0,0 +1,59 @@
import * as Action from './constants'
const initialState = {
status: ''
}
const web3Reducer = (state = initialState, action) => {
if (action.type === Action.WEB3_INITIALIZING) {
return {
...state,
status: 'initializing'
}
}
if (action.type === Action.WEB3_INITIALIZED) {
return {
...state,
status: 'initialized'
}
}
if (action.type === Action.WEB3_FAILED) {
return {
...state,
status: 'failed'
}
}
if (action.type === Action.WEB3_USER_DENIED) {
return {
...state,
status: 'UserDeniedAccess'
}
}
if (action.type === Action.NETWORK_ID_FETCHED) {
return {
...state,
networkId: action.networkId
}
}
if (action.type === Action.NETWORK_ID_FAILED) {
return {
...state,
networkId: action.networkId
}
}
if (action.type === Action.NETWORK_MISMATCH) {
return {
...state,
networkMismatch: true
}
}
return state
}
export default web3Reducer

89
src/web3/web3Saga.js

@ -0,0 +1,89 @@
import { call, put } from 'redux-saga/effects'
import * as Action from './constants'
var Web3 = require('web3')
/*
* Initialization
*/
export function * initializeWeb3 (options) {
try {
let web3 = {}
if (options.customProvider) {
yield put({ type: Action.WEB3_INITIALIZED })
return options.customProvider
}
if (window.ethereum) {
const { ethereum } = window
web3 = new Web3(ethereum)
try {
// ethereum.enable() will return the selected account
// unless user opts out and then it will return undefined
const selectedAccount = yield call([ethereum, 'enable'])
yield put({ type: Action.WEB3_INITIALIZED })
if (!selectedAccount) {
yield put({ type: Action.WEB3_USER_DENIED })
return
}
return web3
} catch (error) {
console.error(error)
yield put({ type: Action.WEB3_FAILED })
return
}
} else if (typeof window.web3 !== 'undefined') {
// Checking if Web3 has been injected by the browser (Mist/MetaMask)
// Use Mist/MetaMask's provider.
web3 = new Web3(window.web3.currentProvider)
yield put({ type: Action.WEB3_INITIALIZED })
return web3
} else if (options.fallback) {
// Attempt fallback if no web3 injection.
switch (options.fallback.type) {
case 'ws':
var provider = new Web3.providers.WebsocketProvider(
options.fallback.url
)
web3 = new Web3(provider)
yield put({ type: Action.WEB3_INITIALIZED })
return web3
default:
// Invalid options; throw.
throw new Error('Invalid web3 fallback provided.')
}
} else {
// Out of web3 options; throw.
throw new Error('Cannot find injected web3 or valid fallback.')
}
} catch (error) {
yield put({ type: Action.WEB3_FAILED, error })
console.error('Error intializing web3:')
console.error(error)
}
}
/*
* Network ID
*/
export function * getNetworkId ({ web3 }) {
try {
const networkId = yield call(web3.eth.net.getId)
yield put({ type: Action.NETWORK_ID_FETCHED, networkId })
return networkId
} catch (error) {
yield put({ type: Action.NETWORK_ID_FAILED, error })
console.error('Error fetching network ID:')
console.error(error)
}
}

53
test/accountBalances.test.js

@ -0,0 +1,53 @@
import * as AccountBalancesActions from '../src/accountBalances/constants'
import {
getAccountBalances,
getAccountsState
} from '../src/accountBalances/accountBalancesSaga'
import { call, put, select } from 'redux-saga/effects'
describe('Account Balance Saga', () => {
let mockedWeb3, mockedGetBalance
let gen
beforeEach(() => {
mockedGetBalance = jest.fn()
mockedWeb3 = { eth: { getBalance: mockedGetBalance } }
gen = getAccountBalances({ web3: mockedWeb3 })
})
test('Retrieves account balances', () => {
let next = gen.next()
expect(next.value).toEqual(select(getAccountsState))
next = gen.next(global.accounts)
// It handles balance queries for all accounts
const accountBalance = 1e20 // default ETH balance
for (let account of global.accounts) {
expect(next.value).toEqual(call(mockedGetBalance, account))
next = gen.next(accountBalance)
expect(next.value).toEqual(
put({ type: AccountBalancesActions.ACCOUNT_BALANCE_FETCHED, account, accountBalance })
)
next = gen.next()
}
// Final dispatch
expect(next.value).toEqual(put({ type: AccountBalancesActions.ACCOUNT_BALANCES_FETCHED }))
})
test('Fails properly', () => {
let next = gen.next()
expect(next.value).toEqual(select(getAccountsState))
next = gen.next(global.accounts)
const error = new Error()
next = gen.throw(error)
expect(next.value).toEqual(put({ type: AccountBalancesActions.ACCOUNT_BALANCE_FAILED, error }))
// Final dispatch
next = gen.next()
expect(next.value).toEqual(put({ type: AccountBalancesActions.ACCOUNT_BALANCES_FETCHED }))
})
})

38
test/accounts.test.js

@ -0,0 +1,38 @@
import { getAccounts } from '../src/accounts/accountsSaga'
import { call, put } from 'redux-saga/effects'
import * as AccountsActions from '../src/accounts/constants'
describe('Accounts Saga', () => {
let mockedWeb3, mockedGetAccounts
let gen
beforeEach(() => {
mockedGetAccounts = jest.fn()
mockedWeb3 = { eth: { getAccounts: mockedGetAccounts } }
gen = getAccounts({ web3: mockedWeb3 })
})
test('retrieves Metamask accounts', () => {
expect(gen.next().value).toEqual(call(mockedGetAccounts))
expect(gen.next(global.accounts).value).toEqual(
put({ type: AccountsActions.ACCOUNTS_FETCHED, accounts: global.accounts })
)
})
describe('Fails', () => {
test('when accounts are not retrieved', () => {
expect(gen.next().value).toEqual(call(mockedGetAccounts))
expect(gen.next(undefined).value).toEqual(
put({ type: AccountsActions.ACCOUNTS_FAILED, error: 'No accounts found!' })
)
})
test('when when an exception occurs', () => {
const error = new Error()
expect(gen.next().value).toEqual(call(mockedGetAccounts))
expect(gen.throw(error).value).toEqual(
put({ type: AccountsActions.ACCOUNTS_FAILED, error })
)
})
})
})

91
test/blocks.test.js

@ -0,0 +1,91 @@
import {
createBlockChannel,
createBlockPollChannel
} from '../src/blocks/blocksSaga'
import { getWeb3 } from './utils/helpers'
import * as BlocksActions from '../src/blocks/constants'
describe('read from blocks', () => {
let web3
let syncAlways
const drizzle = {}
beforeAll(() => {
web3 = getWeb3()
syncAlways = false
})
describe('by listening through websockets', () => {
let blockListener
beforeEach(() => {
blockListener = createBlockChannel({ drizzle, web3, syncAlways })
})
test('listens for block headers', done => {
// Subscribe to event
blockListener.take(event => {
expect(event.type).toEqual(BlocksActions.BLOCK_RECEIVED)
done()
})
// Invoke action to trigger event
web3.eth.sendTransaction({
from: global.accounts[0],
to: global.accounts[1],
value: 200
})
})
test('unsubscribes from block headers', done => {
// Subscribe to event
blockListener.take(event => {
expect(event.type).toEqual('@@redux-saga/CHANNEL_END')
done()
})
// Invoke action to trigger event
blockListener.close()
})
})
describe('by polling', () => {
let blockPoller
beforeEach(() => {
const interval = 1000
blockPoller = createBlockPollChannel({
drizzle,
interval,
web3,
syncAlways
})
})
test('polls for block headers', done => {
// Subscribe to event
blockPoller.take(event => {
expect(event.type).toEqual(BlocksActions.BLOCK_FOUND)
done()
})
// Invoke action to trigger event
web3.eth.sendTransaction({
from: global.accounts[0],
to: global.accounts[1],
value: 200
})
})
test('terminates from block polling', done => {
// Subscribe to event
blockPoller.take(event => {
expect(event.type).toEqual('@@redux-saga/CHANNEL_END')
done()
})
// Invoke action to trigger event
blockPoller.close()
})
})
})

122
test/contractStateUtils.test.js

@ -0,0 +1,122 @@
import {
isGetterFunction,
isSetterFunction,
getAbi,
generateContractInitialState,
generateContractsInitialState
} from '../src/contractStateUtils'
import TestContractABI from './utils/data/TestContract-abi.json'
describe('Contract State Utilities', () => {
describe('isConstant', () => {
test('can identify a constant for solc v0.5.16 and below', () => {
const config = { type: 'function', constant: true }
expect(isGetterFunction(config)).toBe(true)
expect(isSetterFunction(config)).toBe(false)
})
test('can identify non constants for solc v0.5.16 and below', () => {
let config = { type: 'function', constant: false }
expect(isGetterFunction(config)).toBe(false)
expect(isSetterFunction(config)).toBe(true)
config = { type: 'event' }
expect(isGetterFunction(config)).toBe(false)
expect(isSetterFunction(config)).toBe(false)
})
test('can identify a constant for a pure or view func, for breaking changes from solc v0.6.0 and above', () => {
let config = { type: 'function', stateMutability: 'pure' }
expect(isGetterFunction(config)).toBe(true)
expect(isSetterFunction(config)).toBe(false)
config = { type: 'function', stateMutability: 'view' }
expect(isGetterFunction(config)).toBe(true)
expect(isSetterFunction(config)).toBe(false)
})
test('can identify non constants, for breaking changes from solc v0.6.0 and above', () => {
let config = { type: 'function', stateMutability: 'payable' }
expect(isGetterFunction(config)).toBe(false)
expect(isSetterFunction(config)).toBe(true)
config = { type: 'function', stateMutability: 'nonpayable' }
expect(isGetterFunction(config)).toBe(false)
expect(isSetterFunction(config)).toBe(true)
config = { type: 'event' }
expect(isGetterFunction(config)).toBe(false)
expect(isSetterFunction(config)).toBe(false)
})
})
describe('getAbi', () => {
test('can parse Web3 contract', () => {
const jsonInterface = {}
const web3Contract = { options: { jsonInterface } }
expect(getAbi({ web3Contract })).toEqual(jsonInterface)
})
test('can parse TruffleArtifact', () => {
const abi = {}
const artifact = { abi }
expect(getAbi(artifact)).toEqual(abi)
})
})
describe('generateContractinitialState', () => {
test('It generates correct state from truffleArtifact', () => {
const expectedState = {
initialized: false,
synced: false,
storedData: {}
}
const input = { abi: TestContractABI }
expect(generateContractInitialState(input)).toEqual(expectedState)
})
test('It generates correct state from Web3 Contract', () => {
const expectedState = {
initialized: false,
synced: false,
storedData: {}
}
const input = {
web3Contract: { options: { jsonInterface: TestContractABI } }
}
expect(generateContractInitialState(input)).toEqual(expectedState)
})
})
describe('generateContractsInitialState', () => {
test('it generates multi-contract initial state', () => {
const contracts = [
{ contractName: 'C1', abi: TestContractABI },
{ contractName: 'C2', abi: TestContractABI }
]
const expectedStates = {
C1: {
initialized: false,
synced: false,
storedData: {}
},
C2: {
initialized: false,
synced: false,
storedData: {}
}
}
expect(generateContractsInitialState({ contracts })).toEqual(
expectedStates
)
})
test('it generates valid initial state with empty contracts', () => {
expect(generateContractsInitialState({})).toEqual({})
})
})
})

167
test/drizzle/api.test.js

@ -0,0 +1,167 @@
import { put } from 'redux-saga/effects'
import MockedDrizzleContract from '../../src/DrizzleContract'
import { getWeb3Assets } from '../utils/helpers'
import Drizzle from '../../src/Drizzle'
import defaultDrizzleOptions from '../../src/defaultOptions'
import { initializeDrizzle } from '../../src/drizzleStatus/drizzleStatusSaga'
import { NETWORK_IDS, NETWORK_MISMATCH } from '../../src/web3/constants'
import * as DrizzleActions from '../../src/drizzleStatus/constants'
import * as ContractActions from '../../src/contracts/constants'
jest.mock('../../src/DrizzleContract')
describe('Drizzle API', () => {
const accounts = global.accounts
const contractName = 'TestContract'
let dispatchSpy, mockedStore, state, networkId
const drizzleOptions = {}
let drizzle
let contractCreatorSpy
beforeEach(() => {
MockedDrizzleContract.mockClear()
networkId = global.defaultNetworkId
// Mock Store
state = { web3: { networkId }, accounts }
dispatchSpy = jest.fn()
mockedStore = { dispatch: dispatchSpy, getState: () => state }
// Create Drizzle and simulate web3 resolution
contractCreatorSpy = jest.fn()
let mockedWeb3 = { eth: { Contract: contractCreatorSpy } }
drizzle = new Drizzle(drizzleOptions, mockedStore)
drizzle.web3 = mockedWeb3
// Only the contractName is required for these tests
MockedDrizzleContract.mockImplementation(() => ({ contractName }))
})
test('Constructor fires up drizzle store', () => {
const expectedAction = {
type: DrizzleActions.DRIZZLE_INITIALIZING,
drizzle,
options: defaultDrizzleOptions
}
expect(dispatchSpy).toHaveBeenCalledWith(expectedAction)
})
// Default values in drizzleOptions
describe('Default drizzle options', () => {
// networkWhiteList
test('Empty network whitelist does not trigger a mismatch', () => {
networkId = NETWORK_IDS.ropsten
// Iterate to 3rd effect in initializeDrizzle generator
let gen = initializeDrizzle({drizzle, options: drizzleOptions})
let next = gen.next() // initializeWeb3
const fakeWeb3 = {eth: {}};
next = gen.next(fakeWeb3) // getNetworkId
// Replace saga networkId with our own
next = gen.next(networkId) // networkWhitelist check
const unExpectedAction = put({ type: NETWORK_MISMATCH, networkId })
expect(next.value).not.toEqual(unExpectedAction)
})
})
describe('Add:', () => {
test('a Web3 Contracts', () => {
const web3Contract = {}
const contractConfig = { web3Contract, contractName }
drizzle.addContract(contractConfig)
// 1 in constructor, 2 in addContract
expect(dispatchSpy).toHaveBeenCalledTimes(3)
let expectedAction = { type: ContractActions.CONTRACT_INITIALIZING, contractConfig }
expect(dispatchSpy).toHaveBeenNthCalledWith(2, expectedAction)
expectedAction = { type: ContractActions.CONTRACT_INITIALIZED, name: contractName }
expect(dispatchSpy).toHaveBeenNthCalledWith(3, expectedAction)
expect(drizzle.contractList).toHaveLength(1)
expect(drizzle.contracts).toHaveProperty(contractName)
expect(MockedDrizzleContract).toHaveBeenCalledTimes(1)
})
test('a TruffleArtifact Contracts', async () => {
const { truffleArtifact } = await getWeb3Assets()
drizzle.addContract(truffleArtifact)
// 1 in constructor, 2 in addContract
expect(dispatchSpy).toHaveBeenCalledTimes(3)
let expectedAction = {
type: ContractActions.CONTRACT_INITIALIZING,
contractConfig: truffleArtifact
}
expect(dispatchSpy).toHaveBeenNthCalledWith(2, expectedAction)
expectedAction = {
type: ContractActions.CONTRACT_INITIALIZED,
name: truffleArtifact.contractName
}
expect(dispatchSpy).toHaveBeenNthCalledWith(3, expectedAction)
expect(drizzle.contractList).toHaveLength(1)
expect(drizzle.contracts).toHaveProperty(truffleArtifact.contractName)
expect(MockedDrizzleContract).toHaveBeenCalledTimes(1)
})
test('does not add duplicate contract', () => {
const web3Contract = {}
const contractConfig = { web3Contract, contractName }
MockedDrizzleContract.mockImplementation(() => ({ contractName }))
drizzle.addContract(contractConfig)
// Only called on 1st add
expect(dispatchSpy).toHaveBeenCalledTimes(3)
expect(drizzle.contractList).toHaveLength(1)
expect(MockedDrizzleContract).toHaveBeenCalledTimes(1)
// Try to add the same contract
const chucker = () => drizzle.addContract(contractConfig)
expect(chucker).toThrow(/^Contract already exists: TestContract/)
// No more calls to dispatch
expect(dispatchSpy).toHaveBeenCalledTimes(3)
})
})
describe('Delete:', () => {
test('removes a contract', () => {
// Add a contract
const web3Contract = {}
const contractConfig = { web3Contract, contractName }
drizzle.addContract(contractConfig)
expect(drizzle.contractList).toHaveLength(1)
drizzle.deleteContract(contractName)
expect(drizzle.contractList).toHaveLength(0)
// 3 calls to add, 1 to delete
expect(dispatchSpy).toHaveBeenCalledTimes(4)
})
test('throws if contract does not exist', () => {
expect(drizzle.contractList).toHaveLength(0)
const eraser = () => drizzle.deleteContract('Transmogrify')
expect(eraser).toThrow(/^Contract does not exist: Transmogrify/)
// 1 call in ctor to initialize drizzle
expect(dispatchSpy).toHaveBeenCalledTimes(1)
})
})
})

48
test/drizzle/getOrCreateWeb3.test.js

@ -0,0 +1,48 @@
import { getOrCreateWeb3Contract } from '../../src/Drizzle'
describe('getOrCreateWeb3Contract', () => {
const networkId = global.defaultNetworkId
const accounts = global.accounts
let mockedStore, state
beforeEach(() => {
state = { web3: { networkId }, accounts }
mockedStore = { getState: () => state }
})
test('recognizes a web3 contract', () => {
const mockedWeb3Contract = {}
const mockedContractConfig = { web3Contract: mockedWeb3Contract }
const resolved = getOrCreateWeb3Contract(
mockedStore,
mockedContractConfig,
{}
)
expect(resolved).toBe(mockedWeb3Contract)
})
test('recognizes a truffleArtifact', () => {
const address = '0x0123456789'
const abi = 'ABI'
const deployedBytecode = "I am Jack's caffeine fueled ledger code"
const mockedTruffleArtifact = {
abi,
networks: { [networkId]: { address } },
deployedBytecode
}
const contractCreatorSpy = jest.fn()
const mockedWeb3 = { eth: { Contract: contractCreatorSpy } }
getOrCreateWeb3Contract(mockedStore, mockedTruffleArtifact, mockedWeb3)
// Default selected is the 1st by convention
const selectedAccount = accounts[0]
const expectedArgs = [
abi,
address,
{ from: selectedAccount, data: deployedBytecode }
]
expect(contractCreatorSpy).toHaveBeenCalledWith(...expectedArgs)
})
})

184
test/drizzle/middleware.test.js

@ -0,0 +1,184 @@
import MockedDrizzleContract from '../../src/DrizzleContract'
import { drizzleMiddleware } from '../../src/drizzle-middleware'
import Drizzle from '../../src/Drizzle'
import { getWeb3Assets } from '../utils/helpers'
import configureStore from 'redux-mock-store'
import defaultDrizzleOptions from '../../src/defaultOptions'
import * as DrizzleActions from '../../src/drizzleStatus/constants'
import * as ContractActions from '../../src/contracts/constants'
import * as AccountsActions from '../../src/accounts/constants'
jest.mock('../../src/DrizzleContract')
const mockDrizzleInstance = (defaultAccount, numContracts = 1) => ({
contractList: Array.from({ length: numContracts }, () => ({
options: { from: defaultAccount }
}))
})
describe('Drizzle Middleware', () => {
const accounts = global.accounts
let dmw, mockedDrizzleInstance
let next
beforeEach(() => {
mockedDrizzleInstance = mockDrizzleInstance(accounts[0], 10)
next = jest.fn()
dmw = drizzleMiddleware({ contractList: [] })
})
test('it passes action to the rest of middleware Pipeline', () => {
dmw()(next)({}) // call with undefined action
expect(next).toHaveBeenCalledTimes(1)
})
test('default sendFrom changes when wallet provider changes selectedAccount', () => {
const selectedAccount = accounts[2]
dmw()(next)({
type: DrizzleActions.DRIZZLE_INITIALIZING,
drizzle: mockedDrizzleInstance
})
dmw()(next)({ type: AccountsActions.ACCOUNTS_FETCHED, accounts: [selectedAccount] })
// All contract options should have from address set to selectedAccount
const froms = mockedDrizzleInstance.contractList.map(x => x.options.from)
expect(froms).toHaveLength(10)
const fromSet = new Set(froms)
expect(fromSet.size).toBe(1)
expect(fromSet.has(selectedAccount)).toBe(true)
expect(next).toHaveBeenCalledTimes(2)
})
test('default sendFrom does not change unnecessarily', () => {
dmw()(next)({
type: DrizzleActions.DRIZZLE_INITIALIZING,
drizzle: mockedDrizzleInstance
})
// choose 1st account to indicate no change
const selectedAccount = accounts[0]
// Sentinel remains IFF no account change is detected
const sentinel = {}
mockedDrizzleInstance.contractList.push({ options: { from: sentinel } })
dmw()(next)({ type: AccountsActions.ACCOUNTS_FETCHED, accounts: [selectedAccount] })
const froms = mockedDrizzleInstance.contractList.map(x => x.options.from)
expect(froms).toHaveLength(11)
const fromSet = new Set(froms)
expect(fromSet.size).toBe(2)
expect(fromSet.has(selectedAccount)).toBe(true)
expect(fromSet.has(sentinel)).toBe(true)
expect(next).toHaveBeenCalledTimes(2)
})
describe('dispatch AddContract', () => {
const networkId = global.defaultNetworkId
const accounts = global.accounts
const drizzleOptions = {}
const expectedDrizzleOptions = defaultDrizzleOptions
const state = { web3: { networkId }, accounts }
let middlewares, mockedStore
let drizzle, mockedWeb3, contractCreatorSpy
beforeEach(() => {
MockedDrizzleContract.mockClear()
// Mock store with middleware
middlewares = [drizzleMiddleware()]
mockedStore = configureStore(middlewares)(state)
// Mock drizzle instance and dispatch DRIZZLE_INITIALIZING
contractCreatorSpy = jest.fn()
mockedWeb3 = { eth: { Contract: contractCreatorSpy } }
drizzle = new Drizzle(drizzleOptions, mockedStore)
// Get past web3 initialization
drizzle.web3 = mockedWeb3
})
test('is initialized', () => {
const actions = mockedStore.getActions()
expect(actions).toHaveLength(1)
expect(actions[0]).toEqual({
type: DrizzleActions.DRIZZLE_INITIALIZING,
drizzle,
options: expectedDrizzleOptions
})
})
describe('Adds a Contract', () => {
const mockedContractAddress = '0x0123456789'
const mockedEvents = []
let mockedContractConfig
beforeEach(async () => {
// Arrange minimum mock of a ContractConfig
;({ truffleArtifact: mockedContractConfig } = await getWeb3Assets())
MockedDrizzleContract.mockImplementation(() => ({
contractName: mockedContractConfig.contractName
}))
mockedContractConfig.networks = { [networkId]: mockedContractAddress }
})
test('successfully', async () => {
mockedStore.dispatch({
type: ContractActions.ADD_CONTRACT,
contractConfig: mockedContractConfig,
mockedEvents
})
// Assert
const actions = mockedStore.getActions()
expect(actions).toHaveLength(4)
expect(actions[0]).toEqual({
type: DrizzleActions.DRIZZLE_INITIALIZING,
drizzle,
options: expectedDrizzleOptions
})
expect(actions[1]).toEqual({
type: ContractActions.CONTRACT_INITIALIZING,
contractConfig: mockedContractConfig
})
expect(actions[2]).toEqual({
type: ContractActions.CONTRACT_INITIALIZED,
name: mockedContractConfig.contractName
})
expect(actions[3]).toEqual({
type: ContractActions.ADD_CONTRACT,
contractConfig: mockedContractConfig,
mockedEvents
})
})
test('handles exception', async () => {
// Add a contract
const addContractAction = {
type: ContractActions.ADD_CONTRACT,
contractConfig: mockedContractConfig,
mockedEvents
}
mockedStore.dispatch(addContractAction)
const actions = mockedStore.getActions()
expect(actions).toHaveLength(4)
// Add same contract
const doppleganger = () => mockedStore.dispatch(addContractAction)
// Assert
expect(doppleganger).not.toThrow()
expect(actions).toHaveLength(5)
const errorAction = actions[4]
expect(errorAction.type).toEqual(ContractActions.ERROR_ADD_CONTRACT)
expect(errorAction.error.message).toEqual(
`Contract already exists: ${mockedContractConfig.contractName}`
)
expect(errorAction.attemptedAction).toEqual(addContractAction)
})
})
})
})

75
test/drizzle/options.test.js

@ -0,0 +1,75 @@
import { put } from 'redux-saga/effects'
import Drizzle from '../../src/Drizzle'
import defaultDrizzleOptions from '../../src/defaultOptions'
import { initializeDrizzle } from '../../src/drizzleStatus/drizzleStatusSaga'
import { NETWORK_IDS, NETWORK_MISMATCH } from '../../src/web3/constants'
describe('Drizzle options:', () => {
const accounts = global.accounts
const drizzleOptions = {}
let dispatchSpy, mockedStore, state, networkId, drizzle
beforeEach(() => {
networkId = global.defaultNetworkId
// Mock Store
state = { web3: { networkId }, accounts }
dispatchSpy = jest.fn()
mockedStore = { dispatch: dispatchSpy, getState: () => state }
})
describe('Allowed Networks:', () => {
beforeEach(() => {
drizzleOptions['networkWhitelist'] = [
NETWORK_IDS.mainnet,
NETWORK_IDS.rinkeby
]
})
test('Unauthorized network fires a mismatch', () => {
networkId = NETWORK_IDS.ropsten
drizzle = new Drizzle(drizzleOptions, mockedStore)
let next = iterateInitializeDrizzleSagaToNetworkMismatch(drizzle, drizzleOptions, networkId)
const expectedAction = put({ type: NETWORK_MISMATCH, networkId })
expect(next.value).toEqual(expectedAction)
})
test('Authorized network does NOT fire a mismatch', () => {
networkId = NETWORK_IDS.ropsten
drizzleOptions['networkWhitelist'].push(networkId)
drizzle = new Drizzle(drizzleOptions, mockedStore)
let next = iterateInitializeDrizzleSagaToNetworkMismatch(drizzle, drizzleOptions, networkId)
const unExpectedAction = put({ type: NETWORK_MISMATCH, networkId })
expect(next.value).not.toEqual(unExpectedAction)
})
test('Ganache does NOT fire a mismatch', () => {
networkId = NETWORK_IDS.ganache
drizzle = new Drizzle(drizzleOptions, mockedStore)
let next = iterateInitializeDrizzleSagaToNetworkMismatch(drizzle, drizzleOptions, networkId)
const unExpectedAction = put({ type: NETWORK_MISMATCH, networkId })
expect(next.value).not.toEqual(unExpectedAction)
})
})
})
function iterateInitializeDrizzleSagaToNetworkMismatch(drizzle, options, networkId) {
// Iterate to 3rd effect in initializeDrizzle generator
let gen = initializeDrizzle({drizzle, options})
let next = gen.next() // initializeWeb3
const fakeWeb3 = {eth: {}};
next = gen.next(fakeWeb3) // getNetworkId
// Replace saga networkId with our own
return gen.next(networkId) // networkWhitelist
}

45
test/environments/ganache-environment.js

@ -0,0 +1,45 @@
const Ganache = require('ganache-core')
const NodeEnvironment = require('jest-environment-node')
const defaultSeed = 'drizzle'
const defaultNetworkId = 6777
const defaultAccounts = [
// based on default Mnemonic
'0x8aDB46251E9cd45b5027501766531825C04a2E06',
'0xb50CF9eD8f60605bEbB967776925f21Ba5c81D5D',
'0x7fC9AD8C7A3232Aed94d6C68728D22D722694824',
'0x6DADB5b9C2510bD3C266329781adFBa9A5145442',
'0xc41E494bE83a33Bf56B5C071094859067bC9E728',
'0x5B5b5c834daCf8ad46464a283a2B1B4Bd06A456e',
'0x4B165a6036791822777C78cF7931F1d205d29118',
'0x3950A710fb4b4ed456EC469E973D35c170802609',
'0xDA343E876263D988DDD7C18Bb4aB288c7ef66D89',
'0x1Ff0eB66355D4d3A1310FB759A8a67Efd58C888A'
]
class GanacheEnvironment extends NodeEnvironment {
async setup () {
await super.setup()
// Startup a Ganache server.
this.global.provider = Ganache.provider({
seed: defaultSeed,
network_id: defaultNetworkId,
gasLimit: 7000000
})
this.global.accounts = defaultAccounts
this.global.defaultNetworkId = defaultNetworkId
// Simulate document loaded for testing drizzle
this.global.document = { readyState: 'complete' }
}
async teardown () {
// close provider engine gracefully
this.global.provider.close(() => {})
await super.teardown()
}
}
module.exports = GanacheEnvironment

67
test/generateStore.test.js

@ -0,0 +1,67 @@
import { generateStore } from '../src/generateStore'
import { getWeb3Assets } from './utils/helpers'
const partialDrizzleOptions = {
web3: {
block: false,
fallback: {
type: 'ws',
url: 'ws://127.0.0.1:9545'
}
},
polls: {
accounts: 30000
}
}
const hasBasicShape = state => {
expect(state).toHaveProperty('contracts')
expect(state).toHaveProperty('contracts.TestContract')
expect(Object.keys(state.contracts)).toHaveLength(1)
expect(state).toHaveProperty('contracts.TestContract.initialized')
expect(state).toHaveProperty('contracts.TestContract.synced')
expect(state).toHaveProperty('contracts.TestContract.storedData')
expect(state).toHaveProperty('accounts')
expect(state).toHaveProperty('accountBalances')
expect(state).toHaveProperty('currentBlock')
expect(state).toHaveProperty('drizzleStatus')
expect(state).toHaveProperty('drizzleStatus.initialized')
expect(state).toHaveProperty('transactions')
expect(state).toHaveProperty('transactionStack')
expect(state).toHaveProperty('web3')
}
describe('generateStore', () => {
let TestContract, drizzleOptions
beforeEach(async () => {
;({ truffleArtifact: TestContract } = await getWeb3Assets())
drizzleOptions = { ...partialDrizzleOptions, contracts: [TestContract] }
})
describe('has the right shape', () => {
test('when invoked with only drizzleOptions', () => {
const store = generateStore({ drizzleOptions })
const state = store.getState()
hasBasicShape(state)
})
test('when invoked with appReducer', () => {
const initialState = 'This is the initial State'
const myState = jest.fn((state = initialState) => state)
const initialAppState = { myState: initialState }
const appReducers = { myState }
const store = generateStore({
drizzleOptions,
appReducers,
initialAppState
})
const state = store.getState()
hasBasicShape(state)
expect(state).toHaveProperty('myState')
expect(state.myState).toBe(initialState)
})
})
})

42
test/utils/data/TestContract-abi.json

@ -0,0 +1,42 @@
[
{
"constant": false,
"inputs": [
{
"name": "_value",
"type": "uint256"
}
],
"name": "setData",
"outputs": [],
"payable": false,
"stateMutability": "nonpayable",
"type": "function"
},
{
"anonymous": false,
"inputs": [
{
"indexed": false,
"name": "data",
"type": "uint256"
}
],
"name": "LogStoredData",
"type": "event"
},
{
"constant": true,
"inputs": [],
"name": "storedData",
"outputs": [
{
"name": "",
"type": "uint256"
}
],
"payable": false,
"stateMutability": "view",
"type": "function"
}
]

6
test/utils/data/TestContract-byteCode.json

@ -0,0 +1,6 @@
{
"linkReferences": {},
"object": "60806040526000805534801561001457600080fd5b5061010e806100246000396000f3fe6080604052348015600f57600080fd5b5060043610604f576000357c0100000000000000000000000000000000000000000000000000000000900480632a1afcd91460545780635b4b73a9146070575b600080fd5b605a609b565b6040518082815260200191505060405180910390f35b609960048036036020811015608457600080fd5b810190808035906020019092919050505060a1565b005b60005481565b806000819055507f1031b580b746b2e12ccbbb04a94ec78045d1f619d7194ae8863e75cd92d66116816040518082815260200191505060405180910390a15056fea165627a7a72305820c19d6e169510330ca6b40d8ed5aeecaefcb1c50aeb483b5ee99fbf5b57eaab270029",
"opcodes": "PUSH1 0x80 PUSH1 0x40 MSTORE PUSH1 0x0 DUP1 SSTORE CALLVALUE DUP1 ISZERO PUSH2 0x14 JUMPI PUSH1 0x0 DUP1 REVERT JUMPDEST POP PUSH2 0x10E DUP1 PUSH2 0x24 PUSH1 0x0 CODECOPY PUSH1 0x0 RETURN INVALID PUSH1 0x80 PUSH1 0x40 MSTORE CALLVALUE DUP1 ISZERO PUSH1 0xF JUMPI PUSH1 0x0 DUP1 REVERT JUMPDEST POP PUSH1 0x4 CALLDATASIZE LT PUSH1 0x4F JUMPI PUSH1 0x0 CALLDATALOAD PUSH29 0x100000000000000000000000000000000000000000000000000000000 SWAP1 DIV DUP1 PUSH4 0x2A1AFCD9 EQ PUSH1 0x54 JUMPI DUP1 PUSH4 0x5B4B73A9 EQ PUSH1 0x70 JUMPI JUMPDEST PUSH1 0x0 DUP1 REVERT JUMPDEST PUSH1 0x5A PUSH1 0x9B JUMP JUMPDEST PUSH1 0x40 MLOAD DUP1 DUP3 DUP2 MSTORE PUSH1 0x20 ADD SWAP2 POP POP PUSH1 0x40 MLOAD DUP1 SWAP2 SUB SWAP1 RETURN JUMPDEST PUSH1 0x99 PUSH1 0x4 DUP1 CALLDATASIZE SUB PUSH1 0x20 DUP2 LT ISZERO PUSH1 0x84 JUMPI PUSH1 0x0 DUP1 REVERT JUMPDEST DUP2 ADD SWAP1 DUP1 DUP1 CALLDATALOAD SWAP1 PUSH1 0x20 ADD SWAP1 SWAP3 SWAP2 SWAP1 POP POP POP PUSH1 0xA1 JUMP JUMPDEST STOP JUMPDEST PUSH1 0x0 SLOAD DUP2 JUMP JUMPDEST DUP1 PUSH1 0x0 DUP2 SWAP1 SSTORE POP PUSH32 0x1031B580B746B2E12CCBBB04A94EC78045D1F619D7194AE8863E75CD92D66116 DUP2 PUSH1 0x40 MLOAD DUP1 DUP3 DUP2 MSTORE PUSH1 0x20 ADD SWAP2 POP POP PUSH1 0x40 MLOAD DUP1 SWAP2 SUB SWAP1 LOG1 POP JUMP INVALID LOG1 PUSH6 0x627A7A723058 KECCAK256 0xc1 SWAP14 PUSH15 0x169510330CA6B40D8ED5AEECAEFCB1 0xc5 EXP 0xeb 0x48 EXTCODESIZE 0x5e 0xe9 SWAP16 0xbf JUMPDEST JUMPI 0xea 0xab 0x27 STOP 0x29 ",
"sourceMap": "33:209:0:-;;;87:1;61:27;;33:209;8:9:-1;5:2;;;30:1;27;20:12;5:2;33:209:0;;;;;;;"
}

12
test/utils/data/TestContract.sol

@ -0,0 +1,12 @@
pragma solidity >=0.5.0 <0.6.0;
contract TestContract {
uint public storedData = 0;
event LogStoredData(uint data);
function setData(uint _value) public {
storedData = _value;
emit LogStoredData(_value);
}
}

60
test/utils/helpers.js

@ -0,0 +1,60 @@
import Web3 from 'web3'
/**
* mockDrizzleStore
*
* @param {Object} initialState={} Set the initial State of the drizzle store.
* @returns {Array} [mockStore, dispatchedActions]
*/
const mockDrizzleStore = (initialState = {}) => {
const dispatchedActions = []
const mockStore = {
getState: () => initialState,
dispatch: action => dispatchedActions.push(action)
}
return [mockStore, dispatchedActions]
}
/**
* getWeb3
* @param {object} provider
*
* @returns {Object} A Web3 provider sourced from `global.provider`
*/
const getWeb3 = (provider = global.provider) => new Web3(provider)
/**
* getWeb3Assets deploys a contract on ganache provider
*
* @returns {Object} with web3, accounts & truffleArtifact
*/
const getWeb3Assets = async () => {
const abi = require('./data/TestContract-abi.json')
const byteCode = require('./data/TestContract-byteCode.json')
const web3 = getWeb3()
const accounts = await web3.eth.getAccounts() // use global.accounts?
const instance = new web3.eth.Contract(abi)
const deployedByteCode = await instance
.deploy({ data: byteCode.object })
.send({ from: accounts[0], gas: 150000 })
const truffleArtifact = {
contractName: 'TestContract',
abi,
byteCode,
deployedByteCode,
networks: {
[global.defaultNetworkId]: { address: deployedByteCode._address }
}
}
return { web3, accounts, truffleArtifact }
}
module.exports = {
mockDrizzleStore,
getWeb3,
getWeb3Assets
}

176
test/web3.test.js

@ -0,0 +1,176 @@
import { initializeWeb3, getNetworkId } from '../src/web3/web3Saga'
import { call, put } from 'redux-saga/effects'
import { runSaga } from 'redux-saga'
import * as Action from '../src/web3/constants'
const hasWeb3Shape = obj => {
expect(obj).toHaveProperty('currentProvider')
expect(obj).toHaveProperty('BatchRequest')
expect(obj).toHaveProperty('version')
expect(obj).toHaveProperty('utils')
expect(obj).toHaveProperty('eth')
}
describe('Resolving Web3', () => {
let web3Options, resolvedWeb3, gen
describe('with customProvider', () => {
beforeAll(async () => {
global.window = {}
web3Options = { customProvider: global.provider }
})
test('get web3', async () => {
gen = initializeWeb3(web3Options)
// First action dispatched
expect(gen.next().value).toEqual(put({ type: Action.WEB3_INITIALIZED }))
resolvedWeb3 = gen.next().value
expect(resolvedWeb3).toEqual(global.provider)
})
})
describe('with ethereum, EIP-1102 compliance', () => {
test('invokes `ethereum.enable`', async () => {
const mockedEthereumEnable = jest.fn()
const ethereum = { enable: mockedEthereumEnable }
global.window = { ethereum }
gen = initializeWeb3({})
let next = gen.next()
// get permission according to EIP 1102
//
expect(next.value).toEqual(
call({ context: ethereum, fn: ethereum.enable })
)
// return an account to simulate opt-in
next = gen.next('0x123')
expect(next.value).toEqual(put({ type: Action.WEB3_INITIALIZED }))
resolvedWeb3 = gen.next().value
hasWeb3Shape(resolvedWeb3)
})
test('loads when user opts in', async () => {
const mockedEthereumEnable = jest.fn(() => '0x123')
const ethereum = { enable: mockedEthereumEnable }
global.window = { ethereum }
const dispatched = []
const result = await runSaga({
dispatch: (action) => dispatched.push(action),
getState: () => ({ state: 'test' })
}, initializeWeb3, {}).done
// result should be a proper web3 provider
expect(result).toBeInstanceOf(require('web3'))
})
test('does not load when user opts out', async () => {
// opt out
global.window = { ethereum: { enable: jest.fn(() => undefined) } }
const dispatched = []
const web3Result = await runSaga(
{
dispatch: action => dispatched.push(action),
getState: () => ({ state: 'test' })
},
initializeWeb3,
{}
).done
// saga result should be undefined if an exception occurs
expect(web3Result).toBe(undefined)
// and the last action should be WEB3_USER_DENIED
expect(dispatched.pop()).toEqual({ type: Action.WEB3_USER_DENIED })
})
test('does not load when provider throws an error', async () => {
// simulate opting out
const mockedEthereumEnable = jest.fn(() => { throw new Error('oops') })
const ethereum = { enable: mockedEthereumEnable }
global.window = { ethereum }
const dispatched = []
const result = await runSaga({
dispatch: (action) => dispatched.push(action),
getState: () => ({ state: 'test' })
}, initializeWeb3, {}).done
// saga result is undefined when exception is thrown
expect(result).toBe(undefined)
// and the last action should be WEB3_FAILED
expect(dispatched.pop()).toEqual({ type: Action.WEB3_FAILED })
})
})
describe('with injected web3', () => {
beforeAll(async () => {
global.window = {}
global.window.web3 = { currentProvider: global.provider }
gen = initializeWeb3({})
})
test('get web3', async () => {
// First action dispatched
expect(gen.next().value).toEqual(put({ type: Action.WEB3_INITIALIZED }))
})
})
describe('with websocket fallback web3', () => {
let gen
beforeAll(async () => {
global.window = {}
global.provider.providers = { WebSocketProvider: jest.fn() }
})
test('get web3', async () => {
web3Options = {
fallback: {
type: 'ws',
url: 'ws://localhost:12345'
}
}
gen = initializeWeb3(web3Options)
// First action dispatched
expect(gen.next().value).toEqual(put({ type: Action.WEB3_INITIALIZED }))
resolvedWeb3 = gen.next().value
// is it a Web3 object?
hasWeb3Shape(resolvedWeb3)
})
test('fails when fallback type is unknown', async () => {
web3Options = {
fallback: {
type: 'thewrongtype',
url: 'ws://localhost:12345'
}
}
gen = initializeWeb3(web3Options)
const error = new Error('Invalid web3 fallback provided.')
expect(gen.next().value).toEqual(put({ type: Action.WEB3_FAILED, error }))
})
})
describe('Exhausts options', () => {
beforeAll(async () => {
global.window = {}
gen = initializeWeb3({})
})
test('with failure', async () => {
const error = new Error('Cannot find injected web3 or valid fallback.')
expect(gen.next().value).toEqual(put({ type: Action.WEB3_FAILED, error }))
})
})
})

36
types/Drizzle.d.ts

@ -0,0 +1,36 @@
import { Store } from 'redux';
import { IStoreConfig } from './generateStore';
import { IContract } from './IContract';
import { IContractConfig } from './contractStateUtils';
export interface IDrizzleOptions {
contracts: IContract[];
events?: {
[contractName: string]: any;
};
polls?: {
accounts?: number;
blocks?: number;
};
syncAlways?: any;
web3?: {
customProvider?: any;
fallback?: {
type: string;
url: string;
}
},
networkWhitelist?: number[];
}
export class Drizzle {
constructor(options?: IDrizzleOptions, store?: Store);
addContract(contractConfig: IContractConfig, events: any[]): void;
deleteContract(contractName: string): void;
findContractByAddress(address: string): IContract;
generateStore(options: IStoreConfig): Store;
}

83
types/IContract.d.ts

@ -0,0 +1,83 @@
export interface ABI {
constant?: boolean;
inputs: {
name: string;
type: string;
indexed?: boolean;
}[];
name?: string;
outputs?: {
name: string;
type: string;
}[];
payable: boolean;
stateMutability: string;
type: string;
anonymous?: boolean;
}
export interface AST {
absolutePath: string;
exportedSymbols: {
[name: string]: number[];
};
id: number;
nodeType: string;
nodes: INode[];
src: string;
}
export interface INetwork {
events: any;
links: any;
address: string;
transactionHash: string;
}
export interface INetworks {
[key: number]: INetwork;
[key: string]: INetwork;
}
export interface INode {
id: number;
literals: string[];
nodeType: string;
src: string;
baseContracts: any[];
contractDependencies: any[];
contractKind: string;
documentation?: any;
fullyImplemented?: boolean;
linearizedBaseContracts: number[];
name: string;
nodes: any[];
scope?: number;
}
export interface IContract {
contractName: string;
abi: ABI[];
metadata: string;
bytecode: string;
deployedBytecode: string;
sourceMap: string;
deployedSourceMap: string;
source: string;
sourcePath: string;
ast: AST;
legacyAST: AST;
compiler: {
name: string;
version: string;
};
networks: INetworks;
schemaVersion: string;
updatedAt: Date;
devdoc: {
methods: any;
};
userdoc: {
methods: any;
};
}

25
types/contractStateUtils.d.ts

@ -0,0 +1,25 @@
import { ABI } from "./IContract";
export interface IContractConfig {
contractName: string;
web3Contract?: {
options: {
jsonInterface: ABI;
}
};
abi?: ABI;
}
export interface IContractInitialState {
[key: string]: {};
initialized: boolean;
synced: boolean;
}
export interface IContractOptions {
contracts?: IContractConfig[];
}
export function generateContractInitialState(contractConfig: IContractConfig): IContractInitialState;
export function generateContractsInitialState(options: IContractOptions): IContractInitialState[];

13
types/generateStore.d.ts

@ -0,0 +1,13 @@
import { Store } from 'redux';
import { IDrizzleOptions } from './Drizzle';
export interface IStoreConfig {
[key: string]: any;
drizzleOptions: IDrizzleOptions;
reducers?: any;
appSagas?: any[];
appMiddlewares?: any[];
disableReduxDevTools?: boolean;
}
export function generateStore(config: IStoreConfig): Store;

3
types/index.d.ts

@ -0,0 +1,3 @@
export * from './Drizzle';
export * from './generateStore';
export * from './contractStateUtils';

30
webpack/base.config.js

@ -0,0 +1,30 @@
const path = require('path')
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin
process.env.BABEL_ENV = 'production'
module.exports = {
devtool: 'inline-source-map',
entry: './src/index.js',
output: {
filename: 'drizzle-store.js',
library: '@drizzle/store',
libraryTarget: 'umd',
globalObject: "typeof self !== 'undefined' ? self : this",
path: path.resolve(__dirname, '../dist')
},
module: {
rules: [{
test: /\.(js)$/,
include: path.resolve(__dirname, '../src'),
loader: 'babel-loader'
}]
},
plugins: [
new BundleAnalyzerPlugin({
analyzerMode: 'disabled',
generateStatsFile: true,
statsOptions: { source: false }
})
]
}

6
webpack/pure.config.js

@ -0,0 +1,6 @@
const merge = require('webpack-merge');
const baseConfig = require('./base.config.js');
module.exports = merge(baseConfig, {
mode: 'production'
});

14
webpack/release.config.js

@ -0,0 +1,14 @@
const merge = require('webpack-merge');
const baseConfig = require('./base.config.js');
module.exports = merge(baseConfig, {
mode: 'development',
externals: {
'eth-block-tracker': 'eth-block-tracker-es5',
'redux': 'redux',
'redux-saga': 'redux-saga',
'web3': 'web3',
'is-plain-object': 'is-plain-object',
'deepmerge': 'deepmerge'
}
});
Loading…
Cancel
Save