Add ERC-4337 trampoline browser extension

This commit is contained in:
jacque006
2023-05-12 18:05:18 +01:00
parent 2f0e7c80b8
commit a5de6f5901
135 changed files with 24156 additions and 0 deletions

10
trampoline/.babelrc Normal file
View File

@@ -0,0 +1,10 @@
{
"presets": [
"@babel/preset-env",
"@babel/preset-react"
// "react-app"
],
"plugins": [
// "@babel/plugin-proposal-class-properties",
]
}

6
trampoline/.eslintrc Normal file
View File

@@ -0,0 +1,6 @@
{
"extends": "react-app",
"globals": {
"chrome": "readonly"
}
}

62
trampoline/.gitignore vendored Normal file
View File

@@ -0,0 +1,62 @@
# See https://help.github.com/ignore-files/ for more about ignoring files.
# dependencies
/node_modules
# testing
/coverage
# production
/build
# zip
/zip
# misc
.vscode
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
.history
# secrets
secrets.*.js
yarn-error.log
node_modules
.env
coverage
coverage.json
typechain
# Hardhat files
cache
artifacts
node_modules
.env
coverage
coverage.json
typechain
# Hardhat files
cache
artifacts
node_modules
.env
coverage
coverage.json
typechain
# Hardhat files
cache
artifacts
typechain-types
deployments
mnemonic.txt

6
trampoline/.prettierrc Normal file
View File

@@ -0,0 +1,6 @@
{
"singleQuote": true,
"trailingComma": "es5",
"requirePragma": false,
"arrowParens": "always"
}

21
trampoline/LICENSE Normal file
View File

@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2019 Michael Xieyang Liu
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

281
trampoline/README.md Executable file
View File

