├── .editorconfig ├── .gitignore ├── DevNotes.md ├── LICENSE ├── Privacy.md ├── README.md ├── guide ├── 00-start.png ├── 01-configure-accounts-0.png ├── 01-configure-accounts-1.png ├── 01-configure-accounts-2.png ├── 01-configure-accounts-3.png ├── 01-configure-accounts-4.png ├── 01-configure-accounts-5.png ├── 02-activate-account-0.png ├── 02-activate-account-1.png ├── 03-configure-backend-0.png ├── 03-configure-backend-1.png ├── 03-configure-backend-2.png ├── 04-activate-backend-0.png ├── 04-activate-backend-1.png ├── 05-home-0.png ├── 05-home-1.png ├── 06-override-balance-0.png ├── 06-override-balance-1.png ├── 06-override-balance-2.png ├── 07-override-utxos-0.png ├── 07-override-utxos-1.png ├── 08-logs-0.png ├── 08-logs-1.png └── Readme.md ├── plutip ├── .gitignore ├── README.md ├── env.example ├── flake.lock ├── flake.nix └── screenshots │ ├── 01-plutip.png │ ├── 02-ogmios.png │ ├── 03-kupo.png │ ├── 04-fundAda.png │ ├── 05-info.png │ └── extension.png └── webext ├── .gitignore ├── CHANGELOG.md ├── build.config.js ├── build.js ├── dev-keys └── chrome-dev-key.pem ├── package-lock.json ├── package.json ├── src ├── background │ ├── background.js │ └── index.html ├── content-script │ ├── index.ts │ └── trampoline.ts ├── lib │ ├── CIP30 │ │ ├── Backend.ts │ │ ├── Backends │ │ │ ├── Blockfrost.ts │ │ │ └── OgmiosKupo.ts │ │ ├── ErrorTypes.ts │ │ ├── Icon.d.ts │ │ ├── Icon.js │ │ ├── Network.ts │ │ ├── State │ │ │ ├── Store.ts │ │ │ ├── Types.ts │ │ │ └── index.ts │ │ ├── Types.ts │ │ ├── Utils.ts │ │ ├── WalletApi.ts │ │ ├── WalletApiInternal.ts │ │ └── index.ts │ ├── CSLIterator.ts │ ├── Utils.ts │ ├── Wallet.ts │ └── Web │ │ ├── Logger.ts │ │ ├── Storage.ts │ │ └── WebextBridge.ts ├── manifest.json ├── popup │ ├── index.html │ ├── lib │ │ ├── Index.tsx │ │ ├── OptionButtons.tsx │ │ ├── State.ts │ │ ├── pages │ │ │ ├── Accounts.tsx │ │ │ ├── Logs.tsx │ │ │ ├── Network.tsx │ │ │ ├── Overview.tsx │ │ │ └── ShortenedLabel.tsx │ │ └── utils.ts │ ├── static │ │ ├── fonts │ │ │ ├── Inter-Bold.ttf │ │ │ ├── Inter-Regular.ttf │ │ │ ├── JetBrainsMono-Bold.ttf │ │ │ └── JetBrainsMono-Regular.ttf │ │ ├── icon.svg │ │ ├── icons │ │ │ ├── add.svg │ │ │ ├── close.svg │ │ │ ├── copy.svg │ │ │ ├── delete.svg │ │ │ ├── edit.svg │ │ │ ├── expand-down.svg │ │ │ ├── expand-left.svg │ │ │ ├── expand-right.svg │ │ │ ├── expand-up.svg │ │ │ ├── hidden.svg │ │ │ ├── save.svg │ │ │ └── visible.svg │ │ ├── logo.png │ │ └── normalize.css │ ├── styles.scss │ ├── trampoline.html │ └── trampoline.js └── public │ ├── icon-128x128.png │ ├── icon-256x256.png │ ├── icon-512x512.png │ ├── icon-72x72.png │ ├── icon-96x96.png │ └── icon.svg ├── tests ├── README.md └── example.spec.js ├── tsconfig.json └── wasmLoader.js /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # editorconfig.org 4 | 5 | root = true 6 | 7 | [*] 8 | 9 | indent_style = space 10 | indent_size = 2 11 | 12 | end_of_line = lf 13 | charset = utf-8 14 | trim_trailing_whitespace = true 15 | insert_final_newline = true 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .vscode 3 | -------------------------------------------------------------------------------- /DevNotes.md: -------------------------------------------------------------------------------- 1 | # Developer Notes 2 | 3 | ### Node Polyfills for ESBuild 4 | 5 | **History** 6 | 7 | - We are currently using https://github.com/imranbarbhuiya/esbuild-plugins-node-modules-polyfill \ 8 | As of this writing (Oct 27, 2023), we are seeing active development on 9 | this repo; last commit was 4 days ago. 10 | - We tried the following as well: 11 | - https://github.com/remorses/esbuild-plugins \ 12 | As per this issue it's known to be broken: https://github.com/remorses/esbuild-plugins/issues/41 13 | - https://www.npmjs.com/package/esbuild-plugin-polyfill-node \ 14 | We were getting the issue of `eval()` not allowed in the extension 15 | context due to security reasons, when we were trying to enable the 16 | crypto polyfill. 17 | 18 | **Notes** 19 | 20 | - The `imranbarbhuiya/esbuild-plugins-node-modules-polyfill` library wraps 21 | the JSPM core libraries. But the ESBuild wrapper itself is maintained by a 22 | small group of people. This too could go out-of-date/unmaintained some day. 23 | 24 | Keep this in mind if issues with `eval()`s or failed imports of node 25 | libraries start popping up. 26 | 27 | ### CTL Dependency 28 | 29 | - We need to specify CTL as a dependency in `packages.dhall` as well as 30 | `package.json`. 31 | - The former is needed for importing CTL from Purescript. 32 | - The latter is needed for `esbuild` to resolve the JS dependencies used by CTL for bundling. 33 | - This works because when we specify CTL as a dependency 34 | in `package.json`, all the transitive dependencies get pulled in to 35 | `node_modules` and become available for `esbuild`. 36 | - **Important** Make sure the versions of CTL in both `packages.dhall` and 37 | `package.json` are the same. 38 | - Currently we are using the git commit SHA of the latest commit in 39 | Purescript 0.15 branch. 40 | - TODO: Once this is merged into master, use the commit SHA of master. 41 | 42 | ### Removal Of CTL Dependency 43 | 44 | - CTL was giving `maximum call stack size exceeded` error when used inside Chrome's service workers. 45 | - This issue is specific to Chrome. 46 | - Here's a bug report in Chromium. 47 | They claim to have resolved it, but there is a post after the bug is marked as resolved, saying the issue is still present. 48 | https://bugs.chromium.org/p/chromium/issues/detail?id=252492 49 | - Here's another person's report. They were trying to build a game that and ran into the same issue. 50 | This was also a long time after the bug report was marked as resolved. 51 | The call stack size seemed unreasonably small. 52 | https://www.construct.net/en/forum/construct-3/general-discussion-7/maximum-call-stack-size-using-154930 53 | - Purescript code has >20 lines of imports which all get compiled down to ESM imports. 54 | Both ESBuild and Webpack transpile these imports during bundling to 55 | function calls, so to concatenate multiple files into one output file 56 | without having scope conflicts. 57 | The large number of imports seem to be causing the call stack size issue. 58 | - Plus, the added overhead of working with Purescript, we decided to eliminate 59 | the CTL dependency and re-write the small part of CTL we need to implement 60 | CIP30 ourselves, in JS. 61 | This will also make the build process much simpler, and reduce the barrier 62 | for entry for future contributors. 63 | 64 | ### Manual extension loading for development 65 | 66 | - Chrome: 67 | - Go to extensions page, and turn the `Developer Mode` switch on. 68 | - Drag and drop `artefacts/.crx` into the extensions page. 69 | - If the drag and drop seem to be not working: 70 | - I experienced this issue on Linux (Gnome Wayland). 71 | - Try dragging it onto the page, if no overlay appears, drop it back where you dragged it from. 72 | - Repeat this a few times. Should get it working after 2-3 tries. 73 | - If you drop without the overlay visible, the file will get downloaded instead of getting installed. 74 | When you click on the download entry, it will show you `CRX_REQUIRED_PROOF_MISSING` error. 75 | - Firefox: 76 | - Go to extensions page, click the settings icon, select Debug Addons. 77 | - Click Load Temporary Addon and open the `.zip` or the `.xpi` file. 78 | - Note: Firefox doesn't support drag and drop installation of extensions 79 | for development. 80 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Your Company 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Privacy.md: -------------------------------------------------------------------------------- 1 | # Privacy Policy 2 | 3 | We don't collect any user data. 4 | 5 | No data is sent to the network, except for: 6 | - Requests made to network backends to satisfy CIP 30 calls: 7 | - Transaction details when a transaction is submitted. 8 | - Account details when querying balance, utxos and collateral. 9 | - Any API keys configured for the network backend. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Cardano Dev Wallet 2 | 3 | 4 | 5 | A browser extension that implements [CIP-30](https://cips.cardano.org/cip/CIP-30/) Cardano wallet connector with a UI that is more convenient for developers than mainstream user-oriented wallets. 6 | 7 | - Uses Blockfrost or Ogmios+Kupo as the backend 8 | - Works with custom Cardano networks 9 | - Allows to inspect CIP-30 method logs 10 | - Allows to override CIP-30 API endpoint responses 11 | - Allows to load private keys or mnemonics 12 | 13 | [![Mozilla Add-on Users](https://img.shields.io/amo/users/cardano-dev-wallet?logo=firefox&label=Install%20for%20Firefox)](https://addons.mozilla.org/en-US/firefox/addon/cardano-dev-wallet/) [![Chrome Web Store Users](https://img.shields.io/chrome-web-store/users/afnjoihjkimddgemefealgkefejaigme?logo=chrome&label=Install%20for%20Chrome)](https://chromewebstore.google.com/detail/cardano-dev-wallet/afnjoihjkimddgemefealgkefejaigme) 14 | 15 | 16 | 17 | ## User Guide 18 | 19 | See [guide/Readme.md](guide/Readme.md) 20 | 21 | ## Workflow 22 | 23 | `cd webext` before issuing any of the commands below. 24 | 25 | ### Develop UI 26 | - Run: `node build.js` 27 | - Open `http://localhost:8000/` in the browser. 28 | - This will run the extension as a simple webpage. 29 | - No webextension features will be available, like connecting to a dApp. 30 | - Just for faster feedback cycles when tweaking the UI. 31 | 32 | ### Develop WebExtension 33 | - Run: `node build.js --run` 34 | - Chrome will launch with the extension loaded. 35 | - Configure the network and accounts to start developing. 36 | - Any changes to the source code will auto build & reload the extension. 37 | 38 | ### Bundling 39 | - Run: `node build.js --bundle` 40 | 41 | ### More Options 42 | - Run: `node build.js --help` to see all the available options. 43 | 44 | ## Plutip 45 | 46 | See [plutip/README.md](plutip/README.md) to see how to configure Plutip for use with the extension. 47 | 48 | ## Devloper Notes 49 | 50 | See [DevNotes.md](DevNotes.md) 51 | -------------------------------------------------------------------------------- /guide/00-start.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlabs-haskell/cardano-dev-wallet/e846a7c5d2747609d791d7d47d5423616718cb34/guide/00-start.png -------------------------------------------------------------------------------- /guide/01-configure-accounts-0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlabs-haskell/cardano-dev-wallet/e846a7c5d2747609d791d7d47d5423616718cb34/guide/01-configure-accounts-0.png -------------------------------------------------------------------------------- /guide/01-configure-accounts-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlabs-haskell/cardano-dev-wallet/e846a7c5d2747609d791d7d47d5423616718cb34/guide/01-configure-accounts-1.png -------------------------------------------------------------------------------- /guide/01-configure-accounts-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlabs-haskell/cardano-dev-wallet/e846a7c5d2747609d791d7d47d5423616718cb34/guide/01-configure-accounts-2.png -------------------------------------------------------------------------------- /guide/01-configure-accounts-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlabs-haskell/cardano-dev-wallet/e846a7c5d2747609d791d7d47d5423616718cb34/guide/01-configure-accounts-3.png -------------------------------------------------------------------------------- /guide/01-configure-accounts-4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlabs-haskell/cardano-dev-wallet/e846a7c5d2747609d791d7d47d5423616718cb34/guide/01-configure-accounts-4.png -------------------------------------------------------------------------------- /guide/01-configure-accounts-5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlabs-haskell/cardano-dev-wallet/e846a7c5d2747609d791d7d47d5423616718cb34/guide/01-configure-accounts-5.png -------------------------------------------------------------------------------- /guide/02-activate-account-0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlabs-haskell/cardano-dev-wallet/e846a7c5d2747609d791d7d47d5423616718cb34/guide/02-activate-account-0.png -------------------------------------------------------------------------------- /guide/02-activate-account-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlabs-haskell/cardano-dev-wallet/e846a7c5d2747609d791d7d47d5423616718cb34/guide/02-activate-account-1.png -------------------------------------------------------------------------------- /guide/03-configure-backend-0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlabs-haskell/cardano-dev-wallet/e846a7c5d2747609d791d7d47d5423616718cb34/guide/03-configure-backend-0.png -------------------------------------------------------------------------------- /guide/03-configure-backend-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlabs-haskell/cardano-dev-wallet/e846a7c5d2747609d791d7d47d5423616718cb34/guide/03-configure-backend-1.png -------------------------------------------------------------------------------- /guide/03-configure-backend-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlabs-haskell/cardano-dev-wallet/e846a7c5d2747609d791d7d47d5423616718cb34/guide/03-configure-backend-2.png -------------------------------------------------------------------------------- /guide/04-activate-backend-0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlabs-haskell/cardano-dev-wallet/e846a7c5d2747609d791d7d47d5423616718cb34/guide/04-activate-backend-0.png -------------------------------------------------------------------------------- /guide/04-activate-backend-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlabs-haskell/cardano-dev-wallet/e846a7c5d2747609d791d7d47d5423616718cb34/guide/04-activate-backend-1.png -------------------------------------------------------------------------------- /guide/05-home-0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlabs-haskell/cardano-dev-wallet/e846a7c5d2747609d791d7d47d5423616718cb34/guide/05-home-0.png -------------------------------------------------------------------------------- /guide/05-home-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlabs-haskell/cardano-dev-wallet/e846a7c5d2747609d791d7d47d5423616718cb34/guide/05-home-1.png -------------------------------------------------------------------------------- /guide/06-override-balance-0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlabs-haskell/cardano-dev-wallet/e846a7c5d2747609d791d7d47d5423616718cb34/guide/06-override-balance-0.png -------------------------------------------------------------------------------- /guide/06-override-balance-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlabs-haskell/cardano-dev-wallet/e846a7c5d2747609d791d7d47d5423616718cb34/guide/06-override-balance-1.png -------------------------------------------------------------------------------- /guide/06-override-balance-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlabs-haskell/cardano-dev-wallet/e846a7c5d2747609d791d7d47d5423616718cb34/guide/06-override-balance-2.png -------------------------------------------------------------------------------- /guide/07-override-utxos-0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlabs-haskell/cardano-dev-wallet/e846a7c5d2747609d791d7d47d5423616718cb34/guide/07-override-utxos-0.png -------------------------------------------------------------------------------- /guide/07-override-utxos-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlabs-haskell/cardano-dev-wallet/e846a7c5d2747609d791d7d47d5423616718cb34/guide/07-override-utxos-1.png -------------------------------------------------------------------------------- /guide/08-logs-0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlabs-haskell/cardano-dev-wallet/e846a7c5d2747609d791d7d47d5423616718cb34/guide/08-logs-0.png -------------------------------------------------------------------------------- /guide/08-logs-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlabs-haskell/cardano-dev-wallet/e846a7c5d2747609d791d7d47d5423616718cb34/guide/08-logs-1.png -------------------------------------------------------------------------------- /guide/Readme.md: -------------------------------------------------------------------------------- 1 | # User Guide 2 | 3 | ## Table of Contents 4 | 5 | - [Home page](#home-page) 6 | - [Configuration](#configuration) 7 | - [Configure 8 | Accounts](#configure-accounts) 9 | - [Add Root Key](#add-root-key) 10 | - [Add Account](#add-account) 11 | - [Activate Account](#activate-account) 12 | - [Configure Network 13 | Backend](#configure-network-backend) 14 | - [Add Backend](#add-backend) 15 | - [Activate Backend](#activate-backend) 16 | - [Features](#features) 17 | - [Overview](#overview) 18 | - [Override Balance](#override-balance) 19 | - [Override UTxOs and Collateral 20 | UTxOs](#override-utxos-and-collateral-utxos) 21 | - [Logs of CIP 30 API 22 | Calls](#logs-of-cip-30-api-calls) 23 | 24 | 25 | ## Home page 26 | 27 | Click on the extension button in the toolbar to open the extension home 28 | page. 29 | 30 | Use the three vertically stacked buttons to select the active network: 31 | Mainnet, Preprod, Preview. 32 | 33 | Configuration is stored separately for each network. Accounts and 34 | network backends configured in one network won't be visible in another. 35 | 36 | ![](./00-start.png) 37 | 38 | # Configuration 39 | 40 | ## Configure Accounts 41 | 42 | > Note: At the moment, Cardano Dev Wallet only supports HD wallets. 43 | 44 | Click the **Accounts** link in the header to go to Accounts page. 45 | 46 | ![](./01-configure-accounts-0.png) 47 | 48 | ### Add Root Key 49 | 50 | Click **Add Wallet**. 51 | 52 | ![](./01-configure-accounts-1.png) 53 | 54 | Enter a name and the private key (xprv...) or mnemonics corresponding to 55 | the account. 56 | 57 | ![](./01-configure-accounts-2.png) 58 | 59 | Click **Save**. 60 | 61 | ![](./01-configure-accounts-3.png) 62 | 63 | ### Add Account 64 | 65 | Click **Add Account**. 66 | 67 | ![](./01-configure-accounts-4.png) 68 | 69 | Enter the account index. 70 | 71 | Click **Save**. 72 | 73 | ![](./01-configure-accounts-5.png) 74 | 75 | ### Activate Account 76 | 77 | Click **Options** button next to the account (not the wallet) to reveal 78 | the **Set Active** button. 79 | 80 | ![](./02-activate-account-0.png) 81 | 82 | Click **Set Active**. 83 | 84 | ![](./02-activate-account-1.png) 85 | 86 | The active account will be highlighted now. 87 | 88 | ## Configure Network Backend 89 | 90 | Click the **Network** link in the header to go to the Network page. 91 | 92 | ![](./03-configure-backend-0.png) 93 | 94 | ## Add Backend 95 | 96 | Click **Add** next to the **Backend Providers** heading. 97 | 98 | ![](./03-configure-backend-1.png) 99 | 100 | Fill in the details and click **Save**. 101 | 102 | ![](./03-configure-backend-2.png) 103 | 104 | ## Activate Backend 105 | 106 | Click **Options** to reveal the **Set Active** button. 107 | 108 | ![](./04-activate-backend-0.png) 109 | 110 | Click **Set Active**. 111 | 112 | The active backend will be highlighted now. 113 | 114 | ![](./04-activate-backend-1.png) 115 | 116 | # Features 117 | 118 | ## Overview 119 | 120 | See the details of active account in the home page. 121 | 122 | The active account and the balance is displayed at the top. 123 | 124 | ![](./05-home-0.png) 125 | 126 | Scroll down to reveal the list of **UTxOs** and **Collateral** UTxOs are 127 | displayed below. 128 | 129 | ![](./05-home-1.png) 130 | 131 | ## Override Balance 132 | 133 | Click the **Override** button below the Balance section. 134 | 135 | The balance display will change into an input box. The original balance 136 | will be displayed on the right side. 137 | 138 | ![](./06-override-balance-0.png) 139 | 140 | Type in the new balance. 141 | 142 | ![](./06-override-balance-1.png) 143 | 144 | Click **Save**. 145 | 146 | ![](./06-override-balance-2.png) 147 | 148 | Click **Edit Override** to update the value.\ 149 | Click **Reset** to remove the override. 150 | 151 | ## Override UTxOs and Collateral UTxOs 152 | 153 | Cardano Dev Wallet lets you remove some of the UTxOs from the list 154 | returned by `getUtxos()` and `getCollateral()` CIP30 API calls. 155 | 156 | This will help you simulate different cases for your dApps without 157 | having to switch accounts. 158 | 159 | Scroll down to the UTxOs list. 160 | 161 | ![](./07-override-utxos-0.png) 162 | 163 | Click on the **Hide** button on top of a UTxO to hide that UTxO from 164 | CIP30 API calls. 165 | 166 | ![](./07-override-utxos-1.png) 167 | 168 | The hidden UTxOs will be shown dimmed. 169 | 170 | Click on the **Show** button on top of the UTxO to stop hiding it. 171 | 172 | ## Logs of CIP 30 API Calls 173 | 174 | > Note: As of now, the logs are not persisted anywhere. They will be 175 | > reset when the extension tab is closed. They will not be reset while 176 | > navigating between different pages in the extension tab. 177 | > 178 | > Logs will not be captured if the extension tab is closed. Keep the 179 | > extension tab open to keep capturing the logs. 180 | 181 | Click the **Logs** link in the header to go to the Logs page. 182 | 183 | ![](./08-logs-0.png) 184 | 185 | Make some API calls from your dApp to see the logs. 186 | 187 | ![](./08-logs-1.png) 188 | -------------------------------------------------------------------------------- /plutip/.gitignore: -------------------------------------------------------------------------------- 1 | wallets 2 | txns 3 | local-cluster-info.json 4 | .env 5 | -------------------------------------------------------------------------------- /plutip/README.md: -------------------------------------------------------------------------------- 1 | # Plutip + Ogmios + Kupo configuration 2 | 3 | This is a nix flake which automatically starts plutip, ogmios and kupo 4 | and send some ADA to a predefined wallet. 5 | 6 | ## Usage 7 | 8 | `nix run .` 9 | 10 | Wait till the port is displayed in `info` tab. 11 | 12 | ![](./screenshots/05-info.png) 13 | 14 | Configure the extension to use the Ogmios and Kupo instances connected to the launched plutip cluster. 15 | 16 | ![](./screenshots/extension.png) 17 | 18 | ## User Interface 19 | 20 | We use [mprocs](https://github.com/pvolok/mprocs) to manage the processes with a pretty TUI. 21 | 22 | Press Up/Down arrow to switch between different processes. 23 | 24 | Press q to stop all processes and exit. 25 | 26 | ## Configuration 27 | 28 | Configuration is done in `.env` file in this folder. 29 | See [env.example](./env.example) for available options. 30 | 31 | ## Screenshots 32 | 33 | ![](./screenshots/01-plutip.png) 34 | 35 | ![](./screenshots/02-ogmios.png) 36 | 37 | ![](./screenshots/03-kupo.png) 38 | 39 | ![](./screenshots/04-fundAda.png) 40 | 41 | ![](./screenshots/05-info.png) 42 | 43 | -------------------------------------------------------------------------------- /plutip/env.example: -------------------------------------------------------------------------------- 1 | ADDRESS_TO_FUND=addr1q9d06r5h5uktqanvdyd07y747yllfst7rd025y3g0xmwdpwx5mfvatq23xwn72rnygnjvwlj808x4w0eq4ttqts2fums7qp6ll 2 | FUND_ADA=1000 3 | CARDANO_NETWORK=mainnet # or preprod or preview 4 | OGMIOS_PORT=9001 5 | KUPO_PORT=9002 6 | -------------------------------------------------------------------------------- /plutip/flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | inputs = { 3 | nixpkgs.url = "nixpkgs"; # Resolves to github:NixOS/nixpkgs 4 | # Helpers for system-specific outputs 5 | flake-utils.url = "github:numtide/flake-utils"; 6 | plutip.url = "github:mlabs-haskell/plutip"; 7 | ogmios-nixos = { 8 | url = "github:mlabs-haskell/ogmios-nixos/78e829e9ebd50c5891024dcd1004c2ac51facd80"; 9 | }; 10 | kupo-nixos = { 11 | url = "github:mlabs-haskell/kupo-nixos/df5aaccfcec63016e3d9e10b70ef8152026d7bc3"; 12 | }; 13 | cardano-cli.url = "github:intersectmbo/cardano-node/8.7.3"; 14 | }; 15 | outputs = 16 | { self 17 | , nixpkgs 18 | , flake-utils 19 | , plutip 20 | , ogmios-nixos 21 | , kupo-nixos 22 | , cardano-cli 23 | , ... 24 | }: 25 | flake-utils.lib.eachDefaultSystem ( 26 | system: 27 | let 28 | pkgs = import nixpkgs { 29 | inherit system; 30 | }; 31 | plutipBin = plutip.apps.${system}."plutip-core:exe:local-cluster".program; 32 | ogmiosBin = ogmios-nixos.apps.${system}."ogmios:exe:ogmios".program; 33 | kupoBin = kupo-nixos.packages.${system}.kupo.exePath; 34 | cardanoCliBin = cardano-cli.apps.${system}.cardano-cli.program; 35 | 36 | mprocsBin = "${pkgs.mprocs}/bin/mprocs"; 37 | jqBin = "${pkgs.jq}/bin/jq"; 38 | 39 | scriptCommon = '' 40 | set -euo pipefail 41 | sleep 1; 42 | 43 | echo -e "Waiting for cluster info .."; 44 | cluster_info="local-cluster-info.json"; 45 | while [ ! -f $cluster_info ]; do sleep 1; done; 46 | 47 | echo -e "Waiting for socket .."; 48 | do=true; 49 | while $do || [ ! -S $socket ]; do 50 | do=false; 51 | socket=$(jq .ciNodeSocket $cluster_info --raw-output) 52 | sleep 1; 53 | done 54 | echo "Socket found: " $socket " " 55 | 56 | config=''${socket/node.socket/node.config} 57 | 58 | 59 | if [ -f .env ]; then 60 | source ./.env 61 | else 62 | echo "Create a .env file to configure this script." 63 | echo "See env.example to see available options". 64 | fi; 65 | 66 | if [ -z ''${OGMIOS_PORT+x} ]; then 67 | OGMIOS_PORT=1337 68 | fi; 69 | 70 | if [ -z ''${KUPO_PORT+x} ]; then 71 | KUPO_PORT=1442 72 | fi; 73 | ''; 74 | in 75 | rec { 76 | packages.startKupo = pkgs.writeShellScript "startKupo" '' 77 | ${scriptCommon} 78 | 79 | ${kupoBin} \ 80 | --node-socket $socket \ 81 | --node-config $config \ 82 | --match '*' \ 83 | --match '*/*' \ 84 | --since origin \ 85 | --in-memory \ 86 | --host 0.0.0.0 \ 87 | --port $KUPO_PORT 88 | ''; 89 | packages.startOgmios = pkgs.writeShellScript "startOgmios" '' 90 | ${scriptCommon} 91 | 92 | ${ogmiosBin} \ 93 | --node-socket $socket \ 94 | --node-config $config \ 95 | --host 0.0.0.0 \ 96 | --port $OGMIOS_PORT 97 | ''; 98 | packages.startPlutip = pkgs.writeShellScript "startPlutip" '' 99 | rm local-cluster-info.json 100 | rm -rf wallets 101 | ${plutipBin} --wallet-dir wallets 102 | ''; 103 | packages.showInfo = pkgs.writeShellScript "showInfo" '' 104 | ${scriptCommon} 105 | echo 106 | echo Ogmios: 107 | ${ogmiosBin} --version 108 | echo Listening on port $OGMIOS_PORT 109 | echo 110 | echo Kupo: 111 | ${kupoBin} --version 112 | echo Listening on port $KUPO_PORT 113 | echo 114 | ''; 115 | packages.fundAda = pkgs.writeShellScript "fundAda" '' 116 | echo 117 | echo "Funding UTxO #1" 118 | ${packages.fundAdaBase} 119 | echo 120 | echo 121 | echo "Funding UTxO #2" 122 | ${packages.fundAdaBase} 123 | echo 124 | echo 125 | echo "Funding UTxO #3" 126 | ${packages.fundAdaBase} 127 | echo 128 | echo 129 | ''; 130 | packages.fundAdaBase = pkgs.writeShellScript "fundAdaBase" '' 131 | ${scriptCommon} 132 | export CARDANO_NODE_SOCKET_PATH=$socket 133 | 134 | mkdir -p ./txns 135 | 136 | if [ -z ''${ADDRESS_TO_FUND+x} ]; then 137 | echo "Please set ADDRESS_TO_FUND in .env" 138 | exit -1; 139 | fi; 140 | 141 | if [ -z ''${FUND_ADA+x} ]; then 142 | echo "Please set FUND_ADA in .env" 143 | exit -1; 144 | fi; 145 | 146 | if [ -z ''${CARDANO_NETWORK+x} ]; then 147 | echo "Please set CARDANO_NETWORK in .env" 148 | exit -1; 149 | fi; 150 | 151 | case $CARDANO_NETWORK in 152 | mainnet) 153 | export CARDANO_NODE_NETWORK_ID=mainnet; 154 | ;; 155 | preprod) 156 | export CARDANO_NODE_NETWORK_ID=1; 157 | ;; 158 | preview) 159 | export CARDANO_NODE_NETWORK_ID=2; 160 | ;; 161 | *) 162 | echo "CARDANO_NETWORK is set to an invalid value:" $CARDANO_NETWORK 163 | echo "Allowed values: mainnet, preprod, preview"; 164 | exit -1; 165 | esac; 166 | 167 | while [ ! -d wallets ]; do sleep 1; done 168 | 169 | do=true; 170 | while $do || [ ! -f $vkey ]; do 171 | do=false; 172 | vkey="wallets/$(ls wallets | grep verification)" 173 | sleep 1; 174 | done; 175 | 176 | address=$( \ 177 | ${cardanoCliBin} latest \ 178 | address \ 179 | build \ 180 | --payment-verification-key-file \ 181 | $vkey \ 182 | --mainnet \ 183 | ) 184 | 185 | echo 186 | echo Source Address: $address 187 | 188 | txn=$( \ 189 | ${cardanoCliBin} \ 190 | query \ 191 | utxo \ 192 | --address $address \ 193 | --mainnet \ 194 | | head -n 3 | tail -n 1 \ 195 | ) 196 | 197 | txHash=$(echo $txn | cut -d' ' -f 1) 198 | txIdx=$(echo $txn | cut -d' ' -f 2) 199 | 200 | echo Source UTxO: "$txHash#$txIdx" 201 | 202 | 203 | fundAddress=$ADDRESS_TO_FUND 204 | fundLovelace=$(echo "$FUND_ADA*1000000"|bc) 205 | 206 | echo 207 | echo "Sending $FUND_ADA ADA to $ADDRESS_TO_FUND" 208 | echo 209 | 210 | ${cardanoCliBin} \ 211 | latest \ 212 | transaction \ 213 | build \ 214 | --mainnet \ 215 | --tx-in "$txHash#$txIdx" \ 216 | --tx-out $fundAddress+$fundLovelace \ 217 | --change-address $address \ 218 | --out-file ./txns/txn-fund-ada.json; 219 | 220 | ${cardanoCliBin} \ 221 | latest \ 222 | transaction \ 223 | sign \ 224 | --tx-file ./txns/txn-fund-ada.json \ 225 | --signing-key-file ./wallets/signing-key*.skey \ 226 | --mainnet \ 227 | --out-file ./txns/txn-fund-ada-signed.json; 228 | 229 | ${cardanoCliBin} \ 230 | latest \ 231 | transaction \ 232 | submit \ 233 | --tx-file txns/txn-fund-ada-signed.json \ 234 | --mainnet; 235 | ''; 236 | packages.mprocsCfg = pkgs.writeText "mprocs.yaml" '' 237 | procs: 238 | plutip: 239 | cmd: ["${self.packages.${system}.startPlutip}"] 240 | ogmios: 241 | cmd: ["${self.packages.${system}.startOgmios}"] 242 | kupo: 243 | cmd: ["${self.packages.${system}.startKupo}"] 244 | fundAda: 245 | cmd: ["${self.packages.${system}.fundAda}"] 246 | info: 247 | cmd: ["${self.packages.${system}.showInfo}"] 248 | ''; 249 | packages.cardano-cli = cardano-cli.packages.${system}.cardano-cli; 250 | packages.default = pkgs.writeShellScript "startAll" '' 251 | ${mprocsBin} --config ${self.packages.${system}.mprocsCfg} 252 | ''; 253 | apps.default = { 254 | type = "app"; 255 | program = "${self.packages.${system}.default}"; 256 | }; 257 | } 258 | ); 259 | nixConfig = { 260 | extra-substituters = [ 261 | "https://cache.iog.io" 262 | "https://public-plutonomicon.cachix.org" 263 | ]; 264 | extra-trusted-public-keys = [ 265 | "hydra.iohk.io:f/Ea+s+dFdN+3Y/G+FDgSq+a5NEWhJGzdjvKNGv0/EQ=" 266 | "plutonomicon.cachix.org-1:3AKJMhCLn32gri1drGuaZmFrmnue+KkKrhhubQk/CWc=" 267 | ]; 268 | allow-import-from-derivation = true; 269 | }; 270 | } 271 | 272 | -------------------------------------------------------------------------------- /plutip/screenshots/01-plutip.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlabs-haskell/cardano-dev-wallet/e846a7c5d2747609d791d7d47d5423616718cb34/plutip/screenshots/01-plutip.png -------------------------------------------------------------------------------- /plutip/screenshots/02-ogmios.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlabs-haskell/cardano-dev-wallet/e846a7c5d2747609d791d7d47d5423616718cb34/plutip/screenshots/02-ogmios.png -------------------------------------------------------------------------------- /plutip/screenshots/03-kupo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlabs-haskell/cardano-dev-wallet/e846a7c5d2747609d791d7d47d5423616718cb34/plutip/screenshots/03-kupo.png -------------------------------------------------------------------------------- /plutip/screenshots/04-fundAda.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlabs-haskell/cardano-dev-wallet/e846a7c5d2747609d791d7d47d5423616718cb34/plutip/screenshots/04-fundAda.png -------------------------------------------------------------------------------- /plutip/screenshots/05-info.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlabs-haskell/cardano-dev-wallet/e846a7c5d2747609d791d7d47d5423616718cb34/plutip/screenshots/05-info.png -------------------------------------------------------------------------------- /plutip/screenshots/extension.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlabs-haskell/cardano-dev-wallet/e846a7c5d2747609d791d7d47d5423616718cb34/plutip/screenshots/extension.png -------------------------------------------------------------------------------- /webext/.gitignore: -------------------------------------------------------------------------------- 1 | build/ 2 | build-dev/ 3 | artefacts/ 4 | node_modules/ 5 | -------------------------------------------------------------------------------- /webext/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 1.4.0 2 | * Update cardano-serialization-lib to 14.1.2 3 | * Update cardano-message-signing-browser to 1.1.0 4 | 5 | # 1.3.0 6 | * Make UTxO override affect the balance 7 | Previously, the utxo list override only affected the `getUtxos()` call and not the 8 | `getBalance()` call. 9 | The UI only allows overriding the ADA value of the balance. 10 | With this change, the users will be able to use the same UI to override the token balance returned by `getBalance()`. 11 | 12 | # 1.2.0 13 | * Fix SundaeSwap hanging (#17) 14 | 15 | SundaeSwap and many other dApps seem to be not happy if the API object 16 | returned by `wallet.enable()` is not a plain object. 17 | 18 | They could be doing something like `Object.keys(api)` which trips up due to 19 | the presence of internal methods present in the dev wallet, or they 20 | could be doing something like `apiCopy = {...api}`. 21 | 22 | Not sure of the exact mechanism, but returning a plain object seems to 23 | fix this. 24 | 25 | # 1.1.0 26 | 27 | * Fix bugs in the Ogmios/Kupo backend: 28 | * UTxO index was incorrect. 29 | * Tokens were missing from the UTxO list. 30 | * Allow specifying custom endpoint for blockfrost. 31 | * Auto activate newly created accounts and backends if there's none active. 32 | * Inject the wallet before any script in the page is run. 33 | * Add seperators in UTxO list, wallets list and network backends list. 34 | -------------------------------------------------------------------------------- /webext/build.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | buildDir: "build", 3 | artefactsDir: "artefacts", 4 | chromePrivateKeyFile: "dev-keys/chrome-dev-key.pem", 5 | copy: { 6 | "src/popup/trampoline.js": "popup/trampoline.js", 7 | "src/popup/static": "popup/static", 8 | "src/background/background.js": "background/background.js", 9 | "src/public": "public", 10 | }, 11 | scss: { 12 | "src/popup/styles.scss": "popup/styles.css", 13 | }, 14 | typescript: { 15 | "src/popup/lib/Index.tsx": "popup/bundle", 16 | "src/content-script/trampoline.ts": "content-script/trampoline", 17 | "src/content-script/index.ts": "content-script/index", 18 | }, 19 | manifest: { 20 | "src/manifest.json": "manifest.json" 21 | }, 22 | html: { 23 | "src/popup/trampoline.html": "popup/trampoline.html", 24 | "src/popup/index.html": "popup/index.html", 25 | }, 26 | } 27 | -------------------------------------------------------------------------------- /webext/build.js: -------------------------------------------------------------------------------- 1 | import * as esbuild from "esbuild"; 2 | import { wasmLoader } from "./wasmLoader.js"; 3 | import { nodeModulesPolyfillPlugin } from "esbuild-plugins-node-modules-polyfill"; 4 | import * as fs from "node:fs"; 5 | import * as path from "node:path"; 6 | import * as process from "node:process"; 7 | import * as child_process from "node:child_process"; 8 | import * as sass from "sass"; 9 | 10 | import config from "./build.config.js"; 11 | 12 | function printUsage() { 13 | console.log(); 14 | console.log("build.js [options]"); 15 | console.log(); 16 | console.log("Options:"); 17 | console.log(); 18 | console.log(" --release"); 19 | console.log( 20 | " Build in release mode. If not specified, build in dev mode.", 21 | ); 22 | console.log( 23 | " In release mode, dev server is not started and watching is not enabled.", 24 | ); 25 | console.log(); 26 | console.log(" --browser chrome|firefox"); 27 | console.log(" Set browser."); 28 | console.log( 29 | " Used to generate manifest.json, start browser and bundle the webextension.", 30 | ); 31 | console.log(); 32 | console.log(" --run"); 33 | console.log( 34 | " Start the browser and load the webextension. Will auto-reload.", 35 | ); 36 | console.log(); 37 | console.log(" --bundle"); 38 | console.log(" Create the webextension bundle."); 39 | console.log(); 40 | console.log(" --test"); 41 | console.log(" Run tests."); 42 | console.log(); 43 | console.log(" --help"); 44 | console.log(" Show usage."); 45 | } 46 | 47 | let FILE_TYPES = ["copy", "scss", "manifest", "html"]; 48 | 49 | async function main() { 50 | let args = process.argv.slice(2); 51 | 52 | let argsConfig = { 53 | release: false, 54 | browser: "chrome", 55 | run: false, 56 | bundle: false, 57 | test: false, 58 | }; 59 | 60 | for (let i = 0; i < args.length; i++) { 61 | let arg = args[i]; 62 | if (arg == "--release") { 63 | argsConfig.release = true; 64 | } else if (arg == "--browser") { 65 | let browser = args[i + 1]; 66 | i += 1; 67 | if (browser != "chrome" && browser != "firefox") { 68 | console.log("Invalid value for browser:", browser); 69 | printUsage(); 70 | process.exit(-1); 71 | } 72 | argsConfig.browser = browser; 73 | } else if (arg == "--run") { 74 | argsConfig.run = true; 75 | } else if (arg == "--bundle") { 76 | if (argsConfig.run) { 77 | console.log("Can't use --run and --bundle together"); 78 | process.exit(-1); 79 | } 80 | argsConfig.release = true; 81 | argsConfig.bundle = true; 82 | } else if (arg == "--test") { 83 | argsConfig.release = true; 84 | argsConfig.test = true; 85 | } else if (arg == "--help") { 86 | printUsage(); 87 | process.exit(0); 88 | } else { 89 | console.log("Unknown argument:", arg); 90 | printUsage(); 91 | process.exit(-1); 92 | } 93 | } 94 | 95 | fs.rmSync(config.buildDir, { recursive: true, force: true }); 96 | fs.mkdirSync(config.buildDir); 97 | 98 | 99 | for (let fileType of FILE_TYPES) { 100 | for (let key of Object.keys(config[fileType])) { 101 | let dst = config[fileType][key]; 102 | dst = path.join(config.buildDir, dst); 103 | config[fileType][key] = dst; 104 | } 105 | } 106 | 107 | // Fix config paths to work in windows 108 | if (path.sep != "/") { 109 | for (let fileType of FILE_TYPES) { 110 | let catObj = config[fileType]; 111 | let newObj = {}; 112 | for (let key of Object.keys(catObj)) { 113 | let keyFixed = key.replaceAll("/", path.sep); 114 | let valFixed = catObj[key].replaceAll("/", path.sep); 115 | newObj[keyFixed] = valFixed; 116 | } 117 | config[fileType] = newObj; 118 | } 119 | } 120 | 121 | let tsEntryPoints = Object.entries(config.typescript).map(([src, dst]) => ({ 122 | in: src, 123 | out: dst, 124 | })); 125 | 126 | await watchOthers({ config, watch: !argsConfig.release, argsConfig }); 127 | 128 | await new Promise((resolve) => setTimeout(resolve, 500)); 129 | 130 | let ctx = await watchTypescript({ 131 | entryPoints: tsEntryPoints, 132 | outdir: config.buildDir, 133 | watch: !argsConfig.release, 134 | }); 135 | 136 | await new Promise((resolve) => setTimeout(resolve, 500)); 137 | 138 | if (!argsConfig.release) { 139 | await serveBuildDir(ctx, config); 140 | } 141 | 142 | if (argsConfig.run) { 143 | run({ config, argsConfig }); 144 | } 145 | 146 | if (argsConfig.bundle) { 147 | bundle({ config, argsConfig }); 148 | } 149 | 150 | if (argsConfig.test) { 151 | runTests({ config, argsConfig }); 152 | } 153 | } 154 | 155 | async function serveBuildDir(ctx, config) { 156 | let { host, port } = await ctx.serve({ servedir: config.buildDir }); 157 | 158 | log(`Serving on ${host}:${port}`); 159 | } 160 | 161 | async function watchOthers({ config, watch, argsConfig }) { 162 | 163 | let filesToWatch = []; 164 | for (let fileType of FILE_TYPES) { 165 | filesToWatch.push(...Object.keys(config[fileType])) 166 | } 167 | 168 | let dirsToWatch = {}; 169 | 170 | for (let file of filesToWatch) { 171 | let dir = path.dirname(file); 172 | if (dirsToWatch[dir] == null) { 173 | dirsToWatch[dir] = []; 174 | } 175 | dirsToWatch[dir].push(path.basename(file)); 176 | } 177 | 178 | for (let file of filesToWatch) { 179 | if (fs.statSync(file).isDirectory()) { 180 | let dir = file; 181 | if (dirsToWatch[dir] == null) { 182 | dirsToWatch[dir] = []; 183 | } 184 | } 185 | } 186 | 187 | Object.entries(dirsToWatch).map(([dir, files]) => { 188 | if (watch) { 189 | fs.watch(dir, {}, (_event, filename) => 190 | onFileChange({ 191 | filename: path.join(dir, filename), 192 | config, 193 | argsConfig, 194 | }), 195 | ); 196 | } 197 | 198 | for (let file of files) { 199 | onFileChange({ filename: path.join(dir, file), config, argsConfig }); 200 | } 201 | }); 202 | } 203 | 204 | async function watchTypescript({ entryPoints, outdir, watch }) { 205 | let ctx = await esbuild.context({ 206 | entryPoints, 207 | outdir, 208 | define: { 209 | BROWSER_RUNTIME: "1", 210 | }, 211 | plugins: [ 212 | nodeModulesPolyfillPlugin({ 213 | globals: { 214 | Buffer: true, 215 | }, 216 | modules: { 217 | buffer: true, 218 | }, 219 | }), 220 | wasmLoader(), 221 | ], 222 | bundle: true, 223 | platform: "browser", 224 | format: "esm", 225 | treeShaking: true, 226 | allowOverwrite: true, 227 | sourcemap: true, 228 | color: true, 229 | logLevel: "info", 230 | }); 231 | 232 | log( 233 | "Building typescript: " + 234 | "\n " + 235 | entryPoints.map((entrypoint) => entrypoint.in).join("\n ") + 236 | "\n", 237 | ); 238 | if (watch) { 239 | ctx.watch(); 240 | } else { 241 | await ctx.rebuild(); 242 | ctx.dispose(); 243 | ctx = null; 244 | } 245 | 246 | return ctx; 247 | } 248 | 249 | const DEBOUNCER = { 250 | cache: {}, 251 | time_ms: 100, 252 | debounce(key, fn) { 253 | let prevTimer = this.cache[key]; 254 | if (prevTimer != null) { 255 | clearTimeout(prevTimer); 256 | } 257 | 258 | let timer = setTimeout(() => { 259 | fn(); 260 | }, this.time_ms); 261 | this.cache[key] = timer; 262 | }, 263 | }; 264 | 265 | function time() { 266 | let now = new Date(); 267 | let hh = now.getHours(); 268 | let mm = now.getMinutes(); 269 | let ss = now.getSeconds(); 270 | return ( 271 | hh.toString().padStart(2, "0") + 272 | ":" + 273 | mm.toString().padStart(2, "0") + 274 | ":" + 275 | ss.toString().padStart(2, "0") 276 | ); 277 | } 278 | 279 | function log(msg, ...args) { 280 | console.log(time(), msg, ...args); 281 | } 282 | 283 | function onFileChange({ filename, callback, config, argsConfig }) { 284 | let fn = null; 285 | 286 | if (filename in config.scss) { 287 | let dst = config.scss[filename]; 288 | fn = () => { 289 | log(`Compiling SCSS: ${filename}`); 290 | compileScss(filename, dst); 291 | }; 292 | } else if (filename in config.manifest) { 293 | let dst = config.manifest[filename]; 294 | fn = () => { 295 | log(`Compiling Manifest: ${filename}`); 296 | compileManifest(filename, dst, argsConfig.browser); 297 | }; 298 | } else if (filename in config.html) { 299 | let dst = config.html[filename]; 300 | fn = () => { 301 | log(`Compiling HTML: ${filename}`); 302 | compileHtml(filename, dst, argsConfig); 303 | }; 304 | } else { 305 | // See if the changed file or any of its parents is a dir that's specified 306 | // in `config.copy` 307 | while (filename != "." && filename != "/" && filename != "") { 308 | if (filename in config.copy) { 309 | let dst = config.copy[filename]; 310 | fn = () => { 311 | log(`Copying: ${filename}`); 312 | fs.cpSync(filename, dst, { force: true, recursive: true }); 313 | }; 314 | break; 315 | } 316 | filename = path.dirname(filename); 317 | } 318 | } 319 | 320 | if (fn != null) { 321 | DEBOUNCER.debounce(filename, () => { 322 | fn(); 323 | if (callback != null) callback(); 324 | }); 325 | } 326 | } 327 | 328 | function compileScss(src, dst) { 329 | let output; 330 | try { 331 | output = sass.compile(src, { sourceMap: true }); 332 | } catch (e) { 333 | log("Error:", e.toString()); 334 | return; 335 | } 336 | 337 | let dstBaseName = path.basename(dst); 338 | fs.writeFileSync( 339 | dst, 340 | output.css + `\n/*# sourceMappingURL=${dstBaseName}.map */`, 341 | ); 342 | fs.writeFileSync(dst + ".map", JSON.stringify(output.sourceMap)); 343 | } 344 | 345 | function compileManifest(src, dst, prefix) { 346 | let input = fs.readFileSync(src); 347 | let root = JSON.parse(input); 348 | let output = fixupManifest(root, prefix); 349 | 350 | fs.writeFileSync(dst, JSON.stringify(output, null, 2)); 351 | } 352 | 353 | function compileHtml(src, dst, { release }) { 354 | let srcContents = fs.readFileSync(src).toString(); 355 | let lines = srcContents.split("\n"); 356 | let dstLine = []; 357 | 358 | let inDebugBlock = false; 359 | for (let line of lines) { 360 | if (!inDebugBlock && line.includes("[build.js:if(debug)]")) { 361 | inDebugBlock = true; 362 | continue; 363 | } 364 | if (inDebugBlock && line.includes("[build.js:endif]")) { 365 | inDebugBlock = false; 366 | continue; 367 | } 368 | 369 | if (release && inDebugBlock) continue; 370 | 371 | dstLine.push(line); 372 | } 373 | 374 | let dstContents = dstLine.join("\n"); 375 | fs.writeFileSync(dst, dstContents); 376 | } 377 | 378 | function fixupManifest(root, prefix) { 379 | prefix = "$" + prefix + ":"; 380 | 381 | if (!(root instanceof Object && !Array.isArray(root))) return root; 382 | 383 | let newEntries = []; 384 | for (let [key, value] of Object.entries(root)) { 385 | if (key.startsWith("$")) { 386 | if (key.startsWith(prefix)) { 387 | key = key.slice(prefix.length); 388 | } else { 389 | key = null; 390 | } 391 | } 392 | if (key != null) { 393 | newEntries.push([key, fixupManifest(value)]); 394 | } 395 | } 396 | 397 | return Object.fromEntries(newEntries); 398 | } 399 | 400 | function run({ config, argsConfig }) { 401 | let browserType = ""; 402 | if (argsConfig.browser == "firefox") { 403 | browserType = "firefox-desktop"; 404 | } else if (argsConfig.browser == "chrome") { 405 | browserType = "chromium"; 406 | } else { 407 | throw new Error("unreachable"); 408 | } 409 | 410 | log("Launching browser"); 411 | exec(`npx web-ext run -s ${config.buildDir} -t ${browserType} --devtools`); 412 | } 413 | 414 | function bundle({ config, argsConfig }) { 415 | let manifestFilePath = Object.keys(config.manifest)[0] 416 | let manifestFile = fs.readFileSync(manifestFilePath); 417 | let manifest = JSON.parse(manifestFile.toString()); 418 | let version = manifest.version; 419 | 420 | let cmd = ""; 421 | if (argsConfig.browser == "firefox") { 422 | log("Bundling for Firefox"); 423 | cmd = `npx web-ext build -s ${config.buildDir} -a ${config.artefactsDir} -n cardano-dev-wallet-firefox-${version}.zip --overwrite-dest`; 424 | } else if (argsConfig.browser == "chrome") { 425 | log("Bundling for Chrome"); 426 | cmd = `npx web-ext build -s ${config.buildDir} -a ${config.artefactsDir} -n cardano-dev-wallet-chrome-${version}.zip --overwrite-dest`; 427 | } else { 428 | throw new Error("unreachable"); 429 | } 430 | 431 | if (!fs.existsSync(config.artefactsDir)) { 432 | fs.mkdirSync(config.artefactsDir, { recursive: true }); 433 | } 434 | 435 | execSync(cmd); 436 | log("Bundle created"); 437 | } 438 | 439 | function runTests({ argsConfig }) { 440 | log("Starting the test suite"); 441 | let browser = { firefox: "firefox", chrome: "chromium" }[argsConfig.browser]; 442 | execSync(`npx playwright test --browser ${browser}`, { stdio: "inherit" }); 443 | } 444 | 445 | function exec(cmd, opts) { 446 | try { 447 | child_process.exec(cmd, opts); 448 | } catch (e) { 449 | if (e.stdout != null) log("Error:", e.stdout.toString("utf8")); 450 | else log("Process failed"); 451 | } 452 | } 453 | 454 | function execSync(cmd, opts) { 455 | try { 456 | child_process.execSync(cmd, opts); 457 | } catch (e) { 458 | if (e.stdout != null) log("Error:", e.stdout.toString("utf8")); 459 | else log("Process failed"); 460 | } 461 | } 462 | 463 | await main(); 464 | -------------------------------------------------------------------------------- /webext/dev-keys/chrome-dev-key.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCtrJVG/fZYjImi 3 | Hkm8fzazMoP+ey/kImHh8RXSCB/PMHtJkAkHgT+QZBlq1QY68lXuNCA6LcsjAq36 4 | sWgvxrjoWXW2CudiAtFLWl8slra4qfbBm6woQOOdJvDnc3VsNytcb4kBAhQ758ZX 5 | Cibahe7ZQRAvgGNPd1yPfC2NQs/Tta3T3zOBh1cQN4KsIStVst7CF2Y4aK/Qfvz5 6 | 2LxGZSDvp0Hp1sfj6TbZw6iBEPtJBjIEljUOl2FLJ7cKscsEUA07hCZbRbirAHb5 7 | I/g3hb8kiiUAE0OEBXxv4lKKFvU5pdAW83PzoCRVMgfHlSUdnGCYsUpinKFSmuV5 8 | ROdkmxmnAgMBAAECggEAA3Hs9B+Nh2wiPskDBW4wk5Vo8N9Yr9nOv0CdAjGPD/kS 9 | OP9WboOt0xtpNalMGlc8RSFbkkveP6+J6/Mg8fGrMVC0+Qt2U4dix2/fe27x6O/W 10 | KTkBTTscSL3BAZZUufTOM2MzAIYeCKIsWQWmh6coeb7Ep2yQi77+Ywo/jRHKNZTY 11 | fTrEd3mTn7sU5zPRAbWt9X1p8UfleOKIN1s+EZb6vlS3B88ICVOnE7+urL9uNLBz 12 | dt99IZkNWbJ4pgiV+0218PUFfK1jkAyIUEV4kyThucDpBDyx05t8Ecwe4KaVKaNX 13 | jmH2x+IleIei19Zfi0qLek/dhWN1MIi7wVJjZuxn4QKBgQD/M4GezUuxltiz8iIJ 14 | 0ISNf79HOpfkH8w2+/RetI6RWHjXpuShIUaHMLzGxhp8995CWZQzM/G+ZloAmrdd 15 | qxU1MgY2w+t5/ElXM/1A4wvVfE/QXNcTlA28CLao45lFt5H3LxuNDpMzZOswXXbS 16 | tRKUNG3fiJN0/hUjQyh7YsZ3wwKBgQCuN7+1WHVTS/Bn4HOrENx2cbqxwmBmtjYZ 17 | 5yfTj0TFrusxcc0pDYcJkzc9zj2Xf+TzvVS2zzB6LhgU3G/KUWncPOVuEz3XNi3+ 18 | h1wc0jhc7dA7/kU7sXtcHRcMv2jZNlwUbj1I/hL53Sd1DoGxZDA+cDVFlVBYcUnW 19 | SWGYpbVcTQKBgGgnIkCocrsQ4HJYYNH2mxKQz4UHgdQlshfCrpI0SHdDT1ZcE7U7 20 | OmiUWIcbdNYJ51jW7GgVTBUz+omCm1GMMEScnPKe9Sy87UW8vyBLSZogeQaFzXV9 21 | GDnkqH+3G+fbKqRiQnFIQIVaK656hrMqGWIJH8p6GAxIYmIY1527y1o3AoGAZGz6 22 | c3zEVPnHYPm/c3LKwvQYHHPhwhNy6EeZa5iAmjuUk/H3w5xqpRhZlaUXWAd/YQlY 23 | lfClDykW9J+FSWjYzv3De0pYMYCnzrsUXADKQLdNe+e83QYYCCc0rEKCHAP73EMX 24 | zMW5BpN3NUDhffI05SklbDEAGZtkZsPyIF4VR1kCgYA0XoKWRTn9DGSo2JBOmbRR 25 | 1RbFaxSS99umzfODhwg2UhBB515wIdRa+Y40HpJkeD9g1BSbAyH0Qyr4YV9U4AYP 26 | uyKfscmUX+XjabLDMNJGepWVFENTaOzPHNATosZGyToWkF0eIkvl9KD0vFophHHl 27 | qVv27K+dgaQsqw03KOkVtw== 28 | -----END PRIVATE KEY----- -------------------------------------------------------------------------------- /webext/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cardano-wallet", 3 | "type": "module", 4 | "version": "0.0.0", 5 | "description": "A dev cardano wallet that works in all browsers", 6 | "author": "MLabs ", 7 | "license": "MIT", 8 | "private": true, 9 | "scripts": {}, 10 | "devDependencies": { 11 | "@emurgo/cardano-message-signing-browser": "^1.1.0", 12 | "@emurgo/cardano-serialization-lib-browser": "^14.1.2", 13 | "@playwright/test": "^1.40.0", 14 | "@preact/signals": "^1.2.2", 15 | "@types/big.js": "^6.2.2", 16 | "@types/chrome": "^0.0.254", 17 | "big.js": "^6.2.1", 18 | "bip39": "^3.1.0", 19 | "crx": "^5.0.1", 20 | "esbuild": "^0.25.5", 21 | "esbuild-plugins-node-modules-polyfill": "^1.6.1", 22 | "playwright": "^1.39.0", 23 | "preact": "^10.19.3", 24 | "sass": "^1.69.5", 25 | "typescript": "^5.3.2", 26 | "web-ext": "^8.7.1", 27 | "webextension-polyfill": "^0.10.0" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /webext/src/background/background.js: -------------------------------------------------------------------------------- 1 | // Keep this file 2 | // A background script is needed (even if it's empty), by playwright to get extension ID during testing. 3 | // We do this by listening for service worker registration events. 4 | -------------------------------------------------------------------------------- /webext/src/background/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Web Extension Background 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /webext/src/content-script/index.ts: -------------------------------------------------------------------------------- 1 | import * as CIP30 from "../lib/CIP30"; 2 | import { RemoteLogger } from "../lib/Web/Logger"; 3 | import { WebextRemoteStorage } from "../lib/Web/Storage"; 4 | import { WebextBridgeClient } from "../lib/Web/WebextBridge"; 5 | 6 | declare global { 7 | interface Window { 8 | cardano?: any; 9 | } 10 | } 11 | 12 | if (window.cardano == null) { 13 | window.cardano = {}; 14 | } 15 | 16 | let bridge = new WebextBridgeClient("cdw-contentscript-bridge"); 17 | bridge.start() 18 | 19 | let store = new WebextRemoteStorage(bridge); 20 | 21 | let logger = new RemoteLogger(bridge); 22 | CIP30.CIP30Entrypoint.init(store, logger); 23 | 24 | let entryPoint = CIP30.CIP30Entrypoint; 25 | 26 | window.cardano.DevWallet = entryPoint; 27 | window.cardano.nami = entryPoint; 28 | 29 | 30 | console.log("Injected into nami and DevWallet", window.cardano) 31 | -------------------------------------------------------------------------------- /webext/src/content-script/trampoline.ts: -------------------------------------------------------------------------------- 1 | import { RemoteLoggerServer } from "../lib/Web/Logger"; 2 | import { WebextRemoteStorage, WebextStorage } from "../lib/Web/Storage"; 3 | import { WebextBridgeServer } from "../lib/Web/WebextBridge"; 4 | 5 | let url = chrome.runtime.getURL("content-script/index.js"); 6 | let script = document.createElement("script"); 7 | script.src = url; 8 | script.type = "module"; 9 | 10 | let bridge = new WebextBridgeServer("cdw-contentscript-bridge"); 11 | bridge.start() 12 | 13 | WebextRemoteStorage.initServer(bridge, new WebextStorage()); 14 | 15 | new RemoteLoggerServer(bridge).start(); 16 | 17 | let loaded = false; 18 | document.addEventListener("readystatechange", (event) => { 19 | if (!loaded && event?.target?.readyState !== "loading") { 20 | document.head.appendChild(script); 21 | } 22 | }); 23 | -------------------------------------------------------------------------------- /webext/src/lib/CIP30/Backend.ts: -------------------------------------------------------------------------------- 1 | import * as CSL from "@emurgo/cardano-serialization-lib-browser"; 2 | 3 | interface Backend { 4 | getUtxos(address: CSL.Address): Promise; 5 | submitTx(tx: string): Promise; 6 | } 7 | 8 | export { type Backend }; 9 | -------------------------------------------------------------------------------- /webext/src/lib/CIP30/Backends/Blockfrost.ts: -------------------------------------------------------------------------------- 1 | import * as CIP30 from ".."; 2 | import * as CSL from "@emurgo/cardano-serialization-lib-browser"; 3 | 4 | class BlockFrostBackend implements CIP30.Backend { 5 | projectId: string; 6 | url: string; 7 | 8 | constructor(projectId: string, url?: string) { 9 | this.projectId = projectId; 10 | if (url != null) { 11 | url = url.trim(); 12 | } 13 | this.url = url || urlFromProjectId(projectId) 14 | } 15 | 16 | static getNetworkNameFromProjectId( 17 | projectId: string, 18 | ): CIP30.NetworkName | null { 19 | if (projectId.startsWith("mainnet")) { 20 | return CIP30.NetworkName.Mainnet; 21 | } else if (projectId.startsWith("preview")) { 22 | return CIP30.NetworkName.Preview; 23 | } else if (projectId.startsWith("preprod")) { 24 | return CIP30.NetworkName.Preprod; 25 | } else { 26 | return null; 27 | } 28 | } 29 | 30 | async getUtxos( 31 | address: CSL.Address, 32 | ): Promise { 33 | let utxos = await addressesUtxosAll(this.url, this.projectId, address.to_bech32()); 34 | let values: CSL.TransactionUnspentOutput[] = []; 35 | for (let utxo of utxos) { 36 | let value = amountToValue(utxo.amount); 37 | const txIn = CSL.TransactionInput.new( 38 | CSL.TransactionHash.from_hex(utxo.tx_hash), 39 | utxo.output_index, 40 | ); 41 | const txOut = CSL.TransactionOutput.new( 42 | CSL.Address.from_bech32(utxo.address), 43 | value, 44 | ); 45 | let utxo_ = CSL.TransactionUnspentOutput.new(txIn, txOut); 46 | values.push(utxo_); 47 | } 48 | return values; 49 | } 50 | 51 | async submitTx(tx: string): Promise { 52 | return await txSubmit(this.url, this.projectId, tx); 53 | } 54 | } 55 | 56 | function amountToValue( 57 | amount: { 58 | unit: string; 59 | quantity: string; 60 | }[], 61 | ): CSL.Value { 62 | let value = CSL.Value.new(CSL.BigNum.zero()); 63 | let multiasset = CSL.MultiAsset.new(); 64 | for (let item of amount) { 65 | if (item.unit.toLowerCase() == "lovelace") { 66 | value.set_coin(CSL.BigNum.from_str(item.quantity)); 67 | continue; 68 | } 69 | 70 | // policyId is always 28 bytes, which when hex encoded is 56 characters. 71 | let policyId = item.unit.slice(0, 56); 72 | let assetName = item.unit.slice(56); 73 | 74 | let policyIdWasm = CSL.ScriptHash.from_hex(policyId); 75 | let assetNameWasm = CSL.AssetName.from_json('"' + assetName + '"'); 76 | 77 | multiasset.set_asset( 78 | policyIdWasm, 79 | assetNameWasm, 80 | CSL.BigNum.from_str(item.quantity), 81 | ); 82 | } 83 | value.set_multiasset(multiasset); 84 | return value; 85 | } 86 | 87 | interface AddressUtxosResponseItem { 88 | address: string; 89 | tx_hash: string; 90 | output_index: number; 91 | amount: { 92 | unit: string; 93 | quantity: string; 94 | }[]; 95 | block: string; 96 | data_hash: string | null; 97 | inline_datum: string | null; 98 | reference_script_hash: string | null; 99 | } 100 | 101 | type AddressUtxosResponse = AddressUtxosResponseItem[]; 102 | 103 | async function addressesUtxos( 104 | baseUrl: string, 105 | projectId: string, 106 | address: string, 107 | params: { page: number; count: number; order: "asc" | "desc" }, 108 | ): Promise { 109 | let url = new URL( 110 | baseUrl + "/api/v0/addresses/" + address + "/utxos", 111 | ); 112 | url.searchParams.append("page", params.page.toString()); 113 | url.searchParams.append("count", params.count.toString()); 114 | url.searchParams.append("order", params.order); 115 | 116 | let resp = await fetch(url, { 117 | method: "GET", 118 | headers: { project_id: projectId }, 119 | }); 120 | if (resp.status != 200) { 121 | if (resp.status == 404) { 122 | return null; 123 | } 124 | let text = await resp.text(); 125 | throw new Error("Request failed: " + url.toString() + "\nMessage: " + text); 126 | } 127 | return await resp.json(); 128 | } 129 | 130 | async function addressesUtxosAll( 131 | baseUrl: string, 132 | projectId: string, 133 | address: string, 134 | ): Promise { 135 | let result = []; 136 | let page = 1; 137 | let count = 100; 138 | let order = "asc" as const; 139 | while (true) { 140 | let resp = await addressesUtxos(baseUrl, projectId, address, { page, count, order }); 141 | if (resp == null) break; 142 | result.push(...resp); 143 | if (resp.length < count) break; 144 | page += 1; 145 | } 146 | return result; 147 | } 148 | 149 | type SubmitTxResponse = string; 150 | 151 | async function txSubmit(baseUrl: string, projectId: string, tx: string): Promise { 152 | let txBinary = Buffer.from(tx, "hex"); 153 | 154 | let url = new URL(baseUrl + "/api/v0/tx/submit"); 155 | let resp = await fetch(url, { 156 | method: "POST", 157 | headers: { project_id: projectId, "Content-Type": "application/cbor" }, 158 | body: txBinary, 159 | }); 160 | if (resp.status != 200) { 161 | let text = await resp.text(); 162 | throw new Error("Request failed: " + url.toString() + "\nMessage: " + text); 163 | } 164 | return await resp.json(); 165 | } 166 | 167 | function urlFromProjectId(projectId: string) { 168 | let prefix = ""; 169 | if (projectId.startsWith("mainnet")) { 170 | prefix = "mainnet"; 171 | } else if (projectId.startsWith("preview")) { 172 | prefix = "preview"; 173 | } else if (projectId.startsWith("preprod")) { 174 | prefix = "preprod"; 175 | } else { 176 | throw new Error("Invalid project id: " + projectId); 177 | } 178 | 179 | return "https://cardano-" + prefix + ".blockfrost.io"; 180 | } 181 | 182 | export { BlockFrostBackend }; 183 | -------------------------------------------------------------------------------- /webext/src/lib/CIP30/Backends/OgmiosKupo.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Address, 3 | TransactionUnspentOutput, 4 | } from "@emurgo/cardano-serialization-lib-browser"; 5 | import { Backend } from "../Backend"; 6 | import { NetworkName } from "../Network"; 7 | 8 | import * as CSL from "@emurgo/cardano-serialization-lib-browser"; 9 | import { TxSendError, TxSendErrorCode } from "../ErrorTypes"; 10 | 11 | function fixUrl(url: string) { 12 | if (url.startsWith("http://")) return url; 13 | if (url.startsWith("https://")) return url; 14 | return "https://" + url; 15 | } 16 | 17 | class OgmiosKupoBackend implements Backend { 18 | kupoUrl: string; 19 | ogmiosUrl: string; 20 | 21 | constructor({ kupoUrl, ogmiosUrl }: { kupoUrl: string, ogmiosUrl: string }) { 22 | this.kupoUrl = fixUrl(kupoUrl); 23 | this.ogmiosUrl = fixUrl(ogmiosUrl); 24 | } 25 | 26 | async getUtxos(address: Address): Promise { 27 | let matches = await getKupoMatches(this.kupoUrl, address); 28 | 29 | let values: CSL.TransactionUnspentOutput[] = []; 30 | for (let match of matches) { 31 | let value = parseValue(match.value); 32 | const txIn = CSL.TransactionInput.new( 33 | CSL.TransactionHash.from_hex(match.transaction_id), 34 | match.output_index, 35 | ); 36 | const txOut = CSL.TransactionOutput.new( 37 | CSL.Address.from_bech32(match.address), 38 | value 39 | ); 40 | let utxo_ = CSL.TransactionUnspentOutput.new(txIn, txOut); 41 | values.push(utxo_); 42 | } 43 | return values; 44 | } 45 | 46 | getNetwork(): NetworkName | null { 47 | return null 48 | } 49 | 50 | async submitTx(tx: string): Promise { 51 | let res: OgmiosSubmitTxResp = await fetch( 52 | this.ogmiosUrl + "/?SubmitTransaction", 53 | { 54 | method: "POST", 55 | headers: { 56 | Accept: "application/json", 57 | "Content-Type": "application/json", 58 | }, 59 | body: JSON.stringify({ 60 | jsonrpc: "2.0", 61 | method: "submitTransaction", 62 | params: { 63 | transaction: { cbor: tx }, 64 | }, 65 | id: null, 66 | }), 67 | } 68 | ).then((res) => res.json()); 69 | if (res.result != null) { 70 | return res.result.transaction.id; 71 | } 72 | 73 | let errMsg = ""; 74 | if (res.error != null) { 75 | errMsg = "(" + res.error.code + ") " + res.error.message; 76 | } 77 | let err: TxSendError = { 78 | code: TxSendErrorCode.Failure, 79 | info: "Failed to send tx using Ogmios: " + errMsg, 80 | }; 81 | throw err; 82 | } 83 | } 84 | 85 | interface KupoMatch { 86 | transaction_index: number; 87 | transaction_id: string; 88 | output_index: number; 89 | address: string; 90 | value: { 91 | coins: number; 92 | assets: { 93 | [policyIdAssetName: string]: number; 94 | }; 95 | }; 96 | datum_hash: string | null; 97 | datum_type?: "hash" | "inline"; 98 | script_hash: string | null; 99 | created_at: { 100 | slot_no: number; 101 | header_hash: string; 102 | }; 103 | spent_at: { 104 | slot_no: number; 105 | header_hash: string; 106 | } | null; 107 | } 108 | 109 | interface OgmiosSubmitTxResp { 110 | jsonrpc: string; 111 | method: string; 112 | id?: any; 113 | result?: { 114 | transaction: { id: string }; 115 | }; 116 | error?: { 117 | code: number; 118 | message: string; 119 | }; 120 | } 121 | 122 | async function getKupoMatches( 123 | url: string, 124 | address: CSL.Address 125 | ): Promise { 126 | let res = await fetch(url + "/matches/" + address.to_bech32() + "?unspent"); 127 | let resJson = await res.json(); 128 | return resJson; 129 | } 130 | 131 | function parseValue(value: { 132 | coins: number; 133 | assets: { 134 | [policyIdAssetName: string]: number; 135 | }; 136 | }): CSL.Value { 137 | let cslValue = CSL.Value.new(CSL.BigNum.from_str(value.coins.toString())); 138 | let multiasset = CSL.MultiAsset.new(); 139 | for (let [policyIdAssetName, amount] of Object.entries(value.assets)) { 140 | // policyId is always 28 bytes, which when hex encoded is 56 characters. 141 | let policyId = policyIdAssetName.slice(0, 56); 142 | // skip the dot at 56 143 | let assetName = policyIdAssetName.slice(57); 144 | 145 | let policyIdWasm = CSL.ScriptHash.from_hex(policyId); 146 | let assetNameWasm = CSL.AssetName.from_json('"' + assetName + '"'); 147 | 148 | multiasset.set_asset( 149 | policyIdWasm, 150 | assetNameWasm, 151 | CSL.BigNum.from_str(amount.toString()), 152 | ); 153 | } 154 | cslValue.set_multiasset(multiasset); 155 | return cslValue; 156 | } 157 | 158 | export { OgmiosKupoBackend }; 159 | -------------------------------------------------------------------------------- /webext/src/lib/CIP30/ErrorTypes.ts: -------------------------------------------------------------------------------- 1 | // Error Types 2 | 3 | type PaginateError = { 4 | maxSize: number; 5 | }; 6 | 7 | type APIError = { 8 | code: APIErrorCode; 9 | info: string; 10 | }; 11 | 12 | type DataSignError = { 13 | code: DataSignErrorCode; 14 | info: string; 15 | }; 16 | 17 | type TxSendError = { 18 | code: TxSendErrorCode; 19 | info: string; 20 | }; 21 | 22 | type TxSignError = { 23 | code: TxSignErrorCode; 24 | info: string; 25 | }; 26 | 27 | enum APIErrorCode { 28 | InvalidRequest = -1, 29 | InternalError = -2, 30 | Refused = -3, 31 | AccountChange = -4, 32 | } 33 | 34 | enum DataSignErrorCode { 35 | ProofGeneration = 1, 36 | AddressNotPK = 2, 37 | UserDeclined = 3, 38 | } 39 | 40 | enum TxSendErrorCode { 41 | Refused = 1, 42 | Failure = 2, 43 | } 44 | 45 | enum TxSignErrorCode { 46 | ProofGeneration = 1, 47 | UserDeclined = 2, 48 | } 49 | 50 | export type { 51 | PaginateError, 52 | APIError, 53 | DataSignError, 54 | TxSendError, 55 | TxSignError, 56 | }; 57 | 58 | export { APIErrorCode, DataSignErrorCode, TxSendErrorCode, TxSignErrorCode }; 59 | -------------------------------------------------------------------------------- /webext/src/lib/CIP30/Icon.d.ts: -------------------------------------------------------------------------------- 1 | declare const Icon: string; 2 | export default Icon; 3 | -------------------------------------------------------------------------------- /webext/src/lib/CIP30/Network.ts: -------------------------------------------------------------------------------- 1 | enum NetworkId { 2 | Mainnet = 1, 3 | Testnet = 0, 4 | } 5 | 6 | enum NetworkName { 7 | Mainnet = "mainnet", 8 | Preprod = "preprod", 9 | Preview = "preview", 10 | } 11 | 12 | function networkNameToId(networkName: NetworkName): NetworkId { 13 | switch (networkName) { 14 | case NetworkName.Mainnet: 15 | return NetworkId.Mainnet; 16 | case NetworkName.Preprod: 17 | return NetworkId.Testnet; 18 | case NetworkName.Preview: 19 | return NetworkId.Testnet; 20 | } 21 | } 22 | 23 | 24 | export { NetworkId, NetworkName, networkNameToId }; 25 | -------------------------------------------------------------------------------- /webext/src/lib/CIP30/State/Store.ts: -------------------------------------------------------------------------------- 1 | class HeirarchialStore { 2 | prefix: string[]; 3 | backend: Store; 4 | 5 | constructor(backend: Store, prefix: string[] = []) { 6 | this.prefix = prefix; 7 | this.backend = backend; 8 | } 9 | 10 | get(key: string): Promise { 11 | key = [...this.prefix, key].join("/"); 12 | return this.backend.get(key); 13 | } 14 | 15 | set(key: string, value: any): Promise { 16 | key = [...this.prefix, key].join("/"); 17 | return this.backend.set(key, value); 18 | } 19 | 20 | withPrefix(...prefix: string[]): HeirarchialStore { 21 | return new HeirarchialStore(this.backend, [...this.prefix, ...prefix]); 22 | } 23 | } 24 | 25 | interface Store { 26 | set(key: string, value: any): Promise; 27 | get(key: string): Promise; 28 | } 29 | 30 | 31 | export { 32 | HeirarchialStore, 33 | type Store, 34 | }; 35 | -------------------------------------------------------------------------------- /webext/src/lib/CIP30/State/Types.ts: -------------------------------------------------------------------------------- 1 | type Backend = 2 | | { 3 | type: "blockfrost"; 4 | name: string, 5 | projectId: string; 6 | url?: string; 7 | } 8 | | { 9 | type: "ogmios_kupo"; 10 | name: string, 11 | ogmiosUrl: string; 12 | kupoUrl: string; 13 | }; 14 | 15 | interface RootKey { 16 | name: string; 17 | keyBech32: string; 18 | } 19 | 20 | interface Account { 21 | name: string; 22 | keyId: string; 23 | accountIdx: number; 24 | } 25 | 26 | interface Overrides { 27 | balance: string | null; 28 | hiddenUtxos: Utxo[]; 29 | hiddenCollateral: Utxo[]; 30 | } 31 | 32 | interface Utxo { 33 | txHashHex: string; 34 | idx: number; 35 | } 36 | 37 | export type { Backend, RootKey, Account, Overrides, Utxo } 38 | -------------------------------------------------------------------------------- /webext/src/lib/CIP30/State/index.ts: -------------------------------------------------------------------------------- 1 | import { NetworkName } from "../Network"; 2 | import { HeirarchialStore, Store } from "./Store"; 3 | 4 | export * from "./Types"; 5 | export * from "./Store"; 6 | 7 | import { Account, Backend, Overrides, RootKey } from "./Types"; 8 | 9 | type Record = { [key: string]: T }; 10 | 11 | class State { 12 | rootStore: HeirarchialStore; 13 | constructor(store: Store) { 14 | this.rootStore = new HeirarchialStore(store); 15 | } 16 | 17 | async networkActiveGet(): Promise { 18 | let networkActive: NetworkName | null = 19 | await this.rootStore.get("networkActive"); 20 | if (networkActive == null) { 21 | return NetworkName.Mainnet; 22 | } 23 | return networkActive; 24 | } 25 | 26 | async networkActiveSet(network: NetworkName) { 27 | await this.rootStore.set("networkActive", network); 28 | } 29 | 30 | async _getNetworkSubStore(network: NetworkName) { 31 | return this.rootStore.withPrefix(network); 32 | } 33 | 34 | async _recordsGet(network: NetworkName, key: string): Promise> { 35 | let store = await this._getNetworkSubStore(network); 36 | let records = await store.get(key); 37 | if (records == null) return {}; 38 | return records; 39 | } 40 | 41 | async _recordsAdd( 42 | network: NetworkName, 43 | key: string, 44 | value: T, 45 | ): Promise { 46 | let store = await this._getNetworkSubStore(network); 47 | let id: number | null = await store.get(key + "/nextId"); 48 | if (id == null) id = 0; 49 | let records = await store.get(key); 50 | if (records == null) records = {}; 51 | records[id] = value; 52 | await store.set(key, records); 53 | await store.set(key + "/nextId", id + 1); 54 | return id.toString(); 55 | } 56 | 57 | async _recordsUpdate( 58 | network: NetworkName, 59 | key: string, 60 | id: string, 61 | value: T, 62 | ) { 63 | let store = await this._getNetworkSubStore(network); 64 | let records = await store.get(key); 65 | records[id] = value; 66 | await store.set(key, records); 67 | } 68 | 69 | async _recordsDelete(network: NetworkName, key: string, id: string) { 70 | let store = await this._getNetworkSubStore(network); 71 | let records = await store.get(key); 72 | delete records[id]; 73 | await store.set(key, records); 74 | } 75 | 76 | async rootKeysGet(network: NetworkName): Promise> { 77 | return this._recordsGet(network, "rootKeys"); 78 | } 79 | 80 | async rootKeysAdd(network: NetworkName, rootKey: RootKey): Promise { 81 | return this._recordsAdd(network, "rootKeys", rootKey); 82 | } 83 | 84 | async rootKeysUpdate(network: NetworkName, id: string, rootKey: RootKey) { 85 | return this._recordsUpdate(network, "rootKeys", id, rootKey); 86 | } 87 | 88 | async rootKeysDelete(network: NetworkName, id: string) { 89 | return this._recordsDelete(network, "rootKeys", id); 90 | } 91 | 92 | async accountsGet(network: NetworkName): Promise> { 93 | return this._recordsGet(network, "accounts"); 94 | } 95 | 96 | async accountsAdd(network: NetworkName, account: Account): Promise { 97 | return this._recordsAdd(network, "accounts", account); 98 | } 99 | 100 | async accountsUpdate(network: NetworkName, id: string, account: Account) { 101 | return this._recordsUpdate(network, "accounts", id, account); 102 | } 103 | 104 | async accountsDelete(network: NetworkName, id: string) { 105 | return this._recordsDelete(network, "accounts", id); 106 | } 107 | 108 | async accountsActiveGet(network: NetworkName): Promise { 109 | let store = await this._getNetworkSubStore(network); 110 | let id = store.get("accounts/activeId"); 111 | if (id == null) return null; 112 | return id; 113 | } 114 | 115 | async accountsActiveSet(network: NetworkName, id: string) { 116 | let store = await this._getNetworkSubStore(network); 117 | await store.set("accounts/activeId", id); 118 | } 119 | 120 | async backendsGet(network: NetworkName): Promise> { 121 | return this._recordsGet(network, "backends"); 122 | } 123 | 124 | async backendsAdd(network: NetworkName, backend: Backend): Promise { 125 | return this._recordsAdd(network, "backends", backend); 126 | } 127 | 128 | async backendsUpdate(network: NetworkName, id: string, backend: Backend) { 129 | return this._recordsUpdate(network, "backends", id, backend); 130 | } 131 | 132 | async backendsDelete(network: NetworkName, id: string) { 133 | return this._recordsDelete(network, "backends", id); 134 | } 135 | 136 | async backendsActiveGet(network: NetworkName): Promise { 137 | let store = await this._getNetworkSubStore(network); 138 | let id = store.get("backends/activeId"); 139 | if (id == null) return null; 140 | return id; 141 | } 142 | 143 | async backendsActiveSet(network: NetworkName, id: string) { 144 | let store = await this._getNetworkSubStore(network); 145 | await store.set("backends/activeId", id); 146 | } 147 | 148 | async overridesGet(network: NetworkName): Promise { 149 | let store = await this._getNetworkSubStore(network); 150 | let overrides = await store.get("overrides"); 151 | if (overrides == null) 152 | return { 153 | balance: null, 154 | hiddenUtxos: [], 155 | hiddenCollateral: [], 156 | }; 157 | return overrides; 158 | } 159 | 160 | async overridesSet(network: NetworkName, overrides: Overrides) { 161 | let store = await this._getNetworkSubStore(network); 162 | await store.set("overrides", overrides); 163 | } 164 | } 165 | 166 | export { State }; 167 | -------------------------------------------------------------------------------- /webext/src/lib/CIP30/Types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A hex-encoded string representing a CBOR encoded value. 3 | */ 4 | type CborHexStr = string; 5 | 6 | /** A hex-encoded string representing an address. */ 7 | type AddressHexStr = string; 8 | 9 | /** A hex-encoded string or a Bech32 string representing an address. */ 10 | type AddressInputStr = string; 11 | 12 | /** A hex-encoded string of the corresponding bytes. */ 13 | type HexStr = string; 14 | 15 | /** 16 | * `page` is zero indexed. 17 | */ 18 | type Paginate = { page: number; limit: number }; 19 | 20 | type WalletApiExtension = { cip: number }; 21 | 22 | interface DataSignature { 23 | key: HexStr; 24 | signature: HexStr; 25 | } 26 | 27 | export type { 28 | CborHexStr, 29 | AddressHexStr, 30 | AddressInputStr, 31 | HexStr, 32 | Paginate, 33 | WalletApiExtension, 34 | DataSignature, 35 | }; 36 | -------------------------------------------------------------------------------- /webext/src/lib/CIP30/Utils.ts: -------------------------------------------------------------------------------- 1 | import { Paginate } from "."; 2 | 3 | /** 4 | * Pretty much useless client side pagination. 5 | * Because we have the whole thing in memory wherever we use it. 6 | */ 7 | function paginateClientSide(x: T[], paginate?: Paginate): T[] { 8 | if (paginate == null) return x; 9 | let start = paginate.page * paginate.limit; 10 | let end = start + paginate.limit; 11 | return x.slice(start, end); 12 | } 13 | 14 | export { paginateClientSide }; 15 | -------------------------------------------------------------------------------- /webext/src/lib/CIP30/WalletApi.ts: -------------------------------------------------------------------------------- 1 | import * as CSL from "@emurgo/cardano-serialization-lib-browser"; 2 | 3 | import { 4 | WalletApiExtension, 5 | CborHexStr, 6 | AddressHexStr, 7 | Paginate, 8 | HexStr, 9 | DataSignature, 10 | AddressInputStr, 11 | NetworkId, 12 | WalletApiInternal, 13 | APIError, 14 | APIErrorCode, 15 | NetworkName, 16 | } from "."; 17 | import { State } from "./State"; 18 | 19 | function jsonReplacerCSL(_key: string, value: any) { 20 | if (value == null) return null; 21 | 22 | if (value.to_js_value != null) { 23 | return value.to_js_value(); 24 | } else if (value instanceof Map) { 25 | return Object.fromEntries(value.entries()); 26 | } else if (value instanceof Error) { 27 | console.error("Error: ", value); 28 | return value.name + ": " + value.message; 29 | } 30 | return value; 31 | } 32 | 33 | interface Logger { 34 | log(idx: number | null, log: string): Promise; 35 | } 36 | 37 | class WalletApi { 38 | api: WalletApiInternal; 39 | state: State; 40 | logger: Logger; 41 | network: NetworkName; 42 | accountId: string; 43 | 44 | constructor( 45 | api: WalletApiInternal, 46 | state: State, 47 | logger: Logger, 48 | accountId: string, 49 | network: NetworkName, 50 | ) { 51 | this.api = api; 52 | this.state = state; 53 | this.logger = logger; 54 | this.network = network; 55 | this.accountId = accountId; 56 | } 57 | 58 | /* Use this function instead of the constructor. 59 | * Although this is not specified in the spec, some dApps seem to be passing 60 | * around the WalletApi object like this: 61 | * `let copy = {...wallet}` 62 | * which may not work for when wallet is not a plain object. 63 | * This function returns a plain object instead. 64 | */ 65 | static getNew( 66 | api: WalletApiInternal, 67 | state: State, 68 | logger: Logger, 69 | accountId: string, 70 | network: NetworkName, 71 | ) { 72 | let walletApi = new WalletApi(api, state, logger, accountId, network); 73 | 74 | return { 75 | // @ts-ignore 76 | getNetworkId: (...args: any[]) => walletApi.getNetworkId(...args), 77 | // @ts-ignore 78 | getExtensions: (...args: any[]) => walletApi.getExtensions(...args), 79 | getUtxos: (...args: any[]) => walletApi.getUtxos(...args), 80 | // @ts-ignore 81 | getBalance: (...args: any[]) => walletApi.getBalance(...args), 82 | getCollateral: (...args: any[]) => walletApi.getCollateral(...args), 83 | getUsedAddresses: (...args: any[]) => walletApi.getUsedAddresses(...args), 84 | getUnusedAddresses: (...args: any[]) => 85 | // @ts-ignore 86 | walletApi.getUnusedAddresses(...args), 87 | // @ts-ignore 88 | getChangeAddress: (...args: any[]) => walletApi.getChangeAddress(...args), 89 | getRewardAddresses: (...args: any[]) => 90 | // @ts-ignore 91 | walletApi.getRewardAddresses(...args), 92 | // @ts-ignore 93 | signTx: (...args: any[]) => walletApi.signTx(...args), 94 | // @ts-ignore 95 | signData: (...args: any[]) => walletApi.signData(...args), 96 | // @ts-ignore 97 | submitTx: (...args: any[]) => walletApi.submitTx(...args), 98 | } as const; 99 | } 100 | 101 | async ensureAccountNotChanged() { 102 | let networkActive = await this.state.networkActiveGet(); 103 | if (networkActive != this.network) { 104 | let err: APIError = { 105 | code: APIErrorCode.AccountChange, 106 | info: "Account was changed by the user. Please reconnect to the Wallet", 107 | }; 108 | throw err; 109 | } 110 | 111 | let activeAccountId = await this.state.accountsActiveGet(networkActive); 112 | if (activeAccountId != this.accountId) { 113 | let err: APIError = { 114 | code: APIErrorCode.AccountChange, 115 | info: "Account was changed by the user. Please reconnect to the Wallet", 116 | }; 117 | throw err; 118 | } 119 | } 120 | 121 | async logCall( 122 | fn: string, 123 | argsDecoded: readonly any[] = [], 124 | args: readonly any[] = [], 125 | ): Promise { 126 | if (args.length == 0) { 127 | args = argsDecoded; 128 | argsDecoded = []; 129 | } 130 | 131 | let log = 132 | fn + 133 | "(" + 134 | args 135 | .map((p) => 136 | JSON.stringify( 137 | p, 138 | (_k, v) => { 139 | if (v == null) return null; // undefined|null -> null 140 | return v; 141 | }, 142 | 2, 143 | ), 144 | ) 145 | .join(", ") + 146 | ")"; 147 | 148 | let idx = await this.logger.log(null, log); 149 | if (argsDecoded.length > 0) { 150 | log = 151 | "Decoded: " + 152 | fn + 153 | "(" + 154 | argsDecoded 155 | .map((p) => JSON.stringify(p, jsonReplacerCSL, 2)) 156 | .join(", ") + 157 | ")"; 158 | await this.logger.log(idx, log); 159 | } 160 | 161 | return idx; 162 | } 163 | 164 | async logReturn(idx: number, value: any, valueDecoded?: any) { 165 | let log = "return " + JSON.stringify(value, jsonReplacerCSL, 2); 166 | await this.logger.log(idx, log); 167 | 168 | if (valueDecoded != null) { 169 | log = 170 | "Decoded: return " + JSON.stringify(valueDecoded, jsonReplacerCSL, 2); 171 | await this.logger.log(idx, log); 172 | } 173 | } 174 | 175 | async logError(idx: number, error: any) { 176 | let log = "error " + JSON.stringify(error, jsonReplacerCSL, 2); 177 | await this.logger.log(idx, log); 178 | } 179 | 180 | async wrapCall( 181 | fnName: string, 182 | fn: () => Promise, 183 | ): Promise; 184 | 185 | async wrapCall( 186 | fnName: string, 187 | fn: () => Promise, 188 | opts: { 189 | returnEncoder: (value: U) => V; 190 | }, 191 | ): Promise; 192 | 193 | async wrapCall( 194 | fnName: string, 195 | fn: (...argsDecoded: T) => Promise, 196 | opts: { 197 | argsDecoded: T; 198 | args?: any[]; 199 | }, 200 | ): Promise; 201 | 202 | async wrapCall( 203 | fnName: string, 204 | fn: (...argsDecoded: T) => Promise, 205 | opts: { 206 | returnEncoder: (value: U) => V; 207 | argsDecoded: T; 208 | args?: any[]; 209 | }, 210 | ): Promise; 211 | 212 | async wrapCall( 213 | fnName: string, 214 | fn: (...argsDecoded: T) => Promise, 215 | opts: { 216 | argsDecoded?: T; 217 | args?: T | any[]; 218 | returnEncoder?: (value: U) => V; 219 | } = {}, 220 | ): Promise { 221 | let { argsDecoded, args, returnEncoder } = opts; 222 | 223 | let idx = await this.logCall(fnName, argsDecoded, args); 224 | try { 225 | await this.ensureAccountNotChanged(); 226 | 227 | if (argsDecoded == null) { 228 | argsDecoded = [] as unknown[] as T; 229 | } 230 | let ret: U | V = await fn.call(this.api, ...argsDecoded); 231 | let retDecoded = null; 232 | 233 | if (returnEncoder != null) { 234 | retDecoded = ret; 235 | ret = returnEncoder(retDecoded); 236 | } 237 | 238 | await this.logReturn(idx, ret, retDecoded); 239 | 240 | return ret; 241 | } catch (e) { 242 | this.logError(idx, e); 243 | throw e; 244 | } 245 | } 246 | 247 | async getNetworkId(): Promise { 248 | return this.wrapCall("getNetworkId", this.api.getNetworkId); 249 | } 250 | 251 | async getExtensions(): Promise { 252 | return this.wrapCall("getExtensions", this.api.getExtensions); 253 | } 254 | 255 | async getUtxos( 256 | amount?: CborHexStr, 257 | paginate?: Paginate, 258 | ): Promise { 259 | let args = []; 260 | 261 | let argsDecoded: [] | [CSL.Value] | [CSL.Value, Paginate] = []; 262 | 263 | if (amount !== undefined) { 264 | args.push(amount); 265 | argsDecoded = [CSL.Value.from_hex(amount)]; 266 | 267 | if (paginate != undefined) { 268 | args.push(paginate); 269 | argsDecoded = [...argsDecoded, paginate]; 270 | } 271 | } 272 | 273 | return await this.wrapCall("getUtxos", this.api.getUtxos, { 274 | argsDecoded, 275 | args, 276 | returnEncoder: (utxos: CSL.TransactionUnspentOutput[] | null) => { 277 | if (utxos == null) return null; 278 | return utxos.map((utxo) => utxo.to_hex()); 279 | }, 280 | }); 281 | } 282 | 283 | async getBalance(): Promise { 284 | return this.wrapCall("getBalance", this.api.getBalance, { 285 | returnEncoder: (balance) => balance.to_hex(), 286 | }); 287 | } 288 | 289 | async getCollateral(options?: { 290 | amount: CborHexStr; 291 | }): Promise { 292 | let argsDecoded: [params?: { amount: CSL.BigNum }] = []; 293 | if (options != null) { 294 | let amount = CSL.BigNum.from_hex(options.amount); 295 | 296 | argsDecoded.push({ 297 | amount, 298 | }); 299 | } 300 | 301 | return this.wrapCall("getCollateral", this.api.getCollateral, { 302 | argsDecoded, 303 | args: [options], 304 | returnEncoder: (collaterals: CSL.TransactionUnspentOutput[] | null) => 305 | collaterals == null ? null : collaterals.map((c) => c.to_hex()), 306 | }); 307 | } 308 | 309 | async getUsedAddresses(paginate?: Paginate): Promise { 310 | return this.wrapCall("getUsedAddresses", this.api.getUsedAddresses, { 311 | argsDecoded: [paginate], 312 | returnEncoder: (addresses: CSL.Address[]) => 313 | addresses.map((address) => address.to_hex()), 314 | }); 315 | } 316 | 317 | async getUnusedAddresses(): Promise { 318 | return this.wrapCall("getUnusedAddresses", this.api.getUnusedAddresses, { 319 | returnEncoder: (addresses: CSL.Address[]) => 320 | addresses.map((address) => address.to_hex()), 321 | }); 322 | } 323 | 324 | async getChangeAddress(): Promise { 325 | return this.wrapCall("getChangeAddress", this.api.getChangeAddress, { 326 | returnEncoder: (address) => address.to_hex(), 327 | }); 328 | } 329 | 330 | async getRewardAddresses(): Promise { 331 | return this.wrapCall("getRewardAddresses", this.api.getRewardAddresses, { 332 | returnEncoder: (addresses) => 333 | addresses.map((address) => address.to_hex()), 334 | }); 335 | } 336 | 337 | async signTx( 338 | tx: CborHexStr, 339 | partialSign: boolean = false, 340 | ): Promise { 341 | return await this.wrapCall("signTx", this.api.signTx, { 342 | argsDecoded: [CSL.Transaction.from_hex(tx), partialSign], 343 | args: [tx, partialSign], 344 | returnEncoder: (witnessSet: CSL.TransactionWitnessSet) => 345 | witnessSet.to_hex(), 346 | }); 347 | } 348 | 349 | async signData( 350 | addr: AddressInputStr, 351 | payload: HexStr, 352 | ): Promise { 353 | let addrParsed: CSL.Address | null = null; 354 | try { 355 | addrParsed = CSL.Address.from_bech32(addr); 356 | } catch (e) { 357 | // not a bech32 address, try hex 358 | } 359 | if (addrParsed == null) { 360 | addrParsed = CSL.Address.from_hex(addr); 361 | } 362 | return this.wrapCall("signData", this.api.signData, { 363 | argsDecoded: [addrParsed, payload], 364 | args: [addr, payload], 365 | }); 366 | } 367 | 368 | async submitTx(tx: CborHexStr): Promise { 369 | return this.wrapCall("submitTx", this.api.submitTx, { argsDecoded: [tx] }); 370 | } 371 | } 372 | 373 | export { WalletApi, type Logger }; 374 | -------------------------------------------------------------------------------- /webext/src/lib/CIP30/WalletApiInternal.ts: -------------------------------------------------------------------------------- 1 | import { Paginate, WalletApiExtension } from "./Types"; 2 | import * as CSL from "@emurgo/cardano-serialization-lib-browser"; 3 | import * as CMS from "@emurgo/cardano-message-signing-browser"; 4 | import * as Utils from "../Utils"; 5 | import { Account } from "../Wallet"; 6 | import { 7 | HexStr, 8 | DataSignature, 9 | TxSignError, 10 | TxSignErrorCode, 11 | Backend, 12 | NetworkId, 13 | DataSignError, 14 | DataSignErrorCode, 15 | } from "."; 16 | 17 | import { paginateClientSide } from "./Utils"; 18 | import { State, Utxo } from "./State"; 19 | import { Big } from "big.js"; 20 | 21 | class WalletApiInternal { 22 | account: Account; 23 | backend: Backend; 24 | networkId: NetworkId; 25 | state: State; 26 | overridesEnabled: boolean; 27 | 28 | constructor( 29 | account: Account, 30 | backend: Backend, 31 | networkId: NetworkId, 32 | state: State, 33 | overridesEnabled: boolean, 34 | ) { 35 | this.account = account; 36 | this.backend = backend; 37 | this.networkId = networkId; 38 | this.state = state; 39 | this.overridesEnabled = overridesEnabled; 40 | } 41 | 42 | _getBaseAddress(): CSL.BaseAddress { 43 | return this.account.baseAddress; 44 | } 45 | 46 | _getAddress(): CSL.Address { 47 | return this._getBaseAddress().to_address(); 48 | } 49 | 50 | async getNetworkId(): Promise { 51 | return this.networkId; 52 | } 53 | 54 | async getExtensions(): Promise { 55 | return []; 56 | } 57 | 58 | async getUtxos( 59 | amount?: CSL.Value, 60 | paginate?: Paginate, 61 | ): Promise { 62 | let networkActive = await this.state.networkActiveGet(); 63 | let address = this._getAddress(); 64 | 65 | let utxos = await this.backend.getUtxos(address); 66 | if (this.overridesEnabled) { 67 | let overrides = await this.state.overridesGet(networkActive); 68 | if (overrides != null) { 69 | utxos = filterUtxos(utxos, overrides.hiddenUtxos); 70 | } 71 | } 72 | 73 | if (amount != null) { 74 | let res = Utils.getUtxosAddingUpToTarget(utxos, amount); 75 | if (res == null) return null; 76 | utxos = res; 77 | } 78 | 79 | return paginateClientSide(utxos, paginate); 80 | } 81 | 82 | async getBalance(): Promise { 83 | let networkActive = await this.state.networkActiveGet(); 84 | 85 | if (this.overridesEnabled) { 86 | let overrides = await this.state.overridesGet(networkActive); 87 | if (overrides?.balance != null) { 88 | try { 89 | let balance = new Big(overrides.balance); 90 | balance = balance.mul("1000000"); 91 | return CSL.Value.new( 92 | CSL.BigNum.from_str(balance.toFixed(0, Big.roundDown)), 93 | ); 94 | } catch (e) { 95 | console.error("Can't parse balance override", e, overrides.balance); 96 | } 97 | } 98 | } 99 | 100 | let address = this._getAddress(); 101 | let utxos = await this.backend.getUtxos(address); 102 | 103 | let overrides = await this.state.overridesGet(networkActive); 104 | if (overrides != null) { 105 | utxos = filterUtxos(utxos, overrides.hiddenUtxos); 106 | } 107 | 108 | return Utils.sumUtxos(utxos); 109 | } 110 | 111 | async getCollateral(params?: { 112 | amount?: CSL.BigNum; 113 | }): Promise { 114 | let networkActive = await this.state.networkActiveGet(); 115 | const fiveAda = CSL.BigNum.from_str("5000000"); 116 | 117 | let address = this._getAddress(); 118 | 119 | let target = params?.amount || null; 120 | 121 | if (target == null || target.compare(fiveAda) > 1) { 122 | target = fiveAda; 123 | } 124 | 125 | let utxos: CSL.TransactionUnspentOutput[] | null = 126 | await this.backend.getUtxos(address); 127 | 128 | if (this.overridesEnabled) { 129 | let overrides = await this.state.overridesGet(networkActive); 130 | if (overrides != null) { 131 | utxos = filterUtxos(utxos, overrides.hiddenCollateral); 132 | } 133 | } 134 | 135 | utxos = Utils.getPureAdaUtxos(utxos); 136 | 137 | if (params?.amount != null) { 138 | let value = CSL.Value.new(params.amount); 139 | utxos = Utils.getUtxosAddingUpToTarget(utxos, value); 140 | } 141 | return utxos; 142 | } 143 | 144 | async getChangeAddress(): Promise { 145 | return this._getAddress(); 146 | } 147 | 148 | async getUsedAddresses(_paginate?: Paginate): Promise { 149 | return [this._getAddress()]; 150 | } 151 | 152 | async getUnusedAddresses(): Promise { 153 | return []; 154 | } 155 | 156 | async getRewardAddresses(): Promise { 157 | return [this._getAddress()]; 158 | } 159 | 160 | async signTx( 161 | tx: CSL.Transaction, 162 | partialSign: boolean, 163 | ): Promise { 164 | let txBodyFixed = CSL.FixedTransaction.from_bytes(tx.to_bytes()); 165 | let txHash = txBodyFixed.transaction_hash(); 166 | 167 | let account = this.account; 168 | let paymentKeyHash = account.paymentKey.to_public().hash(); 169 | let stakingKeyHash = account.stakingKey.to_public().hash(); 170 | 171 | let requiredKeyHashes = await Utils.getRequiredKeyHashes( 172 | tx, 173 | (await this.getUtxos())!, 174 | paymentKeyHash, 175 | ); 176 | 177 | let requiredKeyHashesSet = new Set(requiredKeyHashes); 178 | 179 | let witnesses: CSL.Vkeywitness[] = []; 180 | for (let keyhash of requiredKeyHashesSet) { 181 | if (keyhash.to_hex() == paymentKeyHash.to_hex()) { 182 | let witness = CSL.make_vkey_witness(txHash, account.paymentKey); 183 | witnesses.push(witness); 184 | } else if (keyhash.to_hex() == stakingKeyHash.to_hex()) { 185 | let witness = CSL.make_vkey_witness(txHash, account.stakingKey); 186 | witnesses.push(witness); 187 | } else { 188 | if (partialSign == false) { 189 | throw { 190 | code: TxSignErrorCode.ProofGeneration, 191 | info: `Unknown keyhash ${keyhash.to_hex()}`, 192 | }; 193 | } 194 | } 195 | } 196 | 197 | let witness_set = tx.witness_set(); 198 | let vkeys = witness_set.vkeys(); 199 | if (vkeys == null) { 200 | vkeys = CSL.Vkeywitnesses.new(); 201 | } 202 | for (let witness of witnesses) { 203 | vkeys.add(witness); 204 | } 205 | witness_set.set_vkeys(vkeys); 206 | 207 | return witness_set; 208 | } 209 | 210 | async signData(addr: CSL.Address, payload: HexStr): Promise { 211 | let account = this.account; 212 | let paymentKey = account.paymentKey; 213 | let stakingKey = account.stakingKey; 214 | let keyToSign: CSL.PrivateKey; 215 | 216 | let paymentAddressFns = [ 217 | ["BaseAddress", CSL.BaseAddress], 218 | ["EnterpriseAddress", CSL.EnterpriseAddress], 219 | ["PointerAddress", CSL.PointerAddress], 220 | ] as const; 221 | 222 | let addressStakeCred: CSL.Credential | null = null; 223 | for (let [_name, fn] of paymentAddressFns) { 224 | let addrDowncasted = fn.from_address(addr); 225 | if (addrDowncasted != null) { 226 | addressStakeCred = addrDowncasted.payment_cred(); 227 | break; 228 | } 229 | } 230 | 231 | if (addressStakeCred == null) { 232 | let addrDowncasted = CSL.RewardAddress.from_address(addr); 233 | if (addrDowncasted != null) { 234 | addressStakeCred = account.baseAddress.stake_cred(); 235 | } 236 | } 237 | 238 | if (addressStakeCred == null) { 239 | throw new Error( 240 | "This should be unreachable unless CSL adds a new address type", 241 | ); 242 | } 243 | 244 | let addressKeyhash = addressStakeCred.to_keyhash()!.to_hex(); 245 | 246 | if (addressKeyhash == paymentKey.to_public().hash().to_hex()) { 247 | keyToSign = paymentKey; 248 | } else if (addressKeyhash == stakingKey.to_public().hash().to_hex()) { 249 | keyToSign = stakingKey; 250 | } else { 251 | let err: DataSignError = { 252 | code: DataSignErrorCode.ProofGeneration, 253 | info: "We don't own the keyhash: " + addressKeyhash, 254 | }; 255 | throw err; 256 | } 257 | 258 | // Headers: 259 | // alg (1): EdDSA (-8) 260 | // kid (4): ignore, nami doesn't set it 261 | // "address": raw bytes of address 262 | // 263 | // Don't hash payload 264 | // Don't use External AAD 265 | 266 | let protectedHeaders = CMS.HeaderMap.new(); 267 | protectedHeaders.set_algorithm_id( 268 | CMS.Label.from_algorithm_id(CMS.AlgorithmId.EdDSA), 269 | ); 270 | protectedHeaders.set_header( 271 | CMS.Label.new_text("address"), 272 | CMS.CBORValue.new_bytes(addr.to_bytes()), 273 | ); 274 | let protectedHeadersWrapped = CMS.ProtectedHeaderMap.new(protectedHeaders); 275 | 276 | let unprotectedHeaders = CMS.HeaderMap.new(); 277 | 278 | let headers = CMS.Headers.new(protectedHeadersWrapped, unprotectedHeaders); 279 | 280 | let builder = CMS.COSESign1Builder.new( 281 | headers, 282 | Buffer.from(payload, "hex"), 283 | false, 284 | ); 285 | let toSign = builder.make_data_to_sign().to_bytes(); 286 | keyToSign.sign(toSign); 287 | 288 | let coseSign1 = builder.build(keyToSign.sign(toSign).to_bytes()); 289 | 290 | let coseKey = CMS.COSEKey.new(CMS.Label.from_key_type(CMS.KeyType.OKP)); 291 | coseKey.set_algorithm_id( 292 | CMS.Label.from_algorithm_id(CMS.AlgorithmId.EdDSA), 293 | ); 294 | coseKey.set_header( 295 | CMS.Label.new_int(CMS.Int.new_negative(CMS.BigNum.from_str("1"))), 296 | CMS.CBORValue.new_int(CMS.Int.new_i32(6)), // CMS.CurveType.Ed25519 297 | ); // crv (-1) set to Ed25519 (6) 298 | coseKey.set_header( 299 | CMS.Label.new_int(CMS.Int.new_negative(CMS.BigNum.from_str("2"))), 300 | CMS.CBORValue.new_bytes(keyToSign.to_public().as_bytes()), 301 | ); // x (-2) set to public key 302 | 303 | return { 304 | signature: Buffer.from(coseSign1.to_bytes()).toString("hex"), 305 | key: Buffer.from(coseKey.to_bytes()).toString("hex"), 306 | }; 307 | } 308 | 309 | async submitTx(tx: string): Promise { 310 | return this.backend.submitTx(tx); 311 | } 312 | } 313 | 314 | function filterUtxos( 315 | utxos: CSL.TransactionUnspentOutput[], 316 | hiddenUtxos: Utxo[], 317 | ) { 318 | utxos = utxos.filter((utxo) => { 319 | for (let utxo1 of hiddenUtxos) { 320 | if ( 321 | utxo.input().index() == utxo1.idx && 322 | utxo.input().transaction_id().to_hex() == utxo1.txHashHex 323 | ) { 324 | return false; 325 | } 326 | } 327 | return true; 328 | }); 329 | return utxos; 330 | } 331 | 332 | function cloneTx(tx: CSL.Transaction): CSL.Transaction { 333 | return CSL.Transaction.from_bytes(tx.to_bytes()); 334 | } 335 | 336 | export { WalletApiInternal }; 337 | -------------------------------------------------------------------------------- /webext/src/lib/CIP30/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./Backend"; 2 | export * from "./ErrorTypes"; 3 | export * from "./Network"; 4 | export * from "./Types"; 5 | export * from "./WalletApi"; 6 | export * from "./WalletApiInternal"; 7 | 8 | import { Wallet } from "../Wallet"; 9 | import { Backend } from "./Backend"; 10 | import { WalletApi, Logger } from "./WalletApi"; 11 | 12 | import WalletIcon from "./Icon"; 13 | import { WalletApiInternal } from "./WalletApiInternal"; 14 | import { State, Store } from "./State"; 15 | import { APIError, APIErrorCode } from "./ErrorTypes"; 16 | import { networkNameToId } from "./Network"; 17 | import { BlockFrostBackend } from "./Backends/Blockfrost"; 18 | import { OgmiosKupoBackend } from "./Backends/OgmiosKupo"; 19 | 20 | /** 21 | * CIP30 Entrypoint. 22 | */ 23 | const CIP30Entrypoint = { 24 | apiVersion: "1", 25 | supportedExtensions: [], 26 | name: "Cardano Dev Wallet", 27 | icon: WalletIcon, 28 | 29 | state: null as State | null, 30 | logger: null as Logger | null, 31 | 32 | init(store: Store, logger: Logger) { 33 | CIP30Entrypoint.state = new State(store); 34 | CIP30Entrypoint.logger = logger; 35 | }, 36 | 37 | isEnabled: async () => { 38 | return true; 39 | }, 40 | 41 | enable: async () => { 42 | let state = CIP30Entrypoint.state!; 43 | let logger = CIP30Entrypoint.logger!; 44 | // Fetch active network 45 | let networkName = await state.networkActiveGet(); 46 | let networkId = networkNameToId(networkName); 47 | 48 | // Fetch active account 49 | let accountId = await state.accountsActiveGet(networkName); 50 | if (accountId == null) { 51 | let err: APIError = { 52 | code: APIErrorCode.Refused, 53 | info: "Please configure the active account in the extension", 54 | }; 55 | throw err; 56 | } 57 | 58 | let accounts = await state.accountsGet(networkName); 59 | let accountInfo = accounts[accountId]; 60 | let keys = await state.rootKeysGet(networkName); 61 | let keyInfo = keys[accountInfo.keyId]; 62 | 63 | let wallet = new Wallet({ networkId, privateKey: keyInfo.keyBech32 }); 64 | let account = wallet.account(accountInfo.accountIdx, 0); 65 | 66 | // Fetch active backend 67 | let backendId = await state.backendsActiveGet(networkName); 68 | if (backendId == null) { 69 | let err: APIError = { 70 | code: APIErrorCode.Refused, 71 | info: "Please configure the active backend in the extension", 72 | }; 73 | throw err; 74 | } 75 | 76 | let backends = await state.backendsGet(networkName); 77 | let backendInfo = backends[backendId]; 78 | let backend: Backend; 79 | if (backendInfo.type == "blockfrost") { 80 | backend = new BlockFrostBackend(backendInfo.projectId, backendInfo.url); 81 | } else if (backendInfo.type == "ogmios_kupo") { 82 | backend = new OgmiosKupoBackend(backendInfo); 83 | } else { 84 | throw new Error("Unreachable"); 85 | } 86 | 87 | // Construct api 88 | let apiInternal = new WalletApiInternal( 89 | account, 90 | backend, 91 | networkId, 92 | state, 93 | true, 94 | ); 95 | 96 | let api = WalletApi.getNew( 97 | apiInternal, 98 | state, 99 | logger, 100 | accountId, 101 | networkName, 102 | ); 103 | return api; 104 | }, 105 | }; 106 | 107 | export { CIP30Entrypoint }; 108 | -------------------------------------------------------------------------------- /webext/src/lib/CSLIterator.ts: -------------------------------------------------------------------------------- 1 | interface CSLContainer { 2 | len(): number; 3 | get(i: number): T; 4 | } 5 | 6 | class CSLIterator implements Iterator, Iterable { 7 | private index: number; 8 | constructor(private container: CSLContainer | undefined) { 9 | this.index = 0; 10 | } 11 | 12 | [Symbol.iterator](): Iterator { 13 | return this; 14 | } 15 | 16 | next(): IteratorResult { 17 | if (this.container != null && this.index < this.container.len()) { 18 | let val = { 19 | done: false, 20 | value: this.container.get(this.index), 21 | }; 22 | this.index += 1; 23 | return val; 24 | } else { 25 | return { 26 | done: true, 27 | value: null, 28 | }; 29 | } 30 | } 31 | } 32 | 33 | export { CSLIterator, type CSLContainer }; 34 | -------------------------------------------------------------------------------- /webext/src/lib/Utils.ts: -------------------------------------------------------------------------------- 1 | import * as CSL from "@emurgo/cardano-serialization-lib-browser"; 2 | import { CSLIterator } from "./CSLIterator"; 3 | 4 | function getAllPolicyIdAssetNames( 5 | value: CSL.Value, 6 | ): [CSL.ScriptHash, CSL.AssetName][] { 7 | let ret: [CSL.ScriptHash, CSL.AssetName][] = []; 8 | let multiasset = value.multiasset() || CSL.MultiAsset.new(); 9 | 10 | let policyIds = new CSLIterator(multiasset.keys()); 11 | 12 | for (let policyId of policyIds) { 13 | let assets = multiasset.get(policyId) || CSL.Assets.new(); 14 | 15 | let assetNames = new CSLIterator(assets.keys()); 16 | for (let assetName of assetNames) { 17 | ret.push([policyId, assetName]); 18 | } 19 | } 20 | return ret; 21 | } 22 | 23 | export function getPureAdaUtxos( 24 | utxos: CSL.TransactionUnspentOutput[], 25 | ): CSL.TransactionUnspentOutput[] { 26 | let ret: CSL.TransactionUnspentOutput[] = []; 27 | 28 | for (let utxo of utxos) { 29 | let multiasset = utxo.output().amount().multiasset(); 30 | if (multiasset != null && multiasset.len() > 0) continue; 31 | 32 | ret.push(utxo); 33 | } 34 | return ret; 35 | } 36 | 37 | export function getUtxosAddingUpToTarget( 38 | utxos: CSL.TransactionUnspentOutput[], 39 | target: CSL.Value, 40 | ): CSL.TransactionUnspentOutput[] | null { 41 | let policyIdAssetNames = getAllPolicyIdAssetNames(target); 42 | 43 | let ret: CSL.TransactionUnspentOutput[] = []; 44 | let sum = CSL.Value.new(CSL.BigNum.zero()); 45 | 46 | for (let utxo of utxos) { 47 | let value = utxo.output().amount(); 48 | ret.push(utxo); 49 | sum = sum.checked_add(value); 50 | 51 | if (sum.coin().less_than(target.coin())) { 52 | continue; 53 | } 54 | 55 | let sumAddsUpToTarget = true; 56 | for (let [policyId, assetName] of policyIdAssetNames) { 57 | let sumAsset = 58 | sum.multiasset()?.get_asset(policyId, assetName) || CSL.BigNum.zero(); 59 | let targetAsset = 60 | target.multiasset()?.get_asset(policyId, assetName) || 61 | CSL.BigNum.zero(); 62 | if (sumAsset.less_than(targetAsset)) { 63 | sumAddsUpToTarget = false; 64 | break; 65 | } 66 | } 67 | if (sumAddsUpToTarget) { 68 | return ret; 69 | } 70 | } 71 | return null; 72 | } 73 | 74 | export function sumUtxos(utxos: CSL.TransactionUnspentOutput[]): CSL.Value { 75 | let sum = CSL.Value.new(CSL.BigNum.zero()); 76 | for (let utxo of utxos) { 77 | sum = sum.checked_add(utxo.output().amount()); 78 | } 79 | return sum; 80 | } 81 | 82 | const UNKNOWN_KEYHASH: CSL.Ed25519KeyHash = CSL.Ed25519KeyHash.from_bytes( 83 | new Uint8Array(new Array(28).fill(0)), 84 | ); 85 | 86 | export async function getRequiredKeyHashes( 87 | tx: CSL.Transaction, 88 | utxos: CSL.TransactionUnspentOutput[], 89 | paymentKeyHash: CSL.Ed25519KeyHash, 90 | ): Promise { 91 | const txBody = tx.body(); 92 | 93 | let result: CSL.Ed25519KeyHash[] = []; 94 | 95 | // get key hashes from inputs 96 | const inputs = txBody.inputs(); 97 | for (let input of new CSLIterator(inputs)) { 98 | if (findUtxo(input, utxos) != null) { 99 | result.push(paymentKeyHash); 100 | } else { 101 | result.push(UNKNOWN_KEYHASH); 102 | } 103 | } 104 | 105 | // get keyHashes from collateral 106 | const collateral = txBody.collateral(); 107 | for (let c of new CSLIterator(collateral)) { 108 | if (findUtxo(c, utxos) != null) { 109 | result.push(paymentKeyHash); 110 | } else { 111 | result.push(UNKNOWN_KEYHASH); 112 | } 113 | } 114 | 115 | // key hashes from withdrawals 116 | const withdrawals = txBody.withdrawals(); 117 | const rewardAddresses = withdrawals?.keys(); 118 | for (let rewardAddress of new CSLIterator(rewardAddresses)) { 119 | const credential = rewardAddress.payment_cred(); 120 | if (credential.kind() === CSL.CredKind.Key) { 121 | result.push(credential.to_keyhash()!); 122 | } 123 | } 124 | 125 | // get key hashes from certificates 126 | let txCerts = txBody.certs(); 127 | if (txCerts != null) { 128 | for (let cert of new CSLIterator(txCerts)) { 129 | result.push(...getRequiredKeyHashesFromCertificate(cert)); 130 | } 131 | } 132 | 133 | // get key hashes from scripts 134 | const scripts = tx.witness_set().native_scripts(); 135 | for (let script of new CSLIterator(scripts)) { 136 | result.push(...new CSLIterator(script.get_required_signers())); 137 | } 138 | 139 | // get keyHashes from required signers 140 | const requiredSigners = txBody.required_signers(); 141 | for (let requiredSigner of new CSLIterator(requiredSigners)) { 142 | result.push(requiredSigner); 143 | } 144 | 145 | return result; 146 | } 147 | 148 | export function findUtxo( 149 | txInput: CSL.TransactionInput, 150 | utxos: CSL.TransactionUnspentOutput[], 151 | ): CSL.TransactionUnspentOutput | null { 152 | let txHash = txInput.transaction_id().to_hex(); 153 | let index = txInput.index(); 154 | for (let utxo of utxos) { 155 | if ( 156 | utxo.input().transaction_id().to_hex() === txHash && 157 | utxo.input().index() === index 158 | ) { 159 | return utxo; 160 | } 161 | } 162 | return null; 163 | } 164 | 165 | export function getRequiredKeyHashesFromCertificate( 166 | cert: CSL.Certificate, 167 | ): CSL.Ed25519KeyHash[] { 168 | let result: CSL.Ed25519KeyHash[] = []; 169 | 170 | if (cert.kind() === CSL.CertificateKind.StakeRegistration) { 171 | // stake registration doesn't required signing 172 | } else if (cert.kind() === CSL.CertificateKind.StakeDeregistration) { 173 | const credential = cert.as_stake_deregistration()!.stake_credential(); 174 | if (credential.kind() === CSL.CredKind.Key) { 175 | result.push(credential.to_keyhash()!); 176 | } 177 | } else if (cert.kind() === CSL.CertificateKind.StakeDelegation) { 178 | const credential = cert.as_stake_delegation()!.stake_credential(); 179 | if (credential.kind() === CSL.CredKind.Key) { 180 | result.push(credential.to_keyhash()!); 181 | } 182 | } else if (cert.kind() === CSL.CertificateKind.PoolRegistration) { 183 | const owners = cert.as_pool_registration()!.pool_params().pool_owners(); 184 | for (let i = 0; i < owners.len(); i++) { 185 | const ownerKeyhash = owners.get(i); 186 | result.push(ownerKeyhash); 187 | } 188 | } else if (cert.kind() === CSL.CertificateKind.PoolRetirement) { 189 | const operator = cert.as_pool_retirement()!.pool_keyhash(); 190 | result.push(operator); 191 | } else if (cert.kind() === CSL.CertificateKind.MoveInstantaneousRewardsCert) { 192 | const instant_reward = cert 193 | .as_move_instantaneous_rewards_cert()! 194 | .move_instantaneous_reward() 195 | .as_to_stake_creds()! 196 | .keys(); 197 | for (let credential of new CSLIterator(instant_reward)) { 198 | if (credential.kind() === CSL.CredKind.Key) { 199 | result.push(credential.to_keyhash()!); 200 | } 201 | } 202 | } else { 203 | // We don't know how to handle other certificate types 204 | result.push(UNKNOWN_KEYHASH); 205 | } 206 | return result; 207 | } 208 | -------------------------------------------------------------------------------- /webext/src/lib/Wallet.ts: -------------------------------------------------------------------------------- 1 | import * as bip39 from "bip39"; 2 | import * as CSL from "@emurgo/cardano-serialization-lib-browser"; 3 | 4 | export class Wallet { 5 | rootKey: CSL.Bip32PrivateKey; 6 | networkId: number; 7 | 8 | constructor( 9 | params: { networkId: number } & ( 10 | | { mnemonics: string[] } 11 | | { privateKey: string } 12 | ), 13 | ) { 14 | this.networkId = params.networkId; 15 | if ("mnemonics" in params) { 16 | const entropy = bip39.mnemonicToEntropy(params.mnemonics.join(" ")); 17 | this.rootKey = CSL.Bip32PrivateKey.from_bip39_entropy( 18 | Buffer.from(entropy, "hex"), 19 | Buffer.from(""), // password 20 | ); 21 | } else { 22 | this.rootKey = CSL.Bip32PrivateKey.from_bech32(params.privateKey); 23 | } 24 | } 25 | 26 | account(account: number, index: number): Account { 27 | let accountKey = this.rootKey 28 | .derive(harden(1852)) 29 | .derive(harden(1815)) 30 | .derive(harden(account)); 31 | return new Account(this.networkId, accountKey, index); 32 | } 33 | } 34 | 35 | export class Account { 36 | networkId: number; 37 | index: number; 38 | paymentKey: CSL.PrivateKey; 39 | stakingKey: CSL.PrivateKey; 40 | baseAddress: CSL.BaseAddress; 41 | 42 | constructor( 43 | networkId: number, 44 | accountKey: CSL.Bip32PrivateKey, 45 | index: number, 46 | ) { 47 | this.networkId = networkId; 48 | this.index = index; 49 | 50 | this.paymentKey = accountKey.derive(0).derive(index).to_raw_key(); 51 | this.stakingKey = accountKey.derive(2).derive(index).to_raw_key(); 52 | this.baseAddress = CSL.BaseAddress.new( 53 | this.networkId, 54 | CSL.Credential.from_keyhash(this.paymentKey.to_public().hash()), 55 | CSL.Credential.from_keyhash(this.stakingKey.to_public().hash()), 56 | ); 57 | } 58 | } 59 | 60 | function harden(num: number): number { 61 | return 0x80000000 + num; 62 | } 63 | -------------------------------------------------------------------------------- /webext/src/lib/Web/Logger.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from "../CIP30"; 2 | import { WebextBridgeClient, WebextBridgeServer } from "./WebextBridge"; 3 | 4 | class RemoteLogger implements Logger { 5 | bridge: WebextBridgeClient; 6 | nextId: number; 7 | 8 | constructor(bridge: WebextBridgeClient) { 9 | this.bridge = bridge; 10 | this.nextId = 0; 11 | } 12 | 13 | _getNextId(): number { 14 | let id = this.nextId; 15 | this.nextId += 1; 16 | return id; 17 | } 18 | 19 | async log(id: number | null, log: string): Promise { 20 | if (id == null) id = this._getNextId(); 21 | 22 | await this.bridge.request("cdw/logger/log", { id, log }); 23 | 24 | return id; 25 | } 26 | } 27 | 28 | class RemoteLoggerServer { 29 | bridge: WebextBridgeServer; 30 | port: chrome.runtime.Port | null; 31 | 32 | constructor(bridge: WebextBridgeServer) { 33 | this.bridge = bridge; 34 | this.port = null; 35 | 36 | this.bridge.register("cdw/logger/log", async ({ id, log }) => { 37 | if (this.port != null) 38 | this.port.postMessage({ method: "popup/log", id, log }); 39 | }); 40 | } 41 | 42 | start() { 43 | this.port = chrome.runtime.connect(); 44 | this.port.onDisconnect.addListener(() => { 45 | this.port = null; 46 | setTimeout(this.start, 1000); 47 | }); 48 | } 49 | } 50 | 51 | export { RemoteLogger, RemoteLoggerServer }; 52 | -------------------------------------------------------------------------------- /webext/src/lib/Web/Storage.ts: -------------------------------------------------------------------------------- 1 | import { Store } from "../CIP30/State"; 2 | import { WebextBridgeClient, WebextBridgeServer } from "./WebextBridge"; 3 | 4 | class WebextStorage implements Store { 5 | async set(key: string, value: any): Promise { 6 | await chrome.storage.local.set({ [key]: value }); 7 | } 8 | 9 | async get(key: string): Promise { 10 | return (await chrome.storage.local.get(key))[key]; 11 | } 12 | } 13 | 14 | class WebStorage implements Store { 15 | async set(key: string, value: any): Promise { 16 | localStorage.setItem(key, JSON.stringify(value)); 17 | } 18 | 19 | async get(key: string): Promise { 20 | let val = localStorage.getItem(key); 21 | if (val == null) return null; 22 | return JSON.parse(val); 23 | } 24 | } 25 | 26 | class WebextRemoteStorage implements Store { 27 | bridge: WebextBridgeClient; 28 | 29 | constructor(bridge: WebextBridgeClient) { 30 | this.bridge = bridge; 31 | } 32 | 33 | static initServer(bridge: WebextBridgeServer, base: Store) { 34 | bridge.register("cdw/storage/get", async ({ key }) => { 35 | let value = await base.get(key); 36 | return value; 37 | }); 38 | bridge.register("cdw/storage/set", async ({ key, value }) => { 39 | await base.set(key, value); 40 | }); 41 | } 42 | 43 | async set(key: string, value: any): Promise { 44 | await this.bridge.request("cdw/storage/set", { key, value }); 45 | } 46 | 47 | async get(key: string): Promise { 48 | let value = await this.bridge.request("cdw/storage/get", { key }); 49 | return value; 50 | } 51 | } 52 | 53 | export { WebStorage, WebextStorage, WebextRemoteStorage }; 54 | -------------------------------------------------------------------------------- /webext/src/lib/Web/WebextBridge.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Communicate between webpage and content script using DOM APIs (window.postMessage) 3 | */ 4 | 5 | export class WebextBridgeClient { 6 | id: string; 7 | 8 | requests: Map void>; 9 | nextRequestId: number; 10 | 11 | constructor(id: string) { 12 | this.id = id; 13 | this.requests = new Map(); 14 | this.nextRequestId = 0; 15 | } 16 | 17 | start() { 18 | window.addEventListener("message", (ev) => { 19 | let data = ev.data; 20 | if (data.bridgeId != this.id) return; 21 | if (data.type != "response") return; 22 | 23 | let reqId = data.reqId as number | null | undefined; 24 | if (reqId == null) return; 25 | 26 | let resolve = this.requests.get(reqId); 27 | if (resolve == null) return; 28 | 29 | resolve(data.response); 30 | }); 31 | } 32 | 33 | _getNextRequestId(): number { 34 | let id = this.nextRequestId; 35 | this.nextRequestId += 1; 36 | return id; 37 | } 38 | 39 | request(method: string, data: any) { 40 | let reqId = this._getNextRequestId(); 41 | let promise = new Promise((resolve) => { 42 | this.requests.set(reqId, resolve); 43 | }); 44 | window.postMessage({ 45 | bridgeId: this.id, 46 | reqId, 47 | type: "request", 48 | request: { 49 | method, 50 | data, 51 | }, 52 | }); 53 | return promise; 54 | } 55 | } 56 | 57 | export class WebextBridgeServer { 58 | id: string; 59 | 60 | handlers: Map Promise>; 61 | 62 | constructor(id: string) { 63 | this.id = id; 64 | this.handlers = new Map(); 65 | } 66 | 67 | register(method: string, handler: (data: any) => Promise) { 68 | this.handlers.set(method, handler); 69 | } 70 | 71 | start() { 72 | window.addEventListener("message", async (ev) => { 73 | let data = ev.data; 74 | if (data.bridgeId != this.id) return; 75 | 76 | if (data.type != "request") return; 77 | 78 | let reqId = data.reqId; 79 | 80 | let request = data.request; 81 | if (request == null) return; 82 | 83 | let handler = this.handlers.get(request.method); 84 | if (handler == null) return; 85 | 86 | let response = await handler(request.data); 87 | 88 | ev.source?.postMessage({ 89 | bridgeId: this.id, 90 | reqId, 91 | type: "response", 92 | response, 93 | }); 94 | }); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /webext/src/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "name": "Cardano Dev Wallet", 4 | "author": "chrome-web-store@mlabs.city", 5 | "description": "A wallet webextension for Cardano, with features that help development of dApps", 6 | "homepage_url": "https://github.com/mlabs-haskell/cardano-dev-wallet/", 7 | "version": "1.4.0", 8 | "content_security_policy": { 9 | "extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self';" 10 | }, 11 | "content_scripts": [ 12 | { 13 | "matches": [""], 14 | "run_at": "document_start", 15 | "js": ["./content-script/trampoline.js"] 16 | } 17 | ], 18 | "web_accessible_resources": [ 19 | { 20 | "resources": ["content-script/index.js", "/*.wasm"], 21 | "matches": [""] 22 | } 23 | ], 24 | "$chrome:background": { 25 | "service_worker": "./background/background.js", 26 | "type": "module" 27 | }, 28 | "$firefox:background": { 29 | "scripts": ["./background/background.js"] 30 | }, 31 | "action": { 32 | "default_popup": "./popup/trampoline.html", 33 | "default_title": "Open the popup" 34 | }, 35 | "permissions": ["storage"], 36 | "icons": { 37 | "72": "public/icon-72x72.png", 38 | "96": "public/icon-96x96.png", 39 | "128": "public/icon-128x128.png", 40 | "256": "public/icon-256x256.png", 41 | "512": "public/icon-512x512.png" 42 | }, 43 | "browser_specific_settings": { 44 | "gecko": { 45 | "id": "cardano-dev-wallet@mlabs.city", 46 | "strict_min_version": "109.0" 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /webext/src/popup/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Cardano Dev Wallet 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /webext/src/popup/lib/Index.tsx: -------------------------------------------------------------------------------- 1 | import { render } from "preact"; 2 | import { useState } from "preact/hooks"; 3 | 4 | import { NetworkName } from "../../lib/CIP30"; 5 | import * as State from "./State"; 6 | 7 | import OverviewPage from "./pages/Overview"; 8 | import AccountsPage from "./pages/Accounts"; 9 | import NetworkPage from "./pages/Network"; 10 | import LogsPage from "./pages/Logs"; 11 | 12 | const BODY_CLASSES = "column gap-xxl gap-no-propagate align-stretch"; 13 | document.body.className = BODY_CLASSES; 14 | render(, document.body); 15 | 16 | function App() { 17 | let [navActive, setNavActive] = useState("Overview"); 18 | 19 | const pages = [ 20 | ["Overview", ], 21 | ["Accounts", ], 22 | ["Network", ], 23 | ["Logs", ], 24 | ] as const; 25 | 26 | const navItems = pages.map(([name, _]) => name); 27 | 28 | const pageStyle = { 29 | class: "column", 30 | }; 31 | 32 | return ( 33 | <> 34 |
39 | {pages.map((item) => { 40 | let [name, page] = item; 41 | let pageStyle_ = { ...pageStyle }; 42 | if (navActive != name) { 43 | pageStyle_.class += " display-none"; 44 | } 45 | return
{page}
; 46 | })} 47 | 48 | ); 49 | } 50 | 51 | function Header({ 52 | navItems, 53 | navActive, 54 | navigate, 55 | }: { 56 | navItems: string[]; 57 | navActive: string; 58 | navigate: (arg: string) => void; 59 | }) { 60 | let networkActive = State.networkActive.value; 61 | 62 | const logo = ( 63 |
64 | 65 |
66 |
Cardano
67 |
68 | Dev Wallet 69 |
70 |
71 |
72 | ); 73 | 74 | const networkSelector = ( 75 |
76 | {[NetworkName.Mainnet, NetworkName.Preprod, NetworkName.Preview].map( 77 | (network) => ( 78 | 86 | ), 87 | )} 88 |
89 | ); 90 | 91 | const nav = ; 101 | 102 | return ( 103 |
104 |
105 | {logo} 106 | {networkSelector} 107 |
108 | {nav} 109 |
110 | ); 111 | } 112 | -------------------------------------------------------------------------------- /webext/src/popup/lib/OptionButtons.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "preact/hooks"; 2 | 3 | export interface OptionButtons { 4 | text?: string; 5 | backText?: string; 6 | expanded?: { value: boolean; set: (value: boolean) => void }; 7 | buttons: OptionButton[]; 8 | } 9 | 10 | export interface OptionButtonSub { 11 | backText: string; 12 | buttons: OptionButton[]; 13 | } 14 | 15 | export interface OptionButton { 16 | text: string; 17 | icon?: string; 18 | secondary?: boolean; 19 | onClick?: () => void; 20 | expand?: OptionButtonSub; 21 | } 22 | 23 | export function OptionButtons({ 24 | text, 25 | backText, 26 | buttons, 27 | expanded, 28 | }: OptionButtons) { 29 | if (text == null) text = "Options"; 30 | if (backText == null) backText = text; 31 | 32 | if (!expanded) { 33 | let [value, set] = useState(false); 34 | expanded = { value, set }; 35 | } 36 | 37 | let [childExpanded, setChildExpanded] = useState( 38 | null, 39 | ); 40 | 41 | if (childExpanded == null) { 42 | let isExpanded = expanded.value; 43 | let setExpanded = expanded.set; 44 | let toggleExpanded = () => setExpanded(!isExpanded); 45 | 46 | let expandButton = ( 47 | 53 | ); 54 | let subButtons = buttons.map((btn, idx) => { 55 | let btnClass = "button"; 56 | if (btn.secondary) btnClass += " -secondary"; 57 | 58 | let onClick = () => { 59 | setExpanded(false); 60 | if (btn.onClick) btn.onClick(); 61 | }; 62 | 63 | if (btn.expand != null) { 64 | let child = btn.expand; 65 | onClick = () => setChildExpanded(child); 66 | } 67 | 68 | return ( 69 | 72 | ); 73 | }); 74 | 75 | return ( 76 |
77 | {expandButton} 78 | {isExpanded && subButtons} 79 |
80 | ); 81 | } else 82 | return ( 83 | { 89 | if (!value) { 90 | setChildExpanded(null) 91 | expanded?.set(false); 92 | } 93 | }, 94 | }} 95 | /> 96 | ); 97 | } 98 | -------------------------------------------------------------------------------- /webext/src/popup/lib/State.ts: -------------------------------------------------------------------------------- 1 | import { signal, computed } from "@preact/signals"; 2 | import * as InternalState from "../../lib/CIP30/State"; 3 | import { 4 | NetworkName, 5 | WalletApiInternal, 6 | networkNameToId, 7 | } from "../../lib/CIP30"; 8 | import { Wallet, Account } from "../../lib/Wallet"; 9 | import { BlockFrostBackend } from "../../lib/CIP30/Backends/Blockfrost"; 10 | import { OgmiosKupoBackend } from "../../lib/CIP30/Backends/OgmiosKupo"; 11 | import { Big } from "big.js"; 12 | import { WebStorage, WebextStorage } from "../../lib/Web/Storage"; 13 | 14 | function makeStore(): InternalState.Store { 15 | let store; 16 | if (window.chrome?.storage?.local != null) { 17 | store = new WebextStorage(); 18 | } else { 19 | store = new WebStorage(); 20 | } 21 | return store; 22 | } 23 | 24 | const STATE = new InternalState.State(makeStore()); 25 | 26 | async function loadInternalState() { 27 | const networkActive = signal(await STATE.networkActiveGet()); 28 | const rootKeys = signal(await STATE.rootKeysGet(networkActive.value)); 29 | const accounts = signal(await STATE.accountsGet(networkActive.value)); 30 | const accountsActiveId = signal( 31 | await STATE.accountsActiveGet(networkActive.value), 32 | ); 33 | const backends = signal(await STATE.backendsGet(networkActive.value)); 34 | const backendsActiveId = signal( 35 | await STATE.backendsActiveGet(networkActive.value), 36 | ); 37 | 38 | const overrides = signal(await STATE.overridesGet(networkActive.value)); 39 | 40 | return { 41 | networkActive, 42 | rootKeys, 43 | accounts, 44 | accountsActiveId, 45 | backends, 46 | backendsActiveId, 47 | overrides, 48 | }; 49 | } 50 | 51 | const internalState = signal(await loadInternalState()); 52 | 53 | async function networkActiveSet(network: NetworkName) { 54 | await STATE.networkActiveSet(network); 55 | internalState.value = await loadInternalState(); 56 | } 57 | 58 | interface WalletDef { 59 | name: string; 60 | wallet: Wallet; 61 | } 62 | 63 | interface AccountDef { 64 | name: string; 65 | walletId: string; 66 | accountIdx: number; 67 | account: Account; 68 | } 69 | 70 | const networkActive = computed(() => internalState.value.networkActive.value); 71 | 72 | const adaSymbol = computed(() => 73 | internalState.value.networkActive.value == NetworkName.Mainnet ? "₳" : "t₳", 74 | ); 75 | 76 | const wallets = computed(() => { 77 | let networkActive = internalState.value.networkActive.value; 78 | let networkId = networkNameToId(networkActive); 79 | 80 | // keyId -> WalletDef 81 | let wallets = new Map(); 82 | 83 | for (let [keyId, rootKey] of Object.entries( 84 | internalState.value.rootKeys.value, 85 | )) { 86 | let name = rootKey.name; 87 | let wallet = new Wallet({ 88 | networkId: networkId, 89 | privateKey: rootKey.keyBech32, 90 | }); 91 | wallets.set(keyId, { name, wallet }); 92 | } 93 | 94 | return wallets; 95 | }); 96 | 97 | async function walletsAdd(name: string, wallet: Wallet) { 98 | let networkActive = internalState.value.networkActive.value; 99 | 100 | let rootKey: InternalState.RootKey = { 101 | name, 102 | keyBech32: wallet.rootKey.to_bech32(), 103 | }; 104 | await STATE.rootKeysAdd(networkActive, rootKey); 105 | 106 | internalState.value.rootKeys.value = await STATE.rootKeysGet(networkActive); 107 | } 108 | 109 | async function walletsRename(walletId: string, name: string) { 110 | let networkActive = internalState.value.networkActive.value; 111 | let rootKeys = await STATE.rootKeysGet(networkActive); 112 | 113 | let rootKey = rootKeys[walletId]; 114 | rootKey.name = name; 115 | 116 | await STATE.rootKeysUpdate(networkActive, walletId, rootKey); 117 | 118 | internalState.value.rootKeys.value = await STATE.rootKeysGet(networkActive); 119 | } 120 | 121 | async function walletsDelete(walletId: string) { 122 | let networkActive = internalState.value.networkActive.value; 123 | 124 | await accountsDeleteByWallet(walletId); 125 | await STATE.rootKeysDelete(networkActive, walletId); 126 | 127 | internalState.value.rootKeys.value = await STATE.rootKeysGet(networkActive); 128 | } 129 | 130 | const accounts = computed(() => { 131 | let accounts = new Map(); 132 | 133 | for (let [acId, account] of Object.entries( 134 | internalState.value.accounts.value, 135 | )) { 136 | let walletDef = wallets.value.get(account.keyId)!; 137 | let wallet = walletDef.wallet; 138 | 139 | let accountDef: AccountDef = { 140 | name: account.name, 141 | walletId: account.keyId, 142 | accountIdx: account.accountIdx, 143 | account: wallet.account(account.accountIdx, 0), 144 | }; 145 | accounts.set(acId, accountDef); 146 | } 147 | return accounts; 148 | }); 149 | 150 | interface AccountNew { 151 | name: string; 152 | walletId: string; 153 | accountIdx: number; 154 | } 155 | 156 | async function accountsAdd({ walletId, name, accountIdx }: AccountNew) { 157 | let networkActive = internalState.value.networkActive.value; 158 | 159 | 160 | let account: InternalState.Account = { 161 | keyId: walletId, 162 | name, 163 | accountIdx: accountIdx, 164 | }; 165 | let id = await STATE.accountsAdd(networkActive, account); 166 | 167 | if (internalState.value.accountsActiveId.value == null) { 168 | await accountsActiveSet(id); 169 | } 170 | 171 | internalState.value.accounts.value = await STATE.accountsGet(networkActive); 172 | } 173 | 174 | async function accountsDeleteByWallet(walletId: string) { 175 | let networkActive = internalState.value.networkActive.value; 176 | 177 | let idsToDelete = []; 178 | for (let [id, ac] of Object.entries(internalState.value.accounts.value)) { 179 | if (ac.keyId == walletId) idsToDelete.push(id); 180 | } 181 | for (let id of idsToDelete) { 182 | await STATE.accountsDelete(networkActive, id); 183 | } 184 | 185 | internalState.value.accounts.value = await STATE.accountsGet(networkActive); 186 | } 187 | 188 | async function accountsRename(accountId: string, name: string) { 189 | let networkActive = internalState.value.networkActive.value; 190 | let accounts = await STATE.accountsGet(networkActive); 191 | 192 | let account = accounts[accountId]; 193 | account.name = name; 194 | 195 | await STATE.accountsUpdate(networkActive, accountId, account); 196 | 197 | internalState.value.accounts.value = await STATE.accountsGet(networkActive); 198 | } 199 | 200 | async function accountsDelete(accountId: string) { 201 | let networkActive = internalState.value.networkActive.value; 202 | 203 | await STATE.accountsDelete(networkActive, accountId); 204 | 205 | internalState.value.accounts.value = await STATE.accountsGet(networkActive); 206 | } 207 | 208 | interface ActiveAccountDef { 209 | walletId: string; 210 | walletDef: WalletDef; 211 | accountId: string; 212 | accountDef: AccountDef; 213 | } 214 | 215 | const accountsActiveId = computed( 216 | () => internalState.value.accountsActiveId.value, 217 | ); 218 | 219 | const accountsActive = computed(() => { 220 | let activeAccountId = internalState.value.accountsActiveId.value; 221 | if (activeAccountId == null) return null; 222 | 223 | let accountDef = accounts.value.get(activeAccountId); 224 | if (accountDef == null) return null; 225 | 226 | let walletId = accountDef.walletId; 227 | let walletDef = wallets.value.get(walletId)!; 228 | if (walletDef == null) return null; 229 | 230 | let activeAccountDef: ActiveAccountDef = { 231 | walletId, 232 | walletDef, 233 | accountId: activeAccountId, 234 | accountDef, 235 | }; 236 | return activeAccountDef; 237 | }); 238 | 239 | async function accountsActiveSet(acId: string) { 240 | await STATE.accountsActiveSet(networkActive.value, acId); 241 | internalState.value.accountsActiveId.value = acId; 242 | } 243 | 244 | type BackendDef = InternalState.Backend; 245 | 246 | const backends = computed(() => internalState.value.backends.value); 247 | 248 | async function backendsAdd(backend: BackendDef) { 249 | let networkActive = internalState.value.networkActive.value; 250 | let id = await STATE.backendsAdd(networkActive, backend); 251 | if (internalState.value.backendsActiveId.value == null) { 252 | await backendsActiveSet(id); 253 | } 254 | internalState.value.backends.value = await STATE.backendsGet(networkActive); 255 | } 256 | 257 | async function backendsUpdate(backendId: string, backend: BackendDef) { 258 | let networkActive = internalState.value.networkActive.value; 259 | 260 | await STATE.backendsUpdate(networkActive, backendId, backend); 261 | 262 | internalState.value.backends.value = await STATE.backendsGet(networkActive); 263 | } 264 | 265 | async function backendsDelete(backendId: string) { 266 | let networkActive = internalState.value.networkActive.value; 267 | 268 | await STATE.backendsDelete(networkActive, backendId); 269 | 270 | internalState.value.backends.value = await STATE.backendsGet(networkActive); 271 | } 272 | 273 | const backendsActiveId = computed( 274 | () => internalState.value.backendsActiveId.value, 275 | ); 276 | 277 | async function backendsActiveSet(backendId: string) { 278 | await STATE.backendsActiveSet(networkActive.value, backendId); 279 | internalState.value.backendsActiveId.value = backendId; 280 | } 281 | 282 | const overrides = computed(() => internalState.value.overrides.value); 283 | 284 | const overrideBalance = computed(() => { 285 | let balance = internalState.value.overrides.value?.balance; 286 | if (balance == null) { 287 | return null; 288 | } 289 | return new Big(balance); 290 | }); 291 | 292 | async function overridesSet(overrides: InternalState.Overrides) { 293 | await STATE.overridesSet(networkActive.value, overrides); 294 | internalState.value.overrides.value = await STATE.overridesGet( 295 | networkActive.value, 296 | ); 297 | } 298 | 299 | const API = computed(() => { 300 | let networkActive = internalState.value.networkActive.value; 301 | let networkId = networkNameToId(networkActive); 302 | 303 | let account = accountsActive.value?.accountDef.account; 304 | if (account == null) return "NO_ACCOUNT"; 305 | 306 | let backendId = backendsActiveId.value; 307 | if (backendId == null) return "NO_BACKEND"; 308 | let backendDef = backends.value[backendId]; 309 | if (backendDef == null) return "NO_BACKEND"; 310 | let backend; 311 | if (backendDef.type == "blockfrost") { 312 | backend = new BlockFrostBackend(backendDef.projectId, backendDef.url); 313 | } else if (backendDef.type == "ogmios_kupo") { 314 | backend = new OgmiosKupoBackend(backendDef); 315 | } else { 316 | throw new Error("Unreachable; Invalid backend type"); 317 | } 318 | 319 | let api = new WalletApiInternal(account, backend, networkId, STATE, false); 320 | return api; 321 | }); 322 | 323 | export type { WalletDef, AccountDef, AccountNew, ActiveAccountDef, BackendDef }; 324 | export { 325 | networkActive, 326 | networkActiveSet, 327 | adaSymbol, 328 | // Wallets 329 | wallets, 330 | walletsAdd, 331 | walletsRename, 332 | walletsDelete, 333 | // Accounts 334 | accounts, 335 | accountsAdd, 336 | accountsRename, 337 | accountsDelete, 338 | accountsActiveId, 339 | accountsActive, 340 | accountsActiveSet, 341 | // Backends 342 | backends, 343 | backendsUpdate, 344 | backendsAdd, 345 | backendsDelete, 346 | backendsActiveId, 347 | backendsActiveSet, 348 | overrides, 349 | overrideBalance, 350 | overridesSet, 351 | API, 352 | }; 353 | -------------------------------------------------------------------------------- /webext/src/popup/lib/pages/Accounts.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "preact/hooks"; 2 | import * as State from "../State"; 3 | import { networkNameToId } from "../../../lib/CIP30"; 4 | import { Wallet as LibWallet } from "../../../lib/Wallet"; 5 | import { bindInput, bindInputNum } from "../utils"; 6 | import { OptionButtons } from "../OptionButtons"; 7 | import { ShortenedLabel } from "./ShortenedLabel"; 8 | 9 | const CARD_WIDTH = "20rem"; 10 | 11 | export default function Page() { 12 | let wallets = State.wallets.value; 13 | 14 | let [adding, setAdding] = useState(false); 15 | 16 | return ( 17 | <> 18 |
19 |
20 |

Wallets

21 | {!adding && ( 22 | 25 | )} 26 |
27 | 28 | {adding && } 29 |
30 | 31 | {[...wallets].map(([walletId, wallet]) => ( 32 | <> 33 | 34 |
35 | 36 | ))} 37 | 38 | ); 39 | } 40 | 41 | function AddWallet({ setAdding }: { setAdding: (v: boolean) => void }) { 42 | let [name, setName] = useState(""); 43 | let [keyOrMnemonics, setKeyOrMnemonics] = useState(""); 44 | let [error, setError] = useState(false); 45 | 46 | const onSubmit = async () => { 47 | let network = State.networkActive.value; 48 | let networkId = networkNameToId(network); 49 | keyOrMnemonics = keyOrMnemonics.trim(); 50 | 51 | let wallet; 52 | try { 53 | if (keyOrMnemonics.indexOf(" ") == -1) { 54 | wallet = new LibWallet({ 55 | networkId, 56 | privateKey: keyOrMnemonics, 57 | }); 58 | } else { 59 | wallet = new LibWallet({ 60 | networkId, 61 | mnemonics: keyOrMnemonics.split(" "), 62 | }); 63 | } 64 | await State.walletsAdd(name, wallet); 65 | setAdding(false); 66 | } catch (e) { 67 | setError(true); 68 | } 69 | }; 70 | return ( 71 |
72 |
73 |
Add Wallet
74 |
75 | 78 | 81 |
82 |
83 | 92 | 100 |
{error && "Invalid Root Key / Mnemonics"}
101 |
102 | ); 103 | } 104 | 105 | function Wallet({ 106 | walletId, 107 | wallet, 108 | }: { 109 | walletId: string; 110 | wallet: State.WalletDef; 111 | }) { 112 | let [action, setAction] = useState<"add_account" | "rename" | null>(null); 113 | 114 | let accounts = State.accounts.value; 115 | let ourAccounts = [...accounts].filter( 116 | ([_acIdx, ac]) => ac.walletId == walletId, 117 | ); 118 | ourAccounts.sort( 119 | ([_id1, ac1], [_id2, ac2]) => ac1.accountIdx - ac2.accountIdx, 120 | ); 121 | 122 | const onConfirmDelete = async () => { 123 | await State.walletsDelete(walletId); 124 | }; 125 | 126 | return ( 127 |
128 | setAction("add_account")} 131 | onRename={() => setAction("rename")} 132 | onConfirmDelete={onConfirmDelete} 133 | showButtons={action == null} 134 | /> 135 | 136 | {action == "add_account" && ( 137 | setAction(null)} /> 138 | )} 139 | 140 | {action == "rename" && ( 141 | setAction(null)} 145 | /> 146 | )} 147 | 148 | {/* Accounts */} 149 | 150 | {ourAccounts.map(([acId, ac]) => ( 151 | 152 | ))} 153 |
154 | ); 155 | } 156 | 157 | function WalletHeader({ 158 | wallet, 159 | showButtons, 160 | onAddAccount, 161 | onRename, 162 | onConfirmDelete, 163 | }: { 164 | wallet: State.WalletDef; 165 | showButtons: boolean; 166 | onAddAccount: () => void; 167 | onRename: () => void; 168 | onConfirmDelete: () => void; 169 | }) { 170 | return ( 171 |
172 |
173 |

{wallet.name || "Unnamed"}

174 | 180 |
181 | 182 | {showButtons && ( 183 |
184 | 187 | 206 |
207 | )} 208 |
209 | ); 210 | } 211 | 212 | function RenameWallet({ 213 | walletId, 214 | onClose, 215 | ...rest 216 | }: { 217 | walletId: string; 218 | name: string; 219 | onClose: () => void; 220 | }) { 221 | let [name, setName] = useState(rest.name); 222 | 223 | const onSubmit = async () => { 224 | await State.walletsRename(walletId, name); 225 | onClose(); 226 | }; 227 | 228 | return ( 229 |
230 |
231 |
Rename Wallet
232 |
233 | 236 | 239 |
240 |
241 | 250 |
251 | ); 252 | } 253 | function AddAccount({ 254 | walletId, 255 | onClose, 256 | }: { 257 | walletId: string; 258 | onClose: () => void; 259 | }) { 260 | let [idx, setIdx] = useState("0"); 261 | 262 | const onSubmit = async () => { 263 | await State.accountsAdd({ 264 | name: "Unnamed", 265 | walletId, 266 | accountIdx: parseInt(idx), 267 | }); 268 | onClose(); 269 | }; 270 | return ( 271 |
272 |
273 |
Add Account
274 |
275 | 278 | 281 |
282 |
283 | 290 |
291 | ); 292 | } 293 | 294 | function Account({ acId, ac }: { acId: string; ac: State.AccountDef }) { 295 | let derivation = "m(1852'/1815'/" + ac.accountIdx + "')"; 296 | let address = ac.account.baseAddress.to_address().to_bech32(); 297 | 298 | const onDeleteConfirm = async () => { 299 | await State.accountsDelete(acId); 300 | }; 301 | 302 | const setActive = async () => { 303 | await State.accountsActiveSet(acId); 304 | }; 305 | 306 | let activeId = State.accountsActiveId.value; 307 | let isActive = activeId == acId; 308 | 309 | let optionButtons = []; 310 | if (!isActive) 311 | optionButtons.push({ 312 | text: "Set Active", 313 | icon: "", 314 | onClick: setActive, 315 | }); 316 | optionButtons.push({ 317 | text: "Delete", 318 | icon: "delete", 319 | expand: { 320 | backText: "Cancel", 321 | buttons: [ 322 | { 323 | text: "Confirm Delete", 324 | icon: "delete", 325 | onClick: onDeleteConfirm, 326 | }, 327 | ], 328 | }, 329 | }); 330 | 331 | return ( 332 |
333 |
334 |
335 |
336 | {derivation} 337 | {isActive && Active} 338 |
339 | 345 |
346 |
347 | 348 |
349 |
350 |
351 | ); 352 | } 353 | -------------------------------------------------------------------------------- /webext/src/popup/lib/pages/Logs.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "preact/hooks"; 2 | 3 | interface Log { 4 | id: number; 5 | log: string; 6 | } 7 | 8 | export default function Page() { 9 | const [logsAvailable, setLogsAvailable] = useState(false); 10 | const [logs, setLogs] = useState([]); 11 | 12 | const onMessage = (message: any) => { 13 | if (message.method == "popup/log") { 14 | let id = message.id as number; 15 | let log = message.log as string; 16 | pushLog({ id, log }); 17 | } 18 | }; 19 | 20 | const onConnect = (port: chrome.runtime.Port) => { 21 | port.onMessage.addListener(onMessage); 22 | }; 23 | 24 | useEffect(() => { 25 | if (window.chrome?.runtime?.onConnect == null) return; 26 | chrome.runtime.onConnect.addListener(onConnect); 27 | setLogsAvailable(true); 28 | return () => { 29 | chrome.runtime.onConnect.removeListener(onConnect); 30 | }; 31 | }); 32 | 33 | const pushLog = ({ id, log }: { id: number; log: string }) => { 34 | setLogs((logs) => [...logs, { id, log }]); 35 | }; 36 | 37 | const clearLogs = () => { 38 | setLogs([]); 39 | }; 40 | 41 | let logsGrouped = []; 42 | let prevGroup: { id: number; logs: string[] } | null = null; 43 | for (let { id, log } of logs) { 44 | if (prevGroup == null || prevGroup.id != id) { 45 | prevGroup = { id, logs: [log] }; 46 | logsGrouped.push(prevGroup); 47 | } else { 48 | prevGroup.logs.push(log); 49 | } 50 | } 51 | 52 | return ( 53 |
54 | {/* Header */} 55 |
56 |

Logs

57 | 60 |
61 | 62 | {/* Contents */} 63 |
64 | {logsGrouped.map(({ id, logs }) => { 65 | // show id only on the first log in a group 66 | let displayId = true; 67 | return ( 68 |
69 | {logs.map((log) => { 70 | let res = ( 71 |
72 | {displayId && id + ": "} 73 | {log} 74 |
75 | ); 76 | displayId = false; 77 | return res; 78 | })} 79 |
80 | ); 81 | })} 82 | {!logsAvailable && 83 | "Unable to connect to the extension runtime. Logs not available."} 84 | {logsAvailable && logs.length == 0 && "Empty."} 85 |
86 |
87 | ); 88 | } 89 | -------------------------------------------------------------------------------- /webext/src/popup/lib/pages/Network.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "preact/hooks"; 2 | import * as State from "../State"; 3 | import { bindInput } from "../utils"; 4 | import { OptionButton, OptionButtons } from "../OptionButtons"; 5 | 6 | const CARD_WIDTH = "20rem"; 7 | 8 | export default function Page() { 9 | let backends = State.backends.value; 10 | 11 | let [adding, setAdding] = useState(false); 12 | 13 | const onSave = async (backend: State.BackendDef) => { 14 | await State.backendsAdd(backend); 15 | setAdding(false); 16 | }; 17 | 18 | return ( 19 | <> 20 |
21 |
22 |

Backend Providers

23 | {!adding && ( 24 | 28 | )} 29 |
30 | 31 | {adding && ( 32 | setAdding(false)} 36 | /> 37 | )} 38 |
39 | 40 | {Object.entries(backends).map(([backendId, backend]) => ( 41 | <> 42 | 43 |
44 | 45 | ))} 46 | 47 | ); 48 | } 49 | 50 | function BackendForm({ 51 | title, 52 | backend, 53 | onSave, 54 | onClose, 55 | }: { 56 | title: string; 57 | backend?: State.BackendDef; 58 | onSave: (backend: State.BackendDef) => void; 59 | onClose: () => void; 60 | }) { 61 | let originalBackend = { 62 | name: "", 63 | type: "blockfrost" as State.BackendDef["type"], 64 | projectId: "", 65 | blockfrostUrl: undefined as (string | undefined), 66 | ogmiosUrl: "", 67 | kupoUrl: "", 68 | }; 69 | if (backend != null) { 70 | originalBackend.name = backend.name; 71 | originalBackend.type = backend.type; 72 | if (backend.type == "blockfrost") { 73 | originalBackend.projectId = backend.projectId; 74 | originalBackend.blockfrostUrl = backend.url || undefined; 75 | } else if (backend.type == "ogmios_kupo") { 76 | originalBackend.ogmiosUrl = backend.ogmiosUrl; 77 | originalBackend.kupoUrl = backend.kupoUrl; 78 | } 79 | } 80 | 81 | let [type, setType] = useState(originalBackend.type); 82 | 83 | let [name, setName] = useState(originalBackend.name); 84 | let [projectId, setProjectId] = useState(originalBackend.projectId); 85 | let [blockfrostUrl, setBlockfrostUrl] = useState(originalBackend.blockfrostUrl); 86 | let [ogmiosUrl, setOgmiosUrl] = useState(originalBackend.ogmiosUrl); 87 | let [kupoUrl, setKupoUrl] = useState(originalBackend.kupoUrl); 88 | 89 | let [error, setError] = useState(""); 90 | 91 | const onSubmit = async () => { 92 | let backend; 93 | if (type == "blockfrost") { 94 | backend = { type, name, projectId, url: blockfrostUrl }; 95 | } else if (type == "ogmios_kupo") { 96 | backend = { type, name, ogmiosUrl, kupoUrl }; 97 | } else { 98 | setError("Invalid type"); 99 | return; 100 | } 101 | onSave(backend); 102 | }; 103 | 104 | return ( 105 |
106 |
107 |
{title}
108 |
109 | 112 | 115 |
116 |
117 | 126 | 143 | 144 | {type == "blockfrost" && ( 145 | <> 146 | 155 | 164 | 165 | )} 166 | 167 | {type == "ogmios_kupo" && ( 168 | <> 169 | 178 | 187 | 188 | )} 189 | 190 |
{error}
191 |
192 | ); 193 | } 194 | 195 | function Backend({ 196 | backendId, 197 | backend, 198 | }: { 199 | backendId: string; 200 | backend: State.BackendDef; 201 | }) { 202 | let [editing, setEditing] = useState(false); 203 | 204 | const onEdit = async (backend: State.BackendDef) => { 205 | await State.backendsUpdate(backendId, backend); 206 | setEditing(false); 207 | }; 208 | 209 | return ( 210 | <> 211 | {!editing ? ( 212 | setEditing(true)} 216 | showButtons 217 | /> 218 | ) : ( 219 | setEditing(false)} 224 | /> 225 | )} 226 | 227 | ); 228 | } 229 | 230 | function BackendView({ 231 | backendId, 232 | backend, 233 | showButtons, 234 | onEdit, 235 | }: { 236 | backendId: string; 237 | backend: State.BackendDef; 238 | showButtons: boolean; 239 | onEdit: () => void; 240 | }) { 241 | const onConfirmDelete = async () => { 242 | await State.backendsDelete(backendId); 243 | }; 244 | 245 | const setActive = async () => { 246 | await State.backendsActiveSet(backendId); 247 | }; 248 | 249 | let activeId = State.backendsActiveId.value; 250 | let isActive = backendId == activeId; 251 | 252 | let backendType = "Unknown"; 253 | if (backend.type == "blockfrost") backendType = "Blockfrost"; 254 | else if (backend.type == "ogmios_kupo") backendType = "Ogmios/Kupo"; 255 | 256 | let setActiveButton: OptionButton[] = []; 257 | if (!isActive) setActiveButton = [{ text: "Set Active", onClick: setActive }]; 258 | 259 | return ( 260 |
261 |
262 |
263 |
264 |

267 | {backend.name || "Unnamed"} 268 |

269 | {showButtons && ( 270 | 275 | )} 276 |
277 |
{backendType}
278 |
{isActive ? "Active" : ""}
279 |
280 |
281 | 282 | {backend.type == "blockfrost" && ( 283 | <> 284 |
285 | 286 |
{backend.projectId}
287 |
288 |
289 | 290 |
{backend.url || "(default)"}
291 |
292 | 293 | )} 294 | 295 | {backend.type == "ogmios_kupo" && ( 296 | <> 297 |
298 | 299 |
{backend.ogmiosUrl}
300 |
301 | 302 |
303 | 304 |
{backend.kupoUrl}
305 |
306 | 307 | )} 308 |
309 | ); 310 | } 311 | function BackendOptionButtons({ 312 | setActiveButton, 313 | onEdit, 314 | onConfirmDelete, 315 | }: { 316 | setActiveButton: OptionButton[]; 317 | onEdit: () => void; 318 | onConfirmDelete: () => Promise; 319 | }) { 320 | return ( 321 |
322 | 342 |
343 | ); 344 | } 345 | -------------------------------------------------------------------------------- /webext/src/popup/lib/pages/ShortenedLabel.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "preact/hooks"; 2 | import { ellipsizeMiddle } from "../utils"; 3 | 4 | export interface ShortenedLabelProps { 5 | classes: string; 6 | text: string; 7 | prefixLen: number; 8 | suffixLen: number; 9 | } 10 | 11 | export function ShortenedLabel({ 12 | classes, 13 | text, 14 | prefixLen, 15 | suffixLen, 16 | }: ShortenedLabelProps) { 17 | const onCopy = () => { 18 | setCopyBtn("copied"); 19 | navigator.clipboard.writeText(text); 20 | setTimeout(() => { 21 | setCopyBtn("copy") 22 | }, 1000); 23 | }; 24 | 25 | let [copyBtn, setCopyBtn] = useState("copy"); 26 | 27 | let textShrunk = ellipsizeMiddle(text, prefixLen, suffixLen); 28 | return ( 29 |
30 | {textShrunk} 31 | 35 |
36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /webext/src/popup/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { BigNum } from "@emurgo/cardano-serialization-lib-browser"; 2 | import { Big } from "big.js"; 3 | 4 | export function lovelaceToAda(lovelace: BigNum): Big { 5 | let lovelaceJs = new Big(lovelace.to_str()); 6 | return lovelaceJs.div("1e6"); 7 | } 8 | 9 | export function bindInput( 10 | fn: (val: string) => void, 11 | ): preact.JSX.InputEventHandler { 12 | return (ev) => fn(ev.currentTarget.value); 13 | } 14 | 15 | export function bindInputNum( 16 | curVal: string, 17 | fn: (val: string) => void, 18 | ): preact.JSX.InputEventHandler { 19 | return (ev) => { 20 | let newVal = ev.currentTarget.value; 21 | if (/^[0-9]*(\.[0-9]*)?$/.test(newVal)) { 22 | fn(newVal); 23 | } else { 24 | let input = ev.currentTarget; 25 | let selectionStart = input.selectionStart; 26 | let selectionEnd = input.selectionEnd; 27 | input.value = curVal; 28 | if (selectionStart != null && selectionStart == selectionEnd) { 29 | input.selectionStart = selectionStart - 1; 30 | input.selectionEnd = selectionEnd - 1; 31 | } else { 32 | input.selectionStart = selectionStart; 33 | input.selectionEnd = selectionEnd; 34 | } 35 | } 36 | }; 37 | } 38 | 39 | export function ellipsizeMiddle( 40 | s: string, 41 | startChars: number, 42 | endChars: number, 43 | ): string { 44 | return s.slice(0, startChars) + "..." + s.slice(s.length - endChars); 45 | } 46 | -------------------------------------------------------------------------------- /webext/src/popup/static/fonts/Inter-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlabs-haskell/cardano-dev-wallet/e846a7c5d2747609d791d7d47d5423616718cb34/webext/src/popup/static/fonts/Inter-Bold.ttf -------------------------------------------------------------------------------- /webext/src/popup/static/fonts/Inter-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlabs-haskell/cardano-dev-wallet/e846a7c5d2747609d791d7d47d5423616718cb34/webext/src/popup/static/fonts/Inter-Regular.ttf -------------------------------------------------------------------------------- /webext/src/popup/static/fonts/JetBrainsMono-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlabs-haskell/cardano-dev-wallet/e846a7c5d2747609d791d7d47d5423616718cb34/webext/src/popup/static/fonts/JetBrainsMono-Bold.ttf -------------------------------------------------------------------------------- /webext/src/popup/static/fonts/JetBrainsMono-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlabs-haskell/cardano-dev-wallet/e846a7c5d2747609d791d7d47d5423616718cb34/webext/src/popup/static/fonts/JetBrainsMono-Regular.ttf -------------------------------------------------------------------------------- /webext/src/popup/static/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 18 | 20 | 38 | 47 | 52 | 56 | 57 | 60 | 69 | DEV 80 | 81 | 82 | -------------------------------------------------------------------------------- /webext/src/popup/static/icons/add.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /webext/src/popup/static/icons/close.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /webext/src/popup/static/icons/copy.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /webext/src/popup/static/icons/delete.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /webext/src/popup/static/icons/edit.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /webext/src/popup/static/icons/expand-down.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /webext/src/popup/static/icons/expand-left.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /webext/src/popup/static/icons/expand-right.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /webext/src/popup/static/icons/expand-up.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /webext/src/popup/static/icons/hidden.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /webext/src/popup/static/icons/save.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /webext/src/popup/static/icons/visible.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /webext/src/popup/static/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlabs-haskell/cardano-dev-wallet/e846a7c5d2747609d791d7d47d5423616718cb34/webext/src/popup/static/logo.png -------------------------------------------------------------------------------- /webext/src/popup/static/normalize.css: -------------------------------------------------------------------------------- 1 | /*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */ 2 | 3 | /* Document 4 | ========================================================================== */ 5 | 6 | /** 7 | * 1. Correct the line height in all browsers. 8 | * 2. Prevent adjustments of font size after orientation changes in iOS. 9 | */ 10 | 11 | html { 12 | line-height: 1.15; /* 1 */ 13 | -webkit-text-size-adjust: 100%; /* 2 */ 14 | } 15 | 16 | /* Sections 17 | ========================================================================== */ 18 | 19 | /** 20 | * Remove the margin in all browsers. 21 | */ 22 | 23 | body { 24 | margin: 0; 25 | } 26 | 27 | /** 28 | * Render the `main` element consistently in IE. 29 | */ 30 | 31 | main { 32 | display: block; 33 | } 34 | 35 | /** 36 | * Correct the font size and margin on `h1` elements within `section` and 37 | * `article` contexts in Chrome, Firefox, and Safari. 38 | */ 39 | 40 | h1 { 41 | font-size: 2em; 42 | margin: 0.67em 0; 43 | } 44 | 45 | /* Grouping content 46 | ========================================================================== */ 47 | 48 | /** 49 | * 1. Add the correct box sizing in Firefox. 50 | * 2. Show the overflow in Edge and IE. 51 | */ 52 | 53 | hr { 54 | box-sizing: content-box; /* 1 */ 55 | height: 0; /* 1 */ 56 | overflow: visible; /* 2 */ 57 | } 58 | 59 | /** 60 | * 1. Correct the inheritance and scaling of font size in all browsers. 61 | * 2. Correct the odd `em` font sizing in all browsers. 62 | */ 63 | 64 | pre { 65 | font-family: monospace, monospace; /* 1 */ 66 | font-size: 1em; /* 2 */ 67 | } 68 | 69 | /* Text-level semantics 70 | ========================================================================== */ 71 | 72 | /** 73 | * Remove the gray background on active links in IE 10. 74 | */ 75 | 76 | a { 77 | background-color: transparent; 78 | } 79 | 80 | /** 81 | * 1. Remove the bottom border in Chrome 57- 82 | * 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari. 83 | */ 84 | 85 | abbr[title] { 86 | border-bottom: none; /* 1 */ 87 | text-decoration: underline; /* 2 */ 88 | text-decoration: underline dotted; /* 2 */ 89 | } 90 | 91 | /** 92 | * Add the correct font weight in Chrome, Edge, and Safari. 93 | */ 94 | 95 | b, 96 | strong { 97 | font-weight: bolder; 98 | } 99 | 100 | /** 101 | * 1. Correct the inheritance and scaling of font size in all browsers. 102 | * 2. Correct the odd `em` font sizing in all browsers. 103 | */ 104 | 105 | code, 106 | kbd, 107 | samp { 108 | font-family: monospace, monospace; /* 1 */ 109 | font-size: 1em; /* 2 */ 110 | } 111 | 112 | /** 113 | * Add the correct font size in all browsers. 114 | */ 115 | 116 | small { 117 | font-size: 80%; 118 | } 119 | 120 | /** 121 | * Prevent `sub` and `sup` elements from affecting the line height in 122 | * all browsers. 123 | */ 124 | 125 | sub, 126 | sup { 127 | font-size: 75%; 128 | line-height: 0; 129 | position: relative; 130 | vertical-align: baseline; 131 | } 132 | 133 | sub { 134 | bottom: -0.25em; 135 | } 136 | 137 | sup { 138 | top: -0.5em; 139 | } 140 | 141 | /* Embedded content 142 | ========================================================================== */ 143 | 144 | /** 145 | * Remove the border on images inside links in IE 10. 146 | */ 147 | 148 | img { 149 | border-style: none; 150 | } 151 | 152 | /* Forms 153 | ========================================================================== */ 154 | 155 | /** 156 | * 1. Change the font styles in all browsers. 157 | * 2. Remove the margin in Firefox and Safari. 158 | */ 159 | 160 | button, 161 | input, 162 | optgroup, 163 | select, 164 | textarea { 165 | font-family: inherit; /* 1 */ 166 | font-size: 100%; /* 1 */ 167 | line-height: 1.15; /* 1 */ 168 | margin: 0; /* 2 */ 169 | } 170 | 171 | /** 172 | * Show the overflow in IE. 173 | * 1. Show the overflow in Edge. 174 | */ 175 | 176 | button, 177 | input { /* 1 */ 178 | overflow: visible; 179 | } 180 | 181 | /** 182 | * Remove the inheritance of text transform in Edge, Firefox, and IE. 183 | * 1. Remove the inheritance of text transform in Firefox. 184 | */ 185 | 186 | button, 187 | select { /* 1 */ 188 | text-transform: none; 189 | } 190 | 191 | /** 192 | * Correct the inability to style clickable types in iOS and Safari. 193 | */ 194 | 195 | button, 196 | [type="button"], 197 | [type="reset"], 198 | [type="submit"] { 199 | -webkit-appearance: button; 200 | } 201 | 202 | /** 203 | * Remove the inner border and padding in Firefox. 204 | */ 205 | 206 | button::-moz-focus-inner, 207 | [type="button"]::-moz-focus-inner, 208 | [type="reset"]::-moz-focus-inner, 209 | [type="submit"]::-moz-focus-inner { 210 | border-style: none; 211 | padding: 0; 212 | } 213 | 214 | /** 215 | * Restore the focus styles unset by the previous rule. 216 | */ 217 | 218 | button:-moz-focusring, 219 | [type="button"]:-moz-focusring, 220 | [type="reset"]:-moz-focusring, 221 | [type="submit"]:-moz-focusring { 222 | outline: 1px dotted ButtonText; 223 | } 224 | 225 | /** 226 | * Correct the padding in Firefox. 227 | */ 228 | 229 | fieldset { 230 | padding: 0.35em 0.75em 0.625em; 231 | } 232 | 233 | /** 234 | * 1. Correct the text wrapping in Edge and IE. 235 | * 2. Correct the color inheritance from `fieldset` elements in IE. 236 | * 3. Remove the padding so developers are not caught out when they zero out 237 | * `fieldset` elements in all browsers. 238 | */ 239 | 240 | legend { 241 | box-sizing: border-box; /* 1 */ 242 | color: inherit; /* 2 */ 243 | display: table; /* 1 */ 244 | max-width: 100%; /* 1 */ 245 | padding: 0; /* 3 */ 246 | white-space: normal; /* 1 */ 247 | } 248 | 249 | /** 250 | * Add the correct vertical alignment in Chrome, Firefox, and Opera. 251 | */ 252 | 253 | progress { 254 | vertical-align: baseline; 255 | } 256 | 257 | /** 258 | * Remove the default vertical scrollbar in IE 10+. 259 | */ 260 | 261 | textarea { 262 | overflow: auto; 263 | } 264 | 265 | /** 266 | * 1. Add the correct box sizing in IE 10. 267 | * 2. Remove the padding in IE 10. 268 | */ 269 | 270 | [type="checkbox"], 271 | [type="radio"] { 272 | box-sizing: border-box; /* 1 */ 273 | padding: 0; /* 2 */ 274 | } 275 | 276 | /** 277 | * Correct the cursor style of increment and decrement buttons in Chrome. 278 | */ 279 | 280 | [type="number"]::-webkit-inner-spin-button, 281 | [type="number"]::-webkit-outer-spin-button { 282 | height: auto; 283 | } 284 | 285 | /** 286 | * 1. Correct the odd appearance in Chrome and Safari. 287 | * 2. Correct the outline style in Safari. 288 | */ 289 | 290 | [type="search"] { 291 | -webkit-appearance: textfield; /* 1 */ 292 | outline-offset: -2px; /* 2 */ 293 | } 294 | 295 | /** 296 | * Remove the inner padding in Chrome and Safari on macOS. 297 | */ 298 | 299 | [type="search"]::-webkit-search-decoration { 300 | -webkit-appearance: none; 301 | } 302 | 303 | /** 304 | * 1. Correct the inability to style clickable types in iOS and Safari. 305 | * 2. Change font properties to `inherit` in Safari. 306 | */ 307 | 308 | ::-webkit-file-upload-button { 309 | -webkit-appearance: button; /* 1 */ 310 | font: inherit; /* 2 */ 311 | } 312 | 313 | /* Interactive 314 | ========================================================================== */ 315 | 316 | /* 317 | * Add the correct display in Edge, IE 10+, and Firefox. 318 | */ 319 | 320 | details { 321 | display: block; 322 | } 323 | 324 | /* 325 | * Add the correct display in all browsers. 326 | */ 327 | 328 | summary { 329 | display: list-item; 330 | } 331 | 332 | /* Misc 333 | ========================================================================== */ 334 | 335 | /** 336 | * Add the correct display in IE 10+. 337 | */ 338 | 339 | template { 340 | display: none; 341 | } 342 | 343 | /** 344 | * Add the correct display in IE 10. 345 | */ 346 | 347 | [hidden] { 348 | display: none; 349 | } 350 | -------------------------------------------------------------------------------- /webext/src/popup/styles.scss: -------------------------------------------------------------------------------- 1 | @use "sass:map"; 2 | @use "sass:color"; 3 | @use "sass:math"; 4 | 5 | @font-face { 6 | font-family: "JetBrainsMono"; 7 | src: url("./static/fonts/JetBrainsMono-Regular.ttf"); 8 | font-weight: normal; 9 | } 10 | 11 | @font-face { 12 | font-family: "JetBrainsMono"; 13 | src: url("./static/fonts/JetBrainsMono-Bold.ttf"); 14 | font-weight: bold; 15 | } 16 | 17 | @font-face { 18 | font-family: "Inter"; 19 | src: url("./static/fonts/Inter-Regular.ttf"); 20 | font-weight: normal; 21 | } 22 | 23 | @font-face { 24 | font-family: "Inter"; 25 | src: url("./static/fonts/Inter-Bold.ttf"); 26 | font-weight: bold; 27 | } 28 | 29 | $color-action: #ad002a; 30 | $color-accent: #0033ad; 31 | $color-text: #000000; 32 | $color-secondary: rgb(0, 0, 0, 0.6); 33 | $color-background: #ffffff; 34 | 35 | * { 36 | box-sizing: border-box; 37 | margin: 0; 38 | padding: 0; 39 | border: none; 40 | text-decoration: none; 41 | font: inherit; 42 | color: inherit; 43 | background: transparent; 44 | transition: 45 | background-color 0.2s ease-out, 46 | color 0.2s, 47 | opacity 0.05s ease-out; 48 | 49 | gap: var(--gap); 50 | flex-direction: var(--flex-direction); 51 | } 52 | 53 | div { 54 | display: flex; 55 | } 56 | 57 | h1, 58 | h2, 59 | h3, 60 | h4, 61 | h5, 62 | h6 { 63 | margin: 0; 64 | padding: 0; 65 | font: inherit; 66 | } 67 | 68 | body { 69 | padding: 40px; 70 | background: $color-background; 71 | 72 | font-family: "Inter", sans-serif; 73 | 74 | --flex-direction: column; 75 | 76 | font-size: 16px; 77 | } 78 | 79 | .mono { 80 | font-family: "JetBrainsMono", monospace; 81 | } 82 | 83 | // Flex //////////////////////////////////////////////////////////////////////// 84 | 85 | .column { 86 | display: flex; 87 | --flex-direction: column; 88 | 89 | justify-content: start; 90 | align-items: stretch; 91 | } 92 | 93 | .row { 94 | display: flex; 95 | --flex-direction: row; 96 | 97 | justify-content: start; 98 | align-items: start; 99 | } 100 | 101 | .column .expand-child { 102 | padding-top: calc(var(--gap) / 2); 103 | padding-bottom: calc(var(--gap) / 2); 104 | margin-top: calc(-1 * var(--gap) / 2); 105 | margin-bottom: calc(-1 * var(--gap) / 2); 106 | } 107 | 108 | .row .expand-child { 109 | padding-left: calc(var(--gap) / 2); 110 | padding-right: calc(var(--gap) / 2); 111 | margin-left: calc(-1 * var(--gap) / 2); 112 | margin-right: calc(-1 * var(--gap) / 2); 113 | } 114 | 115 | .equalize-children>* { 116 | flex-grow: 1; 117 | width: 0; 118 | } 119 | 120 | .gap-none { 121 | --gap: 0px; 122 | } 123 | 124 | .gap-s { 125 | --gap: 8px; 126 | } 127 | 128 | .gap-m { 129 | --gap: 16px; 130 | } 131 | 132 | .gap-l { 133 | --gap: 28px; 134 | } 135 | 136 | .gap-xl { 137 | --gap: 32px; 138 | } 139 | 140 | .gap-xxl { 141 | --gap: 64px; 142 | } 143 | 144 | .align-start { 145 | align-items: start; 146 | } 147 | 148 | .align-center { 149 | align-items: center; 150 | } 151 | 152 | .align-end { 153 | align-items: end; 154 | } 155 | 156 | .align-baseline { 157 | align-items: baseline; 158 | } 159 | 160 | .align-stretch { 161 | align-items: stretch; 162 | } 163 | 164 | .justify-start { 165 | justify-content: start; 166 | } 167 | 168 | .justify-center { 169 | justify-content: center; 170 | } 171 | 172 | .justify-end { 173 | justify-content: end; 174 | } 175 | 176 | .justify-stretch { 177 | justify-content: stretch; 178 | } 179 | 180 | .justify-space { 181 | justify-content: space-between; 182 | } 183 | 184 | .column hr { 185 | height: 1px; 186 | border-top: solid 1px color.scale($color-secondary, $alpha: -80%); 187 | margin-top: calc(-1 * var(--gap) / 2); 188 | margin-bottom: calc(-1 * var(--gap) / 2); 189 | } 190 | 191 | // 192 | 193 | .button { 194 | @extend %focus-outline; 195 | 196 | font-size: 1em; 197 | font-weight: bold; 198 | cursor: pointer; 199 | 200 | display: flex; 201 | align-items: end; 202 | 203 | --color: #{$color-action}; 204 | color: var(--color); 205 | 206 | text-transform: uppercase; 207 | 208 | display: flex; 209 | flex-direction: row; 210 | --gap: 5px; 211 | 212 | .icon { 213 | height: 1.0em; 214 | width: 1.0em; 215 | 216 | -webkit-mask-size: 100%; 217 | mask-size: 100%; 218 | 219 | -webkit-mask-repeat: no-repeat; 220 | mask-repeat: no-repeat; 221 | 222 | // workaround chrome line height bug 223 | margin-bottom: 2px; 224 | 225 | background-color: var(--color); 226 | } 227 | 228 | &:hover { 229 | --color: #{color.scale($color-action, $lightness: 10%)}; 230 | } 231 | 232 | &:active { 233 | --color: #{color.scale($color-action, $lightness: -10%)}; 234 | } 235 | 236 | &.-secondary { 237 | --color: #{$color-secondary}; 238 | 239 | &:hover { 240 | --color: #{color.scale($color-secondary, $lightness: 10%)}; 241 | } 242 | 243 | &:active { 244 | --color: #{color.scale($color-secondary, $lightness: -10%)}; 245 | } 246 | } 247 | } 248 | 249 | %focus-outline { 250 | 251 | &:focus, 252 | &:focus-visible { 253 | outline: dashed 2px $color-action; 254 | outline-offset: 2px; 255 | } 256 | 257 | &:not(:focus-visible) { 258 | outline: none; 259 | } 260 | } 261 | 262 | // Header 263 | 264 | .header { 265 | display: flex; 266 | flex-direction: row; 267 | justify-content: space-between; 268 | align-items: center; 269 | } 270 | 271 | .header-left { 272 | display: flex; 273 | flex-direction: row; 274 | --gap: 32px; 275 | align-items: center; 276 | } 277 | 278 | .logo { 279 | display: flex; 280 | flex-direction: row; 281 | --gap: 18px; 282 | 283 | img { 284 | height: 64px; 285 | object-fit: contain; 286 | } 287 | } 288 | 289 | .logo-text-box { 290 | display: flex; 291 | flex-direction: column; 292 | 293 | color: $color-accent; 294 | } 295 | 296 | .logo-title { 297 | font-size: 32px; 298 | font-weight: bold; 299 | } 300 | 301 | .logo-subtitle { 302 | font-size: 16px; 303 | font-weight: bold; 304 | text-transform: uppercase; 305 | letter-spacing: 18%; 306 | } 307 | 308 | .header-nav { 309 | display: flex; 310 | flex-direction: row; 311 | --gap: 32px; 312 | align-items: center; 313 | } 314 | 315 | .nav-item { 316 | font-family: "Inter", sans-serif; 317 | font-weight: bold; 318 | text-transform: uppercase; 319 | font-size: 17px; 320 | padding-bottom: 5px; 321 | border-bottom: solid 2px transparent; 322 | 323 | cursor: pointer; 324 | 325 | &.-active, 326 | &:hover { 327 | border-bottom-color: $color-action; 328 | } 329 | } 330 | 331 | // Utility 332 | 333 | .color-accent { 334 | color: $color-accent; 335 | } 336 | 337 | .color-action { 338 | color: $color-action; 339 | } 340 | 341 | .color-text { 342 | color: $color-text; 343 | } 344 | 345 | .color-secondary { 346 | color: $color-secondary; 347 | } 348 | 349 | label { 350 | @extend .column, .gap-s; 351 | } 352 | 353 | .label { 354 | font-size: 17px; 355 | font-weight: bold; 356 | text-transform: uppercase; 357 | } 358 | 359 | .label-sub { 360 | font-size: 12px; 361 | font-weight: bold; 362 | text-transform: uppercase; 363 | } 364 | 365 | .label-mono { 366 | @extend .mono; 367 | @extend .color-secondary; 368 | 369 | font-size: 15px; 370 | font-weight: bold; 371 | text-transform: uppercase; 372 | } 373 | 374 | .label-mono-sub { 375 | @extend .mono; 376 | @extend .color-secondary; 377 | 378 | font-size: 12px; 379 | font-weight: bold; 380 | text-transform: uppercase; 381 | } 382 | 383 | .currency { 384 | display: flex; 385 | flex-direction: row; 386 | --gap: 8px; 387 | 388 | align-items: baseline; 389 | 390 | .-amount { 391 | @extend .L1; 392 | } 393 | 394 | .-unit { 395 | @extend .color-accent; 396 | @extend .L2; 397 | } 398 | } 399 | 400 | .currency.-small { 401 | .-amount { 402 | @extend .L2; 403 | } 404 | 405 | .-unit { 406 | @extend .L3; 407 | } 408 | } 409 | 410 | .currency.-xsmall { 411 | .-amount { 412 | @extend .L3; 413 | } 414 | 415 | .-unit { 416 | @extend .L4; 417 | } 418 | } 419 | 420 | .L1 { 421 | font-weight: bold; 422 | font-size: 48px; 423 | } 424 | 425 | .L2 { 426 | font-weight: bold; 427 | font-size: 32px; 428 | } 429 | 430 | .L3 { 431 | font-weight: bold; 432 | font-size: 22px; 433 | } 434 | 435 | .L4 { 436 | font-weight: bold; 437 | font-size: 17px; 438 | } 439 | 440 | .L5 { 441 | font-weight: bold; 442 | font-size: 15px; 443 | } 444 | 445 | .icon { 446 | @each $icon in "edit", "close", "save", "add", "delete", "visible", "hidden", 447 | "expand-left", "expand-right", "expand-up", "expand-down", "copy" 448 | 449 | { 450 | &.-#{$icon} { 451 | -webkit-mask-image: url("static/icons/#{$icon}.svg"); 452 | mask-image: url("static/icons/#{$icon}.svg"); 453 | } 454 | } 455 | } 456 | 457 | input, 458 | textarea { 459 | font-size: 16px; 460 | font-weight: normal; 461 | } 462 | 463 | input { 464 | padding-bottom: 4px; 465 | border-bottom: dashed 2px color.scale($color-action, $alpha: -80%); 466 | 467 | &:hover, 468 | &:focus { 469 | border-bottom: dashed 2px $color-action; 470 | outline: none; 471 | } 472 | 473 | &:read-only { 474 | border-bottom: solid 2px transparent; 475 | } 476 | } 477 | 478 | textarea { 479 | padding-bottom: 4px; 480 | border: dashed 2px color.scale($color-action, $alpha: -80%); 481 | 482 | &:hover, 483 | &:focus { 484 | border: dashed 2px $color-action; 485 | outline: none; 486 | } 487 | 488 | &:read-only { 489 | border: solid 2px transparent; 490 | } 491 | } 492 | 493 | .buttons { 494 | display: flex; 495 | flex-direction: column; 496 | align-items: start; 497 | --gap: 8px; 498 | } 499 | 500 | .display-none { 501 | display: none; 502 | } 503 | 504 | .hidden { 505 | visibility: hidden; 506 | } 507 | 508 | .faded { 509 | opacity: 50%; 510 | } 511 | 512 | .auto-hide { 513 | opacity: 0%; 514 | } 515 | 516 | .auto-hide, 517 | .auto-hide-parent:hover .auto-hide-parent:not(:hover) .auto-hide { 518 | opacity: 10%; 519 | } 520 | 521 | .auto-hide-parent:hover .auto-hide { 522 | opacity: 100%; 523 | } 524 | 525 | .caps { 526 | text-transform: uppercase; 527 | } 528 | 529 | .uncaps { 530 | text-transform: unset; 531 | } 532 | 533 | .pre-wrap { 534 | white-space: pre-wrap; 535 | overflow-wrap: break-word; 536 | } 537 | -------------------------------------------------------------------------------- /webext/src/popup/trampoline.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /webext/src/popup/trampoline.js: -------------------------------------------------------------------------------- 1 | chrome.tabs.create({ url: "/popup/index.html" }) 2 | -------------------------------------------------------------------------------- /webext/src/public/icon-128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlabs-haskell/cardano-dev-wallet/e846a7c5d2747609d791d7d47d5423616718cb34/webext/src/public/icon-128x128.png -------------------------------------------------------------------------------- /webext/src/public/icon-256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlabs-haskell/cardano-dev-wallet/e846a7c5d2747609d791d7d47d5423616718cb34/webext/src/public/icon-256x256.png -------------------------------------------------------------------------------- /webext/src/public/icon-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlabs-haskell/cardano-dev-wallet/e846a7c5d2747609d791d7d47d5423616718cb34/webext/src/public/icon-512x512.png -------------------------------------------------------------------------------- /webext/src/public/icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlabs-haskell/cardano-dev-wallet/e846a7c5d2747609d791d7d47d5423616718cb34/webext/src/public/icon-72x72.png -------------------------------------------------------------------------------- /webext/src/public/icon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlabs-haskell/cardano-dev-wallet/e846a7c5d2747609d791d7d47d5423616718cb34/webext/src/public/icon-96x96.png -------------------------------------------------------------------------------- /webext/src/public/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 18 | 20 | 38 | 47 | 52 | 56 | 57 | 60 | 69 | DEV 80 | 81 | 82 | -------------------------------------------------------------------------------- /webext/tests/README.md: -------------------------------------------------------------------------------- 1 | # How to run 2 | 3 | ### Start Plutip cluster 4 | 5 | * Go to `/plutip` in this repo. 6 | * Run `nix run .` to start the plutip cluster. 7 | 8 | ### Start CTL Test Server 9 | 10 | We need a few changes to the CTL tests: 11 | https://github.com/Plutonomicon/cardano-transaction-lib/pull/1606 12 | 13 | * Checkout the branch of the above PR 14 | * Run `npm i` to install the deps 15 | * Install `spago` and `purescript` as needed 16 | * Run `npm run esbuild-serve` 17 | * The test server should start at port `4008` 18 | 19 | ### Run E2E Tests 20 | 21 | * Wait for the Plutip cluster and the CTL Test Server to be up and running. 22 | * Go to `/webext` in this repo 23 | * Run `node build.js --test` to start the E2E test suite. 24 | -------------------------------------------------------------------------------- /webext/tests/example.spec.js: -------------------------------------------------------------------------------- 1 | import * as base from "@playwright/test"; 2 | import assert from "node:assert"; 3 | import * as path from "node:path"; 4 | import * as url from "node:url"; 5 | 6 | console.log("Starting"); 7 | 8 | const __dirname = url.fileURLToPath(new URL(".", import.meta.url)); 9 | const pathToExtension = path.join(__dirname, "../build"); 10 | 11 | const chromium = base.chromium; 12 | const expect = base.expect; 13 | 14 | const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms)); 15 | 16 | const POPUP_PAGE = "popup/index.html"; 17 | 18 | const OGMIOS_URL = process.env.OGMIOS_URL || "http://localhost:1337" 19 | const KUPO_URL = process.env.KUPO_URL || "http://localhost:1442" 20 | 21 | const CTL_TEST_URL = process.env.CTL_TEST_URL || "http://localhost:4008"; 22 | const CTL_TEST_WALLET = process.env.CTL_TEST_WALLET || "nami-mainnet"; 23 | const CTL_TEST_SUCCESS_MARKER = "[CTL TEST SUCCESS]"; 24 | 25 | const WALLET_NETWORK = 'mainnet'; 26 | 27 | const WALLET_ROOT_KEY = 28 | "adult buyer hover fetch affair moon arctic hidden doll gasp object dumb royal kite brave robust thumb speed shine nerve token budget blame welcome"; 29 | 30 | const BROKEN_TESTS = [ 31 | "ReferenceInputsAndScripts", 32 | "TxChaining", 33 | "SendsToken", 34 | "Utxos", 35 | ]; 36 | 37 | const test = base.test.extend({ 38 | context: async ({ browserName }, use) => { 39 | let context; 40 | if (browserName == "chromium") { 41 | context = await chromium.launchPersistentContext("", { 42 | headless: false, 43 | args: [ 44 | `--disable-extensions-except=${pathToExtension}`, 45 | `--load-extension=${pathToExtension}`, 46 | ], 47 | }); 48 | } else { 49 | throw new Error("Browser not supported: " + browser); 50 | } 51 | await use(context); 52 | await context.close(); 53 | }, 54 | extensionId: async ({ context }, use) => { 55 | // for manifest v3: 56 | let [background] = context.serviceWorkers(); 57 | if (!background) background = await context.waitForEvent("serviceworker"); 58 | const extensionId = background.url().split("/")[2]; 59 | await use(extensionId); 60 | }, 61 | }); 62 | 63 | test("Open popup", async ({ extensionId, page, context }) => { 64 | test.setTimeout(1e9); 65 | 66 | console.log(extensionId); 67 | 68 | let extPage = page; 69 | extPage.goto("chrome-extension://" + extensionId + "/" + POPUP_PAGE); 70 | 71 | await extPage.bringToFront(); 72 | await extPage.getByText(WALLET_NETWORK).click(); 73 | 74 | // Setup Network 75 | { 76 | await extPage.getByText("Network", { exact: true }).click(); 77 | 78 | await extPage.getByText("Add", { exact: true }).click(); 79 | 80 | await extPage.getByLabel("Name", { exact: true }).fill("Ogmios/Kupo"); 81 | await extPage 82 | .locator("label", { hasText: "Type" }) 83 | .locator("button", { hasText: "Ogmios/Kupo" }) 84 | .click(); 85 | 86 | await extPage 87 | .getByLabel("Ogmios URL") 88 | .fill(OGMIOS_URL); 89 | 90 | await extPage 91 | .getByLabel("Kupo URL") 92 | .fill(KUPO_URL); 93 | 94 | await extPage.getByText("Save", { exact: true }).click(); 95 | } 96 | 97 | // Setup Accounts 98 | { 99 | await extPage.getByText("Accounts", { exact: true }).click(); 100 | 101 | await extPage.getByText("Add Wallet", { exact: true }).click(); 102 | 103 | await extPage.getByLabel("Name", { exact: true }).fill("Test Wallet"); 104 | await extPage 105 | .getByLabel("Root Key or Mnemonics", { exact: true }) 106 | .fill(WALLET_ROOT_KEY); 107 | 108 | await extPage.getByText("Save", { exact: true }).click(); 109 | 110 | await extPage.getByText("Add Account", { exact: true }).click(); 111 | await extPage.getByLabel("m(1852'/1815'/_)", { exact: true }).fill("0"); 112 | 113 | await extPage.getByText("Save", { exact: true }).click(); 114 | 115 | } 116 | 117 | await extPage.getByText("Overview", { exact: true }).click(); 118 | 119 | let balance = extPage 120 | .locator("article", { 121 | hasText: "Balance", 122 | hasNot: extPage.locator("article"), 123 | }) 124 | .locator("input"); 125 | 126 | // Wait for page to load 127 | await expect(balance).not.toHaveValue("...", { timeout: 100000 }); 128 | 129 | { 130 | let page = await context.newPage(); 131 | await page.bringToFront(); 132 | 133 | await page.goto(CTL_TEST_URL); 134 | 135 | await page 136 | .locator(":has-text('Example') + select").waitFor() 137 | let tests = await page 138 | .locator(":has-text('Example') + select") 139 | .locator("option") 140 | .all() 141 | .then((options) => 142 | Promise.all(options.map((option) => option.textContent())), 143 | ); 144 | 145 | const runTest = async (testName) => { 146 | 147 | let testDone = new Promise(async (resolve, reject) => { 148 | let exited = false; 149 | 150 | const onConsole = (msg) => { 151 | if (msg.text().includes(CTL_TEST_SUCCESS_MARKER)) { 152 | resolve() 153 | } 154 | } 155 | const onError = (error) => { 156 | if (!exited) { 157 | cleanup(); 158 | reject(`[${testName}]: Error thrown by test: ` + error.message); 159 | exited = true; 160 | return; 161 | } else { 162 | console.error(testName + " :: Uncaught error", error); 163 | } 164 | } 165 | 166 | const setup = () => { 167 | page.on('console', onConsole); 168 | page.on('pageerror', onError); 169 | }; 170 | 171 | const cleanup = () => { 172 | page.removeListener('console', onConsole); 173 | page.removeListener('pageerror', onError); 174 | }; 175 | 176 | setup(); 177 | 178 | await page.goto( 179 | CTL_TEST_URL + "?" + CTL_TEST_WALLET + ":" + testName, 180 | { waitUntil: "load" }, 181 | ); 182 | }); 183 | return testDone; 184 | }; 185 | 186 | console.log("Tests discovered:", tests); 187 | console.log() 188 | let passedTests = []; 189 | let failedTests = []; 190 | 191 | for (let i = 0; i < tests.length; i++) { 192 | let test = tests[i]; 193 | let progress = `[${i + 1}/${tests.length}]` 194 | console.log(progress + " Test:", test); 195 | try { 196 | await runTest(test); 197 | console.log("Pass"); 198 | passedTests.push(test); 199 | } catch (e) { 200 | console.log("Fail:", e); 201 | failedTests.push(test); 202 | } 203 | console.log() 204 | await sleep(2000); 205 | } 206 | 207 | let failedExclBroken = failedTests.filter(x => !BROKEN_TESTS.includes(x)); 208 | 209 | console.log("Passed", passedTests.length, passedTests); 210 | console.log("Failed", failedTests.length, failedTests); 211 | if (failedTests.length > 0) 212 | console.log("Failed (excl. broken)", failedExclBroken.length, failedExclBroken); 213 | 214 | assert(failedExclBroken.length == 0, "Tests failed"); 215 | } 216 | }); 217 | 218 | -------------------------------------------------------------------------------- /webext/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "ES2022", 4 | "target": "ES2020", 5 | "lib": ["dom", "ES2015"], 6 | "moduleResolution": "Node", 7 | "isolatedModules": true, 8 | "jsx": "react-jsx", 9 | "jsxImportSource": "preact", 10 | "strict": true 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /webext/wasmLoader.js: -------------------------------------------------------------------------------- 1 | import * as path from "node:path"; 2 | import * as fs from "node:fs"; 3 | 4 | /** 5 | * How this works: 6 | * - When the code being bundled is trying to import a wasm file, 7 | * it will get intercepted by our onResolve handler. 8 | * - It resolves the absolute path of the wasm file and redirect it to the 9 | * `wasmLoaderImportStub` namespace. 10 | * 11 | * - esbuild will invoke the registered onLoad handler for wasm files in the 12 | * `wasmLoaderImportStub` namespace. 13 | * - This handler generates a JS file (see generateImportStub) 14 | * 15 | * - esbuild will try to bundle the generated JS file. 16 | * - The generated JS file has `import wasm from ${path}` 17 | * - This will get intercepted by the onResolve handler again, but this time 18 | * with the namespace of the calling code, which is wasmLoaderImportStub. 19 | * - The onResolve handler reassigns the namespace to wasmLoaderCopy 20 | * - The handler for onLoad for the wasmLoaderCopy namespace loads the WASM binary and 21 | * asks esbuild to handle it using the file loader. 22 | * - esbuild copies the wasm file to `build-root/-[hash].wasm` and replaces 23 | * import wasm from "path"; 24 | * with 25 | * const wasm = "path relative to current file" 26 | * - The generated import stub takes this constant, resolves it relative to the current file 27 | * and carries out the ceremonies needed to load a WASM module. 28 | */ 29 | 30 | function wasmLoader() { 31 | return { 32 | name: "wasmLoader", 33 | setup(build) { 34 | build.onResolve({ filter: /.wasm$/ }, (args) => { 35 | if (args.namespace == "file") { 36 | return { 37 | path: path.isAbsolute(args.path) 38 | ? args.path 39 | : path.join(args.resolveDir, args.path), 40 | namespace: "wasmLoaderImportStub", 41 | }; 42 | } 43 | 44 | return { 45 | path: args.path, 46 | namespace: "wasmLoaderCopy", 47 | }; 48 | }); 49 | 50 | build.onLoad( 51 | { filter: /.wasm$/, namespace: "wasmLoaderImportStub" }, 52 | async (args) => { 53 | return { 54 | loader: "js", 55 | contents: await generateImportStub(args.path), 56 | resolveDir: path.dirname(args.path), 57 | }; 58 | }, 59 | ); 60 | 61 | build.onLoad( 62 | { filter: /.wasm$/, namespace: "wasmLoaderCopy" }, 63 | async (args) => { 64 | return { 65 | loader: "file", 66 | contents: await fs.promises.readFile(args.path), 67 | }; 68 | }, 69 | ); 70 | }, 71 | }; 72 | } 73 | 74 | async function generateImportStub(importPath) { 75 | const module = await WebAssembly.compile(await fs.promises.readFile(importPath)); 76 | // Get the imports needed for the WASM module 77 | const imports = WebAssembly.Module.imports(module); 78 | // Get the exported members of the WASM module 79 | const exports = WebAssembly.Module.exports(module); 80 | 81 | let resolveDir = path.dirname(importPath); 82 | 83 | return ` 84 | let imports = {}; 85 | // Fill this objects with imports needed by the WASM module. 86 | // The WASM module can't import anything other than what's provided in this object. 87 | ${generateImports("imports", imports, resolveDir)} 88 | 89 | // esbuild will replace this with 90 | // const wasmPath = "..path to the .wasm file relative to current file" 91 | import wasmPath from "${importPath}"; 92 | 93 | // Resolve wasmPath relative to the current file 94 | let url = new URL(wasmPath, import.meta.url); 95 | 96 | // Load the wasm object 97 | let wasm = await WebAssembly.instantiateStreaming(fetch(url), imports); 98 | 99 | // Re-export everything exported by the WASM module 100 | ${generateExports("wasm", exports)} 101 | `; 102 | } 103 | 104 | function generateImports(objName, imports, resolveDir) { 105 | let modules = {}; 106 | for (let { module, name } of imports) { 107 | if (modules[module] == null) modules[module] = []; 108 | let moduleEntry = modules[module]; 109 | moduleEntry.push(name); 110 | } 111 | 112 | return Object.entries(modules) 113 | .map( 114 | ([module, names]) => 115 | ` 116 | import {${names.join(", ")}} from "${path.join(resolveDir, module)}"; 117 | ${objName}["${module}"] = { ${names.join(", ")} } 118 | `, 119 | ) 120 | .join("\n"); 121 | } 122 | 123 | function generateExports(objName, exports) { 124 | return exports 125 | .map( 126 | ({ name }) => 127 | `export const ${name} = ${objName}.instance.exports.${name}`, 128 | ) 129 | .join(";\n"); 130 | } 131 | 132 | export { wasmLoader }; 133 | --------------------------------------------------------------------------------