116 Commits

Author SHA1 Message Date
Andrew Morris
0b8fec818c Add cells readme 2022-06-07 14:41:28 +10:00
Andrew Morris
4fe1241ad2 DemoTable 2022-06-07 14:35:37 +10:00
Andrew Morris
23c037b97f Cleanup 2022-06-07 08:36:22 +10:00
Andrew Morris
bfbf059a13 Remove obsoleted rpc-based cell collection 2022-06-07 08:17:46 +10:00
Andrew Morris
d7cfb87e02 Add dark-theme 2022-06-06 20:50:26 +10:00
Andrew Morris
1e1820cc58 Improve label 2022-06-06 20:18:16 +10:00
Andrew Morris
d52c4884ae More event cleanup fixes 2022-06-06 20:10:50 +10:00
Andrew Morris
553a007804 Balance demo 2022-06-06 19:07:43 +10:00
Andrew Morris
e4d166e8a2 nitpicks 2022-06-06 17:43:09 +10:00
Andrew Morris
4718851812 Page selection, ethersProvider 2022-06-06 17:40:07 +10:00
Andrew Morris
39b7d1ebc4 Add blockNumber cell 2022-06-06 17:18:40 +10:00
Andrew Morris
bb167a419f Use elcc 2022-06-06 16:29:33 +10:00
Andrew Morris
7baed92053 Fix cleanup of end handler 2022-06-06 11:55:42 +10:00
Andrew Morris
6d2c98aaf4 Stop iteration more actively 2022-06-06 11:42:28 +10:00
Andrew Morris
8c9604d8e4 Make FormulaCell lazy 2022-06-06 10:12:06 +10:00
Andrew Morris
a63b0c9d68 Fix demo page 2022-06-06 08:07:54 +10:00
Andrew Morris
67bccb45a4 Use extensionLocalCellCollection in demo 2022-06-06 07:51:50 +10:00
Andrew Morris
69ac847bac localCellCollection 2022-06-06 07:51:25 +10:00
Andrew Morris
7d70686094 Allow cleaning up the change handler 2022-06-06 07:51:25 +10:00
Andrew Morris
82c0d3c58f Use change events in CellCollection 2022-06-06 07:50:56 +10:00
Andrew Morris
8281724ae2 Add events to IAsyncStorage, change extensionLocalStorage to singleton 2022-06-06 07:50:32 +10:00
Andrew Morris
ed6b7b77bd tmp 2022-06-06 07:49:58 +10:00
Andrew Morris
26fe1bfd6c useCellState 2022-06-06 07:48:58 +10:00
Andrew Morris
a76b5164f3 Remove useNewCell 2022-06-06 07:48:56 +10:00
Andrew Morris
f59860fbea Avoid useCellWithFallback 2022-06-06 07:48:40 +10:00
Andrew Morris
dcc45cc5fc useNewCell 2022-06-06 07:48:39 +10:00
Andrew Morris
0ab259f350 Fix CellIterator undefined bug 2022-06-06 07:47:15 +10:00
Andrew Morris
2719637f75 Merge branch 'bw-173-214-215-fix-rpc' into improve-storage 2022-06-03 15:12:06 +10:00
Andrew Morris
a6e9a46822 Merge remote-tracking branch 'origin/main' into bw-173-214-215-fix-rpc 2022-06-03 14:58:06 +10:00
Andrew Morris
34b52e7fb9 Return all accounts when called internally 2022-06-03 12:09:57 +10:00
Andrew Morris
9cb51a0237 Fix lots of things 2022-06-03 12:02:35 +10:00
Andrew Morris
365157bed8 Remove unused window pollution 2022-06-03 11:02:12 +10:00
Andrew Morris
0458aee26c Fix remaining use of window.KeyringController 2022-06-03 11:01:13 +10:00
Andrew Morris
38d4051005 Merge branch 'bw-173-fix-rpc' into improve-storage 2022-06-03 10:52:47 +10:00
Andrew Morris
2a758abe46 Fix rpc (using changes from improve-storage branch) 2022-06-03 10:48:48 +10:00
Andrew Morris
9cdce8b30e Fix .KeyringController in WalletPage 2022-06-03 10:31:38 +10:00
Andrew Morris
6512fd7fa6 Replace KeyringController().createHdAccount with rpc.private.quill_createHdAccount 2022-06-03 10:24:58 +10:00
Andrew Morris
674ef797fb Replace KeyringController().getAccounts with rpc.public.eth_accounts 2022-06-03 10:17:10 +10:00
Andrew Morris
d756102404 Move eth_accounts into typed rpc 2022-06-03 10:15:18 +10:00
Andrew Morris
7c5f95258e Replace internal rpc with public+private 2022-06-03 10:01:58 +10:00
Andrew Morris
5a78d77fcf Fix window.QuillController() in WalletWrapper 2022-06-03 09:27:27 +10:00
Andrew Morris
9e929d1b30 QuillContext 2022-06-03 09:17:44 +10:00
Andrew Morris
cae4e8ea5d Use versionedType when reading 2022-06-03 08:24:57 +10:00
Andrew Morris
94b3d966b7 Use a cell for CurrencyController.state 2022-06-03 08:15:28 +10:00
Andrew Morris
dc9e7b068c Allow async formulas 2022-06-02 17:30:27 +10:00
Andrew Morris
048edbb9cc Quill cells 2022-06-02 16:19:19 +10:00
Andrew Morris
a2bd81f033 Allow mixing unknowns in CellCollection 2022-06-02 15:15:40 +10:00
Andrew Morris
fa2f5fd968 Add counters 2022-06-02 13:58:00 +10:00
Andrew Morris
3c69d7438f CellDisplay 2022-06-02 13:42:44 +10:00
Andrew Morris
aa09b43a2a Add cells demo 2022-06-02 13:21:34 +10:00
Andrew Morris
8713cc1c7d useCell.ts, useReadableCell.ts 2022-06-02 12:44:59 +10:00
Andrew Morris
dba032f87b Enable removing cells 2022-06-02 12:28:57 +10:00
Andrew Morris
61a63dec41 MemoryCellCollection.ts 2022-06-02 12:24:23 +10:00
Andrew Morris
f1bf808638 Define IAsyncStorage abstraction and use it to generalize ExtensionLocalStorage 2022-06-02 12:19:30 +10:00
Andrew Morris
8f5f4ebbc8 ReadableCellIterator -> CellIterator 2022-06-02 11:56:10 +10:00
Andrew Morris
1c84872df8 Split out FormulaCell 2022-06-02 11:52:50 +10:00
Andrew Morris
54e644b24c MemoryCell.ts 2022-06-02 11:50:48 +10:00
Andrew Morris
375e0927d5 Split out CellIterator 2022-06-02 11:32:40 +10:00
Andrew Morris
aee5e56b76 Split out ICell 2022-06-02 11:29:51 +10:00
Andrew Morris
a894a5b1be StorageCell -> ExtensionLocalCell 2022-06-02 11:24:24 +10:00
Andrew Morris
ecd664fc0c Rename to cells/ExtensionLocalStorage.ts 2022-06-02 11:22:53 +10:00
Andrew Morris
02129fc5cf Remove unused functions 2022-06-02 11:18:52 +10:00
Andrew Morris
59f77ea95b FormulaCell 2022-06-02 11:03:11 +10:00
Andrew Morris
8d8d347fbe Define IReadableCell, split out ReadableCellIterator 2022-06-02 07:54:12 +10:00
Jacob Caban-Tomski
d25f144247 Merge pull request #216 from web3well/206-followup
206 followup
2022-06-01 12:18:44 -06:00
Andrew Morris
fb077141a0 Use default to ensure there's always a value 2022-06-01 19:46:42 +10:00
Andrew Morris
f6d1e313fd Fixes, add iteration 2022-06-01 17:45:13 +10:00
Andrew Morris
016103a17e wip StorageManager 2022-06-01 16:47:33 +10:00
Andrew Morris
f54c1f6bda Use node 16 due to webpack 4 2022-06-01 14:04:55 +10:00
Andrew Morris
0c6396d36d Allow specifying Params and Result for ProviderHandler 2022-06-01 12:34:41 +10:00
Andrew Morris
7dbc0ba2c3 Use version from package.json 2022-06-01 12:30:25 +10:00
Andrew Morris
50448d753a Upgrade nodejs 2022-06-01 12:29:04 +10:00
Jacob Caban-Tomski
6508139ce1 Merge pull request #209 from web3well/fix/key-override
Fix public key override to zero after creation
2022-05-27 10:22:10 -06:00
kautukkundan
02d64c113f refactor: move update reset time to initialize function 2022-05-27 18:45:10 +05:30
kautukkundan
ddf743f48d fix: reset time-to-reset after latching on public key 2022-05-27 16:45:59 +05:30
kautukkundan
6510604943 test: test presence of override attack 2022-05-27 16:23:33 +05:30
Andrew Morris
f4823e6e18 Merge pull request #203 from web3well/delete-old-extension-code
Delete old extension code
2022-05-20 17:58:14 +10:00
Andrew Morris
af44e9da34 Merge pull request #206 from web3well/simplify-eth-methods
Radically simplify how window.ethereum methods are defined
2022-05-20 17:57:54 +10:00
Jacob Caban-Tomski
40b207d278 Merge pull request #204 from web3well/metamask-renaming
metamask -> quill
2022-05-19 14:54:08 -06:00
Jacob Caban-Tomski
796eb8327b Merge pull request #205 from web3well/fix-open-on-install
Fix open on install
2022-05-19 14:53:11 -06:00
Andrew Morris
7806342ddf Remove unused import 2022-05-19 17:30:33 +10:00
Andrew Morris
43c2c9c33b Simplify initializeProvider 2022-05-19 17:16:00 +10:00
Andrew Morris
151aba1516 Make createEthMiddleware generic so that QuillController can easily define any method 2022-05-19 17:11:21 +10:00
Andrew Morris
e2ab533e9c Inline wallet_get_provider_state 2022-05-19 16:58:31 +10:00
Andrew Morris
f5d7b6060d Use toAsyncMiddleware for remaining IProviderHandlers 2022-05-19 16:53:00 +10:00
Andrew Morris
769679c800 Simplify eth_coinbase 2022-05-19 16:50:29 +10:00
Andrew Morris
a68faa69c6 Simplify provider handler for eth_sendTransaction 2022-05-19 16:43:02 +10:00
Andrew Morris
09f684172b Move web3_clientVersion to a file constant 2022-05-19 16:06:38 +10:00
Andrew Morris
2cc3720e4e Fix url of quill page 2022-05-19 14:25:47 +10:00
Andrew Morris
994cf3596b metamask -> quill 2022-05-19 14:08:16 +10:00
Andrew Morris
ca32615f25 Delete more unused code 2022-05-19 13:54:53 +10:00
Andrew Morris
2d93ba0fc8 Remove unused code 2022-05-19 13:07:41 +10:00
Jacob Caban-Tomski
43d39efbe7 Merge pull request #202 from web3well/use-custom-aggregator
Enable eth_setPreferredAggregator
2022-05-17 17:31:05 -06:00
Andrew Morris
c8ed6765db Enable eth_setPreferredAggregator 2022-05-17 11:49:55 +10:00
Jacob Caban-Tomski
91d04a691e Update clients package.json repository 2022-05-06 09:15:35 -06:00
Jacob Caban-Tomski
5f905c333f Merge pull request #200 from web3well/aggregator-proxy
Add aggregator-proxy
2022-05-05 15:59:09 -06:00
Andrew Morris
f9945e7e67 0.1.1 2022-05-05 07:11:39 +00:00
Andrew Morris
4c2fe175c1 Enable async transformer 2022-05-05 07:11:02 +00:00
Andrew Morris
59e00ccfdd Set up package 2022-05-05 05:02:43 +00:00
Andrew Morris
c38bf579b3 Expose callback and run functions 2022-05-05 04:54:34 +00:00
Andrew Morris
7f5fc5826b polyfill fetch, fix post->get 2022-05-05 04:13:04 +00:00
Andrew Morris
24912a885b cors 2022-05-05 03:41:06 +00:00
Andrew Morris
b50546c3b6 Proxy requests to upstream aggregator 2022-05-03 06:39:04 +00:00
Andrew Morris
c0145fc8a6 Decode bundle and transform 2022-05-03 05:23:38 +00:00
Andrew Morris
311c28ad8d Get bodyParser working 2022-05-03 04:55:34 +00:00
Andrew Morris
a7ef6a1856 Start aggregator-proxy 2022-05-02 07:56:46 +00:00
Jacob Caban-Tomski
5a899337ee Merge pull request #196 from web3well/arbitrum-testnet-update
Arbitrum testnet update
2022-04-22 14:16:56 +02:00
Andrew Morris
46f9d2d081 Update deno version in dockerfile 2022-04-22 01:40:57 +00:00
Andrew Morris
57cf1e5bd4 Add some logging in Create2Fixture 2022-04-22 01:10:00 +00:00
Andrew Morris
0d46468cda Updated arbitrum testnet deployment 2022-04-22 01:07:43 +00:00
Andrew Morris
382e0e3c38 Comment out estimateFee in manual test since it's not working 2022-04-22 00:55:36 +00:00
Andrew Morris
123e467f3e Merge pull request #195 from web3well/feat/multi-action-support
Feat/multi action support
2022-04-20 17:27:50 +10:00
Jacob Caban-Tomski
77f8e4cd0a Add demo video 2022-04-19 23:12:07 +02:00
James Zaki
bea30d9171 Add system diagram 2022-04-18 10:34:58 +02:00
kautukkundan
d34c7a5c76 restored known transactions list 2022-04-18 05:29:27 +05:30
kautukkundan
dc0c11f666 added support for multiple actions in a single submission 2022-04-18 05:16:17 +05:30
108 changed files with 3752 additions and 1825 deletions

View File

