├── .eslintrc.js
├── .gitignore
├── .travis.yml
├── .vscode
├── launch.json
└── settings.json
├── 99999_addresses.txt
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── LICENSE
├── README.md
├── contracts
├── Airdrop.sol
├── BalanceVerifier.sol
├── DemoToken.sol
├── Migrations.sol
├── Monoplasma.sol
└── Ownable.sol
├── copy-abi-to-ui.js
├── defaultServers.json
├── demo
├── .eslintignore
├── .eslintrc
├── .flowconfig
├── .gitignore
├── gatsby-config.js
├── package-lock.json
├── package.json
├── postcss.config.js
├── src
│ ├── components
│ │ ├── Button
│ │ │ ├── button.module.css
│ │ │ └── index.jsx
│ │ ├── Clock
│ │ │ └── index.jsx
│ │ ├── Container
│ │ │ ├── container.module.css
│ │ │ └── index.jsx
│ │ ├── Home
│ │ │ ├── About
│ │ │ │ ├── about.module.css
│ │ │ │ └── index.jsx
│ │ │ ├── Blocks
│ │ │ │ ├── blocks.module.css
│ │ │ │ └── index.jsx
│ │ │ ├── Headline
│ │ │ │ ├── headline.module.css
│ │ │ │ └── index.jsx
│ │ │ ├── Hero
│ │ │ │ ├── hero.module.css
│ │ │ │ └── index.jsx
│ │ │ ├── Management
│ │ │ │ ├── index.jsx
│ │ │ │ └── management.module.css
│ │ │ ├── RevenuePoolActions
│ │ │ │ ├── index.jsx
│ │ │ │ └── revenuePoolActions.module.css
│ │ │ ├── Section
│ │ │ │ └── index.jsx
│ │ │ ├── Settings
│ │ │ │ ├── index.jsx
│ │ │ │ └── settings.module.css
│ │ │ ├── Stats
│ │ │ │ ├── Stat
│ │ │ │ │ ├── index.jsx
│ │ │ │ │ └── stat.module.css
│ │ │ │ ├── index.jsx
│ │ │ │ └── stats.module.css
│ │ │ ├── UserActions
│ │ │ │ ├── index.jsx
│ │ │ │ └── userActions.module.css
│ │ │ ├── home.module.css
│ │ │ └── index.jsx
│ │ ├── Input
│ │ │ ├── index.js
│ │ │ └── input.module.css
│ │ ├── Layout
│ │ │ ├── index.jsx
│ │ │ └── layout.css
│ │ ├── Notification
│ │ │ ├── index.jsx
│ │ │ └── notification.module.css
│ │ └── Tooltip
│ │ │ ├── index.jsx
│ │ │ └── tooltip.module.css
│ ├── containers
│ │ ├── Home
│ │ │ └── index.jsx
│ │ └── Wallet
│ │ │ └── index.jsx
│ ├── contexts
│ │ ├── Home
│ │ │ └── index.js
│ │ └── Wallet
│ │ │ └── index.js
│ ├── pages
│ │ └── index.jsx
│ ├── stylesheets
│ │ └── variables.css
│ └── utils
│ │ ├── formatFixedDecimal.js
│ │ ├── monoplasmaAbi.json
│ │ ├── proxyRouter.js
│ │ └── tokenAbi.json
└── static
│ └── favicon.ico
├── flatten
├── index.js
├── migrations
└── 1_initial_migration.js
├── package-lock.json
├── package.json
├── src
├── fileStore.js
├── joinPartChannel.js
├── member.js
├── merkletree.js
├── operator.js
├── routers
│ ├── admin.js
│ ├── member.js
│ └── revenueDemo.js
├── state.js
├── utils
│ ├── checkArguments.js
│ ├── deployDemoToken.js
│ ├── events.js
│ ├── formatDecimals.js
│ ├── now.js
│ ├── partitionArray.js
│ └── startGanache.js
├── validator.js
└── watcher.js
├── start_operator.js
├── start_validator.js
├── strings
├── BalanceVerifier.json
├── Monoplasma.json
└── Ownable.json
├── test
├── .eslintrc.js
├── e2e
│ └── revenue-sharing-demo.js
├── mocha
│ ├── fileStore.js
│ ├── joinPartChannel.js
│ ├── member.js
│ ├── merkletree.js
│ ├── routers
│ │ └── member.js
│ ├── state.js
│ ├── test-utils.js
│ ├── utils
│ │ ├── events.js
│ │ ├── formatDecimals.js
│ │ ├── now.js
│ │ └── partitionArray.js
│ ├── validator.js
│ └── watcher.js
├── truffle
│ ├── .eslintrc.js
│ ├── BalanceVerifier.js
│ ├── FailToken.json
│ ├── FailToken.sol.txt
│ ├── Monoplasma.js
│ ├── Ownable.js
│ └── increaseTime.js
└── utils
│ ├── await-until.js
│ ├── increaseTime.js
│ ├── joinPartChannel-client.js
│ ├── joinPartChannel-server.js
│ ├── mockChannel.js
│ ├── mockStore.js
│ ├── mockWeb3.js
│ ├── operatorApi.js
│ ├── sleep-promise.js
│ └── web3Assert.js
└── truffle-config.js
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | "env": {
3 | "node": true,
4 | "es6": true
5 | },
6 | "extends": "eslint:recommended",
7 | "parserOptions": {
8 | "ecmaVersion": 2017
9 | },
10 | "rules": {
11 | "indent": [
12 | "error",
13 | 4,
14 | {
15 | "SwitchCase": 1,
16 | "flatTernaryExpressions": true
17 | },
18 | ],
19 | "linebreak-style": [
20 | "error",
21 | "unix"
22 | ],
23 | "quotes": [
24 | "error",
25 | "double"
26 | ],
27 | "semi": [
28 | "error",
29 | "never"
30 | ],
31 | "no-console": "warn",
32 | "comma-spacing": ["error", { "before": false, "after": true }]
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | .idea/workspace.xml
3 | build
4 | static_web/airdrop/0x*
5 | test/e2e/test-store-*
6 | coverage.json
7 | coverage
8 | monoplasma.iml
9 | .idea
10 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: node_js
2 | node_js:
3 | - "10.14.0"
4 | before_install:
5 | - "npm i -g npm@6.4.1"
6 | branches:
7 | only:
8 | - master
9 | - /^v\d+\.\d+(\.\d+)?(-\S*)?$/
10 | jobs:
11 | include:
12 | - script:
13 | - "npm run lint"
14 | - "npm run test"
15 | stage: "lint & tests"
16 |
--------------------------------------------------------------------------------
/.vscode/launch.json:
--------------------------------------------------------------------------------
1 | {
2 | // Use IntelliSense to learn about possible attributes.
3 | // Hover to view descriptions of existing attributes.
4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
5 | "version": "0.2.0",
6 | "configurations": [
7 | {
8 | "name": "Revenue sharing demo web page",
9 | "type": "chrome",
10 | "request": "launch",
11 | "url": "http://localhost/index.html",
12 | "webRoot": "${workspaceFolder}/static_web"
13 | },
14 | {
15 | "name": "Revenue sharing demo web page",
16 | "type": "chrome",
17 | "request": "launch",
18 | "file": "${workspaceFolder}/static_web/index.html"
19 | },
20 | {
21 | "type": "node",
22 | "request": "launch",
23 | "name": "Truffle test current file",
24 | "runtimeExecutable": "${workspaceFolder}/node_modules/.bin/truffle",
25 | "runtimeArgs": [
26 | "test",
27 | "--inspect-brk",
28 | "${file}"
29 | ],
30 | "internalConsoleOptions": "openOnSessionStart"
31 | }, {
32 | "type": "node",
33 | "request": "launch",
34 | "name": "Mocha test current file",
35 | "runtimeExecutable": "${workspaceFolder}/node_modules/.bin/mocha",
36 | "runtimeArgs": [
37 | "${file}"
38 | ],
39 | "internalConsoleOptions": "openOnSessionStart"
40 | }, {
41 | "type": "node",
42 | "request": "launch",
43 | "name": "Run this file",
44 | "program": "${file}",
45 | }, {
46 | "type": "node",
47 | "request": "launch",
48 | "name": "Truffle test all",
49 | "program": "${workspaceFolder}/node_modules/.bin/truffle",
50 | "args": [
51 | "test",
52 | "${workspaceFolder}/test/truffle/"
53 | ],
54 | "internalConsoleOptions": "openOnSessionStart"
55 | }, {
56 | "type": "node",
57 | "request": "launch",
58 | "name": "Mocha test all",
59 | "program": "${workspaceFolder}/node_modules/.bin/mocha",
60 | "args": [
61 | "${workspaceFolder}/test/mocha/"
62 | ],
63 | "internalConsoleOptions": "openOnSessionStart"
64 | }, {
65 | "type": "node",
66 | "request": "launch",
67 | "name": "operator (Ganache)",
68 | "program": "${workspaceFolder}/start_operator.js"
69 | }, {
70 | "type": "node",
71 | "request": "launch",
72 | "name": "operator (localhost:8545 + web:8080)",
73 | "program": "${workspaceFolder}/start_operator.js",
74 | "env": {
75 | "EXTERNAL_UI_SERVER": "http://localhost:8080/",
76 | "ETHEREUM_SERVER": "ws://localhost:8545/",
77 | "ETHEREUM_PRIVATE_KEY": "0x5e98cce00cff5dea6b454889f359a4ec06b9fa6b88e9d69b86de8e1c81887da0"
78 | }
79 | }, {
80 | "type": "node",
81 | "request": "launch",
82 | "name": "operator (rinkeby infura)",
83 | "program": "${workspaceFolder}/start_operator.js",
84 | "env": {
85 | "ETHEREUM_NETWORK_ID": "4",
86 | "ETHEREUM_PRIVATE_KEY": "0x6E340F41A1C6E03E6E0A4E9805D1CEA342F6A299E7C931D6F3DA6DD34CB6E17D",
87 | "__ADDRESS_FOR_THE_ABOVE_KEY": "0xb3428050eA2448eD2E4409bE47E1a50EBac0B2d2",
88 | "TOKEN_ADDRESS": "0x0c89795f75eEA923Cd1faEea50777c1B87787A06",
89 | "CONTRACT_ADDRESS": "0xC2fA5C809C293aFE830F20037eE33d5521e9e036"
90 | }
91 | }, {
92 | "type": "node",
93 | "request": "launch",
94 | "name": "Start Monoplasma validator",
95 | "program": "${workspaceFolder}/start_validator.js",
96 | "env": {
97 | "WATCHED_ACCOUNTS": "0x41ad2327e5910dcca156dc17d0908b690e2f1f7c,0x0e7a1cf7cf69299c20af39056af232fde05b5204,0x297f393328243147e5fd08bd4b8b3786150635cd,0x1612801262e358bdc6031136c0c2104e7d3bd78e,0x2526401999f6502058d57dee734d2277d8669ce7"
98 | }
99 | }
100 | ]
101 | }
102 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "javascript.implicitProjectConfig.checkJs": true,
3 |
4 | "solidity.compileUsingRemoteVersion": "0.5.16+commit.9c3226c"
5 | }
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Contributor Covenant Code of Conduct
2 |
3 | ## Our Pledge
4 |
5 | In the interest of fostering an open and welcoming environment, we as
6 | contributors and maintainers pledge to making participation in our project and
7 | our community a harassment-free experience for everyone, regardless of age, body
8 | size, disability, ethnicity, sex characteristics, gender identity and expression,
9 | level of experience, education, socio-economic status, nationality, personal
10 | appearance, race, religion, or sexual identity and orientation.
11 |
12 | ## Our Standards
13 |
14 | Examples of behavior that contributes to creating a positive environment
15 | include:
16 |
17 | * Using welcoming and inclusive language
18 | * Being respectful of differing viewpoints and experiences
19 | * Gracefully accepting constructive criticism
20 | * Focusing on what is best for the community
21 | * Showing empathy towards other community members
22 |
23 | Examples of unacceptable behavior by participants include:
24 |
25 | * The use of sexualized language or imagery and unwelcome sexual attention or
26 | advances
27 | * Trolling, insulting/derogatory comments, and personal or political attacks
28 | * Public or private harassment
29 | * Publishing others' private information, such as a physical or electronic
30 | address, without explicit permission
31 | * Other conduct which could reasonably be considered inappropriate in a
32 | professional setting
33 |
34 | ## Our Responsibilities
35 |
36 | Project maintainers are responsible for clarifying the standards of acceptable
37 | behavior and are expected to take appropriate and fair corrective action in
38 | response to any instances of unacceptable behavior.
39 |
40 | Project maintainers have the right and responsibility to remove, edit, or
41 | reject comments, commits, code, wiki edits, issues, and other contributions
42 | that are not aligned to this Code of Conduct, or to ban temporarily or
43 | permanently any contributor for other behaviors that they deem inappropriate,
44 | threatening, offensive, or harmful.
45 |
46 | ## Scope
47 |
48 | This Code of Conduct applies both within project spaces and in public spaces
49 | when an individual is representing the project or its community. Examples of
50 | representing a project or community include using an official project e-mail
51 | address, posting via an official social media account, or acting as an appointed
52 | representative at an online or offline event. Representation of a project may be
53 | further defined and clarified by project maintainers.
54 |
55 | ## Enforcement
56 |
57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be
58 | reported by contacting the project team at contact@streamr.com. All
59 | complaints will be reviewed and investigated and will result in a response that
60 | is deemed necessary and appropriate to the circumstances. The project team is
61 | obligated to maintain confidentiality with regard to the reporter of an incident.
62 | Further details of specific enforcement policies may be posted separately.
63 |
64 | Project maintainers who do not follow or enforce the Code of Conduct in good
65 | faith may face temporary or permanent repercussions as determined by other
66 | members of the project's leadership.
67 |
68 | ## Attribution
69 |
70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
72 |
73 | [homepage]: https://www.contributor-covenant.org
74 |
75 | For answers to common questions about this code of conduct, see
76 | https://www.contributor-covenant.org/faq
77 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | == How to contribute ==
2 |
3 | * Check out the repo
4 | * Run the demo using [instructions in the README](README.md)
5 | * If you bump into problems:
6 | * Fix them, and open a pull request :)
7 | * Contact us in [Telegram](https://t.me/streamrdata)
8 |
9 | Thanks, happy hacking!
10 |
--------------------------------------------------------------------------------
/contracts/Airdrop.sol:
--------------------------------------------------------------------------------
1 | pragma solidity ^0.5.16;
2 |
3 | import "openzeppelin-solidity/contracts/math/SafeMath.sol";
4 | import "openzeppelin-solidity/contracts/token/ERC20/IERC20.sol";
5 |
6 | import "./BalanceVerifier.sol";
7 | import "./Ownable.sol";
8 |
9 | /**
10 | * Hypothetical "continuous airdrop" where recipients can withdraw tokens allocated off-chain.
11 | * Simplest root chain contract implementation.
12 | * For purposes of illustrating implementing the BalanceVerifier, and mainly for testing it.
13 | */
14 | contract Airdrop is BalanceVerifier, Ownable {
15 | using SafeMath for uint256;
16 |
17 | IERC20 public token;
18 | mapping (address => uint) public withdrawn;
19 |
20 | constructor(address tokenAddress) Ownable() public {
21 | token = IERC20(tokenAddress);
22 | }
23 |
24 | /**
25 | * Owner commits the balances
26 | */
27 | function onCommit(uint, bytes32, string memory) internal {
28 | require(msg.sender == owner, "error_notPermitted");
29 | }
30 |
31 | /**
32 | * Called from BalanceVerifier.prove, perform payout directly (ignoring re-entrancy problems for simplicity)
33 | */
34 | function onVerifySuccess(uint, address account, uint balance) internal {
35 | require(withdrawn[account] < balance, "error_oldEarnings");
36 | uint withdrawable = balance.sub(withdrawn[account]);
37 | withdrawn[account] = balance;
38 | require(token.transfer(account, withdrawable), "error_transfer");
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/contracts/BalanceVerifier.sol:
--------------------------------------------------------------------------------
1 | pragma solidity ^0.5.16;
2 |
3 | /**
4 | * Abstract contract, requires implementation to specify who can publish commits and what
5 | * happens when a successful proof is presented
6 | * Verifies Merkle-tree inclusion proofs that show that certain address has
7 | * certain earnings balance, according to hash published ("signed") by a
8 | * sidechain operator or similar authority
9 | *
10 | * ABOUT Merkle-tree inclusion proof: Merkle-tree inclusion proof is an algorithm to prove memebership
11 | * in a set using minimal [ie log(N)] inputs. The hashes of the items are arranged by hash value in a binary Merkle tree where
12 | * each node contains a hash of the hashes of nodes below. The root node (ie "root hash") contains hash information
13 | * about the entire set, and that is the data that BalanceVerifier posts to the blockchain. To prove membership, you walk up the
14 | * tree from the node in question, and use the supplied hashes (the "proof") to fill in the hashes from the adjacent nodes. The proof
15 | * succeeds iff you end up with the known root hash when you get to the top of the tree.
16 | * See https://medium.com/crypto-0-nite/merkle-proofs-explained-6dd429623dc5
17 | *
18 | * Merkle-tree inclusion proof is a related concept to the blockchain Merkle tree, but a somewhat different application.
19 | * BalanceVerifier posts the root hash of the current ledger only, and this does not depend on the hash of previous ledgers.
20 | * This is different from the blockchain, where each block contains the hash of the previous block.
21 | *
22 | * TODO: see if it could be turned into a library, so many contracts could use it
23 | */
24 | contract BalanceVerifier {
25 | event NewCommit(uint blockNumber, bytes32 rootHash, string ipfsHash);
26 |
27 | /**
28 | * Root hashes of merkle-trees constructed from its balances
29 | * @param uint root-chain block number after which the balances were committed
30 | * @return bytes32 root of the balances merkle-tree at that time
31 | */
32 | mapping (uint => bytes32) public committedHash;
33 |
34 | /**
35 | * Handler for proof of off-chain balances
36 | * It is up to the implementing contract to actually distribute out the balances
37 | * @param blockNumber the block whose hash was used for verification
38 | * @param account whose balances were successfully verified
39 | * @param balance the off-chain account balance
40 | */
41 | function onVerifySuccess(uint blockNumber, address account, uint balance) internal;
42 |
43 | /**
44 | * Implementing contract should should do access controls for committing
45 | */
46 | function onCommit(uint blockNumber, bytes32 rootHash, string memory ipfsHash) internal;
47 |
48 | /**
49 | * Monoplasma operator submits commitments to root-chain.
50 | * For convenience, also publish the ipfsHash of the balance book JSON object
51 | * @param blockNumber the root-chain block after which the balances were recorded
52 | * @param rootHash root of the balances merkle-tree
53 | * @param ipfsHash where the whole balances object can be retrieved in JSON format
54 | */
55 | function commit(uint blockNumber, bytes32 rootHash, string calldata ipfsHash) external {
56 | require(committedHash[blockNumber] == 0x0, "error_overwrite");
57 | string memory _hash = ipfsHash;
58 | onCommit(blockNumber, rootHash, _hash); // Access control delegated to implementing class
59 | committedHash[blockNumber] = rootHash;
60 | emit NewCommit(blockNumber, rootHash, _hash);
61 | }
62 |
63 | /**
64 | * Proving can be used to record the sidechain balances permanently into root chain
65 | * @param blockNumber the block after which the balances were recorded
66 | * @param account whose balances will be verified
67 | * @param balance off-chain account balance
68 | * @param proof list of hashes to prove the totalEarnings
69 | */
70 | function prove(uint blockNumber, address account, uint balance, bytes32[] memory proof) public {
71 | require(proofIsCorrect(blockNumber, account, balance, proof), "error_proof");
72 | onVerifySuccess(blockNumber, account, balance);
73 | }
74 |
75 | /**
76 | * Check the merkle proof of balance in the given commit (after blockNumber in root-chain) for given account
77 | * @param blockNumber the block after which the balances were recorded
78 | * @param account whose balances will be verified
79 | * @param balance off-chain account balance
80 | * @param proof list of hashes to prove the totalEarnings
81 | */
82 | function proofIsCorrect(uint blockNumber, address account, uint balance, bytes32[] memory proof) public view returns(bool) {
83 | bytes32 leafHash = keccak256(abi.encodePacked(account, balance, blockNumber));
84 | bytes32 rootHash = committedHash[blockNumber];
85 | require(rootHash != 0x0, "error_blockNotFound");
86 | return rootHash == calculateRootHash(leafHash, proof);
87 | }
88 |
89 | /**
90 | * Calculate root hash of a Merkle tree, given
91 | * @param leafHash of the member whose balances are being be verified
92 | * @param others list of hashes of "other" branches
93 | */
94 | function calculateRootHash(bytes32 leafHash, bytes32[] memory others) public pure returns (bytes32 root) {
95 | root = leafHash;
96 | for (uint8 i = 0; i < others.length; i++) {
97 | bytes32 other = others[i];
98 | if (root < other) {
99 | // TODO: consider hashing in i to defend from https://en.wikipedia.org/wiki/Merkle_tree#Second_preimage_attack
100 | root = keccak256(abi.encodePacked(root, other));
101 | } else {
102 | root = keccak256(abi.encodePacked(other, root));
103 | }
104 | }
105 | }
106 | }
107 |
--------------------------------------------------------------------------------
/contracts/DemoToken.sol:
--------------------------------------------------------------------------------
1 | pragma solidity ^0.5.16;
2 |
3 | import "openzeppelin-solidity/contracts/token/ERC20/ERC20Mintable.sol";
4 | import "openzeppelin-solidity/contracts/token/ERC20/ERC20Detailed.sol";
5 |
6 | /**
7 | * ERC20 token for demo purposes
8 | * Creator starts with 1 million tokens
9 | */
10 | contract DemoToken is ERC20Mintable, ERC20Detailed {
11 | constructor(string memory name, string memory symbol) ERC20Detailed(name, symbol, 18) public {
12 | mint(msg.sender, 10**24);
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/contracts/Migrations.sol:
--------------------------------------------------------------------------------
1 | pragma solidity ^0.5.16;
2 |
3 | contract Migrations {
4 | address public owner;
5 | uint public last_completed_migration;
6 |
7 | constructor() public {
8 | owner = msg.sender;
9 | }
10 |
11 | modifier restricted() {
12 | if (msg.sender == owner) _;
13 | }
14 |
15 | function setCompleted(uint completed) public restricted {
16 | last_completed_migration = completed;
17 | }
18 |
19 | function upgrade(address new_address) public restricted {
20 | Migrations upgraded = Migrations(new_address);
21 | upgraded.setCompleted(last_completed_migration);
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/contracts/Ownable.sol:
--------------------------------------------------------------------------------
1 | pragma solidity ^0.5.16;
2 |
3 | /**
4 | * @title Ownable
5 | * @dev The Ownable contract has an owner address, and provides basic authorization control
6 | * functions, this simplifies the implementation of "user permissions".
7 | */
8 | contract Ownable {
9 | address public owner;
10 | address public pendingOwner;
11 |
12 | event OwnershipTransferred(
13 | address indexed previousOwner,
14 | address indexed newOwner
15 | );
16 |
17 | /**
18 | * @dev The Ownable constructor sets the original `owner` of the contract to the sender
19 | * account.
20 | */
21 | constructor() public {
22 | owner = msg.sender;
23 | }
24 |
25 | /**
26 | * @dev Throws if called by any account other than the owner.
27 | */
28 | modifier onlyOwner() {
29 | require(msg.sender == owner, "error_onlyOwner");
30 | _;
31 | }
32 |
33 | /**
34 | * @dev Allows the current owner to set the pendingOwner address.
35 | * @param newOwner The address to transfer ownership to.
36 | */
37 | function transferOwnership(address newOwner) public onlyOwner {
38 | pendingOwner = newOwner;
39 | }
40 |
41 | /**
42 | * @dev Allows the pendingOwner address to finalize the transfer.
43 | */
44 | function claimOwnership() public {
45 | require(msg.sender == pendingOwner, "error_onlyPendingOwner");
46 | emit OwnershipTransferred(owner, pendingOwner);
47 | owner = pendingOwner;
48 | pendingOwner = address(0);
49 | }
50 | }
--------------------------------------------------------------------------------
/copy-abi-to-ui.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | const fs = require("fs")
4 |
5 | const tokenAbi = JSON.parse(fs.readFileSync("build/contracts/DemoToken.json")).abi
6 | fs.writeFileSync("demo/src/utils/tokenAbi.json", JSON.stringify(tokenAbi))
7 |
8 | let monoplasmaAbi = JSON.parse(fs.readFileSync("build/contracts/Monoplasma.json")).abi
9 | fs.writeFileSync("demo/src/utils/monoplasmaAbi.json", JSON.stringify(monoplasmaAbi))
10 |
--------------------------------------------------------------------------------
/defaultServers.json:
--------------------------------------------------------------------------------
1 | {
2 | "1": "wss://mainnet.infura.io/ws",
3 | "3": "wss://ropsten.infura.io/ws",
4 | "4": "wss://rinkeby.infura.io/ws"
5 | }
--------------------------------------------------------------------------------
/demo/.eslintignore:
--------------------------------------------------------------------------------
1 | static/*
--------------------------------------------------------------------------------
/demo/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "streamr"
3 | }
4 |
--------------------------------------------------------------------------------
/demo/.flowconfig:
--------------------------------------------------------------------------------
1 | [ignore]
2 |
3 | [include]
4 |
5 | [libs]
6 |
7 | [options]
8 | module.name_mapper.extension='css' -> 'empty/object'
9 |
--------------------------------------------------------------------------------
/demo/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 |
8 | # Runtime data
9 | pids
10 | *.pid
11 | *.seed
12 | *.pid.lock
13 |
14 | # Directory for instrumented libs generated by jscoverage/JSCover
15 | lib-cov
16 |
17 | # Coverage directory used by tools like istanbul
18 | coverage
19 |
20 | # nyc test coverage
21 | .nyc_output
22 |
23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
24 | .grunt
25 |
26 | # Bower dependency directory (https://bower.io/)
27 | bower_components
28 |
29 | # node-waf configuration
30 | .lock-wscript
31 |
32 | # Compiled binary addons (http://nodejs.org/api/addons.html)
33 | build/Release
34 |
35 | # Dependency directories
36 | node_modules/
37 | jspm_packages/
38 |
39 | # Typescript v1 declaration files
40 | typings/
41 |
42 | # Optional npm cache directory
43 | .npm
44 |
45 | # Optional eslint cache
46 | .eslintcache
47 |
48 | # Optional REPL history
49 | .node_repl_history
50 |
51 | # Output of 'npm pack'
52 | *.tgz
53 |
54 | # dotenv environment variables file
55 | .env
56 |
57 | # gatsby files
58 | .cache/
59 | public
60 |
61 | # Mac files
62 | .DS_Store
63 |
64 | # Yarn
65 | yarn-error.log
66 | .pnp/
67 | .pnp.js
68 | # Yarn Integrity file
69 | .yarn-integrity
70 |
--------------------------------------------------------------------------------
/demo/gatsby-config.js:
--------------------------------------------------------------------------------
1 | const proxyRouter = require('./src/utils/proxyRouter')
2 |
3 | module.exports = {
4 | plugins: [
5 | 'gatsby-plugin-eslint',
6 | 'gatsby-plugin-flow',
7 | 'gatsby-plugin-postcss',
8 | 'gatsby-plugin-react-helmet',
9 | ],
10 | developMiddleware: (app) => {
11 | app.use(proxyRouter)
12 | },
13 | }
14 |
--------------------------------------------------------------------------------
/demo/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@streamr/revenue-sharing-demo",
3 | "private": true,
4 | "description": "Revenue sharing demo.",
5 | "version": "0.1.0",
6 | "license": "UNLICENSED",
7 | "scripts": {
8 | "build": "gatsby build",
9 | "develop": "gatsby develop -H 0.0.0.0",
10 | "start": "npm run develop",
11 | "serve": "gatsby serve",
12 | "test": "echo \"Write tests! -> https://gatsby.app/unit-testing\""
13 | },
14 | "dependencies": {
15 | "bn.js": "^4.11.8",
16 | "classnames": "^2.2.6",
17 | "empty": "^0.10.1",
18 | "eslint": "^5.13.0",
19 | "eslint-config-airbnb": "^17.1.0",
20 | "eslint-config-streamr": "^1.1.8",
21 | "eslint-loader": "^2.1.1",
22 | "ethjs": "^0.4.0",
23 | "express": "^4.16.4",
24 | "flow-bin": "^0.92.0",
25 | "gatsby": "^2.0.111",
26 | "gatsby-plugin-eslint": "^2.0.4",
27 | "gatsby-plugin-flow": "^1.0.2",
28 | "gatsby-plugin-postcss": "^2.0.5",
29 | "gatsby-plugin-react-helmet": "^3.0.6",
30 | "http-proxy-middleware": "^0.19.1",
31 | "normalize.css": "^8.0.1",
32 | "precss": "^4.0.0",
33 | "react": "^16.8.1",
34 | "react-dom": "^16.8.1",
35 | "react-helmet": "^5.2.0",
36 | "time-ago": "^0.2.1"
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/demo/postcss.config.js:
--------------------------------------------------------------------------------
1 | const path = require('path')
2 | const precss = require('precss')
3 |
4 | module.exports = {
5 | plugins: [
6 | precss({
7 | importFrom: [
8 | path.resolve(__dirname, 'src/stylesheets/variables.css'),
9 | ],
10 | }),
11 | ],
12 | }
13 |
--------------------------------------------------------------------------------
/demo/src/components/Button/button.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | position: relative;
3 | }
4 |
5 | .inner {
6 | border-radius: 4px;
7 | color: white;
8 | cursor: pointer;
9 | font-family: var(--mono);
10 | font-size: 1em;
11 | font-weight: var(--semiBold);
12 | height: 4em;
13 | letter-spacing: 1.33px;
14 | line-height: 1em;
15 | padding: 0;
16 | text-transform: uppercase;
17 | transition: 150ms ease-in;
18 | transition-property: transform, background-color, border-color, opacity;
19 | width: 100%;
20 |
21 | &:--interact {
22 | background-color: #0324ff;
23 | border-color: #0324ff;
24 | outline: 0;
25 | transform: scale(1.05);
26 | transition-duration: 75ms;
27 | transition-time-function: ease-out;
28 | }
29 |
30 | &:--disabled,
31 | &:--idle {
32 | transform: scale(1);
33 | }
34 |
35 | &,
36 | &:active {
37 | background-color: #2037ce;
38 | border: 1px solid #2037ce;
39 | }
40 |
41 | &:--disabled {
42 | background-color: #2037ce;
43 | cursor: default;
44 | border: 1px solid #2037ce;
45 | opacity: 0.5;
46 | }
47 |
48 | &:--interact + .tooltip {
49 | opacity: 1;
50 | transform: translateY(0.5rem);
51 | visibility: visible;
52 | }
53 |
54 | &:--disabled + .tooltip {
55 | opacity: 0;
56 | transform: translateY(0.5rem);
57 | visibility: hidden;
58 | }
59 |
60 | &:--disabled + .tooltip,
61 | &:--interact + .tooltip {
62 | transition-delay: 0s, 0s, 0s;
63 | }
64 | }
65 |
66 | .edge,
67 | .redEdge {
68 | &,
69 | &:--idle,
70 | &:--disabled,
71 | &:--interact {
72 | background-color: transparent;
73 | }
74 | }
75 |
76 | .edge {
77 | &:--interact {
78 | color: #0324ff;
79 | }
80 |
81 | &,
82 | &:--disabled,
83 | &:active {
84 | color: #2037ce;
85 | }
86 | }
87 |
88 | .redEdge {
89 | &:--interact {
90 | border-color: #ff0f2d;
91 | color: #ff0f2d;
92 | }
93 |
94 | &,
95 | &:--disabled,
96 | &:active {
97 | border-color: #d71c34;
98 | color: #d71c34;
99 | }
100 | }
101 |
--------------------------------------------------------------------------------
/demo/src/components/Button/index.jsx:
--------------------------------------------------------------------------------
1 | // @flow
2 |
3 | import React, { type Node } from 'react'
4 | import cx from 'classnames'
5 | import Tooltip from '../Tooltip'
6 |
7 | import styles from './button.module.css'
8 |
9 | type Props = {
10 | children: Node,
11 | className?: string,
12 | theme?: 'default' | 'edge' | 'red-edge',
13 | tooltip?: string,
14 | }
15 |
16 | const Button = ({
17 | children,
18 | className,
19 | theme,
20 | tooltip,
21 | ...props
22 | }: Props) => (
23 |
24 |
32 | {children}
33 |
34 | {tooltip && (
35 |
36 | {tooltip}
37 |
38 | )}
39 |
40 | )
41 |
42 | export default Button
43 |
--------------------------------------------------------------------------------
/demo/src/components/Clock/index.jsx:
--------------------------------------------------------------------------------
1 | // @flow
2 |
3 | import { useState, type Node, useEffect } from 'react'
4 |
5 | type Props = {
6 | children: (number) => Node,
7 | }
8 |
9 | const Clock = ({ children }: Props) => {
10 | const [timestamp, setTimestamp] = useState(new Date().getTime())
11 |
12 | useEffect(() => {
13 | const interval: IntervalID = setInterval(() => {
14 | setTimestamp(new Date().getTime())
15 | }, 1000)
16 |
17 | return () => {
18 | clearInterval(interval)
19 | }
20 | })
21 |
22 | return children(timestamp)
23 | }
24 |
25 | export default Clock
26 |
--------------------------------------------------------------------------------
/demo/src/components/Container/container.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | margin: 0 auto;
3 | position: relative;
4 | width: 896px;
5 | }
6 |
--------------------------------------------------------------------------------
/demo/src/components/Container/index.jsx:
--------------------------------------------------------------------------------
1 | // @flow
2 |
3 | import React, { type Node } from 'react'
4 | import cx from 'classnames'
5 |
6 | import styles from './container.module.css'
7 |
8 | type Props = {
9 | children: Node,
10 | className?: string,
11 | }
12 |
13 | const Container = ({ children, className }: Props) => (
14 |
15 | {children}
16 |
17 | )
18 |
19 | export default Container
20 |
--------------------------------------------------------------------------------
/demo/src/components/Home/About/about.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | line-height: 1.75em;
3 | }
4 |
5 | .root + .root {
6 | margin-top: 6rem;
7 | }
8 |
9 | .root p,
10 | .root ul {
11 | margin: 0;
12 |
13 | + p,
14 | + ul {
15 | margin-top: 1.75em;
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/demo/src/components/Home/About/index.jsx:
--------------------------------------------------------------------------------
1 | // @flow
2 |
3 | import React from 'react'
4 | import Section from '../Section'
5 |
6 | import styles from './about.module.css'
7 |
8 | const About = () => (
9 |
10 |
11 | This is a demonstration of how Monoplasma contracts can be used to implement basic ERC-20 token revenue
12 | sharing. There are three main roles in the system:
13 |
14 |
15 |
16 | Administrator
17 | who owns the root chain contract, selects operator and members
18 |
19 |
20 | Operator
21 | who commits the off-chain balances to root chain
22 |
23 |
24 | Validator
25 | who also runs the off-chain calculations and checks the commits
26 |
27 |
28 | Member
29 | of the revenue sharing community who has the primary interest in ensuring operator honesty
30 |
31 |
32 |
33 | The Operator
34 |
35 | Normally the operator publishes commits when the first revenue transaction arrives after
36 | a cooldown period. This cooldown period plus the freeze period is the time it takes for
37 | a member to be able to withdraw tokens after the token transaction where they earned them. The point
38 | of the cooldown period is to not unnecessarily pay for commit transaction if revenue arrives rapidly.
39 |
40 |
41 | All tokens earned during this period are in the hands of the operator (similar to tokens on an
42 | exchange). The member can lose only those tokens in case of total breakdown of the operator.
43 | By pressing the Force Publish button you can ask the operator to send a commit transaction even during
44 | the cooldown period or if no revenue has been added.
45 |
46 |
47 | )
48 |
49 | About.styles = styles
50 |
51 | export default About
52 |
--------------------------------------------------------------------------------
/demo/src/components/Home/Blocks/blocks.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | border: solid #cdcdcd;
3 | border-width: 1px 0;
4 | font-family: var(--mono);
5 | font-size: 0.75rem;
6 | line-height: 1em;
7 | }
8 |
9 | .row {
10 | display: grid;
11 | grid-template-columns: 1fr 1fr 1fr 1fr;
12 | position: relative;
13 |
14 | > div {
15 | padding: 1em 2rem;
16 | }
17 |
18 | > div + div {
19 | border-left: 1px solid #cdcdcd;
20 | }
21 |
22 | &:--even {
23 | background-color: #ececec;
24 | }
25 |
26 | &.frozen {
27 | background-color: #eaf1f3;
28 | }
29 |
30 | &.frozen:--even {
31 | background-color: #e5ecee;
32 | }
33 | }
34 |
35 | .columnNames {
36 | border-bottom: 1px solid #cdcdcd;
37 | font-weight: var(--medium);
38 | letter-spacing: 1.5px;
39 | text-transform: uppercase;
40 |
41 | > div {
42 | padding-bottom: 1.5em;
43 | padding-top: 1.5em;
44 | }
45 | }
46 |
47 | .flake {
48 | left: 0.5rem;
49 | position: absolute;
50 | top: 50%;
51 | transform: translateY(-50%);
52 | width: 14px;
53 | }
54 |
--------------------------------------------------------------------------------
/demo/src/components/Home/Blocks/index.jsx:
--------------------------------------------------------------------------------
1 | // @flow
2 |
3 | import React from 'react'
4 | import cx from 'classnames'
5 | import BN from 'bn.js'
6 | import ta from 'time-ago'
7 | import formatFixedDecimal from '../../../utils/formatFixedDecimal'
8 | import Clock from '../../Clock'
9 |
10 | import styles from './blocks.module.css'
11 |
12 | export type Block = {
13 | blockNumber: number,
14 | timestamp: number,
15 | memberCount: number,
16 | totalEarnings: BN,
17 | frozen?: boolean,
18 | }
19 |
20 | type Props = {
21 | className?: string,
22 | items: Array,
23 | freezePeriodSeconds: string | number,
24 | }
25 |
26 | const Blocks = ({ items, className, freezePeriodSeconds }: Props) => (
27 |
28 |
29 |
Block #
30 |
Timestamp
31 |
Members
32 |
Earnings
33 |
34 |
35 | {items.map((block) => {
36 | // initial empty blocks are marked with numbers
37 | if (typeof block === 'number') {
38 | return (
39 |
40 |
41 |
42 |
43 |
44 |
45 | )
46 | }
47 | return (
48 |
51 | {(timestamp) => {
52 | const threshold = typeof freezePeriodSeconds === 'number' ? freezePeriodSeconds : Number.parseInt(freezePeriodSeconds, 10)
53 | const frozen = Math.floor((timestamp / 1000) - block.timestamp) < threshold
54 |
55 | return (
56 |
61 |
62 | {frozen && (
63 |
64 |
65 |
68 |
74 |
75 |
76 | )}
77 | {block.blockNumber}
78 |
79 |
{`> ${ta.ago(block.timestamp * 1000)}`}
80 |
{block.memberCount}
81 |
{formatFixedDecimal(block.totalEarnings)}
82 |
83 | )
84 | }}
85 |
86 | )
87 | })}
88 |
89 |
90 | )
91 |
92 | export default Blocks
93 |
--------------------------------------------------------------------------------
/demo/src/components/Home/Headline/headline.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | border-bottom: 1px solid #cdcdcd;
3 | font-size: 1em;
4 | font-weight: var(--medium);
5 | letter-spacing: 2px;
6 | margin: 0 0 2em;
7 | padding-bottom: 1.25em;
8 | text-transform: uppercase;
9 | }
10 |
--------------------------------------------------------------------------------
/demo/src/components/Home/Headline/index.jsx:
--------------------------------------------------------------------------------
1 | // @flow
2 |
3 | import React, { type Node } from 'react'
4 |
5 | import styles from './headline.module.css'
6 |
7 | type Props = {
8 | children: Node,
9 | }
10 |
11 | const Headline = ({ children }: Props) => (
12 |
13 | {children}
14 |
15 | )
16 |
17 | export default Headline
18 |
--------------------------------------------------------------------------------
/demo/src/components/Home/Hero/hero.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | background-color: #f8f8f8;
3 | padding: 10em 0;
4 | text-align: center;
5 | }
6 |
7 | .root h1 {
8 | font-size: 2.5em;
9 | font-weight: var(--regular);
10 | line-height: 1em;
11 | margin: 0 0 0.75em;
12 | }
13 |
14 | .root p {
15 | color: #a3a3a3;
16 | font-size: 0.75em;
17 | font-weight: var(--medium);
18 | line-height: 2em;
19 | letter-spacing: 2px;
20 | margin: 0 auto;
21 | text-transform: uppercase;
22 | width: 75%;
23 | }
24 |
--------------------------------------------------------------------------------
/demo/src/components/Home/Hero/index.jsx:
--------------------------------------------------------------------------------
1 | // @flow
2 |
3 | import React from 'react'
4 | import Container from '../../Container'
5 |
6 | import styles from './hero.module.css'
7 |
8 | const Hero = () => (
9 |
10 |
11 | Monoplasma Revenue Sharing Demo
12 | This is a demonstration of how Monoplasma contracts can be used to implement basic ERC-20 token revenue sharing
13 |
14 |
15 | )
16 |
17 | export default Hero
18 |
--------------------------------------------------------------------------------
/demo/src/components/Home/Management/index.jsx:
--------------------------------------------------------------------------------
1 | // @flow
2 |
3 | import React, { Component } from 'react'
4 | import Button from '../../Button'
5 | import Input from '../../Input'
6 | import Section from '../Section'
7 |
8 | import styles from './management.module.css'
9 |
10 | type Props = {
11 | onAddUsersClick: (Array) => void,
12 | onMintClick: () => void,
13 | onStealClick: () => void,
14 | }
15 |
16 | type State = {
17 | addresses: string,
18 | }
19 |
20 | class Management extends Component {
21 | state = {
22 | addresses: '',
23 | }
24 |
25 | onAddressesChange = ({ target: { value: addresses } }: SyntheticInputEvent) => {
26 | this.setState({
27 | addresses,
28 | })
29 | }
30 |
31 | onAddUsersClick = () => {
32 | const { onAddUsersClick } = this.props
33 | const { addresses } = this.state
34 |
35 | onAddUsersClick(addresses.split(/[\r\n]/m).filter(Boolean))
36 | this.setState({
37 | addresses: '',
38 | })
39 | }
40 |
41 | render() {
42 | const { addresses } = this.state
43 | const { onMintClick, onStealClick } = this.props
44 |
45 | return (
46 |
47 |
48 |
49 |
55 |
56 |
63 | Add users
64 |
65 |
66 |
67 |
68 |
74 | Mint tokens
75 |
76 |
83 | Steal all tokens
84 |
85 |
86 |
87 |
88 | )
89 | }
90 | }
91 |
92 | export default Management
93 |
--------------------------------------------------------------------------------
/demo/src/components/Home/Management/management.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | display: grid;
3 | grid-column-gap: 6em;
4 | grid-template-columns: 7fr 3fr;
5 | margin-bottom: 6em;
6 | }
7 |
8 | .users {
9 | display: grid;
10 | grid-row-gap: 2em;
11 | }
12 |
13 | .buttons {
14 | text-align: right;
15 | }
16 |
17 | .addUsers button {
18 | padding: 0 2em;
19 | width: auto;
20 | }
21 |
22 | .tokens {
23 | display: flex;
24 | flex-direction: column;
25 |
26 | .button {
27 | width: 100%;
28 | }
29 |
30 | .button + .button {
31 | margin-top: 2em;
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/demo/src/components/Home/RevenuePoolActions/index.jsx:
--------------------------------------------------------------------------------
1 | // @flow
2 |
3 | import React, { Component } from 'react'
4 | import Button from '../../Button'
5 | import Input from '../../Input'
6 |
7 | import styles from './revenuePoolActions.module.css'
8 |
9 | type Props = {
10 | onAddRevenueClick: (number) => void,
11 | onForcePublishClick: () => void,
12 | defaultAmount?: string,
13 | }
14 |
15 | type State = {
16 | amount: string,
17 | }
18 |
19 | class RevenuePoolActions extends Component {
20 | static defaultProps = {
21 | defaultAmount: '',
22 | }
23 |
24 | constructor(props: Props) {
25 | super(props)
26 | const { defaultAmount } = props
27 |
28 | this.state = {
29 | amount: defaultAmount || '',
30 | }
31 | }
32 |
33 | onAmountChange = ({ target: { value: amount } }: SyntheticInputEvent) => {
34 | this.setState({
35 | amount,
36 | })
37 | }
38 |
39 | onAddRevenueClick = () => {
40 | const { onAddRevenueClick } = this.props
41 | onAddRevenueClick(this.amount())
42 | }
43 |
44 | amount(): number {
45 | const { amount } = this.state
46 | return Math.max(0, Number.parseFloat(amount) || 0)
47 | }
48 |
49 | render() {
50 | const { onForcePublishClick } = this.props
51 | const { amount } = this.state
52 | const disabled: boolean = !this.amount()
53 |
54 | return (
55 |
56 |
63 |
68 | Add revenue
69 |
70 |
75 | Force publish
76 |
77 |
78 | )
79 | }
80 | }
81 |
82 | export default RevenuePoolActions
83 |
--------------------------------------------------------------------------------
/demo/src/components/Home/RevenuePoolActions/revenuePoolActions.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | display: grid;
3 | grid-column-gap: 2em;
4 | grid-template-columns: 5fr 3fr 3fr;
5 | padding: 6em 0;
6 | }
7 |
--------------------------------------------------------------------------------
/demo/src/components/Home/Section/index.jsx:
--------------------------------------------------------------------------------
1 | // @flow
2 |
3 | import React, { type Node } from 'react'
4 | import Headline from '../Headline'
5 |
6 | type Props = {
7 | className?: string,
8 | title: string,
9 | children: Node,
10 | }
11 |
12 | const Section = ({ className, title, children }: Props) => (
13 |
14 | {title}
15 | {children}
16 |
17 | )
18 |
19 | export default Section
20 |
--------------------------------------------------------------------------------
/demo/src/components/Home/Settings/index.jsx:
--------------------------------------------------------------------------------
1 | // @flow
2 |
3 | import React from 'react'
4 | import About from '../About'
5 | import Section from '../Section'
6 | import { type Config } from '../../../contexts/Home'
7 |
8 | import styles from './settings.module.css'
9 |
10 | type Props = {
11 | value: ?Config,
12 | }
13 |
14 | const withPlaceholder = (value: any) => (
15 | value || (
16 |
17 | )
18 | )
19 |
20 | const Settings = ({ value }: Props) => {
21 | const {
22 | freezePeriodSeconds,
23 | contractAddress,
24 | ethereumServer,
25 | operatorAddress,
26 | tokenAddress,
27 | } = value || {}
28 |
29 | return (
30 |
31 |
32 |
33 | Block freeze period:
34 | {withPlaceholder(`${freezePeriodSeconds} seconds`)}
35 |
36 |
37 | Monoplasma contract address:
38 | {withPlaceholder(contractAddress && contractAddress.toLowerCase())}
39 |
40 |
41 | Token address:
42 | {withPlaceholder(tokenAddress && tokenAddress.toLowerCase())}
43 |
44 |
45 | Ethereum node:
46 | {withPlaceholder(ethereumServer && ethereumServer.replace('ws://', 'http://'))}
47 |
48 |
49 | Operator address:
50 | {withPlaceholder(operatorAddress && operatorAddress.toLowerCase())}
51 |
52 |
53 |
54 | )
55 | }
56 |
57 | export default Settings
58 |
--------------------------------------------------------------------------------
/demo/src/components/Home/Settings/settings.module.css:
--------------------------------------------------------------------------------
1 | @keyframes spin {
2 | from {
3 | transform: rotate(0deg);
4 | }
5 | to {
6 | transform: rotate(360deg);
7 | }
8 | }
9 |
10 | .placeholder {
11 | animation: spin 1s linear infinite;
12 | border: 2px solid;
13 | border-color: #cdcdcd transparent transparent #cdcdcd;
14 | border-radius: 50%;
15 | display: inline-block;
16 | height: 0.3em;
17 | width: 0.3em;
18 | }
19 |
--------------------------------------------------------------------------------
/demo/src/components/Home/Stats/Stat/index.jsx:
--------------------------------------------------------------------------------
1 | // @flow
2 |
3 | import React from 'react'
4 | import { type BN } from 'bn.js'
5 | import cx from 'classnames'
6 | import formatFixedDecimal from '../../../../utils/formatFixedDecimal'
7 |
8 | import styles from './stat.module.css'
9 |
10 | type Props = {
11 | value: BN,
12 | caption: string,
13 | className?: string,
14 | }
15 |
16 | const Stat = ({ value, caption, className }: Props) => (
17 |
18 |
{formatFixedDecimal(value.toString())}
19 |
{caption}
20 |
21 | )
22 |
23 | export default Stat
24 |
--------------------------------------------------------------------------------
/demo/src/components/Home/Stats/Stat/stat.module.css:
--------------------------------------------------------------------------------
1 | .root h1 {
2 | font-size: 2em;
3 | font-weight: var(--extraLight);
4 | letter-spacing: -0.67px;
5 | margin: 0 0 0.5em;
6 | }
7 |
8 | .root p {
9 | color: #525252;
10 | font-family: var(--mono);
11 | font-size: 0.75em;
12 | margin: 0;
13 | }
14 |
--------------------------------------------------------------------------------
/demo/src/components/Home/Stats/index.jsx:
--------------------------------------------------------------------------------
1 | // @flow
2 |
3 | import React from 'react'
4 | import Stat from './Stat'
5 |
6 | import styles from './stats.module.css'
7 |
8 | type Props = {
9 | items: Array,
10 | }
11 |
12 | const Stats = ({ items }: Props) => (
13 |
14 | {items.map((item, index) => {
15 | if (!item) {
16 | /* eslint-disable-next-line react/no-array-index-key */
17 | return
18 | }
19 | const [caption, value] = item
20 | return
21 | })}
22 |
23 | )
24 |
25 | export default Stats
26 |
--------------------------------------------------------------------------------
/demo/src/components/Home/Stats/stats.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | display: grid;
3 | grid-column-gap: 1em;
4 | grid-row-gap: 3em;
5 | grid-template-columns: 1fr 1fr 1fr;
6 | }
7 |
--------------------------------------------------------------------------------
/demo/src/components/Home/UserActions/index.jsx:
--------------------------------------------------------------------------------
1 | // @flow
2 |
3 | import React, { Component } from 'react'
4 | import Eth from 'ethjs'
5 | import Button from '../../Button'
6 | import Input from '../../Input'
7 |
8 | import styles from './userActions.module.css'
9 |
10 | type Props = {
11 | onViewClick: (string) => void,
12 | onKickClick: (string) => void,
13 | onWithdrawClick: (string) => void,
14 | defaultAddress: string,
15 | }
16 |
17 | type State = {
18 | address: string,
19 | }
20 |
21 | class UserActions extends Component {
22 | constructor(props: Props) {
23 | super(props)
24 | const { defaultAddress: address } = props
25 |
26 | this.state = {
27 | address,
28 | }
29 | }
30 |
31 | onAddressChange = ({ target: { value: address } }: SyntheticInputEvent) => {
32 | this.setState({
33 | address,
34 | })
35 | }
36 |
37 | onViewClick = () => {
38 | const { onViewClick } = this.props
39 | const { address } = this.state
40 |
41 | onViewClick(address)
42 | }
43 |
44 | onKickClick = () => {
45 | const { onKickClick } = this.props
46 | const { address } = this.state
47 |
48 | onKickClick(address)
49 | }
50 |
51 | onWithdrawClick = () => {
52 | const { onWithdrawClick } = this.props
53 | const { address } = this.state
54 |
55 | onWithdrawClick(address)
56 | }
57 |
58 | render() {
59 | const { address } = this.state
60 |
61 | return (
62 |
63 |
70 |
75 | View
76 |
77 |
84 | Withdraw
85 |
86 |
93 | Kick
94 |
95 |
96 | )
97 | }
98 | }
99 |
100 | export default UserActions
101 |
--------------------------------------------------------------------------------
/demo/src/components/Home/UserActions/userActions.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | display: grid;
3 | grid-column-gap: 2em;
4 | grid-template-columns: 4fr 1fr 1fr 1fr;
5 | padding: 6em 0;
6 | }
7 |
--------------------------------------------------------------------------------
/demo/src/components/Home/home.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | padding: 8em 0;
3 | }
4 |
5 | .blocks {
6 | margin-top: 6rem;
7 | }
8 |
--------------------------------------------------------------------------------
/demo/src/components/Home/index.jsx:
--------------------------------------------------------------------------------
1 | // @flow
2 |
3 | import React, { type Node } from 'react'
4 |
5 | import Context, { type Props as ContextProps } from '../../contexts/Home'
6 | import Container from '../Container'
7 | import Layout from '../Layout'
8 | import Notification from '../Notification'
9 | import Hero from './Hero'
10 | import Section from './Section'
11 | import Stats from './Stats'
12 | import About from './About'
13 | import UserActions from './UserActions'
14 | import RevenuePoolActions from './RevenuePoolActions'
15 | import Management from './Management'
16 | import Blocks from './Blocks'
17 | import Settings from './Settings'
18 |
19 | import styles from './home.module.css'
20 |
21 | type OwnProps = {
22 | notification: Node,
23 | }
24 |
25 | type Props = ContextProps & OwnProps
26 |
27 | const Home = ({
28 | account,
29 | revenuePool,
30 | blocks,
31 | onViewClick,
32 | onKickClick,
33 | onWithdrawClick,
34 | onAddRevenueClick,
35 | onForcePublishClick,
36 | onAddUsersClick,
37 | onMintClick,
38 | onStealClick,
39 | notification,
40 | config,
41 | }: Props) => (
42 |
43 | {notification && (
44 |
45 | {notification}
46 |
47 | )}
48 |
49 |
52 |
65 |
84 |
89 |
90 |
93 |
94 |
95 | )
96 |
97 | export default (props: OwnProps) => (
98 |
99 | {(context: ContextProps) => (
100 |
101 | )}
102 |
103 | )
104 |
--------------------------------------------------------------------------------
/demo/src/components/Input/index.js:
--------------------------------------------------------------------------------
1 | // @flow
2 |
3 | import styles from './input.module.css'
4 |
5 | export default {
6 | styles,
7 | }
8 |
--------------------------------------------------------------------------------
/demo/src/components/Input/input.module.css:
--------------------------------------------------------------------------------
1 | .textField,
2 | .textArea {
3 | border: 0;
4 | border-radius: 4px;
5 | font-family: var(--mono);
6 | font-weight: var(--medium);
7 | padding: 0 1.5em;
8 | transition: 150ms ease-in-out box-shadow;
9 |
10 | &::placeholder {
11 | color: #a3a3a3;
12 | }
13 |
14 | &:focus {
15 | box-shadow: 0 0 0 1px #0324ff;
16 | outline: 0;
17 | }
18 |
19 | &::selection {
20 | background-color: #cce9fd;
21 | }
22 | }
23 |
24 | .textArea {
25 | box-sizing: border-box;
26 | line-height: 2em;
27 | min-height: 10em;
28 | padding-bottom: 1em;
29 | padding-top: 1em;
30 | resize: vertical;
31 | }
32 |
--------------------------------------------------------------------------------
/demo/src/components/Layout/index.jsx:
--------------------------------------------------------------------------------
1 | // @flow
2 |
3 | import React, { type Node, Fragment } from 'react'
4 | import Helmet from 'react-helmet'
5 |
6 | import 'normalize.css'
7 | import '../../stylesheets/variables.css'
8 | import './layout.css'
9 |
10 | type Props = {
11 | children: Node,
12 | }
13 |
14 | const Layout = ({ children }: Props) => (
15 |
16 |
17 |
18 |
19 |
20 | Revenue sharing demo
21 |
22 | {children}
23 |
24 | )
25 |
26 | export default Layout
27 |
--------------------------------------------------------------------------------
/demo/src/components/Layout/layout.css:
--------------------------------------------------------------------------------
1 | @import url('https://fonts.googleapis.com/css?family=IBM+Plex+Mono:400,500,600|IBM+Plex+Sans:200,300,400,500,600,700');
2 |
3 | html,
4 | body {
5 | background-color: #f1f1f1;
6 | color: #323232;
7 | font-family: var(--sans);
8 | font-size: 16px;
9 | font-weight: var(--regular);
10 | height: 100%;
11 | width: 100%;
12 | }
13 |
14 | strong {
15 | font-weight: var(--medium);
16 | }
17 |
--------------------------------------------------------------------------------
/demo/src/components/Notification/index.jsx:
--------------------------------------------------------------------------------
1 | // @flow
2 |
3 | import React from 'react'
4 | import cx from 'classnames'
5 | import Container from '../Container'
6 |
7 | import styles from './notification.module.css'
8 |
9 | type Props = {
10 | className?: string,
11 | }
12 |
13 | const Notification = ({ className, ...props }: Props) => (
14 |
15 |
16 |
17 | )
18 |
19 | export default Notification
20 |
--------------------------------------------------------------------------------
/demo/src/components/Notification/notification.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | background-color: white;
3 | border-radius: 3px;
4 | box-shadow: 0 0 0.5rem rgba(0, 0, 0, 0.1);
5 | font-weight: var(--medium);
6 | letter-spacing: 2px;
7 | padding: 2rem;
8 | position: absolute;
9 | text-align: center;
10 | text-transform: uppercase;
11 | top: 2.5rem;
12 | width: 100%;
13 | }
14 |
15 | .root a {
16 | &,
17 | &:--interact,
18 | &:--idle {
19 | color: #0324ff;
20 | text-decoration: none;
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/demo/src/components/Tooltip/index.jsx:
--------------------------------------------------------------------------------
1 | // @flow
2 |
3 | import React from 'react'
4 | import cx from 'classnames'
5 |
6 | import styles from './tooltip.module.css'
7 |
8 | type Props = {
9 | className?: string,
10 | }
11 |
12 | const Tooltip = ({ className, ...props }: Props) => (
13 |
14 | )
15 |
16 | export default Tooltip
17 |
--------------------------------------------------------------------------------
/demo/src/components/Tooltip/tooltip.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | background-color: white;
3 | border-radius: 4px;
4 | box-sizing: border-box;
5 | color: #525252;
6 | box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
7 | font-family: var(--mono);
8 | font-size: 0.75em;
9 | line-height: 1.25rem;
10 | margin: 0.5em 0 0;
11 | min-width: 100%;
12 | opacity: 0;
13 | padding: 1rem 1em;
14 | pointer-events: none;
15 | position: absolute;
16 | top: 100%;
17 | transform: translateY(-0.5rem);
18 | transition: 200ms ease-out;
19 | transition-delay: 200ms, 0ms, 0ms;
20 | transition-property: visibility opacity transform;
21 | visibility: hidden;
22 | z-index: 100;
23 | }
24 |
--------------------------------------------------------------------------------
/demo/src/containers/Wallet/index.jsx:
--------------------------------------------------------------------------------
1 | // @flow
2 |
3 | /* eslint-disable react/no-unused-state */
4 |
5 | import React, { type Node, Component } from 'react'
6 | import Eth from 'ethjs'
7 | import Context, { type Props as ContextProps } from '../../contexts/Wallet'
8 |
9 | const { Web3, ethereum, web3 } = typeof window !== 'undefined' ? window : {}
10 | const provider = ethereum || (web3 && web3.currentProvider)
11 |
12 | type Props = {
13 | children: Node,
14 | }
15 |
16 | type State = ContextProps & {}
17 |
18 | class Wallet extends Component {
19 | constructor(props: Props) {
20 | super(props)
21 |
22 | if (provider) {
23 | this.web3 = new Web3(provider)
24 | this.eth = new Eth(provider)
25 | }
26 |
27 | this.state = {
28 | accountAddress: null,
29 | web3: this.web3,
30 | eth: this.eth,
31 | }
32 | }
33 |
34 | async componentDidMount() {
35 | this.getAccountAddress().then((accountAddress) => {
36 | if (!this.unmounted) {
37 | this.setState({
38 | accountAddress,
39 | })
40 | }
41 | })
42 |
43 | if (ethereum) {
44 | ethereum.on('accountsChanged', this.onAccountChange)
45 | }
46 | }
47 |
48 | componentWillUnmount() {
49 | this.unmounted = true
50 | if (ethereum) {
51 | ethereum.off('accountsChanged', this.onAccountChange)
52 | }
53 | }
54 |
55 | onAccountChange = (accounts: Array) => {
56 | if (!this.unmounted) {
57 | this.setState({
58 | accountAddress: accounts[0] || null,
59 | })
60 | }
61 | }
62 |
63 | async getAccountAddress(): Promise {
64 | if (ethereum) {
65 | try {
66 | await ethereum.enable()
67 | return ethereum.selectedAddress
68 | } catch (e) {
69 | /* catcher */
70 | }
71 | } else if (web3) {
72 | try {
73 | const accounts = await this.eth.accounts()
74 | return accounts[0] || null
75 | } catch (e) {
76 | /* catcher */
77 | }
78 | }
79 |
80 | return null
81 | }
82 |
83 | web3: any
84 |
85 | eth: any
86 |
87 | unmounted: boolean
88 |
89 | render() {
90 | const { children } = this.props
91 |
92 | return (
93 |
94 | {children}
95 |
96 | )
97 | }
98 | }
99 |
100 | export default Wallet
101 |
--------------------------------------------------------------------------------
/demo/src/contexts/Home/index.js:
--------------------------------------------------------------------------------
1 | // @flow
2 |
3 | import { createContext } from 'react'
4 | import { type Block } from '../../components/Home/Blocks'
5 |
6 | export type Config = {
7 | freezePeriodSeconds: string,
8 | contractAddress: string,
9 | ethereumServer: string,
10 | gasPrice: number,
11 | lastBlockNumber: number,
12 | lastPublishedBlock: number,
13 | operatorAddress: string,
14 | tokenAddress: string,
15 | }
16 |
17 | export type Props = {
18 | account: Array,
19 | revenuePool: Array,
20 | blocks: Array,
21 | onViewClick: (string) => void,
22 | onKickClick: (string) => void,
23 | onWithdrawClick: (string) => void,
24 | onAddRevenueClick: (number) => void,
25 | onForcePublishClick: () => void,
26 | onAddUsersClick: (Array) => void,
27 | onMintClick: () => void,
28 | onStealClick: () => void,
29 | config: ?Config,
30 | }
31 |
32 | export default createContext({
33 | account: [],
34 | revenuePool: [],
35 | blocks: [],
36 | onViewClick: () => {},
37 | onKickClick: () => {},
38 | onWithdrawClick: () => {},
39 | onAddRevenueClick: () => {},
40 | onForcePublishClick: () => {},
41 | onAddUsersClick: () => {},
42 | onMintClick: () => {},
43 | onStealClick: () => {},
44 | config: null,
45 | })
46 |
--------------------------------------------------------------------------------
/demo/src/contexts/Wallet/index.js:
--------------------------------------------------------------------------------
1 | // @flow
2 |
3 | import { createContext } from 'react'
4 |
5 | export type Props = {
6 | accountAddress: ?string,
7 | web3: any,
8 | eth: any,
9 | }
10 |
11 | export default createContext({
12 | accountAddress: null,
13 | web3: null,
14 | eth: null,
15 | })
16 |
--------------------------------------------------------------------------------
/demo/src/pages/index.jsx:
--------------------------------------------------------------------------------
1 | // @flow
2 |
3 | import React from 'react'
4 | import Home from '../containers/Home'
5 | import Wallet from '../containers/Wallet'
6 |
7 | export default () => (
8 |
9 |
10 |
11 | )
12 |
--------------------------------------------------------------------------------
/demo/src/stylesheets/variables.css:
--------------------------------------------------------------------------------
1 | :root {
2 | --mono: 'IBM Plex Mono', monospace;
3 | --sans: 'IBM Plex Sans', sans-serif;
4 |
5 | --extraLight: 200;
6 | --light: 300;
7 | --regular: 400;
8 | --medium: 500;
9 | --semiBold: 600;
10 | --bold: 700;
11 | }
12 |
13 | @custom-selector :--interact :hover, :focus;
14 | @custom-selector :--idle :active, :visited;
15 | @custom-selector :--disabled :disabled, [disabled];
16 | @custom-selector :--even :nth-child(even);
17 | @custom-selector :--odd :nth-child(odd);
18 |
--------------------------------------------------------------------------------
/demo/src/utils/formatFixedDecimal.js:
--------------------------------------------------------------------------------
1 | // @flow
2 |
3 | // TODO: instead of fixed-point decimals, switch bn.js to bignumber.js that can handle decimals
4 | // OR include a "type" with the number: token amounts should come in as token-wei and be formatted accordingly as full tokens
5 | import BN from 'bn.js'
6 |
7 | /** @param {BN | number | string} value fixed-point decimal integer, with 18 decimals: "semantic 1" ~= "syntactic 10^18" */
8 | export default (value: BN | number | string): string => {
9 | // const [int, fraction] = value.toString().split('.')
10 | const num = new BN(value).toString(10, 36)
11 | const int = num.slice(0, 18).replace(/^(-?)0*/, '$1')
12 | .split('')
13 | .reverse()
14 | .join('')
15 | .replace(/\d{3}/g, '$&,')
16 | .split('')
17 | .reverse()
18 | .join('')
19 | .replace(/^,/, '') || '0'
20 | const fraction = num.slice(18, 30 - int.length).replace(/0*$/, '')
21 | // .replace(/\d{3}/g, '$&,')
22 | // .replace(/,$/, '')
23 |
24 | return fraction ? `${int}.${fraction}` : `${int}`
25 | }
26 |
--------------------------------------------------------------------------------
/demo/src/utils/monoplasmaAbi.json:
--------------------------------------------------------------------------------
1 | [{"inputs":[{"internalType":"address","name":"tokenAddress","type":"address"},{"internalType":"uint256","name":"freezePeriodSeconds","type":"uint256"},{"internalType":"uint256","name":"_adminFee","type":"uint256"}],"payable":false,"stateMutability":"nonpayable","type":"constructor"},{"anonymous":false,"inputs":[{"indexed":false,"internalType":"uint256","name":"adminFee","type":"uint256"}],"name":"AdminFeeChanged","type":"event"},{"anonymous":false,"inputs":[{"indexed":false,"internalType":"uint256","name":"blockNumber","type":"uint256"},{"indexed":false,"internalType":"bytes32","name":"rootHash","type":"bytes32"},{"indexed":false,"internalType":"string","name":"ipfsHash","type":"string"}],"name":"NewCommit","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"newOperator","type":"address"}],"name":"OperatorChanged","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"previousOwner","type":"address"},{"indexed":true,"internalType":"address","name":"newOwner","type":"address"}],"name":"OwnershipTransferred","type":"event"},{"constant":true,"inputs":[],"name":"adminFee","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[],"name":"freezePeriodSeconds","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[{"internalType":"uint256","name":"","type":"uint256"}],"name":"committedHash","outputs":[{"internalType":"bytes32","name":"","type":"bytes32"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[{"internalType":"uint256","name":"","type":"uint256"}],"name":"commitTimestamp","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[{"internalType":"bytes32","name":"hash","type":"bytes32"},{"internalType":"bytes32[]","name":"others","type":"bytes32[]"}],"name":"calculateRootHash","outputs":[{"internalType":"bytes32","name":"root","type":"bytes32"}],"payable":false,"stateMutability":"pure","type":"function"},{"constant":false,"inputs":[],"name":"claimOwnership","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":false,"inputs":[{"internalType":"uint256","name":"blockNumber","type":"uint256"},{"internalType":"bytes32","name":"rootHash","type":"bytes32"},{"internalType":"string","name":"ipfsHash","type":"string"}],"name":"commit","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":true,"inputs":[{"internalType":"address","name":"","type":"address"}],"name":"earnings","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[],"name":"operator","outputs":[{"internalType":"address","name":"","type":"address"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[],"name":"owner","outputs":[{"internalType":"address","name":"","type":"address"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[],"name":"pendingOwner","outputs":[{"internalType":"address","name":"","type":"address"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[{"internalType":"uint256","name":"blockNumber","type":"uint256"},{"internalType":"address","name":"account","type":"address"},{"internalType":"uint256","name":"balance","type":"uint256"},{"internalType":"bytes32[]","name":"proof","type":"bytes32[]"}],"name":"proofIsCorrect","outputs":[{"internalType":"bool","name":"","type":"bool"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":false,"inputs":[{"internalType":"uint256","name":"blockNumber","type":"uint256"},{"internalType":"address","name":"account","type":"address"},{"internalType":"uint256","name":"balance","type":"uint256"},{"internalType":"bytes32[]","name":"proof","type":"bytes32[]"}],"name":"prove","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":true,"inputs":[],"name":"token","outputs":[{"internalType":"contract IERC20","name":"","type":"address"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[],"name":"totalProven","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[],"name":"totalWithdrawn","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":false,"inputs":[{"internalType":"address","name":"newOwner","type":"address"}],"name":"transferOwnership","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":true,"inputs":[{"internalType":"address","name":"","type":"address"}],"name":"withdrawn","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":false,"inputs":[{"internalType":"address","name":"newOperator","type":"address"}],"name":"setOperator","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":false,"inputs":[{"internalType":"uint256","name":"_adminFee","type":"uint256"}],"name":"setAdminFee","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":false,"inputs":[{"internalType":"uint256","name":"blockNumber","type":"uint256"},{"internalType":"uint256","name":"totalEarnings","type":"uint256"},{"internalType":"bytes32[]","name":"proof","type":"bytes32[]"}],"name":"withdrawAll","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":false,"inputs":[{"internalType":"address","name":"recipient","type":"address"},{"internalType":"uint256","name":"blockNumber","type":"uint256"},{"internalType":"uint256","name":"totalEarnings","type":"uint256"},{"internalType":"bytes32[]","name":"proof","type":"bytes32[]"}],"name":"withdrawAllFor","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":false,"inputs":[{"internalType":"address","name":"recipient","type":"address"},{"internalType":"uint256","name":"blockNumber","type":"uint256"},{"internalType":"uint256","name":"totalEarnings","type":"uint256"},{"internalType":"bytes32[]","name":"proof","type":"bytes32[]"}],"name":"withdrawAllTo","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":false,"inputs":[{"internalType":"uint256","name":"amount","type":"uint256"}],"name":"withdraw","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":false,"inputs":[{"internalType":"address","name":"recipient","type":"address"},{"internalType":"uint256","name":"amount","type":"uint256"}],"name":"withdrawFor","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":false,"inputs":[{"internalType":"address","name":"recipient","type":"address"},{"internalType":"uint256","name":"amount","type":"uint256"}],"name":"withdrawTo","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"}]
--------------------------------------------------------------------------------
/demo/src/utils/proxyRouter.js:
--------------------------------------------------------------------------------
1 | const router = require('express').Router()
2 | const proxy = require('http-proxy-middleware')
3 |
4 | router.get('/data/operator.json', proxy({
5 | target: 'http://localhost:8080',
6 | }))
7 |
8 | module.exports = router
9 |
--------------------------------------------------------------------------------
/demo/src/utils/tokenAbi.json:
--------------------------------------------------------------------------------
1 | [{"inputs":[{"internalType":"string","name":"name","type":"string"},{"internalType":"string","name":"symbol","type":"string"}],"payable":false,"stateMutability":"nonpayable","type":"constructor"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"owner","type":"address"},{"indexed":true,"internalType":"address","name":"spender","type":"address"},{"indexed":false,"internalType":"uint256","name":"value","type":"uint256"}],"name":"Approval","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"account","type":"address"}],"name":"MinterAdded","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"account","type":"address"}],"name":"MinterRemoved","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"from","type":"address"},{"indexed":true,"internalType":"address","name":"to","type":"address"},{"indexed":false,"internalType":"uint256","name":"value","type":"uint256"}],"name":"Transfer","type":"event"},{"constant":false,"inputs":[{"internalType":"address","name":"account","type":"address"}],"name":"addMinter","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":true,"inputs":[{"internalType":"address","name":"owner","type":"address"},{"internalType":"address","name":"spender","type":"address"}],"name":"allowance","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":false,"inputs":[{"internalType":"address","name":"spender","type":"address"},{"internalType":"uint256","name":"amount","type":"uint256"}],"name":"approve","outputs":[{"internalType":"bool","name":"","type":"bool"}],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":true,"inputs":[{"internalType":"address","name":"account","type":"address"}],"name":"balanceOf","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[],"name":"decimals","outputs":[{"internalType":"uint8","name":"","type":"uint8"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":false,"inputs":[{"internalType":"address","name":"spender","type":"address"},{"internalType":"uint256","name":"subtractedValue","type":"uint256"}],"name":"decreaseAllowance","outputs":[{"internalType":"bool","name":"","type":"bool"}],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":false,"inputs":[{"internalType":"address","name":"spender","type":"address"},{"internalType":"uint256","name":"addedValue","type":"uint256"}],"name":"increaseAllowance","outputs":[{"internalType":"bool","name":"","type":"bool"}],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":true,"inputs":[{"internalType":"address","name":"account","type":"address"}],"name":"isMinter","outputs":[{"internalType":"bool","name":"","type":"bool"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":false,"inputs":[{"internalType":"address","name":"account","type":"address"},{"internalType":"uint256","name":"amount","type":"uint256"}],"name":"mint","outputs":[{"internalType":"bool","name":"","type":"bool"}],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":true,"inputs":[],"name":"name","outputs":[{"internalType":"string","name":"","type":"string"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":false,"inputs":[],"name":"renounceMinter","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":true,"inputs":[],"name":"symbol","outputs":[{"internalType":"string","name":"","type":"string"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[],"name":"totalSupply","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":false,"inputs":[{"internalType":"address","name":"recipient","type":"address"},{"internalType":"uint256","name":"amount","type":"uint256"}],"name":"transfer","outputs":[{"internalType":"bool","name":"","type":"bool"}],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":false,"inputs":[{"internalType":"address","name":"sender","type":"address"},{"internalType":"address","name":"recipient","type":"address"},{"internalType":"uint256","name":"amount","type":"uint256"}],"name":"transferFrom","outputs":[{"internalType":"bool","name":"","type":"bool"}],"payable":false,"stateMutability":"nonpayable","type":"function"}]
--------------------------------------------------------------------------------
/demo/static/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/streamr-dev/monoplasma/cf9581e267e79797f58f640d9cb6e8f95f3c7f10/demo/static/favicon.ico
--------------------------------------------------------------------------------
/flatten:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | for i in $(find contracts -type d); do mkdir -p ${i/contracts/build/flattened}; done
4 | for i in $(find contracts -name "*.sol"); do node_modules/.bin/truffle-flattener $i > ${i/contracts/build/flattened}; done
5 |
6 |
--------------------------------------------------------------------------------
/index.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | Operator: require("./src/operator"),
3 | Validator: require("./src/validator"),
4 | }
--------------------------------------------------------------------------------
/migrations/1_initial_migration.js:
--------------------------------------------------------------------------------
1 | var Migrations = artifacts.require("./Migrations.sol");
2 |
3 | module.exports = function(deployer) {
4 | deployer.deploy(Migrations);
5 | };
6 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "monoplasma",
3 | "version": "0.2.0",
4 | "description": "Unidirectional payment sidechain with monotonically increasing balances (no in-chain transfers)",
5 | "author": "Streamr Network AG",
6 | "license": "AGPL-3.0",
7 | "repository": "streamr-dev/monoplasma",
8 | "main": "deploy.js",
9 | "scripts": {
10 | "start": "npm run clean-demo && ./start_operator.js",
11 | "lint": "eslint src test",
12 | "build": "npm run clean && truffle compile && ./flatten && ./copy-abi-to-ui.js && npm run build-demo",
13 | "build-demo": "cd demo && npm i && npm run build",
14 | "test-js": "mocha test/mocha --exit",
15 | "test-e2e": "mocha test/e2e --exit",
16 | "test-contracts": "truffle test test/truffle/*",
17 | "coverage": "truffle run coverage --file=test/truffle/*.js",
18 | "test": "npm run clean && truffle compile && npm run test-js && npm run test-contracts && npm run test-e2e",
19 | "clean": "rm -f build/contracts/* && rm -f build/flattened/*",
20 | "clean-demo": "rm -rf demo/public/data/*",
21 | "prepack": "./flatten && jq '{contractName,updatedAt,schemaVersion} + (.metadata|fromjson|del(.output)) + {userdoc,bytecode,abi}' build/contracts/Monoplasma.json > build/Monoplasma.json"
22 | },
23 | "devDependencies": {
24 | "eslint": "6.8.0",
25 | "mocha": "7.0.0",
26 | "node-fetch": "2.6.0",
27 | "sinon": "8.1.1",
28 | "solidity-coverage": "0.7.1",
29 | "truffle-flattener": "1.4.2",
30 | "bn.js": "5.1.1",
31 | "body-parser": "1.19.0",
32 | "cors": "2.8.5",
33 | "ethers": "4.0.46",
34 | "exit-hook": "2.2.0",
35 | "express": "4.17.1",
36 | "fs-extra": "8.1.0",
37 | "ganache-cli": "6.8.2",
38 | "ganache-core": "2.9.2",
39 | "mz": "2.7.0",
40 | "prettyjson": "1.2.1",
41 | "promise-events": "0.1.6",
42 | "source-map-support": "0.5.16",
43 | "solc": "0.6.1",
44 | "truffle": "5.1.9",
45 | "truffle-resolver": "5.0.16",
46 | "web3": "1.2.4",
47 | "websocket": "1.0.31",
48 | "zeromq": "5.2.0"
49 | },
50 | "dependencies": {
51 | "openzeppelin-solidity": "2.4.0"
52 | },
53 | "files": [
54 | "contracts",
55 | "strings",
56 | "build/flattened/Monoplasma.sol",
57 | "build/Monoplasma.json"
58 | ]
59 | }
60 |
--------------------------------------------------------------------------------
/src/fileStore.js:
--------------------------------------------------------------------------------
1 | const fs = require("mz/fs")
2 | const path = require("path")
3 |
4 | /**
5 | * @typedef {Object} OperatorState
6 | * @property {string} tokenAddress Ethereum address of token used by Monoplasma
7 | */
8 | /**
9 | * @param {String} storeDir where json files are stored
10 | */
11 | module.exports = class FileStore {
12 |
13 | constructor(storeDir, log, maxLogLen) {
14 | this.log = log || (() => {})
15 | this.maxLogLen = maxLogLen || 840
16 |
17 | this.log(`Setting up fileStore directories under ${storeDir}...`)
18 | this.blocksDir = path.join(storeDir, "blocks")
19 | const eventsDir = path.join(storeDir, "events")
20 | fs.mkdirSync(storeDir, { recursive: true })
21 | fs.mkdirSync(this.blocksDir, { recursive: true })
22 | fs.mkdirSync(eventsDir, { recursive: true })
23 | this.blockNameRE = /(\d*)\.json/
24 |
25 | this.stateStorePath = path.join(storeDir, "state.json")
26 | this.getBlockPath = blockNumber => path.join(this.blocksDir, blockNumber + ".json")
27 | this.getEventPath = blockNumber => path.join(eventsDir, blockNumber + ".json")
28 | }
29 |
30 | sanitize(logEntry) {
31 | return logEntry.length < this.maxLogLen ? logEntry : logEntry.slice(0, this.maxLogLen) + "... TOTAL LENGTH: " + logEntry.length
32 | }
33 |
34 | /** @returns {OperatorState} Operator state from the file */
35 | async loadState() {
36 | this.log(`Loading state from ${this.stateStorePath}...`)
37 | const raw = await fs.readFile(this.stateStorePath).catch(() => "{}")
38 | return JSON.parse(raw)
39 | }
40 |
41 | /**
42 | * @param {OperatorState} state Operator state to save
43 | */
44 | async saveState(state) {
45 | const raw = JSON.stringify(state)
46 | this.log(`Saving state to ${this.stateStorePath}: ${raw.slice(0, 1000)}${raw.length > 1000 ? "... TOTAL LENGTH: " + raw.length : ""}`)
47 | return fs.writeFile(this.stateStorePath, raw)
48 | }
49 |
50 | /**
51 | * @param {number} blockNumber Root-chain block number after which the commit was produced
52 | */
53 | async loadBlock(blockNumber) {
54 | const path = this.getBlockPath(blockNumber)
55 | this.log(`Loading block ${blockNumber} from ${path}...`)
56 | const raw = await fs.readFile(path)
57 | const memberArray = JSON.parse(raw)
58 | return memberArray
59 | }
60 |
61 | /**
62 | * @param {number} blockNumber Root-chain block number after which the commit was produced
63 | */
64 | async blockExists(blockNumber) {
65 | const path = this.getBlockPath(blockNumber)
66 | return fs.exists(path)
67 | }
68 |
69 | /**
70 | * @param {number} [maxNumberLatest] of latest blocks to list
71 | * @returns {Promise>} block numbers that have been stored
72 | */
73 | async listBlockNumbers(maxNumberLatest) {
74 | const fileList = await fs.readdir(this.blocksDir)
75 | let blockNumbers = fileList
76 | .map(fname => fname.match(this.blockNameRE))
77 | .filter(x => x)
78 | .map(match => +match[1])
79 | blockNumbers.sort((a, b) => a - b) // sort as numbers, just sort() converts to strings first
80 | if (maxNumberLatest) {
81 | blockNumbers = blockNumbers.slice(-maxNumberLatest)
82 | }
83 | return blockNumbers
84 | }
85 |
86 | /**
87 | * @typedef {Object} Block Monoplasma commit, see monoplasma.js:storeBlock
88 | * @property {number} blockNumber Root-chain block number after which the commit was produced
89 | * @property {Array} members MonoplasmaMember.toObject()s with their earnings etc.
90 | * @property {number} timestamp seconds since epoch, similar to Ethereum block.timestamp
91 | * @property {number} totalEarnings sum of members.earnings, to avoid re-calculating it (often needed)
92 | */
93 | /**
94 | * @param {Block} block to be stored, called from monoplasma.js:storeBlock
95 | */
96 | async saveBlock(block) {
97 | if (!block || !block.blockNumber) { throw new Error(`Bad block: ${JSON.stringify(block)}`) }
98 | const path = this.getBlockPath(block.blockNumber)
99 | const raw = JSON.stringify(block)
100 | this.log(`Saving block ${block.blockNumber} to ${path}: ${this.sanitize(raw)}`)
101 | if (await fs.exists(path)) { console.error(`Overwriting block ${block.blockNumber}!`) } // eslint-disable-line no-console
102 | return fs.writeFile(path, raw)
103 | }
104 |
105 | /**
106 | * @typedef {Object} Event join/part events are stored in file for playback, others come from Ethereum
107 | * @property {number} blockNumber Root-chain block number after which events happened
108 | * @property {number} transactionIndex index within block, for join/part it should just be large
109 | * @property {string} event "Join" or "Part" (TODO: could jsdoc enums handle this?)
110 | * @property {Array} addressList
111 | */
112 | /**
113 | * @param {number} blockNumber Root-chain block number after which events happened
114 | * @param {Array} events to be associated with blockNumber
115 | */
116 | async saveEvents(blockNumber, events) {
117 | events = Array.isArray(events) ? events : [events]
118 | if (events.length < 1) {
119 | this.log(`Empty events given for block #${blockNumber}, not saving`)
120 | return
121 | }
122 | const path = this.getEventPath(blockNumber)
123 | const rawOld = await fs.readFile(path).catch(() => "[]")
124 | const oldEvents = JSON.parse(rawOld)
125 | this.log(`Saving ${events.length} event(s like) ${this.sanitize(JSON.stringify(events[0]))} to ${path} (appending after ${oldEvents.length} old events in this block)`)
126 | const newEvents = oldEvents.concat(events)
127 | const raw = JSON.stringify(newEvents)
128 | return fs.writeFile(path, raw)
129 | }
130 |
131 | /**
132 | * @param {number} fromBlock Sidechain block to load
133 | * @param {number} [toBlock=fromBlock+1] load blocks until toBlock, INCLUSIVE (just like getPastEvents)
134 | * @returns {Array} of events, if blocks found, otherwise []
135 | */
136 | async loadEvents(fromBlock, toBlock) {
137 | let ret = []
138 | const to = toBlock || fromBlock
139 | for (let bnum = fromBlock; bnum <= to; bnum++) {
140 | const path = this.getEventPath(bnum)
141 | if (await fs.exists(path)) {
142 | this.log(`Loading events from ${path}`)
143 | const raw = await fs.readFile(path).catch(() => "[]")
144 | const eventList = JSON.parse(raw)
145 | ret = ret.concat(eventList)
146 | }
147 | }
148 | return ret
149 | }
150 | }
151 |
--------------------------------------------------------------------------------
/src/joinPartChannel.js:
--------------------------------------------------------------------------------
1 | const zeromq = require("zeromq")
2 |
3 | /**
4 | * @typedef {string} State
5 | * @enum {string}
6 | */
7 | const State = {
8 | CLOSED: "",
9 | SERVER: "server",
10 | CLIENT: "client",
11 | }
12 |
13 | function reset(channel) {
14 | channel.mode = State.CLOSED
15 | channel.publish = () => { throw new Error("Channel is closed" )}
16 | channel.on = () => { throw new Error("Channel is closed" )}
17 | }
18 |
19 | /**
20 | * @typedef {Object} Channel
21 | * @property {State} mode
22 | * @property {function} publish
23 | * @property {function} on
24 | */
25 | module.exports = class ZeroMqChannel {
26 | constructor(joinPartChannelPort) {
27 | this.channelUrl = "tcp://127.0.0.1:" + (joinPartChannelPort || 4568)
28 | reset(this)
29 | }
30 |
31 | /** After this, call .publish(topic, data) to send */
32 | startServer() {
33 | if (this.mode) { throw new Error(`Already started as ${this.mode}`)}
34 |
35 | this.sock = zeromq.socket("pub")
36 | this.sock.bindSync(this.channelUrl)
37 | this.publish = (topic, addresses) => {
38 | this.sock.send([topic, JSON.stringify(addresses)])
39 | }
40 | this.mode = State.SERVER
41 | }
42 |
43 | /** After this, add a listener for specific topic: .on(topic, msg => { handler } ) */
44 | listen() {
45 | if (this.mode) { throw new Error(`Already started as ${this.mode}`)}
46 |
47 | this.sock = zeromq.socket("sub")
48 | this.sock.connect(this.channelUrl)
49 | this.sock.subscribe("join")
50 | this.sock.subscribe("part")
51 | this.on = (topic, cb) => {
52 | this.sock.on("message", (topicBuffer, messageBuffer) => {
53 | if (topicBuffer.toString() === topic) {
54 | const message = JSON.parse(messageBuffer)
55 | cb(message)
56 | }
57 | })
58 | }
59 | this.mode = State.CLIENT
60 | }
61 |
62 | /** Close the channel */
63 | close() {
64 | if (!this.mode) { throw new Error("Can't close, already closed")}
65 | this.sock.close()
66 | reset(this)
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/src/member.js:
--------------------------------------------------------------------------------
1 | const BN = require("bn.js")
2 | const {utils: { isAddress }} = require("web3")
3 |
4 | module.exports = class MonoplasmaMember {
5 | constructor(name, address, earnings) {
6 | this.name = name || ""
7 | this.address = MonoplasmaMember.validateAddress(address)
8 | this.earnings = earnings ? new BN(earnings) : new BN(0)
9 | this.active = true
10 | }
11 |
12 | getEarningsAsString() {
13 | return this.earnings.toString(10)
14 | }
15 |
16 | getEarningsAsInt() {
17 | return this.earnings.toNumber()
18 | }
19 |
20 | addRevenue(amount) {
21 | this.earnings = this.earnings.add(new BN(amount))
22 | }
23 |
24 | isActive() {
25 | return this.active
26 | }
27 |
28 | /**
29 | * @param {boolean} activeState true if active, false if not going to be getting revenues
30 | */
31 | setActive(activeState) {
32 | this.active = activeState
33 | }
34 |
35 | toObject() {
36 | const obj = {
37 | address: this.address,
38 | earnings: this.earnings.toString(10),
39 | }
40 | if (this.name) {
41 | obj.name = this.name
42 | }
43 | return obj
44 | }
45 |
46 | static fromObject(obj) {
47 | return new MonoplasmaMember(obj.name, obj.address, obj.earnings)
48 | }
49 |
50 | getProof(tree) {
51 | return this.earnings.gt(new BN(0)) ? tree.getPath(this.address) : []
52 | }
53 |
54 | static validateAddress(address) {
55 | let extended = address
56 | if (address.length === 40) {
57 | extended = `0x${address}`
58 | }
59 | if (!isAddress(extended)) {
60 | throw new Error(`Bad Ethereum address: ${address}`)
61 | }
62 | return extended
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/src/merkletree.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-bitwise */
2 |
3 | const {
4 | utils: {
5 | BigNumber: BN,
6 | solidityKeccak256,
7 | }
8 | } = require("ethers")
9 |
10 | const ZERO = "0x0000000000000000000000000000000000000000000000000000000000000000"
11 |
12 | /** @typedef {string} Hash 32-byte string (64 hex characters, 256 bits) */
13 |
14 | /**
15 | * Hash a member's data in the merkle tree leaf
16 | * Corresponding code in BalanceVerifier.sol:
17 | * bytes32 leafHash = keccak256(abi.encodePacked(account, balance, blockNumber));
18 | * @param {MonoplasmaMember} member
19 | * @param {Number} salt e.g. blockNumber
20 | * @returns {Hash} keccak256 hash
21 | */
22 | function hashLeaf(member, salt) {
23 | return solidityKeccak256(["address", "uint256", "uint256"], [member.address, member.earnings.toString(), salt])
24 | }
25 |
26 | /**
27 | * Hash intermediate branch nodes together
28 | * @param {Hash} data1 left branch
29 | * @param {Hash} data2 right branch
30 | * @returns {Hash} keccak256 hash
31 | */
32 | function hashCombined(data1, data2) {
33 | return data1 < data2 ?
34 | solidityKeccak256(["uint256", "uint256"], [data1, data2]) :
35 | solidityKeccak256(["uint256", "uint256"], [data2, data1])
36 | }
37 |
38 | function roundUpToPowerOfTwo(x) {
39 | let i = 1
40 | while (i < x) { i <<= 1 }
41 | return i
42 | }
43 |
44 | /** @typedef {String} EthereumAddress */
45 |
46 | /**
47 | * @typedef {Object} MerkleTree
48 | * @property {Array} hashes
49 | * @property {Map} indexOf the index of given address in the hashes array
50 | */
51 |
52 | /**
53 | * Calculate the Merkle tree hashes
54 | * @param {Array} leafContents
55 | * @returns {MerkleTree} hashes in the tree
56 | */
57 | function buildMerkleTree(leafContents, salt) {
58 | const leafCount = leafContents.length + (leafContents.length % 2) // room for zero next to odd leaf
59 | const branchCount = roundUpToPowerOfTwo(leafCount)
60 | const treeSize = branchCount + leafCount
61 | const hashes = new Array(treeSize)
62 | const indexOf = {}
63 | hashes[0] = branchCount
64 |
65 | // leaf hashes: hash(blockNumber + address + balance)
66 | let i = branchCount
67 | leafContents.forEach(member => {
68 | indexOf[member.address] = i
69 | hashes[i++] = hashLeaf(member, salt) // eslint-disable-line no-plusplus
70 | })
71 |
72 | // Branch hashes: start from leaves, populate branches with hash(hash of left + right child)
73 | // Iterate start...end each level in tree, that is, indices 2^(n-1)...2^n
74 | for (let startI = branchCount, endI = treeSize; startI > 1; endI = startI, startI >>= 1) {
75 | let sourceI = startI
76 | let targetI = startI >> 1
77 | while (sourceI < endI) {
78 | const hash1 = hashes[sourceI]
79 | const hash2 = hashes[sourceI + 1]
80 | if (!hash1) { // end of level in tree because rest are missing
81 | break
82 | } else if (!hash2) { // odd node in the end
83 | hashes[sourceI + 1] = ZERO // add zero on the path
84 | hashes[targetI] = hash1 // no need to hash since no new information was added
85 | break
86 | } else {
87 | hashes[targetI] = hashCombined(hash1, hash2)
88 | }
89 | sourceI += 2
90 | targetI += 1
91 | }
92 | }
93 |
94 | return { hashes, indexOf }
95 | }
96 |
97 | class MerkleTree {
98 | constructor(initialContents = [], initialSalt = 0) {
99 | this.update(initialContents, initialSalt)
100 | }
101 |
102 | /**
103 | * Lazy update, the merkle tree is recalculated only when info is asked from it
104 | * @param newContents list of MonoplasmaMembers
105 | * @param {String | Number} newSalt a number or hex string, e.g. blockNumber
106 | */
107 | update(newContents, newSalt) {
108 | this.isDirty = true
109 | this.contents = newContents
110 | this.salt = new BN(newSalt)
111 | }
112 |
113 | getContents() {
114 | if (this.contents.length === 0) {
115 | throw new Error("Can't construct a MerkleTree with empty contents!")
116 | }
117 | if (this.isDirty) {
118 | // TODO: sort, to enforce determinism?
119 | this.cached = buildMerkleTree(this.contents, this.salt)
120 | this.isDirty = false
121 | }
122 | return this.cached
123 | }
124 |
125 | includes(address) {
126 | const { indexOf } = this.getContents()
127 | return Object.prototype.hasOwnProperty.call(indexOf, address)
128 | }
129 |
130 | /**
131 | * Construct a "Merkle path", that is list of "other" hashes along the way from leaf to root
132 | * This will be sent to the root chain contract as a proof of balance
133 | * @param address of the balance that the path is supposed to verify
134 | * @returns {Array} of bytes32 hashes ["0x123...", "0xabc..."]
135 | */
136 | getPath(address) {
137 | const { hashes, indexOf } = this.getContents()
138 | const index = indexOf[address]
139 | if (!index) {
140 | throw new Error(`Address ${address} not found!`)
141 | }
142 | const path = []
143 | for (let i = index; i > 1; i >>= 1) {
144 | const otherSibling = hashes[i ^ 1]
145 | if (otherSibling !== ZERO) {
146 | path.push(otherSibling)
147 | }
148 | }
149 | return path
150 | }
151 |
152 | getRootHash() {
153 | const { hashes } = this.getContents()
154 | return hashes[1]
155 | }
156 | }
157 | MerkleTree.hashLeaf = hashLeaf
158 | MerkleTree.hashCombined = hashCombined
159 |
160 | module.exports = MerkleTree
161 |
--------------------------------------------------------------------------------
/src/operator.js:
--------------------------------------------------------------------------------
1 | const MonoplasmaWatcher = require("./watcher")
2 |
3 | module.exports = class MonoplasmaOperator extends MonoplasmaWatcher {
4 |
5 | constructor(...args) {
6 | super(...args)
7 |
8 | this.minIntervalBlocks = this.state.minIntervalBlocks || 1 // TODO: think about it more closely
9 | this.address = this.state.operatorAddress
10 | this.state.gasPrice = this.state.gasPrice || 4000000000 // 4 gwei
11 | this.state.lastPublishedBlock = this.state.lastPublishedBlock || 0
12 | }
13 |
14 | async start() {
15 | await super.start()
16 | this.tokenFilter.on("data", event => this.onTokensReceived(event).catch(this.error))
17 | }
18 |
19 | // TODO: block publishing should be based on value-at-risk, that is, publish after so-and-so many tokens received
20 | async onTokensReceived(event) {
21 | this.state.lastBlockNumber = +event.blockNumber // update here too, because there's no guarantee MonoplasmaWatcher's listener gets called first
22 | if (this.state.lastBlockNumber >= this.state.lastPublishedBlock + this.minIntervalBlocks) {
23 | await this.publishBlock()
24 | }
25 | }
26 |
27 | async publishBlock(blockNumber) {
28 | const bnum = blockNumber || this.state.lastBlockNumber
29 | if (blockNumber <= this.state.lastPublishedBlock) {
30 | throw new Error(`Block #${this.state.lastPublishedBlock} has already been published, can't publish #${blockNumber}`)
31 | }
32 | this.log(`Publishing block ${bnum}`)
33 | this.plasma.setBlockNumber(bnum)
34 | const hash = this.plasma.getRootHash()
35 | const ipfsHash = ""
36 | await this.contract.methods.commit(bnum, hash, ipfsHash).send({
37 | from: this.address,
38 | gas: 4000000,
39 | gasPrice: this.state.gasPrice
40 | })
41 | this.state.lastPublishedBlock = bnum
42 |
43 | const block = await this.web3.eth.getBlock(bnum)
44 | return this.plasma.storeBlock(bnum, block.timestamp) // TODO: move this to Watcher
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/src/routers/admin.js:
--------------------------------------------------------------------------------
1 | const express = require("express")
2 | const {utils: { isAddress }} = require("web3")
3 |
4 | module.exports = channel => {
5 | const router = express.Router()
6 |
7 | router.get("/", (req, res) => {
8 | res.send({
9 | status: "ok",
10 | })
11 | })
12 |
13 | // TODO: "join" event must include blockNumber
14 | router.post("/members", (req, res) => {
15 | const addresses = Array.isArray(req.body) ? req.body : [req.body]
16 | if (addresses.length < 1) {
17 | res.status(400).send({error: "Must provide at least one member object to add!"})
18 | return
19 | }
20 | for (const address of addresses) {
21 | if (!isAddress(address)) {
22 | res.status(400).send({error: `Bad Ethereum address when adding members: ${address}`})
23 | return
24 | }
25 | }
26 | channel.publish("join", addresses)
27 | res.set("Location", `${req.url}/${addresses[0].address || addresses[0]}`).status(201).send({
28 | status: "Join sent"
29 | })
30 | })
31 |
32 | // TODO: "part" event must include blockNumber
33 | router.delete("/members/:address", (req, res) => {
34 | const address = req.params.address
35 | if (!isAddress(address)) {
36 | res.status(400).send({error: `Bad Ethereum address: ${address}`})
37 | return
38 | }
39 | channel.publish("part", [address])
40 | res.status(204).send()
41 | })
42 |
43 | return router
44 | }
--------------------------------------------------------------------------------
/src/routers/member.js:
--------------------------------------------------------------------------------
1 | const express = require("express")
2 | const BN = require("bn.js")
3 | const {utils: { isAddress }} = require("web3")
4 |
5 | const {
6 | QUIET,
7 | } = process.env
8 |
9 | const log = QUIET ? () => {} : console.log // eslint-disable-line no-console
10 |
11 | /** Don't send the full member list back, only member count */
12 | function blockToApiObject(block) {
13 | if (!block || !block.members) { block = { members: [] } }
14 | return {
15 | blockNumber: block.blockNumber || 0,
16 | timestamp: block.timestamp || 0,
17 | memberCount: block.members.length,
18 | totalEarnings: block.totalEarnings || 0,
19 | }
20 | }
21 |
22 | /** @type {(plasma: MonoplasmaState) => Function} */
23 | module.exports = plasma => {
24 | const router = express.Router()
25 |
26 | router.get("/", (req, res) => {
27 | res.send({
28 | status: "ok",
29 | })
30 | })
31 |
32 | router.get("/status", (req, res) => {
33 | //log("Requested monoplasma status") // commented out because demo UI spams it
34 | const memberCount = plasma.getMemberCount()
35 | const totalEarnings = plasma.getTotalRevenue()
36 | const latestBlock = blockToApiObject(plasma.getLatestBlock())
37 | const latestWithdrawableBlock = blockToApiObject(plasma.getLatestWithdrawableBlock())
38 | res.send({
39 | memberCount,
40 | totalEarnings,
41 | latestBlock,
42 | latestWithdrawableBlock,
43 | })
44 | })
45 |
46 | router.get("/members", (req, res) => {
47 | log("Requested monoplasma members")
48 | res.send(plasma.getMembers())
49 | })
50 |
51 | router.get("/members/:address", async (req, res) => {
52 | const address = req.params.address
53 | //log(`Requested member ${address}`) // commented out because demo UI spams it
54 | if (!isAddress(address)) {
55 | res.status(400).send({error: `Bad Ethereum address: ${address}`})
56 | return
57 | }
58 | const member = plasma.getMember(address)
59 | if (!member) {
60 | res.status(404).send({error: `Member not found: ${address}`})
61 | return
62 | }
63 |
64 | const frozenBlock = plasma.getLatestBlock()
65 | const withdrawableBlock = plasma.getLatestWithdrawableBlock()
66 | const memberFrozen = frozenBlock ? frozenBlock.members.find(m => m.address === address) || {} : {}
67 | const memberWithdrawable = withdrawableBlock ? withdrawableBlock.members.find(m => m.address === address) || {} : {}
68 | member.recordedEarnings = memberFrozen.earnings || "0"
69 | member.withdrawableEarnings = memberWithdrawable.earnings || "0"
70 | member.frozenEarnings = new BN(member.recordedEarnings).sub(new BN(member.withdrawableEarnings)).toString(10)
71 | if (withdrawableBlock) {
72 | member.withdrawableBlockNumber = withdrawableBlock.blockNumber
73 | member.proof = await plasma.getProofAt(address, withdrawableBlock.blockNumber)
74 | }
75 | res.send(member)
76 | })
77 |
78 | router.get("/blocks", (req, res) => {
79 | const maxNumberLatest = req.query.n
80 | log(`Requested ${maxNumberLatest || "ALL"} latest blocks`)
81 | plasma.listBlockNumbers(maxNumberLatest).then(blockNumberList => {
82 | return Promise.all(blockNumberList.map(bnum => {
83 | return plasma.getBlock(bnum).then(blockToApiObject)
84 | }))
85 | }).then(blockList => {
86 | res.send(blockList)
87 | })
88 | })
89 |
90 | router.get("/blocks/:blockNumber", (req, res) => {
91 | const blockNumber = +req.params.blockNumber
92 | log(`Requested block ${blockNumber}`)
93 | if (Number.isNaN(blockNumber)) {
94 | res.status(400).send({error: `Bad block number: ${req.params.blockNumber}`})
95 | return
96 | }
97 |
98 | plasma.getBlock(blockNumber).then(block => {
99 | const apiResult = blockToApiObject(block)
100 | res.send(apiResult)
101 | }).catch(error => {
102 | res.status(404).send(error)
103 | })
104 | })
105 |
106 | return router
107 | }
108 |
--------------------------------------------------------------------------------
/src/routers/revenueDemo.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-console */ // demo file, console logging is ok
2 | const express = require("express")
3 | const MonoplasmaMember = require("../member")
4 |
5 | module.exports = operator => {
6 | const router = express.Router()
7 |
8 | router.get("/", (req, res) => {
9 | res.send({
10 | status: "ok",
11 | })
12 | })
13 |
14 | router.get("/publishBlock", (req, res) => {
15 | console.log("Forcing side chain block publish...")
16 | operator.publishBlock().then(receipt => {
17 | console.log("Block published: " + JSON.stringify(receipt))
18 | res.send(receipt)
19 | }).catch(error => {
20 | res.status(400).send({ error: error.message })
21 | })
22 | })
23 |
24 | router.get("/stealAllTokens", (req, res) => {
25 | const address = req.query.targetAddress || operator.address
26 | const realMemberList = operator.plasma.members
27 | let tokens
28 | operator.getContractTokenBalance().then(res => {
29 | tokens = res
30 | const fakeMemberList = [new MonoplasmaMember("thief", address, tokens)]
31 | console.log("Swapping operator's balance books with something where we have all the tokens")
32 | operator.plasma.members = fakeMemberList
33 | operator.plasma.tree.update(fakeMemberList, 1)
34 | return operator.publishBlock(operator.state.lastPublishedBlock + 1)
35 | }).then(block => {
36 | console.log(`Block published: ${JSON.stringify(block)}`)
37 | console.log("Swapping back the real balance books like nothing happened.")
38 | operator.plasma.members = realMemberList
39 | operator.plasma.tree.update(realMemberList)
40 | const blockNumber = block.blockNumber
41 | const proof = []
42 | res.send({ blockNumber, tokens, proof })
43 | }).catch(error => {
44 | operator.plasma = realMemberList
45 | operator.plasma.tree.update(realMemberList)
46 | res.status(400).send({ error: error.message })
47 | })
48 | })
49 |
50 | return router
51 | }
52 |
--------------------------------------------------------------------------------
/src/utils/checkArguments.js:
--------------------------------------------------------------------------------
1 | const {utils: { isAddress }} = require("web3")
2 |
3 | /** @typedef {String} EthereumAddress */
4 |
5 | /** Validate contract addresses from user input */
6 | async function throwIfSetButNotContract(web3, address, context) {
7 | if (!address) { return }
8 | return throwIfNotContract(web3, address, context)
9 | }
10 |
11 | /** Validate contract addresses from user input */
12 | async function throwIfNotContract(web3, address, context) {
13 | throwIfBadAddress(address, context)
14 | if (await web3.eth.getCode(address) === "0x") {
15 | throw new Error(`${context || "Error"}: No contract at ${address}`)
16 | }
17 | }
18 |
19 | function throwIfBadAddress(address, context) {
20 | if (!isAddress(address)) {
21 | throw new Error(`${context || "Error"}: Bad Ethereum address ${address}`)
22 | }
23 | }
24 |
25 | module.exports = {
26 | throwIfNotContract,
27 | throwIfSetButNotContract,
28 | throwIfBadAddress,
29 | }
30 |
--------------------------------------------------------------------------------
/src/utils/deployDemoToken.js:
--------------------------------------------------------------------------------
1 | const TokenJson = require("../../build/contracts/DemoToken.json")
2 |
3 | module.exports = async function deployDemoToken(web3, tokenName, tokenSymbol, sendOptions, log) {
4 | log("Deploying a dummy token contract...")
5 | const Token = new web3.eth.Contract(TokenJson.abi)
6 | const token = await Token.deploy({
7 | data: TokenJson.bytecode,
8 | arguments: [
9 | tokenName || "Demo token",
10 | tokenSymbol || "\ud83e\udd84", // unicorn U+1f984
11 | ]
12 | }).send(sendOptions)
13 | return token.options.address
14 | }
15 |
--------------------------------------------------------------------------------
/src/utils/events.js:
--------------------------------------------------------------------------------
1 | const {
2 | QUIET,
3 | } = process.env
4 |
5 | const log = QUIET ? () => {} : console.log // eslint-disable-line no-console
6 |
7 | async function replayEvent(plasma, event) {
8 | switch (event.event) {
9 | // event Transfer(address indexed from, address indexed to, uint256 value);
10 | case "OwnershipTransferred": {
11 | const { previousOwner, newOwner } = event.returnValues
12 | log(`Owner (admin) address changed to ${newOwner} from ${previousOwner} @ block ${event.blockNumber}`)
13 | if(plasma.admin != previousOwner){
14 | throw Error(`plasma admin stored in state ${plasma.admin} != previousOwner reported by OwnershipTransferred event ${previousOwner}`)
15 | }
16 | plasma.admin = newOwner
17 | } break
18 | case "AdminFeeChanged": {
19 | const { adminFee } = event.returnValues
20 | log(`Admin fee changed to ${adminFee} @ block ${event.blockNumber}`)
21 | plasma.setAdminFeeFraction(adminFee)
22 | } break
23 | case "Transfer": {
24 | const { value } = event.returnValues
25 | log(`${value} tokens received @ block ${event.blockNumber}`)
26 | plasma.addRevenue(value, event.blockNumber)
27 | } break
28 | // event NewCommit(uint blockNumber, bytes32 rootHash, string ipfsHash);
29 | case "NewCommit": {
30 | const blockNumber = +event.returnValues.blockNumber
31 | log(`Storing block ${blockNumber}`)
32 | await plasma.storeBlock(blockNumber) // TODO: add timestamp
33 | } break
34 | case "Join": {
35 | const { addressList } = event
36 | plasma.addMembers(addressList)
37 | } break
38 | case "Part": {
39 | const { addressList } = event
40 | plasma.removeMembers(addressList)
41 | } break
42 | default: {
43 | log(`WARNING: Unexpected event: ${JSON.stringify(event)}`)
44 | }
45 | }
46 | }
47 |
48 | /** "empty", for the purposes of event lists */
49 | function empty(x) {
50 | return !Array.isArray(x) || x.length < 1
51 | }
52 |
53 | function mergeEventLists(events1, events2) {
54 | if (empty(events1)) { return empty(events2) ? [] : events2 }
55 | if (empty(events2)) { return empty(events1) ? [] : events1 }
56 | const ret = []
57 | let i1 = 0
58 | let i2 = 0
59 | let block1 = events1[0].blockNumber
60 | let block2 = events2[0].blockNumber
61 | let txi1 = events1[0].transactionIndex
62 | let txi2 = events2[0].transactionIndex
63 | let li1 = events1[0].logIndex
64 | let li2 = events2[0].logIndex
65 | for (;;) {
66 | if (block1 < block2 || block1 === block2 && (txi1 < txi2 || txi1 === txi2 && li1 <= li2)) {
67 | ret.push(events1[i1++])
68 | if (i1 >= events1.length) {
69 | return ret.concat(events2.slice(i2))
70 | }
71 | block1 = events1[i1].blockNumber
72 | txi1 = events1[i1].transactionIndex
73 | li1 = events1[i1].logIndex
74 | } else {
75 | ret.push(events2[i2++])
76 | if (i2 >= events2.length) {
77 | return ret.concat(events1.slice(i1))
78 | }
79 | block2 = events2[i2].blockNumber
80 | txi2 = events2[i2].transactionIndex
81 | li2 = events2[i2].logIndex
82 | }
83 | }
84 | }
85 |
86 | module.exports = {
87 | mergeEventLists,
88 | replayEvent,
89 | }
90 |
--------------------------------------------------------------------------------
/src/utils/formatDecimals.js:
--------------------------------------------------------------------------------
1 | const BN = require("bn.js")
2 |
3 | /**
4 | * Format a fixed-point decimal number
5 | * e.g. formatDecimals("10000", 3) === "10"
6 | * formatDecimals("15200", 3) === "15.2"
7 | * formatDecimals("10000", 6) === "0.01"
8 | * @param {Number|String} x is the number to format
9 | * @param {Number} n is the number of decimals
10 | */
11 | module.exports = function formatDecimals(x, n) {
12 | const base = new BN(10).pow(new BN(n))
13 | const { div, mod } = new BN(x).divmod(base)
14 | const ret = div.toString() + (mod.isZero() ? "" : "." + mod.toString(10, n).replace(/0*$/, ""))
15 | //const ret = (div.toString() + "." + mod.toString(10, n)).replace(/\.?0*$/, "")
16 | return ret
17 | }
18 |
--------------------------------------------------------------------------------
/src/utils/now.js:
--------------------------------------------------------------------------------
1 | /** Timestamp is seconds, just like Ethereum block.timestamp */
2 | module.exports = function now() {
3 | return Math.round(new Date() / 1000)
4 | }
5 |
--------------------------------------------------------------------------------
/src/utils/partitionArray.js:
--------------------------------------------------------------------------------
1 | // yes, I could npm i lodash.partition, but holy cow: https://github.com/lodash/lodash/blob/4.6.0-npm-packages/lodash.partition/index.js
2 | module.exports = function partition(array, filter) {
3 | const res = [[], []]
4 | array.forEach(x => {
5 | res[filter(x) ? 0 : 1].push(x)
6 | })
7 | return res
8 | }
9 |
--------------------------------------------------------------------------------
/src/utils/startGanache.js:
--------------------------------------------------------------------------------
1 | const { spawn } = require("child_process")
2 |
3 | // private keys corresponding to "testrpc" mnemonic
4 | const privateKeys = [
5 | "0x5e98cce00cff5dea6b454889f359a4ec06b9fa6b88e9d69b86de8e1c81887da0",
6 | "0xe5af7834455b7239881b85be89d905d6881dcb4751063897f12be1b0dd546bdb",
7 | "0x4059de411f15511a85ce332e7a428f36492ab4e87c7830099dadbf130f1896ae",
8 | "0x633a182fb8975f22aaad41e9008cb49a432e9fdfef37f151e9e7c54e96258ef9",
9 | "0x957a8212980a9a39bf7c03dcbeea3c722d66f2b359c669feceb0e3ba8209a297",
10 | "0xfe1d528b7e204a5bdfb7668a1ed3adfee45b4b96960a175c9ef0ad16dd58d728",
11 | "0xd7609ae3a29375768fac8bc0f8c2f6ac81c5f2ffca2b981e6cf15460f01efe14",
12 | "0xb1abdb742d3924a45b0a54f780f0f21b9d9283b231a0a0b35ce5e455fa5375e7",
13 | "0x2cd9855d17e01ce041953829398af7e48b24ece04ff9d0e183414de54dc52285",
14 | "0x2c326a4c139eced39709b235fffa1fde7c252f3f7b505103f7b251586c35d543",
15 | ]
16 |
17 | /**
18 | * @typedef {Object} GanacheInfo
19 | * @property {String} url websocket URL for Ganache RPC
20 | * @property {String} httpUrl HTTP URL for Ganache RPC
21 | * @property {Array} privateKeys inside the Ganache chain that have initial 100 ETH
22 | * @property {ChildProcess} process
23 | * @property {Function} shutdown call this to kill the Ganache process
24 | */
25 |
26 | /**
27 | * Start Ganache Ethereum simulator through CLI
28 | * @param {Number} port
29 | * @param {Function} log
30 | * @param {Function}
34 | */
35 | module.exports = async function startGanache(port, log, error, blockDelaySeconds, timeoutMs) {
36 | error = error || log || console.error // eslint-disable-line no-console
37 | log = log || console.log // eslint-disable-line no-console
38 | port = port || 8545
39 | const delay = blockDelaySeconds || 0
40 | const ganache = spawn(process.execPath, [
41 | "./node_modules/.bin/ganache-cli",
42 | "-m", "testrpc",
43 | "-p", port,
44 | "-b", `${delay}`
45 | ])
46 | function onClose(code) { error(new Error("Ganache ethereum simulator exited with code " + code)) }
47 | ganache.on("close", onClose)
48 | function shutdown() {
49 | if (ganache.off) {
50 | ganache.off("close", onClose)
51 | }
52 | ganache.kill()
53 | }
54 | ganache.stderr.on("data", line => {
55 | log(" ERROR > " + line)
56 | })
57 |
58 | // Ganache is ready to use when it says "Listening on 127.0.0.1:8545"
59 | return new Promise((done, fail) => {
60 | const timeoutHandle = setTimeout(() => fail(new Error("timeout")), timeoutMs || 20000)
61 | let launching = true
62 | ganache.stdout.on("data", data => {
63 | const str = data.toString()
64 | str.split("\n").forEach(log)
65 | if (launching) {
66 | const match = str.match(/Listening on ([0-9.:]*)/)
67 | if (match) {
68 | launching = false
69 | clearTimeout(timeoutHandle)
70 | const url = "ws://" + match[1] // "127.0.0.1:8545"
71 | const httpUrl = "http://" + match[1]
72 | done({ url, httpUrl, privateKeys, process: ganache, shutdown })
73 | }
74 | }
75 | })
76 | })
77 | }
78 |
--------------------------------------------------------------------------------
/src/validator.js:
--------------------------------------------------------------------------------
1 | const MonoplasmaState = require("./state")
2 | const MonoplasmaWatcher = require("./watcher")
3 |
4 | module.exports = class MonoplasmaValidator extends MonoplasmaWatcher {
5 | constructor(watchedAccounts, myAddress, ...args) {
6 | super(...args)
7 |
8 | this.watchedAccounts = watchedAccounts
9 | this.address = myAddress
10 | this.eventQueue = []
11 | this.lastSavedBlock = null
12 | this.validatedPlasma = new MonoplasmaState(0, [], {
13 | saveBlock: async block => {
14 | this.lastSavedBlock = block
15 | }
16 | }, this.state.operatorAddress, this.state.adminFeeFraction)
17 | }
18 |
19 | async start() {
20 | await super.start()
21 |
22 | this.log("Validator starts listening to Operator's commits")
23 | const self = this
24 | const blockFilter = this.contract.events.NewCommit({})
25 | blockFilter.on("data", event => self.checkBlock(event.returnValues).catch(this.error))
26 | }
27 |
28 | async checkBlock(block) {
29 | // add the block to store; this won't be done by Watcher because Operator does it now
30 | // TODO: move this to Watcher
31 | const blockNumber = +block.blockNumber
32 | this.plasma.storeBlock(blockNumber, block.timestamp)
33 |
34 | // update the "validated" version to the block number whose hash was published
35 | await super.playbackOn(this.validatedPlasma, this.lastCheckedBlock + 1, blockNumber)
36 | this.lastCheckedBlock = blockNumber
37 |
38 | // check that the hash at that point in history matches
39 | // TODO: get hash from this.lastSavedBlock
40 | // TODO: if there's a Transfer after NewCommit in same block, current approach breaks
41 | const hash = this.validatedPlasma.getRootHash()
42 | if (hash === block.rootHash) {
43 | this.log(`Root hash @ ${blockNumber} validated.`)
44 | this.lastValidatedBlock = blockNumber
45 | this.lastValidatedMembers = this.watchedAccounts.map(address => this.validatedPlasma.getMember(address))
46 | } else {
47 | this.log(`WARNING: Discrepancy detected @ ${blockNumber}!`)
48 | // TODO: recovery attempt logic before gtfo and blowing up everything?
49 | // TODO: needs more research into possible and probable failure modes
50 | await this.exit(this.lastValidatedBlock, this.lastValidatedMembers)
51 | }
52 | }
53 |
54 | /**
55 | * @param Number blockNumber of the block where exit is attempted
56 | * @param List members during the block where exit is attempted
57 | */
58 | async exit(blockNumber, members) {
59 | const opts = {
60 | from: this.address,
61 | gas: 4000000,
62 | gasPrice: this.state.gasPrice
63 | }
64 |
65 | // TODO: sleep until block freeze period is over
66 |
67 | // There should be no hurry, so sequential execution is ok, and it might hurt to send() all at once.
68 | // TODO: Investigate and compare
69 | //return Promise.all(members.map(m => contract.methods.withdrawAll(blockNumber, m.earnings, m.proof).send(opts)))
70 | for (const m of members) {
71 | this.log(`Recording the earnings for ${m.address}: ${m.earnings}`)
72 | await this.contract.methods.prove(blockNumber, m.address, m.earnings, m.proof).send(opts).catch(console.error) // eslint-disable-line no-console
73 | }
74 | }
75 |
76 | // TODO: validate also during playback? That would happen automagically if replayEvents would be hooked somehow
77 | async playback(from, to) {
78 | await super.playback(from, to)
79 | //await super.playbackOn(this.validatedPlasma, from, to)
80 | this.lastCheckedBlock = to
81 | this.validatedPlasma = new MonoplasmaState(0, this.plasma.getMembers(), this.validatedPlasma.store, this.state.operatorAddress, this.state.adminFeeFraction)
82 | this.lastValidatedBlock = to
83 | this.lastValidatedMembers = this.watchedAccounts.map(address => this.validatedPlasma.getMember(address))
84 | }
85 | }
86 |
--------------------------------------------------------------------------------
/src/watcher.js:
--------------------------------------------------------------------------------
1 | const MonoplasmaState = require("./state")
2 | const { replayEvent, mergeEventLists } = require("./utils/events")
3 | const { throwIfSetButNotContract } = require("./utils/checkArguments")
4 |
5 | const TokenJson = require("../build/contracts/ERC20Mintable.json")
6 | const MonoplasmaJson = require("../build/contracts/Monoplasma.json")
7 |
8 | /**
9 | * MonoplasmaWatcher hooks to the root chain contract and keeps a local copy of the Monoplasma state up to date
10 | * Can be inherited to implement Operator and Validator functionality
11 | */
12 | module.exports = class MonoplasmaWatcher {
13 |
14 | constructor(web3, joinPartChannel, startState, store, logFunc, errorFunc) {
15 | this.web3 = web3
16 | this.channel = joinPartChannel
17 | this.state = Object.assign({}, startState)
18 | this.store = store
19 | this.log = logFunc || (() => {})
20 | this.error = errorFunc || console.error // eslint-disable-line no-console
21 | this.explorerUrl = this.state.explorerUrl
22 | this.filters = {}
23 | this.eventLogIndex = +new Date()
24 | }
25 |
26 | async start() {
27 | await throwIfSetButNotContract(this.web3, this.state.contractAddress, "startState contractAddress")
28 |
29 | this.log("Initializing Monoplasma state...")
30 | // double-check state from contracts as a sanity check (TODO: alert if there were wrong in startState?)
31 | this.contract = new this.web3.eth.Contract(MonoplasmaJson.abi, this.state.contractAddress)
32 | //console.log("abi: "+JSON.stringify(MonoplasmaJson.abi))
33 | this.state.tokenAddress = await this.contract.methods.token().call()
34 | //console.log("fee : "+ this.state.adminFeeFraction)
35 | //this.state.operatorAddress = await this.contract.methods.operator().call()
36 | this.token = new this.web3.eth.Contract(TokenJson.abi, this.state.tokenAddress)
37 | this.state.freezePeriodSeconds = await this.contract.methods.freezePeriodSeconds().call()
38 |
39 | const lastBlock = this.state.lastPublishedBlock && await this.store.loadBlock(this.state.lastPublishedBlock)
40 | const savedMembers = lastBlock ? lastBlock.members : []
41 | const adminFeeFraction = lastBlock ? lastBlock.adminFeeFraction : 0
42 | const owner = lastBlock && lastBlock.owner ? lastBlock.owner : await this.contract.methods.owner().call()
43 | //console.log("owner: "+ owner+ " lastBlock: "+ JSON.stringify(lastBlock))
44 |
45 | this.plasma = new MonoplasmaState(this.state.freezePeriodSeconds, savedMembers, this.store, owner, adminFeeFraction)
46 |
47 | // TODO: playback from joinPartChannel not implemented =>
48 | // playback will actually fail if there are joins or parts from the channel in the middle (during downtime)
49 | // the failing will probably be quite quickly noticed though, so at least validators would simply restart
50 | // if the operator fails though...
51 | const latestBlock = await this.web3.eth.getBlockNumber()
52 | const playbackStartingBlock = this.state.lastBlockNumber + 1 || 0
53 | if (playbackStartingBlock <= latestBlock) {
54 | this.log("Playing back events from Ethereum and Channel...")
55 | await this.playback(playbackStartingBlock, latestBlock)
56 | this.state.lastBlockNumber = latestBlock
57 | }
58 |
59 | this.log("Listening to Ethereum events...")
60 | //console.log("state: "+ JSON.stringify(this.state))
61 | const self = this
62 | function handleEvent(event) {
63 | //console.log("seen event: " + JSON.stringify(event))
64 | self.state.lastBlockNumber = +event.blockNumber
65 | replayEvent(self.plasma, event).catch(self.error)
66 | return self.store.saveState(self.state).catch(self.error)
67 | }
68 |
69 | this.tokenFilter = this.token.events.Transfer({ filter: { to: this.state.contractAddress } })
70 | this.tokenFilter.on("data", handleEvent)
71 | this.tokenFilter.on("changed", event => { this.error("Event removed in re-org!", event) })
72 | this.tokenFilter.on("error", this.error)
73 |
74 | this.adminCutChangeFilter = this.contract.events.AdminFeeChanged({ filter: { to: this.state.contractAddress } })
75 | this.adminCutChangeFilter.on("data", handleEvent)
76 | this.adminCutChangeFilter.on("changed", event => { this.error("Event removed in re-org!", event) })
77 | this.adminCutChangeFilter.on("error", this.error)
78 |
79 | this.ownershipChangeFilter = this.contract.events.OwnershipTransferred({ filter: { to: this.state.contractAddress } })
80 | this.ownershipChangeFilter.on("data", handleEvent)
81 | this.ownershipChangeFilter.on("changed", event => { this.error("Event removed in re-org!", event) })
82 | this.ownershipChangeFilter.on("error", this.error)
83 |
84 | this.log("Listening to joins/parts from the Channel...")
85 | this.channel.listen()
86 | this.channel.on("join", addressList => {
87 | const blockNumber = this.state.lastBlockNumber + 1
88 | const addedMembers = this.plasma.addMembers(addressList)
89 | this.log(`Added or activated ${addedMembers.length} new member(s) before block ${blockNumber}`)
90 | return this.store.saveEvents(blockNumber, {
91 | blockNumber,
92 | transactionIndex: -1, // make sure join/part happens BEFORE real Ethereum tx
93 | logIndex: this.eventLogIndex++, // ... but still is internally ordered
94 | event: "Join",
95 | addressList: addedMembers,
96 | }).catch(this.error)
97 | })
98 | this.channel.on("part", addressList => {
99 | const blockNumber = this.state.lastBlockNumber + 1
100 | const removedMembers = this.plasma.removeMembers(addressList)
101 | this.log(`De-activated ${removedMembers.length} member(s) before block ${blockNumber}`)
102 | return this.store.saveEvents(blockNumber, {
103 | blockNumber,
104 | transactionIndex: -1, // make sure join/part happens BEFORE real Ethereum tx
105 | logIndex: this.eventLogIndex++, // ... but still is internally ordered
106 | event: "Part",
107 | addressList: removedMembers,
108 | }).catch(this.error)
109 | })
110 |
111 | await this.store.saveState(this.state)
112 | }
113 |
114 | async stop() {
115 | this.tokenFilter.unsubscribe()
116 | this.channel.close()
117 | }
118 |
119 | async playback(fromBlock, toBlock) {
120 | await this.playbackOn(this.plasma, fromBlock, toBlock)
121 | }
122 |
123 | async playbackOn(plasma, fromBlock, toBlock) {
124 | // TODO: include joinPartHistory in playback
125 | // TODO interim solution: take members from a recent block
126 | this.log(`Playing back blocks ${fromBlock}...${toBlock}`)
127 | const joinPartEvents = await this.store.loadEvents(fromBlock, toBlock + 1) // +1 to catch events after the very latest block, see join/part listening above
128 | const blockCreateEvents = await this.contract.getPastEvents("NewCommit", { fromBlock, toBlock })
129 | const adminFeeChangeEvents = await this.contract.getPastEvents("AdminFeeChanged", { fromBlock, toBlock })
130 | const transferEvents = await this.token.getPastEvents("Transfer", { filter: { to: this.state.contractAddress }, fromBlock, toBlock })
131 |
132 | const m1 = mergeEventLists(blockCreateEvents, transferEvents)
133 | const m2 = mergeEventLists(m1, joinPartEvents)
134 | const m3 = mergeEventLists(m2, adminFeeChangeEvents)
135 |
136 | const allEvents = mergeEventLists(m3, joinPartEvents)
137 | for (const event of allEvents) {
138 | await replayEvent(plasma, event)
139 | }
140 | plasma.setBlockNumber(toBlock)
141 | }
142 |
143 | async getContractTokenBalance() {
144 | const balance = await this.token.methods.balanceOf(this.state.contractAddress).call()
145 | return balance
146 | }
147 | }
148 |
--------------------------------------------------------------------------------
/start_operator.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | const fs = require("mz/fs")
4 | const path = require("path")
5 | const express = require("express")
6 | const cors = require("cors")
7 | const bodyParser = require("body-parser")
8 | const onProcessExit = require("exit-hook")
9 |
10 | const Web3 = require("web3")
11 |
12 | const Operator = require("./src/operator")
13 | const { throwIfSetButNotContract } = require("./src/utils/checkArguments")
14 | const defaultServers = require("./defaultServers.json")
15 | const deployDemoToken = require("./src/utils/deployDemoToken")
16 |
17 | const operatorRouter = require("./src/routers/member")
18 | const adminRouter = require("./src/routers/admin")
19 | const revenueDemoRouter = require("./src/routers/revenueDemo")
20 | const Channel = require("./src/joinPartChannel")
21 |
22 | const MonoplasmaJson = require("./build/contracts/Monoplasma.json")
23 |
24 | const {
25 | ETHEREUM_SERVER,
26 | ETHEREUM_NETWORK_ID,
27 | ETHEREUM_PRIVATE_KEY,
28 | TOKEN_ADDRESS,
29 | CONTRACT_ADDRESS,
30 | FREEZE_PERIOD_SECONDS,
31 | GAS_PRICE_GWEI,
32 | RESET,
33 | STORE_DIR,
34 | QUIET,
35 |
36 | // these will be used 1) for demo token 2) if TOKEN_ADDRESS doesn't support name() and symbol()
37 | TOKEN_SYMBOL,
38 | TOKEN_NAME,
39 |
40 | // if ETHEREUM_SERVER isn't specified, start a local Ethereum simulator (Ganache) in given port
41 | GANACHE_PORT,
42 |
43 | JOIN_PART_CHANNEL_PORT,
44 |
45 | // web UI for revenue sharing demo
46 | WEBSERVER_PORT,
47 | // don't launch web server in start_operator script
48 | // by default start serving static files under demo/public. This is for dev where UI is launched with `npm start` under demo directory.
49 | //EXTERNAL_WEBSERVER,
50 | ADMINFEE_WEI
51 | } = process.env
52 |
53 | const log = QUIET ? () => {} : console.log
54 | const error = (e, ...args) => {
55 | console.error(e.stack, args)
56 | process.exit(1)
57 | }
58 |
59 | const storeDir = fs.existsSync(STORE_DIR) ? STORE_DIR : __dirname + "/demo/public/data"
60 | const FileStore = require("./src/fileStore")
61 | const fileStore = new FileStore(storeDir, log)
62 |
63 | let ganache = null
64 | function stopGanache() {
65 | if (ganache) {
66 | log("Shutting down Ethereum simulator...")
67 | ganache.shutdown()
68 | ganache = null
69 | }
70 | }
71 | onProcessExit(stopGanache)
72 |
73 | async function start() {
74 | let privateKey
75 | let ethereumServer = ETHEREUM_SERVER || defaultServers[ETHEREUM_NETWORK_ID]
76 | if (ethereumServer) {
77 | if (!ETHEREUM_PRIVATE_KEY) { throw new Error("Private key required to deploy the airdrop contract. Deploy transaction must be signed.") }
78 | privateKey = ETHEREUM_PRIVATE_KEY.startsWith("0x") ? ETHEREUM_PRIVATE_KEY : "0x" + ETHEREUM_PRIVATE_KEY
79 | if (privateKey.length !== 66) { throw new Error("Malformed private key, must be 64 hex digits long (optionally prefixed with '0x')") }
80 | } else {
81 | // use account 0: 0xa3d1f77acff0060f7213d7bf3c7fec78df847de1
82 | privateKey = "0x5e98cce00cff5dea6b454889f359a4ec06b9fa6b88e9d69b86de8e1c81887da0"
83 | log("Starting Ethereum simulator...")
84 | const ganachePort = GANACHE_PORT || 8545
85 | const ganacheLog = msg => { log(" " + msg) }
86 | ganache = await require("./src/utils/startGanache")(ganachePort, ganacheLog, error)
87 | ethereumServer = ganache.url
88 | }
89 |
90 | log(`Connecting to ${ethereumServer}`)
91 | const web3 = new Web3(ethereumServer)
92 | const account = web3.eth.accounts.wallet.add(privateKey)
93 |
94 | await throwIfSetButNotContract(web3, TOKEN_ADDRESS, "Environment variable TOKEN_ADDRESS")
95 | await throwIfSetButNotContract(web3, CONTRACT_ADDRESS, "Environment variable CONTRACT_ADDRESS")
96 |
97 | const opts = {
98 | from: account.address,
99 | gas: 4000000,
100 | gasPrice: GAS_PRICE_GWEI || 4000000000,
101 | }
102 |
103 | // ignore the saved config / saved state if not using a fresh ganache instance
104 | // augment the config / saved state with variables that may be useful for the validators
105 | const config = RESET || ganache ? {} : await fileStore.loadState()
106 | config.tokenAddress = TOKEN_ADDRESS || config.tokenAddress || await deployDemoToken(web3, TOKEN_NAME, TOKEN_SYMBOL, opts, log)
107 | config.freezePeriodSeconds = +FREEZE_PERIOD_SECONDS || config.freezePeriodSeconds || 20
108 | const newContractAdminFee = ADMINFEE_WEI || 0
109 | config.contractAddress = CONTRACT_ADDRESS || config.contractAddress || await deployContract(web3, config.tokenAddress, config.freezePeriodSeconds, newContractAdminFee, opts, log)
110 | config.ethereumServer = ethereumServer
111 | config.ethereumNetworkId = ETHEREUM_NETWORK_ID
112 | config.channelPort = JOIN_PART_CHANNEL_PORT
113 | config.operatorAddress = account.address
114 |
115 | log("Starting the joinPartChannel and Operator")
116 | const adminChannel = new Channel(JOIN_PART_CHANNEL_PORT)
117 | adminChannel.startServer()
118 | const operatorChannel = new Channel(JOIN_PART_CHANNEL_PORT)
119 | const operator = new Operator(web3, operatorChannel, config, fileStore, log, error)
120 | await operator.start()
121 |
122 | log("Starting web server...")
123 | const port = WEBSERVER_PORT || 8080
124 | const serverURL = `http://localhost:${port}`
125 | const app = express()
126 | app.use(cors())
127 | app.use(bodyParser.json({limit: "50mb"}))
128 | app.use("/api", operatorRouter(operator.plasma.getMemberApi()))
129 | app.use("/admin", adminRouter(adminChannel))
130 | app.use("/demo", revenueDemoRouter(operator))
131 | app.use(express.static(path.join(__dirname, "demo/public")))
132 | app.listen(port, () => log(`Web server started at ${serverURL}`))
133 |
134 | log("[DONE]")
135 | }
136 |
137 | async function deployContract(web3, tokenAddress, freezePeriodSeconds, adminFee, sendOptions, log) {
138 | log(`Deploying root chain contract (token @ ${tokenAddress}, freezePeriodSeconds = ${freezePeriodSeconds})...`)
139 | const Monoplasma = new web3.eth.Contract(MonoplasmaJson.abi)
140 | const monoplasma = await Monoplasma.deploy({
141 | data: MonoplasmaJson.bytecode,
142 | arguments: [tokenAddress, freezePeriodSeconds, adminFee]
143 | }).send(sendOptions)
144 | return monoplasma.options.address
145 | }
146 |
147 | start().catch(error)
148 |
--------------------------------------------------------------------------------
/start_validator.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | const fs = require("mz/fs")
4 | const fsEx = require("fs-extra")
5 | const path = require("path")
6 |
7 | const prettyjson = require("prettyjson")
8 | const Web3 = require("web3")
9 |
10 | const Validator = require("./src/validator")
11 | const { throwIfNotContract } = require("./src/utils/checkArguments")
12 | const defaultServers = require("./defaultServers.json")
13 | const Channel = require("./src/joinPartChannel")
14 |
15 | const {
16 | CONFIG_JSON,
17 | CONFIG_FILE,
18 | CONFIG_URL,
19 | ETHEREUM_SERVER,
20 | ETHEREUM_NETWORK_ID,
21 | ETHEREUM_PRIVATE_KEY,
22 | WATCHED_ACCOUNTS,
23 | STORE_DIR,
24 | PLAYBACK_EVENTS_DIR,
25 | QUIET,
26 | } = process.env
27 |
28 | const log = !QUIET ? console.log : () => {}
29 | function error() {
30 | console.error(...arguments)
31 | process.exit(1)
32 | }
33 |
34 | const defaultConfigPath = __dirname + "/demo/public/data/state.json"
35 | const storeDir = fs.existsSync(STORE_DIR) ? STORE_DIR : __dirname + "/temp"
36 | const FileStore = require("./src/fileStore")
37 | const fileStore = new FileStore(storeDir, log)
38 |
39 | // TODO: get rid of this copy hack; past events sync should happen through the monoplasmaRouter and HTTP
40 | const eventsDir = path.join(storeDir, "events")
41 | const pastEventsDir = fs.existsSync(PLAYBACK_EVENTS_DIR) ? PLAYBACK_EVENTS_DIR : __dirname + "/demo/public/data/events"
42 | log(`Channel playback hack: Copying past events ${pastEventsDir} -> ${eventsDir}`)
43 | fsEx.copySync(pastEventsDir, eventsDir)
44 |
45 | async function start() {
46 | const config = CONFIG_JSON ? JSON.parse(CONFIG_JSON)
47 | : CONFIG_FILE ? await loadStateFromFile(CONFIG_FILE)
48 | : CONFIG_URL ? await loadStateFromUrl(CONFIG_URL)
49 | : fs.existsSync(defaultConfigPath) ? await loadStateFromFile(defaultConfigPath)
50 | : {}
51 | log("Received config:")
52 | log(prettyjson.render(config))
53 |
54 | // TODO: validate config (operator state)
55 |
56 | const ethereumNetworkId = ETHEREUM_NETWORK_ID || config.ethereumNetworkId
57 | const ethereumServer = ETHEREUM_SERVER || defaultServers[ethereumNetworkId] || config.ethereumServer
58 | if (!ethereumServer) { throw new Error("ethereumServer not found in config, please supply ETHEREUM_SERVER or ETHEREUM_NETWORK_ID you'd like to connect to as environment variable!") }
59 |
60 | log(`Connecting to ${ethereumServer}...`)
61 | const web3 = new Web3(ethereumServer)
62 |
63 | let address = null
64 | const accountList = WATCHED_ACCOUNTS ? WATCHED_ACCOUNTS.split(",") : []
65 | if (accountList.length > 0) {
66 | // TODO: guess private key if missing?
67 | // with ganache, operator uses account 0: 0xa3d1f77acff0060f7213d7bf3c7fec78df847de1
68 | let key = "0x5e98cce00cff5dea6b454889f359a4ec06b9fa6b88e9d69b86de8e1c81887da0"
69 | if (ETHEREUM_PRIVATE_KEY) {
70 | key = ETHEREUM_PRIVATE_KEY.startsWith("0x") ? ETHEREUM_PRIVATE_KEY : "0x" + ETHEREUM_PRIVATE_KEY
71 | if (key.length !== 66) { throw new Error("Malformed private key, must be 64 hex digits long (optionally prefixed with '0x')") }
72 | } else {
73 | log("Environment variable ETHEREUM_PRIVATE_KEY not found, using key for address 0xa3d1f77acff0060f7213d7bf3c7fec78df847de1")
74 | }
75 | const account = web3.eth.accounts.wallet.add(key)
76 | address = account.address
77 | const balance = await web3.eth.getBalance(address)
78 | if (+balance === 0) {
79 | log(`Address ${address} has no ether, it is needed to send exit transaction for the WATCHED_ACCOUNTS!`)
80 | //throw new Error("Ether is needed to send exit transaction for the WATCHED_ACCOUNTS!") }
81 | }
82 | }
83 |
84 | await throwIfNotContract(web3, config.tokenAddress, "Config variable tokenAddress")
85 | await throwIfNotContract(web3, config.contractAddress, "Config variable contractAddress")
86 |
87 | // full playback
88 | config.lastBlockNumber = 0
89 | config.lastPublishedBlock = 0
90 |
91 | log("Starting the joinPartChannel and Validator")
92 | const channel = new Channel(config.channelPort)
93 | const validator = new Validator(accountList, address, web3, channel, config, fileStore, log, error)
94 | await validator.start()
95 | }
96 |
97 | async function loadStateFromFile(path) {
98 | log(`Loading operator state from ${path}...`)
99 | const raw = await fs.readFile(path)
100 | return JSON.parse(raw)
101 | }
102 |
103 | async function loadStateFromUrl(url) {
104 | throw new Error("not implemented, url: " + url)
105 | }
106 |
107 | start().catch(error)
108 |
--------------------------------------------------------------------------------
/strings/BalanceVerifier.json:
--------------------------------------------------------------------------------
1 | {
2 | "error_frozen": "Block is still frozen, try again later",
3 | "error_proof": "Bad proof",
4 | "error_blockNotFound": "Block not found",
5 | "error_overwrite": "Cannot overwrite old commit hashes"
6 | }
--------------------------------------------------------------------------------
/strings/Monoplasma.json:
--------------------------------------------------------------------------------
1 | {
2 | "error_adminFee": "Admin fee cannot be greater than 1",
3 | "error_notPermitted": "Not permitted to call this function",
4 | "error_alreadyExists": "Already added",
5 | "error_notFound": "Not found",
6 | "error_oldEarnings": "Earnings already recorded, cannot change to lower number",
7 | "error_zeroWithdraw": "Can't withdraw zero tokens",
8 | "error_overdraft": "Can't withdraw that many tokens",
9 | "error_transfer": "Error during token transfer; does contract have enough tokens?",
10 | "error_missingBalance": "Inadequate token balance to cover the verified earnings; suspect Operator malfunction",
11 | "error_badSignature": "Invalid signature. It can be old, to another contract, or from another signer.",
12 | "error_badSignatureLength": "Signature must be 32 bytes long",
13 | "error_badSignatureVersion": "Signature version must be 0, 1, 27 or 28"
14 | }
--------------------------------------------------------------------------------
/strings/Ownable.json:
--------------------------------------------------------------------------------
1 | {
2 | "error_onlyOwner": "Only owner is allowed to call this function",
3 | "error_onlyPendingOwner": "Only pending owner is allowed to call this function"
4 | }
--------------------------------------------------------------------------------
/test/.eslintrc.js:
--------------------------------------------------------------------------------
1 | // ESLint settings for tests
2 | module.exports = {
3 | globals: {
4 | describe: "readonly",
5 | it: "readonly",
6 | before: "readonly",
7 | beforeEach: "readonly",
8 | after: "readonly",
9 | afterEach: "readonly",
10 | },
11 | rules: {
12 | "no-console": "off",
13 | }
14 | }
--------------------------------------------------------------------------------
/test/e2e/revenue-sharing-demo.js:
--------------------------------------------------------------------------------
1 | const { spawn } = require("child_process")
2 | const fetch = require("node-fetch")
3 | const Web3 = require("web3")
4 | const BN = require("bn.js")
5 | const assert = require("assert")
6 |
7 | const sleep = require("../utils/sleep-promise")
8 | const { untilStreamContains } = require("../utils/await-until")
9 |
10 | const TokenJson = require("../../build/contracts/ERC20Mintable.json")
11 | const MonoplasmaJson = require("../../build/contracts/Monoplasma.json")
12 |
13 | const STORE_DIR = __dirname + `/test-store-${+new Date()}`
14 | const GANACHE_PORT = 8296
15 | const WEBSERVER_PORT = 3030
16 | const JOIN_PART_CHANNEL_PORT = 5964
17 | const FREEZE_PERIOD_SECONDS = 1
18 |
19 | const from = "0xa3d1f77acff0060f7213d7bf3c7fec78df847de1"
20 |
21 | const FileStore = require("../../src/fileStore")
22 | const fileStore = new FileStore(STORE_DIR, console.log)
23 |
24 | describe("Revenue sharing demo", () => {
25 | let operatorProcess
26 | it("should get through the happy path", async () => {
27 | console.log("--- Running start_operator.js ---")
28 | operatorProcess = spawn(process.execPath, ["start_operator.js"], { env: {
29 | STORE_DIR,
30 | GANACHE_PORT,
31 | WEBSERVER_PORT,
32 | JOIN_PART_CHANNEL_PORT,
33 | FREEZE_PERIOD_SECONDS,
34 | RESET: "yesplease",
35 | //QUIET: "shutup", // TODO: this makes start_operator.js not return in time... weird
36 | }})
37 | operatorProcess.stdout.on("data", data => { console.log(` ${data.toString().trim()}`) })
38 | operatorProcess.stderr.on("data", data => { console.log(`op *** ERROR: ${data}`) })
39 | operatorProcess.on("close", code => { console.log(`start_operator.js exited with code ${code}`) })
40 | operatorProcess.on("error", err => { console.log(`start_operator.js ERROR: ${err}`) })
41 |
42 | await untilStreamContains(operatorProcess.stdout, "[DONE]")
43 |
44 | console.log("--- Operator started, getting the init state ---")
45 | const state = await fileStore.loadState()
46 |
47 | const web3 = new Web3(`ws://localhost:${GANACHE_PORT}`)
48 | const contract = new web3.eth.Contract(MonoplasmaJson.abi, state.contractAddress)
49 | const token = new web3.eth.Contract(TokenJson.abi, state.tokenAddress)
50 |
51 | const opts = {
52 | from,
53 | gas: 4000000,
54 | gasPrice: 4000000000,
55 | }
56 |
57 | console.log("1) click 'Add users' button")
58 | const userList = [from,
59 | "0xeabe498c90fb31f6932ab9da9c4997a6d9f18639",
60 | "0x4f623c9ef67b1d9a067a8043344fb80ae990c734",
61 | "0xbb0965a38fcd97b6f34b4428c4bb32875323e012",
62 | "0x6dde58bf01e320de32aa69f6daf9ba3c887b4db6",
63 | "0xe04d3d361eb88a67a2bd3a4762f07010708b2811",
64 | "0x47262e0936ec174b7813941ee57695e3cdcd2043",
65 | "0xb5fe12f7437dbbc65c53bc9369db133480438f6f",
66 | "0x3ea97ad9b624acd8784011c3ebd0e07557804e45",
67 | "0x4d4bb0980c214b8f4e24d7d58ccf5f8a92f70d76",
68 | ]
69 | const res1 = await fetch(`http://localhost:${WEBSERVER_PORT}/admin/members`, {
70 | method: "POST",
71 | headers: { "Content-Type": "application/json" },
72 | body: JSON.stringify(userList),
73 | }).then(resp => resp.json())
74 | console.log(` Server response: ${JSON.stringify(res1)}`)
75 |
76 | console.log(" check that there are new users in community")
77 | const res1b = await fetch(`http://localhost:${WEBSERVER_PORT}/api/status`).then(resp => resp.json())
78 | console.log(` Status: ${JSON.stringify(res1b)}`)
79 |
80 | console.log("2) click 'Add revenue' button a couple times")
81 | for (let i = 0; i < 5; i++) {
82 | console.log(" Sending 10 tokens to Monoplasma contract...")
83 | await token.methods.transfer(contract.options.address, web3.utils.toWei("10", "ether")).send(opts)
84 |
85 | // TODO: things will break if revenue is added too fast. You can remove the below row to try and fix it.
86 | await sleep(1000)
87 |
88 | // check total revenue
89 | const res2 = await fetch(`http://localhost:${WEBSERVER_PORT}/api/status`).then(resp => resp.json())
90 | console.log(` Total revenue: ${JSON.stringify(res2)}`)
91 | }
92 |
93 | console.log(" Waiting for blocks to unfreeze...")
94 | await sleep(2000)
95 |
96 | console.log("3) click 'View' button")
97 | const res3 = await fetch(`http://localhost:${WEBSERVER_PORT}/api/members/${from}`).then(resp => resp.json())
98 | console.log(res3)
99 |
100 | const balanceBefore = await token.methods.balanceOf(from).call()
101 | console.log(` Token balance before: ${balanceBefore}`)
102 |
103 | console.log("4) click 'Withdraw' button")
104 | await contract.methods.withdrawAll(res3.withdrawableBlockNumber, res3.withdrawableEarnings, res3.proof).send(opts)
105 |
106 | // check that we got the tokens
107 | const balanceAfter = await token.methods.balanceOf(from).call()
108 | console.log(` Token balance after: ${balanceAfter}`)
109 |
110 | const difference = new BN(balanceAfter).sub(new BN(balanceBefore))
111 | console.log(` Withdraw effect: ${difference}`)
112 |
113 | assert.strictEqual(difference.toString(10), web3.utils.toWei("5", "ether"))
114 | }).timeout(15000)
115 |
116 | after(() => {
117 | operatorProcess.kill()
118 | spawn("rm", ["-rf", STORE_DIR])
119 | })
120 | })
121 |
--------------------------------------------------------------------------------
/test/mocha/fileStore.js:
--------------------------------------------------------------------------------
1 | const os = require("os")
2 | const path = require("path")
3 | const assert = require("assert")
4 |
5 | const log = console.log //() => {}
6 |
7 | const tmpDir = path.join(os.tmpdir(), `fileStore-test-${+new Date()}`)
8 | const FileStore = require("../../src/fileStore")
9 | const fileStore = new FileStore(tmpDir, log)
10 |
11 | describe("File system implementation of Monoplasma storage", () => {
12 | it("Correctly loads and saves state", async () => {
13 | const saved = {testing: "yes"}
14 | await fileStore.saveState(saved)
15 | const loaded = await fileStore.loadState()
16 | assert.deepStrictEqual(saved, loaded)
17 | })
18 |
19 | it("Correctly loads and saves blocks", async () => {
20 | const saved = {blockNumber: 42, members: [], timeStamp: +new Date(), totalEarnings: "1"}
21 | await fileStore.saveBlock(saved)
22 | const exists = await fileStore.blockExists(42)
23 | assert(exists)
24 | const loaded = await fileStore.loadBlock(42)
25 | assert.deepStrictEqual(saved, loaded)
26 | })
27 |
28 | it("Checks block existence correctly", async () => {
29 | const exists = await fileStore.blockExists(68)
30 | assert(!exists)
31 | })
32 |
33 | it("Correctly loads and saves events", async () => {
34 | await fileStore.saveEvents(42, [
35 | { blockNumber: 42, event: "Join", addressList: ["0x6dde58bf01e320de32aa69f6daf9ba3c887b4db6"] },
36 | { blockNumber: 42, event: "Join", addressList: ["0x47262e0936ec174b7813941ee57695e3cdcd2043"] },
37 | ])
38 | await fileStore.saveEvents(44, [
39 | { blockNumber: 44, event: "Part", addressList: ["0x6dde58bf01e320de32aa69f6daf9ba3c887b4db6"] },
40 | { blockNumber: 44, event: "Part", addressList: ["0x47262e0936ec174b7813941ee57695e3cdcd2043"] },
41 | ])
42 | await fileStore.saveEvents(46, [
43 | { blockNumber: 46, event: "Join", addressList: ["0x6dde58bf01e320de32aa69f6daf9ba3c887b4db6"] },
44 | { blockNumber: 46, event: "Join", addressList: ["0x47262e0936ec174b7813941ee57695e3cdcd2043"] },
45 | ])
46 | const loaded = await fileStore.loadEvents(42, 44)
47 | assert.deepStrictEqual(loaded, [
48 | { blockNumber: 42, event: "Join", addressList: ["0x6dde58bf01e320de32aa69f6daf9ba3c887b4db6"] },
49 | { blockNumber: 42, event: "Join", addressList: ["0x47262e0936ec174b7813941ee57695e3cdcd2043"] },
50 | { blockNumber: 44, event: "Part", addressList: ["0x6dde58bf01e320de32aa69f6daf9ba3c887b4db6"] },
51 | { blockNumber: 44, event: "Part", addressList: ["0x47262e0936ec174b7813941ee57695e3cdcd2043"] },
52 | ])
53 | })
54 | })
--------------------------------------------------------------------------------
/test/mocha/joinPartChannel.js:
--------------------------------------------------------------------------------
1 |
2 | const assert = require("assert")
3 | const path = require("path")
4 | const { spawn } = require("child_process")
5 |
6 | const Channel = require("../../src/joinPartChannel")
7 |
8 | const { untilStreamContains } = require("../utils/await-until")
9 |
10 | const helperFile = path.normalize(path.join(__dirname, "..", "utils", "joinPartChannel"))
11 |
12 | function assertThrows(fun, reason) {
13 | let failed = false
14 | try {
15 | fun()
16 | } catch (e) {
17 | failed = true
18 | if (reason) {
19 | assert.strictEqual(e.message, reason)
20 | }
21 | }
22 | if (!failed) {
23 | throw new Error("Expected call to fail")
24 | }
25 | }
26 |
27 | describe("joinPartChannel", () => {
28 | it("gets messages through", async function () {
29 | const client0 = spawn("node", [`${helperFile}-client.js`])
30 | const client1 = spawn("node", [`${helperFile}-client.js`])
31 | const server = spawn("node", [`${helperFile}-server.js`])
32 |
33 | await Promise.all([
34 | untilStreamContains(client0.stdout, "[OK]"),
35 | untilStreamContains(client1.stdout, "[OK]"),
36 | untilStreamContains(server.stdout, "[OK]"),
37 | ])
38 |
39 | server.kill()
40 | client1.kill()
41 | client0.kill()
42 | }).timeout(2000)
43 |
44 | it("can't double-start server", () => {
45 | const channel = new Channel(9876)
46 | channel.startServer()
47 | assertThrows(() => channel.startServer(), "Already started as server")
48 | assertThrows(() => channel.listen(), "Already started as server")
49 | channel.close()
50 | })
51 |
52 | it("can't double-start client", () => {
53 | const channel = new Channel(9876)
54 | channel.listen()
55 | assertThrows(() => channel.startServer(), "Already started as client")
56 | assertThrows(() => channel.listen(), "Already started as client")
57 | channel.close()
58 | })
59 | })
60 |
--------------------------------------------------------------------------------
/test/mocha/member.js:
--------------------------------------------------------------------------------
1 |
2 | const MonoplasmaMember = require("../../src/member")
3 | const assert = require("assert")
4 | const sinon = require("sinon")
5 |
6 | describe("MonoplasmaMember", () => {
7 | it("should add revenue to initially undefined balance", () => {
8 | const m = new MonoplasmaMember("tester1", "0xb3428050ea2448ed2e4409be47e1a50ebac0b2d2")
9 | m.addRevenue(100)
10 | assert.strictEqual(m.getEarningsAsInt(), 100)
11 | })
12 | it("should add revenue to initially defined balance", () => {
13 | const m = new MonoplasmaMember("tester1", "0xb3428050ea2448ed2e4409be47e1a50ebac0b2d2", 100)
14 | m.addRevenue(100)
15 | assert.strictEqual(m.getEarningsAsInt(), 200)
16 | })
17 | it("should initially be active", () => {
18 | const m = new MonoplasmaMember("tester1", "0xb3428050ea2448ed2e4409be47e1a50ebac0b2d2")
19 | assert(m.isActive())
20 | })
21 | it("should return correct object representation", () => {
22 | const m = new MonoplasmaMember("tester1", "b3428050ea2448ed2e4409be47e1a50ebac0b2d2", 100)
23 | const obj = {
24 | name: "tester1",
25 | address: "0xb3428050ea2448ed2e4409be47e1a50ebac0b2d2",
26 | earnings: "100"
27 | }
28 | assert.deepStrictEqual(m.toObject(), obj)
29 | })
30 | it("should return empty proof if earnings is zero", () => {
31 | const m = new MonoplasmaMember("tester1", "0xb3428050ea2448ed2e4409be47e1a50ebac0b2d2")
32 | assert.deepStrictEqual(m.getProof(), [])
33 | })
34 | it("should return proof", () => {
35 | const m = new MonoplasmaMember("tester1", "0xb3428050ea2448ed2e4409be47e1a50ebac0b2d2", 100)
36 | const tree = {}
37 | const proof = ["0x30b397c3eb0e07b7f1b8b39420c49f60c455a1a602f1a91486656870e3f8f74c"]
38 | tree.getPath = sinon.stub().returns(proof)
39 | assert.deepStrictEqual(m.getProof(tree), proof)
40 | })
41 | it("should return proof", () => {
42 | const m = new MonoplasmaMember("tester1", "0xb3428050ea2448ed2e4409be47e1a50ebac0b2d2", 100)
43 | const tree = {}
44 | const proof = ["0x30b397c3eb0e07b7f1b8b39420c49f60c455a1a602f1a91486656870e3f8f74c"]
45 | tree.getPath = sinon.stub().returns(proof)
46 | assert.deepStrictEqual(m.getProof(tree), proof)
47 | })
48 | it("should throw when invalid address", () => {
49 | assert.throws(() => new MonoplasmaMember("tester1", "0xbe47e1ac0b2d2"))
50 | })
51 | })
52 |
--------------------------------------------------------------------------------
/test/mocha/routers/member.js:
--------------------------------------------------------------------------------
1 |
2 | const express = require("express")
3 | const bodyParser = require("body-parser")
4 |
5 | const assert = require("assert")
6 | const http = require("http")
7 |
8 | const MonoplasmaState = require("../../../src/state")
9 | const plasma = new MonoplasmaState(0, [], { saveBlock: () => {} }, "0x0000000000000000000000000000000000000001", 0.5)
10 | const router = require("../../../src/routers/member")(plasma)
11 |
12 | describe("Express app / Monoplasma router", () => {
13 | const port = 3030
14 | const serverURL = `http://localhost:${port}`
15 | const { fetchMember } = require("../../utils/operatorApi")(serverURL)
16 |
17 | let server
18 | before(() => {
19 | const app = express()
20 | app.use(bodyParser.json())
21 | app.use("/", router)
22 | server = http.createServer(app)
23 | server.listen(port)
24 | })
25 |
26 | describe("Member API", () => {
27 | it("can request for balance proof", async () => {
28 | // dirty hack? Directly manipulating server's state...
29 | plasma.addMember("0xb3428050ea2448ed2e4409be47e1a50ebac0b2d2", "Tester1")
30 | plasma.addMember("0xe5019d79c3fc34c811e68e68c9bd9966f22370ef", "Tester2")
31 | plasma.addRevenue(100, 1)
32 |
33 | const m2 = await fetchMember("0xe5019d79c3fc34c811e68e68c9bd9966f22370ef")
34 |
35 | const proof = plasma.getProof("0xe5019d79c3fc34c811e68e68c9bd9966f22370ef")
36 | assert.deepStrictEqual(m2.proof, proof)
37 | })
38 | })
39 |
40 | after(() => {
41 | server.close()
42 | })
43 | })
44 |
--------------------------------------------------------------------------------
/test/mocha/test-utils.js:
--------------------------------------------------------------------------------
1 | const assert = require("assert")
2 |
3 | const {
4 | assertEqual,
5 | assertEvent,
6 | assertEventBySignature,
7 | assertFails,
8 | } = require("../utils/web3Assert")
9 |
10 | const {
11 | until,
12 | untilStreamContains,
13 | } = require("../utils/await-until")
14 |
15 | const EventEmitter = require("events")
16 | const sleep = require("../utils/sleep-promise")
17 |
18 | // simulate what Truffle provides
19 | const Web3 = require("web3")
20 | global.web3 = Web3.utils
21 | global.assert = require("assert")
22 |
23 | describe("Test help utilities", () => {
24 | describe("assertEqual", () => {
25 | it("matches numbers", () => {
26 | assertEqual(1, "1")
27 | assert.throws(() => assertEqual(1, 2), "expected 1 to equal 2")
28 | })
29 | it("matches strings", () => {
30 | assertEqual("0x74657374", "test")
31 | assertEqual("0x74657374746573747465737474657374", "testtesttesttest")
32 | assert.throws(() => assertEqual("0x74657374", "jest"), "expected 'test' to equal 'jest'")
33 | })
34 | it("won't convert response to string if address is expected", () => {
35 | assertEqual("0x7465737474657374746573747465737474657374", "0x7465737474657374746573747465737474657374")
36 | assertEqual("0x7465737474657374746573747465737474657374", "testtesttesttesttest")
37 | })
38 | })
39 |
40 | describe("assertEvent", () => {
41 | // TODO: real truffle responses please
42 | it("finds the wanted event and checks args", () => {
43 | assertEvent({
44 | logs: [{
45 | event: "testEvent",
46 | args: {
47 | arg1: "moo",
48 | arg2: "foo",
49 | arg3: "scoo",
50 | },
51 | }],
52 | }, "testEvent", {
53 | arg1: "moo",
54 | arg2: "foo",
55 | })
56 | })
57 | it("throws if arg is missing", () => {
58 | assert.throws(() => assertEvent({
59 | logs: [{
60 | event: "testEvent",
61 | args: {
62 | arg1: "moo",
63 | arg3: "scoo",
64 | },
65 | }],
66 | }, "testEvent", {
67 | arg1: "moo",
68 | arg2: "foo",
69 | }), Error)
70 | })
71 | it("throws if event is missing", () => {
72 | assert.throws(() => assertEvent({
73 | logs: [{
74 | event: "anotherEvent",
75 | args: {
76 | arg1: "moo",
77 | arg3: "scoo",
78 | },
79 | }],
80 | }, "testEvent", {
81 | arg1: "moo",
82 | }), Error)
83 | })
84 | it("works without args", () => {
85 | assertEvent({
86 | logs: [{
87 | event: "testEvent",
88 | args: {
89 | arg1: "moo",
90 | arg3: "scoo",
91 | },
92 | }],
93 | }, "testEvent")
94 | })
95 | })
96 |
97 | describe("assertEventBySignature", () => {
98 | it("finds the wanted event", () => {
99 | assertEventBySignature({ receipt: { logs: [{ topics: ["0x24ec1d3ff24c2f6ff210738839dbc339cd45a5294d85c79361016243157aae7b", "argument", "hashes"] }] }}, "TestEvent()")
100 | })
101 | it("throws if signature is missing", () => {
102 | assert.throws(() => assertEventBySignature({ logs: [{ topics: ["0x24ec1d3ff24c2f6ff210738839dbc339cd45a5294d85c79361016243157aae7c", "argument", "hashes"] }] }, "TestEvent()"), Error)
103 | })
104 | })
105 |
106 | describe("assertFails", () => {
107 | it("fails when it should", async () => {
108 | await assertFails(Promise.reject(new Error("boo!")))
109 | try {
110 | await assertFails(Promise.resolve("done!"))
111 | throw new Error("should fail!")
112 | } catch (e) {
113 | // all good
114 | }
115 | })
116 | })
117 |
118 | describe("await-until", () => {
119 | it("waits until condition is true", async () => {
120 | const start = +new Date()
121 | let done = false
122 | setTimeout(() => { done = true }, 10)
123 | assert(!done)
124 | assert(+new Date() - start < 9)
125 | const ret = await until(() => done)
126 | assert(done)
127 | assert(ret)
128 | assert(+new Date() - start > 9)
129 | assert(+new Date() - start < 900)
130 | })
131 | it("waits until timeout", async () => {
132 | const start = +new Date()
133 | let done = false
134 | assert(!done)
135 | assert(+new Date() - start < 9)
136 | const ret = await until(() => done, 100, 10)
137 | assert(!done)
138 | assert(!ret)
139 | assert(+new Date() - start > 90)
140 | assert(+new Date() - start < 900)
141 | })
142 | it("untilStreamContains", async () => {
143 | const stream = new EventEmitter()
144 | let done = false
145 | untilStreamContains(stream, "DONE").then(() => {
146 | done = true
147 | })
148 | await sleep(1)
149 | assert(!done)
150 | stream.emit("data", "test")
151 | await sleep(1)
152 | assert(!done)
153 | stream.emit("data", "lol DONE")
154 | await sleep(1)
155 | assert(done)
156 | stream.emit("data", "test again")
157 | await sleep(1)
158 | assert(done)
159 | })
160 | })
161 | })
162 |
--------------------------------------------------------------------------------
/test/mocha/utils/events.js:
--------------------------------------------------------------------------------
1 | const assert = require("assert")
2 | const { mergeEventLists } = require("../../../src/utils/events")
3 |
4 | describe("mergeEventLists", () => {
5 | it("merges event lists correctly", () => {
6 | const events1 = [
7 | { event: "A", blockNumber: 5, transactionIndex: -1, logIndex: 1 },
8 | { event: "B", blockNumber: 6, transactionIndex: 1, logIndex: 1 },
9 | { event: "C", blockNumber: 7, transactionIndex: -1, logIndex: 1 },
10 | { event: "D", blockNumber: 8, transactionIndex: 1, logIndex: 1 },
11 | { event: "E", blockNumber: 9, transactionIndex: 1, logIndex: 1 },
12 | ]
13 | const events2 = [
14 | { event: "1", blockNumber: 1, transactionIndex: 0, logIndex: 1 },
15 | { event: "2", blockNumber: 3, transactionIndex: 2, logIndex: 1 },
16 | { event: "3", blockNumber: 5, transactionIndex: 0, logIndex: 1 },
17 | { event: "4", blockNumber: 7, transactionIndex: 2, logIndex: 1 },
18 | { event: "5", blockNumber: 9, transactionIndex: 1, logIndex: 2 },
19 | ]
20 | const merged = mergeEventLists(events1, events2)
21 | assert.strictEqual(merged.map(e => e.event).join(""), "12A3BC4DE5")
22 | })
23 |
24 | it("handles empty lists correctly", () => {
25 | const events = [
26 | { event: "1", blockNumber: 1, transactionIndex: 0, logIndex: 1 },
27 | { event: "5", blockNumber: 9, transactionIndex: 1, logIndex: 2 },
28 | ]
29 | assert.deepStrictEqual(mergeEventLists([], events), events)
30 | assert.deepStrictEqual(mergeEventLists(events, []), events)
31 | assert.deepStrictEqual(mergeEventLists([], []), [])
32 | })
33 |
34 | it("ignores non-lists", () => {
35 | const events = [
36 | { event: "1", blockNumber: 1, transactionIndex: 0, logIndex: 1 },
37 | { event: "5", blockNumber: 9, transactionIndex: 1, logIndex: 2 },
38 | ]
39 | assert.deepStrictEqual(mergeEventLists(null, events), events)
40 | assert.deepStrictEqual(mergeEventLists(events, events.foobar), events)
41 | assert.deepStrictEqual(mergeEventLists({}, 0), [])
42 | assert.deepStrictEqual(mergeEventLists("null", events), events)
43 | assert.deepStrictEqual(mergeEventLists(events, events[0]), events)
44 | assert.deepStrictEqual(mergeEventLists([], true), [])
45 | })
46 | })
47 |
--------------------------------------------------------------------------------
/test/mocha/utils/formatDecimals.js:
--------------------------------------------------------------------------------
1 | /*eslint-disable quotes */
2 | const assert = require("assert")
3 | const formatDecimals = require("../../../src/utils/formatDecimals")
4 |
5 | describe("formatDecimals", () => {
6 | it('formatDecimals("10000", 3) === "10"', () => {
7 | assert.strictEqual(formatDecimals("10000", 3), "10")
8 | })
9 | it('formatDecimals("15200", 3) === "15.2"', () => {
10 | assert.strictEqual(formatDecimals("15200", 3), "15.2")
11 | })
12 | it('formatDecimals("10000", 6) === "0.01"', () => {
13 | assert.strictEqual(formatDecimals("10000", 6), "0.01")
14 | })
15 | })
--------------------------------------------------------------------------------
/test/mocha/utils/now.js:
--------------------------------------------------------------------------------
1 | const assert = require("assert")
2 |
3 | const now = require("../../../src/utils/now")
4 |
5 | describe("now", () => {
6 | it("returns something that could be a block timestamp", () => {
7 | assert(!Number.isNaN(+now()))
8 | })
9 | })
10 |
--------------------------------------------------------------------------------
/test/mocha/utils/partitionArray.js:
--------------------------------------------------------------------------------
1 |
2 | const assert = require("assert")
3 | const partition = require("../../../src/utils/partitionArray")
4 |
5 | describe("Partition array util", () => {
6 | it("works in small case, with stable order", () => {
7 | const list = [67, 34, 45, 35, 34, 34, 1]
8 | const filter = x => x < 40
9 | const res = partition(list, filter)
10 | assert.deepStrictEqual(res, [
11 | [34, 35, 34, 34, 1],
12 | [67, 45],
13 | ])
14 | })
15 |
16 | it("works in large case", () => {
17 | const list = Array(100000).fill(0).map((_, i)=>i)
18 | const filter = x => x < 40000
19 | const res = partition(list, filter)
20 | assert.deepEqual(res[0].length, 40000)
21 | assert.deepEqual(res[1].length, 60000)
22 | })
23 |
24 | it("works also with empty input", () => {
25 | assert.deepStrictEqual(partition([], x => x < 4), [[], []])
26 | })
27 |
28 | it("works also with empty outputs", () => {
29 | assert.deepStrictEqual(partition([1, 2, 3], x => x < 4), [[1, 2, 3], []])
30 | assert.deepStrictEqual(partition([1, 2, 3], x => x > 4), [[], [1, 2, 3]])
31 | })
32 | })
--------------------------------------------------------------------------------
/test/mocha/validator.js:
--------------------------------------------------------------------------------
1 | const assert = require("assert")
2 | const MonoplasmaValidator = require("../../src/validator")
3 | const MonoplasmaOperator = require("../../src/operator")
4 |
5 | const sleep = require("../utils/sleep-promise")
6 |
7 | class TestLogger {
8 | constructor(options) {
9 | this.clear()
10 | if (options && options.watch) {
11 | Object.foroptions.watch.forEach()
12 | }
13 | }
14 | clear() {
15 | this.logs = []
16 | }
17 | log(...args) {
18 | this.logs.splice(this.logs.length, 0, ...args)
19 | }
20 | grep(regex) {
21 | return this.logs.filter(log => log.match(regex))
22 | }
23 | seen(regex) {
24 | return this.logs.reduce((found, log) => found || !!log.match(regex), false)
25 | }
26 | }
27 | function error(e, ...args) {
28 | console.error(e.stack, args)
29 | process.exit(1)
30 | }
31 |
32 | const getMockStore = require("../utils/mockStore")
33 | const MockChannel = require("../utils/mockChannel")
34 | const getMockWeb3 = require("../utils/mockWeb3")
35 |
36 | const initialBlock = {
37 | blockNumber: 3,
38 | members: [
39 | { address: "0x2f428050ea2448ed2e4409be47e1a50ebac0b2d2", earnings: "50" },
40 | { address: "0xb3428050ea2448ed2e4409be47e1a50ebac0b2d2", earnings: "20" },
41 | ],
42 | totalEarnings: 70,
43 | }
44 |
45 | const startState = {
46 | lastBlockNumber: 5,
47 | lastPublishedBlock: 3,
48 | contractAddress: "0x0000000000000000000000000000000000000001",
49 | operatorAddress: "0xa3d1f77acff0060f7213d7bf3c7fec78df847de1"
50 | }
51 |
52 | describe("MonoplasmaValidator", () => {
53 | it("Accepts untampered MonoplasmaOperator's blocks", async () => {
54 | const web3 = getMockWeb3(10)
55 | const channel = new MockChannel()
56 | const logger = new TestLogger()
57 | const log = logger.log.bind(logger)
58 | const operatorStore = getMockStore(startState, initialBlock, log)
59 | const validatorStore = getMockStore(startState, initialBlock, log)
60 | const operator = new MonoplasmaOperator(web3, channel, startState, operatorStore, log, error)
61 | await operator.start()
62 | const validator = new MonoplasmaValidator([], "", web3, channel, startState, validatorStore, log, error)
63 | await validator.start()
64 | channel.publish("join", ["0x5ffe8050112448ed2e4409be47e1a50ebac0b299"])
65 | await web3.mockTransfer(30)
66 | await sleep(200)
67 | assert(logger.seen("validated"))
68 | assert(!logger.seen("WARNING"))
69 | })
70 |
71 | it("Notices a bad root hash from MonoplasmaOperator", () => {
72 | console.log("TODO")
73 | })
74 |
75 | it("Attempts to exit the watchedAccounts if tampering is detected", () => {
76 | console.log("TODO")
77 | })
78 | })
--------------------------------------------------------------------------------
/test/truffle/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | globals: {
3 | contract: "readonly",
4 | artifacts: "readonly",
5 | assert: "readonly",
6 | web3: "readonly"
7 | }
8 | }
--------------------------------------------------------------------------------
/test/truffle/BalanceVerifier.js:
--------------------------------------------------------------------------------
1 | // BalanceVerifier cannot be instantiated so "minimal viable implementation" Airdrop is used instead
2 | const Airdrop = artifacts.require("./Airdrop.sol")
3 | const DemoToken = artifacts.require("./DemoToken.sol")
4 |
5 | const FailTokenJson = require("./FailToken.json")
6 | const FailToken = new web3.eth.Contract(FailTokenJson.abi)
7 |
8 | const { assertEqual, assertFails } = require("../utils/web3Assert")
9 |
10 | const MonoplasmaMember = require("../../src/member")
11 | const MonoplasmaState = require("../../src/state")
12 |
13 | const admin = "0x0000000000000000000000000000000000000001"
14 |
15 | const plasma = new MonoplasmaState(0, [], { saveBlock: () => {} }, admin, 0)
16 |
17 | const { hashLeaf } = require("../../src/merkletree")
18 |
19 | let token
20 | let airdrop
21 | contract("BalanceVerifier", accounts => {
22 | const recipient = accounts[1]
23 | const anotherRecipient = accounts[2]
24 | const admin = accounts[9]
25 | before(async () => {
26 | token = await DemoToken.new("BalanceVerifier test", "TOK", {from: admin, gas: 4000000})
27 | airdrop = await Airdrop.new(token.address, {from: admin, gas: 4000000})
28 | await token.mint(airdrop.address, 1000000, {from: admin})
29 |
30 | // these should be performed by the watcher
31 | plasma.addMember(recipient)
32 | plasma.addMember(anotherRecipient)
33 | plasma.addRevenue(1000, 1)
34 | })
35 |
36 | async function publishBlock() {
37 | const blockNumber = await web3.eth.getBlockNumber()
38 | plasma.setBlockNumber(blockNumber)
39 | const root = plasma.getRootHash()
40 | const resp = await airdrop.commit(blockNumber, root, "ipfs lol", {from: admin})
41 | return resp.logs.find(L => L.event === "NewCommit").args
42 | }
43 |
44 | describe("commit & committedHash", () => {
45 | it("correctly publishes and retrieves a block hash", async () => {
46 | const root = "0x1234000000000000000000000000000000000000000000000000000000000000"
47 | const resp = await airdrop.commit(123, root, "ipfs lol", {from: admin})
48 | const block = resp.logs.find(L => L.event === "NewCommit").args
49 | assertEqual(block.blockNumber, 123)
50 | assertEqual(block.rootHash, root)
51 | assertEqual(await airdrop.committedHash(123), root)
52 | })
53 | it("won't let operator overwrite a root hash (with same block number)", async () => {
54 | await airdrop.commit(124, "0x1234", "ipfs lol", {from: admin})
55 | await airdrop.commit(125, "0x2345", "ipfs lol", {from: admin})
56 | await assertFails(airdrop.commit(125, "0x3456", "ipfs lol", {from: admin}), "error_overwrite")
57 | })
58 | it("won't let non-admin commit", async () => {
59 | await assertFails(airdrop.commit(128, "0x3456", "ipfs lol", {from: recipient}), "error_notPermitted")
60 | })
61 | })
62 |
63 | describe("prove & proofIsCorrect & calculateRootHash", () => {
64 | let block, member, proof, root
65 |
66 | // see also test/mocha/merkletree.js
67 | it("correctly validate a proof", async () => {
68 | plasma.addRevenue(1000, 1)
69 | block = await publishBlock(root)
70 |
71 | const memberObj = plasma.getMember(anotherRecipient)
72 | member = MonoplasmaMember.fromObject(memberObj)
73 | root = plasma.tree.getRootHash()
74 | proof = plasma.getProof(anotherRecipient)
75 |
76 | // check that block was published correctly
77 | assertEqual(block.rootHash, root)
78 |
79 | // check that contract calculates root correctly
80 | const hash = hashLeaf(member, plasma.tree.salt)
81 | assertEqual(await airdrop.calculateRootHash(hash, proof), root)
82 |
83 | // check that contract checks proof correctly
84 | assert(await airdrop.proofIsCorrect(block.blockNumber, member.address, member.earnings, proof), "Contract says: Bad proof")
85 |
86 | // check that contract proves earnings correctly (freeze period)
87 | assertEqual(await token.balanceOf(member.address), 0)
88 | await airdrop.prove(block.blockNumber, member.address, member.earnings, proof, {from: admin, gas: 4000000})
89 | assertEqual(await token.balanceOf(member.address), member.earnings)
90 | })
91 |
92 | it("fails if you try later with an old (though valid) proof", async () => {
93 | await assertFails(airdrop.prove(block.blockNumber, member.address, member.earnings, proof, {from: admin, gas: 4000000}), "error_oldEarnings")
94 | })
95 |
96 | it("fails with error_blockNotFound if block is bad", async () => {
97 | await assertFails(airdrop.prove(12354678, member.address, member.earnings, proof, {from: admin, gas: 4000000}), "error_blockNotFound")
98 | })
99 |
100 | it("fails with error_proof if proof is bad", async () => {
101 | await assertFails(airdrop.prove(block.blockNumber, member.address, member.earnings, [], {from: admin, gas: 4000000}), "error_proof")
102 | })
103 |
104 | it("fails with error_transfer if token transfer returns false", async () => {
105 | const token2 = await FailToken.deploy({data: FailTokenJson.bytecode}).send({from: admin, gas: 4000000})
106 | const airdrop2 = await Airdrop.new(token2.options.address, {from: admin, gas: 4000000})
107 | await airdrop2.commit(block.blockNumber, root, "ipfs lol", {from: admin})
108 | assert(await airdrop2.proofIsCorrect(block.blockNumber, member.address, member.earnings, proof), "Contract says: bad proof")
109 | await assertFails(airdrop2.prove(block.blockNumber, member.address, member.earnings, proof, {from: admin, gas: 4000000}), "error_transfer")
110 | })
111 | })
112 | })
113 |
--------------------------------------------------------------------------------
/test/truffle/FailToken.json:
--------------------------------------------------------------------------------
1 | {
2 | "contractName": "FailToken",
3 | "description": "ERC20 compliant contract that returns false to everything and does nothing, not even throw. Mint tokens with transfers.",
4 | "bytecode": "0x608060405234801561001057600080fd5b50610301806100206000396000f3fe608060405234801561001057600080fd5b50600436106100625760003560e01c8063095ea7b31461006757806318160ddd146100a757806323b872dd146100c157806370a08231146100f7578063a9059cbb1461011d578063dd62ed3e14610149575b600080fd5b6100936004803603604081101561007d57600080fd5b506001600160a01b038135169060200135610177565b604080519115158252519081900360200190f35b6100af6101dd565b60408051918252519081900360200190f35b610093600480360360608110156100d757600080fd5b506001600160a01b038135811691602081013590911690604001356101e3565b6100af6004803603602081101561010d57600080fd5b50356001600160a01b0316610240565b6100936004803603604081101561013357600080fd5b506001600160a01b038135169060200135610252565b6100af6004803603604081101561015f57600080fd5b506001600160a01b03813581169160200135166102ae565b3360008181526001602090815260408083206001600160a01b038716808552908352818420869055815186815291519394909390927f8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925928290030190a350600092915050565b60025481565b6001600160a01b0382166000818152602081815260408083208054860190558051858152905192939284927fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef928290030190a35060009392505050565b60006020819052908152604090205481565b6001600160a01b0382166000818152602081815260408083208054860190558051858152905192939284927fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef928290030190a350600092915050565b60016020908152600092835260408084209091529082529020548156fea2646970667358221220beddd909039c1212d230d6814dfd204f6ed1fa4526e8fdf7e8bc3c73b10a9d1164736f6c63430006010033",
5 | "abi": [
6 | {
7 | "anonymous": false,
8 | "inputs": [
9 | {
10 | "indexed": true,
11 | "internalType": "address",
12 | "name": "",
13 | "type": "address"
14 | },
15 | {
16 | "indexed": true,
17 | "internalType": "address",
18 | "name": "",
19 | "type": "address"
20 | },
21 | {
22 | "indexed": false,
23 | "internalType": "uint256",
24 | "name": "",
25 | "type": "uint256"
26 | }
27 | ],
28 | "name": "Approval",
29 | "type": "event"
30 | },
31 | {
32 | "anonymous": false,
33 | "inputs": [
34 | {
35 | "indexed": true,
36 | "internalType": "address",
37 | "name": "",
38 | "type": "address"
39 | },
40 | {
41 | "indexed": true,
42 | "internalType": "address",
43 | "name": "",
44 | "type": "address"
45 | },
46 | {
47 | "indexed": false,
48 | "internalType": "uint256",
49 | "name": "",
50 | "type": "uint256"
51 | }
52 | ],
53 | "name": "Transfer",
54 | "type": "event"
55 | },
56 | {
57 | "inputs": [
58 | {
59 | "internalType": "address",
60 | "name": "",
61 | "type": "address"
62 | },
63 | {
64 | "internalType": "address",
65 | "name": "",
66 | "type": "address"
67 | }
68 | ],
69 | "name": "allowance",
70 | "outputs": [
71 | {
72 | "internalType": "uint256",
73 | "name": "",
74 | "type": "uint256"
75 | }
76 | ],
77 | "stateMutability": "view",
78 | "type": "function"
79 | },
80 | {
81 | "inputs": [
82 | {
83 | "internalType": "address",
84 | "name": "spender",
85 | "type": "address"
86 | },
87 | {
88 | "internalType": "uint256",
89 | "name": "amount",
90 | "type": "uint256"
91 | }
92 | ],
93 | "name": "approve",
94 | "outputs": [
95 | {
96 | "internalType": "bool",
97 | "name": "",
98 | "type": "bool"
99 | }
100 | ],
101 | "stateMutability": "nonpayable",
102 | "type": "function"
103 | },
104 | {
105 | "inputs": [
106 | {
107 | "internalType": "address",
108 | "name": "",
109 | "type": "address"
110 | }
111 | ],
112 | "name": "balanceOf",
113 | "outputs": [
114 | {
115 | "internalType": "uint256",
116 | "name": "",
117 | "type": "uint256"
118 | }
119 | ],
120 | "stateMutability": "view",
121 | "type": "function"
122 | },
123 | {
124 | "inputs": [],
125 | "name": "totalSupply",
126 | "outputs": [
127 | {
128 | "internalType": "uint256",
129 | "name": "",
130 | "type": "uint256"
131 | }
132 | ],
133 | "stateMutability": "view",
134 | "type": "function"
135 | },
136 | {
137 | "inputs": [
138 | {
139 | "internalType": "address",
140 | "name": "recipient",
141 | "type": "address"
142 | },
143 | {
144 | "internalType": "uint256",
145 | "name": "amount",
146 | "type": "uint256"
147 | }
148 | ],
149 | "name": "transfer",
150 | "outputs": [
151 | {
152 | "internalType": "bool",
153 | "name": "",
154 | "type": "bool"
155 | }
156 | ],
157 | "stateMutability": "nonpayable",
158 | "type": "function"
159 | },
160 | {
161 | "inputs": [
162 | {
163 | "internalType": "address",
164 | "name": "",
165 | "type": "address"
166 | },
167 | {
168 | "internalType": "address",
169 | "name": "recipient",
170 | "type": "address"
171 | },
172 | {
173 | "internalType": "uint256",
174 | "name": "amount",
175 | "type": "uint256"
176 | }
177 | ],
178 | "name": "transferFrom",
179 | "outputs": [
180 | {
181 | "internalType": "bool",
182 | "name": "",
183 | "type": "bool"
184 | }
185 | ],
186 | "stateMutability": "nonpayable",
187 | "type": "function"
188 | }
189 | ]
190 | }
--------------------------------------------------------------------------------
/test/truffle/FailToken.sol.txt:
--------------------------------------------------------------------------------
1 | pragma solidity >=0.6.0;
2 |
3 | contract FailToken {
4 | mapping (address => uint256) public balanceOf;
5 | mapping (address => mapping (address => uint256)) public allowance;
6 | uint256 public totalSupply;
7 |
8 | /**
9 | * @dev Moves `amount` tokens from the caller's account to `recipient`.
10 | *
11 | * Returns a boolean value indicating whether the operation succeeded.
12 | *
13 | * Emits a {Transfer} event.
14 | */
15 | function transfer(address recipient, uint256 amount) external returns (bool) {
16 | balanceOf[recipient] += amount;
17 | emit Transfer(address(0), recipient, amount);
18 | return false;
19 | }
20 |
21 | /**
22 | * @dev Sets `amount` as the allowance of `spender` over the caller's tokens.
23 | *
24 | * Returns a boolean value indicating whether the operation succeeded.
25 | *
26 | * IMPORTANT: Beware that changing an allowance with this method brings the risk
27 | * that someone may use both the old and the new allowance by unfortunate
28 | * transaction ordering. One possible solution to mitigate this race
29 | * condition is to first reduce the spender's allowance to 0 and set the
30 | * desired value afterwards:
31 | * https://github.com/ethereum/EIPs/issues/20#issuecomment-263524729
32 | *
33 | * Emits an {Approval} event.
34 | */
35 | function approve(address spender, uint256 amount) external returns (bool) {
36 | allowance[msg.sender][spender] = amount;
37 | emit Approval(msg.sender, spender, amount);
38 | return false;
39 | }
40 |
41 | /**
42 | * @dev Moves `amount` tokens from `sender` to `recipient` using the
43 | * allowance mechanism. `amount` is then deducted from the caller's
44 | * allowance.
45 | *
46 | * Returns a boolean value indicating whether the operation succeeded.
47 | *
48 | * Emits a {Transfer} event.
49 | */
50 | function transferFrom(address, address recipient, uint256 amount) external returns (bool) {
51 | balanceOf[recipient] += amount;
52 | emit Transfer(address(0), recipient, amount);
53 | return false;
54 | }
55 |
56 | /**
57 | * @dev Emitted when `value` tokens are moved from one account (`from`) to
58 | * another (`to`).
59 | *
60 | * Note that `value` may be zero.
61 | */
62 | event Transfer(address indexed, address indexed, uint256);
63 |
64 | /**
65 | * @dev Emitted when the allowance of a `spender` for an `owner` is set by
66 | * a call to {approve}. `value` is the new allowance.
67 | */
68 | event Approval(address indexed, address indexed, uint256);
69 | }
70 |
--------------------------------------------------------------------------------
/test/truffle/Ownable.js:
--------------------------------------------------------------------------------
1 | // adapted from https://github.com/OpenZeppelin/openzeppelin-solidity/blob/v1.12.0/test/ownership/Claimable.test.js
2 |
3 | const { assertFails } = require("../utils/web3Assert")
4 |
5 | const Ownable = artifacts.require("Ownable")
6 |
7 | contract("Ownable", function (accounts) {
8 | let ownable
9 |
10 | beforeEach(async function () {
11 | ownable = await Ownable.new()
12 | })
13 |
14 | it("should have an owner", async function () {
15 | const owner = await ownable.owner()
16 | assert(owner !== 0)
17 | })
18 |
19 | it("changes pendingOwner after transfer", async function () {
20 | const newOwner = accounts[1]
21 | await ownable.transferOwnership(newOwner)
22 | const pendingOwner = await ownable.pendingOwner()
23 |
24 | assert(pendingOwner === newOwner)
25 | })
26 |
27 | it("should prevent to claimOwnership from no pendingOwner", async function () {
28 | await assertFails(ownable.claimOwnership({ from: accounts[2] }), "error_onlyPendingOwner")
29 | })
30 |
31 | it("should prevent non-owners from transfering", async function () {
32 | const other = accounts[2]
33 | const owner = await ownable.owner.call()
34 |
35 | assert(owner !== other)
36 | await assertFails(ownable.transferOwnership(other, { from: other }), "error_onlyOwner")
37 | })
38 |
39 | describe("after initiating a transfer", function () {
40 | let newOwner
41 |
42 | beforeEach(async function () {
43 | newOwner = accounts[1]
44 | await ownable.transferOwnership(newOwner)
45 | })
46 |
47 | it("changes allow pending owner to claim ownership", async function () {
48 | await ownable.claimOwnership({ from: newOwner })
49 | const owner = await ownable.owner()
50 |
51 | assert(owner === newOwner)
52 | })
53 | })
54 | })
--------------------------------------------------------------------------------
/test/truffle/increaseTime.js:
--------------------------------------------------------------------------------
1 | const increaseTime = require("../utils/increaseTime")
2 |
3 | describe("increaseTime", () => {
4 | it("actually increases the time!", async () => {
5 | const t1 = await increaseTime(1000)
6 | const block1 = await web3.eth.getBlock("latest")
7 | const t2 = await increaseTime(1000)
8 | const block2 = await web3.eth.getBlock("latest")
9 | const diff = block2.timestamp - block1.timestamp
10 | assert(diff >= 1000)
11 | assert((t2 - t1) - diff < 2)
12 | assert((t2 - t1) - diff > -2)
13 | })
14 | })
15 |
--------------------------------------------------------------------------------
/test/utils/await-until.js:
--------------------------------------------------------------------------------
1 | const sleep = require("./sleep-promise")
2 |
3 | /**
4 | * @callback UntilCondition
5 | * @returns {boolean} signifying if it should stop waiting and continue execution
6 | */
7 | /**
8 | * Wait until a condition is true
9 | * @param {UntilCondition} condition wait until this callback function returns true
10 | * @param {number} [timeOutMs=10000] stop waiting after that many milliseconds
11 | * @param {number} [pollingIntervalMs=100] check condition between so many milliseconds
12 | */
13 | async function until(condition, timeOutMs, pollingIntervalMs) {
14 | let timeout = false
15 | setTimeout(() => { timeout = true }, timeOutMs || 10000)
16 | while (!condition() && !timeout) {
17 | await sleep(pollingIntervalMs || 100)
18 | }
19 | return condition()
20 | }
21 |
22 | /**
23 | * Resolves the promise once stream contains the target string
24 | * @param {Readable} stream to subscribe to
25 | * @param {string} target string to search
26 | */
27 | async function untilStreamContains(stream, target) {
28 | return new Promise(done => {
29 | function handler(data) {
30 | if (data.indexOf(target) > -1) {
31 | if (stream.off) { // older versions of node.js don't support .off
32 | stream.off("data", handler)
33 | }
34 | done(data.toString())
35 | }
36 | }
37 | stream.on("data", handler)
38 | })
39 | }
40 |
41 | module.exports = {
42 | until,
43 | untilStreamContains
44 | }
45 |
--------------------------------------------------------------------------------
/test/utils/increaseTime.js:
--------------------------------------------------------------------------------
1 | /*global web3 */
2 |
3 | /**
4 | * Skips ahead a specified number of seconds by increasing EVM/ganache block timestamp
5 | * @param seconds to skip ahead
6 | * @returns {Promise} the new timestamp after the increase (seconds since start of tests)
7 | */
8 | module.exports = function increaseTime(seconds) {
9 | const id = Date.now()
10 |
11 | return new Promise((resolve, reject) => (
12 | web3.currentProvider.send({
13 | jsonrpc: "2.0",
14 | method: "evm_increaseTime",
15 | params: [seconds],
16 | id,
17 | }, (err1, resp) => (err1 ? reject(err1) :
18 | web3.currentProvider.send({
19 | jsonrpc: "2.0",
20 | method: "evm_mine",
21 | id: id + 1,
22 | }, err2 => (err2 ? reject(err2) : resolve(resp.result)))))
23 | ))
24 | }
25 |
--------------------------------------------------------------------------------
/test/utils/joinPartChannel-client.js:
--------------------------------------------------------------------------------
1 | const Channel = require("../../src/joinPartChannel")
2 |
3 | const sleep = require("./sleep-promise")
4 |
5 | async function start() {
6 | const channel = new Channel(8765)
7 | await channel.listen()
8 |
9 | let joinOk = false
10 | channel.on("join", addressList => {
11 | joinOk = addressList[1] === "0x4178babe9e5148c6d5fd431cd72884b07ad855a0"
12 | console.log(`Got ${addressList.length} joining addresses, data was ${joinOk ? "OK" : "NOT OK"}`)
13 | })
14 |
15 | let partOk = false
16 | channel.on("part", addressList => {
17 | partOk = addressList[0] === "0xdc353aa3d81fc3d67eb49f443df258029b01d8ab"
18 | console.log(`Got ${addressList.length} parting addresses, data was ${partOk ? "OK" : "NOT OK"}`)
19 | })
20 |
21 | await sleep(500)
22 |
23 | if (joinOk && partOk) {
24 | console.log("[OK]")
25 | }
26 | channel.close()
27 | }
28 | start()
29 |
--------------------------------------------------------------------------------
/test/utils/joinPartChannel-server.js:
--------------------------------------------------------------------------------
1 | const Channel = require("../../src/joinPartChannel")
2 |
3 | const sleep = require("./sleep-promise")
4 |
5 | async function start() {
6 | const channel = new Channel(8765)
7 | console.log("Starting server")
8 | await channel.startServer()
9 |
10 | await sleep(200)
11 |
12 | //for (let i = 0; i < 2; i++) {
13 | console.log("Sending joins")
14 | channel.publish("join", [
15 | "0xdc353aa3d81fc3d67eb49f443df258029b01d8ab",
16 | "0x4178babe9e5148c6d5fd431cd72884b07ad855a0",
17 | "0xa3d1f77acff0060f7213d7bf3c7fec78df847de1",
18 | ])
19 | await sleep(50)
20 | console.log("Sending parts")
21 | channel.publish("part", [
22 | "0xdc353aa3d81fc3d67eb49f443df258029b01d8ab",
23 | "0xa3d1f77acff0060f7213d7bf3c7fec78df847de1",
24 | ])
25 | await sleep(50)
26 | console.log("[OK]")
27 | //}
28 | channel.close()
29 | }
30 |
31 | start()
32 |
--------------------------------------------------------------------------------
/test/utils/mockChannel.js:
--------------------------------------------------------------------------------
1 | module.exports = class MockChannel {
2 | constructor() {
3 | this.mode = ""
4 | this.listeners = {
5 | join: [],
6 | part: [],
7 | message: [],
8 | error: [],
9 | close: [],
10 | }
11 | }
12 | startServer() { this.mode = "server" }
13 | listen() { this.mode = "client" }
14 | close() { this.mode = "" }
15 | publish(topic, ...args) {
16 | for (const func of this.listeners[topic]) {
17 | func(...args)
18 | }
19 | }
20 | on(topic, cb) {
21 | this.listeners[topic].push(cb)
22 | }
23 | }
--------------------------------------------------------------------------------
/test/utils/mockStore.js:
--------------------------------------------------------------------------------
1 | module.exports = function getMockStore(mockState, mockBlock, logFunc) {
2 | const log = logFunc || (() => {})
3 | const store = {
4 | events: [],
5 | }
6 | store.saveState = async state => {
7 | log(`Saving state: ${JSON.stringify(state)}`)
8 | store.lastSavedState = state
9 | }
10 | store.saveBlock = async (data) => {
11 | log(`Saving block ${data.blockNumber}: ${JSON.stringify(data)}`)
12 | store.lastSavedBlock = data
13 | }
14 | store.loadState = async () => Object.assign({}, mockState)
15 | store.loadBlock = async () => Object.assign({}, mockBlock)
16 | store.blockExists = async () => true
17 | store.loadEvents = async () => store.events
18 | store.saveEvents = async (bnum, event) => {
19 | store.events.push(event)
20 | }
21 | return store
22 | }
23 |
--------------------------------------------------------------------------------
/test/utils/mockWeb3.js:
--------------------------------------------------------------------------------
1 | const sleep = require("../utils/sleep-promise")
2 |
3 | module.exports = function getMockWeb3(bnum, pastEvents) {
4 | const web3 = {
5 | eth: {},
6 | utils: {},
7 | transferListeners: {},
8 | blockListeners: {},
9 | adminFeeListeners: {},
10 | ownershipListeners: {},
11 | pastEvents: Object.assign({
12 | Transfer: [],
13 | NewCommit: [],
14 | }, pastEvents)
15 | }
16 | web3.eth.getBlockNumber = () => bnum
17 | web3.eth.getBlock = () => ({
18 | number: bnum,
19 | timestamp: Date.now(),
20 | transactions: [],
21 | })
22 |
23 | web3.utils.isAddress = () => true
24 | web3.eth.getCode = () => Promise.resolve("")
25 |
26 | web3.eth.Contract = class {
27 | constructor(abi, address) {
28 | this.address = address
29 | }
30 | getPastEvents(event) {
31 | return web3.pastEvents[event]
32 | }
33 | }
34 | web3.eth.Contract.prototype.methods = {
35 | adminFee: () => ({ call: () => 0 }),
36 | owner: () => ({ call: () => "0xa3d1f77acff0060f7213d7bf3c7fec78df847de1" }),
37 | operator: () => ({ call: () => "0xa3d1f77acff0060f7213d7bf3c7fec78df847de1" }),
38 | token: () => ({ call: () => "tokenAddress" }),
39 | freezePeriodSeconds: () => ({ call: () => 1000 }),
40 | commit: (...args) => ({ send: async () => {
41 | // simulate tx lag
42 | sleep(100).then(() => {
43 | web3.mockCommit(...args)
44 | })
45 | } })
46 | }
47 | web3.eth.Contract.prototype.events = {
48 | Transfer: () => ({ on: (eventCode, func) => {
49 | if (!web3.transferListeners[eventCode]) { web3.transferListeners[eventCode] = [] }
50 | web3.transferListeners[eventCode].push(func)
51 | }}),
52 | OwnershipTransferred: () => ({ on: (eventCode, func) => {
53 | if (!web3.ownershipListeners[eventCode]) { web3.ownershipListeners[eventCode] = [] }
54 | web3.ownershipListeners[eventCode].push(func)
55 | }}),
56 | NewCommit: () => ({ on: (eventCode, func) => {
57 | if (!web3.blockListeners[eventCode]) { web3.blockListeners[eventCode] = [] }
58 | web3.blockListeners[eventCode].push(func)
59 | }}),
60 | AdminFeeChanged: () => ({ on: (eventCode, func) => {
61 | if (!web3.adminFeeListeners[eventCode]) { web3.adminFeeListeners[eventCode] = [] }
62 | web3.adminFeeListeners[eventCode].push(func)
63 | }})
64 |
65 | }
66 |
67 | web3.mockTransfer = async (value=1, blockNumber=11, from="from", to="contract") => {
68 | const event = {
69 | event: "Transfer",
70 | blockNumber,
71 | returnValues: { from, to, value },
72 | }
73 | web3.pastEvents.Transfer.push(event)
74 | for (const func of web3.transferListeners.data) {
75 | await func(event)
76 | }
77 | }
78 | web3.mockCommit = async (blockNumber=11, rootHash="hash", ipfsHash="ipfs") => {
79 | const event = {
80 | event: "NewCommit",
81 | blockNumber,
82 | returnValues: {
83 | blockNumber,
84 | rootHash,
85 | ipfsHash
86 | }
87 | }
88 | web3.pastEvents.NewCommit.push(event)
89 | for (const func of web3.blockListeners.data || []) {
90 | await func(event)
91 | }
92 | }
93 |
94 | return web3
95 | }
--------------------------------------------------------------------------------
/test/utils/operatorApi.js:
--------------------------------------------------------------------------------
1 | const fetch = require("node-fetch")
2 |
3 | module.exports = serverURL => ({
4 | fetchMember: address => fetch(`${serverURL}/members/${address}`).then(res => res.json()),
5 | fetchMembers: () => fetch(`${serverURL}/members`).then(res => res.json()),
6 | postMember: body => fetch(`${serverURL}/members`, {
7 | method: "POST",
8 | body: JSON.stringify(body),
9 | headers: { "Content-Type": "application/json" },
10 | }).then(res => res.json()),
11 | })
12 |
--------------------------------------------------------------------------------
/test/utils/sleep-promise.js:
--------------------------------------------------------------------------------
1 | module.exports = function sleep(ms) {
2 | return new Promise(resolve => {
3 | setTimeout(resolve, ms)
4 | })
5 | }
6 |
--------------------------------------------------------------------------------
/test/utils/web3Assert.js:
--------------------------------------------------------------------------------
1 | /*global web3 assert */
2 |
3 | const BN = require("bn.js")
4 |
5 | /**
6 | * Assert equality in web3 return value sense, modulo conversions to "normal" JS strings and numbers
7 | */
8 | function assertEqual(actual, expected) {
9 | // basic assert.equal comparison according to https://nodejs.org/api/assert.html#assert_assert_equal_actual_expected_message
10 | if (actual == expected) { return } // eslint-disable-line eqeqeq
11 | // also handle arrays for convenience
12 | if (Array.isArray(actual) && Array.isArray(expected)) {
13 | assert.strictEqual(actual.length, expected.length, "Arrays have different lengths, supplied wrong number of expected values!")
14 | actual.forEach((a, i) => assertEqual(a, expected[i]))
15 | return
16 | }
17 | // use BigNumber's own comparator
18 | if (BN.isBN(expected)) {
19 | //assert.strictEqual(actual.cmp(expected), 0)
20 | assert.strictEqual(actual.toString(), expected.toString())
21 | return
22 | }
23 | // convert BigNumbers if expecting a number
24 | // NB: there's a reason BigNumbers are used! Keep your numbers small!
25 | // if the number coming back from contract is big, then expect a BigNumber to avoid this conversion
26 | if (typeof expected === "number") {
27 | assert.strictEqual(+actual, +expected)
28 | return
29 | }
30 | // convert hex bytes to string if expected thing looks like a string and not hex
31 | if (typeof expected === "string" && Number.isNaN(+expected) && !Number.isNaN(+actual)) {
32 | assert.strictEqual(web3.toUtf8(actual), expected)
33 | return
34 | }
35 | // fail now with nice error if didn't hit the filters
36 | assert.equal(actual, expected)
37 | }
38 |
39 | function assertEvent(truffleResponse, eventName, eventArgs) {
40 | const allEventNames = truffleResponse.logs.map(log => log.event).join(", ")
41 | const log = truffleResponse.logs.find(L => L.event === eventName)
42 | assert(log, `Event ${eventName} expected, got: ${allEventNames}`)
43 | Object.keys(eventArgs || {}).forEach(arg => {
44 | assert(log.args[arg], `Event ${eventName} doesn't have expected property "${arg}", try one of: ${Object.keys(log.args).join(", ")}`)
45 | assertEqual(log.args[arg], eventArgs[arg])
46 | })
47 | }
48 |
49 | /**
50 | * Sometimes truffle can't decode the event (maybe contract from outside the test)
51 | * It can still be tested if the event function signature is known to you
52 | * NB: This must be VERY exact, no whitespace please, and type names in canonical form
53 | * @see https://solidity.readthedocs.io/en/develop/abi-spec.html#function-selector
54 | */
55 | function assertEventBySignature(truffleResponse, sig) {
56 | const allEventHashes = truffleResponse.receipt.logs.map(log => log.topics[0].slice(0, 8)).join(", ")
57 | const hash = web3.sha3(sig)
58 | const log = truffleResponse.receipt.logs.find(L => L.topics[0] === hash)
59 | assert(log, `Event ${sig} expected, hash: ${hash.slice(0, 8)}, got: ${allEventHashes}`)
60 | }
61 |
62 | async function assertFails(promise, reason) {
63 | let failed = false
64 | try {
65 | await promise
66 | } catch (e) {
67 | failed = true
68 | if (reason) {
69 | // truffle 5.1.9 seems to throw different kind of exceptions from constant methods, without "reason"
70 | // so instead scrape the reason from string like "Returned error: VM Exception while processing transaction: revert error_badSignatureVersion"
71 | // it might end in a period.
72 | const actualReason = e.reason || e.message.match(/.* (\w*)\.?/)[1]
73 | assert.strictEqual(actualReason, reason)
74 | }
75 | }
76 | if (!failed) {
77 | throw new Error("Expected call to fail")
78 | }
79 | }
80 |
81 | module.exports = {
82 | assertEqual,
83 | assertEvent,
84 | assertEventBySignature,
85 | assertFails,
86 | }
87 |
--------------------------------------------------------------------------------
/truffle-config.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Use this file to configure your truffle project. It's seeded with some
3 | * common settings for different networks and features like migrations,
4 | * compilation and testing. Uncomment the ones you need or modify
5 | * them to suit your project as necessary.
6 | *
7 | * More information about configuration can be found at:
8 | *
9 | * truffleframework.com/docs/advanced/configuration
10 | *
11 | * To deploy via Infura you'll need a wallet provider (like truffle-hdwallet-provider)
12 | * to sign your transactions before they're sent to a remote public node. Infura API
13 | * keys are available for free at: infura.io/register
14 | *
15 | * > > Using Truffle V5 or later? Make sure you install the `web3-one` version.
16 | *
17 | * > > $ npm install truffle-hdwallet-provider@web3-one
18 | *
19 | * You'll also need a mnemonic - the twelve word phrase the wallet uses to generate
20 | * public/private key pairs. If you're publishing your code to GitHub make sure you load this
21 | * phrase from a file you've .gitignored so it doesn't accidentally become public.
22 | *
23 | */
24 |
25 | // const HDWallet = require('truffle-hdwallet-provider');
26 | // const infuraKey = "fj4jll3k.....";
27 | //
28 | // const fs = require('fs');
29 | // const mnemonic = fs.readFileSync(".secret").toString().trim();
30 |
31 | module.exports = {
32 | /**
33 | * Networks define how you connect to your ethereum client and let you set the
34 | * defaults web3 uses to send transactions. If you don't specify one truffle
35 | * will spin up a development blockchain for you on port 9545 when you
36 | * run `develop` or `test`. You can ask a truffle command to use a specific
37 | * network from the command line, e.g
38 | *
39 | * $ truffle test --network
40 | */
41 |
42 | networks: {
43 | // Useful for testing. The `development` name is special - truffle uses it by default
44 | // if it's defined here and no other network is specified at the command line.
45 | // You should run a client (like ganache-cli, geth or parity) in a separate terminal
46 | // tab if you use this network and you must also set the `host`, `port` and `network_id`
47 | // options below to some value.
48 | //
49 | // development: {
50 | // host: "127.0.0.1", // Localhost (default: none)
51 | // port: 8545, // Standard Ethereum port (default: none)
52 | // network_id: "*", // Any network (default: none)
53 | // },
54 |
55 | // Another network with more advanced options...
56 | advanced: {
57 | // port: 8777, // Custom port
58 | // network_id: 1342, // Custom network
59 | // gas: 8500000, // Gas sent with each transaction (default: ~6700000)
60 | // gasPrice: 20000000000, // 20 gwei (in wei) (default: 100 gwei)
61 | // from: , // Account to send txs from (default: accounts[0])
62 | // websockets: true // Enable EventEmitter interface for web3 (default: false)
63 | },
64 |
65 | // Useful for deploying to a public network.
66 | // NB: It's important to wrap the provider as a function.
67 | ropsten: {
68 | // provider: () => new HDWalletProvider(mnemonic, `https://ropsten.infura.io/${infuraKey}`),
69 | // network_id: 3, // Ropsten's id
70 | // gas: 5500000, // Ropsten has a lower block limit than mainnet
71 | // confirmations: 2, // # of confs to wait between deployments. (default: 0)
72 | // timeoutBlocks: 200, // # of blocks before a deployment times out (minimum/default: 50)
73 | // skipDryRun: true // Skip dry run before migrations? (default: false for public nets )
74 | },
75 |
76 | // Useful for private networks
77 | private: {
78 | // provider: () => new HDWalletProvider(mnemonic, `https://network.io`),
79 | // network_id: 2111, // This network is yours, in the cloud.
80 | // production: true // Treats this network as if it was a public net. (default: false)
81 | }
82 | },
83 |
84 | // Set default mocha options here, use special reporters etc.
85 | mocha: {
86 | // timeout: 100000
87 | },
88 |
89 | plugins: [
90 | "solidity-coverage"
91 | ],
92 |
93 | // Configure your compilers
94 | compilers: {
95 | solc: {
96 | version: "0.5.16", // Fetch exact version from solc-bin (default: truffle's version)
97 | // docker: true, // Use "0.5.1" you've installed locally with docker (default: false)
98 | settings: { // See the solidity docs for advice about optimization and evmVersion
99 | optimizer: {
100 | enabled: true,
101 | runs: 1500
102 | }
103 | // evmVersion: "byzantium"
104 | }
105 | }
106 | }
107 | }
108 |
--------------------------------------------------------------------------------