├── .eslintrc.json ├── .github └── workflows │ ├── cypress.yml │ └── node.js.yml ├── .gitignore ├── .nvmrc ├── Dockerfile ├── LICENSE ├── README.md ├── cypress.config.ts ├── cypress ├── e2e │ ├── multisig-wallet.cy.ts │ ├── personal-wallet.cy.ts │ └── transactions.cy.ts ├── fixtures │ └── example.json ├── plugins │ └── index.js ├── support │ ├── commands.js │ └── e2e.js └── tsconfig.json ├── jest.config.js ├── next-env.d.ts ├── next.config.js ├── package.json ├── postcss.config.js ├── public ├── Discord-Logo-White.svg ├── eternl.png ├── favicon.ico ├── nami.svg ├── typhon.svg └── vercel.svg ├── setup-tests.tsx ├── src ├── cardano │ ├── config.ts │ ├── multiplatform-lib.ts │ ├── query-api.test.tsx │ ├── query-api.ts │ ├── utils.test.ts │ └── utils.ts ├── components │ ├── address.tsx │ ├── currency.test.ts │ ├── currency.tsx │ ├── layout.tsx │ ├── native-script.tsx │ ├── notification.tsx │ ├── password.test.ts │ ├── password.tsx │ ├── status.tsx │ ├── time.tsx │ ├── transaction.tsx │ ├── user-data.test.ts │ ├── user-data.tsx │ └── wallet.tsx ├── db.ts ├── pages │ ├── _app.tsx │ ├── api │ │ └── hello.ts │ ├── base64 │ │ └── [base64CBOR].tsx │ ├── hex │ │ └── [hexCBOR].tsx │ ├── index.tsx │ ├── multisig │ │ └── [policy].tsx │ ├── new.tsx │ └── personal │ │ └── [personalWalletId].tsx ├── route.ts └── styles │ ├── Home.module.css │ └── globals.css ├── tailwind.config.js ├── tapes └── graphql │ ├── query-1.json5 │ ├── query-2.json5 │ ├── query-3.json5 │ └── query-4.json5 ├── tsconfig.json ├── tsfmt.json └── yarn.lock /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /.github/workflows/cypress.yml: -------------------------------------------------------------------------------- 1 | name: Cypress Tests 2 | 3 | on: [push] 4 | 5 | env: 6 | NEXT_PUBLIC_NETWORK: preview 7 | 8 | jobs: 9 | cypress-run: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v2 14 | # Install NPM dependencies, cache them correctly 15 | # and run all Cypress tests 16 | - name: Cypress run 17 | uses: cypress-io/github-action@v4 18 | with: 19 | build: yarn run build 20 | start: yarn start 21 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: [ main ] 9 | pull_request: 10 | branches: [ main ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | node-version: [16.x, 18.x] 20 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 21 | 22 | steps: 23 | - uses: actions/checkout@v3 24 | - name: Use Node.js ${{ matrix.node-version }} 25 | uses: actions/setup-node@v3 26 | with: 27 | node-version: ${{ matrix.node-version }} 28 | cache: 'yarn' 29 | - run: yarn 30 | - run: yarn run build 31 | - run: yarn test 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | /.log 27 | 28 | # local env files 29 | .env.local 30 | .env.development.local 31 | .env.test.local 32 | .env.production.local 33 | 34 | # vercel 35 | .vercel 36 | 37 | # typescript 38 | *.tsbuildinfo 39 | 40 | # cypress 41 | cypress/videos 42 | cypress/screenshots 43 | cypress/downloads 44 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v18.9.0 2 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18-alpine 2 | 3 | RUN apk add --no-cache libc6-compat 4 | 5 | ENV NODE_ENV production 6 | USER node 7 | WORKDIR /app 8 | COPY --chown=node . . 9 | RUN yarn install 10 | RUN yarn build 11 | 12 | EXPOSE 3000 13 | 14 | ENV PORT 3000 15 | 16 | CMD ["yarn", "start"] 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Round Table 2 | 3 | [![Node.js CI](https://github.com/ADAOcommunity/round-table/actions/workflows/node.js.yml/badge.svg)](https://github.com/ADAOcommunity/round-table/actions/workflows/node.js.yml) 4 | [![Cypress Tests](https://github.com/ADAOcommunity/round-table/actions/workflows/cypress.yml/badge.svg)](https://github.com/ADAOcommunity/round-table/actions/workflows/cypress.yml) 5 | 6 | Round Table is ADAO Community’s open-source wallet on Cardano blockchain. It aims at making multisig easy and intuitive for everyone. The project is designed and developed with decentralization in mind. All the libraries and tools were chosen in favor of decentralization. There is no server to keep your data. Your data is your own. It runs on your browser just like any other light wallets. You could also run it on your own PC easily. 7 | 8 | Round Table supports multisig wallets as well as personal wallets. Besides its personal wallets, these wallets are supported to make multisig wallets. 9 | 10 | We have an active and welcoming community. If you have any issues or questions, feel free to reach out to us via [Twitter](https://twitter.com/adaocommunity) of [Discord](https://discord.gg/BGuhdBXQFU) 11 | 12 | ## Getting Started 13 | 14 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 15 | 16 | First, run the development server: 17 | 18 | ```bash 19 | npm run dev 20 | # or 21 | yarn dev 22 | ``` 23 | 24 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 25 | 26 | You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file. 27 | 28 | [API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`. 29 | 30 | The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages. 31 | 32 | ## Environment Variable 33 | 34 | * To use it on Cardano Preview Testnet, set `NEXT_PUBLIC_NETWORK=preview`. Leave it unset to use the Mainnet. 35 | * To connect it to a GraphQL node, set `NEXT_PUBLIC_GRAPHQL` to the URI of the node. 36 | * To sumbit transactions to relays, set `NEXT_PUBLIC_SUBMIT` to the URI of the node, split the URIs with `;`. **Beware that the server needs a reverse proxy to process CORS request.** 37 | * To sync signatures automatically, set `NEXT_PUBLIC_GUN` to the URIs of the peers, split the URIs with `;`. We use [GUN](https://gun.eco) to sync. 38 | 39 | ## Testing 40 | 41 | * To run Unit Tests, use `yarn test` command. 42 | * To run UI/UX Tests, use `yarn cypress` command. Make sure your dev server `http://localhost:3000/` is on. Or use `yarn cypress:headless` to run it in headless mode. 43 | 44 | ## Learn More 45 | 46 | To learn more about Next.js, take a look at the following resources: 47 | 48 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 49 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 50 | 51 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 52 | 53 | ## Deploy on Docker locally 54 | 55 | In the project folder, run: 56 | 57 | ```sh 58 | docker build -t round-table . 59 | docker run -d -p 3000:3000 --name round-table round-table 60 | ``` 61 | 62 | Then visit http://localhost:3000/ 63 | 64 | ## Deploy on Vercel 65 | 66 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 67 | 68 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 69 | 70 | ## Donation 71 | 72 | We kindly suggest considering tipping the developer as it would greatly contribute to the development and quality of the project. Your support is highly appreciated. Thank you for your consideration. 73 | 74 | addr1qy8yxxrle7hq62zgpazaj7kj36nphqyyxey62wm694dgfds5kkvr22hlffqdj63vk8nf8rje5np37v4fwlpvj4c4qryqtcla0w 75 | -------------------------------------------------------------------------------- /cypress.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'cypress' 2 | 3 | export default defineConfig({ 4 | e2e: { 5 | // We've imported your old cypress plugins here. 6 | // You may want to clean this up later by importing these. 7 | setupNodeEvents(on, config) { 8 | return require('./cypress/plugins/index.js')(on, config) 9 | }, 10 | }, 11 | }) 12 | -------------------------------------------------------------------------------- /cypress/e2e/multisig-wallet.cy.ts: -------------------------------------------------------------------------------- 1 | describe('Create a new wallet', () => { 2 | before(() => { 3 | window.indexedDB.deleteDatabase('round-table') 4 | }) 5 | 6 | const walletName = "Test wallet" 7 | const walletDesc = "This is a description of the wallet" 8 | const addresses = [ 9 | "addr_test1qrmtl76z2yvzw2zas03xze674r2yc6wefw0pm9v5x4ma6zs45zncsuzyfftj8x2ecg69z5f7x2f3uyz6c38uaeftsrdqms6z7t", 10 | "addr_test1qrsaj9wppjzqq9aa8yyg4qjs0vn32zjr36ysw7zzy9y3xztl9fadz30naflhmq653up3tkz275gh5npdejwjj23l0rdquxfsdj" 11 | ] 12 | const addedName = 'added' 13 | const addedDesc = 'xxx' 14 | const editedName = walletName + addedName 15 | const editedDesc = walletDesc + addedDesc 16 | 17 | it('Should show new wallet form', () => { 18 | cy.visit('http://localhost:3000/') 19 | cy.get('aside') 20 | .get('a') 21 | .contains('New Wallet') 22 | .click() 23 | 24 | cy.url() 25 | .should('eq', 'http://localhost:3000/new') 26 | }) 27 | 28 | it('Should fill title and description', () => { 29 | cy.get('input[placeholder="Write Name"]') 30 | .type(walletName) 31 | .should("have.value", walletName); 32 | 33 | cy.get('textarea[placeholder="Describe the wallet"]') 34 | .type(walletDesc) 35 | .should("have.value", walletDesc) 36 | }) 37 | 38 | it('Should add signers', () => { 39 | addresses.forEach((address) => { 40 | cy.contains('Add Signer').click() 41 | 42 | cy.get('#modal-root') 43 | .get('textarea[placeholder="Input receiving address"]') 44 | .type(address) 45 | .should("have.value", address) 46 | 47 | cy.get('#modal-root') 48 | .contains('Add') 49 | .click() 50 | }) 51 | 52 | cy.contains('Policy') 53 | .parent() 54 | .find('ul') 55 | .children() 56 | .should('have.length', 2) 57 | }) 58 | 59 | it('Should limit required signers to amount of signers added to wallet', () => { 60 | cy.contains('Policy') 61 | .parent().find('select') 62 | .select('At least') 63 | 64 | cy.contains('Policy') 65 | .parent().find('input') 66 | .type('{selectall}{backspace}') 67 | 68 | cy.contains('Policy') 69 | .parent().find('input') 70 | .type('100') 71 | 72 | cy.contains('Policy') 73 | .parent().find('input') 74 | .should('have.value', '100') 75 | 76 | cy.contains('Policy') 77 | .click() 78 | 79 | cy.contains('Policy') 80 | .parent().find('input') 81 | .should('have.value', addresses.length.toString()) 82 | }) 83 | 84 | it('Should save wallet', () => { 85 | cy.contains('Policy') 86 | .parent().find('input') 87 | .type('{selectall}{backspace}') 88 | 89 | cy.contains('Policy') 90 | .parent() 91 | .find('input') 92 | .type('2') 93 | 94 | cy.contains('Save') 95 | .should('be.enabled') 96 | 97 | cy.contains('Save') 98 | .click() 99 | 100 | cy.contains(walletName).click() 101 | 102 | cy.url().should('eq', 'http://localhost:3000/multisig/%7B%22type%22%3A%22NofK%22%2C%22policies%22%3A%5B%22addr_test1qrmtl76z2yvzw2zas03xze674r2yc6wefw0pm9v5x4ma6zs45zncsuzyfftj8x2ecg69z5f7x2f3uyz6c38uaeftsrdqms6z7t%22%2C%22addr_test1qrsaj9wppjzqq9aa8yyg4qjs0vn32zjr36ysw7zzy9y3xztl9fadz30naflhmq653up3tkz275gh5npdejwjj23l0rdquxfsdj%22%5D%2C%22number%22%3A2%7D') 103 | }) 104 | 105 | it('Should edit wallet info', () => { 106 | cy.contains('Edit') 107 | .click() 108 | 109 | cy.get('input[placeholder="Write Name"]') 110 | .type(addedName) 111 | .should("have.value", editedName); 112 | 113 | cy.get('textarea[placeholder="Describe the wallet"]') 114 | .type(addedDesc) 115 | .should("have.value", editedDesc) 116 | 117 | cy.contains('Save') 118 | .click() 119 | 120 | cy.contains(editedName) 121 | .should('be.visible') 122 | }) 123 | 124 | it('Should export user data', () => { 125 | cy.get('#config > button') 126 | .click() 127 | cy.get('#modal-root') 128 | .contains('Data') 129 | .click() 130 | cy.get('#modal-root') 131 | .contains('Export User Data') 132 | .click() 133 | }) 134 | 135 | it('Should remove wallet info', () => { 136 | cy.visit('http://localhost:3000') 137 | 138 | cy.contains(editedName) 139 | .click() 140 | 141 | cy.contains('Remove') 142 | .click() 143 | 144 | cy.get('input[placeholder="Type the wallet name to confirm"]') 145 | .type(editedName) 146 | .should("have.value", editedName) 147 | 148 | cy.contains('REMOVE') 149 | .click() 150 | 151 | cy.contains(editedName) 152 | .should('not.exist') 153 | }) 154 | 155 | it('Should import user data', () => { 156 | cy.get('#config > button') 157 | .click() 158 | 159 | cy.get('#modal-root') 160 | .contains('Data') 161 | .click() 162 | 163 | const downloadsFolder = Cypress.config('downloadsFolder') 164 | const downloadedFilename = downloadsFolder + '/roundtable-backup.preview.json' 165 | 166 | cy.get('input[type=file]') 167 | .selectFile(downloadedFilename) 168 | 169 | cy.get('#modal-root button') 170 | .contains('Import') 171 | .click() 172 | 173 | cy.visit('http://localhost:3000') 174 | 175 | cy.contains(walletName + 'added') 176 | .click() 177 | 178 | cy.url().should('eq', 'http://localhost:3000/multisig/%7B%22type%22%3A%22NofK%22%2C%22policies%22%3A%5B%22addr_test1qrmtl76z2yvzw2zas03xze674r2yc6wefw0pm9v5x4ma6zs45zncsuzyfftj8x2ecg69z5f7x2f3uyz6c38uaeftsrdqms6z7t%22%2C%22addr_test1qrsaj9wppjzqq9aa8yyg4qjs0vn32zjr36ysw7zzy9y3xztl9fadz30naflhmq653up3tkz275gh5npdejwjj23l0rdquxfsdj%22%5D%2C%22number%22%3A2%7D') 179 | }) 180 | }) 181 | -------------------------------------------------------------------------------- /cypress/e2e/personal-wallet.cy.ts: -------------------------------------------------------------------------------- 1 | const walletName = 'Main Wallet' 2 | const password = 'ic{K6Bio"pMS7' 3 | const walletDesc = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.' 4 | const recoveryPhrase: string[] = [ 5 | 'moral', 'equip', 'attract', 6 | 'bacon', 'century', 'glad', 7 | 'frown', 'bottom', 'attitude', 8 | 'base', 'deputy', 'pink', 9 | 'erosion', 'beauty', 'razor', 10 | 'route', 'leave', 'wool', 11 | 'type', 'tell', 'trend', 12 | 'skin', 'weapon', 'blush' 13 | ] 14 | const waitTime = 15000 15 | 16 | describe('Personal wallet', () => { 17 | before(() => { 18 | window.indexedDB.deleteDatabase('round-table') 19 | }) 20 | 21 | it('should be able to recover with recovery phrase', () => { 22 | cy.visit('http://localhost:3000/') 23 | cy.get('aside') 24 | .get('a') 25 | .contains('New Wallet') 26 | .click() 27 | cy.get('div') 28 | .find('nav button') 29 | .contains('Personal') 30 | .click() 31 | 32 | recoveryPhrase.forEach((word, index) => { 33 | cy.contains('Recovery Phrase') 34 | .parent() 35 | .contains((index + 1).toString()) 36 | .next('input') 37 | .type(word) 38 | .should('have.value', word) 39 | }) 40 | 41 | cy.contains('Next').click() 42 | }) 43 | 44 | it('should be able to save', () => { 45 | cy.get('input[placeholder="Write Name"]') 46 | .type(walletName) 47 | .should('have.value', walletName) 48 | cy.get('textarea[placeholder="Describe the wallet"]') 49 | .type(walletDesc) 50 | .should('have.value', walletDesc) 51 | cy.get('input[placeholder="Password"]') 52 | .type(password) 53 | .should('have.value', password) 54 | cy.get('input[placeholder="Repeat password"]') 55 | .type(password) 56 | .should('have.value', password) 57 | 58 | cy.contains('Create').click() 59 | 60 | cy.wait(waitTime) 61 | 62 | cy.get('div').should('have.contain.text', 'stake_test1uqhy9wspj5mhwz3jjw5sw7d8750mhqryg93xz562vkjwxpccdkfkl') 63 | 64 | cy.contains('Receive').click() 65 | 66 | cy.contains('addr_test1qry97t0n3a6g4uaj9shz4lz4rsuwsjwaup4te83gaxhageewg2aqr9fhwu9r9yafqau60aglhwqxgstzv9f55edyuvrsku53n7') 67 | .closest('td') 68 | .next('td') 69 | .should('have.text', "m/1852'/1815'/0'/0/0") 70 | .next('td') 71 | .should('have.text', "m/1852'/1815'/0'/2/0") 72 | 73 | cy.contains('addr_test1qzn4k2ss3tgdt7ayg44knneavn0e6gemg7al2wptnvv2fsewg2aqr9fhwu9r9yafqau60aglhwqxgstzv9f55edyuvrs0487rk') 74 | .closest('td') 75 | .next('td') 76 | .should('have.text', "m/1852'/1815'/0'/0/1") 77 | .next('td') 78 | .should('have.text', "m/1852'/1815'/0'/2/0") 79 | 80 | cy.contains('addr_test1qr32njg56puy8cndc57vukg9205ydze4h9tk0qp2552hwzewg2aqr9fhwu9r9yafqau60aglhwqxgstzv9f55edyuvrs7f7txu') 81 | .closest('td') 82 | .next('td') 83 | .should('have.text', "m/1852'/1815'/0'/0/2") 84 | .next('td') 85 | .should('have.text', "m/1852'/1815'/0'/2/0") 86 | }) 87 | 88 | it('should be able to add personal address', () => { 89 | cy.get('td').should('not.have.text', "m/1852'/1815'/0'/0/6") 90 | cy.contains('Add Address').click() 91 | cy.contains("m/1852'/1815'/0'/0/6") 92 | .closest('td') 93 | .next('td') 94 | .should('have.text', "m/1852'/1815'/0'/2/0") 95 | .closest('tr') 96 | .should('have.contain.text', 'addr_test1qrz22ravvdmee9389wvaqgdkfptwzjfv4adut35faeygzsfwg2aqr9fhwu9r9yafqau60aglhwqxgstzv9f55edyuvrsrc0m0h') 97 | }) 98 | 99 | it('should be able to add personal accounts', () => { 100 | cy.contains('Add Account').click() 101 | cy.get('input[type="Password"]').type(password) 102 | cy.get('#modal-root').contains('Confirm').click() 103 | cy.contains('Add Account').parent().get('select').should('have.value', '1') 104 | 105 | cy.contains('addr_test1qz856plw0a560m23p5j6jwjj3sezjnrya0q6qjs7uezvrzqlcrjtpd2lkd088ka782nu8937fklr5lw75xs49wkhs6gsjyg4yw') 106 | .closest('td') 107 | .next('td') 108 | .should('have.text', "m/1852'/1815'/1'/0/0") 109 | .next('td') 110 | .should('have.text', "m/1852'/1815'/1'/2/0") 111 | 112 | cy.contains('addr_test1qz48vjvgvy72z8s9rthx3g64cpenp5g39r6glz3n98zcpkqlcrjtpd2lkd088ka782nu8937fklr5lw75xs49wkhs6gsu2lnfg') 113 | .closest('td') 114 | .next('td') 115 | .should('have.text', "m/1852'/1815'/1'/0/1") 116 | .next('td') 117 | .should('have.text', "m/1852'/1815'/1'/2/0") 118 | 119 | cy.contains('addr_test1qrfkagyyk6la02rvrfp7042h6d8e8a5j4depefrac3m94nqlcrjtpd2lkd088ka782nu8937fklr5lw75xs49wkhs6gsp9hpxw') 120 | .closest('td') 121 | .next('td') 122 | .should('have.text', "m/1852'/1815'/1'/0/2") 123 | .next('td') 124 | .should('have.text', "m/1852'/1815'/1'/2/0") 125 | }) 126 | 127 | it('should be able to remove account', () => { 128 | cy.contains('Summary').click() 129 | 130 | cy.wait(waitTime) 131 | 132 | cy.contains('REMOVE').click() 133 | cy.get('#modal-root').contains('REMOVE').click() 134 | 135 | cy.contains('Add Account').parent().get('select').should('have.value', '0') 136 | 137 | cy.contains('Receive').click() 138 | cy.contains('addr_test1qry97t0n3a6g4uaj9shz4lz4rsuwsjwaup4te83gaxhageewg2aqr9fhwu9r9yafqau60aglhwqxgstzv9f55edyuvrsku53n7') 139 | .closest('td') 140 | .next('td') 141 | .should('have.text', "m/1852'/1815'/0'/0/0") 142 | .next('td') 143 | .should('have.text', "m/1852'/1815'/0'/2/0") 144 | }) 145 | 146 | it('should have multisig addresses created', () => { 147 | cy.contains('Multisig').click() 148 | 149 | cy.contains('addr_test1qzp420vrmccgp4prr2axyjzvjj0qec8d4wdfhamcr9rw0v2afzulsnxumxuw66c2883rj3hv6027uxcvt4qry92hjess4uch94') 150 | .closest('td') 151 | .next('td') 152 | .should('have.text', "m/1854'/1815'/0'/0/0") 153 | .next('td') 154 | .should('have.text', "m/1854'/1815'/0'/2/0") 155 | 156 | cy.contains('addr_test1qpxvl6tnc2d9adsr0p0508xsjxewwsx7snkp3xffgume3qyfn88gvkfnmscje2sazy7mmrsm8n5tkvfr8n7dhezdmhnqng95xm') 157 | .closest('td') 158 | .next('td') 159 | .should('have.text', "m/1854'/1815'/0'/0/1") 160 | .next('td') 161 | .should('have.text', "m/1854'/1815'/0'/2/1") 162 | }) 163 | 164 | it('should be able to create multisig wallet', () => { 165 | cy.contains('New Wallet').click() 166 | cy.contains('Add Signer').click() 167 | cy.get('#modal-root').contains(walletName).click() 168 | cy.get('#modal-root') 169 | .contains('addr_test1qzp420vrmccgp4prr2axyjzvjj0qec8d4wdfhamcr9rw0v2afzulsnxumxuw66c2883rj3hv6027uxcvt4qry92hjess4uch94') 170 | .click() 171 | cy.get('li') 172 | .should('have.contain.text', 'addr_test1qzp420vrmccgp4prr2axyjzvjj0qec8d4wdfhamcr9rw0v2afzulsnxumxuw66c2883rj3hv6027uxcvt4qry92hjess4uch94') 173 | }) 174 | 175 | it('should be able to sign personal transactions', () => { 176 | cy.visit('http://localhost:3000/base64/hKYAgYJYIPrhts%2B6OZKM83FFizlaIjZeIG%2FV7j2hqj5EPXQWBbr1AAGCglg5AKdbKhCK0NX7pEVrac89ZN%2BdIztHu%2FU4K5sYpMMuQroBlTd3CjKTqQd5p%2FUfu4BkQWJhU0plpOMHGgAOzBaCWDkAyF8t8490ivOyLC4q%2FFUcOOhJ3eBqvJ4o6a%2FUZy5CugGVN3cKMpOpB3mn9R%2B7gGRBYmFTSmWk4wcaAGiQ2QIaAAK1EQMaAD0gUwSCggCCAFgcLkK6AZU3dwoyk6kHeaf1H7uAZEFiYVNKZaTjB4MCggBYHC5CugGVN3cKMpOpB3mn9R%2B7gGRBYmFTSmWk4wdYHAzLBKcAAKxvP29HJWhW8XJOGpPP0tSX4lgg85sIGgA7ztOg9fY%3D') 177 | 178 | cy.wait(waitTime) 179 | 180 | cy.get('footer').contains('Sign').click() 181 | cy.get('#modal-root').contains(walletName).click() 182 | cy.get('#modal-root').get('input[type="password"]').type(password).should('have.value', password) 183 | cy.get('#modal-root').contains('Sign').click() 184 | 185 | cy.contains('c85f2df38f748af3b22c2e2afc551c38e849dde06abc9e28e9afd467') 186 | .parent() 187 | .should('have.class', 'text-green-500') 188 | 189 | cy.contains('2e42ba019537770a3293a90779a7f51fbb8064416261534a65a4e307') 190 | .parent() 191 | .should('have.class', 'text-green-500') 192 | }) 193 | 194 | it('should be able to sign multisig transactions', () => { 195 | cy.visit('http://localhost:3000/base64/hKcAgYJYID8suzDDldXtmJgQSIlDudo6DK9M97u29zCZT%2Bx0dO%2B4AAGCglg5MN2pvVjCEyxN2SovQlroQPC5835EpAYIsN4ETgivQOTCSRD%2FEahABE6qc3O1WDh%2BYaz1IdR8bTLHGgAOzBaCWDkw3am9WMITLE3ZKi9CWuhA8LnzfkSkBgiw3gROCK9A5MJJEP8RqEAETqpzc7VYOH5hrPUh1HxtMscaAGh37QIaAALN%2FQMaAD0jmQSCggCCAVgcr0DkwkkQ%2FxGoQAROqnNztVg4fmGs9SHUfG0yx4MCggFYHK9A5MJJEP8RqEAETqpzc7VYOH5hrPUh1HxtMsdYHDhnoJcpoflUdi7qA1qC4tnToU8fp5GgIu8NokIHWCAyfuLnoW9WFfctNWtc1yQGV9OPdh3ZeR2SxgDHo4kHMggaADvSGaEBgoIBgYIAWBxdSLn4TNzZuO1rCjniOUbs09XuGwxdQDIVV5ZhggGBggBYHINVPYPeMIDUIxq6YkhMlJ4M4O2rmpv3eBlG57H1oRkCoqFjbXNngXgbTXVsdGlzaWcgRGVsZWdhdGlvbiBUZXN0aW5n') 196 | 197 | cy.wait(waitTime) 198 | 199 | cy.get('footer').contains('Sign').click() 200 | cy.get('#modal-root').contains(walletName).click() 201 | cy.get('#modal-root').get('input[type="password"]').type(password).should('have.value', password) 202 | cy.get('#modal-root').contains('Sign').click() 203 | 204 | cy.contains('5d48b9f84cdcd9b8ed6b0a39e23946ecd3d5ee1b0c5d403215579661') 205 | .parent() 206 | .should('have.class', 'text-green-500') 207 | 208 | cy.contains('83553d83de3080d4231aba62484c949e0ce0edab9a9bf7781946e7b1') 209 | .parent() 210 | .should('have.class', 'text-green-500') 211 | }) 212 | 213 | it('should be able to be backed up', () => { 214 | cy.get('#config > button') 215 | .click() 216 | 217 | cy.get('#modal-root') 218 | .contains('Data') 219 | .click() 220 | 221 | cy.get('#modal-root') 222 | .contains('Export User Data') 223 | .click() 224 | }) 225 | 226 | it('should be able to get removed', () => { 227 | cy.visit('http://localhost:3000') 228 | 229 | cy.contains(walletName).click() 230 | 231 | cy.wait(waitTime) 232 | 233 | cy.contains('Remove').click() 234 | cy.contains('Remove Wallet').parent().get('input').type(walletName) 235 | cy.get('footer') 236 | .get('button') 237 | .contains('REMOVE') 238 | .click() 239 | }) 240 | 241 | it('should be able to be restored', () => { 242 | cy.get('#config > button') 243 | .click() 244 | 245 | cy.get('#modal-root') 246 | .contains('Data') 247 | .click() 248 | 249 | const downloadsFolder = Cypress.config('downloadsFolder') 250 | const downloadedFilename = downloadsFolder + '/roundtable-backup.preview.json' 251 | 252 | cy.get('input[type=file]') 253 | .selectFile(downloadedFilename) 254 | 255 | cy.wait(1000) 256 | 257 | cy.get('#modal-root button') 258 | .contains('Import') 259 | .click() 260 | 261 | cy.visit('http://localhost:3000') 262 | 263 | cy.contains(walletName).click() 264 | 265 | cy.wait(waitTime) 266 | 267 | cy.get('div').should('have.contain.text', 'stake_test1uqhy9wspj5mhwz3jjw5sw7d8750mhqryg93xz562vkjwxpccdkfkl') 268 | }) 269 | }) 270 | -------------------------------------------------------------------------------- /cypress/e2e/transactions.cy.ts: -------------------------------------------------------------------------------- 1 | const base64URL = 'http://localhost:3000/base64/hKQAgYJYIEKucajsSuebpzW6sBpzFvZs26rMRiWpIRrpaQqKAciUAQGCglg5AOQS%2Buq87qmsRuz8yOT2uBUUMfIJdTZXihGvyK26Uct0D1jECr5YEqIm8eWO56Vsliz7m98Lf%2BFmghoALcbAoVgcEmuGdkRshKXNbjJZIjsWojFMVna4iuHB%2BFeaj6FEdE1JTgOCWDkwAzhDIFjpiFvESzSX6Dy%2Fj975rmF1Wi9NpzNBbgM4QyBY6YhbxEs0l%2Bg8v4%2Fe%2Ba5hdVovTaczQW6CGgDcpwehWBwSa4Z2RGyEpc1uMlkiOxaiMUxWdriK4cH4V5qPoUd0U1VOREFFAgIaAALHzQdYIFzbYa6%2BdlCcqAXaswMUSXy1JOE0630%2FMDczUy6TY4yPoQGBggGCggBYHH5OXyQPqrEesjaDp7c6U0xWONLGa%2BU0vh3aTaWCAFgc5BL66rzuqaxG7PzI5Pa4FRQx8gl1NleKEa%2FIrfWhGQKioWNtc2eBeBlZZXMsIHRoaXMgaXMgdGhlIG1lc3NhZ2Uu' 2 | 3 | const hexURL = 'http://localhost:3000/hex/84a4008182582042ae71a8ec4ae79ba735bab01a7316f66cdbaacc4625a9211ae9690a8a01c89401018282583900e412faeabceea9ac46ecfcc8e4f6b8151431f2097536578a11afc8adba51cb740f58c40abe5812a226f1e58ee7a56c962cfb9bdf0b7fe166821a002dc6c0a1581c126b8676446c84a5cd6e3259223b16a2314c5676b88ae1c1f8579a8fa144744d494e03825839300338432058e9885bc44b3497e83cbf8fdef9ae61755a2f4da733416e0338432058e9885bc44b3497e83cbf8fdef9ae61755a2f4da733416e821a00dca707a1581c126b8676446c84a5cd6e3259223b16a2314c5676b88ae1c1f8579a8fa1477453554e44414502021a0002c7cd0758205cdb61aebe76509ca805dab30314497cb524e134eb7d3f303733532e93638c8fa101818201828200581c7e4e5f240faab11eb23683a7b73a534c5638d2c66be534be1dda4da58200581ce412faeabceea9ac46ecfcc8e4f6b8151431f2097536578a11afc8adf5a11902a2a1636d73678178195965732c207468697320697320746865206d6573736167652e' 4 | 5 | function signTransaction() { 6 | const signatures = 'a100828258205d6be9fd2cfe1c3fa5240ec89ac856b6afa4382ecb1654577a7c73ac517a8bd15840e89c573384f3ca4d8d8d6734bced299e3c5dad67c6c61f3f123efe90359816d9c5aa5ee6e19c70012a635d915f87819508bcd6b7b320be9c27fc1deab68ade0682582057b511ece5ff2cb1f20a72dcb2b2ad4ee3003f65d645a88e66ed2f20c76d49175840d0f654e197d20c09837d04f098daa71f5f2aff83a19cad88a51e2f1a5bde039a1544728375d9fcb7316d0ddd5b210ed0a1bf07a9e85594afe66355b808523809' 7 | 8 | cy.wait(1000) 9 | 10 | cy.get('footer').contains('Sign').click() 11 | 12 | cy.contains('7e4e5f240faab11eb23683a7b73a534c5638d2c66be534be1dda4da5') 13 | .parent() 14 | .should('not.have.class', 'text-green-500') 15 | 16 | cy.contains('e412faeabceea9ac46ecfcc8e4f6b8151431f2097536578a11afc8ad') 17 | .parent() 18 | .should('not.have.class', 'text-green-500') 19 | 20 | cy.get('#modal-root') 21 | .get('textarea[placeholder="Input signature here and import"]') 22 | .type(signatures) 23 | .should("have.value", signatures) 24 | 25 | cy.get('#modal-root').contains('Import').click() 26 | 27 | cy.contains('7e4e5f240faab11eb23683a7b73a534c5638d2c66be534be1dda4da5') 28 | .parent() 29 | .should('have.class', 'text-green-500') 30 | 31 | cy.contains('e412faeabceea9ac46ecfcc8e4f6b8151431f2097536578a11afc8ad') 32 | .parent() 33 | .should('have.class', 'text-green-500') 34 | } 35 | 36 | describe('Sign a base64 transaction created by others', () => { 37 | before(() => { 38 | window.indexedDB.deleteDatabase('round-table') 39 | }) 40 | 41 | it('Should sign the transaction', () => { 42 | cy.visit(base64URL) 43 | signTransaction() 44 | }) 45 | }) 46 | 47 | describe('Sign a base64 transaction created by others by opening URL in Base64', () => { 48 | before(() => { 49 | window.indexedDB.deleteDatabase('round-table') 50 | }) 51 | 52 | it('Should sign the transaction', () => { 53 | cy.visit('http://localhost:3000') 54 | cy.get('#open-tx > button') 55 | .click() 56 | cy.get('#modal-root') 57 | .get('textarea[placeholder="Transaction URL/Hex or multisig wallet URL"]') 58 | .type(base64URL) 59 | .should("have.value", base64URL) 60 | cy.get('#modal-root') 61 | .get('button') 62 | .contains('Open') 63 | .click() 64 | 65 | signTransaction() 66 | }) 67 | }) 68 | 69 | describe('Sign a hex transaction created by others', () => { 70 | before(() => { 71 | window.indexedDB.deleteDatabase('round-table') 72 | }) 73 | 74 | it('Should sign the transaction', () => { 75 | cy.visit(hexURL) 76 | signTransaction() 77 | }) 78 | }) 79 | 80 | describe('Open another transaction', () => { 81 | before(() => { 82 | window.indexedDB.deleteDatabase('round-table') 83 | }) 84 | 85 | it('Should clean the signatures', () => { 86 | const tx1 = 'http://localhost:3000/base64/hKYAgYJYIELhsJAUmJoGYzypmcaluyBySvR3PnJVZ9E4zsyiT8gAAQGCglg5MLl6F5hVYu5cNsTS2%2FYp%2BzZqI4k672wth%2F%2BpxQhdDXOUohw96Qz%2Fiu9zxRWXLoaSldgNEvNp%2F2m1GgAOzBaCWDkwuXoXmFVi7lw2xNLb9in7NmojiTrvbC2H%2F6nFCF0Nc5SiHD3pDP%2BK73PFFZcuhpKV2A0S82n%2FabUaO1%2FehQIaAAKhTQMaAPuWvgdYIEfPOIMG4ldgTIIc4tzp9O7OXU9YwqLogife6QGLDD5GCBoA%2BkU%2BoQGBggGBggBYHBipzmxwynWuMiT8ipbqqqzWoptPBAqlcCz7Xir1oRkCoqFjbXNngWRUWCMx' 87 | cy.visit(tx1) 88 | 89 | const signatures = 'a1008182582098ae5dcf87153a1c3c27fc9eca303e6407d9b563d558c8b869a1727d8340ca0358403b88ce14a5d334fbd3ebe1b26538749d84e69068da0f69bdee1005871a35a652e4f0a53fc30d623ea337e027c40eb008cb0ff1f870d2ed5e248f11655298ef04' 90 | 91 | cy.wait(1000) 92 | 93 | cy.get('footer').contains('Sign').click() 94 | 95 | cy.get('#modal-root') 96 | .get('textarea[placeholder="Input signature here and import"]') 97 | .type(signatures) 98 | .should("have.value", signatures) 99 | 100 | cy.get('#modal-root').contains('Import').click() 101 | 102 | cy.contains('18a9ce6c70ca75ae3224fc8a96eaaaacd6a29b4f040aa5702cfb5e2a') 103 | .parent() 104 | .should('have.class', 'text-green-500') 105 | 106 | const tx2 = 'http://localhost:3000/base64/hKYAgYJYIELhsJAUmJoGYzypmcaluyBySvR3PnJVZ9E4zsyiT8gAAQGCglg5MLl6F5hVYu5cNsTS2%2FYp%2BzZqI4k672wth%2F%2BpxQhdDXOUohw96Qz%2Fiu9zxRWXLoaSldgNEvNp%2F2m1GgAOzBaCWDkwuXoXmFVi7lw2xNLb9in7NmojiTrvbC2H%2F6nFCF0Nc5SiHD3pDP%2BK73PFFZcuhpKV2A0S82n%2FabUaO1%2FehQIaAAKhTQMaAPuW7QdYIPjsM6lUr7I40hEP%2BBRYc9wO8qW2cY%2BjiVm4uQqcLkT3CBoA%2BkVtoQGBggGBggBYHBipzmxwynWuMiT8ipbqqqzWoptPBAqlcCz7Xir1oRkCoqFjbXNngWRUWCMy' 107 | 108 | cy.get('#open-tx > button') 109 | .click() 110 | cy.get('#modal-root') 111 | .get('textarea[placeholder="Transaction URL/Hex or multisig wallet URL"]') 112 | .type(tx2) 113 | .should("have.value", tx2) 114 | cy.get('#modal-root') 115 | .get('button') 116 | .contains('Open') 117 | .click() 118 | 119 | cy.contains('18a9ce6c70ca75ae3224fc8a96eaaaacd6a29b4f040aa5702cfb5e2a') 120 | .parent() 121 | .should('not.have.class', 'text-green-500') 122 | }) 123 | }) 124 | -------------------------------------------------------------------------------- /cypress/fixtures/example.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Using fixtures to represent data", 3 | "email": "hello@cypress.io", 4 | "body": "Fixtures are a great way to mock data for responses to routes", 5 | } 6 | -------------------------------------------------------------------------------- /cypress/plugins/index.js: -------------------------------------------------------------------------------- 1 | /// 2 | // *********************************************************** 3 | // This example plugins/index.js can be used to load plugins 4 | // 5 | // You can change the location of this file or turn off loading 6 | // the plugins file with the 'pluginsFile' configuration option. 7 | // 8 | // You can read more here: 9 | // https://on.cypress.io/plugins-guide 10 | // *********************************************************** 11 | 12 | // This function is called when a project is opened or re-opened (e.g. due to 13 | // the project's config changing) 14 | 15 | /** 16 | * @type {Cypress.PluginConfig} 17 | */ 18 | // eslint-disable-next-line no-unused-vars 19 | module.exports = (on, config) => { 20 | // `on` is used to hook into various events Cypress emits 21 | // `config` is the resolved Cypress config 22 | } 23 | -------------------------------------------------------------------------------- /cypress/support/commands.js: -------------------------------------------------------------------------------- 1 | // *********************************************** 2 | // This example commands.js shows you how to 3 | // create various custom commands and overwrite 4 | // existing commands. 5 | // 6 | // For more comprehensive examples of custom 7 | // commands please read more here: 8 | // https://on.cypress.io/custom-commands 9 | // *********************************************** 10 | // 11 | // 12 | // -- This is a parent command -- 13 | // Cypress.Commands.add('login', (email, password) => { ... }) 14 | // 15 | // 16 | // -- This is a child command -- 17 | // Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... }) 18 | // 19 | // 20 | // -- This is a dual command -- 21 | // Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... }) 22 | // 23 | // 24 | // -- This will overwrite an existing command -- 25 | // Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) 26 | -------------------------------------------------------------------------------- /cypress/support/e2e.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/index.js is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import './commands' 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') 21 | -------------------------------------------------------------------------------- /cypress/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "noEmit": true, 5 | "types": ["cypress"], 6 | "isolatedModules": false 7 | }, 8 | "include": ["../node_modules/cypress", "**/*.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | // jest.config.js 2 | const nextJest = require('next/jest') 3 | 4 | const createJestConfig = nextJest({ 5 | // Provide the path to your Next.js app to load next.config.js and .env files in your test environment 6 | dir: './', 7 | }) 8 | 9 | // Add any custom config to be passed to Jest 10 | const customJestConfig = { 11 | // Add more setup options before each test is run 12 | // setupFilesAfterEnv: ['/jest.setup.js'], 13 | // if using TypeScript with a baseUrl set to the root directory then you need the below for alias' to work 14 | moduleDirectories: ['node_modules', '/'], 15 | testEnvironment: 'jest-environment-jsdom', 16 | setupFilesAfterEnv: ["/setup-tests.tsx"], 17 | } 18 | 19 | // createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async 20 | module.exports = createJestConfig(customJestConfig) 21 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true, 4 | images: { 5 | domains: ['gerowallet.io'] 6 | }, 7 | webpack(config, { dev }) { 8 | config.experiments = { 9 | syncWebAssembly: true, 10 | layers: true 11 | } 12 | return config 13 | } 14 | } 15 | 16 | module.exports = nextConfig 17 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "round-table", 3 | "private": true, 4 | "scripts": { 5 | "dev": "next dev", 6 | "build": "next build", 7 | "start": "next start", 8 | "lint": "next lint", 9 | "test": "jest", 10 | "cypress": "cypress open", 11 | "cypress:headless": "cypress run" 12 | }, 13 | "dependencies": { 14 | "@apollo/client": "^3.7.2", 15 | "@cardano-graphql/client-ts": "^7.0.2", 16 | "@dcspark/cardano-multiplatform-lib-browser": "^3.1.2", 17 | "@heroicons/react": "^2.0.13", 18 | "bip39": "^3.0.4", 19 | "buffer": "^6.0.3", 20 | "cardano-utxo-wasm": "^0.2.0", 21 | "dexie": "^3.2.1", 22 | "dexie-react-hooks": "^1.1.1", 23 | "fractional": "^1.0.0", 24 | "graphql": "^16.6.0", 25 | "gun": "^0.2020.1238", 26 | "next": "13.0.6", 27 | "react": "18.2.0", 28 | "react-dom": "18.2.0", 29 | "react-number-format": "^4.9.1" 30 | }, 31 | "devDependencies": { 32 | "@testing-library/jest-dom": "^5.16.5", 33 | "@testing-library/react": "^13.4.0", 34 | "@types/jest": "^27.4.1", 35 | "@types/node": "18.11.15", 36 | "@types/react": "18.0.26", 37 | "autoprefixer": "^10.4.13", 38 | "cross-fetch": "^3.1.5", 39 | "cypress": "^10.8.0", 40 | "eslint": "8.29.0", 41 | "eslint-config-next": "13.0.6", 42 | "jest": "^27.5.1", 43 | "postcss": "^8.4.20", 44 | "tailwindcss": "^3.2.4", 45 | "talkback": "^3.0.2", 46 | "typescript": "4.9.4" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /public/Discord-Logo-White.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /public/eternl.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ADAOcommunity/round-table/ce1963822e3021230a885fd3ffd250ef8332c3ef/public/eternl.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ADAOcommunity/round-table/ce1963822e3021230a885fd3ffd250ef8332c3ef/public/favicon.ico -------------------------------------------------------------------------------- /public/nami.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /public/typhon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /setup-tests.tsx: -------------------------------------------------------------------------------- 1 | import crypto from 'crypto' 2 | 3 | Object.defineProperty(global.self, 'crypto', { 4 | value: { 5 | subtle: crypto.webcrypto.subtle, 6 | }, 7 | }) 8 | -------------------------------------------------------------------------------- /src/cardano/config.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react' 2 | 3 | type Network = 'mainnet' | 'testnet' | 'preview' 4 | 5 | const parseNetwork = (text: string): Network => { 6 | switch (text) { 7 | case 'mainnet': return 'mainnet' 8 | case 'testnet': return 'testnet' 9 | case 'preview': return 'preview' 10 | default: throw new Error('Unknown network') 11 | } 12 | } 13 | 14 | type Config = { 15 | network: Network 16 | submitAPI: string[] 17 | SMASH: string 18 | gunPeers: string[] 19 | autoSync: boolean 20 | } 21 | 22 | const isMainnet = (config: Config) => config.network === 'mainnet' 23 | 24 | const defaultGraphQLMainnet = 'https://d.graphql-api.mainnet.dandelion.link' 25 | const defaultGraphQLTestnet = 'https://graphql.preview.lidonation.com/graphql' 26 | const defaultSubmitURIMainnet = [ 27 | 'https://adao.panl.org', 28 | 'https://submit-api.apexpool.info/api/submit/tx' 29 | ] 30 | const defaultSubmitURITestnet = [ 31 | 'https://sa-preview.apexpool.info/api/submit/tx', 32 | 'https://preview-submit.panl.org' 33 | ] 34 | const defaultSMASHMainnet = 'https://mainnet-smash.panl.org' 35 | const defaultSMASHTestnet = 'https://preview-smash.panl.org' 36 | 37 | const defaultConfig: Config = { 38 | network: 'mainnet', 39 | submitAPI: defaultSubmitURIMainnet, 40 | SMASH: defaultSMASHMainnet, 41 | gunPeers: [], 42 | autoSync: true, 43 | } 44 | 45 | const createConfig = (): Config => { 46 | const network = parseNetwork(process.env.NEXT_PUBLIC_NETWORK ?? 'mainnet') 47 | const defaultSubmitURI = network === 'mainnet' ? defaultSubmitURIMainnet : defaultSubmitURITestnet 48 | const submitEnv = process.env.NEXT_PUBLIC_SUBMIT 49 | const submitURI = submitEnv ? submitEnv.split(';') : defaultSubmitURI 50 | const defaultSMASH = network === 'mainnet' ? defaultSMASHMainnet : defaultSMASHTestnet 51 | const SMASH = process.env.NEXT_PUBLIC_SMASH ?? defaultSMASH 52 | const gunPeers = (process.env.NEXT_PUBLIC_GUN ?? '').split(';') 53 | 54 | return { 55 | network, 56 | submitAPI: submitURI, 57 | SMASH, 58 | gunPeers, 59 | autoSync: true 60 | } 61 | } 62 | 63 | const config = createConfig() 64 | 65 | const ConfigContext = createContext<[Config, (x: Config) => void]>([defaultConfig, (_) => {}]) 66 | 67 | const defaultGraphQLURI = process.env.NEXT_PUBLIC_GRAPHQL ?? (parseNetwork(process.env.NEXT_PUBLIC_NETWORK ?? 'mainnet') === 'mainnet' ? defaultGraphQLMainnet : defaultGraphQLTestnet) 68 | 69 | const donationAddress = (network: Network): string => { 70 | switch(network) { 71 | case 'mainnet': 72 | return 'addr1qy8yxxrle7hq62zgpazaj7kj36nphqyyxey62wm694dgfds5kkvr22hlffqdj63vk8nf8rje5np37v4fwlpvj4c4qryqtcla0w'; 73 | default: 74 | return 'addr_test1qpe7qk82nqyd77tdqmn6q7y5ll4kwwxdajgwf3llcu4e44nmcxl09wnytjsykngrga52kqhevzv2dn67rt0876qmwn3sf7qxv3'; 75 | } 76 | } 77 | 78 | export type { Config, Network } 79 | export { ConfigContext, config, defaultGraphQLURI, donationAddress, isMainnet } 80 | -------------------------------------------------------------------------------- /src/cardano/query-api.test.tsx: -------------------------------------------------------------------------------- 1 | import { renderHook, waitFor } from '@testing-library/react' 2 | import { getAssetName, getPolicyId, useUTxOSummaryQuery, usePaymentAddressesQuery, sumValues, useSummaryQuery, useTransactionSummaryQuery, useStakePoolsQuery } from './query-api' 3 | import type { Value } from './query-api' 4 | import talkback from 'talkback/es6' 5 | import { ApolloProvider } from '@apollo/client' 6 | import type { FC, ReactNode } from 'react' 7 | import fetch from 'cross-fetch' 8 | import { ApolloClient, InMemoryCache, HttpLink } from '@apollo/client' 9 | 10 | const policyId = '126b8676446c84a5cd6e3259223b16a2314c5676b88ae1c1f8579a8f' 11 | const assetName = '7453554e444145' 12 | const assetId = policyId + assetName 13 | 14 | const createApolloClient = (uri: string) => new ApolloClient({ 15 | link: new HttpLink({ uri, fetch }), 16 | cache: new InMemoryCache() 17 | }) 18 | 19 | test('sumValues', () => { 20 | const valueA: Value = { lovelace: BigInt(100), assets: new Map() } 21 | const valueB: Value = { lovelace: BigInt(1000), assets: new Map([['token1', BigInt(10)]]) } 22 | const valueC: Value = { lovelace: BigInt(50), assets: new Map([['token1', BigInt(1)], ['token2', BigInt(100)]]) } 23 | const total = sumValues([valueA, valueB, valueC]) 24 | expect(total.lovelace).toBe(BigInt(1150)) 25 | expect(total.assets.size).toBe(2) 26 | expect(total.assets.get('token1')).toBe(BigInt(11)) 27 | expect(total.assets.get('token2')).toBe(BigInt(100)) 28 | }) 29 | 30 | test('getAssetName', () => { 31 | expect(getAssetName(assetId)).toBe(assetName) 32 | }) 33 | 34 | test('getPolicyId', () => { 35 | expect(getPolicyId(assetId)).toBe(policyId) 36 | }) 37 | 38 | describe('GraphQL API', () => { 39 | const client = createApolloClient('http://localhost:8080') 40 | const wrapper: FC<{ children: ReactNode }> = ({ children }) => {children}; 41 | 42 | const talkbackServer = talkback({ 43 | host: 'https://preview-gql.junglestakepool.com/graphql', 44 | port: 8080, 45 | tapeNameGenerator: (tapeNumber) => ['graphql', `query-${tapeNumber}`].join('/') 46 | }) 47 | 48 | beforeAll(() => talkbackServer.start()) 49 | afterAll(() => talkbackServer.close()) 50 | 51 | test('useUTxOSummaryQuery', async () => { 52 | const address = 'addr_test1xzuh59uc243wuhpkcnfdha3flvmx5guf8thkctv8l75u2zzap4eefgsu8h5selu2aaeu29vh96rf99wcp5f0x60ldx6s2ad79j' 53 | const rewardAddress = 'stake_test17pws6uu55gwrm6gvl79w7u79zktjap5jjhvq6yhnd8lkndgcsn5h4' 54 | const { result } = renderHook(() => useUTxOSummaryQuery({ variables: { addresses: [address], rewardAddress } }), { wrapper }) 55 | 56 | await waitFor(() => expect(result.current.loading).toBe(false), { timeout: 10000 }) 57 | 58 | const { data } = result.current 59 | 60 | expect(data).toBeTruthy() 61 | 62 | if (data) { 63 | const utxos = data.utxos 64 | expect(utxos.length).toBe(4) 65 | 66 | const utxo1 = utxos[0] 67 | expect(utxo1.address).toBe(address) 68 | expect(utxo1.txHash).toBe('3ec64a8784bddc1b1849a349fe88c01918a58e4d32636420c17aafe156f16f9c') 69 | expect(utxo1.value).toBe('969750') 70 | expect(utxo1.tokens.length).toBe(0) 71 | expect(utxo1.index).toBe(0) 72 | 73 | const utxo2 = utxos[1] 74 | expect(utxo2.address).toBe(address) 75 | expect(utxo2.txHash).toBe('829c0c98a4037f214abe197276ef8b53be3e313b139e73a87f7a8d0ff70ff735') 76 | expect(utxo2.value).toBe('10000000') 77 | expect(utxo2.tokens.length).toBe(1) 78 | expect(utxo2.index).toBe(0) 79 | 80 | const utxo3 = utxos[2] 81 | expect(utxo3.address).toBe(address) 82 | expect(utxo3.txHash).toBe('42e1b09014989a06633ca999c6a5bb20724af4773e725567d138cecca24fc800') 83 | expect(utxo3.value).toBe('1000000') 84 | expect(utxo3.tokens.length).toBe(0) 85 | expect(utxo3.index).toBe(0) 86 | 87 | const { cardano, delegations, stakeRegistrations_aggregate, stakeDeregistrations_aggregate, withdrawals_aggregate, rewards_aggregate } = data 88 | 89 | expect(delegations).toHaveLength(1) 90 | expect(stakeRegistrations_aggregate.aggregate?.count).toBe('1') 91 | expect(stakeDeregistrations_aggregate.aggregate?.count).toBe('0') 92 | expect(withdrawals_aggregate.aggregate?.sum.amount).toBe('1612692') 93 | expect(rewards_aggregate.aggregate?.sum.amount).toBe('1709889') 94 | 95 | const params = cardano.currentEpoch.protocolParams 96 | if (params) { 97 | expect(params.minFeeA).toBe(44) 98 | expect(params.minFeeB).toBe(155381) 99 | expect(params.poolDeposit).toBe(500000000) 100 | expect(params.coinsPerUtxoByte).toBe(4310) 101 | expect(params.keyDeposit).toBe(2000000) 102 | expect(params.maxTxSize).toBe(16384) 103 | expect(params.maxValSize).toBe('5000') 104 | expect(params.priceMem).toBe(0.0577) 105 | expect(params.priceStep).toBe(0.0000721) 106 | expect(params.collateralPercent).toBe(150) 107 | expect(params.maxCollateralInputs).toBe(3) 108 | } 109 | } 110 | }) 111 | 112 | test('useSummaryQuery', async () => { 113 | const address = 'addr_test1xzuh59uc243wuhpkcnfdha3flvmx5guf8thkctv8l75u2zzap4eefgsu8h5selu2aaeu29vh96rf99wcp5f0x60ldx6s2ad79j' 114 | const rewardAddress = 'stake_test17pws6uu55gwrm6gvl79w7u79zktjap5jjhvq6yhnd8lkndgcsn5h4' 115 | const { result } = renderHook(() => useSummaryQuery({ variables: { addresses: [address], rewardAddress } }), { wrapper }) 116 | 117 | await waitFor(() => expect(result.current.loading).toBe(false), { timeout: 10000 }) 118 | 119 | const { data } = result.current 120 | 121 | expect(result.current.data).toBeTruthy() 122 | 123 | if (data) { 124 | const paymentAddresses = data.paymentAddresses 125 | expect(paymentAddresses.length).toBe(1) 126 | const summary = paymentAddresses[0].summary 127 | if (summary) { 128 | expect(summary.assetBalances.length).toBe(2) 129 | expect(summary.assetBalances[0]?.asset.assetId).toBe('ada') 130 | expect(summary.assetBalances[0]?.quantity).toBe('1009250494') 131 | expect(summary.assetBalances[1]?.asset.assetId).toBe('9a556a69ba07adfbbce86cd9af8fd73f60fcf43c73f8deb51d2176b4504855464659') 132 | expect(summary.assetBalances[1]?.quantity).toBe('1') 133 | } 134 | 135 | const { delegations, stakeRegistrations_aggregate, stakeDeregistrations_aggregate, withdrawals_aggregate, rewards_aggregate } = data 136 | 137 | expect(delegations).toHaveLength(1) 138 | expect(stakeRegistrations_aggregate.aggregate?.count).toBe('1') 139 | expect(stakeDeregistrations_aggregate.aggregate?.count).toBe('0') 140 | expect(withdrawals_aggregate.aggregate?.sum.amount).toBe('1612692') 141 | expect(rewards_aggregate.aggregate?.sum.amount).toBe('1709889') 142 | } 143 | }) 144 | 145 | test('usePaymentAddressesQuery', async () => { 146 | const address = 'addr_test1xzuh59uc243wuhpkcnfdha3flvmx5guf8thkctv8l75u2zzap4eefgsu8h5selu2aaeu29vh96rf99wcp5f0x60ldx6s2ad79j' 147 | const { result } = renderHook(() => usePaymentAddressesQuery({ variables: { addresses: [address] } }), { wrapper }) 148 | 149 | await waitFor(() => expect(result.current.loading).toBe(false), { timeout: 10000 }) 150 | 151 | const { data } = result.current 152 | 153 | if (data) { 154 | const paymentAddresses = data.paymentAddresses 155 | expect(paymentAddresses.length).toBe(1) 156 | const summary = paymentAddresses[0].summary 157 | if (summary) { 158 | expect(summary.assetBalances.length).toBe(2) 159 | expect(summary.assetBalances[0]?.asset.assetId).toBe('ada') 160 | expect(summary.assetBalances[0]?.quantity).toBe('1009250494') 161 | expect(summary.assetBalances[1]?.asset.assetId).toBe('9a556a69ba07adfbbce86cd9af8fd73f60fcf43c73f8deb51d2176b4504855464659') 162 | expect(summary.assetBalances[1]?.quantity).toBe('1') 163 | } 164 | } 165 | }) 166 | 167 | test('useTransactionSummaryQuery', async () => { 168 | const txHash = '829c0c98a4037f214abe197276ef8b53be3e313b139e73a87f7a8d0ff70ff735' 169 | const { result } = renderHook(() => useTransactionSummaryQuery({ variables: { hashes: [txHash] } }), { wrapper }) 170 | 171 | await waitFor(() => expect(result.current.loading).toBe(false), { timeout: 10000 }) 172 | 173 | const { data } = result.current 174 | 175 | if (data) { 176 | const { transactions } = data 177 | expect(transactions).toHaveLength(1) 178 | expect(transactions[0].hash).toEqual(txHash) 179 | expect(transactions[0].outputs).toHaveLength(3) 180 | expect(transactions[0].outputs[0]?.index).toBe(2) 181 | expect(transactions[0].outputs[0]?.value).toBe('8949377793') 182 | expect(transactions[0].outputs[0]?.tokens).toHaveLength(0) 183 | expect(transactions[0].outputs[1]?.index).toBe(1) 184 | expect(transactions[0].outputs[2]?.index).toBe(0) 185 | } 186 | }) 187 | 188 | test('useStakePoolsQuery', async () => { 189 | const poolId = 'pool1ayc7a29ray6yv4hn7ge72hpjafg9vvpmtscnq9v8r0zh7azas9c' 190 | const { result } = renderHook(() => useStakePoolsQuery({ variables: { id: poolId, limit: 1, offset: 0 } }), { wrapper }) 191 | 192 | await waitFor(() => expect(result.current.loading).toBe(false), { timeout: 10000 }) 193 | 194 | const { data } = result.current 195 | 196 | if (data) { 197 | const { stakePools } = data 198 | expect(stakePools).toHaveLength(1) 199 | expect(stakePools[0].id).toBe(poolId) 200 | expect(stakePools[0].hash).toBe('e931eea8a3e9344656f3f233e55c32ea5056303b5c313015871bc57f') 201 | } 202 | }) 203 | }) 204 | -------------------------------------------------------------------------------- /src/cardano/query-api.ts: -------------------------------------------------------------------------------- 1 | import { gql, useQuery } from '@apollo/client' 2 | import type { QueryHookOptions, QueryResult } from '@apollo/client' 3 | import type { Cardano, PaymentAddress, TransactionOutput, Reward_Aggregate, Withdrawal_Aggregate, StakeRegistration_Aggregate, StakeDeregistration_Aggregate, Delegation, StakePool, Transaction } from '@cardano-graphql/client-ts/api' 4 | import type { Recipient } from './multiplatform-lib' 5 | import { createContext } from 'react' 6 | 7 | const getPolicyId = (assetId: string) => assetId.slice(0, 56) 8 | const getAssetName = (assetId: string) => assetId.slice(56) 9 | const decodeASCII = (assetName: string): string => { 10 | return Buffer.from(assetName, 'hex').toString('ascii') 11 | } 12 | 13 | type Assets = Map 14 | 15 | type Value = { 16 | lovelace: bigint 17 | assets: Assets 18 | } 19 | 20 | const sumValues = (values: Value[]): Value => values.reduce((acc, value) => { 21 | const assets = new Map(acc.assets) 22 | value.assets.forEach((quantity, id) => assets.set(id, (assets.get(id) ?? BigInt(0)) + quantity)) 23 | 24 | return { 25 | lovelace: acc.lovelace + value.lovelace, 26 | assets 27 | } 28 | }, { lovelace: BigInt(0), assets: new Map() }) 29 | 30 | const getValueFromTransactionOutput = (output: TransactionOutput): Value => { 31 | const assets: Assets = new Map() 32 | 33 | output.tokens.forEach(({ asset, quantity }) => { 34 | const { assetId } = asset 35 | const value = (assets.get(assetId) ?? BigInt(0)) + BigInt(quantity) 36 | assets.set(assetId, value) 37 | }) 38 | 39 | return { 40 | lovelace: BigInt(output.value), 41 | assets 42 | } 43 | } 44 | 45 | const getRecipientFromTransactionOutput = (output: TransactionOutput): Recipient => { 46 | return { 47 | address: output.address, 48 | value: getValueFromTransactionOutput(output) 49 | } 50 | } 51 | 52 | const getBalanceByUTxOs = (utxos: TransactionOutput[]): Value => sumValues(utxos.map(getValueFromTransactionOutput)) 53 | 54 | type Query = (options: QueryHookOptions) => QueryResult; 55 | 56 | const StakePoolFields = gql` 57 | fragment StakePoolFields on StakePool { 58 | id 59 | margin 60 | fixedCost 61 | pledge 62 | hash 63 | metadataHash 64 | } 65 | ` 66 | 67 | const UTxOSummaryQuery = gql` 68 | ${StakePoolFields} 69 | query UTxOSummary($addresses: [String]!, $rewardAddress: StakeAddress!) { 70 | utxos(where: { address: { _in: $addresses } }) { 71 | address 72 | txHash 73 | index 74 | value 75 | tokens { 76 | asset { 77 | assetId 78 | } 79 | quantity 80 | } 81 | } 82 | cardano { 83 | currentEpoch { 84 | protocolParams { 85 | minFeeA 86 | minFeeB 87 | poolDeposit 88 | keyDeposit 89 | coinsPerUtxoByte 90 | maxValSize 91 | maxTxSize 92 | priceMem 93 | priceStep 94 | collateralPercent 95 | maxCollateralInputs 96 | } 97 | } 98 | } 99 | rewards_aggregate(where: { address: { _eq: $rewardAddress } }) { 100 | aggregate { 101 | sum { 102 | amount 103 | } 104 | } 105 | } 106 | withdrawals_aggregate(where: { address: { _eq: $rewardAddress } }) { 107 | aggregate { 108 | sum { 109 | amount 110 | } 111 | } 112 | } 113 | stakeRegistrations_aggregate(where: { address: { _eq: $rewardAddress } }) { 114 | aggregate { 115 | count 116 | } 117 | } 118 | stakeDeregistrations_aggregate(where: { address: { _eq: $rewardAddress } }) { 119 | aggregate { 120 | count 121 | } 122 | } 123 | delegations( 124 | limit: 1 125 | order_by: { transaction: { block: { slotNo: desc } } } 126 | where: { address: { _eq: $rewardAddress } } 127 | ) { 128 | address 129 | stakePool { 130 | ...StakePoolFields 131 | } 132 | } 133 | } 134 | ` 135 | 136 | const useUTxOSummaryQuery: Query< 137 | { utxos: TransactionOutput[], cardano: Cardano, rewards_aggregate: Reward_Aggregate, withdrawals_aggregate: Withdrawal_Aggregate, stakeRegistrations_aggregate: StakeRegistration_Aggregate, stakeDeregistrations_aggregate: StakeDeregistration_Aggregate, delegations: Delegation[] }, 138 | { addresses: string[], rewardAddress: string } 139 | > = (options) => useQuery(UTxOSummaryQuery, options) 140 | 141 | const PaymentAddressesQuery = gql` 142 | query PaymentAddressByAddresses($addresses: [String]!) { 143 | paymentAddresses(addresses: $addresses) { 144 | address 145 | summary { 146 | assetBalances { 147 | asset { 148 | assetId 149 | } 150 | quantity 151 | } 152 | } 153 | } 154 | }` 155 | 156 | const usePaymentAddressesQuery: Query< 157 | { paymentAddresses: PaymentAddress[] }, 158 | { addresses: string[] } 159 | > = (options) => useQuery(PaymentAddressesQuery, options) 160 | 161 | function getBalanceByPaymentAddresses(paymentAddresses: PaymentAddress[]): Value { 162 | const balance: Value = { 163 | lovelace: BigInt(0), 164 | assets: new Map() 165 | } 166 | 167 | paymentAddresses.forEach((paymentAddress) => { 168 | paymentAddress.summary?.assetBalances?.forEach((assetBalance) => { 169 | if (assetBalance) { 170 | const { assetId } = assetBalance.asset 171 | const quantity = assetBalance.quantity 172 | if (assetId === 'ada') { 173 | balance.lovelace = balance.lovelace + BigInt(quantity) 174 | return 175 | } 176 | const value = balance.assets.get(assetId) ?? BigInt(0) 177 | balance.assets.set(assetId, value + BigInt(quantity)) 178 | } 179 | }) 180 | }) 181 | 182 | return balance 183 | } 184 | 185 | const SummaryQuery = gql` 186 | ${StakePoolFields} 187 | query Summary($addresses: [String]!, $rewardAddress: StakeAddress!) { 188 | paymentAddresses(addresses: $addresses) { 189 | address 190 | summary { 191 | assetBalances { 192 | asset { 193 | assetId 194 | } 195 | quantity 196 | } 197 | } 198 | } 199 | rewards_aggregate(where: { address: { _eq: $rewardAddress } }) { 200 | aggregate { 201 | sum { 202 | amount 203 | } 204 | } 205 | } 206 | withdrawals_aggregate(where: { address: { _eq: $rewardAddress } }) { 207 | aggregate { 208 | sum { 209 | amount 210 | } 211 | } 212 | } 213 | stakeRegistrations_aggregate(where: { address: { _eq: $rewardAddress } }) { 214 | aggregate { 215 | count 216 | } 217 | } 218 | stakeDeregistrations_aggregate(where: { address: { _eq: $rewardAddress } }) { 219 | aggregate { 220 | count 221 | } 222 | } 223 | delegations( 224 | limit: 1 225 | order_by: { transaction: { block: { slotNo: desc } } } 226 | where: { address: { _eq: $rewardAddress } } 227 | ) { 228 | address 229 | stakePool { 230 | ...StakePoolFields 231 | } 232 | } 233 | } 234 | ` 235 | 236 | const useSummaryQuery: Query< 237 | { paymentAddresses: PaymentAddress[], rewards_aggregate: Reward_Aggregate, withdrawals_aggregate: Withdrawal_Aggregate, stakeRegistrations_aggregate: StakeRegistration_Aggregate, stakeDeregistrations_aggregate: StakeDeregistration_Aggregate, delegations: Delegation[] }, 238 | { addresses: string[], rewardAddress: string } 239 | > = (options) => useQuery(SummaryQuery, options) 240 | 241 | function isRegisteredOnChain(stakeRegistrationsAggregate: StakeRegistration_Aggregate, stakeDeregistrationsAggregate: StakeDeregistration_Aggregate): boolean { 242 | const registrationCount = BigInt(stakeRegistrationsAggregate.aggregate?.count ?? '0') 243 | const deregistrationCount = BigInt(stakeDeregistrationsAggregate.aggregate?.count ?? '0') 244 | return registrationCount > deregistrationCount 245 | } 246 | 247 | function getCurrentDelegation(stakeRegistrationsAggregate: StakeRegistration_Aggregate, stakeDeregistrationsAggregate: StakeDeregistration_Aggregate, delegations: Delegation[]): Delegation | undefined { 248 | if (isRegisteredOnChain(stakeRegistrationsAggregate, stakeDeregistrationsAggregate)) return delegations[0] 249 | } 250 | 251 | function getAvailableReward(rewardsAggregate: Reward_Aggregate, withdrawalsAggregate: Withdrawal_Aggregate): bigint { 252 | const rewardSum: bigint = BigInt(rewardsAggregate.aggregate?.sum.amount ?? 0) 253 | const withdrawalSum: bigint = BigInt(withdrawalsAggregate.aggregate?.sum.amount ?? 0) 254 | return rewardSum - withdrawalSum 255 | } 256 | 257 | const StakePoolRetirementFields = gql` 258 | fragment RetirementFields on StakePool { 259 | retirements { 260 | retiredInEpoch { 261 | number 262 | } 263 | announcedIn { 264 | hash 265 | } 266 | inEffectFrom 267 | } 268 | } 269 | ` 270 | 271 | const StakePoolsQuery = gql` 272 | ${StakePoolFields} 273 | ${StakePoolRetirementFields} 274 | query StakePools($id: StakePoolID, $limit: Int!, $offset: Int!) { 275 | stakePools( 276 | limit: $limit 277 | offset: $offset 278 | where: { 279 | id: { _eq: $id } 280 | } 281 | ) { 282 | ...StakePoolFields 283 | ...RetirementFields 284 | } 285 | } 286 | ` 287 | 288 | const useStakePoolsQuery: Query< 289 | { stakePools: StakePool[] }, 290 | { id?: string, limit: number, offset: number } 291 | > = (options) => useQuery(StakePoolsQuery, options) 292 | 293 | const OutputFields = gql` 294 | fragment OutputFields on TransactionOutput { 295 | address 296 | txHash 297 | index 298 | value 299 | tokens { 300 | asset { 301 | assetId 302 | } 303 | quantity 304 | } 305 | } 306 | ` 307 | 308 | const TransactionSummaryQuery = gql` 309 | ${OutputFields} 310 | query TransactionSummary($hashes: [Hash32Hex]!) { 311 | transactions(where: { hash: { _in: $hashes } }) { 312 | hash 313 | outputs { 314 | ...OutputFields 315 | } 316 | } 317 | } 318 | ` 319 | 320 | const useTransactionSummaryQuery: Query< 321 | { transactions: Transaction[] }, 322 | { hashes: string[] } 323 | > = (options) => useQuery(TransactionSummaryQuery, options) 324 | 325 | type RecipientRegistry = Map> 326 | 327 | const collectTransactionOutputs = (transactions: Transaction[]): RecipientRegistry => transactions.reduce((collection: RecipientRegistry, transaction) => { 328 | const { hash, outputs } = transaction 329 | const subCollection: Map = collection.get(hash) ?? new Map() 330 | outputs.forEach((output) => { 331 | if (output) subCollection.set(output.index, getRecipientFromTransactionOutput(output)) 332 | }) 333 | return collection.set(hash, subCollection) 334 | }, new Map()) 335 | 336 | const GraphQLURIContext = createContext<[string, (uri: string) => void]>(['', () => {}]) 337 | 338 | export type { Value, RecipientRegistry } 339 | export { decodeASCII, getBalanceByUTxOs, getPolicyId, getAssetName, getBalanceByPaymentAddresses, useUTxOSummaryQuery, usePaymentAddressesQuery, useSummaryQuery, getCurrentDelegation, getAvailableReward, useStakePoolsQuery, isRegisteredOnChain, sumValues, useTransactionSummaryQuery, collectTransactionOutputs, GraphQLURIContext } 340 | -------------------------------------------------------------------------------- /src/cardano/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { formatDerivationPath, decryptWithPassword, encryptWithPassword, estimateDateBySlot, estimateSlotByDate, getEpochBySlot, getSlotInEpochBySlot, harden, parseDerivationPath } from "./utils" 2 | 3 | test('estimateSlotByDate', () => { 4 | expect(estimateSlotByDate(new Date('2022-04-21T22:26:39.000Z'), 'mainnet')).toBe(59013708) 5 | expect(estimateSlotByDate(new Date('2022-04-21T22:28:04.000Z'), 'testnet')).toBe(56210868) 6 | expect(estimateSlotByDate(new Date('2022-04-28T01:56:00.000Z'), 'testnet')).toBe(56741744) 7 | expect(estimateSlotByDate(new Date('2022-11-26T23:51:57Z'), 'preview')).toBe(2850717) 8 | }) 9 | 10 | test('estimateDateBySlot', () => { 11 | expect(estimateDateBySlot(59013708, 'mainnet').toISOString()).toBe('2022-04-21T22:26:39.000Z') 12 | expect(estimateDateBySlot(56210868, 'testnet').toISOString()).toBe('2022-04-21T22:28:04.000Z') 13 | expect(estimateDateBySlot(56741744, 'testnet').toISOString()).toBe('2022-04-28T01:56:00.000Z') 14 | expect(estimateDateBySlot(2850717, 'preview').toISOString()).toBe('2022-11-26T23:51:57.000Z') 15 | }) 16 | 17 | test('estimateDateBySlot', () => { 18 | expect(getEpochBySlot(59013708, 'mainnet')).toBe(334) 19 | expect(getEpochBySlot(59016575, 'mainnet')).toBe(334) 20 | expect(getEpochBySlot(56210868, 'testnet')).toBe(200) 21 | expect(getEpochBySlot(56211570, 'testnet')).toBe(200) 22 | expect(getEpochBySlot(56213638, 'testnet')).toBe(200) 23 | expect(getEpochBySlot(2851702, 'preview')).toBe(33) 24 | }) 25 | 26 | test('getSlotInEpochBySlot', () => { 27 | expect(getSlotInEpochBySlot(59016575, 'mainnet')).toBe(91775) 28 | expect(getSlotInEpochBySlot(56213638, 'testnet')).toBe(183238) 29 | }) 30 | 31 | test('encryption', async () => { 32 | const plaintext = new Uint8Array(Buffer.from('lorem ipsum', 'utf-8')) 33 | const ciphertext = new Uint8Array(await encryptWithPassword(plaintext, 'abcd', 0)) 34 | expect(ciphertext).not.toEqual(new Uint8Array(plaintext)) 35 | expect(ciphertext).not.toEqual(new Uint8Array(await encryptWithPassword(plaintext, '1234', 0))) 36 | expect(ciphertext).not.toEqual(new Uint8Array(await encryptWithPassword(plaintext, 'abcd', 1))) 37 | expect(plaintext).toEqual(new Uint8Array(await decryptWithPassword(ciphertext, 'abcd', 0))) 38 | }) 39 | 40 | test('parseDerivationPath', () => { 41 | expect(parseDerivationPath("m/1854'/1815'/0'/0/0")).toEqual([harden(1854), harden(1815), harden(0), 0, 0]) 42 | expect(() => parseDerivationPath("1854'/1815'/0'/0/0")).toThrowError() 43 | expect(() => parseDerivationPath("1854'/1815'/a'/0/0")).toThrowError() 44 | expect(() => parseDerivationPath("1854'/1815''/0'/0/0")).toThrowError() 45 | }) 46 | 47 | test('buildDerivationPath', () => { 48 | expect(formatDerivationPath([harden(1854), harden(1815), harden(0), 0, 0])).toEqual("m/1854'/1815'/0'/0/0") 49 | }) 50 | -------------------------------------------------------------------------------- /src/cardano/utils.ts: -------------------------------------------------------------------------------- 1 | import type { Network } from './config' 2 | 3 | const slotLength = (network: Network): number => { 4 | switch (network) { 5 | case 'mainnet': return 432000 6 | case 'testnet': return 432000 7 | case 'preview': return 86400 8 | } 9 | } 10 | 11 | const shelleyStart = (network: Network): number => { 12 | switch (network) { 13 | case 'mainnet': return 4924800 14 | case 'testnet': return 4924800 + 129600 - slotLength(network) 15 | case 'preview': return 0 16 | } 17 | } 18 | 19 | const networkOffset = (network: Network): number => { 20 | switch (network) { 21 | case 'mainnet': return 1596491091 22 | case 'testnet': return 1599294016 + 129600 - slotLength(network) 23 | case 'preview': return 1666656000 24 | } 25 | } 26 | 27 | const estimateDateBySlot = (slot: number, network: Network): Date => new Date((slot - shelleyStart(network) + networkOffset(network)) * 1000) 28 | const estimateSlotByDate = (date: Date, network: Network): number => Math.floor(date.getTime() / 1000) + shelleyStart(network) - networkOffset(network) 29 | const slotSinceShelley = (slot: number, network: Network): number => slot - shelleyStart(network) 30 | 31 | const epochBeforeShelly = (network: Network): number => { 32 | switch (network) { 33 | case 'mainnet': return 208 + 1 34 | case 'testnet': return 80 + 1 35 | case 'preview': return 0 36 | } 37 | } 38 | 39 | const getEpochBySlot = (slot: number, network: Network) => Math.floor(slotSinceShelley(slot, network) / slotLength(network)) + epochBeforeShelly(network) 40 | const getSlotInEpochBySlot = (slot: number, network: Network) => slotSinceShelley(slot, network) % slotLength(network) 41 | 42 | const deriveKeyFromPassword = async (password: string, salt: ArrayBuffer): Promise => 43 | crypto.subtle.importKey( 44 | 'raw', 45 | Buffer.from(password, 'utf-8'), 46 | 'PBKDF2', 47 | false, 48 | ['deriveBits', 'deriveKey'] 49 | ).then((material) => 50 | crypto.subtle.deriveKey( 51 | { 52 | name: 'PBKDF2', 53 | salt, 54 | iterations: 100000, 55 | hash: 'SHA-256', 56 | }, 57 | material, 58 | { 'name': 'AES-GCM', 'length': 256 }, 59 | true, 60 | ['encrypt', 'decrypt'] 61 | ) 62 | ) 63 | 64 | 65 | const MAX_IV_NUM = 2 ** 32 - 1 66 | 67 | const getIvFromNumber = (num: number): ArrayBuffer => { 68 | if (num > MAX_IV_NUM) throw new Error(`IV number overflow: ${num}`) 69 | const array = new Uint32Array(4) 70 | array[3] = num 71 | return array.buffer 72 | } 73 | 74 | const encryptWithPassword = async (plaintext: ArrayBuffer, password: string, id: number): Promise => { 75 | const iv = getIvFromNumber(id) 76 | const salt = await SHA256Digest(iv) 77 | return deriveKeyFromPassword(password, salt) 78 | .then((key) => { 79 | return crypto.subtle.encrypt( 80 | { name: 'AES-GCM', iv }, 81 | key, 82 | plaintext, 83 | ) 84 | }) 85 | } 86 | 87 | const decryptWithPassword = async (ciphertext: ArrayBuffer, password: string, id: number): Promise => { 88 | const iv = getIvFromNumber(id) 89 | const salt = await SHA256Digest(iv) 90 | return deriveKeyFromPassword(password, salt) 91 | .then((key) => crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ciphertext)) 92 | } 93 | 94 | const SHA256Digest = async (data: ArrayBuffer): Promise => crypto.subtle.digest('SHA-256', data) 95 | 96 | const harden = (num: number): number => 0x80000000 + num 97 | 98 | const parseDerivationPath = (path: string): number[] => path 99 | .split('/') 100 | .map((item, index) => { 101 | if (index === 0 && item !== 'm') throw new Error('Not "m" founded') 102 | return item 103 | }) 104 | .slice(1) 105 | .map((index) => { 106 | if (index.endsWith("'")) return harden(parseInt(index.substring(0, index.length - 1))) 107 | return parseInt(index) 108 | }) 109 | 110 | const formatDerivationPath = (indices: number[]): string => ['m'].concat(indices.map((index) => { 111 | if (index >= harden(0)) return `${index - harden(0)}'` 112 | return index.toString() 113 | })).join('/') 114 | 115 | export { estimateDateBySlot, estimateSlotByDate, getEpochBySlot, getSlotInEpochBySlot, slotLength, encryptWithPassword, decryptWithPassword, SHA256Digest, harden, parseDerivationPath, formatDerivationPath } 116 | -------------------------------------------------------------------------------- /src/components/address.tsx: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react' 2 | import type { FC, ReactNode } from 'react' 3 | import { ClipboardDocumentCheckIcon, ClipboardDocumentIcon, MagnifyingGlassCircleIcon } from '@heroicons/react/24/solid' 4 | import { CopyButton } from './layout' 5 | import { ConfigContext } from '../cardano/config' 6 | import type { Config } from '../cardano/config' 7 | 8 | const getCardanoScanHost = (config: Config): string => { 9 | switch (config.network) { 10 | case 'mainnet': return 'https://cardanoscan.io' 11 | case 'testnet': return 'https://testnet.cardanoscan.io' 12 | case 'preview': return 'https://preview.cardanoscan.io' 13 | } 14 | } 15 | 16 | type CardanoScanType = 17 | | 'address' 18 | | 'stakekey' 19 | | 'transaction' 20 | | 'pool' 21 | 22 | const CardanoScanLink: FC<{ 23 | className?: string 24 | children: ReactNode 25 | scanType: CardanoScanType 26 | id: string 27 | }> = ({ className, children, scanType, id }) => { 28 | const [config, _] = useContext(ConfigContext) 29 | const host = getCardanoScanHost(config) 30 | const href = new URL([scanType, id].join('/'), host) 31 | return {children}; 32 | } 33 | 34 | const AddressableContent: FC<{ 35 | content: string 36 | buttonClassName?: string 37 | scanType?: CardanoScanType 38 | }> = ({ content, scanType, buttonClassName }) => { 39 | return ( 40 |
41 | {content} 42 | 54 |
55 | ) 56 | } 57 | 58 | export { AddressableContent, CardanoScanLink } 59 | -------------------------------------------------------------------------------- /src/components/currency.test.ts: -------------------------------------------------------------------------------- 1 | import { removeTrailingZero, toDecimal } from './currency' 2 | 3 | test('toDecimal', () => { 4 | expect(toDecimal(BigInt(10000000), 6)).toBe('10.000000') 5 | }) 6 | 7 | test('removeTrailingZero', () => { 8 | expect(removeTrailingZero('10000001.000100')).toBe('10000001.0001') 9 | expect(removeTrailingZero('10000001.0')).toBe('10000001') 10 | expect(removeTrailingZero('10000001')).toBe('10000001') 11 | expect(removeTrailingZero('10000000')).toBe('10000000') 12 | }) 13 | -------------------------------------------------------------------------------- /src/components/currency.tsx: -------------------------------------------------------------------------------- 1 | import { ChangeEventHandler, useCallback, useContext } from "react" 2 | import type { FC } from 'react' 3 | import NumberFormat from "react-number-format" 4 | import { ConfigContext, isMainnet } from "../cardano/config" 5 | import type { Config } from "../cardano/config" 6 | 7 | const toDecimal = (value: bigint, decimals: number): string => { 8 | const text = value.toString() 9 | if (decimals === 0) return text 10 | if (text.length > decimals) { 11 | return [text.slice(0, -decimals), text.slice(-decimals)].join('.') 12 | } else { 13 | return ['0', text.padStart(decimals, '0')].join('.') 14 | } 15 | } 16 | 17 | const removeTrailingZero = (value: string): string => 18 | value 19 | .replace(/(\.[0-9]*[1-9]+)0*$/, '$1') 20 | .replace(/\.0+$/, '') 21 | 22 | const CurrencyInput: FC<{ 23 | disabled?: boolean 24 | value: bigint 25 | onChange: (_: bigint) => void 26 | decimals: number 27 | className?: string 28 | placeholder?: string 29 | }> = ({ disabled, value, onChange, decimals, ...props }) => { 30 | const inputValue = toDecimal(value, decimals) 31 | 32 | const changeHandle: ChangeEventHandler = useCallback((event) => { 33 | const [i, f] = event.target.value.split('.', 2) 34 | const number = BigInt(i + (f || '0').slice(0, decimals).padEnd(decimals, '0')) 35 | onChange(number) 36 | }, [decimals, onChange]) 37 | 38 | return ( 39 | 52 | ) 53 | } 54 | 55 | const getADASymbol = (config: Config) => isMainnet(config) ? '₳' : 't₳' 56 | 57 | const AssetAmount: FC<{ 58 | quantity: bigint 59 | decimals: number 60 | symbol: string 61 | className?: string 62 | }> = ({ quantity, decimals, symbol, className }) => { 63 | const value = removeTrailingZero(toDecimal(quantity, decimals)) 64 | return ( 65 | {`${value} ${symbol}`} 66 | ) 67 | } 68 | 69 | const ADAAmount: FC<{ 70 | lovelace: bigint 71 | className?: string 72 | }> = ({ lovelace, className }) => { 73 | const [config, _] = useContext(ConfigContext) 74 | return 75 | } 76 | 77 | const LabeledCurrencyInput: FC<{ 78 | symbol: string 79 | decimal: number 80 | value: bigint 81 | min?: bigint 82 | max: bigint 83 | maxButton?: boolean 84 | onChange: (_: bigint) => void 85 | placeholder?: string 86 | }> = (props) => { 87 | const { decimal, value, onChange, min, max, maxButton, symbol, placeholder } = props 88 | const changeHandle = useCallback((value: bigint) => { 89 | const min = value > max ? max : value 90 | onChange(min) 91 | }, [max, onChange]) 92 | const isValid = value > 0 && value <= max && (min ? value >= min : true) 93 | 94 | return ( 95 | 115 | ) 116 | } 117 | 118 | const ADAInput: FC<{ 119 | className?: string 120 | disabled?: boolean 121 | lovelace: bigint 122 | setLovelace: (_: bigint) => void 123 | }> = ({ className, disabled, lovelace, setLovelace }) => { 124 | return ( 125 | 126 | ) 127 | } 128 | 129 | export { getADASymbol, removeTrailingZero, toDecimal, ADAAmount, ADAInput, AssetAmount, CurrencyInput, LabeledCurrencyInput } 130 | -------------------------------------------------------------------------------- /src/components/native-script.tsx: -------------------------------------------------------------------------------- 1 | import type { NativeScript, Vkeywitness } from '@dcspark/cardano-multiplatform-lib-browser' 2 | import { useContext, useMemo } from 'react' 3 | import type { FC, ReactNode } from 'react' 4 | import { toIter } from '../cardano/multiplatform-lib' 5 | import type { Cardano } from '../cardano/multiplatform-lib' 6 | import { NoSymbolIcon, ClipboardDocumentCheckIcon, ClipboardDocumentIcon, LockClosedIcon, LockOpenIcon, PencilIcon, ShieldCheckIcon } from '@heroicons/react/24/solid' 7 | import { CopyButton } from './layout' 8 | import { estimateDateBySlot } from '../cardano/utils' 9 | import { ConfigContext } from '../cardano/config' 10 | 11 | type VerifyingData = { 12 | signatures?: Map, 13 | txStartSlot?: number, 14 | txExpirySlot?: number 15 | } 16 | 17 | const Badge: FC<{ 18 | className?: string 19 | children: ReactNode 20 | }> = ({ className, children }) => { 21 | const baseClassName = 'flex items-center space-x-1 p-1 rounded' 22 | return ( 23 |
24 | {children} 25 |
26 | ) 27 | } 28 | 29 | const SignatureBadge: FC = () => { 30 | return ( 31 | 32 | 33 | Signature 34 | 35 | ) 36 | } 37 | 38 | const ExpiryBadge: FC = () => { 39 | return ( 40 | 41 | 42 | Expiry 43 | 44 | ) 45 | } 46 | 47 | const StartBadge: FC = () => { 48 | return ( 49 | 50 | 51 | Start 52 | 53 | ) 54 | } 55 | 56 | const TimelockViewer: FC<{ 57 | slot: number 58 | isValid: boolean 59 | }> = ({ slot, isValid }) => { 60 | const [config, _] = useContext(ConfigContext) 61 | return ( 62 |
63 | {slot} 64 | (est. {estimateDateBySlot(slot, config.network).toLocaleString()}) 65 | {!isValid && } 66 | {isValid && } 67 |
68 | ) 69 | } 70 | 71 | const TimelockStartViewer: FC<{ 72 | slot: number 73 | txStartSlot?: number 74 | }> = ({ slot, txStartSlot }) => { 75 | const isValid = useMemo(() => { 76 | if (!txStartSlot) return false 77 | return txStartSlot >= slot 78 | }, [slot, txStartSlot]) 79 | return ( 80 | 81 | ) 82 | } 83 | 84 | const TimelockExpiryViewer: FC<{ 85 | slot: number 86 | txExpirySlot?: number 87 | }> = ({ slot, txExpirySlot }) => { 88 | const isValid = useMemo(() => { 89 | if (!txExpirySlot) return false 90 | return txExpirySlot <= slot 91 | }, [slot, txExpirySlot]) 92 | return ( 93 | 94 | ) 95 | } 96 | 97 | const SignatureViewer: FC<{ 98 | name: string 99 | className?: string 100 | signature?: string 101 | signedClassName?: string 102 | }> = ({ name, signature, className, signedClassName }) => { 103 | const color = signature ? signedClassName : '' 104 | 105 | return ( 106 |
107 | 108 |
{name}
109 | 113 |
114 | ) 115 | } 116 | 117 | const NativeScriptViewer: FC<{ 118 | nativeScript: NativeScript 119 | cardano?: Cardano 120 | headerClassName?: string 121 | ulClassName?: string 122 | liClassName?: string 123 | className?: string 124 | verifyingData?: VerifyingData 125 | }> = ({ cardano, className, headerClassName, ulClassName, liClassName, nativeScript, verifyingData }) => { 126 | let script; 127 | 128 | script = nativeScript.as_script_pubkey() 129 | if (script) { 130 | const keyHashHex = script.addr_keyhash().to_hex() 131 | const signature = verifyingData?.signatures?.get(keyHashHex) 132 | const signatureHex = cardano?.buildSignatureSetHex(signature) 133 | return ( 134 | 135 | ) 136 | } 137 | 138 | script = nativeScript.as_timelock_expiry() 139 | if (script) { 140 | const slot = parseInt(script.slot().to_str()) 141 | return ( 142 |
143 | 144 | 145 |
146 | ) 147 | } 148 | 149 | script = nativeScript.as_timelock_start() 150 | if (script) { 151 | const slot = parseInt(script.slot().to_str()) 152 | return ( 153 |
154 | 155 | 156 |
157 | ) 158 | } 159 | 160 | script = nativeScript.as_script_all() 161 | if (script) return ( 162 |
163 |
Require all
164 |
    165 | {Array.from(toIter(script.native_scripts())).map((nativeScript, index) => 166 |
  • 167 | 175 |
  • 176 | )} 177 |
178 |
179 | ) 180 | 181 | script = nativeScript.as_script_any() 182 | if (script) return ( 183 |
184 |
Require any
185 |
    186 | {Array.from(toIter(script.native_scripts())).map((nativeScript, index) => 187 |
  • 188 | 196 |
  • 197 | )} 198 |
199 |
200 | ) 201 | 202 | script = nativeScript.as_script_n_of_k() 203 | if (script) return ( 204 |
205 |
Require least {script.n()}
206 |
    207 | {Array.from(toIter(script.native_scripts())).map((nativeScript, index) => 208 |
  • 209 | 217 |
  • 218 | )} 219 |
220 |
221 | ) 222 | 223 | throw new Error('Unsupported NativeScript') 224 | } 225 | 226 | export type { VerifyingData } 227 | export { SignatureBadge, ExpiryBadge, StartBadge, NativeScriptViewer, TimelockStartViewer, TimelockExpiryViewer, SignatureViewer } 228 | -------------------------------------------------------------------------------- /src/components/notification.tsx: -------------------------------------------------------------------------------- 1 | import { CheckCircleIcon, XCircleIcon, XMarkIcon } from '@heroicons/react/24/solid' 2 | import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react" 3 | import type { FC } from 'react' 4 | import { ProgressBar } from "./status" 5 | import { nanoid } from 'nanoid' 6 | 7 | type NotificationType = 'success' | 'error' 8 | type Message = string | Error 9 | const getMessage = (msg: Message): string => msg instanceof Error ? msg.message : String(msg) 10 | 11 | type Notification = { 12 | id: string 13 | type: NotificationType, 14 | message: string 15 | } 16 | 17 | const NotificationContext = createContext<{ 18 | notifications: Notification[] 19 | notify: (type: NotificationType, message: Message) => void 20 | dismissHandle: (id: string) => void 21 | }>({ 22 | notifications: [], 23 | notify: (_: NotificationType, __: Message) => {}, 24 | dismissHandle: (_: string) => {} 25 | }) 26 | 27 | const NotificationIcon: FC<{ 28 | type: NotificationType 29 | }> = ({ type }) => { 30 | const className = 'h-4 w-4' 31 | 32 | switch (type) { 33 | case 'success': return ; 34 | case 'error': return ; 35 | } 36 | } 37 | 38 | const getClassName = (type: NotificationType): string => { 39 | const base = 'rounded shadow overflow-hidden relative' 40 | 41 | switch (type) { 42 | case 'success': return `${base} bg-green-100 text-green-500` 43 | case 'error': return `${base} bg-red-100 text-red-500` 44 | } 45 | } 46 | 47 | const getProgressBarClassName = (type: NotificationType): string => { 48 | const base = `h-1` 49 | 50 | switch (type) { 51 | case 'success': return `${base} bg-green-500 text-green-500` 52 | case 'error': return `${base} bg-red-500 text-red-500` 53 | } 54 | } 55 | 56 | const Notification: FC<{ 57 | notification: Notification 58 | dismissHandle: (id: string) => any 59 | }> = ({ notification, dismissHandle }) => { 60 | const { id, type, message } = notification 61 | const [progress, setProgress] = useState(100) 62 | const [timer, setTimer] = useState(true) 63 | const startTimer = useCallback(() => setTimer(true), []) 64 | const stopTimer = useCallback(() => setTimer(false), []) 65 | const intervalRef = useRef() 66 | 67 | useEffect(() => { 68 | if (!timer) { 69 | clearInterval(intervalRef.current) 70 | return 71 | } 72 | 73 | intervalRef.current = setInterval(() => { 74 | setProgress((prev) => { 75 | if (prev > 0) { 76 | return prev - 0.5 77 | } 78 | return 0 79 | }) 80 | }, 20) 81 | 82 | return () => { 83 | clearInterval(intervalRef.current) 84 | } 85 | }, [timer]) 86 | 87 | useEffect(() => { 88 | if (progress <= 0) { 89 | stopTimer() 90 | dismissHandle(id) 91 | } 92 | }, [progress, dismissHandle, id, stopTimer]) 93 | 94 | return ( 95 |
96 |
97 |
98 |
{message}
99 | 100 |
101 |
102 | 103 |
104 |
105 | ) 106 | } 107 | 108 | const NotificationCenter: FC<{ 109 | className: string 110 | }> = ({ className }) => { 111 | const { notifications, dismissHandle } = useContext(NotificationContext) 112 | 113 | return ( 114 |
    115 | {notifications.map((notification) => 116 |
  • 117 | 118 |
  • 119 | )} 120 |
121 | ) 122 | } 123 | 124 | const useNotification = () => { 125 | const [notifications, setNotificaitons] = useState([]) 126 | 127 | const notify = (type: NotificationType, message: Message) => { 128 | setNotificaitons(notifications.concat({ id: nanoid(), type, message: getMessage(message) })) 129 | } 130 | 131 | const dismissHandle = (id: string) => { 132 | setNotificaitons(notifications.filter((notification) => notification.id !== id)) 133 | } 134 | 135 | return { notifications, notify, dismissHandle } 136 | } 137 | 138 | export type { Notification, NotificationType } 139 | export { NotificationCenter, NotificationContext, useNotification } 140 | -------------------------------------------------------------------------------- /src/components/password.test.ts: -------------------------------------------------------------------------------- 1 | import { isPasswordStrong, testPasswordDigits, testPasswordLength, testPasswordLowerCase, testPasswordSpecials, testPasswordUpperCase } from "./password" 2 | 3 | test('testPasswordLength', () => { 4 | expect(testPasswordLength('ajksakn?')).toBeTruthy() 5 | expect(testPasswordLength('kj!kjfz')).toBeFalsy() 6 | expect(testPasswordLowerCase('')).toBeFalsy() 7 | }) 8 | 9 | test('testPasswordUpperCase', () => { 10 | expect(testPasswordUpperCase('AA#')).toBeTruthy() 11 | expect(testPasswordUpperCase('KZ')).toBeTruthy() 12 | expect(testPasswordUpperCase('Chh')).toBeFalsy() 13 | expect(testPasswordUpperCase('sg')).toBeFalsy() 14 | expect(testPasswordLowerCase('')).toBeFalsy() 15 | }) 16 | 17 | test('testPasswordLowerCase', () => { 18 | expect(testPasswordLowerCase('Bzzz')).toBeTruthy() 19 | expect(testPasswordLowerCase('cl%')).toBeTruthy() 20 | expect(testPasswordLowerCase('EI')).toBeFalsy() 21 | expect(testPasswordLowerCase('JKJKDLJFK143&')).toBeFalsy() 22 | expect(testPasswordLowerCase('')).toBeFalsy() 23 | }) 24 | 25 | test('testPasswordDigits', () => { 26 | expect(testPasswordDigits('37137i21234J#')).toBeTruthy() 27 | expect(testPasswordDigits('37$')).toBeTruthy() 28 | expect(testPasswordDigits('3^')).toBeFalsy() 29 | expect(testPasswordDigits('')).toBeFalsy() 30 | }) 31 | 32 | test('testPasswordSpecials', () => { 33 | expect(testPasswordSpecials('a8#/')).toBeTruthy() 34 | expect(testPasswordSpecials('k8AZluz71.')).toBeFalsy() 35 | expect(testPasswordSpecials('')).toBeFalsy() 36 | }) 37 | 38 | test('isPasswordStrong', () => { 39 | expect(isPasswordStrong('ic{K6Bio"pMS7')).toBeTruthy() 40 | expect(isPasswordStrong('kjaksj8123jk8')).toBeFalsy() 41 | expect(isPasswordStrong('KJAKSJ8123JK8')).toBeFalsy() 42 | }) 43 | -------------------------------------------------------------------------------- /src/components/password.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo, useState, useCallback } from 'react' 2 | import type { ChangeEventHandler, KeyboardEvent, FC, ReactNode, HTMLInputTypeAttribute } from 'react' 3 | import { CheckIcon, EyeIcon, EyeSlashIcon, KeyIcon, XMarkIcon } from '@heroicons/react/24/solid' 4 | import { Modal, useEnterPressListener } from './layout' 5 | 6 | const PasswordInput: FC<{ 7 | password: string 8 | setPassword: (password: string) => void 9 | placeholder?: string 10 | onEnter?: (event: KeyboardEvent) => void 11 | invalid?: boolean 12 | onFocus?: () => void 13 | onBlur?: () => void 14 | autoFocus?: boolean 15 | }> = ({ password, setPassword, placeholder, onEnter, invalid, onFocus, onBlur, autoFocus }) => { 16 | const [isVisible, setIsVisible] = useState(false) 17 | const inputType: HTMLInputTypeAttribute = useMemo(() => isVisible ? 'text' : 'password', [isVisible]) 18 | const onChange: ChangeEventHandler = useCallback((event) => { 19 | setPassword(event.target.value) 20 | }, [setPassword]) 21 | const toggle = useCallback(() => setIsVisible(!isVisible), [isVisible]) 22 | const pressEnter = useEnterPressListener((event) => { 23 | onEnter && onEnter(event) 24 | }) 25 | const className = useMemo(() => invalid ? 'text-red-500' : '', [invalid]) 26 | 27 | return ( 28 | 47 | ) 48 | } 49 | 50 | const MIN_LENGTH = 8 51 | const MIN_UPPERCASE = 2 52 | const MIN_LOWERCASE = 2 53 | const MIN_DIGITS = 2 54 | const MIN_SPECIALS = 2 55 | 56 | type PasswordTester = (password: string) => boolean 57 | 58 | const testPasswordLength: PasswordTester = (password) => password.length >= MIN_LENGTH 59 | const testPasswordUpperCase: PasswordTester = (password) => (password.match(/[A-Z]/g)?.length ?? 0) >= MIN_UPPERCASE 60 | const testPasswordLowerCase: PasswordTester = (password) => (password.match(/[a-z]/g)?.length ?? 0) >= MIN_LOWERCASE 61 | const testPasswordDigits: PasswordTester = (password) => (password.match(/\d/g)?.length ?? 0) >= MIN_DIGITS 62 | const testPasswordSpecials: PasswordTester = (password) => (password.match(/\W/g)?.length ?? 0) >= MIN_SPECIALS 63 | 64 | const testPasswordSuits: PasswordTester[] = [ 65 | testPasswordLength, 66 | testPasswordUpperCase, 67 | testPasswordLowerCase, 68 | testPasswordDigits, 69 | testPasswordSpecials 70 | ] 71 | 72 | const testPasswordDesc: string[] = [ 73 | `Minimum ${MIN_LENGTH} characters`, 74 | `${MIN_UPPERCASE} uppercase letters`, 75 | `${MIN_LOWERCASE} lowercase letters`, 76 | `${MIN_DIGITS} digits`, 77 | `${MIN_SPECIALS} special characters, e.g. #-?[]()` 78 | ] 79 | 80 | const isPasswordStrong: PasswordTester = (password) => testPasswordSuits.every((fn) => fn(password)) 81 | 82 | const PasswordCheckItem: FC<{ 83 | tester: PasswordTester 84 | description: string 85 | password: string 86 | }> = ({ tester, description, password }) => { 87 | const valid = useMemo(() => tester(password), [tester, password]) 88 | const className = useMemo(() => ['flex space-x-1 items-center', valid ? 'text-green-500' : 'text-red-500'].join(' '), [valid]) 89 | return ( 90 |
  • 91 | {valid && } 92 | {!valid && } 93 | {description} 94 |
  • 95 | ) 96 | } 97 | 98 | const PasswordStrenghCheck: FC<{ 99 | className?: string 100 | password: string 101 | }> = ({ className, password }) => { 102 | return ( 103 |
      104 | {testPasswordSuits.map((tester, index) => ( 105 | 110 | ))} 111 |
    112 | ) 113 | } 114 | 115 | const StrongPasswordInput: FC<{ 116 | password: string 117 | setPassword: (password: string) => void 118 | placeholder?: string 119 | }> = ({ password, setPassword, placeholder }) => { 120 | const [tips, setTips] = useState(false) 121 | const openTips = useCallback(() => setTips(true), []) 122 | const closeTips = useCallback(() => setTips(false), []) 123 | const invalid = useMemo(() => !isPasswordStrong(password), [password]) 124 | 125 | return ( 126 |
    127 | 134 | {tips &&
    135 | 138 |
    } 139 |
    140 | ) 141 | } 142 | 143 | const PasswordBox: FC<{ 144 | title: string 145 | children: ReactNode 146 | disabled?: boolean 147 | onConfirm: (password: string) => void 148 | }> = ({ title, children, disabled, onConfirm }) => { 149 | const [password, setPassword] = useState('') 150 | const confirm = useCallback(() => { 151 | onConfirm(password) 152 | }, [onConfirm, password]) 153 | 154 | return (<> 155 |
    156 |
    {title}
    157 | 163 |
    164 | 172 | ) 173 | } 174 | 175 | const AskPasswordModalButton: FC<{ 176 | className?: string 177 | title: string 178 | disabled?: boolean 179 | children?: ReactNode 180 | onConfirm: (password: string) => void 181 | }> = ({ className, title, disabled, children, onConfirm }) => { 182 | const [modal, setModal] = useState(false) 183 | const closeModal = useCallback(() => setModal(false), []) 184 | const confirm = useCallback((password: string) => { 185 | onConfirm(password) 186 | closeModal() 187 | }, [closeModal, onConfirm]) 188 | 189 | return ( 190 | <> 191 | 192 | {modal && 193 | 197 | Confirm 198 | 199 | } 200 | 201 | ) 202 | } 203 | 204 | export { PasswordInput, StrongPasswordInput, AskPasswordModalButton, PasswordBox, testPasswordLength, testPasswordUpperCase, testPasswordLowerCase, testPasswordDigits, testPasswordSpecials, isPasswordStrong } 205 | -------------------------------------------------------------------------------- /src/components/status.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | 3 | const Loading: FC = () => ( 4 |
    5 | 6 | Loading... 7 |
    8 | ) 9 | 10 | const PartialLoading: FC = () => { 11 | return ( 12 | 13 | ) 14 | } 15 | 16 | const ProgressBar: FC<{ 17 | className?: string 18 | max: number 19 | value: number 20 | }> = ({ max, value, className }) => { 21 | const progress = value / max * 100 22 | const style = { width: `${progress}%` } 23 | 24 | return ( 25 |
    26 | ) 27 | } 28 | 29 | const SpinnerIcon: FC<{ 30 | className?: string 31 | }> = ({ className }) => { 32 | return ( 33 | 34 | 35 | 36 | 37 | ) 38 | } 39 | 40 | export { Loading, PartialLoading, ProgressBar, SpinnerIcon } 41 | -------------------------------------------------------------------------------- /src/components/time.tsx: -------------------------------------------------------------------------------- 1 | import { useContext, useEffect, useMemo, useState } from 'react' 2 | import type { FC } from 'react' 3 | import { ConfigContext } from '../cardano/config' 4 | import { estimateSlotByDate, getEpochBySlot, getSlotInEpochBySlot, slotLength } from '../cardano/utils' 5 | import { ChevronDoubleLeftIcon, ChevronDoubleRightIcon, ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/24/solid' 6 | 7 | const useLiveDate = (ms?: number) => { 8 | const [date, setDate] = useState(new Date()) 9 | 10 | useEffect(() => { 11 | const id = setInterval(() => { 12 | setDate(new Date()) 13 | }, ms ?? 1000) 14 | 15 | return () => { 16 | clearInterval(id) 17 | } 18 | }, [setDate, ms]) 19 | 20 | return date 21 | } 22 | 23 | const useLiveSlot = (ms?: number) => { 24 | const [config, _] = useContext(ConfigContext) 25 | const now = useLiveDate(ms) 26 | return useMemo(() => estimateSlotByDate(now, config.network), [now, config.network]) 27 | } 28 | 29 | const ChainProgress: FC<{ 30 | className?: string 31 | }> = ({ className }) => { 32 | const date = useLiveDate() 33 | const baseClassName = 'relative h-6 rounded bg-gray-700 overflow-hidden' 34 | const [config, _] = useContext(ConfigContext) 35 | const [text, setText] = useState('') 36 | const [style, setStyle] = useState<{ width: string }>({ width: '0' }) 37 | const { network } = config 38 | 39 | useEffect(() => { 40 | const slot = estimateSlotByDate(date, network) 41 | const slotInEpoch = getSlotInEpochBySlot(slot, network) 42 | const epoch = getEpochBySlot(slot, network) 43 | const SlotLength = slotLength(network) 44 | const progress = slotInEpoch / SlotLength * 100 45 | 46 | setStyle({ width: `${progress}%` }) 47 | setText(`Epoch ${epoch}: ${slotInEpoch}/${SlotLength} (${progress.toFixed(0)}%)`) 48 | }, [network, date]) 49 | 50 | return ( 51 |
    52 |
    53 | {style &&
    } 54 |
    55 | {text} 56 |
    57 |
    58 |
    59 | ) 60 | } 61 | 62 | function monthIter(year: number, month: number): IterableIterator { 63 | let day = 1 64 | return { 65 | next: () => { 66 | const value = new Date(year, month, day++) 67 | if (value.getMonth() === month) return { done: false, value } 68 | return { done: true, value: null } 69 | }, 70 | [Symbol.iterator]: function () { return this } 71 | } 72 | } 73 | 74 | const Calendar: FC<{ 75 | selectedDate: Date 76 | onChange: (date: Date) => void 77 | isRed?: (date: Date, selectedDate: Date) => boolean 78 | }> = ({ selectedDate, onChange, isRed }) => { 79 | const [date, setDate] = useState(new Date(selectedDate.getFullYear(), selectedDate.getMonth(), 1)) 80 | const year = date.getFullYear() 81 | const month = date.getMonth() 82 | const weeks: Date[][] = useMemo(() => { 83 | const result: Date[][] = new Array([]) 84 | Array 85 | .from(monthIter(year, month)) 86 | .forEach((date) => { 87 | const day = date.getDay() 88 | if (day === 0) { 89 | result.push([date]) 90 | return 91 | } 92 | result[result.length - 1][day] = date 93 | }) 94 | return result 95 | }, [year, month]) 96 | const isOnSelectedDate = (date: Date): boolean => 97 | date.getFullYear() === selectedDate.getFullYear() && 98 | date.getMonth() === selectedDate.getMonth() && 99 | date.getDate() === selectedDate.getDate() 100 | 101 | return ( 102 |
    103 |
    104 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | {weeks.map((dates) => date.getTime()).join('-')}> 132 | {Array.from({ length: 7 }, (_, i) => i).map((day) => { 133 | const date = dates[day] 134 | if (!date) return ( 135 | 136 | ) 137 | const tdClassName = isRed && isRed(date, selectedDate) ? 'bg-red-100 text-red-700' : '' 138 | let buttonClassName = 'block w-full p-2 rounded hover:text-white ' 139 | if (isRed && isRed(date, selectedDate)) { 140 | buttonClassName = buttonClassName + 'hover:bg-red-700 ' 141 | } else { 142 | buttonClassName = buttonClassName + 'text-sky-700 hover:bg-sky-700 ' 143 | } 144 | if (isOnSelectedDate(date)) { 145 | buttonClassName = buttonClassName + 'text-white ' + (isRed && isRed(date, selectedDate) ? 'bg-red-700' : 'bg-sky-700') 146 | } 147 | return ( 148 | 151 | ) 152 | })} 153 | )} 154 | 155 |
    SunMonTueWedThuFriSat
    149 | 150 |
    156 |
    157 |
    158 | ) 159 | } 160 | 161 | export { ChainProgress, Calendar, useLiveDate, useLiveSlot } 162 | -------------------------------------------------------------------------------- /src/components/user-data.test.ts: -------------------------------------------------------------------------------- 1 | import { serializeUserData, deserializeUserData } from "./user-data" 2 | import type { UserData } from "./user-data" 3 | 4 | test('UserData', () => { 5 | const userData: UserData = { 6 | network: 'preview', 7 | version: '2', 8 | multisigWallets: [{ 9 | id: 'addr_test1xpff750879d8u3wvl9yhedaff0gv8zmecxzae3h9ty9wrtz9uf28cs7rk7m67rmse675wltjhya740893pwzgkvqqh8q67usf2', 10 | name: 'MWallet', 11 | description: 'xxx', 12 | policy: { type: 'All', policies: ['addr_test1qrmtl76z2yvzw2zas03xze674r2yc6wefw0pm9v5x4ma6zs45zncsuzyfftj8x2ecg69z5f7x2f3uyz6c38uaeftsrdqms6z7t'] }, 13 | updatedAt: new Date() 14 | }], 15 | personalWallets: [{ 16 | id: 0, 17 | hash: new Uint8Array([0]), 18 | rootKey: new Uint8Array([1]), 19 | updatedAt: new Date(), 20 | name: 'PW', 21 | description: 'lol', 22 | personalAccounts: new Map([[0, { 23 | publicKey: new Uint8Array([3]), 24 | paymentKeyHashes: [new Uint8Array([4])] 25 | }]]), 26 | multisigAccounts: new Map([[0, { 27 | publicKey: new Uint8Array([5]), 28 | addresses: [{ paymentKeyHash: new Uint8Array([6]), stakingKeyHash: new Uint8Array([7]) }] 29 | }]]) 30 | }], 31 | keyHashIndices: [{ 32 | hash: new Uint8Array([8]), 33 | derivationPath: [], 34 | walletId: 0 35 | }] 36 | } 37 | 38 | const dataJSON = serializeUserData(userData) 39 | const parsedData = deserializeUserData(dataJSON) 40 | 41 | expect(parsedData.network).toBe('preview') 42 | expect(parsedData.version).toBe('2') 43 | expect(parsedData.multisigWallets.length).toBe(1) 44 | 45 | const multisigWallet = parsedData.multisigWallets[0] 46 | expect(multisigWallet.id).toBe('addr_test1xpff750879d8u3wvl9yhedaff0gv8zmecxzae3h9ty9wrtz9uf28cs7rk7m67rmse675wltjhya740893pwzgkvqqh8q67usf2') 47 | expect(multisigWallet.name).toBe('MWallet') 48 | expect(multisigWallet.description).toBe('xxx') 49 | if (typeof multisigWallet.policy === 'string') throw new Error('Wrong policy') 50 | expect(multisigWallet.policy.type).toBe('All') 51 | expect(parsedData.personalWallets.length).toBe(1) 52 | 53 | const personalWallet = parsedData.personalWallets[0] 54 | expect(personalWallet.id).toBe(0) 55 | expect(personalWallet.hash[0]).toBe(0) 56 | expect(personalWallet.rootKey[0]).toBe(1) 57 | expect(personalWallet.name).toBe('PW') 58 | expect(personalWallet.description).toBe('lol') 59 | expect(personalWallet.personalAccounts.size).toBe(1) 60 | 61 | const personalAccount = personalWallet.personalAccounts.get(0) 62 | expect(personalAccount?.publicKey[0]).toBe(3) 63 | expect(personalAccount?.paymentKeyHashes[0][0]).toBe(4) 64 | 65 | const multisigAccount = personalWallet.multisigAccounts.get(0) 66 | expect(multisigAccount?.publicKey[0]).toBe(5) 67 | expect(multisigAccount?.addresses[0].paymentKeyHash[0]).toBe(6) 68 | expect(multisigAccount?.addresses[0].stakingKeyHash[0]).toBe(7) 69 | 70 | const keyHashIndex = parsedData.keyHashIndices[0] 71 | expect(keyHashIndex?.hash[0]).toBe(8) 72 | expect(keyHashIndex?.walletId).toBe(0) 73 | }) 74 | -------------------------------------------------------------------------------- /src/components/user-data.tsx: -------------------------------------------------------------------------------- 1 | import { useLiveQuery } from 'dexie-react-hooks' 2 | import { useContext, useEffect, useState, useMemo, useCallback } from 'react' 3 | import type { FC, ChangeEventHandler, MouseEventHandler, ReactNode } from 'react' 4 | import { ConfigContext } from '../cardano/config' 5 | import { db } from '../db' 6 | import type { MultisigWallet, PersonalWallet, KeyHashIndex } from '../db' 7 | import type { Network } from '../cardano/config' 8 | import { NotificationContext } from './notification' 9 | 10 | type UserData = { 11 | network: Network 12 | version: '2' 13 | multisigWallets: MultisigWallet[] 14 | personalWallets: PersonalWallet[] 15 | keyHashIndices: KeyHashIndex[] 16 | } 17 | 18 | const serializeUserData = (userData: UserData): string => { 19 | return JSON.stringify(userData, (_key, value) => { 20 | if (value instanceof Map) return { 21 | dataType: 'Map', 22 | data: Array.from(value.entries()) 23 | } 24 | if (value instanceof Uint8Array) return { 25 | dataType: 'Uint8Array', 26 | encoding: 'base64', 27 | data: Buffer.from(value).toString('base64') 28 | } 29 | return value 30 | }) 31 | } 32 | 33 | const deserializeUserData = (content: string): UserData => { 34 | return JSON.parse(content, (_key, value) => { 35 | if (value.dataType === 'Map') return new Map(value.data) 36 | if (value.dataType === 'Uint8Array') return new Uint8Array(Buffer.from(value.data, 'base64')) 37 | return value 38 | }) 39 | } 40 | 41 | const DownloadButton: FC<{ 42 | className?: string 43 | children: ReactNode 44 | download: string 45 | blobParts: BlobPart[] 46 | options?: BlobPropertyBag 47 | }> = ({ blobParts, options, download, className, children }) => { 48 | const [URI, setURI] = useState() 49 | 50 | useEffect(() => { 51 | if (blobParts) { 52 | const blob = new Blob(blobParts, options) 53 | setURI(window.URL.createObjectURL(blob)) 54 | } 55 | }, [blobParts, options]) 56 | 57 | if (!URI) return null 58 | 59 | return ( 60 | 64 | {children} 65 | 66 | ) 67 | } 68 | 69 | const ExportUserDataButton: FC = () => { 70 | const [config, _] = useContext(ConfigContext) 71 | const multisigWallets = useLiveQuery(async () => 72 | db.multisigWallets.toArray() 73 | ) 74 | const personalWallets = useLiveQuery(async () => 75 | db.personalWallets.toArray() 76 | ) 77 | const keyHashIndices = useLiveQuery(async () => 78 | db.keyHashIndices.toArray() 79 | ) 80 | const userData: UserData | undefined = useMemo(() => { 81 | if (!multisigWallets || !personalWallets || !keyHashIndices) return 82 | 83 | return { 84 | network: config.network, 85 | version: '2', 86 | multisigWallets, 87 | personalWallets, 88 | keyHashIndices 89 | } 90 | }, [multisigWallets, personalWallets, keyHashIndices, config.network]) 91 | const filename = useMemo(() => `roundtable-backup.${config.network}.json`, [config.network]) 92 | 93 | if (!userData) return null 94 | 95 | return ( 96 | 101 | Export User Data 102 | 103 | ) 104 | } 105 | 106 | const ImportUserData: FC = () => { 107 | const [config, _] = useContext(ConfigContext) 108 | const { notify } = useContext(NotificationContext) 109 | const [userDataJSON, setUserDataJSON] = useState('') 110 | 111 | const change: ChangeEventHandler = useCallback(async (event) => { 112 | event.preventDefault() 113 | const reader = new FileReader() 114 | reader.onload = async (e) => { 115 | const text = (e.target?.result) 116 | if (typeof text !== 'string') { 117 | notify('error', 'Invalid backup file') 118 | return 119 | } 120 | setUserDataJSON(text) 121 | } 122 | const files = event.target.files 123 | if (files) { 124 | reader.readAsText(files[0]) 125 | } 126 | }, [notify]) 127 | 128 | const click: MouseEventHandler = useCallback(() => { 129 | if (!userDataJSON) return; 130 | const userData = deserializeUserData(userDataJSON) 131 | if (userData.network !== config.network) { 132 | notify('error', `Wrong network: ${userData.network}`) 133 | return 134 | } 135 | if (userData.version !== '2') { 136 | notify('error', 'Incompatible version. Please recreate wallets and then migrate.') 137 | return 138 | } 139 | if (userData.version === '2') { 140 | db.transaction('rw', db.multisigWallets, db.personalWallets, db.keyHashIndices, async () => { 141 | await db.multisigWallets.bulkAdd(userData.multisigWallets) 142 | await db.personalWallets.bulkAdd(userData.personalWallets) 143 | return db.keyHashIndices.bulkAdd(userData.keyHashIndices) 144 | }).catch((error) => { 145 | console.error(error) 146 | notify('error', 'Failed to import, check the error in console.') 147 | }) 148 | } 149 | }, [userDataJSON, notify, config.network]) 150 | 151 | return ( 152 |
    153 | 156 | 161 |
    162 | ) 163 | } 164 | 165 | export type { UserData } 166 | export { ExportUserDataButton, ImportUserData, DownloadButton, serializeUserData, deserializeUserData } 167 | -------------------------------------------------------------------------------- /src/db.ts: -------------------------------------------------------------------------------- 1 | import Dexie, { Table } from 'dexie' 2 | 3 | type Policy = 4 | | { type: 'All', policies: Array } 5 | | { type: 'Any', policies: Array } 6 | | { type: 'NofK', policies: Array, number: number } 7 | | { type: 'TimelockStart', slot: number } 8 | | { type: 'TimelockExpiry', slot: number } 9 | | string 10 | 11 | type BasicInfoParams = { 12 | name: string 13 | description: string 14 | } 15 | 16 | type MultisigWalletParams = BasicInfoParams & { 17 | policy: Policy 18 | } 19 | 20 | interface Timestamp { 21 | updatedAt: Date 22 | } 23 | 24 | type MultisigWallet = { id: string } & MultisigWalletParams & Timestamp 25 | 26 | type PersonalAccount = { 27 | publicKey: Uint8Array 28 | paymentKeyHashes: Uint8Array[] 29 | } 30 | 31 | type MultisigAccount = { 32 | publicKey: Uint8Array 33 | addresses: { 34 | paymentKeyHash: Uint8Array 35 | stakingKeyHash: Uint8Array 36 | }[] 37 | } 38 | 39 | type PersonalWallet = BasicInfoParams & Timestamp & { 40 | id: number 41 | hash: Uint8Array 42 | rootKey: Uint8Array 43 | personalAccounts: Map 44 | multisigAccounts: Map 45 | } 46 | 47 | type KeyHashIndex = { 48 | hash: Uint8Array 49 | derivationPath: number[] 50 | walletId: number 51 | } 52 | 53 | class LocalDatabase extends Dexie { 54 | multisigWallets!: Table 55 | personalWallets!: Table 56 | keyHashIndices!: Table 57 | 58 | constructor() { 59 | super('round-table') 60 | 61 | this.version(1).stores({ 62 | multisigWallets: '&id', 63 | personalWallets: '&id, &hash', 64 | keyHashIndices: '&hash, walletId' 65 | }) 66 | } 67 | } 68 | 69 | const db = new LocalDatabase() 70 | 71 | const createPersonalWallet = (wallet: PersonalWallet, indices: KeyHashIndex[]) => db.transaction('rw', db.personalWallets, db.keyHashIndices, async () => { 72 | return db.personalWallets.add(wallet).then(() => db.keyHashIndices.bulkPut(indices)) 73 | }) 74 | const updatePersonalWallet = (wallet: PersonalWallet, indices: KeyHashIndex[]) => db.transaction('rw', db.personalWallets, db.keyHashIndices, async () => { 75 | return db.personalWallets.put(wallet).then(() => db.keyHashIndices.bulkPut(indices)) 76 | }) 77 | const deletePersonalWallet = (wallet: PersonalWallet) => db.transaction('rw', db.personalWallets, db.keyHashIndices, async () => { 78 | const walletId = wallet.id 79 | return db.personalWallets.delete(walletId).then(() => db.keyHashIndices.where({ walletId }).delete()) 80 | }) 81 | const updatePersonalWalletAndDeindex = (wallet: PersonalWallet, keyHashes: Uint8Array[]) => db.transaction('rw', db.personalWallets, db.keyHashIndices, async () => { 82 | return db.personalWallets.put(wallet).then(() => db.keyHashIndices.where('hash').anyOf(keyHashes).delete()) 83 | }) 84 | 85 | export type { MultisigWallet, MultisigWalletParams, PersonalWallet, Policy, BasicInfoParams, PersonalAccount, MultisigAccount, KeyHashIndex, Timestamp } 86 | export { db, createPersonalWallet, updatePersonalWallet, updatePersonalWalletAndDeindex, deletePersonalWallet } 87 | -------------------------------------------------------------------------------- /src/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import '../styles/globals.css' 2 | import type { AppProps } from 'next/app' 3 | import { ConfigContext, config, isMainnet, defaultGraphQLURI } from '../cardano/config' 4 | import Head from 'next/head' 5 | import { NotificationContext, useNotification } from '../components/notification' 6 | import { ApolloClient, ApolloProvider, InMemoryCache } from '@apollo/client' 7 | import { GraphQLURIContext } from '../cardano/query-api' 8 | import { useCallback, useEffect, useMemo, useState } from 'react' 9 | 10 | function MyApp({ Component, pageProps }: AppProps) { 11 | const notification = useNotification() 12 | const title = useMemo(() => isMainnet(config) ? 'RoundTable' : `RoundTable ${config.network}`, []) 13 | const configContext = useState(config) 14 | const [graphQLURI, setGraphQLURI] = useState(defaultGraphQLURI) 15 | const apolloClient = useMemo(() => new ApolloClient({ 16 | uri: graphQLURI, 17 | cache: new InMemoryCache({ 18 | typePolicies: { 19 | PaymentAddress: { 20 | keyFields: ['address'] 21 | } 22 | } 23 | }) 24 | }), [graphQLURI]) 25 | const updateGraphQLURI = useCallback((uri: string) => { 26 | const trimmed = uri.trim() 27 | if (trimmed.length > 0) { 28 | window.localStorage.setItem('GraphQLURI', trimmed) 29 | setGraphQLURI(trimmed) 30 | } else { 31 | setGraphQLURI(defaultGraphQLURI) 32 | window.localStorage.removeItem('GraphQLURI') 33 | } 34 | }, []) 35 | const graphQLContext: [string, (uri: string) => void] = useMemo(() => [graphQLURI, updateGraphQLURI], [graphQLURI, updateGraphQLURI]) 36 | useEffect(() => { 37 | const uri = window.localStorage.getItem('GraphQLURI') 38 | if (uri) { 39 | setGraphQLURI(uri) 40 | } 41 | }, []) 42 | 43 | return ( 44 | 45 | 46 | 47 | 48 | 49 | {title} 50 | 51 | 52 | 53 | 54 | 55 | 56 | ) 57 | } 58 | 59 | export default MyApp 60 | -------------------------------------------------------------------------------- /src/pages/api/hello.ts: -------------------------------------------------------------------------------- 1 | // Next.js API route support: https://nextjs.org/docs/api-routes/introduction 2 | import type { NextApiRequest, NextApiResponse } from 'next' 3 | 4 | type Data = { 5 | name: string 6 | } 7 | 8 | export default function handler( 9 | req: NextApiRequest, 10 | res: NextApiResponse 11 | ) { 12 | res.status(200).json({ name: 'John Doe' }) 13 | } 14 | -------------------------------------------------------------------------------- /src/pages/base64/[base64CBOR].tsx: -------------------------------------------------------------------------------- 1 | import type { NextPage } from 'next' 2 | import { useRouter } from 'next/router' 3 | import { Layout, Modal } from '../../components/layout' 4 | import { Loading } from '../../components/status' 5 | import { TransactionLoader } from '../../components/transaction' 6 | 7 | const GetTransaction: NextPage = () => { 8 | const router = useRouter() 9 | const { base64CBOR } = router.query 10 | 11 | if (typeof base64CBOR !== 'string') return null 12 | 13 | return ( 14 | 15 | {!base64CBOR && } 16 | {base64CBOR && } 17 | 18 | ) 19 | } 20 | 21 | export default GetTransaction 22 | -------------------------------------------------------------------------------- /src/pages/hex/[hexCBOR].tsx: -------------------------------------------------------------------------------- 1 | import type { NextPage } from 'next' 2 | import { useRouter } from 'next/router' 3 | import { Layout, Modal } from '../../components/layout' 4 | import { Loading } from '../../components/status' 5 | import { TransactionLoader } from '../../components/transaction' 6 | 7 | const GetTransaction: NextPage = () => { 8 | const router = useRouter() 9 | const { hexCBOR } = router.query 10 | 11 | if (typeof hexCBOR !== 'string') return null 12 | 13 | return ( 14 | 15 | {!hexCBOR && } 16 | {hexCBOR && } 17 | 18 | ) 19 | } 20 | 21 | export default GetTransaction 22 | -------------------------------------------------------------------------------- /src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import type { NextPage } from 'next' 2 | import Image from 'next/image' 3 | import { Layout, Panel } from '../components/layout' 4 | import { CheckIcon, XMarkIcon } from '@heroicons/react/24/solid' 5 | import type { FC } from 'react' 6 | 7 | const flintLogo = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTkwIiBoZWlnaHQ9IjE5MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiBmaWxsPSJub25lIj4KIDxnPgogIDx0aXRsZT5MYXllciAxPC90aXRsZT4KICA8cGF0aCBkPSJtNTYuMDExLDU5LjM4NWw0My40NjIyLC00NC4wODMzYzIuOTcwOCwtMy4yNTM0IDQuMDMxOCwtMi45MzY1IDUuMDQ0OCwwLjc4NzJsMC4zODgsMzEuNDg4MWMtMC4xMDgsNC45MTM2IC0wLjQ2NSw3LjAzMjYgLTEuOTQsOS4wNTI4bC0yNi4zODgxLDI3LjE1ODVjLTMuNDUwNCw0LjI2NjcgLTIuOTc2OSw1Ljk2OTggLTMuMTA0NCw3Ljg3MmMtMC4xMjc2LDEuOTAyMiAzLjM1NzQsNy40NDg0IDkuMzEzMyw3Ljg3MjFjMCwwIDE2LjE1MDUsMC4wMDMzIDE3Ljg1MDIsMGMxLjcsLTAuMDAzNCAyLjg5MSwyLjczNDYgMCw1LjUxMDZsLTM2LjQ3NjksMzYuNjA1Yy00LjUxNDMsNC4yNTIgLTcuMDY4LDQuMjQgLTExLjY0MTYsMi43NTVjLTcuMDE5NiwtMy45MzUgLTcuMTQ1LC03LjU2NyAtNy4zNjM4LC0xMy45MDFsLTAuMDA5MywtMC4yNjlsMCwtNDAuMTQ3MWMtMC4yNDMxLC0xMi43OTgzIDEuNTg2NiwtMTkuNjE4MSAxMC44NjU2LC0zMC43MDA5eiIgZmlsbD0iI0ZGNjEwMCIgaWQ9InN2Z18xIi8+CiAgPHBhdGggZD0ibTEzNC43MSwxMzEuNTlsLTQ0Ljc3ODgsNDQuMDgzYy0zLjA2MTEsMy4yNTQgLTQuMTU0LDIuOTM3IC01LjE5NzYsLTAuNzg3bC0wLjM5OTgsLTMxLjQ4OGMwLjExMDcsLTQuOTEzIC0wLjA3NTMsLTIuOTk4NTcgNi4zNTAyNiwtMTAuOTI0MjRsMjIuODM1OTQsLTI1LjI4Njc2YzMuNTU1LC00LjI2NyAzLjA2NywtNS45NyAzLjE5OSwtNy44NzIyYzAuMTMxLC0xLjkwMjIgLTMuNDU5LC03LjQ0ODQgLTkuNTk2LC03Ljg3MjFjMCwwIC0xNi42Mzk3LC0wLjAwMzMgLTE4LjM5MTMsMGMtMS43NTE1LDAuMDAzNCAtMi45Nzg3LC0yLjczNSAwLC01LjUxMDRsMzcuNTgyMywtMzYuNjA1YzQuNjUxLC00LjI1MjMgNy4yODMsLTQuMjQwNSAxMS45OTUsLTIuNzU1MmM3LjIzMiwzLjkzNSA3LjM2MSw3LjU2NzQgNy41ODcsMTMuOTAxM2wwLjAwOSwwLjI2ODRsMCw0MC4xNDcyYzAuMjUxLDEyLjc5OSAtMS42MzQsMTkuNjE4IC0xMS4xOTUsMzAuNzAxeiIgZmlsbD0iI0ZGNjEwMCIgaWQ9InN2Z18yIi8+CiA8L2c+Cgo8L3N2Zz4=' 8 | 9 | const WalletTable: FC = () => { 10 | return ( 11 |
    12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 28 | 29 | 30 | 31 | 32 | 37 | 38 | 39 | 40 | 41 | 46 | 47 | 48 | 49 | 50 | 55 | 56 | 57 | 58 | 59 | 64 | 65 | 66 | 67 | 68 |
    Multisig Support
    WalletPaymentStaking
    24 |
    25 | NamiNami 26 |
    27 |
    33 |
    34 | EternlEternl 35 |
    36 |
    42 |
    43 | GeroGero 44 |
    45 |
    51 |
    52 | FlintFlint 53 |
    54 |
    60 |
    61 | TyphonTyphon 62 |
    63 |
    69 |
    70 | ) 71 | } 72 | 73 | const Home: NextPage = () => { 74 | return ( 75 | 76 |
    77 | 78 |

    Round Table

    79 |

    Round Table is ADAO Community’s open-source wallet on Cardano blockchain. It aims at making multisig easy and intuitive for everyone. The project is designed and developed with decentralization in mind. All the libraries and tools were chosen in favor of decentralization. There is no server to keep your data. Your data is your own. It runs on your browser just like any other light wallets. You could also run it on your own PC easily.

    80 |

    Round Table supports multisig wallets as well as personal wallets. Besides its personal wallets, these wallets are supported to make multisig wallets.

    81 | 82 |

    We have an active and welcoming community. If you have any issues or questions, feel free to reach out to us via Github or Discord.

    83 |
    84 |
    85 |
    86 | ) 87 | } 88 | 89 | export default Home 90 | -------------------------------------------------------------------------------- /src/pages/multisig/[policy].tsx: -------------------------------------------------------------------------------- 1 | import type { NextPage } from 'next' 2 | import { useRouter } from 'next/router' 3 | import { useCardanoMultiplatformLib } from '../../cardano/multiplatform-lib' 4 | import type { Cardano } from '../../cardano/multiplatform-lib' 5 | import { Loading, PartialLoading } from '../../components/status' 6 | import { db } from '../../db' 7 | import type { Policy, MultisigWalletParams } from '../../db' 8 | import { useCallback, useContext, useMemo, useState } from 'react' 9 | import type { FC } from 'react' 10 | import { ConfigContext, isMainnet } from '../../cardano/config' 11 | import { Hero, Layout, Panel, Modal } from '../../components/layout' 12 | import { useLiveQuery } from 'dexie-react-hooks' 13 | import { useUTxOSummaryQuery, isRegisteredOnChain, getAvailableReward } from '../../cardano/query-api' 14 | import { ArrowDownTrayIcon, InformationCircleIcon } from '@heroicons/react/24/solid' 15 | import { EditMultisigWallet, RemoveWallet, Summary } from '../../components/wallet' 16 | import { NewTransaction } from '../../components/transaction' 17 | import { NotificationContext } from '../../components/notification' 18 | import { NativeScriptViewer } from '../../components/native-script' 19 | import type { VerifyingData } from '../../components/native-script' 20 | import { DownloadButton } from '../../components/user-data' 21 | import type { NativeScript, SingleInputBuilder, SingleCertificateBuilder, SingleWithdrawalBuilder } from '@dcspark/cardano-multiplatform-lib-browser' 22 | import { AddressableContent } from '../../components/address' 23 | import { useLiveSlot } from '../../components/time' 24 | 25 | const Spend: FC<{ 26 | address: string 27 | rewardAddress: string 28 | cardano: Cardano 29 | policy: Policy 30 | }> = ({ address, rewardAddress, cardano, policy }) => { 31 | const buildInputResult = useCallback((builder: SingleInputBuilder) => { 32 | if (typeof policy === 'string') return builder.payment_key() 33 | return builder.native_script(cardano.getPaymentNativeScriptFromPolicy(policy), cardano.lib.NativeScriptWitnessInfo.assume_signature_count()) 34 | }, [cardano, policy]) 35 | const buildCertResult = useCallback((builder: SingleCertificateBuilder) => { 36 | if (typeof policy === 'string') return builder.payment_key() 37 | return builder.native_script(cardano.getStakingNativeScriptFromPolicy(policy), cardano.lib.NativeScriptWitnessInfo.assume_signature_count()) 38 | }, [cardano, policy]) 39 | const buildWithdrawalResult = useCallback((builder: SingleWithdrawalBuilder) => { 40 | if (typeof policy === 'string') return builder.payment_key() 41 | return builder.native_script(cardano.getStakingNativeScriptFromPolicy(policy), cardano.lib.NativeScriptWitnessInfo.assume_signature_count()) 42 | }, [cardano, policy]) 43 | const { loading, error, data } = useUTxOSummaryQuery({ 44 | variables: { addresses: [address], rewardAddress }, 45 | fetchPolicy: 'network-only' 46 | }) 47 | 48 | if (error) { 49 | console.error(error) 50 | return null 51 | } 52 | if (loading || !data) return ( 53 | 54 | ) 55 | 56 | const protocolParameters = data.cardano.currentEpoch.protocolParams 57 | if (!protocolParameters) throw new Error('No protocol parameter') 58 | const { stakeRegistrations_aggregate, stakeDeregistrations_aggregate, delegations } = data 59 | const isRegistered = isRegisteredOnChain(stakeRegistrations_aggregate, stakeDeregistrations_aggregate) 60 | const currentStakePool = isRegistered ? delegations[0]?.stakePool : undefined 61 | const availableReward = getAvailableReward(data.rewards_aggregate, data.withdrawals_aggregate) 62 | 63 | return ( 64 | 76 | ) 77 | } 78 | 79 | const NativeScriptPanel: FC<{ 80 | cardano: Cardano 81 | nativeScript: NativeScript 82 | filename: string 83 | title: string 84 | verifyingData: VerifyingData 85 | }> = ({ cardano, nativeScript, filename, title, verifyingData }) => { 86 | return ( 87 | 88 |
    89 |

    {title}

    90 | 97 |
    98 |
    99 | 104 | 105 | Download 106 | 107 |
    108 |
    109 | ) 110 | } 111 | 112 | const ShowNativeScript: FC<{ 113 | cardano: Cardano 114 | policy: Policy 115 | }> = ({ cardano, policy }) => { 116 | const currentSlot = useLiveSlot() 117 | const verifyingData: VerifyingData = useMemo(() => ({ 118 | txExpirySlot: currentSlot, 119 | txStartSlot: currentSlot 120 | }), [currentSlot]) 121 | const payment = useMemo(() => cardano.getPaymentNativeScriptFromPolicy(policy), [cardano, policy]) 122 | const staking = useMemo(() => cardano.getStakingNativeScriptFromPolicy(policy), [cardano, policy]) 123 | if (typeof policy === 'string') throw new Error('No NativeScript for policy in single address') 124 | 125 | return ( 126 | <> 127 | 133 | 139 | 140 | ) 141 | } 142 | 143 | const GetPolicy: NextPage = () => { 144 | const [config, _] = useContext(ConfigContext) 145 | const cardano = useCardanoMultiplatformLib() 146 | const router = useRouter() 147 | const policyContent = router.query.policy 148 | const result: { policy: Policy, address: string, rewardAddress: string } | undefined = useMemo(() => { 149 | if (!cardano || !policyContent) return 150 | if (typeof policyContent !== 'string') throw new Error('Cannot parse the policy') 151 | const { Address } = cardano.lib 152 | if (Address.is_valid_bech32(policyContent)) return { 153 | policy: policyContent, 154 | address: policyContent, 155 | rewardAddress: cardano.getPolicyRewardAddress(policyContent, isMainnet(config)).to_address().to_bech32() 156 | } 157 | const policy: Policy = JSON.parse(policyContent) 158 | const address = cardano.getPolicyAddress(policy, isMainnet(config)).to_bech32() 159 | const rewardAddress = cardano.getPolicyRewardAddress(policy, isMainnet(config)).to_address().to_bech32() 160 | return { policy, address, rewardAddress } 161 | }, [cardano, config, policyContent]) 162 | const [tab, setTab] = useState<'summary' | 'spend' | 'edit' | 'remove' | 'native script'>('summary') 163 | const multisigWallet = useLiveQuery(async () => result && db.multisigWallets.get(result.address), [result]) 164 | const walletParams: MultisigWalletParams | undefined = useMemo(() => { 165 | if (multisigWallet) return multisigWallet 166 | if (result) return { 167 | name: '', 168 | description: '', 169 | policy: result.policy 170 | } 171 | }, [multisigWallet, result]) 172 | const { notify } = useContext(NotificationContext) 173 | const removeWallet = useCallback(() => { 174 | if (!multisigWallet) return 175 | db 176 | .multisigWallets 177 | .delete(multisigWallet.id) 178 | .then(() => router.push('/')) 179 | .catch((error) => { 180 | notify('error', 'Failed to delete') 181 | console.error(error) 182 | }) 183 | }, [notify, router, multisigWallet]) 184 | 185 | return ( 186 | 187 | {(!cardano || !result) && } 188 | {cardano && result &&
    189 | 190 |

    {multisigWallet?.name ?? 'Unknown Account'}

    191 |
    192 | 193 |
    194 |
    195 | {multisigWallet && multisigWallet.description.length > 0 &&
    {multisigWallet.description}
    } 196 |
    197 |
    198 | 230 |
    231 | {tab === 'edit' &&
    232 | 233 | You can create a new account by editing the policy. The assets in the original one will remain untouched. 234 |
    } 235 |
    236 | {tab === 'summary' && } 237 | {tab === 'spend' && } 238 | {tab === 'edit' && walletParams && } 239 | {tab === 'remove' && multisigWallet && } 240 | {tab === 'native script' && typeof result.policy !== 'string' && } 241 |
    } 242 |
    243 | ) 244 | } 245 | 246 | export default GetPolicy 247 | -------------------------------------------------------------------------------- /src/pages/new.tsx: -------------------------------------------------------------------------------- 1 | import type { NextPage } from 'next' 2 | import { useCardanoMultiplatformLib } from '../cardano/multiplatform-lib' 3 | import type { Cardano } from '../cardano/multiplatform-lib' 4 | import { Hero, Layout, Modal, Panel } from '../components/layout' 5 | import { isPasswordStrong, PasswordInput, StrongPasswordInput } from '../components/password' 6 | import { Loading } from '../components/status' 7 | import { EditMultisigWallet } from '../components/wallet' 8 | import { useCallback, useContext, useMemo, useState } from 'react' 9 | import type { ChangeEventHandler, FC } from 'react' 10 | import { createPersonalWallet, db } from '../db' 11 | import type { MultisigWalletParams, PersonalWallet } from '../db' 12 | import { mnemonicToEntropy, generateMnemonic, wordlists } from 'bip39' 13 | import { useLiveQuery } from 'dexie-react-hooks' 14 | import { NotificationContext } from '../components/notification' 15 | import { encryptWithPassword, SHA256Digest } from '../cardano/utils' 16 | import type { Bip32PrivateKey } from '@dcspark/cardano-multiplatform-lib-browser' 17 | import { useRouter } from 'next/router' 18 | import { getPersonalWalletPath } from '../route' 19 | 20 | const NewMultisigWallet: FC = () => { 21 | const cardano = useCardanoMultiplatformLib() 22 | const params: MultisigWalletParams = useMemo(() => { 23 | return { 24 | name: '', 25 | description: '', 26 | policy: { type: 'All', policies: [] } 27 | } 28 | }, []) 29 | 30 | if (!cardano) return ( 31 | 32 | ) 33 | 34 | return ( 35 | 36 | ) 37 | } 38 | 39 | const NewRecoveryPhrase: FC<{ 40 | className?: string 41 | cancel?: () => void 42 | confirm: () => void 43 | }> = ({ className, cancel, confirm }) => { 44 | const strength = 256 45 | const length = 24 46 | const words: string[] = useMemo(() => { 47 | const phrase = generateMnemonic(strength) 48 | const words = phrase.trim().split(/\s+/g) 49 | if (words.length !== length) throw new Error(`Invalid phrase: ${phrase}`) 50 | return words 51 | }, []) 52 | 53 | return ( 54 |
    55 |
    56 |

    Recovery Phrase

    57 |

    Make sure you write down the 24 words of your wallet recover phase on a piece of paper in the exact order shown here.

    58 |
    59 |
      60 | {words.map((word, index) =>
    • 61 |
      {index + 1}
      62 |
      {word}
      63 |
    • )} 64 |
    65 |
    66 | {cancel && } 67 | 68 |
    69 |
    70 | ) 71 | } 72 | 73 | const WordInput: FC<{ 74 | index: number 75 | word: string 76 | setWord: (index: number, word: string) => void 77 | wordset: Set 78 | }> = ({ index, setWord, word, wordset }) => { 79 | const onChange: ChangeEventHandler = useCallback((event) => { 80 | setWord(index, event.target.value) 81 | }, [setWord, index]) 82 | const valid = useMemo(() => wordset.has(word), [wordset, word]) 83 | const className = useMemo(() => [ 84 | 'flex divide-x border rounded overflow-hidden ring-sky-500 focus-within:ring-1', 85 | valid ? 'text-sky-700' : 'text-red-500'].join(' '), [valid]) 86 | 87 | return ( 88 |
  • 89 |
    {index + 1}
    90 | 95 |
  • 96 | ) 97 | } 98 | 99 | const RecoverHDWallet: FC<{ 100 | cardano: Cardano 101 | setRootKey: (key: Bip32PrivateKey) => void 102 | }> = ({ cardano, setRootKey }) => { 103 | const { notify } = useContext(NotificationContext) 104 | const language = 'english' 105 | const wordset: Set = useMemo(() => new Set(wordlists[language]), [language]) 106 | const length = 24 107 | const [words, setWords] = useState(new Array(length).fill('')) 108 | const setWord = useCallback((index: number, word: string) => setWords(words.map((w, i) => index === i ? word : w)), [words]) 109 | const [BIP32Passphrase, setBIP32Passphrase] = useState('') 110 | const [repeatBIP32Passphrase, setRepeatBIP32Passphrase] = useState('') 111 | const [modal, setModal] = useState<'new' | undefined>() 112 | const closeModal = useCallback(() => setModal(undefined), []) 113 | const openModal = useCallback(() => setModal('new'), []) 114 | const isPhraseValid = useMemo(() => words.length === length && words.every((word) => wordset.has(word)), [words, wordset]) 115 | const buildKey = useCallback(async () => { 116 | if (!isPhraseValid) throw new Error('Invalid recover phrase') 117 | if (BIP32Passphrase !== repeatBIP32Passphrase) throw new Error('Passphrases do not match') 118 | const phrase = words.join(' ') 119 | const entropy = Buffer.from(mnemonicToEntropy(phrase), 'hex') 120 | const key = cardano.lib.Bip32PrivateKey.from_bip39_entropy(entropy, Buffer.from(BIP32Passphrase, 'utf8')) 121 | return SHA256Digest(key.as_bytes()) 122 | .then(async (buffer) => { 123 | const hash = new Uint8Array(buffer) 124 | const duplicate = await db.personalWallets.get({ hash }) 125 | if (duplicate) throw new Error(`This phrase is a duplicate of ${duplicate.name}`) 126 | }) 127 | .then(() => setRootKey(key)) 128 | .catch((error) => notify('error', error)) 129 | }, [cardano, BIP32Passphrase, repeatBIP32Passphrase, isPhraseValid, notify, setRootKey, words]) 130 | 131 | return ( 132 | 133 |
    134 |

    Recovery Phrase

    135 |
      136 | {words.map((word, index) => )} 137 |
    138 | 139 | {Array.from(wordset, (word, index) => 141 |
    142 |
    143 | 147 | 152 |
    153 |
    154 | 160 |
    161 |
    162 | 168 |
    169 |
    170 | ) 171 | } 172 | 173 | const SavePersonalWallet: FC<{ 174 | id: number 175 | cardano: Cardano 176 | rootKey: Bip32PrivateKey 177 | }> = ({ cardano, rootKey, id }) => { 178 | const { notify } = useContext(NotificationContext) 179 | const router = useRouter() 180 | const [name, setName] = useState('') 181 | const [description, setDescription] = useState('') 182 | const [password, setPassword] = useState('') 183 | const [repeatPassword, setRepeatPassword] = useState('') 184 | 185 | const create = useCallback(async () => { 186 | const rootKeyBytes = rootKey.as_bytes() 187 | const hash = new Uint8Array(await SHA256Digest(rootKeyBytes)) 188 | 189 | encryptWithPassword(rootKeyBytes, password, id) 190 | .then(async (ciphertext) => { 191 | const wallet: PersonalWallet = { 192 | id, name, description, hash, 193 | rootKey: new Uint8Array(ciphertext), 194 | personalAccounts: new Map(), 195 | multisigAccounts: new Map(), 196 | updatedAt: new Date() 197 | } 198 | const personalIndices = await cardano.generatePersonalAccount(wallet, password, 0) 199 | const multisigIndices = await cardano.generateMultisigAccount(wallet, password, 0) 200 | const keyHashIndices = [personalIndices, multisigIndices].flat() 201 | createPersonalWallet(wallet, keyHashIndices).then(() => router.push(getPersonalWalletPath(id))) 202 | }) 203 | .catch((error) => { 204 | notify('error', 'Failed to save the key') 205 | console.error(error) 206 | }) 207 | }, [cardano, description, id, name, notify, password, rootKey, router]) 208 | 209 | return ( 210 | 211 |
    212 | 220 |
    221 |
    Signing Password
    222 |
    223 | 224 | 229 |
    230 |
    231 | 241 |
    242 |
    243 | 249 |
    250 |
    251 | ) 252 | } 253 | 254 | const NewPersonalWallet: FC = () => { 255 | const cardano = useCardanoMultiplatformLib() 256 | const id = useLiveQuery(async () => db.personalWallets.count()) 257 | const [rootKey, setRootKey] = useState() 258 | 259 | if (!cardano || id === undefined) return ( 260 | 261 | ) 262 | 263 | if (!rootKey) return ( 264 | 265 | ) 266 | 267 | return ( 268 | 269 | ) 270 | } 271 | 272 | const NewWallet: NextPage = () => { 273 | const [tab, setTab] = useState<'multisig' | 'personal'>('multisig') 274 | 275 | return ( 276 | 277 | 278 |

    New Wallet

    279 |
    280 |

    Multisig wallet is made of multiple signers or time ranges of validity.

    281 |

    Personal wallet is for normal use like other wallets, e.g. Nami/Eternal/etc. It also holds addresses for multisig wallet making.

    282 |
    283 |
    284 | 298 |
    299 |
    300 | {tab === 'multisig' && } 301 | {tab === 'personal' && } 302 |
    303 | ) 304 | } 305 | 306 | export default NewWallet 307 | -------------------------------------------------------------------------------- /src/pages/personal/[personalWalletId].tsx: -------------------------------------------------------------------------------- 1 | import { useLiveQuery } from 'dexie-react-hooks' 2 | import type { NextPage } from 'next' 3 | import { useRouter } from 'next/router' 4 | import { useCallback, useContext, useEffect, useMemo, useState } from 'react' 5 | import type { FC, ChangeEventHandler } from 'react' 6 | import { ConfirmModalButton, Hero, Layout, Modal, Panel, Portal } from '../../components/layout' 7 | import { AskPasswordModalButton } from '../../components/password' 8 | import { Loading, PartialLoading } from '../../components/status' 9 | import { db, deletePersonalWallet, updatePersonalWallet, updatePersonalWalletAndDeindex } from '../../db' 10 | import type { PersonalWallet } from '../../db' 11 | import { useCardanoMultiplatformLib } from '../../cardano/multiplatform-lib' 12 | import type { Cardano } from '../../cardano/multiplatform-lib' 13 | import { ConfigContext, isMainnet } from '../../cardano/config' 14 | import { ExclamationTriangleIcon } from '@heroicons/react/24/solid' 15 | import { NotificationContext } from '../../components/notification' 16 | import { DerivationPath, RemoveWallet, Summary } from '../../components/wallet' 17 | import { getAvailableReward, isRegisteredOnChain, useUTxOSummaryQuery } from '../../cardano/query-api' 18 | import { NewTransaction } from '../../components/transaction' 19 | import { SingleCertificateBuilder, SingleInputBuilder, SingleWithdrawalBuilder } from '@dcspark/cardano-multiplatform-lib-browser' 20 | import { AddressableContent } from '../../components/address' 21 | 22 | const AddressTable: FC<{ 23 | addresses: string[] 24 | addressName: string 25 | }> = ({ addresses, addressName }) => { 26 | const cardano = useCardanoMultiplatformLib() 27 | 28 | return ( 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | {addresses.map((address) => 39 | 42 | 43 | 44 | )} 45 | 46 |
    {addressName}Payment Derivation PathStaking Derivation Path
    40 | 41 |
    47 | ) 48 | } 49 | 50 | const Multisig: FC<{ 51 | wallet: PersonalWallet 52 | }> = ({ wallet }) => { 53 | const cardano = useCardanoMultiplatformLib() 54 | const [config, _] = useContext(ConfigContext) 55 | const accountIndex = 0 56 | const account = useMemo(() => wallet.multisigAccounts.get(accountIndex), [wallet.multisigAccounts, accountIndex]) 57 | const addresses = useMemo(() => account && cardano?.getAddressesFromMultisigAccount(account, isMainnet(config)), [cardano, account, config]) 58 | 59 | if (!cardano || !addresses) return ( 60 | 61 | ) 62 | 63 | const addAddress = () => { 64 | const indices = cardano.generateMultisigAddress(wallet, accountIndex) 65 | updatePersonalWallet(wallet, indices) 66 | } 67 | 68 | return ( 69 | 70 | 71 |
    72 | 75 |
    76 |
    77 | ) 78 | } 79 | 80 | const Edit: FC<{ 81 | wallet: PersonalWallet 82 | }> = ({ wallet }) => { 83 | const [name, setName] = useState(wallet.name) 84 | const [description, setDescription] = useState(wallet.description) 85 | const { notify } = useContext(NotificationContext) 86 | 87 | useEffect(() => { 88 | setName(wallet.name) 89 | setDescription(wallet.description) 90 | }, [wallet]) 91 | 92 | const canSave = name.length > 0 93 | 94 | const save = () => { 95 | db 96 | .personalWallets 97 | .update(wallet.id, { name, description, updatedAt: new Date() }) 98 | .catch(() => notify('error', 'Failed to save')) 99 | } 100 | 101 | return ( 102 | 103 |
    104 | 112 | 122 |
    123 |
    124 | 130 |
    131 |
    132 | ) 133 | } 134 | 135 | const Spend: FC<{ 136 | addresses: string[] 137 | rewardAddress: string 138 | cardano: Cardano 139 | }> = ({ addresses, rewardAddress, cardano }) => { 140 | const { loading, error, data } = useUTxOSummaryQuery({ 141 | variables: { addresses, rewardAddress }, 142 | fetchPolicy: 'network-only' 143 | }) 144 | const defaultChangeAddress = useMemo(() => { 145 | const address = addresses[0] 146 | if (!address) throw new Error('No address is found for change') 147 | return address 148 | }, [addresses]) 149 | const buildResult = useCallback((builder: SingleInputBuilder | SingleCertificateBuilder | SingleWithdrawalBuilder) => builder.payment_key(), []) 150 | 151 | if (error) { 152 | console.error(error) 153 | return null 154 | } 155 | if (loading || !data) return ( 156 | 157 | ) 158 | 159 | const protocolParameters = data.cardano.currentEpoch.protocolParams 160 | if (!protocolParameters) throw new Error('No protocol parameter') 161 | const { stakeRegistrations_aggregate, stakeDeregistrations_aggregate, delegations } = data 162 | const isRegistered = isRegisteredOnChain(stakeRegistrations_aggregate, stakeDeregistrations_aggregate) 163 | const currentStakePool = isRegistered ? delegations[0]?.stakePool : undefined 164 | const availableReward = getAvailableReward(data.rewards_aggregate, data.withdrawals_aggregate) 165 | 166 | return ( 167 | 179 | ) 180 | } 181 | 182 | const Personal: FC<{ 183 | wallet: PersonalWallet 184 | className?: string 185 | }> = ({ wallet, className }) => { 186 | const cardano = useCardanoMultiplatformLib() 187 | const [config, _] = useContext(ConfigContext) 188 | const { notify } = useContext(NotificationContext) 189 | const [accountIndex, setAccountIndex] = useState(0) 190 | const account = useMemo(() => wallet.personalAccounts.get(accountIndex), [wallet.personalAccounts, accountIndex]) 191 | const addresses = useMemo(() => account && cardano?.getAddressesFromPersonalAccount(account, isMainnet(config)), [cardano, account, config]) 192 | const rewardAddress = useMemo(() => account && cardano?.readRewardAddressFromPublicKey(account.publicKey, isMainnet(config)).to_address().to_bech32(), [cardano, config, account]) 193 | const [tab, setTab] = useState<'summary' | 'receive' | 'spend'>('summary') 194 | const selectAccount: ChangeEventHandler = useCallback((e) => setAccountIndex(parseInt(e.target.value)), []) 195 | 196 | const addAddress = useCallback(() => { 197 | if (!cardano) return 198 | const indices = cardano.generatePersonalAddress(wallet, accountIndex) 199 | updatePersonalWallet(wallet, indices) 200 | }, [cardano, wallet, accountIndex]) 201 | 202 | const addAccount = useCallback(async (password: string) => { 203 | if (!cardano) return 204 | const keys = Array.from(wallet.personalAccounts.keys()) 205 | const newAccountIndex = Math.max(...keys) + 1 206 | cardano.generatePersonalAccount(wallet, password, newAccountIndex).then((indices) => { 207 | return updatePersonalWallet(wallet, indices) 208 | }) 209 | .then(() => setAccountIndex(newAccountIndex)) 210 | .catch(() => notify('error', 'Failed to add account')) 211 | }, [cardano, notify, wallet]) 212 | 213 | const deleteAccount = useCallback(() => { 214 | if (!account) return 215 | const keyHashes = account.paymentKeyHashes 216 | wallet.personalAccounts.delete(accountIndex) 217 | updatePersonalWalletAndDeindex(wallet, keyHashes) 218 | .then(() => setAccountIndex(0)) 219 | }, [account, accountIndex, wallet]) 220 | 221 | if (!addresses || !rewardAddress || !cardano) return ( 222 | 223 | ) 224 | 225 | return ( 226 |
    227 | 228 |
    229 | 249 | 259 |
    260 |
    261 | {tab === 'summary' && 262 |
    263 | 268 | REMOVE 269 | 270 |
    271 |
    } 272 | {tab === 'receive' && 273 | 274 |
    275 | 278 |
    279 |
    } 280 | {tab === 'spend' && } 281 |
    282 | ) 283 | } 284 | 285 | const ShowPersonalWallet: NextPage = () => { 286 | const router = useRouter() 287 | const personalWallet = useLiveQuery(async () => { 288 | const id = router.query.personalWalletId 289 | if (typeof id !== 'string') return 290 | return db.personalWallets.get(parseInt(id)) 291 | }, [router.query.personalWalletId]) 292 | const [tab, setTab] = useState<'personal' | 'multisig' | 'edit' | 'remove'>('personal') 293 | const { notify } = useContext(NotificationContext) 294 | 295 | const removeWallet = useCallback(() => { 296 | if (!personalWallet) return 297 | deletePersonalWallet(personalWallet) 298 | .then(() => router.push('/')) 299 | .catch((error) => { 300 | notify('error', 'Failed to delete') 301 | console.error(error) 302 | }) 303 | }, [notify, personalWallet, router]) 304 | 305 | if (!personalWallet) return ( 306 | 307 | ) 308 | 309 | return ( 310 | 311 | 312 |

    {personalWallet.name}

    313 |
    {personalWallet.description}
    314 |
    315 | 341 |
    342 | {tab === 'personal' &&
    } 343 |
    344 | {tab === 'personal' && } 345 | {tab === 'multisig' && <> 346 |
    347 | 348 |
    These addresses are only for multisig wallet making.
    349 | DO NOT USE THEM TO RECEIVE FUNDS. 350 |
    351 | 352 | } 353 | {tab === 'edit' && } 354 | {tab === 'remove' && } 355 |
    356 | ) 357 | } 358 | 359 | export default ShowPersonalWallet 360 | -------------------------------------------------------------------------------- /src/route.ts: -------------------------------------------------------------------------------- 1 | import type { Transaction } from '@dcspark/cardano-multiplatform-lib-browser' 2 | import { encodeCardanoData } from './cardano/multiplatform-lib' 3 | import type { Policy } from './db' 4 | 5 | function getMultisigWalletsPath(subPath?: string): string { 6 | return '/' + ['multisig', subPath].join('/') 7 | } 8 | 9 | function getMultisigWalletPath(policy: Policy, subPath?: string): string { 10 | return getMultisigWalletsPath([encodeURIComponent(JSON.stringify(policy)), subPath].join('/')) 11 | } 12 | 13 | function getPersonalWalletPath(id: number): string { 14 | return `/personal/${id}` 15 | } 16 | 17 | function getTransactionPath(transcation: Transaction): string { 18 | const base64CBOR = encodeCardanoData(transcation, 'base64') 19 | return ['/base64', encodeURIComponent(base64CBOR)].join('/') 20 | } 21 | 22 | export { getMultisigWalletsPath, getMultisigWalletPath, getTransactionPath, getPersonalWalletPath } 23 | -------------------------------------------------------------------------------- /src/styles/Home.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | padding: 0 2rem; 3 | } 4 | 5 | .main { 6 | min-height: 100vh; 7 | padding: 4rem 0; 8 | flex: 1; 9 | display: flex; 10 | flex-direction: column; 11 | justify-content: center; 12 | align-items: center; 13 | } 14 | 15 | .footer { 16 | display: flex; 17 | flex: 1; 18 | padding: 2rem 0; 19 | border-top: 1px solid #eaeaea; 20 | justify-content: center; 21 | align-items: center; 22 | } 23 | 24 | .footer a { 25 | display: flex; 26 | justify-content: center; 27 | align-items: center; 28 | flex-grow: 1; 29 | } 30 | 31 | .title a { 32 | color: #0070f3; 33 | text-decoration: none; 34 | } 35 | 36 | .title a:hover, 37 | .title a:focus, 38 | .title a:active { 39 | text-decoration: underline; 40 | } 41 | 42 | .title { 43 | margin: 0; 44 | line-height: 1.15; 45 | font-size: 4rem; 46 | } 47 | 48 | .title, 49 | .description { 50 | text-align: center; 51 | } 52 | 53 | .description { 54 | margin: 4rem 0; 55 | line-height: 1.5; 56 | font-size: 1.5rem; 57 | } 58 | 59 | .code { 60 | background: #fafafa; 61 | border-radius: 5px; 62 | padding: 0.75rem; 63 | font-size: 1.1rem; 64 | font-family: Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono, 65 | Bitstream Vera Sans Mono, Courier New, monospace; 66 | } 67 | 68 | .grid { 69 | display: flex; 70 | align-items: center; 71 | justify-content: center; 72 | flex-wrap: wrap; 73 | max-width: 800px; 74 | } 75 | 76 | .card { 77 | margin: 1rem; 78 | padding: 1.5rem; 79 | text-align: left; 80 | color: inherit; 81 | text-decoration: none; 82 | border: 1px solid #eaeaea; 83 | border-radius: 10px; 84 | transition: color 0.15s ease, border-color 0.15s ease; 85 | max-width: 300px; 86 | } 87 | 88 | .card:hover, 89 | .card:focus, 90 | .card:active { 91 | color: #0070f3; 92 | border-color: #0070f3; 93 | } 94 | 95 | .card h2 { 96 | margin: 0 0 1rem 0; 97 | font-size: 1.5rem; 98 | } 99 | 100 | .card p { 101 | margin: 0; 102 | font-size: 1.25rem; 103 | line-height: 1.5; 104 | } 105 | 106 | .logo { 107 | height: 1em; 108 | margin-left: 0.5rem; 109 | } 110 | 111 | @media (max-width: 600px) { 112 | .grid { 113 | width: 100%; 114 | flex-direction: column; 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :focus { 7 | @apply outline-none; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | content: [ 3 | "src/pages/**/*.{js,ts,jsx,tsx}", 4 | "src/components/**/*.{js,ts,jsx,tsx}", 5 | ], 6 | theme: { 7 | extend: {}, 8 | }, 9 | plugins: [], 10 | } 11 | -------------------------------------------------------------------------------- /tapes/graphql/query-1.json5: -------------------------------------------------------------------------------- 1 | { 2 | meta: { 3 | createdAt: '2022-12-14T13:13:57.402Z', 4 | host: 'https://preview-gql.junglestakepool.com/graphql', 5 | reqHumanReadable: true, 6 | resHumanReadable: true, 7 | }, 8 | req: { 9 | headers: { 10 | accept: '*/*', 11 | 'content-type': 'application/json', 12 | 'user-agent': 'node-fetch/1.0 (+https://github.com/bitinn/node-fetch)', 13 | 'accept-encoding': 'gzip,deflate', 14 | connection: 'close', 15 | }, 16 | url: '/', 17 | method: 'POST', 18 | body: { 19 | operationName: 'UTxOSummary', 20 | variables: { 21 | addresses: [ 22 | 'addr_test1xzuh59uc243wuhpkcnfdha3flvmx5guf8thkctv8l75u2zzap4eefgsu8h5selu2aaeu29vh96rf99wcp5f0x60ldx6s2ad79j', 23 | ], 24 | rewardAddress: 'stake_test17pws6uu55gwrm6gvl79w7u79zktjap5jjhvq6yhnd8lkndgcsn5h4', 25 | }, 26 | query: 'fragment StakePoolFields on StakePool {\n id\n margin\n fixedCost\n pledge\n hash\n metadataHash\n __typename\n}\n\nquery UTxOSummary($addresses: [String]!, $rewardAddress: StakeAddress!) {\n utxos(where: {address: {_in: $addresses}}) {\n address\n txHash\n index\n value\n tokens {\n asset {\n assetId\n __typename\n }\n quantity\n __typename\n }\n __typename\n }\n cardano {\n currentEpoch {\n protocolParams {\n minFeeA\n minFeeB\n poolDeposit\n keyDeposit\n coinsPerUtxoByte\n maxValSize\n maxTxSize\n priceMem\n priceStep\n collateralPercent\n maxCollateralInputs\n __typename\n }\n __typename\n }\n __typename\n }\n rewards_aggregate(where: {address: {_eq: $rewardAddress}}) {\n aggregate {\n sum {\n amount\n __typename\n }\n __typename\n }\n __typename\n }\n withdrawals_aggregate(where: {address: {_eq: $rewardAddress}}) {\n aggregate {\n sum {\n amount\n __typename\n }\n __typename\n }\n __typename\n }\n stakeRegistrations_aggregate(where: {address: {_eq: $rewardAddress}}) {\n aggregate {\n count\n __typename\n }\n __typename\n }\n stakeDeregistrations_aggregate(where: {address: {_eq: $rewardAddress}}) {\n aggregate {\n count\n __typename\n }\n __typename\n }\n delegations(\n limit: 1\n order_by: {transaction: {block: {slotNo: desc}}}\n where: {address: {_eq: $rewardAddress}}\n ) {\n address\n stakePool {\n ...StakePoolFields\n __typename\n }\n __typename\n }\n}', 27 | }, 28 | }, 29 | res: { 30 | status: 200, 31 | headers: { 32 | date: [ 33 | 'Wed, 14 Dec 2022 13:14:00 GMT', 34 | ], 35 | server: [ 36 | 'Apache/2.4.54 () OpenSSL/1.0.2k-fips', 37 | ], 38 | 'x-powered-by': [ 39 | 'Express', 40 | ], 41 | vary: [ 42 | 'Origin', 43 | ], 44 | 'content-type': [ 45 | 'application/json; charset=utf-8', 46 | ], 47 | 'cache-control': [ 48 | 'max-age=20, public', 49 | ], 50 | 'content-length': [ 51 | '2700', 52 | ], 53 | etag: [ 54 | 'W/"a8c-Gt8dbyAlYTtcshwLDDtqB0Hqjuw"', 55 | ], 56 | connection: [ 57 | 'close', 58 | ], 59 | }, 60 | body: { 61 | data: { 62 | utxos: [ 63 | { 64 | address: 'addr_test1xzuh59uc243wuhpkcnfdha3flvmx5guf8thkctv8l75u2zzap4eefgsu8h5selu2aaeu29vh96rf99wcp5f0x60ldx6s2ad79j', 65 | txHash: '3ec64a8784bddc1b1849a349fe88c01918a58e4d32636420c17aafe156f16f9c', 66 | index: 0, 67 | value: '969750', 68 | tokens: [], 69 | __typename: 'TransactionOutput', 70 | }, 71 | { 72 | address: 'addr_test1xzuh59uc243wuhpkcnfdha3flvmx5guf8thkctv8l75u2zzap4eefgsu8h5selu2aaeu29vh96rf99wcp5f0x60ldx6s2ad79j', 73 | txHash: '829c0c98a4037f214abe197276ef8b53be3e313b139e73a87f7a8d0ff70ff735', 74 | index: 0, 75 | value: '10000000', 76 | tokens: [ 77 | { 78 | asset: { 79 | assetId: '9a556a69ba07adfbbce86cd9af8fd73f60fcf43c73f8deb51d2176b4504855464659', 80 | __typename: 'Asset', 81 | }, 82 | quantity: '1', 83 | __typename: 'Token', 84 | }, 85 | ], 86 | __typename: 'TransactionOutput', 87 | }, 88 | { 89 | address: 'addr_test1xzuh59uc243wuhpkcnfdha3flvmx5guf8thkctv8l75u2zzap4eefgsu8h5selu2aaeu29vh96rf99wcp5f0x60ldx6s2ad79j', 90 | txHash: '42e1b09014989a06633ca999c6a5bb20724af4773e725567d138cecca24fc800', 91 | index: 0, 92 | value: '1000000', 93 | tokens: [], 94 | __typename: 'TransactionOutput', 95 | }, 96 | { 97 | address: 'addr_test1xzuh59uc243wuhpkcnfdha3flvmx5guf8thkctv8l75u2zzap4eefgsu8h5selu2aaeu29vh96rf99wcp5f0x60ldx6s2ad79j', 98 | txHash: '42e1b09014989a06633ca999c6a5bb20724af4773e725567d138cecca24fc800', 99 | index: 1, 100 | value: '997280744', 101 | tokens: [], 102 | __typename: 'TransactionOutput', 103 | }, 104 | ], 105 | cardano: { 106 | currentEpoch: { 107 | protocolParams: { 108 | minFeeA: 44, 109 | minFeeB: 155381, 110 | poolDeposit: 500000000, 111 | keyDeposit: 2000000, 112 | coinsPerUtxoByte: 4310, 113 | maxValSize: '5000', 114 | maxTxSize: 16384, 115 | priceMem: 0.0577, 116 | priceStep: 0.0000721, 117 | collateralPercent: 150, 118 | maxCollateralInputs: 3, 119 | __typename: 'ProtocolParams', 120 | }, 121 | __typename: 'Epoch', 122 | }, 123 | __typename: 'Cardano', 124 | }, 125 | rewards_aggregate: { 126 | aggregate: { 127 | sum: { 128 | amount: '1709889', 129 | __typename: 'Reward_sum_fields', 130 | }, 131 | __typename: 'Reward_aggregate_fields', 132 | }, 133 | __typename: 'Reward_aggregate', 134 | }, 135 | withdrawals_aggregate: { 136 | aggregate: { 137 | sum: { 138 | amount: '1612692', 139 | __typename: 'Withdrawal_sum_fields', 140 | }, 141 | __typename: 'Withdrawal_aggregate_fields', 142 | }, 143 | __typename: 'Withdrawal_aggregate', 144 | }, 145 | stakeRegistrations_aggregate: { 146 | aggregate: { 147 | count: '1', 148 | __typename: 'StakeRegistration_aggregate_fields', 149 | }, 150 | __typename: 'StakeRegistration_aggregate', 151 | }, 152 | stakeDeregistrations_aggregate: { 153 | aggregate: { 154 | count: '0', 155 | __typename: 'StakeDeregistration_aggregate_fields', 156 | }, 157 | __typename: 'StakeDeregistration_aggregate', 158 | }, 159 | delegations: [ 160 | { 161 | address: 'stake_test17pws6uu55gwrm6gvl79w7u79zktjap5jjhvq6yhnd8lkndgcsn5h4', 162 | stakePool: { 163 | id: 'pool1ayc7a29ray6yv4hn7ge72hpjafg9vvpmtscnq9v8r0zh7azas9c', 164 | margin: 0.01, 165 | fixedCost: '340000000', 166 | pledge: '35000000000', 167 | hash: 'e931eea8a3e9344656f3f233e55c32ea5056303b5c313015871bc57f', 168 | metadataHash: '7296d38d3c67d769c38924679e132e7d9098e70891d7574cc5cf053574305629', 169 | __typename: 'StakePool', 170 | }, 171 | __typename: 'Delegation', 172 | }, 173 | ], 174 | }, 175 | }, 176 | }, 177 | } -------------------------------------------------------------------------------- /tapes/graphql/query-2.json5: -------------------------------------------------------------------------------- 1 | { 2 | meta: { 3 | createdAt: '2022-12-14T13:14:00.429Z', 4 | host: 'https://preview-gql.junglestakepool.com/graphql', 5 | reqHumanReadable: true, 6 | resHumanReadable: true, 7 | }, 8 | req: { 9 | headers: { 10 | accept: '*/*', 11 | 'content-type': 'application/json', 12 | 'user-agent': 'node-fetch/1.0 (+https://github.com/bitinn/node-fetch)', 13 | 'accept-encoding': 'gzip,deflate', 14 | connection: 'close', 15 | }, 16 | url: '/', 17 | method: 'POST', 18 | body: { 19 | operationName: 'Summary', 20 | variables: { 21 | addresses: [ 22 | 'addr_test1xzuh59uc243wuhpkcnfdha3flvmx5guf8thkctv8l75u2zzap4eefgsu8h5selu2aaeu29vh96rf99wcp5f0x60ldx6s2ad79j', 23 | ], 24 | rewardAddress: 'stake_test17pws6uu55gwrm6gvl79w7u79zktjap5jjhvq6yhnd8lkndgcsn5h4', 25 | }, 26 | query: 'fragment StakePoolFields on StakePool {\n id\n margin\n fixedCost\n pledge\n hash\n metadataHash\n __typename\n}\n\nquery Summary($addresses: [String]!, $rewardAddress: StakeAddress!) {\n paymentAddresses(addresses: $addresses) {\n address\n summary {\n assetBalances {\n asset {\n assetId\n __typename\n }\n quantity\n __typename\n }\n __typename\n }\n __typename\n }\n rewards_aggregate(where: {address: {_eq: $rewardAddress}}) {\n aggregate {\n sum {\n amount\n __typename\n }\n __typename\n }\n __typename\n }\n withdrawals_aggregate(where: {address: {_eq: $rewardAddress}}) {\n aggregate {\n sum {\n amount\n __typename\n }\n __typename\n }\n __typename\n }\n stakeRegistrations_aggregate(where: {address: {_eq: $rewardAddress}}) {\n aggregate {\n count\n __typename\n }\n __typename\n }\n stakeDeregistrations_aggregate(where: {address: {_eq: $rewardAddress}}) {\n aggregate {\n count\n __typename\n }\n __typename\n }\n delegations(\n limit: 1\n order_by: {transaction: {block: {slotNo: desc}}}\n where: {address: {_eq: $rewardAddress}}\n ) {\n address\n stakePool {\n ...StakePoolFields\n __typename\n }\n __typename\n }\n}', 27 | }, 28 | }, 29 | res: { 30 | status: 200, 31 | headers: { 32 | date: [ 33 | 'Wed, 14 Dec 2022 13:14:01 GMT', 34 | ], 35 | server: [ 36 | 'Apache/2.4.54 () OpenSSL/1.0.2k-fips', 37 | ], 38 | 'x-powered-by': [ 39 | 'Express', 40 | ], 41 | vary: [ 42 | 'Origin', 43 | ], 44 | 'content-type': [ 45 | 'application/json; charset=utf-8', 46 | ], 47 | 'cache-control': [ 48 | 'max-age=20, public', 49 | ], 50 | 'content-length': [ 51 | '1603', 52 | ], 53 | etag: [ 54 | 'W/"643-OQu+0E+qvaa6ayEIMJpGAKW1gho"', 55 | ], 56 | connection: [ 57 | 'close', 58 | ], 59 | }, 60 | body: { 61 | data: { 62 | paymentAddresses: [ 63 | { 64 | address: 'addr_test1xzuh59uc243wuhpkcnfdha3flvmx5guf8thkctv8l75u2zzap4eefgsu8h5selu2aaeu29vh96rf99wcp5f0x60ldx6s2ad79j', 65 | summary: { 66 | assetBalances: [ 67 | { 68 | asset: { 69 | assetId: 'ada', 70 | __typename: 'Asset', 71 | }, 72 | quantity: '1009250494', 73 | __typename: 'AssetBalance', 74 | }, 75 | { 76 | asset: { 77 | assetId: '9a556a69ba07adfbbce86cd9af8fd73f60fcf43c73f8deb51d2176b4504855464659', 78 | __typename: 'Asset', 79 | }, 80 | quantity: '1', 81 | __typename: 'AssetBalance', 82 | }, 83 | ], 84 | __typename: 'PaymentAddressSummary', 85 | }, 86 | __typename: 'PaymentAddress', 87 | }, 88 | ], 89 | rewards_aggregate: { 90 | aggregate: { 91 | sum: { 92 | amount: '1709889', 93 | __typename: 'Reward_sum_fields', 94 | }, 95 | __typename: 'Reward_aggregate_fields', 96 | }, 97 | __typename: 'Reward_aggregate', 98 | }, 99 | withdrawals_aggregate: { 100 | aggregate: { 101 | sum: { 102 | amount: '1612692', 103 | __typename: 'Withdrawal_sum_fields', 104 | }, 105 | __typename: 'Withdrawal_aggregate_fields', 106 | }, 107 | __typename: 'Withdrawal_aggregate', 108 | }, 109 | stakeRegistrations_aggregate: { 110 | aggregate: { 111 | count: '1', 112 | __typename: 'StakeRegistration_aggregate_fields', 113 | }, 114 | __typename: 'StakeRegistration_aggregate', 115 | }, 116 | stakeDeregistrations_aggregate: { 117 | aggregate: { 118 | count: '0', 119 | __typename: 'StakeDeregistration_aggregate_fields', 120 | }, 121 | __typename: 'StakeDeregistration_aggregate', 122 | }, 123 | delegations: [ 124 | { 125 | address: 'stake_test17pws6uu55gwrm6gvl79w7u79zktjap5jjhvq6yhnd8lkndgcsn5h4', 126 | stakePool: { 127 | id: 'pool1ayc7a29ray6yv4hn7ge72hpjafg9vvpmtscnq9v8r0zh7azas9c', 128 | margin: 0.01, 129 | fixedCost: '340000000', 130 | pledge: '35000000000', 131 | hash: 'e931eea8a3e9344656f3f233e55c32ea5056303b5c313015871bc57f', 132 | metadataHash: '7296d38d3c67d769c38924679e132e7d9098e70891d7574cc5cf053574305629', 133 | __typename: 'StakePool', 134 | }, 135 | __typename: 'Delegation', 136 | }, 137 | ], 138 | }, 139 | }, 140 | }, 141 | } -------------------------------------------------------------------------------- /tapes/graphql/query-3.json5: -------------------------------------------------------------------------------- 1 | { 2 | meta: { 3 | createdAt: '2022-12-14T13:14:01.866Z', 4 | host: 'https://preview-gql.junglestakepool.com/graphql', 5 | reqHumanReadable: true, 6 | resHumanReadable: true, 7 | }, 8 | req: { 9 | headers: { 10 | accept: '*/*', 11 | 'content-type': 'application/json', 12 | 'user-agent': 'node-fetch/1.0 (+https://github.com/bitinn/node-fetch)', 13 | 'accept-encoding': 'gzip,deflate', 14 | connection: 'close', 15 | }, 16 | url: '/', 17 | method: 'POST', 18 | body: { 19 | operationName: 'TransactionSummary', 20 | variables: { 21 | hashes: [ 22 | '829c0c98a4037f214abe197276ef8b53be3e313b139e73a87f7a8d0ff70ff735', 23 | ], 24 | }, 25 | query: 'fragment OutputFields on TransactionOutput {\n address\n txHash\n index\n value\n tokens {\n asset {\n assetId\n __typename\n }\n quantity\n __typename\n }\n __typename\n}\n\nquery TransactionSummary($hashes: [Hash32Hex]!) {\n transactions(where: {hash: {_in: $hashes}}) {\n hash\n outputs {\n ...OutputFields\n __typename\n }\n __typename\n }\n}', 26 | }, 27 | }, 28 | res: { 29 | status: 200, 30 | headers: { 31 | date: [ 32 | 'Wed, 14 Dec 2022 13:14:03 GMT', 33 | ], 34 | server: [ 35 | 'Apache/2.4.54 () OpenSSL/1.0.2k-fips', 36 | ], 37 | 'x-powered-by': [ 38 | 'Express', 39 | ], 40 | vary: [ 41 | 'Origin', 42 | ], 43 | 'content-type': [ 44 | 'application/json; charset=utf-8', 45 | ], 46 | 'cache-control': [ 47 | 'max-age=20, public', 48 | ], 49 | 'content-length': [ 50 | '1262', 51 | ], 52 | etag: [ 53 | 'W/"4ee-ZPVnyDlMADw1b9VulkWXS2e+Bx0"', 54 | ], 55 | connection: [ 56 | 'close', 57 | ], 58 | }, 59 | body: { 60 | data: { 61 | transactions: [ 62 | { 63 | hash: '829c0c98a4037f214abe197276ef8b53be3e313b139e73a87f7a8d0ff70ff735', 64 | outputs: [ 65 | { 66 | address: 'addr_test1qplyuheyp74tz84jx6p60de62dx9vwxjce472d97rhdymftllscj7rl5uphpv4utt7rglu4zqgac45pe2gax2wvuc2gsru6rms', 67 | txHash: '829c0c98a4037f214abe197276ef8b53be3e313b139e73a87f7a8d0ff70ff735', 68 | index: 2, 69 | value: '8949377793', 70 | tokens: [], 71 | __typename: 'TransactionOutput', 72 | }, 73 | { 74 | address: 'addr_test1qplyuheyp74tz84jx6p60de62dx9vwxjce472d97rhdymftllscj7rl5uphpv4utt7rglu4zqgac45pe2gax2wvuc2gsru6rms', 75 | txHash: '829c0c98a4037f214abe197276ef8b53be3e313b139e73a87f7a8d0ff70ff735', 76 | index: 1, 77 | value: '1146460', 78 | tokens: [ 79 | { 80 | asset: { 81 | assetId: '9a556a69ba07adfbbce86cd9af8fd73f60fcf43c73f8deb51d2176b4504855464659', 82 | __typename: 'Asset', 83 | }, 84 | quantity: '10', 85 | __typename: 'Token', 86 | }, 87 | ], 88 | __typename: 'TransactionOutput', 89 | }, 90 | { 91 | address: 'addr_test1xzuh59uc243wuhpkcnfdha3flvmx5guf8thkctv8l75u2zzap4eefgsu8h5selu2aaeu29vh96rf99wcp5f0x60ldx6s2ad79j', 92 | txHash: '829c0c98a4037f214abe197276ef8b53be3e313b139e73a87f7a8d0ff70ff735', 93 | index: 0, 94 | value: '10000000', 95 | tokens: [ 96 | { 97 | asset: { 98 | assetId: '9a556a69ba07adfbbce86cd9af8fd73f60fcf43c73f8deb51d2176b4504855464659', 99 | __typename: 'Asset', 100 | }, 101 | quantity: '1', 102 | __typename: 'Token', 103 | }, 104 | ], 105 | __typename: 'TransactionOutput', 106 | }, 107 | ], 108 | __typename: 'Transaction', 109 | }, 110 | ], 111 | }, 112 | }, 113 | }, 114 | } -------------------------------------------------------------------------------- /tapes/graphql/query-4.json5: -------------------------------------------------------------------------------- 1 | { 2 | meta: { 3 | createdAt: '2022-12-14T13:14:03.294Z', 4 | host: 'https://preview-gql.junglestakepool.com/graphql', 5 | reqHumanReadable: true, 6 | resHumanReadable: true, 7 | }, 8 | req: { 9 | headers: { 10 | accept: '*/*', 11 | 'content-type': 'application/json', 12 | 'user-agent': 'node-fetch/1.0 (+https://github.com/bitinn/node-fetch)', 13 | 'accept-encoding': 'gzip,deflate', 14 | connection: 'close', 15 | }, 16 | url: '/', 17 | method: 'POST', 18 | body: { 19 | operationName: 'StakePools', 20 | variables: { 21 | id: 'pool1ayc7a29ray6yv4hn7ge72hpjafg9vvpmtscnq9v8r0zh7azas9c', 22 | limit: 1, 23 | offset: 0, 24 | }, 25 | query: 'fragment StakePoolFields on StakePool {\n id\n margin\n fixedCost\n pledge\n hash\n metadataHash\n __typename\n}\n\nfragment RetirementFields on StakePool {\n retirements {\n retiredInEpoch {\n number\n __typename\n }\n announcedIn {\n hash\n __typename\n }\n inEffectFrom\n __typename\n }\n __typename\n}\n\nquery StakePools($id: StakePoolID, $limit: Int!, $offset: Int!) {\n stakePools(limit: $limit, offset: $offset, where: {id: {_eq: $id}}) {\n ...StakePoolFields\n ...RetirementFields\n __typename\n }\n}', 26 | }, 27 | }, 28 | res: { 29 | status: 200, 30 | headers: { 31 | date: [ 32 | 'Wed, 14 Dec 2022 13:14:05 GMT', 33 | ], 34 | server: [ 35 | 'Apache/2.4.54 () OpenSSL/1.0.2k-fips', 36 | ], 37 | 'x-powered-by': [ 38 | 'Express', 39 | ], 40 | vary: [ 41 | 'Origin', 42 | ], 43 | 'content-type': [ 44 | 'application/json; charset=utf-8', 45 | ], 46 | 'cache-control': [ 47 | 'max-age=20, public', 48 | ], 49 | 'content-length': [ 50 | '343', 51 | ], 52 | etag: [ 53 | 'W/"157-JITwyky3sPHnHcYYr4OBJyFYoSI"', 54 | ], 55 | connection: [ 56 | 'close', 57 | ], 58 | }, 59 | body: { 60 | data: { 61 | stakePools: [ 62 | { 63 | id: 'pool1ayc7a29ray6yv4hn7ge72hpjafg9vvpmtscnq9v8r0zh7azas9c', 64 | margin: 0.01, 65 | fixedCost: '340000000', 66 | pledge: '35000000000', 67 | hash: 'e931eea8a3e9344656f3f233e55c32ea5056303b5c313015871bc57f', 68 | metadataHash: '7296d38d3c67d769c38924679e132e7d9098e70891d7574cc5cf053574305629', 69 | __typename: 'StakePool', 70 | retirements: [], 71 | }, 72 | ], 73 | }, 74 | }, 75 | }, 76 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "types": ["jest"], 17 | "incremental": true 18 | }, 19 | "include": ["next-env.d.ts", "src/**/*.ts", "src/**/*.tsx"], 20 | "exclude": ["node_modules"] 21 | } 22 | -------------------------------------------------------------------------------- /tsfmt.json: -------------------------------------------------------------------------------- 1 | { 2 | "tabSize": 2, 3 | "indentSize": 2, 4 | "convertTabsToSpaces": true 5 | } 6 | --------------------------------------------------------------------------------