@@ -2,10 +2,14 @@
An Ethereum Layer 2 smart contract wallet that uses [BLS signatures](https://en.wikipedia.org/wiki/BLS_digital_signature) and aggregated transactions to reduce gas costs.
You can watch a full end-to-end demo of the project [here](https://www.youtube.com/watch?v=MOQ3sCLP56g)
## Components
See each component's directory `README` for more details.
![System Overview](images/system-overview.svg)
### Aggregator
Service which aggregates BLS wallet transactions.

2
aggregator-proxy/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
node_modules
dist

View File

@@ -0,0 +1,5 @@
*
!/dist/src/**/*
!/src/**/*
!/package.json
!/README.md

View File

@@ -0,0 +1,3 @@
{
"editor.rulers": [80]
}

View File

@@ -0,0 +1,28 @@
# Aggregator Proxy
## Usage
```ts
import {
runAggregatorProxy,
// AggregatorProxyCallback,
// ^ Alternatively, for manual control, import AggregatorProxyCallback to
// just generate the req,res callback for use with http.createServer
} from 'aggregator-proxy';
runAggregatorProxy(
'https://arbitrum-testnet.blswallet.org',
async bundle => {
console.log('proxying bundle', JSON.stringify(bundle, null, 2));
// Return a different/augmented bundle to send to the upstream aggregator
return bundle;
},
8080,
'0.0.0.0',
() => {
console.log('Proxying aggregator on port 8080');
},
);
```

View File

@@ -0,0 +1,14 @@
import { runAggregatorProxy } from "../src";
runAggregatorProxy(
'https://arbitrum-testnet.blswallet.org',
async b => {
console.log('proxying bundle', JSON.stringify(b, null, 2));
return b;
},
8080,
'0.0.0.0',
() => {
console.log('Proxying aggregator on port 8080');
},
);

View File

@@ -0,0 +1,29 @@
{
"name": "bls-wallet-aggregator-proxy",
"version": "0.1.1",
"main": "dist/src/index.js",
"repository": "https://github.com/web3well/bls-wallet",
"author": "Andrew Morris",
"license": "MIT",
"private": false,
"scripts": {
"build": "rm -rf dist && tsc"
},
"dependencies": {
"@koa/cors": "^3.3.0",
"@koa/router": "^10.1.1",
"@types/koa": "^2.13.4",
"@types/koa-bodyparser": "^4.3.7",
"@types/koa__cors": "^3.3.0",
"@types/koa__router": "^8.0.11",
"@types/node-fetch": "^2.6.1",
"bls-wallet-clients": "^0.6.0",
"fp-ts": "^2.12.1",
"io-ts": "^2.2.16",
"io-ts-reporters": "^2.0.1",
"koa": "^2.13.4",
"koa-bodyparser": "^4.3.0",
"node-fetch": "2",
"typescript": "^4.6.4"
}
}

View File

@@ -0,0 +1,73 @@
import fetch from 'node-fetch';
import Koa from 'koa';
import cors from '@koa/cors';
import Router from '@koa/router';
import bodyParser from 'koa-bodyparser';
import { Bundle, bundleFromDto, Aggregator } from 'bls-wallet-clients';
import reporter from 'io-ts-reporters';
import BundleDto from './BundleDto';
(globalThis as any).fetch ??= fetch;
export default function AggregatorProxyCallback(
upstreamAggregatorUrl: string,
bundleTransformer: (clientBundle: Bundle) => Bundle | Promise<Bundle>,
) {
const app = new Koa();
app.use(cors());
const upstreamAggregator = new Aggregator(upstreamAggregatorUrl);
const router = new Router();
router.post('/bundle', bodyParser(), async (ctx) => {
const decodeResult = BundleDto.decode(ctx.request.body);
if ('left' in decodeResult) {
ctx.status = 400;
ctx.body = reporter.report(decodeResult);
return;
}
const clientBundle = bundleFromDto(decodeResult.right);
const transformedBundle = await bundleTransformer(clientBundle);
const addResult = await upstreamAggregator.add(transformedBundle);
ctx.status = 200;
ctx.body = addResult;
});
router.post('/estimateFee', bodyParser(), async (ctx) => {
const decodeResult = BundleDto.decode(ctx.request.body);
if ('left' in decodeResult) {
ctx.status = 400;
ctx.body = reporter.report(decodeResult);
return;
}
const clientBundle = bundleFromDto(decodeResult.right);
const transformedBundle = await bundleTransformer(clientBundle);
const estimateFeeResult = await upstreamAggregator.estimateFee(transformedBundle);
ctx.status = 200;
ctx.body = estimateFeeResult;
});
router.get('/bundleReceipt/:hash', bodyParser(), async (ctx) => {
const lookupResult = await upstreamAggregator.lookupReceipt(ctx.params.hash);
if (lookupResult === undefined) {
ctx.status = 404;
} else {
ctx.status = 200;
ctx.body = lookupResult;
}
});
app.use(router.routes());
return app.callback();
}

View File

@@ -0,0 +1,20 @@
import * as io from 'io-ts';
import { BundleDto } from 'bls-wallet-clients';
const BundleDto = io.type({
signature: io.tuple([io.string, io.string]),
senderPublicKeys: io.array(
io.tuple([io.string, io.string, io.string, io.string])
),
operations: io.array(io.type({
nonce: io.string,
actions: io.array(io.type({
ethValue: io.string,
contractAddress: io.string,
encodedFunction: io.string,
})),
})),
});
export default BundleDto;

View File

@@ -0,0 +1,2 @@
export { default as AggregatorProxyCallback } from './AggregatorProxyCallback';
export { default as runAggregatorProxy } from './runAggregatorProxy';

View File

@@ -0,0 +1,20 @@
import http from 'http';
import { Bundle } from 'bls-wallet-clients';
import AggregatorProxyCallback from './AggregatorProxyCallback';
export default function runAggregatorProxy(
upstreamAggregatorUrl: string,
bundleTransformer: (clientBundle: Bundle) => Bundle | Promise<Bundle>,
port?: number,
hostname?: string,
listeningListener?: () => void,
) {
const server = http.createServer(
AggregatorProxyCallback(upstreamAggregatorUrl, bundleTransformer),
);
server.listen(port, hostname, listeningListener);
return server;
}

View File

@@ -0,0 +1,101 @@
{
"compilerOptions": {
/* Visit https://aka.ms/tsconfig.json to read more about this file */
/* Projects */
// "incremental": true, /* Enable incremental compilation */
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
// "tsBuildInfoFile": "./", /* Specify the folder for .tsbuildinfo incremental compilation files. */
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects */
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
/* Language and Environment */
"target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
// "jsx": "preserve", /* Specify what JSX code is generated. */
// "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h' */
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using `jsx: react-jsx*`.` */
// "reactNamespace": "", /* Specify the object invoked for `createElement`. This only applies when targeting `react` JSX emit. */
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
/* Modules */
"module": "commonjs", /* Specify what module code is generated. */
// "rootDir": "./", /* Specify the root folder within your source files. */
// "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
// "typeRoots": [], /* Specify multiple folders that act like `./node_modules/@types`. */
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
// "resolveJsonModule": true, /* Enable importing .json files */
// "noResolve": true, /* Disallow `import`s, `require`s or `<reference>`s from expanding the number of files TypeScript should add to a project. */
/* JavaScript Support */
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from `node_modules`. Only applicable with `allowJs`. */
/* Emit */
"declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
"declarationMap": true, /* Create sourcemaps for d.ts files. */
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
"sourceMap": true, /* Create source map files for emitted JavaScript files. */
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If `declaration` is true, also designates a file that bundles all .d.ts output. */
"outDir": "./dist", /* Specify an output folder for all emitted files. */
// "removeComments": true, /* Disable emitting comments. */
// "noEmit": true, /* Disable emitting files from a compilation. */
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
// "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types */
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
// "newLine": "crlf", /* Set the newline character for emitting files. */
// "stripInternal": true, /* Disable emitting declarations that have `@internal` in their JSDoc comments. */
// "noEmitHelpers": true, /* Disable generating custom helper functions like `__extends` in compiled output. */
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
// "preserveConstEnums": true, /* Disable erasing `const enum` declarations in generated code. */
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
// "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
/* Interop Constraints */
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
/* Type Checking */
"strict": true, /* Enable all strict type-checking options. */
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied `any` type.. */
// "strictNullChecks": true, /* When type checking, take into account `null` and `undefined`. */
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
// "strictBindCallApply": true, /* Check that the arguments for `bind`, `call`, and `apply` methods match the original function. */
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
// "noImplicitThis": true, /* Enable error reporting when `this` is given the type `any`. */
// "useUnknownInCatchVariables": true, /* Type catch clause variables as 'unknown' instead of 'any'. */
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
// "noUnusedLocals": true, /* Enable error reporting when a local variables aren't read. */
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read */
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
// "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type */
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
/* Completeness */
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
"skipLibCheck": true /* Skip type checking all .d.ts files. */
}
}

1504
aggregator-proxy/yarn.lock Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
FROM denoland/deno:1.16.3
FROM denoland/deno:1.20.6
ADD build /app
WORKDIR /app

View File

@@ -32,10 +32,10 @@ const bundle = wallet.sign({
}],
});
console.log("Calling estimateFee");
// console.log("Calling estimateFee");
const feeEstimation = await client.estimateFee(bundle);
console.log({ feeEstimation });
// const feeEstimation = await client.estimateFee(bundle);
// console.log({ feeEstimation });
console.log("Sending mint bundle to aggregator");

View File

@@ -4,7 +4,7 @@
"description": "Client libraries for interacting with BLS Wallet components",
"main": "dist/src/index.js",
"types": "dist/src/index.d.ts",
"repository": "https://github.com/jzaki/bls-wallet/tree/main/client",
"repository": "https://github.com/web3well/bls-wallet/tree/main/contracts/clients",
"author": "Andrew Morris",
"license": "MIT",
"private": false,

View File

@@ -65,6 +65,8 @@ contract BLSWallet is Initializable, IBLSWallet
trustedBLSGateway = blsGateway;
pendingGatewayTime = type(uint256).max;
pendingPAFunctionTime = type(uint256).max;
pendingRecoveryHashTime = type(uint256).max;
pendingBLSPublicKeyTime = type(uint256).max;
}
/** */

View File

@@ -1,18 +1,19 @@
{
"parameters": {},
"addresses": {
"create2Deployer": "0x036d996D6855B83cd80142f2933d8C2617dA5617",
"precompileCostEstimator": "0x22E4a5251C1F02de8369Dd6f192033F6CB7531A4",
"blsLibrary": "0x52ED3BAF9F4b60c67D2796e8ED5f35AfA3c4938a",
"verificationGateway": "0x115A6d451f50D9E683be5DFaee9a1A22d96BD7a4",
"blsExpander": "0xa89ac804aCF9D38B6b8827ba596678FAC23616FC",
"testToken": "0x8b20135a1C7ad0B069ac0Be5640b84E1481B8854"
"create2Deployer": "0xc1326d37b446bC7df7b36348C963BFcc8eF98Ce3",
"precompileCostEstimator": "0x7a89f10F307Bd51b81eF8D1B2c5fa74c7E2d006D",
"blsLibrary": "0x6F6a92362EA4299B5668dC4A75282bBFd42D4804",
"verificationGateway": "0x697B3E6258B08201d316b31D69805B5F666b62C8",
"blsExpander": "0xaf6E02eAf7855D587ffDE5c424a0991570b56944",
"utilities": "0x5C176B9F019Bfe90cEc3b2492cC5e20f11c97855",
"testToken": "0x09f2C81263B8C079CcE299B4B5b4C32cba0aA0F9"
},
"auxiliary": {
"chainid": 421611,
"domain": "0x0054159611832e24cdd64c6a133e71d373c5f8553dde6c762e6bffe707ad83cc",
"genesisBlock": 8006116,
"deployedBy": "0xf5F21252cEaEB40cAfa64D6367Ab902e3E371f3b",
"version": "87e115b174fd75350fadf12096e9e1e0ee047d52"
"genesisBlock": 11355502,
"deployedBy": "0xcc12Dd5DefC8BCccAbfBA4bFBFECe09B4EDBF263",
"version": "bea30d9171772faf855cdab909f321ce0834a495"
}
}

View File

@@ -83,7 +83,19 @@ export default class Create2Fixture {
// If contract doesn't exist at expected address, deploy it there
if ((await ethers.provider.getCode(contractAddress)) === "0x") {
await (await create2Deployer.deploy(salt, initCode)).wait();
const receipt = await (
await create2Deployer.deploy(salt, initCode)
).wait();
console.log(
[
`Deployed ${contractName}`,
`using ${receipt.gasUsed.toString()} gas:`,
contractAddress,
].join(" "),
);
} else {
console.log(`${contractName} already deployed: ${contractAddress}`);
}
return factory.attach(contractAddress);

View File

@@ -7,6 +7,7 @@ import { PublicKey, BlsWalletWrapper, Signature } from "../clients/src";
import Fixture from "../shared/helpers/Fixture";
import deployAndRunPrecompileCostEstimator from "../shared/helpers/deployAndRunPrecompileCostEstimator";
import { defaultDeployerAddress } from "../shared/helpers/deployDeployer";
import { BLSWallet } from "../typechain";
const signWalletAddress = async (
fx: Fixture,
@@ -43,7 +44,7 @@ describe("Recovery", async function () {
const safetyDelaySeconds = 7 * 24 * 60 * 60;
let fx: Fixture;
let wallet1, wallet2, walletAttacker;
let blsWallet;
let blsWallet: BLSWallet;
let recoverySigner;
let hash1, hash2;
let salt;
@@ -85,6 +86,18 @@ describe("Recovery", async function () {
expect(await blsWallet.getBLSPublicKey()).to.eql(newKey);
});
it("should NOT override public key after creation", async function () {
const initialKey = await blsWallet.getBLSPublicKey();
const ZERO = ethers.BigNumber.from(0);
expect(initialKey).to.not.eql([ZERO, ZERO, ZERO, ZERO]);
await blsWallet.setAnyPending();
const finalKey = await blsWallet.getBLSPublicKey();
expect(finalKey).to.eql(initialKey);
});
it("should set recovery hash", async function () {
// set instantly from 0 value
await fx.call(wallet1, blsWallet, "setRecoveryHash", [recoveryHash], 1);

View File

@@ -10,7 +10,7 @@
"url": "https://blswallet.org"
},
"engines": {
"node": ">=10.0.0",
"node": ">=16.0.0",
"yarn": ">= 1.0.0"
},
"scripts": {
@@ -42,6 +42,8 @@
"eth-rpc-errors": "^4.0.3",
"ethers": "5.5.4",
"fast-deep-equal": "^3.1.3",
"fp-ts": "^2.12.1",
"io-ts": "^2.2.16",
"is-stream": "^3.0.0",
"lodash-es": "^4.17.21",
"phosphor-react": "^1.4.0",
@@ -79,8 +81,8 @@
"@types/react": "^17.0.37",
"@types/react-blockies": "^1.4.1",
"@types/react-dom": "^17.0.11",
"@types/react-table": "^7.7.9",
"@types/react-router-dom": "^5.3.3",
"@types/react-table": "^7.7.9",
"@types/readable-stream": "^2.3.12",
"@types/webextension-polyfill": "^0.8.2",
"@types/webpack": "^4.41.29",

View File

@@ -1,254 +0,0 @@
import { EventEmitter } from 'events';
import { BlsWalletWrapper } from 'bls-wallet-clients';
import type { Aggregator } from 'bls-wallet-clients';
import type { ethers } from 'ethers';
import type TypedEventEmitter from 'typed-emitter';
import { storage } from 'webextension-polyfill';
import { PRIVATE_KEY_STORAGE_KEY } from './env';
import generateRandomHex from './helpers/generateRandomHex';
import TaskQueue from './common/TaskQueue';
import { PageEvents } from './components/Page';
import * as env from './env';
export type AppState = {
privateKey?: string;
walletAddress: {
value?: string;
loadCounter: number;
};
walletState: {
nonce?: string;
balance?: string;
};
};
type Events = {
state(state: AppState): void;
};
export default class App {
events = new EventEmitter() as TypedEventEmitter<Events>;
pageEvents = new EventEmitter() as PageEvents;
cleanupTasks = new TaskQueue();
wallet?: BlsWalletWrapper;
state: AppState;
constructor(
public aggregator: Aggregator,
public provider: ethers.providers.Provider,
public _storage: typeof storage.local,
) {
this.state = {
walletAddress: {
loadCounter: 0,
},
walletState: {},
};
this.setupStorage();
this.setupBlockListener();
}
setupStorage(): void {
type Listener = Parameters<typeof storage.onChanged.addListener>[0];
const listener: Listener = (changes, areaName) => {
if (areaName !== 'local') {
return;
}
const pkChange = changes[PRIVATE_KEY_STORAGE_KEY];
if (pkChange === undefined) {
return;
}
this.setState({ privateKey: pkChange.newValue });
};
storage.onChanged.addListener(listener);
this.cleanupTasks.push(() => {
storage.onChanged.removeListener(listener);
});
storage.local.get(PRIVATE_KEY_STORAGE_KEY).then((results) => {
if (PRIVATE_KEY_STORAGE_KEY in results) {
this.setState({ privateKey: results[PRIVATE_KEY_STORAGE_KEY] });
}
});
}
setupBlockListener(): void {
const listener = () => this.checkWalletState();
this.provider.on('block', listener);
this.cleanupTasks.push(() => {
this.provider.off('block', listener);
});
}
async checkWalletState(): Promise<void> {
if (
this.wallet &&
this.state.privateKey &&
this.wallet.privateKey === this.state.privateKey
) {
const newWalletState = {
nonce: (await this.wallet.Nonce()).toString(),
balance: (
await this.provider.getBalance(this.wallet.address)
).toString(),
};
if (
JSON.stringify(newWalletState) !==
JSON.stringify(this.state.walletState)
) {
this.setState({
walletState: newWalletState,
});
}
}
}
cleanup(): void {
this.cleanupTasks.run();
}
setState(updates: Partial<AppState>): void {
const oldPrivateKey = this.state.privateKey;
const oldWalletAddress = this.state.walletAddress.value;
this.state = { ...this.state, ...updates };
// When the private key changes, update storage and check for an associated
// wallet
if (this.state.privateKey !== oldPrivateKey) {
if (this.state.privateKey === undefined) {
storage.local.remove(PRIVATE_KEY_STORAGE_KEY);
} else {
storage.local.set({
[PRIVATE_KEY_STORAGE_KEY]: this.state.privateKey,
});
}
this.checkWalletAddress();
}
// When the wallet address changes, clear the wallet nonce
if (this.state.walletAddress.value !== oldWalletAddress) {
this.state = { ...this.state, walletState: {} };
}
this.events.emit('state', this.state);
}
createPrivateKey(): void {
this.setState({ privateKey: generateRandomHex(256) });
}
loadPrivateKey(privateKey: string): void {
const expectedBits = 256;
const expectedBytes = expectedBits / 8;
const expectedLength =
2 + // 0x
2 * expectedBytes; // 2 hex characters per byte
if (privateKey.length !== expectedLength) {
this.pageEvents.emit(
'notification',
'error',
'Failed to restore private key: incorrect length',
);
return;
}
if (!/0x([0-9a-f])*$/i.test(privateKey)) {
this.pageEvents.emit(
'notification',
'error',
'Failed to restore private key: incorrect format',
);
return;
}
this.setState({ privateKey });
}
deletePrivateKey(): void {
this.setState({ privateKey: undefined });
}
PublicKey(): string | undefined {
if (this.state.privateKey === undefined) {
return undefined;
}
return this.wallet?.PublicKeyStr();
}
async checkWalletAddress(): Promise<void> {
if (this.state.privateKey === undefined) {
this.setState({
walletAddress: {
...this.state.walletAddress,
value: undefined,
},
});
return;
}
this.incrementWalletAddressLoading();
const lookupPrivateKey = this.state.privateKey;
try {
this.wallet = await BlsWalletWrapper.connect(
this.state.privateKey,
env.NETWORK_CONFIG.addresses.verificationGateway,
this.provider,
);
if (this.state.privateKey !== lookupPrivateKey) {
return;
}
this.setState({
walletAddress: {
...this.state.walletAddress,
value: this.wallet?.address,
},
});
this.checkWalletState();
} catch (err) {
console.error(err);
} finally {
this.decrementWalletAddressLoading();
}
}
incrementWalletAddressLoading(): void {
this.setState({
walletAddress: {
...this.state.walletAddress,
loadCounter: this.state.walletAddress.loadCounter + 1,
},
});
}
decrementWalletAddressLoading(): void {
this.setState({
walletAddress: {
...this.state.walletAddress,
loadCounter: this.state.walletAddress.loadCounter - 1,
},
});
}
}

View File

@@ -1,4 +1,5 @@
import BaseController from '../BaseController';
import CellCollection from '../../cells/CellCollection';
import ICell from '../../cells/ICell';
import {
CurrencyControllerConfig,
CurrencyControllerState,
@@ -7,97 +8,48 @@ import {
// every ten minutes
const POLLING_INTERVAL = 600_000;
export default class CurrencyController extends BaseController<
CurrencyControllerConfig,
CurrencyControllerState
> {
private conversionInterval: number;
const defaultConfig: CurrencyControllerConfig = {
pollInterval: POLLING_INTERVAL,
};
constructor({
config = {},
state,
}: {
config: Partial<CurrencyControllerConfig>;
state?: Partial<CurrencyControllerState>;
}) {
super({ config, state });
this.defaultState = {
export default class CurrencyController {
private conversionInterval: number;
public config: CurrencyControllerConfig;
public state: ICell<CurrencyControllerState>;
constructor(
config: CurrencyControllerConfig | undefined,
storage: CellCollection,
) {
this.config = config ?? defaultConfig;
this.state = storage.Cell('CurrencyController', CurrencyControllerState, {
currentCurrency: 'usd',
conversionRate: 0,
conversionDate: 'N/A',
nativeCurrency: 'ETH',
} as CurrencyControllerState;
this.defaultConfig = {
pollInterval: POLLING_INTERVAL,
} as CurrencyControllerConfig;
this.initialize();
});
}
//
// PUBLIC METHODS
//
public getNativeCurrency(): string {
return this.state.nativeCurrency;
}
public setNativeCurrency(nativeCurrency: string): void {
this.update({
nativeCurrency,
ticker: nativeCurrency,
} as CurrencyControllerState);
}
public getCurrentCurrency(): string {
return this.state.currentCurrency;
}
public setCurrentCurrency(currentCurrency: string): void {
this.update({ currentCurrency } as CurrencyControllerState);
}
/**
* A getter for the conversionRate property
*
* @returns The conversion rate from ETH to the selected currency.
*
*/
public getConversionRate(): number {
return this.state.conversionRate;
}
public setConversionRate(conversionRate: number): void {
this.update({ conversionRate } as CurrencyControllerState);
}
/**
* A getter for the conversionDate property
*
* @returns The date at which the conversion rate was set. Expressed in milliseconds since midnight of
* January 1, 1970
*
*/
public getConversionDate(): string {
return this.state.conversionDate;
}
public setConversionDate(conversionDate: string): void {
this.update({ conversionDate } as CurrencyControllerState);
public async update(stateUpdates: Partial<CurrencyControllerState>) {
await this.state.write({
...(await this.state.read()),
...stateUpdates,
});
}
async updateConversionRate(): Promise<void> {
let currentCurrency = '';
let nativeCurrency = '';
try {
// fiat
currentCurrency = this.getCurrentCurrency();
let state: CurrencyControllerState | undefined;
// crypto
nativeCurrency = this.getNativeCurrency();
try {
state = await this.state.read();
const apiUrl = `${
this.config.api
}?fsym=${nativeCurrency.toUpperCase()}&tsyms=${currentCurrency.toUpperCase()}&api_key=${
}?fsym=${state.nativeCurrency.toUpperCase()}&tsyms=${state.currentCurrency.toUpperCase()}&api_key=${
process.env.CRYPTO_COMPARE_API_KEY
}`;
let response: Response;
@@ -128,30 +80,38 @@ export default class CurrencyController extends BaseController<
// this.setConversionRate(Number(parsedResponse.bid))
// this.setConversionDate(Number(parsedResponse.timestamp))
// } else
if (parsedResponse[currentCurrency.toUpperCase()]) {
if (parsedResponse[state.currentCurrency.toUpperCase()]) {
// ETC
this.setConversionRate(
Number(parsedResponse[currentCurrency.toUpperCase()]),
);
this.setConversionDate((Date.now() / 1000).toString());
this.update({
conversionRate: Number(
parsedResponse[state.currentCurrency.toUpperCase()],
),
conversionDate: (Date.now() / 1000).toString(),
});
} else {
this.setConversionRate(0);
this.setConversionDate('N/A');
this.update({
conversionRate: 0,
conversionDate: 'N/A',
});
}
} catch (error) {
// reset current conversion rate
console.warn(
'Quill - Failed to query currency conversion:',
nativeCurrency,
currentCurrency,
state?.nativeCurrency,
state?.currentCurrency,
error,
);
this.setConversionRate(0);
this.setConversionDate('N/A');
this.update({
conversionRate: 0,
conversionDate: 'N/A',
});
// throw error
console.error(
error,
`CurrencyController - Failed to query rate for currency "${currentCurrency}"`,
`CurrencyController - Failed to query rate for currency "${state?.currentCurrency}"`,
);
}
}

View File

@@ -1,14 +1,17 @@
import { BaseConfig, BaseState } from '../interfaces';
import * as io from 'io-ts';
export interface CurrencyControllerState extends BaseState {
currentCurrency: string;
conversionRate: number;
conversionDate: string;
nativeCurrency: string;
ticker: string;
}
import { BaseConfig } from '../interfaces';
export const CurrencyControllerState = io.type({
currentCurrency: io.string,
conversionRate: io.number,
conversionDate: io.string,
nativeCurrency: io.string,
});
export type CurrencyControllerState = io.TypeOf<typeof CurrencyControllerState>;
export interface CurrencyControllerConfig extends BaseConfig {
pollInterval: number;
api: string;
api?: string;
}

View File

@@ -6,7 +6,21 @@ import {
JRPCResponse,
} from '@toruslabs/openlogin-jrpc';
import { BigNumberish, BytesLike } from 'ethers';
import { PROVIDER_JRPC_METHODS } from '../../common/constants';
import web3_clientVersion from './web3_clientVersion';
type ProviderHandler<Params, Result> = (
req: JRPCRequest<Params>,
) => Promise<Result>;
function toAsyncMiddleware<Params, Result>(
method: ProviderHandler<Params, Result>,
) {
return createAsyncMiddleware(
async (req: JRPCRequest<Params>, res: JRPCResponse<Result>) => {
res.result = await method(req);
},
);
}
export type SendTransactionParams = {
from: string;
@@ -17,80 +31,20 @@ export type SendTransactionParams = {
data: BytesLike;
};
export interface IProviderHandlers {
version: string;
getAccounts: (req: JRPCRequest<unknown>) => Promise<string[]>;
requestAccounts: (req: JRPCRequest<unknown>) => Promise<string[]>;
getProviderState: (
req: JRPCRequest<unknown>,
) => Promise<{ accounts: string[]; chainId: string; isUnlocked: boolean }>;
submitBatch: (req: JRPCRequest<SendTransactionParams>) => Promise<string>;
}
export function createWalletMiddleware({
version,
getAccounts,
requestAccounts,
getProviderState,
submitBatch,
}: IProviderHandlers): JRPCMiddleware<string, unknown> {
if (!getAccounts) {
throw new Error('opts.getAccounts is required');
}
if (!requestAccounts) {
throw new Error('opts.requestAccounts is required');
}
if (!getProviderStateFromController) {
throw new Error('opts.getProviderState is required');
}
async function lookupAccounts(
req: JRPCRequest<unknown>,
res: JRPCResponse<unknown>,
): Promise<void> {
res.result = await getAccounts(req);
}
async function lookupDefaultAccount(
req: JRPCRequest<unknown>,
res: JRPCResponse<unknown>,
): Promise<void> {
const accounts = await getAccounts(req);
res.result = accounts[0] || null;
}
async function requestAccountsFromProvider(
req: JRPCRequest<unknown>,
res: JRPCResponse<unknown>,
): Promise<void> {
res.result = await requestAccounts(req);
}
async function getProviderStateFromController(
req: JRPCRequest<unknown>,
res: JRPCResponse<unknown>,
): Promise<void> {
res.result = await getProviderState(req);
}
async function submitTransaction(
req: JRPCRequest<SendTransactionParams>,
res: JRPCResponse<unknown>,
): Promise<void> {
res.result = await submitBatch(req);
}
return createScaffoldMiddleware({
web3_clientVersion: `Quill/v${version}`,
// account lookups
eth_accounts: createAsyncMiddleware(lookupAccounts),
eth_coinbase: createAsyncMiddleware(lookupDefaultAccount),
eth_requestAccounts: createAsyncMiddleware(requestAccountsFromProvider),
[PROVIDER_JRPC_METHODS.GET_PROVIDER_STATE]: createAsyncMiddleware(
getProviderStateFromController,
),
eth_sendTransaction: createAsyncMiddleware<any, any>(submitTransaction),
});
export type IProviderHandlers = Record<
string,
ProviderHandler<unknown, unknown>
>;
export function createWalletMiddleware(
handlers: IProviderHandlers,
): JRPCMiddleware<string, unknown> {
const asyncMiddlewares = Object.fromEntries(
Object.keys(handlers).map((method) => [
method,
toAsyncMiddleware(handlers[method]),
]),
);
return createScaffoldMiddleware({ web3_clientVersion, ...asyncMiddlewares });
}

View File

@@ -7,7 +7,6 @@ import {
mergeMiddleware,
} from '@toruslabs/openlogin-jrpc';
import { Aggregator } from 'bls-wallet-clients';
import { AGGREGATOR_URL } from '../../env';
import PollingBlockTracker from '../Block/PollingBlockTracker';
import { ProviderConfig } from '../constants';
@@ -98,7 +97,7 @@ function createAggregatorMiddleware(): JRPCMiddleware<unknown, unknown> {
if (hash in knownTransactions) {
const knownTx = knownTransactions[hash];
const aggregator = new Aggregator(AGGREGATOR_URL);
const aggregator = new Aggregator(knownTx.aggregatorUrl);
const bundleReceipt = await aggregator.lookupReceipt(hash);
if (bundleReceipt === undefined) {

View File

@@ -0,0 +1,6 @@
import { version } from '../../../package.json';
// eslint-disable-next-line @typescript-eslint/naming-convention
const web3_clientVersion = `Quill/v${version}`;
export default web3_clientVersion;

View File

@@ -14,8 +14,8 @@ import { BigNumber } from 'ethers';
import { Aggregator } from 'bls-wallet-clients';
import {
createRandomId,
getAllReqParam,
getDefaultProviderConfig,
getFirstReqParam,
getUserLanguage,
} from './utils';
import { ProviderConfig } from './constants';
@@ -31,10 +31,7 @@ import {
providerAsMiddleware,
SafeEventEmitterProvider,
} from './Network/INetworkController';
import {
IProviderHandlers,
SendTransactionParams,
} from './Network/createEthMiddleware';
import { SendTransactionParams } from './Network/createEthMiddleware';
import {
AddressPreferences,
PreferencesConfig,
@@ -60,6 +57,11 @@ import createMetaRPCHandler from './streamHelpers/MetaRPCHandler';
import { PROVIDER_NOTIFICATIONS } from '../common/constants';
import { AGGREGATOR_URL } from '../env';
import knownTransactions from './knownTransactions';
import CellCollection from '../cells/CellCollection';
import assert from '../helpers/assert';
import mapValues from '../helpers/mapValues';
import ExplicitAny from '../types/ExplicitAny';
import Rpc, { rpcMap } from '../types/Rpc';
export const DEFAULT_CONFIG = {
CurrencyControllerConfig: {
@@ -112,18 +114,25 @@ export default class QuillController extends BaseController<
private preferencesController!: PreferencesController;
// This is just kept in memory because it supports setting the preferred
// aggregator for the particular tab only.
private tabPreferredAggregators: Record<number, string> = {};
getRequestAccountTabIds: () => Record<string, number>;
getOpenQuillTabsIds: () => Record<number, boolean>;
// private txController!: TransactionController;
constructor({
config,
state,
}: {
config: Partial<QuillControllerConfig>;
state: Partial<QuillControllerState>;
}) {
constructor(
{
config,
state,
}: {
config: Partial<QuillControllerConfig>;
state: Partial<QuillControllerState>;
},
public storage: CellCollection,
) {
super({ config, state });
}
@@ -202,10 +211,10 @@ export default class QuillController extends BaseController<
state: this.state.NetworkControllerState,
});
this.initializeProvider();
this.currencyController = new CurrencyController({
config: this.config.CurrencyControllerConfig,
state: this.state.CurrencyControllerState,
});
this.currencyController = new CurrencyController(
this.config.CurrencyControllerConfig,
this.storage,
);
this.currencyController.updateConversionRate();
this.currencyController.scheduleConversionInterval();
@@ -287,14 +296,14 @@ export default class QuillController extends BaseController<
return {
// etc
getState: () => this.state,
setCurrentCurrency:
currencyController.setCurrentCurrency.bind(currencyController),
setCurrentCurrency: (currentCurrency) =>
currencyController.update({ currentCurrency }),
setCurrentLocale: preferencesController.setUserLocale.bind(
preferencesController,
),
getRequestAccountTabIds: this.getRequestAccountTabIds,
getOpenMetamaskTabsIds: this.getOpenQuillTabsIds,
getOpenQuillTabsIds: this.getOpenQuillTabsIds,
// network management
setProviderConfig:
@@ -350,9 +359,9 @@ export default class QuillController extends BaseController<
async setDefaultCurrency(currency: string): Promise<void> {
const { ticker } = this.networkController.getProviderConfig();
// This is ETH
this.currencyController.setNativeCurrency(ticker);
this.currencyController.update({ nativeCurrency: ticker });
// This is USD
this.currencyController.setCurrentCurrency(currency);
this.currencyController.update({ currentCurrency: currency });
await this.currencyController.updateConversionRate();
this.preferencesController.setSelectedCurrency(currency);
}
@@ -450,7 +459,7 @@ export default class QuillController extends BaseController<
// logging
engine.push(createLoggerMiddleware(console));
// forward to metamask primary provider
// forward to Quill primary provider
engine.push(providerAsMiddleware(provider));
return engine;
}
@@ -461,10 +470,6 @@ export default class QuillController extends BaseController<
this.update({ PreferencesControllerState: state });
});
this.currencyController.on('store', (state) => {
this.update({ CurrencyControllerState: state });
});
this.networkController.on('store', (state) => {
this.update({ NetworkControllerState: state });
});
@@ -492,11 +497,10 @@ export default class QuillController extends BaseController<
// });
}
private initializeProvider(): SafeEventEmitterProvider {
const providerHandlers: IProviderHandlers = {
version: '1.0.0',
private initializeProvider() {
this.networkController.initializeProvider({
// account management
requestAccounts: async (req) => {
eth_requestAccounts: async (req) => {
const accounts = await this.requestAccounts();
this.notifyConnections((req as any).origin, {
method: PROVIDER_NOTIFICATIONS.UNLOCK_STATE_CHANGED,
@@ -508,13 +512,9 @@ export default class QuillController extends BaseController<
return accounts;
},
getAccounts: async () =>
// Expose no accounts if this origin has not been approved, preventing
// account-requiring RPC methods from completing successfully
// only show address if account is unlocked
this.selectedAddress ? [this.selectedAddress] : [],
eth_coinbase: async () => this.selectedAddress || null,
getProviderState: async () => {
wallet_get_provider_state: async () => {
return {
accounts: this.selectedAddress ? [this.selectedAddress] : [],
chainId: this.networkController.state.chainId,
@@ -522,27 +522,35 @@ export default class QuillController extends BaseController<
};
},
submitBatch: async (req: any) => {
const txParams = getFirstReqParam<SendTransactionParams>(req);
eth_setPreferredAggregator: async (req: any) => {
// eslint-disable-next-line prefer-destructuring
this.tabPreferredAggregators[req.tabId] = req.params[0];
const nonce = await this.keyringController.getNonce(txParams.from);
const ethValue = txParams.value?.toString() ?? 0;
return 'ok';
},
eth_sendTransaction: async (req: any) => {
const txParams = getAllReqParam<SendTransactionParams[]>(req);
const { from } = txParams[0];
const actions = txParams.map((tx) => {
return {
ethValue: tx.value || '0',
contractAddress: tx.to,
encodedFunction: tx.data,
};
});
const nonce = await this.keyringController.getNonce(from);
const tx = {
nonce: nonce.toString(),
actions: [
{
ethValue,
contractAddress: txParams.to,
encodedFunction: txParams.data,
},
],
actions,
};
const bundle = await this.keyringController.signTransactions(
txParams.from,
tx,
);
const agg = new Aggregator(AGGREGATOR_URL);
const bundle = await this.keyringController.signTransactions(from, tx);
const aggregatorUrl =
this.tabPreferredAggregators[req.tabId] ?? AGGREGATOR_URL;
const agg = new Aggregator(aggregatorUrl);
const result = await agg.add(bundle);
if ('failures' in result) {
@@ -550,17 +558,81 @@ export default class QuillController extends BaseController<
}
knownTransactions[result.hash] = {
...txParams,
...txParams[0],
nonce: nonce.toString(),
value: ethValue,
value: txParams[0].value || '0',
aggregatorUrl,
};
return result.hash;
},
...this.makePublicRpc(),
...this.makePrivateRpc(),
});
}
private makePublicRpc(): Record<string, unknown> {
type MethodsWithOrigin = {
[M in keyof Rpc['public']]: (
origin: string,
params: Parameters<Rpc['public'][M]>,
) => ReturnType<Rpc['public'][M]>;
};
const providerProxy =
this.networkController.initializeProvider(providerHandlers);
return providerProxy;
const methods: MethodsWithOrigin = {
eth_accounts: async (origin) => {
if (origin === window.location.origin) {
return this.keyringController.state.wallets.map(
({ address }) => address,
);
}
// Expose no accounts if this origin has not been approved, preventing
// account-requiring RPC methods from completing successfully
// only show address if account is unlocked
// FIXME: The comment above is not yet implemented.
return this.selectedAddress ? [this.selectedAddress] : [];
},
};
return mapValues(methods, (method, methodName) => (req: any) => {
const params = req.params ?? [];
assert(rpcMap.public[methodName].params.is(params));
return (method as ExplicitAny)(req.origin, params);
});
}
private makePrivateRpc(): Record<string, unknown> {
const methods: Rpc['private'] = {
quill_setSelectedAddress: async (newSelectedAddress) => {
this.preferencesController.setSelectedAddress(newSelectedAddress);
return 'ok';
},
quill_createHDAccount: async () => {
return this.keyringController.createHDAccount();
},
quill_isOnboardingComplete: async () => {
return this.keyringController.isOnboardingComplete();
},
quill_setHDPhrase: async (phrase) => {
this.keyringController.setHDPhrase(phrase);
return 'ok';
},
};
return mapValues(methods, (method, methodName) => (req: any) => {
if (req.origin !== window.location.origin) {
return;
}
const params = req.params ?? [];
assert(rpcMap.private[methodName].params.is(params));
return (method as ExplicitAny)(...params);
});
}
private async requestAccounts(): Promise<string[]> {
@@ -618,7 +690,7 @@ export default class QuillController extends BaseController<
}
/**
* Adds a reference to a connection by origin. Ignores the 'metamask' origin.
* Adds a reference to a connection by origin. Ignores the 'quill' origin.
* Caller must ensure that the returned id is stored such that the reference
* can be deleted later.
*/

View File

@@ -20,13 +20,7 @@ import { getDefaultProviderConfig } from './utils';
import ControllerStoreStream from './streamHelpers/ControllerStoreStream';
import ControllerStreamSink from './streamHelpers/ControllerStreamSink';
import PortDuplexStream from '../common/PortStream';
window.QuillController = () => {
throw new Error('window.QuillController not initialized');
};
window.KeyringController = () => {
throw new Error('window.KeyringController not initialized');
};
import extensionLocalCellCollection from '../cells/extensionLocalCellCollection';
// const notificationManager = new NotificationManager();
@@ -90,22 +84,25 @@ async function loadStateFromPersistence(): Promise<QuillControllerState> {
}
/**
* Initializes the MetaMask Controller with any initial state and default language.
* Initializes the Quill Controller with any initial state and default language.
* Configures platform-specific error reporting strategy.
* Streams emitted state updates to platform-specific storage strategy.
* Creates platform listeners for new Dapps/Contexts, and sets up their data connections to the controller.
*/
function setupController(initState: unknown): void {
//
// MetaMask Controller
// Quill Controller
//
console.log(initState, 'initstate');
const controller = new QuillController({
// initial state
state: initState as QuillControllerState,
config: DEFAULT_CONFIG,
});
const controller = new QuillController(
{
// initial state
state: initState as QuillControllerState,
config: DEFAULT_CONFIG,
},
extensionLocalCellCollection,
);
controller.init({
state: initState as QuillControllerState,
@@ -116,16 +113,13 @@ function setupController(initState: unknown): void {
},
});
window.QuillController = () => controller;
window.KeyringController = () => controller.keyringController;
// setup state persistence
pump(
new ControllerStoreStream(controller),
// debounce(1000),
new ControllerStreamSink(persistData),
(error) => {
console.error('MetaMask - Persistence pipeline failed', error);
console.error('Quill - Persistence pipeline failed', error);
},
);
@@ -196,16 +190,16 @@ function setupController(initState: unknown): void {
};
/**
* Connects a Port to the MetaMask controller via a multiplexed duplex stream.
* This method identifies trusted (MetaMask) interfaces, and connects them differently from untrusted (web pages).
* Connects a Port to the Quill controller via a multiplexed duplex stream.
* This method identifies trusted (Quill) interfaces, and connects them differently from untrusted (web pages).
*
* @param {Port} remotePort - The port provided by a new context.
*/
function connectRemote(remotePort: Runtime.Port) {
const processName = remotePort.name;
const isMetaMaskInternalProcess = quillInternalProcessHash[processName];
const isQuillInternalProcess = quillInternalProcessHash[processName];
if (isMetaMaskInternalProcess) {
if (isQuillInternalProcess) {
const portStream = new PortDuplexStream(remotePort);
// communication with popup
controller.isClientOpen = true;
@@ -400,18 +394,18 @@ async function openPopup() {
// TODO: remove this later
if (process.env.NODE_ENV === 'random') openPopup();
// On first install, open a new tab with MetaMask
// On first install, open a new tab with Quill
runtime.onInstalled.addListener(({ reason }) => {
if (
reason === 'install' &&
!(process.env.METAMASK_DEBUG || process.env.IN_TEST)
!(process.env.QUILL_DEBUG || process.env.IN_TEST)
) {
openExtensionInBrowser();
}
});
function openExtensionInBrowser(route = null, queryString = null) {
let extensionURL = runtime.getURL('home.html');
let extensionURL = runtime.getURL('quillPage.html#/wallet');
if (route) {
extensionURL += `#${route}`;

View File

@@ -6,6 +6,7 @@ const knownTransactions: Record<
SendTransactionParams & {
nonce: string;
value: BigNumberish;
aggregatorUrl: string;
}
> = {};

View File

@@ -56,12 +56,20 @@ export const getRPCURL = (chainId: string): string => {
// But this is ok for now.
export const getFirstReqParam = <T>(req: any): T => {
if (!Array.isArray(req.params)) {
throw new Error(
'req.params not array',
);
throw new Error('req.params not array');
}
if (!req.params.length) {
throw new Error('req.params empty');
}
return req.params[0];
}
};
export const getAllReqParam = <T>(req: any): T => {
if (!Array.isArray(req.params)) {
throw new Error('req.params not array');
}
if (!req.params.length) {
throw new Error('req.params empty');
}
return req.params;
};

View File

@@ -1,117 +0,0 @@
import { ethers } from 'ethers';
import { Component, ReactElement } from 'react';
import Button from '../components/Button';
import { PageEvents } from '../components/Page';
import getPropOrUndefined from '../helpers/getPropOrUndefined';
import type QuillEthereumProvider from '../PageContentScript/InPageProvider';
type Props = { events: PageEvents };
export default class CreateTransaction extends Component<Props> {
amountRef?: HTMLInputElement;
recipientRef?: HTMLInputElement;
dataRef?: HTMLInputElement;
render(): ReactElement {
return (
<div className="create-transaction">
<div className="section">
<h1>Create Transaction</h1>
</div>
<div className="section body">
<div className="form">
<div>
Amount:{' '}
<input
ref={(r) => (this.amountRef = r ?? undefined)}
type="text"
style={{ textAlign: 'end' }}
/>{' '}
ETH
</div>
<div>
Contract/Recipient:{' '}
<input
ref={(r) => (this.recipientRef = r ?? undefined)}
type="text"
/>
</div>
<div>
Encoded Function Data:{' '}
<input
ref={(r) => (this.dataRef = r ?? undefined)}
type="text"
defaultValue="0x"
/>
</div>
<div>
<div style={{ display: 'inline-block' }}>
<Button
className="btn-primary"
onPress={async () => {
const provider = getPropOrUndefined(window, 'ethereum') as
| QuillEthereumProvider
| undefined;
if (provider === undefined) {
this.props.events.emit(
'notification',
'error',
'Ethereum provider not found',
);
return;
}
try {
const amount = this.amountRef?.value || undefined;
const to = this.recipientRef?.value || undefined;
const data = this.dataRef?.value || undefined;
if (amount === undefined || to === undefined) {
this.props.events.emit(
'notification',
'error',
'Field(s) missing',
);
return;
}
const result = await provider.request({
method: 'eth_sendTransaction',
params: [
{
value: ethers.utils
.parseEther(amount)
.toHexString(),
to,
data,
},
],
});
this.props.events.emit(
'notification',
'info',
`Result: ${result}`,
);
} catch (error) {
this.props.events.emit(
'notification',
'error',
(error as Error).message,
);
}
}}
>
Send
</Button>
</div>
</div>
</div>
</div>
</div>
);
}
}

View File

@@ -1,18 +0,0 @@
import { EventEmitter } from 'events';
import ReactDOM from 'react-dom';
import CreateTransaction from './CreateTransaction';
import Page, { PageEvents } from '../components/Page';
import '../ContentScript/index';
import './styles.scss';
const pageEvents = new EventEmitter() as PageEvents;
ReactDOM.render(
<Page events={pageEvents}>
<CreateTransaction events={pageEvents} />
</Page>,
document.getElementById('create-transaction-root'),
);

View File

@@ -1,13 +0,0 @@
@import "../styles/quill";
#create-transaction-root {
height: 100vh;
}
.create-transaction {
.form {
display: flex;
flex-direction: column;
gap: 5px;
}
}

View File

@@ -2,11 +2,7 @@ import type { JRPCRequest, JRPCSuccess } from '@toruslabs/openlogin-jrpc';
import { EthereumRpcError } from 'eth-rpc-errors';
import dequal from 'fast-deep-equal';
import type { Duplex } from 'readable-stream';
import {
PROVIDER,
PROVIDER_JRPC_METHODS,
PROVIDER_NOTIFICATIONS,
} from '../common/constants';
import { PROVIDER, PROVIDER_NOTIFICATIONS } from '../common/constants';
import BaseProvider from './BaseProvider';
import {
@@ -18,7 +14,7 @@ import {
} from './interfaces';
import messages from './messages';
class QuillInPageProvider extends BaseProvider<InPageProviderState> {
export class QuillInPageProvider extends BaseProvider<InPageProviderState> {
/**
* The chain ID of the currently connected Ethereum chain.
* See [chainId.network]{@link https://chainid.network} for more information.
@@ -99,7 +95,7 @@ class QuillInPageProvider extends BaseProvider<InPageProviderState> {
async _initializeState(): Promise<void> {
try {
const { accounts, chainId, isUnlocked } = (await this.request({
method: PROVIDER_JRPC_METHODS.GET_PROVIDER_STATE,
method: 'wallet_get_provider_state',
params: [],
})) as InPageWalletProviderState;

View File

@@ -41,13 +41,13 @@ export function initializeProvider({
}
// setup background connection
const metamaskStream = new BasePostMessageStream({
const quillStream = new BasePostMessageStream({
name: INPAGE,
target: CONTENT_SCRIPT,
});
initializeProvider({
connectionStream: metamaskStream,
connectionStream: quillStream,
// setting true will set window.ethereum to the provider
shouldSetOnWindow: true,
});

View File

@@ -1,74 +0,0 @@
import { Component, ReactNode } from 'react';
import TaskQueue from '../common/TaskQueue';
import type App from '../App';
import { AppState } from '../App';
import LoadingScreen from './components/LoadingScreen';
import Page from '../components/Page';
import WalletHomeScreen from './components/WalletHomeScreen';
import WelcomeScreen from './components/WelcomeScreen';
import KeyEntryScreen from './components/KeyEntryScreen';
import '../styles/index.scss';
type Props = {
appPromise: Promise<App>;
};
type State = {
app?: App;
appState?: AppState;
};
const useNewUI = true;
export default class Popup extends Component<Props, State> {
cleanupTasks = new TaskQueue();
constructor(props: Props) {
super(props);
this.state = {};
this.props.appPromise.then((app) => {
this.setState({ app });
app.events.on('state', appStateListener);
this.cleanupTasks.push(() => app.events.off('state', appStateListener));
});
const appStateListener = (appState: AppState) => {
this.setState({ appState });
};
}
componentWillUnmount(): void {
this.cleanupTasks.run();
}
render(): ReactNode {
if (!this.state.app) {
return (
<div className="popup">
<LoadingScreen />
</div>
);
}
return (
<Page classes={['popup']} events={this.state.app.pageEvents}>
{(() => {
if (this.state.app.state.privateKey === undefined) {
return useNewUI ? (
<WelcomeScreen />
) : (
<KeyEntryScreen app={this.state.app} />
);
}
return <WalletHomeScreen app={this.state.app} />;
})()}
</Page>
);
}
}

View File

@@ -1,68 +0,0 @@
import { Component, ReactNode } from 'react';
import App from '../../App';
import Button from '../../components/Button';
import LargeQuillHeading from './LargeQuillHeading';
type Props = {
app: App;
};
type State = {
pasteText: string;
};
export default class KeyEntryScreen extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
pasteText: '',
};
}
render(): ReactNode {
return (
<div className="key-entry-screen">
<LargeQuillHeading />
<div className="body">
<Button
className={this.state.pasteText === '' ? 'btn-primary' : 'btn'}
onPress={() => this.props.app.createPrivateKey()}
>
Create BLS Key
</Button>
<div style={{ color: '#ccc' }}>OR</div>
<div style={{ flexGrow: 1, position: 'relative' }}>
<textarea
placeholder="Paste BLS Private Key..."
value={this.state.pasteText}
onInput={(evt) => {
this.setState({
pasteText: (evt.target as HTMLTextAreaElement).value,
});
}}
/>
<div
className="restore-button"
style={this.state.pasteText === '' ? { display: 'none' } : {}}
>
<Button
className="btn-primary"
onPress={() =>
this.props.app.loadPrivateKey(this.state.pasteText)
}
>
Restore
</Button>
</div>
</div>
<div className="notice">
Quill&apos;s development is in Alpha, please do not to use large
sums of money
</div>
</div>
</div>
);
}
}

View File

@@ -1,12 +0,0 @@
import { ReactElement } from 'react';
import LargeQuillHeading from './LargeQuillHeading';
const LoadingScreen = (): ReactElement => (
<div>
<LargeQuillHeading />
Loading...
</div>
);
export default LoadingScreen;

View File

@@ -1,14 +0,0 @@
import App from '../../App';
import Button from '../../components/Button';
export default function NotImplemented(app: App): void {
app.pageEvents.emit('overlay', (close) => (
<>
<div style={{ marginBottom: '12px' }}>Not implemented</div>
<Button className="btn-primary" onPress={close}>
Ok
</Button>
</>
));
}

View File

@@ -1,7 +0,0 @@
import { ReactElement } from 'react';
export const overrideScreenEnabled = false;
const OverrideScreen = (): ReactElement => <>Override screen</>;
export default OverrideScreen;

View File

@@ -1,61 +0,0 @@
import { Component, ReactElement, ReactNode } from 'react';
import defineAction from '../../helpers/defineAction';
type Props = {
content: [string, ReactElement][];
defaultTab?: string;
};
type State = {
selectedTab: string;
};
export default class Tabs extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { selectedTab: props.defaultTab ?? props.content[0][0] };
}
render(): ReactNode {
const selectedContent = this.props.content.find(
([tabName]) => tabName === this.state.selectedTab,
)?.[1];
return (
<div className="tabs-container">
<div className="tab-chooser">
{this.props.content.map(([tabName], i) => {
const selected = tabName === this.state.selectedTab;
const notLast = i < this.props.content.length - 1;
const nextSelected =
notLast &&
this.props.content[i + 1][0] === this.state.selectedTab;
const needsRightBorder = notLast && !selected && !nextSelected;
const classes = [
...(selected ? ['selected'] : []),
...(needsRightBorder ? ['needs-right-border'] : []),
];
return (
<div
key={tabName}
className={classes.join(' ')}
{...defineAction(() =>
this.setState({
selectedTab: tabName,
}),
)}
>
{tabName}
</div>
);
})}
</div>
<div className="selected-content">{selectedContent}</div>
</div>
);
}
}

View File

@@ -1,36 +0,0 @@
import { Component, ReactElement } from 'react';
import App from '../../App';
import formatBalance from '../helpers/formatBalance';
type Props = {
app: App;
};
type State = {
contractAddressText: string;
};
const initialState: State = {
contractAddressText: '',
};
export default class TransactionTab extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = initialState;
}
render(): ReactElement {
return (
<div className="transaction-tab">
<div className="balance">
<div className="label">Balance:</div>
<div className="value">
{formatBalance(this.props.app.state.walletState.balance, 'ETH')}
</div>
</div>
</div>
);
}
}

View File

@@ -1,20 +0,0 @@
import { ReactElement } from 'react';
import App from '../../App';
import CompactQuillHeading from '../../components/CompactQuillHeading';
import Tabs from './Tabs';
import TransactionTab from './TransactionTab';
const TransactionsScreen = (props: { app: App }): ReactElement => (
<div className="transactions-screen">
<CompactQuillHeading />
<Tabs
content={[
['Transaction', <TransactionTab app={props.app} key={1} />],
['Outbox', <>Not implemented</>],
]}
/>
</div>
);
export default TransactionsScreen;

View File

@@ -1,283 +0,0 @@
import { ReactElement } from 'react';
import { runtime, tabs } from 'webextension-polyfill';
import { CREATE_TX_URL } from '../../env';
import assertExists from '../../helpers/assertExists';
import defineAction from '../../helpers/defineAction';
import App from '../../App';
import formatBalance from '../helpers/formatBalance';
import formatCompactAddress from '../helpers/formatCompactAddress';
import Button from '../../components/Button';
import CompactQuillHeading from '../../components/CompactQuillHeading';
import CopyIcon from './CopyIcon';
import Grow from './Grow';
export type BlsKey = {
public: string;
private: string;
};
type Props = {
app: App;
};
const WalletHomeScreen = ({ app }: Props): ReactElement => (
<div className="wallet-home-screen">
<div className="section">
<CompactQuillHeading />
</div>
<div className="section">
<div className="field-list">
<BLSKeyField app={app} />
<NetworkField />
{(() => {
const { walletAddress, walletState } = app.state;
if (walletAddress.loadCounter > 0 || !walletAddress.value) {
return (
<>
<div />
<Button
className="btn-primary"
onPress={() => {
/* no-op */
}}
loading={true}
>
Loading...
</Button>
</>
);
}
return (
<AddressField
app={app}
address={walletAddress.value}
nonce={walletState.nonce}
/>
);
})()}
</div>
</div>
<WalletContent app={app} />
</div>
);
export default WalletHomeScreen;
const BLSKeyField = ({ app }: Props): ReactElement | null => {
const publicKey = app.PublicKey();
if (!publicKey) {
return null;
}
return (
<div>
<div style={{ width: '17px' }}>
<img
src={runtime.getURL('assets/key.svg')}
alt="key"
width="14"
height="15"
/>
</div>
<div className="field-label">BLS Key:</div>
<div
className="field-value grow"
{...defineAction(() => {
navigator.clipboard.writeText(publicKey);
app.pageEvents.emit(
'notification',
'info',
'BLS public key copied to clipboard',
);
})}
>
<div className="grow">{formatCompactAddress(publicKey)}</div>
<CopyIcon />
</div>
<div className="field-trailer">
<KeyIcon
src={runtime.getURL('assets/download.svg')}
text="Backup private key"
onAction={() =>
app.pageEvents.emit('overlay', (close) => (
<CopyPrivateKeyPrompt app={app} close={close} />
))
}
/>
<KeyIcon
src={runtime.getURL('assets/trashcan.svg')}
text="Delete BLS key"
onAction={() =>
app.pageEvents.emit('overlay', (close) => (
<DeleteKeyPrompt app={app} close={close} />
))
}
/>
</div>
</div>
);
};
const NetworkField = (): ReactElement => (
<div>
<div style={{ width: '17px' }}>
<img
src={runtime.getURL('assets/network.svg')}
alt="network"
width="14"
height="15"
/>
</div>
<div className="field-label">Network:</div>
<select
className="field-value grow"
style={{
backgroundImage: `url("${runtime.getURL(
'assets/selector-down-arrow.svg',
)}")`,
}}
>
<option>Arbitrum</option>
<option disabled>Optimism</option>
</select>
<div className="field-trailer" />
</div>
);
const AddressField = (props: {
app: App;
address: string;
nonce?: string;
}): ReactElement => (
<div>
<div style={{ width: '17px' }}>
<img
src={runtime.getURL('assets/address.svg')}
alt="address"
width="14"
height="15"
/>
</div>
<div className="field-label">Address:</div>
<div
className="field-value grow"
{...defineAction(() => {
navigator.clipboard.writeText(props.address);
props.app.pageEvents.emit(
'notification',
'info',
'Address copied to clipboard',
);
})}
>
<div className="grow">{formatCompactAddress(props.address)}</div>
<CopyIcon />
</div>
<div className="field-trailer">#{props.nonce}</div>
</div>
);
const WalletContent = ({ app }: Props): ReactElement => {
if (!app.state.walletAddress.value) {
return <></>;
}
return (
<div className="section wallet-content">
<div className="balance">
<div className="label">Balance:</div>
<div className="value">
{formatBalance(app.state.walletState.balance, 'ETH')}
</div>
</div>
<Button
className="btn-primary"
onPress={() => {
tabs.create({
url: CREATE_TX_URL || 'createTransaction.html',
});
}}
>
Create Transaction
</Button>
</div>
);
};
const KeyIcon = (props: {
src: string;
text: string;
onAction: () => void;
}): ReactElement => (
<div className="key-icon" style={{ width: '22px', height: '22px' }}>
<img
src={props.src}
alt={props.text}
width="22"
height="22"
{...defineAction(props.onAction)}
/>
<div className="info-box">
<Grow />
<div className="content">{props.text}</div>
</div>
<div className="info-arrow" />
</div>
);
const DeleteKeyPrompt = (props: {
app: App;
close: () => void;
}): ReactElement => (
<div className="delete-key-prompt">
<div>
Are you sure that you want to delete this key? You can only restore your
wallet if you have backed up your private key.
</div>
<div />
<Button
className="btn-primary"
onPress={() => {
props.app.deletePrivateKey();
props.close();
}}
>
Delete BLS key
</Button>
<Button className="btn-secondary" onPress={props.close}>
Cancel
</Button>
</div>
);
const CopyPrivateKeyPrompt = (props: {
app: App;
close: () => void;
}): ReactElement => (
<div className="delete-key-prompt">
<div>
You should make sure you store your private key somewhere safe. If you
lose it, you wont be able to restore your wallet.
</div>
<div />
<Button
className="btn-primary"
onPress={() => {
navigator.clipboard.writeText(assertExists(props.app.state.privateKey));
props.close();
props.app.pageEvents.emit(
'notification',
'info',
'BLS private key copied to clipboard',
);
}}
>
Copy private key
</Button>
<Button className="btn-secondary" onPress={props.close}>
Cancel
</Button>
</div>
);

