├── .env.development ├── .env.example ├── .eslintrc.json ├── .github └── workflows │ ├── add-issues-to-devx-project.yml │ ├── build-bundle.yml │ └── ci.yml ├── .gitignore ├── .prettierrc ├── .vscode └── launch.json ├── LICENSE ├── NOTICE ├── README.md ├── SECURITY.md ├── babel.config.js ├── banner.svg ├── cadence ├── contracts │ ├── FCL.cdc │ ├── FCLCrypto.cdc │ └── utility │ │ ├── FlowToken.cdc │ │ ├── FungibleToken.cdc │ │ ├── FungibleTokenMetadataViews.cdc │ │ ├── MetadataViews.cdc │ │ ├── NonFungibleToken.cdc │ │ └── ViewResolver.cdc ├── scripts │ ├── getAccount.cdc │ └── getAccounts.cdc └── transactions │ ├── fundFLOW.cdc │ ├── init.cdc │ ├── newAccount.cdc │ └── updateAccount.cdc ├── components ├── AccountBalances.tsx ├── AccountForm.tsx ├── AccountImage.tsx ├── AccountListItemScopes.tsx ├── AccountSectionHeading.tsx ├── AccountsList.tsx ├── AccountsListItem.tsx ├── AuthnActions.tsx ├── AuthzActions.tsx ├── AuthzDetails.tsx ├── AuthzDetailsTable.tsx ├── AuthzHeader.tsx ├── Button.tsx ├── CaretIcon.tsx ├── Code.tsx ├── ConnectedAppHeader.tsx ├── ConnectedAppIcon.tsx ├── Dialog.tsx ├── ExpandCollapseButton.tsx ├── FormErrors.tsx ├── InfoIcon.tsx ├── Inputs.tsx ├── Label.tsx ├── PlusButton.tsx ├── Spinner.tsx └── Switch.tsx ├── contexts ├── AuthnContext.tsx ├── AuthnRefreshContext.tsx ├── AuthzContext.tsx └── ConfigContext.tsx ├── cypress.json ├── cypress ├── integration │ ├── authn.spec.js │ └── authz.spec.js ├── plugins │ └── index.js └── support │ ├── commands.js │ └── index.js ├── docs ├── overview.md └── project-development-tips.md ├── flow.json ├── go.mod ├── go.sum ├── go ├── README.md └── wallet │ ├── .env.development │ ├── bundle.zip │ ├── cmd │ └── main.go │ ├── config_handler.go │ ├── dev_wallet_handler.go │ ├── discovery_handler.go │ ├── polling_session_handler.go │ ├── server.go │ ├── service_handler.go │ └── util.go ├── hooks ├── useAccount.ts ├── useAccounts.ts ├── useAuthnContext.ts ├── useAuthnRefreshContext.ts ├── useAuthzContext.ts ├── useConfig.ts ├── useConnectedAppConfig.ts ├── useFclData.ts ├── useThemeUI.ts └── useVariants.ts ├── modules.d.ts ├── next-env.d.ts ├── next.config.js ├── package-lock.json ├── package.json ├── pages ├── _app.tsx ├── fcl │ ├── authn-refresh.tsx │ ├── authn.tsx │ ├── authz.tsx │ └── user-sig.tsx ├── fonts.css └── index.tsx ├── public ├── back-arrow.svg ├── collapse.svg ├── expand.svg ├── external-link.svg ├── favicon.ico ├── flow-logo.svg ├── fonts │ ├── bebas-neue-v2-latin │ │ └── bebas-neue-v2-latin-regular.woff2 │ └── overpass │ │ ├── overpass-bold.woff2 │ │ ├── overpass-mono-regular.woff2 │ │ ├── overpass-regular.woff2 │ │ └── overpass-semibold.woff2 ├── missing-app-icon.svg ├── missing-avatar-icon.svg ├── plus-icon.svg ├── settings.svg ├── transaction.svg ├── vercel.svg └── x-icon.svg ├── server ├── src ├── accountAuth.ts ├── accountGenerator.ts ├── accounts.ts ├── authz.ts ├── avatar.ts ├── balance.ts ├── cadence.ts ├── comps │ └── err.comp.tsx ├── constants.ts ├── crypto.ts ├── fclConfig.ts ├── harness │ ├── cmds │ │ ├── index.js │ │ ├── login.js │ │ ├── logout.js │ │ ├── m1.js │ │ ├── m2.js │ │ ├── q1.js │ │ ├── q2.js │ │ └── us1.js │ ├── config.js │ ├── decorate.js │ ├── hooks │ │ ├── use-config.js │ │ └── use-current-user.js │ └── util.js ├── init.ts ├── middleware.ts ├── safe.ts ├── scopes.ts ├── services.ts ├── theme.ts ├── utils.ts └── validate.ts ├── styles └── globals.css ├── tsconfig.json └── types.d.ts /.env.development: -------------------------------------------------------------------------------- 1 | # The FCL Dev Wallet requires a single account to use as a base/starting point. 2 | # This account will be used to create and manage other accounts. 3 | # We recommend to use the service account defined in the flow.json file your emulator is using. 4 | 5 | FLOW_INIT_ACCOUNTS=0 6 | FLOW_AVATAR_URL=https://avatars.onflow.org/avatar/ 7 | 8 | # EMULATOR REST API ENDPOINT 9 | FLOW_ACCESS_NODE=http://localhost:8888 10 | # Default values. These should match locally running emulator. 11 | # Will only be used when wallet is started from source (npm run dev) 12 | FLOW_ACCOUNT_KEY_ID=0 13 | FLOW_ACCOUNT_ADDRESS=0xf8d6e0586b0a20c7 14 | FLOW_ACCOUNT_PRIVATE_KEY=f8e188e8af0b8b414be59c4a1a15cc666c898fb34d94156e9b51e18bfde754a5 15 | FLOW_ACCOUNT_PUBLIC_KEY=6e70492cb4ec2a6013e916114bc8bf6496f3335562f315e18b085c19da659bdfd88979a5904ae8bd9b4fd52a07fc759bad9551c04f289210784e7b08980516d2 16 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # The FCL Dev Wallet requires a single account to use as a base/starting point. 2 | # This account will be used to create and manage other accounts. 3 | # We recommend to use the service account definied in the flow.json file your emulator is using. 4 | 5 | FLOW_INIT_ACCOUNTS=0 6 | FLOW_AVATAR_URL=https://avatars.onflow.org/avatar/ 7 | 8 | # EMULATOR REST API ENDPOINT 9 | FLOW_ACCESS_NODE=http://localhost:8888 10 | # Default values. These should match locally running emulator. 11 | # Will only be used when wallet is started from source (npm run dev) 12 | FLOW_ACCOUNT_KEY_ID=0 13 | FLOW_ACCOUNT_ADDRESS=0xf8d6e0586b0a20c7 14 | FLOW_ACCOUNT_PRIVATE_KEY=f8e188e8af0b8b414be59c4a1a15cc666c898fb34d94156e9b51e18bfde754a5 15 | FLOW_ACCOUNT_PUBLIC_KEY=6e70492cb4ec2a6013e916114bc8bf6496f3335562f315e18b085c19da659bdfd88979a5904ae8bd9b4fd52a07fc759bad9551c04f289210784e7b08980516d2 16 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "extends": [ 4 | "plugin:react/recommended", 5 | "plugin:prettier/recommended", 6 | "plugin:react-hooks/recommended" 7 | ], 8 | "plugins": ["react", "prettier", "react-hooks"], 9 | "rules": { 10 | "react/react-in-jsx-scope": "off", 11 | "no-console": "warn" 12 | }, 13 | "settings": { 14 | "react": { 15 | "version": "detect" 16 | } 17 | }, 18 | "overrides": [ 19 | { 20 | "parser": "@typescript-eslint/parser", 21 | "extends": ["plugin:@typescript-eslint/recommended"], 22 | "files": ["*.ts", "*.tsx"], 23 | "plugins": ["@typescript-eslint"], 24 | "rules": { 25 | "@typescript-eslint/explicit-module-boundary-types": "off" 26 | } 27 | } 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /.github/workflows/add-issues-to-devx-project.yml: -------------------------------------------------------------------------------- 1 | name: Adds all issues to the DevEx project board. 2 | 3 | on: 4 | issues: 5 | types: 6 | - opened 7 | 8 | jobs: 9 | add-to-project: 10 | name: Add issue to project 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/add-to-project@v0.4.1 14 | with: 15 | project-url: https://github.com/orgs/onflow/projects/13 16 | github-token: ${{ secrets.GH_ACTION_FOR_PROJECTS }} 17 | -------------------------------------------------------------------------------- /.github/workflows/build-bundle.yml: -------------------------------------------------------------------------------- 1 | name: Build a static wallet bundle 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | paths: 7 | - 'src/**' 8 | 9 | workflow_dispatch: 10 | 11 | jobs: 12 | buildBundle: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v3 16 | with: 17 | fetch-depth: 0 18 | 19 | - name: Build Bundle 20 | uses: actions/setup-node@v3 21 | with: 22 | node-version: '16' 23 | cache: 'npm' 24 | 25 | - run: npm install 26 | - run: npm run build 27 | - run: npm run export 28 | 29 | - uses: vimtor/action-zip 30 | with: 31 | files: out/ 32 | dest: ./go/wallet/bundle.zip 33 | 34 | - name: Create Pull Request 35 | uses: peter-evans/create-pull-request@v3 36 | with: 37 | commit-message: build a static bundle 38 | title: Update Built Bundle 39 | body: This PR is automatically generated to update the build bundle of the dev-wallet which is then used by go library for integration in other tools. 40 | assignees: sideninja 41 | reviewers: justinbarry,JeffreyDoyle,chasefleming,gregsantos 42 | branch: update-bundle 43 | base: main 44 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | 6 | jobs: 7 | static: 8 | name: Static checks 9 | runs-on: ubuntu-latest 10 | 11 | strategy: 12 | matrix: 13 | node-version: [16.x] 14 | 15 | steps: 16 | - uses: actions/checkout@v3 17 | - name: Use Node.js ${{ matrix.node-version }} 18 | uses: actions/setup-node@v3 19 | with: 20 | node-version: ${{ matrix.node-version }} 21 | - run: npm ci 22 | - run: npm run lint 23 | - run: npm run tsc 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | .idea 4 | 5 | # dependencies 6 | /node_modules 7 | /.pnp 8 | .pnp.js 9 | 10 | # testing 11 | /coverage 12 | 13 | # next.js 14 | /.next/ 15 | /out 16 | 17 | # production 18 | /build 19 | 20 | # misc 21 | .DS_Store 22 | *.pem 23 | 24 | # debug 25 | npm-debug.log* 26 | yarn-debug.log* 27 | yarn-error.log* 28 | 29 | # local env files 30 | .env.local 31 | .env.development.local 32 | .env.test.local 33 | .env.production.local 34 | 35 | # vercel 36 | .vercel 37 | 38 | .idea 39 | /.env -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "trailingComma": "es5", 4 | "bracketSpacing": false, 5 | "arrowParens": "avoid" 6 | } 7 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Launch Package", 9 | "type": "go", 10 | "request": "launch", 11 | "mode": "debug", 12 | "program": "${workspaceRoot}/cmd/main.go", 13 | } 14 | ] 15 | } -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | Flow 2 | Copyright 2019-2021 Dapper Labs, Inc. 3 | 4 | This product includes software developed at Dapper Labs, Inc. (https://www.dapperlabs.com/). 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

3 | 4 | Logo 5 | 6 | 7 |

8 | A Flow wallet for effortless development, to be used with the Flow Emulator and FCL. 9 |
10 | FCL docs» 11 |
12 |
13 | Report Bug 14 | · 15 | Getting Started 16 |

17 |

