initial commit

This commit is contained in:
bdim1
2022-04-16 22:11:51 +02:00
commit 9d2868325e
95 changed files with 203618 additions and 0 deletions

33
README.md Normal file
View File

@@ -0,0 +1,33 @@
# Description (WIP)
ZK-Keeper is a browser plugin which enables Zero knowledge identity management and proof generation.
Currently it supports operations for Semaphore and RLN gadgets.
This plugin is still in development phase.
The following features are supported currently:
- Identity secret and Identity commitment generation
- Semaphore ZK-Proof generation
- RLN ZK-Proof generation
The plugin uses the [zk-kit library](https://github.com/appliedzkp/zk-kit).
Proof generation is enabled in two ways:
- by providing merkle witness directly
- by providing a secure service address from which the merkle witness should be obtained
# Development
1. `npm install`
2. `npm run dev`
3. Load the dist directory as an unpacked extension from your browser.
# Demo
1. `npm run dev`
2. `npm run merkle`
3. `npm run serve`
4. `cd demo && npm run demo`
To run the demo and generate proofs, you additionally need the circuit files for Semaphore and RLN. For compatible Semaphore and RLN zk files you can use the following [link](https://drive.google.com/file/d/1Yi14jwly70VwMSuqJrPCc3j15MWeE7mc/view?usp=sharing).
Please extract the files into a directory named `zkeyFiles` at the root of this repository.

13
demo/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>ZK-keeper demo</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script type="module" src="./index.tsx"></script>
</body>
</html>

232
demo/index.tsx Normal file
View File

@@ -0,0 +1,232 @@
import React, { useState, useEffect } from 'react'
import ReactDOM from 'react-dom'
import { genExternalNullifier, RLN } from '@zk-kit/protocols'
import { bigintToHex } from 'bigint-conversion'
import { ZkIdentity } from '@zk-kit/identity'
import { ToastContainer, toast } from 'react-toastify'
import 'react-toastify/dist/ReactToastify.css'
const semaphorePath = {
circuitFilePath: 'http://localhost:8095/semaphore/semaphore.wasm',
zkeyFilePath: 'http://localhost:8095/semaphore/semaphore_final.zkey'
}
const rlnPath = {
circuitFilePath: 'http://localhost:8095/rln/rln.wasm',
zkeyFilePath: 'http://localhost:8095/rln/rln_final.zkey'
}
const merkleStorageAddress = 'http://localhost:8090/merkleProof'
enum MerkleProofType {
STORAGE_ADDRESS,
ARTIFACTS
}
const genMockIdentityCommitments = (): string[] => {
let identityCommitments: string[] = []
for (let i = 0; i < 10; i++) {
const mockIdentity = new ZkIdentity()
let idCommitment = bigintToHex(mockIdentity.genIdentityCommitment())
identityCommitments.push(idCommitment)
}
return identityCommitments
}
function NotConnected() {
return <div>Please connect to ZK-Keeper to continue.</div>
}
function NoActiveIDCommitment() {
return <div>Please set an active Identity Commitment in the ZK-Keeper plugin to continue.</div>
}
function App() {
const [client, setClient] = useState(null)
const [isLocked, setIsLocked] = useState(true)
const [identityCommitment, setIdentityCommitment] = useState('')
const mockIdentityCommitments: string[] = genMockIdentityCommitments()
const genSemaphoreProof = async (proofType: MerkleProofType = MerkleProofType.STORAGE_ADDRESS) => {
const externalNullifier = genExternalNullifier('voting-1')
const signal = '0x111'
let storageAddressOrArtifacts: any = `${merkleStorageAddress}/Semaphore`
if (proofType === MerkleProofType.ARTIFACTS) {
if (!mockIdentityCommitments.includes(identityCommitment)) {
mockIdentityCommitments.push(identityCommitment)
}
storageAddressOrArtifacts = {
leaves: mockIdentityCommitments,
depth: 20,
leavesPerNode: 2
}
}
let toastId
try {
toastId = toast('Generating semaphore proof...', {
type: 'info',
hideProgressBar: true,
closeOnClick: true,
pauseOnHover: false
})
const proof = await client.semaphoreProof(
externalNullifier,
signal,
semaphorePath.circuitFilePath,
semaphorePath.zkeyFilePath,
storageAddressOrArtifacts
)
toast('Semaphore proof generated successfully!', { type: 'success' })
} catch (e) {
toast('Error while generating Semaphore proof!', { type: 'error' })
console.error(e)
}
toast.dismiss(toastId)
}
const genRLNProof = async (proofType: MerkleProofType = MerkleProofType.STORAGE_ADDRESS) => {
const externalNullifier = genExternalNullifier('voting-1')
const signal = '0x111'
const rlnIdentifier = RLN.genIdentifier()
const rlnIdentifierHex = bigintToHex(rlnIdentifier)
let storageAddressOrArtifacts: any = `${merkleStorageAddress}/RLN`
if (proofType === MerkleProofType.ARTIFACTS) {
if (!mockIdentityCommitments.includes(identityCommitment)) {
mockIdentityCommitments.push(identityCommitment)
}
storageAddressOrArtifacts = {
leaves: mockIdentityCommitments,
depth: 15,
leavesPerNode: 2
}
}
let circuitPath = rlnPath.circuitFilePath
let zkeyFilePath = rlnPath.zkeyFilePath
let toastId
try {
toastId = toast('Generating RLN proof...', {
type: 'info',
hideProgressBar: true,
closeOnClick: true,
pauseOnHover: false
})
const proof = await client.rlnProof(
externalNullifier,
signal,
circuitPath,
zkeyFilePath,
storageAddressOrArtifacts,
rlnIdentifierHex
)
toast('RLN proof generated successfully!', { type: 'success' })
} catch (e) {
toast('Error while generating RLN proof!', { type: 'error' })
console.error(e)
}
toast.dismiss(toastId)
}
const getIdentityCommitment = async () => {
const idCommitment = await client.getActiveIdentity()
setIdentityCommitment(idCommitment)
}
useEffect(() => {
;(async function IIFE() {
initClient()
if (client) {
await getIdentityCommitment()
await client.on('identityChanged', (idCommitment) => {
setIdentityCommitment(idCommitment)
})
await client.on('logout', async () => {
setIdentityCommitment('')
setIsLocked(true)
})
await client.on('login', async () => {
setIsLocked(false)
await getIdentityCommitment()
})
}
})()
}, [client])
const initClient = async () => {
const { zkpr } = window as any
const client = await zkpr.connect()
setClient(client)
setIsLocked(false)
}
return (
<div>
{!client || isLocked ? (
<NotConnected />
) : identityCommitment === '' || identityCommitment === null ? (
<NoActiveIDCommitment />
) : (
<div>
<div>
<h2>Semaphore</h2>
<button onClick={() => genSemaphoreProof(MerkleProofType.STORAGE_ADDRESS)}>
Generate proof from Merkle proof storage address
</button>{' '}
<br />
<br />
<button onClick={() => genSemaphoreProof(MerkleProofType.ARTIFACTS)}>
Generate proof from Merkle proof artifacts
</button>
</div>
<hr />
<div>
<h2>RLN</h2>
<button onClick={() => genRLNProof(MerkleProofType.STORAGE_ADDRESS)}>
Generate proof from Merkle proof storage address
</button>{' '}
<br />
<br />
<button onClick={() => genRLNProof(MerkleProofType.ARTIFACTS)}>
Generate proof from Merkle proof artifacts
</button>
</div>
<hr />
<div>
<h2>Get identity commitment</h2>
<button onClick={() => getIdentityCommitment()}>Get</button> <br />
<br />
</div>
<hr />
<div>
<h2>Identity commitment for active identity:</h2>
<p>{identityCommitment}</p>
</div>
<ToastContainer newestOnTop={true} />
</div>
)}
</div>
)
}
const root = document.getElementById('root')
ReactDOM.render(<App />, root)

14290
demo/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

17
demo/package.json Normal file
View File

@@ -0,0 +1,17 @@
{
"name": "zk-keeper-demo",
"version": "0.1.0",
"scripts": {
"start": "parcel serve --open --no-cache index.html"
},
"dependencies": {
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-toastify": "^8.1.0"
},
"devDependencies": {
"@types/react": "^17.0.18",
"@types/react-dom": "^17.0.9",
"parcel": "^2.0.0-rc.0"
}
}

BIN
dist/icon-128.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

BIN
dist/icon-16.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 423 B

BIN
dist/icon-48.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

134792
dist/index.e15ca01d.js vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/index.e15ca01d.js.map vendored Normal file

File diff suppressed because one or more lines are too long

7
dist/index.html vendored Normal file
View File

@@ -0,0 +1,7 @@
<!DOCTYPE html>
<head>
</head>
<body>
<script src="/index.e15ca01d.js" defer=""></script>
</body>

53
dist/manifest.json vendored Normal file
View File

@@ -0,0 +1,53 @@
{
"manifest_version": 2,
"name": "ZK-KEEPER",
"description": "Extension that stores credentials and creates semaphore proofs",
"version": "1.0.0",
"browser_action": {
"default_icon": "icon-16.png",
"default_popup": "popup.html"
},
"background": {
"scripts": [
"js/backgroundPage.js"
],
"persistent": true
},
"content_scripts": [
{
"matches": [
"file://*/*",
"http://*/*",
"https://*/*"
],
"js": [
"js/content.js"
],
"run_at": "document_start",
"all_frames": true
}
],
"icons": {
"16": "icon-16.png",
"48": "icon-48.png",
"128": "icon-128.png"
},
"content_security_policy": "script-src 'self' 'unsafe-eval'; object-src 'self'; worker-src 'self' data:",
"permissions": [
"tabs",
"activeTab",
"notifications",
"http://*/",
"https://*/",
"fileSystem",
"webRequest",
"webRequestBlocking",
"proxy",
"storage",
"unlimitedStorage",
"<all_urls>"
],
"web_accessible_resources": [
"js/injected.js"
]
}

16
dist/popup.html vendored Normal file
View File

@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html width="357" height="600">
<head>
<title>zk keeper</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="preconnect" href="https://fonts.gstatic.com">
<link href="https://fonts.googleapis.com/css2?family=Open+Sans:wght@300;400;600;700&family=Roboto+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.3/css/all.min.css" integrity="sha512-iBBXm8fW90+nuLcSKlbmrPcLa0OT92xO1BIsZ+ywDWZCvqsWgccV3gFoRBv0z+8dLJgyAHIhR35VZc2oM/gI1w==" crossorigin="anonymous" />
<link href="https://unpkg.com/tailwindcss@^2/dist/tailwind.min.css" rel="stylesheet">
<script src="js/popup.js"></script>
</head>
<body>
<div id="popup"></div>
<div id="modal"></div>
</body>
</html>

2
externals/worker_threads.js vendored Normal file
View File

@@ -0,0 +1,2 @@
// eslint-disable-next-line no-undef
module.exports = window.worker_threads

20
jest.config.json Normal file
View File

@@ -0,0 +1,20 @@
{
"transform": {
"\\.(ts|tsx)": "ts-jest"
},
"testRegex": "(/__tests__/.*|\\.(test|spec))\\.(ts|tsx)$",
"moduleFileExtensions": ["ts", "js", "tsx"],
"moduleNameMapper": {
"@src/(.*)$": "<rootDir>/src/$1"
},
"coveragePathIgnorePatterns": ["/node_modules/", "/test/"],
"collectCoverageFrom": ["src/**/*.{ts,tsx}"],
"coverageThreshold": {
"global": {
"branches": 90,
"functions": 95,
"lines": 95,
"statements": 95
}
}
}

46074
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

130
package.json Normal file
View File

@@ -0,0 +1,130 @@
{
"name": "zk-keeper",
"version": "1.0.0",
"description": "ZK-Keeper, zero knowledge identity management and proof generation tool",
"main": "index.js",
"scripts": {
"build": "NODE_ENV=production webpack --config webpack.prod.js",
"dev": "NODE_ENV=development webpack -w --config webpack.dev.js",
"serve": "./scripts/serve.sh",
"merkle": "node scripts/mock-merkle-proof.js",
"test": "jest",
"test:watch": "jest --watch",
"lint": "eslint . --ext .js,.jsx,.ts,.tsx",
"lint:fix": "eslint . --ext .js,.jsx,.ts,.tsx --fix",
"prettier": "prettier -c .",
"prettier:fix": "prettier -w .",
"commit": "cz",
"precommit": "lint-staged"
},
"repository": {
"type": "git",
"url": "git+https://github.com"
},
"keywords": [
"react",
"typescript",
"chrome",
"extension",
"boilerplate"
],
"author": "Privacy and Scaling explorations team",
"license": "MIT",
"bugs": {
"url": "https://github.com/appliedzkp/zk-keeper"
},
"homepage": "https://github.com/appliedzkp/zk-keeper",
"devDependencies": {
"@babel/core": "^7.15.5",
"@babel/plugin-proposal-export-namespace-from": "^7.14.5",
"@babel/preset-env": "^7.11.5",
"@babel/preset-typescript": "^7.10.4",
"@commitlint/cli": "^15.0.0",
"@commitlint/config-conventional": "^15.0.0",
"@types/chrome": "^0.0.124",
"@types/eventemitter2": "^4.1.0",
"@types/jest": "^27.0.3",
"@types/node": "^14.11.8",
"@types/react": "^16.9.52",
"@types/react-dom": "^16.9.8",
"@types/react-router-dom": "^5.1.7",
"@types/redux-logger": "^3.0.8",
"@typescript-eslint/eslint-plugin": "^5.5.0",
"@typescript-eslint/parser": "^4.4.1",
"assert": "^2.0.0",
"awesome-typescript-loader": "^5.2.1",
"babel-core": "^6.26.3",
"babel-jest": "^26.5.2",
"babel-loader": "^8.2.2",
"commitizen": "^4.2.4",
"copy-webpack-plugin": "^6.4.1",
"css-loader": "^4.3.0",
"cz-conventional-changelog": "^3.3.0",
"eslint": "^8.3.0",
"eslint-config-airbnb": "^19.0.1",
"eslint-config-airbnb-typescript": "^16.1.0",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-import": "^2.25.3",
"eslint-plugin-jest": "^25.3.0",
"eslint-plugin-jsx-a11y": "^6.5.1",
"eslint-plugin-prettier": "^3.1.4",
"eslint-plugin-react": "^7.27.1",
"eslint-plugin-react-hooks": "^4.3.0",
"express": "^4.17.1",
"file-loader": "^6.2.0",
"http-server": "^14.0.0",
"image-webpack-loader": "^7.0.1",
"jest": "^27.5.1",
"lint-staged": "^12.1.2",
"npm-force-resolutions": "0.0.10",
"path-browserify": "^1.0.1",
"prettier": "^2.5.0",
"react-docgen-typescript-loader": "^3.7.2",
"react-docgen-typescript-webpack-plugin": "^1.1.0",
"sass-loader": "^10.0.3",
"stream-browserify": "^3.0.0",
"stream-http": "^3.2.0",
"style-loader": "^2.0.0",
"ts-loader": "^8.0.5",
"typescript": "^4.5.0",
"webpack": "^5.52.0",
"webpack-cli": "^3.3.10",
"webpack-merge": "^4.2.2"
},
"dependencies": {
"@dimensiondev/metamask-extension-provider": "https://github.com/DimensionDev/extension-provider/tarball/master",
"@interep/identity": "^0.1.1",
"@types/classnames": "^2.2.11",
"@zk-kit/identity": "^1.4.1",
"@zk-kit/protocols": "^1.8.2",
"axios": "^0.24.0",
"bigint-conversion": "^2.1.12",
"browserify": "^17.0.0",
"circomlibjs": "^0.0.8",
"classnames": "^2.2.6",
"copy-to-clipboard": "^3.3.1",
"crypto-js": "^4.1.1",
"eventemitter2": "^6.4.4",
"extensionizer": "^1.0.1",
"fast-deep-equal": "^3.1.3",
"ffjavascript": "0.2.39",
"link-preview-js": "^2.1.13",
"node-sass": "^6.0.1",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-jazzicon": "^0.1.3",
"react-redux": "^7.2.3",
"react-router": "^5.2.0",
"react-router-dom": "^5.2.0",
"redux": "^4.0.5",
"redux-logger": "^3.0.6",
"redux-thunk": "^2.3.0",
"web3": "^1.5.3",
"webextension-polyfill-ts": "^0.20.0"
},
"config": {
"commitizen": {
"path": "./node_modules/cz-conventional-changelog"
}
}
}

View File

@@ -0,0 +1,69 @@
// eslint-disable-next-line import/no-extraneous-dependencies
const express = require('express')
const { generateMerkleProof } = require('@zk-kit/protocols')
const { ZkIdentity } = require('@zk-kit/identity')
const { bigintToHex, hexToBigint } = require('bigint-conversion')
const DEPTH_RLN = 15
const NUMBER_OF_LEAVES_RLN = 2
const DEPTH_SEMAPHORE = 20
const NUMBER_OF_LEAVES_SEMAPHORE = 2
const ZERO_VALUE = BigInt(0)
const serializeMerkleProof = (merkleProof) => {
const serialized = {}
serialized.root = bigintToHex(merkleProof.root)
serialized.siblings = merkleProof.siblings.map((siblings) =>
Array.isArray(siblings) ? siblings.map((element) => bigintToHex(element)) : bigintToHex(siblings)
)
serialized.pathIndices = merkleProof.pathIndices
serialized.leaf = bigintToHex(merkleProof.leaf)
return serialized
}
const generateMerkleProofRLN = (identityCommitments, identityCommitment) => {
return generateMerkleProof(DEPTH_RLN, ZERO_VALUE, NUMBER_OF_LEAVES_RLN, identityCommitments, identityCommitment)
}
const generateMerkleProofSemaphore = (identityCommitments, identityCommitment) => {
return generateMerkleProof(
DEPTH_SEMAPHORE,
ZERO_VALUE,
NUMBER_OF_LEAVES_SEMAPHORE,
identityCommitments,
identityCommitment
)
}
const identityCommitments = []
// eslint-disable-next-line no-plusplus
for (let i = 0; i < 2; i++) {
const mockIdentity = new ZkIdentity()
identityCommitments.push(mockIdentity.genIdentityCommitment())
}
const app = express()
app.use(express.json())
app.post('/merkleProof/:type', (req, res) => {
let type = req.params.type
let { identityCommitment } = req.body
identityCommitment = hexToBigint(identityCommitment)
if (!identityCommitments.includes(identityCommitment)) {
identityCommitments.push(identityCommitment)
}
const merkleProof =
type === 'RLN'
? generateMerkleProofRLN(identityCommitments, identityCommitment)
: generateMerkleProofSemaphore(identityCommitments, identityCommitment)
const serializedMerkleProof = serializeMerkleProof(merkleProof)
console.log('Sending proof with root: ', serializedMerkleProof.root)
res.send({ merkleProof: serializedMerkleProof })
})
app.listen(8090, () => {
console.log('Merkle service is listening')
})

6
scripts/serve.sh Executable file
View File

@@ -0,0 +1,6 @@
#!/usr/bin/env bash
cd "$(dirname "$0")"
cd ../zkeyFiles
http-server -p 8095 --cors

View File

@@ -0,0 +1,30 @@
import { browser } from 'webextension-polyfill-ts'
import { Request } from '@src/types'
import ZkKepperController from './zk-kepeer'
// TODO consider adding inTest env
const app: ZkKepperController = new ZkKepperController()
app.initialize().then(async () => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
browser.runtime.onMessage.addListener(async (request: Request, _) => {
try {
const res = await app.handle(request)
return [null, res]
} catch (e: any) {
return [e.message, null]
}
})
})
browser.runtime.onInstalled.addListener(async ({ reason }) => {
if (reason === 'install') {
// TODO open html where password will be interested
// browser.tabs.create({
// url: 'popup.html'
// });
}
if (process.env.NODE_ENV === 'development') {
// browser.tabs.create({ url: 'popup.html' });
}
})

View File

@@ -0,0 +1,50 @@
import { browser } from 'webextension-polyfill-ts'
class BrowserUtils {
cached: any | null
constructor() {
browser.windows.onRemoved.addListener((windowId) => {
if (this.cached?.id === windowId) {
this.cached = null
}
})
}
createTab = async (options: any) => browser.tabs.create(options)
createWindow = async (options: any) => browser.windows.create(options)
openPopup = async () => {
if (this.cached) {
this.focusWindow(this.cached.id)
return this.cached
}
const tab = await this.createTab({ url: 'popup.html', active: false })
// TODO add this in config/constants...
const popup = await this.createWindow({
tabId: tab.id,
type: 'popup',
focused: true,
width: 357,
height: 600
})
this.cached = popup
return popup
}
closePopup = async () => {
if (this.cached) {
browser.windows.remove(this.cached.id)
}
}
focusWindow = (windowId) => browser.windows.update(windowId, { focused: true })
getAllWindows = () => browser.windows.getAll()
}
export default new BrowserUtils()

View File

@@ -0,0 +1,35 @@
import { Request } from '@src/types'
type Chain = {
middlewares: Array<(payload: any, meta?: any) => Promise<any>>
handler: (payload: any, meta?: any) => Promise<any>
}
export default class Handler {
private handlers: Map<string, Chain>
constructor() {
this.handlers = new Map()
}
add = (method: string, ...args: Array<(payload: any, meta?: any) => any>) => {
const handler = args[args.length - 1]
const middlewares = args.slice(0, args.length - 1)
this.handlers.set(method, { middlewares, handler })
}
handle = async (request: Request): Promise<any> => {
const { method } = request
const handler: Chain | undefined = this.handlers.get(method)
if (!handler) throw new Error(`method: ${method} not detected`)
let { payload, meta } = request
for (const middleware of handler.middlewares) {
// eslint-disable-next-line no-await-in-loop
payload = await middleware(payload, meta)
}
return handler.handler(payload, meta)
}
}

View File

@@ -0,0 +1,73 @@
import pushMessage from '@src/util/pushMessage'
import { EventEmitter2 } from 'eventemitter2'
import { PendingRequest, PendingRequestType, RequestResolutionAction } from '@src/types'
import { setPendingRequest } from '@src/ui/ducks/requests'
import { browser } from 'webextension-polyfill-ts'
import BrowserUtils from './browser-utils'
let nonce = 0
export default class RequestManager extends EventEmitter2 {
private pendingRequests: Array<PendingRequest>
constructor() {
super()
this.pendingRequests = []
}
getRequests = (): PendingRequest[] => this.pendingRequests
finalizeRequest = async (action: RequestResolutionAction<any>): Promise<boolean> => {
const { id } = action
if (!id) throw new Error('id not provided')
// TODO add some mutex lock just in case something strange occurs
this.pendingRequests = this.pendingRequests.filter((pendingRequest: PendingRequest) => pendingRequest.id !== id)
this.emit(`${id}:finalized`, action)
await pushMessage(setPendingRequest(this.pendingRequests))
return true
}
addToQueue = async (type: PendingRequestType, payload?: any): Promise<string> => {
// eslint-disable-next-line no-plusplus
const id: string = `${nonce++}`
this.pendingRequests.push({ id, type, payload })
await pushMessage(setPendingRequest(this.pendingRequests))
return id
}
newRequest = async (type: PendingRequestType, payload?: any) => {
const id: string = await this.addToQueue(type, payload)
const popup = await BrowserUtils.openPopup()
return new Promise((resolve, reject) => {
const onPopupClose = (windowId: number) => {
if (windowId === popup.id) {
reject(new Error('user rejected.'))
browser.windows.onRemoved.removeListener(onPopupClose)
}
}
browser.windows.onRemoved.addListener(onPopupClose)
this.once(`${id}:finalized`, (action: RequestResolutionAction<any>) => {
browser.windows.onRemoved.removeListener(onPopupClose)
switch (action.status) {
case 'accept':
resolve(action.data)
return
case 'reject':
// eslint-disable-next-line prefer-promise-reject-errors
reject(new Error('user rejected.'))
return
default:
reject(new Error(`action: ${action.status} not supproted`))
}
})
})
}
handlePopup = async () => {
const newPopup = await BrowserUtils.openPopup()
if (!newPopup?.id) throw new Error('Something went wrong in opening popup')
}
}

View File

@@ -0,0 +1,37 @@
import { ZkIdentity } from '@zk-kit/identity'
import { SerializedIdentity, IdentityMetadata } from '@src/types'
export default class ZkIdentityDecorater {
public zkIdentity: ZkIdentity
public metadata: IdentityMetadata
constructor(zkIdentity: ZkIdentity, metadata: IdentityMetadata) {
this.zkIdentity = zkIdentity
this.metadata = metadata
}
genIdentityCommitment = (): bigint => {
const idCommitment = this.zkIdentity.genIdentityCommitment()
return idCommitment
}
serialize = (): string => {
const serialized = {
secret: this.zkIdentity.serializeIdentity(),
metadata: this.metadata
}
return JSON.stringify(serialized)
}
static genFromSerialized = (serialized: string): ZkIdentityDecorater => {
const data: SerializedIdentity = JSON.parse(serialized)
if (!data.metadata) throw new Error('Metadata missing')
if (!data.secret) throw new Error('Secret missing')
// TODO overload zkIdentity function to work both with array and string
const zkIdentity = new ZkIdentity(2, data.secret)
return new ZkIdentityDecorater(zkIdentity, data.metadata)
}
}

View File

@@ -0,0 +1,25 @@
import ZkIdentityDecorater from './identity-decorater'
import identityFactory from './identity-factory'
describe('# identityFactory', () => {
it('Should not create a random identity without the required parameters', async () => {
const fun = () => identityFactory('random', undefined as any)
await expect(fun).rejects.toThrow("Parameter 'config' is not defined")
})
it('Should create a random identity', async () => {
const identity1 = await identityFactory('random', { name: 'name' })
const identity2 = ZkIdentityDecorater.genFromSerialized(identity1.serialize())
expect(identity1.zkIdentity.getTrapdoor()).toEqual(identity2.zkIdentity.getTrapdoor())
expect(identity1.zkIdentity.getNullifier()).toEqual(identity2.zkIdentity.getNullifier())
expect(identity1.zkIdentity.getSecretHash()).toEqual(identity2.zkIdentity.getSecretHash())
})
it('Should not create an InterRep identity without the required parameters', async () => {
const fun = () => identityFactory('interrep', undefined as any)
await expect(fun).rejects.toThrow()
})
})

View File

@@ -0,0 +1,53 @@
import { IdentityMetadata } from '@src/types'
import { ZkIdentity } from '@zk-kit/identity'
import createIdentity from '@interep/identity'
import checkParameter from '@src/util/checkParameter'
import ZkIdentityDecorater from './identity-decorater'
const createInterrepIdentity = async (config: any): Promise<ZkIdentityDecorater> => {
checkParameter(config, 'config', 'object')
const { web2Provider, nonce = 0, name, web3, walletInfo } = config
checkParameter(name, 'name', 'string')
checkParameter(web2Provider, 'provider', 'string')
checkParameter(web3, 'web3', 'object')
checkParameter(walletInfo, 'walletInfo', 'object')
const sign = (message: string) => web3.eth.personal.sign(message, walletInfo?.account)
const identity: ZkIdentity = await createIdentity(sign, web2Provider, nonce)
const metadata: IdentityMetadata = {
account: walletInfo.account,
name,
provider: 'interrep'
}
return new ZkIdentityDecorater(identity, metadata)
}
const createRandomIdentity = (config: any): ZkIdentityDecorater => {
checkParameter(config, 'config', 'object')
const { name } = config
checkParameter(name, 'name', 'string')
const identity: ZkIdentity = new ZkIdentity()
const metadata: IdentityMetadata = {
account: '',
name: config.name,
provider: 'random'
}
return new ZkIdentityDecorater(identity, metadata)
}
const strategiesMap = {
random: createRandomIdentity,
interrep: createInterrepIdentity
}
const identityFactory = async (strategy: keyof typeof strategiesMap, config: any): Promise<ZkIdentityDecorater> =>
strategiesMap[strategy](config)
export default identityFactory

View File

@@ -0,0 +1,120 @@
import SimpleStorage from './simple-storage'
import LockService from './lock'
const DB_KEY = '@APPROVED@'
export default class ApprovalService extends SimpleStorage {
private allowedHosts: Array<string>
permissions: SimpleStorage
constructor() {
super(DB_KEY)
this.allowedHosts = []
this.permissions = new SimpleStorage('@HOST_PERMISSIONS@')
}
getAllowedHosts = () => this.allowedHosts
isApproved = (origin: string): boolean => this.allowedHosts.includes(origin)
unlock = async (): Promise<boolean> => {
const encrypedArray: Array<string> = await this.get()
if (!encrypedArray) return true
const promises: Array<Promise<string>> = encrypedArray.map((cipertext: string) =>
LockService.decrypt(cipertext)
)
this.allowedHosts = await Promise.all(promises)
return true
}
refresh = async () => {
const encrypedArray: Array<string> = await this.get()
if (!encrypedArray) {
this.allowedHosts = []
return
}
const promises: Array<Promise<string>> = encrypedArray.map((cipertext: string) =>
LockService.decrypt(cipertext)
)
this.allowedHosts = await Promise.all(promises)
}
getPermission = async (host: string) => {
const store = await this.permissions.get()
const permission = store ? store[host] : false
return {
noApproval: !!permission?.noApproval
}
}
setPermission = async (
host: string,
permission: {
noApproval: boolean
}
) => {
const { noApproval } = permission
const existing = await this.getPermission(host)
const newPer = {
...existing,
noApproval
}
const store = await this.permissions.get()
await this.permissions.set({
...(store || {}),
[host]: newPer
})
return newPer
}
add = async (payload: { host: string; noApproval?: boolean }) => {
const { host, noApproval } = payload
if (!host) throw new Error('No host provided')
if (this.allowedHosts.includes(host)) return
this.allowedHosts.push(host)
const promises: Array<Promise<string>> = this.allowedHosts.map((allowedHost: string) =>
LockService.encrypt(allowedHost)
)
const newValue: Array<string> = await Promise.all(promises)
await this.set(newValue)
await this.refresh()
}
remove = async (payload: any) => {
const { host }: { host: string } = payload
console.log(payload)
if (!host) throw new Error('No address provided')
const index: number = this.allowedHosts.indexOf(host)
if (index === -1) return
this.allowedHosts = [...this.allowedHosts.slice(0, index), ...this.allowedHosts.slice(index + 1)]
const promises: Array<Promise<string>> = this.allowedHosts.map((allowedHost: string) =>
LockService.encrypt(allowedHost)
)
const newValue: Array<string> = await Promise.all(promises)
await this.set(newValue)
await this.refresh()
}
/** dev only */
empty = async (): Promise<any> => {
if (!(process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'test')) return
await this.clear()
await this.refresh()
}
}

View File

@@ -0,0 +1,118 @@
import { bigintToHex } from 'bigint-conversion'
import pushMessage from '@src/util/pushMessage'
import { setIdentities, setSelected } from '@src/ui/ducks/identities'
import { browser } from 'webextension-polyfill-ts'
import { IdentityMetadata } from '@src/types'
import SimpleStorage from './simple-storage'
import LockService from './lock'
import ZkIdentityDecorater from '../identity-decorater'
const DB_KEY = '@@IDS-t1@@'
export default class IdentityService extends SimpleStorage {
identities: Map<string, ZkIdentityDecorater>
activeIdentity?: ZkIdentityDecorater
constructor() {
super(DB_KEY)
this.identities = new Map()
this.activeIdentity = undefined
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
unlock = async (_: any) => {
const encryptedContent = await this.get()
if (!encryptedContent) return true
const decrypted: any = await LockService.decrypt(encryptedContent)
await this.loadInMemory(JSON.parse(decrypted))
await this.setDefaultIdentity()
pushMessage(setIdentities(await this.getIdentities()))
return true
}
refresh = async () => {
const encryptedContent = await this.get()
if (!encryptedContent) return
const decrypted: any = await LockService.decrypt(encryptedContent)
await this.loadInMemory(JSON.parse(decrypted))
// if the first identity just added, set it to active
if (this.identities.size === 1) {
await this.setDefaultIdentity()
}
pushMessage(setIdentities(await this.getIdentities()))
}
loadInMemory = async (decrypted: any) => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
Object.entries(decrypted || {}).forEach(([_, value]) => {
const identity: ZkIdentityDecorater = ZkIdentityDecorater.genFromSerialized(value as string)
const identityCommitment: bigint = identity.genIdentityCommitment()
this.identities.set(bigintToHex(identityCommitment), identity)
})
}
setDefaultIdentity = async () => {
if (!this.identities.size) return
const firstKey: string = this.identities.keys().next().value
this.activeIdentity = this.identities.get(firstKey)
}
setActiveIdentity = async (identityCommitment: string) => {
if (this.identities.has(identityCommitment)) {
this.activeIdentity = this.identities.get(identityCommitment)
pushMessage(setSelected(identityCommitment))
const tabs = await browser.tabs.query({ active: true })
for (const tab of tabs) {
await browser.tabs.sendMessage(tab.id as number, setSelected(identityCommitment))
}
}
}
getActiveidentity = async (): Promise<ZkIdentityDecorater | undefined> => this.activeIdentity
getIdentityCommitments = async () => {
const commitments: string[] = []
for (const key of this.identities.keys()) {
commitments.push(key)
}
return commitments
}
getIdentities = async (): Promise<{ commitment: string; metadata: IdentityMetadata }[]> => {
const commitments = await this.getIdentityCommitments()
return commitments.map((commitment) => {
const id = this.identities.get(commitment)
return {
commitment,
metadata: id!.metadata
}
})
}
insert = async (newIdentity: ZkIdentityDecorater): Promise<boolean> => {
const identityCommitment: string = bigintToHex(newIdentity.genIdentityCommitment())
const existing: boolean = this.identities.has(identityCommitment)
if (existing) return false
const existingIdentites: string[] = []
for (const identity of this.identities.values()) {
existingIdentites.push(identity.serialize())
}
const newValue: string[] = [...existingIdentites, newIdentity.serialize()]
const ciphertext: string = await LockService.encrypt(JSON.stringify(newValue))
await this.set(ciphertext)
await this.refresh()
await this.setActiveIdentity(identityCommitment)
return true
}
getNumOfIdentites = (): number => this.identities.size
}

View File

@@ -0,0 +1,118 @@
import CryptoJS from 'crypto-js'
import pushMessage from '@src/util/pushMessage'
import { setStatus } from '@src/ui/ducks/app'
import { browser } from 'webextension-polyfill-ts'
import SimpleStorage from './simple-storage'
const passwordKey: string = '@password@'
class LockService extends SimpleStorage {
private isUnlocked: boolean
private password?: string
private passwordChecker: string
private unlockCB?: any
constructor() {
super(passwordKey)
this.isUnlocked = false
this.password = undefined
this.passwordChecker = 'Password is correct'
}
/**
* This method is called when install event occurs
*/
setupPassword = async (password: string) => {
const ciphertext: string = CryptoJS.AES.encrypt(this.passwordChecker, password).toString()
await this.set(ciphertext)
await this.unlock(password)
await pushMessage(setStatus(await this.getStatus()))
}
getStatus = async () => {
const ciphertext = await this.get()
return {
initialized: !!ciphertext,
unlocked: this.isUnlocked
}
}
awaitUnlock = async () => {
if (this.isUnlocked) return
return new Promise((resolve) => {
this.unlockCB = resolve
})
}
onUnlocked = () => {
if (this.unlockCB) {
this.unlockCB()
this.unlockCB = undefined
}
return true
}
unlock = async (password: string): Promise<boolean> => {
if (this.isUnlocked) return true
const ciphertext = await this.get()
if (!ciphertext) {
throw new Error('Something badly gone wrong (reinstallation probably required)')
}
if (!password) {
throw new Error('Password is not provided')
}
const bytes = CryptoJS.AES.decrypt(ciphertext, password)
const retrievedPasswordChecker: string = bytes.toString(CryptoJS.enc.Utf8)
if (retrievedPasswordChecker !== this.passwordChecker) {
throw new Error('Incorrect password')
}
this.password = password
this.isUnlocked = true
const status = await this.getStatus()
await pushMessage(setStatus(status))
const tabs = await browser.tabs.query({ active: true })
for (const tab of tabs) {
await browser.tabs.sendMessage(tab.id as number, setStatus(status))
}
return true
}
logout = async (): Promise<boolean> => {
this.isUnlocked = false
this.password = undefined
const status = await this.getStatus()
await pushMessage(setStatus(status))
const tabs = await browser.tabs.query({ active: true })
for (const tab of tabs) {
await browser.tabs.sendMessage(tab.id as number, setStatus(status))
}
return true
}
ensure = async (payload: any = null) => {
if (!this.isUnlocked || !this.password) throw new Error('state is locked!')
return payload
}
encrypt = async (payload: string): Promise<string> => {
await this.ensure()
return CryptoJS.AES.encrypt(payload, this.password).toString()
}
decrypt = async (ciphertext: string): Promise<string> => {
await this.ensure()
const bytes = CryptoJS.AES.decrypt(ciphertext, this.password)
return bytes.toString(CryptoJS.enc.Utf8)
}
}
export default new LockService()

View File

@@ -0,0 +1,98 @@
import pushMessage from '@src/util/pushMessage'
import createMetaMaskProvider from '@dimensiondev/metamask-extension-provider'
import Web3 from 'web3'
import { setAccount, setChainId, setNetwork, setWeb3Connecting } from '@src/ui/ducks/web3'
import { WalletInfo } from '@src/types'
export default class MetamaskService {
provider?: any
web3?: Web3
constructor() {
this.ensure()
}
ensure = async (payload: any = null) => {
if (!this.provider) {
this.provider = await createMetaMaskProvider()
}
if (this.provider) {
if (!this.web3) {
this.web3 = new Web3(this.provider)
}
this.provider.on('accountsChanged', ([account]) => {
pushMessage(setAccount(account))
})
this.provider.on('chainChanged', async () => {
const networkType = await this.web3?.eth.net.getNetworkType()
const chainId = await this.web3?.eth.getChainId()
if (networkType) pushMessage(setNetwork(networkType))
if (chainId) pushMessage(setChainId(chainId))
})
}
return payload
}
getWeb3 = async (): Promise<Web3> => {
if (!this.web3) throw new Error(`web3 is not initialized`)
return this.web3
}
getWalletInfo = async (): Promise<WalletInfo | null> => {
await this.ensure()
if (!this.web3) {
return null
}
if (this.provider?.selectedAddress) {
const accounts = await this.web3.eth.requestAccounts()
const networkType = await this.web3.eth.net.getNetworkType()
const chainId = await this.web3.eth.getChainId()
if (!accounts.length) {
throw new Error('No accounts found')
}
return {
account: accounts[0],
networkType,
chainId
}
}
return null
}
connectMetamask = async () => {
await pushMessage(setWeb3Connecting(true))
try {
await this.ensure()
if (this.web3) {
const accounts = await this.web3.eth.requestAccounts()
const networkType = await this.web3.eth.net.getNetworkType()
const chainId = await this.web3.eth.getChainId()
if (!accounts.length) {
throw new Error('No accounts found')
}
await pushMessage(setAccount(accounts[0]))
await pushMessage(setNetwork(networkType))
await pushMessage(setChainId(chainId))
}
await pushMessage(setWeb3Connecting(false))
} catch (e) {
await pushMessage(setWeb3Connecting(false))
throw e
}
}
}

View File

@@ -0,0 +1,27 @@
import { MerkleProof, SemaphoreFullProof, SemaphoreSolidityProof } from '@zk-kit/protocols'
import { MerkleProofArtifacts } from '@src/types'
export enum Protocol {
SEMAPHORE,
RLN,
NRLN
}
export interface SemaphoreProofRequest {
externalNullifier: string
signal: string
merkleStorageAddress?: string
circuitFilePath: string
zkeyFilePath: string
merkleProofArtifacts?: MerkleProofArtifacts
merkleProof?: MerkleProof
}
export interface RLNProofRequest extends SemaphoreProofRequest {
rlnIdentifier: string
}
export interface SemaphoreProof {
fullProof: SemaphoreFullProof
solidityProof: SemaphoreSolidityProof
}

View File

@@ -0,0 +1,59 @@
import { RLN, MerkleProof, RLNFullProof, generateMerkleProof } from '@zk-kit/protocols'
import { ZkIdentity } from '@zk-kit/identity'
import { bigintToHex, hexToBigint } from 'bigint-conversion'
import axios, { AxiosResponse } from 'axios'
import { MerkleProofArtifacts } from '@src/types'
import { RLNProofRequest } from './interfaces'
import { deserializeMerkleProof } from './utils'
export default class RLNService {
// eslint-disable-next-line class-methods-use-this
async genProof(identity: ZkIdentity, request: RLNProofRequest): Promise<RLNFullProof> {
try {
const {
circuitFilePath,
zkeyFilePath,
merkleStorageAddress,
externalNullifier,
signal,
merkleProofArtifacts,
rlnIdentifier
} = request
let merkleProof: MerkleProof
const identitySecretHash: bigint = identity.getSecretHash()
const identityCommitment = identity.genIdentityCommitment()
const identityCommitmentHex = bigintToHex(identityCommitment)
const rlnIdentifierBigInt = hexToBigint(rlnIdentifier)
if (merkleStorageAddress) {
const response: AxiosResponse = await axios.post(merkleStorageAddress, {
identityCommitment: identityCommitmentHex
})
merkleProof = deserializeMerkleProof(response.data.merkleProof)
} else {
const proofArtifacts = merkleProofArtifacts as MerkleProofArtifacts
const leaves = proofArtifacts.leaves.map((leaf) => hexToBigint(leaf))
merkleProof = generateMerkleProof(
proofArtifacts.depth,
BigInt(0),
proofArtifacts.leavesPerNode,
leaves,
identityCommitment
)
}
const witness = RLN.genWitness(
identitySecretHash,
merkleProof,
externalNullifier,
signal,
rlnIdentifierBigInt
)
const fullProof: RLNFullProof = await RLN.genProof(witness, circuitFilePath, zkeyFilePath)
return fullProof
} catch (e) {
throw new Error(`Error while generating RLN proof: ${e}`)
}
}
}

View File

@@ -0,0 +1,75 @@
import {
Semaphore,
MerkleProof,
SemaphoreFullProof,
SemaphoreSolidityProof,
SemaphorePublicSignals,
genSignalHash,
generateMerkleProof
} from '@zk-kit/protocols'
import { ZkIdentity } from '@zk-kit/identity'
import { bigintToHex, hexToBigint } from 'bigint-conversion'
import axios, { AxiosResponse } from 'axios'
import { MerkleProofArtifacts } from '@src/types'
import { SemaphoreProof, SemaphoreProofRequest } from './interfaces'
import { deserializeMerkleProof } from './utils'
export default class SemaphoreService {
// eslint-disable-next-line class-methods-use-this
async genProof(identity: ZkIdentity, request: SemaphoreProofRequest): Promise<SemaphoreProof> {
try {
const {
circuitFilePath,
zkeyFilePath,
merkleStorageAddress,
externalNullifier,
signal,
merkleProofArtifacts,
merkleProof: _merkleProof
} = request
let merkleProof: MerkleProof
const identityCommitment = identity.genIdentityCommitment()
const identityCommitmentHex = bigintToHex(identityCommitment)
if (_merkleProof) {
merkleProof = _merkleProof
} else if (merkleStorageAddress) {
const response: AxiosResponse = await axios.post(merkleStorageAddress, {
identityCommitment: identityCommitmentHex
})
merkleProof = deserializeMerkleProof(response.data.merkleProof)
} else {
const proofArtifacts = merkleProofArtifacts as MerkleProofArtifacts
const leaves = proofArtifacts.leaves.map((leaf) => hexToBigint(leaf))
merkleProof = generateMerkleProof(
proofArtifacts.depth,
BigInt(0),
proofArtifacts.leavesPerNode,
leaves,
identityCommitment
)
}
const witness = Semaphore.genWitness(
identity.getTrapdoor(),
identity.getNullifier(),
merkleProof,
externalNullifier,
signal
)
const fullProof: SemaphoreFullProof = await Semaphore.genProof(witness, circuitFilePath, zkeyFilePath)
const solidityProof: SemaphoreSolidityProof = Semaphore.packToSolidityProof(fullProof)
return {
fullProof,
solidityProof
}
} catch (e) {
throw new Error(`Error while generating semaphore proof: ${e}`)
}
}
}

View File

@@ -0,0 +1,16 @@
import { hexToBigint } from 'bigint-conversion'
import { MerkleProof } from '@zk-kit/protocols'
import * as ciromlibjs from 'circomlibjs'
// eslint-disable-next-line import/prefer-default-export
export function deserializeMerkleProof(merkleProof): MerkleProof {
const deserialized = {} as MerkleProof
deserialized.root = hexToBigint(merkleProof.root)
deserialized.siblings = merkleProof.siblings.map((siblings) =>
Array.isArray(siblings) ? siblings.map((element) => hexToBigint(element)) : hexToBigint(siblings)
)
deserialized.pathIndices = merkleProof.pathIndices
deserialized.leaf = hexToBigint(merkleProof.leaf)
return deserialized
}
export const poseidonHash = (data: Array<bigint>): bigint => ciromlibjs.poseidon(data)

View File

@@ -0,0 +1,18 @@
import { browser } from 'webextension-polyfill-ts'
export default class SimpleStorage {
private key: string
constructor(key) {
this.key = key
}
get = async (): Promise<any | null> => {
const content = await browser.storage.sync.get(this.key)
return content ? content[this.key] : null
}
set = async (value) => browser.storage.sync.set({ [this.key]: value })
clear = async () => browser.storage.sync.remove(this.key)
}

View File

@@ -0,0 +1,10 @@
import { browser } from 'webextension-polyfill-ts'
export async function get(key): Promise<any | null> {
const content = await browser.storage.sync.get(key)
return content ? content[key] : null
}
export async function set(key, value) {
return browser.storage.sync.set({ [key]: value })
}

View File

@@ -0,0 +1,25 @@
import { ZkInputs } from '@src/types'
export default class ZkValidator {
// eslint-disable-next-line class-methods-use-this
validateZkInputs(payload: Required<ZkInputs>) {
const { circuitFilePath, zkeyFilePath, merkleProofArtifacts, merkleProof } = payload
if (!circuitFilePath) throw new Error('circuitFilePath not provided')
if (!zkeyFilePath) throw new Error('zkeyFilePath not provided')
if (merkleProof) {
if (!merkleProof.root) throw new Error('invalid merkleProof.root value')
if (!merkleProof.siblings.length) throw new Error('invalid merkleProof.siblings value')
if (!merkleProof.pathIndices.length) throw new Error('invalid merkleProof.pathIndices value')
if (!merkleProof.leaf) throw new Error('invalid merkleProof.leaf value')
} else if (merkleProofArtifacts) {
if (!merkleProofArtifacts.leaves.length || merkleProofArtifacts.leaves.length === 0)
throw new Error('invalid merkleProofArtifacts.leaves value')
if (!merkleProofArtifacts.depth) throw new Error('invalid merkleProofArtifacts.depth value')
if (!merkleProofArtifacts.leavesPerNode) throw new Error('invalid merkleProofArtifacts.leavesPerNode value')
}
return payload
}
}

242
src/background/zk-kepeer.ts Normal file
View File

@@ -0,0 +1,242 @@
import RPCAction from '@src/util/constants'
import { PendingRequestType, NewIdentityRequest, WalletInfo } from '@src/types'
import Web3 from 'web3'
import { bigintToHex } from 'bigint-conversion'
import { RLNFullProof } from '@zk-kit/protocols'
import Handler from './controllers/handler'
import LockService from './services/lock'
import IdentityService from './services/identity'
import MetamaskService from './services/metamask'
import ZkValidator from './services/zk-validator'
import RequestManager from './controllers/request-manager'
import SemaphoreService from './services/protocols/semaphore'
import RLNService from './services/protocols/rln'
import { RLNProofRequest, SemaphoreProof, SemaphoreProofRequest } from './services/protocols/interfaces'
import ApprovalService from './services/approval'
import ZkIdentityWrapper from './identity-decorater'
import identityFactory from './identity-factory'
import BrowserUtils from './controllers/browser-utils'
export default class ZkKepperController extends Handler {
private identityService: IdentityService
private metamaskService: MetamaskService
private zkValidator: ZkValidator
private requestManager: RequestManager
private semaphoreService: SemaphoreService
private rlnService: RLNService
private approvalService: ApprovalService
constructor() {
super()
this.identityService = new IdentityService()
this.metamaskService = new MetamaskService()
this.zkValidator = new ZkValidator()
this.requestManager = new RequestManager()
this.semaphoreService = new SemaphoreService()
this.rlnService = new RLNService()
this.approvalService = new ApprovalService()
}
initialize = async (): Promise<ZkKepperController> => {
// common
this.add(
RPCAction.UNLOCK,
LockService.unlock,
this.metamaskService.ensure,
this.identityService.unlock,
this.approvalService.unlock,
LockService.onUnlocked
)
this.add(RPCAction.LOCK, LockService.logout)
/**
* Return status of background process
* @returns {Object} status Background process status
* @returns {boolean} status.initialized has background process been initialized
* @returns {boolean} status.unlocked is background process unlocked
*/
this.add(RPCAction.GET_STATUS, async () => {
const { initialized, unlocked } = await LockService.getStatus()
return {
initialized,
unlocked
}
})
// requests
this.add(RPCAction.GET_PENDING_REQUESTS, LockService.ensure, this.requestManager.getRequests)
this.add(RPCAction.FINALIZE_REQUEST, LockService.ensure, this.requestManager.finalizeRequest)
// web3
this.add(RPCAction.CONNECT_METAMASK, LockService.ensure, this.metamaskService.connectMetamask)
this.add(RPCAction.GET_WALLET_INFO, this.metamaskService.getWalletInfo)
// lock
this.add(RPCAction.SETUP_PASSWORD, (payload: string) => LockService.setupPassword(payload))
// identites
this.add(
RPCAction.CREATE_IDENTITY,
LockService.ensure,
this.metamaskService.ensure,
async (payload: NewIdentityRequest) => {
try {
const { strategy, options } = payload
if (!strategy) throw new Error('strategy not provided')
const numOfIdentites = this.identityService.getNumOfIdentites()
const config: any = {
...options,
name: options?.name || `Account # ${numOfIdentites}`
}
if (strategy === 'interrep') {
const web3: Web3 = await this.metamaskService.getWeb3()
const walletInfo: WalletInfo | null = await this.metamaskService.getWalletInfo()
config.web3 = web3
config.walletInfo = walletInfo
}
const identity: ZkIdentityWrapper | undefined = await identityFactory(strategy, config)
if (!identity) {
throw new Error('Identity not created, make sure to check strategy')
}
await this.identityService.insert(identity)
return true
} catch (error: any) {
throw new Error(error.message)
}
}
)
this.add(RPCAction.GET_COMMITMENTS, LockService.ensure, this.identityService.getIdentityCommitments)
this.add(RPCAction.GET_IDENTITIES, LockService.ensure, this.identityService.getIdentities)
this.add(RPCAction.SET_ACTIVE_IDENTITY, LockService.ensure, this.identityService.setActiveIdentity)
this.add(RPCAction.GET_ACTIVE_IDENTITY, LockService.ensure, async () => {
const identity = await this.identityService.getActiveidentity()
if (!identity) {
return null
}
const identityCommitment: bigint = identity.genIdentityCommitment()
const identityCommitmentHex = bigintToHex(identityCommitment)
return identityCommitmentHex
})
// protocols
this.add(
RPCAction.SEMAPHORE_PROOF,
LockService.ensure,
this.zkValidator.validateZkInputs,
async (payload: SemaphoreProofRequest, meta: any) => {
const { unlocked } = await LockService.getStatus()
if (!unlocked) {
await BrowserUtils.openPopup()
await LockService.awaitUnlock()
}
const identity: ZkIdentityWrapper | undefined = await this.identityService.getActiveidentity()
const approved: boolean = await this.approvalService.isApproved(meta.origin)
const perm: any = await this.approvalService.getPermission(meta.origin)
if (!identity) throw new Error('active identity not found')
if (!approved) throw new Error(`${meta.origin} is not approved`)
try {
if (!perm.noApproval) {
await this.requestManager.newRequest(PendingRequestType.SEMAPHORE_PROOF, {
...payload,
origin: meta.origin
})
}
await BrowserUtils.closePopup()
const proof: SemaphoreProof = await this.semaphoreService.genProof(identity.zkIdentity, payload)
return proof
} catch (err) {
await BrowserUtils.closePopup()
throw err
}
}
)
this.add(
RPCAction.RLN_PROOF,
LockService.ensure,
this.zkValidator.validateZkInputs,
async (payload: RLNProofRequest) => {
const identity: ZkIdentityWrapper | undefined = await this.identityService.getActiveidentity()
if (!identity) throw new Error('active identity not found')
const proof: RLNFullProof = await this.rlnService.genProof(identity.zkIdentity, payload)
return proof
}
)
// injecting
this.add(RPCAction.TRY_INJECT, async (payload: any) => {
const { origin }: { origin: string } = payload
if (!origin) throw new Error('Origin not provided')
const { unlocked } = await LockService.getStatus()
if (!unlocked) {
await BrowserUtils.openPopup()
await LockService.awaitUnlock()
}
const includes: boolean = await this.approvalService.isApproved(origin)
if (includes) return true
try {
await this.requestManager.newRequest(PendingRequestType.INJECT, { origin })
return true
} catch (e) {
console.error(e)
return false
}
})
this.add(RPCAction.APPROVE_HOST, LockService.ensure, async (payload: any) => {
this.approvalService.add(payload)
})
this.add(RPCAction.IS_HOST_APPROVED, LockService.ensure, this.approvalService.isApproved)
this.add(RPCAction.REMOVE_HOST, LockService.ensure, this.approvalService.remove)
this.add(RPCAction.GET_HOST_PERMISSIONS, LockService.ensure, async (payload: any) => this.approvalService.getPermission(payload))
this.add(RPCAction.SET_HOST_PERMISSIONS, LockService.ensure, async (payload: any) => {
const { host, ...permissions } = payload
return this.approvalService.setPermission(host, permissions)
})
this.add(RPCAction.CLOSE_POPUP, async () => BrowserUtils.closePopup())
this.add(RPCAction.CREATE_IDENTITY_REQ, LockService.ensure, this.metamaskService.ensure, async () => {
const res: any = await this.requestManager.newRequest(PendingRequestType.CREATE_IDENTITY, { origin })
const { provider, options } = res
return this.handle({
method: RPCAction.CREATE_IDENTITY,
payload: {
strategy: provider,
options
}
})
})
// dev
this.add(RPCAction.CLEAR_APPROVED_HOSTS, this.approvalService.empty)
this.add(RPCAction.DUMMY_REQUEST, async () =>
this.requestManager.newRequest(PendingRequestType.DUMMY, 'hello from dummy')
)
return this
}
}

View File

@@ -0,0 +1,70 @@
import { browser } from 'webextension-polyfill-ts'
import { ActionType as IdentityActionType } from '@src/ui/ducks/identities'
import { ActionType as AppActionType } from '@src/ui/ducks/app'
;
(async function () {
try {
const url = browser.runtime.getURL('js/injected.js')
const container = document.head || document.documentElement
const scriptTag = document.createElement('script')
scriptTag.src = url
scriptTag.setAttribute('async', 'false')
container.insertBefore(scriptTag, container.children[0])
container.removeChild(scriptTag)
window.addEventListener('message', async (event) => {
const { data } = event
if (data && data.target === 'injected-contentscript') {
const res = await browser.runtime.sendMessage(data.message)
window.postMessage(
{
target: 'injected-injectedscript',
payload: res,
nonce: data.nonce
},
'*'
)
}
})
browser.runtime.onMessage.addListener((action) => {
switch (action.type) {
case IdentityActionType.SET_SELECTED:
window.postMessage(
{
target: 'injected-injectedscript',
payload: [null, action.payload],
nonce: 'identityChanged'
},
'*'
)
return
case AppActionType.SET_STATUS:
if (!action.payload.unlocked) {
window.postMessage(
{
target: 'injected-injectedscript',
payload: [null],
nonce: 'logout'
},
'*'
)
} else {
window.postMessage(
{
target: 'injected-injectedscript',
payload: [null],
nonce: 'login'
},
'*'
)
}
}
})
} catch (e) {
console.error('error occured', e)
}
})()

View File

@@ -0,0 +1,307 @@
/* eslint-disable @typescript-eslint/no-use-before-define */
import { MerkleProofArtifacts } from '@src/types'
import RPCAction from '@src/util/constants'
import { MerkleProof } from '@zk-kit/protocols'
export type IRequest = {
method: string
payload?: any
error?: boolean
meta?: any
}
const promises: {
[k: string]: {
resolve: Function
reject: Function
}
} = {}
let nonce = 0
async function getIdentityCommitments() {
return post({
method: RPCAction.GET_COMMITMENTS
})
}
async function getActiveIdentity() {
return post({
method: RPCAction.GET_ACTIVE_IDENTITY
})
}
async function getHostPermissions(host: string) {
return post({
method: RPCAction.GET_HOST_PERMISSIONS,
payload: host
})
}
async function setHostPermissions(
host: string,
permissions?: {
noApproval?: boolean
}
) {
return post({
method: RPCAction.SET_HOST_PERMISSIONS,
payload: {
host,
...permissions
}
})
}
async function createIdentity() {
try {
const res = await post({
method: RPCAction.CREATE_IDENTITY_REQ
})
await post({ method: RPCAction.CLOSE_POPUP })
return res
} catch (e) {
await post({ method: RPCAction.CLOSE_POPUP })
throw e
}
}
async function createDummyRequest() {
try {
const res = await post({
method: RPCAction.DUMMY_REQUEST
})
await post({ method: RPCAction.CLOSE_POPUP })
return res
} catch (e) {
await post({ method: RPCAction.CLOSE_POPUP })
throw e
}
}
async function semaphoreProof(
externalNullifier: string,
signal: string,
circuitFilePath: string,
zkeyFilePath: string,
merkleProofArtifactsOrStorageAddress: string | MerkleProofArtifacts,
merkleProof?: MerkleProof
) {
const merkleProofArtifacts =
typeof merkleProofArtifactsOrStorageAddress === 'string' ? undefined : merkleProofArtifactsOrStorageAddress
const merkleStorageAddress =
typeof merkleProofArtifactsOrStorageAddress === 'string' ? merkleProofArtifactsOrStorageAddress : undefined
return post({
method: RPCAction.SEMAPHORE_PROOF,
payload: {
externalNullifier,
signal,
merkleStorageAddress,
circuitFilePath,
zkeyFilePath,
merkleProofArtifacts,
merkleProof
}
})
}
async function rlnProof(
externalNullifier: string,
signal: string,
circuitFilePath: string,
zkeyFilePath: string,
merkleProofArtifactsOrStorageAddress: string | MerkleProofArtifacts,
rlnIdentifier: string
) {
const merkleProofArtifacts =
typeof merkleProofArtifactsOrStorageAddress === 'string' ? undefined : merkleProofArtifactsOrStorageAddress
const merkleStorageAddress =
typeof merkleProofArtifactsOrStorageAddress === 'string' ? merkleProofArtifactsOrStorageAddress : undefined
return post({
method: RPCAction.RLN_PROOF,
payload: {
externalNullifier,
signal,
merkleStorageAddress,
circuitFilePath,
zkeyFilePath,
merkleProofArtifacts,
rlnIdentifier
}
})
}
// dev-only
async function clearApproved() {
return post({
method: RPCAction.CLEAR_APPROVED_HOSTS
})
}
/**
* Open Popup
*/
async function openPopup() {
return post({
method: 'OPEN_POPUP'
})
}
async function tryInject(origin: string) {
return post({
method: RPCAction.TRY_INJECT,
payload: { origin }
})
}
async function addHost(host: string) {
return post({
method: RPCAction.APPROVE_HOST,
payload: { host }
})
}
const EVENTS: {
[eventName: string]: ((data: unknown) => void)[]
} = {}
const on = (eventName: string, cb: (data: unknown) => void) => {
const bucket = EVENTS[eventName] || []
bucket.push(cb)
EVENTS[eventName] = bucket
}
const off = (eventName: string, cb: (data: unknown) => void) => {
const bucket = EVENTS[eventName] || []
EVENTS[eventName] = bucket.filter((callback) => callback === cb)
}
const emit = (eventName: string, payload?: any) => {
const bucket = EVENTS[eventName] || []
for (const cb of bucket) {
cb(payload)
}
}
/**
* Injected Client
*/
const client = {
openPopup,
getIdentityCommitments,
getActiveIdentity,
createIdentity,
getHostPermissions,
setHostPermissions,
semaphoreProof,
rlnProof,
on,
off,
// dev-only
clearApproved,
createDummyRequest
}
/**
* Connect to Extension
* @returns injected client
*/
// eslint-disable-next-line consistent-return
async function connect() {
let result
try {
const approved = await tryInject(window.location.origin)
if (approved) {
await addHost(window.location.origin)
result = client
}
} catch (err) {
// eslint-disable-next-line no-console
console.log('Err: ', err)
result = null
}
await post({ method: RPCAction.CLOSE_POPUP })
return result
}
declare global {
interface Window {
zkpr: {
connect: () => any
}
}
}
window.zkpr = {
connect
}
// Connect injected script messages with content script messages
async function post(message: IRequest) {
return new Promise((resolve, reject) => {
// eslint-disable-next-line no-plusplus
const messageNonce = nonce++
window.postMessage(
{
target: 'injected-contentscript',
message: {
...message,
meta: {
...message.meta,
origin: window.location.origin
},
type: message.method
},
nonce: messageNonce
},
'*'
)
promises[messageNonce] = { resolve, reject }
})
}
window.addEventListener('message', (event) => {
const { data } = event
if (data && data.target === 'injected-injectedscript') {
if (data.nonce === 'identityChanged') {
const [err, res] = data.payload
emit('identityChanged', res)
return
}
if (data.nonce === 'logout') {
const [err, res] = data.payload
emit('logout', res)
return
}
if (data.nonce === 'login') {
const [err, res] = data.payload
emit('login', res)
return
}
if (!promises[data.nonce]) return
const [err, res] = data.payload
const { resolve, reject } = promises[data.nonce]
if (err) {
// eslint-disable-next-line consistent-return
return reject(new Error(err))
}
resolve(res)
delete promises[data.nonce]
}
})

14
src/custom.d.ts vendored Normal file
View File

@@ -0,0 +1,14 @@
declare module '*.svg' {
const content: string
export default content
}
declare module '*.png' {
const content: string
export default content
}
declare module '*.gif' {
const content: string
export default content
}

2914
src/static/chains.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="margin: auto; background: none; display: block; shape-rendering: auto;" width="200px" height="200px" viewBox="0 0 100 100" preserveAspectRatio="xMidYMid">
<circle cx="50" cy="50" fill="none" stroke="#ffffff" stroke-width="10" r="35" stroke-dasharray="164.93361431346415 56.97787143782138">
<animateTransform attributeName="transform" type="rotate" repeatCount="indefinite" dur="1s" values="0 50 50;360 50 50" keyTimes="0;1"></animateTransform>
</circle>
<!-- [ldio] generated by https://loading.io/ --></svg>

After

Width:  |  Height:  |  Size: 639 B

View File

@@ -0,0 +1,6 @@
<svg width="160" height="160" viewBox="0 0 160 160" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M68.3781 22.0343H24.0643V19H73.0223V76.0773H23V73.043L68.3781 22.0343ZM69.4424 73.043V25.8886L27.4507 73.043H69.4424Z" fill="#94FEBF"/>
<path d="M108.941 42.7822L137 76.0773H82.8172V19H137L108.941 42.7822ZM106.425 44.9144L86.3972 61.89V73.043H130.13L106.425 44.9144Z" fill="#94FEBF"/>
<path d="M25.6124 141V83.9227H53.7681C58.4768 83.9227 62.5405 84.6608 65.9592 86.1369C69.8939 87.8317 73.0223 90.2646 75.3444 93.4356C77.6665 96.5519 78.8276 99.9962 78.8276 103.769C78.8276 107.541 77.6665 111.013 75.3444 114.184C73.0868 117.354 69.9907 119.787 66.056 121.482C62.7018 122.958 58.6058 123.696 53.7681 123.696H43.2218V141H25.6124Z" fill="#94FEBF"/>
<path d="M135.839 141H84.075V83.9227H110.296C115.907 83.9227 120.487 84.7701 124.035 86.4649C127.518 88.1051 130.227 90.3466 132.162 93.1896C134.097 96.0325 135.065 99.1761 135.065 102.62C135.065 106.885 133.517 110.712 130.421 114.101C127.325 117.436 123.422 119.651 118.713 120.744L135.839 141ZM90.1706 86.957L116.488 118.12C120.81 117.354 124.39 115.523 127.228 112.625C130.066 109.728 131.485 106.393 131.485 102.62C131.485 99.6135 130.614 96.8799 128.873 94.4197C127.131 91.9594 124.68 90.0733 121.519 88.7611C118.681 87.5584 114.972 86.957 110.392 86.957H90.1706ZM87.655 88.9252V118.366H112.521L87.655 88.9252ZM87.655 137.966H129.066L114.94 121.318L111.94 121.4H87.655V137.966Z" fill="#94FEBF"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

92
src/types/index.ts Normal file
View File

@@ -0,0 +1,92 @@
import { MerkleProof } from '@zk-kit/protocols'
export type Request = {
method: string
payload?: any
error?: boolean
meta?: any
}
export type WalletInfo = {
account: string
networkType: string
chainId: number
}
export type CreateInterrepIdentityMetadata = {
web2Provider: 'Twitter' | 'Reddit' | 'Github'
nonce?: number
name?: string
}
export type CreateRandomIdentityMetadata = {
name?: string
}
export type CreateIdentityMetadata = CreateInterrepIdentityMetadata | CreateRandomIdentityMetadata
export type CreateIdentityStrategy = 'interrep' | 'random'
export type NewIdentityRequest = {
strategy: CreateIdentityStrategy
options: any
}
export type MerkleProofArtifacts = {
leaves: string[]
depth: number
leavesPerNode: number
}
export type ZkInputs = {
circuitFilePath: string
zkeyFilePath: string
merkleStorageAddress?: string
merkleProofArtifacts?: MerkleProofArtifacts
merkleProof?: MerkleProof
}
export enum PendingRequestType {
SEMAPHORE_PROOF,
DUMMY,
APPROVE,
INJECT,
CREATE_IDENTITY
}
export type PendingRequest = {
id: string
type: PendingRequestType
payload?: any
}
export type RequestResolutionAction<data> = {
id: string
status: 'accept' | 'reject'
data?: data
}
export type FinalizedRequest = {
id: string
action: boolean
}
export type ApprovalAction = {
host: string
action: 'add' | 'remove'
}
export type IdentityMetadata = {
account: string
name: string
provider: string
}
export type SerializedIdentity = {
metadata: IdentityMetadata
secret: string
}
export enum ZkProofType {
SEMAPHORE,
RLN
}

View File

@@ -0,0 +1,97 @@
@import './src/util/variables';
.button {
@extend %regular-font;
@extend %bold;
@extend %row-nowrap;
align-items: center;
width: fit-content;
cursor: pointer;
padding: 0.5rem 1rem;
border-radius: 0.25rem;
border: none;
user-select: none;
outline: none;
position: relative;
height: 2.375rem;
font-weight: 600;
.icon {
margin-right: 0.5rem;
}
&__loader {
width: 2rem;
height: 2rem;
}
&--small {
padding: 0.25rem 0.75rem;
height: auto;
}
&--tiny {
font-size: 0.6875rem;
padding: 0.125rem 0.25rem;
font-weight: 600;
height: auto;
}
}
.button--primary {
background-color: $primary-green;
color: $black;
border: 2px solid $primary-green;
transition: background-color 150ms ease-in-out;
&:hover {
background-color: lighten($primary-green, 5);
border: 2px solid lighten($primary-green, 5);
}
&:active {
background-color: darken($primary-green, 5);
border: 2px solid darken($primary-green, 5);
}
&:disabled {
&:hover,
&:active {
background-color: $primary-green;
color: $black;
border: 2px solid $primary-green;
}
}
}
.button--secondary {
box-sizing: border-box;
background-color: transparent;
color: $primary-green;
border: 2px solid $primary-green;
transition: background-color 150ms ease-in-out, color 150ms ease-in-out;
&:hover {
background-color: rgba($primary-green, 0.5);
color: $white;
}
&:active {
background-color: rgba($primary-green, 8);
color: $white;
}
&:disabled {
&:hover,
&:active {
background-color: transparent;
color: $primary-green;
border: 2px solid $primary-green;
}
}
}
.button:disabled {
opacity: 0.5;
cursor: default;
}

View File

@@ -0,0 +1,41 @@
/* eslint-disable react/jsx-props-no-spreading */
/* eslint-disable react/button-has-type */
/* eslint-disable react/function-component-definition */
import React, { ButtonHTMLAttributes, ReactElement } from 'react'
import classNames from 'classnames'
import './button.scss'
import Icon from '@src/ui/components/Icon'
import LoaderGIF from '../../../static/icons/loader.svg'
export enum ButtonType {
primary,
secondary
}
export type ButtonProps = {
className?: string
loading?: boolean
btnType?: ButtonType
small?: boolean
tiny?: boolean
} & ButtonHTMLAttributes<HTMLButtonElement>
export default function Button(props: ButtonProps): ReactElement {
const { className, loading, children, btnType = ButtonType.primary, small, tiny, ...btnProps } = props
return (
<button
className={classNames('button', className, {
'button--small': small,
'button--tiny': tiny,
'button--loading': loading,
'button--primary': btnType === ButtonType.primary,
'button--secondary': btnType === ButtonType.secondary
})}
{...btnProps}
>
{loading && <Icon className="button__loader" url={LoaderGIF} size={2} />}
{!loading && children}
</button>
)
}

View File

@@ -0,0 +1,46 @@
@import './src/util/variables';
.checkbox {
position: relative;
height: 1.25rem;
width: 1.25rem;
border-radius: 0.1875rem;
cursor: pointer;
transition: background-color 200ms ease-in-out, border-color 200ms ease-in-out, color 200ms ease-in-out;
opacity: 1;
border: 2px solid $gray-800;
input {
position: absolute;
top: 0;
left: 0;
height: 100%;
width: 100%;
cursor: pointer;
margin: 0;
padding: 0;
opacity: 0;
z-index: 200;
}
.icon {
font-size: 0.75rem;
position: absolute;
top: 0.125rem;
left: 0.125rem;
color: transparent;
}
&:hover {
border-color: $gray-600;
}
&--checked {
background-color: $primary-blue;
border-color: $primary-blue;
.icon {
color: $white;
}
}
}

View File

@@ -0,0 +1,28 @@
/* eslint-disable react/require-default-props */
/* eslint-disable react/function-component-definition */
import React, { ChangeEventHandler, ReactElement } from 'react'
import c from 'classnames'
import './index.scss'
import Icon from '@src/ui/components/Icon'
type Props = {
checked: boolean
onChange: ChangeEventHandler<HTMLInputElement>
className?: string
disabled?: boolean
}
export default function Checkbox(props: Props): ReactElement {
const { className, checked, onChange, disabled } = props
return (
<div
className={c('checkbox', className, {
'checkbox--checked': checked
})}
>
<input type="checkbox" checked={checked} onChange={onChange} disabled={disabled} />
<Icon fontAwesome="fa-check" />
</div>
)
}

View File

@@ -0,0 +1,16 @@
.confirm-modal {
@media only screen and (min-width: 358px) {
min-height: 36rem;
}
}
.semaphore-proof__file {
font-size: 0.75rem;
margin: 0.5rem;
.icon {
font-size: 1rem !important;
cursor: pointer;
margin-top: 0.25rem;
}
}

View File

@@ -0,0 +1,449 @@
import React, { ReactElement, useCallback, useEffect, useState } from 'react'
import { useDispatch } from 'react-redux'
import FullModal, { FullModalContent, FullModalFooter, FullModalHeader } from '@src/ui/components/FullModal'
import Button, { ButtonType } from '@src/ui/components/Button'
import { useRequestsPending } from '@src/ui/ducks/requests'
import { PendingRequest, PendingRequestType, RequestResolutionAction } from '@src/types'
import RPCAction from '@src/util/constants'
import postMessage from '@src/util/postMessage'
import './confirm-modal.scss'
import Input from '@src/ui/components/Input'
import Dropdown from '@src/ui/components/Dropdown'
import Icon from '@src/ui/components/Icon'
import copy from 'copy-to-clipboard'
import Checkbox from '@src/ui/components/Checkbox'
import { getLinkPreview } from 'link-preview-js'
export default function ConfirmRequestModal(): ReactElement {
const pendingRequests = useRequestsPending()
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const dispatch = useDispatch()
const [activeIndex, setActiveIndex] = useState(0)
const pendingRequest = pendingRequests[activeIndex]
const reject = useCallback(
async (err?: any) => {
setLoading(true)
try {
const id = pendingRequest?.id
const req: RequestResolutionAction<undefined> = {
id,
status: 'reject',
data: err
}
await postMessage({
method: RPCAction.FINALIZE_REQUEST,
payload: req
})
} catch (e: any) {
setError(e.message)
} finally {
setLoading(false)
}
},
[pendingRequest]
)
const approve = useCallback(
async (data?: any) => {
setLoading(true)
try {
const id = pendingRequest?.id
const req: RequestResolutionAction<undefined> = {
id,
status: 'accept',
data
}
await postMessage({
method: RPCAction.FINALIZE_REQUEST,
payload: req
})
} catch (e: any) {
setError(e.message)
} finally {
setLoading(false)
}
},
[pendingRequest]
)
if (!pendingRequest) return <></>
switch (pendingRequest.type) {
case PendingRequestType.INJECT:
return (
<ConnectionApprovalModal
len={pendingRequests.length}
pendingRequest={pendingRequest}
accept={() => approve()}
reject={() => reject()}
error={error}
loading={loading}
/>
)
case PendingRequestType.SEMAPHORE_PROOF:
return (
<ProofModal
len={pendingRequests.length}
pendingRequest={pendingRequest}
accept={() => approve()}
reject={() => reject()}
error={error}
loading={loading}
/>
)
case PendingRequestType.DUMMY:
return (
<DummyApprovalModal
len={pendingRequests.length}
pendingRequest={pendingRequest}
accept={() => approve()}
reject={() => reject()}
error={error}
loading={loading}
/>
)
case PendingRequestType.CREATE_IDENTITY:
return (
<CreateIdentityApprovalModal
len={pendingRequests.length}
pendingRequest={pendingRequest}
accept={approve}
reject={reject}
error={error}
loading={loading}
/>
)
default:
return (
<DefaultApprovalModal
len={pendingRequests.length}
pendingRequest={pendingRequest}
accept={approve}
reject={reject}
error={error}
loading={loading}
/>
)
}
}
var ConnectionApprovalModal = function(props: {
len: number
reject: () => void
accept: () => void
loading: boolean
error: string
pendingRequest: PendingRequest
}) {
const origin = props.pendingRequest.payload?.origin
const [checked, setChecked] = useState(false)
useEffect(() => {
;(async () => {
if (origin) {
const res = await postMessage({
method: RPCAction.GET_HOST_PERMISSIONS,
payload: origin
})
setChecked(res?.noApproval)
}
})()
}, [origin])
const [faviconUrl, setFaviconUrl] = useState('')
useEffect(() => {
;(async () => {
if (origin) {
const data = await getLinkPreview(origin)
const [favicon] = data?.favicons || []
setFaviconUrl(favicon)
}
})()
}, [origin])
const setApproval = useCallback(
async (noApproval: boolean) => {
const res = await postMessage({
method: RPCAction.SET_HOST_PERMISSIONS,
payload: {
host: origin,
noApproval
}
})
setChecked(res?.noApproval)
},
[origin]
)
return (
<FullModal className="confirm-modal" onClose={() => null}>
<FullModalHeader>
Connect with ZK Keeper
{props.len > 1 && <div className="flex-grow flex flex-row justify-end">{`1 of ${props.len}`}</div>}
</FullModalHeader>
<FullModalContent className="flex flex-col items-center">
<div className="w-16 h-16 rounded-full my-6 border border-gray-800 p-2 flex-shrink-0">
<div
className="w-16 h-16"
style={{
backgroundSize: 'contain',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
backgroundImage: `url(${faviconUrl})`
}}
/>
</div>
<div className="text-lg font-semibold mb-2 text-center">
{`${origin} would like to connect to your identity`}
</div>
<div className="text-sm text-gray-500 text-center">
This site is requesting access to view your current identity. Always make sure you trust the site
you interact with.
</div>
<div className="font-bold mt-4">Permissions</div>
<div className="flex flex-row items-start">
<Checkbox
className="mr-2 mt-2 flex-shrink-0"
checked={checked}
onChange={(e) => {
setApproval(e.target.checked)
}}
/>
<div className="text-sm mt-2">Allow host to create proof without approvals</div>
</div>
</FullModalContent>
{props.error && <div className="text-xs text-red-500 text-center pb-1">{props.error}</div>}
<FullModalFooter>
<Button btnType={ButtonType.secondary} onClick={props.reject} loading={props.loading}>
Reject
</Button>
<Button className="ml-2" onClick={props.accept} loading={props.loading}>
Approve
</Button>
</FullModalFooter>
</FullModal>
)
}
var DummyApprovalModal = function(props: {
len: number
reject: () => void
accept: () => void
loading: boolean
error: string
pendingRequest: PendingRequest
}) {
const {payload} = props.pendingRequest
return (
<FullModal className="confirm-modal" onClose={() => null}>
<FullModalHeader>
Dummy Request
{props.len > 1 && <div className="flex-grow flex flex-row justify-end">{`1 of ${props.len}`}</div>}
</FullModalHeader>
<FullModalContent className="flex flex-col">
<div className="text-sm font-semibold mb-2">{payload}</div>
</FullModalContent>
{props.error && <div className="text-xs text-red-500 text-center pb-1">{props.error}</div>}
<FullModalFooter>
<Button btnType={ButtonType.secondary} onClick={props.reject} loading={props.loading}>
Reject
</Button>
<Button className="ml-2" onClick={props.accept} loading={props.loading}>
Approve
</Button>
</FullModalFooter>
</FullModal>
)
}
var CreateIdentityApprovalModal = function(props: {
len: number
reject: (error?: any) => void
accept: (data?: any) => void
loading: boolean
error: string
pendingRequest: PendingRequest
}) {
const [nonce, setNonce] = useState(0)
const [identityType, setIdentityType] = useState<'InterRep' | 'Random'>('InterRep')
const [web2Provider, setWeb2Provider] = useState<'Twitter' | 'Github' | 'Reddit'>('Twitter')
const create = useCallback(async () => {
let options: any = {
nonce,
web2Provider
}
let provider = 'interrep'
if (identityType === 'Random') {
provider = 'random'
options = {}
}
props.accept({
provider,
options
})
}, [nonce, web2Provider, identityType, props.accept])
return (
<FullModal className="confirm-modal" onClose={() => null}>
<FullModalHeader>
Create Identity
{props.len > 1 && <div className="flex-grow flex flex-row justify-end">{`1 of ${props.len}`}</div>}
</FullModalHeader>
<FullModalContent>
<Dropdown
className="my-2"
label="Identity type"
options={[{ value: 'InterRep' }, { value: 'Random' }]}
onChange={(e) => {
setIdentityType(e.target.value as any)
}}
value={identityType}
/>
{identityType === 'InterRep' && (
<>
<Dropdown
className="my-2"
label="Web2 Provider"
options={[{ value: 'Twitter' }, { value: 'Reddit' }, { value: 'Github' }]}
onChange={(e) => {
setWeb2Provider(e.target.value as any)
}}
value={web2Provider}
/>
<Input
className="my-2"
type="number"
label="Nonce"
step={1}
defaultValue={nonce}
onChange={(e) => setNonce(Number(e.target.value))}
/>
</>
)}
</FullModalContent>
{props.error && <div className="text-xs text-red-500 text-center pb-1">{props.error}</div>}
<FullModalFooter>
<Button btnType={ButtonType.secondary} onClick={() => props.reject()} loading={props.loading}>
Reject
</Button>
<Button className="ml-2" onClick={create} loading={props.loading}>
Approve
</Button>
</FullModalFooter>
</FullModal>
)
}
var DefaultApprovalModal = function(props: {
len: number
reject: () => void
accept: () => void
loading: boolean
error: string
pendingRequest: PendingRequest
}) {
return (
<FullModal className="confirm-modal" onClose={() => null}>
<FullModalHeader>
Unhandled Request
{props.len > 1 && <div className="flex-grow flex flex-row justify-end">{`1 of ${props.len}`}</div>}
</FullModalHeader>
<FullModalContent className="flex flex-col">
<div className="text-sm font-semibold mb-2 break-all">{JSON.stringify(props.pendingRequest)}</div>
</FullModalContent>
{props.error && <div className="text-xs text-red-500 text-center pb-1">{props.error}</div>}
<FullModalFooter>
<Button btnType={ButtonType.secondary} onClick={props.reject} loading={props.loading}>
Reject
</Button>
<Button className="ml-2" onClick={props.accept} loading={props.loading} disabled>
Approve
</Button>
</FullModalFooter>
</FullModal>
)
}
var ProofModal = function(props: {
len: number
reject: () => void
accept: () => void
loading: boolean
error: string
pendingRequest: PendingRequest
}) {
const { circuitFilePath, externalNullifier, merkleProof, signal, zkeyFilePath, origin } =
props.pendingRequest?.payload || {}
const [faviconUrl, setFaviconUrl] = useState('')
useEffect(() => {
;(async () => {
if (origin) {
const data = await getLinkPreview(origin)
const [favicon] = data?.favicons || []
setFaviconUrl(favicon)
}
})()
}, [origin])
return (
<FullModal className="confirm-modal" onClose={() => null}>
<FullModalHeader>
Generate Semaphore Proof
{props.len > 1 && <div className="flex-grow flex flex-row justify-end">{`1 of ${props.len}`}</div>}
</FullModalHeader>
<FullModalContent className="flex flex-col items-center">
<div className="w-16 h-16 rounded-full my-6 border border-gray-800 p-2 flex-shrink-0">
<div
className="w-16 h-16"
style={{
backgroundSize: 'contain',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
backgroundImage: `url(${faviconUrl}`
}}
/>
</div>
<div className="text-lg font-semibold mb-2 text-center">
{`${origin} is requesting a semaphore proof`}
</div>
<div className="semaphore-proof__files flex flex-row items-center mb-2">
<div className="semaphore-proof__file">
<div className="semaphore-proof__file__title">Circuit</div>
<Icon fontAwesome="fas fa-link" onClick={() => window.open(circuitFilePath, '_blank')} />
</div>
<div className="semaphore-proof__file">
<div className="semaphore-proof__file__title">ZKey</div>
<Icon fontAwesome="fas fa-link" onClick={() => window.open(zkeyFilePath, '_blank')} />
</div>
<div className="semaphore-proof__file">
<div className="semaphore-proof__file__title">Merkle</div>
{typeof merkleProof === 'string' ? (
<Icon fontAwesome="fas fa-link" onClick={() => window.open(merkleProof, '_blank')} />
) : (
<Icon fontAwesome="fas fa-copy" onClick={() => copy(JSON.stringify(merkleProof))} />
)}
</div>
</div>
<Input className="w-full mb-2" label="External Nullifier" value={externalNullifier} />
<Input className="w-full mb-2" label="Signal" value={signal} />
</FullModalContent>
{props.error && <div className="text-xs text-red-500 text-center pb-1">{props.error}</div>}
<FullModalFooter>
<Button btnType={ButtonType.secondary} onClick={props.reject} loading={props.loading}>
Reject
</Button>
<Button className="ml-2" onClick={props.accept} loading={props.loading}>
Approve
</Button>
</FullModalFooter>
</FullModal>
)
}

View File

@@ -0,0 +1,127 @@
import FullModal, { FullModalContent, FullModalFooter, FullModalHeader } from '@src/ui/components/FullModal'
import React, { useCallback, useEffect, useState } from 'react'
import { browser } from 'webextension-polyfill-ts'
import Button, { ButtonType } from '@src/ui/components/Button'
import Icon from '@src/ui/components/Icon'
import postMessage from '@src/util/postMessage'
import RPCAction from '@src/util/constants'
import Checkbox from '@src/ui/components/Checkbox'
import { getLinkPreview } from 'link-preview-js'
export default function ConnectionModal(props: { onClose: () => void; refreshConnectionStatus: () => void }) {
const { onClose, refreshConnectionStatus } = props
const [checked, setChecked] = useState(false)
const [url, setUrl] = useState<URL>()
const [faviconUrl, setFaviconUrl] = useState('')
useEffect(() => {
;(async function onConnectionModalMount() {
try {
const tabs = await browser.tabs.query({
active: true,
lastFocusedWindow: true
})
const [tab] = tabs || []
if (tab?.url) {
setUrl(new URL(tab.url))
}
} catch (e) {}
})()
}, [])
useEffect(() => {
;(async () => {
if (url?.origin) {
const res = await postMessage({
method: RPCAction.GET_HOST_PERMISSIONS,
payload: url?.origin
})
setChecked(res?.noApproval)
}
})()
}, [url])
useEffect(() => {
;(async () => {
if (url?.origin) {
const data = await getLinkPreview(url?.origin)
const [favicon] = data?.favicons || []
setFaviconUrl(favicon)
}
})()
}, [url])
const onRemoveHost = useCallback(async () => {
await postMessage({
method: RPCAction.REMOVE_HOST,
payload: {
host: url?.origin
}
})
await refreshConnectionStatus()
props.onClose()
}, [url?.origin])
const setApproval = useCallback(
async (noApproval: boolean) => {
const res = await postMessage({
method: RPCAction.SET_HOST_PERMISSIONS,
payload: {
host: url?.origin,
noApproval
}
})
setChecked(res?.noApproval)
},
[url?.origin]
)
return (
<FullModal onClose={onClose}>
<FullModalHeader onClose={onClose}>
{url?.protocol === 'chrome-extension:' ? 'Chrome Extension Page' : url?.host}
</FullModalHeader>
<FullModalContent className="flex flex-col items-center">
{url?.protocol === 'chrome-extension:' ? (
<div className="w-16 h-16 rounded-full my-6 border border-gray-800 p-2 flex-shrink-0 flex flex-row items-center justify-center">
<Icon fontAwesome="fas fa-tools" size={1.5} className="text-gray-700" />
</div>
) : (
<div className="w-16 h-16 rounded-full my-6 border border-gray-800 p-2 flex-shrink-0 flex flex-row items-center justify-center">
<div
className="w-16 h-16"
style={{
backgroundSize: 'contain',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
backgroundImage: `url(${faviconUrl})`
}}
/>
</div>
)}
<div className="font-bold">Permissions</div>
<div className="flex flex-row items-start">
<Checkbox
className="mr-2 mt-2 flex-shrink-0"
checked={checked}
onChange={(e) => {
setApproval(e.target.checked)
}}
/>
<div className="text-sm mt-2">Allow host to create proof without approvals</div>
</div>
</FullModalContent>
<FullModalFooter className="justify-center">
<Button className="ml-2" btnType={ButtonType.secondary} onClick={onRemoveHost}>
Disconnect
</Button>
<Button className="ml-2" onClick={props.onClose}>
Close
</Button>
</FullModalFooter>
</FullModal>
)
}

View File

@@ -0,0 +1,83 @@
import React, { ReactElement, useCallback, useState } from 'react'
import { useDispatch } from 'react-redux'
import { createIdentity } from '@src/ui/ducks/identities'
import FullModal, { FullModalContent, FullModalFooter, FullModalHeader } from '@src/ui/components/FullModal'
import Dropdown from '@src/ui/components/Dropdown'
import Input from '@src/ui/components/Input'
import Button from '@src/ui/components/Button'
export default function CreateIdentityModal(props: { onClose: () => void }): ReactElement {
const [nonce, setNonce] = useState(0)
const [identityType, setIdentityType] = useState<'InterRep' | 'Random'>('InterRep')
const [web2Provider, setWeb2Provider] = useState<'Twitter' | 'Github' | 'Reddit'>('Twitter')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const dispatch = useDispatch()
const create = useCallback(async () => {
setLoading(true)
try {
let options: any = {
nonce,
web2Provider
}
let provider = 'interrep'
if (identityType === 'Random') {
provider = 'random'
options = {}
}
await dispatch(createIdentity(provider, options))
props.onClose()
} catch (e: any) {
setError(e.message)
} finally {
setLoading(false)
}
}, [nonce, web2Provider, identityType])
return (
<FullModal onClose={props.onClose}>
<FullModalHeader onClose={props.onClose}>Create Identity</FullModalHeader>
<FullModalContent>
<Dropdown
className="my-2"
label="Identity type"
options={[{ value: 'InterRep' }, { value: 'Random' }]}
onChange={(e) => {
setIdentityType(e.target.value as any)
}}
value={identityType}
/>
{identityType === 'InterRep' && (
<>
<Dropdown
className="my-2"
label="Web2 Provider"
options={[{ value: 'Twitter' }, { value: 'Reddit' }, { value: 'Github' }]}
onChange={(e) => {
setWeb2Provider(e.target.value as any)
}}
value={web2Provider}
/>
<Input
className="my-2"
type="number"
label="Nonce"
step={1}
defaultValue={nonce}
onChange={(e) => setNonce(Number(e.target.value))}
/>
</>
)}
</FullModalContent>
{error && <div className="text-xs text-red-500 text-center pb-1">{error}</div>}
<FullModalFooter>
<Button onClick={create} loading={loading}>
Create
</Button>
</FullModalFooter>
</FullModal>
)
}

View File

@@ -0,0 +1,40 @@
@import '../../../util/variables';
.dropdown {
@extend %regular-font;
outline: none;
background-color: transparent;
border: none;
width: 100%;
color: $white;
font-family: 'Open Sans', -apple-system, BlinkMacSystemFont, Helvetica, Arial, sans-serif;
&__label {
@extend %small-font;
color: $label-text;
padding: 0 0.5rem 0.25rem;
cursor: default;
}
&__group {
@extend %row-nowrap;
background-color: lighten($black, 15);
border-bottom: 1px solid transparent;
border-radius: 0.125rem;
padding: 0.5rem 0.5rem;
cursor: pointer;
select {
flex: 1 1 auto;
background-color: transparent;
outline: none;
cursor: pointer;
}
}
&__error-message {
@extend %small-font;
color: $error-red;
padding: 0.25rem 0.5rem 0;
}
}

View File

@@ -0,0 +1,33 @@
/* eslint-disable @typescript-eslint/no-shadow */
/* eslint-disable react/destructuring-assignment */
/* eslint-disable react/jsx-props-no-spreading */
/* eslint-disable react/function-component-definition */
/* eslint-disable react/require-default-props */
import React, { InputHTMLAttributes, ReactElement } from 'react'
import './dropdown.scss'
import classNames from 'classnames'
type Props = {
label?: string
errorMessage?: string
options: { value: string; label?: string }[]
} & InputHTMLAttributes<HTMLSelectElement>
export default function Dropdown(props: Props): ReactElement {
const { label, errorMessage, className, ...selectProps } = props
return (
<div className={classNames('dropdown', className)}>
{label && <div className="dropdown__label">{label}</div>}
<div className="dropdown__group">
<select {...selectProps}>
{props.options.map(({ value, label }) => (
<option key={value} value={value}>
{label || value}
</option>
))}
</select>
</div>
{errorMessage && <div className="dropdown__error-message">{errorMessage}</div>}
</div>
)
}

View File

@@ -0,0 +1,52 @@
@import '../../../util/variables';
.full-modal {
@extend %col-nowrap;
overflow-x: hidden;
overflow-y: auto;
@media only screen and (min-width: 358px) {
border: 1px solid $gray-700;
max-width: 32rem;
min-height: 24rem;
max-height: 36rem;
}
@media only screen and (max-width: 357px) {
margin: 0;
width: 100vw;
height: 100vh;
}
&__header {
@extend %row-nowrap;
align-items: center;
padding: 1rem;
background-color: $gray-900;
border-bottom: 1px solid $gray-800;
&__content {
@extend %row-nowrap;
}
&__action {
}
}
&__content {
@extend %col-nowrap;
overflow-y: auto;
flex: 1 1 auto;
height: 0;
padding: 1rem;
}
&__footer {
@extend %row-nowrap;
align-items: center;
justify-content: end;
border-top: 1px solid $gray-800;
flex: 0 0 auto;
padding: 1rem 2rem;
}
}

View File

@@ -0,0 +1,41 @@
import React, { ReactElement, ReactNode } from 'react'
import Modal from '@src/ui/components/Modal'
import classNames from 'classnames'
import './full-modal.scss'
import Icon from '@src/ui/components/Icon'
type Props = {
onClose: () => void
className?: string
children: ReactNode
}
export default function FullModal(props: Props): ReactElement {
return (
<Modal className={classNames('full-modal', props.className)} onClose={props.onClose}>
{props.children}
</Modal>
)
}
export var FullModalHeader = function(props: {
className?: string
children: ReactNode
onClose?: () => void
}): ReactElement {
return (
<div className={classNames('full-modal__header', props.className)}>
<div className="text-xl flex-grow flex-shrink full-modal__header__content">{props.children}</div>
<div className="flex-grow-0 flex-shrink-0 full-modal__header__action">
{props.onClose && <Icon fontAwesome="fas fa-times" size={1.25} onClick={props.onClose} />}
</div>
</div>
)
}
export var FullModalContent = function(props: { className?: string; children: ReactNode }): ReactElement {
return <div className={classNames('full-modal__content', props.className)}>{props.children}</div>
}
export var FullModalFooter = function(props: { className?: string; children: ReactNode }): ReactElement {
return <div className={classNames('full-modal__footer', props.className)}>{props.children}</div>
}

View File

@@ -0,0 +1,42 @@
@import '../../../util/variables';
.header {
border-bottom: 1px solid $gray-800;
flex: 0 0 auto;
&__network-type {
padding: 0.5rem 0.75rem;
background-color: $gray-800;
color: $gray-200;
font-weight: 600;
margin-right: 0.5rem;
}
&__account-icon {
@extend %row-nowrap;
align-items: center;
justify-content: center;
height: 2.375rem;
width: 2.375rem;
border-radius: 50%;
border: 3px solid $gray-600;
cursor: pointer;
&:hover {
border-color: $gray-300;
}
.paper {
flex: 0 0 auto;
}
}
}
.user-menu {
.menuable {
&__menu {
right: 0;
left: auto;
}
}
}

View File

@@ -0,0 +1,59 @@
import React, { ReactElement, useCallback } from 'react'
import Icon from '@src/ui/components/Icon'
import LogoSVG from '@src/static/icons/logo.svg'
import LoaderSVG from '@src/static/icons/loader.svg'
import { useAccount, useNetwork, useWeb3Connecting } from '@src/ui/ducks/web3'
import postMessage from '@src/util/postMessage'
import RPCAction from '@src/util/constants'
import Jazzicon, { jsNumberForAddress } from 'react-jazzicon'
import './header.scss'
import classNames from 'classnames'
import Menuable from '@src/ui/components/Menuable'
export default function Header(): ReactElement {
const network = useNetwork()
const account = useAccount()
const web3Connecting = useWeb3Connecting()
const connectMetamask = useCallback(async () => {
await postMessage({ method: RPCAction.CONNECT_METAMASK })
}, [])
const disconnect = useCallback(async () => {
await postMessage({ method: RPCAction.LOCK })
}, [])
return (
<div className="header h-16 flex flex-row items-center px-4">
<Icon url={LogoSVG} size={3} />
<div className="flex-grow flex flex-row items-center justify-end header__content">
{network && <div className="text-sm rounded-full header__network-type">{network?.name}</div>}
<div className="header__account-icon">
{account ? (
<Menuable
className="flex user-menu"
items={[
{
label: 'Logout',
onClick: disconnect
}
]}
>
<Jazzicon diameter={32} seed={jsNumberForAddress(account)} />
</Menuable>
) : (
<div onClick={connectMetamask}>
<Icon
fontAwesome={classNames({
'fas fa-plug': !web3Connecting
})}
url={web3Connecting ? LoaderSVG : undefined}
size={1.25}
/>
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,48 @@
@import './src/util/variables';
.icon {
@extend %row-nowrap;
align-items: center;
justify-content: center;
background-repeat: no-repeat;
background-size: contain;
background-position: center;
font-family: 'Font Awesome 5 Free';
user-select: none;
&__text {
@extend %row-nowrap;
align-items: center;
justify-content: center;
text-align: center;
color: $white;
font-weight: 400;
height: 80%;
width: 80%;
background-color: rgba(#000, 0.05);
border-radius: 50%;
}
&--disabled {
opacity: 0.5;
cursor: default;
}
&--clickable {
@extend %clickable;
}
}
button.icon {
cursor: pointer;
padding: 0 !important;
border: none;
outline: none;
background-color: transparent;
&:hover,
&:active,
&:focus {
background-color: transparent;
}
}

View File

@@ -0,0 +1,39 @@
/* eslint-disable jsx-a11y/click-events-have-key-events */
/* eslint-disable jsx-a11y/no-static-element-interactions */
/* eslint-disable react/prefer-stateless-function */
import React, { Component, MouseEventHandler } from 'react'
import c from 'classnames'
import './icon.scss'
type Props = {
url?: string
fontAwesome?: string
className?: string
size?: number
onClick?: MouseEventHandler
disabled?: boolean
}
export default class Icon extends Component<Props> {
render() {
const { url, size = 0.75, className = '', disabled, fontAwesome, onClick } = this.props
return (
<div
className={c('icon', className, {
'icon--disabled': disabled,
'icon--clickable': onClick
})}
style={{
backgroundImage: url ? `url(${url})` : undefined,
width: !fontAwesome ? `${size}rem` : undefined,
height: !fontAwesome ? `${size}rem` : undefined,
fontSize: fontAwesome && `${size}rem`
}}
onClick={onClick}
>
{fontAwesome && <i className={`fas ${fontAwesome}`} />}
</div>
)
}
}

View File

@@ -0,0 +1,36 @@
import React, { InputHTMLAttributes, MouseEventHandler } from 'react'
import './input.scss'
import classNames from 'classnames'
import Icon from '../Icon'
type Props = {
label?: string
errorMessage?: string
fontAwesome?: string
url?: string
onIconClick?: MouseEventHandler
} & InputHTMLAttributes<HTMLInputElement>
export default function Input(props: Props) {
const { fontAwesome, url, size = 1, onIconClick, label, errorMessage, className, ...inputProps } = props
return (
<div className={classNames(`input-group`, className)}>
{label && <div className="input-group__label">{label}</div>}
<div className="input-group__group">
<input
className={classNames('input', {
'input--full-width': !url && !fontAwesome
})}
title={label}
{...(inputProps as any)}
/>
{(!!url || !!fontAwesome) && (
<Icon fontAwesome={fontAwesome} url={url} size={size} onClick={onIconClick} />
)}
</div>
{errorMessage && <div className="input-group__error-message">{errorMessage}</div>}
</div>
)
}

View File

@@ -0,0 +1,73 @@
@import './src/util/variables';
.input {
@extend %regular-font;
padding: 0.5rem 0.5rem;
outline: none;
background-color: transparent;
border: none;
width: calc(100% - 2rem);
color: $primary-text;
font-family: 'Open Sans', -apple-system, BlinkMacSystemFont, Helvetica, Arial, sans-serif;
&--full-width {
width: 100%;
}
&::placeholder {
color: rgba($white, 0.2);
}
&:disabled {
cursor: default;
}
}
.input-group {
@extend %col-nowrap;
&__label {
@extend %small-font;
color: $label-text;
padding: 0 0.5rem 0.25rem;
cursor: default;
}
&__error-message {
@extend %small-font;
color: $error-red;
padding: 0.25rem 0.5rem 0;
}
&__group {
@extend %row-nowrap;
background-color: lighten($black, 15);
border-bottom: 1px solid transparent;
border-radius: 0.125rem;
&:focus-within {
background-color: $gray-900;
border-bottom: 1px solid $primary-green;
}
.icon {
opacity: 0.1;
transition: opacity 150ms ease-in-out;
margin-right: 0.75rem;
&:hover {
opacity: 0.2;
}
&:active {
opacity: 0.3;
}
}
}
}
input[type='number']::-webkit-inner-spin-button,
input[type='number']::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}

View File

@@ -0,0 +1,170 @@
import React, { MouseEvent, ReactElement, ReactNode, useCallback, useEffect, useState } from 'react'
import classNames from 'classnames'
import './menuable.scss'
import Icon from '../Icon'
type MenuableProps = {
items: ItemProps[]
children?: ReactNode
className?: string
menuClassName?: string
onOpen?: () => void
onClose?: () => void
opened?: boolean
}
export type ItemProps = {
label: string
iconUrl?: string
iconFA?: string
iconClassName?: string
className?: string
onClick?: (e: MouseEvent, reset: () => void) => void
disabled?: boolean
children?: ItemProps[]
component?: ReactNode
}
export default function Menuable(props: MenuableProps): ReactElement {
const { opened } = props
const [isShowing, setShowing] = useState(!!props.opened)
const [path, setPath] = useState<number[]>([])
useEffect(() => {
if (typeof opened !== 'undefined') {
setShowing(opened)
if (!opened) {
setPath([])
}
}
}, [opened])
const onClose = useCallback(() => {
props.onClose && props.onClose()
setShowing(false)
}, [])
const onOpen = useCallback(() => {
props.onOpen && props.onOpen()
setShowing(true)
const cb = () => {
onClose()
window.removeEventListener('click', cb)
}
window.addEventListener('click', cb)
}, [onClose])
const goBack = useCallback(
(e) => {
e.stopPropagation()
const newPath = [...path]
newPath.pop()
setPath(newPath)
},
[path]
)
const onItemClick = useCallback(
(e, item, i) => {
e.stopPropagation()
if (item.disabled) return
if (item.children) {
setPath([...path, i])
} else if (item.onClick) {
item.onClick(e, () => setPath([]))
}
},
[path]
)
let {items} = props
if (path) {
for (const pathIndex of path) {
if (items[pathIndex].children) {
items = items[pathIndex].children as ItemProps[]
}
}
}
return (
<div
className={classNames(
'menuable',
{
'menuable--active': isShowing
},
props.className
)}
onClick={(e) => {
e.stopPropagation()
if (isShowing) return onClose()
onOpen()
}}
>
{props.children}
{isShowing && (
<div className={classNames('rounded-xl border border-gray-700 menuable__menu', props.menuClassName)}>
{!!path.length && (
<div
className={classNames(
'text-sm whitespace-nowrap cursor-pointer',
'flex flex-row flex-nowrap items-center',
'text-gray-500 hover:text-gray-300 hover:bg-gray-900 menuable__menu__item'
)}
onClick={goBack}
>
<Icon fontAwesome="fas fa-caret-left" />
<span className="ml-2">Go back</span>
</div>
)}
{items.map((item, i) => (
<div
key={i}
className={classNames(
'text-sm whitespace-nowrap',
'flex flex-row flex-nowrap items-center',
'menuable__menu__item hover:bg-gray-900 ',
{ 'cursor-pointer': !item.disabled },
item.className
)}
onClick={(e) => onItemClick(e, item, i)}
>
{item.component ? (
item.component
) : (
<>
<div
className={classNames('flex-grow', {
'text-gray-500 hover:text-gray-300 hover:font-semibold': !item.disabled,
'text-gray-700': item.disabled
})}
>
{item.label}
</div>
{(item.iconUrl || item.iconFA) && (
<Icon
fontAwesome={item.iconFA}
url={item.iconUrl}
className={classNames(
'ml-4',
{
'opacity-50': item.disabled
},
item.iconClassName
)}
/>
)}
</>
)}
</div>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,22 @@
@import '../../../util/variables';
.menuable {
position: relative;
&__menu {
position: absolute;
margin-top: 0.5rem;
top: 100%;
left: 0;
background-color: $black;
border-radius: 0.75rem;
padding: 0.5rem 0;
z-index: 300;
&__item {
@extend %row-nowrap;
align-content: center;
padding: 0.5rem 1rem;
}
}
}

View File

@@ -0,0 +1,28 @@
import React, { MouseEventHandler, ReactElement, ReactNode } from 'react'
import ReactDOM from 'react-dom'
import './modal.scss'
let modalRoot: HTMLDivElement | null
export type ModalProps = {
onClose?: MouseEventHandler
className?: string
children?: ReactNode | ReactNode[]
}
export default function Modal(props: ModalProps): ReactElement {
const { className = '', onClose, children } = props
modalRoot = document.querySelector('#modal')
if (!modalRoot) return <></>
return ReactDOM.createPortal(
<div className="modal__overlay" onClick={onClose}>
<div className={`modal__wrapper ${className}`} onClick={(e) => e.stopPropagation()}>
{children}
</div>
</div>,
modalRoot
)
}

View File

@@ -0,0 +1,28 @@
@import '../../../util/variables';
div#modal {
position: relative;
z-index: 1000;
}
.modal {
&__overlay {
position: fixed;
width: 100%;
height: 100%;
top: 0;
left: 0;
background-color: rgba($black, 0.7);
z-index: 100;
overflow: auto;
}
&__wrapper {
max-width: 24rem;
margin: 3rem auto;
background-color: $bg-black;
border-radius: 0.5rem;
box-shadow: 0 2px 8px 4px rgba($black, 0.2);
z-index: 200;
}
}

View File

@@ -0,0 +1,21 @@
/* eslint-disable react/destructuring-assignment */
/* eslint-disable react/function-component-definition */
/* eslint-disable react/require-default-props */
import React, { ChangeEventHandler, ReactElement } from 'react'
import './switch-button.scss'
import classNames from 'classnames'
type Props = {
checked?: boolean
onChange?: ChangeEventHandler<HTMLInputElement>
className?: string
}
export default function SwitchButton(props: Props): ReactElement {
return (
<div className={classNames('switch-button', props.className)}>
<input type="checkbox" onChange={props.onChange} checked={props.checked} />
<span className="slider round" />
</div>
)
}

View File

@@ -0,0 +1,66 @@
@import './src/util/variables';
.switch-button {
cursor: pointer;
position: relative;
display: inline-block;
width: 3.75rem;
height: 2rem;
}
/* Hide default HTML checkbox */
.switch-button input {
opacity: 0;
width: 100%;
height: 100%;
position: relative;
z-index: 200;
cursor: pointer;
}
/* The slider */
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: $border-gray;
-webkit-transition: 0.4s;
transition: 0.4s;
}
.slider:before {
position: absolute;
content: '';
height: 1.5rem;
width: 1.5rem;
left: 4px;
bottom: 4px;
background-color: white;
-webkit-transition: 0.4s;
transition: 0.4s;
}
input:checked + .slider {
background-color: $primary-blue;
}
input:focus + .slider {
box-shadow: 0 0 1px $primary-blue;
}
input:checked + .slider:before {
-webkit-transform: translateX(26px);
-ms-transform: translateX(26px);
transform: translateX(26px);
}
/* Rounded sliders */
.slider.round {
border-radius: 34px;
}
.slider.round:before {
border-radius: 50%;
}

View File

@@ -0,0 +1,29 @@
/* eslint-disable react/jsx-props-no-spreading */
/* eslint-disable react/require-default-props */
/* eslint-disable react/function-component-definition */
import React, { MouseEventHandler, TextareaHTMLAttributes } from 'react'
import './textarea.scss'
import classNames from 'classnames'
type Props = {
label?: string
errorMessage?: string
fontAwesome?: string
url?: string
onIconClick?: MouseEventHandler
} & TextareaHTMLAttributes<HTMLTextAreaElement>
export default function Textarea(props: Props) {
const { fontAwesome, onIconClick, label, errorMessage, className, ...textareaProps } = props
return (
<div className={classNames(`textarea-group`, className)}>
{label && <div className="textarea-group__label">{label}</div>}
<div className="textarea-group__group">
<textarea className="textarea" {...textareaProps} />
</div>
{errorMessage && <div className="textarea-group__error-message">{errorMessage}</div>}
</div>
)
}

View File

@@ -0,0 +1,64 @@
@import './src/util/variables';
.textarea {
@extend %regular-font;
margin: 0.5rem 0.5rem;
outline: none;
background-color: transparent;
border: none;
width: 100%;
color: $primary-text;
font-family: 'Open Sans', -apple-system, BlinkMacSystemFont, Helvetica, Arial, sans-serif;
resize: none;
&::placeholder {
color: rgba($white, 0.2);
}
&:disabled {
cursor: default;
}
}
.textarea-group {
@extend %col-nowrap;
&__label {
@extend %small-font;
color: $label-text;
padding: 0 0.5rem 0.25rem;
cursor: default;
}
&__error-message {
@extend %small-font;
color: $error-red;
padding: 0.25rem 0.5rem 0;
}
&__group {
@extend %row-nowrap;
background-color: lighten($black, 15);
border-bottom: 1px solid transparent;
border-radius: 0.125rem;
&:focus-within {
background-color: rgba($black, 0.035);
border-bottom: 1px solid $primary-blue;
}
.icon {
opacity: 0.1;
transition: opacity 150ms ease-in-out;
margin-right: 0.75rem;
&:hover {
opacity: 0.2;
}
&:active {
opacity: 0.3;
}
}
}
}

58
src/ui/ducks/app.ts Normal file
View File

@@ -0,0 +1,58 @@
import { Dispatch } from 'redux'
import postMessage from '@src/util/postMessage'
import RPCAction from '@src/util/constants'
import { useSelector } from 'react-redux'
import { AppRootState } from '@src/ui/store/configureAppStore'
import deepEqual from 'fast-deep-equal'
export enum ActionType {
SET_STATUS = 'app/setStatus'
}
type Action<payload> = {
type: ActionType
payload?: payload
meta?: any
error?: boolean
}
type State = {
initialized: boolean
unlocked: boolean
}
const initialState: State = {
initialized: false,
unlocked: false
}
export const setStatus = (status: {
initialized: boolean
unlocked: boolean
}): Action<{
initialized: boolean
unlocked: boolean
}> => ({
type: ActionType.SET_STATUS,
payload: status
})
export const fetchStatus = () => async (dispatch: Dispatch) => {
const status = await postMessage({ method: RPCAction.GET_STATUS })
dispatch(setStatus(status))
}
export default function app(state = initialState, action: Action<any>): State {
switch (action.type) {
case ActionType.SET_STATUS:
return {
...state,
initialized: action.payload.initialized,
unlocked: action.payload.unlocked
}
default:
return state
}
}
export const useAppStatus = () => useSelector((state: AppRootState) => state.app, deepEqual)

143
src/ui/ducks/identities.ts Normal file
View File

@@ -0,0 +1,143 @@
/* eslint-disable @typescript-eslint/no-shadow */
/* eslint-disable @typescript-eslint/no-unused-vars */
import { IdentityMetadata } from '@src/types'
import { Dispatch } from 'redux'
import postMessage from '@src/util/postMessage'
import RPCAction from '@src/util/constants'
import { useSelector } from 'react-redux'
import { AppRootState } from '@src/ui/store/configureAppStore'
import deepEqual from 'fast-deep-equal'
export enum ActionType {
SET_COMMITMENTS = 'app/identities/setCommitments',
SET_SELECTED = 'app/identities/setSelected',
SET_REQUEST_PENDING = 'app/identities/setRequestPending'
}
type Action<payload> = {
type: ActionType
payload?: payload
meta?: any
error?: boolean
}
type State = {
identityCommitments: string[]
identityMap: {
[commitment: string]: IdentityMetadata
}
requestPending: boolean
selected: string
}
const initialState: State = {
identityCommitments: [],
identityMap: {},
requestPending: false,
selected: ''
}
export const createIdentity = (strategy: string, options: any) => async (dispatch: Dispatch) =>
postMessage({
method: RPCAction.CREATE_IDENTITY,
payload: {
strategy,
options
}
})
export const setActiveIdentity = (identityCommitment: string) => async (dispatch: Dispatch) => {
if (!identityCommitment) {
throw new Error('Identity Commitment not provided!')
}
return postMessage({
method: RPCAction.SET_ACTIVE_IDENTITY,
payload: identityCommitment
})
}
export const setSelected = (identityCommitment: string) => ({
type: ActionType.SET_SELECTED,
payload: identityCommitment
})
export const setIdentities = (
identities: { commitment: string; metadata: IdentityMetadata }[]
): Action<{ commitment: string; metadata: IdentityMetadata }[]> => ({
type: ActionType.SET_COMMITMENTS,
payload: identities
})
export const setIdentityRequestPending = (requestPending: boolean): Action<boolean> => ({
type: ActionType.SET_REQUEST_PENDING,
payload: requestPending
})
export const fetchIdentities = () => async (dispatch: Dispatch) => {
const identities = await postMessage({ method: RPCAction.GET_IDENTITIES })
const selected = await postMessage({ method: RPCAction.GET_ACTIVE_IDENTITY })
dispatch(setIdentities(identities))
dispatch(setSelected(selected))
}
// eslint-disable-next-line @typescript-eslint/default-param-last
export default function identities(state = initialState, action: Action<any>): State {
switch (action.type) {
case ActionType.SET_COMMITMENTS:
return reduceSetIdentities(state, action)
case ActionType.SET_SELECTED:
return {
...state,
selected: action.payload
}
case ActionType.SET_REQUEST_PENDING:
return {
...state,
requestPending: action.payload
}
default:
return state
}
}
function reduceSetIdentities(
state: State,
action: Action<{ commitment: string; metadata: IdentityMetadata }[]>
): State {
const identityCommitments: string[] = []
const identityMap = {}
if (action.payload) {
for (const id of action.payload) {
identityMap[id.commitment] = id.metadata
identityCommitments.push(id.commitment)
}
}
return {
...state,
identityMap,
identityCommitments
}
}
export const useIdentities = () =>
useSelector((state: AppRootState) => {
const { identityMap, identityCommitments } = state.identities
return identityCommitments.map((commitment) => ({
commitment,
metadata: identityMap[commitment]
}))
}, deepEqual)
export const useSelectedIdentity = () =>
useSelector((state: AppRootState) => {
const { identityMap, selected } = state.identities
return {
commitment: selected,
metadata: identityMap[selected]
}
}, deepEqual)
export const useIdentityRequestPending = () =>
useSelector((state: AppRootState) => state.identities.requestPending, deepEqual)

68
src/ui/ducks/requests.ts Normal file
View File

@@ -0,0 +1,68 @@
import { useSelector } from 'react-redux'
import { AppRootState } from '@src/ui/store/configureAppStore'
import deepEqual from 'fast-deep-equal'
import { PendingRequest } from '@src/types'
import { Dispatch } from 'redux'
import RPCAction from '@src/util/constants'
import postMessage from '@src/util/postMessage'
enum ActionType {
SET_PENDING_REQUESTS = 'request/setPendingRequests'
}
type Action<payload> = {
type: ActionType
payload?: payload
meta?: any
error?: boolean
}
type State = {
pendingRequests: PendingRequest[]
}
const initialState: State = {
pendingRequests: []
}
export const setPendingRequest = (pendingRequests: PendingRequest[]): Action<PendingRequest[]> => ({
type: ActionType.SET_PENDING_REQUESTS,
payload: pendingRequests
})
export const fetchRequestPendingStatus = () => async (dispatch: Dispatch) => {
const pendingRequests = await postMessage({ method: RPCAction.GET_PENDING_REQUESTS })
dispatch(setPendingRequest(pendingRequests))
}
/**
* NOTE: This pattern that return a function(dispatch, getState) is called a Thunk
* You don't need to use a thunk unless you want to dispatch an action after async requests
* When calling a thunk, you must dispatch it, e.g.:
*
* dispatch(finalizeRequest('0', 'dummy'));
*/
// export const finalizeRequest = (id: string, action: RequestResolutionAction) => async (dispatch: Dispatch) => {
// return postMessage({
// method: RPCAction.FINALIZE_REQUEST,
// payload: {
// id,
// action,
// },
// });
// }
// eslint-disable-next-line @typescript-eslint/default-param-last
export default function requests(state = initialState, action: Action<any>): State {
switch (action.type) {
case ActionType.SET_PENDING_REQUESTS:
return {
...state,
pendingRequests: action.payload
}
default:
return state
}
}
export const useRequestsPending = () => useSelector((state: AppRootState) => state.requests.pendingRequests, deepEqual)

132
src/ui/ducks/web3.ts Normal file
View File

@@ -0,0 +1,132 @@
import { useSelector } from 'react-redux'
import deepEqual from 'fast-deep-equal'
import { AppRootState } from '@src/ui/store/configureAppStore'
import { Dispatch } from 'redux'
import postMessage from '@src/util/postMessage'
import RPCAction from '@src/util/constants'
import ChainsJSON from '@src/static/chains.json'
type ChainInfo = {
chainId: number
infoURL: string
name: string
nativeCurrency: {
name: string
symbol: string
decimals: number
}
shortName: string
}
export const chainsMap = ChainsJSON.reduce(
(
map: {
[id: number]: ChainInfo
},
chainInfo: ChainInfo
) => {
map[chainInfo.chainId] = chainInfo
return map
},
{}
)
enum ActionTypes {
SET_LOADING = 'web3/setLoading',
SET_CONNECTING = 'web3/setConnecting',
SET_ACCOUNT = 'web3/setAccount',
SET_NETWORK = 'web3/setNetwork',
SET_CHAIN_ID = 'web3/setChainId'
}
type Action<payload> = {
type: ActionTypes
payload?: payload
meta?: any
error?: boolean
}
type State = {
account: string
networkType: string
chainId: number
loading: boolean
connecting: boolean
}
const initialState: State = {
account: '',
networkType: '',
chainId: -1,
loading: false,
connecting: false
}
export const setWeb3Connecting = (connecting: boolean): Action<boolean> => ({
type: ActionTypes.SET_CONNECTING,
payload: connecting
})
export const setAccount = (account: string): Action<string> => ({
type: ActionTypes.SET_ACCOUNT,
payload: account
})
export const setNetwork = (network: string): Action<string> => ({
type: ActionTypes.SET_NETWORK,
payload: network
})
export const setChainId = (chainId: number): Action<number> => ({
type: ActionTypes.SET_CHAIN_ID,
payload: chainId
})
export const fetchWalletInfo = () => async (dispatch: Dispatch) => {
const info = await postMessage({ method: RPCAction.GET_WALLET_INFO })
if (info) {
dispatch(setAccount(info.account))
dispatch(setNetwork(info.networkType))
dispatch(setChainId(info.chainId))
}
}
// eslint-disable-next-line @typescript-eslint/default-param-last
export default function web3(state = initialState, action: Action<any>): State {
switch (action.type) {
case ActionTypes.SET_ACCOUNT:
return {
...state,
account: action.payload
}
case ActionTypes.SET_NETWORK:
return {
...state,
networkType: action.payload
}
case ActionTypes.SET_CHAIN_ID:
return {
...state,
chainId: action.payload
}
case ActionTypes.SET_CONNECTING:
return {
...state,
connecting: action.payload
}
default:
return state
}
}
export const useWeb3Connecting = () => useSelector((state: AppRootState) => state.web3.connecting, deepEqual)
export const useAccount = () => useSelector((state: AppRootState) => state.web3.account, deepEqual)
export const useNetwork = (): ChainInfo | null => useSelector((state: AppRootState) => {
const chainInfo = chainsMap[state.web3.chainId]
return chainInfo || null
}, deepEqual)
export const useChainId = () => useSelector((state: AppRootState) => state.web3.chainId, deepEqual)

147
src/ui/pages/Home/home.scss Normal file
View File

@@ -0,0 +1,147 @@
@import '../../../util/variables';
.home {
position: relative;
&__info {
@extend %col-nowrap;
padding: 0.5rem 1rem;
}
&__scroller {
&::-webkit-scrollbar {
display: none;
}
.home__list__fix-header {
display: none;
}
&--fixed-menu {
.home__list__header {
//display: none;
}
.home__list__fix-header {
display: flex;
position: absolute;
top: 64px;
left: 0;
width: 100%;
background-color: $bg-black;
}
}
}
&__connection-button {
@extend %row-nowrap;
align-items: center;
width: fit-content;
padding: 0.25rem;
border-radius: 1rem;
margin: 0.5rem 0;
&--connected {
cursor: pointer;
&:hover {
background-color: $gray-800;
}
}
&__icon {
width: 0.5rem;
height: 0.5rem;
background-color: $gray-500;
border-radius: 50%;
margin: 0.25rem 0.5rem 0.25rem 0.25rem;
&--connected {
background-color: $success-green;
}
}
&__text {
color: $gray-300;
margin-right: 0.5rem;
}
}
&__list {
@extend %col-nowrap;
flex: 1 1 auto;
&__header {
@extend %row-nowrap;
&__tab {
@extend %row-nowrap;
justify-content: center;
align-items: center;
flex: 1 1 auto;
cursor: pointer;
padding: 1rem;
border-bottom: 2px solid $gray-800;
&--selected {
border-color: $primary-green;
}
}
}
&__content {
flex: 1 1 auto;
background-color: $black;
}
}
}
.identity-row {
@extend %row-nowrap;
align-items: center;
border-bottom: 1px solid $gray-900;
cursor: pointer;
&:hover {
background-color: $gray-950;
}
&__select-icon {
font-size: 0.8125rem !important;
border: 2px solid $gray-800;
color: $gray-800;
width: 2rem;
height: 2rem;
border-radius: 50%;
margin-right: 1rem;
padding: 0.25rem;
&:hover {
color: $primary-green;
border-color: $primary-green;
}
&--selected {
color: $primary-green;
border-color: $primary-green;
}
}
&__menu-icon {
font-size: 0.8125rem !important;
color: $gray-600;
&:hover {
color: $gray-300;
}
}
}
.create-identity-row {
&:hover {
color: $gray-300;
.icon {
color: $primary-green;
}
}
}

216
src/ui/pages/Home/index.tsx Normal file
View File

@@ -0,0 +1,216 @@
import React, { ReactElement, useCallback, useEffect, useRef, useState } from 'react'
import { useDispatch } from 'react-redux'
import postMessage from '@src/util/postMessage'
import RPCAction from '@src/util/constants'
import { fetchWalletInfo, useNetwork } from '@src/ui/ducks/web3'
import Icon from '@src/ui/components/Icon'
import {
fetchIdentities,
setActiveIdentity,
useIdentities,
useSelectedIdentity
} from '@src/ui/ducks/identities'
import Header from '@src/ui/components/Header'
import classNames from 'classnames'
import { browser } from 'webextension-polyfill-ts'
import './home.scss'
import {ellipsify} from '@src/util/account'
import CreateIdentityModal from '@src/ui/components/CreateIdentityModal'
import ConnectionModal from '@src/ui/components/ConnectionModal'
export default function Home(): ReactElement {
const dispatch = useDispatch()
const scrollRef = useRef<HTMLDivElement>(null)
const [fixedTabs, fixTabs] = useState(false)
useEffect(() => {
dispatch(fetchIdentities())
dispatch(fetchWalletInfo())
}, [])
const onScroll = useCallback(async () => {
if (!scrollRef.current) return
const scrollTop = scrollRef.current?.scrollTop
fixTabs(scrollTop > 92)
}, [scrollRef])
return (
<div className="w-full h-full flex flex-col home">
<Header />
<div
ref={scrollRef}
className={classNames('flex flex-col flex-grow flex-shrink overflow-y-auto home__scroller', {
'home__scroller--fixed-menu': fixedTabs
})}
onScroll={onScroll}
>
<HomeInfo />
<HomeList />
</div>
</div>
)
}
var HomeInfo = function(): ReactElement {
const network = useNetwork()
const [connected, setConnected] = useState(false)
const [showingModal, showModal] = useState(false)
useEffect(() => {
;(async () => {
await refreshConnectionStatus()
})()
}, [])
const refreshConnectionStatus = useCallback(async () => {
try {
const tabs = await browser.tabs.query({ active: true, lastFocusedWindow: true })
const [tab] = tabs || []
if (tab?.url) {
const { origin } = new URL(tab.url)
const isHostApproved = await postMessage({
method: RPCAction.IS_HOST_APPROVED,
payload: origin
})
setConnected(isHostApproved)
}
} catch (e) {
setConnected(false)
}
}, [])
return (
<>
{showingModal && (
<ConnectionModal onClose={() => showModal(false)} refreshConnectionStatus={refreshConnectionStatus} />
)}
<div className="home__info">
<div
className={classNames('home__connection-button', {
'home__connection-button--connected': connected
})}
onClick={connected ? () => showModal(true) : undefined}
>
<div
className={classNames('home__connection-button__icon', {
'home__connection-button__icon--connected': connected
})}
/>
<div className="text-xs home__connection-button__text">
{connected ? 'Connected' : 'Not Connected'}
</div>
</div>
<div>
<div className="text-3xl font-semibold">
{network ? `0.0000 ${network.nativeCurrency.symbol}` : '-'}
</div>
</div>
</div>
</>
)
}
var HomeList = function(): ReactElement {
const [selectedTab, selectTab] = useState<'identities' | 'activity'>('identities')
return (
<div className="home__list">
<div className="home__list__header">
<div
className={classNames('home__list__header__tab', {
'home__list__header__tab--selected': selectedTab === 'identities'
})}
onClick={() => selectTab('identities')}
>
Identities
</div>
<div
className={classNames('home__list__header__tab', {
'home__list__header__tab--selected': selectedTab === 'activity'
})}
onClick={() => selectTab('activity')}
>
Activity
</div>
</div>
<div className="home__list__fix-header">
<div
className={classNames('home__list__header__tab', {
'home__list__header__tab--selected': selectedTab === 'identities'
})}
onClick={() => selectTab('identities')}
>
Identities
</div>
<div
className={classNames('home__list__header__tab', {
'home__list__header__tab--selected': selectedTab === 'activity'
})}
onClick={() => selectTab('activity')}
>
Activity
</div>
</div>
<div className="home__list__content">
{selectedTab === 'identities' ? <IdentityList /> : null}
{selectedTab === 'activity' ? <ActivityList /> : null}
</div>
</div>
)
}
var IdentityList = function(): ReactElement {
const identities = useIdentities()
const selected = useSelectedIdentity()
const dispatch = useDispatch()
const selectIdentity = useCallback(async (identityCommitment: string) => {
dispatch(setActiveIdentity(identityCommitment))
}, [])
const [showingModal, showModal] = useState(false)
useEffect(() => {
dispatch(fetchIdentities())
}, [])
return (
<>
{showingModal && <CreateIdentityModal onClose={() => showModal(false)} />}
{identities.map(({ commitment, metadata }, i) => (
<div className="p-4 identity-row" key={commitment}>
<Icon
className={classNames('identity-row__select-icon', {
'identity-row__select-icon--selected': selected.commitment === commitment
})}
fontAwesome="fas fa-check"
onClick={() => selectIdentity(commitment)}
/>
<div className="flex flex-col flex-grow">
<div className="flex flex-row items-center text-lg font-semibold">
{`${metadata.name}`}
<span className="text-xs py-1 px-2 ml-2 rounded-full bg-gray-500 text-gray-800">
{metadata.provider}
</span>
</div>
<div className="text-base text-gray-500">{ellipsify(commitment)}</div>
</div>
<Icon className="identity-row__menu-icon" fontAwesome="fas fa-ellipsis-h" />
</div>
))}
<div
className="create-identity-row flex flex-row items-center justify-center p-4 cursor-pointer text-gray-600"
onClick={() => showModal(true)}
>
<Icon fontAwesome="fas fa-plus" size={1} className="mr-2" />
<div>Add Identity</div>
</div>
</>
)
}
var ActivityList = function(): ReactElement {
return <div />
}

View File

@@ -0,0 +1,66 @@
import React, { ReactElement, useCallback, useState } from 'react'
import './login.scss'
import Button, { ButtonType } from '@src/ui/components/Button'
import Icon from '@src/ui/components/Icon'
import LogoSVG from '@src/static/icons/logo.svg'
import Input from '@src/ui/components/Input'
import postMessage from '@src/util/postMessage'
import RPCAction from '@src/util/constants'
const Login = function(): ReactElement {
const [pw, setPW] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const valid = !!pw
const login = useCallback(async () => {
if (!valid) {
setError('Invalid password')
return
}
setLoading(true)
try {
await postMessage({
method: RPCAction.UNLOCK,
payload: pw
})
} catch (e: any) {
setError(e.message)
} finally {
setLoading(false)
}
}, [pw])
return (
<div className="flex flex-col flex-nowrap h-full login">
<div className="flex flex-col items-center flex-grow p-8 login__content">
<Icon url={LogoSVG} />
<div className="text-lg pt-8">
<b>Welcome Back!</b>
</div>
<div className="text-base">To continue, please unlock your wallet</div>
<div className="py-8 w-full">
<Input
className="mb-4"
type="password"
label="Password"
value={pw}
onChange={(e) => setPW(e.target.value)}
autoFocus
/>
</div>
</div>
{error && <div className="text-red-500 text-sm text-center">{error}</div>}
<div className="flex flex-row items-center justify-center flex-shrink p-8 login__footer">
<Button btnType={ButtonType.primary} disabled={!pw} onClick={login} loading={loading}>
Unlock
</Button>
</div>
</div>
)
}
export default Login;

View File

@@ -0,0 +1,12 @@
@import '../../../util/variables';
.login {
color: $primary-green;
&__content {
.icon {
width: 8rem !important;
height: 8rem !important;
}
}
}

View File

@@ -0,0 +1,65 @@
import React, { ReactElement, useCallback, useState } from 'react'
import './onboarding.scss'
import Button, { ButtonType } from '@src/ui/components/Button'
import Icon from '@src/ui/components/Icon'
import LogoSVG from '@src/static/icons/logo.svg'
import Input from '@src/ui/components/Input'
import postMessage from '@src/util/postMessage'
import RPCAction from '@src/util/constants'
export default function Onboarding(): ReactElement {
const [pw, setPW] = useState('')
const [pw2, setPW2] = useState('')
const [error, setError] = useState('')
const valid = !!pw && pw === pw2
const createPassword = useCallback(async () => {
if (!valid) {
setError('Invalid password')
return
}
try {
await postMessage({
method: RPCAction.SETUP_PASSWORD,
payload: pw
})
} catch (e: any) {
setError(e.message)
}
}, [pw, pw2])
return (
<div className="flex flex-col flex-nowrap h-full onboarding">
<div className="flex flex-col items-center flex-grow p-8 onboarding__content">
<Icon url={LogoSVG} />
<div className="text-lg pt-8">
<b>Thanks for using ZKeeper!</b>
</div>
<div className="text-base">To continue, please setup a password</div>
<div className="py-8 w-full">
<Input
className="mb-4"
type="password"
label="Password"
value={pw}
onChange={(e) => setPW(e.target.value)}
/>
<Input
label="Confirm Password"
type="password"
value={pw2}
onChange={(e) => setPW2(e.target.value)}
/>
</div>
</div>
{error && <div className="text-red-500 text-sm text-center">{error}</div>}
<div className="flex flex-row items-center justify-center flex-shrink p-8 onboarding__footer">
<Button btnType={ButtonType.primary} disabled={!pw || pw !== pw2} onClick={createPassword}>
Continue
</Button>
</div>
</div>
)
}

View File

@@ -0,0 +1,12 @@
@import '../../../util/variables';
.onboarding {
color: $primary-green;
&__content {
.icon {
width: 8rem !important;
height: 8rem !important;
}
}
}

View File

@@ -0,0 +1,64 @@
/* eslint-disable react-hooks/exhaustive-deps */
/* eslint-disable react/function-component-definition */
import React, { ReactElement, ReactNode, useEffect, useState } from 'react'
import './popup.scss'
import { Redirect, Route, Switch } from 'react-router'
import Home from '@src/ui/pages/Home'
import { useRequestsPending, fetchRequestPendingStatus } from '@src/ui/ducks/requests'
import { useDispatch } from 'react-redux'
import { fetchStatus, useAppStatus } from '@src/ui/ducks/app'
import Onboarding from '@src/ui/pages/Onboarding'
import Login from '@src/ui/pages/Login'
import ConfirmRequestModal from '@src/ui/components/ConfirmRequestModal'
export default function Popup(): ReactElement {
const pendingRequests = useRequestsPending()
const dispatch = useDispatch()
const [loading, setLoading] = useState(true)
const { initialized, unlocked } = useAppStatus()
useEffect(() => {
;(async () => {
try {
await dispatch(fetchStatus())
await dispatch(fetchRequestPendingStatus())
} catch (e) {
console.error(e)
} finally {
setLoading(false)
}
})()
}, [])
useEffect(() => {
dispatch(fetchRequestPendingStatus())
}, [unlocked])
if (loading) {
return <></>
}
let content: ReactNode
if (!initialized) {
content = <Onboarding />
} else if (!unlocked) {
content = <Login />
} else if (pendingRequests.length) {
const [pendingRequest] = pendingRequests
return <ConfirmRequestModal />
} else {
content = (
<Switch>
<Route path="/">
<Home />
</Route>
<Route>
<Redirect to="/" />
</Route>
</Switch>
)
}
return <div className="popup">{content}</div>
}

View File

@@ -0,0 +1,56 @@
@import './src/util/variables';
body {
font-family: 'Open Sans', -apple-system, BlinkMacSystemFont, Helvetica, Arial, sans-serif;
margin: 0;
padding: 0;
background-color: $bg-black;
color: $white;
overflow: hidden;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-tap-highlight-color: rgba(255, 255, 255, 0);
}
html,
body {
font-size: 16px;
width: 357px;
height: 600px;
cursor: default;
}
a {
color: $primary-blue;
}
.popup {
@extend %col-nowrap;
width: 100vw;
height: 100vh;
display: flex;
&__loading {
@extend %col-nowrap;
background-color: $header-gray;
width: 100vw;
height: 100vh;
align-items: center;
justify-content: center;
}
}
@media only screen and (min-width: 358px) {
body {
width: 100vw;
height: 100vh;
}
.popup {
width: 36rem;
height: 40rem;
border: 1px solid $gray-700;
margin: 3rem auto;
box-shadow: 0 2px 4px 0px $header-gray;
}
}

27
src/ui/popup.tsx Normal file
View File

@@ -0,0 +1,27 @@
import * as React from 'react'
import * as ReactDOM from 'react-dom'
import { browser } from 'webextension-polyfill-ts'
import Popup from '@src/ui/pages/Popup'
import { Provider } from 'react-redux'
import configureAppStore from '@src/ui/store/configureAppStore'
import { HashRouter } from 'react-router-dom'
const store = configureAppStore()
browser.runtime.onMessage.addListener((action) => {
if (action?.type) {
store.dispatch(action)
}
})
browser.tabs.query({ active: true, currentWindow: true }).then(() => {
browser.runtime.connect()
ReactDOM.render(
<Provider store={store}>
<HashRouter>
<Popup />
</HashRouter>
</Provider>,
document.getElementById('popup')
)
})

View File

@@ -0,0 +1,30 @@
import { applyMiddleware, combineReducers, createStore } from 'redux'
import { createLogger } from 'redux-logger'
import thunk from 'redux-thunk'
import web3 from '@src/ui/ducks/web3'
import identities from '@src/ui/ducks/identities'
import requests from '@src/ui/ducks/requests'
import app from '@src/ui/ducks/app'
const rootReducer = combineReducers({
web3,
identities,
requests,
app
})
export type AppRootState = ReturnType<typeof rootReducer>
export default function configureAppStore() {
return createStore(
rootReducer,
process.env.NODE_ENV !== 'production'
? applyMiddleware(
thunk,
createLogger({
collapsed: (getState, action = {}) => [''].includes(action.type)
})
)
: applyMiddleware(thunk)
)
}

1
src/util/account.ts Normal file
View File

@@ -0,0 +1 @@
export const ellipsify = (text: string, start = 6, end = 4) => `${text.slice(0, start)}...${text.slice(-end)}`

View File

@@ -0,0 +1,13 @@
export default function checkParameter(
value: any,
name: string,
type: 'boolean' | 'number' | 'string' | 'object' | 'function'
) {
if (value === undefined) {
throw new TypeError(`Parameter '${name}' is not defined`)
}
if (typeof value !== type) {
throw new TypeError(`Parameter '${name}' is not ${type === 'object' ? 'an' : 'a'} ${type}`)
}
}

32
src/util/constants.ts Normal file
View File

@@ -0,0 +1,32 @@
enum RPCAction {
UNLOCK = 'rpc/unlock',
LOCK = 'rpc/lock',
GET_STATUS = 'rpc/getStatus',
TRY_INJECT = 'rpc/inject',
SETUP_PASSWORD = 'rpc/lock/setupPassword',
CONNECT_METAMASK = 'rpc/metamask/connectMetamask',
GET_WALLET_INFO = 'rpc/metamask/getWalletInfo',
CREATE_IDENTITY = 'rpc/identity/createIdentity',
CREATE_IDENTITY_REQ = 'rpc/identity/createIdentityRequest',
SET_ACTIVE_IDENTITY = 'rpc/identity/setActiveIdentity',
GET_ACTIVE_IDENTITY = 'rpc/identity/getActiveidentity',
GET_COMMITMENTS = 'rpc/identity/getIdentityCommitments',
GET_IDENTITIES = 'rpc/identity/getIdentities',
GET_REQUEST_PENDING_STATUS = 'rpc/identity/getRequestPendingStatus',
FINALIZE_REQUEST = 'rpc/requests/finalize',
GET_PENDING_REQUESTS = 'rpc/requests/get',
SEMAPHORE_PROOF = 'rpc/protocols/semaphore/genProof',
RLN_PROOF = 'rpc/protocols/rln/genProof',
NRLN_PROOF = 'rpc/protocols/nrln/genProof',
DUMMY_REQUEST = 'rpc/protocols/semaphore/dummyReuqest',
REQUEST_ADD_REMOVE_APPROVAL = 'rpc/hosts/request',
APPROVE_HOST = 'rpc/hosts/approve',
IS_HOST_APPROVED = 'rpc/hosts/isHostApprove',
GET_HOST_PERMISSIONS = 'rpc/hosts/getHostPermissions',
SET_HOST_PERMISSIONS = 'rpc/hosts/setHostPermissions',
REMOVE_HOST = 'rpc/hosts/remove',
CLOSE_POPUP = 'rpc/popup/close',
// DEV RPCS
CLEAR_APPROVED_HOSTS = 'rpc/hosts/clear'
}
export default RPCAction

18
src/util/postMessage.ts Normal file
View File

@@ -0,0 +1,18 @@
import { browser } from 'webextension-polyfill-ts'
export type MessageAction = {
method: string
payload?: any
error?: boolean
meta?: any
}
export default async function postMessage(message: MessageAction) {
const [err, res] = await browser.runtime.sendMessage(message)
if (err) {
throw new Error(err)
}
return res
}

16
src/util/pushMessage.ts Normal file
View File

@@ -0,0 +1,16 @@
import { browser } from 'webextension-polyfill-ts'
export type ReduxAction = {
type: string
payload?: any
error?: boolean
meta?: any
}
export default async function pushMessage(message: ReduxAction) {
if (chrome && chrome.runtime) {
return chrome.runtime.sendMessage(message)
}
return browser.runtime.sendMessage(message)
}

146
src/util/variables.scss Normal file
View File

@@ -0,0 +1,146 @@
$white: #ffffff;
$black: #000000;
$bg-black: #000403;
$primary-blue: #2580f8;
$primary-green: #94febf;
$header-gray: rgba($black, 0.05);
$border-gray: rgba($black, 0.1);
$gray-950: lighten($black, 5);
$gray-900: lighten($black, 10);
$gray-800: lighten($black, 20);
$gray-700: lighten($black, 30);
$gray-600: lighten($black, 40);
$gray-500: lighten($black, 50);
$gray-400: lighten($black, 60);
$gray-300: lighten($black, 70);
$gray-200: lighten($black, 80);
$gray-100: lighten($black, 90);
$primary-text: $gray-200;
$secondary-text: $gray-400;
$label-text: lighten($black, 75);
$error-red: #f52525;
$success-green: #2ed272;
$warning-orange: #ffa31a;
%flex {
display: flex;
}
%col-nowrap {
@extend %flex;
flex-flow: column nowrap;
}
%row-nowrap {
@extend %flex;
flex-flow: row nowrap;
}
%small-font {
font-size: 0.8125rem;
line-height: 1.3125;
}
%lite-font {
font-size: 0.875rem;
line-height: 1.3125;
}
%regular-font {
font-size: 0.9375rem;
line-height: 1.3125;
}
%h1-font {
font-size: 2rem;
line-height: 1.3125;
}
%h2-font {
font-size: 1.5rem;
line-height: 1.3125;
}
%h3-font {
font-size: 1.17rem;
line-height: 1.3125;
}
%h5-font {
font-size: 0.83rem;
line-height: 1.3125;
}
%h4-font {
font-size: 1rem;
line-height: 1.3125;
}
%bold {
font-weight: 600;
}
%ellipsis {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
%clickable {
cursor: pointer;
transition: 200ms ease-in-out;
&:hover {
opacity: 0.8;
}
&:active {
opacity: 1;
}
}
$breakpoint-tablet: 768px;
table {
@extend %col-nowrap;
width: 100%;
border-collapse: collapse;
}
thead {
@extend %bold;
@extend %lite-font;
flex: 0 0 auto;
background-color: $black;
color: rgba($white, 0.5);
text-transform: uppercase;
}
tr {
@extend %row-nowrap;
&:nth-of-type(odd) {
background-color: rgba($black, 0.1);
}
}
td {
align-items: flex-start;
flex: 1 1 auto;
padding: 0.75rem 1.5rem;
}
tbody {
flex: 1 1 auto;
td {
@extend %lite-font;
}
}
small {
color: $secondary-text;
}

23
tsconfig.json Normal file
View File

@@ -0,0 +1,23 @@
{
"compilerOptions": {
"baseUrl": "./",
"paths": {
"@src/*": ["src/*"]
},
"target": "ES2020",
"module": "commonjs",
"moduleResolution": "node",
"rootDir": "./src",
"outDir": "dist/js",
"strict": true,
"sourceMap": true,
"jsx": "react",
"esModuleInterop": true,
"lib": ["es2020", "dom"],
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"noImplicitAny": false
},
"include": ["src"]
}

85
webpack.common.js Normal file
View File

@@ -0,0 +1,85 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
/* eslint-disable import/no-extraneous-dependencies */
const webpack = require('webpack')
const path = require('path')
const CopyPlugin = require('copy-webpack-plugin')
const envPlugin = new webpack.EnvironmentPlugin(['NODE_ENV'])
module.exports = {
entry: {
injected: path.join(__dirname, 'src/contentscripts/injected.ts'),
content: path.join(__dirname, 'src/contentscripts/index.ts'),
backgroundPage: path.join(__dirname, 'src/background/backgroundPage.ts'),
popup: path.join(__dirname, 'src/ui/popup.tsx')
},
output: {
path: path.join(__dirname, 'dist/js'),
filename: '[name].js'
},
plugins: [
envPlugin,
new webpack.ProvidePlugin({
Buffer: ['buffer', 'Buffer']
}),
new webpack.ProvidePlugin({
process: 'process/browser'
})
],
module: {
rules: [
{
exclude: /node_modules/,
test: /\.tsx?$/,
use: 'ts-loader'
},
{
exclude: /node_modules/,
test: /\.scss$/,
use: [
{
loader: 'style-loader' // Creates style nodes from JS strings
},
{
loader: 'css-loader' // Translates CSS into CommonJS
},
{
loader: 'sass-loader' // Compiles Sass to CSS
}
]
},
{
test: /\.(gif|png|jpe?g|svg)$/i,
use: [
'file-loader',
{
loader: 'image-webpack-loader',
options: {
publicPath: 'assets',
bypassOnDebug: true, // webpack@1.x
disable: true // webpack@2.x and newer
}
}
]
}
]
},
resolve: {
extensions: ['.ts', '.tsx', '.js', '.png', '.svg'],
alias: {
'@src': path.resolve(__dirname, 'src/'),
buffer: 'buffer'
},
fallback: {
browserify: require.resolve('browserify'),
stream: require.resolve('stream-browserify'),
path: require.resolve('path-browserify'),
crypto: require.resolve('crypto-browserify'),
os: require.resolve('os-browserify/browser'),
http: require.resolve('stream-http'),
https: require.resolve('https-browserify'),
fs: false
}
},
externals: /^(worker_threads)$/
}

9
webpack.dev.js Normal file
View File

@@ -0,0 +1,9 @@
/* eslint-disable import/no-extraneous-dependencies */
/* eslint-disable import/extensions */
const merge = require('webpack-merge')
const common = require('./webpack.common.js')
module.exports = merge(common, {
mode: 'development',
devtool: 'inline-source-map'
})

8
webpack.prod.js Normal file
View File

@@ -0,0 +1,8 @@
/* eslint-disable import/extensions */
/* eslint-disable import/no-extraneous-dependencies */
const merge = require('webpack-merge')
const common = require('./webpack.common.js')
module.exports = merge(common, {
mode: 'production'
})