├── .nvmrc ├── .dockerignore ├── .gitignore ├── migrations ├── 1_initial_migration.js └── 2_deploy_contracts.js ├── ethpm.json ├── Dockerfile ├── .solhint.json ├── docker-compose.yml ├── security └── mythril.js ├── .eslintrc.json ├── contracts ├── KVStore.sol └── Migrations.sol ├── .travis.yml ├── truffle.js ├── LICENSE ├── package.json ├── test └── kvstore.js └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | v10.10.0 2 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | node_modules/ 3 | .env 4 | -------------------------------------------------------------------------------- /migrations/1_initial_migration.js: -------------------------------------------------------------------------------- 1 | const Migrations = artifacts.require('./Migrations.sol'); 2 | 3 | module.exports = deployer => deployer.deploy(Migrations); 4 | -------------------------------------------------------------------------------- /ethpm.json: -------------------------------------------------------------------------------- 1 | { 2 | "package_name": "eth-kvstore", 3 | "version": "0.0.1", 4 | "description": "Key-value store on Ethereum", 5 | "authors": [ 6 | "HUMAN Protocol" 7 | ], 8 | "keywords": ["ethereum", "hcaptcha"], 9 | "license": "MIT" 10 | } 11 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | 2 | FROM node:10-alpine 3 | 4 | WORKDIR /work 5 | RUN apk update && \ 6 | apk upgrade && \ 7 | apk add git python-dev build-base 8 | COPY package.json package-lock.json /work/ 9 | RUN npm install 10 | 11 | COPY . /work/ 12 | 13 | CMD ["npm", "run", "deploy"] 14 | -------------------------------------------------------------------------------- /.solhint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "default", 3 | "rules": { 4 | "avoid-throw": false, 5 | "avoid-suicide": "error", 6 | "avoid-sha3": "warn", 7 | "compiler-fixed": false, 8 | "indent": ["warn", 4], 9 | "max-line-length": 150, 10 | "var-name-mixedcase": false, 11 | "func-param-name-mixedcase": false 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | services: 3 | ganache: 4 | image: trufflesuite/ganache-cli:latest 5 | ports: 6 | - 8545:8545 7 | 8 | kvstore: 9 | build: . 10 | image: hcaptcha/eth-kvstore:latest 11 | environment: 12 | - ETH_HOST=ganache 13 | - ETH_PORT=8545 14 | depends_on: 15 | - ganache 16 | -------------------------------------------------------------------------------- /security/mythril.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config(); 2 | 3 | const armlet = require('armlet'); 4 | 5 | const { MYTHRIL_API_KEY, EMAIL, KVSTORE_BYTECODE } = process.env; 6 | const client = new armlet.Client({ apiKey: MYTHRIL_API_KEY, userEmail: EMAIL }); 7 | 8 | client.analyze({ bytecode: KVSTORE_BYTECODE, timeout: 500000 }) 9 | .then((issues) => { 10 | console.log(issues); 11 | }).catch((err) => { 12 | console.log(err); 13 | }); 14 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "airbnb", 3 | "env": { 4 | "node": true, 5 | "mocha": true, 6 | "es6": true 7 | }, 8 | "rules": { 9 | "no-console": "off" 10 | }, 11 | "parserOptions": { 12 | "ecmaVersion": 8, 13 | "sourceType": "module" 14 | }, 15 | "globals": { 16 | "assert": true, 17 | "it": true, 18 | "before": true, 19 | "beforeEach": true, 20 | "artifacts": true, 21 | "contract": true, 22 | "web3": true, 23 | "no-console": false 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /contracts/KVStore.sol: -------------------------------------------------------------------------------- 1 | pragma solidity ^0.5.0; 2 | 3 | 4 | contract KVStore { 5 | 6 | uint constant private MAX_STRING_LENGTH = 1000; 7 | mapping(address => mapping(string => string)) private store; 8 | 9 | function get(address _account, string memory _key) public view returns(string memory) { 10 | return store[_account][_key]; 11 | } 12 | 13 | function set(string memory _key, string memory _value) public { 14 | require(bytes(_key).length <= MAX_STRING_LENGTH && bytes(_value).length <= MAX_STRING_LENGTH); 15 | store[msg.sender][_key] = _value; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /contracts/Migrations.sol: -------------------------------------------------------------------------------- 1 | pragma solidity ^0.5.0; 2 | 3 | 4 | contract Migrations { 5 | address public owner; 6 | uint public last_completed_migration; 7 | 8 | constructor() public { 9 | owner = msg.sender; 10 | } 11 | 12 | modifier restricted() { 13 | if (msg.sender == owner) _; 14 | } 15 | 16 | function setCompleted(uint completed) public restricted { 17 | last_completed_migration = completed; 18 | } 19 | 20 | function upgrade(address new_address) public restricted { 21 | Migrations upgraded = Migrations(new_address); 22 | upgraded.setCompleted(last_completed_migration); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | --- 2 | language: node_js 3 | cache: 4 | yarn: true 5 | directories: 6 | - node_modules 7 | script: 8 | - npm install -g ganache-cli 9 | - ganache-cli -p 7545 > /dev/null & 10 | - sleep 5 11 | - npm install 12 | - npm test 13 | notifications: 14 | slack: 15 | secure: wMJM+iGWxiMgVSisD0qRGRjKgHgfjrXzS1zHxPbnJLn1BH1JF/48O0+Yn4QOVAf7aa0WXSDvSkHnnXwMtoh9RZ2OgN++93aq27wZRDDDlhG/sHX1uMRg5RIO6tIUZKUjJN/SuVxzJ9YVG/yq97U9CQZVd74xDcS2lXu0COTznIw3AZfm14Kv0Gw9ikJut1tulqakHLjnaxI0a+BQVNnmryx5yTUXhG7LgHWfLZ7qUD8ZB20UtrF4Qvl3/wBRoqgpOPWsifUfufARPForMSh//5Gw1DfTtJZ2+B1VgHgfj9RHFKkWke/jvg39mF0aRxrjnWOkIU3Gj+NOt4OhR7unwbScm0wcxm57KbdoHy9dSbS2G6Bma0sYbManShjQiMXQJXYW/nCBLTPXeo/EMtHvpwUZFxIs7HHIcG2BBJ7113DkDZXddatQBCAid/UO25RQ2xooG5E2Obw2o7liqCc9+qYEOQfelganx324dIb2OfJnLDpKveetvdY2YZ16lX+CLZ7+Y0FT8wUR03kqdUqlY4KleXJZci52Oq6Iu20iUdhKo4o/fEQjchwyDZSW8BsbO2BCPFgUm4TMkAad0rGPt4kqEMDeIzAC0XMpdUGXZ+tQd1QJOR99RWTw3DDjq63aepaJn3MqPHVBYA1LqruiMOyxr8m06Qrr4nIZYEGtyro= 16 | -------------------------------------------------------------------------------- /migrations/2_deploy_contracts.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | 4 | const ADDRESS_OUTPUT_FILENAME = process.env.ADDRESS_OUTPUT_FILENAME || 'ethkvstore.address.json'; 5 | const KVStore = artifacts.require('./KVStore.sol'); 6 | 7 | module.exports = (deployer) => { 8 | deployer 9 | .deploy(KVStore) 10 | .then(() => { 11 | const fileContent = { 12 | address: KVStore.address, 13 | }; 14 | 15 | try { 16 | fs.mkdirSync(path.dirname(ADDRESS_OUTPUT_FILENAME)); 17 | } catch (err) { 18 | if (err.code !== 'EEXIST') throw err; 19 | } 20 | 21 | fs.writeFile(ADDRESS_OUTPUT_FILENAME, JSON.stringify(fileContent, null, 2), (err) => { 22 | if (err) { 23 | console.error(`unable to write address to output file: ${ADDRESS_OUTPUT_FILENAME}`); 24 | } else { 25 | console.log(`deployed hmt token address stored in ${ADDRESS_OUTPUT_FILENAME}`); 26 | } 27 | }); 28 | }); 29 | }; 30 | -------------------------------------------------------------------------------- /truffle.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config(); 2 | 3 | const { INFURA_TOKEN, MNEMONIC, ETH_HOST, ETH_PORT } = process.env; 4 | const HDWalletProvider = require('truffle-hdwallet-provider'); 5 | 6 | module.exports = { 7 | networks: { 8 | development: { 9 | host: ETH_HOST || '127.0.0.1', 10 | port: ETH_PORT || 7545, 11 | network_id: '*', 12 | }, 13 | live: { 14 | provider: () => new HDWalletProvider(MNEMONIC, `https://mainnet.infura.io/${INFURA_TOKEN}`), 15 | network_id: '1', 16 | }, 17 | kovan: { 18 | provider: () => new HDWalletProvider(MNEMONIC, `https://kovan.infura.io/${INFURA_TOKEN}`), 19 | network_id: '2', 20 | }, 21 | ropsten: { 22 | provider: () => new HDWalletProvider(MNEMONIC, `https://ropsten.infura.io/${INFURA_TOKEN}`), 23 | network_id: '3', 24 | gas: 4700000, 25 | }, 26 | rinkeby: { 27 | provider: () => new HDWalletProvider(MNEMONIC, `https://rinkeby.infura.io/${INFURA_TOKEN}`), 28 | network_id: '4', 29 | }, 30 | }, 31 | }; 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 HUMAN Protocol 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "eth-kvstore", 3 | "version": "0.0.1", 4 | "description": "Key-value store on Ethereum", 5 | "main": "truffle.js", 6 | "directories": { 7 | "test": "test" 8 | }, 9 | "scripts": { 10 | "compile": "truffle compile", 11 | "migrate": "truffle migrate", 12 | "deploy": "npm run compile && npm run migrate", 13 | "fix:js": "eslint --fix test/** migrations/**", 14 | "lint:js": "eslint test/** migrations/** security/**", 15 | "lint:sol": "solhint contracts/*.sol contracts/*/*.sol test/*.sol test/*/*.sol", 16 | "lint": "npm run lint:js && npm run lint:sol", 17 | "mythril": "node security/mythril.js", 18 | "publish": "truffle publish", 19 | "pretest": "npm run lint", 20 | "test": "truffle migrate; truffle test" 21 | }, 22 | "repository": { 23 | "type": "git", 24 | "url": "git+https://github.com/hCaptcha/eth-kvstore.git" 25 | }, 26 | "keywords": [ 27 | "ethereum" 28 | ], 29 | "authors": [ 30 | "HUMAN Protocol" 31 | ], 32 | "license": "MIT", 33 | "homepage": "https://github.com/hCaptcha/eth-kvstore", 34 | "dependencies": { 35 | "armlet": "^0.1.8", 36 | "dotenv": "^6.0.0", 37 | "truffle": "^5.0.3", 38 | "truffle-hdwallet-provider": "0.0.6" 39 | }, 40 | "devDependencies": { 41 | "eslint": "^4.19.1", 42 | "eslint-config-airbnb": "^17.0.0", 43 | "eslint-plugin-import": "^2.13.0", 44 | "eslint-plugin-jsx-a11y": "^6.1.1", 45 | "eslint-plugin-react": "^7.10.0", 46 | "solhint": "^1.2.1" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /test/kvstore.js: -------------------------------------------------------------------------------- 1 | const KVStore = artifacts.require('./KVStore.sol'); 2 | 3 | contract('KVStore', async (accounts) => { 4 | it('returns a correct value to the address storing the key-value pair', async () => { 5 | const instance = await KVStore.deployed(); 6 | const [accountOne] = accounts; 7 | await instance.set('satoshi', 'nakamoto', { 8 | from: accountOne, 9 | }); 10 | const value = await instance.get.call(accountOne, 'satoshi', { 11 | from: accountOne, 12 | }); 13 | assert.equal(value, 'nakamoto'); 14 | }); 15 | 16 | it('returns a correct value to another address', async () => { 17 | const instance = await KVStore.deployed(); 18 | const [accountOne, accountTwo] = accounts; 19 | await instance.set('satoshi', 'nakamoto', { 20 | from: accountOne, 21 | }); 22 | const value = await instance.get.call(accountOne, 'satoshi', { 23 | from: accountTwo, 24 | }); 25 | assert.equal(value, 'nakamoto'); 26 | }); 27 | 28 | it("doesn't allow storing a too long key", async () => { 29 | const instance = await KVStore.deployed(); 30 | const [accountOne] = accounts; 31 | try { 32 | await instance.set( 33 | 'satoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamoto', 34 | 'satoshi', 35 | { from: accountOne }, 36 | ); 37 | assert.fail(); 38 | } catch (err) { 39 | assert.ok(/revert/.test(err.message)); 40 | } 41 | }); 42 | 43 | it("doesn't allow storing a too long value", async () => { 44 | const instance = await KVStore.deployed(); 45 | const [accountOne] = accounts; 46 | try { 47 | await instance.set( 48 | 'satoshi', 49 | 'satoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamotosatoshinakamoto', 50 | { from: accountOne }, 51 | ); 52 | assert.fail(); 53 | } catch (err) { 54 | assert.ok(/revert/.test(err.message)); 55 | } 56 | }); 57 | 58 | it('outputs an address on deployment', async () => { 59 | const instance = await KVStore.deployed(); 60 | assert.typeOf(instance.address, 'string'); 61 | assert.isAtLeast(instance.address.length, 10); 62 | }); 63 | }); 64 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## eth-kvstore 2 | [![Travis Build Status](https://travis-ci.org/hCaptcha/eth-kvstore.svg?&branch=master)](https://travis-ci.org/hCaptcha/eth-kvstore) 3 | 4 | A way to store key-value pairs tied to your ethereum address. 5 | 6 | ## Motivation 7 | We built this smart contract for our own needs in the [HUMAN Protocol](https://www.hmt.ai/) to allow ethereum addresses to publish and rotate public keys for signing and encrypting arbitrary data. 8 | 9 | Separating identity (ETH addr keypair) and signing/encryption (public key published by ETH addr) means we avoid putting the identity key at risk via chosen-plaintext or known-plaintext attacks on the signing/encryption keys, and can easily rotate signing/encryption keys as needed. 10 | 11 | ## Prerequisites 12 | Three environment variables are needed to interact with the contract: 13 | ``` 14 | const { MNEMONIC, INFURA_TOKEN, KVSTORE_ADDRESS } = process.env; 15 | ``` 16 | 17 | `MNEMONIC` is a list containing 12 to 24 words that ensure certain entropy to our wallet's security. You can get your 12-word mnemonic easily from https://metamask.io/ 18 | 19 | `INFURA_TOKEN` is our gateway to the Ethereum blockchain and lets us easily interact with our contract without setting up our own node. You can get your token easily from https://infura.io/ 20 | 21 | `KVSTORE_ADDRESS` is the location where KVStore has been deployed. Current location of our Ropsten testnet deployment is in the following address: `0xc1db3c61b47d1f7d6529e435d5b360865a3130bc`. You can also deploy your own KVStore by following the `Deployment` section of the guide. 22 | 23 | Once you have setup all the needed prerequisites, setup your local `.env` file in the root of your project 24 | ``` 25 | touch .env 26 | ``` 27 | with the following content: 28 | ``` 29 | MNEMONIC= 30 | INFURA_TOKEN= 31 | KVSTORE_ADDRESS= 32 | ``` 33 | 34 | ## Get started 35 | 36 | Main prerequisites to get going are: 37 | 1. Setup all the env variables in `Prerequisites` 38 | 2. Copy the `KVStore.json` to your project's folder. 39 | 40 | An example application using eth-kvstore could look something like the following code snippet: 41 | 42 | ``` 43 | require('dotenv').config(); 44 | 45 | const HDWalletProvider = require('truffle-hdwallet-provider'); 46 | const Web3 = require('web3'); 47 | const KVStore = require('./build/contracts/KVStore.json'); 48 | 49 | const { MNEMONIC, INFURA_TOKEN, KVSTORE_ADDRESS } = process.env; 50 | 51 | const provider = new HDWalletProvider( 52 | MNEMONIC, 53 | `https://ropsten.infura.io/${INFURA_TOKEN}`, 54 | ); 55 | const web3 = new Web3(provider); 56 | 57 | const RopstenKVStore = new web3.eth.Contract(KVStore.abi, KVSTORE_ADDRESS); 58 | 59 | const set = async (key, value) => { 60 | const [accountOne] = await web3.eth.getAccounts(); 61 | const receipt = await RopstenKVStore.methods.set(key, value).send({ 62 | from: accountOne, 63 | }); 64 | console.log(receipt); 65 | return receipt; 66 | }; 67 | 68 | const get = async (key) => { 69 | const [accountOne] = await web3.eth.getAccounts(); 70 | 71 | const value = await RopstenKVStore.methods.get(accountOne, key).call({ 72 | from: accountOne, 73 | }); 74 | console.log(value); 75 | return value; 76 | }; 77 | 78 | set('satoshi', 'nakamoto'); 79 | get('satoshi'); 80 | ``` 81 | 82 | ## Docker usage 83 | 84 | The built docker image at `hcaptcha/eth-kvstore` will by default deploy the eth-kvstore contract to whatever is defined by `ETH_HOST` and `ETH_PORT` and the provided credentials as described above, if present. 85 | 86 | It will drop the address of the deployed contract into the default path `./ethkvstore.address.json` that can be used by various other tools by providing a volume mount and reading the contents of that file. The destination can be overridden via the `ADDRESS_OUTPUT_FILENAME` env var. 87 | 88 | To quickly see this in action, run: 89 | 90 | ```bash 91 | docker-compose up 92 | # or 93 | docker run -it --rm -v $(pwd)/deployed:/deployed hcaptcha/eth-kvstore 94 | cat deployed/ethkvstore.address.json 95 | ``` 96 | 97 | ## Installation 98 | You need Node as your environmental dependency. At the moment this is guaranteed to work with Node 8. 99 | 100 | ``` 101 | npm install 102 | npm run compile 103 | ``` 104 | 105 | ## Testing 106 | ``` 107 | npm run test 108 | ``` 109 | 110 | ## Deployment 111 | If you want to deploy your own KVStore to the Ethereum blockchain, you need to install Ganache first. Easiest way to get started is to install it from https://truffleframework.com/ganache 112 | 113 | ### Local deployment 114 | ``` 115 | truffle migrate 116 | ``` 117 | 118 | ### Testnet deployment 119 | Currently we support the following testnets: Mainnet, Kovan, Ropsten, Rinkeby. 120 | ``` 121 | truffle migrate --network ropsten 122 | ``` 123 | 124 | ## Contribute 125 | We welcome all pull requests! Please ensure you lint before the commit. 126 | ``` 127 | npm run lint 128 | ``` 129 | You can inspect more of our linting scripts at `package.json`. 130 | Please submit your pull request against our `staging` branch. 131 | If you find a bug feel free to [Click](https://github.com/hCaptcha/bounties) 132 | You can reach out to us on [telegram](https://t.me/hcaptchachat) 133 | 134 | ## Prior Work 135 | An earlier version of our specification was implemented at: https://github.com/willhay/kvstore. 136 | 137 | ## License 138 | MIT © HUMAN Protocol 139 | 140 | 141 | --------------------------------------------------------------------------------