├── .github ├── CODEOWNERS ├── filters.yml └── workflows │ ├── ci.yaml │ ├── e2e.yaml │ ├── fmt.yaml │ ├── lint.yaml │ ├── provision-darwin.sh │ ├── provision-linux.sh │ ├── shellcheck.yml │ └── test.yml ├── .gitignore ├── .nvmrc ├── CHANGELOG.md ├── Cargo.lock ├── Cargo.toml ├── Dockerfile ├── LICENSE ├── README.md ├── demo.sh ├── dfx.json ├── docker-compose.yml ├── e2e ├── assets │ ├── wallet-v0.wasm │ └── wallet-v1.wasm └── bash │ ├── controller.bash │ ├── events.bash │ ├── send.bash │ ├── upgrade.bash │ ├── util │ └── assertions.bash │ └── version.bash ├── jest.config.js ├── overrideQueries.js ├── package-lock.json ├── package.json ├── rust-toolchain.toml ├── test-setup.js ├── tsconfig.json ├── wallet ├── Cargo.toml ├── build.rs ├── build.sh └── src │ ├── address.rs │ ├── events.rs │ ├── lib.did │ ├── lib.rs │ ├── migrations.rs │ └── migrations │ └── v1.rs ├── wallet_ui ├── canister │ ├── index.ts │ └── wallet │ │ ├── index.ts │ │ ├── wallet.did.js │ │ └── wallet.ts ├── components │ ├── App.tsx │ ├── Buttons.tsx │ ├── CycleSlider.tsx │ ├── WalletAppBar.tsx │ ├── icons │ │ └── PlusIcon.tsx │ ├── panels │ │ ├── BalanceChart.tsx │ │ ├── Canisters.tsx │ │ ├── CreateCanister.tsx │ │ ├── CreateDialog.tsx │ │ ├── CreateWallet.tsx │ │ ├── CycleBalance.tsx │ │ ├── SendCycles.tsx │ │ └── Transactions.tsx │ └── routes │ │ ├── Authorize.tsx │ │ └── Dashboard.tsx ├── css │ ├── CycleBalance.css │ ├── Events.scss │ ├── Footer.css │ ├── Header.css │ ├── Input.css │ └── main.css ├── declarations │ ├── wallet │ │ ├── index.js │ │ ├── wallet.did │ │ ├── wallet.did.d.ts │ │ └── wallet.did.js │ └── xdr │ │ ├── canister.did │ │ ├── canister.did.d.ts │ │ ├── canister.did.js │ │ └── index.js ├── index.html ├── index.tsx ├── public │ ├── Handle.png │ ├── Plus.svg │ ├── checkers.png │ └── logo.png └── utils │ ├── authClient.ts │ ├── chart.test.ts │ ├── chart.ts │ ├── cycles.ts │ ├── hooks.ts │ └── materialTheme.ts ├── webpack.config.js └── webpack.dev.js /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @dfinity/sdk 2 | -------------------------------------------------------------------------------- /.github/filters.yml: -------------------------------------------------------------------------------- 1 | shell: 2 | - 'e2e/**' 3 | - 'wallet/build.sh' 4 | - 'demo.sh' 5 | backend: &backend 6 | - 'wallet/**' 7 | - 'Cargo.toml' 8 | - 'Cargo.lock' 9 | frontend: 10 | - 'wallet_ui/**' 11 | - '*.js' 12 | - '!(dfx).json' 13 | canister: &canister 14 | - *backend 15 | - 'dfx.json' 16 | e2e: 17 | - *canister 18 | - 'e2e/**' 19 | demo: 20 | - *canister 21 | - 'demo.sh' 22 | workflows: 23 | - '.github/**' 24 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: build 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | jobs: 8 | filter: 9 | runs-on: ubuntu-latest 10 | outputs: 11 | filter: ${{ steps.filter.outputs.demo == 'true' || steps.filter.outputs.workflows == 'true' }} 12 | steps: 13 | - uses: actions/checkout@v2 14 | - uses: dorny/paths-filter@v2 15 | id: filter 16 | with: 17 | filters: .github/filters.yml 18 | demo: 19 | needs: filter 20 | if: ${{ needs.filter.outputs.filter == 'true' }} 21 | runs-on: ubuntu-latest 22 | 23 | strategy: 24 | matrix: 25 | node-version: [12.x] 26 | 27 | steps: 28 | - name: Checkout 29 | uses: actions/checkout@v2 30 | 31 | - name: Install dfx 32 | uses: dfinity/setup-dfx@main 33 | with: 34 | dfx-version: "0.9.2" 35 | 36 | - name: Install Node.js ${{ matrix.node-version }} 37 | uses: actions/setup-node@v1 38 | with: 39 | node-version: ${{ matrix.node-version }} 40 | 41 | - name: Demo 42 | run: sh demo.sh 43 | 44 | aggregate: 45 | name: demo:required 46 | runs-on: ubuntu-latest 47 | if: ${{ always() }} 48 | needs: [demo, filter] 49 | steps: 50 | - name: Check demo result 51 | if: ${{ needs.demo.result != 'success' && needs.filter.outputs.filter == 'true' }} 52 | run: exit 1 53 | -------------------------------------------------------------------------------- /.github/workflows/e2e.yaml: -------------------------------------------------------------------------------- 1 | name: e2e 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | 8 | jobs: 9 | filter: 10 | runs-on: ubuntu-latest 11 | outputs: 12 | filter: ${{ steps.filter.outputs.e2e == 'true' || steps.filter.outputs.workflows == 'true' }} 13 | steps: 14 | - uses: actions/checkout@v2 15 | - uses: dorny/paths-filter@v2 16 | id: filter 17 | with: 18 | filters: .github/filters.yml 19 | test: 20 | name: Run e2e tests 21 | needs: filter 22 | if: ${{ needs.filter.outputs.filter == 'true' }} 23 | runs-on: ${{ matrix.os }} 24 | strategy: 25 | matrix: 26 | os: [ macos-latest, ubuntu-latest ] 27 | # only dfx >= 0.8.3 lets us query multiple controllers 28 | dfx: [ '0.9.2' ] 29 | env: 30 | DFX_VERSION: ${{ matrix.dfx }} 31 | 32 | steps: 33 | - uses: actions/checkout@v1 34 | - uses: actions/setup-node@v3 35 | with: 36 | node-version: 16 37 | - name: Cache Cargo 38 | uses: actions/cache@v2 39 | with: 40 | path: | 41 | ~/.cargo/registry 42 | ~/.cargo/git 43 | ./target 44 | key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}-${{ matrix.rust }}-1 45 | - name: Provision Darwin 46 | if: matrix.os == 'macos-latest' 47 | run: bash .github/workflows/provision-darwin.sh 48 | - name: Provision Linux 49 | if: matrix.os == 'ubuntu-latest' 50 | run: bash .github/workflows/provision-linux.sh 51 | - name: Install dfx 52 | uses: dfinity/setup-dfx@main 53 | with: 54 | dfx-version: ${{ matrix.dfx }} 55 | 56 | - name: Build 57 | run: | 58 | dfx start --background 59 | dfx canister create wallet 60 | dfx build wallet 61 | dfx stop 62 | 63 | - name: Run e2e tests vs dfx ${{ matrix.dfx }} 64 | run: | 65 | export DFX_WALLET_WASM=$GITHUB_WORKSPACE/target/wasm32-unknown-unknown/release/wallet-opt.wasm 66 | export assets=$GITHUB_WORKSPACE/e2e/assets 67 | bats e2e/bash/*.bash 68 | 69 | aggregate: 70 | name: e2e:required 71 | runs-on: ubuntu-latest 72 | if: ${{ always() }} 73 | needs: [test, filter] 74 | steps: 75 | - name: Check e2e result 76 | if: ${{ needs.test.result != 'success' && needs.filter.outputs.filter == 'true' }} 77 | run: exit 1 78 | -------------------------------------------------------------------------------- /.github/workflows/fmt.yaml: -------------------------------------------------------------------------------- 1 | name: Format 2 | 3 | on: [pull_request] 4 | 5 | jobs: 6 | filter: 7 | runs-on: ubuntu-latest 8 | outputs: 9 | filter: ${{ steps.filter.outputs.backend == 'true' || steps.filter.outputs.workflows == 'true' }} 10 | steps: 11 | - uses: actions/checkout@v2 12 | - uses: dorny/paths-filter@v2 13 | id: filter 14 | with: 15 | filters: .github/filters.yml 16 | fmt: 17 | name: fmt 18 | needs: filter 19 | if: ${{ needs.filter.outputs.filter == 'true' }} 20 | runs-on: ${{ matrix.os }} 21 | strategy: 22 | matrix: 23 | os: [ ubuntu-latest ] 24 | 25 | steps: 26 | - uses: actions/checkout@v2 27 | 28 | - uses: actions/cache@v2 29 | with: 30 | path: | 31 | ~/.cargo/registry 32 | ~/.cargo/git 33 | ./target 34 | key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} 35 | 36 | - name: Run Cargo fmt 37 | run: cargo fmt --all -- --check 38 | env: 39 | RUST_BACKTRACE: 1 40 | aggregate: 41 | name: fmt:required 42 | runs-on: ubuntu-latest 43 | needs: [fmt, filter] 44 | if: ${{ always() }} 45 | steps: 46 | - name: Check fmt result 47 | if: ${{ needs.fmt.result != 'success' && needs.filter.outputs.filter == 'true' }} 48 | run: exit 1 49 | -------------------------------------------------------------------------------- /.github/workflows/lint.yaml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: [pull_request] 4 | 5 | jobs: 6 | filter: 7 | runs-on: ubuntu-latest 8 | outputs: 9 | filter: ${{ steps.filter.outputs.backend == 'true' || steps.filter.outputs.workflows == 'true' }} 10 | steps: 11 | - uses: actions/checkout@v2 12 | - uses: dorny/paths-filter@v2 13 | id: filter 14 | with: 15 | filters: .github/filters.yml 16 | lint: 17 | name: Lint 18 | needs: filter 19 | if: ${{ needs.filter.outputs.filter == 'true' }} 20 | runs-on: ${{ matrix.os }} 21 | strategy: 22 | fail-fast: false 23 | matrix: 24 | os: [ubuntu-latest] 25 | node-version: ['12.x'] 26 | 27 | steps: 28 | - uses: actions/checkout@v2 29 | 30 | - uses: actions/cache@v2 31 | with: 32 | path: | 33 | ~/.cargo/registry 34 | ~/.cargo/git 35 | ./target 36 | key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} 37 | - name: Install dfx 38 | uses: dfinity/setup-dfx@main 39 | with: 40 | dfx-version: "0.9.2" 41 | - name: Install Node.js ${{ matrix.node-version }} 42 | uses: actions/setup-node@v1 43 | with: 44 | node-version: ${{ matrix.node-version }} 45 | - name: Run Lint 46 | run: | 47 | dfx start --background 48 | dfx canister create wallet 49 | dfx build wallet 50 | cargo clippy --verbose --tests --benches -- -D clippy::all 51 | env: 52 | RUST_BACKTRACE: 1 53 | 54 | aggregate: 55 | name: lint:required 56 | runs-on: ubuntu-latest 57 | if: ${{ always() }} 58 | needs: [lint, filter] 59 | steps: 60 | - name: Check lint result 61 | if: ${{ needs.lint.result != 'success' && needs.filter.outputs.filter == 'true' }} 62 | run: exit 1 63 | -------------------------------------------------------------------------------- /.github/workflows/provision-darwin.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -ex 4 | 5 | export 6 | 7 | # Enter temporary directory. 8 | pushd /tmp 9 | 10 | # Install Homebrew 11 | curl --location --output install-brew.sh "https://raw.githubusercontent.com/Homebrew/install/master/install.sh" 12 | bash install-brew.sh 13 | rm install-brew.sh 14 | 15 | # Install Node. 16 | version=14.15.4 17 | curl --location --output node.pkg "https://nodejs.org/dist/v$version/node-v$version.pkg" 18 | sudo installer -pkg node.pkg -store -target / 19 | rm node.pkg 20 | 21 | # Install Bats. 22 | if [ "$(uname -r)" = "19.6.0" ]; then 23 | brew unlink bats 24 | fi 25 | brew install bats-core 26 | 27 | # Install Bats support. 28 | version=0.3.0 29 | curl --location --output bats-support.tar.gz https://github.com/ztombol/bats-support/archive/v$version.tar.gz 30 | mkdir /usr/local/lib/bats-support 31 | tar --directory /usr/local/lib/bats-support --extract --file bats-support.tar.gz --strip-components 1 32 | rm bats-support.tar.gz 33 | 34 | # Set environment variables. 35 | BATS_SUPPORT="/usr/local/lib/bats-support" 36 | echo "BATS_SUPPORT=${BATS_SUPPORT}" >> "$GITHUB_ENV" 37 | 38 | # Exit temporary directory. 39 | popd 40 | -------------------------------------------------------------------------------- /.github/workflows/provision-linux.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -ex 4 | 5 | export 6 | 7 | # Enter temporary directory. 8 | pushd /tmp 9 | 10 | # Install Node. 11 | wget --output-document install-node.sh "https://deb.nodesource.com/setup_14.x" 12 | sudo bash install-node.sh 13 | sudo apt-get install --yes nodejs 14 | rm install-node.sh 15 | 16 | # Install Bats. 17 | sudo apt-get install --yes bats 18 | 19 | # Install Bats support. 20 | version=0.3.0 21 | wget https://github.com/ztombol/bats-support/archive/v$version.tar.gz 22 | sudo mkdir /usr/local/lib/bats-support 23 | sudo tar --directory /usr/local/lib/bats-support --extract --file v$version.tar.gz --strip-components 1 24 | rm v$version.tar.gz 25 | 26 | # Set environment variables. 27 | BATS_SUPPORT="/usr/local/lib/bats-support" 28 | echo "BATS_SUPPORT=${BATS_SUPPORT}" >> "$GITHUB_ENV" 29 | echo "$HOME/bin" >> "$GITHUB_PATH" 30 | 31 | # Exit temporary directory. 32 | popd 33 | -------------------------------------------------------------------------------- /.github/workflows/shellcheck.yml: -------------------------------------------------------------------------------- 1 | name: shellcheck 2 | on: 3 | pull_request: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | filter: 10 | runs-on: ubuntu-latest 11 | outputs: 12 | filter: ${{ steps.filter.outputs.shell == 'true' || steps.filter.outputs.workflows == 'true' }} 13 | steps: 14 | - uses: actions/checkout@v2 15 | - uses: dorny/paths-filter@v2 16 | id: filter 17 | with: 18 | filters: .github/filters.yml 19 | 20 | check: 21 | needs: filter 22 | if: ${{ needs.filter.outputs.filter == 'true' }} 23 | name: Check shell scripts 24 | runs-on: macos-latest 25 | steps: 26 | - uses: actions/checkout@v2 27 | - name: Install shellcheck 28 | run: | 29 | mkdir $HOME/bin 30 | cd $HOME/bin 31 | curl -L https://github.com/koalaman/shellcheck/releases/download/v0.7.1/shellcheck-v0.7.1.darwin.x86_64.tar.xz \ 32 | | xz -d | tar x 33 | - name: Check e2e scripts 34 | run: $HOME/bin/shellcheck-v0.7.1/shellcheck e2e/bash/**/*.bash 35 | - name: Check provision scripts 36 | run: $HOME/bin/shellcheck-v0.7.1/shellcheck .github/workflows/*.sh 37 | - name: Check wallet build script 38 | run: $HOME/bin/shellcheck-v0.7.1/shellcheck wallet/build.sh 39 | - name: Check demo script 40 | run: $HOME/bin/shellcheck-v0.7.1/shellcheck demo.sh 41 | 42 | aggregate: 43 | name: shellcheck:required 44 | runs-on: ubuntu-latest 45 | if: ${{ always() }} 46 | needs: [check, filter] 47 | steps: 48 | - name: Check shellcheck result 49 | if: ${{ needs.check.result != 'success' && needs.filter.outputs.filter == 'true' }} 50 | run: exit 1 51 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | 8 | jobs: 9 | filter: 10 | runs-on: ubuntu-latest 11 | outputs: 12 | filter: ${{ steps.filter.outputs.backend == 'true' || steps.filter.outputs.workflows == 'true' }} 13 | steps: 14 | - uses: actions/checkout@v2 15 | - uses: dorny/paths-filter@v2 16 | id: filter 17 | with: 18 | filters: .github/filters.yml 19 | test: 20 | name: Run built-in tests 21 | needs: filter 22 | if: ${{ needs.filter.outputs.filter == 'true' }} 23 | runs-on: ${{ matrix.os }} 24 | strategy: 25 | matrix: 26 | os: [ubuntu-latest, macos-latest] 27 | node-version: ['12.x'] 28 | steps: 29 | - uses: actions/checkout@v1 30 | - uses: actions/cache@v2 31 | with: 32 | path: | 33 | ~/.cargo/registry 34 | ~/.cargo/git 35 | ./target 36 | key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} 37 | - name: Install dfx 38 | uses: dfinity/setup-dfx@main 39 | with: 40 | dfx-version: "0.9.2" 41 | - name: Install Node.js ${{ matrix.node-version }} 42 | uses: actions/setup-node@v1 43 | with: 44 | node-version: ${{ matrix.node-version }} 45 | - name: Run tests 46 | run: | 47 | dfx start --background 48 | dfx canister create wallet 49 | dfx build wallet 50 | cargo test --verbose --no-fail-fast --workspace --all-targets --locked -- --nocapture 51 | env: 52 | RUST_BACKTRACE: 1 53 | 54 | aggregate: 55 | name: test:required 56 | needs: [test, filter] 57 | runs-on: ubuntu-latest 58 | if: ${{ always() }} 59 | steps: 60 | - name: Check test result 61 | if: ${{ needs.test.result != 'success' && needs.filter.outputs.filter == 'true' }} 62 | run: exit 1 63 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .dfx/ 3 | canister_ids.json 4 | node_modules/ 5 | target/ 6 | dist/ 7 | .idea/ 8 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v16.19.1 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [UNRELEASED] 9 | 10 | ## [20240410] 11 | 12 | ### Added 13 | 14 | - Added `wallet_call_with_max_cycles` 15 | 16 | ## [20230530] 17 | 18 | ### Fixed 19 | 20 | Upgraded `agent-js` packages so that the wallet can work on icp0.io domain as intended. 21 | 22 | Also gzips the wasm so it can be deployed successfully 23 | 24 | ## [20230308] 25 | 26 | ### Added 27 | 28 | - UI no longer uses queries, so that all requests go through consensus for added security. 29 | 30 | - Now queries XDR to ICP conversion rate from Cycles Minting Canister (CMC). The interface can be found at `nns-dapp/frontend/ts/src/canisters/cyclesMinting/canister.did`. 31 | 32 | - The CycleSlider UI feature shows this conversion when allocating cycles to canisters. 33 | 34 | - Managed canisters are now tracked, and the events that pertain to them are tracked under them. 35 | - Added `list_managed_canisters`, `get_managed_canister_events`, and `set_short_name` functions. 36 | 37 | - Each function that deals with a 64-bit cycle count has been paired with a 128-bit equivalent. 38 | - The canister now holds 128-bit data internally and the 128-bit functions should be preferred going forward 39 | - `get_events` and `get_managed_canister_events` will trap if any events would be returned with cycle counts that overflow a `nat64` 40 | 41 | - Introduced Security Headers including Content Security Policy. 42 | 43 | ### Changed 44 | 45 | - `wallet_receive` now takes an optional memo parameter, for recording information about a particular transaction. 46 | 47 | ## [0.2.1] - 2021-12-03 48 | 49 | ### Changed 50 | 51 | - When `wallet_create_wallet` is not given any controllers to use, now it will 52 | set the caller as a controller in addition to itself (previously only set self). 53 | 54 | ## [0.2.0] - 2021-09-03 55 | 56 | ### Added 57 | 58 | - Added wallet_api_version() method. 59 | - Added 'controllers' field to CanisterSettings field. 60 | - Either the controller field or the controllers field may be present, but not both. 61 | - Support for certified assets and http_request() interface. 62 | 63 | ### Changed 64 | 65 | - The frontend now formats cycle balances in a human readable format, for example 5 KC = 5000 cycles, 10 TC = 10 trillion cycles. 66 | 67 | ## [0.1.0] - 2021-06-06 68 | 69 | Module hash: 1404b28b1c66491689b59e184a9de3c2be0dbdd75d952f29113b516742b7f898 70 | 71 | ### Fixed 72 | 73 | - It is no longer possible to remove the last controller. 74 | 75 | ### Changed 76 | 77 | - Differentiate between controllers and custodians in error output. 78 | - The deauthorize() method will now only deauthorize custodians, not controllers. 79 | 80 | ## [0.1.0] - 2021-05-17 81 | 82 | Module hash: a609400f2576d1d6df72ce868b359fd08e1d68e58454ef17db2361d2f1c242a1 83 | 84 | ### Changed 85 | 86 | - Updated frontend to use the Internet Identity Service. 87 | 88 | ## [0.1.0] - 2021-04-30 89 | 90 | Module hash: 3d5b221387875574a9fd75b3165403cf1b301650a602310e9e4229d2f6766dcc 91 | 92 | This is the oldest version of this module found on the IC. It was released with dfx 0.7.0-beta.5. 93 | It conforms to version 0.17.0 of the public interface. 94 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = ["wallet"] 3 | 4 | [profile.release] 5 | lto = true 6 | opt-level = 'z' 7 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu:focal 2 | 3 | ENV DEBIAN_FRONTEND noninteractive 4 | 5 | ### Install baseline packages. 6 | RUN apt-get update 7 | RUN apt-get install -y build-essential cmake git sudo wget 8 | 9 | # Create development user. 10 | RUN groupadd developer 11 | RUN useradd -d /home/developer -g developer -m developer 12 | 13 | # Enable passwordless sudo privileges. 14 | RUN echo developer ALL=NOPASSWD:ALL > /etc/sudoers.d/developer 15 | RUN chmod 440 /etc/sudoers.d/developer 16 | RUN echo Set disable_coredump false > /etc/sudo.conf 17 | 18 | # Become development user. 19 | USER developer 20 | 21 | ### Install Rust toolchain. 22 | ARG RUSTUP_VERSION=1.22.1 23 | ARG RUSTUP_DIR=rustup-${RUSTUP_VERSION} 24 | ARG RUSTUP_TARBALL=${RUSTUP_VERSION}.tar.gz 25 | WORKDIR /tmp 26 | RUN wget https://github.com/rust-lang/rustup/archive/${RUSTUP_TARBALL} 27 | RUN tar -f ${RUSTUP_TARBALL} -x 28 | RUN sh ${RUSTUP_DIR}/rustup-init.sh -y 29 | ENV PATH "/home/developer/.cargo/bin:${PATH}" 30 | RUN rustup target add wasm32-unknown-unknown 31 | RUN rm -r ${RUSTUP_DIR} ${RUSTUP_TARBALL} 32 | 33 | # Install DFINITY SDK. 34 | RUN wget -O install-dfx.sh -q https://sdk.dfinity.org/install.sh 35 | RUN yes Y | DFX_VERSION=0.9.2 bash install-dfx.sh 36 | RUN rm install-dfx.sh 37 | ENV PATH "/home/developer/bin:${PATH}" 38 | 39 | # Install Node. 40 | RUN wget -O install-node.sh -q https://deb.nodesource.com/setup_12.x 41 | RUN sudo bash install-node.sh 42 | RUN sudo apt-get install -y nodejs 43 | RUN npm install 44 | 45 | # Create development workspace. 46 | WORKDIR /workspace 47 | RUN sudo chown -R developer:developer . 48 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DFINITY cycles wallet 2 | 3 | [![Build Status](https://github.com/dfinity/cycles-wallet/workflows/build/badge.svg)](https://github.com/dfinity-lab/wallet-canister/actions?query=workflow%3Abuild) 4 | 5 | ICP tokens can be converted into **cycles** to power canister operations. Cycles reflect the operational cost of communication, computation, and storage that dapps consume. 6 | 7 | Unlike ICP tokens, cycles are only associated with canisters and not with user or developer principals. Because only canisters require cycles to perform operations and pay for the resources they use, users and developers manage the distribution and ownership of cycles through a special type of canister called a **cycles wallet**. The cycles wallet holds the cycles required to perform operations such as creating new canisters. These operations are executed using the canister principal of the cycles wallet instead of your user principal. 8 | 9 | ## Prerequisites 10 | 11 | - [x] Install the [IC SDK](https://internetcomputer.org/docs/current/developer-docs/setup/install/). 12 | 13 | - [x] Download and install [Node.js](https://nodejs.org/en/download/current), version 16.x.x and older. Running a version newer than 16.x.x may result in an error. 14 | 15 | ## Deploying the cycles wallet 16 | 17 | The cycles wallet can be installed and deployed using the following steps: 18 | 19 | - #### Step 1: Install dependencies: 20 | 21 | ``` 22 | npm ci 23 | ``` 24 | 25 | - #### Step 2: Start the local replica: 26 | 27 | ``` 28 | dfx start --background --clean 29 | ``` 30 | 31 | - #### Step 3: Deploy to local replica: 32 | 33 | ``` 34 | dfx deploy 35 | ``` 36 | 37 | Once deployed, you can obtain the canister ID with the command: 38 | 39 | ``` 40 | dfx canister id wallet 41 | ``` 42 | 43 | ## Resources 44 | 45 | - [Cycles wallet developer documentation](https://internetcomputer.org/docs/current/developer-docs/setup/cycles/cycles-wallet) 46 | -------------------------------------------------------------------------------- /demo.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # The roles here are: 4 | # Both Alice and Bob have their own wallet 5 | # Charlie is a custodian of Alice (but Alice is the owner of her own wallet). 6 | 7 | set -e 8 | trap 'dfx stop' EXIT 9 | 10 | npm install 11 | 12 | dfx start --background --clean 13 | 14 | dfx identity new id_alice || true 15 | dfx identity new id_bob || true 16 | dfx identity new id_charlie || true 17 | 18 | dfx --identity id_alice canister create --no-wallet alice --with-cycles=5000000000000 19 | dfx --identity id_bob canister create --no-wallet bob --with-cycles=2000000000000 20 | dfx --identity default canister create --no-wallet wallet --with-cycles=3000000000000 21 | 22 | alice_wallet="$(dfx canister id alice)" 23 | bob_wallet="$(dfx canister id bob)" 24 | default_wallet="$(dfx canister id wallet)" 25 | 26 | dfx build 27 | 28 | dfx --identity id_alice canister install alice 29 | dfx --identity id_bob canister install bob 30 | dfx --identity default canister install wallet 31 | 32 | dfx --identity id_alice identity set-wallet "$alice_wallet" 33 | dfx --identity id_bob identity set-wallet "$bob_wallet" 34 | dfx --identity default identity set-wallet "$default_wallet" 35 | 36 | echo 37 | echo '== Initial cycle balances for Alice and Bob.' 38 | echo 39 | 40 | echo "Alice = $(dfx --identity id_alice canister call alice wallet_balance)" 41 | echo "Bob = $(dfx --identity id_bob canister call bob wallet_balance)" 42 | 43 | echo 44 | echo '== Create a new canister with Alice as controller using 1000000000001 cycles.' 45 | echo 46 | 47 | CREATE_RES=$(dfx --identity id_alice canister call alice wallet_create_canister "(record { cycles = 1000000000001; settings = record {null; null; null;}; })") 48 | echo "New canister id = $(echo "${CREATE_RES}" | tr '\n' ' ' | cut -d'"' -f 2)" 49 | 50 | echo 51 | echo '== Transfer 1000000000000 cycles from Alice to Bob.' 52 | echo 53 | 54 | eval dfx --identity id_alice canister call alice wallet_send "'(record { canister = principal \"$(dfx canister id bob)\"; amount = 1000000000000 })'" 55 | 56 | echo 57 | echo '== Final cycle balances for Alice and Bob.' 58 | echo 59 | 60 | echo "Alice = $(dfx --identity id_alice canister call alice wallet_balance)" 61 | echo "Bob = $(dfx --identity id_bob canister call bob wallet_balance)" 62 | 63 | echo 64 | echo '== Setting custodian of Alices wallet to Charlie' 65 | echo 66 | dfx --identity id_alice canister call alice authorize "(principal \"$(dfx --identity id_charlie identity get-principal)\")" 67 | 68 | echo 69 | echo '== Upgrading...' 70 | echo 71 | dfx --identity id_alice canister install alice --mode=upgrade 72 | 73 | echo 74 | echo '== Using Charlie to send cycles...' 75 | echo 76 | eval dfx --identity id_charlie canister call alice wallet_send "'(record { canister = principal \"$(dfx canister id bob)\"; amount = 1000000000000 })'" 77 | 78 | echo "Alice = $(dfx --identity id_alice canister call alice wallet_balance)" 79 | echo "Alice^ = $(dfx --identity id_charlie canister call alice wallet_balance)" 80 | echo "Bob = $(dfx --identity id_bob canister call bob wallet_balance)" 81 | -------------------------------------------------------------------------------- /dfx.json: -------------------------------------------------------------------------------- 1 | { 2 | "canisters": { 3 | "wallet": { 4 | "build": "wallet/build.sh", 5 | "candid": "wallet/src/lib.did", 6 | "type": "custom", 7 | "wasm": "target/wasm32-unknown-unknown/release/wallet-opt.wasm", 8 | "declarations": { 9 | "output": "wallet_ui/declarations/wallet" 10 | } 11 | }, 12 | "alice": { 13 | "build": "true", 14 | "dependencies": ["wallet"], 15 | "candid": "wallet/src/lib.did", 16 | "type": "custom", 17 | "wasm": "target/wasm32-unknown-unknown/release/wallet-opt.wasm" 18 | }, 19 | "bob": { 20 | "build": "true", 21 | "dependencies": ["wallet"], 22 | "candid": "wallet/src/lib.did", 23 | "type": "custom", 24 | "wasm": "target/wasm32-unknown-unknown/release/wallet-opt.wasm" 25 | } 26 | }, 27 | "version": 1 28 | } 29 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | services: 3 | dfx: 4 | build: . 5 | ports: 6 | - "8080" 7 | volumes: 8 | - .:/workspace 9 | 10 | -------------------------------------------------------------------------------- /e2e/assets/wallet-v0.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dfinity/cycles-wallet/b013764dd827560d8538ee2b7be9ecf66bed6be7/e2e/assets/wallet-v0.wasm -------------------------------------------------------------------------------- /e2e/assets/wallet-v1.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dfinity/cycles-wallet/b013764dd827560d8538ee2b7be9ecf66bed6be7/e2e/assets/wallet-v1.wasm -------------------------------------------------------------------------------- /e2e/bash/controller.bash: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bats 2 | 3 | # shellcheck source=/dev/null 4 | source "$BATS_SUPPORT/load.bash" 5 | 6 | load util/assertions 7 | 8 | setup() { 9 | # We want to work from a temporary directory, different for every test. 10 | x=$(mktemp -d -t dfx-usage-env-home-XXXXXXXX) 11 | cd "$x" || exit 12 | export DFX_CONFIG_ROOT=$x 13 | 14 | dfx new --no-frontend e2e_project 15 | cd e2e_project || exit 1 16 | dfx start --background 17 | } 18 | 19 | teardown() { 20 | dfx stop 21 | rm -rf "$DFX_CONFIG_ROOT" 22 | } 23 | 24 | @test "canister creation sets controller to the wallet" { 25 | # invokes: 26 | # - wallet_create_canister 27 | 28 | assert_command dfx identity new alice 29 | assert_command dfx identity use alice 30 | ALICE_WALLET=$(dfx --identity alice identity get-wallet) 31 | ALICE_ID=$(dfx --identity alice identity get-principal) 32 | 33 | dfx --identity alice canister create --all 34 | 35 | assert_command dfx --identity alice canister status e2e_project 36 | assert_match "Controllers: ($ALICE_WALLET $ALICE_ID|$ALICE_ID $ALICE_WALLET)" 37 | assert_match "Module hash: None" 38 | 39 | assert_command dfx --identity alice canister info e2e_project 40 | assert_match "Controllers: ($ALICE_WALLET $ALICE_ID|$ALICE_ID $ALICE_WALLET)" 41 | assert_match "Module hash: None" 42 | 43 | dfx --identity alice canister status e2e_project 44 | 45 | } 46 | 47 | @test "canister installation sets controller to the wallet" { 48 | # invokes: 49 | # - wallet_create_canister 50 | 51 | assert_command dfx identity new alice 52 | assert_command dfx identity use alice 53 | 54 | ALICE_WALLET=$(dfx --identity alice identity get-wallet) 55 | ALICE_ID=$(dfx --identity alice identity get-principal) 56 | 57 | dfx --identity alice canister create --all 58 | dfx --identity alice build 59 | dfx --identity alice canister install --all 60 | 61 | assert_command dfx --identity alice canister status e2e_project 62 | assert_match "Controllers: ($ALICE_WALLET $ALICE_ID|$ALICE_ID $ALICE_WALLET)" 63 | assert_match "Module hash: 0x" 64 | 65 | assert_command dfx --identity alice canister info e2e_project 66 | assert_match "Controllers: ($ALICE_WALLET $ALICE_ID|$ALICE_ID $ALICE_WALLET)" 67 | assert_match "Module hash: 0x" 68 | } 69 | 70 | @test "update-settings sets controller" { 71 | # invokes: 72 | # - wallet_create_canister 73 | 74 | assert_command dfx identity new alice 75 | assert_command dfx identity new bob 76 | 77 | assert_command dfx identity use alice 78 | 79 | ALICE_WALLET=$(dfx --identity alice identity get-wallet) 80 | ALICE_ID=$(dfx --identity alice identity get-principal) 81 | BOB_WALLET=$(dfx --identity bob identity get-wallet) 82 | # shellcheck disable=SC2034 83 | BOB_ID=$(dfx --identity bob identity get-principal) 84 | 85 | dfx deploy e2e_project 86 | 87 | assert_command dfx --identity alice canister --wallet "$ALICE_WALLET" status e2e_project 88 | assert_match "Controllers: ($ALICE_WALLET $ALICE_ID|$ALICE_ID $ALICE_WALLET)" 89 | 90 | # Set controller using canister name and identity name 91 | # Surprise: This doesn't actually call update_settings in the wallet 92 | # canister. It calls wallet_call on the wallet canister, which 93 | # forwards to update_settings on the management canister. 94 | assert_command dfx canister --wallet "$ALICE_WALLET" update-settings e2e_project --controller "${BOB_WALLET}" 95 | assert_match "Set controller of \"e2e_project\" to: ${BOB_WALLET}" 96 | 97 | assert_command dfx --identity bob canister --wallet "$BOB_WALLET" status e2e_project 98 | assert_match "Controllers: $BOB_WALLET" 99 | 100 | # Bob is controller, Alice cannot reinstall 101 | echo yes | assert_command_fail dfx canister --wallet "$ALICE_WALLET" install e2e_project -m reinstall 102 | 103 | # Bob can reinstall 104 | echo yes | assert_command dfx --identity bob canister --wallet "$BOB_WALLET" install e2e_project -m reinstall 105 | } 106 | 107 | @test "create wallet with single controller through wallet_create_wallet" { 108 | # invokes: 109 | # - wallet_create_wallet 110 | # - update_settings_call 111 | # - update_settings_call (with controller) 112 | 113 | # curious: the cycles wallet has a wallet_create_wallet method, published 114 | # in its .did file. dfx doesn't call it. Maybe other users of the agent call it, though. 115 | # The sdk repo has one call to the method in an e2e ref test. This is a copy of that test, 116 | # there called "wallet create wallet". 117 | WALLET_ID=$(dfx identity get-wallet) 118 | CREATE_RES=$(dfx canister call "${WALLET_ID}" wallet_create_wallet "(record { cycles = (2000000000000:nat64); settings = record {controller = opt principal \"$(dfx identity get-principal)\";};})") 119 | CHILD_ID=$(echo "${CREATE_RES}" | tr '\n' ' ' | cut -d'"' -f 2) 120 | assert_command dfx canister call "${CHILD_ID}" wallet_balance '()' 121 | } 122 | 123 | @test "create wallet with multiple controllers through wallet_create_wallet" { 124 | # invokes: 125 | # - wallet_create_wallet 126 | # - update_settings_call 127 | # - update_settings_call (with controllers) 128 | 129 | assert_command dfx identity new alice 130 | assert_command dfx identity new bob 131 | 132 | WALLET_ID=$(dfx identity get-wallet) 133 | CREATE_RES=$(dfx canister call "${WALLET_ID}" wallet_create_wallet "(record { cycles = (2000000000000:nat64); settings = record {controllers = opt vec { principal \"$(dfx identity get-principal)\"; principal \"$(dfx --identity alice identity get-principal)\";};};})") 134 | CHILD_ID=$(echo "${CREATE_RES}" | tr '\n' ' ' | cut -d'"' -f 2) 135 | 136 | assert_command dfx canister call "${CHILD_ID}" wallet_balance '()' 137 | assert_command dfx --identity alice canister call "${CHILD_ID}" wallet_balance '()' 138 | assert_command_fail dfx --identity bob canister call "${CHILD_ID}" wallet_balance '()' 139 | 140 | assert_command dfx canister call "${CHILD_ID}" get_custodians '()' 141 | assert_eq '(vec {})' 142 | assert_command dfx canister call "${CHILD_ID}" get_controllers '()' 143 | assert_match 'principal "'"$(dfx identity get-principal)"'"'; 144 | assert_match 'principal "'"$(dfx --identity alice identity get-principal)"'"'; 145 | } 146 | 147 | @test "create wallet with multiple controllers, other than caller, through wallet_create_wallet" { 148 | # invokes: 149 | # - wallet_create_wallet 150 | # - update_settings_call 151 | # - update_settings_call (with controllers) 152 | 153 | assert_command dfx identity new alice 154 | assert_command dfx identity new bob 155 | 156 | WALLET_ID=$(dfx identity get-wallet) 157 | CREATE_RES=$(dfx canister call "${WALLET_ID}" wallet_create_wallet "(record { cycles = (2000000000000:nat64); settings = record {controllers = opt vec { principal \"$(dfx --identity alice identity get-principal)\"; principal \"$(dfx --identity bob identity get-principal)\";};};})") 158 | CHILD_ID=$(echo "${CREATE_RES}" | tr '\n' ' ' | cut -d'"' -f 2) 159 | 160 | assert_command_fail dfx canister call "${CHILD_ID}" wallet_balance '()' 161 | assert_command dfx --identity alice canister call "${CHILD_ID}" wallet_balance '()' 162 | assert_command dfx --identity bob canister call "${CHILD_ID}" wallet_balance '()' 163 | 164 | assert_command_fail dfx canister call "${CHILD_ID}" get_custodians '()' 165 | assert_command dfx --identity bob canister call "${CHILD_ID}" get_custodians '()' 166 | assert_eq '(vec {})' 167 | 168 | assert_command_fail dfx canister call "${CHILD_ID}" get_controllers '()' 169 | assert_command dfx --identity alice canister call "${CHILD_ID}" get_controllers '()' 170 | assert_not_match "$(dfx identity get-principal)" 171 | assert_match 'principal "'"$(dfx --identity bob identity get-principal)"'"'; 172 | assert_match 'principal "'"$(dfx --identity alice identity get-principal)"'"'; 173 | } 174 | 175 | -------------------------------------------------------------------------------- /e2e/bash/events.bash: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bats 2 | 3 | # shellcheck source=/dev/null 4 | source "$BATS_SUPPORT/load.bash" 5 | load util/assertions 6 | 7 | setup() { 8 | x=$(mktemp -d -t dfx-usage-env-home-XXXXXXXX) 9 | cd "$x" || exit 10 | export DFX_CONFIG_ROOT=$x 11 | 12 | dfx new --no-frontend e2e_project 13 | cd e2e_project || exit 1 14 | dfx start --background 15 | } 16 | 17 | teardown() { 18 | dfx stop 19 | rm -rf "$DFX_CONFIG_ROOT" 20 | } 21 | 22 | @test "canister events are recorded correctly" { 23 | WALLET=$(dfx identity get-wallet) 24 | assert_command dfx deploy e2e_project 25 | CANISTER=$(dfx canister id e2e_project) 26 | # guaranteed to fail, but still reports the event 27 | assert_command dfx canister call "$WALLET" wallet_send "(record { canister = principal \"$CANISTER\"; amount = 1000000000:nat64 })" 28 | # 4449444c0001710d6379636c65735f77616c6c6574 = ("cycles_wallet") 29 | assert_command dfx canister call "$WALLET" wallet_call "(record { canister = principal \"$CANISTER\"; cycles = 0:nat64; method_name = \"greet\"; args = vec { 0x44;0x49;0x44;0x4c;0x00;0x01;0x71;0x0d;0x63;0x79;0x63;0x6c;0x65;0x73;0x5f;0x77;0x61;0x6c;0x6c;0x65;0x74; }:blob})" 30 | assert_command dfx canister call "$WALLET" list_managed_canisters '(record {})' 31 | assert_match "23_515 = principal \"$CANISTER\";" 32 | assert_command dfx canister call "$WALLET" get_managed_canister_events "(record { canister = principal \"$CANISTER\" })" 33 | # CyclesSent=2171739429; Called=3950823581; Cretaed=3736853960 34 | assert_match '3_736_853_960.*2_171_739_429.*3_950_823_581' 35 | } 36 | -------------------------------------------------------------------------------- /e2e/bash/send.bash: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bats 2 | 3 | # shellcheck source=/dev/null 4 | source "$BATS_SUPPORT/load.bash" 5 | 6 | load util/assertions 7 | 8 | setup() { 9 | # We want to work from a temporary directory, different for every test. 10 | x=$(mktemp -d -t dfx-usage-env-home-XXXXXXXX) 11 | cd "$x" || exit 12 | export DFX_CONFIG_ROOT=$x 13 | 14 | dfx new --no-frontend e2e_project 15 | cd e2e_project || exit 1 16 | dfx start --background --clean 17 | } 18 | 19 | teardown() { 20 | dfx stop 21 | rm -rf "$DFX_CONFIG_ROOT" 22 | } 23 | 24 | @test "wallet_call_with_max_cycles" { 25 | dfx identity new alice 26 | dfx identity new bob 27 | WALLET_ALICE=$(dfx --identity alice identity get-wallet) 28 | WALLET_BOB=$(dfx --identity bob identity get-wallet) 29 | 30 | ALICE_CYCLES_BEFORE_SEND=$(dfx --identity alice wallet balance | sed 's/[^0-9]//g') 31 | if (( ALICE_CYCLES_BEFORE_SEND < 2000000000000 )); then 32 | echo "alice has unexpectedly few cycles before sending: ${ALICE_CYCLES_BEFORE_SEND}" 33 | exit 1 34 | fi 35 | 36 | # non-controller can't make the call 37 | assert_command_fail dfx --identity bob canister call "${WALLET_ALICE}" wallet_call_with_max_cycles "(record { canister = principal \"${WALLET_BOB}\"; method_name = \"wallet_receive\"; args = blob \"\44\49\44\4c\00\00\"; })" 38 | 39 | assert_command dfx --identity alice canister call "${WALLET_ALICE}" wallet_call_with_max_cycles "(record { canister = principal \"${WALLET_BOB}\"; method_name = \"wallet_receive\"; args = blob \"\44\49\44\4c\00\00\"; })" 40 | 41 | # has less than 0.2T cycles afterwards 42 | ALICE_CYCLES_AFTER_SEND=$(dfx --identity alice wallet balance | sed 's/[^0-9]//g') 43 | if (( ALICE_CYCLES_AFTER_SEND > 200000000000 )); then 44 | echo "expected alice to have <1TC after wallet_call_with_max_cycles, actually has ${ALICE_CYCLES_AFTER_SEND}, before was ${ALICE_CYCLES_BEFORE_SEND}" 45 | exit 1 46 | fi 47 | } 48 | -------------------------------------------------------------------------------- /e2e/bash/upgrade.bash: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bats 2 | 3 | # shellcheck source=/dev/null 4 | source "$BATS_SUPPORT/load.bash" 5 | 6 | load util/assertions 7 | 8 | setup() { 9 | # We want to work from a temporary directory, different for every test. 10 | x=$(mktemp -d -t dfx-usage-env-home-XXXXXXXX) 11 | cd "$x" || exit 12 | export DFX_CONFIG_ROOT=$x 13 | 14 | dfx new --no-frontend e2e_project 15 | cd e2e_project || exit 1 16 | dfx start --background 17 | } 18 | 19 | teardown() { 20 | dfx stop 21 | rm -rf "$DFX_CONFIG_ROOT" 22 | } 23 | 24 | @test "upgrading v0->current correctly migrates events" { 25 | ( 26 | export DFX_WALLET_WASM=$assets/wallet-v0.wasm 27 | WALLET=$(dfx identity get-wallet) 28 | assert_command dfx deploy --with-cycles 1000000000 --wallet "$WALLET" e2e_project 29 | CANISTER=$(dfx canister id e2e_project) 30 | assert_command_fail dfx canister call "$WALLET" get_managed_canister_events "(record { canister = principal \"$CANISTER\" })" 31 | assert_command dfx canister call "$WALLET" get_events '(null)' 32 | # CanisterCreated = 1205528161; cycles = 2190693645; canister = 2631180839 33 | assert_match "1_205_528_161 = record \\{[[:space:]]+2_190_693_645 = 1_000_000_000 : nat64;[[:space:]]+2_631_180_839 = principal \"$CANISTER\"" 34 | ) 35 | # ^ reset DFX_WALLET_WASM 36 | assert_command [ -n "$DFX_WALLET_WASM" ] 37 | assert_command dfx canister info "$(dfx identity get-wallet)" 38 | assert_match "Module hash: 0x([0-9a-f]+)" 39 | HASH=${BASH_REMATCH[1]} 40 | assert_command [ -n "$HASH" ] 41 | assert_command dfx wallet upgrade 42 | assert_command dfx canister info "$(dfx identity get-wallet)" 43 | assert_not_match "$HASH" 44 | WALLET=$(dfx identity get-wallet) 45 | CANISTER=$(dfx canister id e2e_project) 46 | assert_command dfx canister call "$WALLET" get_events '(null)' 47 | # CanisterCreated = 1205528161; cycles = 2190693645; canister = 2631180839 48 | assert_match "1_205_528_161 = record \\{[[:space:]]+2_190_693_645 = 1_000_000_000 : nat64;[[:space:]]+2_631_180_839 = principal \"$CANISTER\"" 49 | assert_command dfx canister call "$WALLET" get_managed_canister_events "(record { canister = principal \"$CANISTER\" })" 50 | # Created = 3736853960; cycles = 2190693645 51 | assert_match "3_736_853_960 = record \\{[[:space:]]+2_190_693_645 = 1_000_000_000 : nat64" 52 | } 53 | 54 | @test "upgrading v1->current correctly migrates events" { 55 | ( 56 | export DFX_WALLET_WASM=$assets/wallet-v1.wasm 57 | WALLET=$(dfx identity get-wallet) 58 | assert_command dfx deploy --with-cycles 1000000000 --wallet "$WALLET" e2e_project 59 | CANISTER=$(dfx canister id e2e_project) 60 | assert_command_fail dfx canister call "$WALLET" get_events128 '(null)' 61 | assert_command dfx canister call "$WALLET" get_events '(null)' 62 | # CanisterCreated = 1205528161; cycles = 2190693645; canister = 2631180839 63 | assert_match "1_205_528_161 = record \\{[[:space:]]+2_190_693_645 = 1_000_000_000 : nat64;[[:space:]]+2_631_180_839 = principal \"$CANISTER\"" 64 | ) 65 | # ^ reset DFX_WALLET_WASM 66 | assert_command [ -n "$DFX_WALLET_WASM" ] 67 | assert_command dfx canister info "$(dfx identity get-wallet)" 68 | assert_match "Module hash: 0x([0-9a-f]+)" 69 | HASH=${BASH_REMATCH[1]} 70 | assert_command [ -n "$HASH" ] 71 | assert_command dfx wallet upgrade 72 | assert_command dfx canister info "$(dfx identity get-wallet)" 73 | assert_not_match "$HASH" 74 | WALLET=$(dfx identity get-wallet) 75 | CANISTER=$(dfx canister id e2e_project) 76 | assert_command dfx canister call "$WALLET" get_events '(null)' 77 | # CanisterCreated = 1205528161; cycles = 2190693645; canister = 2631180839 78 | assert_match "1_205_528_161 = record \\{[[:space:]]+2_190_693_645 = 1_000_000_000 : nat64;[[:space:]]+2_631_180_839 = principal \"$CANISTER\"" 79 | assert_command dfx canister call "$WALLET" get_events128 '(null)' 80 | assert_match "1_205_528_161 = record \\{[[:space:]]+2_190_693_645 = 1_000_000_000 : nat;[[:space:]]+2_631_180_839 = principal \"$CANISTER\"" 81 | } 82 | -------------------------------------------------------------------------------- /e2e/bash/util/assertions.bash: -------------------------------------------------------------------------------- 1 | #! 2 | 3 | # Asserts that a command line succeeds. Still sets $output to the stdout and stderr 4 | # of the command. 5 | # Arguments: 6 | # $@ - The command to run. 7 | # Returns: 8 | # none 9 | assert_command() { 10 | x="$(mktemp)" 11 | local stderrf="$x" 12 | x="$(mktemp)" 13 | local stdoutf="$x" 14 | x="$(mktemp)" 15 | local statusf="$x" 16 | ( set +e; "$@" 2>"$stderrf" >"$stdoutf"; echo -n "$?" > "$statusf" ) 17 | status="$(<"$statusf")"; rm "$statusf" 18 | 19 | stderr="$(cat "$stderrf")"; rm "$stderrf" 20 | stdout="$(cat "$stdoutf")"; rm "$stdoutf" 21 | # shellcheck disable=SC2015 22 | output="$( \ 23 | [ "$stderr" ] && echo "$stderr" || true; \ 24 | [ "$stdout" ] && echo "$stdout" || true; \ 25 | )" 26 | 27 | [[ $status == 0 ]] || \ 28 | ( (echo "$*"; echo "status: $status"; echo "$output" | batslib_decorate "Output") \ 29 | | batslib_decorate "Command failed" \ 30 | | fail) 31 | } 32 | 33 | # Asserts that a command line fails. Still sets $output to the stdout and stderr 34 | # of the command. 35 | # Arguments: 36 | # $@ - The command to run. 37 | # Returns: 38 | # none 39 | assert_command_fail() { 40 | x="$(mktemp)" 41 | local stderrf="$x" 42 | x="$(mktemp)" 43 | local stdoutf="$x" 44 | x="$(mktemp)" 45 | local statusf="$x" 46 | ( set +e; "$@" 2>"$stderrf" >"$stdoutf"; echo -n "$?" >"$statusf" ) 47 | status="$(<"$statusf")"; rm "$statusf" 48 | 49 | stderr="$(cat "$stderrf")"; rm "$stderrf" 50 | stdout="$(cat "$stdoutf")"; rm "$stdoutf" 51 | # shellcheck disable=SC2015 52 | output="$( 53 | [ "$stderr" ] && echo "$stderr" || true; 54 | [ "$stdout" ] && echo "$stdout" || true; 55 | )" 56 | 57 | [[ $status != 0 ]] || \ 58 | ( (echo "$*"; echo "$output" | batslib_decorate "Output") \ 59 | | batslib_decorate "Command succeeded (should have failed)" \ 60 | | fail) 61 | } 62 | 63 | # Asserts that a string contains another string, using regexp. 64 | # Arguments: 65 | # $1 - The regex to use to match. 66 | # $2 - The string to match against (output). By default it will use 67 | # $output. 68 | assert_match() { 69 | regex="$1" 70 | if [[ $# -lt 2 ]]; then 71 | text="$output" 72 | else 73 | text="$2" 74 | fi 75 | [[ "$text" =~ $regex ]] || \ 76 | (batslib_print_kv_single_or_multi 10 "regex" "$regex" "actual" "$text" \ 77 | | batslib_decorate "output does not match" \ 78 | | fail) 79 | } 80 | 81 | # Asserts that a string does not contain another string, using regexp. 82 | # Arguments: 83 | # $1 - The regex to use to match. 84 | # $2 - The string to match against (output). By default it will use 85 | # $output. 86 | assert_not_match() { 87 | regex="$1" 88 | if [[ $# -lt 2 ]]; then 89 | text="$output" 90 | else 91 | text="$2" 92 | fi 93 | if [[ "$text" =~ $regex ]]; then 94 | (batslib_print_kv_single_or_multi 10 "regex" "$regex" "actual" "$text" \ 95 | | batslib_decorate "output matches but is expected not to" \ 96 | | fail) 97 | fi 98 | } 99 | 100 | # Asserts a command will timeout. This assertion will fail if the command finishes before 101 | # the timeout period. If the command fails, it will also fail. 102 | # Arguments: 103 | # $1 - The amount of time (in seconds) to wait for. 104 | # $@ - The command to run. 105 | 106 | # Asserts that two values are equal. 107 | # Arguments: 108 | # $1 - The expected value. 109 | # $2 - The actual value. 110 | assert_eq() { 111 | expected="$1" 112 | if [[ $# -lt 2 ]]; then 113 | actual="$output" 114 | else 115 | actual="$2" 116 | fi 117 | 118 | [[ "$actual" == "$expected" ]] || \ 119 | (batslib_print_kv_single_or_multi 10 "expected" "$expected" "actual" "$actual" \ 120 | | batslib_decorate "output does not match" \ 121 | | fail) 122 | } 123 | 124 | # Asserts that two values are not equal. 125 | # Arguments: 126 | # $1 - The expected value. 127 | # $2 - The actual value. 128 | assert_neq() { 129 | expected="$1" 130 | if [[ $# -lt 2 ]]; then 131 | actual="$output" 132 | else 133 | actual="$2" 134 | fi 135 | 136 | [[ "$actual" != "$expected" ]] || \ 137 | (batslib_print_kv_single_or_multi 10 "expected" "$expected" "actual" "$actual" \ 138 | | batslib_decorate "output does not match" \ 139 | | fail) 140 | } 141 | 142 | 143 | # Asserts that a process exits within a timeframe 144 | # Arguments: 145 | # $1 - the PID 146 | # $2 - the timeout 147 | assert_process_exits() { 148 | pid="$1" 149 | timeout="$2" 150 | 151 | timeout "$timeout" sh -c \ 152 | "while kill -0 $pid; do echo waiting for process $pid to exit; sleep 1; done" \ 153 | || (echo "process $pid did not exit" && ps aux && exit 1) 154 | 155 | } 156 | 157 | assert_file_eventually_exists() { 158 | filename="$1" 159 | timeout="$2" 160 | 161 | timeout "$timeout" sh -c \ 162 | "until [ -f $filename ]; do echo waiting for $filename; sleep 1; done" \ 163 | || (echo "file $filename was never created" && ls && exit 1) 164 | } 165 | -------------------------------------------------------------------------------- /e2e/bash/version.bash: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bats 2 | 3 | # shellcheck source=/dev/null 4 | source "$BATS_SUPPORT/load.bash" 5 | 6 | load util/assertions 7 | 8 | setup() { 9 | # We want to work from a temporary directory, different for every test. 10 | x=$(mktemp -d -t dfx-usage-env-home-XXXXXXXX) 11 | cd "$x" || exit 12 | export DFX_CONFIG_ROOT=$x 13 | 14 | dfx new --no-frontend e2e_project 15 | cd e2e_project || exit 1 16 | dfx start --background 17 | } 18 | 19 | teardown() { 20 | dfx stop 21 | rm -rf "$DFX_CONFIG_ROOT" 22 | } 23 | 24 | @test "reports the wallet API version" { 25 | WALLET_ID=$(dfx identity get-wallet) 26 | assert_command dfx canister call "${WALLET_ID}" wallet_api_version "()" 27 | assert_eq '("0.3.2")' 28 | } 29 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | bail: false, 3 | setupFiles: ["./test-setup"], 4 | setupFilesAfterEnv: ["jest-expect-message"], 5 | testPathIgnorePatterns: ["/node_modules/", "/out/", "\\.js$"], 6 | transform: { 7 | "^.+\\.ts$": "ts-jest", 8 | }, 9 | }; 10 | -------------------------------------------------------------------------------- /overrideQueries.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs"); 2 | const path = require("path"); 3 | 4 | const path1 = path.join( 5 | __dirname, 6 | "wallet_ui", 7 | "declarations", 8 | "wallet", 9 | "wallet.did.js" 10 | ); 11 | const path2 = path.join( 12 | __dirname, 13 | "wallet_ui", 14 | "canister", 15 | "wallet", 16 | "wallet.did.js" 17 | ); 18 | 19 | const list = [path1, path2]; 20 | list.forEach((p) => { 21 | let declarations = fs.readFileSync(p).toString(); 22 | 23 | // replace all instances of ['query'] with [] 24 | declarations = declarations.replace(/\['query'\]/g, () => { 25 | return "[]"; 26 | }); 27 | declarations = declarations.replace(/\["query"]/g, () => { 28 | return "[]"; 29 | }); 30 | 31 | fs.writeFileSync(p, declarations); 32 | }); 33 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": "DFINITY Stiftung ", 3 | "license": "Apache-2.0", 4 | "name": "@dfinity/wallet-ui", 5 | "scripts": { 6 | "build": "webpack", 7 | "start": "webpack serve --config webpack.dev.js", 8 | "generate_candid": "dfx generate wallet; npm run override_queries", 9 | "override_queries": "node overrideQueries.js", 10 | "postoverride_queries": "npm run prettier:format", 11 | "presideload-ui": "npm run build", 12 | "prettier:format": "npx -p prettier -p pretty-quick pretty-quick", 13 | "test": "jest --verbose" 14 | }, 15 | "version": "0.1.0", 16 | "private": true, 17 | "dependencies": { 18 | "@babel/core": "7.9.0", 19 | "@babel/preset-react": "7.9.0", 20 | "@dfinity/agent": "0.15.6", 21 | "@dfinity/auth-client": "0.15.6", 22 | "@dfinity/candid": "0.15.6", 23 | "@dfinity/identity": "0.15.6", 24 | "@dfinity/principal": "0.15.6", 25 | "@emotion/css": "^11.1.3", 26 | "@material-ui/core": "^4.11.2", 27 | "@material-ui/icons": "^4.11.2", 28 | "@types/react-router-dom": "^5.1.6", 29 | "@types/react-timeago": "^4.1.2", 30 | "assert": "^2.0.0", 31 | "babel-loader": "8.1.0", 32 | "buffer": "^6.0.3", 33 | "cbor": "^5.1.0", 34 | "compression-webpack-plugin": "^7.1.2", 35 | "css-loader": "^5.2.6", 36 | "events": "^3.2.0", 37 | "hook-shell-script-webpack-plugin": "^0.1.3", 38 | "html-webpack-plugin": "^5.2.0", 39 | "nan": "^2.14.1", 40 | "process": "^0.11.10", 41 | "react": "16.13.1", 42 | "react-copy-to-clipboard": "^5.0.2", 43 | "react-countup": "4.3.3", 44 | "react-dom": "16.13.1", 45 | "react-router-dom": "^5.2.0", 46 | "react-timeago": "^5.2.0", 47 | "recharts": "^2.4.3", 48 | "stream-browserify": "^3.0.0", 49 | "style-loader": "1.2.1", 50 | "terser-webpack-plugin": "^5.0.3", 51 | "util": "^0.12.3", 52 | "webpack-cli": "^4.9.0" 53 | }, 54 | "devDependencies": { 55 | "@trust/webcrypto": "^0.9.2", 56 | "@types/cbor": "^5.0.1", 57 | "@types/jest": "^26.0.22", 58 | "@types/react": "^16.9.49", 59 | "@types/react-copy-to-clipboard": "^5.0.0", 60 | "@types/react-dom": "^16.9.8", 61 | "@types/react-syntax-highlighter": "^13.5.0", 62 | "@types/recharts": "^1.8.18", 63 | "@types/webpack-env": "^1.16.0", 64 | "@webpack-cli/serve": "^1.1.0", 65 | "copy-webpack-plugin": "^8.0.0", 66 | "html-loader": "^2.1.1", 67 | "html-webpack-plugin": "^4.5.2", 68 | "jest": "^26.6.3", 69 | "jest-expect-message": "^1.0.2", 70 | "node-fetch": "^2.6.1", 71 | "prettier": "^2.7.0", 72 | "pretty-quick": "^3.1.3", 73 | "react-number-format": "^4.4.3", 74 | "sass": "^1.32.8", 75 | "sass-loader": "^11.0.1", 76 | "text-encoding": "^0.7.0", 77 | "ts-jest": "^26.4.4", 78 | "ts-loader": "^8.0.4", 79 | "typescript": "^4.2.3", 80 | "url-loader": "^4.1.0", 81 | "webpack": "^5.76.0", 82 | "webpack-dev-server": "^4.4.0" 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /rust-toolchain.toml: -------------------------------------------------------------------------------- 1 | [toolchain] 2 | channel = "1.74.1" 3 | components = ["clippy", "rustfmt"] 4 | targets = ["wasm32-unknown-unknown"] 5 | -------------------------------------------------------------------------------- /test-setup.js: -------------------------------------------------------------------------------- 1 | global.TextEncoder = require("text-encoding").TextEncoder; 2 | 3 | const { Actor, Principal } = require("@dfinity/agent"); 4 | 5 | window.ic = { 6 | canister: Actor.createActor(({ IDL }) => IDL.Service({}), { 7 | canisterId: Principal.anonymous(), 8 | }), 9 | }; 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "incremental": true, 5 | "target": "es2018" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */, 6 | "module": "esnext" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */, 7 | "moduleResolution": "node", 8 | "lib": [ 9 | "ES2020", 10 | "DOM" 11 | ] /* Specify library files to be included in the compilation. */, 12 | "allowJs": true /* Allow javascript files to be compiled. */, 13 | "jsx": "react" /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */, 14 | "typeRoots": ["./src/@types", "./wallet_ui/types", "./node_modules/@types"], 15 | "allowSyntheticDefaultImports": true 16 | }, 17 | "include": ["wallet_ui/**/*"] 18 | } 19 | -------------------------------------------------------------------------------- /wallet/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "wallet" 3 | version = "0.3.2" 4 | authors = ["DFINITY Stiftung "] 5 | edition = "2021" 6 | rust-version = "1.58.1" 7 | 8 | [lib] 9 | crate-type = ["cdylib"] 10 | path = "src/lib.rs" 11 | 12 | [dependencies] 13 | base64 = "0.21.0" 14 | ic-cdk = "0.12" 15 | ic-certified-map = "0.4.0" 16 | candid = "0.10" 17 | lazy_static = "1.4.0" 18 | libflate = "2" 19 | num-traits = "0.2.14" 20 | serde = "1.0.116" 21 | serde_cbor = "0.11" 22 | serde_bytes = "0.11" 23 | serde_with = "3.6" 24 | indexmap = "2.2" 25 | sha2 = "0.10.2" 26 | regex = "1" 27 | 28 | [build-dependencies] 29 | sha2 = "0.10.2" 30 | -------------------------------------------------------------------------------- /wallet/build.rs: -------------------------------------------------------------------------------- 1 | use sha2::Digest; 2 | use std::env; 3 | use std::ffi::OsStr; 4 | use std::fs; 5 | use std::fs::File; 6 | use std::io::Write; 7 | use std::path::{Path, PathBuf}; 8 | use std::process::Command; 9 | 10 | fn hash_file(path: &Path) -> [u8; 32] { 11 | let bytes = fs::read(path) 12 | .unwrap_or_else(|e| panic!("failed to read file {}: {}", &path.to_str().unwrap(), e)); 13 | let mut hasher = sha2::Sha256::new(); 14 | hasher.update(&bytes); 15 | hasher.finalize().into() 16 | } 17 | 18 | fn main() { 19 | let out_dir = env::var("OUT_DIR").unwrap(); 20 | let location = Command::new(env::var("CARGO").unwrap()) 21 | .args(["locate-project", "--workspace", "--message-format=plain"]) 22 | .output() 23 | .expect("Could not locate project"); 24 | assert!(location.status.success(), "Could not locate project"); 25 | let pwd = String::from_utf8(location.stdout).expect("Could not locate project"); 26 | let pwd = Path::new(pwd.trim()).parent().unwrap(); 27 | let getting_out_dir: PathBuf = PathBuf::from(out_dir.clone()) 28 | .strip_prefix(pwd) 29 | .unwrap() 30 | .components() 31 | .map(|_| "..") 32 | .collect(); 33 | println!( 34 | "cargo:rustc-env=DIST_DIR={}/dist/", 35 | getting_out_dir.to_str().unwrap() 36 | ); 37 | let loader_path = Path::new(&out_dir).join("assets.rs"); 38 | eprintln!("cargo:rerun-if-changed={}", loader_path.to_string_lossy()); 39 | let mut f = File::create(&loader_path).unwrap(); 40 | 41 | writeln!( 42 | f, 43 | r#" 44 | pub fn for_each_asset(mut f: impl FnMut(&'static str, Vec<(String, String)>, &'static [u8], &[u8; 32])) {{ 45 | "# 46 | ) 47 | .unwrap(); 48 | 49 | for entry in std::fs::read_dir(pwd.join("dist")).unwrap() { 50 | let entry = entry.unwrap(); 51 | let path = entry.path(); 52 | let filename = path.file_name().unwrap().to_str().unwrap(); 53 | 54 | let (file_type, gzipped) = if filename.ends_with(".js") { 55 | ("text/javascript; charset=UTF-8", false) 56 | } else if filename.ends_with(".js.gz") { 57 | ("text/javascript; charset=UTF-8", true) 58 | } else if filename.ends_with(".html") { 59 | ("text/html; charset=UTF-8", false) 60 | } else if filename.ends_with(".html.gz") { 61 | ("text/html; charset=UTF-8", true) 62 | } else if filename.ends_with(".png") { 63 | ("image/png", false) 64 | } else if filename.ends_with(".ico") { 65 | ("image/x-icon", false) 66 | } else if filename.ends_with(".svg") { 67 | ("image/svg+xml", false) 68 | } else if filename.ends_with(".txt") { 69 | // Ignore these. 70 | eprintln!("File ignored: {}", filename); 71 | continue; 72 | } else { 73 | unreachable!( 74 | "Filename extension needs to be added to resolve content type: {}", 75 | filename 76 | ); 77 | }; 78 | 79 | let url_path = path.file_name().unwrap(); 80 | let path_buf = PathBuf::from(url_path); 81 | let ext = path_buf.extension(); 82 | let ext_len = if ext == Some(OsStr::new("gz")) { 3 } else { 0 }; 83 | let url_path = url_path.to_str().unwrap(); 84 | let url_path = &url_path[..url_path.len() - ext_len]; 85 | let url_path = "/".to_string() + url_path; 86 | 87 | let hash = hash_file(&path); 88 | writeln!( 89 | f, 90 | r#" f("{}", vec![("Content-Type".to_string(), "{}".to_string()){},("Cache-Control".to_string(), "max-age=600".to_string())], &include_bytes!(concat!(env!("DIST_DIR"), "{}"))[..], &{:?});"#, 91 | url_path, 92 | file_type, 93 | if gzipped { r#",("Content-Encoding".to_string(), "gzip".to_string())"# } else { "" }, 94 | filename, 95 | hash 96 | ) 97 | .unwrap(); 98 | println!("cargo:rerun-if-changed={}", path.to_str().unwrap()); 99 | } 100 | writeln!(f, "}}").unwrap(); 101 | println!("cargo:rerun-if-changed=build.rs"); 102 | } 103 | -------------------------------------------------------------------------------- /wallet/build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | [ -x "$(which npm)" ] || { 5 | echo "You need npm installed to build the frontend." 6 | echo "This is an error." 7 | exit 1 8 | } 9 | 10 | # Build frontend before wallet. 11 | npm install 12 | npm run build 13 | gzip -f dist/*.js 14 | 15 | # Disable modern wasm features so the wallet binary will run on dfx 0.9.2's bundled replica 16 | cargo rustc -p wallet --target wasm32-unknown-unknown --release -- -Ctarget-cpu=mvp -Ctarget-feature=-sign-ext 17 | 18 | cargo install ic-wasm --root target --locked 19 | STATUS=$? 20 | 21 | if [ "$STATUS" -eq "0" ]; then 22 | target/bin/ic-wasm \ 23 | target/wasm32-unknown-unknown/release/wallet.wasm \ 24 | -o target/wasm32-unknown-unknown/release/wallet-opt.wasm \ 25 | shrink 26 | 27 | true 28 | else 29 | echo Could not install ic-wasm. 30 | false 31 | fi 32 | -------------------------------------------------------------------------------- /wallet/src/address.rs: -------------------------------------------------------------------------------- 1 | use candid::{CandidType, Principal}; 2 | use serde::Deserialize; 3 | use std::cell::RefCell; 4 | use std::cmp::Ordering; 5 | use std::collections::BTreeSet; 6 | use std::fmt::Formatter; 7 | 8 | /// The role of the address, whether it's a [Contact], [Custodian], or a [Controller]. A 9 | /// [Controller] is the most privileged role, and can rename the wallet, add entries to the 10 | /// address book. A [Custodian] can access the wallet information, send cycles, forward 11 | /// calls, and create canisters. 12 | /// 13 | /// A [Contact] is simply a way to name canisters, and can be seen as a crude address book. 14 | /// 15 | /// TODO: add support for an address book in the frontend when sending cycles. 16 | #[derive(Clone, Debug, Ord, PartialOrd, Eq, PartialEq, CandidType, Deserialize)] 17 | pub enum Role { 18 | Contact, 19 | Custodian, 20 | Controller, 21 | } 22 | 23 | /// The kind of address, whether it's a user or canister, and whether it's known. 24 | #[derive(Copy, Clone, Debug, Ord, PartialOrd, Eq, PartialEq, CandidType, Deserialize)] 25 | pub enum Kind { 26 | Unknown, 27 | User, 28 | Canister, 29 | } 30 | 31 | /// An entry in the address book. It must have an ID and a role. 32 | #[derive(Debug, Clone, Eq, CandidType, Deserialize)] 33 | pub struct AddressEntry { 34 | /// The canister ID. 35 | pub id: Principal, 36 | /// An optional name for this address. 37 | pub name: Option, 38 | /// The kind of address (whether it is a known canister or user). 39 | pub kind: Kind, 40 | /// The role this address has on the wallet. 41 | pub role: Role, 42 | } 43 | 44 | impl PartialOrd for AddressEntry { 45 | fn partial_cmp(&self, other: &Self) -> Option { 46 | Some(self.cmp(other)) 47 | } 48 | } 49 | 50 | impl Ord for AddressEntry { 51 | fn cmp(&self, other: &Self) -> Ordering { 52 | self.id.cmp(&other.id) 53 | } 54 | } 55 | 56 | impl PartialEq for AddressEntry { 57 | fn eq(&self, other: &Self) -> bool { 58 | self.id == other.id 59 | } 60 | } 61 | 62 | impl AddressEntry { 63 | pub fn new(id: Principal, name: Option, role: Role) -> AddressEntry { 64 | AddressEntry { 65 | id, 66 | name, 67 | role, 68 | kind: Kind::Unknown, 69 | } 70 | } 71 | 72 | pub fn is_controller(&self) -> bool { 73 | self.role == Role::Controller 74 | } 75 | 76 | pub fn is_custodian(&self) -> bool { 77 | self.role == Role::Custodian 78 | } 79 | 80 | pub fn is_controller_or_custodian(&self) -> bool { 81 | self.role == Role::Controller || self.role == Role::Custodian 82 | } 83 | } 84 | 85 | /// The address book for this wallet. 86 | #[derive(Default, Clone)] 87 | pub struct AddressBook(BTreeSet); 88 | 89 | thread_local! { 90 | pub static ADDRESS_BOOK: RefCell = Default::default(); 91 | } 92 | 93 | impl AddressBook { 94 | #[inline] 95 | pub fn insert(&mut self, entry: AddressEntry) { 96 | if let Some(mut existing) = self.0.take(&entry) { 97 | if entry.name.is_some() { 98 | existing.name = entry.name; 99 | } 100 | if entry.role > existing.role { 101 | existing.role = entry.role; 102 | } 103 | if !matches!(entry.kind, Kind::Unknown) { 104 | existing.kind = entry.kind; 105 | } 106 | self.0.insert(existing); 107 | } else { 108 | self.0.insert(entry); 109 | } 110 | } 111 | 112 | #[inline] 113 | pub fn find(&self, id: &Principal) -> Option<&AddressEntry> { 114 | self.0.iter().find(|&a| &a.id == id) 115 | } 116 | 117 | #[inline] 118 | pub fn remove(&mut self, principal: &Principal) { 119 | // Because we order by ID, we can create entries and remove them. 120 | self.0 121 | .remove(&AddressEntry::new(*principal, None, Role::Contact)); 122 | } 123 | 124 | #[inline] 125 | pub fn take(&mut self, principal: &Principal) -> Option { 126 | // Because we order by ID, we can create entries and remove them. 127 | self.0 128 | .take(&AddressEntry::new(*principal, None, Role::Contact)) 129 | } 130 | 131 | #[inline] 132 | pub fn is_custodian(&self, principal: &Principal) -> bool { 133 | self.find(principal).map_or(false, |e| e.is_custodian()) 134 | } 135 | 136 | #[inline] 137 | pub fn is_controller(&self, principal: &Principal) -> bool { 138 | self.find(principal).map_or(false, |e| e.is_controller()) 139 | } 140 | 141 | #[inline] 142 | pub fn is_controller_or_custodian(&self, principal: &Principal) -> bool { 143 | self.find(principal) 144 | .map_or(false, |e| e.is_controller_or_custodian()) 145 | } 146 | 147 | #[inline] 148 | pub fn custodians(&self) -> impl Iterator { 149 | self.iter().filter(|e| e.is_custodian()) 150 | } 151 | 152 | #[inline] 153 | pub fn controllers(&self) -> impl Iterator { 154 | self.iter().filter(|e| e.is_controller()) 155 | } 156 | 157 | #[inline] 158 | pub fn iter(&self) -> impl Iterator { 159 | self.0.iter() 160 | } 161 | } 162 | 163 | impl std::fmt::Debug for AddressBook { 164 | fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { 165 | f.debug_tuple("AddressBook").field(&self.0).finish() 166 | } 167 | } 168 | 169 | #[cfg(test)] 170 | mod tests { 171 | use crate::address::{AddressBook, AddressEntry, Role}; 172 | use candid::Principal; 173 | 174 | #[test] 175 | fn can_update_existing() { 176 | let mut book: AddressBook = Default::default(); 177 | book.insert(AddressEntry::new( 178 | Principal::anonymous(), 179 | None, 180 | Role::Contact, 181 | )); 182 | book.insert(AddressEntry::new( 183 | Principal::anonymous(), 184 | None, 185 | Role::Controller, 186 | )); 187 | assert!(book.is_controller(&Principal::anonymous())); 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /wallet/src/lib.did: -------------------------------------------------------------------------------- 1 | type EventKind = variant { 2 | CyclesSent: record { 3 | to: principal; 4 | amount: nat64; 5 | refund: nat64; 6 | }; 7 | CyclesReceived: record { 8 | from: principal; 9 | amount: nat64; 10 | memo: opt text; 11 | }; 12 | AddressAdded: record { 13 | id: principal; 14 | name: opt text; 15 | role: Role; 16 | }; 17 | AddressRemoved: record { 18 | id: principal; 19 | }; 20 | CanisterCreated: record { 21 | canister: principal; 22 | cycles: nat64; 23 | }; 24 | CanisterCalled: record { 25 | canister: principal; 26 | method_name: text; 27 | cycles: nat64; 28 | }; 29 | WalletDeployed: record { 30 | canister: principal; 31 | } 32 | }; 33 | 34 | type EventKind128 = variant { 35 | CyclesSent: record { 36 | to: principal; 37 | amount: nat; 38 | refund: nat; 39 | }; 40 | CyclesReceived: record { 41 | from: principal; 42 | amount: nat; 43 | memo: opt text; 44 | }; 45 | AddressAdded: record { 46 | id: principal; 47 | name: opt text; 48 | role: Role; 49 | }; 50 | AddressRemoved: record { 51 | id: principal; 52 | }; 53 | CanisterCreated: record { 54 | canister: principal; 55 | cycles: nat; 56 | }; 57 | CanisterCalled: record { 58 | canister: principal; 59 | method_name: text; 60 | cycles: nat; 61 | }; 62 | WalletDeployed: record { 63 | canister: principal; 64 | }; 65 | }; 66 | 67 | type Event = record { 68 | id: nat32; 69 | timestamp: nat64; 70 | kind: EventKind; 71 | }; 72 | 73 | type Event128 = record { 74 | id: nat32; 75 | timestamp: nat64; 76 | kind: EventKind128; 77 | }; 78 | 79 | type Role = variant { 80 | Contact; 81 | Custodian; 82 | Controller; 83 | }; 84 | 85 | type Kind = variant { 86 | Unknown; 87 | User; 88 | Canister; 89 | }; 90 | 91 | // An entry in the address book. It must have an ID and a role. 92 | type AddressEntry = record { 93 | id: principal; 94 | name: opt text; 95 | kind: Kind; 96 | role: Role; 97 | }; 98 | 99 | type ManagedCanisterInfo = record { 100 | id: principal; 101 | name: opt text; 102 | created_at: nat64; 103 | }; 104 | 105 | type ManagedCanisterEventKind = variant { 106 | CyclesSent: record { 107 | amount: nat64; 108 | refund: nat64; 109 | }; 110 | Called: record { 111 | method_name: text; 112 | cycles: nat64; 113 | }; 114 | Created: record { 115 | cycles: nat64; 116 | }; 117 | }; 118 | 119 | type ManagedCanisterEventKind128 = variant { 120 | CyclesSent: record { 121 | amount: nat; 122 | refund: nat; 123 | }; 124 | Called: record { 125 | method_name: text; 126 | cycles: nat; 127 | }; 128 | Created: record { 129 | cycles: nat; 130 | }; 131 | }; 132 | 133 | type ManagedCanisterEvent = record { 134 | id: nat32; 135 | timestamp: nat64; 136 | kind: ManagedCanisterEventKind; 137 | }; 138 | 139 | type ManagedCanisterEvent128 = record { 140 | id: nat32; 141 | timestamp: nat64; 142 | kind: ManagedCanisterEventKind128; 143 | }; 144 | 145 | type ReceiveOptions = record { 146 | memo: opt text; 147 | }; 148 | 149 | type WalletResultCreate = variant { 150 | Ok : record { canister_id: principal }; 151 | Err: text; 152 | }; 153 | 154 | type WalletResult = variant { 155 | Ok : null; 156 | Err : text; 157 | }; 158 | 159 | type WalletResultCall = variant { 160 | Ok : record { return: blob }; 161 | Err : text; 162 | }; 163 | 164 | type WalletResultCallWithMaxCycles = variant { 165 | Ok : record { 166 | return: blob; 167 | attached_cycles: nat; 168 | }; 169 | Err : text; 170 | }; 171 | 172 | type CanisterSettings = record { 173 | controller: opt principal; 174 | controllers: opt vec principal; 175 | compute_allocation: opt nat; 176 | memory_allocation: opt nat; 177 | freezing_threshold: opt nat; 178 | }; 179 | 180 | type CreateCanisterArgs = record { 181 | cycles: nat64; 182 | settings: CanisterSettings; 183 | }; 184 | 185 | type CreateCanisterArgs128 = record { 186 | cycles: nat; 187 | settings: CanisterSettings; 188 | }; 189 | 190 | // Assets 191 | type HeaderField = record { text; text; }; 192 | 193 | type HttpRequest = record { 194 | method: text; 195 | url: text; 196 | headers: vec HeaderField; 197 | body: blob; 198 | }; 199 | 200 | type HttpResponse = record { 201 | status_code: nat16; 202 | headers: vec HeaderField; 203 | body: blob; 204 | streaming_strategy: opt StreamingStrategy; 205 | }; 206 | 207 | type StreamingCallbackHttpResponse = record { 208 | body: blob; 209 | token: opt Token; 210 | }; 211 | 212 | type Token = record {}; 213 | 214 | type StreamingStrategy = variant { 215 | Callback: record { 216 | callback: func (Token) -> (StreamingCallbackHttpResponse) query; 217 | token: Token; 218 | }; 219 | }; 220 | 221 | service : { 222 | wallet_api_version: () -> (text) query; 223 | 224 | // Wallet Name 225 | name: () -> (opt text) query; 226 | set_name: (text) -> (); 227 | 228 | // Controller Management 229 | get_controllers: () -> (vec principal) query; 230 | add_controller: (principal) -> (); 231 | remove_controller: (principal) -> (WalletResult); 232 | 233 | // Custodian Management 234 | get_custodians: () -> (vec principal) query; 235 | authorize: (principal) -> (); 236 | deauthorize: (principal) -> (WalletResult); 237 | 238 | // Cycle Management 239 | wallet_balance: () -> (record { amount: nat64 }) query; 240 | wallet_balance128: () -> (record { amount: nat }) query; 241 | wallet_send: (record { canister: principal; amount: nat64 }) -> (WalletResult); 242 | wallet_send128: (record { canister: principal; amount: nat }) -> (WalletResult); 243 | wallet_receive: (opt ReceiveOptions) -> (); // Endpoint for receiving cycles. 244 | 245 | // Managing canister 246 | wallet_create_canister: (CreateCanisterArgs) -> (WalletResultCreate); 247 | wallet_create_canister128: (CreateCanisterArgs128) -> (WalletResultCreate); 248 | 249 | wallet_create_wallet: (CreateCanisterArgs) -> (WalletResultCreate); 250 | wallet_create_wallet128: (CreateCanisterArgs128) -> (WalletResultCreate); 251 | 252 | wallet_store_wallet_wasm: (record { 253 | wasm_module: blob; 254 | }) -> (); 255 | 256 | // Call Forwarding 257 | wallet_call: (record { 258 | canister: principal; 259 | method_name: text; 260 | args: blob; 261 | cycles: nat64; 262 | }) -> (WalletResultCall); 263 | wallet_call128: (record { 264 | canister: principal; 265 | method_name: text; 266 | args: blob; 267 | cycles: nat; 268 | }) -> (WalletResultCall); 269 | wallet_call_with_max_cycles: (record{ 270 | canister: principal; 271 | method_name: text; 272 | args: blob; 273 | }) -> (WalletResultCallWithMaxCycles); 274 | 275 | // Address book 276 | add_address: (address: AddressEntry) -> (); 277 | list_addresses: () -> (vec AddressEntry) query; 278 | remove_address: (address: principal) -> (WalletResult); 279 | 280 | // Events 281 | // If `from` is not specified, it will start 20 from the end; if `to` is not specified, it will stop at the end 282 | get_events: (opt record { from: opt nat32; to: opt nat32; }) -> (vec Event) query; 283 | get_events128: (opt record { from: opt nat32; to: opt nat32; }) -> (vec Event128) query; 284 | get_chart: (opt record { count: opt nat32; precision: opt nat64; } ) -> (vec record { nat64; nat64; }) query; 285 | 286 | // Managed canisters 287 | list_managed_canisters: (record { from: opt nat32; to: opt nat32; }) -> (vec ManagedCanisterInfo, nat32) query; 288 | // If `from` is not specified, it will start 20 from the end; if `to` is not specified, it will stop at the end 289 | get_managed_canister_events: (record { canister: principal; from: opt nat32; to: opt nat32; }) -> (opt vec ManagedCanisterEvent) query; 290 | get_managed_canister_events128: (record { canister: principal; from: opt nat32; to: opt nat32; }) -> (opt vec ManagedCanisterEvent128) query; 291 | set_short_name: (principal, opt text) -> (opt ManagedCanisterInfo); 292 | 293 | // Assets 294 | http_request: (request: HttpRequest) -> (HttpResponse) query; 295 | } 296 | -------------------------------------------------------------------------------- /wallet/src/migrations.rs: -------------------------------------------------------------------------------- 1 | use crate::events::*; 2 | use crate::*; 3 | use ic_cdk::storage; 4 | 5 | pub mod v1; 6 | use v1::*; 7 | 8 | use Event as V2Event; 9 | use EventBuffer as V2EventBuffer; 10 | use EventKind as V2EventKind; 11 | use ManagedCanister as V2ManagedCanister; 12 | use ManagedCanisterEvent as V2ManagedCanisterEvent; 13 | use ManagedCanisterEventKind as V2ManagedCanisterEventKind; 14 | use ManagedList as V2ManagedList; 15 | use StableStorage as V2StableStorage; 16 | 17 | pub(crate) fn migrate_from(version: u32) -> Option { 18 | let v2 = if version != 2 { 19 | let (mut v1,) = storage::stable_restore::<(V1StableStorage,)>().ok()?; 20 | // from before versioning 21 | if v1.managed.is_none() { 22 | _1_create_managed_canister_list(&mut v1); 23 | } 24 | _2_convert_nat64_to_nat(v1) 25 | } else { 26 | storage::stable_restore::<(V2StableStorage,)>().ok()?.0 27 | }; 28 | Some(v2) 29 | } 30 | 31 | /// Creates the managed canister list from the event list. 32 | /// 33 | /// Call during `#[post_upgrade]`, after the event list is deserialized, if the canister list can't be deserialized. 34 | pub fn _1_create_managed_canister_list(store: &mut V1StableStorage) { 35 | let mut managed = V1ManagedList::default(); 36 | let events = &store.events; 37 | for event in events.events.as_slice() { 38 | if let Some((to, kind)) = event.kind.to_managed() { 39 | managed.push_with_timestamp(to, kind, event.timestamp); 40 | } 41 | } 42 | store.managed = Some(managed); 43 | } 44 | 45 | pub(crate) fn _2_convert_nat64_to_nat( 46 | V1StableStorage { 47 | address_book, 48 | events, 49 | name, 50 | chart, 51 | wasm_module, 52 | managed, 53 | }: V1StableStorage, 54 | ) -> V2StableStorage { 55 | let events = events 56 | .events 57 | .into_iter() 58 | .map( 59 | |V1Event { 60 | id, 61 | timestamp, 62 | kind, 63 | }| { 64 | let kind = match kind { 65 | V1EventKind::AddressAdded { id, name, role } => { 66 | V2EventKind::AddressAdded { id, name, role } 67 | } 68 | V1EventKind::AddressRemoved { id } => V2EventKind::AddressRemoved { id }, 69 | V1EventKind::CanisterCalled { 70 | canister, 71 | cycles, 72 | method_name, 73 | } => V2EventKind::CanisterCalled { 74 | canister, 75 | cycles: cycles as u128, 76 | method_name, 77 | }, 78 | V1EventKind::CanisterCreated { canister, cycles } => { 79 | V2EventKind::CanisterCreated { 80 | canister, 81 | cycles: cycles as u128, 82 | } 83 | } 84 | V1EventKind::CyclesReceived { amount, from, memo } => { 85 | V2EventKind::CyclesReceived { 86 | amount: amount as u128, 87 | from, 88 | memo, 89 | } 90 | } 91 | V1EventKind::CyclesSent { amount, refund, to } => V2EventKind::CyclesSent { 92 | amount: amount as u128, 93 | refund: refund as u128, 94 | to, 95 | }, 96 | V1EventKind::WalletDeployed { canister } => { 97 | V2EventKind::WalletDeployed { canister } 98 | } 99 | }; 100 | V2Event { 101 | id, 102 | timestamp, 103 | kind, 104 | } 105 | }, 106 | ) 107 | .collect(); 108 | let events = V2EventBuffer { events }; 109 | let managed = managed 110 | .unwrap_or_default() 111 | .0 112 | .into_iter() 113 | .map(|(principal, V1ManagedCanister { info, events })| { 114 | let events = events 115 | .into_iter() 116 | .map( 117 | |V1ManagedCanisterEvent { 118 | id, 119 | timestamp, 120 | kind, 121 | }| { 122 | let kind = match kind { 123 | V1ManagedCanisterEventKind::Called { 124 | cycles, 125 | method_name, 126 | } => V2ManagedCanisterEventKind::Called { 127 | cycles: cycles as u128, 128 | method_name, 129 | }, 130 | V1ManagedCanisterEventKind::Created { cycles } => { 131 | V2ManagedCanisterEventKind::Created { 132 | cycles: cycles as u128, 133 | } 134 | } 135 | V1ManagedCanisterEventKind::CyclesSent { amount, refund } => { 136 | V2ManagedCanisterEventKind::CyclesSent { 137 | amount: amount as u128, 138 | refund: refund as u128, 139 | } 140 | } 141 | }; 142 | V2ManagedCanisterEvent { 143 | id, 144 | timestamp, 145 | kind, 146 | } 147 | }, 148 | ) 149 | .collect(); 150 | (principal, V2ManagedCanister { info, events }) 151 | }) 152 | .collect(); 153 | let managed = Some(V2ManagedList(managed)); 154 | V2StableStorage { 155 | address_book, 156 | events, 157 | name, 158 | chart, 159 | wasm_module, 160 | managed, 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /wallet/src/migrations/v1.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::{self, Formatter}; 2 | 3 | use crate::events::*; 4 | use crate::*; 5 | use candid::types::{Compound, Serializer, Type, TypeInner}; 6 | use candid::{CandidType, Deserialize, Principal}; 7 | use indexmap::IndexMap; 8 | use serde::de::{SeqAccess, Visitor}; 9 | use serde::Deserializer; 10 | 11 | #[derive(CandidType, Clone, Deserialize)] 12 | pub enum V1EventKind { 13 | CyclesSent { 14 | to: Principal, 15 | amount: u64, 16 | refund: u64, 17 | }, 18 | CyclesReceived { 19 | from: Principal, 20 | amount: u64, 21 | memo: Option, 22 | }, 23 | AddressAdded { 24 | id: Principal, 25 | name: Option, 26 | role: Role, 27 | }, 28 | AddressRemoved { 29 | id: Principal, 30 | }, 31 | CanisterCreated { 32 | canister: Principal, 33 | cycles: u64, 34 | }, 35 | CanisterCalled { 36 | canister: Principal, 37 | method_name: String, 38 | cycles: u64, 39 | }, 40 | WalletDeployed { 41 | canister: Principal, 42 | }, 43 | } 44 | 45 | #[derive(CandidType, Deserialize)] 46 | pub struct V1EventBuffer { 47 | pub events: Vec, 48 | } 49 | 50 | #[derive(CandidType, Clone, Deserialize)] 51 | pub struct V1Event { 52 | pub id: u32, 53 | pub timestamp: u64, 54 | pub kind: V1EventKind, 55 | } 56 | 57 | #[derive(CandidType, Deserialize)] 58 | pub struct V1StableStorage { 59 | pub address_book: Vec, 60 | pub events: V1EventBuffer, 61 | pub name: Option, 62 | pub chart: Vec, 63 | pub wasm_module: Option, 64 | pub managed: Option, 65 | } 66 | 67 | #[derive(CandidType, Deserialize)] 68 | pub enum V1ManagedCanisterEventKind { 69 | CyclesSent { amount: u64, refund: u64 }, 70 | Called { method_name: String, cycles: u64 }, 71 | Created { cycles: u64 }, 72 | } 73 | 74 | #[derive(CandidType, Deserialize)] 75 | pub struct V1ManagedCanisterEvent { 76 | pub id: u32, 77 | pub timestamp: u64, 78 | pub kind: V1ManagedCanisterEventKind, 79 | } 80 | 81 | #[derive(CandidType, Deserialize)] 82 | pub struct V1ManagedCanister { 83 | pub info: ManagedCanisterInfo, 84 | pub events: Vec, 85 | } 86 | 87 | #[derive(Default)] 88 | pub struct V1ManagedList(pub IndexMap); 89 | 90 | impl CandidType for V1ManagedList { 91 | fn _ty() -> Type { 92 | Type(<_>::from(TypeInner::Vec(ManagedCanister::ty()))) 93 | } 94 | fn idl_serialize(&self, serializer: S) -> Result<(), S::Error> 95 | where 96 | S: Serializer, 97 | { 98 | let mut compound = serializer.serialize_vec(self.0.len())?; 99 | for value in self.0.values() { 100 | compound.serialize_element(value)?; 101 | } 102 | Ok(()) 103 | } 104 | } 105 | 106 | impl V1ManagedList { 107 | pub fn push_with_timestamp( 108 | &mut self, 109 | canister: Principal, 110 | event: V1ManagedCanisterEventKind, 111 | timestamp: u64, 112 | ) { 113 | let events = &mut self 114 | .0 115 | .entry(canister) 116 | .or_insert_with(|| V1ManagedCanister::new(canister)) 117 | .events; 118 | events.push(V1ManagedCanisterEvent { 119 | kind: event, 120 | id: events.len() as u32, 121 | timestamp, 122 | }) 123 | } 124 | } 125 | 126 | impl V1EventKind { 127 | pub fn to_managed(&self) -> Option<(Principal, V1ManagedCanisterEventKind)> { 128 | match *self { 129 | Self::CanisterCreated { cycles, canister } => { 130 | Some((canister, V1ManagedCanisterEventKind::Created { cycles })) 131 | } 132 | Self::CanisterCalled { 133 | canister, 134 | ref method_name, 135 | cycles, 136 | } => Some(( 137 | canister, 138 | V1ManagedCanisterEventKind::Called { 139 | method_name: method_name.clone(), 140 | cycles, 141 | }, 142 | )), 143 | Self::CyclesSent { to, amount, refund } => Some(( 144 | to, 145 | V1ManagedCanisterEventKind::CyclesSent { amount, refund }, 146 | )), 147 | Self::AddressAdded { .. } 148 | | Self::AddressRemoved { .. } 149 | | Self::CyclesReceived { .. } 150 | | Self::WalletDeployed { .. } => None, 151 | } 152 | } 153 | } 154 | 155 | impl<'de> Deserialize<'de> for V1ManagedList { 156 | fn deserialize(deserializer: D) -> Result 157 | where 158 | D: Deserializer<'de>, 159 | { 160 | deserializer.deserialize_seq(IdxMapVisitor) 161 | } 162 | } 163 | 164 | struct IdxMapVisitor; 165 | 166 | impl<'de> Visitor<'de> for IdxMapVisitor { 167 | type Value = V1ManagedList; 168 | fn expecting(&self, formatter: &mut Formatter) -> fmt::Result { 169 | write!(formatter, "a sequence of `ManagedList` records") 170 | } 171 | fn visit_seq(self, mut seq: A) -> Result 172 | where 173 | A: SeqAccess<'de>, 174 | { 175 | let mut map = IndexMap::with_capacity(seq.size_hint().unwrap_or(20)); 176 | while let Some(elem) = seq.next_element::()? { 177 | map.insert(elem.info.id, elem); 178 | } 179 | Ok(V1ManagedList(map)) 180 | } 181 | } 182 | 183 | impl V1ManagedCanister { 184 | pub fn new(id: Principal) -> Self { 185 | Self { 186 | info: ManagedCanisterInfo { 187 | id, 188 | name: None, 189 | created_at: api::time(), 190 | }, 191 | events: vec![], 192 | } 193 | } 194 | } 195 | -------------------------------------------------------------------------------- /wallet_ui/canister/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This file is a HUGE proxy for the Wallet canister itself. It is used because the 3 | * current SDK has limitations which makes it impossible to do "the right thing" in 4 | * this event. 5 | * 6 | * . We use the same canister ID as the frontend as backend. In this case this means 7 | * we cannot just create a new wallet canister actor using the DID.js, as it doesn't 8 | * exist when the UI is compiled. 9 | * 10 | * It is thus very important that the frontend only uses this file when communicating 11 | * with the wallet canister. 12 | * 13 | * It is also useful because that puts all the code in one place, including the 14 | * authentication logic. We do not use `window.ic` anywhere in this. 15 | */ 16 | import { 17 | HttpAgent, 18 | Actor, 19 | ActorSubclass, 20 | AnonymousIdentity, 21 | } from "@dfinity/agent"; 22 | import type { 23 | _SERVICE, 24 | CreateCanisterArgs, 25 | ManagedCanisterInfo, 26 | } from "../declarations/wallet/wallet.did"; 27 | import factory, { Event } from "./wallet"; 28 | import { authClient } from "../utils/authClient"; 29 | import { Principal } from "@dfinity/principal"; 30 | import { createActor } from "../declarations/wallet"; 31 | export * from "./wallet"; 32 | 33 | export function convertIdlEventMap(idlEvent: any): Event { 34 | return { 35 | id: idlEvent.id, 36 | timestamp: idlEvent.timestamp / BigInt(1000000), 37 | kind: idlEvent.kind, 38 | }; 39 | } 40 | // Need to export the enumeration from wallet.did 41 | export { Principal } from "@dfinity/principal"; 42 | 43 | function getCanisterId(): Principal { 44 | // Check the query params. 45 | const maybeCanisterId = new URLSearchParams(window.location.search).get( 46 | "canisterId" 47 | ); 48 | if (maybeCanisterId) { 49 | return Principal.fromText(maybeCanisterId); 50 | } 51 | 52 | // Return the first canister ID when resolving from the right hand side. 53 | const domain = window.location.hostname.split(".").reverse(); 54 | for (const subdomain of domain) { 55 | try { 56 | if (subdomain.length >= 25) { 57 | // The following throws if it can't decode or the checksum is invalid. 58 | return Principal.fromText(subdomain); 59 | } 60 | } catch (_) {} 61 | } 62 | 63 | throw new Error("Could not find the canister ID."); 64 | } 65 | let walletCanisterCache: ActorSubclass<_SERVICE> | null = null; 66 | 67 | export async function getAgentPrincipal(): Promise { 68 | const identity = await authClient.getIdentity(); 69 | if (identity) { 70 | return await identity.getPrincipal(); 71 | } else { 72 | return Promise.reject("Could not find identity"); 73 | } 74 | } 75 | 76 | async function getWalletCanister(): Promise> { 77 | if (walletCanisterCache) { 78 | return walletCanisterCache; 79 | } 80 | 81 | let walletId: Principal | null = null; 82 | walletId = getWalletId(walletId); 83 | 84 | if (!authClient.ready) { 85 | return Promise.reject("not yet ready"); 86 | } 87 | 88 | const identity = (await authClient.getIdentity()) ?? new AnonymousIdentity(); 89 | const agent = new HttpAgent({ 90 | identity, 91 | }); 92 | 93 | // Fetch root key if not on IC mainnet 94 | if (!window.location.host.endsWith("ic0.app")) { 95 | agent.fetchRootKey(); 96 | } 97 | 98 | if (!walletId) { 99 | throw new Error("Need to have a wallet ID."); 100 | } else { 101 | walletCanisterCache = (Actor as any).createActor(factory as any, { 102 | agent, 103 | canisterId: (await getWalletId()) || "", 104 | // Override the defaults for polling. 105 | maxAttempts: 201, 106 | throttleDurationInMSecs: 1500, 107 | }) as ActorSubclass<_SERVICE>; 108 | return walletCanisterCache; 109 | } 110 | } 111 | 112 | export enum ChartPrecision { 113 | Minutes, 114 | Hourly, 115 | Daily, 116 | Weekly, 117 | Monthly, 118 | } 119 | 120 | export function getWalletId(walletId: Principal | null = null) { 121 | const params = new URLSearchParams(location.search); 122 | const maybeWalletId = params.get("wallet"); 123 | if (maybeWalletId) { 124 | walletId = Principal.fromText(maybeWalletId); 125 | } else { 126 | walletId = getCanisterId(); 127 | } 128 | return walletId; 129 | } 130 | 131 | function precisionToNanoseconds(precision: ChartPrecision) { 132 | // Precision is a second by default (in nanoseconds). 133 | let result = 1000000; 134 | if (precision >= ChartPrecision.Monthly) result *= 4; 135 | if (precision >= ChartPrecision.Weekly) result *= 7; 136 | if (precision >= ChartPrecision.Daily) result *= 24; 137 | if (precision >= ChartPrecision.Hourly) result *= 60; 138 | if (precision >= ChartPrecision.Minutes) result *= 60; 139 | 140 | return BigInt(result); 141 | } 142 | 143 | export const Wallet = { 144 | getGeneratedActor: async () => { 145 | const identity = 146 | (await authClient.getIdentity()) ?? new AnonymousIdentity(); 147 | return createActor((await getWalletId()) || "", { 148 | agentOptions: { 149 | identity, 150 | }, 151 | }); 152 | }, 153 | async name(): Promise { 154 | return (await (await getWalletCanister()).name())[0] || ""; 155 | }, 156 | async init(): Promise { 157 | await this.balance(); 158 | }, 159 | async balance(): Promise { 160 | const walletCanister = await getWalletCanister(); 161 | return Number((await walletCanister.wallet_balance()).amount); 162 | }, 163 | clearWalletCache() { 164 | walletCanisterCache = null; 165 | }, 166 | async events(from?: number, to?: number): Promise { 167 | return await ( 168 | await this.getGeneratedActor().then((actor) => { 169 | return actor.get_events([ 170 | { 171 | to: to ? [to] : [], 172 | from: from ? [from] : [], 173 | }, 174 | ]); 175 | }) 176 | ).map(convertIdlEventMap); 177 | }, 178 | async chart(p: ChartPrecision, count?: number): Promise<[Date, number][]> { 179 | const precision = precisionToNanoseconds(p); 180 | const optCount: [] | [number] = count ? [count] : []; 181 | const optPrecision: [] | [bigint] = precision ? [precision] : []; 182 | return ( 183 | await (await getWalletCanister()).get_chart([ 184 | { 185 | count: optCount, 186 | precision: optPrecision, 187 | }, 188 | ]) 189 | ).map(([a, b]) => [ 190 | new Date(Number(BigInt(a) / BigInt(1000000))), 191 | Number(b), 192 | ]); 193 | }, 194 | async create_canister(p: { 195 | controllers: Principal[]; 196 | cycles: number; 197 | }): Promise { 198 | if (p.controllers.length < 1) { 199 | throw new Error("Canister must be created with at least one controller"); 200 | } 201 | const settings: CreateCanisterArgs["settings"] = { 202 | compute_allocation: [], 203 | freezing_threshold: [], 204 | memory_allocation: [], 205 | // Prefer storing single controller as controllers 206 | controller: [], 207 | controllers: [p.controllers], 208 | }; 209 | 210 | const result = await (await getWalletCanister()).wallet_create_canister({ 211 | settings, 212 | cycles: BigInt(p.cycles), 213 | }); 214 | if ("Ok" in result) { 215 | return result.Ok.canister_id; 216 | } else { 217 | throw result.Err; 218 | } 219 | }, 220 | async create_wallet(p: { 221 | controller?: Principal; 222 | cycles: number; 223 | }): Promise { 224 | const result = await (await getWalletCanister()).wallet_create_wallet({ 225 | settings: { 226 | compute_allocation: [], 227 | controller: p.controller ? [p.controller] : [], 228 | controllers: [], 229 | freezing_threshold: [], 230 | memory_allocation: [], 231 | }, 232 | cycles: BigInt(p.cycles), 233 | }); 234 | if ("Ok" in result) { 235 | return result.Ok.canister_id; 236 | } else { 237 | throw result.Err; 238 | } 239 | }, 240 | async send(p: { canister: Principal; amount: bigint }): Promise { 241 | await (await getWalletCanister()).wallet_send({ 242 | canister: p.canister, 243 | amount: BigInt(p.amount), 244 | }); 245 | }, 246 | async update_canister_name( 247 | pr: string, 248 | n: string 249 | ): Promise { 250 | return this.getGeneratedActor().then((actor) => { 251 | return actor.set_short_name(Principal.fromText(pr), [n]); 252 | }); 253 | }, 254 | async list_managed_canisters(): Promise<[ManagedCanisterInfo[], number]> { 255 | const optFrom: [] | [number] = [0]; 256 | const optTo: [] | [number] = []; 257 | const args = { 258 | from: optFrom, 259 | to: optTo, 260 | }; 261 | return this.getGeneratedActor().then((actor) => { 262 | return actor.list_managed_canisters(args); 263 | }); 264 | }, 265 | }; 266 | -------------------------------------------------------------------------------- /wallet_ui/canister/wallet/index.ts: -------------------------------------------------------------------------------- 1 | import WalletIdlFactory from "./wallet.did"; 2 | import { IDL } from "@dfinity/candid"; 3 | 4 | export * from "./wallet"; 5 | 6 | export default WalletIdlFactory as IDL.InterfaceFactory; 7 | -------------------------------------------------------------------------------- /wallet_ui/canister/wallet/wallet.did.js: -------------------------------------------------------------------------------- 1 | export default ({ IDL }) => { 2 | const Kind = IDL.Variant({ 3 | User: IDL.Null, 4 | Canister: IDL.Null, 5 | Unknown: IDL.Null, 6 | }); 7 | const Role = IDL.Variant({ 8 | Custodian: IDL.Null, 9 | Contact: IDL.Null, 10 | Controller: IDL.Null, 11 | }); 12 | const AddressEntry = IDL.Record({ 13 | id: IDL.Principal, 14 | kind: Kind, 15 | name: IDL.Opt(IDL.Text), 16 | role: Role, 17 | }); 18 | const EventKind = IDL.Variant({ 19 | CyclesReceived: IDL.Record({ 20 | from: IDL.Principal, 21 | amount: IDL.Nat64, 22 | memo: IDL.Opt(IDL.Text), 23 | }), 24 | CanisterCreated: IDL.Record({ 25 | cycles: IDL.Nat64, 26 | canister: IDL.Principal, 27 | }), 28 | CanisterCalled: IDL.Record({ 29 | cycles: IDL.Nat64, 30 | method_name: IDL.Text, 31 | canister: IDL.Principal, 32 | }), 33 | CyclesSent: IDL.Record({ 34 | to: IDL.Principal, 35 | amount: IDL.Nat64, 36 | refund: IDL.Nat64, 37 | }), 38 | AddressRemoved: IDL.Record({ id: IDL.Principal }), 39 | WalletDeployed: IDL.Record({ canister: IDL.Principal }), 40 | AddressAdded: IDL.Record({ 41 | id: IDL.Principal, 42 | name: IDL.Opt(IDL.Text), 43 | role: Role, 44 | }), 45 | }); 46 | const Event = IDL.Record({ 47 | id: IDL.Nat32, 48 | kind: EventKind, 49 | timestamp: IDL.Nat64, 50 | }); 51 | const ResultCall = IDL.Variant({ 52 | Ok: IDL.Record({ return: IDL.Vec(IDL.Nat8) }), 53 | Err: IDL.Text, 54 | }); 55 | const CanisterSettings = IDL.Record({ 56 | controller: IDL.Opt(IDL.Principal), 57 | controllers: IDL.Opt(IDL.Vec(IDL.Principal)), 58 | freezing_threshold: IDL.Opt(IDL.Nat), 59 | memory_allocation: IDL.Opt(IDL.Nat), 60 | compute_allocation: IDL.Opt(IDL.Nat), 61 | }); 62 | const CreateCanisterArgs = IDL.Record({ 63 | cycles: IDL.Nat64, 64 | settings: CanisterSettings, 65 | }); 66 | const ResultCreate = IDL.Variant({ 67 | Ok: IDL.Record({ canister_id: IDL.Principal }), 68 | Err: IDL.Text, 69 | }); 70 | const ResultSend = IDL.Variant({ Ok: IDL.Null, Err: IDL.Text }); 71 | return IDL.Service({ 72 | add_address: IDL.Func([AddressEntry], [], []), 73 | add_controller: IDL.Func([IDL.Principal], [], []), 74 | authorize: IDL.Func([IDL.Principal], [], []), 75 | deauthorize: IDL.Func([IDL.Principal], [], []), 76 | get_chart: IDL.Func( 77 | [ 78 | IDL.Opt( 79 | IDL.Record({ 80 | count: IDL.Opt(IDL.Nat32), 81 | precision: IDL.Opt(IDL.Nat64), 82 | }) 83 | ), 84 | ], 85 | [IDL.Vec(IDL.Tuple(IDL.Nat64, IDL.Nat64))], 86 | [] 87 | ), 88 | get_controllers: IDL.Func([], [IDL.Vec(IDL.Principal)], []), 89 | get_custodians: IDL.Func([], [IDL.Vec(IDL.Principal)], []), 90 | get_events: IDL.Func( 91 | [ 92 | IDL.Opt( 93 | IDL.Record({ 94 | to: IDL.Opt(IDL.Nat32), 95 | from: IDL.Opt(IDL.Nat32), 96 | }) 97 | ), 98 | ], 99 | [IDL.Vec(Event)], 100 | [] 101 | ), 102 | list_addresses: IDL.Func([], [IDL.Vec(AddressEntry)], []), 103 | name: IDL.Func([], [IDL.Opt(IDL.Text)], []), 104 | remove_address: IDL.Func([IDL.Principal], [], []), 105 | remove_controller: IDL.Func([IDL.Principal], [], []), 106 | set_name: IDL.Func([IDL.Text], [], []), 107 | wallet_balance: IDL.Func([], [IDL.Record({ amount: IDL.Nat64 })], []), 108 | wallet_call: IDL.Func( 109 | [ 110 | IDL.Record({ 111 | args: IDL.Vec(IDL.Nat8), 112 | cycles: IDL.Nat64, 113 | method_name: IDL.Text, 114 | canister: IDL.Principal, 115 | }), 116 | ], 117 | [ResultCall], 118 | [] 119 | ), 120 | wallet_create_canister: IDL.Func([CreateCanisterArgs], [ResultCreate], []), 121 | wallet_create_wallet: IDL.Func([CreateCanisterArgs], [ResultCreate], []), 122 | wallet_receive: IDL.Func([], [], []), 123 | wallet_send: IDL.Func( 124 | [IDL.Record({ canister: IDL.Principal, amount: IDL.Nat64 })], 125 | [ResultSend], 126 | [] 127 | ), 128 | wallet_store_wallet_wasm: IDL.Func( 129 | [IDL.Record({ wasm_module: IDL.Vec(IDL.Nat8) })], 130 | [], 131 | [] 132 | ), 133 | }); 134 | }; 135 | export const init = ({ IDL }) => { 136 | return []; 137 | }; 138 | -------------------------------------------------------------------------------- /wallet_ui/canister/wallet/wallet.ts: -------------------------------------------------------------------------------- 1 | import type { Principal } from '@dfinity/principal'; 2 | export interface AddressEntry { 3 | 'id' : Principal, 4 | 'kind' : Kind, 5 | 'name' : [] | [string], 6 | 'role' : Role, 7 | }; 8 | export interface CanisterSettings { 9 | 'controller' : [] | [Principal], 10 | 'controllers' : [] | [Principal[]], 11 | 'freezing_threshold' : [] | [bigint], 12 | 'memory_allocation' : [] | [bigint], 13 | 'compute_allocation' : [] | [bigint], 14 | }; 15 | export interface CreateCanisterArgs { 16 | 'cycles' : bigint, 17 | 'settings' : CanisterSettings, 18 | }; 19 | export interface Event { 20 | 'id' : number, 21 | 'kind' : EventKind, 22 | 'timestamp' : bigint, 23 | }; 24 | export type EventKind = { 25 | 'CyclesReceived' : { 'from' : Principal, 'amount' : bigint } 26 | } | 27 | { 'CanisterCreated' : { 'cycles' : bigint, 'canister' : Principal } } | 28 | { 29 | 'CanisterCalled' : { 30 | 'cycles' : bigint, 31 | 'method_name' : string, 32 | 'canister' : Principal, 33 | } 34 | } | 35 | { 36 | 'CyclesSent' : { 'to' : Principal, 'amount' : bigint, 'refund' : bigint } 37 | } | 38 | { 'AddressRemoved' : { 'id' : Principal } } | 39 | { 'WalletDeployed' : { 'canister' : Principal } } | 40 | { 41 | 'AddressAdded' : { 'id' : Principal, 'name' : [] | [string], 'role' : Role } 42 | }; 43 | export type Kind = { 'User' : null } | 44 | { 'Canister' : null } | 45 | { 'Unknown' : null }; 46 | export type ResultCall = { 'Ok' : { 'return' : Array } } | 47 | { 'Err' : string }; 48 | export type ResultCreate = { 'Ok' : { 'canister_id' : Principal } } | 49 | { 'Err' : string }; 50 | export type ResultSend = { 'Ok' : null } | 51 | { 'Err' : string }; 52 | export type Role = { 'Custodian' : null } | 53 | { 'Contact' : null } | 54 | { 'Controller' : null }; 55 | export default interface _SERVICE { 56 | 'add_address' : (arg_0: AddressEntry) => Promise, 57 | 'add_controller' : (arg_0: Principal) => Promise, 58 | 'authorize' : (arg_0: Principal) => Promise, 59 | 'deauthorize' : (arg_0: Principal) => Promise, 60 | 'get_chart' : ( 61 | arg_0: [] | [{ 'count' : [] | [number], 'precision' : [] | [bigint] }], 62 | ) => Promise>, 63 | 'get_controllers' : () => Promise>, 64 | 'get_custodians' : () => Promise>, 65 | 'get_events' : ( 66 | arg_0: [] | [{ 'to' : [] | [number], 'from' : [] | [number] }], 67 | ) => Promise>, 68 | 'list_addresses' : () => Promise>, 69 | 'name' : () => Promise<[] | [string]>, 70 | 'remove_address' : (arg_0: Principal) => Promise, 71 | 'remove_controller' : (arg_0: Principal) => Promise, 72 | 'set_name' : (arg_0: string) => Promise, 73 | 'wallet_balance' : () => Promise<{ 'amount' : bigint }>, 74 | 'wallet_call' : ( 75 | arg_0: { 76 | 'args' : Array, 77 | 'cycles' : bigint, 78 | 'method_name' : string, 79 | 'canister' : Principal, 80 | }, 81 | ) => Promise, 82 | 'wallet_create_canister' : (arg_0: CreateCanisterArgs) => Promise< 83 | ResultCreate 84 | >, 85 | 'wallet_create_wallet' : (arg_0: CreateCanisterArgs) => Promise, 86 | 'wallet_receive' : () => Promise, 87 | 'wallet_send' : ( 88 | arg_0: { 'canister' : Principal, 'amount' : bigint }, 89 | ) => Promise, 90 | 'wallet_store_wallet_wasm' : ( 91 | arg_0: { 'wasm_module' : Array }, 92 | ) => Promise, 93 | }; 94 | -------------------------------------------------------------------------------- /wallet_ui/components/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | import makeStyles from "@material-ui/core/styles/makeStyles"; 3 | import CssBaseline from "@material-ui/core/CssBaseline"; 4 | import { WalletAppBar } from "./WalletAppBar"; 5 | import Typography from "@material-ui/core/Typography"; 6 | import Link from "@material-ui/core/Link"; 7 | import { 8 | BrowserRouter as Router, 9 | Switch as RouterSwitch, 10 | Route, 11 | Redirect, 12 | } from "react-router-dom"; 13 | 14 | // For Switch Theming 15 | import ThemeProvider from "@material-ui/styles/ThemeProvider"; 16 | 17 | // For document title setting 18 | import { Wallet } from "../canister"; 19 | 20 | // Routes 21 | import { Authorize } from "./routes/Authorize"; 22 | import { Dashboard } from "./routes/Dashboard"; 23 | import { useLocalStorage } from "../utils/hooks"; 24 | import generateTheme from "../utils/materialTheme"; 25 | import { authClient } from "../utils/authClient"; 26 | 27 | export function Copyright() { 28 | return ( 29 | 35 | {"Copyright © "} 36 | 37 | DFINITY Stiftung. All rights reserved. 38 | {" "} 39 | {new Date().getFullYear()} 40 | {"."} 41 | 42 | ); 43 | } 44 | 45 | const useStyles = makeStyles((theme) => ({ 46 | root: { 47 | display: "flex", 48 | }, 49 | toolbar: { 50 | paddingRight: 24, // keep right padding when drawer closed 51 | }, 52 | toolbarIcon: { 53 | display: "flex", 54 | alignItems: "center", 55 | justifyContent: "flex-end", 56 | padding: "0 8px", 57 | ...theme.mixins.toolbar, 58 | }, 59 | menuButton: { 60 | marginRight: 36, 61 | }, 62 | menuButtonHidden: { 63 | display: "none", 64 | }, 65 | title: { 66 | flexGrow: 1, 67 | }, 68 | appBarSpacer: theme.mixins.toolbar, 69 | content: { 70 | flexGrow: 1, 71 | height: "100vh", 72 | overflow: "auto", 73 | }, 74 | container: { 75 | paddingTop: theme.spacing(4), 76 | paddingBottom: theme.spacing(4), 77 | }, 78 | paper: { 79 | padding: theme.spacing(2), 80 | display: "flex", 81 | overflow: "auto", 82 | flexDirection: "column", 83 | }, 84 | fixedHeight: { 85 | height: 240, 86 | }, 87 | })); 88 | 89 | function useDarkState(): [boolean, (newState?: boolean) => void] { 90 | const localStorageKey = "dark-mode"; 91 | const darkModeMediaMatch = "(prefers-color-scheme: dark)"; 92 | const darkStorage = localStorage.getItem(localStorageKey); 93 | const defaultDarkMode = 94 | darkStorage === null 95 | ? window.matchMedia(darkModeMediaMatch).matches 96 | : darkStorage == "1"; 97 | 98 | const [dark, setDark] = useState(defaultDarkMode); 99 | 100 | // Listen to media changes, and if local storage isn't set, change it when dark mode is 101 | // enabled system-wide. 102 | useEffect(() => { 103 | function listener(event: MediaQueryListEvent) { 104 | if (darkStorage !== null) { 105 | return; 106 | } 107 | if (event.matches) { 108 | setDark(true); 109 | } else { 110 | setDark(false); 111 | } 112 | } 113 | 114 | const media = window.matchMedia(darkModeMediaMatch); 115 | media.addEventListener("change", listener); 116 | 117 | return () => media.removeEventListener("change", listener); 118 | }, []); 119 | 120 | return [ 121 | dark, 122 | function (newDark: boolean = !dark) { 123 | setDark(newDark); 124 | localStorage.setItem(localStorageKey, newDark ? "1" : "0"); 125 | }, 126 | ]; 127 | } 128 | 129 | export default function App() { 130 | const [ready, setReady] = useState(false); 131 | const [isAuthenticated, setIsAuthenticated] = useState(null); 132 | const [open, setOpen] = useLocalStorage("app-menu-open", false); 133 | const [darkState, setDarkState] = useDarkState(); 134 | const classes = useStyles(); 135 | const theme = generateTheme(darkState); 136 | 137 | useEffect(() => { 138 | Wallet.name().then((name) => { 139 | document.title = name; 140 | }); 141 | }, []); 142 | 143 | useEffect(() => { 144 | if (!authClient.ready) { 145 | return; 146 | } 147 | setReady(true); 148 | authClient.isAuthenticated().then((value) => { 149 | setIsAuthenticated(value ?? false); 150 | }); 151 | }, [authClient.ready]); 152 | 153 | if (!ready) return null; 154 | 155 | return ( 156 | 157 | 167 | 168 |
169 | 170 | setDarkState(!darkState)} 173 | open={open} 174 | onOpenToggle={() => setOpen(!open)} 175 | /> 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | {authClient.ready && isAuthenticated === false ? ( 184 | 190 | ) : ( 191 | setOpen(!open)} /> 192 | )} 193 | 194 | 195 |
196 |
197 |
198 | ); 199 | } 200 | -------------------------------------------------------------------------------- /wallet_ui/components/Buttons.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import type { ButtonBaseProps } from "@material-ui/core/ButtonBase"; 3 | import { css } from "@emotion/css"; 4 | 5 | export const PrimaryButton = (props: ButtonBaseProps) => { 6 | const { children, className, ...rest } = props; 7 | return ( 8 | 27 | ); 28 | }; 29 | 30 | export const PlainButton = (props: ButtonBaseProps) => { 31 | const { children, ...rest } = props; 32 | return ( 33 | 45 | ); 46 | }; 47 | -------------------------------------------------------------------------------- /wallet_ui/components/CycleSlider.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Box, InputLabel, Typography } from "@material-ui/core"; 3 | import { css } from "@emotion/css"; 4 | import TextField from "@material-ui/core/TextField"; 5 | import NumberFormat from "react-number-format"; 6 | import { xdr } from "../declarations/xdr"; 7 | 8 | const thumb = css` 9 | border: transparent; 10 | height: 19.5px; 11 | width: 19.5px; 12 | border-radius: 100%; 13 | background-color: transparent; 14 | background-image: url("Handle.png"), 15 | -webkit-gradient(linear, left top, left bottom, color-stop(0, #fefefe), color-stop(0.49, #dddddd), color-stop(0.51, #d1d1d1), color-stop(1, #a1a1a1)); 16 | background-size: 20px; 17 | background-repeat: no-repeat; 18 | background-position: 50%; 19 | opacity: 1; 20 | cursor: pointer; 21 | -webkit-appearance: none; 22 | margin-top: -10px; 23 | /* box-shadow: 280px 0 0 280px #424242; */ 24 | `; 25 | 26 | const track = css` 27 | width: 100%; 28 | height: 2px; 29 | cursor: pointer; 30 | box-shadow: none; 31 | background: linear-gradient(to right, #29abe2, #522785); 32 | border-radius: 1.3px; 33 | border: none; 34 | `; 35 | 36 | const styles = css` 37 | position: relative; 38 | border: 1px solid #d9d9da; 39 | @media (prefers-color-scheme: dark) { 40 | } 41 | 42 | .input-container { 43 | overflow: hidden; 44 | position: absolute; 45 | bottom: -11px; 46 | width: 100%; 47 | z-index: 0; 48 | } 49 | 50 | .MuiInputLabel-formControl { 51 | position: static; 52 | margin-bottom: 24px; 53 | } 54 | 55 | input[type="range"] { 56 | -webkit-appearance: none; 57 | margin: 10px 0; 58 | width: 100%; 59 | height: 2px; 60 | 61 | &:focus:not(:focus-visible) { 62 | outline: none; 63 | } 64 | 65 | /* Thumb Styles */ 66 | &::-webkit-slider-thumb { 67 | ${thumb} 68 | } 69 | &::-moz-range-thumb { 70 | ${thumb} 71 | } 72 | &::-ms-thumb { 73 | ${thumb} 74 | } 75 | 76 | /* Track Styles */ 77 | &::-webkit-slider-runnable-track { 78 | ${track} 79 | } 80 | &::-moz-range-track { 81 | ${track} 82 | } 83 | &::-ms-track { 84 | ${track} 85 | } 86 | 87 | &::-ms-fill-lower { 88 | background: #2a6495; 89 | border: 0.2px solid #010101; 90 | border-radius: 2.6px; 91 | box-shadow: 1px 1px 1px #000000, 0px 0px 1px #0d0d0d; 92 | } 93 | 94 | &::-ms-fill-upper { 95 | background: #3071a9; 96 | border: 0.2px solid #010101; 97 | border-radius: 2.6px; 98 | box-shadow: 1px 1px 1px #000000, 0px 0px 1px #0d0d0d; 99 | } 100 | 101 | &:focus { 102 | &::-ms-fill-lower { 103 | background: #3071a9; 104 | } 105 | 106 | &::-ms-fill-upper { 107 | background: #367ebd; 108 | } 109 | 110 | &::-webkit-slider-runnable-track { 111 | background: linear-gradient(to right, #29abe2, #522785); 112 | } 113 | } 114 | } 115 | `; 116 | 117 | function NumberFormatCustom(props: any) { 118 | const { inputRef, onChange, ...other } = props; 119 | 120 | return ( 121 | { 125 | onChange({ 126 | target: { 127 | value: values.value, 128 | }, 129 | }); 130 | }} 131 | thousandSeparator 132 | isNumericString 133 | /> 134 | ); 135 | } 136 | 137 | interface Props { 138 | cycles: number; 139 | setCycles: (c: number) => void; 140 | balance?: number; 141 | startingNumber?: number; 142 | loading?: boolean; 143 | } 144 | 145 | function CycleSlider(props: Props) { 146 | const { balance = 0, cycles, setCycles, loading } = props; 147 | const [xdr_permyriad_per_icp, setRate] = React.useState(BigInt(0)); 148 | 149 | const cyclesToIcp = React.useMemo(() => { 150 | if (cycles === 0) { 151 | return 0; 152 | } 153 | if (!xdr_permyriad_per_icp) { return null} 154 | const MYRIAD = BigInt(10_000); 155 | const TRILLION = 1_000_000_000_000; 156 | const selected_cycles_in_trillion = cycles / TRILLION; 157 | const xdr_per_icp = Number(xdr_permyriad_per_icp) / Number(MYRIAD); 158 | const icpCost = (selected_cycles_in_trillion / xdr_per_icp).toFixed(5); 159 | 160 | return icpCost; 161 | }, [xdr_permyriad_per_icp, cycles]) 162 | 163 | const error = cycles < 2000000000000; 164 | const message = error ? "Must be minimum of 2TC" : ""; 165 | 166 | function handleSlide(e: any) { 167 | if (balance && e.target?.value) { 168 | const newValue = Math.floor((balance * e.target.value) / 1000); 169 | setCycles(newValue); 170 | } 171 | } 172 | 173 | React.useEffect(() => { 174 | xdr.get_icp_xdr_conversion_rate().then(result => { 175 | setRate(result.data.xdr_permyriad_per_icp); 176 | }) 177 | }, []); 178 | 179 | return ( 180 |
193 | 204 | Add Cycles 205 | 206 |
207 |
208 | 209 | { 226 | setCycles(Number(e.target.value)); 227 | }} 228 | InputProps={{ 229 | inputComponent: NumberFormatCustom, 230 | }} 231 | helperText={ 232 | `${(cycles / 1000000000000).toFixed(2)} TC ` + message 233 | } 234 | error={error} 235 | /> 236 | 237 | 238 | {cyclesToIcp} ICP 239 | 240 |
241 |
242 | 252 |
253 |
254 |
255 | ); 256 | } 257 | 258 | export default CycleSlider; 259 | -------------------------------------------------------------------------------- /wallet_ui/components/WalletAppBar.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | import { useRouteMatch } from "react-router-dom"; 3 | import clsx from "clsx"; 4 | import AppBar from "@material-ui/core/AppBar"; 5 | import FormControlLabel from "@material-ui/core/FormControlLabel"; 6 | import IconButton from "@material-ui/core/IconButton"; 7 | import Menu from "@material-ui/core/Menu"; 8 | import MenuIcon from "@material-ui/icons/Menu"; 9 | import MenuItem from "@material-ui/core/MenuItem"; 10 | import Switch from "@material-ui/core/Switch"; 11 | import Toolbar from "@material-ui/core/Toolbar"; 12 | import Typography from "@material-ui/core/Typography"; 13 | 14 | import makeStyles from "@material-ui/core/styles/makeStyles"; 15 | import AccountCircle from "@material-ui/icons/AccountCircle"; 16 | import { getWalletId, Principal } from "../canister"; 17 | 18 | const drawerWidth = 240; 19 | 20 | const useStyles = makeStyles((theme) => ({ 21 | appBar: { 22 | zIndex: theme.zIndex.drawer + 1, 23 | transition: theme.transitions.create(["width", "margin"], { 24 | easing: theme.transitions.easing.sharp, 25 | duration: theme.transitions.duration.leavingScreen, 26 | }), 27 | height: "58px", 28 | boxShadow: "none", 29 | borderBottom: `1px solid ${ 30 | theme.palette.type === "dark" ? "#3F4043" : "#EFEFEF" 31 | }`, 32 | display: "flex", 33 | flexDirection: "row-reverse", 34 | alignItems: "center", 35 | }, 36 | appBarShift: { 37 | marginLeft: drawerWidth, 38 | width: `calc(100% - ${drawerWidth}px)`, 39 | transition: theme.transitions.create(["width", "margin"], { 40 | easing: theme.transitions.easing.sharp, 41 | duration: theme.transitions.duration.enteringScreen, 42 | }), 43 | }, 44 | menuButton: { 45 | marginRight: "auto", 46 | justifyContent: "start", 47 | }, 48 | menuButtonHidden: { 49 | display: "none", 50 | }, 51 | toolbar: { 52 | flexDirection: "row-reverse", 53 | }, 54 | title: { 55 | marginLeft: "auto", 56 | }, 57 | logo: { 58 | height: 16, 59 | marginRight: 12, 60 | }, 61 | })); 62 | 63 | export function WalletAppBar(props: { 64 | dark: boolean; 65 | open: boolean; 66 | onOpenToggle: () => void; 67 | onDarkToggle: () => void; 68 | }) { 69 | const { dark, open, onOpenToggle, onDarkToggle } = props; 70 | const [walletId, setWalletId] = useState(); 71 | useEffect(() => { 72 | const walletId = getWalletId(null); 73 | if (walletId === null) { 74 | return; 75 | } 76 | setWalletId(walletId); 77 | }, []); 78 | const classes = useStyles(); 79 | const menu = !useRouteMatch("/authorize"); 80 | 81 | const [anchorEl, setAnchorEl] = React.useState(null); 82 | 83 | const isMenuOpen = Boolean(anchorEl); 84 | 85 | const handleProfileMenuOpen = (event: React.MouseEvent) => { 86 | setAnchorEl(event.currentTarget); 87 | }; 88 | 89 | const handleMenuClose = () => { 90 | setAnchorEl(null); 91 | }; 92 | 93 | const menuId = "primary-search-account-menu"; 94 | 95 | return ( 96 | 101 | 111 | 120 | 121 | } 123 | label="Dark Mode" 124 | /> 125 | 126 | 127 | 128 | ); 129 | } 130 | -------------------------------------------------------------------------------- /wallet_ui/components/icons/PlusIcon.tsx: -------------------------------------------------------------------------------- 1 | import { css } from "@emotion/css"; 2 | import SvgIcon from "@material-ui/core/SvgIcon"; 3 | import React from "react"; 4 | 5 | interface Props { 6 | size?: string; 7 | } 8 | 9 | function PlusIcon(props: Props) { 10 | const { size = 16 } = props; 11 | 12 | return ( 13 | 23 | 27 | 31 | 32 | ); 33 | } 34 | 35 | export default PlusIcon; 36 | -------------------------------------------------------------------------------- /wallet_ui/components/panels/BalanceChart.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | LineChart, 3 | Line, 4 | XAxis, 5 | YAxis, 6 | ResponsiveContainer, 7 | Tooltip, 8 | } from "recharts"; 9 | import MenuItem from "@material-ui/core/MenuItem"; 10 | import FormControl from "@material-ui/core/FormControl"; 11 | import Select from "@material-ui/core/Select"; 12 | import React, { useEffect, useState } from "react"; 13 | import { ChartPrecision, Wallet } from "../../canister"; 14 | import "../../css/CycleBalance.css"; 15 | import Typography from "@material-ui/core/Typography"; 16 | import { useLocalStorage } from "../../utils/hooks"; 17 | import useTheme from "@material-ui/core/styles/useTheme"; 18 | import makeStyles from "@material-ui/core/styles/makeStyles"; 19 | import { buildData, ChartData } from "../../utils/chart"; 20 | import Button from "@material-ui/core/Button"; 21 | import { css } from "@emotion/css"; 22 | 23 | const useStyles = makeStyles((theme) => ({ 24 | depositContext: { 25 | flex: 1, 26 | }, 27 | formControl: { 28 | margin: theme.spacing(1), 29 | minWidth: 120, 30 | right: 0, 31 | position: "absolute", 32 | }, 33 | formControlParagraph: { 34 | position: "relative", 35 | }, 36 | selectEmpty: { 37 | marginTop: theme.spacing(2), 38 | }, 39 | title: { 40 | marginBottom: "0.5em", 41 | }, 42 | chartContainer: { 43 | position: "relative", 44 | left: "-20px", 45 | height: "100%", 46 | }, 47 | })); 48 | 49 | function CustomTooltip({ payload, active }: any) { 50 | if (active) { 51 | const p: ChartData = payload[0].payload; 52 | return ( 53 |
57 |

58 | Timestamp: {p.date.toLocaleString()} 59 |
60 | Amount: {p.realAmount.toLocaleString()} cycles 61 |

62 |
63 | ); 64 | } 65 | 66 | return null; 67 | } 68 | 69 | export function BalanceChart() { 70 | const [precision, setPrecision] = useLocalStorage( 71 | "chart-precision", 72 | ChartPrecision.Hourly 73 | ); 74 | const [data, setData] = useState(undefined); 75 | const theme = useTheme(); 76 | const classes = useStyles(); 77 | 78 | useEffect(() => { 79 | if (!precision) setPrecision(ChartPrecision.Hourly); 80 | Wallet.chart(precision, 20).then((data) => 81 | setData(buildData(data, precision)) 82 | ); 83 | }, [precision]); 84 | 85 | if (data === undefined) { 86 | return <>; 87 | } 88 | 89 | const min = Math.min( 90 | ...data.map(({ scaledAmount }) => Math.floor(scaledAmount)) 91 | ); 92 | const max = Math.min( 93 | ...data.map(({ scaledAmount }) => Math.ceil(scaledAmount)) 94 | ); 95 | 96 | // prettier-ignore 97 | const ticks = [ 98 | 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 99 | ].slice(min, max + 1); 100 | // prettier-ignore 101 | const tickArray = [ 102 | "1", "10", "100", "1,000", "10k", "100k", "1M", "10M", "100M", "1B", "10B", "100B", 103 | "1T", "10T", "100T", "1P", "10P", "100P", 104 | ]; 105 | 106 | return ( 107 | <> 108 | 109 | Cycles 110 | 111 | 116 | 120 | 121 | tickArray[x]} 125 | dataKey="scaledAmount" 126 | domain={[min, max]} 127 | /> 128 | } /> 129 | x.scaledAmount} 132 | stroke={theme.palette.primary.main} 133 | isAnimationActive={false} 134 | /> 135 | 136 | 137 | 138 | ); 139 | } 140 | -------------------------------------------------------------------------------- /wallet_ui/components/panels/Canisters.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import CircularProgress from "@material-ui/core/CircularProgress"; 3 | import Grid from "@material-ui/core/Grid"; 4 | import List from "@material-ui/core/List"; 5 | import ListItem from "@material-ui/core/ListItem"; 6 | import Typography from "@material-ui/core/Typography"; 7 | import { EventList } from "../routes/Dashboard"; 8 | import "../../css/Events.scss"; 9 | import { CreateCanisterDialog } from "./CreateCanister"; 10 | import { CreateWalletDialog } from "./CreateWallet"; 11 | import { CreateDialog } from "./CreateDialog"; 12 | import PlusIcon from "../icons/PlusIcon"; 13 | import { css } from "@emotion/css"; 14 | import { PlainButton } from "../Buttons"; 15 | import { format_cycles_trillion } from "../../utils/cycles"; 16 | import { Wallet } from "../../canister"; 17 | 18 | interface Props { 19 | canisters: EventList["canisters"]; 20 | refreshEvents: () => Promise; 21 | } 22 | 23 | type ManagedCanister = { 24 | id: string; 25 | name: string | undefined; 26 | }; 27 | 28 | function Canisters(props: Props) { 29 | const [ 30 | canisterCreateDialogOpen, 31 | setCanisterCreateDialogOpen, 32 | ] = React.useState(false); 33 | 34 | const [walletCreateDialogOpen, setWalletCreateDialogOpen] = React.useState( 35 | false 36 | ); 37 | 38 | const [dialogDialogOpen, setDialogDialogOpen] = React.useState(false); 39 | const [managedCanisters, setManagedCan] = React.useState( 40 | [] 41 | ); 42 | 43 | function handleWalletCreateDialogOpen() { 44 | setWalletCreateDialogOpen(true); 45 | } 46 | 47 | const { canisters, refreshEvents } = props; 48 | 49 | function refreshManagedCanisters() { 50 | Wallet.list_managed_canisters().then((result) => { 51 | const mapped: ManagedCanister[] = result[0] 52 | .map((c) => { 53 | return { 54 | id: c.id.toString(), 55 | name: c.name[0], 56 | }; 57 | }) 58 | .reverse(); 59 | setManagedCan(mapped); 60 | }); 61 | } 62 | 63 | const mappedCanisters = React.useMemo(() => { 64 | return canisters.map((canister) => { 65 | const kind = canister["kind"]; 66 | if ("CanisterCreated" in kind) { 67 | const principal = kind.CanisterCreated.canister.toString(); 68 | return { 69 | id: canister.id, 70 | principal, 71 | timestamp: canister.timestamp, 72 | cycles: format_cycles_trillion(kind.CanisterCreated.cycles, 2), 73 | name: 74 | managedCanisters.find( 75 | (managed: ManagedCanister) => managed.id == principal 76 | )?.name || "Anonymous Canister", 77 | }; 78 | } 79 | }); 80 | }, [canisters, managedCanisters]); 81 | 82 | function setName(canisterPrincipal: string, inputName: string) { 83 | Wallet.update_canister_name(canisterPrincipal, inputName) 84 | .then( 85 | (r) => { 86 | console.log("canister name set:", r); 87 | }, 88 | (e) => { 89 | console.error("Update to Name failed:", e); 90 | } 91 | ) 92 | .then(() => refreshManagedCanisters()); 93 | } 94 | 95 | React.useEffect(() => { 96 | refreshManagedCanisters(); 97 | }, []); 98 | 99 | return ( 100 | 101 | setDialogDialogOpen(false)} 104 | > 105 |
115 | setCanisterCreateDialogOpen(true)} 118 | > 119 | Create a Canister 120 | 121 | 122 | Create a Wallet 123 | 124 |
125 |
126 | 127 | setCanisterCreateDialogOpen(false)} 130 | refreshEvents={refreshEvents} 131 | refreshManagedCanisters={refreshManagedCanisters} 132 | closeDialogDialog={() => setDialogDialogOpen(false)} 133 | setName={setName} 134 | /> 135 | 136 | setWalletCreateDialogOpen(false)} 139 | /> 140 | 141 | 156 | 157 | 163 | Canisters 164 | 165 |