18 |
19 |
20 | 21 | ## Introduction 22 | 23 | The FCL dev wallet is a mock Flow wallet that simulates the protocols used by [FCL](https://docs.onflow.org/fcl/) to interact with the Flow blockchain on behalf of simulated user accounts. 24 | 25 | **IMPORTANT** 26 | 27 | > **Warning** 28 | > 29 | > This project implements an FCL compatible interface, but should **not** be used as a reference for building a production grade wallet. 30 | > 31 | > This project should only be used in aid of local development against a locally run instance of the Flow blockchain like the Flow emulator, 32 | > and should never be used in conjunction with Flow Mainnet, Testnet, Canarynet or any other instances of Flow. 33 | 34 | ## Getting started 35 | 36 | Before using the dev wallet, you'll need to start the Flow emulator. 37 | 38 | ### Install the `flow-cli` 39 | 40 | The Flow emulator is bundles with the Flow CLI. Instructions for installing the CLI can be found here: [https://docs.onflow.org/flow-cli/install/](https://docs.onflow.org/flow-cli/install/) 41 | 42 | ### Create a `flow.json` file 43 | 44 | Run this command to create `flow.json` file (typically in your project's root directory): 45 | 46 | ```sh 47 | flow init 48 | ``` 49 | 50 | ### Start the emulator 51 | 52 | Start the emulator and deploy the contracts by running the following command from the directory containing `flow.json` in your project: 53 | 54 | ```sh 55 | flow emulator start 56 | flow project deploy --network emulator 57 | ``` 58 | 59 | ## Start the dev wallet 60 | 61 | ```js 62 | PORT=8701 npm run dev 63 | ``` 64 | 65 | **Note:** The following variables should match the `emulator-account` defined in your project's `flow.json` file. 66 | For details about `flow.json` visit the `flow-cli` [configuration reference](https://docs.onflow.org/flow-cli/configuration/). 67 | 68 | ## Configuring your JavaScript application 69 | 70 | The FCL dev wallet is designed to be used with [`@onflow/fcl`](https://github.com/onflow/flow-js-sdk) version `1.0.0` or higher. The FCL package can be installed with: `npm install @onflow/fcl` or `yarn add @onflow/fcl`. 71 | 72 | To use the dev wallet, configure FCL to point to the address of a locally running [Flow emulator](#start-the-emulator) and the dev wallet endpoint. 73 | 74 | ```javascript 75 | import * as fcl from "@onflow/fcl" 76 | 77 | fcl 78 | .config() 79 | // Point App at Emulator REST API 80 | .put("accessNode.api", "http://localhost:8888") 81 | // Point FCL at dev-wallet (default port) 82 | .put("discovery.wallet", "http://localhost:8701/fcl/authn") 83 | ``` 84 | 85 | ### Test harness 86 | 87 | It's easy to use this FCL harness app as a barebones 88 | app to interact with the dev-wallet during development: 89 | 90 | Navigate to http://localhost:8701/harness 91 | 92 | 🚀 93 | 94 | ## Contributing 95 | Releasing a new version of Dev Wallet is as simple as tagging and creating a release, a Github Action will then build a bundle of the Dev Wallet that can be used in other tools (such as CLI). If the update of the Dev Wallet is required in the CLI, a seperate update PR on the CLI should be created. 96 | 97 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Responsible Disclosure Policy 2 | 3 | Flow was built from the ground up with security in mind. Our code, infrastructure, and development methodology helps us keep our users safe. 4 | 5 | We really appreciate the community's help. Responsible disclosure of vulnerabilities helps to maintain the security and privacy of everyone. 6 | 7 | If you care about making a difference, please follow the guidelines below. 8 | 9 | # **Guidelines For Responsible Disclosure** 10 | 11 | We ask that all researchers adhere to these guidelines. 12 | 13 | ## **Rules of Engagement** 14 | 15 | - Make every effort to avoid unauthorized access, use, and disclosure of personal information. 16 | - Avoid actions which could impact user experience, disrupt production systems, change, or destroy data during security testing. 17 | - Don’t perform any attack that is intended to cause Denial of Service to the network, hosts, or services on any port or using any protocol. 18 | - Use our provided communication channels to securely report vulnerability information to us. 19 | - Keep information about any bug or vulnerability you discover confidential between us until we publicly disclose it. 20 | - Please don’t use scanners to crawl us and hammer endpoints. They’re noisy and we already do this. If you find anything this way, we have likely already identified it. 21 | - Never attempt non-technical attacks such as social engineering, phishing, or physical attacks against our employees, users, or infrastructure. 22 | 23 | ## **In Scope URIs** 24 | 25 | Be careful that you're looking at domains and systems that belong to us and not someone else. When in doubt, please ask us. Maybe ask us anyway. 26 | 27 | Bottom line, we suggest that you limit your testing to infrastructure that is clearly ours. 28 | 29 | ## **Out of Scope URIs** 30 | 31 | The following base URIs are explicitly out of scope: 32 | 33 | - None 34 | 35 | ## **Things Not To Do** 36 | 37 | In the interests of your safety, our safety, and for our customers, the following test types are prohibited: 38 | 39 | - Physical testing such as office and data-centre access (e.g. open doors, tailgating, card reader attacks, physically destructive testing) 40 | - Social engineering (e.g. phishing, vishing) 41 | - Testing of applications or systems NOT covered by the ‘In Scope’ section, or that are explicitly out of scope. 42 | - Network level Denial of Service (DoS/DDoS) attacks 43 | 44 | ## **Sensitive Data** 45 | 46 | In the interests of protecting privacy, we never want to receive: 47 | 48 | - Personally identifiable information (PII) 49 | - Payment card (e.g. credit card) data 50 | - Financial information (e.g. bank records) 51 | - Health or medical information 52 | - Accessed or cracked credentials in cleartext 53 | 54 | ## **Our Commitment To You** 55 | 56 | If you follow these guidelines when researching and reporting an issue to us, we commit to: 57 | 58 | - Not send lawyers after you related to your research under this policy; 59 | - Work with you to understand and resolve any issues within a reasonable timeframe, including an initial confirmation of your report within 72 hours of submission; and 60 | - At a minimum, we will recognize your contribution in our Disclosure Acknowledgements if you are the first to report the issue and we make a code or configuration change based on the issue. 61 | 62 | ## **Disclosure Acknowledgements** 63 | 64 | We're happy to acknowledge contributors. Security acknowledgements can be found here. 65 | 66 | ## Rewards 67 | 68 | We run closed bug bounty programs, but beyond that we also pay out rewards, once per eligible bug, to the first responsibly disclosing third party. Rewards are based on the seriousness of the bug, but the minimum is $100 and we have and are willing to pay $5,000 or more at our sole discretion. 69 | 70 | ### **Elligibility** 71 | 72 | To qualify, the bug must fall within our scope and rules and meet the following criteria: 73 | 74 | 1. **Previously unknown** - When reported, we must not have already known of the issue, either by internal discovery or separate disclosure. 75 | 2. **Material impact** - Demonstrable exploitability where, if exploited, the bug would materially affect the confidentiality, integrity, or availability of our services. 76 | 3. **Requires action** - The bug requires some mitigation. It is both valid and actionable. 77 | 78 | ## **Reporting Security Findings To Us** 79 | 80 | Reports are welcome! Please definitely reach out to us if you have a security concern. 81 | 82 | We prefer you to please send us an email: security@onflow.org 83 | 84 | Note: If you believe you may have found a security vulnerability in our open source repos, to be on the safe side, do NOT open a public issue. 85 | 86 | We encourage you to encrypt the information you send us using our PGP key at [keys.openpgp.org/security@onflow.org](https://keys.openpgp.org/vks/v1/by-fingerprint/AE3264F330AB51F7DBC52C400BB5D3D7516D168C) 87 | 88 | Please include the following details with your report: 89 | 90 | - A description of the location and potential impact of the finding(s); 91 | - A detailed description of the steps required to reproduce the issue; and 92 | - Any POC scripts, screenshots, and compressed screen captures, where feasible. 93 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = function (api) { 2 | const env = api.env() 3 | api.assertVersion("^7.12.10") 4 | 5 | const removeDataTestAttributes = 6 | env === "production" && !process.env.PRESERVE_DATA_TEST_ATTRIBUTES 7 | 8 | const presets = ["next/babel"] 9 | const plugins = ["@emotion"] 10 | 11 | if (removeDataTestAttributes) 12 | plugins.push(["react-remove-properties", {properties: ["data-test"]}]) 13 | 14 | return { 15 | presets, 16 | plugins, 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /cadence/contracts/FCL.cdc: -------------------------------------------------------------------------------- 1 | access(all) contract FCL { 2 | access(all) let storagePath: StoragePath 3 | 4 | access(all) struct FCLKey { 5 | access(all) let publicKey: [UInt8] 6 | access(all) let signatureAlgorithm: UInt8 7 | access(all) let hashAlgorithm: UInt8 8 | 9 | init(publicKey: [UInt8], signatureAlgorithm: UInt8, hashAlgorithm: UInt8) { 10 | self.publicKey = publicKey 11 | self.signatureAlgorithm = signatureAlgorithm 12 | self.hashAlgorithm = hashAlgorithm 13 | } 14 | } 15 | 16 | access(all) struct FCLAccount { 17 | access(all) let type: String 18 | access(all) let address: Address 19 | access(all) let keyId: Int 20 | access(all) var label: String 21 | access(all) var scopes: [String] 22 | 23 | init(address: Address, label: String, scopes: [String]) { 24 | self.type = "ACCOUNT" 25 | self.address = address 26 | self.keyId = 0 27 | self.label = label 28 | self.scopes = scopes 29 | } 30 | 31 | access(all) fun update(label: String, scopes: [String]) { 32 | self.label = label 33 | self.scopes = scopes 34 | } 35 | } 36 | 37 | access(all) resource Root { 38 | access(all) let key: FCLKey 39 | access(all) let accounts: {Address: FCLAccount} 40 | 41 | init (_ key: FCLKey) { 42 | self.key = key 43 | self.accounts = {} 44 | } 45 | 46 | access(all) fun add(_ acct: FCLAccount) { 47 | self.accounts[acct.address] = acct 48 | } 49 | 50 | access(all) fun update(address: Address, label: String, scopes: [String]) { 51 | let acct = self.accounts[address] 52 | acct!.update(label: label, scopes: scopes) 53 | self.accounts[address] = acct 54 | } 55 | } 56 | 57 | access(all) fun accounts(): &{Address: FCLAccount} { 58 | return self.account.storage.borrow<&Root>(from: self.storagePath)!.accounts 59 | } 60 | 61 | access(all) fun getServiceKey(): &FCLKey { 62 | return self.account.storage.borrow<&Root>(from: self.storagePath)!.key 63 | } 64 | 65 | access(all) fun new(label: String, scopes: [String], address: Address?): &Account { 66 | let acct = Account(payer: self.account) 67 | let key = self.getServiceKey() 68 | 69 | acct.keys.add( 70 | publicKey: PublicKey( 71 | publicKey: key.publicKey.map(fun (_ byte: UInt8): UInt8 { 72 | return byte 73 | }), 74 | signatureAlgorithm: SignatureAlgorithm(key.signatureAlgorithm)!, 75 | ), 76 | hashAlgorithm: HashAlgorithm(key.hashAlgorithm)!, 77 | weight: 1000.0 78 | ) 79 | 80 | self.account 81 | .storage 82 | .borrow<&Root>(from: self.storagePath)! 83 | .add(FCLAccount(address: address ?? acct.address, label: label, scopes: scopes)) 84 | 85 | return acct 86 | } 87 | 88 | access(all) fun update(address: Address, label: String, scopes: [String]) { 89 | self.account.storage.borrow<&Root>(from: self.storagePath)! 90 | .update(address: address, label: label, scopes: scopes) 91 | } 92 | 93 | init (publicKey: String, hashAlgorithm: UInt8, signAlgorithm: UInt8, initAccountsLabels: [String]) { 94 | let keyByteArray = publicKey.decodeHex() 95 | let key = FCLKey(publicKey: keyByteArray, signatureAlgorithm: signAlgorithm, hashAlgorithm: hashAlgorithm) 96 | 97 | self.storagePath = /storage/FCL_DEV_WALLET 98 | self.account.storage.save(<- create Root(key), to: self.storagePath) 99 | 100 | self.new(label: initAccountsLabels[0], scopes: [], address: self.account.address) 101 | var acctInitIndex = 1 102 | while acctInitIndex < initAccountsLabels.length { 103 | self.new(label: initAccountsLabels[acctInitIndex], scopes: [], address: nil) 104 | acctInitIndex = acctInitIndex + 1 105 | } 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /cadence/contracts/FCLCrypto.cdc: -------------------------------------------------------------------------------- 1 | /* 2 | FCLCrypto 3 | 4 | The FCLCrypto contract provides functions which allow to verify signatures and check for signing power. 5 | */ 6 | 7 | access(all) contract FCLCrypto { 8 | 9 | /// verifyUserSignatures allows to verify the user signatures for the given account. 10 | /// 11 | /// @param address: The address of the account 12 | /// @param message: The signed data 13 | /// @param keyIndices: This integer array maps the signatures to the account keys by index 14 | /// @param signatures: The signatures belonging to the account keys 15 | /// 16 | /// @return Whether all signatures are valid and the combined total key weight reaches signing power 17 | /// 18 | access(all) fun verifyUserSignatures( 19 | address: Address, 20 | message: String, 21 | keyIndices: [Int], 22 | signatures: [String] 23 | ): Bool { 24 | return self.verifySignatures( 25 | address: address, 26 | message: message, 27 | keyIndices: keyIndices, 28 | signatures: signatures, 29 | domainSeparationTag: self.domainSeparationTagFlowUser, 30 | ) 31 | } 32 | 33 | /// verifyAccountProofSignatures allows to verify the account proof signatures for the given account. 34 | /// 35 | /// @param address: The address of the account 36 | /// @param message: The signed data 37 | /// @param keyIndices: This integer array maps the signatures to the account keys by index 38 | /// @param signatures: The signatures belonging to the account keys 39 | /// 40 | /// @return Whether all signatures are valid and the combined total key weight reaches signing power 41 | /// 42 | access(all) fun verifyAccountProofSignatures( 43 | address: Address, 44 | message: String, 45 | keyIndices: [Int], 46 | signatures: [String] 47 | ): Bool { 48 | return self.verifySignatures( 49 | address: address, 50 | message: message, 51 | keyIndices: keyIndices, 52 | signatures: signatures, 53 | domainSeparationTag: self.domainSeparationTagAccountProof, 54 | ) || 55 | self.verifySignatures( 56 | address: address, 57 | message: message, 58 | keyIndices: keyIndices, 59 | signatures: signatures, 60 | domainSeparationTag: self.domainSeparationTagFlowUser, 61 | ) 62 | } 63 | 64 | /// verifySignatures is a private function which provides the functionality to verify 65 | /// signatures for the public functions. 66 | /// 67 | /// @param address: The address of the account 68 | /// @param message: The signed data 69 | /// @param keyIndices: This integer array maps the signatures to the account keys by index 70 | /// @param signatures: The signatures belonging to the account keys 71 | /// @param domainSeparationTag: The domain tag originally used for the signatures 72 | /// 73 | /// @return Whether all signatures are valid and the combined total key weight reaches signing power 74 | /// 75 | access(self) fun verifySignatures( 76 | address: Address, 77 | message: String, 78 | keyIndices: [Int], 79 | signatures: [String], 80 | domainSeparationTag: String, 81 | ): Bool { 82 | pre { 83 | keyIndices.length == signatures.length : "Key index list length does not match signature list length" 84 | } 85 | 86 | let account = getAccount(address) 87 | let messageBytes = message.decodeHex() 88 | 89 | var totalWeight: UFix64 = 0.0 90 | let seenKeyIndices: {Int: Bool} = {} 91 | 92 | var i = 0 93 | 94 | for keyIndex in keyIndices { 95 | 96 | let accountKey = account.keys.get(keyIndex: keyIndex) ?? panic("Key provided does not exist on account") 97 | let signature = signatures[i].decodeHex() 98 | 99 | // Ensure this key index has not already been seen 100 | 101 | if seenKeyIndices[accountKey.keyIndex] ?? false { 102 | return false 103 | } 104 | 105 | // Record the key index was seen 106 | 107 | seenKeyIndices[accountKey.keyIndex] = true 108 | 109 | // Ensure the key is not revoked 110 | 111 | if accountKey.isRevoked { 112 | return false 113 | } 114 | 115 | // Ensure the signature is valid 116 | 117 | if !accountKey.publicKey.verify( 118 | signature: signature, 119 | signedData: messageBytes, 120 | domainSeparationTag: domainSeparationTag, 121 | hashAlgorithm: accountKey.hashAlgorithm 122 | ) { 123 | return false 124 | } 125 | 126 | totalWeight = totalWeight + accountKey.weight 127 | 128 | i = i + 1 129 | } 130 | 131 | return totalWeight >= 1000.0 132 | } 133 | 134 | access(self) let domainSeparationTagFlowUser: String 135 | access(self) let domainSeparationTagFCLUser: String 136 | access(self) let domainSeparationTagAccountProof: String 137 | 138 | init() { 139 | self.domainSeparationTagFlowUser = "FLOW-V0.0-user" 140 | self.domainSeparationTagFCLUser = "FCL-USER-V0.0" 141 | self.domainSeparationTagAccountProof = "FCL-ACCOUNT-PROOF-V0.0" 142 | } 143 | } -------------------------------------------------------------------------------- /cadence/contracts/utility/ViewResolver.cdc: -------------------------------------------------------------------------------- 1 | // Taken from the NFT Metadata standard, this contract exposes an interface to let 2 | // anyone borrow a contract and resolve views on it. 3 | // 4 | // This will allow you to obtain information about a contract without necessarily knowing anything about it. 5 | // All you need is its address and name and you're good to go! 6 | access(all) contract interface ViewResolver { 7 | 8 | /// Function that returns all the Metadata Views implemented by the resolving contract 9 | /// 10 | /// @return An array of Types defining the implemented views. This value will be used by 11 | /// developers to know which parameter to pass to the resolveView() method. 12 | /// 13 | access(all) view fun getViews(): [Type] { 14 | return [] 15 | } 16 | 17 | /// Function that resolves a metadata view for this token. 18 | /// 19 | /// @param view: The Type of the desired view. 20 | /// @return A structure representing the requested view. 21 | /// 22 | access(all) fun resolveView(_ view: Type): AnyStruct? { 23 | return nil 24 | } 25 | 26 | /// Provides access to a set of metadata views. A struct or 27 | /// resource (e.g. an NFT) can implement this interface to provide access to 28 | /// the views that it supports. 29 | /// 30 | access(all) resource interface Resolver { 31 | 32 | /// Same as getViews above, but on a specific NFT instead of a contract 33 | access(all) view fun getViews(): [Type] { 34 | return [] 35 | } 36 | 37 | /// Same as resolveView above, but on a specific NFT instead of a contract 38 | access(all) fun resolveView(_ view: Type): AnyStruct? { 39 | return nil 40 | } 41 | } 42 | 43 | /// A group of view resolvers indexed by ID. 44 | /// 45 | access(all) resource interface ResolverCollection { 46 | access(all) view fun borrowViewResolver(id: UInt64): &{Resolver}? { 47 | return nil 48 | } 49 | 50 | access(all) view fun getIDs(): [UInt64] { 51 | return [] 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /cadence/scripts/getAccount.cdc: -------------------------------------------------------------------------------- 1 | access(all) fun main(address: Address): &Account { 2 | return getAccount(address) 3 | } 4 | -------------------------------------------------------------------------------- /cadence/scripts/getAccounts.cdc: -------------------------------------------------------------------------------- 1 | import "FCL" 2 | 3 | access(all) fun main(): &[FCL.FCLAccount] { 4 | return FCL.accounts().values 5 | } 6 | -------------------------------------------------------------------------------- /cadence/transactions/fundFLOW.cdc: -------------------------------------------------------------------------------- 1 | import "FlowToken" 2 | import "FungibleToken" 3 | 4 | transaction(address: Address, amount: UFix64) { 5 | let tokenAdmin: &FlowToken.Administrator 6 | let tokenReceiver: &{FungibleToken.Receiver} 7 | 8 | prepare(signer: auth(Storage, Capabilities) &Account) { 9 | self.tokenAdmin = signer 10 | .storage 11 | .borrow<&FlowToken.Administrator>(from: /storage/flowTokenAdmin) 12 | ?? panic("Signer is not the token admin") 13 | 14 | self.tokenReceiver = getAccount(address) 15 | .capabilities 16 | .get<&{FungibleToken.Receiver}>(/public/flowTokenReceiver)! 17 | .borrow() 18 | ?? panic("Unable to borrow receiver reference") 19 | } 20 | 21 | execute { 22 | let minter <- self.tokenAdmin.createNewMinter(allowedAmount: amount) 23 | let mintedVault <- minter.mintTokens(amount: amount) 24 | 25 | self.tokenReceiver.deposit(from: <-mintedVault) 26 | 27 | destroy minter 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /cadence/transactions/init.cdc: -------------------------------------------------------------------------------- 1 | transaction(code: String, publicKey: String, hashAlgorithm: UInt8, signAlgorithm: UInt8, initAccountsLabels: [String]) { 2 | prepare(acct: auth(Contracts) &Account) { 3 | acct.contracts.add(name: "FCL", code: code.utf8, publicKey: publicKey, hashAlgorithm: hashAlgorithm, signAlgorithm: signAlgorithm, initAccountsLabels: initAccountsLabels) 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /cadence/transactions/newAccount.cdc: -------------------------------------------------------------------------------- 1 | import "FCL" 2 | import "FlowToken" 3 | import "FungibleToken" 4 | 5 | /// This transaction creates a new account and funds it with 6 | /// an initial balance of FLOW tokens. 7 | /// 8 | transaction(label: String, scopes: [String], initialBalance: UFix64) { 9 | 10 | let tokenAdmin: &FlowToken.Administrator 11 | let tokenReceiver: &{FungibleToken.Receiver} 12 | 13 | prepare(signer: auth(Storage) &Account) { 14 | let account = FCL.new(label: label, scopes: scopes, address: nil) 15 | 16 | self.tokenAdmin = signer 17 | .storage 18 | .borrow<&FlowToken.Administrator>(from: /storage/flowTokenAdmin) 19 | ?? panic("Signer is not the token admin") 20 | 21 | self.tokenReceiver = account 22 | .capabilities 23 | .get<&{FungibleToken.Receiver}>(/public/flowTokenReceiver)! 24 | .borrow() 25 | ?? panic("Unable to borrow receiver reference") 26 | } 27 | 28 | execute { 29 | let minter <- self.tokenAdmin.createNewMinter(allowedAmount: initialBalance) 30 | let mintedVault <- minter.mintTokens(amount: initialBalance) 31 | 32 | self.tokenReceiver.deposit(from: <- mintedVault) 33 | 34 | destroy minter 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /cadence/transactions/updateAccount.cdc: -------------------------------------------------------------------------------- 1 | import "FCL" 2 | 3 | transaction(address: Address, label: String, scopes: [String]) { 4 | prepare() { 5 | FCL.update(address: address, label: label, scopes: scopes) 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /components/AccountBalances.tsx: -------------------------------------------------------------------------------- 1 | /** @jsxImportSource theme-ui */ 2 | import * as fcl from "@onflow/fcl" 3 | import useAccount from "hooks/useAccount" 4 | import {fundAccount} from "src/accounts" 5 | import {formattedBalance} from "src/balance" 6 | import {FLOW_TYPE, TokenTypes} from "src/constants" 7 | import useConfig from "hooks/useConfig" 8 | import {Label, Themed} from "theme-ui" 9 | import {SXStyles} from "types" 10 | import AccountSectionHeading from "./AccountSectionHeading" 11 | import Button from "./Button" 12 | 13 | const styles: SXStyles = { 14 | label: {textTransform: "capitalize", margin: 0, marginRight: "auto"}, 15 | accountSection: { 16 | height: 50, 17 | display: "flex", 18 | alignItems: "center", 19 | justifyContent: "space-between", 20 | borderBottom: "1px solid", 21 | borderColor: "gray.200", 22 | }, 23 | balance: { 24 | fontSize: 1, 25 | }, 26 | fundButton: { 27 | fontWeight: "bold", 28 | fontSize: 0, 29 | color: "black", 30 | ml: 4, 31 | px: 30, 32 | py: "7px", 33 | borderColor: "gray.200", 34 | }, 35 | } 36 | 37 | export default function AccountBalances({ 38 | address, 39 | flowAccountAddress, 40 | }: { 41 | address: string 42 | flowAccountAddress: string 43 | }) { 44 | const {data: account, refresh: refreshAccount} = useAccount(address) 45 | const config = useConfig() 46 | 47 | const isServiceAccount = 48 | fcl.withPrefix(address) === fcl.withPrefix(flowAccountAddress) 49 | 50 | const fund = async (token: TokenTypes) => { 51 | await fundAccount(config, address, token) 52 | refreshAccount() 53 | } 54 | 55 | return ( 56 | <> 57 | Funds 58 | 59 |
60 | 61 |
62 | {!!account?.balance ? formattedBalance(account.balance) : "0"} 63 |
64 | {!isServiceAccount && ( 65 | 74 | )} 75 |
76 | 77 | ) 78 | } 79 | -------------------------------------------------------------------------------- /components/AccountForm.tsx: -------------------------------------------------------------------------------- 1 | /** @jsxImportSource theme-ui */ 2 | import AccountListItemScopes from "components/AccountListItemScopes" 3 | import ConnectedAppHeader from "components/ConnectedAppHeader" 4 | import {styles as dialogStyles} from "components/Dialog" 5 | import FormErrors from "components/FormErrors" 6 | import {Field, Form, Formik} from "formik" 7 | import {useState} from "react" 8 | import {Account, NewAccount, newAccount, updateAccount} from "src/accounts" 9 | import useConfig from "hooks/useConfig" 10 | import {updateAccountSchemaClient} from "src/validate" 11 | import {Box} from "theme-ui" 12 | import {SXStyles} from "types" 13 | import AccountBalances from "./AccountBalances" 14 | import AuthnActions from "./AuthnActions" 15 | import Button from "./Button" 16 | import {CustomInputComponent} from "./Inputs" 17 | 18 | const styles: SXStyles = { 19 | form: { 20 | position: "relative", 21 | }, 22 | backButton: { 23 | background: "none", 24 | border: 0, 25 | position: "absolute", 26 | top: 0, 27 | left: 0, 28 | cursor: "pointer", 29 | p: 0, 30 | zIndex: 10, 31 | }, 32 | } 33 | 34 | export default function AccountForm({ 35 | account, 36 | onSubmitComplete, 37 | onCancel, 38 | flowAccountAddress, 39 | avatarUrl, 40 | }: { 41 | account: Account | NewAccount 42 | onSubmitComplete: (createdAccountAddress?: string) => void 43 | onCancel: () => void 44 | flowAccountAddress: string 45 | avatarUrl: string 46 | }) { 47 | const [errors, setErrors] = useState([]) 48 | const config = useConfig() 49 | 50 | return ( 51 | (account.scopes), 55 | }} 56 | validationSchema={updateAccountSchemaClient} 57 | onSubmit={async ({label, scopes}, {setSubmitting}) => { 58 | setErrors([]) 59 | 60 | const scopesList = Array.from(scopes) as [string] 61 | 62 | try { 63 | if (account.address) { 64 | await updateAccount(config, account.address, label!, scopesList) 65 | 66 | setSubmitting(false) 67 | onSubmitComplete(undefined) 68 | } else { 69 | const address = await newAccount(config, label!, scopesList) 70 | 71 | setSubmitting(false) 72 | onSubmitComplete(address) 73 | } 74 | } catch (error) { 75 | throw error 76 | // TODO: Fix error string 77 | // setErrors([error]) 78 | setSubmitting(false) 79 | } 80 | }} 81 | > 82 | {({values, setFieldValue}) => ( 83 | <> 84 |
85 |
86 | 89 | 90 | 91 | 101 | 102 | 103 | 104 | 111 | 112 | 113 | {!!account.address && ( 114 | 115 | 119 | 120 | )} 121 | 122 | 123 | setFieldValue("scopes", newScopes)} 126 | compact={true} 127 | /> 128 | 129 | 130 | {errors.length > 0 && } 131 | 132 |
133 |
134 | 135 |
136 | 137 | )} 138 |
139 | ) 140 | } 141 | -------------------------------------------------------------------------------- /components/AccountImage.tsx: -------------------------------------------------------------------------------- 1 | /** @jsxImportSource theme-ui */ 2 | import * as fcl from "@onflow/fcl" 3 | import {Img} from "react-image" 4 | import {avatar} from "src/avatar" 5 | import {ThemeUICSSObject} from "theme-ui" 6 | 7 | interface Props { 8 | address?: string 9 | src?: string 10 | seed: string 11 | sxStyles?: ThemeUICSSObject 12 | lg?: boolean 13 | flowAccountAddress: string 14 | avatarUrl: string 15 | } 16 | 17 | const styles = { 18 | border: "1px solid", 19 | borderColor: "gray.200", 20 | backgroundColor: "white", 21 | display: "flex", 22 | alignItems: "center", 23 | overflow: "hidden", 24 | "> img": { 25 | width: "100%", 26 | }, 27 | } 28 | 29 | export default function AccountImage({ 30 | address = "", 31 | src, 32 | seed, 33 | sxStyles = {}, 34 | lg, 35 | flowAccountAddress, 36 | avatarUrl, 37 | }: Props) { 38 | const size = lg ? 65 : 50 39 | const prefixedAddress = fcl.withPrefix(address) 40 | const isServiceAccount = 41 | prefixedAddress === fcl.withPrefix(flowAccountAddress) 42 | const defaultSrc = isServiceAccount 43 | ? "/settings.svg" 44 | : avatar(avatarUrl, `${prefixedAddress}-${seed}`) 45 | 46 | return ( 47 |
57 | 58 |
59 | ) 60 | } 61 | -------------------------------------------------------------------------------- /components/AccountListItemScopes.tsx: -------------------------------------------------------------------------------- 1 | /** @jsxImportSource theme-ui */ 2 | import Switch from "components/Switch" 3 | import useAuthnContext from "hooks/useAuthnContext" 4 | import {Label, Themed} from "theme-ui" 5 | import {SXStyles} from "types" 6 | import AccountSectionHeading from "./AccountSectionHeading" 7 | 8 | const styles: SXStyles = { 9 | headingContainer: { 10 | display: "flex", 11 | alignItems: "center", 12 | justifyContent: "space-between", 13 | }, 14 | heading: { 15 | textTransform: "uppercase", 16 | color: "gray.400", 17 | fontWeight: "normal", 18 | fontSize: 0, 19 | letterSpacing: "0.05em", 20 | }, 21 | label: {textTransform: "capitalize", margin: 0}, 22 | scope: { 23 | height: 40, 24 | display: "flex", 25 | alignItems: "center", 26 | justifyContent: "space-between", 27 | borderBottom: "1px solid", 28 | borderColor: "gray.200", 29 | }, 30 | } 31 | 32 | export default function AccountListItemScopes({ 33 | scopes, 34 | setScopes, 35 | compact = false, 36 | }: { 37 | scopes: Set 38 | setScopes: (newScopes: Set) => void 39 | compact?: boolean 40 | }) { 41 | const {appScopes} = useAuthnContext() 42 | 43 | const toggleScope = (scope: string) => { 44 | scopes.has(scope) ? scopes.delete(scope) : scopes.add(scope) 45 | setScopes(scopes) 46 | } 47 | 48 | return ( 49 |
50 | 51 | {appScopes.length > 0 && "Scopes"} 52 | 53 | {appScopes.length > 0 && ( 54 | <> 55 | 58 | {appScopes.map(scope => ( 59 |
60 |
61 | 64 |
68 | toggleScope(scope)} 73 | aria-checked="true" 74 | /> 75 |
76 |
77 |
78 | ))} 79 | 80 | )} 81 |
82 | ) 83 | } 84 | -------------------------------------------------------------------------------- /components/AccountSectionHeading.tsx: -------------------------------------------------------------------------------- 1 | /** @jsxImportSource theme-ui */ 2 | import {SXStyles} from "types" 3 | 4 | const styles: SXStyles = { 5 | headingContainer: { 6 | display: "flex", 7 | alignItems: "center", 8 | justifyContent: "space-between", 9 | }, 10 | heading: { 11 | textTransform: "uppercase", 12 | color: "gray.400", 13 | fontWeight: "normal", 14 | fontSize: 0, 15 | letterSpacing: "0.05em", 16 | }, 17 | } 18 | 19 | export default function AccountSectionHeading({ 20 | compact, 21 | children, 22 | }: { 23 | compact?: boolean 24 | children: React.ReactNode 25 | }) { 26 | return ( 27 |
28 |
{children}
29 |
30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /components/AccountsList.tsx: -------------------------------------------------------------------------------- 1 | /** @jsxImportSource theme-ui */ 2 | import AccountsListItem from "components/AccountsListItem" 3 | import ConnectedAppHeader from "components/ConnectedAppHeader" 4 | import PlusButton from "components/PlusButton" 5 | import useAuthnContext from "hooks/useAuthnContext" 6 | import {Account, NewAccount} from "src/accounts" 7 | import accountGenerator from "src/accountGenerator" 8 | import {Box, Themed} from "theme-ui" 9 | import {SXStyles} from "types" 10 | import FormErrors from "./FormErrors" 11 | 12 | const styles: SXStyles = { 13 | accountCreated: { 14 | backgroundColor: "#00EF8B1A", 15 | border: "1px solid #00EF8B", 16 | textAlign: "center", 17 | py: 10, 18 | px: 3, 19 | mb: 3, 20 | }, 21 | plusButtonContainer: { 22 | height: 90, 23 | display: "flex", 24 | alignItems: "center", 25 | }, 26 | footer: { 27 | lineHeight: 1.7, 28 | color: "gray.400", 29 | fontSize: 0, 30 | }, 31 | } 32 | 33 | export default function AccountsList({ 34 | accounts, 35 | onEditAccount, 36 | createdAccountAddress, 37 | flowAccountAddress, 38 | flowAccountPrivateKey, 39 | avatarUrl, 40 | }: { 41 | accounts: Account[] 42 | onEditAccount: (account: Account | NewAccount) => void 43 | createdAccountAddress: string | null 44 | flowAccountAddress: string 45 | flowAccountPrivateKey: string 46 | avatarUrl: string 47 | }) { 48 | const {initError} = useAuthnContext() 49 | 50 | return ( 51 |
52 | 53 | 58 | 59 | {createdAccountAddress && ( 60 |
61 | Account Created 62 |
63 | )} 64 | {initError ? ( 65 | 66 | ) : ( 67 | <> 68 | 69 | {accounts.map(account => ( 70 | 79 | ))} 80 | 81 | 82 | 84 | onEditAccount(accountGenerator(accounts.length - 1)) 85 | } 86 | data-test="create-account-button" 87 | > 88 | Create New Account 89 | 90 | 91 | 92 | 93 | )} 94 |
95 | ) 96 | } 97 | -------------------------------------------------------------------------------- /components/AccountsListItem.tsx: -------------------------------------------------------------------------------- 1 | /** @jsxImportSource theme-ui */ 2 | import AccountImage from "components/AccountImage" 3 | import Button from "components/Button" 4 | import useAccount from "hooks/useAccount" 5 | import useAuthnContext from "hooks/useAuthnContext" 6 | import {Account, NewAccount} from "src/accounts" 7 | import {useEffect, useState} from "react" 8 | import {chooseAccount} from "src/accountAuth" 9 | import {formattedBalance} from "src/balance" 10 | import {Flex, Themed} from "theme-ui" 11 | import {SXStyles} from "types" 12 | import {getBaseUrl} from "src/utils" 13 | 14 | const styles: SXStyles = { 15 | accountListItem: { 16 | marginX: -15, 17 | paddingX: 15, 18 | height: 90, 19 | display: "flex", 20 | alignItems: "center", 21 | flex: 1, 22 | }, 23 | accountButtonContainer: { 24 | display: "flex", 25 | items: "center", 26 | alignItems: "center", 27 | justifyContent: "space-between", 28 | flex: 1, 29 | }, 30 | accountImage: { 31 | marginRight: 20, 32 | }, 33 | chooseAccountButton: { 34 | display: "flex", 35 | alignItems: "center", 36 | justifyContent: "flex-start", 37 | height: 66, 38 | paddingX: 0, 39 | color: "colors.black", 40 | background: "transparent", 41 | border: 0, 42 | textAlign: "left", 43 | fontSize: 2, 44 | marginRight: 10, 45 | textTransform: "initial", 46 | fontWeight: 600, 47 | lineHeight: "1.3rem", 48 | }, 49 | chooseAccountAddress: { 50 | fontSize: 0, 51 | color: "gray.600", 52 | }, 53 | chooseAccountFlow: { 54 | fontSize: 0, 55 | color: "gray.600", 56 | display: "flex", 57 | alignItems: "center", 58 | }, 59 | chooseAccountFlowLabel: { 60 | fontWeight: "normal", 61 | ml: 2, 62 | }, 63 | chooseAccountButtonText: { 64 | height: "100%", 65 | display: "flex", 66 | flexDirection: "column", 67 | justifyContent: "center", 68 | }, 69 | isNew: { 70 | textTransform: "uppercase", 71 | backgroundColor: "green", 72 | fontSize: 11, 73 | fontWeight: "bold", 74 | borderRadius: 14, 75 | position: "relative", 76 | top: "-2px", 77 | py: "1px", 78 | px: "7px", 79 | ml: 2, 80 | }, 81 | manageAccountButton: { 82 | margin: 0, 83 | padding: 0, 84 | fontSize: 1, 85 | fontWeight: "normal", 86 | }, 87 | } 88 | 89 | export default function AccountsListItem({ 90 | account, 91 | onEditAccount, 92 | isNew, 93 | flowAccountAddress, 94 | flowAccountPrivateKey, 95 | avatarUrl, 96 | }: { 97 | account: Account 98 | onEditAccount: (account: Account | NewAccount) => void 99 | isNew: boolean 100 | flowAccountAddress: string 101 | flowAccountPrivateKey: string 102 | avatarUrl: string 103 | }) { 104 | const {connectedAppConfig} = useAuthnContext() 105 | const { 106 | config: { 107 | app: {title}, 108 | }, 109 | } = connectedAppConfig 110 | const baseUrl = getBaseUrl() 111 | 112 | const [scopes, setScopes] = useState>(new Set(account.scopes)) 113 | const {data: accountData} = useAccount(account.address) 114 | 115 | useEffect(() => { 116 | setScopes(new Set(account.scopes)) 117 | }, [account.scopes]) 118 | 119 | const handleSelect = () => { 120 | chooseAccount( 121 | baseUrl, 122 | flowAccountPrivateKey, 123 | account, 124 | scopes, 125 | connectedAppConfig 126 | ).catch(e => 127 | // eslint-disable-next-line no-console 128 | console.error(e) 129 | ) 130 | } 131 | 132 | return ( 133 | <> 134 |
135 |
136 | 163 | 164 | 172 | 173 |
174 |
175 | 176 | 177 | ) 178 | } 179 | -------------------------------------------------------------------------------- /components/AuthnActions.tsx: -------------------------------------------------------------------------------- 1 | /** @jsxImportSource theme-ui */ 2 | import {useFormikContext} from "formik" 3 | import {Account, NewAccount} from "src/accounts" 4 | import {SXStyles} from "types" 5 | import Button from "./Button" 6 | 7 | const styles: SXStyles = { 8 | actionsContainer: { 9 | display: "flex", 10 | alignItems: "center", 11 | width: "100%", 12 | borderTop: "1px solid", 13 | borderColor: "gray.200", 14 | backgroundColor: "white", 15 | borderBottomLeftRadius: 10, 16 | borderBottomRightRadius: 10, 17 | px: [10, 20], 18 | }, 19 | actions: { 20 | display: "flex", 21 | flex: 1, 22 | pt: 20, 23 | pb: 20, 24 | }, 25 | } 26 | 27 | function AuthnActions({ 28 | account, 29 | onCancel, 30 | }: { 31 | account: Account | NewAccount 32 | onCancel: () => void 33 | }) { 34 | const {isSubmitting, isValid, submitForm} = useFormikContext() 35 | 36 | return ( 37 |
38 |
39 | 49 | 50 | 60 |
61 |
62 | ) 63 | } 64 | 65 | export default AuthnActions 66 | -------------------------------------------------------------------------------- /components/AuthzActions.tsx: -------------------------------------------------------------------------------- 1 | /** @jsxImportSource theme-ui */ 2 | import {SXStyles} from "types" 3 | import Button from "./Button" 4 | 5 | const styles: SXStyles = { 6 | actionsContainer: { 7 | display: "flex", 8 | alignItems: "center", 9 | width: "100%", 10 | borderTop: "1px solid", 11 | borderColor: "gray.200", 12 | backgroundColor: "white", 13 | borderBottomLeftRadius: 10, 14 | borderBottomRightRadius: 10, 15 | px: [10, 20], 16 | }, 17 | actions: { 18 | display: "flex", 19 | flex: 1, 20 | pt: 20, 21 | pb: 20, 22 | }, 23 | } 24 | 25 | function AuthzActions({ 26 | onApprove, 27 | onDecline, 28 | isLoading, 29 | }: { 30 | onApprove: () => void 31 | onDecline: () => void 32 | isLoading?: boolean 33 | }) { 34 | return ( 35 |
36 |
37 | 45 | 54 |
55 |
56 | ) 57 | } 58 | 59 | export default AuthzActions 60 | -------------------------------------------------------------------------------- /components/AuthzDetails.tsx: -------------------------------------------------------------------------------- 1 | /** @jsxImportSource theme-ui */ 2 | import useAuthzContext from "hooks/useAuthzContext" 3 | import {SXStyles} from "types" 4 | import AuthzDetailsTable, { 5 | AuthzDetailsAccount, 6 | AuthzDetailsRow, 7 | } from "./AuthzDetailsTable" 8 | import Code from "./Code" 9 | 10 | const styles: SXStyles = { 11 | container: { 12 | display: "flex", 13 | flexDirection: "column", 14 | flex: 1, 15 | height: "100%", 16 | mx: [-15, -30], 17 | }, 18 | wrappedValue: { 19 | width: [170, 200], 20 | overflowWrap: "break-word", 21 | textAlign: "right", 22 | ml: "auto", 23 | }, 24 | codeContainer: { 25 | mt: 3, 26 | mb: -20, 27 | }, 28 | } 29 | 30 | function AuthzDetails() { 31 | const { 32 | proposer, 33 | payer, 34 | authorizers, 35 | proposalKey, 36 | computeLimit, 37 | refBlock, 38 | args, 39 | cadence, 40 | } = useAuthzContext() 41 | return ( 42 |
43 |
51 | 52 | 53 | Proposer 54 | 55 | 56 | 57 | 58 | 59 | Payer 60 | 61 | 62 | 63 | 64 | 65 | Authorizers 66 | 67 | {authorizers 68 | .filter(authz => authz !== null) 69 | .map(authorizer => ( 70 | 74 | ))} 75 | 76 | 77 | 78 | Proposal Key 79 | 80 |
{proposalKey.keyId}
81 | 82 |
83 | 84 | Sequence # 85 | {proposalKey.sequenceNum} 86 | 87 | 88 | Gas Limit 89 | {computeLimit} 90 | 91 | 92 | Reference Block 93 | 94 |
95 | {refBlock} 96 |
97 | 98 |
99 |
100 |
101 | 102 | 103 |
104 |
105 |
106 | ) 107 | } 108 | 109 | export default AuthzDetails 110 | -------------------------------------------------------------------------------- /components/AuthzDetailsTable.tsx: -------------------------------------------------------------------------------- 1 | /** @jsxImportSource theme-ui */ 2 | import {alpha} from "@theme-ui/color" 3 | import useAuthzContext from "hooks/useAuthzContext" 4 | import {Account} from "src/accounts" 5 | import {SXStyles} from "types" 6 | 7 | const styles: SXStyles = { 8 | table: { 9 | fontSize: [0, 1], 10 | width: "100%", 11 | borderCollapse: "collapse", 12 | "word-break": "break-word", 13 | }, 14 | row: { 15 | "> td": { 16 | borderBottom: "1px solid", 17 | borderColor: alpha("gray.200", 0.7), 18 | verticalAlign: "top", 19 | py: 1, 20 | "&:first-of-type": { 21 | color: "gray.500", 22 | }, 23 | "&:last-of-type": { 24 | textAlign: "right", 25 | fontFamily: "monospace", 26 | letterSpacing: "0.16em", 27 | }, 28 | }, 29 | "&:last-of-type": { 30 | "> td": { 31 | border: 0, 32 | }, 33 | }, 34 | }, 35 | accountDetail: { 36 | display: "flex", 37 | alignItems: ["flex-end", "center"], 38 | justifyContent: "flex-end", 39 | flexDirection: ["column", "row"], 40 | }, 41 | accountDetailLabel: { 42 | textTransform: "uppercase", 43 | fontFamily: "sans-serif", 44 | fontSize: "0.625rem", 45 | letterSpacing: "initial", 46 | color: "gray.300", 47 | border: "1px solid", 48 | borderColor: "gray.200", 49 | borderRadius: 20, 50 | px: 2, 51 | }, 52 | accountAddress: { 53 | ml: 2, 54 | }, 55 | accountDetailLabelCurrent: { 56 | backgroundColor: "primary", 57 | borderColor: "primary", 58 | color: "black", 59 | fontWeight: 700, 60 | }, 61 | } 62 | 63 | export function AuthzDetailsAccount({account}: {account: Account}) { 64 | const {currentUser} = useAuthzContext() 65 | const isCurrent = account.address === currentUser.address 66 | return ( 67 |
68 |
74 | {account.label} 75 |
76 |
{account.address}
77 |
78 | ) 79 | } 80 | 81 | export function AuthzDetailsRow({children}: {children: React.ReactNode}) { 82 | return {children} 83 | } 84 | 85 | function AuthzDetailsTable({children}: {children: React.ReactNode}) { 86 | return ( 87 | 88 | 89 | 90 | 93 | 94 | {children} 95 |
91 | 92 |
96 | ) 97 | } 98 | 99 | export default AuthzDetailsTable 100 | -------------------------------------------------------------------------------- /components/AuthzHeader.tsx: -------------------------------------------------------------------------------- 1 | /** @jsxImportSource theme-ui */ 2 | import useAuthzContext from "hooks/useAuthzContext" 3 | import {UNTITLED_APP_NAME} from "src/constants" 4 | import {Label} from "theme-ui" 5 | import {SXStyles} from "types" 6 | import AccountImage from "./AccountImage" 7 | import ConnectedAppIcon from "./ConnectedAppIcon" 8 | 9 | const styles: SXStyles = { 10 | header: { 11 | position: "relative", 12 | display: "flex", 13 | alignItems: "center", 14 | justifyContent: "center", 15 | pt: 4, 16 | }, 17 | headerSection: { 18 | display: "flex", 19 | flexDirection: "column", 20 | alignItems: "center", 21 | justifyContent: "center", 22 | width: "50%", 23 | px: [0, 3], 24 | zIndex: 1, 25 | }, 26 | label: { 27 | position: "relative", 28 | width: "auto", 29 | display: "flex", 30 | alignItems: "center", 31 | }, 32 | greenDot: { 33 | position: "absolute", 34 | mt: "-2px", 35 | left: -15, 36 | backgroundColor: "primary", 37 | width: 7, 38 | height: 7, 39 | borderRadius: 7, 40 | }, 41 | transactionIcon: { 42 | position: "absolute", 43 | top: 45, 44 | }, 45 | } 46 | 47 | function AuthzHeader({ 48 | flowAccountAddress, 49 | avatarUrl, 50 | }: { 51 | flowAccountAddress: string 52 | avatarUrl: string 53 | }) { 54 | const {currentUser, connectedAppConfig} = useAuthzContext() 55 | const title = connectedAppConfig?.config?.app?.title || UNTITLED_APP_NAME 56 | const icon = connectedAppConfig?.config?.app?.icon 57 | return ( 58 |
59 |
60 | 61 |
62 |
63 | 70 |
80 | ) 81 | } 82 | 83 | export default AuthzHeader 84 | -------------------------------------------------------------------------------- /components/Button.tsx: -------------------------------------------------------------------------------- 1 | /** @jsxImportSource theme-ui */ 2 | import useVariants from "hooks/useVariants" 3 | import {useRef} from "react" 4 | import {Button as ThemeUIButton, Link, ThemeUICSSObject} from "theme-ui" 5 | 6 | type Props = { 7 | variant?: "primary" | "secondary" | "ghost" | "link" | "unstyled" 8 | size?: "xs" | "sm" | "md" | "lg" 9 | block?: boolean 10 | sx?: ThemeUICSSObject 11 | disabled?: boolean 12 | type?: "submit" | "button" | "reset" 13 | href?: string 14 | target?: "_blank" 15 | onClick?: (event: React.MouseEvent) => void 16 | children: React.ReactNode 17 | } 18 | 19 | const Button = ({ 20 | variant = "primary", 21 | size = "md", 22 | block = false, 23 | sx = {}, 24 | disabled, 25 | href, 26 | target, 27 | ...props 28 | }: Props) => { 29 | const ref = useRef(null) 30 | const variants = useVariants([ 31 | `buttons.${disabled ? "disabled" : variant}`, 32 | `buttons.sizes.${size}`, 33 | `${variant === "unstyled" ? "buttons.unstyled" : ""}`, 34 | ]) 35 | 36 | const style: ThemeUICSSObject = { 37 | display: "inline-flex", 38 | cursor: "pointer", 39 | textTransform: "uppercase", 40 | alignItems: "center", 41 | justifyContent: "center", 42 | width: block ? "100%" : "auto", 43 | m: 0, 44 | border: 0, 45 | borderRadius: 4, 46 | textDecoration: "none", 47 | fontFamily: "body", 48 | "&:hover": { 49 | opacity: 0.8, 50 | }, 51 | ...variants, 52 | ...sx, 53 | } 54 | 55 | if (!!href) { 56 | return ( 57 | 58 | {props.children} 59 | 60 | ) 61 | } 62 | 63 | return ( 64 | 65 | {props.children} 66 | 67 | ) 68 | } 69 | 70 | export default Button 71 | -------------------------------------------------------------------------------- /components/CaretIcon.tsx: -------------------------------------------------------------------------------- 1 | import useThemeUI from "hooks/useThemeUI" 2 | import React from "react" 3 | 4 | function CaretIcon({up, active}: {up: boolean; active: boolean}) { 5 | const {theme} = useThemeUI() 6 | const d = up 7 | ? "M8.825 6.842L5 3.025 1.175 6.842 0 5.667l5-5 5 5-1.175 1.175z" 8 | : "M8.825.158L5 3.975 1.175.158 0 1.333l5 5 5-5L8.825.158z" 9 | return ( 10 | 17 | 23 | 24 | ) 25 | } 26 | 27 | export default CaretIcon 28 | -------------------------------------------------------------------------------- /components/Code.tsx: -------------------------------------------------------------------------------- 1 | /** @jsxImportSource theme-ui */ 2 | import {useMonaco} from "@monaco-editor/react" 3 | import useAuthzContext from "hooks/useAuthzContext" 4 | import {useEffect, useState} from "react" 5 | import configureCadence, {CADENCE_LANGUAGE_ID} from "src/cadence" 6 | import {SXStyles} from "types" 7 | import ExpandCollapseButton from "./ExpandCollapseButton" 8 | 9 | const styles: SXStyles = { 10 | codeContainer: { 11 | mx: [-15, -30], 12 | }, 13 | block: { 14 | backgroundColor: "gray.100", 15 | overflow: "hidden", 16 | cursor: "pointer", 17 | py: 15, 18 | px: [15, 30], 19 | }, 20 | blockExpanded: { 21 | backgroundColor: "white", 22 | maxHeight: "initial", 23 | cursor: "inherit", 24 | py: 0, 25 | px: 0, 26 | borderBottomRightRadius: 8, 27 | borderBottomLeftRadius: 8, 28 | code: { 29 | "> span:before": { 30 | display: "inline-block", 31 | }, 32 | }, 33 | }, 34 | code: { 35 | display: "flex", 36 | flexDirection: "column", 37 | whiteSpace: "pre", 38 | overflow: "hidden", 39 | "> br": { 40 | display: "none", 41 | }, 42 | "> span": { 43 | counterIncrement: "line", 44 | "&:first-of-type:before": { 45 | pt: 2, 46 | }, 47 | "&:last-of-type:before": { 48 | pb: 2, 49 | }, 50 | "&:before": { 51 | display: "none", 52 | backgroundColor: "gray.100", 53 | borderRight: "1px solid", 54 | borderColor: "gray.300", 55 | content: "counter(line)", 56 | color: "gray.500", 57 | textAlign: "center", 58 | mr: 3, 59 | px: "2px", 60 | minWidth: 20, 61 | }, 62 | }, 63 | }, 64 | header: { 65 | height: 30, 66 | px: [15, 30], 67 | display: "flex", 68 | alignItems: "center", 69 | justifyContent: "space-between", 70 | borderTop: "1px solid", 71 | borderBottom: "1px solid", 72 | borderColor: "gray.200", 73 | }, 74 | headerTitle: { 75 | color: "gray.300", 76 | textTransform: "uppercase", 77 | fontSize: 0, 78 | }, 79 | largeExpandButton: { 80 | background: "transparent", 81 | border: 0, 82 | padding: 0, 83 | display: "block", 84 | minWidth: "100%", 85 | textAlign: "initial", 86 | }, 87 | } 88 | 89 | function CodeBlock({ 90 | isExpanded, 91 | colorizedSafeHtml, 92 | }: { 93 | isExpanded: boolean 94 | colorizedSafeHtml: string 95 | }) { 96 | return ( 97 |
103 | {colorizedSafeHtml.length > 0 && ( 104 | 112 | )} 113 |
114 | ) 115 | } 116 | 117 | export default function Code({title, value}: {title: string; value: string}) { 118 | const monaco = useMonaco() 119 | const [colorizedSafeHtml, setColorizedHtml] = useState("") 120 | const {isExpanded, setCodePreview} = useAuthzContext() 121 | 122 | useEffect(() => { 123 | if (monaco) { 124 | configureCadence(monaco) 125 | monaco.editor 126 | .colorize(value, CADENCE_LANGUAGE_ID, {tabSize: 2}) 127 | .then(setColorizedHtml) 128 | } 129 | }, [monaco, value]) 130 | 131 | const expand = () => setCodePreview({title, value}) 132 | 133 | return ( 134 |
135 |
141 |
{title}
142 | {!isExpanded && ( 143 |
144 | 145 |
146 | )} 147 |
148 | 149 | {isExpanded ? ( 150 | 154 | ) : ( 155 | 161 | )} 162 |
163 | ) 164 | } 165 | -------------------------------------------------------------------------------- /components/ConnectedAppHeader.tsx: -------------------------------------------------------------------------------- 1 | /** @jsxImportSource theme-ui */ 2 | import AccountImage from "components/AccountImage" 3 | import InfoIcon from "components/InfoIcon" 4 | import useAuthnContext from "hooks/useAuthnContext" 5 | import {Account, NewAccount} from "src/accounts" 6 | import {useState} from "react" 7 | import {UNTITLED_APP_NAME} from "src/constants" 8 | import {Button, Link, Themed} from "theme-ui" 9 | import {SXStyles} from "types" 10 | import ConnectedAppIcon from "./ConnectedAppIcon" 11 | 12 | const styles: SXStyles = { 13 | container: { 14 | textAlign: "center", 15 | position: "relative", 16 | }, 17 | info: { 18 | textAlign: "left", 19 | fontSize: 1, 20 | color: "textMedium", 21 | marginTop: -2, 22 | }, 23 | infoLabel: { 24 | opacity: 0.8, 25 | }, 26 | imageContainer: { 27 | width: 65, 28 | height: 65, 29 | display: "flex", 30 | alignItems: "center", 31 | justifyContent: "center", 32 | overflow: "hidden", 33 | margin: "0 auto", 34 | }, 35 | title: { 36 | marginTop: 3, 37 | marginBottom: 2, 38 | }, 39 | image: { 40 | borderRadius: 65, 41 | width: 65, 42 | }, 43 | description: { 44 | maxWidth: 340, 45 | margin: "0 auto", 46 | }, 47 | externalAddressLink: {ml: 1, color: "blue", fontSize: 1}, 48 | externalLinkImage: { 49 | ml: 2, 50 | position: "relative", 51 | top: "1px", 52 | }, 53 | infoButton: { 54 | position: "absolute", 55 | right: 0, 56 | display: "flex", 57 | alignItems: "center", 58 | justifyContent: "center", 59 | }, 60 | missingAppDetail: { 61 | color: "red.200", 62 | display: "inline-flex", 63 | alignItems: "center", 64 | }, 65 | } 66 | 67 | function MissingAppDetail({text}: {text: string}) { 68 | return ( 69 |
70 | {text} 71 | 76 | Learn More 77 | 78 |
79 | ) 80 | } 81 | 82 | export default function ConnectedAppHeader({ 83 | title, 84 | description, 85 | info = true, 86 | account, 87 | flowAccountAddress, 88 | avatarUrl, 89 | }: { 90 | title?: string 91 | description?: string 92 | info?: boolean 93 | account?: Account | NewAccount 94 | flowAccountAddress: string 95 | avatarUrl: string 96 | }) { 97 | const [showInfo, setShowInfo] = useState(false) 98 | const { 99 | connectedAppConfig: { 100 | config: { 101 | app: {icon, title: connectedAppTitle}, 102 | }, 103 | }, 104 | } = useAuthnContext() 105 | const toggleShowInfo = () => setShowInfo(prev => !prev) 106 | 107 | return ( 108 |
109 | {info && ( 110 |
111 | {showInfo && ( 112 |
113 |
114 | app.detail.icon:{" "} 115 | {icon || ( 116 | 117 | )} 118 |
119 |
120 | app.detail.title:{" "} 121 | {connectedAppTitle || ( 122 | 123 | )} 124 |
125 | 126 |
127 | )} 128 |
129 | )} 130 |
131 | {info && ( 132 | 141 | )} 142 |
143 | {account?.address ? ( 144 | 151 | ) : ( 152 | 153 | )} 154 |
155 | 156 | {title || connectedAppTitle || UNTITLED_APP_NAME} 157 | 158 | {!!description && ( 159 | {description} 160 | )} 161 |
162 |
163 | ) 164 | } 165 | -------------------------------------------------------------------------------- /components/ConnectedAppIcon.tsx: -------------------------------------------------------------------------------- 1 | /** @jsxImportSource theme-ui */ 2 | 3 | const styles = { 4 | image: { 5 | borderRadius: 65, 6 | width: 65, 7 | }, 8 | } 9 | 10 | export default function ConnectedAppIcon({icon}: {icon?: string}) { 11 | const appIcon = icon || "/missing-app-icon.svg" 12 | return ( 13 | 18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /components/Dialog.tsx: -------------------------------------------------------------------------------- 1 | /** @jsxImportSource theme-ui */ 2 | import {Dialog as HUIDialog} from "@headlessui/react" 3 | import {WalletUtils} from "@onflow/fcl" 4 | import useAuthzContext from "hooks/useAuthzContext" 5 | import {useRef} from "react" 6 | import {Box, Button} from "theme-ui" 7 | import {SXStyles} from "types" 8 | import ExpandCollapseButton from "./ExpandCollapseButton" 9 | import {getBaseUrl, isBackchannel, updatePollingSession} from "src/utils" 10 | 11 | export const styles: SXStyles = { 12 | dialog: { 13 | width: ["100%", 500], 14 | height: "90vh", 15 | margin: "0 auto", 16 | backgroundColor: "white", 17 | boxShadow: "0px 4px 74px 0px #00000026", 18 | borderRadius: 8, 19 | display: "flex", 20 | flexDirection: "column", 21 | }, 22 | dialogExpanded: { 23 | width: ["100%", "100%", "100%", 950], 24 | minHeight: "auto", 25 | }, 26 | header: { 27 | position: "sticky", 28 | zIndex: 1, 29 | top: 0, 30 | }, 31 | topHeader: { 32 | display: "flex", 33 | alignItems: "center", 34 | justifyContent: "space-between", 35 | minHeight: 52, 36 | pl: 3, 37 | pr: 1, 38 | borderBottom: "1px solid", 39 | borderColor: "gray.200", 40 | backgroundColor: "white", 41 | borderTopLeftRadius: 8, 42 | borderTopRightRadius: 8, 43 | }, 44 | footer: { 45 | position: "sticky", 46 | zIndex: 1, 47 | bottom: 0, 48 | }, 49 | logo: { 50 | display: "flex", 51 | alignItems: "center", 52 | justifyContent: "center", 53 | }, 54 | logoText: { 55 | ml: 1, 56 | fontSize: 1, 57 | color: "gray.500", 58 | marginTop: 1, 59 | }, 60 | closeButton: { 61 | width: 30, 62 | height: 30, 63 | display: "flex", 64 | alignItems: "center", 65 | justifyContent: "center", 66 | }, 67 | "closeButton:hover": { 68 | opacity: 0.5, 69 | }, 70 | body: { 71 | pt: 40, 72 | px: [15, 30], 73 | pb: 0, 74 | display: "flex", 75 | flexDirection: "column", 76 | flex: 1, 77 | overflowY: "auto", 78 | }, 79 | } 80 | 81 | export default function Dialog({ 82 | title, 83 | header, 84 | footer, 85 | root, 86 | children, 87 | }: { 88 | title?: string 89 | header?: React.ReactNode 90 | footer?: React.ReactNode 91 | root?: boolean 92 | children: React.ReactNode 93 | }) { 94 | const baseUrl = getBaseUrl() 95 | const closeButtonRef = useRef(null) 96 | const onClose = () => { 97 | const declineResponse = { 98 | f_type: "PollingResponse", 99 | f_vsn: "1.0.0", 100 | status: "DECLINED", 101 | reason: "User declined", 102 | data: null, 103 | } 104 | 105 | if (isBackchannel()) { 106 | updatePollingSession(baseUrl, declineResponse) 107 | } else { 108 | WalletUtils.sendMsgToFCL("FCL:VIEW:RESPONSE", declineResponse) 109 | } 110 | } 111 | const {isExpanded, setCodePreview} = useAuthzContext() 112 | 113 | return ( 114 | // @ts-expect-error The headless-ui dialog raises a "Expression produces a union type that is too complex to represent" error when used with theme-ui sx props 115 | // See https://github.com/tailwindlabs/headlessui/issues/233, https://github.com/tailwindlabs/headlessui/issues/330 116 | 123 | 124 | 125 |
126 |
127 |
128 | 129 | 130 | {["FCL Dev Wallet", title].filter(Boolean).join(" - ")} 131 | 132 |
133 | {isExpanded ? ( 134 | 135 | setCodePreview(null)} /> 136 | 137 | ) : ( 138 | 146 | )} 147 |
148 | {header} 149 |
150 | 151 | {root ? ( 152 | children 153 | ) : ( 154 | <> 155 |
162 | {children} 163 |
164 |
{footer}
165 | 166 | )} 167 |
168 | ) 169 | } 170 | -------------------------------------------------------------------------------- /components/ExpandCollapseButton.tsx: -------------------------------------------------------------------------------- 1 | /** @jsxImportSource theme-ui */ 2 | import useAuthzContext from "hooks/useAuthzContext" 3 | import {Flex} from "theme-ui" 4 | import Button from "./Button" 5 | 6 | export default function ExpandCollapseButton({onClick}: {onClick: () => void}) { 7 | const {isExpanded} = useAuthzContext() 8 | 9 | return ( 10 | 21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /components/FormErrors.tsx: -------------------------------------------------------------------------------- 1 | /** @jsxImportSource theme-ui */ 2 | import {Box} from "theme-ui" 3 | 4 | export const FieldError = ({children}: {children: React.ReactNode}) => { 5 | const style = { 6 | border: "1px solid", 7 | borderColor: "red.200", 8 | backgroundColor: "red.100", 9 | borderBottomLeftRadius: 4, 10 | borderBottomRightRadius: 4, 11 | color: "red.200", 12 | marginTop: "-1px", 13 | px: "20px", 14 | py: 3, 15 | "> a": { 16 | color: "red.200", 17 | }, 18 | } 19 | 20 | return
{children}
21 | } 22 | 23 | export default function FormErrors({errors}: {errors: string[]}) { 24 | return ( 25 | 26 | {errors.join(". ")} 27 | 28 | ) 29 | } 30 | -------------------------------------------------------------------------------- /components/InfoIcon.tsx: -------------------------------------------------------------------------------- 1 | import useThemeUI from "hooks/useThemeUI" 2 | import React from "react" 3 | 4 | function InfoIcon({active}: {active: boolean}) { 5 | const {theme} = useThemeUI() 6 | return ( 7 | 14 | 20 | 21 | ) 22 | } 23 | 24 | export default InfoIcon 25 | -------------------------------------------------------------------------------- /components/Inputs.tsx: -------------------------------------------------------------------------------- 1 | /** @jsxImportSource theme-ui */ 2 | import {FieldError} from "components/FormErrors" 3 | import Label from "components/Label" 4 | import {FieldProps} from "formik" 5 | import {Input, ThemeUICSSObject, Theme} from "theme-ui" 6 | 7 | type CustomFieldProps = FieldProps & { 8 | inputLabel: string 9 | disabled?: boolean 10 | required?: boolean 11 | sx?: ThemeUICSSObject 12 | } 13 | 14 | const errorInputStyles = { 15 | border: "1px solid", 16 | borderColor: "red.200", 17 | color: "red.200", 18 | outlineColor: "red.200", 19 | borderBottomLeftRadius: 0, 20 | borderBottomRightRadius: 0, 21 | "&:focus, &:focus-visible": { 22 | outline: "none", 23 | boxShadow: (theme: Theme) => 24 | `inset 0 0 0 1pt ${theme.colors?.red ? ["200"] : ""}`, 25 | borderBottomLeftRadius: 0, 26 | borderBottomRightRadius: 0, 27 | }, 28 | } 29 | 30 | export const CustomInputComponent = ({ 31 | field, 32 | form: {touched, errors}, 33 | inputLabel, 34 | required = false, 35 | sx = {}, 36 | ...props 37 | }: CustomFieldProps) => { 38 | const showError = touched[field.name] && errors[field.name] 39 | 40 | return ( 41 | <> 42 | 43 | 49 | {showError && {errors[field.name]}} 50 | 51 | ) 52 | } 53 | -------------------------------------------------------------------------------- /components/Label.tsx: -------------------------------------------------------------------------------- 1 | /** @jsxImportSource theme-ui */ 2 | import {Label as ThemeUILabel, LabelProps} from "theme-ui" 3 | 4 | type Props = { 5 | required?: boolean 6 | children: React.ReactNode 7 | } & LabelProps 8 | 9 | const styles = { 10 | required: { 11 | color: "red.200", 12 | fontWeight: "normal", 13 | }, 14 | } 15 | 16 | const Label = ({required = false, children, ...props}: Props) => { 17 | return ( 18 | 19 | {children} 20 | {required &&
*
} 21 |
22 | ) 23 | } 24 | export default Label 25 | -------------------------------------------------------------------------------- /components/PlusButton.tsx: -------------------------------------------------------------------------------- 1 | /** @jsxImportSource theme-ui */ 2 | import Button from "components/Button" 3 | import React from "react" 4 | import {SXStyles} from "types" 5 | 6 | const styles: SXStyles = { 7 | button: { 8 | textTransform: "none", 9 | paddingLeft: 0, 10 | justifyContent: "flex-start", 11 | backgroundColor: "transparent", 12 | fontWeight: 600, 13 | }, 14 | icon: { 15 | backgroundColor: "green", 16 | display: "flex", 17 | alignItems: "center", 18 | justifyContent: "center", 19 | width: 50, 20 | height: 50, 21 | borderRadius: 50, 22 | marginRight: 3, 23 | fontFamily: "inherit", 24 | fontWeight: "normal", 25 | }, 26 | } 27 | 28 | export default function PlusButton({ 29 | onClick, 30 | disabled, 31 | children, 32 | }: { 33 | onClick?: (event: React.MouseEvent) => void 34 | disabled?: boolean 35 | children: React.ReactNode 36 | }) { 37 | return ( 38 | 52 | ) 53 | } 54 | -------------------------------------------------------------------------------- /components/Spinner.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import styled from "@emotion/styled" 3 | 4 | // https://gist.github.com/knowbody/578b35164b69e867ed4913423f6bed30 5 | export const Spinner = () => ( 6 | 7 | 15 | 16 | ) 17 | 18 | const Svg = styled.svg` 19 | animation: rotate 2s linear infinite; 20 | margin: -25px 0 0 -25px; 21 | width: 50px; 22 | height: 50px; 23 | 24 | & .path { 25 | stroke: ${props => props.theme.colors.primary}; 26 | stroke-linecap: round; 27 | animation: dash 1.5s ease-in-out infinite; 28 | } 29 | 30 | @keyframes rotate { 31 | 100% { 32 | transform: rotate(360deg); 33 | } 34 | } 35 | @keyframes dash { 36 | 0% { 37 | stroke-dasharray: 1, 150; 38 | stroke-dashoffset: 0; 39 | } 40 | 50% { 41 | stroke-dasharray: 90, 150; 42 | stroke-dashoffset: -35; 43 | } 44 | 100% { 45 | stroke-dasharray: 90, 150; 46 | stroke-dashoffset: -124; 47 | } 48 | } 49 | ` 50 | -------------------------------------------------------------------------------- /components/Switch.tsx: -------------------------------------------------------------------------------- 1 | /** @jsxImportSource theme-ui */ 2 | import useVariants from "hooks/useVariants" 3 | import {Switch as ThemeUISwitch, ThemeUICSSObject} from "theme-ui" 4 | 5 | type Props = { 6 | onClick?: (event: React.MouseEvent) => void 7 | defaultChecked?: boolean 8 | variant?: "primary" 9 | size?: "lg" 10 | sx?: ThemeUICSSObject 11 | id: string 12 | } 13 | 14 | const Switch = ({ 15 | onClick, 16 | defaultChecked = false, 17 | variant = "primary", 18 | size = "lg", 19 | sx = {}, 20 | id = "", 21 | }: Props) => { 22 | const variants = useVariants([ 23 | `forms.switch.${variant}`, 24 | `forms.switch.sizes.${size}`, 25 | ]) 26 | return ( 27 | 34 | ) 35 | } 36 | 37 | export default Switch 38 | -------------------------------------------------------------------------------- /contexts/AuthnContext.tsx: -------------------------------------------------------------------------------- 1 | import useConfig from "hooks/useConfig" 2 | import useConnectedAppConfig, { 3 | ConnectedAppConfig, 4 | } from "hooks/useConnectedAppConfig" 5 | import React, {createContext, useEffect, useState} from "react" 6 | import {initializeWallet} from "src/init" 7 | import {Err} from "../src/comps/err.comp" 8 | import {Spinner} from "../components/Spinner" 9 | 10 | type AuthnContextType = { 11 | connectedAppConfig: ConnectedAppConfig 12 | appScopes: string[] 13 | initError: string | null 14 | } 15 | 16 | export const AuthnContext = createContext({ 17 | connectedAppConfig: {} as ConnectedAppConfig, 18 | appScopes: [], 19 | initError: null, 20 | }) 21 | 22 | export function AuthnContextProvider({children}: {children: React.ReactNode}) { 23 | const [isInitialized, setIsInitialized] = useState(false) 24 | const [error, setError] = useState(null) 25 | const {connectedAppConfig, appScopes} = useConnectedAppConfig() 26 | const config = useConfig() 27 | const isLoading = !isInitialized || !connectedAppConfig 28 | 29 | useEffect(() => { 30 | async function initialize() { 31 | try { 32 | await initializeWallet(config) 33 | setIsInitialized(true) 34 | } catch (error) { 35 | setError(`Dev wallet initialization failed: ${error}`) 36 | } 37 | } 38 | 39 | initialize() 40 | }, []) 41 | 42 | if (error) return 43 | if (isLoading) return 44 | 45 | const value = {connectedAppConfig, appScopes, initError: error} 46 | 47 | return {children} 48 | } 49 | -------------------------------------------------------------------------------- /contexts/AuthnRefreshContext.tsx: -------------------------------------------------------------------------------- 1 | import React, {createContext, useEffect, useState} from "react" 2 | import {parseScopes} from "src/scopes" 3 | import {useFclData} from "hooks/useFclData" 4 | 5 | type AuthnRefreshContextType = { 6 | address: string 7 | keyId: number 8 | scopes: Set 9 | nonce: string | undefined 10 | appIdentifier: string | undefined 11 | } | null 12 | 13 | export const AuthnRefreshContext = createContext(null) 14 | 15 | export function AuthnRefreshContextProvider({ 16 | children, 17 | }: { 18 | children: React.ReactNode 19 | }) { 20 | const fclData: any = useFclData() 21 | const [value, setValue] = useState(null) 22 | 23 | useEffect(() => { 24 | if (fclData) { 25 | const {timestamp, appDomainTag} = fclData.body 26 | 27 | const service = fclData.service 28 | const address = service?.data?.address 29 | const keyId = service?.data?.keyId 30 | const scopes = new Set(parseScopes(service?.params?.scopes)) 31 | 32 | setValue({ 33 | address, 34 | keyId, 35 | scopes, 36 | timestamp, 37 | appDomainTag, 38 | }) 39 | } 40 | }, [fclData]) 41 | 42 | return ( 43 | 44 | {children} 45 | 46 | ) 47 | } 48 | -------------------------------------------------------------------------------- /contexts/AuthzContext.tsx: -------------------------------------------------------------------------------- 1 | import * as fcl from "@onflow/fcl" 2 | import useAccounts from "hooks/useAccounts" 3 | import {ConnectedAppConfig} from "hooks/useConnectedAppConfig" 4 | import {Account} from "src/accounts" 5 | import React, {createContext, useMemo, useState} from "react" 6 | import {useFclData} from "hooks/useFclData" 7 | 8 | type AuthzReadyData = { 9 | type: string 10 | body: AuthSignable 11 | service: Record 12 | config: { 13 | services: {"OpenID.scopes": string} 14 | app: { 15 | icon: string 16 | title: string 17 | } 18 | } 19 | } 20 | 21 | export type ProposalKey = { 22 | address: string 23 | keyId: number 24 | sequenceNum: number 25 | } 26 | 27 | type AuthSignable = { 28 | addr: string 29 | args: string[] 30 | cadence: string 31 | f_type: "Signable" 32 | f_vsn: string 33 | keyId: number 34 | message: string 35 | roles: Record 36 | interaction: unknown 37 | voucher: { 38 | arguments: string[] 39 | authorizers: string[] 40 | cadence: string 41 | computeLimit: number 42 | payer: string 43 | payloadSigs: string[] 44 | proposalKey: ProposalKey 45 | refBlock: string 46 | } 47 | } 48 | 49 | type CodePreview = { 50 | title: string 51 | value: string 52 | } 53 | 54 | type AuthzContextType = { 55 | currentUser: Account 56 | proposer: Account 57 | payer: Account 58 | authorizers: Account[] 59 | roles: Record 60 | proposalKey: ProposalKey 61 | args: string[] 62 | cadence: string 63 | computeLimit: number 64 | refBlock: string 65 | message: string 66 | codePreview: CodePreview | null 67 | setCodePreview: React.Dispatch> 68 | isExpanded: boolean 69 | connectedAppConfig: ConnectedAppConfig | undefined 70 | } 71 | 72 | export const AuthzContext = createContext({ 73 | currentUser: {} as Account, 74 | proposer: {} as Account, 75 | payer: {} as Account, 76 | authorizers: [], 77 | roles: {}, 78 | proposalKey: { 79 | address: "", 80 | keyId: 0, 81 | sequenceNum: 0, 82 | }, 83 | args: [], 84 | cadence: "", 85 | computeLimit: 0, 86 | refBlock: "", 87 | message: "", 88 | codePreview: null, 89 | setCodePreview: () => null, 90 | isExpanded: false, 91 | connectedAppConfig: undefined, 92 | }) 93 | 94 | export function AuthzContextProvider({children}: {children: React.ReactNode}) { 95 | const signable = useFclData({ 96 | transformFrontchannel: (data: AuthzReadyData) => { 97 | return data.body 98 | }, 99 | }) 100 | const [codePreview, setCodePreview] = useState(null) 101 | 102 | const {data: accountsData} = useAccounts() 103 | 104 | const accounts = useMemo(() => { 105 | if (!accountsData) return {} 106 | const hash: Record = {} 107 | accountsData.forEach(account => (hash[account.address] = account)) 108 | return hash 109 | }, [accountsData]) 110 | 111 | if (!signable || Object.entries(accounts).length === 0) return null 112 | 113 | const {addr: currentUserAddress, voucher, roles, message} = signable 114 | const savedConnectedAppConfig = localStorage.getItem("connectedAppConfig") 115 | 116 | const value = { 117 | currentUser: accounts[fcl.withPrefix(currentUserAddress)], 118 | proposer: accounts[fcl.withPrefix(voucher.proposalKey.address)], 119 | payer: accounts[fcl.withPrefix(voucher.payer)], 120 | authorizers: voucher.authorizers.map(authorizer => 121 | accounts[fcl.withPrefix(authorizer)] 122 | ? accounts[fcl.withPrefix(authorizer)] 123 | : ({ 124 | address: authorizer, 125 | keyId: 0, 126 | label: "Outside Account", 127 | scopes: [], 128 | type: "ACCOUNT", 129 | } as Account) 130 | ), 131 | roles, 132 | proposalKey: voucher.proposalKey, 133 | args: voucher.arguments, 134 | cadence: voucher.cadence, 135 | computeLimit: voucher.computeLimit, 136 | refBlock: voucher.refBlock, 137 | message, 138 | codePreview, 139 | setCodePreview, 140 | isExpanded: codePreview !== null, 141 | connectedAppConfig: savedConnectedAppConfig 142 | ? JSON.parse(savedConnectedAppConfig) 143 | : undefined, 144 | appTitle: "Test Harness", 145 | appIcon: "https://placekitten.com/g/200/200", 146 | } 147 | 148 | return {children} 149 | } 150 | -------------------------------------------------------------------------------- /contexts/ConfigContext.tsx: -------------------------------------------------------------------------------- 1 | import React, {createContext, useEffect, useState} from "react" 2 | import fclConfig from "src/fclConfig" 3 | import {Spinner} from "../components/Spinner" 4 | import {getBaseUrl} from "src/utils" 5 | 6 | interface RuntimeConfig { 7 | flowAvatarUrl: string 8 | flowAccountAddress: string 9 | flowAccountPrivateKey: string 10 | flowAccountPublicKey: string 11 | flowAccountKeyId: string 12 | flowAccessNode: string 13 | flowInitAccountsNo: number 14 | flowInitAccountBalance: string 15 | } 16 | 17 | const defaultConfig = { 18 | flowAvatarUrl: process.env.flowAvatarUrl || "", 19 | flowAccountAddress: process.env.flowAccountAddress || "", 20 | flowAccountPrivateKey: process.env.flowAccountPrivateKey || "", 21 | flowAccountPublicKey: process.env.flowAccountPublicKey || "", 22 | flowAccountKeyId: process.env.flowAccountKeyId || "", 23 | flowAccessNode: process.env.flowAccessNode || "", 24 | flowInitAccountsNo: parseInt(process.env.flowInitAccountsNo || "0") || 0, 25 | flowInitAccountBalance: process.env.flowInitAccountBalance || "1000.0", 26 | } 27 | 28 | export const ConfigContext = createContext(defaultConfig) 29 | 30 | async function getConfig(): Promise { 31 | if (process.env.isLocal) { 32 | return replaceAccessUrlBaseUrl(defaultConfig) 33 | } 34 | 35 | const result = await fetch(`${getBaseUrl()}/api/`) 36 | .then(res => res.json()) 37 | .then(remoteConfig => { 38 | return Object.assign(defaultConfig, remoteConfig) 39 | }) 40 | .catch(e => { 41 | console.log( 42 | `Warning: Failed to fetch config from API. 43 | If you see this warning during CI you can ignore it. 44 | Returning default config. 45 | ${e} 46 | ` 47 | ) 48 | return defaultConfig 49 | }) 50 | .then(config => replaceAccessUrlBaseUrl(config)) 51 | 52 | return result 53 | } 54 | 55 | // Replace localhost Flow Access Node with the base URL of the app 56 | function replaceAccessUrlBaseUrl(config: RuntimeConfig): RuntimeConfig { 57 | const accessNodeUrl = new URL(config.flowAccessNode) 58 | const {hostname} = accessNodeUrl 59 | const isLocalhost = 60 | hostname === "127.0.0.1" || hostname === "::1" || hostname === "localhost" 61 | 62 | if (isLocalhost) { 63 | accessNodeUrl.hostname = new URL(getBaseUrl()).hostname 64 | // Must remove trailing slash to work 65 | config.flowAccessNode = accessNodeUrl.href.replace(/\/$/, "") 66 | } 67 | 68 | return config 69 | } 70 | 71 | export function ConfigContextProvider({children}: {children: React.ReactNode}) { 72 | const [config, setConfig] = useState() 73 | 74 | useEffect(() => { 75 | async function fetchConfig() { 76 | const config = await getConfig() 77 | 78 | const {flowAccessNode} = config 79 | 80 | fclConfig(flowAccessNode) 81 | setConfig(config) 82 | } 83 | 84 | fetchConfig() 85 | }, []) 86 | 87 | if (!config) return 88 | 89 | return ( 90 | {children} 91 | ) 92 | } 93 | -------------------------------------------------------------------------------- /cypress.json: -------------------------------------------------------------------------------- 1 | { 2 | "baseUrl": "http://localhost:3000/harness", 3 | "video": false, 4 | "viewportWidth": 1440, 5 | "viewportHeight": 764, 6 | "defaultCommandTimeout": 3000, 7 | "responseTimeout": 4000, 8 | "retries": {"runMode": 3}, 9 | "screenshotOnRunFailure": false, 10 | "chromeWebSecurity": false 11 | } 12 | -------------------------------------------------------------------------------- /cypress/integration/authn.spec.js: -------------------------------------------------------------------------------- 1 | import {paths} from "src/constants" 2 | 3 | export const accountA = { 4 | type: "ACCOUNT", 5 | address: "0x179b6b1cb6755e31", 6 | keyId: 0, 7 | label: "Account A", 8 | scopes: [], 9 | } 10 | 11 | export const ACCOUNTS_RESPONSE = [ 12 | { 13 | type: "ACCOUNT", 14 | address: "0xf8d6e0586b0a20c7", 15 | keyId: 0, 16 | label: "Service Account", 17 | scopes: ["email"], 18 | }, 19 | accountA, 20 | ] 21 | const newLabel = "New Account Label" 22 | const EDITED_ACCOUNTS_RESPONSE = [ 23 | { 24 | type: "ACCOUNT", 25 | address: "0xf8d6e0586b0a20c7", 26 | keyId: 0, 27 | label: "Service Account", 28 | scopes: [], 29 | }, 30 | { 31 | type: "ACCOUNT", 32 | address: "0x179b6b1cb6755e31", 33 | keyId: 0, 34 | label: newLabel, 35 | scopes: ["email"], 36 | }, 37 | ] 38 | 39 | const loggedInText = '"loggedIn": true' 40 | const loggedInEmailText = "@example.com" 41 | const appLogInButton = () => cy.get("button").contains("Log In") 42 | const appLogOutButton = () => cy.get("button").contains("Log Out") 43 | const createAccountButton = () => cy.wallet().find("[data-test='plus-button']") 44 | const logInButton = () => cy.wallet().find("[data-test='log-in-button']") 45 | const expandAccountButtons = () => 46 | cy.wallet().find("[data-test='expand-account-button']") 47 | const accountScopeSwitch = () => 48 | cy.wallet().find("[data-test='account-scope-switch']") 49 | const accountScopeSwitchInput = () => cy.wallet().find("input#scope-email") 50 | 51 | describe("Authn", () => { 52 | beforeEach(() => { 53 | cy.visit("/") 54 | cy.intercept("POST", paths.apiIsInit, { 55 | body: true, 56 | delay: 50, 57 | }) 58 | 59 | cy.intercept("GET", paths.apiAccounts, { 60 | body: ACCOUNTS_RESPONSE, 61 | delay: 50, 62 | }) 63 | 64 | cy.intercept("POST", paths.apiAccountUpdate("0x179b6b1cb6755e31"), { 65 | body: {}, 66 | delay: 50, 67 | }) 68 | 69 | cy.intercept("POST", paths.apiAccountsNew, { 70 | body: {}, 71 | delay: 50, 72 | }) 73 | }) 74 | 75 | it("Creates an account", () => { 76 | appLogInButton().click() 77 | cy.fclIframeLoaded() 78 | 79 | cy.wallet().should("contain", "FCL Dev Wallet") 80 | cy.wallet().should("not.contain", newLabel) 81 | 82 | createAccountButton().click() 83 | 84 | cy.intercept("GET", paths.apiAccounts, { 85 | body: ACCOUNTS_RESPONSE, 86 | delay: 50, 87 | }) 88 | 89 | cy.intercept("GET", paths.apiAccounts, { 90 | body: EDITED_ACCOUNTS_RESPONSE, 91 | delay: 50, 92 | }) 93 | 94 | cy.wallet().find("input[name='label']").clear().type(newLabel) 95 | accountScopeSwitch().click() 96 | 97 | cy.wallet().find("button[type='submit']").click() 98 | cy.wallet().should("contain", newLabel) 99 | 100 | expandAccountButtons().last().click() 101 | accountScopeSwitchInput().should("be.checked") 102 | }) 103 | 104 | it("Updates an account", () => { 105 | appLogInButton().click() 106 | cy.fclIframeLoaded() 107 | 108 | cy.wallet().should("contain", "FCL Dev Wallet") 109 | cy.wallet().should("not.contain", newLabel) 110 | 111 | cy.wallet() 112 | .find("[data-test='log-in-button']") 113 | .should("contain", "Account A") 114 | 115 | expandAccountButtons().first().click() 116 | accountScopeSwitchInput().should("be.checked") 117 | expandAccountButtons().first().click() 118 | 119 | cy.wallet().find("[data-test='manage-account-button']").last().click() 120 | 121 | cy.intercept("GET", paths.apiAccounts, { 122 | body: ACCOUNTS_RESPONSE, 123 | delay: 50, 124 | }) 125 | 126 | cy.intercept("GET", paths.apiAccounts, { 127 | body: EDITED_ACCOUNTS_RESPONSE, 128 | delay: 50, 129 | }) 130 | 131 | cy.wallet().find("input[name='label']").clear().type(newLabel) 132 | accountScopeSwitch().click() 133 | 134 | cy.wallet().find("button[type='submit']").click() 135 | cy.wallet().should("contain", newLabel) 136 | 137 | expandAccountButtons().last().click() 138 | accountScopeSwitchInput().should("be.checked") 139 | }) 140 | 141 | it("Logs in and out", () => { 142 | cy.get("body").should("not.contain", loggedInText) 143 | 144 | appLogInButton().click() 145 | cy.fclIframeLoaded() 146 | 147 | logInButton().last().click() 148 | cy.get("body").should("contain", loggedInText) 149 | cy.get("body").should("not.contain", loggedInEmailText) 150 | 151 | appLogOutButton().click() 152 | cy.get("body").should("not.contain", loggedInText) 153 | }) 154 | 155 | it("Logs in with email scope", () => { 156 | cy.get("body").should("not.contain", loggedInEmailText) 157 | 158 | appLogInButton().click() 159 | cy.fclIframeLoaded() 160 | 161 | logInButton().last().click() 162 | cy.get("body").should("not.contain", loggedInEmailText) 163 | 164 | appLogInButton().click() 165 | cy.fclIframeLoaded() 166 | 167 | expandAccountButtons().last().click() 168 | accountScopeSwitch().click() 169 | logInButton().last().click() 170 | 171 | cy.get("body").should("contain", loggedInEmailText) 172 | }) 173 | }) 174 | -------------------------------------------------------------------------------- /cypress/integration/authz.spec.js: -------------------------------------------------------------------------------- 1 | import {paths} from "src/constants" 2 | import {accountA, ACCOUNTS_RESPONSE} from "./authn.spec" 3 | 4 | const appLogOutButton = () => cy.get("button").contains("Log Out") 5 | const appMutateButton = () => cy.get("button").contains("Mutate 2 (args)") 6 | const logInButton = () => cy.wallet().find("[data-test='log-in-button']") 7 | const expandCollapseButton = () => 8 | cy.wallet().find("[data-test='expand-collapse-button']") 9 | const approveTransactionButton = () => 10 | cy.wallet().find("[data-test='approve-transaction-button']") 11 | 12 | describe("Authz", () => { 13 | beforeEach(() => { 14 | cy.visit("/") 15 | cy.intercept("POST", paths.apiIsInit, { 16 | body: true, 17 | delay: 50, 18 | }) 19 | 20 | cy.intercept("GET", paths.apiAccounts, { 21 | body: ACCOUNTS_RESPONSE, 22 | delay: 50, 23 | }) 24 | 25 | cy.intercept("POST", paths.apiAccountUpdate("0x179b6b1cb6755e31"), { 26 | body: {}, 27 | delay: 50, 28 | }) 29 | 30 | cy.intercept("POST", paths.apiAccountsNew, { 31 | body: {}, 32 | delay: 50, 33 | }) 34 | }) 35 | 36 | it("Authorizes a transaction", () => { 37 | cy.intercept("GET", paths.apiAccounts, { 38 | body: ACCOUNTS_RESPONSE, 39 | delay: 50, 40 | }) 41 | appLogOutButton().click() 42 | appMutateButton().click() 43 | cy.fclIframeLoaded() 44 | 45 | cy.wallet().should("contain", "FCL Dev Wallet") 46 | logInButton().last().click() 47 | cy.wallet().should("contain", "Authorize Transaction") 48 | cy.wallet().should("contain", accountA.address) 49 | 50 | cy.wallet().should("contain", "expand") 51 | expandCollapseButton().first().click() 52 | cy.wallet().should("contain", "collapse") 53 | expandCollapseButton().click() 54 | cy.wallet().should("contain", "expand") 55 | 56 | approveTransactionButton().click() 57 | 58 | cy.get("iframe").should("not.exist") 59 | }) 60 | }) 61 | -------------------------------------------------------------------------------- /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 | import "cypress-iframe" 2 | 3 | Cypress.Commands.add("fclIframeLoaded", () => cy.frameLoaded("#FCL_IFRAME")) 4 | Cypress.Commands.add("wallet", () => 5 | cy.iframe("#FCL_IFRAME").find("[data-test='dev-wallet']") 6 | ) 7 | -------------------------------------------------------------------------------- /cypress/support/index.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 | -------------------------------------------------------------------------------- /docs/overview.md: -------------------------------------------------------------------------------- 1 | ## Introduction 2 | 3 | The FCL dev wallet is a mock Flow wallet that simulates the protocols used by [FCL](https://docs.onflow.org/fcl/) to interact with the Flow blockchain on behalf of simulated user accounts. 4 | 5 | **IMPORTANT** 6 | 7 | ``` 8 | This project implements an FCL compatible 9 | interface, but should **not** be used as a reference for 10 | building a production grade wallet. 11 | 12 | This project should only be used in aid of local 13 | development against a locally run instance of the Flow 14 | blockchain like the Flow emulator, and should never be used in 15 | conjunction with Flow Mainnet, Testnet, Canarynet or any 16 | other instances of Flow. 17 | ``` 18 | 19 | ## Getting started 20 | 21 | Before using the dev wallet, you'll need to start the Flow emulator. 22 | 23 | ### Install the `flow-cli` 24 | 25 | The Flow emulator is bundles with the Flow CLI. Instructions for installing the CLI can be found here: [https://docs.onflow.org/flow-cli/install/](https://docs.onflow.org/flow-cli/install/) 26 | 27 | ### Create a `flow.json` file 28 | 29 | Run this command to create `flow.json` file (typically in your project's root directory): 30 | 31 | ```sh 32 | flow init 33 | ``` 34 | 35 | ### Start the emulator 36 | 37 | Start the emulator and deploy the contracts by running the following command from the directory containing `flow.json` in your project: 38 | 39 | ```sh 40 | flow emulator start 41 | flow project deploy --network emulator 42 | ``` 43 | 44 | ## Configuring your JavaScript application 45 | 46 | The FCL dev wallet is designed to be used with [`@onflow/fcl`](https://github.com/onflow/flow-js-sdk) version `1.0.0` or higher. The FCL package can be installed with: `npm install @onflow/fcl` or `yarn add @onflow/fcl`. 47 | 48 | To use the dev wallet, configure FCL to point to the address of a locally running [Flow emulator](#start-the-emulator) and the dev wallet endpoint. 49 | 50 | ```javascript 51 | import * as fcl from "@onflow/fcl" 52 | 53 | fcl 54 | .config() 55 | // Point App at Emulator REST API 56 | .put("accessNode.api", "http://localhost:8888") 57 | // Point FCL at dev-wallet (default port) 58 | .put("discovery.wallet", "http://localhost:8701/fcl/authn") 59 | ``` 60 | 61 | ### Test harness 62 | 63 | It's easy to use this FCL harness app as a barebones 64 | app to interact with the dev-wallet during development: 65 | 66 | Navigate to http://localhost:8701/harness 67 | 68 | ## Contributing 69 | Releasing a new version of Dev Wallet is as simple as tagging and creating a release, a Github Action will then build a bundle of the Dev Wallet that can be used in other tools (such as CLI). If the update of the Dev Wallet is required in the CLI, a seperate update PR on the CLI should be created. 70 | 71 | -------------------------------------------------------------------------------- /docs/project-development-tips.md: -------------------------------------------------------------------------------- 1 | # This document has been moved to a new location: 2 | 3 | https://github.com/onflow/docs/tree/main/docs/cadence/styleguide/project-development-tips.md 4 | -------------------------------------------------------------------------------- /flow.json: -------------------------------------------------------------------------------- 1 | { 2 | "emulators": { 3 | "default": { 4 | "port": 3569, 5 | "serviceAccount": "emulator-account" 6 | } 7 | }, 8 | "contracts": { 9 | "FCL": { 10 | "source": "./cadence/contracts/FCL.cdc", 11 | "aliases": { 12 | "emulator": "0xf8d6e0586b0a20c7" 13 | } 14 | }, 15 | "FCLCrypto": { 16 | "source": "./cadence/contracts/FCLCrypto.cdc", 17 | "aliases": {} 18 | }, 19 | "FlowToken": { 20 | "source": "./cadence/contracts/utility/FlowToken.cdc", 21 | "aliases": { 22 | "emulator": "0x0ae53cb6e3f42a79" 23 | } 24 | }, 25 | "FungibleToken": { 26 | "source": "./cadence/contracts/utility/FungibleToken.cdc", 27 | "aliases": { 28 | "emulator": "0xee82856bf20e2aa6" 29 | } 30 | }, 31 | "FungibleTokenMetadataViews": { 32 | "source": "./cadence/contracts/utility/FungibleTokenMetadataViews.cdc", 33 | "aliases": { 34 | "emulator": "0xee82856bf20e2aa6" 35 | } 36 | }, 37 | "MetadataViews": { 38 | "source": "./cadence/contracts/utility/MetadataViews.cdc", 39 | "aliases": { 40 | "emulator": "0xf8d6e0586b0a20c7" 41 | } 42 | }, 43 | "NonFungibleToken": { 44 | "source": "./cadence/contracts/utility/NonFungibleToken.cdc", 45 | "aliases": { 46 | "emulator": "0xf8d6e0586b0a20c7" 47 | } 48 | }, 49 | "ViewResolver": { 50 | "source": "./cadence/contracts/utility/ViewResolver.cdc", 51 | "aliases": { 52 | "emulator": "0xf8d6e0586b0a20c7" 53 | } 54 | } 55 | }, 56 | "networks": { 57 | "emulator": "127.0.0.1:3569", 58 | "mainnet": "access.mainnet.nodes.onflow.org:9000", 59 | "testnet": "access.devnet.nodes.onflow.org:9000" 60 | }, 61 | "accounts": { 62 | "emulator-account": { 63 | "address": "f8d6e0586b0a20c7", 64 | "key": "f8e188e8af0b8b414be59c4a1a15cc666c898fb34d94156e9b51e18bfde754a5" 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/onflow/fcl-dev-wallet 2 | 3 | go 1.18 4 | 5 | require ( 6 | github.com/gorilla/mux v1.8.0 7 | github.com/joho/godotenv v1.4.0 8 | github.com/spf13/cobra v1.7.0 9 | github.com/spf13/viper v1.16.0 10 | ) 11 | 12 | require ( 13 | github.com/fsnotify/fsnotify v1.6.0 // indirect 14 | github.com/hashicorp/hcl v1.0.0 // indirect 15 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 16 | github.com/magiconair/properties v1.8.7 // indirect 17 | github.com/mitchellh/mapstructure v1.5.0 // indirect 18 | github.com/pelletier/go-toml/v2 v2.0.8 // indirect 19 | github.com/spf13/afero v1.9.5 // indirect 20 | github.com/spf13/cast v1.5.1 // indirect 21 | github.com/spf13/jwalterweatherman v1.1.0 // indirect 22 | github.com/spf13/pflag v1.0.5 // indirect 23 | github.com/subosito/gotenv v1.4.2 // indirect 24 | golang.org/x/sys v0.8.0 // indirect 25 | golang.org/x/text v0.9.0 // indirect 26 | gopkg.in/ini.v1 v1.67.0 // indirect 27 | gopkg.in/yaml.v3 v3.0.1 // indirect 28 | ) 29 | -------------------------------------------------------------------------------- /go/README.md: -------------------------------------------------------------------------------- 1 | ## Dev Wallet Go Library 2 | The FCL dev wallet is being integrated into the Flow CLI and since the FCL Dev Wallet is a 3 | software written in javascript/typescript it introduces this Go wrapper, that embeds 4 | the statically built bundle of the FCL Dev Wallet app and serves it with the implemented 5 | Go HTTP server. 6 | 7 | It also introduces a config API endpoint `/api` that serves the configuration needed by the 8 | statically built bundle during the runtime. This Go library gets the configuration from 9 | two sources: 10 | - From the configuration passed to the library 11 | - From the `.env.development` file 12 | 13 | The configuration that is being passed to the library will overwrite the configuration 14 | in the `.env.development` but it's only limited to providing values for the following keys: 15 | - `flowAccountAddress` 16 | - `flowAccountPrivateKey` 17 | - `flowAccountPublicKey` 18 | - `flowAccessNode` 19 | 20 | The configuration that is fetched from the `.env.development` file will be converted from 21 | snake case to camel case. Example a property named `FOO_BAR_ZOO` wil become `fooBarZoo` 22 | when server with the API configuration endpoint. 23 | 24 | Configuration is dynamically built, so even if new values are added there is no change 25 | required in the Go implementation, beside just building the bundle and releasing a new version 26 | on the Github. 27 | 28 | ### Testing 29 | During the development of Dev wallet you can test how the Go library works by running: 30 | ``` 31 | npm run go-server 32 | ``` 33 | This command will build the latest bundle, copy the `.env.development` from the root 34 | directory in the `/go/wallet` (just to make sure they are in sync), build the Go server and 35 | run it. **You must make sure you have Go installed**, here are instructions: https://go.dev/doc/install 36 | -------------------------------------------------------------------------------- /go/wallet/.env.development: -------------------------------------------------------------------------------- 1 | # The FCL Dev Wallet requires a single account to use as a base/starting point. 2 | # This account will be used to create and manage other accounts. 3 | # We recommend to use the service account defined in the flow.json file your emulator is using. 4 | 5 | FLOW_INIT_ACCOUNTS=0 6 | FLOW_AVATAR_URL=https://avatars.onflow.org/avatar/ 7 | 8 | # EMULATOR REST API ENDPOINT 9 | FLOW_ACCESS_NODE=http://localhost:8888 10 | # Default values. These should match locally running emulator. 11 | # Will only be used when wallet is started from source (npm run dev) 12 | FLOW_ACCOUNT_KEY_ID=0 13 | FLOW_ACCOUNT_ADDRESS=0xf8d6e0586b0a20c7 14 | FLOW_ACCOUNT_PRIVATE_KEY=f8e188e8af0b8b414be59c4a1a15cc666c898fb34d94156e9b51e18bfde754a5 15 | FLOW_ACCOUNT_PUBLIC_KEY=6e70492cb4ec2a6013e916114bc8bf6496f3335562f315e18b085c19da659bdfd88979a5904ae8bd9b4fd52a07fc759bad9551c04f289210784e7b08980516d2 16 | -------------------------------------------------------------------------------- /go/wallet/bundle.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/onflow/fcl-dev-wallet/69de8fd4c33535e48f8b5e45a8d4adc43aeb31ee/go/wallet/bundle.zip -------------------------------------------------------------------------------- /go/wallet/cmd/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/onflow/fcl-dev-wallet/go/wallet" 7 | "github.com/spf13/cobra" 8 | "github.com/spf13/viper" 9 | ) 10 | 11 | func main() { 12 | var port uint 13 | rootCmd := &cobra.Command{ 14 | Use: "wallet", 15 | Short: "Flow Dev Wallet", 16 | Long: `Flow Dev Wallet`, 17 | Run: func(cmd *cobra.Command, args []string) { 18 | srv, err := wallet.NewHTTPServer(port, nil) 19 | if err != nil { 20 | panic(err) 21 | } 22 | 23 | fmt.Printf("Development server started on port %d\n", port) 24 | srv.StartStandalone() 25 | }, 26 | } 27 | 28 | rootCmd.PersistentFlags().UintVar(&port, "port", 8701, "Port to run the server on") 29 | viper.BindPFlag("port", rootCmd.PersistentFlags().Lookup("port")) 30 | 31 | if err := rootCmd.Execute(); err != nil { 32 | panic(err) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /go/wallet/config_handler.go: -------------------------------------------------------------------------------- 1 | package wallet 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "net/http" 7 | 8 | "github.com/joho/godotenv" 9 | ) 10 | 11 | // configHandler handles config endpoints 12 | func (server *Server) configHandler(w http.ResponseWriter, r *http.Request) { 13 | w.Header().Set("Content-Type", "application/json") 14 | w.WriteHeader(http.StatusOK) 15 | 16 | conf, err := buildConfig(server.config, envConfig) 17 | if err != nil { 18 | w.WriteHeader(http.StatusInternalServerError) 19 | } 20 | 21 | err = json.NewEncoder(w).Encode(conf) 22 | if err != nil { 23 | w.WriteHeader(http.StatusInternalServerError) 24 | } 25 | } 26 | 27 | // buildConfig from provided flow config and the env config 28 | func buildConfig(flowConfig *FlowConfig, envConfig []byte) (map[string]string, error) { 29 | env, _ := godotenv.Parse(bytes.NewReader(envConfig)) 30 | 31 | flowConf, err := json.Marshal(flowConfig) 32 | if err != nil { 33 | return nil, err 34 | } 35 | 36 | var flow map[string]string 37 | err = json.Unmarshal(flowConf, &flow) 38 | if err != nil { 39 | return nil, err 40 | } 41 | 42 | tempt := make(map[string]string) 43 | 44 | for k, v := range env { 45 | tempt[convertSnakeToCamel(k)] = v 46 | } 47 | 48 | // don't overwrite empty values 49 | for k, v := range flow { 50 | if v != "" { 51 | tempt[k] = v 52 | } 53 | } 54 | 55 | return tempt, nil 56 | } 57 | 58 | -------------------------------------------------------------------------------- /go/wallet/dev_wallet_handler.go: -------------------------------------------------------------------------------- 1 | package wallet 2 | 3 | import ( 4 | "archive/zip" 5 | "bytes" 6 | "fmt" 7 | "net/http" 8 | "strings" 9 | ) 10 | 11 | // devWalletHandler handles endpoints to exported static html files 12 | func (srv *Server) devWalletHandler(writer http.ResponseWriter, request *http.Request) { 13 | zipContent, _ := srv.bundle.ReadFile(srv.bundleZip) 14 | zipFS, _ := zip.NewReader(bytes.NewReader(zipContent), int64(len(zipContent))) 15 | rootFS := http.FS(zipFS) 16 | 17 | path := strings.TrimPrefix(request.URL.Path, "/") 18 | path = strings.TrimSuffix(path, "/") 19 | if path != "" { // api requests don't include .html so that needs to be added 20 | if _, err := zipFS.Open(path); err != nil { 21 | path = fmt.Sprintf("%s.html", path) 22 | } 23 | } 24 | 25 | writer.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") 26 | writer.Header().Set("Pragma", "no-cache") 27 | writer.Header().Set("Expires", "0") 28 | 29 | request.URL.Path = path 30 | http.FileServer(rootFS).ServeHTTP(writer, request) 31 | } 32 | 33 | -------------------------------------------------------------------------------- /go/wallet/polling_session_handler.go: -------------------------------------------------------------------------------- 1 | package wallet 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | "strconv" 7 | ) 8 | 9 | type UpdatePollingSessionRequest struct { 10 | PollingId string `json:"pollingId"` 11 | Data map[string]interface{} `json:"data"` 12 | } 13 | 14 | func (srv *Server) getPollingSessionHandler(w http.ResponseWriter, r *http.Request) { 15 | pollingId, err := strconv.Atoi(r.URL.Query().Get("pollingId")) 16 | if err != nil { 17 | w.WriteHeader(http.StatusBadRequest) 18 | return 19 | } 20 | 21 | if _, ok := srv.pollingSessions[pollingId]; !ok { 22 | w.WriteHeader(http.StatusNotFound) 23 | } else { 24 | w.WriteHeader(http.StatusOK) 25 | w.Header().Set("Content-Type", "application/json") 26 | 27 | pollingSession := srv.pollingSessions[pollingId] 28 | 29 | obj, err := json.Marshal(pollingSession) 30 | if err != nil { 31 | w.WriteHeader(http.StatusInternalServerError) 32 | return 33 | } 34 | 35 | w.Write(obj) 36 | 37 | if pollingSession["status"] == "APPROVED" || pollingSession["status"] == "DECLINED" { 38 | delete(srv.pollingSessions, pollingId) 39 | } 40 | } 41 | } 42 | 43 | func (srv *Server) postPollingSessionHandler(w http.ResponseWriter, r *http.Request) { 44 | var req UpdatePollingSessionRequest 45 | err := json.NewDecoder(r.Body).Decode(&req) 46 | if err != nil { 47 | w.WriteHeader(http.StatusBadRequest) 48 | return 49 | } 50 | 51 | pollingId, err := strconv.Atoi(req.PollingId) 52 | if err != nil { 53 | w.WriteHeader(http.StatusBadRequest) 54 | return 55 | } 56 | 57 | srv.pollingSessions[pollingId] = req.Data 58 | 59 | w.WriteHeader(http.StatusCreated) 60 | } 61 | 62 | -------------------------------------------------------------------------------- /go/wallet/server.go: -------------------------------------------------------------------------------- 1 | package wallet 2 | 3 | import ( 4 | "context" 5 | "embed" 6 | "fmt" 7 | "net/http" 8 | "os" 9 | "os/signal" 10 | "syscall" 11 | 12 | "github.com/gorilla/mux" 13 | ) 14 | 15 | //go:embed bundle.zip 16 | var bundle embed.FS 17 | 18 | //go:embed .env.development 19 | var envConfig []byte 20 | 21 | const bundleZip = "bundle.zip" 22 | 23 | type Server struct { 24 | http *http.Server 25 | config *FlowConfig 26 | bundle embed.FS 27 | bundleZip string 28 | pollingSessions map[int]map[string]interface{} 29 | nextPollingId int 30 | } 31 | 32 | type FlowConfig struct { 33 | Address string `json:"flowAccountAddress"` 34 | PrivateKey string `json:"flowAccountPrivateKey"` 35 | PublicKey string `json:"flowAccountPublicKey"` 36 | AccessNode string `json:"flowAccessNode"` 37 | AvatarUrl string `json:"flowAvatarUrl"` 38 | } 39 | 40 | // NewHTTPServer returns a new wallet server listening on provided port number. 41 | func NewHTTPServer(port uint, config *FlowConfig) (*Server, error) { 42 | http := http.Server{ 43 | Addr: fmt.Sprintf(":%d", port), 44 | Handler: nil, 45 | } 46 | 47 | srv := &Server{ 48 | http: &http, 49 | config: config, 50 | bundle: bundle, 51 | bundleZip: bundleZip, 52 | pollingSessions: make(map[int]map[string]interface{}), 53 | nextPollingId: 0, 54 | } 55 | 56 | r := mux.NewRouter() 57 | 58 | // API routes 59 | apiRouter := r.PathPrefix("/api").Subrouter() 60 | apiRouter.HandleFunc("/", srv.configHandler).Methods("GET") 61 | apiRouter.HandleFunc("/polling-session", srv.getPollingSessionHandler).Methods("GET") 62 | apiRouter.HandleFunc("/polling-session", srv.postPollingSessionHandler).Methods("POST") 63 | apiRouter.HandleFunc("/discovery", srv.discoveryHandler) 64 | apiRouter.HandleFunc("/{service}", srv.postServiceHandler).Methods("POST") 65 | 66 | // Main route 67 | r.PathPrefix("/").HandlerFunc(srv.devWalletHandler).Methods("GET") 68 | 69 | srv.http.Handler = enableCors(r) 70 | 71 | return srv, nil 72 | } 73 | 74 | func (s *Server) Start() error { 75 | return s.http.ListenAndServe() 76 | } 77 | 78 | func (s *Server) StartStandalone() { 79 | done := make(chan os.Signal, 1) 80 | signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM) 81 | 82 | go func() { 83 | err := s.http.ListenAndServe() 84 | if err != nil { 85 | fmt.Printf("error starting up the server: %s\n", err) 86 | done <- syscall.SIGTERM 87 | } 88 | }() 89 | 90 | <-done 91 | s.Stop() 92 | } 93 | 94 | func (s *Server) Stop() { 95 | err := s.http.Shutdown(context.Background()) 96 | if err != nil { 97 | panic(err) 98 | } 99 | } 100 | 101 | func enableCors(handler http.Handler) http.Handler { 102 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 103 | // Add the necessary CORS headers 104 | w.Header().Set("Access-Control-Allow-Origin", "*") 105 | w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") 106 | w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") 107 | 108 | // If it's a preflight request, respond with 200 OK 109 | if r.Method == http.MethodOptions { 110 | return 111 | } 112 | 113 | // Call the next handler 114 | handler.ServeHTTP(w, r) 115 | }) 116 | } 117 | -------------------------------------------------------------------------------- /go/wallet/service_handler.go: -------------------------------------------------------------------------------- 1 | package wallet 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io/ioutil" 7 | "net/http" 8 | "strings" 9 | ) 10 | 11 | type Service struct { 12 | FType string `json:"f_type"` 13 | FVsn string `json:"f_vsn"` 14 | Type string `json:"type"` 15 | Endpoint string `json:"endpoint"` 16 | Method string `json:"method"` 17 | Params map[string]string `json:"params"` 18 | } 19 | 20 | type FclResponse struct { 21 | FType string `json:"f_type"` 22 | FVsn string `json:"f_vsn"` 23 | Status string `json:"status"` 24 | Updates Service `json:"updates"` 25 | } 26 | 27 | type ServicePostResponse struct { 28 | FclResponse 29 | Local Service `json:"local"` 30 | } 31 | 32 | type FCLClient struct { 33 | Platform string `json:"platform"` 34 | } 35 | 36 | type FCLConfig struct { 37 | Client FCLClient `json:"client"` 38 | } 39 | 40 | type FCLMessage struct { 41 | Config FCLConfig `json:"config"` 42 | } 43 | 44 | func getMethod(fclMessageJson []byte) string { 45 | var fclMessage FCLMessage 46 | err := json.Unmarshal(fclMessageJson, &fclMessage) 47 | if err != nil { 48 | fmt.Println("Error:", err) 49 | return "VIEW/IFRAME" 50 | } 51 | 52 | if fclMessage.Config.Client.Platform == "react-native" { 53 | return "VIEW/MOBILE_BROWSER" 54 | } 55 | 56 | return "VIEW/IFRAME" 57 | } 58 | 59 | func (server *Server) postServiceHandler(w http.ResponseWriter, r *http.Request) { 60 | service := strings.TrimPrefix(r.URL.Path, "/api/") 61 | if service == "" { 62 | w.WriteHeader(http.StatusNotFound) 63 | return 64 | } 65 | 66 | fclMessageJson, err := ioutil.ReadAll(r.Body) 67 | method := getMethod(fclMessageJson) 68 | 69 | if err != nil { 70 | w.WriteHeader(http.StatusBadRequest) 71 | return 72 | } 73 | 74 | pollingId := server.nextPollingId 75 | server.nextPollingId++ 76 | 77 | // Resolve baseUrl 78 | baseUrl := getBaseUrl(r) 79 | 80 | pendingResponse := FclResponse{ 81 | FType: "PollingResponse", 82 | FVsn: "1.0.0", 83 | Status: "PENDING", 84 | Updates: Service{ 85 | FType: "PollingResponse", 86 | FVsn: "1.0.0", 87 | Type: "back-channel-rpc", 88 | Endpoint: baseUrl + "/api/polling-session", 89 | Method: "HTTP/GET", 90 | Params: map[string]string{ 91 | "pollingId": fmt.Sprint(pollingId), 92 | }, 93 | }, 94 | } 95 | 96 | // Use json to convert struct to map 97 | tmp, err := json.Marshal(pendingResponse) 98 | if err != nil { 99 | w.WriteHeader(http.StatusInternalServerError) 100 | return 101 | } 102 | pendingResponseMap := make(map[string]interface{}) 103 | err = json.Unmarshal(tmp, &pendingResponseMap) 104 | if err != nil { 105 | w.WriteHeader(http.StatusInternalServerError) 106 | return 107 | } 108 | 109 | server.pollingSessions[pollingId] = pendingResponseMap 110 | 111 | responseJson, err := json.Marshal(ServicePostResponse{ 112 | FclResponse: pendingResponse, 113 | Local: Service{ 114 | FType: "Service", 115 | FVsn: "1.0.0", 116 | Type: "local-view", 117 | Endpoint: baseUrl + "/fcl/" + service, 118 | Method: method, 119 | Params: map[string]string{ 120 | "pollingId": fmt.Sprint(pollingId), 121 | "channel": "back", 122 | "fclMessageJson": string(fclMessageJson), 123 | }, 124 | }, 125 | }) 126 | if err != nil { 127 | w.WriteHeader(http.StatusInternalServerError) 128 | return 129 | } 130 | 131 | w.WriteHeader(http.StatusCreated) 132 | w.Header().Set("Content-Type", "application/json") 133 | w.Write([]byte(responseJson)) 134 | } 135 | 136 | -------------------------------------------------------------------------------- /go/wallet/util.go: -------------------------------------------------------------------------------- 1 | package wallet 2 | 3 | import ( 4 | "net/http" 5 | "regexp" 6 | "strings" 7 | ) 8 | 9 | // convertSnakeToCamel converts snake string format to camel string format. 10 | func convertSnakeToCamel(input string) string { 11 | r, _ := regexp.Compile("_(\\w)") 12 | input = strings.ToLower(input) 13 | out := r.ReplaceAllFunc([]byte(input), func(s []byte) []byte { 14 | return []byte(strings.ToUpper(strings.ReplaceAll(string(s), "_", ""))) 15 | }) 16 | return string(out) 17 | } 18 | 19 | func getBaseUrl(r *http.Request) string { 20 | protocol := r.Header.Get("X-Forwarded-Proto") 21 | if(protocol == "") { 22 | protocol = "http" 23 | } 24 | 25 | host := r.Header.Get("X-Forwarded-Host") 26 | if(host == "") { 27 | host = r.Host 28 | } 29 | 30 | baseUrl := protocol + "://" + host 31 | return baseUrl 32 | } -------------------------------------------------------------------------------- /hooks/useAccount.ts: -------------------------------------------------------------------------------- 1 | import {useCallback, useEffect, useState} from "react" 2 | import {Account, getAccount} from "src/accounts" 3 | 4 | export default function useAccount(address: string) { 5 | const [account, setAccount] = useState(null) 6 | const [error, setError] = useState(null) 7 | const [isLoading, setIsLoading] = useState(true) 8 | 9 | const fetchAccount = useCallback(() => { 10 | getAccount(address) 11 | .then(account => { 12 | setAccount(account) 13 | }) 14 | .catch(error => { 15 | setError(error) 16 | }) 17 | .finally(() => setIsLoading(false)) 18 | }, [address]) 19 | 20 | useEffect(() => { 21 | fetchAccount() 22 | }, [fetchAccount]) 23 | 24 | return { 25 | data: account, 26 | error: error, 27 | isLoading: isLoading, 28 | refresh: fetchAccount, 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /hooks/useAccounts.ts: -------------------------------------------------------------------------------- 1 | import {useCallback, useEffect, useState} from "react" 2 | import {Account, getAccounts} from "src/accounts" 3 | import useConfig from "hooks/useConfig" 4 | 5 | export default function useAccounts() { 6 | const [accounts, setAccounts] = useState>([]) 7 | const [error, setError] = useState(null) 8 | const [isLoading, setIsLoading] = useState(true) 9 | const {flowAccountAddress} = useConfig() 10 | 11 | const fetchAccounts = useCallback(() => { 12 | getAccounts({flowAccountAddress}) 13 | .then(accounts => { 14 | setAccounts(accounts) 15 | }) 16 | .catch(error => { 17 | setError(error) 18 | }) 19 | .finally(() => setIsLoading(false)) 20 | }, [flowAccountAddress]) 21 | 22 | useEffect(() => { 23 | fetchAccounts() 24 | }, [fetchAccounts]) 25 | 26 | return { 27 | data: accounts, 28 | error: error, 29 | isLoading: isLoading, 30 | refresh: fetchAccounts, 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /hooks/useAuthnContext.ts: -------------------------------------------------------------------------------- 1 | import {AuthnContext} from "contexts/AuthnContext" 2 | import {useContext} from "react" 3 | 4 | const useAuthnContext = () => { 5 | return useContext(AuthnContext) 6 | } 7 | 8 | export default useAuthnContext 9 | -------------------------------------------------------------------------------- /hooks/useAuthnRefreshContext.ts: -------------------------------------------------------------------------------- 1 | import {AuthnRefreshContext} from "contexts/AuthnRefreshContext" 2 | import {useContext} from "react" 3 | 4 | const useAuthnRefreshContext = () => { 5 | return useContext(AuthnRefreshContext) 6 | } 7 | 8 | export default useAuthnRefreshContext 9 | -------------------------------------------------------------------------------- /hooks/useAuthzContext.ts: -------------------------------------------------------------------------------- 1 | import {AuthzContext} from "contexts/AuthzContext" 2 | import {useContext} from "react" 3 | 4 | const useAuthzContext = () => { 5 | return useContext(AuthzContext) 6 | } 7 | 8 | export default useAuthzContext 9 | -------------------------------------------------------------------------------- /hooks/useConfig.ts: -------------------------------------------------------------------------------- 1 | import {ConfigContext} from "contexts/ConfigContext" 2 | import {useContext} from "react" 3 | 4 | const useConfig = () => { 5 | return useContext(ConfigContext) 6 | } 7 | 8 | export default useConfig 9 | -------------------------------------------------------------------------------- /hooks/useConnectedAppConfig.ts: -------------------------------------------------------------------------------- 1 | import {parseScopes} from "src/scopes" 2 | import {useFclData} from "./useFclData" 3 | 4 | export type ConnectedAppConfig = { 5 | type: string 6 | body: { 7 | appIdentifier?: string | undefined 8 | data: unknown 9 | extensions: unknown[] 10 | nonce?: string | undefined 11 | } 12 | service: Record 13 | config: { 14 | services: {"OpenID.scopes": string} 15 | app: { 16 | icon: string 17 | title: string 18 | } 19 | client?: { 20 | platform?: string 21 | } 22 | } 23 | } 24 | 25 | export default function useConnectedAppConfig() { 26 | const connectedAppConfig = useFclData({ 27 | transformBackchannel: data => { 28 | const {appIdentifier, nonce, ...restData} = data 29 | return { 30 | ...restData, 31 | body: { 32 | appIdentifier: appIdentifier, 33 | nonce: nonce, 34 | }, 35 | } 36 | }, 37 | }) 38 | 39 | const appScopes = parseScopes( 40 | connectedAppConfig?.config?.services?.["OpenID.scopes"] 41 | ) 42 | 43 | return {connectedAppConfig, appScopes} 44 | } 45 | -------------------------------------------------------------------------------- /hooks/useFclData.ts: -------------------------------------------------------------------------------- 1 | import {WalletUtils} from "@onflow/fcl" 2 | import {useEffect, useState} from "react" 3 | 4 | export function useFclData({ 5 | transformFrontchannel, 6 | transformBackchannel, 7 | }: { 8 | transformFrontchannel?: (data: any) => T 9 | transformBackchannel?: (data: any) => T 10 | } = {}) { 11 | const [data, setData] = useState(null) 12 | 13 | useEffect(() => { 14 | const urlParams = new URLSearchParams(window.location.search) 15 | if (urlParams.has("fclMessageJson")) { 16 | const fclMessageJson = urlParams.get("fclMessageJson") 17 | if (!fclMessageJson) { 18 | throw new Error("fclMessageJson is missing") 19 | } 20 | const data = JSON.parse(fclMessageJson) 21 | setData(transformBackchannel ? transformBackchannel(data) : data) 22 | return 23 | } 24 | 25 | function callback(data: any) { 26 | setData( 27 | transformFrontchannel ? transformFrontchannel(data as any) : (data as T) 28 | ) 29 | } 30 | 31 | WalletUtils.ready(callback) 32 | }, []) 33 | 34 | return data 35 | } 36 | -------------------------------------------------------------------------------- /hooks/useThemeUI.ts: -------------------------------------------------------------------------------- 1 | import {FlowTheme} from "src/theme" 2 | import {ThemeUIContextValue, useThemeUI as themeUIUseThemeUI} from "theme-ui" 3 | 4 | interface FlowThemeContextValue extends Omit { 5 | theme: FlowTheme 6 | } 7 | 8 | const useThemeUI = themeUIUseThemeUI as unknown as () => FlowThemeContextValue 9 | 10 | export default useThemeUI 11 | -------------------------------------------------------------------------------- /hooks/useVariants.ts: -------------------------------------------------------------------------------- 1 | // Adds support for multiple variants https://github.com/system-ui/theme-ui/issues/403#issuecomment-561322255 2 | 3 | import {useResponsiveValue} from "@theme-ui/match-media" 4 | import theme from "src/theme" 5 | import {get} from "theme-ui" 6 | 7 | export default function useVariants(...variantBlocks: string[][]) { 8 | const variants = useResponsiveValue(variantBlocks) 9 | let styles = {} 10 | variants.map((variant: string) => { 11 | styles = {...styles, ...get(theme, variant)} 12 | }) 13 | return styles 14 | } 15 | -------------------------------------------------------------------------------- /modules.d.ts: -------------------------------------------------------------------------------- 1 | // Untyped dependencies 2 | declare module "@onflow/fcl" 3 | declare module "@onflow/transport-grpc" 4 | declare module "@onflow/util-encode-key" 5 | declare module "@onflow/types" 6 | declare module "sha3" 7 | declare module "namegenerator" 8 | declare module "*.css" 9 | declare module "*.cdc" { 10 | const content: string 11 | export default content 12 | } 13 | -------------------------------------------------------------------------------- /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 | module.exports = { 2 | productionBrowserSourceMaps: true, 3 | async rewrites() { 4 | return [ 5 | { 6 | source: "/api/:path*", 7 | destination: "http://localhost:8799/api/:path*", // Proxy to Go API 8 | }, 9 | ] 10 | }, 11 | async headers() { 12 | return [ 13 | { 14 | // matching all API routes 15 | source: "/api/:path*", 16 | headers: [ 17 | {key: "Access-Control-Allow-Credentials", value: "true"}, 18 | {key: "Access-Control-Allow-Origin", value: "*"}, 19 | { 20 | key: "Access-Control-Allow-Methods", 21 | value: "GET,OPTIONS,PATCH,DELETE,POST,PUT", 22 | }, 23 | { 24 | key: "Access-Control-Allow-Headers", 25 | value: 26 | "X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version", 27 | }, 28 | ], 29 | }, 30 | ] 31 | }, 32 | webpack: (config, _options) => { 33 | config.module.rules.push({ 34 | test: /\.cdc/, 35 | type: "asset/source", 36 | }) 37 | return config 38 | }, 39 | env: { 40 | isLocal: process.env.APP_ENV === "local", 41 | flowInitAccountsNo: process.env.FLOW_INIT_ACCOUNTS, 42 | flowAccountKeyId: process.env.FLOW_ACCOUNT_KEY_ID, 43 | flowAccountAddress: process.env.FLOW_ACCOUNT_ADDRESS, 44 | flowAccountPrivateKey: process.env.FLOW_ACCOUNT_PRIVATE_KEY, 45 | flowAccountPublicKey: process.env.FLOW_ACCOUNT_PUBLIC_KEY, 46 | flowAccessNode: process.env.FLOW_ACCESS_NODE, 47 | flowAvatarUrl: process.env.FLOW_AVATAR_URL, 48 | }, 49 | } 50 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fcl-dev-wallet", 3 | "version": "0.3.4", 4 | "private": true, 5 | "license": "Apache-2.0", 6 | "scripts": { 7 | "dev": "concurrently \"APP_ENV=local next dev --port 8701\" \"go run ./go/wallet/cmd/main.go --port 8799\" --raw", 8 | "build": "next build", 9 | "start": "concurrently \"next start\" \"go run ./go/wallet/cmd/main.go --port 8799\" --raw", 10 | "lint": "eslint .", 11 | "lint:fix": "eslint . --fix", 12 | "tsc": "./node_modules/typescript/bin/tsc", 13 | "test": "cypress run", 14 | "check": "eslint . && npm run tsc", 15 | "export": "next export", 16 | "bundle": "next build && next export", 17 | "zip": "cd ./out && zip -FSr ../go/wallet/bundle.zip . && cd ..", 18 | "config": "cp .env.development ./go/wallet", 19 | "go-server": "npm run bundle && npm run zip && npm run config && go run ./go/wallet/cmd/main.go" 20 | }, 21 | "dependencies": { 22 | "@emotion/react": "^11.9.0", 23 | "@emotion/styled": "^11.8.1", 24 | "@headlessui/react": "^1.3.0", 25 | "@mdx-js/react": "^1.6.22", 26 | "@monaco-editor/react": "^4.2.1", 27 | "@onflow/fcl": "^1.10.0-alpha.4", 28 | "@onflow/types": "^1.3.0-alpha.2", 29 | "@theme-ui/color": "^0.14.5", 30 | "@theme-ui/css": "^0.14.5", 31 | "@theme-ui/match-media": "^0.14.5", 32 | "cors": "^2.8.5", 33 | "elliptic": "^6.5.4", 34 | "formik": "^2.2.9", 35 | "monaco-editor": "^0.27.0", 36 | "namegenerator": "^0.1.1", 37 | "next": "latest", 38 | "react": "17.0.2", 39 | "react-dom": "17.0.2", 40 | "react-image": "^4.0.3", 41 | "sha3": "^2.1.4", 42 | "styled-components": "^5.2.1", 43 | "theme-ui": "^0.14.5", 44 | "yup": "^0.32.9" 45 | }, 46 | "devDependencies": { 47 | "@emotion/babel-plugin": "^11.9.2", 48 | "@types/cors": "^2.8.12", 49 | "@types/elliptic": "^6.4.12", 50 | "@types/node": "^15.12.5", 51 | "@types/react": "^17.0.11", 52 | "@typescript-eslint/eslint-plugin": "^4.28.1", 53 | "@typescript-eslint/parser": "^4.28.1", 54 | "babel-eslint": "^10.1.0", 55 | "babel-plugin-react-remove-properties": "^0.3.0", 56 | "concurrently": "^8.2.0", 57 | "cypress": "^8.0.0", 58 | "cypress-iframe": "^1.0.1", 59 | "eslint": "^7.32.0", 60 | "eslint-config-next": "^11.0.1", 61 | "eslint-config-prettier": "^8.3.0", 62 | "eslint-plugin-prettier": "^3.4.0", 63 | "eslint-plugin-react": "^7.24.0", 64 | "eslint-plugin-react-hooks": "^4.2.0", 65 | "prettier": "^2.3.2", 66 | "rimraf": "^5.0.1", 67 | "typescript": "^4.3.4" 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import {AppProps} from "next/app" 2 | import theme from "src/theme" 3 | import {ThemeProvider} from "theme-ui" 4 | import "../styles/globals.css" 5 | import "./fonts.css" 6 | 7 | import {ConfigContextProvider} from "contexts/ConfigContext" 8 | 9 | function MyApp({Component, pageProps}: AppProps) { 10 | return ( 11 | 12 | 13 | 14 | 15 | 16 | ) 17 | } 18 | 19 | export default MyApp 20 | -------------------------------------------------------------------------------- /pages/fcl/authn-refresh.tsx: -------------------------------------------------------------------------------- 1 | /** @jsxImportSource theme-ui */ 2 | 3 | import {AuthnRefreshContextProvider} from "contexts/AuthnRefreshContext" 4 | import useAuthnRefreshContext from "hooks/useAuthnRefreshContext" 5 | import {refreshAuthn} from "src/accountAuth" 6 | import Dialog from "components/Dialog" 7 | import useConfig from "hooks/useConfig" 8 | import {getBaseUrl} from "src/utils" 9 | 10 | function AuthnRefreshDialog() { 11 | const data = useAuthnRefreshContext() 12 | const baseUrl = getBaseUrl() 13 | const {flowAccountPrivateKey} = useConfig() 14 | 15 | if (data) { 16 | const {address, keyId, scopes, nonce, appIdentifier} = data 17 | 18 | refreshAuthn( 19 | baseUrl, 20 | flowAccountPrivateKey, 21 | address, 22 | keyId, 23 | scopes, 24 | nonce, 25 | appIdentifier 26 | ) 27 | } 28 | 29 | // TODO: improve UI 30 | // e.g. add prompt to confirm reauthentication 31 | return Refreshing... 32 | } 33 | 34 | function AuthnRefresh() { 35 | return ( 36 | 37 | 38 | 39 | ) 40 | } 41 | 42 | export default AuthnRefresh 43 | -------------------------------------------------------------------------------- /pages/fcl/authn.tsx: -------------------------------------------------------------------------------- 1 | /** @jsxImportSource theme-ui */ 2 | import AccountForm from "components/AccountForm" 3 | import AccountsList from "components/AccountsList" 4 | import Dialog, {styles as dialogStyles} from "components/Dialog" 5 | import {AuthnContextProvider} from "contexts/AuthnContext" 6 | import useAccounts from "hooks/useAccounts" 7 | import {Account, NewAccount} from "src/accounts" 8 | import {useState} from "react" 9 | import {Err} from "src/comps/err.comp" 10 | import useConfig from "hooks/useConfig" 11 | import {Spinner} from "../../components/Spinner" 12 | 13 | function AuthnDialog({ 14 | flowAccountAddress, 15 | flowAccountPrivateKey, 16 | avatarUrl, 17 | }: { 18 | flowAccountAddress: string 19 | flowAccountPrivateKey: string 20 | avatarUrl: string 21 | }) { 22 | const [editingAccount, setEditingAccount] = useState< 23 | Account | NewAccount | null 24 | >(null) 25 | 26 | const { 27 | data: accounts, 28 | error, 29 | isLoading, 30 | refresh: refreshAccounts, 31 | } = useAccounts() 32 | 33 | const [createdAccountAddress, setCreatedAccountAddress] = useState< 34 | string | null 35 | >(null) 36 | 37 | const onEditAccount = (account: Account | NewAccount) => { 38 | setCreatedAccountAddress(null) 39 | setEditingAccount(account) 40 | } 41 | 42 | const onSubmitComplete = (createdAccountAddress?: string) => { 43 | setEditingAccount(null) 44 | if (createdAccountAddress) { 45 | setCreatedAccountAddress(createdAccountAddress) 46 | } 47 | refreshAccounts() 48 | } 49 | 50 | const onCancel = () => setEditingAccount(null) 51 | 52 | if (error) return 53 | if (isLoading) return 54 | 55 | return ( 56 | 57 | {editingAccount ? ( 58 | 65 | ) : ( 66 |
67 | 75 |
76 | )} 77 |
78 | ) 79 | } 80 | 81 | function Authn() { 82 | const {flowAvatarUrl, flowAccountAddress, flowAccountPrivateKey} = useConfig() 83 | 84 | return ( 85 | 86 | 91 | 92 | ) 93 | } 94 | 95 | export default Authn 96 | -------------------------------------------------------------------------------- /pages/fcl/authz.tsx: -------------------------------------------------------------------------------- 1 | /** @jsxImportSource theme-ui */ 2 | import {WalletUtils} from "@onflow/fcl" 3 | import AuthzActions from "components/AuthzActions" 4 | import AuthzDetails from "components/AuthzDetails" 5 | import AuthzHeader from "components/AuthzHeader" 6 | import Code from "components/Code" 7 | import Dialog from "components/Dialog" 8 | import {AuthzContextProvider} from "contexts/AuthzContext" 9 | import useConfig from "hooks/useConfig" 10 | import useAuthzContext from "hooks/useAuthzContext" 11 | import {useState} from "react" 12 | import {sign} from "src/crypto" 13 | import {getBaseUrl, isBackchannel, updatePollingSession} from "src/utils" 14 | 15 | function AuthzContent({ 16 | flowAccountAddress, 17 | flowAccountPrivateKey, 18 | avatarUrl, 19 | }: { 20 | flowAccountAddress: string 21 | flowAccountPrivateKey: string 22 | avatarUrl: string 23 | }) { 24 | const baseUrl = getBaseUrl() 25 | const {isExpanded, codePreview} = useAuthzContext() 26 | const {currentUser, proposalKey, message} = useAuthzContext() 27 | const [isLoading, setIsLoading] = useState(false) 28 | 29 | const onApprove = () => { 30 | setIsLoading(true) 31 | 32 | const signature = sign(flowAccountPrivateKey, message) 33 | 34 | const response = { 35 | f_type: "PollingResponse", 36 | f_vsn: "1.0.0", 37 | status: "APPROVED", 38 | reason: null, 39 | data: new WalletUtils.CompositeSignature( 40 | currentUser.address, 41 | proposalKey.keyId, 42 | signature 43 | ), 44 | } 45 | 46 | if (isBackchannel()) { 47 | updatePollingSession(baseUrl, response) 48 | } else { 49 | WalletUtils.sendMsgToFCL("FCL:VIEW:RESPONSE", response) 50 | } 51 | } 52 | 53 | const onDecline = () => { 54 | const declineResponse = { 55 | f_type: "PollingResponse", 56 | f_vsn: "1.0.0", 57 | status: "DECLINED", 58 | reason: "User declined", 59 | data: null, 60 | } 61 | 62 | if (isBackchannel()) { 63 | updatePollingSession(baseUrl, declineResponse) 64 | } else { 65 | WalletUtils.sendMsgToFCL("FCL:VIEW:RESPONSE", declineResponse) 66 | } 67 | } 68 | 69 | return ( 70 | 78 | ) 79 | } 80 | footer={ 81 | !isExpanded && ( 82 | 87 | ) 88 | } 89 | > 90 | {!!codePreview ? ( 91 | 92 | ) : ( 93 | 94 | )} 95 | 96 | ) 97 | } 98 | 99 | function Authz() { 100 | const {flowAvatarUrl, flowAccountAddress, flowAccountPrivateKey} = useConfig() 101 | return ( 102 | 103 | 108 | 109 | ) 110 | } 111 | 112 | export default Authz 113 | -------------------------------------------------------------------------------- /pages/fcl/user-sig.tsx: -------------------------------------------------------------------------------- 1 | /** @jsxImportSource theme-ui */ 2 | import {WalletUtils} from "@onflow/fcl" 3 | import AuthzActions from "components/AuthzActions" 4 | import AuthzDetailsTable, {AuthzDetailsRow} from "components/AuthzDetailsTable" 5 | import Dialog from "components/Dialog" 6 | import {sign} from "src/crypto" 7 | import {Box, Themed} from "theme-ui" 8 | import getWalletConfig from "hooks/useConfig" 9 | import {useFclData} from "hooks/useFclData" 10 | import {getBaseUrl, isBackchannel, updatePollingSession} from "src/utils" 11 | 12 | type AuthReadyResponseSignable = { 13 | data: { 14 | addr: string 15 | keyId: string 16 | } 17 | message: string 18 | } 19 | 20 | type AuthReadyResponseData = { 21 | type: string 22 | body: AuthReadyResponseSignable 23 | } 24 | 25 | function userSignature( 26 | signable: AuthReadyResponseSignable, 27 | privateKey: string 28 | ) { 29 | const { 30 | message, 31 | data: {addr, keyId}, 32 | } = signable 33 | 34 | const rightPaddedHexBuffer = (value: string, pad: number) => 35 | Buffer.from(value.padEnd(pad * 2, "0"), "hex") 36 | 37 | const USER_DOMAIN_TAG = rightPaddedHexBuffer( 38 | Buffer.from("FLOW-V0.0-user").toString("hex"), 39 | 32 40 | ).toString("hex") 41 | 42 | const prependUserDomainTag = (msg: string) => USER_DOMAIN_TAG + msg 43 | 44 | return { 45 | addr: addr, 46 | keyId: keyId, 47 | signature: sign(privateKey, prependUserDomainTag(message)), 48 | } 49 | } 50 | 51 | export default function UserSign() { 52 | const baseUrl = getBaseUrl() 53 | const {flowAccountPrivateKey} = getWalletConfig() 54 | 55 | const signable = useFclData({ 56 | transformFrontchannel: (data: AuthReadyResponseData) => { 57 | return data.body 58 | }, 59 | }) 60 | 61 | function onApprove() { 62 | const {addr, keyId, signature} = userSignature( 63 | signable!, 64 | flowAccountPrivateKey 65 | ) 66 | 67 | const response = { 68 | f_type: "PollingResponse", 69 | f_vsn: "1.0.0", 70 | status: "APPROVED", 71 | reason: null, 72 | data: new WalletUtils.CompositeSignature(addr, keyId, signature), 73 | } 74 | 75 | if (isBackchannel()) { 76 | updatePollingSession(baseUrl, response) 77 | } else { 78 | WalletUtils.sendMsgToFCL("FCL:VIEW:RESPONSE", response) 79 | } 80 | } 81 | 82 | const onDecline = () => { 83 | const declineResponse = { 84 | f_type: "PollingResponse", 85 | f_vsn: "1.0.0", 86 | status: "DECLINED", 87 | reason: "User declined", 88 | data: null, 89 | } 90 | 91 | if (isBackchannel()) { 92 | updatePollingSession(baseUrl, declineResponse) 93 | } else { 94 | WalletUtils.sendMsgToFCL("FCL:VIEW:RESPONSE", declineResponse) 95 | } 96 | } 97 | 98 | return ( 99 | 106 | } 107 | > 108 | Sign Message 109 | 110 | Please prove that you have access to this wallet. 111 |
112 | This won’t cost you any FLOW. 113 |
114 | 115 | 116 | 117 | Address 118 | {signable?.data.addr} 119 | 120 | 121 | Key ID 122 | {signable?.data.keyId} 123 | 124 | 125 | Message (Hex) 126 | {signable?.message} 127 | 128 | 129 | 130 |
131 | ) 132 | } 133 | -------------------------------------------------------------------------------- /pages/fonts.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: Overpass, system-ui, -apple-system, BlinkMacSystemFont, 3 | "Segoe UI", Roboto, "Helvetica Neue", sans-serif; 4 | } 5 | 6 | latin-ext @font-face { 7 | font-family: "Overpass"; 8 | font-style: normal; 9 | font-weight: 400; 10 | font-display: swap; 11 | src: url("/fonts/overpass/overpass-regular.woff2") format("woff2"); 12 | unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, 13 | U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF; 14 | } 15 | /* latin */ 16 | @font-face { 17 | font-family: "Overpass"; 18 | font-style: normal; 19 | font-weight: 400; 20 | font-display: swap; 21 | src: url("/fonts/overpass/overpass-regular.woff2") format("woff2"); 22 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, 23 | U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, 24 | U+FEFF, U+FFFD; 25 | } 26 | /* latin-ext */ 27 | @font-face { 28 | font-family: "Overpass"; 29 | font-style: normal; 30 | font-weight: 600; 31 | font-display: swap; 32 | src: url("/fonts/overpass/overpass-semibold.woff2") format("woff2"); 33 | unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, 34 | U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF; 35 | } 36 | /* latin */ 37 | @font-face { 38 | font-family: "Overpass"; 39 | font-style: normal; 40 | font-weight: 600; 41 | font-display: swap; 42 | src: url("/fonts/overpass/overpass-semibold.woff2") format("woff2"); 43 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, 44 | U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, 45 | U+FEFF, U+FFFD; 46 | } 47 | /* latin-ext */ 48 | @font-face { 49 | font-family: "Overpass"; 50 | font-style: normal; 51 | font-weight: 700; 52 | font-display: swap; 53 | src: url("/fonts/overpass/overpass-bold.woff2") format("woff2"); 54 | unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, 55 | U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF; 56 | } 57 | /* latin */ 58 | @font-face { 59 | font-family: "Overpass"; 60 | font-style: normal; 61 | font-weight: 700; 62 | font-display: swap; 63 | src: url("/fonts/overpass/overpass-bold.woff2") format("woff2"); 64 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, 65 | U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, 66 | U+FEFF, U+FFFD; 67 | } 68 | /* latin-ext */ 69 | @font-face { 70 | font-family: "Overpass Mono"; 71 | font-style: normal; 72 | font-weight: 400; 73 | font-display: swap; 74 | src: url("/fonts/overpass/overpass-mono-regular.woff2") format("woff2"); 75 | unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, 76 | U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF; 77 | } 78 | /* latin */ 79 | @font-face { 80 | font-family: "Overpass Mono"; 81 | font-style: normal; 82 | font-weight: 400; 83 | font-display: swap; 84 | src: url("/fonts/overpass/overpass-mono-regular.woff2") format("woff2"); 85 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, 86 | U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, 87 | U+FEFF, U+FFFD; 88 | } 89 | 90 | /* bebas-neue-regular - latin */ 91 | @font-face { 92 | font-family: "Bebas Neue"; 93 | font-style: normal; 94 | font-weight: 400; 95 | src: url("/fonts/bebas-neue-v2-latin/bebas-neue-v2-latin-regular.eot"); /* IE9 Compat Modes */ 96 | src: local(""), 97 | url("/fonts/bebas-neue-v2-latin/bebas-neue-v2-latin-regular.eot?#iefix") 98 | format("embedded-opentype"), 99 | /* IE6-IE8 */ 100 | url("/fonts/bebas-neue-v2-latin/bebas-neue-v2-latin-regular.woff2") 101 | format("woff2"), 102 | /* Super Modern Browsers */ 103 | url("/fonts/bebas-neue-v2-latin/bebas-neue-v2-latin-regular.woff") 104 | format("woff"), 105 | /* Modern Browsers */ 106 | url("/fonts/bebas-neue-v2-latin/bebas-neue-v2-latin-regular.ttf") 107 | format("truetype"), 108 | /* Safari, Android, iOS */ 109 | url("/fonts/bebas-neue-v2-latin/bebas-neue-v2-latin-regular.svg#BebasNeue") 110 | format("svg"); /* Legacy iOS */ 111 | } 112 | -------------------------------------------------------------------------------- /pages/index.tsx: -------------------------------------------------------------------------------- 1 | import React, {useEffect} from "react" 2 | import "../src/harness/config" 3 | import decorate from "../src/harness/decorate" 4 | import {COMMANDS} from "../src/harness/cmds" 5 | import useCurrentUser from "../src/harness/hooks/use-current-user" 6 | import useConfig from "../src/harness/hooks/use-config" 7 | 8 | const renderCommand = (d: any) => { 9 | return ( 10 |
  • 11 | 12 |
  • 13 | ) 14 | } 15 | 16 | export default function Page() { 17 | useEffect(() => { 18 | decorate() 19 | }) 20 | 21 | const currentUser = useCurrentUser() 22 | const config = useConfig() 23 | 24 | return ( 25 |
    26 |
      {COMMANDS.map(renderCommand)}
    27 |
    {JSON.stringify({currentUser, config}, null, 2)}
    28 |
    29 | ) 30 | } 31 | -------------------------------------------------------------------------------- /public/back-arrow.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /public/collapse.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /public/expand.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /public/external-link.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/onflow/fcl-dev-wallet/69de8fd4c33535e48f8b5e45a8d4adc43aeb31ee/public/favicon.ico -------------------------------------------------------------------------------- /public/flow-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /public/fonts/bebas-neue-v2-latin/bebas-neue-v2-latin-regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/onflow/fcl-dev-wallet/69de8fd4c33535e48f8b5e45a8d4adc43aeb31ee/public/fonts/bebas-neue-v2-latin/bebas-neue-v2-latin-regular.woff2 -------------------------------------------------------------------------------- /public/fonts/overpass/overpass-bold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/onflow/fcl-dev-wallet/69de8fd4c33535e48f8b5e45a8d4adc43aeb31ee/public/fonts/overpass/overpass-bold.woff2 -------------------------------------------------------------------------------- /public/fonts/overpass/overpass-mono-regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/onflow/fcl-dev-wallet/69de8fd4c33535e48f8b5e45a8d4adc43aeb31ee/public/fonts/overpass/overpass-mono-regular.woff2 -------------------------------------------------------------------------------- /public/fonts/overpass/overpass-regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/onflow/fcl-dev-wallet/69de8fd4c33535e48f8b5e45a8d4adc43aeb31ee/public/fonts/overpass/overpass-regular.woff2 -------------------------------------------------------------------------------- /public/fonts/overpass/overpass-semibold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/onflow/fcl-dev-wallet/69de8fd4c33535e48f8b5e45a8d4adc43aeb31ee/public/fonts/overpass/overpass-semibold.woff2 -------------------------------------------------------------------------------- /public/missing-app-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /public/missing-avatar-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /public/plus-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/settings.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /public/transaction.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /public/x-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /server: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/onflow/fcl-dev-wallet/69de8fd4c33535e48f8b5e45a8d4adc43aeb31ee/server -------------------------------------------------------------------------------- /src/accountAuth.ts: -------------------------------------------------------------------------------- 1 | import {WalletUtils} from "@onflow/fcl" 2 | import {ConnectedAppConfig} from "hooks/useConnectedAppConfig" 3 | import {Account} from "src/accounts" 4 | import {sign} from "src/crypto" 5 | import {buildServices} from "./services" 6 | import {isBackchannel, updatePollingSession} from "./utils" 7 | 8 | type AccountProofData = { 9 | address: string 10 | nonce: string | undefined 11 | appIdentifier: string | undefined 12 | } 13 | 14 | const getSignature = (key: string, accountProofData: AccountProofData) => { 15 | return sign(key, WalletUtils.encodeAccountProof(accountProofData)) 16 | } 17 | 18 | function proveAuthn( 19 | flowAccountPrivateKey: string, 20 | address: string, 21 | keyId: number, 22 | nonce: string | undefined, 23 | appIdentifier: string | undefined 24 | ) { 25 | return { 26 | addr: address, 27 | keyId: keyId, 28 | signature: getSignature(flowAccountPrivateKey, { 29 | address, 30 | nonce, 31 | appIdentifier, 32 | }), 33 | } 34 | } 35 | 36 | export async function refreshAuthn( 37 | baseUrl: string, 38 | flowAccountPrivateKey: string, 39 | address: string, 40 | keyId: number, 41 | scopes: Set, 42 | nonce: string | undefined, 43 | appIdentifier: string | undefined 44 | ) { 45 | const signature = getSignature(flowAccountPrivateKey, { 46 | address, 47 | nonce, 48 | appIdentifier, 49 | }) 50 | 51 | const compSig = new WalletUtils.CompositeSignature(address, keyId, signature) 52 | 53 | const services = buildServices({ 54 | baseUrl, 55 | address, 56 | nonce, 57 | scopes, 58 | compSig, 59 | keyId, 60 | includeRefresh: false, 61 | }) 62 | 63 | WalletUtils.approve({ 64 | f_type: "PollingResponse", 65 | f_vsn: "1.0.0", 66 | addr: address, 67 | services, 68 | }) 69 | } 70 | 71 | export async function chooseAccount( 72 | baseUrl: string, 73 | flowAccountPrivateKey: string, 74 | account: Account, 75 | scopes: Set, 76 | connectedAppConfig: ConnectedAppConfig 77 | ) { 78 | const {address, keyId} = account 79 | const {nonce, appIdentifier} = connectedAppConfig.body 80 | const {client} = connectedAppConfig.config 81 | 82 | let compSig 83 | if (nonce) { 84 | const {addr, signature} = proveAuthn( 85 | flowAccountPrivateKey, 86 | address, 87 | keyId!, 88 | nonce, 89 | appIdentifier 90 | ) 91 | compSig = new WalletUtils.CompositeSignature(addr, keyId, signature) 92 | } 93 | 94 | const services = buildServices({ 95 | baseUrl, 96 | address, 97 | nonce, 98 | scopes, 99 | compSig, 100 | keyId, 101 | includeRefresh: false, 102 | client, 103 | }) 104 | 105 | localStorage.setItem("connectedAppConfig", JSON.stringify(connectedAppConfig)) 106 | 107 | const data = { 108 | addr: address, 109 | services, 110 | } 111 | 112 | const message = { 113 | f_type: "PollingResponse", 114 | f_vsn: "1.0.0", 115 | status: "APPROVED", 116 | data, 117 | } 118 | 119 | if (isBackchannel()) { 120 | updatePollingSession(baseUrl, message) 121 | } else { 122 | WalletUtils.sendMsgToFCL("FCL:VIEW:RESPONSE", message) 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/accountGenerator.ts: -------------------------------------------------------------------------------- 1 | import Namegenerator from "namegenerator" 2 | import {NewAccount} from "src/accounts" 3 | 4 | const ALPHABET = [ 5 | "A", 6 | "B", 7 | "C", 8 | "D", 9 | "E", 10 | "F", 11 | "G", 12 | "H", 13 | "I", 14 | "J", 15 | "K", 16 | "L", 17 | "M", 18 | "N", 19 | "O", 20 | "P", 21 | "Q", 22 | "R", 23 | "S", 24 | "T", 25 | "U", 26 | "V", 27 | "W", 28 | "X", 29 | "Y", 30 | "Z", 31 | ] 32 | 33 | export function accountLabelGenerator(index: number) { 34 | const namegenerator = new Namegenerator(ALPHABET) 35 | return `Account ${namegenerator.nameForId(index)}` 36 | } 37 | 38 | export default function accountGenerator(index: number): NewAccount { 39 | return {type: "ACCOUNT", label: accountLabelGenerator(index), scopes: []} 40 | } 41 | -------------------------------------------------------------------------------- /src/accounts.ts: -------------------------------------------------------------------------------- 1 | import * as fcl from "@onflow/fcl" 2 | import * as t from "@onflow/types" 3 | import {Optional} from "types" 4 | 5 | import getAccountsScript from "cadence/scripts/getAccounts.cdc" 6 | import getAccountScript from "cadence/scripts/getAccount.cdc" 7 | import newAccountTransaction from "cadence/transactions/newAccount.cdc" 8 | import updateAccountTransaction from "cadence/transactions/updateAccount.cdc" 9 | import fundAccountFLOWTransaction from "cadence/transactions/fundFLOW.cdc" 10 | 11 | import {authz} from "src/authz" 12 | import {FLOW_EVENT_TYPES} from "src/constants" 13 | 14 | import {FLOW_TYPE, TokenType, TokenTypes} from "src/constants" 15 | 16 | export type Account = { 17 | type: "ACCOUNT" 18 | address: string 19 | scopes: string[] 20 | keyId?: number 21 | label?: string 22 | balance?: string 23 | } 24 | 25 | export type NewAccount = Optional 26 | 27 | type CreatedAccountResponse = { 28 | events: CreatedAccountEvent[] 29 | } 30 | 31 | type CreatedAccountEvent = { 32 | type: string 33 | data: { 34 | address: string 35 | } 36 | } 37 | 38 | export async function getAccount(address: string) { 39 | return await fcl 40 | .send([ 41 | fcl.script(getAccountScript), 42 | fcl.args([fcl.arg(address, t.Address)]), 43 | ]) 44 | .then(fcl.decode) 45 | } 46 | 47 | export async function getAccounts(config: {flowAccountAddress: string}) { 48 | const {flowAccountAddress} = config 49 | 50 | fcl.config().all().then(console.log) 51 | 52 | const accounts = await fcl 53 | .send([fcl.script(getAccountsScript)]) 54 | .then(fcl.decode) 55 | 56 | const serviceAccount = accounts.find( 57 | (acct: Account) => acct.address === flowAccountAddress 58 | ) 59 | 60 | const userAccounts = accounts 61 | .filter((acct: Account) => acct.address !== flowAccountAddress) 62 | .sort((a: Account, b: Account) => a.label!.localeCompare(b.label!)) 63 | 64 | return [serviceAccount, ...userAccounts] 65 | } 66 | 67 | export async function newAccount( 68 | config: { 69 | flowAccountAddress: string 70 | flowAccountKeyId: string 71 | flowAccountPrivateKey: string 72 | flowInitAccountBalance: string 73 | }, 74 | label: string, 75 | scopes: [string] 76 | ) { 77 | const { 78 | flowAccountAddress, 79 | flowAccountKeyId, 80 | flowAccountPrivateKey, 81 | flowInitAccountBalance, 82 | } = config 83 | 84 | const authorization = await authz( 85 | flowAccountAddress, 86 | flowAccountKeyId, 87 | flowAccountPrivateKey 88 | ) 89 | 90 | const txId = await fcl 91 | .send([ 92 | fcl.transaction(newAccountTransaction), 93 | fcl.args([ 94 | fcl.arg(label, t.String), 95 | fcl.arg(scopes, t.Array(t.String)), 96 | fcl.arg(flowInitAccountBalance, t.UFix64), 97 | ]), 98 | fcl.authorizations([authorization]), 99 | fcl.proposer(authorization), 100 | fcl.payer(authorization), 101 | fcl.limit(9999), 102 | ]) 103 | .then(fcl.decode) 104 | 105 | const txStatus: CreatedAccountResponse = await fcl.tx(txId).onceSealed() 106 | 107 | const createdAccountEvent = txStatus.events.find( 108 | (e: CreatedAccountEvent) => e.type === FLOW_EVENT_TYPES.accountCreated 109 | ) 110 | if (!createdAccountEvent?.data?.address) throw "Account address not created" 111 | 112 | return createdAccountEvent.data.address 113 | } 114 | 115 | export async function updateAccount( 116 | config: { 117 | flowAccountAddress: string 118 | flowAccountKeyId: string 119 | flowAccountPrivateKey: string 120 | }, 121 | address: string, 122 | label: string, 123 | scopes: [string] 124 | ) { 125 | const {flowAccountAddress, flowAccountKeyId, flowAccountPrivateKey} = config 126 | address = fcl.withPrefix(address) 127 | 128 | const authorization = await authz( 129 | flowAccountAddress, 130 | flowAccountKeyId, 131 | flowAccountPrivateKey 132 | ) 133 | 134 | const txId = await fcl 135 | .send([ 136 | fcl.transaction(updateAccountTransaction), 137 | fcl.args([ 138 | fcl.arg(address, t.Address), 139 | fcl.arg(label, t.String), 140 | fcl.arg(scopes, t.Array(t.String)), 141 | ]), 142 | fcl.proposer(authorization), 143 | fcl.payer(authorization), 144 | fcl.limit(9999), 145 | ]) 146 | .then(fcl.decode) 147 | 148 | await fcl.tx(txId).onceSealed() 149 | } 150 | 151 | type Token = { 152 | tx: string 153 | amount: string 154 | } 155 | 156 | type Tokens = Record 157 | 158 | export const TOKEN_FUNDING_AMOUNTS: Record = { 159 | FLOW: "100.0", 160 | } 161 | 162 | export const tokens: Tokens = { 163 | FLOW: { 164 | tx: fundAccountFLOWTransaction, 165 | amount: TOKEN_FUNDING_AMOUNTS[FLOW_TYPE], 166 | }, 167 | } 168 | 169 | export async function fundAccount( 170 | config: { 171 | flowAccountAddress: string 172 | flowAccountKeyId: string 173 | flowAccountPrivateKey: string 174 | }, 175 | address: string, 176 | token: TokenType 177 | ) { 178 | const {flowAccountAddress, flowAccountKeyId, flowAccountPrivateKey} = config 179 | 180 | if (!["FLOW"].includes(token)) { 181 | throw "Incorrect TokenType" 182 | } 183 | 184 | const minterAuthz = await authz( 185 | flowAccountAddress, 186 | flowAccountKeyId, 187 | flowAccountPrivateKey 188 | ) 189 | 190 | const {tx, amount} = tokens[token] 191 | 192 | const authorizations = [minterAuthz] 193 | 194 | const txId = await fcl 195 | .send([ 196 | fcl.transaction(tx), 197 | fcl.args([fcl.arg(address, t.Address), fcl.arg(amount, t.UFix64)]), 198 | fcl.proposer(minterAuthz), 199 | fcl.authorizations(authorizations), 200 | fcl.payer(minterAuthz), 201 | fcl.limit(9999), 202 | ]) 203 | .then(fcl.decode) 204 | 205 | await fcl.tx(txId).onceSealed() 206 | } 207 | -------------------------------------------------------------------------------- /src/authz.ts: -------------------------------------------------------------------------------- 1 | import * as fcl from "@onflow/fcl" 2 | import {Account} from "src/accounts" 3 | import {sign} from "./crypto" 4 | 5 | // alias Hex = String 6 | // type signable = { message: Hex, voucher: voucher } 7 | // type compositeSignature = { addr: String, keyId: Number, signature: Hex } 8 | // signingFunction :: signable -> compositeSignature 9 | // type account = { tempId: String, addr: String, keyId: Number, signingFunction: signingFunction } 10 | // authz :: account -> account 11 | 12 | export async function authz( 13 | flowAccountAddress: string, 14 | flowAccountKeyId: string, 15 | flowAccountPrivateKey: string 16 | ) { 17 | return (account: Account) => { 18 | return { 19 | // there is stuff in the account that is passed in 20 | // you need to make sure its part of what is returned 21 | ...account, 22 | // the tempId here is a very special and specific case. 23 | // what you are usually looking for in a tempId value is a unique string for the address and keyId as a pair 24 | // if you have no idea what this is doing, or what it does, or are getting an error in your own 25 | // implementation of an authorization function it is recommended that you use a string with the address and keyId in it. 26 | // something like... tempId: `${address}-${keyId}` 27 | tempId: "SERVICE_ACCOUNT", 28 | addr: fcl.sansPrefix(flowAccountAddress), // eventually it wont matter if this address has a prefix or not, sadly :'( currently it does matter. 29 | keyId: Number(flowAccountKeyId), // must be a number 30 | signingFunction: (signable: {message: string}) => ({ 31 | addr: fcl.withPrefix(flowAccountAddress), // must match the address that requested the signature, but with a prefix 32 | keyId: Number(flowAccountKeyId), // must match the keyId in the account that requested the signature 33 | signature: sign(flowAccountPrivateKey, signable.message), // signable.message |> hexToBinArray |> hash |> sign |> binArrayToHex 34 | // if you arent in control of the transaction that is being signed we recommend constructing the 35 | // message from signable.voucher using the @onflow/encode module 36 | }), 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/avatar.ts: -------------------------------------------------------------------------------- 1 | export const avatar = (avatarUrl: string, hash: string): string => 2 | encodeURI(`${avatarUrl}avatar/${hash}.svg`) 3 | -------------------------------------------------------------------------------- /src/balance.ts: -------------------------------------------------------------------------------- 1 | // Formats string representations of UFix64 numbers. The maximum `UFix64` value is 184467440737.09551615 2 | export function formattedBalance(amount: string) { 3 | if (amount.length === 0) return "0" 4 | const [integer, decimal] = amount.split(".") 5 | // Format the integer separately to avoid rounding 6 | const formattedInteger = parseFloat(integer).toLocaleString("en-US") 7 | return [formattedInteger, decimal?.replace(/0+$/, "")] 8 | .filter(Boolean) 9 | .join(".") 10 | } 11 | -------------------------------------------------------------------------------- /src/cadence.ts: -------------------------------------------------------------------------------- 1 | // Imported from https://github.com/onflow/flow-playground/blob/master/src/util/cadence.ts 2 | 3 | import monacoEditor, {languages} from "monaco-editor/esm/vs/editor/editor.api" 4 | 5 | export const CADENCE_LANGUAGE_ID = "cadence" 6 | 7 | export default function configureCadence(monaco: typeof monacoEditor) { 8 | monaco.languages.register({ 9 | id: CADENCE_LANGUAGE_ID, 10 | extensions: [".cdc"], 11 | aliases: ["CDC", "cdc"], 12 | }) 13 | 14 | const languageDef: languages.IMonarchLanguage & { 15 | keywords: string[] 16 | typeKeywords: string[] 17 | operators: string[] 18 | symbols: RegExp 19 | escapes: RegExp 20 | digits: RegExp 21 | octaldigits: RegExp 22 | binarydigits: RegExp 23 | hexdigits: RegExp 24 | } = { 25 | keywords: [ 26 | "if", 27 | "else", 28 | "return", 29 | "continue", 30 | "break", 31 | "while", 32 | "pre", 33 | "post", 34 | "prepare", 35 | "execute", 36 | "import", 37 | "from", 38 | "create", 39 | "destroy", 40 | "priv", 41 | "pub", 42 | "get", 43 | "set", 44 | "log", 45 | "emit", 46 | "event", 47 | "init", 48 | "struct", 49 | "interface", 50 | "fun", 51 | "let", 52 | "var", 53 | "resource", 54 | "access", 55 | "all", 56 | "contract", 57 | "self", 58 | "transaction", 59 | ], 60 | 61 | typeKeywords: [ 62 | "AnyStruct", 63 | "AnyResource", 64 | "Void", 65 | "Never", 66 | "String", 67 | "Character", 68 | "Bool", 69 | "Self", 70 | "Int8", 71 | "Int16", 72 | "Int32", 73 | "Int64", 74 | "Int128", 75 | "Int256", 76 | "UInt8", 77 | "UInt16", 78 | "UInt32", 79 | "UInt64", 80 | "UInt128", 81 | "UInt256", 82 | "Word8", 83 | "Word16", 84 | "Word32", 85 | "Word64", 86 | "Word128", 87 | "Word256", 88 | "Fix64", 89 | "UFix64", 90 | ], 91 | 92 | operators: [ 93 | "<-", 94 | "<=", 95 | ">=", 96 | "==", 97 | "!=", 98 | "+", 99 | "-", 100 | "*", 101 | "/", 102 | "%", 103 | "&", 104 | "!", 105 | "&&", 106 | "||", 107 | "?", 108 | "??", 109 | ":", 110 | "=", 111 | "@", 112 | ], 113 | 114 | // we include these common regular expressions 115 | symbols: /[=>](?!@symbols)/, "@brackets"], 146 | [ 147 | /@symbols/, 148 | { 149 | cases: { 150 | "@operators": "delimiter", 151 | "@default": "", 152 | }, 153 | }, 154 | ], 155 | 156 | // numbers 157 | [/(@digits)[eE]([\-+]?(@digits))?/, "number.float"], 158 | [/(@digits)\.(@digits)([eE][\-+]?(@digits))?/, "number.float"], 159 | [/0[xX](@hexdigits)/, "number.hex"], 160 | [/0[oO]?(@octaldigits)/, "number.octal"], 161 | [/0[bB](@binarydigits)/, "number.binary"], 162 | [/(@digits)/, "number"], 163 | 164 | // delimiter: after number because of .\d floats 165 | [/[;,.]/, "delimiter"], 166 | 167 | // strings 168 | [/"([^"\\]|\\.)*$/, "string.invalid"], // non-teminated string 169 | [/"/, "string", "@string_double"], 170 | ], 171 | 172 | whitespace: [ 173 | [/[ \t\r\n]+/, ""], 174 | [/\/\*/, "comment", "@comment"], 175 | [/\/\/.*$/, "comment"], 176 | ], 177 | 178 | comment: [ 179 | [/[^\/*]+/, "comment"], 180 | [/\*\//, "comment", "@pop"], 181 | [/[\/*]/, "comment"], 182 | ], 183 | 184 | string_double: [ 185 | [/[^\\"]+/, "string"], 186 | [/@escapes/, "string.escape"], 187 | [/\\./, "string.escape.invalid"], 188 | [/"/, "string", "@pop"], 189 | ], 190 | }, 191 | } 192 | 193 | monaco.languages.setMonarchTokensProvider(CADENCE_LANGUAGE_ID, languageDef) 194 | } 195 | -------------------------------------------------------------------------------- /src/comps/err.comp.tsx: -------------------------------------------------------------------------------- 1 | /** @jsxImportSource theme-ui */ 2 | import Dialog from "../../components/Dialog" 3 | import {Themed} from "theme-ui" 4 | 5 | export type StackError = { 6 | stack: string 7 | } 8 | 9 | export type ErrProps = { 10 | title?: string 11 | error: StackError | string 12 | } 13 | 14 | export function Err({error, title = "Error occurred"}: ErrProps) { 15 | return ( 16 | 17 | {title} 18 |
    19 |
    {getErrorMessage(error)}
    20 |
    21 |
    22 | ) 23 | } 24 | 25 | function getErrorMessage(error: StackError | string) { 26 | if (typeof error === "string") { 27 | return error 28 | } else { 29 | return error.stack 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const FLOW_EVENT_TYPES = { 2 | accountCreated: "flow.AccountCreated", 3 | } 4 | 5 | export const LABEL_MISSING_ERROR = "Label is required." 6 | export const SERVICE_ACCOUNT_LABEL = "Service Account" 7 | export const UNTITLED_APP_NAME = "Untitled Dapp" 8 | 9 | export const FLOW_TYPE = "FLOW" 10 | 11 | export type TokenTypes = typeof FLOW_TYPE 12 | export type TokenType = "FLOW" 13 | -------------------------------------------------------------------------------- /src/crypto.ts: -------------------------------------------------------------------------------- 1 | import {ec as EC} from "elliptic" 2 | import {SHA3} from "sha3" 3 | 4 | export enum HashAlgorithm { 5 | SHA2_256 = 1, 6 | SHA2_384 = 2, 7 | SHA3_256 = 3, 8 | SHA3_384 = 4, 9 | KMAC128_BLS_BLS12_381 = 5, 10 | KECCAK_256 = 6, 11 | } 12 | 13 | export enum SignAlgorithm { 14 | ECDSA_P256 = 1, 15 | ECDSA_secp256k1 = 2, 16 | BLS_BLS12_381 = 3, 17 | } 18 | 19 | const ec = new EC("p256") 20 | 21 | const hashMsgHex = (msgHex: string) => { 22 | const sha = new SHA3(256) 23 | sha.update(Buffer.from(msgHex, "hex")) 24 | return sha.digest() 25 | } 26 | 27 | export function sign(privateKey: string, msgHex: string) { 28 | const key = ec.keyFromPrivate(Buffer.from(privateKey, "hex")) 29 | const sig = key.sign(hashMsgHex(msgHex)) 30 | const n = 32 31 | const r = sig.r.toArrayLike(Buffer, "be", n) 32 | const s = sig.s.toArrayLike(Buffer, "be", n) 33 | return Buffer.concat([r, s]).toString("hex") 34 | } 35 | -------------------------------------------------------------------------------- /src/fclConfig.ts: -------------------------------------------------------------------------------- 1 | import {config} from "@onflow/fcl" 2 | import flowJSON from "../flow.json" 3 | 4 | export default function fclConfig(flowAccessNode: string) { 5 | config().put("accessNode.api", flowAccessNode).put("flow.network", "local") 6 | config().load({flowJSON}) 7 | } 8 | -------------------------------------------------------------------------------- /src/harness/cmds/index.js: -------------------------------------------------------------------------------- 1 | import * as LOGIN from "./login" 2 | import * as LOGOUT from "./logout" 3 | import * as Q1 from "./q1" 4 | import * as Q2 from "./q2" 5 | import * as M1 from "./m1" 6 | import * as M2 from "./m2" 7 | import * as US1 from "./us1" 8 | 9 | export const COMMANDS = [LOGIN, LOGOUT, Q1, Q2, M1, M2, US1] 10 | -------------------------------------------------------------------------------- /src/harness/cmds/login.js: -------------------------------------------------------------------------------- 1 | import {reauthenticate} from "@onflow/fcl" 2 | 3 | export const LABEL = "Log In" 4 | export const CMD = reauthenticate 5 | -------------------------------------------------------------------------------- /src/harness/cmds/logout.js: -------------------------------------------------------------------------------- 1 | import {unauthenticate} from "@onflow/fcl" 2 | 3 | export const LABEL = "Log Out" 4 | export const CMD = unauthenticate 5 | -------------------------------------------------------------------------------- /src/harness/cmds/m1.js: -------------------------------------------------------------------------------- 1 | import {mutate} from "@onflow/fcl" 2 | import {yup, nope} from "../util" 3 | 4 | export const LABEL = "Mutate 1 (no args)" 5 | export const CMD = async () => { 6 | // prettier-ignore 7 | return mutate({ 8 | cadence: ` 9 | transaction() { 10 | prepare(acct: AuthAccount) { 11 | log(acct) 12 | } 13 | } 14 | `, 15 | limit: 50, 16 | }).then(yup('M-1')) 17 | .catch(nope('M-1')) 18 | } 19 | -------------------------------------------------------------------------------- /src/harness/cmds/m2.js: -------------------------------------------------------------------------------- 1 | import {mutate} from "@onflow/fcl" 2 | import {yup, nope} from "../util" 3 | 4 | export const LABEL = "Mutate 2 (args)" 5 | export const CMD = async () => { 6 | // prettier-ignore 7 | return mutate({ 8 | cadence: ` 9 | transaction(a: Int, b: Int, c: Address) { 10 | prepare(acct: AuthAccount) { 11 | log(acct) 12 | log(a) 13 | log(b) 14 | log(c) 15 | } 16 | } 17 | `, 18 | args: (arg, t) => [ 19 | arg(6, t.Int), 20 | arg(7, t.Int), 21 | arg('0xba1132bc08f82fe2', t.Address), 22 | ], 23 | limit: 50, 24 | }).then(yup('M-1')) 25 | .catch(nope('M-1')) 26 | } 27 | -------------------------------------------------------------------------------- /src/harness/cmds/q1.js: -------------------------------------------------------------------------------- 1 | import {query} from "@onflow/fcl" 2 | import {yup, nope} from "../util" 3 | 4 | export const LABEL = "Query 1 (no args)" 5 | export const CMD = async () => { 6 | // prettier-ignore 7 | return query({ 8 | cadence: ` 9 | access(all) fun main(): Int { 10 | return 7 11 | } 12 | `, 13 | }).then(yup('Q-1')) 14 | .catch(nope('Q-1')) 15 | } 16 | -------------------------------------------------------------------------------- /src/harness/cmds/q2.js: -------------------------------------------------------------------------------- 1 | import {query} from "@onflow/fcl" 2 | import {yup, nope} from "../util" 3 | 4 | export const LABEL = "Query 2 (args)" 5 | export const CMD = async () => { 6 | // prettier-ignore 7 | return query({ 8 | cadence: ` 9 | access(all) fun main(a: Int, b: Int): Int { 10 | return a + b 11 | } 12 | `, 13 | args: (arg, t) => [ 14 | arg(5, t.Int), 15 | arg(7, t.Int), 16 | ], 17 | }).then(yup('Q-1')) 18 | .catch(nope('Q-1')) 19 | } 20 | -------------------------------------------------------------------------------- /src/harness/cmds/us1.js: -------------------------------------------------------------------------------- 1 | import {currentUser} from "@onflow/fcl" 2 | import {yup, nope} from "../util" 3 | 4 | export const LABEL = "User Sign 1 (No Verification)" 5 | export const CMD = async () => { 6 | const MSG = "FOO" 7 | // prettier-ignore 8 | return currentUser() 9 | .signUserMessage(Buffer.from(MSG).toString('hex')) 10 | .then(yup('US-1')) 11 | .catch(nope('US-1')) 12 | } 13 | -------------------------------------------------------------------------------- /src/harness/config.js: -------------------------------------------------------------------------------- 1 | import * as fcl from "@onflow/fcl" 2 | 3 | const USE_LOCAL = true 4 | 5 | // prettier-ignore 6 | fcl.config() 7 | .put('app.detail.title', 'Test Harness') 8 | .put('app.detail.icon', 'https://placekitten.com/g/200/200') 9 | .put('service.OpenID.scopes', 'email') 10 | .put('fcl.appDomainTag', 'harness-app') 11 | 12 | if (USE_LOCAL) { 13 | // prettier-ignore 14 | fcl.config() 15 | .put('flow.network', 'local') 16 | .put('env', 'local') 17 | .put('accessNode.api', 'http://localhost:8888') 18 | .put('discovery.wallet', 'http://localhost:8701/fcl/authn') 19 | } else { 20 | // prettier-ignore 21 | fcl.config() 22 | .put('flow.network', 'testnet') 23 | .put('env', 'testnet') 24 | .put('accessNode.api', 'https://access-testnet.onflow.org') 25 | .put('discovery.wallet', 'https://fcl-discovery.onflow.org/testnet/authn') 26 | } 27 | -------------------------------------------------------------------------------- /src/harness/decorate.js: -------------------------------------------------------------------------------- 1 | import * as fcl from "@onflow/fcl" 2 | import * as t from "@onflow/types" 3 | 4 | export default function decorate() { 5 | window.fcl = fcl 6 | window.t = t 7 | 8 | window.addEventListener("FLOW::TX", d => { 9 | // eslint-disable-next-line no-console 10 | console.log("FLOW::TX", d.detail.delta, d.detail.txId) 11 | fcl 12 | .tx(d.detail.txId) 13 | // eslint-disable-next-line no-console 14 | .subscribe(txStatus => console.log("TX:STATUS", d.detail.txId, txStatus)) 15 | }) 16 | 17 | window.addEventListener("message", d => { 18 | // eslint-disable-next-line no-console 19 | console.log("Harness Message Received", d.data) 20 | }) 21 | } 22 | -------------------------------------------------------------------------------- /src/harness/hooks/use-config.js: -------------------------------------------------------------------------------- 1 | import {useState, useEffect} from "react" 2 | import * as fcl from "@onflow/fcl" 3 | 4 | export default function useConfig() { 5 | const [config, setConfig] = useState(null) 6 | useEffect(() => fcl.config().subscribe(setConfig), []) 7 | return config 8 | } 9 | -------------------------------------------------------------------------------- /src/harness/hooks/use-current-user.js: -------------------------------------------------------------------------------- 1 | import {useState, useEffect} from "react" 2 | import * as fcl from "@onflow/fcl" 3 | 4 | export default function useCurrentUser() { 5 | const [currentUser, setCurrentUser] = useState(null) 6 | useEffect(() => fcl.currentUser().subscribe(setCurrentUser), []) 7 | return currentUser 8 | } 9 | -------------------------------------------------------------------------------- /src/harness/util.js: -------------------------------------------------------------------------------- 1 | export const yup = tag => d => { 2 | // eslint-disable-next-line no-console 3 | console.log(`${tag}`, d) 4 | return d 5 | } 6 | 7 | export const nope = tag => d => { 8 | // eslint-disable-next-line no-console 9 | console.error(`Oh No!! [${tag}]`, d) 10 | return d 11 | } 12 | -------------------------------------------------------------------------------- /src/init.ts: -------------------------------------------------------------------------------- 1 | import * as fcl from "@onflow/fcl" 2 | import * as t from "@onflow/types" 3 | import FCLContract from "cadence/contracts/FCL.cdc" 4 | import initTransaction from "cadence/transactions/init.cdc" 5 | import {accountLabelGenerator} from "src/accountGenerator" 6 | import {authz} from "src/authz" 7 | import {SERVICE_ACCOUNT_LABEL} from "src/constants" 8 | import {HashAlgorithm, SignAlgorithm} from "src/crypto" 9 | 10 | async function isInitialized(flowAccountAddress: string): Promise { 11 | try { 12 | const account = await fcl.decode( 13 | await fcl.send([fcl.getAccount(flowAccountAddress)]) 14 | ) 15 | 16 | if (account["contracts"]["FCL"]) { 17 | return true 18 | } 19 | 20 | return false 21 | } catch (error) { 22 | return false 23 | } 24 | } 25 | 26 | export async function initializeWallet(config: { 27 | flowAccountAddress: string 28 | flowAccountKeyId: string 29 | flowAccountPrivateKey: string 30 | flowAccountPublicKey: string 31 | flowInitAccountsNo: number 32 | }) { 33 | const { 34 | flowAccountAddress, 35 | flowAccountKeyId, 36 | flowAccountPrivateKey, 37 | flowAccountPublicKey, 38 | flowInitAccountsNo, 39 | } = config 40 | 41 | const initialized = await isInitialized(flowAccountAddress) 42 | 43 | if (initialized) { 44 | return 45 | } 46 | 47 | const autoGeneratedLabels = [...Array(flowInitAccountsNo)].map((_n, i) => 48 | accountLabelGenerator(i) 49 | ) 50 | 51 | const initAccountsLabels = [SERVICE_ACCOUNT_LABEL, ...autoGeneratedLabels] 52 | 53 | const authorization = await authz( 54 | flowAccountAddress, 55 | flowAccountKeyId, 56 | flowAccountPrivateKey 57 | ) 58 | 59 | const txId = await fcl 60 | .send([ 61 | fcl.transaction(initTransaction), 62 | fcl.args([ 63 | fcl.arg(FCLContract, t.String), 64 | fcl.arg(flowAccountPublicKey, t.String), 65 | fcl.arg(HashAlgorithm.SHA3_256, t.UInt8), 66 | fcl.arg(SignAlgorithm.ECDSA_P256, t.UInt8), 67 | fcl.arg(initAccountsLabels, t.Array(t.String)), 68 | ]), 69 | fcl.proposer(authorization), 70 | fcl.payer(authorization), 71 | fcl.authorizations([authorization]), 72 | fcl.limit(9999), 73 | ]) 74 | .then(fcl.decode) 75 | 76 | await fcl.tx(txId).onceSealed() 77 | 78 | // TODO: is this code block needed? 79 | fcl 80 | .account(flowAccountAddress) 81 | .then((d: {contracts: Record}) => { 82 | // eslint-disable-next-line no-console 83 | console.log("ACCOUNT", Object.keys(d.contracts)) 84 | }) 85 | } 86 | -------------------------------------------------------------------------------- /src/middleware.ts: -------------------------------------------------------------------------------- 1 | import Cors from "cors" 2 | import {NextApiRequest, NextApiResponse} from "next" 3 | 4 | type CorsMiddleware = ( 5 | req: NextApiRequest, 6 | res: { 7 | statusCode?: number | undefined 8 | setHeader(key: string, value: string): unknown 9 | end(): unknown 10 | }, 11 | next: (err?: unknown) => unknown 12 | ) => void 13 | 14 | // Helper method to wait for a middleware to execute before continuing 15 | // And to throw an error when an error happens in a middleware 16 | export default function initMiddleware(middleware: CorsMiddleware) { 17 | return (req: NextApiRequest, res: NextApiResponse) => 18 | new Promise((resolve, reject) => { 19 | const result = () => { 20 | if (result instanceof Error) { 21 | return reject(result) 22 | } 23 | return resolve(result) 24 | } 25 | middleware(req, res, result) 26 | }) 27 | } 28 | 29 | // Initialize the cors middleware 30 | const cors = initMiddleware( 31 | // You can read more about the available options here: https://github.com/expressjs/cors#configuration-options 32 | Cors({ 33 | // Only allow requests with GET, POST and OPTIONS 34 | methods: ["GET", "POST", "OPTIONS"], 35 | }) 36 | ) 37 | 38 | export {cors} 39 | -------------------------------------------------------------------------------- /src/safe.ts: -------------------------------------------------------------------------------- 1 | export function safe(dx: T) { 2 | return Array.isArray(dx) ? dx : [] 3 | } 4 | -------------------------------------------------------------------------------- /src/scopes.ts: -------------------------------------------------------------------------------- 1 | export const parseScopes = (scopes?: string) => 2 | scopes?.trim()?.split(/\s+/) ?? [] 3 | -------------------------------------------------------------------------------- /src/services.ts: -------------------------------------------------------------------------------- 1 | import {isBackchannel} from "./utils" 2 | 3 | const PROFILE_SCOPES = new Set( 4 | "name family_name given_name middle_name nickname preferred_username profile picture website gender birthday zoneinfo locale updated_at" 5 | .trim() 6 | .split(/\s+/) 7 | ) 8 | const EMAIL_SCOPES = new Set("email email_verified".trim().split(/\s+/)) 9 | 10 | type CompositeSignature = { 11 | f_type: string 12 | f_vsn: string 13 | addr: string 14 | keyId: number 15 | signature: string 16 | } 17 | 18 | type AuthResponseService = { 19 | f_type: string 20 | f_vsn: string 21 | type: string 22 | uid: string 23 | endpoint?: string 24 | id?: string 25 | method?: string 26 | data?: Record< 27 | string, 28 | string | boolean | number | null | Array | unknown 29 | > 30 | identity?: { 31 | address: string 32 | keyId?: number 33 | } 34 | provider?: { 35 | address: string | null 36 | name: string 37 | icon: string | null 38 | description: string 39 | } 40 | params?: Record 41 | } 42 | 43 | const intersection = (a: Set, b: Set) => 44 | new Set([...a].filter(x => b.has(x))) 45 | 46 | function entries(arr: Array = []) { 47 | const arrEntries = [arr].flatMap(a => a).filter(Boolean) as Iterable< 48 | readonly [PropertyKey, string] 49 | > 50 | return Object.fromEntries(arrEntries) 51 | } 52 | 53 | const getMethod = (backchannel: boolean, platform: string | null) => { 54 | return backchannel 55 | ? "HTTP/POST" 56 | : platform === "react-native" 57 | ? "DEEPLINK/RPC" 58 | : "IFRAME/RPC" 59 | } 60 | 61 | const entry = ( 62 | scopes: Set, 63 | key: string, 64 | value: string | boolean | number 65 | ) => scopes.has(key) && [key, value] 66 | 67 | export const buildServices = ({ 68 | baseUrl, 69 | address, 70 | nonce, 71 | scopes, 72 | compSig, 73 | keyId, 74 | includeRefresh = false, 75 | client, 76 | }: { 77 | baseUrl: string 78 | address: string 79 | nonce: string | undefined 80 | scopes: Set 81 | compSig: string | undefined 82 | keyId?: number 83 | includeRefresh?: boolean 84 | client?: {platform?: string} 85 | }) => { 86 | const backchannel = isBackchannel() 87 | const platform = client?.platform || null 88 | 89 | const services: AuthResponseService[] = [ 90 | { 91 | f_type: "Service", 92 | f_vsn: "1.0.0", 93 | type: "authn", 94 | uid: "fcl-dev-wallet#authn", 95 | endpoint: backchannel ? `${baseUrl}/api/authn` : `${baseUrl}/fcl/authn`, 96 | method: getMethod(backchannel, platform), 97 | id: address, 98 | identity: { 99 | address: address, 100 | }, 101 | provider: { 102 | address: null, 103 | name: "FCL Dev Wallet", 104 | icon: null, 105 | description: "For Local Development Only", 106 | }, 107 | }, 108 | { 109 | f_type: "Service", 110 | f_vsn: "1.0.0", 111 | type: "authz", 112 | uid: "fcl-dev-wallet#authz", 113 | endpoint: backchannel ? `${baseUrl}/api/authz` : `${baseUrl}/fcl/authz`, 114 | method: getMethod(backchannel, platform), 115 | identity: { 116 | address: address, 117 | keyId: Number(keyId), 118 | }, 119 | }, 120 | { 121 | f_type: "Service", 122 | f_vsn: "1.0.0", 123 | type: "user-signature", 124 | uid: "fcl-dev-wallet#user-sig", 125 | endpoint: backchannel 126 | ? `${baseUrl}/api/user-sig` 127 | : `${baseUrl}/fcl/user-sig`, 128 | method: getMethod(backchannel, platform), 129 | id: address, 130 | data: {addr: address, keyId: Number(keyId)}, 131 | params: {}, 132 | }, 133 | ] 134 | 135 | // Account Proof Service 136 | if (nonce) { 137 | services.push({ 138 | f_type: "Service", 139 | f_vsn: "1.0.0", 140 | type: "account-proof", 141 | method: "DATA", 142 | uid: "fcl-dev-wallet#account-proof", 143 | data: { 144 | f_type: "account-proof", 145 | f_vsn: "1.0.0", 146 | address: address, 147 | nonce: nonce, 148 | signatures: [compSig] ?? null, 149 | }, 150 | }) 151 | } 152 | 153 | // Authentication Refresh Service 154 | if (includeRefresh) { 155 | services.push({ 156 | f_type: "Service", 157 | f_vsn: "1.0.0", 158 | type: "authn-refresh", 159 | uid: "fcl-dev-wallet#authn-refresh", 160 | endpoint: backchannel 161 | ? `${baseUrl}/api/authn-refresh` 162 | : `${baseUrl}/fcl/authn-refresh`, 163 | method: getMethod(backchannel, platform), 164 | id: address, 165 | data: { 166 | f_type: "authn-refresh", 167 | f_vsn: "1.0.0", 168 | address: address, 169 | keyId: Number(keyId), 170 | }, 171 | params: { 172 | sessionId: "C7OXWaVpU-efRymW7a5d", 173 | scopes: Array.from(scopes).join(" "), 174 | }, 175 | }) 176 | } 177 | 178 | if (!!scopes.size) { 179 | services.push({ 180 | f_type: "Service", 181 | f_vsn: "1.0.0", 182 | type: "open-id", 183 | uid: "fcl-dev-wallet#open-id", 184 | method: "data", 185 | data: { 186 | f_type: "OpenID", 187 | f_vsn: "1.0.0", 188 | ...entries([ 189 | intersection(PROFILE_SCOPES, scopes).size && [ 190 | "profile", 191 | entries([ 192 | entry(scopes, "name", `name[${address}]`), 193 | entry(scopes, "family_name", `family_name[${address}]`), 194 | entry(scopes, "given_name", `given_name[${address}]`), 195 | entry(scopes, "middle_name", `middle_name[${address}]`), 196 | entry(scopes, "nickname", `nickname[${address}]`), 197 | entry( 198 | scopes, 199 | "preferred_username", 200 | `preferred_username[${address}]` 201 | ), 202 | entry(scopes, "profile", `https://onflow.org`), 203 | entry( 204 | scopes, 205 | "piture", 206 | `https://https://avatars.onflow.org/avatar/${address}` 207 | ), 208 | entry(scopes, "website", "https://onflow.org"), 209 | entry(scopes, "gender", `gender[${address}]`), 210 | entry( 211 | scopes, 212 | "birthday", 213 | `0000-${new Date().getMonth() + 1}-${new Date().getDate()}` 214 | ), 215 | entry(scopes, "zoneinfo", `America/Vancouver`), 216 | entry(scopes, "locale", `en`), 217 | entry(scopes, "updated_at", Date.now()), 218 | ]), 219 | ], 220 | intersection(EMAIL_SCOPES, scopes).size && [ 221 | "email", 222 | entries([ 223 | entry(scopes, "email", `${address}@example.com`), 224 | entry(scopes, "email_verified", true), 225 | ]), 226 | ], 227 | ]), 228 | }, 229 | }) 230 | } 231 | 232 | return services 233 | } 234 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | export function isBackchannel() { 2 | const urlParams = new URLSearchParams(window.location.search) 3 | return urlParams.get("channel") === "back" 4 | } 5 | 6 | export function getPollingId() { 7 | const urlParams = new URLSearchParams(window.location.search) 8 | const pollingId = urlParams.get("pollingId") 9 | 10 | if (!pollingId) { 11 | throw new Error("Missing pollingId") 12 | } 13 | 14 | return pollingId 15 | } 16 | 17 | /* 18 | * This function is used to update a polling session with data from the frontchannel. 19 | * It is used to emulate a backchannel response from the frontchannel. 20 | */ 21 | export async function updatePollingSession(baseUrl: string, data: any) { 22 | const body = { 23 | pollingId: getPollingId(), 24 | data, 25 | } 26 | 27 | const response = await fetch(baseUrl + "/api/polling-session", { 28 | method: "POST", 29 | body: JSON.stringify(body), 30 | headers: { 31 | "Content-Type": "application/json", 32 | }, 33 | }) 34 | 35 | if (!response.ok) { 36 | throw new Error("Failed to update polling session") 37 | } 38 | 39 | // window.close() needs to be called at the end of a backchannel flow 40 | // to support Android devices where the parent FCL instance is unable 41 | // to dismiss the child window. We also have an extra safety check 42 | // to make sure we don't close the window if we're in an iframe. 43 | if (!inIframe()) { 44 | try { 45 | window.close() 46 | } catch (e) {} 47 | } 48 | } 49 | 50 | export function inIframe() { 51 | try { 52 | return window.self !== window.top 53 | } catch (e) { 54 | return true 55 | } 56 | } 57 | 58 | export function getBaseUrl() { 59 | return window.location.origin 60 | } 61 | -------------------------------------------------------------------------------- /src/validate.ts: -------------------------------------------------------------------------------- 1 | import * as yup from "yup" 2 | import {LABEL_MISSING_ERROR} from "./constants" 3 | 4 | const updateAccountSchemaClientShape = { 5 | label: yup.string().required(LABEL_MISSING_ERROR), 6 | } 7 | 8 | export const updateAccountSchemaClient = yup 9 | .object() 10 | .shape(updateAccountSchemaClientShape) 11 | -------------------------------------------------------------------------------- /styles/globals.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --fg: white; 3 | --bg: #6600ff; 4 | } 5 | 6 | html, 7 | body { 8 | padding: 0; 9 | margin: 0; 10 | min-height: 100%; 11 | font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, 12 | Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; 13 | } 14 | 15 | body { 16 | display: flex; 17 | align-items: center; 18 | justify-content: center; 19 | width: 100%; 20 | } 21 | 22 | a { 23 | color: inherit; 24 | text-decoration: none; 25 | } 26 | 27 | * { 28 | box-sizing: border-box; 29 | } 30 | 31 | #headlessui-portal-root { 32 | display: flex; 33 | justify-content: center; 34 | align-items: center; 35 | flex-direction: column; 36 | padding: 10px 20px; 37 | width: 100%; 38 | } 39 | 40 | #headlessui-portal-root > div { 41 | width: 100%; 42 | } 43 | 44 | pre { 45 | white-space: pre-wrap; 46 | } 47 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "strict": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "noEmit": true, 14 | "esModuleInterop": true, 15 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "jsx": "preserve", 20 | "noImplicitAny": true, 21 | "strictNullChecks": true, 22 | "noUnusedLocals": true, 23 | "noImplicitReturns": true, 24 | "baseUrl": "./", 25 | "downlevelIteration": true, 26 | "incremental": true 27 | }, 28 | "include": [ 29 | "next-env.d.ts", 30 | "**/*.ts", 31 | "**/*.tsx" 32 | ], 33 | "exclude": [ 34 | "node_modules" 35 | ] 36 | } 37 | -------------------------------------------------------------------------------- /types.d.ts: -------------------------------------------------------------------------------- 1 | import {ThemeUICSSObject} from "theme-ui" 2 | import "@emotion/react" 3 | import {FlowTheme} from "./src/theme" 4 | 5 | // https://emotion.sh/docs/typescript#define-a-theme 6 | declare module "@emotion/react" { 7 | // eslint-disable-next-line @typescript-eslint/no-empty-interface 8 | export interface Theme extends FlowTheme {} 9 | } 10 | 11 | export type SXStyles = Record 12 | export type Optional = Omit & Partial 13 | --------------------------------------------------------------------------------