├── .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 |
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 |
FLOW
61 |
62 | {!!account?.balance ? formattedBalance(account.balance) : "0"}
63 |
64 | {!isServiceAccount && (
65 |
fund(FLOW_TYPE)}
69 | sx={styles.fundButton}
70 | type="button"
71 | >
72 | Fund
73 |
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 |
133 |
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 |
62 | {scope}
63 |
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 |
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 |
142 |
149 |
150 |
151 | {account.label || account.address}
152 | {isNew && New }
153 |
154 |
{account.address}
155 |
156 | {!!accountData?.balance
157 | ? formattedBalance(accountData.balance)
158 | : "0"}
159 | FLOW
160 |
161 |
162 |
163 |
164 | onEditAccount(account)}
167 | sx={styles.manageAccountButton}
168 | data-test="manage-account-button"
169 | >
170 | Manage
171 |
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 |
47 | Cancel
48 |
49 |
50 |
58 | {account.address ? "Save" : "Create"}
59 |
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 |
43 | Decline
44 |
45 |
52 | Approve
53 |
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 |
91 |
92 |
93 |
94 | {children}
95 |
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 |
71 |
72 | {currentUser.label}
73 |
74 |
75 |
76 |
77 | {title}
78 |
79 |
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 |
156 |
160 |
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 |
139 |
140 |
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 |
144 |
145 |
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 |
16 | {isExpanded ? "collapse" : "expand"}
17 |
18 |
19 |
20 |
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 | {inputLabel}
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 |
47 |
48 |
49 |
50 | {children}
51 |
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 |
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 | {d.LABEL}
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 |
--------------------------------------------------------------------------------