172 | Canisters you've created 173 |

174 | }> 175 | 176 | {mappedCanisters?.map((can) => { 177 | if (!can || Object.entries(can).length === 0) { 178 | return null; 179 | } 180 | return ( 181 | 182 |

{can.name}

183 |
184 |

{can.principal}

185 | 186 |

187 | {can.cycles} 188 |

189 | TC 190 |
191 |
192 |
193 | ); 194 | })} 195 |
196 |
197 |
198 | ); 199 | } 200 | 201 | export default React.memo(Canisters); 202 | -------------------------------------------------------------------------------- /wallet_ui/components/panels/CreateDialog.tsx: -------------------------------------------------------------------------------- 1 | import React, { ChangeEvent, useState } from "react"; 2 | import DialogTitle from "@material-ui/core/DialogTitle"; 3 | import Dialog from "@material-ui/core/Dialog"; 4 | import DialogContent from "@material-ui/core/DialogContent"; 5 | import CircularProgress from "@material-ui/core/CircularProgress"; 6 | import makeStyles from "@material-ui/core/styles/makeStyles"; 7 | import green from "@material-ui/core/colors/green"; 8 | import { Principal, Wallet } from "../../canister"; 9 | 10 | const useStyles = makeStyles((theme) => ({ 11 | wrapper: { 12 | margin: theme.spacing(1), 13 | position: "relative", 14 | }, 15 | buttonProgress: { 16 | color: green[500], 17 | position: "absolute", 18 | top: "50%", 19 | left: "50%", 20 | marginTop: -12, 21 | marginLeft: -12, 22 | }, 23 | formControl: { 24 | display: "flex", 25 | flexWrap: "wrap", 26 | }, 27 | })); 28 | 29 | export function CreateDialog(props: { 30 | open: boolean; 31 | close: (err?: any) => void; 32 | children: React.ReactNode; 33 | }) { 34 | const { open, close, children } = props; 35 | 36 | const [loading, setLoading] = useState(false); 37 | const [controller, setController] = useState(""); 38 | const [cycles, setCycles] = useState(0); 39 | const [canisterId, setCanisterId] = useState(); 40 | const [error, setError] = useState(false); 41 | const classes = useStyles(); 42 | 43 | function handleClose() { 44 | close(); 45 | } 46 | function handleControllerChange(ev: ChangeEvent) { 47 | let p = ev.target.value; 48 | 49 | setController(p); 50 | try { 51 | Principal.fromText(p); 52 | setError(false); 53 | } catch { 54 | setError(true); 55 | } 56 | } 57 | function handleCycleChange(ev: ChangeEvent) { 58 | let c = +ev.target.value; 59 | setCycles(c); 60 | } 61 | 62 | function create() { 63 | setLoading(true); 64 | 65 | Wallet.create_wallet({ 66 | controller: controller ? Principal.fromText(controller) : undefined, 67 | cycles, 68 | }).then( 69 | (canisterId) => { 70 | setLoading(false); 71 | setCanisterId(canisterId); 72 | }, 73 | (err) => { 74 | setLoading(false); 75 | close(err); 76 | } 77 | ); 78 | } 79 | 80 | return ( 81 | 88 | {"Create"} 89 | {children} 90 | 91 | ); 92 | } 93 | -------------------------------------------------------------------------------- /wallet_ui/components/panels/CreateWallet.tsx: -------------------------------------------------------------------------------- 1 | import NumberFormat from "react-number-format"; 2 | import * as React from "react"; 3 | import DialogTitle from "@material-ui/core/DialogTitle"; 4 | import Dialog from "@material-ui/core/Dialog"; 5 | import DialogContent from "@material-ui/core/DialogContent"; 6 | import DialogActions from "@material-ui/core/DialogActions"; 7 | import DialogContentText from "@material-ui/core/DialogContentText"; 8 | import Button from "@material-ui/core/Button"; 9 | import CircularProgress from "@material-ui/core/CircularProgress"; 10 | import makeStyles from "@material-ui/core/styles/makeStyles"; 11 | import green from "@material-ui/core/colors/green"; 12 | import Typography from "@material-ui/core/Typography"; 13 | import FormControl from "@material-ui/core/FormControl"; 14 | import TextField from "@material-ui/core/TextField"; 15 | import { Principal, Wallet } from "../../canister"; 16 | import CycleSlider from "../CycleSlider"; 17 | 18 | const useStyles = makeStyles((theme) => ({ 19 | wrapper: { 20 | margin: theme.spacing(1), 21 | position: "relative", 22 | }, 23 | buttonProgress: { 24 | color: green[500], 25 | position: "absolute", 26 | top: "50%", 27 | left: "50%", 28 | marginTop: -12, 29 | marginLeft: -12, 30 | }, 31 | formControl: { 32 | display: "flex", 33 | flexWrap: "wrap", 34 | }, 35 | })); 36 | 37 | function NumberFormatCustom(props: any) { 38 | const { inputRef, onChange, ...other } = props; 39 | 40 | return ( 41 | { 45 | onChange({ 46 | target: { 47 | value: values.value, 48 | }, 49 | }); 50 | }} 51 | thousandSeparator 52 | isNumericString 53 | suffix=" cycles" 54 | /> 55 | ); 56 | } 57 | 58 | export function CreateWalletDialog(props: { 59 | open: boolean; 60 | close: (err?: any) => void; 61 | }) { 62 | const { open, close } = props; 63 | 64 | const [loading, setLoading] = React.useState(false); 65 | const [controller, setController] = React.useState(""); 66 | const [cycles, setCycles] = React.useState(0); 67 | const [balance, setBalance] = React.useState(0); 68 | const [canisterId, setCanisterId] = React.useState(); 69 | const [error, setError] = React.useState(false); 70 | const classes = useStyles(); 71 | 72 | React.useEffect(() => { 73 | Wallet.balance().then((amount) => { 74 | setBalance(amount); 75 | }); 76 | }, []); 77 | 78 | function handleClose() { 79 | close(); 80 | } 81 | function handleControllerChange(ev: React.ChangeEvent) { 82 | let p = ev.target.value; 83 | 84 | setController(p); 85 | try { 86 | Principal.fromText(p); 87 | setError(false); 88 | } catch { 89 | setError(true); 90 | } 91 | } 92 | 93 | function create() { 94 | setLoading(true); 95 | 96 | Wallet.create_wallet({ 97 | controller: controller ? Principal.fromText(controller) : undefined, 98 | cycles, 99 | }).then( 100 | (canisterId) => { 101 | setLoading(false); 102 | setCanisterId(canisterId); 103 | }, 104 | (err) => { 105 | setLoading(false); 106 | close(err); 107 | } 108 | ); 109 | } 110 | 111 | return ( 112 | 119 | {"Create a new Wallet"} 120 | 121 |
122 | 123 | Create a wallet. If the controller field is left empty, the 124 | controller will be this wallet canister. 125 | 126 | 127 | 137 | 143 | 144 |
145 |
146 | 147 | 155 |
156 | 166 | {loading && ( 167 | 168 | )} 169 |
170 | 171 | close(undefined)} 174 | aria-labelledby="alert-dialog-title" 175 | aria-describedby="alert-dialog-description" 176 | > 177 | New Canister ID 178 | 179 | 180 | Canister ID: {canisterId?.toString()} 181 | 182 | 183 | 184 | 187 | 188 | 189 |
190 |
191 | ); 192 | } 193 | -------------------------------------------------------------------------------- /wallet_ui/components/panels/CycleBalance.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | import { useHistory } from "react-router"; 3 | import { Wallet } from "../../canister"; 4 | import "../../css/CycleBalance.css"; 5 | import { createMuiTheme, MuiThemeProvider } from "@material-ui/core/styles"; 6 | import { format_cycles_trillion_fullrange } from "../../utils/cycles"; 7 | import { Box, Tooltip, Typography } from "@material-ui/core"; 8 | 9 | const theme = createMuiTheme({ 10 | overrides: { 11 | MuiTooltip: { 12 | tooltipPlacementRight: { 13 | position: "relative", 14 | left: 20, 15 | }, 16 | tooltip: { 17 | fontSize: 16, 18 | }, 19 | }, 20 | }, 21 | }); 22 | 23 | export function CycleBalance() { 24 | const [cycles, setCycles] = useState(undefined); 25 | const [first, setFirst] = useState(true); 26 | const [timeStamp, setTimeStamp] = useState(new Date()); 27 | const [refreshRate, setRefreshRate] = useState(2); 28 | const history = useHistory(); 29 | 30 | function refreshBalance() { 31 | Wallet.balance().then( 32 | (amount) => { 33 | setTimeStamp(new Date()); 34 | if (cycles !== undefined) { 35 | setFirst(false); 36 | } 37 | setCycles(amount); 38 | }, 39 | () => history.push(`/authorize${location.search}`) 40 | ); 41 | } 42 | 43 | useEffect(() => { 44 | if (cycles === undefined || refreshRate > 0) refreshBalance(); 45 | 46 | if (refreshRate > 0) { 47 | const iv = setInterval(refreshBalance, refreshRate * 1000); 48 | return () => clearInterval(iv); 49 | } 50 | }, [refreshRate]); 51 | 52 | if (cycles === undefined) { 53 | return <>; 54 | } 55 | 56 | const cycles_string = format_cycles_trillion_fullrange(BigInt(cycles)); 57 | 58 | return ( 59 | 60 | 66 | Balance 67 | 68 | 69 | Current cycles in this wallet 70 | 71 | 72 | 73 | {cycles_string && ( 74 | <> 75 | 76 | 80 | 87 | {cycles_string} 88 | 89 | 90 | 91 | TC 92 | 93 | )} 94 | 95 | 96 | ); 97 | } 98 | -------------------------------------------------------------------------------- /wallet_ui/components/panels/SendCycles.tsx: -------------------------------------------------------------------------------- 1 | import React, { ChangeEvent, useEffect, useState } from "react"; 2 | import Dialog from "@material-ui/core/Dialog"; 3 | import DialogContent from "@material-ui/core/DialogContent"; 4 | import DialogActions from "@material-ui/core/DialogActions"; 5 | import Button from "@material-ui/core/Button"; 6 | import makeStyles from "@material-ui/core/styles/makeStyles"; 7 | import green from "@material-ui/core/colors/green"; 8 | import DialogContentText from "@material-ui/core/DialogContentText"; 9 | import Typography from "@material-ui/core/Typography"; 10 | import CircularProgress from "@material-ui/core/CircularProgress"; 11 | import Box from "@material-ui/core/Box"; 12 | import FormControl from "@material-ui/core/FormControl"; 13 | import TextField from "@material-ui/core/TextField"; 14 | import { Principal, Wallet } from "../../canister"; 15 | import CycleSlider from "../CycleSlider"; 16 | 17 | const useStyles = makeStyles((theme) => ({ 18 | wrapper: { 19 | margin: theme.spacing(1), 20 | position: "relative", 21 | }, 22 | buttonProgress: { 23 | color: green[500], 24 | position: "absolute", 25 | top: "50%", 26 | left: "50%", 27 | marginTop: -12, 28 | marginLeft: -12, 29 | }, 30 | formControl: { 31 | display: "flex", 32 | flexWrap: "wrap", 33 | marginBottom: "24px", 34 | }, 35 | })); 36 | 37 | export function SendCyclesDialog(props: { 38 | open: boolean; 39 | close: (err?: any) => void; 40 | }) { 41 | const { open, close } = props; 42 | 43 | const [loading, setLoading] = useState(false); 44 | const [principal, setPrincipal] = useState(""); 45 | const [balance, setBalance] = useState(0); 46 | const [cycles, setCycles] = useState(0); 47 | const [error, setError] = useState(false); 48 | const classes = useStyles(); 49 | 50 | useEffect(() => { 51 | Wallet.balance().then((amount) => { 52 | setBalance(amount); 53 | }); 54 | }, []); 55 | 56 | function handleClose() { 57 | close(); 58 | } 59 | function handlePrincipalChange(ev: ChangeEvent) { 60 | let p = ev.target.value; 61 | 62 | setPrincipal(p); 63 | try { 64 | Principal.fromText(p); 65 | setError(false); 66 | } catch { 67 | setError(true); 68 | } 69 | } 70 | 71 | function send() { 72 | setLoading(true); 73 | 74 | Wallet.send({ 75 | canister: Principal.fromText(principal), 76 | amount: BigInt(cycles), 77 | }).then( 78 | () => { 79 | setLoading(false); 80 | close(); 81 | }, 82 | (err) => { 83 | setLoading(false); 84 | close(err); 85 | } 86 | ); 87 | } 88 | 89 | return ( 90 | 97 | 98 | 99 | Send Cycles 100 | 101 | Send to an existing or new canister. 102 | 103 | 104 |
105 | 106 | Send cycles to a canister. Do not send cycles to a user, the call 107 | will fail. This cannot be validated from the user interface. 108 | 109 | 110 | 120 | 121 | 127 |
128 |
129 | 130 | 138 |
139 | 148 | {loading && ( 149 | 150 | )} 151 |
152 |
153 |
154 | ); 155 | } 156 | -------------------------------------------------------------------------------- /wallet_ui/components/panels/Transactions.tsx: -------------------------------------------------------------------------------- 1 | import makeStyles from "@material-ui/core/styles/makeStyles"; 2 | import React, { useEffect, useState } from "react"; 3 | import Table from "@material-ui/core/Table"; 4 | import TableBody from "@material-ui/core/TableBody"; 5 | import TableCell from "@material-ui/core/TableCell"; 6 | import TableHead from "@material-ui/core/TableHead"; 7 | import TableRow from "@material-ui/core/TableRow"; 8 | import Typography from "@material-ui/core/Typography"; 9 | import { Wallet } from "../../canister"; 10 | import ReactTimeago from "react-timeago"; 11 | import { Event, EventKind } from "../../canister"; 12 | 13 | interface TransactionRowProps { 14 | event: Event; 15 | expanded: boolean; 16 | setExpanded: (expanded: boolean) => void; 17 | } 18 | 19 | const useRowStyles = makeStyles((_theme) => ({ 20 | // Certain rows have two rows; one that's the regular table row, and one 21 | // that's invisible until the row is "expanded". Both have the same styles 22 | // which includes a bottom border. This rule removes the bottom border to 23 | // any row followed by another, which effectively removes the first border 24 | // when there's 2 (so they don't show double borders on those rows). 25 | root: { 26 | "& > *": { 27 | borderBottom: "unset", 28 | }, 29 | }, 30 | })); 31 | 32 | function AddressAddedRow({ event }: TransactionRowProps) { 33 | const classes = useRowStyles(); 34 | if ("AddressAdded" in event.kind) { 35 | const addressAdded = event.kind.AddressAdded!; 36 | const role = Object.keys(addressAdded.role!)[0]; 37 | 38 | return ( 39 | <> 40 | 41 | 42 | 43 | 44 | {role} Added 45 | 46 | Principal "{addressAdded.id?.toText()}" 47 | {addressAdded.name.length == 1 48 | ? ` with name ${addressAdded.name[0]}` 49 | : ""} 50 | 51 | 52 | 53 | 54 | ); 55 | } 56 | return null; 57 | } 58 | 59 | function CyclesSentRow({ event }: TransactionRowProps) { 60 | const classes = useRowStyles(); 61 | if ("CyclesSent" in event.kind) { 62 | const cyclesSent = event.kind.CyclesSent!; 63 | 64 | return ( 65 | <> 66 | 67 | 68 | 69 | 70 | Cycle Sent 71 | 72 | Sent {cyclesSent.amount.toLocaleString()} cycles to{" "} 73 | {cyclesSent.to.toText()} 74 | 75 | 76 | 77 | 78 | ); 79 | } 80 | return null; 81 | } 82 | 83 | function CyclesReceivedRow({ event }: TransactionRowProps) { 84 | const classes = useRowStyles(); 85 | if ("CyclesReceived" in event.kind) { 86 | const cyclesReceived = event.kind.CyclesReceived!; 87 | 88 | return ( 89 | <> 90 | 91 | 92 | 93 | 94 | Cycle Received 95 | 96 | Received {cyclesReceived.amount.toLocaleString()} cycles from{" "} 97 | {cyclesReceived.from.toText()} 98 | 99 | 100 | 101 | 102 | ); 103 | } 104 | return null; 105 | } 106 | 107 | function CanisterCreatedRow({ event }: TransactionRowProps) { 108 | const classes = useRowStyles(); 109 | if ("CanisterCreated" in event.kind) { 110 | const createdCanister = event.kind.CanisterCreated; 111 | 112 | return ( 113 | 114 | 115 | 116 | 117 | Canister Created 118 | 119 | Created{" "} 120 | 121 | {createdCanister.canister.toText()} (used{" "} 122 | {createdCanister.cycles.toLocaleString()} cycles) 123 | 124 | 125 | 126 | 127 | ); 128 | } 129 | return null; 130 | } 131 | 132 | function TransactionRow({ event, expanded, setExpanded }: TransactionRowProps) { 133 | const eventKind = Object.keys(event.kind)[0] as keyof EventKind; 134 | 135 | switch (eventKind) { 136 | case "CyclesSent": 137 | return ; 138 | case "CyclesReceived": 139 | return ; 140 | case "AddressAdded": 141 | return ; 142 | case "AddressRemoved": 143 | return <>; 144 | case "CanisterCreated": 145 | return ; 146 | case "CanisterCalled": 147 | return <>; 148 | 149 | default: 150 | return ( 151 | 152 | Unknown Transaction Received... 153 | 154 | ); 155 | } 156 | } 157 | 158 | type Props = { 159 | transactions?: Event[]; 160 | }; 161 | export default function Transactions(props: Props) { 162 | const { transactions } = props; 163 | const [expanded, setExpanded] = useState(-1); 164 | 165 | if (!transactions) { 166 | return null; 167 | } 168 | 169 | return ( 170 | 171 | 172 | Transaction History 173 | 174 | 175 | 176 | 177 | Date 178 | 179 | Details 180 | 181 | 182 | 183 | 184 | {transactions.map((event) => ( 185 | 190 | setExpanded(isExpanded ? event.id : -1) 191 | } 192 | /> 193 | ))} 194 | 195 |
196 |
197 | ); 198 | } 199 | -------------------------------------------------------------------------------- /wallet_ui/components/routes/Authorize.tsx: -------------------------------------------------------------------------------- 1 | import DoneIcon from "@material-ui/icons/Done"; 2 | import React, { useEffect, useState } from "react"; 3 | import { 4 | Wallet, 5 | Principal, 6 | getAgentPrincipal, 7 | getWalletId, 8 | } from "../../canister"; 9 | import { useHistory } from "react-router"; 10 | import Typography from "@material-ui/core/Typography"; 11 | import Paper from "@material-ui/core/Paper"; 12 | import Tooltip from "@material-ui/core/Tooltip"; 13 | import makeStyles from "@material-ui/core/styles/makeStyles"; 14 | import Grid from "@material-ui/core/Grid"; 15 | import Container from "@material-ui/core/Container"; 16 | import FileCopyIcon from "@material-ui/icons/FileCopy"; 17 | import { CopyToClipboard } from "react-copy-to-clipboard"; 18 | import Box from "@material-ui/core/Box"; 19 | import { Copyright } from "../App"; 20 | import Button from "@material-ui/core/Button"; 21 | import { PrimaryButton } from "../Buttons"; 22 | import { css } from "@emotion/css"; 23 | import { authClient } from "../../utils/authClient"; 24 | 25 | const CHECK_ACCESS_FREQUENCY_IN_SECONDS = 15; 26 | 27 | const useStyles = makeStyles((theme) => ({ 28 | paper: { 29 | padding: theme.spacing(2), 30 | display: "flex", 31 | overflow: "auto", 32 | flexDirection: "column", 33 | }, 34 | seeMore: { 35 | marginTop: theme.spacing(3), 36 | }, 37 | container: { 38 | paddingTop: theme.spacing(4), 39 | paddingBottom: theme.spacing(4), 40 | }, 41 | appBarSpacer: theme.mixins.toolbar, 42 | content: { 43 | flexGrow: 1, 44 | height: "100vh", 45 | overflow: "auto", 46 | }, 47 | })); 48 | 49 | const CopyButton = (props: { 50 | copyHandler: () => void; 51 | copied: boolean; 52 | canisterCallShCode: string; 53 | }) => { 54 | const { copyHandler, copied, canisterCallShCode } = props; 55 | return ( 56 | 63 | 73 | 74 |
75 | {copied ? ( 76 | 77 | ) : ( 78 | <> 79 | 80 | Copy 81 | 82 | )} 83 |
84 |
85 |
86 |
87 | ); 88 | }; 89 | 90 | type AuthorizeProps = { 91 | setIsAuthenticated: (x: boolean) => void; 92 | }; 93 | 94 | export function Authorize(props: AuthorizeProps) { 95 | const { setIsAuthenticated } = props; 96 | const [agentPrincipal, setAgentPrincipal] = useState(null); 97 | const [copied, setCopied] = useState(false); 98 | const history = useHistory(); 99 | const classes = useStyles(); 100 | 101 | function checkAccess() { 102 | Wallet.init().then( 103 | () => history.push("/"), 104 | () => {} 105 | ); 106 | } 107 | 108 | useEffect(() => { 109 | getAgentPrincipal().then(setAgentPrincipal); 110 | checkAccess(); 111 | 112 | const id = setInterval( 113 | checkAccess, 114 | CHECK_ACCESS_FREQUENCY_IN_SECONDS * 1000 115 | ); 116 | return () => clearInterval(id); 117 | }, []); 118 | 119 | if (agentPrincipal && !agentPrincipal.isAnonymous()) { 120 | const canisterId = getWalletId(); 121 | const isLocalhost = !!window.location.hostname.match(/^(.*\.)?localhost$/); 122 | const canisterCallShCode = `dfx canister${ 123 | isLocalhost ? "" : " --network ic" 124 | } call "${ 125 | canisterId?.toText() || "" 126 | }" authorize '(principal "${agentPrincipal.toText()}")'`; 127 | 128 | function copyHandler() { 129 | setCopied(true); 130 | setTimeout(() => setCopied(false), 2000); 131 | } 132 | 133 | return ( 134 |
135 |
136 | 137 | 138 | 139 | 140 | 141 | 142 | Register Device 143 | 144 | 145 | 146 | 147 | This user does not have access to this wallet. If you have 148 | administrative control, or know someone who does, add your 149 | principal as a custodian. 150 | 151 | 152 | 153 | 154 | 155 | If you are using DFX, use the following command to register 156 | your principal as custodian: 157 | 158 | 159 |
169 | 170 | 171 | Code 172 | 173 | 174 | 179 |
180 | 188 | {canisterCallShCode} 189 | 190 | 191 | After this step has been performed, you can refresh this page 192 | (or it will refresh automatically after a while). 193 | 194 |
195 |
196 |
197 |
198 | 199 | 200 | 201 | 202 |
203 | ); 204 | } 205 | 206 | return ( 207 |
208 |
209 | 210 | 211 | 212 | 213 | 214 | Anonymous Device 215 | 216 | 217 | 218 | You are currently using this service anonymously. Please 219 | authenticate. 220 | 221 | 222 | { 224 | await authClient.login(); 225 | const identity = await authClient.getIdentity(); 226 | Wallet.clearWalletCache(); 227 | if (identity) { 228 | setIsAuthenticated(true); 229 | setAgentPrincipal(identity.getPrincipal()); 230 | } else { 231 | console.error("could not get identity"); 232 | } 233 | }} 234 | > 235 | Authenticate 236 | 237 | 238 | 239 | 240 | 241 |
242 | ); 243 | } 244 | -------------------------------------------------------------------------------- /wallet_ui/components/routes/Dashboard.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import clsx from "clsx"; 3 | import makeStyles from "@material-ui/core/styles/makeStyles"; 4 | import Box from "@material-ui/core/Box"; 5 | import Divider from "@material-ui/core/Divider"; 6 | import IconButton from "@material-ui/core/IconButton"; 7 | import Container from "@material-ui/core/Container"; 8 | import Grid from "@material-ui/core/Grid"; 9 | import Paper from "@material-ui/core/Paper"; 10 | import ChevronLeftIcon from "@material-ui/icons/ChevronLeft"; 11 | import { CycleBalance } from "../panels/CycleBalance"; 12 | import Drawer from "@material-ui/core/Drawer"; 13 | import { Copyright } from "../App"; 14 | import { SendCyclesDialog } from "../panels/SendCycles"; 15 | import { BalanceChart } from "../panels/BalanceChart"; 16 | import Dialog from "@material-ui/core/Dialog"; 17 | import DialogTitle from "@material-ui/core/DialogTitle"; 18 | import DialogContent from "@material-ui/core/DialogContent"; 19 | import DialogContentText from "@material-ui/core/DialogContentText"; 20 | import DialogActions from "@material-ui/core/DialogActions"; 21 | import Button from "@material-ui/core/Button"; 22 | import { Wallet, convertIdlEventMap } from "../../canister"; 23 | import type { Event } from "../../canister/wallet/wallet"; 24 | import Canisters from "../panels/Canisters"; 25 | import { PrimaryButton } from "../Buttons"; 26 | import Events from "../panels/Transactions"; 27 | 28 | const drawerWidth = 240; 29 | 30 | const useStyles = makeStyles((theme) => ({ 31 | root: { 32 | display: "flex", 33 | }, 34 | toolbarIcon: { 35 | display: "flex", 36 | alignItems: "center", 37 | justifyContent: "flex-end", 38 | padding: "0 8px", 39 | ...theme.mixins.toolbar, 40 | }, 41 | drawerPaper: { 42 | position: "relative", 43 | whiteSpace: "nowrap", 44 | width: drawerWidth, 45 | transition: theme.transitions.create("width", { 46 | easing: theme.transitions.easing.sharp, 47 | duration: theme.transitions.duration.enteringScreen, 48 | }), 49 | }, 50 | drawerPaperClose: { 51 | overflowX: "hidden", 52 | transition: theme.transitions.create("width", { 53 | easing: theme.transitions.easing.sharp, 54 | duration: theme.transitions.duration.leavingScreen, 55 | }), 56 | width: theme.spacing(7), 57 | [theme.breakpoints.up("sm")]: { 58 | width: theme.spacing(9), 59 | }, 60 | }, 61 | menuButton: { 62 | marginRight: 36, 63 | }, 64 | menuButtonHidden: { 65 | display: "none", 66 | }, 67 | title: { 68 | flexGrow: 1, 69 | fontSize: "2rem", 70 | lineHeight: "2.34rem", 71 | }, 72 | title2: { 73 | flexGrow: 1, 74 | fontSize: "1.5rem", 75 | lineHeight: "1.76rem", 76 | }, 77 | appBarSpacer: theme.mixins.toolbar, 78 | content: { 79 | flexGrow: 1, 80 | height: "100vh", 81 | overflow: "auto", 82 | }, 83 | container: { 84 | paddingTop: theme.spacing(4), 85 | paddingBottom: theme.spacing(4), 86 | }, 87 | paper: { 88 | padding: theme.spacing(2), 89 | display: "flex", 90 | overflow: "auto", 91 | flexDirection: "column", 92 | }, 93 | })); 94 | 95 | export type EventList = { 96 | canisters: Event[]; 97 | transactions: Event[]; 98 | }; 99 | 100 | export function Dashboard(props: { open: boolean; onOpenToggle: () => void }) { 101 | const [cyclesDialogOpen, setCyclesDialogOpen] = useState(false); 102 | const [canisterCreateDialogOpen, setCanisterCreateDialogOpen] = useState( 103 | false 104 | ); 105 | const [walletCreateDialogOpen, setWalletCreateDialogOpen] = useState(false); 106 | const [errorDialogContent, setErrorDialogContent] = useState( 107 | undefined 108 | ); 109 | const { open, onOpenToggle } = props; 110 | const classes = useStyles(); 111 | const [events, setEvents] = useState(); 112 | 113 | const reduceStart: EventList = { 114 | canisters: [], 115 | transactions: [], 116 | }; 117 | 118 | const refreshEvents = async () => { 119 | const events = await ( 120 | await Wallet.getGeneratedActor().then((actor) => { 121 | return actor.get_events([ 122 | { 123 | to: [], 124 | from: [0], 125 | }, 126 | ]); 127 | }) 128 | ).map(convertIdlEventMap); 129 | 130 | const sortedEvents = events 131 | .sort((a, b) => { 132 | // Reverse sort on timestamp. 133 | return Number(b.timestamp) - Number(a.timestamp); 134 | }) 135 | .reduce((start, next) => { 136 | if ("CanisterCreated" in next.kind || "WalletCreated" in next.kind) { 137 | start.canisters.push(next); 138 | } else { 139 | start.transactions.push(next); 140 | } 141 | return start; 142 | }, reduceStart); 143 | 144 | setEvents(sortedEvents); 145 | }; 146 | 147 | React.useEffect(() => { 148 | refreshEvents(); 149 | }, []); 150 | 151 | function handleWalletCreateDialogClose(maybeErr?: any) { 152 | setWalletCreateDialogOpen(false); 153 | setErrorDialogContent(maybeErr); 154 | } 155 | 156 | return ( 157 | <> 158 | 166 |
167 | {" "} 168 | onOpenToggle()}> 169 | 170 | 171 |
172 | 173 | 174 |
175 | 176 | setCyclesDialogOpen(false)} 179 | /> 180 | 181 | setErrorDialogContent(undefined)} 184 | aria-labelledby="alert-dialog-title" 185 | aria-describedby="alert-dialog-description" 186 | > 187 | 188 | An error occured during the call 189 | 190 | 191 | 192 | Details: 193 |
194 | {errorDialogContent?.toString()} 195 |
196 |
197 | 198 | 205 | 206 |
207 | 208 |
209 |
210 | 211 | 212 | 213 | 214 |

Cycles Wallet

215 |
216 | {/* Balance */} 217 | 218 | 219 | 220 | setCyclesDialogOpen(true)} 224 | > 225 | Send Cycles 226 | 227 | 228 | 229 | 230 | {/* Chart */} 231 | 232 | 233 | 234 | 235 | 236 | 237 | {/* Canisters */} 238 | 239 | 240 | {events?.canisters && ( 241 | 245 | )} 246 | 247 | 248 | 249 | {/* Transactions */} 250 | 251 | 252 | {} 253 | 254 | 255 |
256 |
257 | 258 | 259 | 260 | 261 |
262 | 263 | ); 264 | } 265 | -------------------------------------------------------------------------------- /wallet_ui/css/CycleBalance.css: -------------------------------------------------------------------------------- 1 | .cycles { 2 | font-size: 72px; 3 | font-weight: bold; 4 | text-align: right; 5 | } 6 | 7 | caption { 8 | display: block; 9 | margin-top: -10px; 10 | text-align: right; 11 | font-size: 24px; 12 | font-weight: bold; 13 | } 14 | -------------------------------------------------------------------------------- /wallet_ui/css/Events.scss: -------------------------------------------------------------------------------- 1 | .events-list { 2 | .column { 3 | padding-left: 0; 4 | justify-content: start; 5 | align-items: flex-start; 6 | width: 100%; 7 | border-bottom: 1px solid #efefef; 8 | .row { 9 | justify-content: space-between; 10 | } 11 | h4, 12 | p { 13 | margin-top: 0; 14 | } 15 | } 16 | } 17 | 18 | .canisters { 19 | display: grid; 20 | grid-template-columns: 1fr auto; 21 | #canisters-trigger { 22 | grid-row: 1; 23 | grid-column: 2; 24 | } 25 | h2 { 26 | grid-row: 1; 27 | } 28 | p, 29 | ul { 30 | grid-column-start: 1; 31 | grid-column-end: span 2; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /wallet_ui/css/Footer.css: -------------------------------------------------------------------------------- 1 | footer { 2 | position: fixed; 3 | bottom: var(--gutters); 4 | color: var(--faded-white); 5 | font-size: 14px; 6 | } 7 | 8 | footer a:link, footer a:visited { 9 | color: var(--faded-white); 10 | } 11 | -------------------------------------------------------------------------------- /wallet_ui/css/Header.css: -------------------------------------------------------------------------------- 1 | .logo { 2 | position: absolute; 3 | top: 100px; 4 | right: 0; 5 | height: 25px; 6 | } 7 | -------------------------------------------------------------------------------- /wallet_ui/css/Input.css: -------------------------------------------------------------------------------- 1 | .input { 2 | width: 50%; 3 | margin: 50px auto; 4 | } 5 | 6 | input { 7 | width: 100%; 8 | border: 2px solid var(--full-white); 9 | border-radius: 10px; 10 | font-family: var(--monospace); 11 | background: var(--faded-white); 12 | text-align: center; 13 | line-height: 50px; 14 | color: var(--full-white); 15 | font-size: 24px; 16 | } 17 | 18 | output { 19 | border: 2px solid white; 20 | border-radius: 10px; 21 | background: rgba(0,0,0,0.25); 22 | padding: 10px; 23 | display: block; 24 | font-family: var(--monospace); 25 | } 26 | -------------------------------------------------------------------------------- /wallet_ui/css/main.css: -------------------------------------------------------------------------------- 1 | button { 2 | background: transparent; 3 | border: none; 4 | } 5 | 6 | .flex { 7 | display: flex; 8 | } 9 | .row { 10 | flex-direction: row; 11 | width: 100%; 12 | } 13 | .column { 14 | flex-direction: column; 15 | } 16 | .wrap { 17 | flex-flow: row wrap; 18 | } 19 | .justifyCenter { 20 | justify-content: center; 21 | } 22 | .alignCenter { 23 | align-content: center; 24 | } 25 | 26 | body :is(.MuiFormLabel-root.Mui-focused, .MuiInput-underline:after) { 27 | color: var(--textColor); 28 | } 29 | 30 | body .MuiInput-underline:after { 31 | border-bottom-color: var(--textColor); 32 | } 33 | -------------------------------------------------------------------------------- /wallet_ui/declarations/wallet/index.js: -------------------------------------------------------------------------------- 1 | import { Actor, HttpAgent } from "@dfinity/agent"; 2 | 3 | // Imports and re-exports candid interface 4 | import { idlFactory } from "./wallet.did.js"; 5 | export { idlFactory } from "./wallet.did.js"; 6 | // CANISTER_ID is replaced by webpack based on node environment 7 | export const canisterId = process.env.WALLET_CANISTER_ID; 8 | 9 | /** 10 | * 11 | * @param {string | import("@dfinity/principal").Principal} canisterId Canister ID of Agent 12 | * @param {{agentOptions?: import("@dfinity/agent").HttpAgentOptions; actorOptions?: import("@dfinity/agent").ActorConfig}} [options] 13 | * @return {import("@dfinity/agent").ActorSubclass} 14 | */ 15 | export const createActor = (canisterId, options) => { 16 | const agent = new HttpAgent({ ...options?.agentOptions }); 17 | 18 | // Fetch root key for certificate validation during development 19 | if (process.env.NODE_ENV !== "production") { 20 | agent.fetchRootKey().catch((err) => { 21 | console.warn( 22 | "Unable to fetch root key. Check to ensure that your local replica is running" 23 | ); 24 | console.error(err); 25 | }); 26 | } 27 | 28 | // Creates an actor with using the candid interface and the HttpAgent 29 | return Actor.createActor(idlFactory, { 30 | agent, 31 | canisterId, 32 | ...options?.actorOptions, 33 | }); 34 | }; 35 | 36 | /** 37 | * A ready-to-use agent for the wallet canister 38 | * @type {import("@dfinity/agent").ActorSubclass} 39 | */ 40 | export const wallet = createActor(canisterId); 41 | -------------------------------------------------------------------------------- /wallet_ui/declarations/wallet/wallet.did: -------------------------------------------------------------------------------- 1 | type EventKind = variant { 2 | CyclesSent: record { 3 | to: principal; 4 | amount: nat64; 5 | refund: nat64; 6 | }; 7 | CyclesReceived: record { 8 | from: principal; 9 | amount: nat64; 10 | memo: opt text; 11 | }; 12 | AddressAdded: record { 13 | id: principal; 14 | name: opt text; 15 | role: Role; 16 | }; 17 | AddressRemoved: record { 18 | id: principal; 19 | }; 20 | CanisterCreated: record { 21 | canister: principal; 22 | cycles: nat64; 23 | }; 24 | CanisterCalled: record { 25 | canister: principal; 26 | method_name: text; 27 | cycles: nat64; 28 | }; 29 | WalletDeployed: record { 30 | canister: principal; 31 | } 32 | }; 33 | 34 | type EventKind128 = variant { 35 | CyclesSent: record { 36 | to: principal; 37 | amount: nat; 38 | refund: nat; 39 | }; 40 | CyclesReceived: record { 41 | from: principal; 42 | amount: nat; 43 | memo: opt text; 44 | }; 45 | AddressAdded: record { 46 | id: principal; 47 | name: opt text; 48 | role: Role; 49 | }; 50 | AddressRemoved: record { 51 | id: principal; 52 | }; 53 | CanisterCreated: record { 54 | canister: principal; 55 | cycles: nat; 56 | }; 57 | CanisterCalled: record { 58 | canister: principal; 59 | method_name: text; 60 | cycles: nat; 61 | }; 62 | WalletDeployed: record { 63 | canister: principal; 64 | }; 65 | }; 66 | 67 | type Event = record { 68 | id: nat32; 69 | timestamp: nat64; 70 | kind: EventKind; 71 | }; 72 | 73 | type Event128 = record { 74 | id: nat32; 75 | timestamp: nat64; 76 | kind: EventKind128; 77 | }; 78 | 79 | type Role = variant { 80 | Contact; 81 | Custodian; 82 | Controller; 83 | }; 84 | 85 | type Kind = variant { 86 | Unknown; 87 | User; 88 | Canister; 89 | }; 90 | 91 | // An entry in the address book. It must have an ID and a role. 92 | type AddressEntry = record { 93 | id: principal; 94 | name: opt text; 95 | kind: Kind; 96 | role: Role; 97 | }; 98 | 99 | type ManagedCanisterInfo = record { 100 | id: principal; 101 | name: opt text; 102 | created_at: nat64; 103 | }; 104 | 105 | type ManagedCanisterEventKind = variant { 106 | CyclesSent: record { 107 | amount: nat64; 108 | refund: nat64; 109 | }; 110 | Called: record { 111 | method_name: text; 112 | cycles: nat64; 113 | }; 114 | Created: record { 115 | cycles: nat64; 116 | }; 117 | }; 118 | 119 | type ManagedCanisterEventKind128 = variant { 120 | CyclesSent: record { 121 | amount: nat; 122 | refund: nat; 123 | }; 124 | Called: record { 125 | method_name: text; 126 | cycles: nat; 127 | }; 128 | Created: record { 129 | cycles: nat; 130 | }; 131 | }; 132 | 133 | type ManagedCanisterEvent = record { 134 | id: nat32; 135 | timestamp: nat64; 136 | kind: ManagedCanisterEventKind; 137 | }; 138 | 139 | type ManagedCanisterEvent128 = record { 140 | id: nat32; 141 | timestamp: nat64; 142 | kind: ManagedCanisterEventKind128; 143 | }; 144 | 145 | type ReceiveOptions = record { 146 | memo: opt text; 147 | }; 148 | 149 | type WalletResultCreate = variant { 150 | Ok : record { canister_id: principal }; 151 | Err: text; 152 | }; 153 | 154 | type WalletResult = variant { 155 | Ok : null; 156 | Err : text; 157 | }; 158 | 159 | type WalletResultCall = variant { 160 | Ok : record { return: blob }; 161 | Err : text; 162 | }; 163 | 164 | type CanisterSettings = record { 165 | controller: opt principal; 166 | controllers: opt vec principal; 167 | compute_allocation: opt nat; 168 | memory_allocation: opt nat; 169 | freezing_threshold: opt nat; 170 | }; 171 | 172 | type CreateCanisterArgs = record { 173 | cycles: nat64; 174 | settings: CanisterSettings; 175 | }; 176 | 177 | type CreateCanisterArgs128 = record { 178 | cycles: nat; 179 | settings: CanisterSettings; 180 | }; 181 | 182 | // Assets 183 | type HeaderField = record { text; text; }; 184 | 185 | type HttpRequest = record { 186 | method: text; 187 | url: text; 188 | headers: vec HeaderField; 189 | body: blob; 190 | }; 191 | 192 | type HttpResponse = record { 193 | status_code: nat16; 194 | headers: vec HeaderField; 195 | body: blob; 196 | streaming_strategy: opt StreamingStrategy; 197 | }; 198 | 199 | type StreamingCallbackHttpResponse = record { 200 | body: blob; 201 | token: opt Token; 202 | }; 203 | 204 | type Token = record {}; 205 | 206 | type StreamingStrategy = variant { 207 | Callback: record { 208 | callback: func (Token) -> (StreamingCallbackHttpResponse) query; 209 | token: Token; 210 | }; 211 | }; 212 | 213 | service : { 214 | wallet_api_version: () -> (text) query; 215 | 216 | // Wallet Name 217 | name: () -> (opt text) query; 218 | set_name: (text) -> (); 219 | 220 | // Controller Management 221 | get_controllers: () -> (vec principal) query; 222 | add_controller: (principal) -> (); 223 | remove_controller: (principal) -> (WalletResult); 224 | 225 | // Custodian Management 226 | get_custodians: () -> (vec principal) query; 227 | authorize: (principal) -> (); 228 | deauthorize: (principal) -> (WalletResult); 229 | 230 | // Cycle Management 231 | wallet_balance: () -> (record { amount: nat64 }) query; 232 | wallet_balance128: () -> (record { amount: nat }) query; 233 | wallet_send: (record { canister: principal; amount: nat64 }) -> (WalletResult); 234 | wallet_send128: (record { canister: principal; amount: nat }) -> (WalletResult); 235 | wallet_receive: (opt ReceiveOptions) -> (); // Endpoint for receiving cycles. 236 | 237 | // Managing canister 238 | wallet_create_canister: (CreateCanisterArgs) -> (WalletResultCreate); 239 | wallet_create_canister128: (CreateCanisterArgs128) -> (WalletResultCreate); 240 | 241 | wallet_create_wallet: (CreateCanisterArgs) -> (WalletResultCreate); 242 | wallet_create_wallet128: (CreateCanisterArgs128) -> (WalletResultCreate); 243 | 244 | wallet_store_wallet_wasm: (record { 245 | wasm_module: blob; 246 | }) -> (); 247 | 248 | // Call Forwarding 249 | wallet_call: (record { 250 | canister: principal; 251 | method_name: text; 252 | args: blob; 253 | cycles: nat64; 254 | }) -> (WalletResultCall); 255 | wallet_call128: (record { 256 | canister: principal; 257 | method_name: text; 258 | args: blob; 259 | cycles: nat; 260 | }) -> (WalletResultCall); 261 | 262 | // Address book 263 | add_address: (address: AddressEntry) -> (); 264 | list_addresses: () -> (vec AddressEntry) query; 265 | remove_address: (address: principal) -> (WalletResult); 266 | 267 | // Events 268 | // If `from` is not specified, it will start 20 from the end; if `to` is not specified, it will stop at the end 269 | get_events: (opt record { from: opt nat32; to: opt nat32; }) -> (vec Event) query; 270 | get_events128: (opt record { from: opt nat32; to: opt nat32; }) -> (vec Event128) query; 271 | get_chart: (opt record { count: opt nat32; precision: opt nat64; } ) -> (vec record { nat64; nat64; }) query; 272 | 273 | // Managed canisters 274 | list_managed_canisters: (record { from: opt nat32; to: opt nat32; }) -> (vec ManagedCanisterInfo, nat32) query; 275 | // If `from` is not specified, it will start 20 from the end; if `to` is not specified, it will stop at the end 276 | get_managed_canister_events: (record { canister: principal; from: opt nat32; to: opt nat32; }) -> (opt vec ManagedCanisterEvent) query; 277 | get_managed_canister_events128: (record { canister: principal; from: opt nat32; to: opt nat32; }) -> (opt vec ManagedCanisterEvent128) query; 278 | set_short_name: (principal, opt text) -> (opt ManagedCanisterInfo); 279 | 280 | // Assets 281 | http_request: (request: HttpRequest) -> (HttpResponse) query; 282 | } 283 | -------------------------------------------------------------------------------- /wallet_ui/declarations/wallet/wallet.did.d.ts: -------------------------------------------------------------------------------- 1 | import type { Principal } from "@dfinity/principal"; 2 | import type { ActorMethod } from "@dfinity/agent"; 3 | 4 | export interface AddressEntry { 5 | id: Principal; 6 | kind: Kind; 7 | name: [] | [string]; 8 | role: Role; 9 | } 10 | export interface CanisterSettings { 11 | controller: [] | [Principal]; 12 | freezing_threshold: [] | [bigint]; 13 | controllers: [] | [Array]; 14 | memory_allocation: [] | [bigint]; 15 | compute_allocation: [] | [bigint]; 16 | } 17 | export interface CreateCanisterArgs { 18 | cycles: bigint; 19 | settings: CanisterSettings; 20 | } 21 | export interface CreateCanisterArgs128 { 22 | cycles: bigint; 23 | settings: CanisterSettings; 24 | } 25 | export interface Event { 26 | id: number; 27 | kind: EventKind; 28 | timestamp: bigint; 29 | } 30 | export interface Event128 { 31 | id: number; 32 | kind: EventKind128; 33 | timestamp: bigint; 34 | } 35 | export type EventKind = 36 | | { 37 | CyclesReceived: { 38 | from: Principal; 39 | memo: [] | [string]; 40 | amount: bigint; 41 | }; 42 | } 43 | | { CanisterCreated: { cycles: bigint; canister: Principal } } 44 | | { 45 | CanisterCalled: { 46 | cycles: bigint; 47 | method_name: string; 48 | canister: Principal; 49 | }; 50 | } 51 | | { 52 | CyclesSent: { to: Principal; amount: bigint; refund: bigint }; 53 | } 54 | | { AddressRemoved: { id: Principal } } 55 | | { WalletDeployed: { canister: Principal } } 56 | | { 57 | AddressAdded: { id: Principal; name: [] | [string]; role: Role }; 58 | }; 59 | export type EventKind128 = 60 | | { 61 | CyclesReceived: { 62 | from: Principal; 63 | memo: [] | [string]; 64 | amount: bigint; 65 | }; 66 | } 67 | | { CanisterCreated: { cycles: bigint; canister: Principal } } 68 | | { 69 | CanisterCalled: { 70 | cycles: bigint; 71 | method_name: string; 72 | canister: Principal; 73 | }; 74 | } 75 | | { 76 | CyclesSent: { to: Principal; amount: bigint; refund: bigint }; 77 | } 78 | | { AddressRemoved: { id: Principal } } 79 | | { WalletDeployed: { canister: Principal } } 80 | | { 81 | AddressAdded: { id: Principal; name: [] | [string]; role: Role }; 82 | }; 83 | export type HeaderField = [string, string]; 84 | export interface HttpRequest { 85 | url: string; 86 | method: string; 87 | body: Array; 88 | headers: Array; 89 | } 90 | export interface HttpResponse { 91 | body: Array; 92 | headers: Array; 93 | streaming_strategy: [] | [StreamingStrategy]; 94 | status_code: number; 95 | } 96 | export type Kind = { User: null } | { Canister: null } | { Unknown: null }; 97 | export interface ManagedCanisterEvent { 98 | id: number; 99 | kind: ManagedCanisterEventKind; 100 | timestamp: bigint; 101 | } 102 | export interface ManagedCanisterEvent128 { 103 | id: number; 104 | kind: ManagedCanisterEventKind128; 105 | timestamp: bigint; 106 | } 107 | export type ManagedCanisterEventKind = 108 | | { 109 | CyclesSent: { amount: bigint; refund: bigint }; 110 | } 111 | | { Created: { cycles: bigint } } 112 | | { Called: { cycles: bigint; method_name: string } }; 113 | export type ManagedCanisterEventKind128 = 114 | | { 115 | CyclesSent: { amount: bigint; refund: bigint }; 116 | } 117 | | { Created: { cycles: bigint } } 118 | | { Called: { cycles: bigint; method_name: string } }; 119 | export interface ManagedCanisterInfo { 120 | id: Principal; 121 | name: [] | [string]; 122 | created_at: bigint; 123 | } 124 | export interface ReceiveOptions { 125 | memo: [] | [string]; 126 | } 127 | export type Role = 128 | | { Custodian: null } 129 | | { Contact: null } 130 | | { Controller: null }; 131 | export interface StreamingCallbackHttpResponse { 132 | token: [] | [Token]; 133 | body: Array; 134 | } 135 | export type StreamingStrategy = { 136 | Callback: { token: Token; callback: [Principal, string] }; 137 | }; 138 | export type Token = {}; 139 | export type WalletResult = { Ok: null } | { Err: string }; 140 | export type WalletResultCall = 141 | | { Ok: { return: Array } } 142 | | { Err: string }; 143 | export type WalletResultCreate = 144 | | { Ok: { canister_id: Principal } } 145 | | { Err: string }; 146 | export interface _SERVICE { 147 | add_address: ActorMethod<[AddressEntry], undefined>; 148 | add_controller: ActorMethod<[Principal], undefined>; 149 | authorize: ActorMethod<[Principal], undefined>; 150 | deauthorize: ActorMethod<[Principal], WalletResult>; 151 | get_chart: ActorMethod< 152 | [[] | [{ count: [] | [number]; precision: [] | [bigint] }]], 153 | Array<[bigint, bigint]> 154 | >; 155 | get_controllers: ActorMethod<[], Array>; 156 | get_custodians: ActorMethod<[], Array>; 157 | get_events: ActorMethod< 158 | [[] | [{ to: [] | [number]; from: [] | [number] }]], 159 | Array 160 | >; 161 | get_events128: ActorMethod< 162 | [[] | [{ to: [] | [number]; from: [] | [number] }]], 163 | Array 164 | >; 165 | get_managed_canister_events: ActorMethod< 166 | [{ to: [] | [number]; from: [] | [number]; canister: Principal }], 167 | [] | [Array] 168 | >; 169 | get_managed_canister_events128: ActorMethod< 170 | [{ to: [] | [number]; from: [] | [number]; canister: Principal }], 171 | [] | [Array] 172 | >; 173 | http_request: ActorMethod<[HttpRequest], HttpResponse>; 174 | list_addresses: ActorMethod<[], Array>; 175 | list_managed_canisters: ActorMethod< 176 | [{ to: [] | [number]; from: [] | [number] }], 177 | [Array, number] 178 | >; 179 | name: ActorMethod<[], [] | [string]>; 180 | remove_address: ActorMethod<[Principal], WalletResult>; 181 | remove_controller: ActorMethod<[Principal], WalletResult>; 182 | set_name: ActorMethod<[string], undefined>; 183 | set_short_name: ActorMethod< 184 | [Principal, [] | [string]], 185 | [] | [ManagedCanisterInfo] 186 | >; 187 | wallet_api_version: ActorMethod<[], string>; 188 | wallet_balance: ActorMethod<[], { amount: bigint }>; 189 | wallet_balance128: ActorMethod<[], { amount: bigint }>; 190 | wallet_call: ActorMethod< 191 | [ 192 | { 193 | args: Array; 194 | cycles: bigint; 195 | method_name: string; 196 | canister: Principal; 197 | } 198 | ], 199 | WalletResultCall 200 | >; 201 | wallet_call128: ActorMethod< 202 | [ 203 | { 204 | args: Array; 205 | cycles: bigint; 206 | method_name: string; 207 | canister: Principal; 208 | } 209 | ], 210 | WalletResultCall 211 | >; 212 | wallet_create_canister: ActorMethod<[CreateCanisterArgs], WalletResultCreate>; 213 | wallet_create_canister128: ActorMethod< 214 | [CreateCanisterArgs128], 215 | WalletResultCreate 216 | >; 217 | wallet_create_wallet: ActorMethod<[CreateCanisterArgs], WalletResultCreate>; 218 | wallet_create_wallet128: ActorMethod< 219 | [CreateCanisterArgs128], 220 | WalletResultCreate 221 | >; 222 | wallet_receive: ActorMethod<[[] | [ReceiveOptions]], undefined>; 223 | wallet_send: ActorMethod< 224 | [{ canister: Principal; amount: bigint }], 225 | WalletResult 226 | >; 227 | wallet_send128: ActorMethod< 228 | [{ canister: Principal; amount: bigint }], 229 | WalletResult 230 | >; 231 | wallet_store_wallet_wasm: ActorMethod< 232 | [{ wasm_module: Array }], 233 | undefined 234 | >; 235 | } 236 | -------------------------------------------------------------------------------- /wallet_ui/declarations/xdr/canister.did: -------------------------------------------------------------------------------- 1 | type IcpXdrConversionRate = 2 | record { 3 | timestamp_seconds: nat64; 4 | xdr_permyriad_per_icp: nat64; 5 | }; 6 | 7 | type IcpXdrConversionRateCertifiedResponse = 8 | record { 9 | data: IcpXdrConversionRate; 10 | hash_tree: blob; 11 | certificate: blob; 12 | }; 13 | 14 | service : { 15 | get_icp_xdr_conversion_rate: () -> (IcpXdrConversionRateCertifiedResponse) query; 16 | } 17 | -------------------------------------------------------------------------------- /wallet_ui/declarations/xdr/canister.did.d.ts: -------------------------------------------------------------------------------- 1 | import type { Principal } from "@dfinity/principal"; 2 | export interface IcpXdrConversionRate { 3 | xdr_permyriad_per_icp: bigint; 4 | timestamp_seconds: bigint; 5 | } 6 | export interface IcpXdrConversionRateCertifiedResponse { 7 | certificate: Array; 8 | data: IcpXdrConversionRate; 9 | hash_tree: Array; 10 | } 11 | export interface _SERVICE { 12 | 'get_icp_xdr_conversion_rate': () => Promise; 13 | } 14 | -------------------------------------------------------------------------------- /wallet_ui/declarations/xdr/canister.did.js: -------------------------------------------------------------------------------- 1 | export const idlFactory = ({ IDL }) => { 2 | const IcpXdrConversionRate = IDL.Record({ 3 | xdr_permyriad_per_icp: IDL.Nat64, 4 | timestamp_seconds: IDL.Nat64, 5 | }); 6 | const IcpXdrConversionRateCertifiedResponse = IDL.Record({ 7 | certificate: IDL.Vec(IDL.Nat8), 8 | data: IcpXdrConversionRate, 9 | hash_tree: IDL.Vec(IDL.Nat8), 10 | }); 11 | return IDL.Service({ 12 | 'get_icp_xdr_conversion_rate': IDL.Func( 13 | [], 14 | [IcpXdrConversionRateCertifiedResponse], 15 | ["query"] 16 | ), 17 | }); 18 | }; 19 | export const init = ({ IDL }) => { 20 | return []; 21 | }; 22 | -------------------------------------------------------------------------------- /wallet_ui/declarations/xdr/index.js: -------------------------------------------------------------------------------- 1 | import { Actor, HttpAgent, AnonymousIdentity } from "@dfinity/agent"; 2 | 3 | // Imports and re-exports candid interface 4 | import { idlFactory } from "./canister.did.js"; 5 | export { idlFactory } from "./canister.did.js"; 6 | // CANISTER_ID is replaced by webpack based on node environment 7 | export const canisterId = "rkp4c-7iaaa-aaaaa-aaaca-cai"; 8 | 9 | /** 10 | * 11 | * @param {string | import("@dfinity/principal").Principal} canisterId Canister ID of Agent 12 | * @param {{agentOptions?: import("@dfinity/agent").HttpAgentOptions; actorOptions?: import("@dfinity/agent").ActorConfig}} [options] 13 | * @return {import("@dfinity/agent").ActorSubclass} 14 | */ 15 | export const createActor = (canisterId, options) => { 16 | const agent = new HttpAgent({ ...options?.agentOptions }); 17 | 18 | // Creates an actor with using the candid interface and the HttpAgent 19 | return Actor.createActor(idlFactory, { 20 | agent, 21 | canisterId, 22 | ...options?.actorOptions, 23 | }); 24 | }; 25 | 26 | /** 27 | * A ready-to-use agent for the canister canister 28 | * @type {import("@dfinity/agent").ActorSubclass} 29 | */ 30 | export const xdr = createActor(canisterId, { 31 | agentOptions: { 32 | host: 'https://ic0.app' 33 | } 34 | }); 35 | -------------------------------------------------------------------------------- /wallet_ui/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Cycle Wallet 7 | 11 | 15 | 16 | 17 |
18 | 19 | 20 | -------------------------------------------------------------------------------- /wallet_ui/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as ReactDOM from "react-dom"; 3 | 4 | // Components 5 | import App from "./components/App"; 6 | 7 | import "./css/main.css"; 8 | 9 | const render = () => { 10 | ReactDOM.render(, document.getElementById("app")); 11 | }; 12 | render(); 13 | 14 | // Hot Module Replacement API 15 | if (module.hot) { 16 | module.hot.accept("./components/App", render); 17 | } 18 | -------------------------------------------------------------------------------- /wallet_ui/public/Handle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dfinity/cycles-wallet/b013764dd827560d8538ee2b7be9ecf66bed6be7/wallet_ui/public/Handle.png -------------------------------------------------------------------------------- /wallet_ui/public/Plus.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /wallet_ui/public/checkers.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dfinity/cycles-wallet/b013764dd827560d8538ee2b7be9ecf66bed6be7/wallet_ui/public/checkers.png -------------------------------------------------------------------------------- /wallet_ui/public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dfinity/cycles-wallet/b013764dd827560d8538ee2b7be9ecf66bed6be7/wallet_ui/public/logo.png -------------------------------------------------------------------------------- /wallet_ui/utils/authClient.ts: -------------------------------------------------------------------------------- 1 | import { Identity } from "@dfinity/agent"; 2 | import { AuthClient } from "@dfinity/auth-client"; 3 | 4 | class AuthClientWrapper { 5 | public authClient?: AuthClient; 6 | public ready = false; 7 | constructor() { 8 | return this; 9 | } 10 | async create() { 11 | this.authClient = await AuthClient.create(); 12 | await this.authClient?.isAuthenticated(); 13 | this.ready = true; 14 | } 15 | async login(): Promise { 16 | return new Promise(async (resolve) => { 17 | return await this.authClient?.login({ 18 | identityProvider: this.identityProvider, 19 | onSuccess: async () => { 20 | resolve(await this.authClient?.getIdentity()); 21 | }, 22 | }); 23 | }); 24 | } 25 | 26 | async getIdentity() { 27 | return await this.authClient?.getIdentity(); 28 | } 29 | 30 | async isAuthenticated() { 31 | return await this.authClient?.isAuthenticated(); 32 | } 33 | 34 | /** 35 | * Get the internet-identity identityProvider URL to use when authenticating the end-user. 36 | * Use ?identityProvider if present (useful in development), otherwise return undefined 37 | * so that AuthClient default gets used. 38 | * For development, open browser to : 39 | * `http://localhost:8080/?canisterId=&identityProvider=http://localhost:8000/?canisterId=` 40 | */ 41 | private get identityProvider(): string | undefined { 42 | const fromUrl = new URLSearchParams(location.search).get( 43 | "identityProvider" 44 | ); 45 | 46 | return fromUrl || undefined; 47 | } 48 | } 49 | 50 | export const authClient = new AuthClientWrapper(); 51 | authClient.create(); 52 | -------------------------------------------------------------------------------- /wallet_ui/utils/chart.test.ts: -------------------------------------------------------------------------------- 1 | import { buildData, buildTimeArray, ceilTime } from "./chart"; 2 | import { ChartPrecision } from "../canister"; 3 | import exp from "constants"; 4 | 5 | // For reference, through this file, (in milliseconds); 6 | // 100000000 => Thu Jan 01 1970 19:46:40 GMT-0800 (Pacific Standard Time) 7 | // 1000000000000 => Sat Sep 08 2001 18:46:40 GMT-0700 (Pacific Daylight Time) 8 | 9 | const ceilTimeCases: [Date, ChartPrecision, Date][] = [ 10 | [new Date(100000000), ChartPrecision.Minutes, new Date(100020000)], // 0 11 | [new Date(100010000), ChartPrecision.Minutes, new Date(100020000)], // 1 12 | [new Date(100020000), ChartPrecision.Minutes, new Date(100020000)], // 2 13 | [new Date(100020001), ChartPrecision.Minutes, new Date(100080000)], // 3 14 | [new Date(100000000), ChartPrecision.Hourly, new Date(100800000)], // 4 15 | [new Date(100200000), ChartPrecision.Hourly, new Date(100800000)], // 5 16 | [new Date(100800000), ChartPrecision.Hourly, new Date(100800000)], // 6 17 | [new Date(102000000), ChartPrecision.Hourly, new Date(104400000)], // 7 18 | [new Date(100000000), ChartPrecision.Daily, new Date(115200000)], // 8 19 | [new Date(100000000), ChartPrecision.Weekly, new Date(288000000)], // 9 20 | [new Date(201600001), ChartPrecision.Weekly, new Date(288000000)], // 10 21 | [new Date(288000001), ChartPrecision.Weekly, new Date(892800000)], // 11 22 | [new Date(100000000), ChartPrecision.Monthly, new Date(2707200000)], // 12 23 | ]; 24 | 25 | test.each(ceilTimeCases)("(%#) ceilTime %s(%s) == %s", (i, p, expected) => { 26 | expect(+ceilTime(i, p)).toEqual(+expected); 27 | }); 28 | 29 | // This test only valid if your locale is PST, sorry. 30 | const buildTimeArrayCases: [ChartPrecision, number, number[]][] = [ 31 | [ChartPrecision.Minutes, 1, [1000000020000, 999999960000, 999999900000]], 32 | [ChartPrecision.Hourly, 1, [1000000800000, 999997200000, 999993600000]], 33 | [ChartPrecision.Daily, 0, [1000018800000, 999932400000, 999846000000]], 34 | [ChartPrecision.Weekly, 0, [1000018800000, 999414000000, 998809200000]], 35 | [ChartPrecision.Monthly, 0, [1001919600000, 999327600000, 996649200000]], 36 | ]; 37 | 38 | test.each(buildTimeArrayCases)( 39 | "(%#) buildTimeArray(" + new Date(1000000000000) + ", %i)", 40 | (p, type, date) => { 41 | let expected; 42 | if (type == 0) { 43 | expected = date.map((d) => new Date(d).toLocaleDateString()); 44 | } else if (type == 1) { 45 | expected = date.map((d) => new Date(d).toLocaleTimeString()); 46 | } else { 47 | expected = date.map( 48 | (d) => 49 | new Date(d).toLocaleDateString() + 50 | " " + 51 | new Date(d).toLocaleTimeString() 52 | ); 53 | } 54 | 55 | expect( 56 | buildTimeArray(new Date(1000000000000), p, 3).map((x) => x[1]) 57 | ).toEqual(expected); 58 | } 59 | ); 60 | 61 | const buildDataCases: [ChartPrecision, [Date, number][], number[]][] = [ 62 | [ 63 | ChartPrecision.Minutes, 64 | [ 65 | [new Date(100000000), 1], 66 | [new Date(100001000), 2], 67 | [new Date(100002000), 3], 68 | [new Date(100003000), 4], 69 | [new Date(100100000), 5], 70 | [new Date(100101000), 6], 71 | [new Date(100102000), 7], 72 | [new Date(100103000), 8], 73 | [new Date(100200000), 9], 74 | [new Date(100201000), 10], 75 | [new Date(100202000), 11], 76 | [new Date(100203000), 12], 77 | ], 78 | [1, 1, 1, 1, 1, 5, 5, 9, 9, 12], 79 | ], 80 | [ 81 | ChartPrecision.Hourly, 82 | [ 83 | [new Date(1000000000), 1], 84 | [new Date(1000010000), 2], 85 | [new Date(1000020000), 3], 86 | [new Date(1000030000), 4], 87 | [new Date(1001000000), 5], 88 | [new Date(1001010000), 6], 89 | [new Date(1001020000), 7], 90 | [new Date(1001030000), 8], 91 | [new Date(1002000000), 9], 92 | [new Date(1002010000), 10], 93 | [new Date(1002020000), 11], 94 | [new Date(1002030000), 12], 95 | [new Date(1003000000), 13], 96 | [new Date(1004000000), 14], 97 | [new Date(1005000000), 15], 98 | [new Date(1006000000), 16], 99 | [new Date(1007000000), 17], 100 | [new Date(1008000000), 18], 101 | [new Date(1008500000), 19], 102 | [new Date(1009000000), 20], 103 | [new Date(1009500000), 21], 104 | [new Date(1010000000), 22], 105 | [new Date(1010500000), 23], 106 | [new Date(1011000000), 24], 107 | [new Date(1011500000), 25], 108 | [new Date(1021500000), 26], 109 | ], 110 | [1, 1, 1, 5, 15, 18, 26, 26, 26, 26], 111 | ], 112 | [ 113 | ChartPrecision.Monthly, 114 | [ 115 | [new Date(1000000000), 1], 116 | [new Date(2000000000), 2], 117 | [new Date(3000000000), 3], 118 | [new Date(4000000000), 4], 119 | [new Date(5000000000), 5], 120 | [new Date(6000000000), 6], 121 | [new Date(7000000000), 7], 122 | [new Date(8000000000), 8], 123 | [new Date(9000000000), 9], 124 | [new Date(10000000000), 10], 125 | [new Date(11000000000), 11], 126 | [new Date(12000000000), 12], 127 | [new Date(13000000000), 13], 128 | ], 129 | [1, 1, 1, 1, 3, 6, 8, 11, 13, 13], 130 | ], 131 | ]; 132 | 133 | test.each(buildDataCases)("(%#) buildDataCases(%i)", (p, chart, expected) => { 134 | const actual = buildData(chart, p, 10); 135 | expect(actual.map((x) => x.realAmount)).toEqual(expected); 136 | }); 137 | -------------------------------------------------------------------------------- /wallet_ui/utils/chart.ts: -------------------------------------------------------------------------------- 1 | import { ChartPrecision } from "../canister"; 2 | 3 | export interface ChartData { 4 | date: Date; 5 | humanDate: string; 6 | realAmount: number; 7 | scaledAmount: number; 8 | } 9 | 10 | /** 11 | * Round up the date to the precision. 12 | */ 13 | export function ceilTime(date: Date, precision: ChartPrecision) { 14 | // Round up to nearest minute, then apply a delta milliseconds to reach the proper ceiling. 15 | if (date.getMilliseconds() > 0 || date.getSeconds() > 0) { 16 | date.setMinutes(date.getMinutes() + 1); 17 | } 18 | date.setMilliseconds(0); 19 | date.setSeconds(0); 20 | 21 | if (precision >= ChartPrecision.Hourly && date.getMinutes() > 0) { 22 | date.setMinutes(0); 23 | date.setHours(date.getHours() + 1); 24 | } 25 | if (precision >= ChartPrecision.Daily && date.getHours() > 0) { 26 | date.setHours(0); 27 | date.setDate(date.getDate() + 1); 28 | } 29 | 30 | // Weeks and Month can round up in surprising way so we distinguish them separately. 31 | if (precision == ChartPrecision.Weekly && date.getDay() > 0) { 32 | date.setDate(date.getDate() + (7 - date.getDay())); 33 | } else if (precision >= ChartPrecision.Monthly && date.getDate() > 0) { 34 | date.setDate(1); // JavaScript _date_ is 1 based. 35 | date.setMonth(date.getMonth() + 1); 36 | } 37 | return date; 38 | } 39 | 40 | /** 41 | * Build a time table backward, with precision, from the date. 42 | */ 43 | export function buildTimeArray( 44 | from: Date, 45 | precision: ChartPrecision, 46 | count = 20 47 | ): [Date, string][] { 48 | const result: [Date, string][] = []; 49 | from = ceilTime(from, precision); 50 | 51 | for (let i = 0; i < count; i++) { 52 | const d = from.toLocaleDateString(); 53 | const t = from.toLocaleTimeString(); 54 | const s = new Date(+from); 55 | let time = `${d} ${t}`; 56 | 57 | switch (precision) { 58 | case ChartPrecision.Minutes: 59 | time = t; 60 | from = new Date(+from - 60 * 1000); 61 | break; 62 | case ChartPrecision.Hourly: 63 | time = t; 64 | from = new Date(+from - 60 * 60 * 1000); 65 | break; 66 | case ChartPrecision.Daily: 67 | time = d; 68 | from = new Date(+from - 24 * 60 * 60 * 1000); 69 | break; 70 | case ChartPrecision.Weekly: 71 | time = d; 72 | from = new Date(+from - 7 * 24 * 60 * 60 * 1000); 73 | break; 74 | case ChartPrecision.Monthly: 75 | time = d; 76 | from.setMonth(from.getMonth() - 1); 77 | break; 78 | } 79 | 80 | result.push([s, time]); 81 | } 82 | 83 | return result; 84 | } 85 | 86 | export function buildData( 87 | data: [Date, number][], 88 | precision: ChartPrecision, 89 | count = 20 90 | ): ChartData[] { 91 | // Do not trust of the order of data from canister, as it can be unsorted. 92 | const actualData = [...data].sort((a, b) => +a[0] - +b[0]); 93 | const maxDate = new Date(Math.max(...data.map((x) => +x[0]))); 94 | 95 | const times = buildTimeArray(maxDate, precision, count); 96 | return times 97 | .map(([date, humanDate]) => { 98 | const i = actualData.find((v) => +v[0] >= +date); 99 | const amount = (i || actualData[actualData.length - 1])[1]; 100 | 101 | return { 102 | date, 103 | humanDate, 104 | realAmount: amount, 105 | scaledAmount: Math.log10(amount), 106 | }; 107 | }) 108 | .reverse(); 109 | } 110 | -------------------------------------------------------------------------------- /wallet_ui/utils/cycles.ts: -------------------------------------------------------------------------------- 1 | export function format_cycles_trillion(cycles: bigint, fixed: number) { 2 | const trillion = 1000000000000; 3 | const cyclesInTrillion = parseFloat(cycles.toString()) / trillion; 4 | const cycles_string = 5 | cyclesInTrillion % 1 === 0 6 | ? cyclesInTrillion 7 | : cyclesInTrillion.toFixed(fixed); 8 | return cycles_string.toLocaleString(); 9 | } 10 | 11 | export function format_cycles_trillion_fullrange(cycles: bigint) { 12 | const cycles_float = parseFloat(cycles.toString()); 13 | const units = { 14 | kilo: 1000, 15 | mega: 1000000, 16 | giga: 1000000000, 17 | tril: 1000000000000, 18 | }; 19 | if (cycles_float < units.kilo) { 20 | return format_cycles_trillion(cycles, 12); 21 | } 22 | if (cycles_float < units.mega) { 23 | return format_cycles_trillion(cycles, 9); 24 | } 25 | if (cycles_float < units.giga) { 26 | return format_cycles_trillion(cycles, 6); 27 | } 28 | if (cycles_float < units.tril) { 29 | return format_cycles_trillion(cycles, 3); 30 | } 31 | return format_cycles_trillion(cycles, 2); 32 | } 33 | -------------------------------------------------------------------------------- /wallet_ui/utils/hooks.ts: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | 3 | /** 4 | * Use a local storage value that persists across reloads. This does NOT listen to 5 | * changes using the storage event. 6 | * Values must be JSON parseable and stringifiable. 7 | * 8 | * @param key The local storage key to use. 9 | * @param initialValue The initial value to use. 10 | */ 11 | export function useLocalStorage(key: string, initialValue: T) { 12 | // State to store our value 13 | // Pass initial state function to useState so logic is only executed once 14 | const [storedValue, setStoredValue] = useState(() => { 15 | try { 16 | // Get from local storage by key 17 | const item = window.localStorage.getItem(key); 18 | // Parse stored json or if none return initialValue 19 | return item ? JSON.parse(item) : initialValue; 20 | } catch (error) { 21 | // If error also return initialValue 22 | console.error(error); 23 | return initialValue; 24 | } 25 | }); 26 | 27 | // Return a wrapped version of useState's setter function that ... 28 | // ... persists the new value to localStorage. 29 | const setValue = (value: T) => { 30 | try { 31 | // Allow value to be a function so we have same API as useState 32 | const valueToStore = 33 | value instanceof Function ? value(storedValue) : value; 34 | // Save state 35 | setStoredValue(valueToStore); 36 | // Save to local storage 37 | window.localStorage.setItem(key, JSON.stringify(valueToStore)); 38 | } catch (error) { 39 | // A more advanced implementation would handle the error case 40 | console.log(error); 41 | } 42 | }; 43 | 44 | return [storedValue, setValue]; 45 | } 46 | -------------------------------------------------------------------------------- /wallet_ui/utils/materialTheme.ts: -------------------------------------------------------------------------------- 1 | import { common, deepPurple } from "@material-ui/core/colors"; 2 | import createMuiTheme from "@material-ui/core/styles/createMuiTheme"; 3 | 4 | const generateTheme = (darkState: boolean) => { 5 | const palletType = darkState ? "dark" : "light"; 6 | const mainPrimaryColor = darkState ? "#292A2E" : "#292A2E"; 7 | const mainSecondaryColor = darkState ? "#292A2E" : "#292A2E"; 8 | // const mainSecondaryColor = darkState ? common.white : deepPurple[500]; 9 | 10 | return createMuiTheme({ 11 | palette: { 12 | type: palletType, 13 | primary: { 14 | main: mainPrimaryColor, 15 | }, 16 | secondary: { 17 | main: mainSecondaryColor, 18 | }, 19 | }, 20 | transitions: { 21 | // So we have `transition: none;` everywhere 22 | create: () => "none", 23 | }, 24 | }); 25 | }; 26 | 27 | export default generateTheme; 28 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const HtmlWebpackPlugin = require("html-webpack-plugin"); 2 | const CopyPlugin = require("copy-webpack-plugin"); 3 | const TerserPlugin = require("terser-webpack-plugin"); 4 | const path = require("path"); 5 | const webpack = require("webpack"); 6 | const public = path.join(__dirname, "wallet_ui", "public"); 7 | 8 | const dist = path.join(__dirname, "dist"); 9 | 10 | module.exports = { 11 | entry: { 12 | index: path.join(__dirname, "wallet_ui/index.tsx"), 13 | }, 14 | output: { 15 | filename: "[name].js", 16 | path: dist, 17 | }, 18 | mode: "production", 19 | module: { 20 | rules: [ 21 | { test: /\.tsx?$/, loader: "ts-loader" }, 22 | { 23 | exclude: /node_modules/, 24 | test: /\.css$/, 25 | use: [ 26 | { 27 | loader: "style-loader", 28 | }, 29 | { 30 | loader: "css-loader", 31 | }, 32 | ], 33 | }, 34 | { 35 | test: /\.s[ac]ss$/i, 36 | use: [ 37 | // Creates `style` nodes from JS strings 38 | "style-loader", 39 | // Translates CSS into CommonJS 40 | "css-loader", 41 | // Compiles Sass to CSS 42 | "sass-loader", 43 | ], 44 | }, 45 | ], 46 | }, 47 | optimization: { 48 | minimize: true, 49 | minimizer: [ 50 | new TerserPlugin({ 51 | test: /\.[tj]s(\?.*)?$/i, 52 | terserOptions: { 53 | format: { 54 | comments: false, 55 | }, 56 | }, 57 | }), 58 | ], 59 | splitChunks: { 60 | chunks: "async", 61 | minSize: 20000, 62 | minRemainingSize: 0, 63 | maxSize: 250000, 64 | minChunks: 1, 65 | maxAsyncRequests: 30, 66 | maxInitialRequests: 30, 67 | enforceSizeThreshold: 50000, 68 | cacheGroups: { 69 | defaultVendors: { 70 | test: /[\\/]node_modules[\\/]/, 71 | priority: -10, 72 | reuseExistingChunk: true, 73 | }, 74 | default: { 75 | reuseExistingChunk: true, 76 | minChunks: 2, 77 | priority: -20, 78 | }, 79 | }, 80 | }, 81 | }, 82 | resolve: { 83 | extensions: [".js", ".ts", ".jsx", ".tsx"], 84 | fallback: { 85 | assert: require.resolve("assert/"), 86 | buffer: require.resolve("buffer/"), 87 | events: require.resolve("events/"), 88 | stream: require.resolve("stream-browserify/"), 89 | util: require.resolve("util/"), 90 | }, 91 | }, 92 | plugins: [ 93 | new webpack.ProvidePlugin({ 94 | Buffer: [require.resolve("buffer/"), "Buffer"], 95 | process: require.resolve("process/browser"), 96 | }), 97 | new CopyPlugin({ 98 | patterns: [{ from: public, to: path.join(__dirname, "dist") }], 99 | }), 100 | new HtmlWebpackPlugin({ 101 | template: "./wallet_ui/index.html", 102 | filename: "index.html", 103 | chunks: ["index"], 104 | }), 105 | ], 106 | }; 107 | -------------------------------------------------------------------------------- /webpack.dev.js: -------------------------------------------------------------------------------- 1 | const HtmlWebpackPlugin = require("html-webpack-plugin"); 2 | const path = require("path"); 3 | const { DefinePlugin } = require("webpack"); 4 | 5 | const prodConfig = require("./webpack.config"); 6 | module.exports = { 7 | ...prodConfig, 8 | mode: "development", 9 | optimization: { 10 | minimize: false, 11 | }, 12 | devtool: "source-map", 13 | module: { 14 | ...prodConfig.module, 15 | rules: [...prodConfig.module.rules], 16 | }, 17 | plugins: [ 18 | ...prodConfig.plugins, 19 | new DefinePlugin({ 20 | "process.browser": true, 21 | "process.env.NODE_DEBUG": false, 22 | }), 23 | ], 24 | devServer: { 25 | port: 8080, 26 | watchFiles: "./wallet_ui", 27 | hot: true, 28 | proxy: { 29 | "/api": "http://localhost:8000", 30 | "/bls.wasm": "http://localhost:8000", 31 | }, 32 | historyApiFallback: { 33 | index: "/", 34 | }, 35 | }, 36 | }; 37 | --------------------------------------------------------------------------------