View File

@@ -7,7 +7,7 @@ import Carousel from './Carousel';
import LargeQuillHeading from './LargeQuillHeading';
const WelcomeScreen: FunctionComponent = () => (
<div>
<div className="welcome-screen">
<LargeQuillHeading />
<Carousel
images={[

View File

@@ -1,25 +1,7 @@
import { Aggregator } from 'bls-wallet-clients';
import { ethers } from 'ethers';
import ReactDOM from 'react-dom';
import { storage } from 'webextension-polyfill';
import App from '../App';
import { getDefaultProviderConfig } from '../Controllers/utils';
import { AGGREGATOR_URL } from '../env';
import Popup from './Popup';
import './styles.scss';
import '../styles/index.scss';
import WelcomeScreen from './components/WelcomeScreen';
const appPromise = (async () => {
const providerConfig = getDefaultProviderConfig();
return new App(
new Aggregator(AGGREGATOR_URL),
new ethers.providers.JsonRpcProvider(providerConfig.rpcTarget),
storage.local,
);
})();
ReactDOM.render(
<Popup appPromise={appPromise} />,
document.getElementById('popup-root'),
);
ReactDOM.render(<WelcomeScreen />, document.getElementById('popup-root'));

View File

@@ -7,7 +7,7 @@
}
.quill {
.popup {
.welcome-screen {
.key-entry-screen {
height: 100%;
display: flex;

View File

@@ -0,0 +1,69 @@
import { ethers } from 'ethers';
import { FunctionComponent, useMemo } from 'react';
import { FormulaCell } from '../../cells/FormulaCell';
import MemoryCell from '../../cells/MemoryCell';
import QuillContext from '../QuillContext';
import { Display } from './Display';
import TextBox from './TextBox';
const BalanceWidget: FunctionComponent = () => {
const quillCtx = QuillContext.use();
const cells = useMemo(() => {
const address = new MemoryCell('');
const balanceDisplay = new FormulaCell(
{ address, _: quillCtx.blockNumber },
// eslint-disable-next-line @typescript-eslint/no-shadow
async ({ address }) => {
const addressError = (() => {
if (!/^0x[0-9a-fA-F]{40}$/.test(address)) {
return new Error('Invalid address');
}
try {
// Handles mixed case checksums
ethers.utils.getAddress(address);
return undefined;
} catch (err) {
const error = err as Error;
if (error.message.includes('bad address checksum')) {
return new Error('bad address checksum');
}
return error;
}
})();
if (addressError) {
return `(${addressError.message})`;
}
const balance = await quillCtx.ethersProvider.getBalance(address);
return `ETH: ${(+ethers.utils.formatEther(balance)).toFixed(3)}`;
},
);
return { address, balanceDisplay };
}, [quillCtx]);
return (
<>
<tr>
<td>Address:</td>
<td>
<TextBox value={cells.address} />
</td>
</tr>
<tr>
<td>Balance:</td>
<td>
<Display cell={cells.balanceDisplay} />
</td>
</tr>
</>
);
};
export default BalanceWidget;

View File

@@ -0,0 +1,123 @@
import * as io from 'io-ts';
import { FunctionComponent, useMemo } from 'react';
import { FormulaCell } from '../../cells/FormulaCell';
import MemoryCell from '../../cells/MemoryCell';
import useCell from '../../cells/useCell';
import delay from '../../helpers/delay';
import Range from '../../helpers/Range';
import QuillContext from '../QuillContext';
import BalanceWidget from './BalanceWidget';
import CheckBox from './CheckBox';
import { Counter } from './Counter';
import DemoTable from './DemoTable';
import { DisplayJson } from './DisplayJson';
import Selector from './Selector';
export const CellsDemoPage: FunctionComponent = () => {
const quill = QuillContext.use();
const cells = useMemo(() => {
const page = new MemoryCell('math');
const a = quill.Cell('a', io.number, 3);
const b = new MemoryCell(5);
const c = new MemoryCell(0);
const includeSlow = new MemoryCell(true);
// eslint-disable-next-line @typescript-eslint/no-shadow
const ab = new FormulaCell({ a, b }, ({ a, b }) => a * b);
// eslint-disable-next-line @typescript-eslint/no-shadow
const abSlow = new FormulaCell({ a, b }, async ({ a, b }) => {
console.log(`calculating ${a}*${b}...`);
await delay(500);
const res = a * b;
console.log(`...${res}`);
return res;
});
return { page, a, b, c, includeSlow, ab, abSlow };
}, [quill]);
(window as any).cells = cells;
const includeSlowValue = useCell(cells.includeSlow);
const cValue = useCell(cells.c);
const pageValue = useCell(cells.page);
if (pageValue === undefined) {
return <></>;
}
return (
<DemoTable>
<tr>
<td style={{ height: '3em' }}>page</td>
<td>
<Selector
options={['math', 'blockNumber', 'balance', 'settings']}
selection={cells.page}
/>
</td>
</tr>
{pageValue === 'math' && (
<>
<tr>
<td>a:&nbsp;</td>
<td>
<Counter cell={cells.a} />
</td>
</tr>
<tr>
<td>b:&nbsp;</td>
<td>
<Counter cell={cells.b} />
</td>
</tr>
<tr>
<td>ab: </td>
<td>
<DisplayJson cell={cells.ab} />
</td>
</tr>
<tr>
<td>
abSlow: <CheckBox cell={cells.includeSlow} />
</td>
<td>{includeSlowValue && <DisplayJson cell={cells.abSlow} />}</td>
</tr>
</>
)}
{pageValue === 'blockNumber' && (
<>
<tr>
<td>components:&nbsp;</td>
<td>
<Counter cell={cells.c} />
</td>
</tr>
{Range(cValue ?? 0).map((i) => (
<tr key={i}>
<td>blockNumber: </td>
<td>
<DisplayJson cell={quill.blockNumber} />
</td>
</tr>
))}
</>
)}
{pageValue === 'balance' && <BalanceWidget />}
{pageValue === 'settings' && (
<>
<tr>
<td>Theme</td>
<td>
<Selector options={['light', 'dark']} selection={quill.theme} />
</td>
</tr>
</>
)}
</DemoTable>
);
};

View File

@@ -0,0 +1,13 @@
import { FunctionComponent } from 'react';
import DemoTable from './DemoTable';
export const CellsDemoPage2: FunctionComponent = () => {
return (
<DemoTable>
<tr>
<td>Hello:</td>
<td>World</td>
</tr>
</DemoTable>
);
};

View File

@@ -0,0 +1,22 @@
import { FunctionComponent } from 'react';
import ICell from '../../cells/ICell';
import useCell from '../../cells/useCell';
const CheckBox: FunctionComponent<{ cell: ICell<boolean> }> = ({ cell }) => {
const value = useCell(cell);
if (value === undefined) {
return <></>;
}
return (
<input
type="checkbox"
checked={value}
onChange={() => cell.write(!value)}
style={{ width: 'initial' }}
/>
);
};
export default CheckBox;

View File

@@ -0,0 +1,33 @@
import { FunctionComponent } from 'react';
import ICell from '../../cells/ICell';
import useCell from '../../cells/useCell';
import Button from '../../components/Button';
export const Counter: FunctionComponent<{
cell: ICell<number>;
}> = ({ cell }) => {
const value = useCell(cell);
if (value === undefined) {
return <></>;
}
return (
<div style={{ display: 'flex', flexDirection: 'row' }}>
<Button
className="btn-secondary"
onPress={() => value !== undefined && cell.write(value - 1)}
>
-
</Button>
<div>{value}</div>
<Button
className="btn-secondary"
onPress={() => value !== undefined && cell.write(value + 1)}
>
+
</Button>
</div>
);
};

View File

@@ -0,0 +1,25 @@
import { FunctionComponent } from 'react';
const DemoTable: FunctionComponent = ({ children }) => {
return (
<div
style={{
padding: '2em',
fontSize: '3em',
lineHeight: '1.5em',
fontFamily: 'monospace',
}}
>
<table className="demo-table">
<tbody>
<tr>
<td style={{ width: '380px', height: '0' }} />
</tr>
{children}
</tbody>
</table>
</div>
);
};
export default DemoTable;

View File

@@ -0,0 +1,11 @@
import React, { FunctionComponent } from 'react';
import { IReadableCell } from '../../cells/ICell';
import useCell from '../../cells/useCell';
export const Display: FunctionComponent<{
cell: IReadableCell<unknown>;
}> = ({ cell }) => {
const value = useCell(cell);
return <>{value}</>;
};

View File

@@ -0,0 +1,16 @@
import { FunctionComponent } from 'react';
import { IReadableCell } from '../../cells/ICell';
import useCell from '../../cells/useCell';
export const DisplayJson: FunctionComponent<{
cell: IReadableCell<unknown>;
}> = ({ cell }) => {
const value = useCell(cell);
return (
<pre style={{ display: 'inline-block' }}>
{JSON.stringify(value, null, 2)}
</pre>
);
};

View File

@@ -0,0 +1,30 @@
import { FunctionComponent } from 'react';
import ICell from '../../cells/ICell';
import useCell from '../../cells/useCell';
const Selector: FunctionComponent<{
options: string[];
selection: ICell<string>;
}> = ({ options, selection }) => {
const selectionValue = useCell(selection);
if (selectionValue === undefined) {
return <></>;
}
return (
<select
value={selectionValue}
onChange={(evt) => {
selection.write(options[evt.target.selectedIndex]);
}}
style={{ border: '1px solid black' }}
>
{options.map((option) => (
<option key={option}>{option}</option>
))}
</select>
);
};
export default Selector;

View File

@@ -0,0 +1,17 @@
import { FunctionComponent } from 'react';
import ICell from '../../cells/ICell';
import useCell from '../../cells/useCell';
const TextBox: FunctionComponent<{ value: ICell<string> }> = ({ value }) => {
const valueValue = useCell(value) ?? '';
return (
<input
type="text"
value={valueValue}
onInput={(evt) => value.write((evt.target as HTMLInputElement).value)}
/>
);
};
export default TextBox;

View File

@@ -1,21 +1,24 @@
import { FunctionComponent, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import QuillContext from '../QuillContext';
import OnboardingActionPanel from './OnboardingActionPanel';
import OnboardingInfoPanel from './OnboardingInfoPanel';
const OnboardingPage: FunctionComponent = () => {
const quillCtx = QuillContext.use();
const navigate = useNavigate();
useEffect(() => {
const onboardingComplete = window
.KeyringController()
.isOnboardingComplete();
(async () => {
const onboardingComplete =
await quillCtx.rpc.private.quill_isOnboardingComplete();
if (onboardingComplete) {
navigate('/wallet/');
}
}, [navigate]);
if (onboardingComplete) {
navigate('/wallet/');
}
})();
}, [navigate, quillCtx]);
return (
<div className="flex h-screen">

View File

@@ -4,6 +4,7 @@ import { ArrowRight } from 'phosphor-react';
import { useNavigate } from 'react-router-dom';
import Button from '../../components/Button';
import Range from '../../helpers/Range';
import QuillContext from '../QuillContext';
const WordInReview: FunctionComponent<{
index: number;
@@ -49,6 +50,8 @@ const ReviewSecretPhrasePanel: FunctionComponent<{
sampleIndexes?: number[];
onBack: () => void;
}> = ({ secretPhrase, sampleIndexes = [0, 3, 9, 11], onBack }) => {
const quillCtx = QuillContext.use();
const len = sampleIndexes.length;
const [reviewWordStates, setReviewWordStates] = useState<boolean[]>(
@@ -67,11 +70,9 @@ const ReviewSecretPhrasePanel: FunctionComponent<{
const navigate = useNavigate();
const setHDWalletPhrase = async () => {
window.KeyringController().setHDPhrase(secretPhrase.join(' '));
window.KeyringController().createHDAccount();
const accounts = window.KeyringController().getAccounts();
window.QuillController().getApi().setSelectedAddress(accounts[0]);
await quillCtx.rpc.private.quill_setHDPhrase(secretPhrase.join(' '));
const address = await quillCtx.rpc.private.quill_createHDAccount();
await quillCtx.rpc.private.quill_setSelectedAddress(address);
navigate('/wallet');
};

View File

@@ -0,0 +1,68 @@
import * as io from 'io-ts';
import { createContext, useContext } from 'react';
import { ethers } from 'ethers';
import mapValues from '../helpers/mapValues';
import assert from '../helpers/assert';
import { QuillInPageProvider } from '../PageContentScript/InPageProvider';
import ExplicitAny from '../types/ExplicitAny';
import Rpc, { rpcMap } from '../types/Rpc';
import TimeCell from './TimeCell';
import approximate from './approximate';
import { FormulaCell } from '../cells/FormulaCell';
import elcc from '../cells/extensionLocalCellCollection';
export default class QuillContext {
ethersProvider: ethers.providers.Provider;
rpc: Rpc;
Cell = elcc.Cell.bind(elcc);
time = TimeCell(100);
blockNumber = new FormulaCell(
{ _: approximate(this.time, 5000) },
async () => {
console.log('getting block number');
return Number(await this.ethereum.request({ method: 'eth_blockNumber' }));
},
);
theme = this.Cell('theme', io.string, 'light');
constructor(public ethereum: QuillInPageProvider) {
this.ethersProvider = new ethers.providers.Web3Provider(ethereum);
this.rpc = {
public: mapValues(
rpcMap.public,
({ params: paramsType, output }, method) => {
return async (...params: unknown[]) => {
assert(paramsType.is(params));
const response = await this.ethereum.request({ method, params });
assert(output.is(response));
return response as ExplicitAny;
};
},
),
private: mapValues(
rpcMap.private,
({ params: paramsType, output }, method) => {
return async (...params: unknown[]) => {
assert(paramsType.is(params));
const response = await this.ethereum.request({ method, params });
assert(output.is(response));
return response as ExplicitAny;
};
},
),
};
}
private static context = createContext<QuillContext>({} as QuillContext);
static Provider = QuillContext.context.Provider;
static use() {
// eslint-disable-next-line react-hooks/rules-of-hooks
return useContext(QuillContext.context);
}
}

View File

@@ -3,15 +3,28 @@ import { HashRouter, Route, Routes } from 'react-router-dom';
import OnboardingPage from './Onboarding/OnboardingPage';
import { WalletPage } from './Wallet/WalletPage';
import { CellsDemoPage } from './CellsDemo/CellsDemoPage';
import { CellsDemoPage2 } from './CellsDemo/CellsDemoPage2';
import QuillContext from './QuillContext';
import useCell from '../cells/useCell';
const QuillPage: FunctionComponent = () => {
const quillCtx = QuillContext.use();
const theme = useCell(quillCtx.theme);
return (
<HashRouter>
<Routes>
<Route path="/onboarding" element={<OnboardingPage />} />
<Route path="/wallet/*" element={<WalletPage />} />
</Routes>
</HashRouter>
<div className={`themable1 ${theme === 'dark' && 'dark-theme'}`}>
<div className={`themable2 ${theme === 'dark' && 'dark-theme'}`}>
<HashRouter>
<Routes>
<Route path="/onboarding" element={<OnboardingPage />} />
<Route path="/wallet/*" element={<WalletPage />} />
<Route path="/cells-demo" element={<CellsDemoPage />} />
<Route path="/cells-demo2" element={<CellsDemoPage2 />} />
</Routes>
</HashRouter>
</div>
</div>
);
};

View File

@@ -0,0 +1,17 @@
import { IReadableCell } from '../cells/ICell';
import MemoryCell from '../cells/MemoryCell';
import delay from '../helpers/delay';
export default function TimeCell(accuracy: number): IReadableCell<number> {
const cell = new MemoryCell(Math.floor(Date.now() / accuracy));
(async () => {
while (true) {
const now = Date.now();
await delay(accuracy * Math.ceil(now / accuracy) - now);
await cell.write(accuracy * Math.round(Date.now() / accuracy));
}
})();
return cell;
}

View File

@@ -1,5 +1,6 @@
import * as React from 'react';
import { Route, Routes, useNavigate } from 'react-router-dom';
import QuillContext from '../QuillContext';
import { ConnectionsWrapper } from './Connections/ConnectionWrapper';
import { ContactsWrapper } from './Contacts/ContactsWrapper';
import { Navigation } from './Navigation';
@@ -42,18 +43,20 @@ const routes: IRoutes[] = [
];
export const WalletPage: React.FunctionComponent = () => {
const quillCtx = QuillContext.use();
const navigate = useNavigate();
React.useEffect(() => {
const onboardingComplete = window
.KeyringController()
.isOnboardingComplete();
console.debug('onboardingComplete', onboardingComplete);
(async () => {
const onboardingComplete =
await quillCtx.rpc.private.quill_isOnboardingComplete();
console.debug('onboardingComplete', onboardingComplete);
if (!onboardingComplete) {
navigate('/onboarding?p=1');
}
}, [navigate]);
if (!onboardingComplete) {
navigate('/onboarding?p=1');
}
})();
}, [navigate, quillCtx]);
return (
<div className="flex h-screen">

View File

@@ -1,5 +1,6 @@
import { FunctionComponent, useEffect, useState } from 'react';
import Button from '../../../components/Button';
import QuillContext from '../../QuillContext';
import { WalletSummary } from './WalletSummary';
export interface IWallet {
@@ -11,34 +12,36 @@ export interface IWallet {
}
export const WalletsWrapper: FunctionComponent = () => {
const quillCtx = QuillContext.use();
const [selected, setSelected] = useState<number>(0);
const [wallets, setWallets] = useState<IWallet[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const setSelectedAddress = (address: string) => {
window.QuillController().getApi().setSelectedAddress(address);
};
useEffect(() => {
setLoading(true);
(async () => {
setLoading(true);
const accounts = window.KeyringController().getAccounts();
const accounts = await quillCtx.rpc.public.eth_accounts();
setWallets(
accounts.map((address: string, index: number) => {
return {
address,
name: `wallet ${index}`,
ether: 0,
networks: 1,
tokens: 0,
};
}),
);
setLoading(false);
setWallets(
accounts.map((address: string, index: number) => {
return {
address,
name: `wallet ${index}`,
ether: 0,
networks: 1,
tokens: 0,
};
}),
);
setLoading(false);
setSelectedAddress(accounts[0]);
}, []);
if (accounts[0]) {
quillCtx.rpc.private.quill_setSelectedAddress(accounts[0]);
}
})();
}, [quillCtx]);
return (
<div className="">
@@ -46,7 +49,7 @@ export const WalletsWrapper: FunctionComponent = () => {
<div className="text-body">Wallets</div>
<Button
onPress={async () => {
await window.KeyringController().createHDAccount();
await quillCtx.rpc.private.quill_createHDAccount();
window.location.reload();
}}
children={'Add Wallet'}
@@ -62,7 +65,7 @@ export const WalletsWrapper: FunctionComponent = () => {
<WalletSummary
onClick={() => {
setSelected(index);
setSelectedAddress(wallet.address);
quillCtx.rpc.private.quill_setSelectedAddress(wallet.address);
}}
key={wallet.name}
wallet={wallet}

View File

@@ -0,0 +1,20 @@
import { FormulaCell } from '../cells/FormulaCell';
import { IReadableCell } from '../cells/ICell';
export default function approximate(
value: IReadableCell<number>,
accuracy: number,
): IReadableCell<number> {
return new FormulaCell<{ value: IReadableCell<number> }, number>(
{ value },
// eslint-disable-next-line @typescript-eslint/no-shadow
({ value }) => value,
(previous, latest) => {
if (previous === undefined) {
return true;
}
return Math.abs(latest - previous) >= accuracy;
},
);
}

View File

@@ -0,0 +1,33 @@
import assert from '../helpers/assert';
import { QuillInPageProvider } from '../PageContentScript/InPageProvider';
export default function getWindowEthereum() {
const windowAny = window as any;
if (windowAny.ethereum?.isQuill) {
return Promise.resolve(windowAny.ethereum);
}
return new Promise<QuillInPageProvider>((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('Timed out waiting for window.ethereum'));
}, 1000);
function handleEthereumInitialized() {
clearTimeout(timeout);
window.removeEventListener(
'ethereum#initialized',
handleEthereumInitialized,
);
const { ethereum } = windowAny;
assert(ethereum?.isQuill === true);
resolve(ethereum);
}
window.addEventListener('ethereum#initialized', handleEthereumInitialized);
});
}

View File

@@ -1,9 +1,23 @@
import '../ContentScript';
import '../styles/index.scss';
import './styles.scss';
import ReactDOM from 'react-dom';
import QuillPage from './QuillPage';
import QuillContext from './QuillContext';
import getWindowEthereum from './getWindowEthereum';
import Browser from 'webextension-polyfill';
import '../styles/index.scss';
import './styles.scss';
import '../Controllers/background';
(window as any).Browser ??= Browser;
ReactDOM.render(<QuillPage />, document.getElementById('quill-page-root'));
getWindowEthereum().then((ethereum) => {
const quillContext = new QuillContext(ethereum);
ReactDOM.render(
<QuillContext.Provider value={quillContext}>
<QuillPage />
</QuillContext.Provider>,
document.getElementById('quill-page-root'),
);
});

View File

@@ -0,0 +1,7 @@
type AsyncIteratee<I extends AsyncIterable<unknown>> = I extends AsyncIterable<
infer T
>
? T
: never;
export default AsyncIteratee;

View File

@@ -0,0 +1,206 @@
import { EventEmitter } from 'events';
import * as io from 'io-ts';
import ExplicitAny from '../types/ExplicitAny';
import ICell, { CellEmitter } from './ICell';
import CellIterator from './CellIterator';
import jsonHasChanged from './jsonHasChanged';
import assert from '../helpers/assert';
import IAsyncStorage from './IAsyncStorage';
export default class CellCollection {
cells: Record<string, CollectionCell<ExplicitAny> | undefined> = {};
#asyncStorageChangeHandler = (keys: string[]) => {
for (const key of keys) {
const cell = this.cells[key];
if (cell) {
cell.versionedRead();
}
}
};
constructor(public asyncStorage: IAsyncStorage) {
asyncStorage.events.on('change', this.#asyncStorageChangeHandler);
}
/**
* A `CellCollection` is usually intended to live for the life of your
* application. In that case, calling .end is not required. However, if you're
* doing something different and you'd like to clean up the change event
* handler, this will take care of that.
*/
end() {
this.asyncStorage.events.removeListener(
'change',
this.#asyncStorageChangeHandler,
);
}
Cell<T>(
key: string,
type: io.Type<T>,
defaultValue: T,
hasChanged = jsonHasChanged,
): CollectionCell<T> {
let cell = this.cells[key];
if (cell) {
cell.applyType(type);
return cell;
}
cell = new CollectionCell(
this.asyncStorage,
key,
type,
defaultValue,
hasChanged,
);
this.cells[key] = cell;
return cell;
}
async remove(key: string) {
const cell = this.cells[key];
cell?.end();
delete this.cells[key];
await this.asyncStorage.write(key, io.undefined, undefined);
}
}
export type Versioned<T> = { version: number; value: T };
export class CollectionCell<T> implements ICell<T> {
events = new EventEmitter() as CellEmitter<T>;
ended = false;
versionedType: io.Type<Versioned<T>>;
lastSeen?: Versioned<T>;
initialRead: Promise<void>;
constructor(
public asyncStorage: IAsyncStorage,
public key: string,
public type: io.Type<T>,
public defaultValue: T,
public hasChanged: ICell<T>['hasChanged'] = jsonHasChanged,
) {
this.versionedType = io.type({ version: io.number, value: type });
this.initialRead = this.versionedRead().then((versionedReadResult) => {
this.lastSeen = versionedReadResult;
});
}
applyType<X>(type: io.Type<X>) {
if (type === io.unknown) {
return;
}
if (this.type !== io.unknown && this.type.name !== type.name) {
throw new Error(
[
'Tried to get existing storage cell with a different type',
`(type: ${type.name}, existing: ${this.type.name})`,
].join(' '),
);
}
if (this.lastSeen && !type.is(this.lastSeen.value)) {
throw new Error(
[
`Type mismatch at storage key ${this.key}`,
`contents: ${JSON.stringify(this.lastSeen.value)}`,
`expected: ${type.name}`,
].join(' '),
);
}
if (this.type === io.unknown) {
this.type = type as unknown as io.Type<T>;
this.versionedType = io.type({ version: io.number, value: this.type });
}
}
async read(): Promise<T> {
const latest = await this.versionedRead();
return latest.value;
}
async write(newValue: T): Promise<void> {
assert(!this.ended);
assert(this.type.is(newValue));
await this.initialRead;
const newVersionedValue = {
version: (this.lastSeen?.version ?? 0) + 1,
value: newValue,
};
const latest = await this.versionedRead();
if (!(newVersionedValue.version > latest.version)) {
throw new Error('Rejecting write which is not newer than remote');
}
await this.asyncStorage.write(
this.key,
this.versionedType,
newVersionedValue,
);
const { lastSeen: previous } = this;
this.lastSeen = newVersionedValue;
if (this.hasChanged(previous?.value, newValue)) {
this.events.emit('change', {
previous: previous?.value,
latest: newVersionedValue.value,
});
}
}
end() {
this.events.emit('end');
this.ended = true;
}
[Symbol.asyncIterator](): AsyncIterator<T> {
return new CellIterator(this);
}
async versionedRead(): Promise<Versioned<T>> {
const readResult = await this.asyncStorage.read(
this.key,
this.versionedType,
) ?? { version: 0, value: this.defaultValue };
if (!this.versionedType.is(readResult)) {
throw new Error(
[
`Type mismatch at storage key ${this.key}`,
`contents: ${JSON.stringify(readResult)}`,
`expected: ${this.versionedType.name}`,
].join(' '),
);
}
if (this.hasChanged(this.lastSeen?.value, readResult.value)) {
this.events.emit('change', {
previous: this.lastSeen?.value,
latest: readResult.value,
});
}
this.lastSeen = readResult;
return readResult;
}
}

View File

@@ -0,0 +1,69 @@
import { EventEmitter } from 'events';
import TypedEmitter from 'typed-emitter';
import { ChangeEvent, IReadableCell } from './ICell';
export default class CellIterator<T> implements AsyncIterator<T> {
lastProvided?: { value: T };
cleanup = () => {};
endHandler = () => {};
endListener = () => {
this.endHandler();
};
events = new EventEmitter() as TypedEmitter<{
finished(): void;
}>;
constructor(public cell: IReadableCell<T>) {
this.cell.events.once('end', this.endListener);
this.cleanup = () => {
this.cell.events.off('end', this.endListener);
};
}
async next() {
const latestRead = await this.cell.read();
if (
this.lastProvided === undefined ||
this.cell.hasChanged(this.lastProvided.value, latestRead)
) {
this.lastProvided = { value: latestRead };
return { value: latestRead, done: false };
}
if (this.cell.ended) {
return { value: undefined, done: true as const };
}
return new Promise<IteratorResult<T>>((resolve) => {
const changeHandler = ({ latest }: ChangeEvent<T>) => {
this.cleanup();
this.lastProvided = { value: latest };
resolve({ value: latest });
};
this.cell.events.once('change', changeHandler);
this.endHandler = () => {
this.cleanup();
resolve({ value: undefined, done: true });
};
this.cleanup = () => {
this.cell.events.off('change', changeHandler);
this.cell.events.off('end', this.endListener);
this.cleanup = () => {};
};
});
}
async return() {
this.cleanup();
this.events.emit('finished');
return { value: undefined, done: true as const };
}
}

View File

@@ -0,0 +1,238 @@
import { EventEmitter } from 'events';
import TypedEmitter from 'typed-emitter';
import AsyncReturnType from '../types/AsyncReturnType';
import ExplicitAny from '../types/ExplicitAny';
import CellIterator from './CellIterator';
import { IReadableCell, ChangeEvent } from './ICell';
import jsonHasChanged from './jsonHasChanged';
import recordKeys from '../helpers/recordKeys';
import MemoryCell from './MemoryCell';
import AsyncIteratee from './AsyncIteratee';
import Stoppable from './Stoppable';
import nextEvent from './nextEvent';
type InputValues<InputCells extends Record<string, IReadableCell<unknown>>> = {
[K in keyof InputCells]: AsyncReturnType<InputCells[K]['read']>;
};
export class FormulaCell<
InputCells extends Record<string, IReadableCell<unknown>>,
T,
> implements IReadableCell<Awaited<T>>
{
events = new EventEmitter() as TypedEmitter<{
change(changeEvent: ChangeEvent<T>): void;
end(): void;
'zero-iterators'(): void;
}>;
lastProvidedValue?: Awaited<T>;
iterationCell?: MemoryCell<'pending' | { value: Awaited<T> }>;
ended = false;
iteratorCount = 0;
constructor(
public inputCells: InputCells,
public formula: (inputValues: InputValues<InputCells>) => T,
public hasChanged: (
previous: Awaited<T> | undefined,
latest: Awaited<T>,
) => boolean = jsonHasChanged,
) {}
end() {
this.events.emit('end');
this.ended = true;
}
async read(): Promise<Awaited<T>> {
const iterationCell = this.iterateInputs();
try {
for await (const iterationState of iterationCell) {
if (iterationState === 'pending') {
continue;
}
return iterationState.value;
}
} finally {
this.decrementIterators();
}
throw new Error('Inputs ended');
}
[Symbol.asyncIterator](): AsyncIterator<Awaited<T>> {
this.iterateInputs();
const iterator = new CellIterator<Awaited<T>>(this);
iterator.events.on('finished', () => {
this.decrementIterators();
});
return iterator;
}
decrementIterators() {
this.iteratorCount -= 1;
if (this.iteratorCount === 0) {
this.events.emit('zero-iterators');
}
}
iterateInputs() {
this.iteratorCount += 1;
if (this.iterationCell) {
return this.iterationCell;
}
this.iterationCell = new MemoryCell<'pending' | { value: Awaited<T> }>(
'pending',
(previous, latest) => {
if (previous === undefined) {
return true;
}
if (previous === 'pending' || latest === 'pending') {
return previous !== latest;
}
return this.hasChanged(previous.value, latest.value);
},
);
const { iterationCell } = this;
(async () => {
const stoppableSequence = new Stoppable(
toIterableOfRecords(this.inputCells),
);
{
const handler = () => {
stoppableSequence.stop();
this.events.off('zero-iterators', handler);
this.events.off('end', handler);
};
this.events.once('zero-iterators', handler);
this.events.once('end', handler);
}
for await (const inputValues of stoppableSequence) {
const latest = await this.formula(
inputValues as InputValues<InputCells>,
);
iterationCell.write({ value: latest });
if (this.hasChanged(this.lastProvidedValue, latest)) {
this.events.emit('change', {
previous: this.lastProvidedValue,
latest,
});
this.lastProvidedValue = latest;
}
}
iterationCell.end();
this.iterationCell = undefined;
if (this.iteratorCount > 0) {
this.end();
}
})();
return iterationCell;
}
}
function toIterableOfRecords<R extends Record<string, AsyncIterable<unknown>>>(
recordOfIterables: R,
): AsyncIterable<{ [K in keyof R]: AsyncIteratee<R[K]> }> {
return {
[Symbol.asyncIterator]() {
const latest = {} as { [K in keyof R]: AsyncIteratee<R[K]> };
let latestVersion = 0;
let providedVersion = 0;
let keysFilled = 0;
const keysNeeded = recordKeys(recordOfIterables).length;
let cleanup = () => {};
const events = new EventEmitter() as TypedEmitter<{
updated(): void;
end(): void;
}>;
const endedPromise = nextEvent(events, 'end');
function end() {
events.emit('end');
cleanup();
}
for (const key of recordKeys(recordOfIterables)) {
// eslint-disable-next-line no-loop-func
(async () => {
const stoppableSequence = new Stoppable(recordOfIterables[key]);
endedPromise.then(() => stoppableSequence.stop());
for await (const value of stoppableSequence) {
if (!(key in latest)) {
keysFilled += 1;
}
latest[key] = value as ExplicitAny;
if (keysFilled === keysNeeded) {
latestVersion += 1;
events.emit('updated');
}
}
})();
}
return {
async next() {
if (latestVersion > providedVersion) {
providedVersion = latestVersion;
return { value: latest };
}
return new Promise<IteratorResult<typeof latest>>((resolve) => {
const updatedHandler = () => {
cleanup();
providedVersion = latestVersion;
resolve({ value: latest });
};
events.on('updated', updatedHandler);
const endHandler = () => {
cleanup();
resolve({ value: undefined, done: true });
};
events.on('end', endHandler);
cleanup = () => {
events.off('updated', updatedHandler);
events.off('end', endHandler);
};
});
},
async return(): Promise<IteratorResult<typeof latest>> {
end();
return { value: undefined, done: true };
},
};
},
};
}

View File

@@ -0,0 +1,12 @@
import * as io from 'io-ts';
import TypedEmitter from 'typed-emitter';
type IAsyncStorage = {
read<T>(key: string, type: io.Type<T>): Promise<T | undefined>;
write<T>(key: string, type: io.Type<T>, value: T | undefined): Promise<void>;
events: TypedEmitter<{
change(keys: string[]): void,
}>;
};
export default IAsyncStorage;

View File

@@ -0,0 +1,24 @@
import TypedEmitter from 'typed-emitter';
type ICell<T> = {
events: CellEmitter<T>;
ended: boolean;
read(): Promise<T>;
write(newValue: T): Promise<void>;
hasChanged(previous: T | undefined, latest: T): boolean;
[Symbol.asyncIterator](): AsyncIterator<T>;
};
export type IReadableCell<T> = Omit<ICell<T>, 'write'>;
export type ChangeEvent<T> = {
previous?: T;
latest: T;
};
export type CellEmitter<T> = TypedEmitter<{
change(changeEvent: ChangeEvent<T>): void;
end(): void;
}>;
export default ICell;

View File

@@ -0,0 +1,43 @@
import { EventEmitter } from 'events';
import assert from '../helpers/assert';
import CellIterator from './CellIterator';
import ICell, { CellEmitter } from './ICell';
import jsonHasChanged from './jsonHasChanged';
export default class MemoryCell<T> implements ICell<T> {
events = new EventEmitter() as CellEmitter<T>;
ended = false;
constructor(
public value: T,
public hasChanged: ICell<T>['hasChanged'] = jsonHasChanged,
) {}
async read(): Promise<T> {
return this.value;
}
async write(newValue: T): Promise<void> {
assert(!this.ended);
const { value: previous } = this;
this.value = newValue;
if (this.hasChanged(previous, this.value)) {
this.events.emit('change', {
previous,
latest: this.value,
});
}
}
end() {
this.events.emit('end');
this.ended = true;
}
[Symbol.asyncIterator](): AsyncIterator<T> {
return new CellIterator(this);
}
}

View File

@@ -0,0 +1,41 @@
import { EventEmitter } from 'events';
import * as io from 'io-ts';
import assert from '../helpers/assert';
import CellCollection from './CellCollection';
import IAsyncStorage from './IAsyncStorage';
export default function MemoryCellCollection(
memory: Record<string, unknown> = {},
) {
const events = new EventEmitter() as IAsyncStorage['events'];
return new CellCollection({
async read<T>(key: string, type: io.Type<T>): Promise<T | undefined> {
const readResult = memory[key];
if (readResult !== undefined) {
assert(type.is(readResult));
}
return readResult;
},
async write<T>(
key: string,
type: io.Type<T>,
value: T | undefined,
): Promise<void> {
if (value !== undefined) {
assert(type.is(value));
}
memory[key] = value;
events.emit('change', [key]);
},
events,
});
}

View File

@@ -0,0 +1,10 @@
# cells
Cells is an internal library designed to enable:
- Persistence
- Synchronization
- Modelling interior mutability
## [Video walkthrough](https://www.youtube.com/watch?v=0BwxE_EUcug)
[![Video walkthrough](https://img.youtube.com/vi/0BwxE_EUcug/0.jpg)](https://www.youtube.com/watch?v=0BwxE_EUcug)

View File

@@ -0,0 +1,44 @@
import { EventEmitter } from 'events';
import { once } from 'lodash-es';
import TypedEmitter from 'typed-emitter';
import raceWithEvent from './raceWithEvent';
export default class Stoppable<T> {
stopped = false;
events = new EventEmitter() as TypedEmitter<{
stopped(): void;
}>;
constructor(public asyncIterable: AsyncIterable<T>) {}
stop() {
if (this.stopped) {
return;
}
this.stopped = true;
this.events.emit('stopped');
}
[Symbol.asyncIterator]() {
const aiIterator = this.asyncIterable[Symbol.asyncIterator]();
const aiIteratorReturn = once(() => aiIterator.return?.());
return {
next: async () => {
if (this.stopped) {
return { value: undefined, done: true };
}
return raceWithEvent(aiIterator.next(), this.events, 'stopped', () => {
aiIteratorReturn();
return { value: undefined, done: true };
});
},
return() {
return aiIteratorReturn();
},
};
}
}

View File

@@ -0,0 +1,45 @@
import { EventEmitter } from 'events';
import * as io from 'io-ts';
import Browser from 'webextension-polyfill';
import assert from '../helpers/assert';
import CellCollection from './CellCollection';
import IAsyncStorage from './IAsyncStorage';
const events = new EventEmitter() as IAsyncStorage['events'];
Browser.storage.onChanged.addListener((changes) => {
events.emit('change', Object.keys(changes));
});
export default new CellCollection({
async read<T>(key: string, type: io.Type<T>): Promise<T | undefined> {
const readResult = (await Browser.storage.local.get(key))[key];
if (readResult !== undefined) {
assert(type.is(readResult));
}
return readResult;
},
async write<T>(
key: string,
type: io.Type<T>,
value: T | undefined,
): Promise<void> {
if (value === undefined) {
Browser.storage.local.remove(key);
return;
}
assert(type.is(value));
Browser.storage.local.set({
[key]: value,
});
},
events,
});

View File

@@ -0,0 +1,3 @@
export default function jsonHasChanged<T>(previous: T | undefined, latest: T) {
return JSON.stringify(previous) !== JSON.stringify(latest);
}

View File

@@ -0,0 +1,45 @@
import { EventEmitter } from 'events';
import * as io from 'io-ts';
import assert from '../helpers/assert';
import CellCollection from './CellCollection';
import IAsyncStorage from './IAsyncStorage';
const events = new EventEmitter() as IAsyncStorage['events'];
window.addEventListener('storage', evt => {
if (evt.key !== null) {
events.emit('change', [evt.key]);
}
});
export default new CellCollection({
async read<T>(key: string, type: io.Type<T>): Promise<T | undefined> {
const readResultStr = localStorage.getItem(key) ?? undefined;
const readResult = readResultStr && JSON.parse(readResultStr);
if (readResult !== undefined) {
assert(type.is(readResult));
}
return readResult;
},
async write<T>(
key: string,
type: io.Type<T>,
value: T | undefined,
): Promise<void> {
if (value === undefined) {
localStorage.removeItem(key);
return;
}
assert(type.is(value));
localStorage.setItem(key, JSON.stringify(value));
},
events,
});

View File

@@ -0,0 +1,14 @@
import TypedEmitter from 'typed-emitter';
import ExplicitAny from '../types/ExplicitAny';
export default function nextEvent<
Events extends Record<string, ExplicitAny>,
E extends keyof Events,
>(
emitter: TypedEmitter<Events>,
eventName: E,
): Promise<Parameters<Events[E]>[0]> {
return new Promise<Parameters<Events[E]>[0]>((resolve) => {
emitter.once(eventName, resolve as ExplicitAny);
});
}

View File

@@ -0,0 +1,31 @@
import TypedEmitter from 'typed-emitter';
import ExplicitAny from '../types/ExplicitAny';
export default async function raceWithEvent<
PromiseResult,
EventResult,
Events extends Record<string, ExplicitAny>,
E extends keyof Events,
>(
promise: Promise<PromiseResult>,
emitter: TypedEmitter<Events>,
eventName: E,
listener: (...params: Parameters<Events[E]>) => EventResult,
): Promise<PromiseResult | EventResult> {
const result = Promise.race([
promise,
new Promise<EventResult>((resolve) => {
const wrappedListener = (...params: Parameters<Events[E]>) => {
resolve(listener(...params));
};
emitter.once(eventName, wrappedListener as ExplicitAny);
promise.finally(() =>
emitter.off(eventName, wrappedListener as ExplicitAny),
);
}),
]);
return result;
}

View File

@@ -0,0 +1,30 @@
import { useEffect, useState } from 'react';
import AsyncReturnType from '../types/AsyncReturnType';
import { IReadableCell } from './ICell';
import Stoppable from './Stoppable';
export default function useCell<C extends IReadableCell<unknown>>(
cellParam: C,
) {
type T = AsyncReturnType<C['read']>;
const cell = cellParam as IReadableCell<T>;
const [value, setValue] = useState<T>();
useEffect(() => {
const stoppableSequence = new Stoppable(cell);
(async () => {
for await (const v of stoppableSequence) {
setValue(v);
}
})();
return () => {
stoppableSequence.stop();
};
}, [cell]);
return value;
}

View File

@@ -0,0 +1,23 @@
import { useMemo } from 'react';
import ICell from './ICell';
import MemoryCell from './MemoryCell';
import useCell from './useCell';
declare function useCellStateOverload<T>(
initialValue: T,
): [T, (newValue: T) => void, ICell<T>];
declare function useCellStateOverload<T>(): /* no argument given */
[T | undefined, (newValue: T | undefined) => void, ICell<T | undefined>];
function useCellState<T>(
initialValue: T,
): [T, (newValue: T) => void, ICell<T>] {
// eslint-disable-next-line react-hooks/exhaustive-deps
const cell = useMemo(() => new MemoryCell(initialValue), []);
const cellValue = useCell(cell);
return [cellValue ?? initialValue, (newValue) => cell.write(newValue), cell];
}
export default useCellState as typeof useCellStateOverload;

View File

@@ -1,23 +0,0 @@
type RpcMap = {
eth_sendTransaction: {
params: [
{
nonce?: string;
gasPrice?: string;
gas?: string;
to?: string;
from?: string;
value?: string;
data?: string;
chainId?: string;
},
];
result: string;
};
add: {
params: [number, number];
result: number;
};
};
export default RpcMap;

View File

@@ -1,3 +0,0 @@
type TransportClient = (...args: unknown[]) => Promise<unknown>;
export default TransportClient;

View File

@@ -2,10 +2,6 @@ export const CONTENT_SCRIPT = 'quill-contentscript';
export const INPAGE = 'quill-inpage';
export const PROVIDER = 'quill-provider';
export const PROVIDER_JRPC_METHODS = {
GET_PROVIDER_STATE: 'wallet_get_provider_state',
};
export const PROVIDER_NOTIFICATIONS = {
ACCOUNTS_CHANGED: 'wallet_accounts_changed',
CHAIN_CHANGED: 'wallet_chain_changed',

View File

@@ -1,93 +0,0 @@
import generateRandomHex from '../helpers/generateRandomHex';
import TransportClient from './TransportClient';
const ackDelayMax = 100; // milliseconds
export function PostMessageTransportClient(target: string): TransportClient {
return (...args: unknown[]) =>
new Promise<unknown>((resolve, reject) => {
const messageId = generateRandomHex(256);
const ackTimer = setTimeout(() => {
reject(new Error('Message not acknowledged'));
}, ackDelayMax);
function messageListener(evt: MessageEvent) {
if (
// Ignore messages without our id
evt.data.messageId !== messageId ||
// The message we send out has our id, also ignore that
evt.data.target === target
) {
return;
}
clearTimeout(ackTimer);
if (evt.data.type === 'ack') {
// Do nothing (timer cleared on any messageId match above)
} else if (evt.data.type === 'response') {
window.removeEventListener('message', messageListener);
resolve(evt.data.response);
} else if (evt.data.type === 'error') {
window.removeEventListener('message', messageListener);
reject(new Error(evt.data.errorMessage));
} else {
console.warn('Unexpected message', evt);
}
}
window.addEventListener('message', messageListener);
window.postMessage(
{
target,
messageId,
args,
},
'*',
);
});
}
export function PostMessageTransportServer(
target: string,
handler: (...args: unknown[]) => Promise<unknown>,
): { close(): void } {
async function messageListener(evt: MessageEvent) {
if (evt.data.target !== target) {
return;
}
const { messageId } = evt.data;
let replied = false;
// Send an ack if we don't send a reply asap
setTimeout(() => {
if (replied === false) {
window.postMessage({ messageId, type: 'ack' }, '*');
}
});
try {
const response = await handler(...evt.data.args);
window.postMessage({ messageId, type: 'response', response }, '*');
} catch (error) {
window.postMessage(
{ messageId, type: 'error', errorMessage: (error as Error).message },
'*',
);
}
replied = true;
}
window.addEventListener('message', messageListener);
return {
close() {
window.removeEventListener('message', messageListener);
},
};
}

View File

@@ -1,33 +0,0 @@
import addErrorContext from './addErrorContext';
export default function validateOptionalStringRecord<
Keys extends readonly string[],
>(keys: Keys): (value: unknown) => { [K in Keys[number]]?: string } {
return addErrorContext('validateOptionalStringRecord', (value) => {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
throw new Error('Expected object');
}
const valueRecord = value as Record<string, unknown>;
const result: { [K in Keys[number]]?: string } = {};
for (const k of keys) {
const fieldValue = valueRecord[k];
if (fieldValue === undefined) {
continue;
}
if (typeof fieldValue !== 'string') {
throw new Error(
`Expected field "${k}" to be a string (but was: ${typeof fieldValue})`,
);
}
result[k as Keys[number]] = fieldValue;
}
return result;
});
}

View File

@@ -13,7 +13,7 @@ const Button = (props: {
className={
props.loading
? 'btn-loading'
: `flex gap-2 items-center ${props.className}`
: `flex gap-2 items-center select-none ${props.className}`
}
onClick={props.onPress}
onKeyDown={(evt) => {

View File

@@ -1,75 +0,0 @@
import { Component, ReactNode } from 'react';
import delay from '../helpers/delay';
import type { PageEvents } from './Page';
type Props = {
events: PageEvents;
};
type Level = 'info' | 'error';
type State = {
activeCount: number;
presentCount: number;
level: Level;
text: string;
};
const initialState: State = {
activeCount: 0,
presentCount: 0,
level: 'info',
text: '',
};
export default class NotificationContainer extends Component<Props, State> {
targetState = initialState;
constructor(props: Props) {
super(props);
this.state = initialState;
this.props.events.on('notification', this.onNotify);
}
onNotify = async (level: 'info' | 'error', text: string): Promise<void> => {
this.setTarget({
presentCount: this.targetState.presentCount + 1,
level,
text,
});
await delay(0);
this.setTarget({ activeCount: this.targetState.activeCount + 1 });
await delay(3000);
this.setTarget({ activeCount: this.targetState.activeCount - 1 });
await delay(500);
this.setTarget({ presentCount: this.targetState.presentCount - 1 });
};
componentWillUnmount(): void {
this.props.events.off('notification', this.onNotify);
}
setTarget(updates: Partial<State>): void {
this.targetState = { ...this.targetState, ...updates };
super.setState(this.targetState);
}
render(): ReactNode {
const classes = ['notification', this.state.level];
if (this.state.activeCount > 0) {
classes.push('active');
}
if (this.state.presentCount > 0) {
classes.push('present');
}
return <div className={classes.join(' ')}>{this.state.text}</div>;
}
}

View File

@@ -1,99 +0,0 @@
import { Component, ReactElement, ReactNode } from 'react';
import delay from '../helpers/delay';
import type { PageEvents, PageOverlay } from './Page';
type Props = {
events: PageEvents;
};
type State = {
activeCount: number;
presentCount: number;
overlayRenders: ReactElement[];
};
const initialState: State = {
activeCount: 0,
presentCount: 0,
overlayRenders: [],
};
export default class OverlayContainer extends Component<Props, State> {
targetState = initialState;
constructor(props: Props) {
super(props);
this.state = initialState;
this.props.events.on('overlay', this.onOverlay);
}
onOverlay = async (overlay: PageOverlay): Promise<void> => {
const overlayRender = overlay(close);
let isClosed = false;
this.setTarget({
overlayRenders: [...this.targetState.overlayRenders, overlayRender],
presentCount: this.targetState.presentCount + 1,
});
await delay(0);
this.setTarget({ activeCount: this.targetState.activeCount + 1 });
// eslint-disable-next-line @typescript-eslint/no-this-alias
const self = this;
async function close() {
if (isClosed) {
return;
}
isClosed = true;
self.setTarget({ activeCount: self.targetState.activeCount - 1 });
await delay(500);
self.setTarget({
overlayRenders: self.targetState.overlayRenders.filter(
(oi) => oi !== overlayRender,
),
presentCount: self.targetState.presentCount - 1,
});
}
};
componentWillUnmount(): void {
this.props.events.off('overlay', this.onOverlay);
}
setTarget(updates: Partial<State>): void {
this.targetState = { ...this.targetState, ...updates };
super.setState(this.targetState);
}
render(): ReactNode {
const currentRender = this.state.overlayRenders.slice(-1)[0];
if (currentRender === undefined) {
return <></>;
}
const classes = ['overlay'];
if (this.state.activeCount > 0) {
classes.push('active');
}
if (this.state.presentCount > 0) {
classes.push('present');
}
return (
<div className={classes.join(' ')}>
<div className="content">{currentRender}</div>
</div>
);
}
}

View File

@@ -1,40 +0,0 @@
import { Component, ReactElement, ReactNode } from 'react';
import TypedEventEmitter from 'typed-emitter';
import NotificationContainer from './NotificationContainer';
import OverlayContainer from './OverlayContainer';
import ScreenContainer from './ScreenContainer';
export type PageOverlay = (close: () => void) => ReactElement;
export type PageEventMap = {
notification(level: 'info' | 'error', text: string): void;
overlay(overlay: PageOverlay): void;
screen(screen: ReactElement): void;
};
export type PageEvents = TypedEventEmitter<PageEventMap>;
type Props = {
classes?: string[];
events: PageEvents;
};
// eslint-disable-next-line @typescript-eslint/ban-types
type State = {};
export default class Page extends Component<Props, State> {
render(): ReactNode {
const classes = ['page', ...(this.props.classes ?? [])];
return (
<div className={classes.join(' ')}>
<ScreenContainer events={this.props.events}>
{this.props.children}
</ScreenContainer>
<NotificationContainer events={this.props.events} />
<OverlayContainer events={this.props.events} />
</div>
);
}
}

View File

@@ -1,83 +0,0 @@
import { Component, ReactElement, ReactNode } from 'react';
import Button from './Button';
// import DefaultScreen from '../Popup/components/DefaultScreen';
import OverrideScreen, {
overrideScreenEnabled,
} from '../Popup/components/OverrideScreen';
import type { PageEvents } from './Page';
type Props = {
events: PageEvents;
};
type State = {
screens: ReactElement[];
};
const initialState: State = {
screens: [],
};
export default class ScreenContainer extends Component<Props, State> {
targetState = initialState;
constructor(props: Props) {
super(props);
this.state = initialState;
if (overrideScreenEnabled) {
this.state.screens.push(<OverrideScreen key={1} />);
}
this.props.events.on('screen', this.onScreen);
}
onScreen = async (screen: ReactElement): Promise<void> => {
this.setTarget({
screens: [...this.targetState.screens, screen],
});
};
componentWillUnmount(): void {
this.props.events.off('screen', this.onScreen);
}
setTarget(updates: Partial<State>): void {
this.targetState = { ...this.targetState, ...updates };
super.setState(this.targetState);
}
back(): void {
const newScreens = this.targetState.screens.slice();
newScreens.pop();
this.setTarget({
screens: newScreens,
});
}
render(): ReactNode {
const currentScreen =
this.state.screens.slice(-1)[0] ?? this.props.children;
return (
<div className="screen">
{currentScreen}
{(() => {
if (this.state.screens.length === 0) {
return <></>;
}
return (
<div className="back-container">
<Button onPress={() => this.back()}>Back</Button>
</div>
);
})()}
</div>
);
}
}

View File

@@ -0,0 +1,20 @@
import recordKeys from './recordKeys';
export default function mapValues<
InputRecord extends Record<string, unknown>,
Output,
>(
inputs: InputRecord,
mapper: (
input: InputRecord[keyof InputRecord],
key: keyof InputRecord,
) => Output,
): Record<keyof InputRecord, Output> {
const res = {} as Record<keyof InputRecord, Output>;
for (const key of recordKeys(inputs)) {
res[key] = mapper(inputs[key], key);
}
return res;
}

View File

@@ -0,0 +1,10 @@
/**
* Wrapper around Object.keys which uses keyof to get the accurate key type.
* (The builtin typing for Object.keys unnecessarily widens the type to
* string[].)
*/
export default function recordKeys<R extends Record<string, unknown>>(
record: R,
): (keyof R)[] {
return Object.keys(record) as (keyof R)[];
}

View File

@@ -269,4 +269,25 @@
.transaction-tab {
padding: 12px 24px;
}
.themable1.dark-theme {
filter: unquote("invert()");
background-color: white;
height: 100vh;
}
.themable2.dark-theme {
filter: unquote("hue-rotate(180deg)");
}
.demo-table {
td:nth-child(2) {
display: flex;
flex-direction: row;
}
td {
vertical-align: top;
}
}
}

View File

@@ -0,0 +1,9 @@
import ExplicitAny from './ExplicitAny';
type AsyncReturnType<T> = T extends (
...args: ExplicitAny[]
) => Promise<infer Ret>
? Ret
: never;
export default AsyncReturnType;

View File

@@ -0,0 +1,11 @@
/**
* Use this when you need to use `any` to do something meta that is beyond
* TypeScript's ability to keep track of it. This often happens with generics.
* Make sure you only use this type in a limited scope that exposes a well-typed
* boundary. If you need to resort to `any` for some other reason, please just
* use it normally.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type ExplicitAny = any;
export default ExplicitAny;

Some files were not shown because too many files have changed in this diff Show More