Merge branch 'master' of github.com:status-im/keycard-redeem

This commit is contained in:
Michele Balistreri 2020-04-30 08:34:41 +03:00
commit 60133de7d1
No known key found for this signature in database
GPG Key ID: E9567DA33A4F791A
27 changed files with 6401 additions and 461 deletions

View File

View File

@ -1,10 +0,0 @@
<html>
<head>
<title>Keycard Redeem</title>
<meta name="viewport" content="initial-scale=1, maximum-scale=1">
</head>
<body>
<div id="root"></div>
<script src="/js/app.js"></script>
</body>
</html>

View File

View File

@ -36,6 +36,19 @@ module.exports = {
ERC20BucketFactory: {
params: [],
},
ERC20Bucket: {
params: [],
proxyFor: "Bucket",
deploy: false,
},
NFTBucketFactory: {
params: [],
},
NFTBucket: {
params: [],
proxyFor: "Bucket",
deploy: false,
},
}
},

View File

@ -22,7 +22,7 @@ module.exports = {
// A new DApp can be created from that template with:
// embark new --template typescript
// NOTE: the `--template` option is DEPRECATED in v5.
enabled: true
enabled: false
// Setting `enabled: false` in this config will disable Embark's built-in Webpack
// pipeline. The developer will need to use a different frontend build tool, such as
// `create-react-app` or Angular CLI to build their dapp

View File

@ -67,14 +67,21 @@ abstract contract Bucket {
function bucketType() virtual external returns (uint256);
function redeem(Redeem calldata _redeem, bytes calldata _sig) external {
validateRedeem(_redeem, maxTxDelayInBlocks, expirationTime, startTime);
// validate Redeem
require(_redeem.blockNumber < block.number, "transaction cannot be in the future");
require(_redeem.blockNumber >= (block.number - maxTxDelayInBlocks), "transaction too old");
require(_redeem.blockHash == blockhash(_redeem.blockNumber), "invalid block hash");
require(block.timestamp < expirationTime, "expired redeemable");
require(block.timestamp > startTime, "reedeming not yet started");
address recipient = recoverSigner(DOMAIN_SEPARATOR, _redeem, _sig);
Redeemable storage redeemable = redeemables[recipient];
require(redeemable.recipient == recipient, "not found");
validateCode(_redeem, redeemable.code);
// validate code
bytes32 codeHash = keccak256(abi.encodePacked(_redeem.code));
require(codeHash == redeemable.code, "invalid code");
uint256 data = redeemable.data;
@ -109,15 +116,6 @@ abstract contract Bucket {
require(block.timestamp >= _expirationTime, "not expired yet");
}
function validateRedeem(Redeem memory _redeem, uint256 _maxTxDelayInBlocks, uint256 _expirationTime, uint256 _startTime) internal view {
require(_redeem.blockNumber < block.number, "transaction cannot be in the future");
require(_redeem.blockNumber >= (block.number - _maxTxDelayInBlocks), "transaction too old");
require(_redeem.blockHash == blockhash(_redeem.blockNumber), "invalid block hash");
require(block.timestamp < _expirationTime, "expired redeemable");
require(block.timestamp > _startTime, "reedeming not yet started");
}
function hashRedeem(Redeem memory _redeem) internal pure returns (bytes32) {
return keccak256(abi.encode(
REDEEM_TYPEHASH,
@ -155,9 +153,4 @@ abstract contract Bucket {
return ecrecover(digest, v, r, s);
}
function validateCode(Redeem memory _redeem, bytes32 _code) internal pure {
bytes32 codeHash = keccak256(abi.encodePacked(_redeem.code));
require(codeHash == _code, "invalid code");
}
}

View File

@ -1,11 +1,5 @@
{
"contracts": ["contracts/**"],
"app": {
"css/app.css": ["app/css/**"],
"js/app.js": ["app/js/index.tsx"],
"images/": ["app/images/**"],
"index.html": "app/index.html"
},
"buildDir": "dist/",
"config": "config/",
"versions": {
@ -26,5 +20,5 @@
"optimize-runs": 200
}
},
"generationDir": "embarkArtifacts"
"generationDir": "src/embarkArtifacts"
}

View File

@ -3,28 +3,42 @@
"version": "0.0.1",
"description": "",
"scripts": {
"test": "embark test"
"test": "embark test",
"start": "react-scripts start",
"build": "react-scripts build",
"eject": "react-scripts eject"
},
"keywords": [],
"author": "",
"license": "MIT",
"dependencies": {
"@types/history": "^4.7.5",
"@types/react": "^16.9.0",
"@types/react-dom": "^16.9.0",
"@types/react-redux": "^7.1.7",
"@types/react-router-dom": "^5.1.5",
"bn.js": "^5.1.1",
"connected-react-router": "^6.7.0",
"esm": "^3.2.25",
"eth-sig-util": "^2.5.3",
"history": "^4.10.1",
"minimist": "^1.2.3",
"react": "^16.12.0",
"react-dom": "^16.12.0",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-redux": "^7.2.0",
"react-router": "^5.1.2",
"react-router-dom": "^5.1.2",
"react-scripts": "3.4.1",
"redux": "^4.0.5",
"redux-thunk": "^2.3.0",
"web3": "^1.2.6"
"typescript": "^3.8.3",
"web3": "^1.2.6",
"web3-eth": "^1.2.6"
},
"eslintConfig": {
"extends": "react-app"
},
"devDependencies": {
"@babel/preset-typescript": "^7.8.3",
"embark": "^5.3.0-nightly.7",
"embark-geth": "^5.3.0-nightly.7",
"embark-graph": "^5.3.0-nightly.7",
@ -38,7 +52,18 @@
"embarkjs-ipfs": "^5.3.0-nightly.4",
"embarkjs-swarm": "^5.3.0-nightly.4",
"embarkjs-web3": "^5.3.0-nightly.4",
"embarkjs-whisper": "^5.3.0-nightly.4",
"typescript": "^3.8.3"
"embarkjs-whisper": "^5.3.0-nightly.4"
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}

13
public/index.html Normal file
View File

@ -0,0 +1,13 @@
<html>
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<title>Keycard Redeem</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" href="/css/app.css" type="text/css" media="screen" charset="utf-8">
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>

View File

@ -1,8 +1,7 @@
import { RootState } from '../reducers';
import ERC20Bucket from '../../../embarkArtifacts/contracts/ERC20Bucket';
import IERC20Detailed from '../../../embarkArtifacts/contracts/IERC20Detailed';
import ERC20Bucket from '../embarkArtifacts/contracts/ERC20Bucket';
import IERC20Detailed from '../embarkArtifacts/contracts/IERC20Detailed';
import { config } from "../config";
import { Contract } from 'web3-eth-contract';
import { Dispatch } from 'redux';
export const ERROR_REDEEMABLE_NOT_FOUND = "ERROR_REDEEMABLE_NOT_FOUND";
@ -129,9 +128,10 @@ export const loadRedeemable = (bucketAddress: string, recipientAddress: string)
return async (dispatch: Dispatch, getState: () => RootState) => {
dispatch(loadingRedeemable(bucketAddress, recipientAddress));
const bucket = newBucketContract(bucketAddress);
bucket.methods.expirationTime().call().then(expirationTime => {
bucket.methods.expirationTime().call().then((expirationTime: number) => {
bucket.methods.redeemables(recipientAddress).call().then((result: any) => {
const { recipient, amount, code } = result;
const { recipient, data, code } = result;
const amount = data;
if (amount === "0") {
dispatch(redeemableNotFound())
return;
@ -139,20 +139,21 @@ export const loadRedeemable = (bucketAddress: string, recipientAddress: string)
dispatch(redeemableLoaded(expirationTime, recipient, amount, code));
dispatch<any>(loadToken(bucket))
}).catch(err => {
}).catch((err: string) => {
dispatch(errorLoadingRedeemable(err))
console.error("err: ", err)
})
}).catch(err => {
}).catch((err: string) => {
dispatch(errorLoadingRedeemable(`error loading expirationTime: ${err}`))
console.error("err: ", err)
});
};
};
export const loadToken = (bucket: Contract) => {
//FIXME: set the proper Contract type
export const loadToken = (bucket: any) => {
return (dispatch: Dispatch, getState: () => RootState) => {
bucket.methods.tokenContract().call().then(async (address: string) => {
bucket.methods.tokenAddress().call().then(async (address: string) => {
const erc20Abi = IERC20Detailed.options.jsonInterface;
const erc20 = new config.web3!.eth.Contract(erc20Abi, address);
dispatch(loadingToken(address));

View File

@ -1,12 +1,12 @@
import { RootState } from '../reducers';
import ERC20Bucket from '../../../embarkArtifacts/contracts/ERC20Bucket';
import IERC20Detailed from '../../../embarkArtifacts/contracts/IERC20Detailed';
import IERC20Detailed from '../embarkArtifacts/contracts/IERC20Detailed';
import { config } from "../config";
import { Dispatch } from 'redux';
import { newBucketContract } from "./bucket";
import { sha3 } from "web3-utils";
import { recoverTypedSignature } from 'eth-sig-util';
import { Web3Type } from "../actions/web3";
import { KECCAK_EMPTY_STRING } from '../utils';
const sleep = (ms: number) => {
return new Promise(resolve => {
@ -91,31 +91,37 @@ const redeemDone = (txHash: string) => ({
txHash,
});
export const redeem = (bucketAddress: string, recipientAddress: string, code: string) => {
export const redeem = (bucketAddress: string, recipientAddress: string, cleanCode: string) => {
return async (dispatch: Dispatch, getState: () => RootState) => {
let finalCode;
if (cleanCode === "") {
finalCode = KECCAK_EMPTY_STRING;
} else {
finalCode = sha3(cleanCode);
}
dispatch(redeeming());
const state = getState();
const web3Type = state.web3.type;
const bucketAddress = state.bucket.address;
const bucket = newBucketContract(bucketAddress);
const codeHash = sha3(code);
const account = state.web3.account;
const block = await config.web3!.eth.getBlock("latest");
const message = {
receiver: state.web3.account,
code: codeHash,
receiver: state.web3.account!,
code: finalCode!,
blockNumber: block.number,
blockHash: block.hash,
};
//FIXME: is signer needed?
signRedeem(web3Type, bucketAddress, state.web3.account, message).then(async ({ sig, address }: SignRedeemResponse) => {
const recipient = state.bucket.recipient;
signRedeem(web3Type, bucketAddress, state.web3.account!, message).then(async ({ sig, address }: SignRedeemResponse) => {
const recipient = state.bucket.recipient!;
//FIXME: remove! hack to wait for the request screen to slide down
await sleep(3000);
if (address.toLowerCase() != recipient.toLowerCase()) {
if (address.toLowerCase() !== recipient.toLowerCase()) {
//FIXME: handle error
dispatch(wrongSigner(recipient, address));
return;
@ -123,13 +129,16 @@ export const redeem = (bucketAddress: string, recipientAddress: string, code: st
const redeem = bucket.methods.redeem(message, sig);
const gas = await redeem.estimateGas();
redeem.send({ from: account, gas }).then(resp => {
redeem.send({
from: account,
gas
}).then((resp: any) => {
dispatch(redeemDone(resp.transactionHash));
}).catch(err => {
}).catch((err: string) => {
console.error("redeem error: ", err);
dispatch(redeemError(err))
});
}).catch(err => {
}).catch((err: string) => {
console.error("sign redeem error: ", err);
dispatch(redeemError(err))
});
@ -183,7 +192,7 @@ const signWithWeb3 = (signer: string, data: any): Promise<SignRedeemResponse> =>
method: "eth_signTypedData_v3",
params: [signer, JSON.stringify(data)],
from: signer,
}, (err, resp) => {
}, (err: string, resp: any) => {
if (err) {
reject(err);
} else {
@ -201,14 +210,14 @@ const signWithWeb3 = (signer: string, data: any): Promise<SignRedeemResponse> =>
const signWithKeycard = (signer: string, data: any): Promise<SignRedeemResponse> => {
return new Promise((resolve, reject) => {
(window as any).ethereum.send("keycard_signTypedData", [signer, JSON.stringify(data)]).then(resp => {
(window as any).ethereum.send("keycard_signTypedData", [signer, JSON.stringify(data)]).then((resp: any) => {
const sig = resp.result;
const address = recoverTypedSignature({
data,
sig
});
resolve({ sig, address });
}).catch(err => {
}).catch((err: string) => {
reject(err);
})
});

View File

@ -1,9 +1,9 @@
import React from 'react';
import ERC20BucketFactory from '../../../embarkArtifacts/contracts/ERC20BucketFactory';
import ERC20BucketFactory from '../embarkArtifacts/contracts/ERC20BucketFactory';
import { RootState } from '../reducers';
import {
shallowEqual,
useSelector,
useDispatch,
} from 'react-redux';
import { Web3Type } from "../actions/web3";
@ -21,7 +21,7 @@ const web3Type = (t: Web3Type) => {
}
export default function(ownProps: any) {
const props = useSelector(state => {
const props = useSelector((state: RootState) => {
return {
initialized: state.web3.networkID,
networkID: state.web3.networkID,
@ -31,11 +31,11 @@ export default function(ownProps: any) {
}, shallowEqual);
if (props.error) {
return `Error: ${props.error}`;
return <>Error: {props.error}</>;
}
if (!props.initialized) {
return "initializing...";
return <>initializing...</>;
}
return <>

View File

@ -1,16 +1,23 @@
import React from 'react';
class ErrorBoundary extends React.Component {
constructor(props) {
interface Props {
}
interface State {
hasError: boolean
}
class ErrorBoundary extends React.Component<Props, State> {
constructor(props: any) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error) {
static getDerivedStateFromError(error: any) {
return { hasError: true };
}
componentDidCatch(error, errorInfo) {
componentDidCatch(error: any, errorInfo: any) {
console.error(error);
}

View File

@ -7,7 +7,5 @@ export default function() {
const dispatch = useDispatch();
return <>
<p>
</p>
</>;
}

View File

@ -13,7 +13,10 @@ import {
ERROR_LOADING_REDEEMABLE,
ERROR_REDEEMABLE_NOT_FOUND,
} from '../actions/bucket';
import { toBaseUnit } from "../utils";
import {
toBaseUnit,
KECCAK_EMPTY_STRING2,
} from "../utils";
import {
redeem,
RedeemErrors,
@ -21,8 +24,6 @@ import {
ERROR_WRONG_SIGNER,
} from '../actions/redeem';
const REDEEM_CODE = "hello world";
const buckerErrorMessage = (error: BucketErrors): string => {
switch (error.type) {
case ERROR_LOADING_REDEEMABLE:
@ -49,13 +50,23 @@ const redeemErrorMessage = (error: RedeemErrors): string => {
}
}
interface URLParams {
bucketAddress: string
recipientAddress: string
}
export default function(ownProps: any) {
const dispatch = useDispatch()
const match = useRouteMatch({
const match = useRouteMatch<URLParams>({
path: redeemPath,
exact: true,
});
if (match === null) {
return null;
}
const bucketAddress = match.params.bucketAddress;
const recipientAddress = match.params.recipientAddress;
@ -78,30 +89,32 @@ export default function(ownProps: any) {
}
}, shallowEqual);
const emptyCode = props.codeHash === KECCAK_EMPTY_STRING2;
useEffect(() => {
dispatch(loadRedeemable(bucketAddress, recipientAddress));
}, [bucketAddress, recipientAddress]);
}, [dispatch, bucketAddress, recipientAddress]);
if (props.error) {
return `Error: ${buckerErrorMessage(props.error)}`;
return <>Error: {buckerErrorMessage(props.error)}</>;
}
if (props.loading) {
return "loading bucket...";
return <>loading bucket...</>;
}
if (props.tokenSymbol === undefined || props.tokenDecimals === undefined) {
return "loading token info...";
return <>loading token info...</>;
}
const [displayAmount, roundedDisplayAmount] = toBaseUnit(props.amount, props.tokenDecimals, 2);
const [displayAmount, roundedDisplayAmount] = toBaseUnit(props.amount!, props.tokenDecimals, 2);
return <>
Bucket Address: {props.bucketAddress}<br />
Recipient: {props.recipient}<br />
Amount: {props.amount}<br />
Expiration Time: {new Date(props.expirationTime * 1000).toLocaleDateString("default", {hour: "numeric", minute: "numeric"})}<br />
Code Hash: {props.codeHash}<br />
Expiration Time: {new Date(props.expirationTime! * 1000).toLocaleDateString("default", {hour: "numeric", minute: "numeric"})}<br />
Code Hash: {props.codeHash} {emptyCode ? "(empty string)" : ""}<br />
Token Address: {props.tokenAddress}<br />
Token Symbol: {props.tokenSymbol}<br />
Token Decimals: {props.tokenDecimals}<br />
@ -112,7 +125,7 @@ export default function(ownProps: any) {
<br /><br /><br />
<button
disabled={props.redeeming}
onClick={() => dispatch(redeem(bucketAddress, recipientAddress, REDEEM_CODE))}>
onClick={() => dispatch(redeem(bucketAddress, recipientAddress, ""))}>
{props.redeeming ? "Redeeming..." : "Redeem"}
</button>
<br />

View File

@ -1,4 +1,3 @@
import EmbarkJS from 'Embark/EmbarkJS';
import React, { useEffect } from 'react';
import ReactDOM from 'react-dom';
import thunkMiddleware from 'redux-thunk';
@ -41,23 +40,21 @@ const store = createStore(
applyMiddleware(...middlewares),
);
EmbarkJS.onReady(err => {
store.dispatch<any>(initializeWeb3());
store.dispatch<any>(initializeWeb3());
ReactDOM.render(
<ErrorBoundary>
<Provider store={store}>
<App>
<ConnectedRouter history={history}>
<Switch>
<Route exact path="/"><Home /></Route>
<Route exact path={redeemPath}><Redeem /></Route>
<Route render={() => "page not found"} />
</Switch>
</ConnectedRouter>
</App>
</Provider>
</ErrorBoundary>,
document.getElementById("root")
);
});
ReactDOM.render(
<ErrorBoundary>
<Provider store={store}>
<App>
<ConnectedRouter history={history}>
<Switch>
<Route exact path="/"><Home /></Route>
<Route exact path={redeemPath}><Redeem /></Route>
<Route render={() => "page not found"} />
</Switch>
</ConnectedRouter>
</App>
</Provider>
</ErrorBoundary>,
document.getElementById("root")
);

View File

@ -1,5 +1,6 @@
import { combineReducers } from 'redux';
import { connectRouter } from 'connected-react-router';
import { History } from 'history';
import {
Web3State,
web3Reducer,
@ -19,7 +20,7 @@ export interface RootState {
redeem: RedeemState,
}
export default function(history) {
export default function(history: History) {
return combineReducers({
web3: web3Reducer,
router: connectRouter(history),

View File

@ -1,6 +1,9 @@
import Web3Utils from "web3-utils";
import { sha3 } from "web3-utils";
import BN from "bn.js";
const BN = Web3Utils.BN;
// keccak256("")
export const KECCAK_EMPTY_STRING = "0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470";
export const KECCAK_EMPTY_STRING2 = sha3(KECCAK_EMPTY_STRING);
export const toBaseUnit = (fullAmount: string, decimalsSize: number, roundDecimals: number) => {
const amount = new BN(fullAmount);

View File

@ -1,188 +0,0 @@
// some packages, plugins, and presets referenced/required in this webpack
// config are deps of embark and will be transitive dapp deps unless specified
// in the dapp's own package.json
// embark modifies process.env.NODE_PATH so that when running dapp scripts in
// embark's child processes, embark's own node_modules directory will be
// searched by node's require(); however, webpack and babel do not directly
// support NODE_PATH, so modules such as babel plugins and presets must be
// resolved with require.resolve(); that is only necessary if a plugin/preset
// is in embark's node_modules vs. the dapp's node_modules
const cloneDeep = require('lodash.clonedeep');
// const CompressionPlugin = require('compression-webpack-plugin');
const glob = require('glob');
const HardSourceWebpackPlugin = require('hard-source-webpack-plugin');
const path = require('path');
const dappPath = process.env.DAPP_PATH;
const embarkPath = process.env.EMBARK_PATH;
const embarkAliases = require(path.join(dappPath, '.embark/embark-aliases.json'));
const embarkAssets = require(path.join(dappPath, '.embark/embark-assets.json'));
const embarkNodeModules = path.join(embarkPath, 'node_modules');
const embarkJson = require(path.join(dappPath, 'embark.json'));
const buildDir = path.join(dappPath, embarkJson.buildDir);
// it's important to `embark reset` if a pkg version is specified in
// embark.json and changed/removed later, otherwise pkg resolution may behave
// unexpectedly
let versions;
try {
versions = glob.sync(path.join(dappPath, '.embark/versions/*/*'));
} catch (e) {
versions = [];
}
const entry = Object.keys(embarkAssets)
.filter(key => key.match(/\.js$/))
.reduce((obj, key) => {
// webpack entry paths should start with './' if they're relative to the
// webpack context; embark.json "app" keys correspond to lists of .js
// source paths relative to the top-level dapp dir and may be missing the
// leading './'
obj[key] = embarkAssets[key]
.map(file => {
let file_path = file.path;
if (!file.path.match(/^\.\//)) {
file_path = './' + file_path;
}
return file_path;
});
return obj;
}, {});
function resolve(pkgName) {
if (Array.isArray(pkgName)) {
const _pkgName = pkgName[0];
pkgName[0] = require.resolve(_pkgName);
return pkgName;
}
return require.resolve(pkgName);
}
// base config
// -----------------------------------------------------------------------------
const base = {
context: dappPath,
entry: entry,
module: {
rules: [
{
test: /\.css$/,
use: [{loader: 'style-loader'}, {loader: 'css-loader'}]
},
{
test: /\.scss$/,
use: [{loader: 'style-loader'}, {loader: 'css-loader'}]
},
{
test: /\.(png|woff|woff2|eot|ttf|svg)$/,
loader: 'url-loader?limit=100000'
},
{
test: /\.(js|jsx|tsx|ts)$/,
loader: 'babel-loader',
exclude: /(node_modules|bower_components|\.embark[\\/]versions)/,
options: {
plugins: [
[
'babel-plugin-module-resolver', {
'alias': embarkAliases
}
],
[
'@babel/plugin-transform-runtime', {
corejs: 2,
useESModules: true
}
]
].map(resolve),
presets: [
[
'@babel/preset-env', {
modules: false,
targets: {
browsers: ['last 1 version', 'not dead', '> 0.2%']
}
}
],
'@babel/preset-react',
'@babel/preset-typescript'
].map(resolve)
}
}
]
},
output: {
filename: (chunkData) => chunkData.chunk.name,
// globalObject workaround for node-compatible UMD builds with webpack 4
// see: https://github.com/webpack/webpack/issues/6522#issuecomment-371120689
globalObject: 'typeof self !== \'undefined\' ? self : this',
libraryTarget: 'umd',
path: buildDir
},
plugins: [new HardSourceWebpackPlugin()],
// profiling and generating verbose stats increases build time; if stats
// are generated embark will write the output to:
// path.join(dappPath, '.embark/stats.[json,report]')
// to visualize the stats info in a browser run:
// npx webpack-bundle-analyzer .embark/stats.json <buildDir>
profile: true, stats: 'verbose',
resolve: {
alias: embarkAliases,
modules: [
...versions,
'node_modules',
embarkNodeModules
]
},
resolveLoader: {
modules: [
'node_modules',
embarkNodeModules
]
}
};
// typescript mods
// -----------------------------------------------------------------------------
base.resolve.extensions = [
// webpack defaults
// see: https://webpack.js.org/configuration/resolve/#resolve-extensions
'.wasm', '.mjs', '.js', '.json',
// typescript extensions
'.ts', '.tsx'
];
// development config
// -----------------------------------------------------------------------------
const development = cloneDeep(base);
// full source maps increase build time but are useful during dapp development
development.devtool = 'source-map';
development.mode = 'development';
// alternatively:
// development.mode = 'none';
development.name = 'development';
const devBabelLoader = development.module.rules[3];
devBabelLoader.options.compact = false;
// production config
// -----------------------------------------------------------------------------
const production = cloneDeep(base);
production.mode = 'production';
production.name = 'production';
// compression of webpack's JS output not enabled by default
// production.plugins.push(new CompressionPlugin());
// export a list of named configs
// -----------------------------------------------------------------------------
module.exports = [
development,
production
];

6389
yarn.lock

File diff suppressed because it is too large Load Diff