@@ -0,0 +1,281 @@
<img src="src/assets/img/logo.png" width="260"/>
Trampoline is a chrome extension boilerplate code to showcase your own Smart Contract Wallets with React 18 and Webpack 5 support.
## Installation and Running
### Steps:
1. Verify that your [Node.js](https://nodejs.org/) version is >= **18.12.0**.
2. Clone this repository.
3. Make sure you configure the `provider` in `src/exconfig.ts` to the `Goerli` network.
4. Edit the `bundler` URL pointing to `Goerli` network and accepting EntryPoint=`0x0576a174D229E3cFA37253523E645A78A0C91B57`
5. Run `yarn install` to install the dependencies.
6. Run `yarn start`
7. Load your extension in Chrome by following these steps:
1. Go to `chrome://extensions/`
2. Enable `Developer mode`
3. Click on `Load unpacked extension`
4. Select the `build` folder.
8. Happy hacking.
> **Warning**
> Auto refresh is disabled by default, so you will have to manually refresh the page.
> If you make changes in background script or account-api, you will also have to refresh the background page. Check instructions on how to do that below.
> **Warning**
> Logs of all the blockchain interactions are shown in the background script. Do keep it open for faster debugging.
### How to see and refresh background page
1. Open extension's page: `chrome://extensions/`
2. Find the Trampoline extension, and click Details.
3. Check the `Inspect views` area and click on `background page` to inspect it's logs
4. To refresh click `cmd + r` or `ctrl + r` in the background inspect page to refresh the background script.
5. You can reload the extension completely too, the state is always kept in localstorage so nothing will be lost.
## Config
Config of the extension can be set in `excnfig.json` file.
```json
{
// Enable or disable password for the user.
"enablePasswordEncryption": true,
// Show default transaction screen
"showTransactionConfirmationScreen": true,
// stateVersion is the version of state stored in localstorage of your browser. If you want to reset your extension, change this number to a new version and that will invalidate the older state.
stateVersion: '0.1',
// Network that your SCW supports. Currently this app only supports a single network, we will soon have support for multiple networks in future
"network": {
"chainID": "5",
"family": "EVM",
"name": "Goerli",
"provider": "https://goerli.infura.io/v3/bdabe9d2f9244005af0f566398e648da",
"entryPointAddress": "0x0F46c65C17AA6b4102046935F33301f0510B163A",
"bundler": "https://app.stackup.sh/api/v1/bundler/96771b1b09e802669c33a3fc50f517f0f514a40da6448e24640ecfd83263d336",
"baseAsset": {
"symbol": "ETH",
"name": "ETH",
"decimals": 18,
"image": "https://ethereum.org/static/6b935ac0e6194247347855dc3d328e83/6ed5f/eth-diamond-black.webp"
}
}
}
```
### Custom Network
1. Make sure EntryPoint is deployed on the target network.
2. Edit the `entryPointAddress` in `src/exconfig.ts`.
3. Add your network details in `hardhat.condig.ts`.
4. Deploy the factory using `INFURA_ID=<required> npx hardhat deploy --network <network>`.
5. Edit the `factory_address` in `src/exconfig.ts`
6. Edit the `bundler` url in `src/exconfig.ts` that points to your network and accepts requests for your EntryPoint.
7. Run `yarn start`
### Local Network
1. Run a local hardhat node with `npx hardhat node` or use the node inside the bundler repo.
2. Deploy EntryPoint from [the infinitism repo](https://github.com/eth-infinitism/account-abstraction), you can find the instructions [below](#how-to-deploy-entrypoint-locally).
3. Edit the `entryPointAddress` in `src/exconfig.ts`.
4. Deploy the factory using `npx hardhat deploy --network localhost`.
5. Edit the `factory_address` in `src/exconfig.ts`
6. Start a local bunder from [the infinitism repo](https://github.com/eth-infinitism/bundler), you can find the instructions [below](#how-to-run-bundler-locally).
7. Edit the `bundler` to `http://localhost:9000/rpc` url in `src/exconfig.ts` that points to your network and accepts requests for your EntryPoint.
8. Run `yarn start`
### How to deploy EntryPoint Locally
1. Clone the repo https://github.com/eth-infinitism/account-abstraction
2. Run `yarn install` to install the dependencies.
3. Deploy EntryPoint with `DEBUG=true MNEMONIC_FILE=<path-to-mnemonic-file> yarn deploy --network dev`
### How to run bundler Locally
1. Clone the repo https://github.com/eth-infinitism/bundler
2. Run `yarn install` to install the dependencies.
3. Run `yarn preprocess` to compile all the local dependencies.
4. Edit `bundler.config.json` at `packages/bundler/localconfig`:
a. Edit `network` to your local hardhat node
b. Edit the `entryPoint` address that you got while deploying it using instructions above.
c. Make sure your mnemonic & beneficiary are setup correctly.
5. Run the bunder using `yarn bundler --unsafe --auto`
---
## Extension Structure
1. You can change the icons at `src/assets/img/icon-34.png` and `src/assets/img/icon-128.png` for the chrome extension.
## Wallet Structure
All your extension's account code must be placed in the `src/pages/Account` folder.
There are two subfolders in `src/pages/Account`:
- account-api
- components
### account-api folder
This folder is used to define the `AccountAPI` of your specific account implementation. Every implementation must implement `AccountApiType`.
```typescript
export abstract class AccountApiType extends BaseAccountAPI {
abstract serialize: () => Promise<object>;
/** sign a message for the user */
abstract signMessage: (
request?: MessageSigningRequest,
context?: any
) => Promise<string>;
abstract signUserOpWithContext(
userOp: UserOperationStruct,
context?: any
): Promise<string>;
}
export declare abstract class BaseAccountAPI {
/**
* return the value to put into the "initCode" field, if the contract is not yet deployed.
* this value holds the "factory" address, followed by this account's information
*/
abstract getAccountInitCode(): Promise<string>;
/**
* return current account's nonce.
*/
abstract getNonce(): Promise<BigNumber>;
/**
* encode the call from entryPoint through our account to the target contract.
* @param target
* @param value
* @param data
*/
abstract encodeExecute(
target: string,
value: BigNumberish,
data: string
): Promise<string>;
}
```
The boilerplate includes a SimpleAccount Implementation by Eth-Infinitism, which you can find [here](https://github.com/eth-infinitism/bundler/blob/main/packages/sdk/src/SimpleAccountAPI.ts).
### components folder
This folder is used to define the components that will be used in the Chrome extension. This folder should contain two subfolders.
- onboarding
- sign-message
- transaction
The `onboarding` folder defines the component that will be displayed to the user during the creation of a new wallet. You can display custom information or collect user inputs if needed.
The signature of the `OnboardingComponent` is defined as follows.
```typescript
export interface OnboardingComponentProps {
onOnboardingComplete: (context?: any) => void;
}
export interface OnboardingComponent
extends React.FC<OnboardingComponentProps> {}
```
Once the component has collected enough information from the user, it should pass the collected information to `onOnboardingComplete` as the `context` parameter. This `context` will be passed on to your `account-api`
The signature of the `account-api` is as follows, which shows how the `context` will be passed:
```typescript
export interface AccountApiParamsType extends BaseApiParams {
context?: any;
}
export type AccountImplementationType = new (
params: AccountApiParamsType
) => AccountApiType;
```
The `sign-message` folder defines the component that will be displayed to the user whenever the dapp requests the user to sign any message, i.e. dapp calls `personal_sign` RPC method. You can display custom information or collect user inputs if needed.
The signature of the `SignMessageComponenet` is defined as follows.
```typescript
export interface SignMessageComponenetProps {
onComplete: (context?: any) => Promise<void>;
}
export interface SignMessageComponenet
extends React.FC<SignMessageComponenetProps> {}
```
Once the component has collected enough information from the user, it should pass the collected information to `onComplete` as the `context` parameter. This `context` will be passed on to your `signMessage` function of `account-api`
The signature of the `signMessage` is as follows, which shows how the `context` will be passed:
```typescript
/** sign a message for the user */
abstract signMessage: (
request?: MessageSigningRequest,
context?: any
) => Promise<string>;
```
The `transaction` folder defines the component that will be displayed to the user whenever the dapp requests to initiate a transaction, i.e. dapp calls `eth_sendTransaction` RPC method. You can display custom information or collect user inputs if needed.
The signature of the `TransactionComponent` is defined as follows.
```typescript
export interface TransactionComponentProps {
transaction: EthersTransactionRequest;
onComplete: (
modifiedTransaction: EthersTransactionRequest,
context?: any
) => Promise<void>;
}
export interface TransactionComponent
extends React.FC<TransactionComponentProps> {}
```
Once the component has collected enough information from the user, it should pass the collected information to `onComplete` as the `context` parameter. You can also modify the transaction if you want and return it also as a parameter of `onComplete` function. This `context` and `modifiedTransaction` will be passed on to your `createUnsignedUserOp` function of `account-api`
The signature of the `createUnsignedUserOp` is as follows, which shows how the `context` will be passed:
```typescript
/** sign a message for the user */
abstract createUnsignedUserOp: (
request?: MessageSigningRequest,
context?: any
) => Promise<string>;
```
If you want you can also attach a paymaster here if your wallet wants to sponsor the transaction as well. The paymaster information will be displayed to the user.
## FAQ
### Is the password screen mandatory?
No you can disable that by setting `enablePasswordEncryption` flag to `false` in `exconfig.ts`.
> **Warning:** the local storage will be unencrypted and your wallet must return an encrypted state when `serialize` function of `account-api` willo be called or else the user's fund will be at risk.
### Is the view transaction screen mandatory?
If you want to show a custom screen then you must present it to the user in `TransactionComponent` and set `showTransactionConfirmationScreen` to `false`.
### How do I, as a wallet provider attach a custom paymaster?
You must return the paymaster information in the `userOp` constructed by the function `createUnsignedUserOp`.
> **Warning:** If `showTransactionConfirmationScreen` has been disabled then the user will not be aware of paymaster and you must inform the user about paymaster in your custom transaction confirmation screen.
## Webpack auto-reload and HRM Errors
This repository is based on the boilerplate code found at [lxieyang/chrome-extension-boilerplate-react](https://github.com/lxieyang/chrome-extension-boilerplate-react). To understand how hot-reloading and content scripts work, refer to its README.
### LOGO Attributions
Designed by Tomo Saito, a designer and artist at the Ethereum Foundation. [@tomosaito](https://twitter.com/tomosaito)

View File

@@ -0,0 +1,18 @@
//SPDX-License-Identifier: Unlicense
pragma solidity ^0.8.0;
contract Greeter {
string greeting;
constructor(string memory _greeting) {
greeting = _greeting;
}
function greet() public view returns (string memory) {
return greeting;
}
function setGreeting(string memory _greeting) public {
greeting = _greeting;
}
}

View File

@@ -0,0 +1,13 @@
import { HardhatRuntimeEnvironment } from 'hardhat/types';
import { DeployFunction } from 'hardhat-deploy/types';
const func: DeployFunction = async function (hre: HardhatRuntimeEnvironment) {
const accounts = await hre.getUnnamedAccounts();
await hre.deployments.deploy('Greeter', {
from: accounts[0],
deterministicDeployment: true,
args: ['Test'],
log: true,
});
};
export default func;

View File

@@ -0,0 +1,51 @@
// import '@nomiclabs/hardhat-waffle';
import '@nomiclabs/hardhat-ethers';
import 'hardhat-deploy';
import '@typechain/hardhat';
import { HardhatUserConfig } from 'hardhat/types';
import * as fs from 'fs';
import '@nomiclabs/hardhat-etherscan';
const mnemonicFileName =
process.env.MNEMONIC_FILE ??
`${process.env.HOME}/.secret/testnet-mnemonic.txt`;
let mnemonic = 'test '.repeat(11) + 'junk';
if (fs.existsSync(mnemonicFileName)) {
mnemonic = fs.readFileSync(mnemonicFileName, 'ascii');
}
function getNetwork1(url: string): {
url: string;
accounts: { mnemonic: string };
} {
return {
url,
accounts: { mnemonic },
};
}
function getNetwork(name: string): {
url: string;
accounts: { mnemonic: string };
} {
return getNetwork1(`https://${name}.infura.io/v3/${process.env.INFURA_ID}`);
// return getNetwork1(`wss://${name}.infura.io/ws/v3/${process.env.INFURA_ID}`)
}
const config: HardhatUserConfig = {
defaultNetwork: 'hardhat',
solidity: {
compilers: [{ version: '0.8.12', settings: {} }, { version: '0.5.0' }],
},
typechain: {
outDir: 'src/pages/Account/account-api/typechain-types',
},
networks: {
goerli: getNetwork('goerli'),
},
etherscan: {
apiKey: process.env.ETHERSCAN_API_KEY,
},
};
export default config;

116
trampoline/package.json Executable file
View File

@@ -0,0 +1,116 @@
{
"name": "trampoline",
"version": "5.0.4",
"description": "Trampoline is a chrome extension boilerplate code to showcase your own Smart Contract Wallets",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/lxieyang/chrome-extension-boilerplate-react.git"
},
"scripts": {
"server": "node utils/webserver.js",
"start": "QUIET=false NODE_ENV=development run-p devtools \"dev {@}\" --",
"dev": "npx hardhat clean && npx hardhat compile && webpack --config webpack.config.js --watch",
"hardhat-clean-comile": "npx hardhat clean && npx hardhat compile",
"devtools": "npx redux-devtools --hostname=localhost --port=8000 --open",
"build": "npx hardhat clean && npx hardhat compile && node utils/build.js",
"prettier": "prettier --write '**/*.{js,jsx,ts,tsx,json,css,scss,md}'"
},
"dependencies": {
"@account-abstraction/contracts": "^0.6.0",
"@account-abstraction/sdk": "^0.6.0",
"@emotion/react": "^11.10.5",
"@emotion/styled": "^11.10.5",
"@fontsource/roboto": "^4.5.8",
"@metamask/browser-passworder": "^4.0.2",
"@mui/icons-material": "^5.11.0",
"@mui/material": "^5.11.6",
"@nomicfoundation/hardhat-toolbox": "^2.0.1",
"@peculiar/asn1-ecc": "^2.3.4",
"@peculiar/asn1-schema": "^2.3.3",
"@redux-devtools/cli": "^2.0.0",
"@redux-devtools/remote": "^0.8.0",
"@reduxjs/toolkit": "^1.9.1",
"@simplewebauthn/typescript-types": "^7.0.0",
"base64url": "^3.0.1",
"buffer": "^6.0.3",
"daisyui": "^2.49.0",
"elliptic": "^6.5.4",
"emittery": "^1.0.1",
"ethers": "^5.7.2",
"extensionizer": "^1.0.1",
"magic-sdk": "^13.4.0",
"node-polyfill-webpack-plugin": "^2.0.1",
"path-browserify": "^1.0.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-redux": "^8.0.5",
"react-router-dom": "^6.8.0",
"redux-devtools": "^3.7.0",
"redux-persist": "^6.0.0",
"siwe": "^1.1.6",
"ssestream": "^1.1.0",
"uuid": "^9.0.0",
"wagmi": "^0.11.3",
"webext-redux": "^2.1.9",
"webextension-polyfill": "^0.10.0",
"webpack-livereload-plugin": "^3.0.2"
},
"devDependencies": {
"@babel/core": "^7.20.12",
"@babel/plugin-proposal-class-properties": "^7.18.6",
"@babel/preset-env": "^7.20.2",
"@babel/preset-react": "^7.18.6",
"@nomicfoundation/hardhat-chai-matchers": "^1.0.6",
"@nomicfoundation/hardhat-network-helpers": "^1.0.8",
"@nomiclabs/hardhat-ethers": "^2.2.2",
"@nomiclabs/hardhat-etherscan": "^3.1.6",
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.10",
"@typechain/ethers-v5": "^10.2.0",
"@typechain/hardhat": "^6.1.5",
"@types/chai": "^4.3.4",
"@types/chrome": "^0.0.212",
"@types/mocha": "^10.0.1",
"@types/react": "^18.0.26",
"@types/react-dom": "^18.0.10",
"babel-eslint": "^10.1.0",
"babel-loader": "^9.1.2",
"babel-preset-react-app": "^10.0.1",
"clean-webpack-plugin": "^4.0.0",
"copy-webpack-plugin": "^11.0.0",
"css-loader": "^6.7.3",
"eslint": "^8.31.0",
"eslint-config-react-app": "^7.0.1",
"eslint-plugin-flowtype": "^8.0.3",
"eslint-plugin-import": "^2.27.4",
"eslint-plugin-jsx-a11y": "^6.7.1",
"eslint-plugin-react": "^7.32.0",
"eslint-plugin-react-hooks": "^4.6.0",
"file-loader": "^6.2.0",
"fs-extra": "^11.1.0",
"hardhat": "^2.12.7",
"hardhat-deploy": "^0.11.24",
"hardhat-gas-reporter": "^1.0.9",
"html-loader": "^4.2.0",
"html-webpack-plugin": "^5.5.0",
"npm-run-all": "^4.1.5",
"prettier": "^2.8.3",
"react-refresh": "^0.14.0",
"react-refresh-typescript": "^2.0.7",
"sass": "^1.57.1",
"sass-loader": "^13.2.0",
"solidity-coverage": "^0.8.2",
"source-map-loader": "^3.0.1",
"style-loader": "^3.3.1",
"tailwindcss": "^3.2.4",
"terser-webpack-plugin": "^5.3.6",
"ts-loader": "^9.4.2",
"type-fest": "^3.5.2",
"typechain": "^8.1.1",
"typescript": "^4.9.4",
"webpack": "^5.75.0",
"webpack-cli": "^4.10.0",
"webpack-dev-server": "^4.11.1",
"zip-webpack-plugin": "^4.0.1"
}
}

View File

@@ -0,0 +1,22 @@
// We require the Hardhat Runtime Environment explicitly here. This is optional
// but useful for running the script in a standalone fashion through `node <script>`.
//
// You can also run a script with `npx hardhat run <script>`. If you do that, Hardhat
// will compile your contracts, add the Hardhat Runtime Environment's members to the
// global scope, and execute the script.
import { ethers } from 'hardhat';
async function main() {
const [signer] = await ethers.getSigners();
signer.sendTransaction({
to: '0x0A77cEdFB8459084aB81CF6786EadDaCf7974146',
value: ethers.utils.parseEther('1'),
});
}
// We recommend this pattern to be able to use async/await everywhere
// and properly handle errors.
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

View File

@@ -0,0 +1,230 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 599.124 142.705">
<defs>
<style>
.cls-1 {
fill: #f89f72;
}
.cls-2 {
fill: #f58b77;
}
.cls-3 {
fill: #fab06c;
}
.cls-4 {
fill: #faad6c;
}
.cls-5 {
fill: #f9a66f;
}
.cls-6 {
fill: #677bbc;
}
.cls-7 {
fill: #f69175;
}
.cls-8 {
fill: #f79874;
}
.cls-9 {
fill: #fdd0b2;
}
.cls-10 {
fill: #fbb46b;
}
.cls-11 {
fill: #fcb868;
}
.cls-12 {
fill: #fdbe68;
}
.cls-13 {
fill: #fdc068;
}
.cls-14 {
fill: #fcf8c8;
}
.cls-15 {
fill: #8dcb8e;
}
.cls-16 {
fill: #8eacb1;
}
.cls-17, .cls-18 {
fill: #ceebed;
}
.cls-19 {
fill: #b082ba;
}
.cls-20 {
fill: #c3a1cb;
}
.cls-21, .cls-22 {
fill: #37374d;
}
.cls-23 {
fill: #54bc7b;
}
.cls-22 {
stroke: #37374d;
stroke-miterlimit: 10;
stroke-width: .2px;
}
.cls-18 {
opacity: .75;
}
</style>
</defs>
<g>
<g>
<g>
<path class="cls-15" d="m60.839,115.602l-2.2,21.02c-.17,1.63-1.12,2.92-2.33,3.34-.27.09-.55.14-.84.14-1.59,0-2.95-1.49-3.16-3.48l-1.86-17.74-.34-3.28c-.1-.89.06-1.79.42-2.55.16-.35.38-.68.63-.96.59-.65,1.34-1.01,2.11-1.01h4.4c.78,0,1.53.36,2.11,1.01.26.28.47.61.63.96.37.76.52,1.66.43,2.55Z"/>
<path class="cls-15" d="m28.789,99.102l-4.82,6.42-16.08,21.39c-.55.73-1.43,1.17-2.34,1.17-1.06,0-2.01-.54-2.54-1.45-.53-.92-.54-2.01-.02-2.93l8.68-15.37,3.69-6.53,3.34-5.91c.52-.92,1.5-1.5,2.56-1.5h5.19c1.13,0,2.12.61,2.62,1.63.51,1.02.41,2.17-.28,3.08Z"/>
<path class="cls-15" d="m108.059,126.632c-.06.11-.13.21-.22.31v.01c-.55.72-1.39,1.13-2.31,1.13s-1.8-.44-2.35-1.17l-10.89-14.5-5.48-7.29-4.53-6.02c-.68-.91-.79-2.06-.28-3.08.51-1.02,1.49-1.63,2.63-1.63h5.19c1.06,0,2.03.58,2.56,1.5l3.16,5.59,12.55,22.22c.52.92.51,2.01-.03,2.93Z"/>
<g>
<path class="cls-21" d="m57.674,110.386h-4.401c-.979,0-1.912.438-2.629,1.234-.927,1.029-1.387,2.546-1.228,4.058l2.2,21.018c.245,2.342,1.903,4.108,3.857,4.108s3.611-1.766,3.856-4.108l2.2-21.018c.159-1.512-.301-3.029-1.228-4.058-.717-.796-1.65-1.234-2.629-1.234Zm3.161,5.219l-2.2,21.018c-.209,1.985-1.567,3.481-3.161,3.481s-2.953-1.497-3.162-3.481l-2.2-21.018c-.138-1.317.256-2.632,1.053-3.517.582-.646,1.331-1.002,2.108-1.002h4.401c.777,0,1.526.356,2.108,1.002.797.885,1.19,2.199,1.053,3.517Z"/>
<path class="cls-21" d="m26.445,93.697h-5.189c-1.31,0-2.523.708-3.167,1.848l-15.71,27.814c-.644,1.139-.634,2.493.025,3.622.659,1.13,1.834,1.805,3.142,1.805,1.138,0,2.225-.543,2.908-1.453l20.898-27.813c.834-1.109.967-2.569.348-3.811-.62-1.241-1.867-2.012-3.255-2.012Zm-20.899,34.389c-1.057,0-2.005-.544-2.537-1.457-.532-.912-.54-2.006-.021-2.925l15.71-27.814c.521-.921,1.5-1.493,2.558-1.493h5.189c1.138,0,2.12.607,2.628,1.625.509,1.018.403,2.167-.28,3.077l-20.899,27.813c-.552.735-1.43,1.173-2.348,1.173Z"/>
<path class="cls-21" d="m108.695,123.359l-15.711-27.814c-.644-1.14-1.857-1.848-3.167-1.848h-5.189c-1.387,0-2.634.771-3.254,2.012-.62,1.241-.487,2.701.346,3.811l20.899,27.813c.684.91,1.771,1.453,2.909,1.453,1.308,0,2.481-.674,3.141-1.804.66-1.129.67-2.484.026-3.623Zm-3.167,4.727c-.919,0-1.797-.438-2.349-1.173l-20.899-27.813c-.684-.909-.788-2.06-.28-3.077.509-1.017,1.491-1.625,2.628-1.625h5.189c1.058,0,2.037.572,2.558,1.493l15.711,27.813c.52.92.512,2.014-.021,2.926-.532.912-1.48,1.457-2.536,1.457Z"/>
</g>
<path class="cls-23" d="m23.969,105.522l-16.08,21.39c-.55.73-1.43,1.17-2.34,1.17l10.93-16.84c-3.172-1.631-4.659-3.177-4.659-3.177l3.539-6.263,8.61,3.72Z"/>
<path class="cls-23" d="m60.839,115.602l-2.2,21.02c-.17,1.63-1.12,2.92-2.33,3.34l1.079-20.913c-4.33.25-6.939-.167-6.939-.167l-.34-3.28c-.1-.89.06-1.79.42-2.55h9.88c.37.76.52,1.66.43,2.55Z"/>
<path class="cls-23" d="m108.059,126.632c-.06.11-.13.21-.22.31l-10.77-17.16c-2.69,1.74-4.78,2.63-4.78,2.63l-5.48-7.29,8.73-3.64,12.55,22.22c.52.92.51,2.01-.03,2.93Z"/>
</g>
<g>
<path class="cls-20" d="m84.332,81.154s0,0,0,0c-.182-.064-.366-.127-.551-.189h.001c.184.062.368.125.549.189Z"/>
<path class="cls-20" d="m82.923,80.686s0,0,0,0c-.186-.058-.375-.114-.564-.169,0,0,0,0,.001,0,.188.056.376.111.562.169Z"/>
<path class="cls-20" d="m79.934,79.863h0c-.186-.045-.374-.087-.561-.131h.002c.186.044.374.086.558.131Z"/>
<path class="cls-20" d="m81.453,80.256h0c-.187-.051-.377-.1-.566-.149,0,0,.001,0,.002,0,.188.049.377.098.563.149Z"/>
<path class="cls-20" d="m86.931,82.215s0,0,0,0c-.169-.08-.338-.158-.512-.235,0,0,0,0,0,0,.173.077.342.155.51.235Z"/>
<path class="cls-20" d="m89.159,83.453s0,0,.001,0c-.147-.097-.292-.195-.447-.289,0,0,0,0,0,0,.154.094.299.191.446.288Z"/>
<path class="cls-20" d="m85.673,81.663s0,0,0,0c-.177-.072-.355-.142-.536-.212h0c.18.07.358.14.534.212Z"/>
<path class="cls-20" d="m88.095,82.81s0,0,.001,0c-.159-.088-.317-.175-.482-.26h0c.164.085.322.172.48.26Z"/>
<path class="cls-20" d="m78.377,79.505s0,0,0,0c-.177-.038-.357-.074-.536-.111.001,0,.002,0,.003,0,.177.037.356.072.532.11Z"/>
<path class="cls-20" d="m76.79,79.18s0,0,0,0c-.152-.029-.305-.056-.457-.084.002,0,.004,0,.006.001.15.028.301.054.451.083Z"/>
<path class="cls-20" d="m66.764,77.794s.001,0,.002,0c-.38-.036-.755-.07-1.125-.101,0,0,0,0,0,0,.369.032.743.066,1.122.101Z"/>
<path class="cls-20" d="m65.327,77.667s0,0,.001,0c-.407-.034-.806-.066-1.197-.095,0,0,0,0,0,0,.391.029.789.061,1.195.095Z"/>
<path class="cls-20" d="m63.945,77.558s0,0,.001,0c-.441-.032-.87-.062-1.288-.089,0,0,0,0,0,0,.417.027.846.057,1.286.089Z"/>
<path class="cls-20" d="m62.644,77.468c-3.622-.234-6.333-.278-7.102-.278h0c.769,0,3.48.044,7.102.278,0,0,0,0,0,0Z"/>
<path class="cls-20" d="m72.653,78.489s.008.001.012.002c-.214-.031-.429-.064-.642-.094,0,0,0,0,0,0,.209.029.42.061.63.092Z"/>
<path class="cls-20" d="m90.107,84.146s0,0,.001.001c-.134-.108-.264-.217-.407-.322,0,0,0,0,0,0,.142.104.272.213.405.321Z"/>
<path class="cls-20" d="m68.25,77.942s.001,0,.002,0c-.355-.037-.708-.074-1.056-.108,0,0,0,0,0,0,.347.034.699.07,1.053.108Z"/>
<path class="cls-20" d="m71.275,78.3s.003,0,.005,0c-.296-.039-.592-.079-.885-.115,0,0,0,0,0,0,.292.036.586.076.88.114Z"/>
<path class="cls-20" d="m69.763,78.111s.002,0,.003,0c-.33-.039-.66-.078-.986-.114,0,0,0,0,0,0,.325.036.653.074.982.113Z"/>
<path class="cls-20" d="m92.232,86.718s.003.006.005.009c-.099-.214-.2-.427-.328-.631,0,0,0,0,0,0,.126.201.225.411.323.622Z"/>
<path class="cls-20" d="m75.168,78.884c.007.001.014.002.022.003-.007-.001-.014-.002-.022-.003Z"/>
<path class="cls-20" d="m91.627,85.709s.001.002.002.003c-.104-.142-.2-.286-.318-.423,0,0,0,0,0,0,.116.136.212.279.315.42Z"/>
<path class="cls-20" d="m19.872,101.704c10.47,4.84,26.74,6.156,35.67,6.156s20.538-.695,34.352-5.509c16.861-5.876,11.989-16.6,11.898-17.061h0v-.01c-9.82-13.47-43.66-13.47-46.25-13.47-3.03,0-48.92,0-48.92,21.62,0,3.9,1.59,7.1,4.22,9.72,11.84,11.9,44.7,11.9,44.7,11.9h0s-32.86,0-44.7-11.9c2.4-2.57,4.82-3.386,9.03-1.446Zm35.72-26.184s36.752,0,36.76,11.426c.251.612.385,1.262.385,1.953,0,1.632-1.138,2.997-3.003,4.141-8.374,5.147-31.615,5.799-34.192,5.799-1.18,0-6.689-.139-13.117-.779-.028-.003-.057-.006-.086-.009-.495-.05-.994-.102-1.498-.158-.047-.005-.093-.01-.14-.015-.526-.059-1.055-.12-1.588-.186-.004,0-.008,0-.013-.002-1.107-.137-2.223-.289-3.333-.459-.007-.001-.015-.002-.022-.003-.52-.08-1.037-.164-1.552-.251-.075-.013-.149-.026-.223-.039-.461-.079-.919-.162-1.374-.248-.079-.015-.158-.03-.237-.045-.487-.094-.969-.191-1.446-.292-.029-.006-.059-.012-.088-.018-1.049-.225-2.068-.47-3.043-.736-.009-.003-.018-.005-.027-.008-.456-.125-.899-.255-1.333-.389-.067-.021-.132-.042-.198-.063-.385-.121-.759-.246-1.124-.375-.068-.024-.136-.048-.203-.072-.384-.139-.756-.282-1.114-.43-.03-.012-.062-.024-.091-.037-.795-.333-1.522-.688-2.167-1.07-.023-.014-.044-.028-.066-.042-.205-.123-.41-.246-.597-.374,0,0,0-.01-.01-.01,0,0,0,0,0,0-.361-.15-4.37-2.61-4.37-4.71,0-2.27,3.85-12.5,39.11-12.5Z"/>
<path class="cls-20" d="m90.931,84.892s.001,0,.002.002c-.119-.121-.232-.244-.361-.362,0,0,0,0,0,0,.129.117.241.24.359.36Z"/>
<path class="cls-6" d="m92.031,88.9c0-.288-.028-.571-.081-.848-.029-.144-.08-.282-.123-.423-.034-.116-.06-.234-.104-.347-3.026-7.614-27.312-9.39-36.183-9.39-9.468,0-36.496,2.022-36.496,11.007,0,.207.027.409.072.607.008.035.019.07.029.105.044.165.103.327.177.486.017.037.033.074.052.111.137.268.326.525.542.777.135.157.282.314.453.47.059.054.123.107.185.16.172.147.36.292.562.437.123.087.244.175.376.26.065.042.133.083.201.125.225.137.458.273.707.405.016.009.033.018.049.026,2.645,1.385,6.584,2.446,10.908,3.242.169.031.337.062.507.092.094.017.19.032.284.049,8.009,1.379,16.922,1.828,20.646,1.883.255.003.509.005.745.005,7.003,0,22.791-1.131,31.119-4.384,3.257-1.273,5.372-2.871,5.372-4.854Z"/>
<path class="cls-6" d="m91.724,87.282c.044.113.069.231.104.347-.035-.115-.059-.234-.104-.347Z"/>
<path class="cls-6" d="m54.795,98.133c-3.724-.055-12.637-.504-20.646-1.883,7.4,1.294,15.743,1.833,20.646,1.883Z"/>
<path class="cls-6" d="m19.146,89.612c-.009-.035-.021-.07-.029-.105.008.035.019.07.029.105Z"/>
<path class="cls-6" d="m21.693,92.436c-.067-.041-.135-.083-.201-.125.066.042.133.083.201.125Z"/>
<path class="cls-6" d="m22.449,92.867c-.017-.009-.033-.018-.049-.026.016.009.033.018.049.026Z"/>
<path class="cls-6" d="m33.865,96.201c-.17-.03-.338-.062-.507-.092.169.031.337.062.507.092Z"/>
<path class="cls-6" d="m92.036,88.899c0-.29-.031-.572-.085-.847.053.277.081.56.081.848,0,1.982-2.115,3.581-5.372,4.854,3.26-1.273,5.376-2.872,5.376-4.855Z"/>
<path class="cls-6" d="m19.374,90.208c-.019-.037-.034-.074-.052-.111.017.037.033.074.052.111Z"/>
<path class="cls-6" d="m20.369,91.454c-.17-.156-.318-.313-.453-.47.137.159.285.316.453.47Z"/>
<path class="cls-6" d="m21.116,92.051c-.202-.144-.39-.29-.562-.437.174.148.361.294.562.437Z"/>
<g>
<path class="cls-21" d="m102.403,84.941c-.014-.025-.029-.05-.046-.073-9.546-13.094-40.673-13.758-46.816-13.758-3.243,0-14.684.196-25.752,2.72-15.615,3.561-23.868,10.338-23.868,19.6,0,3.853,1.489,7.29,4.426,10.216,5.016,5.041,14.161,8.611,27.178,10.608,9.636,1.479,17.933,1.496,18.016,1.496.123,0,12.445-.031,24.606-2.719,16.358-3.615,25.004-10.393,25.004-19.601,0-3.124-.925-5.98-2.749-8.489Zm-.612.339v.01c1.69,2.3,2.66,4.99,2.66,8.14,0,21.62-48.91,21.62-48.91,21.62,0,0-32.86,0-44.7-11.9-2.63-2.62-4.22-5.82-4.22-9.72,0-21.62,45.89-21.62,48.92-21.62,2.59,0,36.43,0,46.25,13.47Z"/>
<path class="cls-21" d="m18.483,89.826c.054.195.125.386.212.573.025.053.05.106.077.159.134.257.288.51.48.752h.01c.073.091.172.174.253.262.122.133.24.267.38.394.12.11.257.215.389.322.144.115.285.231.442.343.045.032.081.067.127.099.01,0,.01.01.01.01.187.128.392.251.597.374.023.014.043.028.066.042.645.381,1.372.737,2.167,1.07.03.013.061.024.091.037.358.148.73.291,1.114.43.067.024.135.048.203.072.365.129.739.254,1.124.375.066.021.132.042.198.063.434.134.878.264,1.333.389.009.003.018.005.027.008.974.266,1.994.511,3.043.736.029.006.059.012.088.018.477.102.959.199,1.446.292.079.015.158.03.237.045.455.086.913.169,1.374.248.074.013.149.026.223.039.515.087,1.033.172,1.552.251.007.001.015.002.022.003,1.11.17,2.227.323,3.333.459.004,0,.008.001.013.002.532.066,1.062.127,1.588.186.047.005.093.01.14.015.503.056,1.003.108,1.498.158.028.003.057.006.086.009,6.429.64,11.937.779,13.117.779,2.577,0,25.818-.652,34.192-5.799,1.865-1.145,3.003-2.509,3.003-4.141,0-.691-.134-1.341-.385-1.953,0,.001,0,.002,0,.004-.031-.077-.079-.148-.115-.223-.001-.003-.003-.006-.005-.009-.098-.21-.197-.421-.323-.622,0,0,0,0,0,0-.055-.089-.123-.173-.188-.258-.032-.042-.062-.086-.094-.128-.103-.141-.199-.284-.315-.42,0,0,0,0,0,0-.116-.135-.25-.264-.378-.395,0,0,0-.001-.002-.002-.118-.121-.231-.243-.359-.36,0,0,0,0,0,0-.119-.108-.25-.211-.38-.316-.029-.023-.056-.047-.085-.071-.133-.108-.263-.217-.405-.321-.105-.077-.219-.151-.33-.225-.072-.048-.14-.097-.212-.145,0,0,0,0-.001,0-.147-.097-.292-.194-.446-.288,0,0,0,0,0,0-.198-.121-.406-.238-.616-.354,0,0,0,0-.001,0-.158-.087-.316-.175-.48-.26h0c-.222-.115-.451-.226-.683-.335,0,0,0,0,0,0-.168-.079-.337-.158-.51-.235-.025-.011-.05-.021-.075-.032-.22-.097-.443-.192-.671-.285,0,0,0,0,0,0-.176-.072-.354-.142-.534-.211h0c-.264-.102-.532-.201-.805-.298,0,0,0,0,0,0-.181-.064-.364-.126-.549-.188h-.001c-.17-.057-.343-.112-.515-.168-.115-.037-.228-.076-.344-.112-.185-.058-.373-.113-.562-.169,0,0,0,0-.001,0-.3-.089-.601-.177-.907-.261h0c-.186-.051-.375-.1-.563-.149-.288-.075-.577-.15-.869-.222-.029-.007-.057-.015-.086-.022h0c-.185-.045-.372-.087-.558-.131h-.002c-.331-.078-.661-.155-.997-.227,0,0,0,0,0,0-.176-.038-.354-.073-.532-.11,0,0-.002,0-.003,0-.349-.073-.698-.146-1.051-.214,0,0,0,0,0,0-.15-.029-.301-.055-.451-.083-.002,0-.004,0-.006-.001-.38-.071-.761-.142-1.143-.208-.007-.001-.014-.002-.022-.003-.834-.144-1.67-.272-2.503-.393-.004,0-.008-.001-.012-.002-.21-.03-.421-.063-.63-.092,0,0,0,0,0,0-.144-.02-.287-.038-.431-.057-.105-.014-.211-.028-.317-.042-.294-.039-.588-.078-.88-.114-.046-.006-.09-.011-.136-.016-.165-.02-.329-.039-.494-.058,0,0-.002,0-.003,0-.329-.039-.657-.078-.982-.113-.011-.001-.021-.002-.032-.003-.167-.018-.331-.035-.497-.052,0,0-.001,0-.002,0-.354-.037-.706-.074-1.053-.108,0,0,0,0,0,0-.145-.014-.287-.027-.431-.04,0,0-.001,0-.002,0-.379-.036-.754-.07-1.122-.101,0,0,0,0,0,0-.105-.009-.208-.017-.313-.026,0,0,0,0-.001,0-.406-.034-.804-.066-1.195-.095,0,0,0,0,0,0-.062-.005-.123-.009-.185-.014,0,0,0,0-.001,0-.44-.032-.869-.062-1.286-.089,0,0,0,0,0,0-.001,0-.002,0-.004,0-.004,0-.008,0-.011,0-3.621-.234-6.332-.278-7.102-.278-1.331,0-8.456.127-16.118,1.159-6.749.909-13.914,2.519-17.911,5.37-1.97,1.405-3.17,3.111-3.17,5.181,0,.262.038.515.094.763.013.055.032.109.047.164Zm68.177,3.928c-8.328,3.253-24.116,4.384-31.119,4.384-.236,0-.49-.002-.745-.005-4.903-.05-13.245-.589-20.646-1.883-.094-.017-.19-.032-.284-.049-.17-.03-.338-.061-.507-.092-4.324-.796-8.263-1.857-10.908-3.242-.017-.009-.033-.018-.049-.026-.249-.132-.482-.267-.707-.405-.068-.041-.135-.083-.201-.125-.133-.085-.253-.173-.376-.26-.201-.143-.388-.288-.562-.437-.062-.053-.127-.106-.185-.16-.167-.153-.315-.31-.453-.47-.102-.119-.196-.239-.284-.36-.092-.138-.187-.277-.258-.416-.019-.037-.034-.074-.052-.111-.074-.159-.133-.321-.177-.486-.009-.035-.021-.07-.029-.105-.045-.199-.072-.401-.072-.607,0-8.985,27.028-11.007,36.496-11.007,8.871,0,33.157,1.776,36.183,9.39.045.113.068.231.104.347.043.141.095.279.123.423.055.275.085.557.085.847,0,1.983-2.117,3.581-5.376,4.855Z"/>
</g>
<path class="cls-19" d="m104.452,93.43c0-3.15-.97-5.84-2.66-8.14.091.461,4.963,11.185-11.898,17.061-13.814,4.814-25.432,5.509-34.352,5.509s-25.2-1.316-35.67-6.156c-4.21-1.94-6.63-1.124-9.03,1.446,11.84,11.9,44.7,11.9,44.7,11.9.002,0,48.91,0,48.91-21.62Z"/>
<path class="cls-19" d="m101.791,85.29h0c1.69,2.3,2.66,4.99,2.66,8.14,0,21.62-48.908,21.62-48.91,21.62h0s48.91,0,48.91-21.62c0-3.15-.97-5.84-2.66-8.14Z"/>
<path class="cls-19" d="m20.851,92.73c-.046-.032-.082-.067-.127-.099-.157-.111-.298-.227-.44-.342-.133-.107-.27-.212-.39-.323-.139-.128-.257-.261-.378-.393-.082-.089-.18-.172-.254-.264h-.01c-.192-.242-.345-.494-.48-.752-.027-.052-.052-.105-.077-.158-.087-.187-.158-.379-.212-.574-.015-.054-.034-.108-.047-.162-.056-.248-.094-.501-.094-.763,0-2.071,1.2-3.776,3.171-5.181,3.987-2.845,11.13-4.454,17.865-5.364,7.68-1.038,14.831-1.165,16.164-1.165h0c.769,0,3.481.044,7.102.278.005,0,.009,0,.014,0,.418.027.847.057,1.288.089.062.005.123.009.185.014.391.029.79.061,1.197.095.104.009.207.017.313.026.37.032.745.066,1.125.101.144.013.286.026.431.04.348.034.701.07,1.056.108.177.018.35.036.528.055.326.036.656.075.986.114.21.025.418.048.629.074.293.036.589.076.885.115.248.032.494.063.743.097.214.03.428.063.642.094.833.12,1.669.249,2.503.393.007.001.014.002.022.003.382.066.763.137,1.143.208.152.028.305.055.457.084.353.068.701.141,1.051.214.179.037.358.073.536.111.335.073.666.15.997.227.187.044.375.086.561.131.321.078.637.161.952.243.189.05.379.098.566.15.307.084.607.172.907.261.189.056.378.112.564.169.291.09.575.184.858.279.185.062.369.125.551.189.273.097.541.196.805.298.181.07.359.14.536.212.253.103.502.208.745.316.174.077.344.156.512.235.233.11.462.221.683.335.165.085.323.173.482.26.21.116.418.233.616.354.155.094.3.192.447.289.185.122.37.244.542.37.143.105.273.214.407.322.158.127.318.253.463.385.129.117.242.241.361.362.128.131.262.259.378.395.117.137.214.281.318.423.094.128.198.252.28.384.128.204.229.418.328.631.034.074.083.144.114.22-.008-11.426-36.76-11.426-36.76-11.426-35.26,0-39.11,10.23-39.11,12.5,0,2.1,4.009,4.559,4.37,4.71Z"/>
<path class="cls-19" d="m19.894,91.967s0,0,0,0c.121.11.257.216.39.323,0,0,0,0,0,0-.132-.106-.269-.212-.389-.322Z"/>
<path class="cls-19" d="m19.262,91.31h0c.073.091.172.175.254.264,0,0,0,0-.001-.001-.081-.088-.179-.172-.253-.262Z"/>
<path class="cls-19" d="m18.772,90.558c.134.258.288.51.48.752h0c-.192-.242-.345-.494-.48-.752,0,0,0,0,0,0Z"/>
<path class="cls-19" d="m55.541,77.19h0c-1.333,0-8.484.127-16.164,1.165.015-.002.03-.004.045-.006,7.663-1.032,14.788-1.159,16.118-1.159Z"/>
<path class="cls-19" d="m18.342,88.9c0-2.07,1.2-3.776,3.17-5.181,0,0,0,0,0,0-1.97,1.405-3.171,3.111-3.171,5.181,0,.262.038.515.094.763,0,0,0,0,0,0-.056-.248-.094-.501-.094-.763Z"/>
<path class="cls-19" d="m20.724,92.631s0,0,0,0c.045.032.081.067.127.099,0,0,0,0,0,0-.046-.032-.082-.067-.127-.099Z"/>
<path class="cls-19" d="m18.483,89.826s0,0,0,0c.054.196.125.387.212.574,0,0,0,0,0,0-.087-.187-.157-.378-.212-.573Z"/>
<path class="cls-19" d="m19.894,91.966c-.139-.127-.257-.261-.378-.393.122.132.239.265.378.393Z"/>
<path class="cls-19" d="m18.772,90.558c-.027-.052-.052-.105-.077-.158.025.053.049.106.077.158Z"/>
<path class="cls-19" d="m18.483,89.825c-.015-.054-.034-.108-.047-.162.012.055.032.108.047.162Z"/>
<path class="cls-19" d="m20.724,92.631c-.156-.111-.297-.227-.44-.342.143.115.284.231.44.342Z"/>
<path class="cls-19" d="m92.352,86.95s0-.002,0-.004c-.031-.076-.08-.145-.114-.22.035.076.083.147.115.223Z"/>
<path class="cls-19" d="m90.933,84.894c.128.131.262.259.378.395-.116-.135-.25-.264-.378-.395Z"/>
<path class="cls-19" d="m85.674,81.664c.253.103.502.208.745.316-.244-.108-.492-.213-.745-.316Z"/>
<path class="cls-19" d="m82.924,80.686c.291.09.575.184.858.279-.283-.095-.567-.189-.858-.279Z"/>
<path class="cls-19" d="m84.333,81.154c.273.097.541.196.805.298-.264-.102-.532-.201-.805-.298Z"/>
<path class="cls-19" d="m79.935,79.863c.321.078.637.161.952.243-.316-.083-.631-.165-.952-.243Z"/>
<path class="cls-19" d="m81.453,80.256c.306.084.607.172.907.261-.3-.089-.6-.177-.907-.261Z"/>
<path class="cls-19" d="m86.932,82.215c.233.11.462.221.683.335-.222-.114-.451-.225-.683-.335Z"/>
<path class="cls-19" d="m91.629,85.711c.094.128.198.252.28.384-.082-.132-.186-.256-.28-.384Z"/>
<path class="cls-19" d="m90.108,84.147c.158.127.318.253.463.385-.145-.132-.306-.258-.463-.385Z"/>
<path class="cls-19" d="m88.097,82.811c.21.116.418.233.616.354-.198-.121-.406-.238-.616-.354Z"/>
<path class="cls-19" d="m89.16,83.454c.185.122.37.244.542.37-.172-.127-.357-.248-.542-.37Z"/>
<path class="cls-19" d="m78.377,79.505c.335.073.666.15.997.227-.331-.077-.661-.154-.997-.227Z"/>
<path class="cls-19" d="m63.946,77.558c.062.005.123.009.185.014-.062-.005-.123-.009-.185-.014Z"/>
<path class="cls-19" d="m68.252,77.943c.176.018.351.036.528.055-.178-.019-.352-.037-.528-.055Z"/>
<path class="cls-19" d="m69.766,78.112c.21.025.418.048.629.074-.211-.026-.419-.049-.629-.074Z"/>
<path class="cls-19" d="m65.328,77.667c.104.009.207.017.313.026-.105-.009-.208-.017-.313-.026Z"/>
<path class="cls-19" d="m21.512,83.719c3.988-2.844,11.13-4.454,17.865-5.364-6.736.909-13.878,2.519-17.865,5.364Z"/>
<path class="cls-19" d="m62.644,77.468s.009,0,.014,0c-.005,0-.009,0-.014,0Z"/>
<path class="cls-19" d="m66.766,77.794c.144.013.286.026.431.04-.145-.014-.287-.027-.431-.04Z"/>
<path class="cls-19" d="m71.28,78.3c.248.032.494.063.743.097-.249-.034-.495-.065-.743-.097Z"/>
<path class="cls-19" d="m76.791,79.18c.353.068.702.141,1.051.214-.349-.073-.698-.146-1.051-.214Z"/>
<path class="cls-19" d="m75.19,78.887c.382.066.763.137,1.143.208-.381-.071-.761-.143-1.143-.208Z"/>
<path class="cls-19" d="m72.665,78.491c.833.121,1.669.249,2.503.393-.834-.143-1.67-.272-2.503-.393Z"/>
<path class="cls-19" d="m18.436,89.662s0,0,0,0c.013.055.032.108.047.162,0,0,0,0,0,0-.015-.055-.034-.108-.047-.164Z"/>
<path class="cls-19" d="m39.423,78.349c-.015.002-.03.004-.045.006-6.735.91-13.877,2.52-17.865,5.364,0,0,0,0,0,0,3.997-2.85,11.162-4.461,17.911-5.37Z"/>
<path class="cls-19" d="m20.283,92.288s0,0,0,0c.143.115.284.231.44.342,0,0,0,0,0,0-.157-.111-.298-.227-.442-.343Z"/>
<path class="cls-19" d="m18.695,90.399s0,0,0,0c.025.053.05.106.077.158,0,0,0,0,0,0-.027-.053-.052-.106-.077-.159Z"/>
<path class="cls-19" d="m19.514,91.572s0,0,.001.001c.122.132.24.266.378.393,0,0,0,0,0,0-.139-.128-.258-.262-.38-.394Z"/>
</g>
</g>
<g>
<path class="cls-18" d="m72.699,70.732c-8.132,1.567-16.792,1.585-17.156,1.585-.921,0-22.628-.075-29.753-6.622-.181-.165-.35-.334-.51-.506-5.43,2.311-9.404,5.755-9.404,10.911,0,2.532,1.271,4.612,3.362,6.325,8.899,7.27,32.664,7.764,36.299,7.764,4.486,0,39.661-.746,39.661-14.089,0-2.889-1.25-5.253-3.31-7.165v-.01c-1.605-1.499-3.709-2.727-6.088-3.74-2.267,2.442-6.637,4.303-13.101,5.548Z"/>
<path class="cls-18" d="m55.537,59.798h.02c5.131,0,19.883.395,30.042,4.542.841-1.032,1.33-2.206,1.33-3.549,0-2.031-.833-3.724-2.232-5.127v-.011h-.012c-6.811-6.843-26.98-6.843-29.142-6.843-2.614,0-31.397,0-31.397,11.981,0,1.347.483,2.521,1.324,3.548,2.5-1.015,5.438-1.878,8.841-2.577,8.634-1.773,17.716-1.965,21.227-1.965Z"/>
<path class="cls-17" d="m55.543,71.616c2.999,0,23.323-.417,29.565-6.719-10.988-4.389-27.312-4.4-29.55-4.4h-.02c-2.163,0-18.543-.004-29.564,4.395.095.095.187.192.289.284,6.568,6.035,26.332,6.439,29.281,6.439Z"/>
<path class="cls-16" d="m55.557,59.798h-.02c-3.511,0-12.594.191-21.228,1.965-3.403.699-6.341,1.562-8.841,2.577.088.107.177.213.272.317.074.08.153.158.231.237,11.021-4.399,27.401-4.395,29.564-4.395h.02c2.239,0,18.562.011,29.55,4.4.09-.091.165-.189.249-.283.082-.091.166-.181.242-.274-10.158-4.148-24.91-4.542-30.042-4.542Z"/>
<path class="cls-21" d="m92.434,68.486c-.021-.026-.043-.051-.068-.074-1.62-1.512-3.713-2.76-6.085-3.79.89-1.157,1.348-2.433,1.348-3.832,0-2.09-.804-3.965-2.39-5.574-.033-.041-.071-.079-.112-.111-6.555-6.49-24.292-6.995-29.584-6.995-2.774,0-9.965.147-16.815,1.511-10.14,2.019-15.282,5.777-15.282,11.17,0,1.391.454,2.674,1.35,3.831-6.381,2.754-9.619,6.592-9.619,11.478,0,2.59,1.217,4.9,3.618,6.866,9.587,7.832,35.638,7.923,36.743,7.923,1.648,0,40.361-.168,40.361-14.789,0-2.889-1.165-5.45-3.464-7.613Zm-36.897,21.702c-3.635,0-27.4-.494-36.299-7.764-2.091-1.712-3.362-3.793-3.362-6.325,0-5.155,3.974-8.599,9.404-10.911.161.171.329.34.51.506,7.125,6.547,28.832,6.622,29.753,6.622.365,0,9.025-.018,17.156-1.585,6.464-1.245,10.834-3.106,13.101-5.548,2.38,1.013,4.483,2.241,6.088,3.74v.01c2.059,1.912,3.31,4.276,3.31,7.165,0,13.343-35.175,14.089-39.661,14.089Zm.006-41.379c2.162,0,22.331,0,29.142,6.843h.012v.011c1.399,1.402,2.232,3.096,2.232,5.127,0,1.344-.489,2.518-1.33,3.549-.076.093-.16.183-.242.274-.084.094-.159.191-.249.283-6.241,6.302-26.566,6.719-29.565,6.719-2.949,0-22.713-.404-29.281-6.439-.102-.093-.194-.189-.289-.284-.078-.078-.158-.156-.231-.236-.095-.104-.185-.21-.272-.317-.841-1.027-1.324-2.201-1.324-3.548,0-11.981,28.784-11.981,31.397-11.981Z"/>
</g>
<g>
<polygon class="cls-14" points="36.855 36.028 55.187 44.002 55.187 3.573 36.855 36.028"/>
<polygon class="cls-13" points="74.222 35.38 55.886 3.55 55.886 43.985 74.222 35.38"/>
<polygon class="cls-9" points="37.344 37.004 55.187 62.832 55.187 44.766 52.018 43.387 37.344 37.004"/>
<polygon class="cls-9" points="37.344 37.004 37.344 37.004 52.018 43.387 37.344 37.004"/>
<polygon class="cls-2" points="55.886 62.81 73.715 36.392 69.264 38.48 55.886 44.759 55.886 62.81"/>
<polygon class="cls-2" points="69.264 38.48 55.886 44.758 55.886 44.759 69.264 38.48"/>
<path class="cls-21" d="m75.019,35.361L55.84,2.066c-.005-.008-.014-.009-.019-.017-.021-.03-.052-.048-.08-.07-.031-.024-.058-.051-.096-.064-.008-.003-.012-.011-.02-.013-.031-.009-.06.006-.091.006-.03,0-.058-.014-.088-.006-.008.002-.012.01-.019.013-.038.013-.066.04-.096.064-.028.022-.059.04-.079.071-.005.008-.015.009-.019.017l-19.178,33.953c-.026.047-.039.098-.042.151,0,.009.003.017.003.026,0,.036,0,.072.012.107.008.023.024.041.037.062.005.008.002.018.007.025l19.178,27.76s.004.003.006.005c.005.007.014.01.019.017.037.044.085.078.14.099.008.003.012.012.02.014.034.011.068.016.103.016.035,0,.069-.005.104-.016.009-.003.013-.012.021-.015.053-.021.098-.054.134-.096.007-.008.018-.013.024-.022.002-.002.005-.003.007-.006l19.179-28.419c.005-.007.002-.017.007-.025.013-.022.029-.042.037-.066.011-.035.011-.072.011-.108,0-.008.003-.015.002-.022-.004-.052-.017-.103-.043-.149Zm-1.304,1.031l-17.829,26.418v-18.051h0s13.378-6.278,13.378-6.278l4.451-2.089ZM55.187,3.573v40.43l-18.332-7.974L55.187,3.573Zm.699,40.413V3.55l18.336,31.831-18.336,8.605Zm-18.542-6.981l17.843,7.761v18.066l-17.843-25.828h0Z"/>
</g>
</g>
<path class="cls-22" d="m55.604,1.9c.007,0,.014,0,.021.003.008.002.012.011.02.013.038.013.066.04.096.064.028.022.059.039.08.07.005.007.014.009.019.017l19.179,33.294c.027.046.04.097.043.149,0,.007-.002.015-.002.022,0,.037,0,.073-.011.108-.008.025-.024.044-.037.066-.004.008-.002.017-.007.024l-8.748,12.962c6.903.763,14.918,2.499,18.87,6.412.041.033.078.07.112.111,1.586,1.61,2.39,3.485,2.39,5.574,0,1.398-.457,2.675-1.348,3.832,2.372,1.03,4.466,2.278,6.085,3.79.024.023.047.048.068.074,2.299,2.163,3.464,4.724,3.464,7.614,0,1.013-.203,1.951-.549,2.83,2.854,1.622,5.286,3.575,7.009,5.938.017.023.032.048.046.073,1.824,2.509,2.749,5.365,2.749,8.489,0,4.671-2.235,8.713-6.563,12.038l10.106,17.891c.644,1.139.634,2.494-.026,3.623-.659,1.129-1.833,1.804-3.141,1.804-1.138,0-2.226-.543-2.909-1.453l-12.873-17.131c-2.831,1.086-6.019,2.038-9.599,2.829-6.907,1.527-13.863,2.196-18.609,2.49-.004.053-.003.105-.009.158l-2.2,21.018c-.245,2.342-1.903,4.108-3.856,4.108s-3.612-1.766-3.857-4.108l-2.2-21.018c-.006-.055-.005-.11-.009-.166-3.212-.201-7.395-.57-11.881-1.258-6.337-.972-11.748-2.32-16.219-4.026l-12.852,17.104c-.684.91-1.771,1.453-2.908,1.453-1.308,0-2.482-.675-3.142-1.805-.659-1.129-.669-2.483-.025-3.622l10.087-17.859c-.772-.594-1.48-1.212-2.119-1.854-2.937-2.926-4.426-6.363-4.426-10.216,0-5.891,3.352-10.771,9.8-14.464-.36-.912-.546-1.868-.546-2.866,0-4.886,3.238-8.724,9.619-11.478-.896-1.157-1.35-2.44-1.35-3.831,0-5.393,5.142-9.151,15.282-11.17,1.965-.391,3.954-.68,5.863-.896l-8.52-12.333c-.005-.008-.003-.017-.007-.025-.012-.021-.029-.039-.037-.062-.011-.035-.012-.071-.012-.107,0-.009-.003-.017-.003-.026.003-.052.016-.104.042-.151L55.232,2.069c.004-.008.014-.01.019-.017.021-.031.051-.049.079-.071.031-.024.058-.051.096-.064.008-.003.011-.011.019-.013.007-.002.014-.002.02-.002.023,0,.045.008.068.008.024,0,.047-.009.07-.009m0-1.8c-.024,0-.05,0-.077.002-.021,0-.042-.001-.062-.001-.156,0-.331.023-.482.063-.122.032-.242.078-.355.136-.187.09-.329.198-.427.278-.061.044-.166.129-.28.257-.1.107-.188.225-.259.351l-19.176,33.95c-.159.283-.25.592-.271.919-.005.084-.005.173.002.261.006.136.027.333.1.554.03.09.073.189.123.285.046.098.1.192.162.279l6.832,9.89c-1.065.16-2.087.338-3.059.531-11.102,2.21-16.731,6.562-16.731,12.935,0,1.062.204,2.077.608,3.036-5.893,3.011-8.878,7.133-8.878,12.272,0,.692.074,1.377.22,2.046-6.205,3.94-9.474,9.2-9.474,15.284,0,4.349,1.667,8.215,4.955,11.491.337.339.698.677,1.078,1.01L.811,122.474c-.962,1.702-.948,3.726.038,5.415.985,1.689,2.741,2.697,4.696,2.697,1.7,0,3.325-.812,4.347-2.171l12.049-16.035c4.337,1.532,9.48,2.76,15.311,3.654,3.349.514,6.879.907,10.513,1.172l2.06,19.678c.341,3.261,2.769,5.721,5.648,5.721s5.305-2.459,5.647-5.721l2.059-19.669c4.65-.337,11.022-1.025,17.357-2.426,3.066-.678,5.944-1.494,8.577-2.433l12.067,16.059c1.022,1.36,2.647,2.171,4.348,2.171,1.955,0,3.71-1.008,4.695-2.696.986-1.688,1.001-3.713.039-5.416l-9.362-16.573c4.017-3.51,6.051-7.699,6.051-12.47,0-3.483-1.023-6.671-3.042-9.478-.031-.049-.063-.096-.096-.143-1.537-2.108-3.664-4.022-6.336-5.703.146-.663.22-1.333.22-2.006,0-3.375-1.333-6.355-3.961-8.859-.044-.048-.09-.095-.137-.14-1.286-1.2-2.888-2.295-4.781-3.269.406-.962.611-1.979.611-3.041,0-2.558-.963-4.843-2.862-6.791-.07-.079-.144-.154-.223-.223-3.198-3.131-8.895-5.312-16.961-6.498l7.114-10.542c.049-.072.092-.149.13-.228.045-.084.096-.194.137-.325.076-.245.092-.455.095-.581.004-.071.003-.143-.001-.211-.024-.339-.117-.648-.278-.928L57.4,1.168c-.08-.139-.182-.27-.298-.385-.097-.101-.186-.174-.259-.228-.088-.07-.206-.156-.36-.235-.123-.066-.253-.119-.385-.155-.154-.042-.334-.066-.494-.066h0Z"/>
<g>
<path class="cls-2" d="m178.64,48.449c3.272,0,4.795-.305,5.937-.533,1.065-.305,1.675-.533,3.12-.533,2.969,0,9.134,5.861,9.134,14.081,0,3.806-1.979,6.317-5.86,6.317-6.622,0-6.546-8.981-10.428-8.981-2.968,0-2.663,4.11-2.663,4.719v23.366c0,9.057,7.002,4.719,7.002,10.046,0,2.359-1.446,5.708-15.679,5.708-3.882,0-18.114.152-18.114-5.708,0-4.034,4.719-2.436,6.24-5.328.762-1.599,1.066-6.165,1.142-8.068l.609-16.82c.305-7.992-1.294-7.916-2.359-7.916-3.425,0-5.1,8.981-11.265,8.981-2.968,0-4.719-2.74-4.719-5.48,0-6.47,5.48-14.918,9.818-14.918.533,0,1.37.229,2.816.533,1.445.228,3.501.533,6.545.533h18.724Z"/>
<path class="cls-7" d="m200.861,78.741c0-7.459-4.719-3.273-4.719-7.916,0-6.013,17.81-7.688,18.495-7.688,2.892,0,2.968.761,3.272,3.349.076.914,0,2.664,1.294,2.664,1.827,0,2.969-6.013,9.514-6.013,5.1,0,8.448,3.806,8.448,8.829,0,5.1-4.338,9.133-9.361,9.133-4.947,0-6.317-4.338-8.296-4.338-2.131,0-1.751,5.1-1.751,6.47,0,2.283.229,5.479.609,8.372.456,3.349,6.241,1.294,6.241,5.86,0,4.795-6.698,5.176-15.07,5.176-3.501,0-13.853.152-13.853-5.1,0-3.197,3.501-3.197,4.339-5.86.608-1.903.837-6.241.837-8.373v-4.566Z"/>
<path class="cls-8" d="m275.523,83.612c0,13.396,4.567,6.089,4.567,10.199,0,4.338-5.938,9.057-11.493,9.057-6.013,0-6.165-3.881-8.067-3.881-.914,0-1.903.989-3.577,1.902-1.675.99-3.958,1.979-7.459,1.979-6.317,0-12.635-3.349-12.635-10.884,0-5.404,5.176-12.102,18.571-12.102,2.359,0,3.425.152,3.425-2.588,0-2.512-.076-7.763-3.577-7.763-4.566,0-5.023,8.22-11.797,8.22-2.436,0-4.263-1.75-4.263-4.186,0-6.469,12.863-10.655,20.094-10.655,4.338,0,16.211,1.674,16.211,13.091v7.611Zm-22.833,6.698c0,1.979,1.065,4.11,3.272,4.11,2.893,0,3.121-3.121,3.121-5.404,0-1.674.076-3.577-2.055-3.577-2.588,0-4.339,2.436-4.339,4.871Z"/>
<path class="cls-1" d="m284.578,80.567c0-4.871-.99-6.241-1.751-6.926-.989-.837-2.055-.761-2.055-2.74,0-5.632,18.875-7.764,19.027-7.764,1.903,0,2.055,1.675,2.055,3.273,0,.914-.304,3.501,1.294,3.501.762,0,1.979-1.674,4.11-3.425,2.131-1.674,5.176-3.349,9.666-3.349,9.058,0,9.134,6.774,10.731,6.774.609,0,1.979-1.674,4.187-3.425,2.283-1.674,5.327-3.349,9.209-3.349,7.23,0,11.722,3.958,11.722,11.417v16.059c0,4.415,2.815,4.795,2.815,7.002,0,4.795-8.677,5.023-11.645,5.023-2.512,0-11.341-.533-11.341-4.491,0-2.816,2.664-2.74,2.664-7.154v-6.85c0-3.197.381-9.59-3.958-9.59-4.262,0-3.882,5.175-3.882,7.992v4.719c0,9.362,2.74,7.763,2.74,10.58,0,4.719-9.133,4.795-11.873,4.795-11.493,0-12.102-2.131-12.025-4.262.076-4.034,3.882-.837,3.653-9.895v-4.338c0-2.74.381-9.59-3.958-9.59-4.262,0-3.882,5.175-3.882,7.992v6.165c0,7.002,2.056,6.546,2.056,9.286,0,1.979-1.751,4.643-12.178,4.643-2.969,0-11.95.076-11.95-3.958,0-3.425,4.567-1.598,4.567-11.112v-7.002Z"/>
<path class="cls-5" d="m361.216,86.732c0-7.992-.456-9.742-.837-10.123-.989-.989-5.1,0-5.1-4.338,0-5.176,15.679-9.134,21.312-9.134,1.826,0,2.131.609,2.131,2.284,0,.533.076,1.598.837,1.598,1.066,0,5.328-4.11,11.569-4.11,9.971,0,15.298,9.97,15.298,18.875,0,10.199-7.23,21.083-18.343,21.083-3.577,0-7.154-1.903-7.839-1.903-1.065,0-1.218,1.599-1.218,2.436,0,5.785,4.719,3.958,4.719,7.688,0,4.262-6.926,4.795-9.895,4.795-10.199,0-18.19-3.729-18.19-7.611,0-3.425,3.12-2.131,4.49-5.099.762-1.599,1.065-3.806,1.065-12.254v-4.186Zm20.854-12.559c-2.739,0-3.653,1.142-3.653,14.994,0,2.512,1.218,3.425,3.882,3.425,4.719,0,5.328-5.1,5.328-8.753s-.762-9.666-5.557-9.666Z"/>
<path class="cls-4" d="m407.184,84.221c0-13.624,11.949-21.311,24.508-21.311,13.319,0,20.931,10.427,20.931,19.865,0,12.482-13.396,20.093-24.584,20.093-11.265,0-20.854-6.622-20.854-18.647Zm21.234-12.33c-2.512,0-3.881,2.359-3.729,4.567.304,4.795,1.674,16.516,6.926,16.82,2.892.152,3.501-2.283,3.501-4.719,0-3.425-1.522-16.668-6.698-16.668Z"/>
<path class="cls-3" d="m475.909,88.178c0,1.37-.076,3.882.305,5.1.838,2.74,4.263,1.446,4.263,4.643,0,5.1-9.286,4.719-12.559,4.719-3.425,0-14.08.38-14.08-5.023,0-2.664,3.272-1.674,4.186-4.491.989-3.121.989-12.634.989-16.364,0-2.968.152-8.677-.532-12.102-1.142-5.632-5.633-3.653-5.633-7.992,0-6.622,19.789-8.22,20.854-8.22,2.588,0,3.197,1.674,3.197,3.958,0,1.75-.99,10.123-.99,21.007v14.765Z"/>
<path class="cls-10" d="m502.622,87.722c0,8.981,4.49,5.708,4.49,9.742,0,5.176-9.666,5.176-13.091,5.176-13.091,0-13.624-2.969-13.624-4.795,0-2.74,3.121-2.893,4.034-5.252.989-2.664,1.142-13.7.305-16.516-.686-2.664-4.567-1.827-4.567-5.251,0-5.404,19.332-7.688,20.094-7.688,2.283,0,2.359,1.751,2.359,3.349v21.235Zm-15.07-27.02c-3.196,0-6.621-1.446-6.621-5.175,0-5.023,10.047-7.078,13.928-7.078,3.045,0,7.383.989,7.383,4.795,0,5.937-10.351,7.458-14.689,7.458Z"/>
<path class="cls-11" d="m528.725,89.092c0,7.383,2.588,6.089,2.588,8.981,0,4.719-10.122,4.567-13.243,4.567-3.044,0-10.808,0-10.808-4.567,0-3.653,3.501-.38,3.729-7.839l.305-11.417c.152-5.632-4.491-3.806-4.491-7.839s17.506-7.84,19.028-7.84c1.674,0,2.739,1.446,2.739,2.969,0,.457-.075.913-.075,1.37,0,.914.38,1.979,1.521,1.979,1.218,0,1.903-1.598,3.425-3.197,1.522-1.522,3.806-3.121,8.145-3.121,15.755,0,13.091,15.451,13.852,27.02.381,6.013,3.502,4.643,3.502,7.916,0,1.37.304,4.567-14.005,4.567-2.74,0-10.047.152-10.047-4.11,0-2.74,2.512-1.446,2.893-6.393.381-5.404,2.512-17.582-4.643-17.582-4.871,0-4.415,5.251-4.415,8.677v5.86Z"/>
<path class="cls-12" d="m577.889,84.297c-.838,0-2.056-.229-2.056,1.065,0,3.806,4.947,7.002,8.524,7.002,5.709,0,8.524-3.425,10.123-3.425s2.968,2.359,2.968,3.806c0,5.86-10.731,10.123-17.581,10.123-13.7,0-20.778-9.894-20.778-19.712,0-11.721,9.971-20.246,21.387-20.246,12.939,0,18.647,9.97,18.647,15.679,0,5.023-2.283,5.708-4.719,5.708h-16.516Zm6.621-9.59c0-2.74-1.675-4.871-4.49-4.871-2.512,0-4.566,2.587-4.566,5.023,0,2.74,2.587,2.131,4.566,2.131,2.055,0,4.49.457,4.49-2.283Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 34 KiB

View File

@@ -0,0 +1,19 @@
import React, { Component } from 'react';
import icon from '../../assets/img/icon-128.png';
class GreetingComponent extends Component {
state = {
name: 'dev',
};
render() {
return (
<div>
<p>Hello, {this.state.name}!</p>
<img src={icon} alt="extension icon" />
</div>
);
}
}
export default GreetingComponent;

View File

@@ -0,0 +1,22 @@
// eslint-disable-next-line import/no-anonymous-default-export
export default {
enablePasswordEncryption: false,
showTransactionConfirmationScreen: true,
factory_address: '0x9406Cc6185a346906296840746125a0E44976454',
stateVersion: '0.1',
network: {
chainID: '11155111',
family: 'EVM',
name: 'Sepolia',
provider: 'https://sepolia.infura.io/v3/bdabe9d2f9244005af0f566398e648da',
entryPointAddress: '0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789',
bundler: 'https://sepolia.voltaire.candidewallet.com/rpc',
baseAsset: {
symbol: 'ETH',
name: 'ETH',
decimals: 18,
image:
'https://ethereum.org/static/6b935ac0e6194247347855dc3d328e83/6ed5f/eth-diamond-black.webp',
},
},
};

25
trampoline/src/manifest.json Executable file
View File

@@ -0,0 +1,25 @@
{
"manifest_version": 2,
"name": "Trampoline Example",
"description": "Trampoline is a chrome extension boilerplate code to showcase your own Smart Contract Wallets",
"options_page": "options.html",
"background": {
"persistent": true,
"scripts": ["ex_background.bundle.js"]
},
"browser_action": {
"default_title": "Taho",
"default_icon": "icon-34.png",
"default_popup": "popup.html"
},
"icons": {
"128": "icon-128.png"
},
"content_scripts": [
{
"matches": ["http://*/*", "https://*/*", "<all_urls>"],
"js": ["ex_contentScript.bundle.js"]
}
],
"web_accessible_resources": ["popup.html", "*.js", "*.json"]
}

View File

@@ -0,0 +1,140 @@
import { BigNumber, BigNumberish, ethers, Wallet } from 'ethers';
import {
SimpleAccount,
SimpleAccount__factory,
SimpleAccountFactory,
SimpleAccountFactory__factory,
UserOperationStruct,
} from '@account-abstraction/contracts';
import { arrayify, hexConcat } from 'ethers/lib/utils';
import { AccountApiParamsType, AccountApiType } from './types';
import { MessageSigningRequest } from '../../Background/redux-slices/signing';
import { TransactionDetailsForUserOp } from '@account-abstraction/sdk/dist/src/TransactionDetailsForUserOp';
import config from '../../../exconfig';
const FACTORY_ADDRESS =
config.factory_address || '0x6C583EE7f3a80cB53dDc4789B0Af1aaFf90e55F3';
/**
* An implementation of the BaseAccountAPI using the SimpleAccount contract.
* - contract deployer gets "entrypoint", "owner" addresses and "index" nonce
* - owner signs requests using normal "Ethereum Signed Message" (ether's signer.signMessage())
* - nonce method is "nonce()"
* - execute method is "execFromEntryPoint()"
*/
class SimpleAccountAPI extends AccountApiType {
name: string;
factoryAddress?: string;
owner: Wallet;
index: number;
/**
* our account contract.
* should support the "execFromEntryPoint" and "nonce" methods
*/
accountContract?: SimpleAccount;
factory?: SimpleAccountFactory;
constructor(params: AccountApiParamsType<{}, { privateKey: string }>) {
super(params);
this.factoryAddress = FACTORY_ADDRESS;
this.owner = params.deserializeState?.privateKey
? new ethers.Wallet(params.deserializeState?.privateKey)
: ethers.Wallet.createRandom();
this.index = 0;
this.name = 'SimpleAccountAPI';
}
serialize = async (): Promise<{ privateKey: string }> => {
return {
privateKey: this.owner.privateKey,
};
};
async _getAccountContract(): Promise<SimpleAccount> {
if (this.accountContract == null) {
this.accountContract = SimpleAccount__factory.connect(
await this.getAccountAddress(),
this.provider
);
}
return this.accountContract;
}
/**
* return the value to put into the "initCode" field, if the account is not yet deployed.
* this value holds the "factory" address, followed by this account's information
*/
async getAccountInitCode(): Promise<string> {
if (this.factory == null) {
if (this.factoryAddress != null && this.factoryAddress !== '') {
this.factory = SimpleAccountFactory__factory.connect(
this.factoryAddress,
this.provider
);
} else {
throw new Error('no factory to get initCode');
}
}
return hexConcat([
this.factory.address,
this.factory.interface.encodeFunctionData('createAccount', [
await this.owner.getAddress(),
this.index,
]),
]);
}
async getNonce(): Promise<BigNumber> {
if (await this.checkAccountPhantom()) {
return BigNumber.from(0);
}
const accountContract = await this._getAccountContract();
return await accountContract.getNonce();
}
/**
* encode a method call from entryPoint to our contract
* @param target
* @param value
* @param data
*/
async encodeExecute(
target: string,
value: BigNumberish,
data: string
): Promise<string> {
const accountContract = await this._getAccountContract();
return accountContract.interface.encodeFunctionData('execute', [
target,
value,
data,
]);
}
async signUserOpHash(userOpHash: string): Promise<string> {
return await this.owner.signMessage(arrayify(userOpHash));
}
signMessage = async (
context: any,
request?: MessageSigningRequest
): Promise<string> => {
return this.owner.signMessage(request?.rawSigningData || '');
};
signUserOpWithContext = async (
userOp: UserOperationStruct,
context: any
): Promise<UserOperationStruct> => {
return {
...userOp,
signature: await this.signUserOpHash(await this.getUserOpHash(userOp)),
};
};
}
export default SimpleAccountAPI;

View File

@@ -0,0 +1,3 @@
import AccountApi from './account-api';
export default AccountApi;

View File

@@ -0,0 +1,31 @@
import { UserOperationStruct } from '@account-abstraction/contracts';
import {
BaseAccountAPI,
BaseApiParams,
} from '@account-abstraction/sdk/dist/src/BaseAccountAPI';
import { TransactionDetailsForUserOp } from '@account-abstraction/sdk/dist/src/TransactionDetailsForUserOp';
import { MessageSigningRequest } from '../../Background/redux-slices/signing';
export abstract class AccountApiType extends BaseAccountAPI {
abstract serialize: () => Promise<object>;
/** sign a message for the use */
abstract signMessage: (
request?: MessageSigningRequest,
context?: any
) => Promise<string>;
abstract signUserOpWithContext(
userOp: UserOperationStruct,
context?: any
): Promise<UserOperationStruct>;
}
export interface AccountApiParamsType<T, S> extends BaseApiParams {
context?: T;
deserializeState?: S;
}
export type AccountImplementationType = new (
params: AccountApiParamsType<any, any>
) => AccountApiType;

View File

@@ -0,0 +1,3 @@
import Onboarding from './onboarding';
export default Onboarding;

View File

@@ -0,0 +1,50 @@
import {
Box,
Button,
CardActions,
CardContent,
Typography,
} from '@mui/material';
import { Stack } from '@mui/system';
import React from 'react';
import { OnboardingComponent, OnboardingComponentProps } from '../types';
const Onboarding: OnboardingComponent = ({
onOnboardingComplete,
}: OnboardingComponentProps) => {
return (
<Box sx={{ padding: 2 }}>
<CardContent>
<Typography variant="h3" gutterBottom>
Customisable Account Component
</Typography>
<Typography variant="body1" color="text.secondary">
You can show as many steps as you want in this dummy component. You
need to call the function <b>onOnboardingComplete</b> passed as a
props to this component. <br />
<br />
The function takes a context as a parameter, this context will be
passed to your AccountApi when creating a new account.
<br />
This Component is defined in exported in{' '}
</Typography>
<Typography variant="caption">
trampoline/src/pages/Account/components/onboarding/index.ts
</Typography>
</CardContent>
<CardActions sx={{ pl: 4, pr: 4, width: '100%' }}>
<Stack spacing={2} sx={{ width: '100%' }}>
<Button
size="large"
variant="contained"
onClick={() => onOnboardingComplete()}
>
Continue
</Button>
</Stack>
</CardActions>
</Box>
);
};
export default Onboarding;

View File

@@ -0,0 +1,3 @@
import SignMessage from './sign-message';
export default SignMessage;

View File

@@ -0,0 +1,44 @@
import {
Button,
CardActions,
CardContent,
Stack,
Typography,
} from '@mui/material';
import React from 'react';
const SignMessage = ({
onComplete,
}: {
onComplete: (context: any) => void;
}) => {
return (
<>
<CardContent>
<Typography variant="h3" gutterBottom>
Customaisable sign message Account Component
</Typography>
<Typography variant="body1" color="text.secondary">
You can show as many steps as you want in this dummy component. You
need to call the function <b>onComplete</b> passed as a props to this
component. <br />
<br />
The function takes a context as a parameter, this context will be
passed to your AccountApi when creating a new account.
<br />
This Component is defined in exported in
trampoline/src/pages/Account/components/sign-message/index.ts
</Typography>
</CardContent>
<CardActions sx={{ pl: 4, pr: 4, width: '100%' }}>
<Stack spacing={2} sx={{ width: '100%' }}>
<Button size="large" variant="contained" onClick={() => onComplete()}>
Continue
</Button>
</Stack>
</CardActions>
</>
);
};
export default SignMessage;

View File

@@ -0,0 +1,3 @@
import Transaction from './transaction';
export default Transaction;

View File

@@ -0,0 +1,72 @@
import {
Button,
CardActions,
CardContent,
CircularProgress,
Stack,
Typography,
} from '@mui/material';
import React from 'react';
import { EthersTransactionRequest } from '../../../Background/services/provider-bridge';
import { TransactionComponentProps } from '../types';
const Transaction = ({
transaction,
onComplete,
onReject,
}: TransactionComponentProps) => {
const [loader, setLoader] = React.useState<boolean>(false);
return (
<>
<CardContent>
<Typography variant="h3" gutterBottom>
Dummy Account Component
</Typography>
<Typography variant="body1" color="text.secondary">
You can show as many steps as you want in this dummy component. You
need to call the function <b>onComplete</b> passed as a props to this
component. <br />
<br />
The function takes a modifiedTransactions & context as a parameter,
the context will be passed to your AccountApi when creating a new
account. While modifiedTransactions will be agreed upon by the user.
<br />
This Component is defined in exported in{' '}
</Typography>
<Typography variant="caption">
trampoline/src/pages/Account/components/transaction/index.ts
</Typography>
</CardContent>
<CardActions sx={{ pl: 4, pr: 4, width: '100%' }}>
<Stack spacing={2} sx={{ width: '100%' }}>
<Button
disabled={loader}
size="large"
variant="contained"
onClick={() => {
onComplete(transaction, undefined);
setLoader(true);
}}
>
Continue
{loader && (
<CircularProgress
size={24}
sx={{
position: 'absolute',
top: '50%',
left: '50%',
marginTop: '-12px',
marginLeft: '-12px',
}}
/>
)}
</Button>
</Stack>
</CardActions>
</>
);
};
export default Transaction;

View File

@@ -0,0 +1,35 @@
import { UserOperationStruct } from '@account-abstraction/contracts';
import { EthersTransactionRequest } from '../../Background/services/types';
export interface OnboardingComponentProps {
accountName: string;
onOnboardingComplete: (context?: any) => void;
}
export interface OnboardingComponent
extends React.FC<OnboardingComponentProps> {}
export interface TransactionComponentProps {
transaction: EthersTransactionRequest;
onReject: () => Promise<void>;
onComplete: (
modifiedTransaction: EthersTransactionRequest,
context?: any
) => Promise<void>;
}
export interface TransactionComponent
extends React.FC<TransactionComponentProps> {}
export interface SignMessageComponenetProps {
onComplete: (context?: any) => Promise<void>;
}
export interface SignMessageComponenet
extends React.FC<SignMessageComponenetProps> {}
export interface AccountImplementationComponentsType {
Onboarding?: OnboardingComponent;
Transaction?: TransactionComponent;
SignMessage?: SignMessageComponenet;
}

View File

@@ -0,0 +1,3 @@
const ActiveAccountImplementation: string = 'active';
export { ActiveAccountImplementation };

View File

@@ -0,0 +1,45 @@
import { useCallback, useEffect, useState } from 'react';
import { useBackgroundDispatch, useBackgroundSelector } from '../App/hooks';
import { callAccountApiThunk } from '../Background/redux-slices/account';
import {
getAccountApiCallResult,
getActiveAccount,
} from '../Background/redux-slices/selectors/accountSelectors';
const useAccountApi = () => {
const [result, setResult] = useState(null);
const [loading, setLoading] = useState(false);
const activeAccount = useBackgroundSelector(getActiveAccount);
const backgroundDispatch = useBackgroundDispatch();
const { accountApiCallResult, accountApiCallResultState } =
useBackgroundSelector(getAccountApiCallResult);
const callAccountApi = useCallback(
async (functionName: string, args?: any[]) => {
setLoading(true);
if (activeAccount) {
await backgroundDispatch(
callAccountApiThunk({ address: activeAccount, functionName, args })
);
}
},
[backgroundDispatch, activeAccount]
);
useEffect(() => {
if (accountApiCallResultState === 'set' && loading) {
setResult(accountApiCallResult);
setLoading(false);
}
}, [accountApiCallResult, accountApiCallResultState, loading]);
return {
result,
loading,
callAccountApi,
};
};
export default useAccountApi;

View File

@@ -0,0 +1,100 @@
import React, { useMemo } from 'react';
import { Route, Routes } from 'react-router-dom';
import {
ProtectedRouteHasAccounts,
ProtectedRouteKeyringUnlocked,
} from './protected-route';
import Home from './pages/home';
import Onboarding from './pages/onboarding';
import NewAccounts from './pages/new-accounts';
import { InitializeKeyring } from './pages/keyring';
import { WagmiConfig, createClient, configureChains, goerli } from 'wagmi';
import { useBackgroundSelector } from './hooks';
import { getActiveNetwork } from '../Background/redux-slices/selectors/networkSelectors';
import DeployAccount from './pages/deploy-account';
import { jsonRpcProvider } from '@wagmi/core/providers/jsonRpc';
import '../Content/index';
import { WalletConnectConnector } from 'wagmi/connectors/walletConnect';
import Config from '../../exconfig';
import TransferAsset from './pages/transfer-asset';
console.debug('---- LAUNCHING WITH CONFIG ----', Config);
const App = () => {
const activeNetwork = useBackgroundSelector(getActiveNetwork);
const client = useMemo(() => {
const { chains, provider, webSocketProvider } = configureChains(
[goerli],
[
jsonRpcProvider({
rpc: (chain) => ({
http: activeNetwork.provider,
}),
}),
]
);
return createClient({
provider,
webSocketProvider,
connectors: [
new WalletConnectConnector({
chains,
options: {
qrcode: true,
},
}),
],
});
}, [activeNetwork]);
return (
<WagmiConfig client={client}>
<Routes>
<Route
path="/"
element={
<ProtectedRouteHasAccounts>
<ProtectedRouteKeyringUnlocked>
<Home />
</ProtectedRouteKeyringUnlocked>
</ProtectedRouteHasAccounts>
}
/>
<Route
path="/transfer-assets"
element={
<ProtectedRouteHasAccounts>
<ProtectedRouteKeyringUnlocked>
<TransferAsset />
</ProtectedRouteKeyringUnlocked>
</ProtectedRouteHasAccounts>
}
/>
<Route
path="/deploy-account"
element={
<ProtectedRouteHasAccounts>
<ProtectedRouteKeyringUnlocked>
<DeployAccount />
</ProtectedRouteKeyringUnlocked>
</ProtectedRouteHasAccounts>
}
/>
<Route
path="/accounts/new"
element={
<ProtectedRouteKeyringUnlocked>
<NewAccounts />
</ProtectedRouteKeyringUnlocked>
}
/>
<Route path="/keyring/initialize" element={<InitializeKeyring />} />
<Route path="/onboarding/intro" element={<Onboarding />} />
</Routes>
</WagmiConfig>
);
};
export default App;

View File

@@ -0,0 +1,26 @@
import { Box, Tab, Tabs } from '@mui/material';
import React, { useState } from 'react';
const AccountActivity = () => {
const [activeTab, setActiveTab] = useState<'assets' | 'activity'>('assets');
return (
<Box>
<Tabs
variant="fullWidth"
value={activeTab}
onChange={(e, newTab) => setActiveTab(newTab)}
sx={{
borderBottom: '1px solid rgb(0, 0, 0, 0.2)',
}}
>
<Tab label="Assets" value="assets" />
<Tab label="Activity" value="activity" />
</Tabs>
{/* <TabPanel value="assets">Assets List</TabPanel> */}
{/* <TabPanel value="activity">Activity</TabPanel> */}
</Box>
);
};
export default AccountActivity;

View File

@@ -0,0 +1,3 @@
import AccountActivity from './account-activity';
export default AccountActivity;

View File

@@ -0,0 +1,80 @@
import { Stack, Typography, Chip, Tooltip } from '@mui/material';
import React, { useEffect } from 'react';
import { getActiveNetwork } from '../../../Background/redux-slices/selectors/networkSelectors';
import { useBackgroundDispatch, useBackgroundSelector } from '../../hooks';
import CancelIcon from '@mui/icons-material/Cancel';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import {
AccountData,
getAccountData,
} from '../../../Background/redux-slices/account';
import { getAccountEVMData } from '../../../Background/redux-slices/selectors/accountSelectors';
import { useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
const AccountBalanceInfo = ({ address }: { address: string }) => {
const navigate = useNavigate();
const activeNetwork = useBackgroundSelector(getActiveNetwork);
const accountData: AccountData | 'loading' = useBackgroundSelector((state) =>
getAccountEVMData(state, { address, chainId: activeNetwork.chainID })
);
const walletDeployed: boolean = useMemo(
() => (accountData === 'loading' ? false : accountData.accountDeployed),
[accountData]
);
const backgroundDispatch = useBackgroundDispatch();
useEffect(() => {
backgroundDispatch(getAccountData(address));
}, [backgroundDispatch, address]);
return (
<Stack spacing={1} justifyContent="center" alignItems="center">
{activeNetwork.baseAsset.image && (
<img
height={40}
src={activeNetwork.baseAsset.image}
alt={`${activeNetwork.baseAsset.name} asset logo`}
/>
)}
{accountData !== 'loading' &&
accountData.balances &&
accountData.balances[activeNetwork.baseAsset.symbol] && (
<Typography variant="h3">
{parseFloat(
accountData.balances[activeNetwork.baseAsset.symbol].assetAmount
.amount
).toFixed(4)}{' '}
{activeNetwork.baseAsset.symbol}
</Typography>
)}
<Tooltip
title={
walletDeployed
? `Wallet has been deployed on ${activeNetwork.name} chain`
: `Wallet is not deployed on ${activeNetwork.name} chain, it will be deployed upon the first transaction`
}
>
<Chip
sx={{ cursor: 'pointer' }}
onClick={() => navigate('/deploy-account')}
variant="outlined"
color={walletDeployed ? 'success' : 'error'}
size="small"
icon={walletDeployed ? <CheckCircleIcon /> : <CancelIcon />}
label={
accountData === 'loading'
? 'Loading deployment status...'
: walletDeployed
? 'Deployed'
: 'Not deployed'
}
/>
</Tooltip>
</Stack>
);
};
export default AccountBalanceInfo;

View File

@@ -0,0 +1,3 @@
import AccountBalanceInfo from './account-balance-info';
export default AccountBalanceInfo;

View File

@@ -0,0 +1,86 @@
import { Box, Tooltip, Typography } from '@mui/material';
import React, { useCallback, useState } from 'react';
import { getAccountInfo } from '../../../Background/redux-slices/selectors/accountSelectors';
import { useBackgroundSelector } from '../../hooks';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import MoreVertIcon from '@mui/icons-material/MoreVert';
const AccountInfo = ({
address,
showOptions = true,
}: {
address: string;
showOptions: boolean;
}) => {
const [tooltipMessage, setTooltipMessage] = useState<string>('Copy address');
const accountInfo = useBackgroundSelector((state) =>
getAccountInfo(state, address)
);
const copyAddress = useCallback(async () => {
await navigator.clipboard.writeText(address);
setTooltipMessage('Address copied');
}, [address]);
return (
<Box
component="div"
display="flex"
flexDirection="row"
justifyContent="center"
alignItems="center"
sx={{
borderBottom: '1px solid rgba(0, 0, 0, 0.20)',
position: 'relative',
}}
>
<Box
component="div"
display="flex"
flexDirection="column"
justifyContent="center"
alignItems="center"
flexGrow={1}
>
<Tooltip title={tooltipMessage} enterDelay={0}>
<Box
onClick={copyAddress}
component="div"
display="flex"
flexDirection="column"
justifyContent="center"
alignItems="center"
sx={{
minWidth: 300,
borderRadius: 4,
cursor: 'pointer',
'&:hover': {
background: '#f2f4f6',
},
}}
>
<Typography variant="h6">{accountInfo.name}</Typography>
<Box
component="div"
display="flex"
flexDirection="row"
justifyContent="center"
alignItems="center"
>
<Typography variant="overline">
{address.substring(0, 5)}...
{address.substring(address.length - 5)}
</Typography>
<ContentCopyIcon sx={{ height: 16, cursor: 'pointer' }} />
</Box>
</Box>
</Tooltip>
</Box>
{showOptions && <MoreVertIcon sx={{ position: 'absolute', right: 0 }} />}
</Box>
);
};
export default AccountInfo;

View File

@@ -0,0 +1,3 @@
import AccountInfo from './account-info';
export default AccountInfo;

View File

@@ -0,0 +1,79 @@
import {
Box,
Container,
FormControl,
InputLabel,
MenuItem,
Select,
Stack,
Typography,
} from '@mui/material';
import React from 'react';
import logo from '../../../../assets/img/logo.svg';
import {
getActiveNetwork,
getSupportedNetworks,
} from '../../../Background/redux-slices/selectors/networkSelectors';
import { useBackgroundSelector } from '../../hooks';
import SettingsIcon from '@mui/icons-material/Settings';
import { useNavigate } from 'react-router-dom';
const Header = () => {
const navigate = useNavigate();
const activeNetwork = useBackgroundSelector(getActiveNetwork);
const supportedNetworks = useBackgroundSelector(getSupportedNetworks);
return (
<Box
component="div"
display="flex"
flexDirection="row"
justifyContent="space-between"
alignItems="center"
sx={{
mr: 4,
ml: 4,
mt: 2,
mb: 2,
height: 60,
}}
>
<Stack
direction="row"
spacing={2}
justifyContent="center"
alignItems="center"
sx={{ cursor: 'pointer' }}
onClick={() => navigate('/')}
>
<img height={30} src={logo} className="App-logo" alt="logo" />
</Stack>
<Stack
direction="row"
spacing={2}
justifyContent="center"
alignItems="center"
>
<FormControl sx={{ minWidth: 80 }}>
<InputLabel id="chain-selector">Chain</InputLabel>
<Select
labelId="chain-selector"
id="chain-selector"
value={activeNetwork.chainID}
label="Chain"
// onChange={handleChange}
>
{supportedNetworks.map((network) => (
<MenuItem key={network.chainID} value={network.chainID}>
{network.name}
</MenuItem>
))}
</Select>
</FormControl>
<SettingsIcon fontSize="large" />
</Stack>
</Box>
);
};
export default Header;

View File

@@ -0,0 +1,3 @@
import Header from './header';
export default Header;

View File

@@ -0,0 +1,3 @@
import TransferAssetButton from './transfer-asset-button';
export default TransferAssetButton;

View File

@@ -0,0 +1,78 @@
import React, { useCallback } from 'react';
import SendRoundedIcon from '@mui/icons-material/SendRounded';
import StoreIcon from '@mui/icons-material/Store';
import { Avatar, Stack, Tooltip, Typography, useTheme } from '@mui/material';
import SwapHorizIcon from '@mui/icons-material/SwapHoriz';
import { ethers } from 'ethers';
import { useNavigate } from 'react-router-dom';
const TransferAssetButton = ({ activeAccount }: { activeAccount: string }) => {
const theme = useTheme();
const navigate = useNavigate();
const sendMoney = useCallback(async () => {
// if (window.ethereum) {
// const accounts = await window.ethereum.request({
// method: 'eth_requestAccounts',
// });
// const txHash = await window.ethereum.request({
// method: 'eth_sendTransaction',
// params: [
// {
// from: activeAccount,
// to: ethers.constants.AddressZero,
// data: '0x',
// },
// ],
// });
// console.log(txHash);
// }
}, [activeAccount]);
return (
<Stack direction={'row'} spacing={4}>
<Tooltip title="Coming soon">
<Stack
justifyContent="center"
alignItems="center"
spacing={'4px'}
sx={{ cursor: 'not-allowed', opacity: 0.5 }}
>
<Avatar sx={{ bgcolor: theme.palette.primary.main }}>
<StoreIcon />
</Avatar>
<Typography variant="button">Buy</Typography>
</Stack>
</Tooltip>
<Stack
justifyContent="center"
alignItems="center"
spacing={'4px'}
sx={{ cursor: 'pointer' }}
>
<Avatar sx={{ bgcolor: theme.palette.primary.main }}>
<SendRoundedIcon
onClick={() => navigate('/transfer-assets')}
sx={{ transform: 'rotate(-45deg)', ml: '4px', mb: '6px' }}
/>
</Avatar>
<Typography variant="button">Send</Typography>
</Stack>
<Tooltip title="Coming soon">
<Stack
justifyContent="center"
alignItems="center"
spacing={'4px'}
sx={{ cursor: 'not-allowed', opacity: 0.5 }}
>
<Avatar sx={{ bgcolor: theme.palette.primary.main }}>
<SwapHorizIcon />
</Avatar>
<Typography variant="button">Swap</Typography>
</Stack>
</Tooltip>
</Stack>
);
};
export default TransferAssetButton;

View File

@@ -0,0 +1,19 @@
import Onboarding from '../../Account/components/onboarding';
import Transaction from '../../Account/components/transaction';
import SignMessage from '../../Account/components/sign-message';
import { AccountImplementationComponentsType } from '../../Account/components/types';
import { ActiveAccountImplementation } from '../../Account';
const AccountImplementation: AccountImplementationComponentsType = {
Onboarding,
Transaction,
SignMessage,
};
const AccountImplementations: {
[name: string]: AccountImplementationComponentsType;
} = {
active: AccountImplementation,
};
export { ActiveAccountImplementation, AccountImplementations };

View File

@@ -0,0 +1 @@
export * from './constants';

View File

@@ -0,0 +1,2 @@
export * from './keyring-hooks';
export * from './redux-hooks';

View File

@@ -0,0 +1,49 @@
import { useEffect, useMemo } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { selectKeyringStatus } from '../../Background/redux-slices/selectors/keyringsSelectors';
import { BackgroundDispatch } from '../../Background/redux-slices';
import { useDispatch } from 'react-redux';
import { useBackgroundSelector } from './redux-hooks';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type AsyncifyFn<K> = K extends (...args: any[]) => any
? (...args: Parameters<K>) => Promise<ReturnType<K>>
: never;
export const useBackgroundDispatch = (): AsyncifyFn<BackgroundDispatch> =>
useDispatch<BackgroundDispatch>() as AsyncifyFn<BackgroundDispatch>;
export const useAreKeyringsUnlocked = (
redirectIfNot: boolean,
redirectTo: string
): boolean => {
const keyringStatus = useBackgroundSelector(selectKeyringStatus);
const location = useLocation();
const navigate = useNavigate();
const currentUrl = useMemo(() => location.pathname, [location]);
let redirectTarget: string | undefined;
if (keyringStatus === 'uninitialized') {
redirectTarget = '/keyring/initialize';
} else if (keyringStatus === 'locked') {
redirectTarget = '/keyring/unlock';
}
useEffect(() => {
if (
redirectIfNot &&
typeof redirectTarget !== 'undefined' &&
currentUrl !== redirectTarget
) {
navigate(redirectTarget, {
state: {
redirectTo,
},
});
}
});
return keyringStatus === 'unlocked';
};

View File

@@ -0,0 +1,5 @@
import { RootState } from '../../Background/redux-slices';
import { TypedUseSelectorHook, useSelector } from 'react-redux';
export const useBackgroundSelector: TypedUseSelectorHook<RootState> =
useSelector;

View File

@@ -0,0 +1,48 @@
/*
Josh's Custom CSS Reset
https://www.joshwcomeau.com/css/custom-css-reset/
*/
*,
*::before,
*::after {
box-sizing: border-box;
}
* {
margin: 0;
}
html,
body {
height: 100%;
background: #f2f4f6;
}
body {
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
img,
picture,
video,
canvas,
svg {
display: block;
max-width: 100%;
}
input,
button,
textarea,
select {
font: inherit;
}
p,
h1,
h2,
h3,
h4,
h5,
h6 {
overflow-wrap: break-word;
}
#root,
#__next {
isolation: isolate;
}

View File

@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>
Trampoline is a chrome extension boilerplate code to showcase your own
Smart Contract Wallets
</title>
</head>
<body>
<div id="app-container"></div>
</body>
</html>

View File

@@ -0,0 +1,37 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import { Provider } from 'react-redux';
import App from './app';
import './index.css';
import { Store } from 'webext-redux';
import '@fontsource/roboto/300.css';
import '@fontsource/roboto/400.css';
import '@fontsource/roboto/500.css';
import '@fontsource/roboto/700.css';
import { HashRouter } from 'react-router-dom';
const store = new Store();
Object.assign(store, {
dispatch: store.dispatch.bind(store),
getState: store.getState.bind(store),
subscribe: store.subscribe.bind(store),
});
const container = document.getElementById('app-container');
store
.ready()
.then(() => {
if (container) {
const root = createRoot(container); // createRoot(container!) if you use TypeScript
root.render(
<Provider store={store}>
<HashRouter>
<App />
</HashRouter>
</Provider>
);
}
})
.catch(console.log);

View File

@@ -0,0 +1,220 @@
import {
Box,
Button,
Card,
Checkbox,
CircularProgress,
Container,
Stack,
Step,
StepContent,
StepLabel,
Stepper,
Tooltip,
Typography,
} from '@mui/material';
import React, { useCallback, useMemo } from 'react';
import { getAccountData } from '../../../Background/redux-slices/account';
import {
getAccountEVMData,
getActiveAccount,
} from '../../../Background/redux-slices/selectors/accountSelectors';
import { getActiveNetwork } from '../../../Background/redux-slices/selectors/networkSelectors';
import { useBackgroundDispatch, useBackgroundSelector } from '../../hooks';
import AccountBalanceInfo from '../../components/account-balance-info';
import AccountInfo from '../../components/account-info';
import Header from '../../components/header';
import { useState } from 'react';
import { BigNumber, ethers } from 'ethers';
import { useProvider } from 'wagmi';
import { useEffect } from 'react';
import {
sendTransaction,
sendTransactionsRequest,
} from '../../../Background/redux-slices/transactions';
import { useNavigate } from 'react-router-dom';
const DeployAccount = () => {
const navigate = useNavigate();
const [deployLoader, setDeployLoader] = useState<boolean>(false);
const [tooltipMessage, setTooltipMessage] = useState<string>('Copy address');
const activeAccount = useBackgroundSelector(getActiveAccount);
const activeNetwork = useBackgroundSelector(getActiveNetwork);
const provider = useProvider();
const accountData = useBackgroundSelector((state) =>
getAccountEVMData(state, {
chainId: activeNetwork.chainID,
address: activeAccount || '',
})
);
const walletDeployed: boolean = useMemo(
() => (accountData === 'loading' ? false : accountData.accountDeployed),
[accountData]
);
useEffect(() => {
if (walletDeployed) {
alert('Account already deployed');
navigate('/');
}
}, [navigate, walletDeployed]);
const backgroundDispatch = useBackgroundDispatch();
const [minimumRequiredFundsPrice, setMinimumRequiredFundsPrice] =
useState<BigNumber>(BigNumber.from(0));
useEffect(() => {
const fetchMinimumRequiredFundsPrice = async () => {
if (accountData !== 'loading') {
const gasPrice = await provider.getGasPrice();
setMinimumRequiredFundsPrice(
ethers.utils
.parseEther(accountData.minimumRequiredFunds)
.mul(gasPrice)
.add(ethers.utils.parseEther('0.001')) // TODO: read from config
);
}
};
fetchMinimumRequiredFundsPrice();
}, [accountData, provider]);
let isButtonDisabled = useMemo(() => {
if (accountData === 'loading') return true;
if (!accountData.balances) return true;
if (
ethers.utils
.parseEther(
accountData.balances[activeNetwork.baseAsset.symbol].assetAmount
.amount
)
.lte(minimumRequiredFundsPrice)
)
return true;
return false;
}, [accountData, activeNetwork, minimumRequiredFundsPrice]);
useEffect(() => {
if (!isButtonDisabled) return;
const timer = setInterval(() => {
if (activeAccount) backgroundDispatch(getAccountData(activeAccount));
}, 1000);
return () => clearInterval(timer);
}, [activeAccount, backgroundDispatch, isButtonDisabled]);
const copyAddress = useCallback(async () => {
await navigator.clipboard.writeText(activeAccount || '');
setTooltipMessage('Address copied');
setTimeout(() => {
setTooltipMessage('Copy address');
}, 6000);
}, [activeAccount]);
const deployAcount = useCallback(async () => {
if (!activeAccount) return;
setDeployLoader(true);
if (window.ethereum) {
const accounts = await window.ethereum.request({
method: 'eth_requestAccounts',
});
const txHash = await window.ethereum.request({
method: 'eth_sendTransaction',
params: [
{
from: activeAccount,
to: ethers.constants.AddressZero,
data: '0x',
},
],
});
console.log(accounts, txHash);
alert('success');
navigate('/');
}
// await backgroundDispatch(sendTransaction(activeAccount));
}, [activeAccount, navigate]);
return (
<Container sx={{ width: '62vw', height: '100vh' }}>
<Header />
<Card sx={{ ml: 4, mr: 4, mt: 2, mb: 2 }}>
<Box sx={{ p: 2 }}>
<Typography textAlign="center" variant="h6">
Account not deployed
</Typography>
</Box>
{activeAccount && (
<AccountInfo showOptions={false} address={activeAccount} />
)}
{activeAccount && <AccountBalanceInfo address={activeAccount} />}
<Box sx={{ m: 4 }}>
<Typography variant="h6">Perform the following steps:</Typography>
<Stepper activeStep={isButtonDisabled ? 0 : 1} orientation="vertical">
<Step key={0}>
<StepLabel optional={null}>Transfer Funds</StepLabel>
<StepContent>
<Typography>
Transfer more than{' '}
<Typography component={'span'}>
{ethers.utils.formatEther(minimumRequiredFundsPrice)}{' '}
{activeNetwork.baseAsset.symbol}
</Typography>{' '}
to the account
</Typography>
<Box sx={{ mb: 2 }}>
<Tooltip title={tooltipMessage} enterDelay={0}>
<Button
onClick={copyAddress}
variant="contained"
sx={{ mt: 1, mr: 1 }}
>
Copy address
</Button>
</Tooltip>
</Box>
</StepContent>
</Step>
<Step key={1}>
<StepLabel optional={null}>Initiate Deploy Transaction</StepLabel>
<StepContent>
<Typography>
Initiate the deployment transaction, it may take some time for
the transaction to be added to the blockchain.
</Typography>
<Box sx={{ mb: 2 }}>
<Button
disabled={deployLoader}
onClick={deployAcount}
variant="contained"
sx={{ mt: 1, mr: 1, position: 'relative' }}
>
Deploy Account
{deployLoader && (
<CircularProgress
size={24}
sx={{
position: 'absolute',
top: '50%',
left: '50%',
marginTop: '-12px',
marginLeft: '-12px',
}}
/>
)}
</Button>
</Box>
</StepContent>
</Step>
</Stepper>
</Box>
</Card>
</Container>
);
};
export default DeployAccount;

View File

@@ -0,0 +1,3 @@
import DeployAccount from './deploy-account';
export default DeployAccount;

View File

@@ -0,0 +1,53 @@
import {
Box,
Card,
CardActions,
CardContent,
Container,
Typography,
} from '@mui/material';
import React, { useEffect } from 'react';
import { getActiveAccount } from '../../../Background/redux-slices/selectors/accountSelectors';
import AccountActivity from '../../components/account-activity';
import AccountBalanceInfo from '../../components/account-balance-info';
import AccountInfo from '../../components/account-info';
import Header from '../../components/header';
import TransferAssetButton from '../../components/transfer-asset-button';
import { useBackgroundSelector } from '../../hooks';
const Home = () => {
const activeAccount = useBackgroundSelector(getActiveAccount);
return (
<Container sx={{ width: '62vw', height: '100vh' }}>
<Header />
<Card sx={{ ml: 4, mr: 4, mt: 2, mb: 2 }}>
<CardContent>
{activeAccount && <AccountInfo address={activeAccount}></AccountInfo>}
<Box
component="div"
display="flex"
flexDirection="column"
justifyContent="center"
alignItems="center"
sx={{ m: 2 }}
>
<AccountBalanceInfo address={activeAccount} />
</Box>
<Box
component="div"
display="flex"
flexDirection="column"
justifyContent="center"
alignItems="center"
sx={{ m: 4 }}
>
<TransferAssetButton activeAccount={activeAccount || ''} />
</Box>
</CardContent>
</Card>
</Container>
);
};
export default Home;

View File

@@ -0,0 +1,3 @@
import Home from './home';
export default Home;

View File

@@ -0,0 +1,3 @@
import InitializeKeyring from './initialize-keyring';
export { InitializeKeyring };

View File

@@ -0,0 +1,216 @@
import { Visibility, VisibilityOff } from '@mui/icons-material';
import {
Box,
Button,
CardActions,
CardContent,
Checkbox,
CircularProgress,
Container,
FormControl,
FormGroup,
FormHelperText,
IconButton,
InputAdornment,
InputLabel,
Link,
OutlinedInput,
Stack,
Typography,
} from '@mui/material';
import React, { useState, useCallback, useEffect } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { initializeKeyring } from '../../../Background/redux-slices/keyrings';
import { selectKeyringStatus } from '../../../Background/redux-slices/selectors/keyringsSelectors';
import { useBackgroundDispatch, useBackgroundSelector } from '../../hooks';
import Config from '../../../../exconfig';
const InitializeKeyring = () => {
const keyringState = useBackgroundSelector(selectKeyringStatus);
const navigate = useNavigate();
const { state } = useLocation();
const [password, setPassword] = useState<string>('');
const [confirmPassword, setConfirmPassword] = useState<string>('');
const [error, setError] = useState<string>('');
const [declaration, setDeclaration] = useState<boolean>(true);
const [showPassword, setShowPassword] = useState<boolean>(false);
const [showLoader, setShowLoader] = useState<boolean>(false);
const backgroundDispatch = useBackgroundDispatch();
useEffect(() => {
if (Config.enablePasswordEncryption === false) {
setShowLoader(true);
backgroundDispatch(initializeKeyring('12345'));
}
}, [backgroundDispatch, setShowLoader]);
const handleClickShowPassword = () => setShowPassword((show) => !show);
const handleMouseDownPassword = (
event: React.MouseEvent<HTMLButtonElement>
) => {
event.preventDefault();
};
const validatePassword = useCallback((): boolean => {
if (password !== confirmPassword) {
setError("Passwords don't match with each other");
return false;
}
setError('');
return true;
}, [password, confirmPassword]);
const onSetPasswordClick = useCallback(() => {
if (!validatePassword()) return;
setShowLoader(true);
backgroundDispatch(initializeKeyring(password));
// setShowLoader(false);
}, [validatePassword, backgroundDispatch, password]);
useEffect(() => {
if (keyringState === 'locked') {
navigate('/keyring/unlock');
}
if (keyringState === 'unlocked') {
navigate((state && state.redirectTo) || '/');
}
}, [keyringState, navigate, state]);
return Config.enablePasswordEncryption ? (
<Container sx={{ height: '100vh' }}>
<Stack
spacing={2}
sx={{ height: '100%' }}
justifyContent="center"
alignItems="center"
>
<Box
component="span"
display="flex"
flexDirection="column"
justifyContent="center"
alignItems="center"
sx={{
width: 600,
p: 2,
border: '1px solid #d6d9dc',
borderRadius: 5,
background: 'white',
}}
>
<CardContent>
<Typography textAlign="center" variant="h3" gutterBottom>
Create password
</Typography>
<Typography
textAlign="center"
variant="body1"
color="text.secondary"
>
This password will unlock your account only on this device. We can
not recover this password, <Link>learn more</Link>
</Typography>
<FormGroup sx={{ p: 2, pt: 4 }}>
<FormControl sx={{ m: 1 }} variant="outlined">
<InputLabel htmlFor="password">Password</InputLabel>
<OutlinedInput
value={password}
onChange={(e) => setPassword(e.target.value)}
autoFocus
id="password"
type={showPassword ? 'text' : 'password'}
endAdornment={
<InputAdornment position="end">
<IconButton
aria-label="toggle password visibility"
onClick={handleClickShowPassword}
onMouseDown={handleMouseDownPassword}
edge="end"
>
{showPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
}
label="Password"
/>
</FormControl>
<FormControl sx={{ m: 1, mt: 2 }} variant="outlined">
<InputLabel htmlFor="confirm-password">
Confirm Password
</InputLabel>
<OutlinedInput
onBlur={validatePassword}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
id="confirm-password"
type={showPassword ? 'text' : 'password'}
endAdornment={
<InputAdornment position="end">
<IconButton
aria-label="toggle password visibility"
onClick={handleClickShowPassword}
onMouseDown={handleMouseDownPassword}
edge="end"
>
{showPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
}
label="Confirm Password"
/>
{error ? <FormHelperText error>{error}</FormHelperText> : null}
</FormControl>
<FormControl sx={{ m: 1 }} variant="outlined">
<Stack direction="row" alignItems="center">
<Checkbox
checked={declaration}
onChange={(e, checked) => setDeclaration(checked)}
/>{' '}
<Typography variant="body2" color="text.secondary">
I understand that TRAMPOLINE Account cannot recover this
password for me
</Typography>
</Stack>
</FormControl>
</FormGroup>
</CardContent>
<CardActions sx={{ width: '100%', pl: 2, pr: 2, pt: 0 }}>
<Stack spacing={2} sx={{ width: '100%', pl: 2, pr: 2 }}>
<Box sx={{ position: 'relative' }}>
<Button
sx={{ width: '100%' }}
disabled={
password.length === 0 ||
confirmPassword.length === 0 ||
showLoader
}
size="large"
variant="contained"
onClick={onSetPasswordClick}
>
Set password
</Button>
{showLoader && (
<CircularProgress
size={24}
sx={{
position: 'absolute',
top: '50%',
left: '50%',
marginTop: '-12px',
marginLeft: '-12px',
}}
/>
)}
</Box>
</Stack>
</CardActions>
</Box>
</Stack>
</Container>
) : null;
};
export default InitializeKeyring;

View File

@@ -0,0 +1,3 @@
import NewAccount from './new-account';
export default NewAccount;

View File

@@ -0,0 +1,201 @@
import React, { useCallback, useEffect, useState } from 'react';
import {
Box,
Button,
CardActions,
CardContent,
CircularProgress,
Container,
FormControl,
FormGroup,
InputLabel,
Link,
OutlinedInput,
Stack,
Typography,
} from '@mui/material';
import {
ActiveAccountImplementation,
AccountImplementations,
} from '../../constants';
import { useBackgroundDispatch, useBackgroundSelector } from '../../hooks';
import { createNewAccount } from '../../../Background/redux-slices/keyrings';
import { getSupportedNetworks } from '../../../Background/redux-slices/selectors/networkSelectors';
import { EVMNetwork } from '../../../Background/types/network';
import { useNavigate } from 'react-router-dom';
import { getAccountAdded } from '../../../Background/redux-slices/selectors/accountSelectors';
import { resetAccountAdded } from '../../../Background/redux-slices/account';
import { FlashOffOutlined } from '@mui/icons-material';
const TakeNameComponent = ({
name,
setName,
showLoader,
nextStage,
}: {
name: string;
setName: (name: string) => void;
showLoader: boolean;
nextStage: () => void;
}) => {
return (
<>
<CardContent>
<Typography textAlign="center" variant="h3" gutterBottom>
New account
</Typography>
<Typography textAlign="center" variant="body1" color="text.secondary">
Give a name to your account so that you can recoganise it easily.
</Typography>
<FormGroup sx={{ p: 2, pt: 4 }}>
<FormControl sx={{ m: 1 }} variant="outlined">
<InputLabel htmlFor="name">Name</InputLabel>
<OutlinedInput
value={name}
onChange={(e) => setName(e.target.value)}
autoFocus
id="name"
type="text"
label="Name"
/>
</FormControl>
</FormGroup>
</CardContent>
<CardActions sx={{ width: '100%', pl: 2, pr: 2, pt: 0 }}>
<Stack spacing={2} sx={{ width: '100%', pl: 2, pr: 2 }}>
<Box sx={{ position: 'relative' }}>
<Button
sx={{ width: '100%' }}
disabled={name.length === 0 || showLoader}
size="large"
variant="contained"
onClick={nextStage}
>
Set name
</Button>
{showLoader && (
<CircularProgress
size={24}
sx={{
position: 'absolute',
top: '50%',
left: '50%',
marginTop: '-12px',
marginLeft: '-12px',
}}
/>
)}
</Box>
</Stack>
</CardActions>
</>
);
};
const AccountOnboarding =
AccountImplementations[ActiveAccountImplementation].Onboarding;
const NewAccount = () => {
const [stage, setStage] = useState<'name' | 'account-onboarding'>('name');
const [name, setName] = useState<string>('');
const [showLoader, setShowLoader] = useState<boolean>(false);
const navigate = useNavigate();
const backgroundDispatch = useBackgroundDispatch();
const supportedNetworks: Array<EVMNetwork> =
useBackgroundSelector(getSupportedNetworks);
const addingAccount: string | null = useBackgroundSelector(getAccountAdded);
useEffect(() => {
if (addingAccount) {
backgroundDispatch(resetAccountAdded());
navigate('/');
}
}, [addingAccount, backgroundDispatch, navigate]);
const onOnboardingComplete = useCallback(
async (context?: any) => {
setShowLoader(true);
await backgroundDispatch(
createNewAccount({
name: name,
chainIds: supportedNetworks.map((network) => network.chainID),
implementation: ActiveAccountImplementation,
context,
})
);
setShowLoader(false);
},
[backgroundDispatch, supportedNetworks, name]
);
const nextStage = useCallback(() => {
setShowLoader(true);
if (stage === 'name' && AccountOnboarding) {
setStage('account-onboarding');
}
if (stage === 'name' && !AccountOnboarding) {
onOnboardingComplete();
}
setShowLoader(false);
}, [stage, setStage, onOnboardingComplete]);
return (
<Container sx={{ height: '100vh' }}>
<Stack
spacing={2}
sx={{ height: '100%' }}
justifyContent="center"
alignItems="center"
>
<Box
display="flex"
flexDirection="column"
justifyContent="center"
alignItems="center"
sx={{
width: 600,
minHeight: 300,
p: 2,
border: '1px solid #d6d9dc',
background: 'white',
borderRadius: 5,
}}
>
{showLoader && (
<CircularProgress
size={24}
sx={{
position: 'absolute',
top: '50%',
left: '50%',
marginTop: '-12px',
marginLeft: '-12px',
}}
/>
)}
{!showLoader && stage === 'name' && (
<TakeNameComponent
name={name}
setName={setName}
showLoader={showLoader}
nextStage={nextStage}
/>
)}
{!showLoader &&
stage === 'account-onboarding' &&
AccountOnboarding && (
<AccountOnboarding
accountName={name}
onOnboardingComplete={onOnboardingComplete}
/>
)}
</Box>
</Stack>
</Container>
);
};
export default NewAccount;

View File

@@ -0,0 +1,3 @@
import Onboarding from './onboarding';
export default Onboarding;

View File

@@ -0,0 +1,81 @@
import React from 'react';
import {
Box,
Button,
CardActions,
CardContent,
Link,
Stack,
Typography,
} from '@mui/material';
import logo from '../../../../assets/img/logo.svg';
import { useNavigate } from 'react-router-dom';
const Intro = () => {
const navigate = useNavigate();
return (
<Stack
spacing={2}
sx={{ height: '100%' }}
justifyContent="center"
alignItems="center"
>
<Box
component="span"
display="flex"
flexDirection="column"
justifyContent="center"
alignItems="center"
sx={{
width: 600,
p: 2,
border: '1px solid #d6d9dc',
borderRadius: 5,
background: 'white',
}}
>
<CardContent>
<Typography textAlign="center" variant="h3" gutterBottom>
Start your eth journey
</Typography>
<Typography textAlign="center" variant="body1" color="text.secondary">
Your smart contract account with unlimited possibilities,{' '}
<Link href="https://github.com/eth-infinitism/trampoline">
learn more
</Link>
</Typography>
<Box
display="flex"
justifyContent="center"
alignItems="center"
sx={{ p: 5 }}
>
<img height={250} src={logo} className="App-logo" alt="logo" />
</Box>
<Typography
textAlign="center"
sx={{ fontSize: 14 }}
color="text.secondary"
gutterBottom
>
Ethereum Foundation
</Typography>
</CardContent>
<CardActions sx={{ pl: 4, pr: 4, width: '100%' }}>
<Stack spacing={2} sx={{ width: '100%' }}>
<Button
size="large"
variant="contained"
onClick={() => navigate('/accounts/new')}
>
Create/recover new account
</Button>
</Stack>
</CardActions>
</Box>
</Stack>
);
};
export default Intro;

View File

@@ -0,0 +1,26 @@
import { Container } from '@mui/material';
import React, { useEffect } from 'react';
import { redirect } from 'react-router-dom';
import { getAddressCount } from '../../../Background/redux-slices/selectors/accountSelectors';
import { useBackgroundSelector } from '../../hooks';
import Intro from './intro';
const Onboarding = () => {
const hasAccounts = useBackgroundSelector(
(state) => getAddressCount(state) > 0
);
useEffect(() => {
if (hasAccounts) {
redirect('/');
}
}, [hasAccounts]);
return (
<Container sx={{ height: '100vh' }}>
<Intro />
</Container>
);
};
export default Onboarding;

View File

@@ -0,0 +1,3 @@
import TransferAsset from './transfer-asset';
export default TransferAsset;

View File

@@ -0,0 +1,139 @@
import {
Box,
Button,
Card,
CardContent,
CircularProgress,
Container,
FormControl,
FormGroup,
InputAdornment,
InputLabel,
OutlinedInput,
Stack,
Typography,
} from '@mui/material';
import React, { useCallback } from 'react';
import Header from '../../components/header';
import { ethers } from 'ethers';
import { useBackgroundSelector } from '../../hooks';
import { getActiveAccount } from '../../../Background/redux-slices/selectors/accountSelectors';
import { useNavigate } from 'react-router-dom';
const TransferAsset = () => {
const navigate = useNavigate();
const [toAddress, setToAddress] = React.useState<string>('');
const [value, setValue] = React.useState<string>('');
const [error, setError] = React.useState<string>('');
const activeAccount = useBackgroundSelector(getActiveAccount);
const [loader, setLoader] = React.useState<boolean>(false);
const sendEth = useCallback(async () => {
if (!ethers.utils.isAddress(toAddress)) {
setError('Invalid to address');
return;
}
setLoader(true);
setError('');
if (window.ethereum) {
await window.ethereum.request({
method: 'eth_requestAccounts',
});
const txHash = await window.ethereum.request({
method: 'eth_sendTransaction',
params: [
{
from: activeAccount,
to: toAddress,
data: '0x',
value: ethers.utils.parseEther(value),
},
],
});
console.log(txHash);
navigate('/');
}
setLoader(false);
}, [activeAccount, navigate, toAddress, value]);
return (
<Container sx={{ width: '62vw', height: '100vh' }}>
<Header />
<Card sx={{ ml: 4, mr: 4, mt: 2, mb: 2 }}>
<CardContent>
<Box
component="div"
display="flex"
flexDirection="row"
justifyContent="center"
alignItems="center"
sx={{
borderBottom: '1px solid rgba(0, 0, 0, 0.20)',
position: 'relative',
}}
>
<Typography variant="h6">Transfer Eth</Typography>
</Box>
<Box
component="div"
display="flex"
flexDirection="column"
justifyContent="center"
alignItems="center"
sx={{ mt: 4 }}
>
<FormGroup sx={{ p: 2, pt: 4 }}>
<FormControl sx={{ m: 1, width: 300 }} variant="outlined">
<InputLabel htmlFor="password">Send to</InputLabel>
<OutlinedInput
value={toAddress}
onChange={(e) => setToAddress(e.target.value)}
autoFocus
label="Send to"
/>
</FormControl>
<FormControl sx={{ m: 1, width: 300 }} variant="outlined">
<InputLabel htmlFor="password">Value</InputLabel>
<OutlinedInput
endAdornment={
<InputAdornment position="end">ETH</InputAdornment>
}
value={value}
onChange={(e) => setValue(e.target.value)}
label="Value"
/>
</FormControl>
<Typography variant="body1" color="error">
{error}
</Typography>
<Button
disabled={loader}
onClick={sendEth}
sx={{ mt: 4 }}
size="large"
variant="contained"
>
Send
{loader && (
<CircularProgress
size={24}
sx={{
position: 'absolute',
top: '50%',
left: '50%',
marginTop: '-12px',
marginLeft: '-12px',
}}
/>
)}
</Button>
</FormGroup>
</Box>
</CardContent>
</Card>
</Container>
);
};
export default TransferAsset;

View File

@@ -0,0 +1,35 @@
import React, { ReactElement } from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import { getAddressCount } from '../Background/redux-slices/selectors/accountSelectors';
import { useAreKeyringsUnlocked, useBackgroundSelector } from './hooks';
export const ProtectedRouteHasAccounts = ({
children,
}: {
children: ReactElement;
}) => {
const hasAccounts = useBackgroundSelector(
(state) => getAddressCount(state) > 0
);
let location = useLocation();
if (!hasAccounts) {
return (
<Navigate to="/onboarding/intro" state={{ from: location }} replace />
);
}
return children;
};
export const ProtectedRouteKeyringUnlocked = ({
children,
}: {
children: ReactElement;
}) => {
const { pathname } = useLocation();
const areKeyringsUnlocked: boolean = useAreKeyringsUnlocked(true, pathname);
if (areKeyringsUnlocked) return children;
return <></>;
};

View File

@@ -0,0 +1,19 @@
import AccountApi from '../../Account/account-api';
import { AccountImplementationType } from '../../Account/account-api/types';
import { ActiveAccountImplementation } from '../../Account/';
const AccountImplementation: AccountImplementationType = AccountApi;
const AccountImplementations: {
[name: string]: AccountImplementationType;
} = {
[ActiveAccountImplementation]: AccountImplementation,
};
export const PROVIDER_BRIDGE_TARGET = 'aa-extension-provider-bridge';
export const WINDOW_PROVIDER_TARGET = 'aa-extension-window-provider';
export const EXTERNAL_PORT_NAME = 'aa-extension-external';
export const AA_EXTENSION_CONFIG = 'aa-extension_getConfig';
export { ActiveAccountImplementation, AccountImplementations };

View File

@@ -0,0 +1 @@
export * from './constants';

View File

@@ -0,0 +1,11 @@
import startMain from './main';
/**
* @metamask/browser-passworder uses window.crypto and since
* background script is a service worker window is not available anymore.
* Below is a quick but dirty fix for now.
*/
// global.window = {
// crypto: crypto,
// };
startMain();

View File

@@ -0,0 +1,52 @@
import { RootState } from './redux-slices';
import KeyringService from './services/keyring';
import MainServiceManager, {
MainServiceManagerServicesMap,
} from './services/main';
import ProviderBridgeService from './services/provider-bridge';
import Config from '../../exconfig';
console.debug('---- LAUNCHING WITH CONFIG ----', Config);
chrome.runtime.onInstalled.addListener((e) => {
if (e.reason === chrome.runtime.OnInstalledReason.INSTALL) {
const url = chrome.runtime.getURL('app.html');
chrome.tabs.create({
url,
});
}
});
const serviceInitializer = async (
mainServiceManager: MainServiceManager
): Promise<MainServiceManagerServicesMap> => {
const storeState: RootState = mainServiceManager.store.getState();
const keyringService = await KeyringService.create({
mainServiceManager: mainServiceManager,
initialState: storeState.keyrings.vault,
provider: storeState.network.activeNetwork.provider || '',
bundler: storeState.network.activeNetwork.bundler || '',
entryPointAddress: storeState.network.activeNetwork.entryPointAddress,
});
const providerBridgeService = await ProviderBridgeService.create({
mainServiceManager,
});
return {
[KeyringService.name]: keyringService,
[ProviderBridgeService.name]: providerBridgeService,
};
};
/**
* Starts the API subsystems, including all services.
*/
export default async function startMain(): Promise<MainServiceManager> {
const mainService = await MainServiceManager.create(
'background',
serviceInitializer
);
mainService.startService();
return mainService.started();
}

View File

@@ -0,0 +1,189 @@
import { createSlice } from '@reduxjs/toolkit';
import type { PayloadAction } from '@reduxjs/toolkit';
import { DomainName, HexString, URI } from '../types/common';
import { EVMNetwork } from '../types/network';
import { AccountBalance } from '../types/account';
import { createBackgroundAsyncThunk } from './utils';
import KeyringService from '../services/keyring';
import { RootState } from '.';
import { BigNumber } from 'ethers';
export type AccountData = {
address: HexString;
network: EVMNetwork;
accountDeployed: boolean;
minimumRequiredFunds: string;
balances?: {
[assetSymbol: string]: AccountBalance;
};
ens?: {
name?: DomainName;
avatarURL?: URI;
};
};
type AccountsByChainID = {
[chainID: string]: {
[address: string]: AccountData | 'loading';
};
};
export interface AccountState {
account?: HexString;
accountAdded: HexString | null;
hasAccountError?: boolean;
accountsData: {
info: {
[address: string]: {
name: string;
};
};
evm: AccountsByChainID;
};
accountApiCallResult?: any;
accountApiCallResultState?: 'awaiting' | 'set';
}
const initialState = {
accountsData: { evm: {}, info: {} },
accountAdded: null,
combinedData: {
totalMainCurrencyValue: '',
assets: [],
},
} as AccountState;
const accountSlice = createSlice({
name: 'account',
initialState,
reducers: {
resetAccountAdded: (state): AccountState => ({
...state,
accountAdded: null,
}),
addNewAccount: (
state,
{
payload,
}: {
payload: {
name: string;
makeActive: boolean;
chainIds: Array<string>;
address: string;
};
}
): AccountState => ({
...state,
account: payload.makeActive ? payload.address : state.account,
accountAdded: payload.address,
accountsData: {
info: {
...state.accountsData.info,
[payload.address]: {
name: payload.name,
},
},
evm: {
...state.accountsData.evm,
...payload.chainIds.reduce(
(result: AccountsByChainID, chainId: string) => {
result[chainId] = {
[payload.address]: 'loading',
};
return result;
},
{}
),
},
},
}),
setAccountApiCallResult: (
state: AccountState,
{ payload }: { payload: any }
) => ({
...state,
accountApiCallResult: payload,
}),
setAccountApiCallResultState: (
state: AccountState,
{ payload }: { payload: 'awaiting' | 'set' }
) => ({
...state,
accountApiCallResultState: payload,
}),
setAccountData: (
state: AccountState,
{
payload,
}: {
payload: AccountData;
}
): AccountState => ({
...state,
accountsData: {
...state.accountsData,
evm: {
...state.accountsData.evm,
[payload.network.chainID]: {
[payload.address]: payload,
},
},
},
}),
},
});
export const { addNewAccount, resetAccountAdded, setAccountData } =
accountSlice.actions;
export default accountSlice.reducer;
export const getAccountData = createBackgroundAsyncThunk(
'account/getAccountData',
async (address: string, { dispatch, extra: { mainServiceManager } }) => {
const keyringService = mainServiceManager.getService(
KeyringService.name
) as KeyringService;
const activeNetwork = (mainServiceManager.store.getState() as RootState)
.network.activeNetwork;
keyringService.getAccountData(address, activeNetwork).then((accountData) =>
dispatch(
setAccountData({
minimumRequiredFunds: accountData.minimumRequiredFunds,
address: address,
network: activeNetwork,
accountDeployed: accountData.accountDeployed,
balances: accountData.balances,
ens: accountData.ens,
})
)
);
}
);
export const callAccountApiThunk = createBackgroundAsyncThunk(
'account/callAccountApiThunk',
async (
{
address,
functionName,
args,
}: { address: string; functionName: string; args?: any[] },
{ dispatch, extra: { mainServiceManager } }
) => {
dispatch(accountSlice.actions.setAccountApiCallResultState('awaiting'));
const keyringService = mainServiceManager.getService(
KeyringService.name
) as KeyringService;
const result = await keyringService.callAccountApi(
address,
functionName,
args
);
dispatch(accountSlice.actions.setAccountApiCallResult(result));
dispatch(accountSlice.actions.setAccountApiCallResultState('set'));
}
);

View File

@@ -0,0 +1,101 @@
import MainServiceManager from '../services/main';
import { decodeJSON, encodeJSON } from '../utils';
import { devToolsEnhancer } from '@redux-devtools/remote';
import { combineReducers, configureStore, isPlain } from '@reduxjs/toolkit';
import { alias } from 'webext-redux';
import account from './account';
import keyrings from './keyrings';
import network from './network';
import transactions from './transactions';
import dappPermissions from './permissions';
import signing from './signing';
import { allAliases } from './utils';
import Config from '../../../exconfig';
import { debounce } from '@mui/material';
const rootReducer = combineReducers({
account,
keyrings,
network,
dappPermissions,
signing,
transactions,
});
export default rootReducer;
export type RootState = ReturnType<typeof rootReducer>;
// This sanitizer runs on store and action data before serializing for remote
// redux devtools. The goal is to end up with an object that is directly
// JSON-serializable and deserializable; the remote end will display the
// resulting objects without additional processing or decoding logic.
const devToolsSanitizer = (input: unknown) => {
switch (typeof input) {
// We can make use of encodeJSON instead of recursively looping through
// the input
case 'bigint':
case 'object':
return JSON.parse(encodeJSON(input));
// We only need to sanitize bigints and objects that may or may not contain
// them.
default:
return input;
}
};
const persistStoreFn = <T>(state: T) => {
// Browser extension storage supports JSON natively, despite that we have
// to stringify to preserve BigInts
localStorage.setItem('state', encodeJSON(state));
localStorage.setItem('version', Config.stateVersion);
};
const persistStoreState = debounce(persistStoreFn, 50);
const reduxCache = (store) => (next) => (action) => {
const result = next(action);
const state = store.getState();
persistStoreState(state);
return result;
};
export const initializeStore = (
preloadedState,
mainServiceManager: MainServiceManager
) =>
configureStore({
preloadedState: preloadedState,
reducer: rootReducer,
devTools: false,
middleware: (getDefaultMiddleware) => {
const middleware = getDefaultMiddleware({
serializableCheck: {
isSerializable: (value: unknown) =>
isPlain(value) || typeof value === 'bigint',
},
thunk: { extraArgument: { mainServiceManager } },
});
middleware.unshift(alias(allAliases));
middleware.push(reduxCache);
return middleware;
},
enhancers:
process.env.NODE_ENV === 'development'
? [
devToolsEnhancer({
hostname: 'localhost',
port: 8000,
realtime: true,
actionSanitizer: devToolsSanitizer,
stateSanitizer: devToolsSanitizer,
}),
]
: [],
});
export type ReduxStoreType = ReturnType<typeof initializeStore>;
export type BackgroundDispatch = ReduxStoreType['dispatch'];

View File

@@ -0,0 +1,124 @@
import { createSlice } from '@reduxjs/toolkit';
import { Keyring, KeyringMetadata } from '../types/keyrings';
import { createBackgroundAsyncThunk } from './utils';
import { NewAccountView } from '../types/chrome-messages';
import { RootState } from '.';
import KeyringService from '../services/keyring';
import { addNewAccount, getAccountData } from './account';
import { EVMNetwork } from '../types/network';
export type Vault = {
vault: string;
encryptionKey?: string;
encryptionSalt?: string;
};
export type KeyringsState = {
keyrings: Keyring[];
keyringMetadata: {
[keyringId: string]: KeyringMetadata;
};
importing: false | 'pending' | 'done';
status: 'locked' | 'unlocked' | 'uninitialized';
vault: Vault;
keyringToVerify: {
id: string;
mnemonic: string[];
} | null;
};
export const initialState: KeyringsState = {
keyrings: [],
keyringMetadata: {},
vault: {
vault:
'{"data":"Ukexw7sD847Dj98jjvGP+USD","iv":"+X2ZjepqanEDFIJneBDHcw==","salt":"LWHFdiZSZwESRu0M5vBaeLIBwszt8zclfbUH4h8tWFU="}',
encryptionKey:
'{"alg":"A256GCM","ext":true,"k":"SnGTN4MUv2Ugv7wy_dGvb-Tmz-CKNnMYbyBHIfUbYJg","key_ops":["encrypt","decrypt"],"kty":"oct"}',
encryptionSalt: 'LWHFdiZSZwESRu0M5vBaeLIBwszt8zclfbUH4h8tWFU=',
},
importing: false,
status: 'uninitialized',
keyringToVerify: null,
};
const keyringsSlice = createSlice({
name: 'account',
initialState,
reducers: {
keyringLocked: (state) => ({
...state,
status: state.status !== 'uninitialized' ? 'locked' : 'uninitialized',
vault: {
vault: state.vault.vault,
},
}),
keyringUnlocked: (state) => ({ ...state, status: 'unlocked' }),
vaultUpdate: (
state,
{
payload: vault,
}: {
payload: Vault;
}
) => ({
...state,
vault,
status:
!vault.encryptionKey && state.status !== 'uninitialized'
? 'locked'
: state.status,
}),
},
});
export const { keyringLocked, vaultUpdate, keyringUnlocked } =
keyringsSlice.actions;
export default keyringsSlice.reducer;
/**
* -------------------------------
* Background Actions
* -------------------------------
*/
export const initializeKeyring = createBackgroundAsyncThunk(
'keyring/initialize',
async (password: string, { dispatch, extra: { mainServiceManager } }) => {
const keyringService = mainServiceManager.getService(
KeyringService.name
) as KeyringService;
await keyringService.createPassword(password);
}
);
export const createNewAccount = createBackgroundAsyncThunk(
'keyring/createNewAccount',
async (
{
name,
implementation,
context,
chainIds,
}: {
name: string;
chainIds: Array<string>;
implementation: string;
context?: any;
},
{ dispatch, extra: { mainServiceManager } }
) => {
const keyringService = mainServiceManager.getService(
KeyringService.name
) as KeyringService;
const address = await keyringService.addAccount(implementation, context);
dispatch(
addNewAccount({
name,
makeActive: true,
chainIds: chainIds,
address: address,
})
);
}
);

View File

@@ -0,0 +1,46 @@
import { createSlice } from '@reduxjs/toolkit';
import { EVMNetwork } from '../types/network';
import Config from '../../../exconfig';
export type Vault = {
vault: string;
encryptionKey?: string;
encryptionSalt?: string;
};
export type NetworkState = {
activeNetwork: EVMNetwork;
supportedNetworks: Array<EVMNetwork>;
};
const GoerliNetwork: EVMNetwork = Config.network || {
chainID: '5',
family: 'EVM',
name: 'Goerli',
provider: 'https://goerli.infura.io/v3/bdabe9d2f9244005af0f566398e648da',
entryPointAddress: '0x0F46c65C17AA6b4102046935F33301f0510B163A',
bundler:
'https://app.stackup.sh/api/v1/bundler/96771b1b09e802669c33a3fc50f517f0f514a40da6448e24640ecfd83263d336',
baseAsset: {
symbol: 'ETH',
name: 'ETH',
decimals: 18,
image:
'https://ethereum.org/static/6b935ac0e6194247347855dc3d328e83/6ed5f/eth-diamond-black.webp',
},
};
export const initialState: NetworkState = {
activeNetwork: GoerliNetwork,
supportedNetworks: [GoerliNetwork],
};
type NetworkReducers = {};
const networkSlice = createSlice<NetworkState, NetworkReducers, 'network'>({
name: 'network',
initialState,
reducers: {},
});
export default networkSlice.reducer;

View File

@@ -0,0 +1,138 @@
import { createSlice } from '@reduxjs/toolkit';
import ProviderBridgeService, {
PermissionRequest,
} from '../services/provider-bridge';
import { createBackgroundAsyncThunk } from './utils';
export type DappPermissionState = {
permissionRequests: { [origin: string]: PermissionRequest };
allowed: {
evm: {
[origin_address: string]: PermissionRequest | undefined;
};
};
};
export const initialState: DappPermissionState = {
permissionRequests: {},
allowed: {
evm: {},
},
};
type DappPermissionReducers = {
requestPermission: (
state: DappPermissionState,
{ payload }: { payload: PermissionRequest }
) => DappPermissionState;
grantPermission: (
state: DappPermissionState,
{ payload }: { payload: PermissionRequest }
) => DappPermissionState;
permissionDenyOrRevoke: (
state: DappPermissionState,
{ payload }: { payload: PermissionRequest }
) => DappPermissionState;
};
const dappPermissionSlice = createSlice<
DappPermissionState,
DappPermissionReducers,
'dapp-permission'
>({
name: 'dapp-permission',
initialState,
reducers: {
requestPermission: (state, { payload: permissionRequest }) => {
return {
...state,
permissionRequests: {
...state.permissionRequests,
[permissionRequest.origin]: { ...permissionRequest },
},
};
},
grantPermission: (state, { payload: permission }) => {
return {
...state,
permissionRequests: {
...state.permissionRequests,
[permission.origin]: undefined,
},
allowed: {
evm: {
...state.allowed.evm,
[`${permission.origin}_${permission.accountAddress}`]: permission,
},
},
};
},
permissionDenyOrRevoke: (state, { payload: permission }) => {
const permissionRequests = {
...state.permissionRequests,
};
delete permissionRequests[permission.origin];
const allowed = {
evm: {
...state.allowed.evm,
},
};
delete allowed.evm[`${permission.origin}_${permission.accountAddress}`];
return {
...state,
permissionRequests: {
...permissionRequests,
},
allowed: {
...allowed,
},
};
},
},
});
export const { requestPermission } = dappPermissionSlice.actions;
export default dappPermissionSlice.reducer;
export const denyOrRevokePermission = createBackgroundAsyncThunk(
'dapp-permission/denyOrRevokePermission',
async (
permission: PermissionRequest,
{ dispatch, extra: { mainServiceManager } }
) => {
const newPermission: PermissionRequest = {
...permission,
state: 'deny',
};
dispatch(dappPermissionSlice.actions.permissionDenyOrRevoke(newPermission));
const providerBridgeService = mainServiceManager.getService(
ProviderBridgeService.name
) as ProviderBridgeService;
providerBridgeService.denyOrRevokePermission(newPermission);
}
);
// Async thunk to bubble the permissionGrant action from store to emitter.
export const grantPermission = createBackgroundAsyncThunk(
'dapp-permission/permissionGrant',
async (
permission: PermissionRequest,
{ dispatch, extra: { mainServiceManager } }
) => {
const newPermission: PermissionRequest = {
...permission,
state: 'allow',
};
dispatch(dappPermissionSlice.actions.grantPermission(newPermission));
const providerBridgeService = mainServiceManager.getService(
ProviderBridgeService.name
) as ProviderBridgeService;
providerBridgeService.grantPermission(newPermission);
}
);

View File

@@ -0,0 +1,66 @@
import { createSelector } from '@reduxjs/toolkit';
import { RootState } from '..';
const getAccountState = (state: RootState) => state.account;
export const getAllAddresses = createSelector(getAccountState, (account) => {
const ret = [
...Array.from(
new Set(
Object.values(account.accountsData.evm).flatMap((chainAddresses) =>
Object.keys(chainAddresses)
)
)
),
];
return ret;
});
const getAccountsData = createSelector(
getAccountState,
(account) => account.accountsData
);
export const getAccountsEVMData = createSelector(
getAccountsData,
(accountsData) => accountsData.evm
);
export const getAddressCount = createSelector(
getAllAddresses,
(allAddresses) => allAddresses.length
);
export const getAccountAdded = createSelector(
getAccountState,
(account) => account.accountAdded
);
export const getActiveAccount = createSelector(
getAccountState,
(account) => account.account
);
export const getAccountEVMData = createSelector(
[
getAccountsEVMData,
(state, { chainId, address }: { chainId: string; address: string }) => ({
chainId,
address,
}),
],
(evm, { chainId, address }) => evm[chainId][address]
);
export const getAccountInfo = createSelector(
[getAccountsData, (state, address) => address],
(accountsData, address) => accountsData.info[address]
);
export const getAccountApiCallResult = createSelector(
getAccountState,
(account) => ({
accountApiCallResult: account.accountApiCallResult,
accountApiCallResultState: account.accountApiCallResultState,
})
);

View File

@@ -0,0 +1,42 @@
import { createSelector } from '@reduxjs/toolkit';
import { RootState } from '..';
import { HexString } from '../../types/common';
import { DappPermissionState } from '../permissions';
const getDappPermissionState = (state: RootState) => state.dappPermissions;
export const selectPermissionRequests = createSelector(
getDappPermissionState,
(slice: DappPermissionState) => Object.values(slice.permissionRequests)
);
export const selectPendingPermissionRequests = createSelector(
selectPermissionRequests,
(permissionRequests) => {
return permissionRequests.filter((p) => p.state === 'request');
}
);
export const selectCurrentPendingPermission = createSelector(
selectPendingPermissionRequests,
(permissionRequests) => {
return permissionRequests.length > 0 ? permissionRequests[0] : undefined;
}
);
export const selectOriginPermissionState = createSelector(
getDappPermissionState,
(slice: DappPermissionState) => slice.allowed.evm
);
export const selectCurrentOriginPermission = createSelector(
[
selectOriginPermissionState,
(state, { origin, address }: { origin: string; address: HexString }) => ({
origin,
address,
}),
],
(evmState: DappPermissionState['allowed']['evm'], { origin, address }) =>
evmState[`${origin}_${address}`]
);

View File

@@ -0,0 +1,31 @@
import { createSelector } from '@reduxjs/toolkit';
import { RootState } from '..';
import { KeyringsState } from '../keyrings';
export const selectKeyrings = createSelector(
(state: RootState) => state.keyrings,
(keyrings) => keyrings
);
export const selectLoadedKeyrings = createSelector(
selectKeyrings,
(keyrings) => keyrings.keyrings
);
export const selectKeyringStatus = createSelector(
selectKeyrings,
(keyrings) => (keyrings as KeyringsState).status
);
export const selectKeyringVault = createSelector(
selectKeyrings,
(keyrings) => (keyrings as KeyringsState).vault
);
export const selectKeyringView = createSelector(
[selectKeyrings, (state, implementation: string) => implementation],
(keyrings, implementation: string) =>
(keyrings as KeyringsState).keyringMetadata[implementation]
? (keyrings as KeyringsState).keyringMetadata[implementation].view
: undefined
);

View File

@@ -0,0 +1,14 @@
import { createSelector } from '@reduxjs/toolkit';
import { RootState } from '..';
const getNetworkState = (state: RootState) => state.network;
export const getActiveNetwork = createSelector(
getNetworkState,
(network) => network.activeNetwork
);
export const getSupportedNetworks = createSelector(
getNetworkState,
(network) => network.supportedNetworks
);

View File

@@ -0,0 +1,11 @@
import { createSelector } from '@reduxjs/toolkit';
import { RootState } from '..';
const getSigningState = (state: RootState) => state.signing;
export const selectCurrentPendingSignDataRequest = createSelector(
getSigningState,
(signing) => {
return signing.signDataRequest;
}
);

View File

@@ -0,0 +1,17 @@
import { createSelector } from '@reduxjs/toolkit';
import { RootState } from '..';
const getTransactionsState = (state: RootState) => state.transactions;
export const selectCurrentPendingSendTransactionRequest = createSelector(
getTransactionsState,
(transactions) => ({
transactionRequest: transactions.transactionRequest,
origin: transactions.requestOrigin,
})
);
export const selectCurrentPendingSendTransactionUserOp = createSelector(
getTransactionsState,
(transactions) => transactions.unsignedUserOperation
);

View File

@@ -0,0 +1,231 @@
import { createSlice } from '@reduxjs/toolkit';
import { RootState } from '.';
import KeyringService from '../services/keyring';
import ProviderBridgeService from '../services/provider-bridge';
import { HexString } from '../types/common';
import { AddressOnNetwork, EVMNetwork } from '../types/network';
import { createBackgroundAsyncThunk } from './utils';
export type Vault = {
vault: string;
encryptionKey?: string;
encryptionSalt?: string;
};
export type NetworkState = {
activeNetwork: EVMNetwork;
supportedNetworks: Array<EVMNetwork>;
};
const GoerliNetwork: EVMNetwork = {
chainID: '5',
family: 'EVM',
name: 'Goerli',
provider: 'https://goerli.infura.io/v3/bdabe9d2f9244005af0f566398e648da',
entryPointAddress: '0x0F46c65C17AA6b4102046935F33301f0510B163A',
baseAsset: {
symbol: 'ETH',
name: 'ETH',
decimals: 18,
image:
'https://ethereum.org/static/6b935ac0e6194247347855dc3d328e83/6ed5f/eth-diamond-black.webp',
},
};
export type EIP712DomainType = {
name?: string;
version?: string;
chainId?: number | string;
verifyingContract?: HexString;
};
export interface TypedDataField {
name: string;
type: string;
}
export type EIP712TypedData<T = Record<string, unknown>> = {
domain: EIP712DomainType;
types: Record<string, TypedDataField[]>;
message: T;
primaryType: string;
};
export type SignTypedDataRequest = {
account: AddressOnNetwork;
typedData: EIP712TypedData;
};
export type EIP2612SignTypedDataAnnotation = {
type: 'EIP-2612';
source: string;
displayFields: {
owner: string;
tokenContract: string;
spender: string;
value: string;
nonce: number;
expiry: string;
token?: string;
};
};
export type SignTypedDataAnnotation = EIP2612SignTypedDataAnnotation;
export type EnrichedSignTypedDataRequest = SignTypedDataRequest & {
annotation?: SignTypedDataAnnotation;
};
export type EIP191Data = string;
// spec found https://eips.ethereum.org/EIPS/eip-4361
export interface EIP4361Data {
/**
* The message string that was parsed to produce this EIP-4361 data.
*/
unparsedMessageData: string;
domain: string;
address: string;
version: string;
chainId: number;
nonce: string;
expiration?: string;
statement?: string;
}
type EIP191SigningData = {
messageType: 'eip191';
signingData: EIP191Data;
};
type EIP4361SigningData = {
messageType: 'eip4361';
signingData: EIP4361Data;
};
export type MessageSigningData = EIP191SigningData | EIP4361SigningData;
export type MessageSigningRequest<
T extends MessageSigningData = MessageSigningData
> = T & {
origin: string;
account: AddressOnNetwork;
rawSigningData: string;
};
type SigningState = {
signedTypedData: string | undefined;
typedDataRequest: EnrichedSignTypedDataRequest | undefined;
signedData: string | undefined;
signDataRequest: MessageSigningRequest | undefined;
};
export const initialState: SigningState = {
typedDataRequest: undefined,
signedTypedData: undefined,
signedData: undefined,
signDataRequest: undefined,
};
type SigningReducers = {
signedTypedData: (
state: SigningState,
{ payload }: { payload: string }
) => SigningState;
typedDataRequest: (
state: SigningState,
{ payload }: { payload: EnrichedSignTypedDataRequest }
) => SigningState;
signDataRequest: (
state: SigningState,
{ payload }: { payload: MessageSigningRequest }
) => SigningState;
signedData: (
state: SigningState,
{ payload }: { payload: string }
) => SigningState;
clearSigningState: (state: SigningState) => SigningState;
};
const signingSlice = createSlice<SigningState, SigningReducers, 'signing'>({
name: 'signing',
initialState,
reducers: {
signedTypedData: (state, { payload }: { payload: string }) => ({
...state,
signedTypedData: payload,
typedDataRequest: undefined,
}),
typedDataRequest: (
state,
{ payload }: { payload: EnrichedSignTypedDataRequest }
) => ({
...state,
typedDataRequest: payload,
}),
signDataRequest: (
state,
{ payload }: { payload: MessageSigningRequest }
) => {
return {
...state,
signDataRequest: payload,
};
},
signedData: (state, { payload }: { payload: string }) => ({
...state,
signedData: payload,
signDataRequest: undefined,
}),
clearSigningState: (state) => ({
...state,
typedDataRequest: undefined,
signDataRequest: undefined,
}),
},
});
export const {
signedTypedData,
typedDataRequest,
signedData,
signDataRequest,
clearSigningState,
} = signingSlice.actions;
export default signingSlice.reducer;
export const getSignedData = createBackgroundAsyncThunk(
'signing/getSignedData',
async (context: any, { dispatch, extra: { mainServiceManager } }) => {
const pendingSigningDataRequest = (
mainServiceManager.store.getState() as RootState
).signing.signDataRequest;
const keyringService = mainServiceManager.getService(
KeyringService.name
) as KeyringService;
const activeAccount = (mainServiceManager.store.getState() as RootState)
.account.account;
const signedMessage = await keyringService.personalSign(
activeAccount || '',
context,
pendingSigningDataRequest
);
dispatch(signedData(signedMessage));
const providerBridgeService = mainServiceManager.getService(
ProviderBridgeService.name
) as ProviderBridgeService;
providerBridgeService.resolveRequest(
pendingSigningDataRequest?.origin || '',
signedMessage
);
}
);

View File

@@ -0,0 +1,228 @@
import { UserOperationStruct } from '@account-abstraction/contracts';
import { createSlice } from '@reduxjs/toolkit';
import { RootState } from '.';
import KeyringService from '../services/keyring';
import ProviderBridgeService, {
EthersTransactionRequest,
} from '../services/provider-bridge';
import { createBackgroundAsyncThunk } from './utils';
export type TransactionState = {
transactionRequest?: EthersTransactionRequest;
transactionsRequest?: EthersTransactionRequest[];
modifiedTransactionsRequest?: EthersTransactionRequest[];
requestOrigin?: string;
userOperationRequest?: Partial<UserOperationStruct>;
unsignedUserOperation?: UserOperationStruct;
};
export const initialState: TransactionState = {
transactionsRequest: undefined,
transactionRequest: undefined,
userOperationRequest: undefined,
unsignedUserOperation: undefined,
};
type SigningReducers = {
sendTransactionRequest: (
state: TransactionState,
{
payload,
}: {
payload: {
transactionRequest: EthersTransactionRequest;
origin: string;
};
}
) => TransactionState;
sendTransactionsRequest: (
state: TransactionState,
{
payload,
}: {
payload: {
transactionsRequest: EthersTransactionRequest[];
origin: string;
};
}
) => TransactionState;
setModifyTransactionsRequest: (
state: TransactionState,
{
payload,
}: {
payload: EthersTransactionRequest[];
}
) => TransactionState;
sendUserOperationRquest: (
state: TransactionState,
{ payload }: { payload: UserOperationStruct }
) => TransactionState;
setUnsignedUserOperation: (
state: TransactionState,
{ payload }: { payload: UserOperationStruct }
) => TransactionState;
clearTransactionState: (state: TransactionState) => TransactionState;
};
const transactionsSlice = createSlice<
TransactionState,
SigningReducers,
'signing'
>({
name: 'signing',
initialState,
reducers: {
sendTransactionRequest: (
state,
{
payload: { transactionRequest, origin },
}: {
payload: {
transactionRequest: EthersTransactionRequest;
origin: string;
};
}
) => {
return {
...state,
transactionRequest: transactionRequest,
requestOrigin: origin,
};
},
sendTransactionsRequest: (
state,
{
payload: { transactionsRequest, origin },
}: {
payload: {
transactionsRequest: EthersTransactionRequest[];
origin: string;
};
}
) => {
return {
...state,
transactionsRequest: transactionsRequest,
requestOrigin: origin,
};
},
setModifyTransactionsRequest: (
state,
{
payload,
}: {
payload: EthersTransactionRequest[];
}
) => ({
...state,
modifiedTransactionsRequest: payload,
}),
sendUserOperationRquest: (
state,
{ payload }: { payload: UserOperationStruct }
) => ({
...state,
userOperationRequest: payload,
}),
setUnsignedUserOperation: (
state,
{ payload }: { payload: UserOperationStruct }
) => ({
...state,
unsignedUserOperation: payload,
}),
clearTransactionState: (state) => ({
...state,
typedDataRequest: undefined,
signDataRequest: undefined,
transactionRequest: undefined,
transactionsRequest: undefined,
modifiedTransactionsRequest: undefined,
requestOrigin: undefined,
userOperationRequest: undefined,
unsignedUserOperation: undefined,
}),
},
});
export const {
sendTransactionRequest,
sendTransactionsRequest,
setModifyTransactionsRequest,
sendUserOperationRquest,
setUnsignedUserOperation,
clearTransactionState,
} = transactionsSlice.actions;
export default transactionsSlice.reducer;
export const sendTransaction = createBackgroundAsyncThunk(
'transactions/sendTransaction',
async (
{ address, context }: { address: string; context?: any },
{ dispatch, extra: { mainServiceManager } }
) => {
const keyringService = mainServiceManager.getService(
KeyringService.name
) as KeyringService;
const state = mainServiceManager.store.getState() as RootState;
const unsignedUserOp = state.transactions.unsignedUserOperation;
const origin = state.transactions.requestOrigin;
if (unsignedUserOp) {
const signedUserOp = await keyringService.signUserOpWithContext(
address,
unsignedUserOp,
context
);
const txnHash = keyringService.sendUserOp(address, signedUserOp);
dispatch(clearTransactionState());
const providerBridgeService = mainServiceManager.getService(
ProviderBridgeService.name
) as ProviderBridgeService;
providerBridgeService.resolveRequest(origin || '', txnHash);
}
}
);
export const createUnsignedUserOp = createBackgroundAsyncThunk(
'transactions/createUnsignedUserOp',
async (address: string, { dispatch, extra: { mainServiceManager } }) => {
const keyringService = mainServiceManager.getService(
KeyringService.name
) as KeyringService;
const state = mainServiceManager.store.getState() as RootState;
const transactionRequest = state.transactions.transactionRequest;
if (transactionRequest) {
const userOp = await keyringService.createUnsignedUserOp(
address,
transactionRequest
);
dispatch(setUnsignedUserOperation(userOp));
}
}
);
export const rejectTransaction = createBackgroundAsyncThunk(
'transactions/rejectTransaction',
async (address: string, { dispatch, extra: { mainServiceManager } }) => {
dispatch(clearTransactionState());
const requestOrigin = (mainServiceManager.store.getState() as RootState)
.transactions.requestOrigin;
const providerBridgeService = mainServiceManager.getService(
ProviderBridgeService.name
) as ProviderBridgeService;
providerBridgeService.rejectRequest(requestOrigin || '', '');
}
);

View File

@@ -0,0 +1,171 @@
import MainServiceManager from '../services/main';
import {
Action,
AsyncThunk,
AsyncThunkAction,
AsyncThunkOptions,
AsyncThunkPayloadCreator,
createAsyncThunk,
} from '@reduxjs/toolkit';
// Below, we use `any` to deal with the fact that allAliases is a heterogeneous
// collection of async thunk actions whose payload types have little in common
// with each other.
/* eslint-disable @typescript-eslint/no-explicit-any */
/**
* A list of all webext-redux aliases that have been registered globally. These
* are generally updated automatically by helpers like
* `createBackgroundAsyncThunk` and should rarely need to be touched directly.
*
* webext-redux aliases are actions that are only run in the background script,
* but can be invoked with a payload in UI and other scripts. Their type and
* payload is relayed to the background, and the background uses an enriched
* action creator to turn them into the final intent. They are primarily used
* for more complex actions that require middleware to process, such as thunks.
*
* @see {@link createBackgroundAsyncThunk} for an example use.
*/
export const allAliases: Record<
string,
(action: {
type: string;
payload: any;
}) => AsyncThunkAction<unknown, unknown, any>
> = {};
/* eslint-enable @typescript-eslint/no-explicit-any */
// All props of an AsyncThunk.
type AsyncThunkProps = keyof AsyncThunk<
unknown,
unknown,
Record<string, unknown>
>;
// The type system will make sure we've listed all additional props that redux
// toolkit adds to the AsyncThunk action creator below.
//
// The approach is a bit ugly, but the goal here is transparent usage wrt
// createAsyncThunk, and this allows ensuring that any future upgrades don't
// break expectations without breaking the compile.
type ExhaustivePropList<PropListType, TargetType> =
PropListType extends readonly (infer T)[]
? keyof TargetType extends T
? readonly T[]
: never
: never;
const asyncThunkProperties = (() => {
const temp = ['typePrefix', 'pending', 'rejected', 'fulfilled'] as const;
const exhaustiveList: ExhaustivePropList<
typeof temp,
AsyncThunk<unknown, unknown, Record<string, unknown>>
> = temp;
return exhaustiveList;
})();
/**
* Create an async thunk action that will always run in the background script,
* and dispatches lifecycle actions (pending, fulfilled, rejected) on the
* shared store. The lifecycle actions are observable on synced non-background
* stores.
*
* NOTE: To ensure the action is handled correctly, a central location should
* add the webext-redux `alias` middleware to the background store, referencing
* the `allAliases` variable in this module. This variable exposes all async
* thunks for use with the `alias` middleware.
*
* @see {@link createAsyncThunk} for more information on the `options` parameter
* and the `pending`, `rejected`, and `fulfilled` properties on the
* returned action creator. Also note that the async thunk action creator
* returned directly by `createAsyncThunk` is not directly exposed.
*
* @param typePrefix This is both the name of the action that starts the thunk
* process, and the prefix for the three generated actions that update the
* thunk status---`pending`, `rejected`, and `fulfilled`---based on the
* payload creator's promise status.
* @param payloadCreator A function that will always run in the background
* script; this takes the action payload and runs an async action whose
* result is eventually dispatched normally into the redux store. When
* the function is initially invoked, the `typePrefix`-pending action is
* dispatched; if the function's returned promise resolves,
* `typePrefix`-fulfilled or `typePrefix`-rejected is dispatched on the
* store depending on the promise's settled status. When -fulfilled is
* dispatched, the payload is the fulfilled value of the promise.
* @param options Additional options specified by `createAsyncThunk`, including
* conditions for executing the thunk vs not.
*
* @return A function that takes the payload and returns a plain action for
* dispatching on the background store. This function has four
* additional properties, which are the same as those returned by
* `createAsyncThunk`.
*/
export function createBackgroundAsyncThunk<
TypePrefix extends string,
Returned,
ThunkArg = void,
ThunkApiConfig = {
extra: { mainServiceManager: MainServiceManager };
}
>(
typePrefix: TypePrefix,
payloadCreator: AsyncThunkPayloadCreator<Returned, ThunkArg, ThunkApiConfig>,
options?: AsyncThunkOptions<ThunkArg, ThunkApiConfig>
): ((payload: ThunkArg) => Action<TypePrefix> & { payload: ThunkArg }) &
Pick<AsyncThunk<Returned, ThunkArg, ThunkApiConfig>, AsyncThunkProps> {
// Exit early if this type prefix is already aliased for handling in the
// background script.
if (allAliases[typePrefix]) {
throw new Error('Attempted to register an alias twice.');
}
// Use reduxtools' createAsyncThunk to build the infrastructure.
const baseThunkActionCreator = createAsyncThunk(
typePrefix,
async (...args: Parameters<typeof payloadCreator>) => {
try {
return await payloadCreator(...args);
} catch (error) {
console.error('Async thunk failed', error);
throw error;
}
},
options
);
// Wrap the top-level action creator to make it compatible with webext-redux.
const webextActionCreator = Object.assign(
(payload: ThunkArg) => ({
type: typePrefix,
payload,
}),
// Copy the utility props on the redux-tools version to our version.
Object.fromEntries(
asyncThunkProperties.map((prop) => [prop, baseThunkActionCreator[prop]])
) as Pick<AsyncThunk<Returned, ThunkArg, ThunkApiConfig>, AsyncThunkProps>
);
// Register the alias to ensure it will always get proxied back to the
// background script, where we will run our proxy action creator to fire off
// the thunk correctly.
allAliases[typePrefix] = (action: { type: string; payload: ThunkArg }) =>
baseThunkActionCreator(action.payload);
return webextActionCreator;
}
/**
* Utility type to extract the fulfillment type of an async thunk. Useful when
* wanting to declare something as "the type that this thunk will return once
* it completes".
*/
export type AsyncThunkFulfillmentType<T> = T extends Pick<
// We don't really need the other two inferred values.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
AsyncThunk<infer Returned, infer _1, infer _2>,
'fulfilled'
>
? Returned
: never;
export const noopAction = createBackgroundAsyncThunk('noop', () => {});

View File

@@ -0,0 +1,208 @@
import Emittery from 'emittery';
import { Alarms } from 'webextension-polyfill';
import MainServiceManager from './main';
import { Service, ServiceLifecycleEvents } from './types';
/**
* An alarm schedule for use in the `browser.alarms` API.
*
* Note that even if `periodInMinutes` is less than 1, the alarm will only fire
* a maximum of once a minute in Chrome for a packaged extension. When an
* extension is loaded unpacked (from a directory for development), periods
* less than 1 minute are respected across browsers.
*
* @see {@link https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/alarms/create|
* The MDN docs for `alarms.create`}.
*/
type AlarmSchedule =
| {
when: number;
periodInMinutes?: number;
}
| {
delayInMinutes: number;
periodInMinutes?: number;
}
| { periodInMinutes: number };
/**
* An object carrying the same information as {@link AlarmSchedule}, but that
* also provides a handler to handle the specified alarm. Designed for use with
* {@link AlarmHandlerScheduleMap}, which allows for disambiguating between
* different alarms.
*
* Also provides an optional `runAtStart` property that will immediately fire
* the handler at service start for the first time instead of waiting for the
* first scheduled run to execute.
*/
export type AlarmHandlerSchedule = {
schedule: AlarmSchedule;
handler: (alarm?: Alarms.Alarm) => void;
runAtStart?: boolean;
};
/*
* An object mapping alarm names to their designated schedules. Alarm names are
* used to disambiguate between different alarms when they are fired, so as to
* fire the handler associated with the appropriate alarm.
*/
export type AlarmHandlerScheduleMap = {
[alarmName: string]: AlarmHandlerSchedule;
};
export type BaseServiceCreateProps = {
mainServiceManager?: MainServiceManager;
};
export default abstract class BaseService<Events extends ServiceLifecycleEvents>
implements Service<Events>
{
/**
* {@inheritdoc Service.emitter}
*/
readonly emitter = new Emittery<Events>();
/**
* Takes the set of alarm schedules that this service wants to run. Schedules
* are not added until `startService` is called.
*/
protected constructor(
protected readonly alarmSchedules: AlarmHandlerScheduleMap = {}
) {}
private serviceState: 'unstarted' | 'started' | 'stopped' = 'unstarted';
/**
* {@inheritdoc Service.started}
*
* @throws {Error} If the service has already been stopped.
*/
readonly started = async (): Promise<this> => {
switch (this.serviceState) {
case 'started':
return this;
case 'stopped':
throw new Error('Service is already stopped and cannot be restarted.');
case 'unstarted':
return this.emitter.once('serviceStarted').then(() => this);
default: {
const exhaustiveCheck: never = this.serviceState;
throw new Error(`Unreachable code: ${exhaustiveCheck}`);
}
}
};
abstract _startService(): Promise<void>;
abstract _stopService(): Promise<void>;
/**
* {@inheritdoc Service.startService}
*
* Subclasses should extend `internalStartService` to handle additional
* starting tasks.
*
* @throws {Error} If the service has already been stopped.
*
* @sealed
*/
readonly startService = async (): Promise<void> => {
switch (this.serviceState) {
case 'started':
return;
case 'stopped':
throw new Error('Service is already stopped and cannot be restarted.');
case 'unstarted':
this.serviceState = 'started';
await this.internalStartService();
await this._startService();
this.emitter.emit('serviceStarted', undefined);
break;
default: {
const exhaustiveCheck: never = this.serviceState;
throw new Error(`Unreachable code: ${exhaustiveCheck}`);
}
}
};
/**
* Hook for subclass starting tasks. Subclasses should call
* `await super.internalStartService()`, as the base implementation sets up
* all alarms and their handling.
*/
protected async internalStartService(): Promise<void> {
const scheduleEntries = Object.entries(this.alarmSchedules);
scheduleEntries.forEach(([name, { schedule, runAtStart, handler }]) => {
chrome.alarms.create(name, schedule);
if (runAtStart) {
handler();
}
});
if (scheduleEntries.length > 0) {
chrome.alarms.onAlarm.addListener(this.handleAlarm);
}
}
/**
* Hook for subclass stopping tasks. Subclasses should call
* `await super.internalStopService()`, as the base implementation cleans up
* all alarms and their handling.
*/
protected async internalStopService(): Promise<void> {
const scheduleNames = Object.keys(this.alarmSchedules);
scheduleNames.forEach((alarmName) => chrome.alarms.clear(alarmName));
if (scheduleNames.length > 0) {
chrome.alarms.onAlarm.removeListener(this.handleAlarm);
}
}
/**
* {@inheritdoc Service.stopService}
*
* Subclasses should extend `internalStopService` to handle additional
* stopping tasks.
*
* @throws {Error} If the service has never been started.
*
* @sealed
*/
readonly stopService = async (): Promise<void> => {
switch (this.serviceState) {
case 'unstarted':
throw new Error('Attempted to stop a service that was never started.');
case 'stopped':
return;
case 'started':
this.serviceState = 'stopped';
await this.internalStopService();
await this._stopService();
this.emitter.emit('serviceStopped', undefined);
break;
default: {
const exhaustiveCheck: never = this.serviceState;
throw new Error(`Unreachable code: ${exhaustiveCheck}`);
}
}
};
/**
* Default handler for alarms. By default, calls the defined handler for the
* named alarm, if available. Override for custom behavior.
*/
protected handleAlarm = (alarm: Alarms.Alarm): void => {
this.alarmSchedules[alarm.name]?.handler(alarm);
};
}

View File

@@ -0,0 +1,465 @@
import { keyringUnlocked, Vault, vaultUpdate } from '../redux-slices/keyrings';
import BaseService, { BaseServiceCreateProps } from './base';
import MainServiceManager from './main';
import { ServiceLifecycleEvents } from './types';
import * as encryptor from '@metamask/browser-passworder';
import { Provider } from '@ethersproject/providers';
import { BigNumber, ethers } from 'ethers';
import { AccountApiType } from '../../Account/account-api/types';
import {
AccountImplementations,
ActiveAccountImplementation,
} from '../constants';
import { HttpRpcClient, PaymasterAPI } from '@account-abstraction/sdk';
import { MessageSigningRequest } from '../redux-slices/signing';
import { AccountData } from '../redux-slices/account';
import { AccountBalance } from '../types/account';
import { DomainName, URI } from '../types/common';
import { EVMNetwork } from '../types/network';
import { EthersTransactionRequest } from './types';
import { UserOperationStruct } from '@account-abstraction/contracts';
interface Events extends ServiceLifecycleEvents {
createPassword: string;
}
type KeyringSerialisedState = {
type: string;
address: string;
data: any;
};
export type KeyringServiceCreateProps = {
initialState?: Vault;
provider: string;
bundler: string;
entryPointAddress: string;
} & BaseServiceCreateProps;
export default class KeyringService extends BaseService<Events> {
keyrings: {
[address: string]: AccountApiType;
};
vault?: string;
password?: string;
encryptionKey?: string;
encryptionSalt?: string;
provider: Provider;
bundler?: HttpRpcClient;
paymasterAPI?: PaymasterAPI;
constructor(
readonly mainServiceManager: MainServiceManager,
provider: string,
bundler: string,
readonly entryPointAddress: string,
vault?: string
) {
super();
this.keyrings = {};
this.provider = new ethers.providers.JsonRpcBatchProvider(provider);
this.provider
.getNetwork()
.then((net) => net.chainId)
.then(async (chainId) => {
let bundlerRPC;
try {
bundlerRPC = new ethers.providers.JsonRpcProvider(bundler);
} catch (e) {
throw new Error(`Bundler network is not connected on url ${bundler}`);
}
if (bundlerRPC) {
const supportedEntryPoint = await bundlerRPC.send(
'eth_supportedEntryPoints',
[]
);
if (!supportedEntryPoint.includes(entryPointAddress)) {
throw new Error(
`Bundler network doesn't support entryPoint ${entryPointAddress}`
);
}
}
const code = await this.provider.getCode(entryPointAddress);
if (code === '0x')
throw new Error(`Entrypoint not deployed at ${entryPointAddress}`);
this.bundler = new HttpRpcClient(bundler, entryPointAddress, chainId);
});
this.vault = vault;
}
async unlockVault(
password?: string,
encryptionKey?: string,
encryptionSalt?: string
): Promise<{ [address: string]: AccountApiType }> {
if (!this.vault) throw new Error('No vault to restore');
let vault: any;
if (password) {
const result = await encryptor.decryptWithDetail(password, this.vault);
vault = result.vault;
this.password = password;
this.encryptionKey = result.exportedKeyString;
this.encryptionSalt = result.salt;
} else {
const parsedEncryptedVault = JSON.parse(this.vault);
if (encryptionSalt !== parsedEncryptedVault.salt) {
throw new Error('Encryption key and salt provided are expired');
}
const key = await encryptor.importKey(encryptionKey || '');
vault = await encryptor.decryptWithKey(key, parsedEncryptedVault);
this.encryptionKey = encryptionKey;
this.encryptionSalt = encryptionSalt;
}
await Promise.all(vault.map(this._restoreKeyring));
return this.keyrings;
}
/**
* Restore Keyring Helper
*
* Attempts to initialize a new keyring from the provided serialized payload.
* On success, returns the resulting keyring instance.
*
* @param {object} serialized - The serialized keyring.
* @returns {Promise<Keyring|undefined>} The deserialized keyring or undefined if the keyring type is unsupported.
*/
_restoreKeyring = async (
serialized: KeyringSerialisedState
): Promise<AccountApiType | undefined> => {
const { address, type, data } = serialized;
const keyring = await this._newKeyring(address, type, data);
this.keyrings[address] = keyring;
return keyring;
};
/**
* Instantiate, initialize and return a new keyring
*
* The keyring instantiated is of the given `type`.
*
* @param {string} type - The type of keyring to add.
* @param {object} data - The data to restore a previously serialized keyring.
* @returns {Promise<Keyring>} The new keyring.
*/
async _newKeyring(
address: string,
type: string,
data: any
): Promise<AccountApiType> {
const account = new AccountImplementations[type]({
provider: this.provider,
entryPointAddress: this.entryPointAddress,
paymasterAPI: this.paymasterAPI,
deserializeState: data,
});
return account;
}
/**
* Clear Keyrings
*
* Deallocates all currently managed keyrings and accounts.
* Used before initializing a new vault.
*/
/* eslint-disable require-await */
clearKeyrings = async (): Promise<void> => {
// clear keyrings from memory
this.keyrings = {};
};
registerEventListeners = () => {};
removeEventListeners = () => {};
updateStore = () => {};
createPassword = async (password: string) => {
this.password = password;
await this.persistAllKeyrings();
this.keyringUnlocked();
};
keyringUnlocked = () => {
this.mainServiceManager.store.dispatch(keyringUnlocked());
};
persistAllKeyrings = async () => {
if (!this.password && !this.encryptionKey) {
throw new Error(
'Cannot persist vault without password and encryption key'
);
}
const serializedKeyrings: KeyringSerialisedState[] = await Promise.all(
Object.values(this.keyrings).map(async (keyring) => {
const [address, data] = await Promise.all([
await keyring.getAccountAddress(),
keyring.serialize(),
]);
return { type: ActiveAccountImplementation, address, data };
})
);
let vault: string;
if (this.password) {
const { vault: newVault, exportedKeyString } =
await encryptor.encryptWithDetail(this.password, serializedKeyrings);
vault = newVault;
this.encryptionKey = exportedKeyString;
this.encryptionSalt = JSON.parse(newVault).salt;
} else {
const key = await encryptor.importKey(this.encryptionKey || '');
const vaultJSON = await encryptor.encryptWithKey(key, serializedKeyrings);
vaultJSON.salt = this.encryptionSalt;
vault = JSON.stringify(vaultJSON);
}
this.mainServiceManager.store.dispatch(
vaultUpdate({
vault,
encryptionKey: this.encryptionKey,
encryptionSalt: this.encryptionSalt,
})
);
};
sendUnlockKeyringChromeMessage = () => {};
createKeyringForImplementation = async (implementation: string) => {};
addAccount = async (
implementation: string,
context?: any
): Promise<string> => {
const account = new AccountImplementations[implementation]({
provider: this.provider,
entryPointAddress: this.entryPointAddress,
context,
paymasterAPI: this.paymasterAPI,
});
const address = await account.getAccountAddress();
if (address === ethers.constants.AddressZero)
throw new Error(
`EntryPoint getAccountAddress returned error and returned address ${ethers.constants.AddressZero}, check factory contract is properly deployed.`
);
this.keyrings[address] = account;
await this.persistAllKeyrings();
return account.getAccountAddress();
};
getAccountData = async (
address: string,
activeNetwork: EVMNetwork
): Promise<{
accountDeployed: boolean;
minimumRequiredFunds: string;
balances?: {
[assetSymbol: string]: AccountBalance;
};
ens?: {
name?: DomainName;
avatarURL?: URI;
};
}> => {
const response: {
accountDeployed: boolean;
minimumRequiredFunds: string;
balances?: {
[assetSymbol: string]: AccountBalance;
};
ens?: {
name?: DomainName;
avatarURL?: URI;
};
} = {
accountDeployed: false,
minimumRequiredFunds: '0',
balances: undefined,
ens: undefined,
};
const code = await this.provider.getCode(address);
if (code !== '0x') response.accountDeployed = true;
const keyring = this.keyrings[address];
response.minimumRequiredFunds = ethers.utils.formatEther(
BigNumber.from(
await keyring.estimateCreationGas(await keyring.getInitCode())
)
);
const balance = await this.provider.getBalance(address);
response.balances = {
[activeNetwork.baseAsset.symbol]: {
address: '0x',
assetAmount: {
asset: {
symbol: activeNetwork.baseAsset.symbol,
name: activeNetwork.baseAsset.name,
},
amount: ethers.utils.formatEther(balance),
},
network: activeNetwork,
retrievedAt: Date.now(),
dataSource: 'custom',
},
};
return response;
};
personalSign = async (
address: string,
context: any,
request?: MessageSigningRequest
): Promise<string> => {
const keyring = this.keyrings[address];
if (!keyring) throw new Error('No keyring for the address found');
return keyring.signMessage(context, request);
};
callAccountApi = async (
address: string,
functionName: string,
args?: any[]
) => {
const keyring = this.keyrings[address];
return args ? keyring[functionName](...args) : keyring[functionName]();
};
signUserOpWithContext = async (
address: string,
userOp: UserOperationStruct,
context?: any
): Promise<UserOperationStruct> => {
const keyring = this.keyrings[address];
return keyring.signUserOpWithContext(userOp, context);
};
sendUserOp = async (
address: string,
userOp: UserOperationStruct
): Promise<string | null> => {
if (this.bundler) {
const userOpHash = await this.bundler.sendUserOpToBundler(userOp);
const keyring = this.keyrings[address];
return await keyring.getUserOpReceipt(userOpHash);
}
return null;
};
createUnsignedUserOp = async (
address: string,
transaction: EthersTransactionRequest
): Promise<UserOperationStruct> => {
const keyring = this.keyrings[address];
const userOp = await keyring.createUnsignedUserOp({
target: transaction.to,
data: transaction.data
? ethers.utils.hexConcat([transaction.data])
: '0x',
value: transaction.value
? ethers.BigNumber.from(transaction.value)
: undefined,
gasLimit: transaction.gasLimit,
maxFeePerGas: transaction.maxFeePerGas,
maxPriorityFeePerGas: transaction.maxPriorityFeePerGas,
});
userOp.sender = await userOp.sender;
userOp.nonce = ethers.BigNumber.from(await userOp.nonce).toHexString();
userOp.initCode = await userOp.initCode;
userOp.callData = await userOp.callData;
userOp.callGasLimit = ethers.BigNumber.from(
await userOp.callGasLimit
).toHexString();
userOp.verificationGasLimit = ethers.BigNumber.from(
await userOp.verificationGasLimit
).toHexString();
userOp.preVerificationGas = await userOp.preVerificationGas;
userOp.maxFeePerGas = ethers.BigNumber.from(
await userOp.maxFeePerGas
).toHexString();
userOp.maxPriorityFeePerGas = ethers.BigNumber.from(
await userOp.maxPriorityFeePerGas
).toHexString();
userOp.paymasterAndData = await userOp.paymasterAndData;
userOp.signature = await userOp.signature;
const gasParameters = await this.bundler?.estimateUserOpGas(
await keyring.signUserOp(userOp)
);
userOp.callGasLimit = ethers.BigNumber.from(
gasParameters?.callGasLimit || userOp.callGasLimit
).toHexString();
userOp.verificationGasLimit = ethers.BigNumber.from(
gasParameters?.verificationGas || userOp.verificationGasLimit
).toHexString();
userOp.preVerificationGas = ethers.BigNumber.from(
gasParameters?.preVerificationGas || userOp.preVerificationGas
).toHexString();
return userOp;
};
validateKeyringViewInputValue = async () => {};
static async create({
mainServiceManager,
initialState,
provider,
bundler,
entryPointAddress,
}: KeyringServiceCreateProps): Promise<KeyringService> {
if (!mainServiceManager)
throw new Error('mainServiceManager is needed for Keyring Servie');
const keyringService = new KeyringService(
mainServiceManager,
provider,
bundler,
entryPointAddress,
initialState?.vault
);
if (initialState?.encryptionKey && initialState?.encryptionSalt) {
await keyringService.unlockVault(
undefined,
initialState?.encryptionKey,
initialState?.encryptionSalt
);
}
return keyringService;
}
_startService = async (): Promise<void> => {
this.registerEventListeners();
};
_stopService = async (): Promise<void> => {
this.removeEventListeners();
};
}

View File

@@ -0,0 +1,76 @@
import { wrapStore } from 'webext-redux';
import { initializeStore, ReduxStoreType } from '../redux-slices';
import BaseService from './base';
import Config from '../../../exconfig';
import { decodeJSON } from '../utils';
import { initialState as initialNetworkState } from '../redux-slices/network';
import { initialState as initialTransactionsState } from '../redux-slices/transactions';
export interface MainServiceManagerServicesMap {
[key: string]: BaseService<any>;
}
export interface MainServiceManagerProps {
services: MainServiceManagerServicesMap;
}
export default class MainServiceManager extends BaseService<never> {
store: ReduxStoreType;
services?: MainServiceManagerServicesMap;
constructor(readonly name: string) {
super();
let state = {};
const version = localStorage.getItem('version');
if (version === Config.stateVersion) {
const stateFromStorage = decodeJSON(
localStorage.getItem('state') || ''
) as {};
if (
stateFromStorage &&
stateFromStorage.network &&
stateFromStorage.network.activeNetwork.chainID ===
initialNetworkState.activeNetwork.chainID
) {
state = stateFromStorage;
}
}
state.network = initialNetworkState;
state.transactions = initialTransactionsState;
this.store = initializeStore(state, this);
wrapStore(this.store);
}
init = async (props: MainServiceManagerProps) => {
this.services = props.services;
};
static async create(
name: string,
serviceInitializer: (
mainServiceManager: MainServiceManager
) => Promise<MainServiceManagerServicesMap>
) {
const mainServiceManager = new this(name);
await mainServiceManager.init({
services: await serviceInitializer(mainServiceManager),
});
return mainServiceManager;
}
getService = (name: string): BaseService<any> => {
if (!this.services) throw new Error('No services initialised');
return this.services[name];
};
_startService = async (): Promise<void> => {
if (!this.services) throw new Error('No services initialised');
Object.values(this.services).map((service) => service.startService());
};
_stopService = async (): Promise<void> => {
if (!this.services) throw new Error('No services initialised');
Object.values(this.services).map((service) => service.stopService());
};
}

View File

@@ -0,0 +1,730 @@
import BaseService, { BaseServiceCreateProps } from './base';
import MainServiceManager from './main';
import { EthersTransactionRequest, ServiceLifecycleEvents } from './types';
import browser from 'webextension-polyfill';
import {
EIP1193ErrorPayload,
PortResponseEvent,
RPCRequest,
} from '../../Content/types';
import { AA_EXTENSION_CONFIG, EXTERNAL_PORT_NAME } from '../constants';
import { RootState } from '../redux-slices';
import { isAAExtensionConfigPayload } from '../../Content/window-provider/runtime-type-checks';
import showExtensionPopup, {
checkPermissionSign,
checkPermissionSignTransaction,
parseSigningData,
toHexChainID,
} from '../utils';
import {
EIP1193Error,
EIP1193_ERROR_CODES,
isEIP1193Error,
} from '../../Content/window-provider/eip-1193';
import { AllowedQueryParamPage } from '../types/chrome-messages';
import { requestPermission } from '../redux-slices/permissions';
import { ethers } from 'ethers';
import { hexlify, toUtf8Bytes } from 'ethers/lib/utils.js';
import { signDataRequest } from '../redux-slices/signing';
import { HexString } from '../types/common';
import { sendTransactionRequest } from '../redux-slices/transactions';
type JsonRpcTransactionRequest = Omit<EthersTransactionRequest, 'gasLimit'> & {
gas?: string;
input?: string;
annotation?: string;
};
export type PermissionRequest = {
key: string;
origin: string;
faviconUrl: string;
chainID: string;
title: string;
state: 'request' | 'allow' | 'deny';
accountAddress: string;
};
// https://eips.ethereum.org/EIPS/eip-3326
export type SwitchEthereumChainParameter = {
chainId: string;
};
// https://eips.ethereum.org/EIPS/eip-3085
export type AddEthereumChainParameter = {
chainId: string;
blockExplorerUrls?: string[];
chainName?: string;
iconUrls?: string[];
nativeCurrency?: {
name: string;
symbol: string;
decimals: number;
};
rpcUrls?: string[];
};
export type PortRequestEvent = {
id: string;
request: RPCRequest;
};
export type PermissionMap = {
evm: {
[chainID: string]: {
[address: string]: {
[origin: string]: PermissionRequest;
};
};
};
};
type Events = ServiceLifecycleEvents & {
requestPermission: PermissionRequest;
initializeAllowedPages: PermissionMap;
setClaimReferrer: string;
walletConnectInit: string;
};
type ProviderBridgeServiceProps = {} & BaseServiceCreateProps;
function parsedRPCErrorResponse(error: { body: string }):
| {
code: number;
message: string;
}
| undefined {
try {
const parsedError = JSON.parse(error.body).error;
return {
/**
* The code should be the same as for user rejected requests because otherwise it will not be displayed.
*/
code: 4001,
message:
'message' in parsedError && parsedError.message
? parsedError.message[0].toUpperCase() + parsedError.message.slice(1)
: EIP1193_ERROR_CODES.rpcErrorNotParsed.message,
};
} catch (err) {
return undefined;
}
}
export default class ProviderBridgeService extends BaseService<Events> {
#pendingRequests: {
[origin: string]: {
resolve: (value: unknown) => void;
reject: (value: unknown) => void;
};
} = {};
openPorts: Array<browser.Runtime.Port> = [];
static create = async ({
mainServiceManager,
}: ProviderBridgeServiceProps): Promise<ProviderBridgeService> => {
if (!mainServiceManager)
throw new Error(
'mainServiceManager is needed for Provider Bridge Servie'
);
return new this(mainServiceManager);
};
_startService = async () => {};
_stopService = async () => {};
private constructor(readonly mainServiceManager: MainServiceManager) {
super();
browser.runtime.onConnect.addListener(
async (port: browser.Runtime.Port) => {
if (port.name === EXTERNAL_PORT_NAME && port.sender?.url) {
port.onMessage.addListener((event: any) => {
this.onMessageListener(port, event);
});
port.onDisconnect.addListener(() => {
this.openPorts = this.openPorts.filter(
(openPort) => openPort !== port
);
});
this.openPorts.push(port);
// we need to send this info ASAP so it arrives before the webpage is initializing
// so we can set our provider into the correct state, BEFORE the page has a chance to
// to cache it, store it etc.
port.postMessage({
id: 'aa-extension',
jsonrpc: '2.0',
result: {
method: AA_EXTENSION_CONFIG,
},
});
}
}
);
}
async onMessageListener(
port: browser.Runtime.Port,
event: PortRequestEvent
): Promise<void> {
const { url, tab } = port.sender;
if (typeof url === 'undefined') {
return;
}
const { origin } = new URL(url);
const response: PortResponseEvent = {
id: event.id,
jsonrpc: '2.0',
result: [],
};
const network = (this.mainServiceManager.store.getState() as RootState)
.network.activeNetwork;
const originPermission = await this.checkPermission(origin);
if (isAAExtensionConfigPayload(event.request)) {
// let's start with the internal communication
response.id = 'aa-extension';
response.result = {
method: event.request.method,
chainId: toHexChainID(network.chainID),
};
} else if (
event.request.method === 'eth_chainId' ||
event.request.method === 'net_version'
) {
response.result = await this.routeSafeRPCRequest(
event.request.method,
event.request.params,
origin
);
} else if (typeof originPermission !== 'undefined') {
// // if it's not internal but dapp has permission to communicate we proxy the request
// TODO: here comes format validation
response.result = await this.routeContentScriptRPCRequest(
originPermission,
event.request.method,
event.request.params,
origin
);
} else if (
event.request.method === 'wallet_addEthereumChain' ||
event.request.method === 'wallet_switchEthereumChain'
) {
response.result = await this.routeSafeRPCRequest(
event.request.method,
event.request.params,
origin
);
} else if (
event.request.method === 'eth_requestAccounts' ||
event.request.method === 'eth_accounts'
) {
// if it's external communication AND the dApp does not have permission BUT asks for it
// then let's ask the user what he/she thinks
const state: RootState =
this.mainServiceManager.store.getState() as RootState;
const address = state.account.account;
const network = state.network.activeNetwork;
if (!address) {
response.result = new EIP1193Error(
EIP1193_ERROR_CODES.userRejectedRequest
).toJSON();
} else {
// Get last prefferec chainID for the DAPP
// const dAppChainID = Number(
// (await this.internalEthereumProviderService.routeSafeRPCRequest(
// "eth_chainId",
// [],
// origin
// )) as string
// ).toString()
// these params are taken directly from the dapp website
const [title, faviconUrl] = event.request.params as string[];
const permissionRequest: PermissionRequest = {
key: `${origin}_${address}_${network.chainID}`,
origin,
chainID: network.chainID,
faviconUrl: faviconUrl || tab?.favIconUrl || '', // if favicon was not found on the website then try with browser's `tab`
title,
state: 'request',
accountAddress: address,
};
// TODO:// add ask permision from a user in popup
const blockUntilUserAction = await this.requestPermission(
permissionRequest
);
await blockUntilUserAction;
// Fetch the latest permission
const persistedPermission = await this.checkPermission(origin);
if (typeof persistedPermission !== 'undefined') {
// if agrees then let's return the account data
response.result = await this.routeContentScriptRPCRequest(
persistedPermission,
'eth_accounts',
event.request.params,
origin
);
} else {
// if user does NOT agree, then reject
response.result = new EIP1193Error(
EIP1193_ERROR_CODES.userRejectedRequest
).toJSON();
}
}
} else {
// sorry dear dApp, there is no love for you here
response.result = new EIP1193Error(
EIP1193_ERROR_CODES.unauthorized
).toJSON();
}
port.postMessage(response);
}
async checkPermission(
origin: string
): Promise<PermissionRequest | undefined> {
const state: RootState =
this.mainServiceManager.store.getState() as RootState;
const account = state.account.account || '';
return state.dappPermissions.allowed.evm[`${origin}_${account}`];
}
async grantPermission(permission: PermissionRequest): Promise<void> {
// FIXME proper error handling if this happens - should not tho
if (permission.state !== 'allow' || !permission.accountAddress) return;
if (this.#pendingRequests[permission.origin]) {
this.#pendingRequests[permission.origin].resolve(permission);
delete this.#pendingRequests[permission.origin];
}
}
async resolveRequest(origin: string, result: any): Promise<void> {
if (this.#pendingRequests[origin]) {
this.#pendingRequests[origin].resolve(result);
delete this.#pendingRequests[origin];
}
}
async rejectRequest(origin: string, result: any): Promise<void> {
if (this.#pendingRequests[origin]) {
this.#pendingRequests[origin].reject(result);
delete this.#pendingRequests[origin];
}
}
async denyOrRevokePermission(permission: PermissionRequest) {
if (permission.state !== 'deny' || !permission.accountAddress) {
return;
}
if (this.#pendingRequests[permission.origin]) {
this.#pendingRequests[permission.origin].reject('Time to move on');
delete this.#pendingRequests[permission.origin];
}
this.notifyContentScriptsAboutAddressChange();
}
notifyContentScriptsAboutAddressChange(newAddress?: string): void {
this.openPorts.forEach(async (port) => {
// we know that url exists because it was required to store the port
const { origin } = new URL(port.sender?.url as string);
const { chainID } = (
this.mainServiceManager.store.getState() as RootState
).network.activeNetwork;
if (await this.checkPermission(origin, chainID)) {
port.postMessage({
id: 'aa-extension',
result: {
method: 'aa-extension_accountChanged',
address: [newAddress],
},
});
} else {
port.postMessage({
id: 'aa-extension',
result: {
method: 'aa-extension_accountChanged',
address: [],
},
});
}
});
}
async requestPermission(
permissionRequest: PermissionRequest
): Promise<unknown> {
this.emitter.emit('requestPermission', permissionRequest);
this.mainServiceManager.store.dispatch(
requestPermission(permissionRequest)
);
await showExtensionPopup(AllowedQueryParamPage.dappPermission);
return new Promise((resolve, reject) => {
this.#pendingRequests[permissionRequest.origin] = {
resolve,
reject,
};
});
}
private async signData(
{
input,
account,
}: {
input: string;
account: string;
},
origin: string
) {
const state: RootState =
this.mainServiceManager.store.getState() as RootState;
const hexInput = input.match(/^0x[0-9A-Fa-f]*$/)
? input
: hexlify(toUtf8Bytes(input));
const typeAndData = parseSigningData(input);
const currentNetwork = state.network.activeNetwork;
this.mainServiceManager.store.dispatch(
signDataRequest({
origin: origin,
account: {
address: account,
network: currentNetwork,
},
rawSigningData: hexInput,
...typeAndData,
})
);
return new Promise(async (resolve, reject) => {
this.#pendingRequests[origin] = { resolve, reject };
});
}
private async sendTransaction(
transactionRequest: JsonRpcTransactionRequest,
origin: string
) {
this.mainServiceManager.store.dispatch(
sendTransactionRequest({
transactionRequest: transactionRequest,
origin: origin,
})
);
return new Promise(async (resolve, reject) => {
this.#pendingRequests[origin] = { resolve, reject };
});
}
async routeSafeRPCRequest(
method: string,
params: RPCRequest['params'],
origin: string
): Promise<unknown> {
const state: RootState =
this.mainServiceManager.store.getState() as RootState;
const provider = new ethers.providers.JsonRpcProvider(
state.network.activeNetwork.provider
);
switch (method) {
// supported alchemy methods: https://docs.alchemy.com/alchemy/apis/ethereum
// case 'eth_signTypedData':
// case 'eth_signTypedData_v1':
// case 'eth_signTypedData_v3':
// case 'eth_signTypedData_v4':
// return this.signTypedData({
// account: {
// address: params[0] as string,
// network: state.network.activeNetwork,
// },
// typedData: JSON.parse(params[1] as string),
// });
case 'eth_chainId':
return toHexChainID(state.network.activeNetwork.chainID);
case 'eth_blockNumber':
case 'eth_call':
case 'eth_estimateGas':
case 'eth_feeHistory':
case 'eth_gasPrice':
case 'eth_getBalance':
case 'eth_getBlockByHash':
case 'eth_getBlockByNumber':
case 'eth_getBlockTransactionCountByHash':
case 'eth_getBlockTransactionCountByNumber':
case 'eth_getCode':
case 'eth_getFilterChanges':
case 'eth_getFilterLogs':
case 'eth_getLogs':
case 'eth_getProof':
case 'eth_getStorageAt':
case 'eth_getTransactionByBlockHashAndIndex':
case 'eth_getTransactionByBlockNumberAndIndex':
case 'eth_getTransactionByHash':
case 'eth_getTransactionCount':
case 'eth_getTransactionReceipt':
case 'eth_getUncleByBlockHashAndIndex':
case 'eth_getUncleByBlockNumberAndIndex':
case 'eth_getUncleCountByBlockHash':
case 'eth_getUncleCountByBlockNumber':
case 'eth_maxPriorityFeePerGas':
case 'eth_newBlockFilter':
case 'eth_newFilter':
case 'eth_newPendingTransactionFilter':
case 'eth_protocolVersion':
case 'eth_sendRawTransaction':
case 'eth_subscribe':
case 'eth_syncing':
case 'eth_uninstallFilter':
case 'eth_unsubscribe':
case 'net_listening':
case 'net_version':
case 'web3_clientVersion':
case 'web3_sha3':
return provider.send(method, params);
case 'eth_accounts': {
// This is a special method, because Alchemy provider DO support it, but always return null (because they do not store keys.)
const address = state.account.account;
return [address];
}
case 'eth_sendTransaction':
return this.sendTransaction(
{
...(params[0] as JsonRpcTransactionRequest),
},
origin
);
// case 'eth_signTransaction':
// return this.signTransaction(
// params[0] as JsonRpcTransactionRequest,
// origin
// ).then((signedTransaction) =>
// serializeEthersTransaction(
// ethersTransactionFromSignedTransaction(signedTransaction),
// {
// r: signedTransaction.r,
// s: signedTransaction.s,
// v: signedTransaction.v,
// }
// )
// );
// case 'eth_sign': // --- important wallet methods ---
// return this.signData(
// {
// input: params[1] as string,
// account: params[0] as string,
// },
// origin
// );
case 'personal_sign':
return this.signData(
{
input: params[0] as string,
account: params[1] as string,
},
origin
);
case 'wallet_addEthereumChain': {
// const chainInfo = params[0] as AddEthereumChainParameter;
// const { chainId } = chainInfo;
// const supportedNetwork = await this.getTrackedNetworkByChainId(chainId);
// if (supportedNetwork) {
// this.switchToSupportedNetwork(origin, supportedNetwork);
// return null;
// }
// if (!FeatureFlags.SUPPORT_CUSTOM_NETWORKS) {
// // Dissallow adding new chains until feature flag is turned on.
throw new EIP1193Error(EIP1193_ERROR_CODES.methodNotSupported);
// }
// try {
// const validatedParam = validateAddEthereumChainParameter(chainInfo);
// await this.chainService.addCustomChain(validatedParam);
// return null;
// } catch (e) {
// logger.error(e);
// throw new EIP1193Error(EIP1193_ERROR_CODES.userRejectedRequest);
// }
}
case 'wallet_switchEthereumChain': {
// const newChainId = (params[0] as SwitchEthereumChainParameter).chainId;
// const supportedNetwork = await this.getTrackedNetworkByChainId(
// newChainId
// );
// if (supportedNetwork) {
// this.switchToSupportedNetwork(origin, supportedNetwork);
// return null;
// }
throw new EIP1193Error(EIP1193_ERROR_CODES.chainDisconnected);
}
case 'metamask_getProviderState': // --- important MM only methods ---
case 'metamask_sendDomainMetadata':
case 'wallet_requestPermissions':
case 'wallet_watchAsset':
case 'estimateGas': // --- eip1193-bridge only method --
case 'eth_coinbase': // --- MM only methods ---
case 'eth_decrypt':
case 'eth_getEncryptionPublicKey':
case 'eth_getWork':
case 'eth_hashrate':
case 'eth_mining':
case 'eth_submitHashrate':
case 'eth_submitWork':
case 'metamask_accountsChanged':
case 'metamask_chainChanged':
case 'metamask_logWeb3ShimUsage':
case 'metamask_unlockStateChanged':
case 'metamask_watchAsset':
case 'net_peerCount':
case 'wallet_accountsChanged':
case 'wallet_registerOnboarding':
default:
throw new EIP1193Error(EIP1193_ERROR_CODES.unsupportedMethod);
}
}
async routeSafeRequest(
method: string,
params: unknown[],
origin: string,
popupPromise: Promise<browser.Windows.Window>
): Promise<unknown> {
const response = await this.routeSafeRPCRequest(
method,
params,
origin
).finally(async () => {
// Close the popup once we're done submitting.
const popup = await popupPromise;
if (typeof popup.id !== 'undefined') {
browser.windows.remove(popup.id);
}
});
return response;
}
async routeContentScriptRPCRequest(
enablingPermission: PermissionRequest,
method: string,
params: RPCRequest['params'],
origin: string
): Promise<unknown> {
try {
switch (method) {
case 'eth_requestAccounts':
case 'eth_accounts':
return [enablingPermission.accountAddress];
// case 'eth_signTypedData':
// case 'eth_signTypedData_v1':
// case 'eth_signTypedData_v3':
// case 'eth_signTypedData_v4':
// checkPermissionSignTypedData(
// params[0] as HexString,
// enablingPermission
// );
// return await this.routeSafeRequest(
// method,
// params,
// origin,
// showExtensionPopup(AllowedQueryParamPage.signData)
// );
// case 'eth_sign':
// checkPermissionSign(params[0] as HexString, enablingPermission);
// return await this.routeSafeRequest(
// method,
// params,
// origin,
// showExtensionPopup(AllowedQueryParamPage.personalSignData)
// );
case 'personal_sign':
checkPermissionSign(params[1] as HexString, enablingPermission);
return await this.routeSafeRequest(
method,
params,
origin,
showExtensionPopup(AllowedQueryParamPage.personalSignData)
);
// case 'eth_signTransaction':
case 'eth_sendTransaction':
checkPermissionSignTransaction(
{
// A dApp can't know what should be the next nonce because it can't access
// the information about how many tx are in the signing process inside the
// wallet. Nonce should be assigned only by the wallet.
...(params[0] as EthersTransactionRequest),
nonce: undefined,
},
enablingPermission
);
return await this.routeSafeRequest(
method,
params,
origin,
showExtensionPopup(AllowedQueryParamPage.signTransaction)
);
default: {
return await this.routeSafeRPCRequest(method, params, origin);
}
}
} catch (error) {
return this.handleRPCErrorResponse(error);
}
}
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
handleRPCErrorResponse(error: unknown) {
let response;
console.error('error processing request', error);
if (typeof error === 'object' && error !== null) {
/**
* Get error per the RPC methods specification
*/
if ('eip1193Error' in error) {
const { eip1193Error } = error as {
eip1193Error: EIP1193ErrorPayload;
};
if (isEIP1193Error(eip1193Error)) {
response = eip1193Error;
}
/**
* In the case of a non-matching error message, the error is returned without being nested in an object.
* This is due to the error handling implementation.
* Check the code for more details https://github.com/ethers-io/ethers.js/blob/master/packages/providers/src.ts/json-rpc-provider.ts#L96:L130
*/
} else if ('body' in error) {
response = parsedRPCErrorResponse(error as { body: string });
} else if ('error' in error) {
response = parsedRPCErrorResponse(
(error as { error: { body: string } }).error
);
}
}
/**
* If no specific error is obtained return a user rejected request error
*/
return (
response ??
new EIP1193Error(EIP1193_ERROR_CODES.rpcErrorNotParsed).toJSON()
);
}
}

View File

@@ -0,0 +1,105 @@
import Emittery from 'emittery';
import { BigNumberish, BytesLike } from 'ethers';
import { AccessListish } from 'ethers/lib/utils.js';
export interface ServiceLifecycleEvents {
serviceStarted: void;
serviceStopped: void;
}
export type EthersTransactionRequest = {
to: string;
from?: string;
nonce?: BigNumberish;
gasLimit?: BigNumberish;
gasPrice?: BigNumberish;
data?: BytesLike;
value?: BigNumberish;
chainId?: number;
type?: number;
accessList?: AccessListish;
maxPriorityFeePerGas?: BigNumberish;
maxFeePerGas?: BigNumberish;
customData?: Record<string, any>;
};
/**
* A simple interface for service lifecycles and event emission. Services
* should emit a `serviceStarted` event when they are started, and a
* `serviceStopped` event when they are stopped. It is strongly recommended
* that all services have a single static `create` method to create but not
* start the service. This method should adhere to `ServiceCreatorFunction` for
* consistency.
*
* Services are generally considered to have three phases: created, started,
* and stopped. Once services are created, they should have any internal and
* local state initialized. Once started, they should have any external state,
* such as subscriptions to external systems and polling set up. Events should
* not be emitted before a service is started. This creates an ideal moment to
* hook up event handlers _after_ the service has been created but _before_ it
* has been started.
*
* Implementors, see {@link BaseService} for a good base class that handles
* most of the lifecycle details.
*/
export interface Service<T extends ServiceLifecycleEvents> {
/**
* The emitter for service events. Services are generally expected to emit
* events when any action of interest occurs.
*
* All services should emit a `serviceStarted` event when the service has
* finished its start process, and a `serviceStopped` event when the service
* has finished its stop process.
*/
readonly emitter: Emittery<T>;
/**
* Waits for any internal initialization to occur before fulfilling the
* returned promise. The promise returns the same instance, so that it can be
* chained. Calling `started` does _not_ start the service! Instead, it acts
* as a hook for calling things once a service has been started.
*/
started(): Promise<this>;
/**
* Starts any internal monitoring, scheduling, etc and then resolves the
* returned promise. May not wait for all data to be resolved before
* resolving the returned promise, just for all relevant processes to be
* kicked off. Events should only be emitted after a service is started.
* This includes any initialization events that provide a starting view of
* persisted data.
*
* Calling this method more than once should be a noop. Starting a service
* that has already been started and stopped is not guaranteed to work.
*
* When the start process is complete, the `serviceStarted` event should be
* emitted on the service's event emitter.
*
* @returns An immediately-resolved promise if the service is already
* started, otherwise a promise that will resolve once the service
* has finished starting.
*/
startService(): Promise<void>;
/**
* Stops any internal monitoring, scheduling, etc and then resolves the
* returned promise. May not wait for all in-flight requests to be completed
* before resolving the returned promise, just for all relevant scheduling to
* be halted.
*
* Calling this method more than once should be a noop.
*
* When the stop process is complete, the `serviceStopped` event should be
* emitted on the service's event emitter.
*
* @returns An immediately-resolved promise if the service is already
* stopped, otherwise a promise that will resolve once the service
* has finished stopping.
*/
stopService(): Promise<void>;
}

View File

@@ -0,0 +1,31 @@
import { AnyAssetAmount } from './asset';
import { HexString } from './common';
import { EVMNetwork } from './network';
export type AccountBalance = {
/**
* The address whose balance was measured.
*/
address: HexString;
/**
* The measured balance and the asset in which it's denominated.
*/
assetAmount: AnyAssetAmount;
/**
* The network on which the account balance was measured.
*/
network: EVMNetwork;
/**
* The block height at while the balance measurement is valid.
*/
blockHeight?: string;
/**
* When the account balance was measured, using Unix epoch timestamps.
*/
retrievedAt: number;
/**
* A loose attempt at tracking balance data provenance, in case providers
* disagree and need to be disambiguated.
*/
dataSource: 'alchemy' | 'local' | 'infura' | 'custom';
};

View File

@@ -0,0 +1,33 @@
/**
* Metadata for a given asset, as well as the one or more token lists that
* provided that metadata.
*
* Note that the metadata is entirely optional.
*/
export type AssetMetadata = {
logoURL?: string;
websiteURL?: string;
};
/**
* The name and symbol of an arbitrary asset, fungible or non-fungible,
* alongside potential metadata about that asset.
*/
export type Asset = {
symbol: string;
name: string;
metadata?: AssetMetadata;
};
/*
* A union of all assets we expect to price.
*/
export type AnyAsset = Asset;
/*
* The primary type representing amounts in fungible asset transactions.
*/
export type AnyAssetAmount<T extends AnyAsset = AnyAsset> = {
asset: T;
amount: string;
};

View File

@@ -0,0 +1,71 @@
import {
KeyringInputError,
KeyringView,
KeyringViewInputFieldValue,
KeyringViewUserInput,
StoreState,
VaultState,
} from '@epf-wallet/keyring-controller';
export type ChromeMessages<T> = {
type:
| 'keyring/createPassword'
| 'keyring/locked'
| 'keyring/unlock'
| 'keyring/unlocked'
| 'keyring/vaultUpdate'
| 'keyring/createKeyringForImplementation'
| 'keyring/newAccountView'
| 'keyring/validateKeyringViewInputValue'
| 'keyring/addAcount';
data?: T;
};
export type CreatePasswordChromeMessage = {
password: string;
};
export type UnlockedKeyringChromeMessage = {
storeState: StoreState;
};
export type UnlockKeyringChromeMessage = {
password: string;
};
export type VaultUpdate = {
vault: VaultState;
};
export type CreateKeyringForImplementation = {
implementation: string;
};
export type NewAccountView = {
implementation: string;
view: KeyringView | undefined;
};
export type ValidateKeyringViewInputValue = {
implementation: string;
inputs: Array<KeyringViewInputFieldValue>;
};
export type KeyringInputErrorMessage = {
errors: Array<KeyringInputError>;
};
export type AddAcount = {
implementation: string;
userInputs: KeyringViewUserInput;
};
export const AllowedQueryParamPage = {
signTransaction: '/sign-transaction',
dappPermission: '/dapp-permission',
signData: '/sign-data',
personalSignData: '/personal-sign',
} as const;
export type AllowedQueryParamPageType =
(typeof AllowedQueryParamPage)[keyof typeof AllowedQueryParamPage];

View File

@@ -0,0 +1,32 @@
/**
* Named type for strings that should be hexadecimal numbers.
*
* Currently *does not offer type safety*, just documentation value; see
* https://github.com/microsoft/TypeScript/issues/202 and
* https://github.com/microsoft/TypeScript/issues/41160 for TS features that
* would give this some more teeth. Right now, any `string` can be assigned
* into a variable of type `HexString` and vice versa.
*/
export type HexString = string;
/**
* Named type for strings that should be domain names.
*
* Currently *does not offer type safety*, just documentation value; see
* https://github.com/microsoft/TypeScript/issues/202 and
* https://github.com/microsoft/TypeScript/issues/41160 for TS features that
* would give this some more teeth. Right now, any `string` can be assigned
* into a variable of type `DomainName` and vice versa.
*/
export type DomainName = string;
/**
* Named type for strings that should be URIs.
*
* Currently *does not offer type safety*, just documentation value; see
* https://github.com/microsoft/TypeScript/issues/202 and
* https://github.com/microsoft/TypeScript/issues/41160 for TS features that
* would give this some more teeth. Right now, any `string` can be assigned
* into a variable of type `URI` and vice versa.
*/
export type URI = string;

View File

@@ -0,0 +1,11 @@
import { KeyringView } from '@epf-wallet/keyring-controller';
export type Keyring = {
id: string | null;
addresses: string[];
};
export interface KeyringMetadata {
view: KeyringView | null;
source: 'import' | 'internal';
}

View File

@@ -0,0 +1,42 @@
import { HexString } from './common';
export type NetworkFamily = 'EVM';
/**
* Base asset of the network
* Should be structurally compatible with FungibleAsset
*/
export type NetworkBaseAsset = {
symbol: string;
name: string;
decimals: number;
contractAddress?: string;
image?: string;
};
/**
* Represents a cryptocurrency network; these can potentially be L1 or L2.
*/
export type Network = {
// two Networks must never share a name.
name: string;
baseAsset: NetworkBaseAsset;
family: NetworkFamily;
chainID?: string;
provider: string;
bundler: string;
entryPointAddress: string;
};
/**
* An EVM-style network which *must* include a chainID.
*/
export type EVMNetwork = Network & {
chainID: string;
family: 'EVM';
};
export type AddressOnNetwork = {
address: HexString;
network: EVMNetwork;
};

View File

@@ -0,0 +1 @@
export type SCWImplementation = string;

View File

@@ -0,0 +1,222 @@
import { hexlify, toUtf8Bytes, toUtf8String } from 'ethers/lib/utils.js';
import browser from 'webextension-polyfill';
import { AllowedQueryParamPageType } from '../types/chrome-messages';
import { SiweMessage } from 'siwe';
import { HexString } from '../types/common';
import {
EthersTransactionRequest,
PermissionRequest,
} from '../services/provider-bridge';
import {
EIP1193Error,
EIP1193_ERROR_CODES,
} from '../../Content/window-provider/eip-1193';
/**
* Encode an unknown input as JSON, special-casing bigints and undefined.
*
* @param input an object, array, or primitive to encode as JSON
*/
export function encodeJSON(input: unknown): string {
return JSON.stringify(input, (_, value) => {
if (typeof value === 'bigint') {
return { B_I_G_I_N_T: value.toString() };
}
return value;
});
}
/**
* Decode a JSON string, as encoded by `encodeJSON`, including bigint support.
* Note that the functions aren't invertible, as `encodeJSON` discards
* `undefined`.
*
* @param input a string output from `encodeJSON`
*/
export function decodeJSON(input: string): unknown {
return JSON.parse(input, (_, value) =>
value !== null && typeof value === 'object' && 'B_I_G_I_N_T' in value
? BigInt(value.B_I_G_I_N_T)
: value
);
}
/**
* Returns a 0x-prefixed hexadecimal representation of a number or string chainID
* while also handling cases where an already hexlified chainID is passed in.
*/
export function toHexChainID(chainID: string | number): string {
if (typeof chainID === 'string' && chainID.startsWith('0x')) {
return chainID.toLowerCase();
}
return `0x${BigInt(chainID).toString(16)}`;
}
export default async function showExtensionPopup(
url: AllowedQueryParamPageType
): Promise<browser.Windows.Window> {
const { left = 0, top, width = 1920 } = await browser.windows.getCurrent();
const popupWidth = 384;
const popupHeight = 628;
return browser.windows.create({
url: `${browser.runtime.getURL('popup.html')}#${url}`,
type: 'popup',
left: left + width - popupWidth,
top,
width: popupWidth,
height: popupHeight,
focused: true,
});
}
export type EIP191Data = string;
// spec found https://eips.ethereum.org/EIPS/eip-4361
export interface EIP4361Data {
/**
* The message string that was parsed to produce this EIP-4361 data.
*/
unparsedMessageData: string;
domain: string;
address: string;
version: string;
chainId: number;
nonce: string;
expiration?: string;
statement?: string;
}
type EIP191SigningData = {
messageType: 'eip191';
signingData: EIP191Data;
};
type EIP4361SigningData = {
messageType: 'eip4361';
signingData: EIP4361Data;
};
export type MessageSigningData = EIP191SigningData | EIP4361SigningData;
const checkEIP4361: (message: string) => EIP4361Data | undefined = (
message
) => {
try {
const siweMessage = new SiweMessage(message);
return {
unparsedMessageData: message,
domain: siweMessage.domain,
address: siweMessage.address,
statement: siweMessage.statement,
version: siweMessage.version,
chainId: siweMessage.chainId,
expiration: siweMessage.expirationTime,
nonce: siweMessage.nonce,
};
} catch (err) {
// console.error(err)
}
return undefined;
};
/**
* Takes a string and parses the string into a ExpectedSigningData Type
*
* EIP4361 standard can be found https://eips.ethereum.org/EIPS/eip-4361
*/
export function parseSigningData(signingData: string): MessageSigningData {
let normalizedData = signingData;
// Attempt to normalize hex signing data to a UTF-8 string message. If the
// signing data is <= 32 bytes long, assume it's a hash or other short data
// that need not be normalized to a regular UTF-8 string.
if (signingData.startsWith('0x') && signingData.length > 66) {
let possibleMessageString: string | undefined;
try {
possibleMessageString = toUtf8String(signingData);
// Below, if the signing data is not a valid UTF-8 string, we move on
// with an undefined possibleMessageString.
// eslint-disable-next-line no-empty
} catch (err) {}
// If the hex was parsable as UTF-8 and re-converting to bytes in a hex
// string produces the identical output, accept it as a valid string and
// set the interpreted data to the UTF-8 string.
if (
possibleMessageString !== undefined &&
hexlify(toUtf8Bytes(possibleMessageString)) === signingData.toLowerCase()
) {
normalizedData = possibleMessageString;
}
}
const data = checkEIP4361(normalizedData);
if (data) {
return {
messageType: 'eip4361',
signingData: data,
};
}
return {
messageType: 'eip191',
signingData: normalizedData,
};
}
function normalizeHexAddress(address: any) {
var addressString =
typeof address === 'object' && !('toLowerCase' in address)
? address.toString('hex')
: address;
var noPrefix = addressString.replace(/^0x/, '');
var even = noPrefix.length % 2 === 0 ? noPrefix : '0' + noPrefix;
return '0x' + Buffer.from(even, 'hex').toString('hex');
}
export function sameEVMAddress(
address1: string | Buffer | undefined | null,
address2: string | Buffer | undefined | null
): boolean {
if (
typeof address1 === 'undefined' ||
typeof address2 === 'undefined' ||
address1 === null ||
address2 === null
) {
return false;
}
return normalizeHexAddress(address1) === normalizeHexAddress(address2);
}
export function checkPermissionSign(
walletAddress: HexString,
enablingPermission: PermissionRequest
): void {
if (
enablingPermission.state !== 'allow' ||
!sameEVMAddress(walletAddress, enablingPermission.accountAddress)
) {
throw new EIP1193Error(EIP1193_ERROR_CODES.unauthorized);
}
}
export function checkPermissionSignTransaction(
transactionRequest: EthersTransactionRequest,
enablingPermission: PermissionRequest
): void {
if (typeof transactionRequest.chainId !== 'undefined') {
if (
toHexChainID(transactionRequest.chainId) !==
toHexChainID(enablingPermission.chainID)
) {
throw new EIP1193Error(EIP1193_ERROR_CODES.unauthorized);
}
}
if (
enablingPermission.state !== 'allow' ||
!sameEVMAddress(transactionRequest.from, enablingPermission.accountAddress)
) {
throw new EIP1193Error(EIP1193_ERROR_CODES.unauthorized);
}
}

View File

@@ -0,0 +1,89 @@
/// <reference types="chrome"/>
import {
AA_EXTENSION_CONFIG,
EXTERNAL_PORT_NAME,
PROVIDER_BRIDGE_TARGET,
WINDOW_PROVIDER_TARGET,
} from '../Background/constants';
const windowOriginAtLoadTime = window.location.origin;
export function connectProviderBridge(): void {
const port = chrome.runtime.connect({ name: EXTERNAL_PORT_NAME });
window.addEventListener('message', (event) => {
if (
event.origin === windowOriginAtLoadTime && // we want to recieve msgs only from the in-page script
event.source === window && // we want to recieve msgs only from the in-page script
event.data.target === PROVIDER_BRIDGE_TARGET
) {
// if dapp wants to connect let's grab its details
if (
event.data.request.method === 'eth_requestAccounts' ||
event.data.request.method === 'eth_accounts'
) {
const faviconElements: NodeListOf<HTMLLinkElement> =
window.document.querySelectorAll("link[rel*='icon']");
const largestFavicon = [...faviconElements].sort((el) =>
parseInt(el.sizes?.toString().split('x')[0], 10)
)[0];
const faviconUrl = largestFavicon?.href ?? '';
const { title } = window.document ?? '';
event.data.request.params.push(title, faviconUrl);
}
console.log(
`%c content: inpage >>> background: ${JSON.stringify(event.data)}`,
'background: #bada55; color: #222'
);
port.postMessage(event.data);
}
});
port.onMessage.addListener((data) => {
console.log(
`%c content: background >>> inpage: ${JSON.stringify(data)}`,
'background: #222; color: #bada55'
);
window.postMessage(
{
...data,
target: WINDOW_PROVIDER_TARGET,
},
windowOriginAtLoadTime
);
});
// let's grab the internal config that also has chainId info
// we send the config on port initialization, but that needs to
// be as fast as possible, so we omit the chainId information
// from that payload to save the service call
port.postMessage({
request: { method: AA_EXTENSION_CONFIG, origin: windowOriginAtLoadTime },
});
}
function injectWindowProvider(): void {
if (document.contentType !== 'text/html') return;
try {
const container = document.head || document.documentElement;
const scriptTag = document.createElement('script');
// this makes the script loading blocking which is good for us
// bc we want to load before anybody has a chance to temper w/ the window obj
scriptTag.setAttribute('async', 'false');
scriptTag.src = chrome.runtime.getURL('ex_injectScript.bundle.js');
container.insertBefore(scriptTag, container.children[0]);
} catch (e) {
throw new Error(
`aa-account: oh nos the content-script failed to initilaize the window provider.
${e}
It's time to be dead...🗡`
);
}
}
injectWindowProvider();
connectProviderBridge();

View File

@@ -0,0 +1,114 @@
import {
WindowRequestEvent,
WindowListener,
Window,
WindowEthereum,
WalletProvider,
} from './types';
import AAWindowProvider from './window-provider';
Object.defineProperty(window, 'aa-provider', {
value: new AAWindowProvider({
postMessage: (data: WindowRequestEvent) =>
window.postMessage(data, window.location.origin),
addEventListener: (fn: WindowListener) =>
window.addEventListener('message', fn, false),
removeEventListener: (fn: WindowListener) =>
window.removeEventListener('message', fn, false),
origin: window.location.origin,
}),
writable: false,
configurable: false,
});
if (!(window as Window).walletRouter) {
Object.defineProperty(window, 'walletRouter', {
value: {
currentProvider: (window as Window)['aa-provider'],
lastInjectedProvider: window.ethereum,
providers: [
// deduplicate the providers array: https://medium.com/@jakubsynowiec/unique-array-values-in-javascript-7c932682766c
...new Set([
(window as Window)['aa-provider'],
// eslint-disable-next-line no-nested-ternary
...(window.ethereum
? // let's use the providers that has already been registered
// This format is used by coinbase wallet
Array.isArray(window.ethereum.providers)
? [...window.ethereum.providers, window.ethereum]
: [window.ethereum]
: []),
(window as Window)['aa-provider'],
]),
],
getProviderInfo(provider: WalletProvider) {
return (
provider.providerInfo || {
label: 'Injected Provider',
injectedNamespace: 'ethereum',
}
);
},
setSelectedProvider() {},
addProvider(newProvider: WalletProvider) {
if (!this.providers.includes(newProvider)) {
this.providers.push(newProvider);
}
this.lastInjectedProvider = newProvider;
},
},
writable: false,
configurable: false,
});
}
let cachedWindowEthereumProxy: WindowEthereum;
let cachedCurrentProvider: WalletProvider;
Object.defineProperty(window, 'ethereum', {
get() {
const walletRouter = (window as Window).walletRouter;
if (!walletRouter) return undefined;
if (
cachedWindowEthereumProxy &&
cachedCurrentProvider === walletRouter.currentProvider
) {
return cachedWindowEthereumProxy;
}
cachedWindowEthereumProxy = new Proxy(walletRouter.currentProvider, {
get(target, prop, receiver) {
if (
walletRouter &&
!(prop in walletRouter.currentProvider) &&
prop in walletRouter
) {
// Uniswap MM connector checks the providers array for the MM provider and forces to use that
// https://github.com/Uniswap/web3-react/blob/main/packages/metamask/src/index.ts#L57
// as a workaround we need to remove this list for uniswap so the actual provider change can work after reload.
// The same is true for `galaxy.eco`
if (
(window.location.href.includes('app.uniswap.org') ||
window.location.href.includes('kwenta.io') ||
window.location.href.includes('galxe.com')) &&
prop === 'providers'
) {
return null;
}
// let's publish the api of `window.walletRouter` also on `window.ethereum` for better discoverability
// @ts-expect-error ts accepts symbols as index only from 4.4
// https://stackoverflow.com/questions/59118271/using-symbol-as-object-key-type-in-typescript
return window.walletRouter[prop];
}
return Reflect.get(target, prop, receiver);
},
});
cachedCurrentProvider = walletRouter.currentProvider;
return cachedWindowEthereumProxy;
},
});

View File

@@ -0,0 +1,101 @@
import { EIP1193_ERROR_CODES } from './window-provider/eip-1193';
export type WalletProvider = {
providerInfo?: {
label: string;
injectedNamespace: string;
iconURL: string;
identityFlag?: string;
checkIdentity?: () => boolean;
};
on: (
eventName: string | symbol,
listener: (...args: unknown[]) => void
) => unknown;
removeListener: (
eventName: string | symbol,
listener: (...args: unknown[]) => void
) => unknown;
[optionalProps: string]: unknown;
};
export type WindowEthereum = WalletProvider & {
isMetaMask?: boolean;
autoRefreshOnNetworkChange?: boolean;
};
export interface Window {
'aa-provider'?: {};
walletRouter?: {
currentProvider: WalletProvider;
providers: WalletProvider[];
shouldSetTallyForCurrentProvider: (
shouldSetTally: boolean,
shouldReload?: boolean
) => void;
getProviderInfo: (
provider: WalletProvider
) => WalletProvider['providerInfo'];
addProvider: (newProvider: WalletProvider) => void;
};
ethereum?: WindowEthereum;
oldEthereum?: WindowEthereum;
}
export type RPCRequest = {
method: string;
params: Array<unknown>; // This typing is required by ethers.js but is not EIP-1193 compatible
};
export type WindowRequestEvent = {
id: string;
target: unknown;
request: RPCRequest;
};
export type WindowResponseEvent = {
origin: string;
source: unknown;
data: { id: string; target: string; result: unknown };
};
export type WindowListener = (event: WindowResponseEvent) => void;
export type PortResponseEvent = {
id: string;
jsonrpc: '2.0';
result: unknown;
};
export type EIP1193ErrorPayload =
| (typeof EIP1193_ERROR_CODES)[keyof typeof EIP1193_ERROR_CODES] & {
data?: unknown;
};
export type WindowTransport = {
postMessage: (data: WindowRequestEvent) => void;
addEventListener: (listener: WindowListener) => void;
removeEventListener: (listener: WindowListener) => void;
origin: string;
};
export type PortListenerFn = (callback: unknown, ...params: unknown[]) => void;
export type PortListener = (listener: PortListenerFn) => void;
export type PortTransport = {
postMessage: (data: unknown) => void;
addEventListener: PortListener;
removeEventListener: PortListener;
origin: string;
};
export type ProviderTransport = WindowTransport | PortTransport;
export type EthersSendCallback = (error: unknown, response: unknown) => void;
export type AAExtensionConfigPayload = {
method: 'aa-extension_getConfig';
chainId?: string;
shouldReload?: boolean;
[prop: string]: unknown;
};

View File

@@ -0,0 +1,83 @@
// https://eips.ethereum.org/EIPS/eip-1193#request
import { isNumber, isObject, isString } from './runtime-type-checks';
export type RequestArgument = {
readonly method: string;
readonly params?: Array<unknown>;
};
export const EIP1193_ERROR_CODES = {
userRejectedRequest: {
code: 4001,
message: 'The user rejected the request.',
},
unauthorized: {
code: 4100,
message:
'The requested method and/or account has not been authorized by the user.',
},
unsupportedMethod: {
code: 4200,
message: 'The Provider does not support the requested method.',
},
disconnected: {
// 4900 is intended to indicate that the Provider is disconnected from all chains
code: 4900,
message: 'The Provider is disconnected from all chains.',
},
chainDisconnected: {
// 4901 is intended to indicate that the Provider is disconnected from a specific chain only.
// In other words, 4901 implies that the Provider is connected to other chains, just not the requested one.
code: 4901,
message: 'The Provider is not connected to the requested chain.',
},
rpcErrorNotParsed: {
code: 5000,
message:
"The budler RPC response coudn't be parsed properly, check bundler logs",
},
methodNotSupported: {
code: 4004,
message: 'The method is not supported',
},
} as const;
export type EIP1193ErrorPayload =
| (typeof EIP1193_ERROR_CODES)[keyof typeof EIP1193_ERROR_CODES] & {
data?: unknown;
};
export type EIP1193ErrorCodeNumbers = Pick<
(typeof EIP1193_ERROR_CODES)[keyof typeof EIP1193_ERROR_CODES],
'code'
>;
export class EIP1193Error extends Error {
constructor(public eip1193Error: EIP1193ErrorPayload) {
super(eip1193Error.message);
}
toJSON(): unknown {
return this.eip1193Error;
}
}
export function isEIP1193ErrorCodeNumber(
code: unknown
): code is EIP1193ErrorCodeNumbers {
return (
isNumber(code) &&
Object.values(EIP1193_ERROR_CODES)
.map((e) => e.code as number)
.includes(code)
);
}
export function isEIP1193Error(arg: unknown): arg is EIP1193ErrorPayload {
return (
isObject(arg) &&
isNumber(arg.code) &&
isEIP1193ErrorCodeNumber(arg.code) &&
isString(arg.message)
);
}

View File

@@ -0,0 +1,253 @@
import { EventEmitter } from 'events';
import {
PROVIDER_BRIDGE_TARGET,
WINDOW_PROVIDER_TARGET,
} from '../../Background/constants';
import {
EthersSendCallback,
ProviderTransport,
WalletProvider,
} from '../types';
import { isEIP1193Error, RequestArgument } from './eip-1193';
import {
isObject,
isPortResponseEvent,
isWindowResponseEvent,
} from './runtime-type-checks';
const METAMASK_STATE_MOCK = {
accounts: null,
isConnected: false,
isUnlocked: false,
initialized: false,
isPermanentlyDisconnected: false,
};
export default class AAWindowProvider extends EventEmitter {
// TODO: This should come from the background with onConnect when any interaction is initiated by the dApp.
// onboard.js relies on this, or uses a deprecated api. It seemed to be a reasonable workaround for now.
chainId = '0x5';
selectedAddress: string | undefined;
connected = false;
isAAExtension = true;
isMetaMask = false;
isWeb3 = true;
requestResolvers = new Map<
string,
{
resolve: (value: unknown) => void;
reject: (value: unknown) => void;
sendData: {
id: string;
target: string;
request: Required<RequestArgument>;
};
}
>();
_state?: typeof METAMASK_STATE_MOCK;
providerInfo = {
label: 'AA Chrome Extension!',
injectedNamespace: 'AA-Chrome-Extension',
identityFlag: 'isAAExtension',
checkIdentity: (provider: WalletProvider) =>
!!provider && !!provider.isAAExtension,
} as const;
constructor(public transport: ProviderTransport) {
super();
/**
* Some dApps may have a problem with preserving a reference to a provider object.
* This is the result of incorrect assignment.
* In such a case, the object this is undefined
* which results in an error in the execution of the request.
* The request function should always have a provider object set.
*/
// this.transport.addEventListener(internalListener);
this.transport.addEventListener(this.internalBridgeListener);
}
private internalBridgeListener = (event: unknown): void => {
let id: string;
let result: unknown;
if (isWindowResponseEvent(event)) {
if (
event.origin !== this.transport.origin || // filter to messages claiming to be from the provider-bridge script
event.source !== window || // we want to recieve messages only from the provider-bridge script
event.data.target !== WINDOW_PROVIDER_TARGET
) {
return;
}
({ id, result } = event.data);
} else if (isPortResponseEvent(event)) {
({ id, result } = event);
} else {
return;
}
const requestResolver = this.requestResolvers.get(id);
if (!requestResolver) return;
const { sendData, reject, resolve } = requestResolver;
this.requestResolvers.delete(sendData.id);
const { method: sentMethod } = sendData.request;
if (isEIP1193Error(result)) {
reject(result);
}
// let's emit connected on the first successful response from background
if (!this.connected) {
this.connected = true;
this.emit('connect', { chainId: this.chainId });
}
switch (sentMethod) {
case 'wallet_switchEthereumChain':
case 'wallet_addEthereumChain':
// null result indicates successful chain change https://eips.ethereum.org/EIPS/eip-3326#specification
if (result === null) {
this.handleChainIdChange(
(sendData.request.params[0] as { chainId: string }).chainId
);
}
break;
case 'eth_chainId':
case 'net_version':
if (
typeof result === 'string' &&
Number(this.chainId) !== Number(result)
) {
this.handleChainIdChange(result);
}
break;
case 'eth_accounts':
case 'eth_requestAccounts':
if (Array.isArray(result) && result.length !== 0) {
this.handleAddressChange(result);
}
break;
default:
break;
}
resolve(result);
};
// deprecated EIP-1193 method
async enable(): Promise<unknown> {
return this.request({ method: 'eth_requestAccounts' });
}
isConnected(): boolean {
return this.connected;
}
// deprecated EIP1193 send for web3-react injected provider Send type:
// https://github.com/NoahZinsmeister/web3-react/blob/d0b038c748a42ec85641a307e6c588546d86afc2/packages/injected-connector/src/types.ts#L4
send(method: string, params: Array<unknown>): Promise<unknown>;
// deprecated EIP1193 send for ethers.js Web3Provider > ExternalProvider:
// https://github.com/ethers-io/ethers.js/blob/73a46efea32c3f9a4833ed77896a216e3d3752a0/packages/providers/src.ts/web3-provider.ts#L19
send(
request: RequestArgument,
callback: (error: unknown, response: unknown) => void
): void;
send(
methodOrRequest: string | RequestArgument,
paramsOrCallback: Array<unknown> | EthersSendCallback
): Promise<unknown> | void {
if (
typeof methodOrRequest === 'string' &&
typeof paramsOrCallback !== 'function'
) {
return this.request({
method: methodOrRequest,
params: paramsOrCallback,
});
}
if (isObject(methodOrRequest) && typeof paramsOrCallback === 'function') {
return this.sendAsync(methodOrRequest, paramsOrCallback);
}
return Promise.reject(new Error('Unsupported function parameters'));
}
// deprecated EIP-1193 method
// added as some dapps are still using it
sendAsync(
request: RequestArgument & { id?: number; jsonrpc?: string },
callback: (error: unknown, response: unknown) => void
): Promise<unknown> | void {
return this.request(request).then(
(response) =>
callback(null, {
result: response,
id: request.id,
jsonrpc: request.jsonrpc,
}),
(error) => callback(error, null)
);
}
// Provider-wide counter for requests.
private requestID = 0n;
request = (arg: RequestArgument): Promise<unknown> => {
const { method, params = [] } = arg;
if (typeof method !== 'string') {
return Promise.reject(new Error(`unsupported method type: ${method}`));
}
const sendData = {
id: this.requestID.toString(),
target: PROVIDER_BRIDGE_TARGET,
request: {
method,
params,
},
};
this.requestID += 1n;
this.transport.postMessage(sendData);
return new Promise<unknown>((resolve, reject) => {
this.requestResolvers.set(sendData.id, {
resolve,
reject,
sendData,
});
});
};
handleChainIdChange(chainId: string): void {
this.chainId = chainId;
this.emit('chainChanged', chainId);
this.emit('networkChanged', Number(chainId).toString());
}
handleAddressChange(address: Array<string>): void {
if (this.selectedAddress !== address[0]) {
// eslint-disable-next-line prefer-destructuring
this.selectedAddress = address[0];
this.emit('accountsChanged', address);
}
}
}

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