diff --git a/.changeset/afraid-readers-swim.md b/.changeset/afraid-readers-swim.md new file mode 100644 index 000000000..f7828b608 --- /dev/null +++ b/.changeset/afraid-readers-swim.md @@ -0,0 +1,5 @@ +--- +"@ledgerhq/device-signer-kit-ethereum": patch +--- + +Add ProvideTransactionGenericContext Task diff --git a/.changeset/afraid-readers-swimmer.md b/.changeset/afraid-readers-swimmer.md new file mode 100644 index 000000000..817d38c8a --- /dev/null +++ b/.changeset/afraid-readers-swimmer.md @@ -0,0 +1,5 @@ +--- +"@ledgerhq/context-module": patch +--- + +Add ProvideTransactionGenericContext Task diff --git a/tmp_changeset/calm-months-live.md b/.changeset/calm-months-live.md similarity index 56% rename from tmp_changeset/calm-months-live.md rename to .changeset/calm-months-live.md index 9cf9e7d6a..07cd31d49 100644 --- a/tmp_changeset/calm-months-live.md +++ b/.changeset/calm-months-live.md @@ -1,5 +1,5 @@ --- -"@ledgerhq/keyring-btc": minor +"@ledgerhq/device-signer-kit-btc": minor --- Implement MerkleTree and MerkleMap services diff --git a/.changeset/clever-badgers-pull.md b/.changeset/clever-badgers-pull.md new file mode 100644 index 000000000..f2ee79b41 --- /dev/null +++ b/.changeset/clever-badgers-pull.md @@ -0,0 +1,5 @@ +--- +"@ledgerhq/device-management-kit-sample": minor +--- + +Add GetAddress Solana Signer use case diff --git a/.changeset/config.json b/.changeset/config.json index c720bb119..d792eda2d 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -12,5 +12,11 @@ "access": "public", "baseBranch": "develop", "updateInternalDependencies": "patch", - "ignore": ["@ledgerhq/keyring-btc"] + "ignore": [ + "@ledgerhq/ledger-dmk-docs", + "@ledgerhq/device-management-kit-sample", + "@ledgerhq/device-signer-kit-btc", + "@ledgerhq/device-signer-kit-ethereum", + "@ledgerhq/context-module" + ] } diff --git a/.changeset/cool-dancers-coin.md b/.changeset/cool-dancers-coin.md new file mode 100644 index 000000000..3aa3fa81d --- /dev/null +++ b/.changeset/cool-dancers-coin.md @@ -0,0 +1,6 @@ +--- +"@ledgerhq/device-signer-kit-ethereum": patch +"@ledgerhq/context-module": patch +--- + +Update license to Apache-2.0 diff --git a/.changeset/cuddly-ducks-confessio0n.md b/.changeset/cuddly-ducks-confessio0n.md new file mode 100644 index 000000000..d618d83d8 --- /dev/null +++ b/.changeset/cuddly-ducks-confessio0n.md @@ -0,0 +1,6 @@ +--- +"@ledgerhq/device-signer-kit-ethereum": patch +"@ledgerhq/device-management-kit-sample": patch +--- + +Rename keyring to signer diff --git a/tmp_changeset/cuddly-impalas-sing.md b/.changeset/cuddly-impalas-sing.md similarity index 53% rename from tmp_changeset/cuddly-impalas-sing.md rename to .changeset/cuddly-impalas-sing.md index 9bbf2168c..26244e39b 100644 --- a/tmp_changeset/cuddly-impalas-sing.md +++ b/.changeset/cuddly-impalas-sing.md @@ -1,5 +1,5 @@ --- -"@ledgerhq/keyring-btc": minor +"@ledgerhq/device-signer-kit-btc": minor --- Implement GetExtendedPublicKeyCommand diff --git a/.changeset/eleven-spies-explainer.md b/.changeset/eleven-spies-explainer.md new file mode 100644 index 000000000..1ba22779b --- /dev/null +++ b/.changeset/eleven-spies-explainer.md @@ -0,0 +1,6 @@ +--- +"@ledgerhq/context-module": patch +"@ledgerhq/device-signer-kit-ethereum": patch +--- + +Use esbuild to build libraries diff --git a/.changeset/fuzzy-cups-refuse.md b/.changeset/fuzzy-cups-refuse.md new file mode 100644 index 000000000..a098a0a4f --- /dev/null +++ b/.changeset/fuzzy-cups-refuse.md @@ -0,0 +1,5 @@ +--- +"@ledgerhq/device-signer-kit-ethereum": patch +--- + +Adapt SignTransactionCommand and add StoreTransaction to prepare for the new version of Ethereum app (1.13) diff --git a/.changeset/great-paws-sell.md b/.changeset/great-paws-sell.md new file mode 100644 index 000000000..33f798c32 --- /dev/null +++ b/.changeset/great-paws-sell.md @@ -0,0 +1,5 @@ +--- +"@ledgerhq/device-signer-kit-ethereum": patch +--- + +Add support for EIP712 filters with missing token diff --git a/.changeset/hip-ducks-study.md b/.changeset/hip-ducks-study.md new file mode 100644 index 000000000..a0e629db4 --- /dev/null +++ b/.changeset/hip-ducks-study.md @@ -0,0 +1,5 @@ +--- +"@ledgerhq/device-signer-kit-ethereum": patch +--- + +Fix clear signing of EIP712 messages with an empty array diff --git a/.changeset/lemon-impalas-flash.md b/.changeset/lemon-impalas-flash.md new file mode 100644 index 000000000..01d169da6 --- /dev/null +++ b/.changeset/lemon-impalas-flash.md @@ -0,0 +1,5 @@ +--- +"@ledgerhq/device-signer-kit-ethereum": patch +--- + +Allow signing a message as a byte array diff --git a/.changeset/lemon-suits-noticer.md b/.changeset/lemon-suits-noticer.md new file mode 100644 index 000000000..f39ba06c0 --- /dev/null +++ b/.changeset/lemon-suits-noticer.md @@ -0,0 +1,6 @@ +--- +"@ledgerhq/device-signer-kit-ethereum": patch +"@ledgerhq/device-management-kit-sample": patch +--- + +Rename SDK to DMK diff --git a/.changeset/loud-balloons-poke.md b/.changeset/loud-balloons-poke.md new file mode 100644 index 000000000..2dd25db3e --- /dev/null +++ b/.changeset/loud-balloons-poke.md @@ -0,0 +1,5 @@ +--- +"@ledgerhq/device-signer-kit-ethereum": patch +--- + +Add support for V1 clear signing contexts diff --git a/tmp_changeset/lucky-keys-explode.md b/.changeset/lucky-keys-explode.md similarity index 53% rename from tmp_changeset/lucky-keys-explode.md rename to .changeset/lucky-keys-explode.md index fb482bd0c..92b949c54 100644 --- a/tmp_changeset/lucky-keys-explode.md +++ b/.changeset/lucky-keys-explode.md @@ -1,5 +1,5 @@ --- -"@ledgerhq/keyring-btc": minor +"@ledgerhq/device-signer-kit-btc": minor --- Implement GetMasterFingerprintCommand diff --git a/.changeset/metal-wombats-relate.md b/.changeset/metal-wombats-relate.md new file mode 100644 index 000000000..3b6d9423c --- /dev/null +++ b/.changeset/metal-wombats-relate.md @@ -0,0 +1,5 @@ +--- +"@ledgerhq/device-signer-kit-ethereum": patch +--- + +Reconstruct V full value for legacy transactions diff --git a/.changeset/mighty-vans-kiss.md b/.changeset/mighty-vans-kiss.md new file mode 100644 index 000000000..de801c9ec --- /dev/null +++ b/.changeset/mighty-vans-kiss.md @@ -0,0 +1,5 @@ +--- +"@ledgerhq/device-signer-kit-btc": patch +--- + +Implement wallet policy service diff --git a/.changeset/nasty-islands-pretend.md b/.changeset/nasty-islands-pretend.md new file mode 100644 index 000000000..c010f0090 --- /dev/null +++ b/.changeset/nasty-islands-pretend.md @@ -0,0 +1,5 @@ +--- +"@ledgerhq/device-management-kit-sample": minor +--- + +Add solana getAppConfiguration use case diff --git a/.changeset/nine-tools-bow.md b/.changeset/nine-tools-bow.md new file mode 100644 index 000000000..1ef12d18d --- /dev/null +++ b/.changeset/nine-tools-bow.md @@ -0,0 +1,5 @@ +--- +"@ledgerhq/device-management-kit-sample": patch +--- + +Implement basic Flipper client for the Ledger Device Management Kit diff --git a/.changeset/perfect-deers-sneeze.md b/.changeset/perfect-deers-sneeze.md new file mode 100644 index 000000000..7cb606bab --- /dev/null +++ b/.changeset/perfect-deers-sneeze.md @@ -0,0 +1,5 @@ +--- +"@ledgerhq/device-signer-kit-btc": patch +--- + +Rename packages diff --git a/.changeset/plenty-snakes-agree.md b/.changeset/plenty-snakes-agree.md new file mode 100644 index 000000000..1fafd2cf5 --- /dev/null +++ b/.changeset/plenty-snakes-agree.md @@ -0,0 +1,5 @@ +--- +"@ledgerhq/device-signer-kit-ethereum": patch +--- + +Add ProvideTransactionInformation command diff --git a/.changeset/slow-lies-float.md b/.changeset/slow-lies-float.md new file mode 100644 index 000000000..402e83483 --- /dev/null +++ b/.changeset/slow-lies-float.md @@ -0,0 +1,5 @@ +--- +"@ledgerhq/device-signer-kit-ethereum": patch +--- + +Add ProvideTransactionFieldDescription command diff --git a/.changeset/smart-games-brush.md b/.changeset/smart-games-brush.md new file mode 100644 index 000000000..d3461c64d --- /dev/null +++ b/.changeset/smart-games-brush.md @@ -0,0 +1,5 @@ +--- +"@ledgerhq/device-management-kit-sample": patch +--- + +Add mockserver integration with transport diff --git a/.changeset/spicy-toes-ring.md b/.changeset/spicy-toes-ring.md new file mode 100644 index 000000000..ae5535879 --- /dev/null +++ b/.changeset/spicy-toes-ring.md @@ -0,0 +1,5 @@ +--- +"@ledgerhq/device-management-kit-sample": patch +--- + +Add signTransaction usecase for Solana signer diff --git a/.changeset/strange-mayflies-repair.md b/.changeset/strange-mayflies-repair.md new file mode 100644 index 000000000..535652c80 --- /dev/null +++ b/.changeset/strange-mayflies-repair.md @@ -0,0 +1,5 @@ +--- +"@ledgerhq/context-module": patch +--- + +Fix CAL test signatures for EIP712 diff --git a/.changeset/sweet-stingrays-give.md b/.changeset/sweet-stingrays-give.md new file mode 100644 index 000000000..08ef915a2 --- /dev/null +++ b/.changeset/sweet-stingrays-give.md @@ -0,0 +1,5 @@ +--- +"@ledgerhq/device-signer-kit-ethereum": patch +--- + +Prevent chunking legacy transactions just before the EIP-155 marker diff --git a/.changeset/tall-hairs-cheer.md b/.changeset/tall-hairs-cheer.md new file mode 100644 index 000000000..d9f6dbaf7 --- /dev/null +++ b/.changeset/tall-hairs-cheer.md @@ -0,0 +1,5 @@ +--- +"@ledgerhq/device-signer-kit-btc": minor +--- + +Create device-signer-kit-btc package diff --git a/.changeset/tasty-falcons-doubts.md b/.changeset/tasty-falcons-doubts.md new file mode 100644 index 000000000..b27d56d5a --- /dev/null +++ b/.changeset/tasty-falcons-doubts.md @@ -0,0 +1,7 @@ +--- +"@ledgerhq/context-module": patch +"@ledgerhq/device-signer-kit-ethereum": patch +"@ledgerhq/device-management-kit-sample": patch +--- + +Use type keyword when importing type diff --git a/.changeset/ten-carrots-wait.md b/.changeset/ten-carrots-wait.md new file mode 100644 index 000000000..70ae7dd59 --- /dev/null +++ b/.changeset/ten-carrots-wait.md @@ -0,0 +1,5 @@ +--- +"@ledgerhq/device-signer-kit-ethereum": patch +--- + +Remove legacy parameter for internal sign transacion command diff --git a/.changeset/tiny-hornets-grin.md b/.changeset/tiny-hornets-grin.md new file mode 100644 index 000000000..28450ba89 --- /dev/null +++ b/.changeset/tiny-hornets-grin.md @@ -0,0 +1,5 @@ +--- +"@ledgerhq/device-management-kit-sample": patch +--- + +Add unlock timeout input in open app device action diff --git a/.changeset/tiny-otters-draw.md b/.changeset/tiny-otters-draw.md new file mode 100644 index 000000000..06299e3f7 --- /dev/null +++ b/.changeset/tiny-otters-draw.md @@ -0,0 +1,5 @@ +--- +"@ledgerhq/device-management-kit-sample": minor +--- + +Add keyring eth provider diff --git a/.changeset/twelve-snakes-agreeing.md b/.changeset/twelve-snakes-agreeing.md new file mode 100644 index 000000000..0ac9b33eb --- /dev/null +++ b/.changeset/twelve-snakes-agreeing.md @@ -0,0 +1,5 @@ +--- +"@ledgerhq/device-management-kit-sample": patch +--- + +New use case listenToKnownDevices diff --git a/.changeset/twelve-stingrays-shake.md b/.changeset/twelve-stingrays-shake.md new file mode 100644 index 000000000..403ba5994 --- /dev/null +++ b/.changeset/twelve-stingrays-shake.md @@ -0,0 +1,5 @@ +--- +"@ledgerhq/device-signer-kit-ethereum": patch +--- + +Early return when EIP712 Domain fails to be sent diff --git a/tmp_changeset/weak-ads-chew.md b/.changeset/weak-ads-chew.md similarity index 55% rename from tmp_changeset/weak-ads-chew.md rename to .changeset/weak-ads-chew.md index d41b3070f..0a4e2d756 100644 --- a/tmp_changeset/weak-ads-chew.md +++ b/.changeset/weak-ads-chew.md @@ -1,5 +1,5 @@ --- -"@ledgerhq/keyring-btc": patch +"@ledgerhq/device-signer-kit-btc": patch --- Implement PSBT parser and mapper services diff --git a/.changeset/witty-boats-lay.md b/.changeset/witty-boats-lay.md new file mode 100644 index 000000000..7d0bb6138 --- /dev/null +++ b/.changeset/witty-boats-lay.md @@ -0,0 +1,5 @@ +--- +"@ledgerhq/device-signer-kit-ethereum": patch +--- + +Add ProvideEnum command diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 7b51cc8f3..35977fda7 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -52,22 +52,5 @@ updates: typescript: patterns: - "typescript*" - - "@types*" exclude-patterns: - - "typescript-eslint" - - # Sample App dependencies for pnpm - - package-ecosystem: "npm" - directory: "/apps/sample" - schedule: - interval: "weekly" - day: "sunday" - timezone: "Europe/Paris" - labels: - - "dependencies" - reviewers: - - "LedgerHQ/live-devices" - commit-message: - prefix: "âŦ†ī¸ (sample) [NO-ISSUE]: " - ignore: - - dependency-name: "typescript" \ No newline at end of file + - "typescript-eslint" \ No newline at end of file diff --git a/.github/workflows/generate_sbom.yml b/.github/workflows/generate_sbom.yml index cfd48aab1..78cd8a011 100644 --- a/.github/workflows/generate_sbom.yml +++ b/.github/workflows/generate_sbom.yml @@ -15,4 +15,4 @@ jobs: - uses: LedgerHQ/device-sdk-ts/.github/actions/setup-toolchain-composite@develop - - uses: ./.github/actions/generate-sbom-composite + - uses: LedgerHQ/device-sdk-ts/.github/actions/generate-sbom-composite@develop diff --git a/.github/workflows/merge_queue.yml b/.github/workflows/merge_queue.yml new file mode 100644 index 000000000..35f13c7ca --- /dev/null +++ b/.github/workflows/merge_queue.yml @@ -0,0 +1,28 @@ +name: Merge Queue Checks +on: + merge_group: + +env: + FORCE_COLOR: "1" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref_name != 'develop' && github.ref || github.run_id }} + cancel-in-progress: true + +jobs: + checks: + name: Run health check and unit tests + runs-on: ledgerhq-device-sdk + steps: + - uses: actions/checkout@v4 + + - uses: LedgerHQ/device-sdk-ts/.github/actions/setup-toolchain-composite@develop + + - name: Health check + id: health-check + run: pnpm health-check + + - name: Tests + id: unit-tests + if: ${{ steps.health-check.conclusion == 'success' }} + run: pnpm test -- -- --maxWorkers=50% diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index a6d6fd476..04293c013 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -2,6 +2,8 @@ name: Pull Request Checks on: pull_request: types: [opened, synchronize, reopened, edited] + branches-ignore: + - main env: FORCE_COLOR: "1" @@ -49,10 +51,10 @@ jobs: - name: Tests id: unit-tests if: ${{ steps.health-check.conclusion == 'success' }} - run: pnpm test:coverage -- --max-warnings=0 + run: pnpm test:coverage -- --maxWorkers=50% - - uses: sonarsource/sonarqube-scan-action@v3 - if: ${{ steps.unit-tests.conclusion == 'success' && github.actor != 'dependabot[bot]' && github.event.pull_request.head.repo.fork == 'false' }} + - uses: sonarsource/sonarqube-scan-action@v4 + if: ${{ steps.unit-tests.conclusion == 'success' && github.actor != 'dependabot[bot]' && !github.event.pull_request.head.repo.fork }} env: - SONAR_TOKEN: ${{ secrets.GREEN_SONAR_TOKEN }} - SONAR_HOST_URL: ${{ secrets.GREEN_SONAR_HOST_URL }} + SONAR_TOKEN: ${{ secrets.PUBLIC_GREEN_SONAR_TOKEN }} + SONAR_HOST_URL: ${{ secrets.PUBLIC_SONAR_HOST_URL }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5b00b6999..674622fc1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -46,9 +46,8 @@ jobs: - name: Publish id: changesets - uses: changesets/action@v1 + uses: ledgerhq/changeset-action-ledger@main with: - version: echo "not running version" publish: pnpm release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/snapshot_release.yml b/.github/workflows/snapshot_release.yml index 67c89ac30..269386700 100644 --- a/.github/workflows/snapshot_release.yml +++ b/.github/workflows/snapshot_release.yml @@ -30,9 +30,6 @@ jobs: - uses: LedgerHQ/device-sdk-ts/.github/actions/setup-toolchain-composite@develop - - name: install dependencies - run: pnpm install - - name: build libraries run: pnpm build diff --git a/.github/workflows/update_toolchain.yml b/.github/workflows/update_toolchain.yml new file mode 100644 index 000000000..8ae5d842f --- /dev/null +++ b/.github/workflows/update_toolchain.yml @@ -0,0 +1,70 @@ +name: Update toolchain + +on: + schedule: + - cron: "0 0 * * 0" + workflow_dispatch: + +env: + BRANCH_NAME: chore/update-toolchain + +jobs: + update-toolchain: + runs-on: ["ledgerhq-device-sdk"] + steps: + - uses: actions/checkout@v4 + + - uses: LedgerHQ/device-sdk-ts/.github/actions/setup-toolchain-composite@develop + + - name: Setup git and branch + run: | + git config user.name 'github-actions[bot]' + git config user.email 'github-actions[bot]@users.noreply.github.com' + git checkout -b ${{ env.BRANCH_NAME }} + + - name: Update toolchain + run: | + proto outdated --update + + - name: Check for changes + id: changes + run: | + echo "status=$(git status --porcelain | wc -l)" >> $GITHUB_OUTPUT + + - name: Set new versions + if: steps.changes.outputs.status > 0 + id: new-versions + run: | + proto use + pnpm i + echo "version=$(pnpm -v)" >> $GITHUB_OUTPUT + + - name: Update package.json + if: steps.changes.outputs.status > 0 + run: | + jq '.packageManager = "pnpm@${{ steps.new-versions.outputs.version }}"' package.json > tmp.json && mv tmp.json package.json + + - name: Health check + if: steps.changes.outputs.status > 0 + id: health-check + run: pnpm health-check + + - name: Tests + id: unit-tests + if: steps.changes.outputs.status > 0 && success() + run: pnpm test:coverage -- --maxWorkers=50% + + - name: Create PR + if: steps.changes.outputs.status > 0 && success() + run: | + git add .prototools + git add package.json + git commit -m "🔧 (repo) [NO-ISSUE]: Update toolchain" + git push origin ${{ env.BRANCH_NAME }} + gh pr create \ + --title "🔧 (repo) [NO-ISSUE]: Update toolchain" \ + --body "This PR updates the toolchain (node, npm, pnpm) to the newest versions" \ + --base develop \ + --head ${{ env.BRANCH_NAME }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/version.yml b/.github/workflows/version.yml index e44813323..0ca1a6a35 100644 --- a/.github/workflows/version.yml +++ b/.github/workflows/version.yml @@ -15,17 +15,14 @@ jobs: - uses: LedgerHQ/device-sdk-ts/.github/actions/setup-toolchain-composite@develop - - name: install dependencies - run: pnpm install - - name: build libraries run: pnpm build:libs - - name: create release pull request or publish + - name: create release pull request uses: changesets/action@v1 with: version: pnpm bump - commit: "🔖 (release): versioning packages" - title: "🔖 (release) [NO-ISSUE]: versioning packages" + commit: "🔖 (release): Versioning packages" + title: "🔖 (release) [NO-ISSUE]: Versioning packages" env: GITHUB_TOKEN: ${{ github.token }} diff --git a/.prototools b/.prototools index 8961f47a9..9cb538e05 100644 --- a/.prototools +++ b/.prototools @@ -1,3 +1,3 @@ -node = "20.17.0" -npm = "10.8.2" -pnpm = "9.10.0" +node = "20.18.0" +npm = "10.9.0" +pnpm = "9.13.2" diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 000000000..7b8623d8e --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1,9 @@ +# How does it work? => https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners +# This is a comment. +# Each line is a file pattern followed by one or more owners. +# Order is important; the last matching pattern takes the most +# precedence. When someone opens a pull request that only +# modifies JS files, only @js-owner and not the global +# owner(s) will be requested for a review. + +* @ledgerhq/live-devices diff --git a/README.md b/README.md index b90d78b08..152bd5c12 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,6 @@

- TypeScript @@ -29,6 +26,10 @@ NPM +
+ + Pull request Tests Passing +

@@ -57,11 +58,11 @@ The purpose of the Ledger Device Management Kit(LDMK in short) is to provide a l ## How does it works -The Device Management Kit features an interface for applications to handle any Ledge device (a.k. hardware wallets). It convert intention into +The Device Management Kit features an interface for applications to handle any Ledger device (a.k. hardware wallets). It convert intention into ```mermaid flowchart LR; - application(Application) <--API--> DSDK(DeviceSDK) <--USB/BLE--> device(Device); + application(Application) <--API--> LDMK(LedgerDeviceManagementKit) <--USB/BLE--> device(Device); ``` The Device Management Kit is available in 3 different environments (web, Android & iOS). @@ -72,7 +73,7 @@ This repository is dedicated to **web environment** and is written in TypeScript ### Repository -The Device Management Kit is structured as a monorepository whose prupose is to centralise all the TypeScript code related to the SDK in one place. +The Device Management Kit is structured as a monorepository whose prupose is to centralise all the TypeScript code related to the Device Management Kit in one place. This project uses [turbo monorepo](https://turbo.build/repo/docs) to build and release different packages on NPM registry and a sample demo application on Vercel. @@ -80,16 +81,16 @@ This project uses [turbo monorepo](https://turbo.build/repo/docs) to build and r A brief description of this project packages: -| Name | Path | Description | -| --------------------------------- | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | -| @ledgerhq/device-sdk-sample | apps/sample | React Next web app used to test & demonstrate the Web Device Management Kit | -| @ledgerhq/eslint-config-dsdk | packages/config/eslint | internal package which contains eslint shared config. Used by `extends: ["@ledgerhq/dsdk"]` in `.eslintrc`. | -| @ledgerhq/jest-config-dsdk | packages/config/jest | internal package which contains jest shared config. Used by `preset: "@ledgerhq/jest-config-dsdk"` in `jest.config.ts` | -| @ledgerhq/tsconfig-dsdk | packages/config/typescript | internal package which contains typescript shared config. Used by `"extends": "@ledgerhq/tsconfig-dsdk/sdk"` in `tsconfig.json` | -| @ledgerhq/device-management-kit | packages/core | external package that contains the core of the Web SDK | -| @ledgerhq/device-sdk-signer | packages/signer | external package that contains device coin application dedicated handlers | -| @ledgerhq/device-sdk-trusted-apps | packages/trusted-apps | external package that contains device trusted application dedicated handlers | -| @ledgerhq/device-sdk-ui | packages/ui | external package | +| Name | Path | Description | +|----------------------------------------|--------------------------------|------------------------------------------------------------------------------------------------------------------------------------------| +| @ledgerhq/device-management-kit-sample | apps/sample | React Next web app used to test & demonstrate the Web Device Management Kit | +| @ledgerhq/eslint-config-dsdk | packages/config/eslint | internal package which contains eslint shared config. Used by `extends: ["@ledgerhq/dsdk"]` in `.eslintrc`. | +| @ledgerhq/jest-config-dsdk | packages/config/jest | internal package which contains jest shared config. Used by `preset: "@ledgerhq/jest-config-dsdk"` in `jest.config.ts` | +| @ledgerhq/tsconfig-dsdk | packages/config/typescript | internal package which contains typescript shared config. Used by `"extends": "@ledgerhq/tsconfig-dsdk/tsconfig.sdk"` in `tsconfig.json` | +| @ledgerhq/device-management-kit | packages/device-management-kit | external package that contains the core of the Web Device Management Kit | +| @ledgerhq/device-signer-kit-ethereum | packages/signer/signer-eth | external package that contains device ethereum coin application dedicated handlers | +| @ledgerhq/device-signer-kit-solana | packages/signer/signer-solana | external package that contains device solana coin application dedicated handlers | +| @ledgerhq/device-management-kit-flipper-plugin-client | packages/flipper-plugin-client | external package that contains [flipper](https://github.com/facebook/flipper) logger for Device Management Kit | # Getting started @@ -182,7 +183,7 @@ Each package is built using the following command (at the root of the monorepo). Device Management Kit main module. ```bash -pnpm core build +pnpm dmk build ``` ### Signer @@ -258,7 +259,7 @@ Finally, we should add a script in the correct `package.json` as a shortcut to t eg: ``` -pnpm core module:create +pnpm dmk module:create ``` Under the hood, the script looks like this: @@ -301,4 +302,4 @@ Each individual project may include its own specific guidelines, located within # License -Please check each project [`LICENSE`](https://github.com/LedgerHQ/device-sdk-ts/blob/develop/LICENSE.md) file, most of them are under the `MIT` license. +Please check each project [`LICENSE`](https://github.com/LedgerHQ/device-sdk-ts/blob/develop/LICENSE.md) file, most of them are under the `Apache-2.0` license. diff --git a/apps/docs/.gitignore b/apps/docs/.gitignore new file mode 100644 index 000000000..2077bd84c --- /dev/null +++ b/apps/docs/.gitignore @@ -0,0 +1,42 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js +.yarn/install-state.gz + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +# Sentry Config File +.sentryclirc + +# Playwright test results +test-results/ diff --git a/apps/docs/.prettierignore b/apps/docs/.prettierignore new file mode 100644 index 000000000..e69de29bb diff --git a/packages/core/.prettierrc.js b/apps/docs/.prettierrc.js similarity index 100% rename from packages/core/.prettierrc.js rename to apps/docs/.prettierrc.js diff --git a/apps/docs/eslint.config.mjs b/apps/docs/eslint.config.mjs new file mode 100644 index 000000000..6f7e467ff --- /dev/null +++ b/apps/docs/eslint.config.mjs @@ -0,0 +1,32 @@ +import baseConfig from "@ledgerhq/eslint-config-dsdk"; +import globals from "globals"; + +export default [ + ...baseConfig, + { + ignores: [".next"], + }, + { + languageOptions: { + parserOptions: { + project: "./tsconfig.eslint.json", + }, + }, + }, + { + files: [ + "next.config.js", + "postcss.config.js", + "tailwind.config.js", + "theme.config.tsx", + ], + languageOptions: { + globals: { + ...globals.node, + }, + }, + rules: { + "@typescript-eslint/no-var-requires": "off", + }, + }, +]; diff --git a/apps/docs/next.config.js b/apps/docs/next.config.js new file mode 100644 index 000000000..22f367e90 --- /dev/null +++ b/apps/docs/next.config.js @@ -0,0 +1,8 @@ +// eslint-disable-next-line @typescript-eslint/no-require-imports +const withNextra = require("nextra")({ + defaultShowCopyCode: true, + theme: "nextra-theme-docs", + themeConfig: "./theme.config.tsx", +}); + +module.exports = withNextra(); diff --git a/apps/docs/package.json b/apps/docs/package.json new file mode 100644 index 000000000..b1dc832a1 --- /dev/null +++ b/apps/docs/package.json @@ -0,0 +1,37 @@ +{ + "name": "@ledgerhq/ledger-dmk-docs", + "version": "1.0.0", + "description": "", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "eslint", + "lint:fix": "eslint --fix", + "prettier": "prettier . --check", + "prettier:fix": "prettier . --write", + "typecheck": "tsc --noEmit" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "autoprefixer": "^10.4.17", + "next": "^14.1.0", + "nextra": "^2.13.3", + "nextra-theme-docs": "^2.13.3", + "postcss": "^8.4.35", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "tailwindcss": "^3.4.1" + }, + "devDependencies": { + "@ledgerhq/eslint-config-dsdk": "workspace:*", + "@ledgerhq/prettier-config-dsdk": "workspace:*", + "@ledgerhq/tsconfig-dsdk": "workspace:*", + "@types/node": "^22.7.5", + "@types/react": "^18.3.11", + "globals": "15.11.0" + } +} diff --git a/apps/docs/pages/_app.mdx b/apps/docs/pages/_app.mdx new file mode 100644 index 000000000..d412129bd --- /dev/null +++ b/apps/docs/pages/_app.mdx @@ -0,0 +1,5 @@ +import "../style.css"; + +export default function App({ Component, pageProps }) { + return ; +} diff --git a/apps/docs/pages/_meta.json b/apps/docs/pages/_meta.json new file mode 100644 index 000000000..eaa43e4fd --- /dev/null +++ b/apps/docs/pages/_meta.json @@ -0,0 +1,17 @@ +{ + "index": { + "title": "Home", + "theme": { + "layout": "raw" + }, + "display": "hidden" + }, + "docs": { + "title": "Documentation", + "type": "page" + }, + "references": { + "title": "References", + "type": "page" + } +} diff --git a/apps/docs/pages/docs/_meta.json b/apps/docs/pages/docs/_meta.json new file mode 100644 index 000000000..db267d3ea --- /dev/null +++ b/apps/docs/pages/docs/_meta.json @@ -0,0 +1,8 @@ +{ + "docs": "Ledger Device Management Kits", + "explanations": "Explanations", + "begginers": "Begginer's guide", + "integration_walkthroughs": "Integration Walkthrough", + "migrations": "Migrations", + "references": "References (TSDoc)" +} diff --git a/apps/docs/pages/docs/begginers.mdx b/apps/docs/pages/docs/begginers.mdx new file mode 100644 index 000000000..e69de29bb diff --git a/apps/docs/pages/docs/begginers/_meta.json b/apps/docs/pages/docs/begginers/_meta.json new file mode 100644 index 000000000..0ca1e3447 --- /dev/null +++ b/apps/docs/pages/docs/begginers/_meta.json @@ -0,0 +1,6 @@ +{ + "setup": "Setup", + "init_dmk": "Initialize Device Management Kit", + "discover_and_connect": "Discover and connect", + "exchange_data": "Exchange data with the device" +} diff --git a/apps/docs/pages/docs/begginers/discover_and_connect.mdx b/apps/docs/pages/docs/begginers/discover_and_connect.mdx new file mode 100644 index 000000000..06036d4bb --- /dev/null +++ b/apps/docs/pages/docs/begginers/discover_and_connect.mdx @@ -0,0 +1,40 @@ +# Connecting to a Device + +There are two steps to connecting to a device: + +- **Discovery**: `sdk.startDiscovering()` + - Returns an observable which will emit a new `DiscoveredDevice` for every scanned device. + - The `DiscoveredDevice` objects contain information about the device model. + - Use one of these values to connect to a given discovered device. +- **Connection**: `sdk.connect({ deviceId: device.id })` + - Returns a Promise resolving in a device session identifier `DeviceSessionId`. + - **Keep this device session identifier to further interact with the device.** + - Then, `sdk.getConnectedDevice({ sessionId })` returns the `ConnectedDevice`, which contains information about the device model and its name. + +```ts +sdk.startDiscovering().subscribe({ + next: (device) => { + sdk.connect({ deviceId: device.id }).then((sessionId) => { + const connectedDevice = sdk.getConnectedDevice({ sessionId }); + }); + }, + error: (error) => { + console.error(error); + }, +}); +``` + +Then once a device is connected: + +- **Disconnection**: `sdk.disconnect({ sessionId })` +- **Observe the device session state**: `sdk.getDeviceSessionState({ sessionId })` + - This will return an `Observable` to listen to the known information about the device: + - device status: + - ready to process a command + - busy + - locked + - disconnected + - device name + - information on the OS + - battery status + - currently opened app diff --git a/apps/docs/pages/docs/begginers/exchange_data.mdx b/apps/docs/pages/docs/begginers/exchange_data.mdx new file mode 100644 index 000000000..6446f7b4f --- /dev/null +++ b/apps/docs/pages/docs/begginers/exchange_data.mdx @@ -0,0 +1,187 @@ +# Exchange data with the device + +## Sending an APDU + +Once you have a connected device, you can send it APDU commands. + +> ℹī¸ It is recommended to use the [pre-defined commands](#sending-a-pre-defined-command) when possible, or [build your own command](#building-a-new-command), to avoid dealing with the APDU directly. It will make your code more reusable. + +```ts +import { + ApduBuilder, + ApduParser, + CommandUtils, +} from "@ledgerhq/device-management-kit"; + +// ### 1. Building the APDU +// Use `ApduBuilder` to easily build the APDU and add data to its data field. + +// Build the APDU to open the Bitcoin app +const openAppApduArgs = { + cla: 0xe0, + ins: 0xd8, + p1: 0x00, + p2: 0x00, +}; +const apdu = new ApduBuilder(openAppApduArgs) + .addAsciiStringToData("Bitcoin") + .build(); + +// ### 2. Sending the APDU + +const apduResponse = await sdk.sendApdu({ sessionId, apdu }); + +// ### 3. Parsing the result + +const parser = new ApduParser(apduResponse); + +if (!CommandUtils.isSuccessResponse(apduResponse)) { + throw new Error( + `Unexpected status word: ${parser.encodeToHexaString( + apduResponse.statusCode, + )}`, + ); +} +``` + +## Sending a Pre-defined Command + +There are some pre-defined commands that you can send to a connected device. + +The `sendCommand` method will take care of building the APDU, sending it to the device and returning the parsed response. + +> ## ❗ī¸ Error Responses +> +> Most of the commands will reject with an error if the device is locked. +> Ensure that the device is unlocked before sending commands. You can check the device session state (`sdk.getDeviceSessionState`) to know if the device is locked. +> +> Most of the commands will reject with an error if the response status word is not `0x9000` (success response from the device). + +### Open App + +This command will open the app with the given name. If the device is unlocked, it will not resolve/reject until the user has confirmed or denied the app opening on the device. + +```ts +import { OpenAppCommand } from "@ledgerhq/device-management-kit"; + +const command = new OpenAppCommand("Bitcoin"); // Open the Bitcoin app + +await sdk.sendCommand({ sessionId, command }); +``` + +### Close App + +This command will close the currently opened app. + +```ts +import { CloseAppCommand } from "@ledgerhq/device-management-kit"; + +const command = new CloseAppCommand(); + +await sdk.sendCommand({ sessionId, command }); +``` + +### Get OS Version + +This command will return information about the currently installed OS on the device. + +> ℹī¸ If you want this information you can simply get it from the device session state by observing it with `sdk.getDeviceSessionState({ sessionId })`. + +```ts +import { GetOsVersionCommand } from "@ledgerhq/device-management-kit"; + +const command = new GetOsVersionCommand(); + +const { seVersion, mcuSephVersion, mcuBootloaderVersion } = + await sdk.sendCommand({ sessionId, command }); +``` + +### Get App and Version + +This command will return the name and version of the currently running app on the device. + +> ℹī¸ If you want this information you can simply get it from the device session state by observing it with `sdk.getDeviceSessionState({ sessionId })`. + +```ts +import { GetAppAndVersionCommand } from "@ledgerhq/device-management-kit"; + +const command = new GetAppAndVersionCommand(); + +const { name, version } = await sdk.sendCommand({ sessionId, command }); +``` + +## Sending a Pre-defined flow - Device Actions + +Device actions define a succession of commands to be sent to the device. + +They are useful for actions that require user interaction, like opening an app, +or approving a transaction. + +The result of a device action execution is an observable that will emit different states of the action execution. These states contain information about the current status of the action, some intermediate values like the user action required, and the final result. + +### Open App Device Action + +```ts +import { + OpenAppDeviceAction, + OpenAppDAState, +} from "@ledgerhq/device-management-kit"; + +const openAppDeviceAction = new OpenAppDeviceAction({ appName: "Bitcoin" }); + +const { observable, cancel } = await dmk.executeDeviceAction({ + sessionId, + openAppDeviceAction, +}); + +observable.subscribe({ + next: (state: OpenAppDAState) => { + switch (state.status) { + case DeviceActionStatus.NotStarted: + console.log("Action not started yet"); + break; + case DeviceActionStatus.Pending: + const { + intermediateValue: { userActionRequired }, + } = state; + switch (userActionRequired) { + case UserActionRequiredType.None: + console.log("No user action required"); + break; + case UserActionRequiredType.ConfirmOpenApp: + console.log( + "The user should confirm the app opening on the device", + ); + break; + case UserActionRequiredType.UnlockDevice: + console.log("The user should unlock the device"); + break; + default: + /** + * you should make sure that you handle all the possible user action + * required types by displaying them to the user. + */ + throw new Exception("Unhandled user action required"); + break; + } + console.log("Action is pending"); + break; + case DeviceActionStatus.Stopped: + console.log("Action has been stopped"); + break; + case DeviceActionStatus.Completed: + const { output } = state; + console.log("Action has been completed", output); + break; + case DeviceActionStatus.Error: + const { error } = state; + console.log("An error occurred during the action", error); + break; + } + }, +}); +``` + +### Example in React + +Check [the sample app](https://github.com/LedgerHQ/device-sdk-ts/tree/develop/apps/sample) for an advanced example showcasing all possible usages of the Device Management Kit in a React app. diff --git a/apps/docs/pages/docs/begginers/init_dmk.mdx b/apps/docs/pages/docs/begginers/init_dmk.mdx new file mode 100644 index 000000000..c2e6453b6 --- /dev/null +++ b/apps/docs/pages/docs/begginers/init_dmk.mdx @@ -0,0 +1,23 @@ +# Setting up the SDK + +The core package exposes an SDK builder `DeviceSdkBuilder` which will be used to initialise the SDK with your configuration. + +For now it allows you to add one or more custom loggers. + +In the following example, we add a console logger (`.addLogger(new ConsoleLogger())`). Then we build the SDK with `.build()`. + +**The returned object will be the entrypoint for all your interactions with the SDK. You should keep it as a SINGLETON.** + +The SDK should be built only once in your application runtime so keep a reference of this object somewhere. + +```ts +import { + ConsoleLogger, + DeviceSdk, + DeviceSdkBuilder, +} from "@ledgerhq/device-management-kit"; + +export const sdk = new DeviceSdkBuilder() + .addLogger(new ConsoleLogger()) + .build(); +``` diff --git a/apps/docs/pages/docs/begginers/setup.mdx b/apps/docs/pages/docs/begginers/setup.mdx new file mode 100644 index 000000000..292c2a6b5 --- /dev/null +++ b/apps/docs/pages/docs/begginers/setup.mdx @@ -0,0 +1,23 @@ +# Setup + +## Description + +This package contains the core of the Device Management Kit. It provides a simple interface to handle Ledger devices and features the Device Management Kit's entry points, classes, types, structures, and models. + +## Installation + +To install the dmk package, run the following command: + +```sh +npm install @ledgerhq/device-management-kit +``` + +## Usage + +### Compatibility + +This library works in [any browser supporting the WebHID API](https://developer.mozilla.org/en-US/docs/Web/API/WebHID_API#browser_compatibility). + +### Pre-requisites + +Some of the APIs exposed return objects of type `Observable` from RxJS. Ensure you are familiar with the basics of the Observer pattern and RxJS before using this SDK. You can refer to [RxJS documentation](https://rxjs.dev/guide/overview) for more information. diff --git a/apps/docs/pages/docs/docs.mdx b/apps/docs/pages/docs/docs.mdx new file mode 100644 index 000000000..04abedc30 --- /dev/null +++ b/apps/docs/pages/docs/docs.mdx @@ -0,0 +1,34 @@ +import { Callout } from "nextra/components"; + +# Documentation + +Here you will find the all the documention related to the device management kit and all the signer coming along with it. + + + This project is still in early development so we allow ourselves to make + breaking changes regarding the usage of the Libraries. + +That's why any feedback is relevant for us in order to be able to make it stable as soon as +possible. Get in touch with us on the [Ledger Discord +server](https://developers.ledger.com/discord/) to provide your feedbacks. + +You can follow the migration guidelines [here](./migrations/) + + + +## Glossary + +Through all the documentation we will use some acronyme that you can find the following description : + +- DMK: Device Management Kit +- DSK: Device Signer Kit + +## Libraries + +Here you can found a summary of all the libraries that are composing the DMK + +| Library | NPM | Version | +| ---------------------- | ---------------------------------------------------------------------------------------------------------- | ------- | +| Device Management Kit | [@LedgerHQ/device-mangement-kit](https://www.npmjs.com/package/@ledgerhq/device-management-kit) | 0.4.0 | +| Device Signer Ethereum | [@LedgerHQ/device-signer-kit-ethereum](https://www.npmjs.com/package/@ledgerhq/device-signer-kit-ethereum) | 1.0.0 | +| Context Module | [@LedgerHQ/context-module](https://www.npmjs.com/package/@ledgerhq/context-module) | 1.0.0 | diff --git a/apps/docs/pages/docs/explanations.mdx b/apps/docs/pages/docs/explanations.mdx new file mode 100644 index 000000000..e69de29bb diff --git a/apps/docs/pages/docs/explanations/_meta.json b/apps/docs/pages/docs/explanations/_meta.json new file mode 100644 index 000000000..047f56a7e --- /dev/null +++ b/apps/docs/pages/docs/explanations/_meta.json @@ -0,0 +1,6 @@ +{ + "introduction": "Why the Device Management Kit?", + "ledgerjs": "Differences with LedgerJS", + "dmk": "Device Management Kit", + "signers": "Signer kits" +} diff --git a/apps/docs/pages/docs/explanations/dmk.mdx b/apps/docs/pages/docs/explanations/dmk.mdx new file mode 100644 index 000000000..620db4379 --- /dev/null +++ b/apps/docs/pages/docs/explanations/dmk.mdx @@ -0,0 +1,91 @@ +import { Callout } from "nextra/components"; + +# Device Management Kit + +The device management kit is the entry point for all the other libraries related to. +As we wanted to make the project modular. + +## Main Features + +- Discovering and connecting to Ledger devices via USB, through [WebHID](https://developer.mozilla.org/en-US/docs/Web/API/WebHID_API). +- Discovering and connecting to Ledger devices via BLE, through [WebBLE](https://developer.mozilla.org/en-US/docs/Web/API/Web_Bluetooth_API). +- Observing the state of a connected device. +- Sending custom APDU commands to Ledger devices. +- Sending a set of pre-defined commands to Ledger devices. + - Get OS version + - Get app and version + - Open app + - Close app + - Get battery status +- Execute a flow of commands with **DeviceAction**. + +> [!NOTE] +> At the moment we do not provide the possibility to distinguish two devices of the same model, via WebHID and to avoid connection to the same device twice. + +## Communicate with the device + +DMK is offering several ways to communicate with the device. + +### Send APDU + + + This method is not recommended for most of the use cases. We recommend using + the _Command_ or _DeviceAction_ instead. + + +You can send APDU commands to the device using the `sendApdu` method of the `dmk` instance. +Parameters: + +- `sessionId`: string - The session ID, which an identifier of the connection with a device. +- `apdu`: UInt8Array - Byte array of data to be send to the device. + +```typescript +await dmk.sendApdu({ sessionId, apdu }); +``` + +### Commands + +Commands are pre-defined actions that you can send to the device. +You can use the `sendCommand` method of the `dmk` instance to send a command to the device. +Parameters: + +- `sessionId`: string - The session ID, which an identifier of the connection with a device. +- `command`: Command - The command to be sent to the device. + +```typescript +import { OpenAppCommand } from "@ledgerhq/device-management-kit"; + +const command = new OpenAppCommand("Bitcoin"); // Open the Bitcoin app +await dmk.sendCommand({ sessionId, command }); +``` + +### Device Actions + +Device actions are a set of commands that are executed in a sequence. +You can use the `executeDeviceAction` method of the `dmk` instance to execute a device action. + +It is returning an observable that will emit different states of the action execution. +A device action is cancellable, you can cancel it by calling the `cancel` function returned by the `executeDeviceAction` method. + +Parameters: + +- `sessionId`: string - The session ID, which an identifier of the connection with a device. +- `deviceAction`: DeviceAction - The DeviceAction to be sent to the device. + +```typescript +const openAppDeviceAction = new OpenAppDeviceAction({ appName: "Bitcoin" }); + +const { observable, cancel } = await dmk.executeDeviceAction({ + sessionId, + openAppDeviceAction, +}); +``` + +## State Management + +For each connected device, we are managing and providing a device state. +The states are: + +- `connected`: The device is connected. +- `locked`: The device is locked. +- `busy`: The device is busy, so not reachable. diff --git a/apps/docs/pages/docs/explanations/introduction.mdx b/apps/docs/pages/docs/explanations/introduction.mdx new file mode 100644 index 000000000..2f5b75904 --- /dev/null +++ b/apps/docs/pages/docs/explanations/introduction.mdx @@ -0,0 +1,9 @@ +# Why Device Management Kit ? + +The DMK is a set of tools and libraries that allow you to manage your devices in a secure and efficient way. It is designed to be used in conjunction with the Device Signer Kit, which is a set of tools and libraries that allow you to sign transactions securely. + +It has been designed for external developers with the main idea to provide the best experience for developers to interact with Ledger devices. + +It should as maximum as possible abstract the complexity of the communication with the device and provide a simple and easy-to-use API. + +It tends to be a replacement for the LedgerJS libraries, mainly `hw-app-XXX` and `transport-XXX` libraries. These libraries are intended to be deprecated in the future. diff --git a/apps/docs/pages/docs/explanations/ledgerjs.mdx b/apps/docs/pages/docs/explanations/ledgerjs.mdx new file mode 100644 index 000000000..aa0f242a1 --- /dev/null +++ b/apps/docs/pages/docs/explanations/ledgerjs.mdx @@ -0,0 +1,32 @@ +# Differences with LedgerJS + +Device management kit aim to replace LedgerJS libraries, mainly `hw-app-XXX` and `transport-XXX` libraries + +## Current Problems + +Ledger JS libraries where initially made for **Ledger Live** applications. As Ledger Live is a pretty old project (> 7 years), +we have inevitably a big technical debt. Moreover time make that some part of the logic are today hard to understand and to maintain. + +Moreover, some device behavior are not well handled by the libraries. For example, opening an application on the device will cause unexpected disconnection. +Another feedback we have learnt from partners (software wallets) is that we have a lack of simplicity in the libraries, it require low level knowledge to use them (ex: APDU concept). + +## Target + +LedgerJS was intended for Ledger Live. It was not designed to be used by third party developers. +With DMK we are targeting **third party developers first**. + +## Abstract complexity + +As said above, we wanted to reduce the entry level to interact with Ledger devices. +So we have tried as much as possible to abstract the complexity of the communication with the device and provide a simple and easy-to-use API. + +## Multiple Connected devices + +With LedgerJS, it was impossible to be connected at the same time to two devices. +With DMK, we have made it possible to connect to multiple devices at the same time and interact from one to another. + +## When prefer LedgerJS instead of DMK ? + +As we are still missing some features in DMK, you may still need to use LedgerJS in some cases. +For example, we are missing signer for some blockchains, so you may still need to use LedgerJS to sign transactions. +Some solutions are studied at the moment to provide a better compatibility between DMK and LedgerJS. diff --git a/apps/docs/pages/docs/explanations/signers.mdx b/apps/docs/pages/docs/explanations/signers.mdx new file mode 100644 index 000000000..f9414ce84 --- /dev/null +++ b/apps/docs/pages/docs/explanations/signers.mdx @@ -0,0 +1,13 @@ +# Signer Kits + +As ledger device are able to install application that will allow to be compatible with different blockchain, +we have created these kits. + +Each **signer kit** is coming along with a Ledger Embedded App (ex: _signer-kit-eth_ is comig with _ledger app ethereum_ ). + +The main goal of each signer is to ease interaction with the app in the seamlessly possible way. + +## Available Signers + +- [Signer Ethereum](./signers/eth) +- [Signer Solana](./signers/solana) diff --git a/apps/docs/pages/docs/explanations/signers/_meta.json b/apps/docs/pages/docs/explanations/signers/_meta.json new file mode 100644 index 000000000..a47efbd21 --- /dev/null +++ b/apps/docs/pages/docs/explanations/signers/_meta.json @@ -0,0 +1,5 @@ +{ + "eth": "Signer Ethereum (EVM)", + "solana": "Signer Solana", + "btc": "Signer Bitcoin" +} diff --git a/apps/docs/pages/docs/explanations/signers/btc.mdx b/apps/docs/pages/docs/explanations/signers/btc.mdx new file mode 100644 index 000000000..24530b979 --- /dev/null +++ b/apps/docs/pages/docs/explanations/signers/btc.mdx @@ -0,0 +1,3 @@ +# Bitcoin Signer Kit + +_Coming Soon..._ diff --git a/apps/docs/pages/docs/explanations/signers/eth.mdx b/apps/docs/pages/docs/explanations/signers/eth.mdx new file mode 100644 index 000000000..942d5b121 --- /dev/null +++ b/apps/docs/pages/docs/explanations/signers/eth.mdx @@ -0,0 +1,538 @@ +# Ethereum Signer Kit + +## Features + +Following doc is matching for v1.0.0 of the Ethereum Signer Kit. + +### 1: Get Address + +This method allows users to retrieve the Ethereum address according to given `derivationPath`. + +```typescript +const { observable, cancel } = signerEth.getAddress(derivationPath, options); +``` + +**Parameters** + +- `derivationPath` + + - **Required** + - **Type:** `string` (e.g., `"44'/60'/0'/0/0"`) + - The derivation path used for the Ethereum address. See [here](https://www.ledger.com/blog/understanding-crypto-addresses-and-derivation-paths) for more information. + +- `options` + + - Optional + - Type: `AddressOptions` + + ```typescript + type AddressOptions = { + checkOnDevice?: boolean; + returnChainCode?: boolean; + }; + ``` + + - `checkOnDevice`: An optional boolean indicating whether user confirmation on the device is required (`true`) or not (`false`). + - `returnChainCode`: An optional boolean indicating whether the chain code should be returned (`true`) or not (`false`). + +**Returns** + +- `observable` + + - An [Observable](https://rxjs.dev/guide/observable) object that contains the [`DeviceActionState`](https://github.com/LedgerHQ/device-sdk-ts/blob/develop/packages/device-management-kit/src/api/device-action/model/DeviceActionState.ts) derived instance, which reprensents the operation's state. For example: + + ```typescript + observable.subscribe({ + next: (state: DeviceActionState) => { + switch (state.status) { + case DeviceActionStatus.NotStarted: { + console.log("The action is not started yet."); + break; + } + case DeviceActionStatus.Pending: { + const { + intermediateValue: { requiredUserInteraction }, + } = state; + // Access the intermediate value here, explained below + console.log( + "The action is pending and the intermediate value is: ", + intermediateValue, + ); + break; + } + case DeviceActionStatus.Stopped: { + console.log("The action has been stopped."); + break; + } + case DeviceActionStatus.Completed: { + const { output } = state; + // Access the output of the completed action here + console.log("The action has been completed: ", output); + break; + } + case DeviceActionStatus.Error: { + const { error } = state; + // Access the error here if occured + console.log("An error occured during the action: ", error); + break; + } + } + }, + }); + ``` + + - When the action status is `DeviceActionStatus.Pending`, the state will include an `intermediateValue` object that provides useful information for interaction: + + ```typescript + const { requiredUserInteraction } = intermediateValue; + + switch (requiredUserInteraction) { + case UserInteractionRequired.VerifyAddress: { + // User needs to verify the address displayed on the device + console.log( + "User needs to verify the address displayed on the device.", + ); + break; + } + case UserInteractionRequired.None: { + // No user action required + console.log("No user action needed."); + break; + } + case UserInteractionRequired.UnlockDevice: { + // User needs to unlock the device + console.log("The user needs to unlock the device."); + break; + } + case UserInteractionRequired.ConfirmOpenApp: { + // User needs to confirm on the device to open the app + console.log("The user needs to confirm on the device to open the app."); + break; + } + default: + // Type guard to ensure all cases are handled + const uncaughtUserInteraction: never = requiredUserInteraction; + console.error( + "Unhandled user interaction case:", + uncaughtUserInteraction, + ); + } + ``` + + - When the action status is `DeviceActionStatus.Completed`, the execution result can be accessed through the `output` property in the state. The `output` property is of type `GetAddressCommandResponse`, which has the following structure: + + ```typescript + type GetAddressCommandResponse = { + publicKey: string; + address: `0x${string}`; + chainCode?: string; + }; + ``` + +- `cancel` + - The function without a return value to cancel the action on the Ledger device. + +### 2: Sign Transaction + +This method enables users to securely sign transactions using clear signing on Ledger devices. + +```typescript +const { observable, cancel } = signerEth.signTransaction( + derivationPath, + transaction, + options, +); +``` + +**Parameters** + +- `derivationPath` + + - **Required** + - **Type:** `string` (e.g., `"44'/60'/0'/0/0"`) + - The derivation path used in the transaction. See [here](https://www.ledger.com/blog/understanding-crypto-addresses-and-derivation-paths) for more information. + +- `transaction` + + - **Required** + - **Type:**`Transaction` (compatible with [ethers v5](https://docs.ethers.org/v5/) or [ethers v6](https://docs.ethers.org/v6/)) + - The transaction object that needs to be signed. + +- `options` + + - **Optional** + - **Type:** `TransactionOptions` + + ```typescript + type TransactionOptions = { + domain?: string; + }; + ``` + + - `domain` An optional string representing the domain present in the transaction. Currently, only ENS domains are supported. + +**Returns** + +- `observable` + + - An [Observable](https://rxjs.dev/guide/observable) object that contains the [`DeviceActionState`](https://github.com/LedgerHQ/device-sdk-ts/blob/develop/packages/device-management-kit/src/api/device-action/model/DeviceActionState.ts) derived instance which reprensents the operation's state. For example: + + ```typescript + observable.subscribe({ + next: (state: SignTransactionDAState) => { + switch (state.status) { + case DeviceActionStatus.NotStarted: { + console.log("The action is not started yet."); + break; + } + case DeviceActionStatus.Pending: { + const { + intermediateValue: { requiredUserInteraction }, + } = state; + // Access the intermediate value here, explained below + console.log( + "The action is pending and the intermediate value is: ", + intermediateValue, + ); + break; + } + case DeviceActionStatus.Stopped: { + console.log("The action has been stopped."); + break; + } + case DeviceActionStatus.Completed: { + const { output } = state; + // Access the output of the completed action here + console.log("The action has been completed: ", output); + break; + } + case DeviceActionStatus.Error: { + const { error } = state; + // Access the error here if occured + console.log("An error occured during the action: ", error); + break; + } + } + }, + }); + ``` + + - When the action status is `DeviceActionStatus.Pending`, the state will include an `intermediateValue` object that provides useful information for interaction: + + ```typescript + const { requiredUserInteraction } = intermediateValue; + + switch (requiredUserInteraction) { + case UserInteractionRequired.SignTransaction: { + // User needs to sign the transaction displayed on the device + console.log( + "User needs to sign the transaction displayed on the device.", + ); + break; + } + case UserInteractionRequired.None: { + // No user action required + console.log("No user action needed."); + break; + } + case UserInteractionRequired.UnlockDevice: { + // User needs to unlock the device + console.log("The user needs to unlock the device."); + break; + } + case UserInteractionRequired.ConfirmOpenApp: { + // User needs to confirm on the device to open the app + console.log("The user needs to confirm on the device to open the app."); + break; + } + default: + // Type guard to ensure all cases are handled + const uncaughtUserInteraction: never = requiredUserInteraction; + console.error( + "Unhandled user interaction case:", + uncaughtUserInteraction, + ); + } + ``` + + - When the action status is `DeviceActionStatus.Completed`, the execution result can be accessed through the `output` property in the state. This property is a `Signature` object with the following structure: + + ```typescript + type Signature = { + r: `0x${string}`; + s: `0x${string}`; + v: number; + }; + ``` + +- `cancel` + - The function without a return value to cancel the action on the Ledger device. + +### 3: Sign Message + +This method allows users to sign a text string that is displayed on Ledger devices. + +```typescript +const { observable, cancel } = signerEth.signMessage(derivationPath, message); +``` + +**Parameters** + +- `derivationPath` + + - **Required** + - **Type:** `string` (e.g., `"44'/60'/0'/0/0"`) + - The derivation path used by the Ethereum message. See [here](https://www.ledger.com/blog/understanding-crypto-addresses-and-derivation-paths) for more information. + +- `message` + + - **Required** + - **Type:** `string` + - The message to be signed, which will be displayed on the Ledger device. + +**Returns** + +- `observable` + + - An [Observable](https://rxjs.dev/guide/observable) object that contains the [`DeviceActionState`](https://github.com/LedgerHQ/device-sdk-ts/blob/develop/packages/device-management-kit/src/api/device-action/model/DeviceActionState.ts) derived instance which reprensents the operation's state. For example: + + ```typescript + observable.subscribe({ + next: (state: SignPersonalMessageDAState) => { + switch (state.status) { + case DeviceActionStatus.NotStarted: { + console.log("The action is not started yet."); + break; + } + case DeviceActionStatus.Pending: { + const { + intermediateValue: { requiredUserInteraction }, + } = state; + // Access the intermediate value here, explained below + console.log( + "The action is pending and the intermediate value is: ", + intermediateValue, + ); + break; + } + case DeviceActionStatus.Stopped: { + console.log("The action has been stopped."); + break; + } + case DeviceActionStatus.Completed: { + const { output } = state; + // Access the output of the completed action here + console.log("The action has been completed: ", output); + break; + } + case DeviceActionStatus.Error: { + const { error } = state; + // Access the error here if occured + console.log("An error occured during the action: ", error); + break; + } + } + }, + }); + ``` + + - When the action status is `DeviceActionStatus.Pending`, the state will include an `intermediateValue` object that provides useful information for interaction: + + ```typescript + const { requiredUserInteraction } = intermediateValue; + + switch (requiredUserInteraction) { + case UserInteractionRequired.SignPersonalMessage: { + // User needs to sign the message displayed on the device + console.log("User needs to sign the message displayed on the device."); + break; + } + case UserInteractionRequired.None: { + // No user action required + console.log("No user action needed."); + break; + } + case UserInteractionRequired.UnlockDevice: { + // User needs to unlock the device + console.log("The user needs to unlock the device."); + break; + } + case UserInteractionRequired.ConfirmOpenApp: { + // User needs to confirm on the device to open the app + console.log("The user needs to confirm on the device to open the app."); + break; + } + default: + // Type guard to ensure all cases are handled + const uncaughtUserInteraction: never = requiredUserInteraction; + console.error( + "Unhandled user interaction case:", + uncaughtUserInteraction, + ); + } + ``` + + - When the action status is `DeviceActionStatus.Completed`, the execution result can be accessed through the `output` property in the state. This property is a `Signature` object with the following structure: + + ```typescript + type Signature = { + r: `0x${string}`; + s: `0x${string}`; + v: number; + }; + ``` + +- `cancel` + - The function without a return value to cancel the action on the Ledger device. + +### 4: Sign TypedData + +This method enables users to sign an Ethereum message following the [EIP-712](https://eips.ethereum.org/EIPS/eip-712) specification. + +```typescript +const { observable, cancel } = signerEth.signTypedData( + derivationPath, + typedData, +); +``` + +**Parameters** + +- `derivationPath` + + - **Required** + - **Type:** `string` (e.g., `"44'/60'/0'/0/0"`) + - The derivation path used by the Ethereum message. See [here](https://www.ledger.com/blog/understanding-crypto-addresses-and-derivation-paths) for more information. + +- `typedData` + + - **Required** + - **Type:** `TypedData` + + ```typescript + interface TypedData { + domain: TypedDataDomain; + types: Record>; + primaryType: string; + message: Record; + } + + interface TypedDataDomain { + name?: string; + version?: string; + chainId?: number; + verifyingContract?: string; + salt?: string; + } + + interface TypedDataField { + name: string; + type: string; + } + ``` + + - The typed data as defined at [EIP-712](https://eips.ethereum.org/EIPS/eip-712). + +**Returns** + +- `observable` + + - An [Observable](https://rxjs.dev/guide/observable) object that contains the [`DeviceActionState`](https://github.com/LedgerHQ/device-sdk-ts/blob/develop/packages/device-management-kit/src/api/device-action/model/DeviceActionState.ts) derived instance which reprensents the operation's state. For example: + + ```typescript + observable.subscribe({ + next: (state: SignTypedDataDAState) => { + switch (state.status) { + case DeviceActionStatus.NotStarted: { + console.log("The action is not started yet."); + break; + } + case DeviceActionStatus.Pending: { + const { intermediateValue } = state; + // Access the intermediate value here, explained below + console.log( + "The action is pending and the intermediate value is: ", + requiredUserInteraction, + ); + break; + } + case DeviceActionStatus.Stopped: { + console.log("The action has been stopped."); + break; + } + case DeviceActionStatus.Completed: { + const { output } = state; + // Access the output of the completed action here, explained below + console.log("The action has been completed: ", output); + break; + } + case DeviceActionStatus.Error: { + const { error } = state; + // Access the error here if occured + console.log("An error occured during the action: ", error); + break; + } + } + }, + }); + ``` + + - When the action status is `DeviceActionStatus.Pending`, the state will include an `intermediateValue` object that provides useful information for interaction: + + ```typescript + const { requiredUserInteraction } = intermediateValue; + + switch (requiredUserInteraction) { + case UserInteractionRequired.SignTypedData: { + // User needs to sign the typed data displayed on the device + console.log( + "User needs to sign the typed data displayed on the device.", + ); + break; + } + case UserInteractionRequired.None: { + // No user action required + console.log("No user action needed."); + break; + } + case UserInteractionRequired.UnlockDevice: { + // User needs to unlock the device + console.log("The user needs to unlock the device."); + break; + } + case UserInteractionRequired.ConfirmOpenApp: { + // User needs to confirm on the device to open the app + console.log("The user needs to confirm on the device to open the app."); + break; + } + default: + // Type guard to ensure all cases are handled + const uncaughtUserInteraction: never = requiredUserInteraction; + console.error( + "Unhandled user interaction case:", + uncaughtUserInteraction, + ); + } + ``` + + - When the action status is `DeviceActionStatus.Completed`, the execution result can be accessed through the `output` property in the state. This property is a `Signature` object with the following structure: + + ```typescript + type Signature = { + r: `0x${string}`; + s: `0x${string}`; + v: number; + }; + ``` + +- `cancel` + - The function without a return value to cancel the action on the Ledger device. + +## Example + +We encourage you to explore the Ethereum Signer by trying it out in our online [sample application](https://app.devicesdk.ledger-test.com/). Experience how it works and see its capabilities in action. Of course, you will need a Ledger device connected. + +## Clear Signing Initiative + +As the signer is already integrating the context module. It will also provide the capability of clear sign transaction +if we have the data related to the transaction. diff --git a/apps/docs/pages/docs/explanations/signers/solana.mdx b/apps/docs/pages/docs/explanations/signers/solana.mdx new file mode 100644 index 000000000..810b3802e --- /dev/null +++ b/apps/docs/pages/docs/explanations/signers/solana.mdx @@ -0,0 +1,479 @@ +# Solana Signer Kit + +## Features + +Following doc is matching for v1.0.0 of the Ethereum Signer Kit. + +### 1: Get Address + +This method allows users to retrieve the Solana address according to given `derivationPath`. + +```typescript +const { observable, cancel } = signerSolana.getAddress(derivationPath, options); +``` + +**Parameters** + +- `derivationPath` + + - **Required** + - **Type:** `string` (e.g., `"44'/501'/0'"`) + - The derivation path used for the Solana address. See [here](https://www.ledger.com/blog/understanding-crypto-addresses-and-derivation-paths) for more information. + +- `options` + + - Optional + - Type: `AddressOptions` + + ```typescript + type AddressOptions = { + checkOnDevice?: boolean; + }; + ``` + + - `checkOnDevice`: An optional boolean indicating whether user confirmation on the device is required (`true`) or not (`false`). + +**Returns** + +- `observable` + + - An [Observable](https://rxjs.dev/guide/observable) object that contains the [`DeviceActionState`](https://github.com/LedgerHQ/device-sdk-ts/blob/develop/packages/device-management-kit/src/api/device-action/model/DeviceActionState.ts) derived instance, which reprensents the operation's state. For example: + + ```typescript + observable.subscribe({ + next: (state: DeviceActionState) => { + switch (state.status) { + case DeviceActionStatus.NotStarted: { + console.log("The action is not started yet."); + break; + } + case DeviceActionStatus.Pending: { + const { + intermediateValue: { requiredUserInteraction }, + } = state; + // Access the intermediate value here, explained below + console.log( + "The action is pending and the intermediate value is: ", + intermediateValue, + ); + break; + } + case DeviceActionStatus.Stopped: { + console.log("The action has been stopped."); + break; + } + case DeviceActionStatus.Completed: { + const { output } = state; + // Access the output of the completed action here + console.log("The action has been completed: ", output); + break; + } + case DeviceActionStatus.Error: { + const { error } = state; + // Access the error here if occured + console.log("An error occured during the action: ", error); + break; + } + } + }, + }); + ``` + + - When the action status is `DeviceActionStatus.Pending`, the state will include an `intermediateValue` object that provides useful information for interaction: + + ```typescript + const { requiredUserInteraction } = intermediateValue; + + switch (requiredUserInteraction) { + case UserInteractionRequired.VerifyAddress: { + // User needs to verify the address displayed on the device + console.log( + "User needs to verify the address displayed on the device.", + ); + break; + } + case UserInteractionRequired.None: { + // No user action required + console.log("No user action needed."); + break; + } + case UserInteractionRequired.UnlockDevice: { + // User needs to unlock the device + console.log("The user needs to unlock the device."); + break; + } + case UserInteractionRequired.ConfirmOpenApp: { + // User needs to confirm on the device to open the app + console.log("The user needs to confirm on the device to open the app."); + break; + } + default: + // Type guard to ensure all cases are handled + const uncaughtUserInteraction: never = requiredUserInteraction; + console.error( + "Unhandled user interaction case:", + uncaughtUserInteraction, + ); + } + ``` + + - When the action status is `DeviceActionStatus.Completed`, the execution result can be accessed through the `output` property in the state. The `output` property is of type `PublicKey` in `base58`. + + ```typescript + type PublicKey = string; // address in base58 format + ``` + +- `cancel` + - The function without a return value to cancel the action on the Ledger device. + +### 2: Sign Transaction + +This method enables users to securely sign transactions using clear signing on Ledger devices. + +```typescript +const { observable, cancel } = signerSolana.signTransaction( + derivationPath, + transaction, + options, +); +``` + +**Parameters** + +- `derivationPath` + + - **Required** + - **Type:** `string` (e.g., `"44'/501'/0'"`) + - The derivation path used in the transaction. See [here](https://www.ledger.com/blog/understanding-crypto-addresses-and-derivation-paths) for more information. + +- `transaction` + + - **Required** + - **Type:** `Uint8Array` + - The transaction object that needs to be signed. + +- `options` + + - **Optional** + - **Type:** `TBD` + - No option defined yet, but will be used for clear signing in a near future. + + ```typescript + type TransactionOptions = {}; + ``` + +**Returns** + +- `observable` + + - An [Observable](https://rxjs.dev/guide/observable) object that contains the [`DeviceActionState`](https://github.com/LedgerHQ/device-sdk-ts/blob/develop/packages/device-management-kit/src/api/device-action/model/DeviceActionState.ts) derived instance which reprensents the operation's state. For example: + + ```typescript + observable.subscribe({ + next: (state: SignTransactionDAState) => { + switch (state.status) { + case DeviceActionStatus.NotStarted: { + console.log("The action is not started yet."); + break; + } + case DeviceActionStatus.Pending: { + const { + intermediateValue: { requiredUserInteraction }, + } = state; + // Access the intermediate value here, explained below + console.log( + "The action is pending and the intermediate value is: ", + intermediateValue, + ); + break; + } + case DeviceActionStatus.Stopped: { + console.log("The action has been stopped."); + break; + } + case DeviceActionStatus.Completed: { + const { output } = state; + // Access the output of the completed action here + console.log("The action has been completed: ", output); + break; + } + case DeviceActionStatus.Error: { + const { error } = state; + // Access the error here if occured + console.log("An error occured during the action: ", error); + break; + } + } + }, + }); + ``` + + - When the action status is `DeviceActionStatus.Pending`, the state will include an `intermediateValue` object that provides useful information for interaction: + + ```typescript + const { requiredUserInteraction } = intermediateValue; + + switch (requiredUserInteraction) { + case UserInteractionRequired.SignTransaction: { + // User needs to sign the transaction displayed on the device + console.log( + "User needs to sign the transaction displayed on the device.", + ); + break; + } + case UserInteractionRequired.None: { + // No user action required + console.log("No user action needed."); + break; + } + case UserInteractionRequired.UnlockDevice: { + // User needs to unlock the device + console.log("The user needs to unlock the device."); + break; + } + case UserInteractionRequired.ConfirmOpenApp: { + // User needs to confirm on the device to open the app + console.log("The user needs to confirm on the device to open the app."); + break; + } + default: + // Type guard to ensure all cases are handled + const uncaughtUserInteraction: never = requiredUserInteraction; + console.error( + "Unhandled user interaction case:", + uncaughtUserInteraction, + ); + } + ``` + + - When the action status is `DeviceActionStatus.Completed`, the execution result can be accessed through the `output` property in the state. This property is a `Signature` object with the following structure: + + ```typescript + type Signature = Uint8Array; + ``` + +- `cancel` + - The function without a return value to cancel the action on the Ledger device. + +### 3: Sign Message + +This method allows users to sign a text string that is displayed on Ledger devices. + +```typescript +const { observable, cancel } = signerSolana.signMessage( + derivationPath, + message, +); +``` + +**Parameters** + +- `derivationPath` + + - **Required** + - **Type:** `string` (e.g., `"44'/501'/0'"`) + - The derivation path used by the Solana message. See [here](https://www.ledger.com/blog/understanding-crypto-addresses-and-derivation-paths) for more information. + +- `message` + + - **Required** + - **Type:** `string` + - The message to be signed, which will be displayed on the Ledger device. + +**Returns** + +- `observable` + + - An [Observable](https://rxjs.dev/guide/observable) object that contains the [`DeviceActionState`](https://github.com/LedgerHQ/device-sdk-ts/blob/develop/packages/device-management-kit/src/api/device-action/model/DeviceActionState.ts) derived instance which reprensents the operation's state. For example: + + ```typescript + observable.subscribe({ + next: (state: SignPersonalMessageDAState) => { + switch (state.status) { + case DeviceActionStatus.NotStarted: { + console.log("The action is not started yet."); + break; + } + case DeviceActionStatus.Pending: { + const { + intermediateValue: { requiredUserInteraction }, + } = state; + // Access the intermediate value here, explained below + console.log( + "The action is pending and the intermediate value is: ", + intermediateValue, + ); + break; + } + case DeviceActionStatus.Stopped: { + console.log("The action has been stopped."); + break; + } + case DeviceActionStatus.Completed: { + const { output } = state; + // Access the output of the completed action here + console.log("The action has been completed: ", output); + break; + } + case DeviceActionStatus.Error: { + const { error } = state; + // Access the error here if occured + console.log("An error occured during the action: ", error); + break; + } + } + }, + }); + ``` + + - When the action status is `DeviceActionStatus.Pending`, the state will include an `intermediateValue` object that provides useful information for interaction: + + ```typescript + const { requiredUserInteraction } = intermediateValue; + + switch (requiredUserInteraction) { + case UserInteractionRequired.SignPersonalMessage: { + // User needs to sign the message displayed on the device + console.log("User needs to sign the message displayed on the device."); + break; + } + case UserInteractionRequired.None: { + // No user action required + console.log("No user action needed."); + break; + } + case UserInteractionRequired.UnlockDevice: { + // User needs to unlock the device + console.log("The user needs to unlock the device."); + break; + } + case UserInteractionRequired.ConfirmOpenApp: { + // User needs to confirm on the device to open the app + console.log("The user needs to confirm on the device to open the app."); + break; + } + default: + // Type guard to ensure all cases are handled + const uncaughtUserInteraction: never = requiredUserInteraction; + console.error( + "Unhandled user interaction case:", + uncaughtUserInteraction, + ); + } + ``` + + - When the action status is `DeviceActionStatus.Completed`, the execution result can be accessed through the `output` property in the state. This property is a `Signature` object with the following structure: + + ```typescript + type Signature = Uint8Array; + ``` + +- `cancel` + - The function without a return value to cancel the action on the Ledger device. + +### 4: Get App Configuration + +This method allow the user to fetch the current app configuration. + +```typescript +const { observable, cancel } = signerSolana.getAppConfiguration(); +``` + +**Returns** + +- `observable` + + - An [Observable](https://rxjs.dev/guide/observable) object that contains the [`DeviceActionState`](https://github.com/LedgerHQ/device-sdk-ts/blob/develop/packages/device-management-kit/src/api/device-action/model/DeviceActionState.ts) derived instance which reprensents the operation's state. For example: + + ```typescript + observable.subscribe({ + next: (state: SignTypedDataDAState) => { + switch (state.status) { + case DeviceActionStatus.NotStarted: { + console.log("The action is not started yet."); + break; + } + case DeviceActionStatus.Pending: { + const { intermediateValue } = state; + // Access the intermediate value here, explained below + console.log( + "The action is pending and the intermediate value is: ", + requiredUserInteraction, + ); + break; + } + case DeviceActionStatus.Stopped: { + console.log("The action has been stopped."); + break; + } + case DeviceActionStatus.Completed: { + const { output } = state; + // Access the output of the completed action here, explained below + console.log("The action has been completed: ", output); + break; + } + case DeviceActionStatus.Error: { + const { error } = state; + // Access the error here if occured + console.log("An error occured during the action: ", error); + break; + } + } + }, + }); + ``` + + - When the action status is `DeviceActionStatus.Pending`, the state will include an `intermediateValue` object that provides useful information for interaction: + + ```typescript + const { requiredUserInteraction } = intermediateValue; + + switch (requiredUserInteraction) { + case UserInteractionRequired.SignTypedData: { + // User needs to sign the typed data displayed on the device + console.log( + "User needs to sign the typed data displayed on the device.", + ); + break; + } + case UserInteractionRequired.None: { + // No user action required + console.log("No user action needed."); + break; + } + case UserInteractionRequired.UnlockDevice: { + // User needs to unlock the device + console.log("The user needs to unlock the device."); + break; + } + case UserInteractionRequired.ConfirmOpenApp: { + // User needs to confirm on the device to open the app + console.log("The user needs to confirm on the device to open the app."); + break; + } + default: + // Type guard to ensure all cases are handled + const uncaughtUserInteraction: never = requiredUserInteraction; + console.error( + "Unhandled user interaction case:", + uncaughtUserInteraction, + ); + } + ``` + + - When the action status is `DeviceActionStatus.Completed`, the execution result can be accessed through the `output` property in the state. This property is a `Signature` object with the following structure: + + ```typescript + type AppConfiguration = { + blindSigningEnabled: boolean; + pubKeyDisplayMode: PublicKeyDisplayMode; + version: string; + }; + ``` + +- `cancel` + - The function without a return value to cancel the action on the Ledger device. + +## Example + +We encourage you to explore the Solana Signer by trying it out in our online [sample application](https://app.devicesdk.ledger-test.com/). Experience how it works and see its capabilities in action. Of course, you will need a Ledger device connected. diff --git a/apps/docs/pages/docs/integration_walkthroughs.mdx b/apps/docs/pages/docs/integration_walkthroughs.mdx new file mode 100644 index 000000000..5218a5f6e --- /dev/null +++ b/apps/docs/pages/docs/integration_walkthroughs.mdx @@ -0,0 +1 @@ +# Integration Walkthrough diff --git a/apps/docs/pages/docs/integration_walkthroughs/_meta.json b/apps/docs/pages/docs/integration_walkthroughs/_meta.json new file mode 100644 index 000000000..dd7ec8efa --- /dev/null +++ b/apps/docs/pages/docs/integration_walkthroughs/_meta.json @@ -0,0 +1,3 @@ +{ + "how_to": "How to ..." +} diff --git a/apps/docs/pages/docs/integration_walkthroughs/how_to/_meta.json b/apps/docs/pages/docs/integration_walkthroughs/how_to/_meta.json new file mode 100644 index 000000000..453a0d648 --- /dev/null +++ b/apps/docs/pages/docs/integration_walkthroughs/how_to/_meta.json @@ -0,0 +1,4 @@ +{ + "how_to_use_a_signer": "use a Signer", + "build_custom_command": "build a custom command" +} diff --git a/apps/docs/pages/docs/integration_walkthroughs/how_to/build_custom_command.mdx b/apps/docs/pages/docs/integration_walkthroughs/how_to/build_custom_command.mdx new file mode 100644 index 000000000..70e03c1bd --- /dev/null +++ b/apps/docs/pages/docs/integration_walkthroughs/how_to/build_custom_command.mdx @@ -0,0 +1,73 @@ +# Build a Custom Command + +You can build your own command simply by extending the `Command` class and implementing the `getApdu` and `parseResponse` methods. + +Then you can use the `sendCommand` method to send it to a connected device. + +This is strongly recommended over direct usage of `sendApdu`. + +Check the existing commands for a variety of examples. + +```typescript +export class MyCustomCommand + implements Command +{ + args: GetAddressCommandArgs; + + constructor(args: GetAddressCommandArgs) { + this.args = args; + } + + getApdu(): Apdu { + + // Main args for the APDU + const getEthAddressArgs: ApduBuilderArgs = { + cla: 0xe0, // Command CLA + ins: 0x02, // Command INS + p1: 0x00, //Parameter P1 + p2: 0x00, //Parameter P1 + }; + + //Add the data attache to the APDU with the builder + const builder = new ApduBuilder(getEthAddressArgs); + builder.add32BitUIntToData(0); + ... // Add more data to the APDU + return builder.build(); + } + + parseResponse( + response: ApduResponse, + ): CommandResult { + //Create Apdu Parser + const parser = new ApduParser(response); + + // FIRST: check status word + if (!CommandUtils.isSuccessResponse(response)) { + return CommandResultFactory({ + error: GlobalCommandErrorHandler.handle(response), + }); + } + + // Extract fields from the response + const customAttributes1 = parser.extract8BitUInt(); + if (customAttributes1 === undefined) { + return CommandResultFactory({ + error: new InvalidStatusWordError("Public key length is missing"), + }); + } + ... // Extract more fields from the response + + return CommandResultFactory({ + MyCustomResponse: { + customAttributes1, + customAttributes2, + ... + }, + }); + } +} +``` + +## Usage of ApduBuilder + +## Usage of ApduParser diff --git a/apps/docs/pages/docs/integration_walkthroughs/how_to/how_to_use_a_signer.mdx b/apps/docs/pages/docs/integration_walkthroughs/how_to/how_to_use_a_signer.mdx new file mode 100644 index 000000000..c66d35b5e --- /dev/null +++ b/apps/docs/pages/docs/integration_walkthroughs/how_to/how_to_use_a_signer.mdx @@ -0,0 +1,32 @@ +# Use a signer + +> [Note] We will show the usage of the signer with the Ethereum signer. +> The same logic can be applied to the other signers. + +## Installation + +> **Note:** This module is not standalone; it depends on the [@ledgerhq/device-management-kit](https://github.com/LedgerHQ/device-sdk-ts/tree/develop/packages/device-management-kit) package, so you need to install it first. + +To install the `device-signer-kit-ethereum` package, run the following command: + +```sh +npm install @ledgerhq/device-signer-kit-ethereum +``` + +## Usage + +To initialize an Ethereum signer instance, you need a Ledger Device Management Kit instance and the ID of the session of the connected device. Use the `SignerEthBuilder` along with the [Context Module](https://github.com/LedgerHQ/device-sdk-ts/tree/develop/packages/signer/context-module) by default developed by Ledger: + +```typescript +// Initialize an Ethereum signer instance using default context module +const signerEth = new SignerEthBuilder({ sdk, sessionId }).build(); +``` + +You can also configure the context module yourself: + +```typescript +// Initialize an Ethereum signer instance using customized context module +const signerEth = new SignerEthBuilder({ sdk, sessionId }) + .withContextModule(customContextModule) + .build(); +``` diff --git a/apps/docs/pages/docs/migrations.mdx b/apps/docs/pages/docs/migrations.mdx new file mode 100644 index 000000000..4a20c2ebc --- /dev/null +++ b/apps/docs/pages/docs/migrations.mdx @@ -0,0 +1,11 @@ +# Migrations + +Find below all the migrations guide available: + +## Device Management Kit + +- migration from [v0.4.0 to v0.5.0](./migrations/04_to_05/) + +## Device Signer Kit - Ethereum + +_coming soon_ diff --git a/apps/docs/pages/docs/migrations/04_to_05.mdx b/apps/docs/pages/docs/migrations/04_to_05.mdx new file mode 100644 index 000000000..5fe07eb92 --- /dev/null +++ b/apps/docs/pages/docs/migrations/04_to_05.mdx @@ -0,0 +1,3 @@ +# Migration from 0.4.0 to 0.5.0 + +_coming soon_ diff --git a/apps/docs/pages/docs/migrations/_meta.json b/apps/docs/pages/docs/migrations/_meta.json new file mode 100644 index 000000000..7c82c5769 --- /dev/null +++ b/apps/docs/pages/docs/migrations/_meta.json @@ -0,0 +1,3 @@ +{ + "04_to_05": "DMK: Migrate from v0.4.0 to 0.5.0" +} diff --git a/apps/docs/pages/docs/references.mdx b/apps/docs/pages/docs/references.mdx new file mode 100644 index 000000000..f17d827d8 --- /dev/null +++ b/apps/docs/pages/docs/references.mdx @@ -0,0 +1,3 @@ +#References + +Device Management Kit References diff --git a/apps/docs/pages/docs/references/_meta.json b/apps/docs/pages/docs/references/_meta.json new file mode 100644 index 000000000..94a99ac11 --- /dev/null +++ b/apps/docs/pages/docs/references/_meta.json @@ -0,0 +1,4 @@ +{ + "dmk": "Device Management Kit", + "signer_eth": "Signer Kit Ethereum" +} diff --git a/apps/docs/pages/index.mdx b/apps/docs/pages/index.mdx new file mode 100644 index 000000000..17ae03b2c --- /dev/null +++ b/apps/docs/pages/index.mdx @@ -0,0 +1,30 @@ +--- +title: Ledger Device Management Kit Documentation +--- + +export function Card({ title, description, link, icon }) { + return ( +

+
{icon}
+
{title}
+

{description}

+ + Explore + +
+ ); +} + +export function Hero() { + return ( +
+

Interact with Ledger Devices

+

+ Seamlessly connect Ledger's devices into your applications with our{" "} + Device Management Kit. +

+
+ ); +} + + diff --git a/apps/docs/postcss.config.js b/apps/docs/postcss.config.js new file mode 100644 index 000000000..0e01dd929 --- /dev/null +++ b/apps/docs/postcss.config.js @@ -0,0 +1,9 @@ +// If you want to use other PostCSS plugins, see the following: +// https://tailwindcss.com/docs/using-with-preprocessors +/** @type {import("postcss").Postcss} */ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/apps/docs/style.css b/apps/docs/style.css new file mode 100644 index 000000000..2a6f5b82f --- /dev/null +++ b/apps/docs/style.css @@ -0,0 +1,106 @@ +/* Hero Section */ + +.title { + font-size: 2em; +} + +.hero { + padding: 60px 20px; + text-align: center; + height: 60vh; + display: flex; + flex-direction: column; + justify-content: center; +} + +.hero h1 { + font-size: 2.5em; + margin-bottom: 20px; +} + +.hero p { + font-size: 1.2em; +} + +em { + color: #ff5300; +} + +/* Cards */ +.card-container { + display: flex; + justify-content: center; + margin: 40px 0; +} + +.card { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + border-radius: 8px; + padding: 20px; + width: 250px; + text-align: center; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); + margin: 0 20px; +} + +.card .icon { + font-size: 3em; +} + +.card h3 { + margin-top: 20px; + margin-bottom: 10px; + font-size: 1.5em; +} + +.card p { + font-size: 1em; + margin-bottom: 20px; +} + +.btn { + display: inline-block; + background-color: #ff5300; + color: white; + padding: 10px 20px; + text-align: center; + border-radius: 4px; + text-decoration: none; +} + +.btn:hover { + background-color: #ff5300; +} + +/* Additional Resources */ +.resources-container { + text-align: center; + padding: 40px 20px; +} + +.resources-container h2 { + font-size: 1.8em; + margin-bottom: 20px; +} + +.resources-container ul { + list-style: none; + padding: 0; +} + +.resources-container ul li { + margin: 10px 0; +} + +.resources-container ul li a { + font-size: 1.1em; + text-decoration: none; + color: #333; +} + +.resources-container ul li a:hover { + text-decoration: underline; +} diff --git a/apps/docs/tailwind.config.js b/apps/docs/tailwind.config.js new file mode 100644 index 000000000..521c405bd --- /dev/null +++ b/apps/docs/tailwind.config.js @@ -0,0 +1,10 @@ +/** @type {import("tailwindcss").Config} */ + +module.exports = { + content: ["./pages/**/*.{js,ts,jsx,tsx,mdx}", "./theme.config.tsx"], + theme: { + extend: {}, + }, + plugins: [], + darkMode: "class", +}; diff --git a/apps/docs/theme.config.tsx b/apps/docs/theme.config.tsx new file mode 100644 index 000000000..600894f9f --- /dev/null +++ b/apps/docs/theme.config.tsx @@ -0,0 +1,103 @@ +/* eslint-disable no-restricted-syntax */ +import React from "react"; +import { useRouter } from "next/router"; + +export default { + head: ( + <> + + + + + + ), + logo: ( +
+ + Ledger logo + + + Ledger Device Management Kit +
+ ), + project: { + link: "https://github.com/LedgerHQ/device-sdk-ts", + }, + docsRepositoryBase: + "https://github.com/LedgerHQ/device-sdk-ts/tree/main/apps/docs", + chat: { + link: "https://developers.ledger.com/discord/", + }, + editLink: { + text: "Edit this page on GitHub →", + }, + feedback: { + content: "Question? Give us feedback →", + labels: "feedback", + }, + useNextSeoProps() { + const { asPath } = useRouter(); + if (asPath === "/") { + return { + titleTemplate: "Ledger DMK - Documentation", + }; + } + const word = asPath.split("/")[1]; + const capitalizedWord = word.charAt(0).toUpperCase() + word.slice(1); + return { + titleTemplate: `Ledger DMK - ${capitalizedWord}`, + }; + }, + footer: { + text: ( +
+ + + +

+ Copyright Š 2024 Ledger SAS. All rights reserved. +

+
+ ), + }, +}; diff --git a/apps/docs/tsconfig.eslint.json b/apps/docs/tsconfig.eslint.json new file mode 100644 index 000000000..9cb4a43f9 --- /dev/null +++ b/apps/docs/tsconfig.eslint.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + "next.config.js", + "postcss.config.js", + "eslint.config.mjs", + "tailwind.config.js" + ] +} diff --git a/apps/docs/tsconfig.json b/apps/docs/tsconfig.json new file mode 100644 index 000000000..4e49202f2 --- /dev/null +++ b/apps/docs/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "@ledgerhq/tsconfig-dsdk/tsconfig.web", + "compilerOptions": { + "plugins": [ + { + "name": "next" + } + ], + "forceConsistentCasingInFileNames": true + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + "eslint.config.mjs", + "tailwind.config.js" + ], + "exclude": ["node_modules"] +} diff --git a/apps/sample/.gitignore b/apps/sample/.gitignore index 0ea48f883..2077bd84c 100644 --- a/apps/sample/.gitignore +++ b/apps/sample/.gitignore @@ -37,3 +37,6 @@ next-env.d.ts # Sentry Config File .sentryclirc + +# Playwright test results +test-results/ diff --git a/apps/sample/next.config.js b/apps/sample/next.config.js index 3dad7f968..8a4688eca 100644 --- a/apps/sample/next.config.js +++ b/apps/sample/next.config.js @@ -1,55 +1,37 @@ /** @type {import('next').NextConfig} */ +// eslint-disable-next-line @typescript-eslint/no-require-imports +const { withSentryConfig } = require("@sentry/nextjs"); + const nextConfig = { reactStrictMode: true, compiler: { styledComponents: true, }, -}; - -module.exports = nextConfig; - -// Injected content via Sentry wizard below - -// eslint-disable-next-line @typescript-eslint/no-require-imports -const { withSentryConfig } = require("@sentry/nextjs"); - -module.exports = withSentryConfig( - module.exports, - { - // For all available options, see: - // https://github.com/getsentry/sentry-webpack-plugin#options - - // Suppresses source map uploading logs during build - silent: true, - org: "ledger", - project: "device-sdk-sample", + env: { + SDK_CONFIG_TRANSPORT: + process.env.npm_lifecycle_event === "dev:default-mock" + ? "MOCK_SERVER" + : "", }, - { - // For all available options, see: - // https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/ - - // Upload a larger set of source maps for prettier stack traces (increases build time) - widenClientFileUpload: true, - - // Transpiles SDK to be compatible with IE11 (increases bundle size) - transpileClientSDK: true, - - // Routes browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers. (increases server load) - // Note: Check that the configured route will not match with your Next.js middleware, otherwise reporting of client- - // side errors will fail. - tunnelRoute: "/monitoring", - - // Hides source maps from generated client bundles - hideSourceMaps: true, +}; - // Automatically tree-shake Sentry logger statements to reduce bundle size - disableLogger: true, +// Define Sentry configuration options +const sentryWebpackPluginOptions = { + // For all available options, see: + // https://github.com/getsentry/sentry-webpack-plugin#options + + silent: true, + org: "ledger", + project: "device-management-kit-sample", + + // Additional Sentry options + widenClientFileUpload: true, // Upload a larger set of source maps for prettier stack traces (increases build time) + transpileClientSDK: true, // Transpiles SDK to be compatible with IE11 (increases bundle size) + tunnelRoute: "/monitoring", // Routes browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers (increases server load) + hideSourceMaps: true, // Hides source maps from generated client bundles + disableLogger: true, // Automatically tree-shake Sentry logger statements to reduce bundle size + automaticVercelMonitors: true, // Enables automatic instrumentation of Vercel Cron Monitors +}; - // Enables automatic instrumentation of Vercel Cron Monitors. - // See the following for more information: - // https://docs.sentry.io/product/crons/ - // https://vercel.com/docs/cron-jobs - automaticVercelMonitors: true, - }, -); +module.exports = withSentryConfig(nextConfig, sentryWebpackPluginOptions); diff --git a/apps/sample/package.json b/apps/sample/package.json index 462684b2a..2663105fa 100644 --- a/apps/sample/package.json +++ b/apps/sample/package.json @@ -1,28 +1,35 @@ { - "name": "@ledgerhq/device-sdk-sample", + "name": "@ledgerhq/device-management-kit-sample", "version": "0.1.3", "private": true, "scripts": { "dev": "next dev", + "dev:default-mock": "next dev", "build": "next build", "start": "next start", "lint": "eslint", "lint:fix": "eslint --fix", "prettier": "prettier . --check", "prettier:fix": "prettier . --write", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "test:playwright": "pnpm playwright test" }, "dependencies": { "@ledgerhq/context-module": "workspace:*", "@ledgerhq/device-management-kit": "workspace:*", + "@ledgerhq/device-management-kit-flipper-plugin-client": "workspace:*", "@ledgerhq/device-signer-kit-ethereum": "workspace:*", - "@ledgerhq/react-ui": "^0.16.0", + "@ledgerhq/device-signer-kit-solana": "workspace:*", + "@ledgerhq/device-transport-kit-mock-client": "workspace:*", + "@ledgerhq/react-ui": "^0.16.2", "@sentry/nextjs": "^8.32.0", + "@playwright/test": "^1.48.2", "ethers": "^6.13.2", "next": "14.2.13", "react": "^18.3.1", "react-dom": "^18.3.1", "react-lottie": "^1.2.4", + "rxjs": "^7.8.1", "styled-components": "^5.3.11" }, "devDependencies": { @@ -33,8 +40,7 @@ "@types/react-lottie": "^1.2.10", "@types/styled-components": "^5.1.25", "autoprefixer": "^10.4.20", - "globals": "15.10.0", - "postcss": "^8.4.47", - "typescript": "5.6.2" + "globals": "15.11.0", + "postcss": "^8.4.47" } } diff --git a/apps/sample/playwright.config.ts b/apps/sample/playwright.config.ts new file mode 100644 index 000000000..700951daa --- /dev/null +++ b/apps/sample/playwright.config.ts @@ -0,0 +1,27 @@ +import { defineConfig, type PlaywrightTestConfig } from "@playwright/test"; +import path from "path"; + +export const config: PlaywrightTestConfig = { + testDir: "./playwright/cases", + retries: 2, + use: { + headless: true, + viewport: { width: 1280, height: 720 }, + actionTimeout: 0, + trace: "on", + }, + reporter: [["list"]], + reportSlowTests: { + max: 0, + threshold: 60_000, + }, + webServer: { + command: `sh ${path.join(__dirname, "playwright/start-servers.sh")}`, + port: 3000, + timeout: 120 * 1000, + reuseExistingServer: !process.env.CI, + }, +}; + +// eslint-disable-next-line no-restricted-syntax +export default defineConfig(config); diff --git a/apps/sample/playwright/cases/device-action_list-apps.spec.ts b/apps/sample/playwright/cases/device-action_list-apps.spec.ts new file mode 100644 index 000000000..eef568ac8 --- /dev/null +++ b/apps/sample/playwright/cases/device-action_list-apps.spec.ts @@ -0,0 +1,46 @@ +/* eslint-disable no-restricted-imports */ +import { expect, test } from "@playwright/test"; + +import { thenDeviceIsConnected } from "../utils/thenHandlers"; +import { getLastDeviceResponseContent } from "../utils/utils"; +import { + whenConnectingDevice, + whenExecuteDeviceAction, + whenNavigateTo, +} from "../utils/whenHandlers"; + +interface ListAppsResponse { + status: string; + output?: object[]; + error?: object; + pending?: object; +} + +test.describe("device action: list apps", () => { + test.beforeEach(async ({ page }) => { + await page.goto("http://localhost:3000/"); + }); + + test("device should list apps via device action", async ({ page }) => { + await test.step("Given first device is connected", async () => { + await whenConnectingDevice(page); + + await thenDeviceIsConnected(page, 0); + }); + + await test.step("Then execute list apps via device action", async () => { + await whenNavigateTo(page, "/device-actions"); + + await whenExecuteDeviceAction(page, "List apps"); + + await page.waitForTimeout(1000); + + const response = (await getLastDeviceResponseContent( + page, + )) as ListAppsResponse; + + expect(response.status).toBe("completed"); + expect(response.output).toBeInstanceOf(Array); + }); + }); +}); diff --git a/apps/sample/playwright/cases/device-action_open-app.spec.ts b/apps/sample/playwright/cases/device-action_open-app.spec.ts new file mode 100644 index 000000000..fec1e9557 --- /dev/null +++ b/apps/sample/playwright/cases/device-action_open-app.spec.ts @@ -0,0 +1,45 @@ +/* eslint-disable no-restricted-imports */ +import { expect, test } from "@playwright/test"; + +import { thenDeviceIsConnected } from "../utils/thenHandlers"; +import { getLastDeviceResponseContent } from "../utils/utils"; +import { + whenConnectingDevice, + whenExecuteDeviceAction, + whenNavigateTo, +} from "../utils/whenHandlers"; + +interface OpenAppResponse { + status: string; + error?: object; + pending?: object; +} + +test.describe("device action: open bitcoin app", () => { + test.beforeEach(async ({ page }) => { + await page.goto("http://localhost:3000/"); + }); + + test("device should open bitcoin app via device action", async ({ page }) => { + await test.step("Given first device is connected", async () => { + await whenConnectingDevice(page); + + await thenDeviceIsConnected(page, 0); + }); + + await test.step("Then execute open app via device action", async () => { + await whenNavigateTo(page, "/device-actions"); + + await whenExecuteDeviceAction(page, "Open app", { + inputField: "input-text_appName", + inputValue: "Bitcoin", + }); + + const response = (await getLastDeviceResponseContent( + page, + )) as OpenAppResponse; + + expect(response.status).toBe("completed"); + }); + }); +}); diff --git a/apps/sample/playwright/cases/device-command_close-bitcoin-app.spec.ts b/apps/sample/playwright/cases/device-command_close-bitcoin-app.spec.ts new file mode 100644 index 000000000..f29950172 --- /dev/null +++ b/apps/sample/playwright/cases/device-command_close-bitcoin-app.spec.ts @@ -0,0 +1,62 @@ +/* eslint-disable no-restricted-imports */ +import { expect, test } from "@playwright/test"; + +import { thenDeviceIsConnected } from "../utils/thenHandlers"; +import { getLastDeviceResponseContent } from "../utils/utils"; +import { + whenCloseDrawer, + whenConnectingDevice, + whenExecuteDeviceCommand, + whenNavigateTo, +} from "../utils/whenHandlers"; + +interface CloseAppResponse { + status: string; + error?: object; + pending?: object; +} + +test.describe("device command: close bitcoin app", () => { + test.beforeEach(async ({ page }) => { + await page.goto("http://localhost:3000/"); + }); + + test("device should open and close bitcoin app via device command", async ({ + page, + }) => { + await test.step("Given first device is connected", async () => { + await whenConnectingDevice(page); + + await thenDeviceIsConnected(page, 0); + }); + + await test.step("Then execute open app via device command", async () => { + await whenNavigateTo(page, "/commands"); + + await whenExecuteDeviceCommand(page, "Open app", { + inputField: "input-text_appName", + inputValue: "Bitcoin", + }); + + const response = (await getLastDeviceResponseContent( + page, + "span", + )) as CloseAppResponse; + + expect(response.status).toBe("SUCCESS"); + }); + + await test.step("Then execute close app via device command", async () => { + await whenCloseDrawer(page); + + await whenExecuteDeviceCommand(page, "Close app"); + + const response = (await getLastDeviceResponseContent( + page, + "span", + )) as CloseAppResponse; + + expect(response.status).toBe("SUCCESS"); + }); + }); +}); diff --git a/apps/sample/playwright/cases/device-command_get-app-and-version.spec.ts b/apps/sample/playwright/cases/device-command_get-app-and-version.spec.ts new file mode 100644 index 000000000..a7abe7dfd --- /dev/null +++ b/apps/sample/playwright/cases/device-command_get-app-and-version.spec.ts @@ -0,0 +1,74 @@ +/* eslint-disable no-restricted-imports */ +import { expect, test } from "@playwright/test"; + +import { thenDeviceIsConnected } from "../utils/thenHandlers"; +import { getLastDeviceResponseContent } from "../utils/utils"; +import { + whenCloseDrawer, + whenConnectingDevice, + whenExecuteDeviceCommand, + whenNavigateTo, +} from "../utils/whenHandlers"; + +interface getAppAndVersionResponse { + status: string; + data?: { + name: string; + version: string; + flags: object; + }; + error?: object; + pending?: object; +} + +interface openAppResponse { + status: string; + error?: object; + pending?: object; +} + +test.describe("device command: get app and version", () => { + test.beforeEach(async ({ page }) => { + await page.goto("http://localhost:3000/"); + }); + + test("device should get app and version via device command", async ({ + page, + }) => { + await test.step("Given first device is connected", async () => { + await whenConnectingDevice(page); + + await thenDeviceIsConnected(page, 0); + }); + + await test.step("Then execute open app via device command", async () => { + await whenNavigateTo(page, "/commands"); + + await whenExecuteDeviceCommand(page, "Open app", { + inputField: "input-text_appName", + inputValue: "Bitcoin", + }); + + const response = (await getLastDeviceResponseContent( + page, + "span", + )) as openAppResponse; + + expect(response.status).toBe("SUCCESS"); + }); + + await test.step("Then execute get app and version via device command", async () => { + await whenCloseDrawer(page); + + await whenExecuteDeviceCommand(page, "Get app and version"); + + const response = (await getLastDeviceResponseContent( + page, + "span", + )) as getAppAndVersionResponse; + + expect(response.status).toBe("SUCCESS"); + expect(response?.data?.name).toBe("Bitcoin"); + }); + }); +}); diff --git a/apps/sample/playwright/cases/device-command_open-bitcoin-app.spec.ts b/apps/sample/playwright/cases/device-command_open-bitcoin-app.spec.ts new file mode 100644 index 000000000..ba8bce09f --- /dev/null +++ b/apps/sample/playwright/cases/device-command_open-bitcoin-app.spec.ts @@ -0,0 +1,48 @@ +/* eslint-disable no-restricted-imports */ +import { expect, test } from "@playwright/test"; + +import { thenDeviceIsConnected } from "../utils/thenHandlers"; +import { getLastDeviceResponseContent } from "../utils/utils"; +import { + whenConnectingDevice, + whenExecuteDeviceCommand, + whenNavigateTo, +} from "../utils/whenHandlers"; + +interface commandOpenAppResponse { + status: string; + error?: object; + pending?: object; +} + +test.describe("device command: open bitcoin app", () => { + test.beforeEach(async ({ page }) => { + await page.goto("http://localhost:3000/"); + }); + + test("device should open bitcoin app via device command", async ({ + page, + }) => { + await test.step("Given first device is connected", async () => { + await whenConnectingDevice(page); + + await thenDeviceIsConnected(page, 0); + }); + + await test.step("Then execute open app via device command", async () => { + await whenNavigateTo(page, "/commands"); + + await whenExecuteDeviceCommand(page, "Open app", { + inputField: "input-text_appName", + inputValue: "Bitcoin", + }); + + const response = (await getLastDeviceResponseContent( + page, + "span", + )) as commandOpenAppResponse; + + expect(response.status).toBe("SUCCESS"); + }); + }); +}); diff --git a/apps/sample/playwright/cases/device-connection.spec.ts b/apps/sample/playwright/cases/device-connection.spec.ts new file mode 100644 index 000000000..c9c2341ee --- /dev/null +++ b/apps/sample/playwright/cases/device-connection.spec.ts @@ -0,0 +1,33 @@ +/* eslint-disable no-restricted-imports */ +import { test } from "@playwright/test"; + +import { thenDeviceIsConnected } from "../utils/thenHandlers"; +import { whenConnectingDevice } from "../utils/whenHandlers"; + +test.describe("device connection", () => { + test.beforeEach(async ({ page }) => { + await page.goto("http://localhost:3000/"); + }); + + test("first device should connect", async ({ page }) => { + await test.step("Given first device is connected", async () => { + await whenConnectingDevice(page); + + await thenDeviceIsConnected(page, 0); + }); + }); + + test("second device should connect", async ({ page }) => { + await test.step("Given first device is connected", async () => { + await whenConnectingDevice(page); + + await thenDeviceIsConnected(page, 0); + }); + + await test.step("Given second device is connected", async () => { + await whenConnectingDevice(page); + + await thenDeviceIsConnected(page, 1); + }); + }); +}); diff --git a/apps/sample/playwright/cases/device-disconnection.spec.ts b/apps/sample/playwright/cases/device-disconnection.spec.ts new file mode 100644 index 000000000..c4b9fbff7 --- /dev/null +++ b/apps/sample/playwright/cases/device-disconnection.spec.ts @@ -0,0 +1,53 @@ +/* eslint-disable no-restricted-imports */ +import { test } from "@playwright/test"; + +import { + thenDeviceIsConnected, + thenNoDeviceIsConnected, +} from "../utils/thenHandlers"; +import { + whenConnectingDevice, + whenDisconnectDevice, +} from "../utils/whenHandlers"; + +test.describe("device disconnection", () => { + test.beforeEach(async ({ page }) => { + await page.goto("http://localhost:3000/"); + }); + + test("first device should disconnect", async ({ page }) => { + await test.step("Given first device is connected", async () => { + await whenConnectingDevice(page); + + await thenDeviceIsConnected(page, 0); + }); + + await test.step("Then disconnect device", async () => { + await whenDisconnectDevice(page); + + await thenNoDeviceIsConnected(page); + }); + }); + + test("first and second devices should disconnect", async ({ page }) => { + await test.step("Given first device is connected", async () => { + await whenConnectingDevice(page); + + await thenDeviceIsConnected(page, 0); + }); + + await test.step("Given second device is connected", async () => { + await whenConnectingDevice(page); + + await thenDeviceIsConnected(page, 1); + }); + + await test.step("Then disconnect device", async () => { + await whenDisconnectDevice(page); + + await whenDisconnectDevice(page); + + await thenNoDeviceIsConnected(page); + }); + }); +}); diff --git a/apps/sample/playwright/cases/eth_get-address_happy-paths.spec.ts b/apps/sample/playwright/cases/eth_get-address_happy-paths.spec.ts new file mode 100644 index 000000000..06644b21c --- /dev/null +++ b/apps/sample/playwright/cases/eth_get-address_happy-paths.spec.ts @@ -0,0 +1,161 @@ +/* eslint-disable no-restricted-imports */ +import { expect, test } from "@playwright/test"; + +import { thenDeviceIsConnected } from "../utils/thenHandlers"; +import { + getLastDeviceResponseContent, + isValidEthereumAddress, + isValidPublicKey, +} from "../utils/utils"; +import { + whenClicking, + whenConnectingDevice, + whenExecute, + whenExecuteDeviceAction, + whenNavigateTo, +} from "../utils/whenHandlers"; + +interface GetAddressResponse { + status: string; + output?: { + publicKey: string; + address: string; + }; + error?: object; + pending?: object; +} + +test.describe("ETH Signer: get address, happy paths", () => { + test.beforeEach(async ({ page }) => { + await page.goto("http://localhost:3000/"); + }); + + test("device should return ETH pubKey and address when fed default derivation path", async ({ + page, + }) => { + await test.step("Given first device is connected", async () => { + await whenConnectingDevice(page); + + await thenDeviceIsConnected(page, 0); + }); + + await test.step("When execute ETH: get address", async () => { + await whenNavigateTo(page, "/signer"); + + await whenClicking(page, "CTA_command-Ethereum"); + + await whenExecuteDeviceAction(page, "Get address", { + inputField: "input-text_derivationPath", + inputValue: "44'/60'/0'/0/0", + }); + }); + + await test.step("Then verify that response is successful and it contains an address and pKey", async () => { + await page.waitForTimeout(1000); + + const response = (await getLastDeviceResponseContent( + page, + )) as GetAddressResponse; + + expect(response.status).toBe("completed"); + expect(isValidEthereumAddress(response?.output?.address || "")).toBe( + true, + ); + expect(isValidPublicKey(response?.output?.publicKey || "")).toBe(true); + }); + }); + + test("device should return ETH pubKey and address when fed default derivation path and wait to checked on device", async ({ + page, + }) => { + await test.step("Given first device is connected", async () => { + await whenConnectingDevice(page); + + await thenDeviceIsConnected(page, 0); + }); + + await test.step("When execute ETH: get address with checkOnDevice on", async () => { + await whenNavigateTo(page, "/signer"); + + await whenClicking(page, "CTA_command-Ethereum"); + + await whenClicking(page, "CTA_command-Get address"); + + await whenClicking(page, "input-switch_checkOnDevice"); + + await whenExecute("device-action")(page, "Get address", { + inputField: "input-text_derivationPath", + inputValue: "44'/60'/0'/0/0", + }); + }); + + await test.step("Then verify that response is successful and it contains an address and pKey after timeout", async () => { + await page.waitForTimeout(1000); + expect( + ((await getLastDeviceResponseContent(page)) as GetAddressResponse) + .status, + ).toBe("pending"); + + await page.waitForTimeout(1000); + expect( + ((await getLastDeviceResponseContent(page)) as GetAddressResponse) + .status, + ).toBe("pending"); + + await page.waitForTimeout(2000); + const response = (await getLastDeviceResponseContent( + page, + )) as GetAddressResponse; + + expect(response.status).toBe("completed"); + expect(isValidEthereumAddress(response?.output?.address || "")).toBe( + true, + ); + expect(isValidPublicKey(response?.output?.publicKey || "")).toBe(true); + }); + }); + + test("device should return a different ETH pubKey and address when fed a different derivation path", async ({ + page, + }) => { + await test.step("Given first device is connected", async () => { + await whenConnectingDevice(page); + + await thenDeviceIsConnected(page, 0); + }); + + await test.step("Then execute ETH: get address", async () => { + await whenNavigateTo(page, "/signer"); + + await whenClicking(page, "CTA_command-Ethereum"); + + await whenExecuteDeviceAction(page, "Get address", { + inputField: "input-text_derivationPath", + inputValue: "44'/60'/0'/0/0", + }); + }); + + await test.step("Then veryfy that the address with a different derivation path is different", async () => { + await page.waitForTimeout(1000); + + const addressWithFirstAddressIndex = ( + (await getLastDeviceResponseContent(page)) as GetAddressResponse + )?.output?.address; + + await whenExecute("device-action")(page, "Get address", { + inputField: "input-text_derivationPath", + inputValue: "44'/60'/0'/0/1", + }); + + await page.waitForTimeout(1000); + + const addressWithSecondAddressIndex = ( + (await getLastDeviceResponseContent(page)) as GetAddressResponse + )?.output?.address; + + expect(addressWithFirstAddressIndex).not.toBe( + addressWithSecondAddressIndex, + ); + }); + }); +}); diff --git a/apps/sample/playwright/cases/eth_get-address_unhappy-paths.spec.ts b/apps/sample/playwright/cases/eth_get-address_unhappy-paths.spec.ts new file mode 100644 index 000000000..35789636d --- /dev/null +++ b/apps/sample/playwright/cases/eth_get-address_unhappy-paths.spec.ts @@ -0,0 +1,79 @@ +/* eslint-disable no-restricted-imports */ +import { expect, test } from "@playwright/test"; + +import { thenDeviceIsConnected } from "../utils/thenHandlers"; +import { getLastDeviceResponseContent } from "../utils/utils"; +import { + whenClicking, + whenConnectingDevice, + whenExecute, + whenExecuteDeviceAction, + whenNavigateTo, +} from "../utils/whenHandlers"; + +interface GetAddressResponse { + status: string; + output?: { + publicKey: string; + address: string; + }; + error?: object; + pending?: object; +} + +test.describe("ETH Signer: get address, unhappy paths", () => { + test.beforeEach(async ({ page }) => { + await page.goto("http://localhost:3000/"); + }); + + test("device should return error if pub key is malformed", async ({ + page, + }) => { + await test.step("Given first device is connected", async () => { + await whenConnectingDevice(page); + + await thenDeviceIsConnected(page, 0); + }); + + await test.step("Then execute ETH: get address with malformed derivation paths", async () => { + await whenNavigateTo(page, "/signer"); + await whenClicking(page, "CTA_command-Ethereum"); + + const malformedDerivationPaths = [ + "aa'/60'/0'/0/0", + "44'/aa'/0'/0/0", + "44'/60'/aa'/0/0", + "44'/60'/0'/aa/0", + "44'/60'/0'/0/aa", + ]; + + await whenExecuteDeviceAction(page, "Get address", { + inputField: "input-text_derivationPath", + inputValue: malformedDerivationPaths[0], + }); + + await page.waitForTimeout(1000); + + expect( + ((await getLastDeviceResponseContent(page)) as GetAddressResponse) + .status, + ).toBe("error"); + + for (let i = 1; i < malformedDerivationPaths.length; i++) { + const path = malformedDerivationPaths[i]; + + await whenExecute("device-action")(page, "Get address", { + inputField: "input-text_derivationPath", + inputValue: path, + }); + + await page.waitForTimeout(1000); + + expect( + ((await getLastDeviceResponseContent(page)) as GetAddressResponse) + .status, + ).toBe("error"); + } + }); + }); +}); diff --git a/apps/sample/playwright/cases/eth_sign-message_happy-paths.spec.ts b/apps/sample/playwright/cases/eth_sign-message_happy-paths.spec.ts new file mode 100644 index 000000000..33b8c3b98 --- /dev/null +++ b/apps/sample/playwright/cases/eth_sign-message_happy-paths.spec.ts @@ -0,0 +1,193 @@ +/* eslint-disable no-restricted-imports */ +import { expect, test } from "@playwright/test"; + +import { thenDeviceIsConnected } from "../utils/thenHandlers"; +import { getLastDeviceResponseContent, isValid256BitHex } from "../utils/utils"; +import { + whenClicking, + whenConnectingDevice, + whenExecute, + whenExecuteDeviceAction, + whenNavigateTo, +} from "../utils/whenHandlers"; + +interface SignMessageResponse { + status: string; + output?: { + r: string; + s: string; + v: string; + }; + error?: object; + pending?: object; +} + +test.describe("ETH Signer: sign message, happy paths", () => { + test.beforeEach(async ({ page }) => { + await page.goto("http://localhost:3000/"); + }); + + test("device should sign a message when fed default derivation path", async ({ + page, + }) => { + await test.step("Given first device is connected", async () => { + await whenConnectingDevice(page); + + await thenDeviceIsConnected(page, 0); + }); + + await test.step("When execute ETH: sign message", async () => { + await whenNavigateTo(page, "/signer"); + await whenClicking(page, "CTA_command-Ethereum"); + + await whenExecuteDeviceAction(page, "Sign message", [ + { + inputField: "input-text_derivationPath", + inputValue: "44'/60'/0'/0/0", + }, + { + inputField: "input-text_message", + inputValue: "hello, world!", + }, + ]); + }); + + await test.step("Then verify the response is successful and contains signed message", async () => { + await page.waitForTimeout(1000); + + const response = (await getLastDeviceResponseContent( + page, + )) as SignMessageResponse; + + expect(response.status).toBe("completed"); + expect(isValid256BitHex(response?.output?.r || "")).toBe(true); + expect(isValid256BitHex(response?.output?.s || "")).toBe(true); + }); + }); + + test("device should output a different result when fed a different derivation path", async ({ + page, + }) => { + await test.step("Given first device is connected", async () => { + await whenConnectingDevice(page); + + await thenDeviceIsConnected(page, 0); + }); + + await test.step("When execute ETH: sign message", async () => { + await whenNavigateTo(page, "/signer"); + + await whenClicking(page, "CTA_command-Ethereum"); + + await whenExecuteDeviceAction(page, "Sign message", [ + { + inputField: "input-text_derivationPath", + inputValue: "44'/60'/0'/0/0", + }, + { + inputField: "input-text_message", + inputValue: "hello, world!", + }, + ]); + }); + + await test.step("Then verify the response with different address index is successful and contains a different signed message", async () => { + await page.waitForTimeout(1000); + + const responseWithDefaultDerivationPath = + (await getLastDeviceResponseContent(page)) as SignMessageResponse; + + await whenExecute("device-action")(page, "Sign message", [ + { + inputField: "input-text_derivationPath", + inputValue: "44'/60'/0'/0/1", + }, + { + inputField: "input-text_message", + inputValue: "hello, world!", + }, + ]); + + await page.waitForTimeout(1000); + + const responseWithSecondDerivationPath = + (await getLastDeviceResponseContent(page)) as SignMessageResponse; + + expect(responseWithDefaultDerivationPath?.output?.r).toBeDefined(); + expect(responseWithDefaultDerivationPath?.output?.s).toBeDefined(); + expect(responseWithSecondDerivationPath?.output?.r).toBeDefined(); + expect(responseWithSecondDerivationPath?.output?.s).toBeDefined(); + + expect(responseWithDefaultDerivationPath?.output?.r).not.toBe( + responseWithSecondDerivationPath?.output?.r, + ); + expect(responseWithDefaultDerivationPath?.output?.s).not.toBe( + responseWithSecondDerivationPath?.output?.s, + ); + }); + }); + + test("device should output a different result when fed a different message", async ({ + page, + }) => { + await test.step("Given first device is connected", async () => { + await whenConnectingDevice(page); + + await thenDeviceIsConnected(page, 0); + }); + + await test.step("When execute ETH: sign message", async () => { + await whenNavigateTo(page, "/signer"); + + await whenClicking(page, "CTA_command-Ethereum"); + + await whenExecuteDeviceAction(page, "Sign message", [ + { + inputField: "input-text_derivationPath", + inputValue: "44'/60'/0'/0/0", + }, + { + inputField: "input-text_message", + inputValue: "hello, world!", + }, + ]); + }); + + await test.step("Then verify the response with different message is successful and contains a different signed message", async () => { + await page.waitForTimeout(1000); + + const responseWithDefaultMessage = (await getLastDeviceResponseContent( + page, + )) as SignMessageResponse; + + await whenExecute("device-action")(page, "Sign message", [ + { + inputField: "input-text_derivationPath", + inputValue: "44'/60'/0'/0/0", + }, + { + inputField: "input-text_message", + inputValue: "Bonjour le monde!", + }, + ]); + + await page.waitForTimeout(1000); + + const responseWithSecondMessage = (await getLastDeviceResponseContent( + page, + )) as SignMessageResponse; + + expect(responseWithDefaultMessage?.output?.r).toBeDefined(); + expect(responseWithDefaultMessage?.output?.s).toBeDefined(); + expect(responseWithSecondMessage?.output?.r).toBeDefined(); + expect(responseWithSecondMessage?.output?.s).toBeDefined(); + + expect(responseWithDefaultMessage?.output?.r).not.toBe( + responseWithSecondMessage?.output?.r, + ); + expect(responseWithDefaultMessage?.output?.s).not.toBe( + responseWithSecondMessage?.output?.s, + ); + }); + }); +}); diff --git a/apps/sample/playwright/cases/eth_sign-message_unhappy-paths.spec.ts b/apps/sample/playwright/cases/eth_sign-message_unhappy-paths.spec.ts new file mode 100644 index 000000000..edf77039f --- /dev/null +++ b/apps/sample/playwright/cases/eth_sign-message_unhappy-paths.spec.ts @@ -0,0 +1,92 @@ +/* eslint-disable no-restricted-imports */ +import { expect, test } from "@playwright/test"; + +import { thenDeviceIsConnected } from "../utils/thenHandlers"; +import { getLastDeviceResponseContent } from "../utils/utils"; +import { + whenClicking, + whenConnectingDevice, + whenExecute, + whenExecuteDeviceAction, + whenNavigateTo, +} from "../utils/whenHandlers"; + +interface SignMessageResponse { + status: string; + output?: { + r: string; + s: string; + v: string; + }; + error?: object; + pending?: object; +} + +test.describe("ETH Signer: sign message, unhappy paths", () => { + test.beforeEach(async ({ page }) => { + await page.goto("http://localhost:3000/"); + }); + + test("device should return error if derivation path is malformed when signing a message", async ({ + page, + }) => { + await test.step("Given first device is connected", async () => { + await whenConnectingDevice(page); + + await thenDeviceIsConnected(page, 0); + }); + + await test.step("When execute ETH: sign message with malformed derivation paths", async () => { + await whenNavigateTo(page, "/signer"); + await whenClicking(page, "CTA_command-Ethereum"); + + const malformedDerivationPaths = [ + "aa'/60'/0'/0/0", + "44'/aa'/0'/0/0", + "44'/60'/aa'/0/0", + "44'/60'/0'/aa/0", + "44'/60'/0'/0/aa", + ]; + + await whenExecuteDeviceAction(page, "Sign message", [ + { + inputField: "input-text_derivationPath", + inputValue: malformedDerivationPaths[0], + }, + { + inputField: "input-text_message", + inputValue: "hello, world!", + }, + ]); + + await page.waitForTimeout(1000); + + expect( + ((await getLastDeviceResponseContent(page)) as SignMessageResponse) + .status, + ).toBe("error"); + + for (let i = 1; i < malformedDerivationPaths.length; i++) { + const path = malformedDerivationPaths[i]; + + await whenExecute("device-action")(page, "Sign message", [ + { + inputField: "input-text_derivationPath", + inputValue: path, + }, + { + inputField: "input-text_message", + inputValue: "hello, world!", + }, + ]); + + await page.waitForTimeout(1000); + + expect( + ((await getLastDeviceResponseContent(page)) as SignMessageResponse) + .status, + ).toBe("error"); + } + }); + }); +}); diff --git a/apps/sample/playwright/cases/eth_sign-transaction_happy-paths.spec.ts b/apps/sample/playwright/cases/eth_sign-transaction_happy-paths.spec.ts new file mode 100644 index 000000000..b15fbabfd --- /dev/null +++ b/apps/sample/playwright/cases/eth_sign-transaction_happy-paths.spec.ts @@ -0,0 +1,133 @@ +/* eslint-disable no-restricted-imports */ +import { expect, test } from "@playwright/test"; + +import { thenDeviceIsConnected } from "../utils/thenHandlers"; +import { getLastDeviceResponseContent, isValid256BitHex } from "../utils/utils"; +import { + whenClicking, + whenConnectingDevice, + whenExecute, + whenExecuteDeviceAction, + whenNavigateTo, +} from "../utils/whenHandlers"; + +interface SignTransactionResponse { + status: string; + output?: { + r: string; + s: string; + v: string; + }; + error?: object; + pending?: object; +} + +test.describe("ETH Signer: sign transaction, happy paths", () => { + test.beforeEach(async ({ page }) => { + await page.goto("http://localhost:3000/"); + }); + + const rawTransactionHex = + "0x02f8b4018325554c847735940085022d0b7c608307a12094dac17f958d2ee523a2206206994597c13d831ec780b844a9059cbb000000000000000000000000920ab45225b3057293e760a3c2d74643ad696a1b000000000000000000000000000000000000000000000000000000012a05f200c080a009e2ef5a2c4b7a1d7f0d868388f3949a00a1bdc5669c59b73e57b2a4e7c5e29fa0754aa9f4f1acc99561678492a20c31e01da27d648e69665f7768f96db39220ca"; + + test("device should sign a transaction when fed default derivation path", async ({ + page, + }) => { + await test.step("Given first device is connected", async () => { + await whenConnectingDevice(page); + + await thenDeviceIsConnected(page, 0); + }); + + await test.step("When execute ETH: sign transaction", async () => { + await whenNavigateTo(page, "/signer"); + + await whenClicking(page, "CTA_command-Ethereum"); + + await whenExecuteDeviceAction(page, "Sign transaction", [ + { + inputField: "input-text_derivationPath", + inputValue: "44'/60'/0'/0/0", + }, + { + inputField: "input-text_transaction", + inputValue: rawTransactionHex, + }, + ]); + }); + + await test.step("Then verify the response is successful and contains signed transaction", async () => { + await page.waitForTimeout(1000); + + const response = (await getLastDeviceResponseContent( + page, + )) as SignTransactionResponse; + + expect(response.status).toBe("completed"); + expect(isValid256BitHex(response?.output?.r || "")).toBe(true); + expect(isValid256BitHex(response?.output?.s || "")).toBe(true); + }); + }); + + test("device should output a different result when fed a different derivation path", async ({ + page, + }) => { + await test.step("Given first device is connected", async () => { + await whenConnectingDevice(page); + + await thenDeviceIsConnected(page, 0); + }); + + await test.step("When execute ETH: sign transaction", async () => { + await whenNavigateTo(page, "/signer"); + + await whenClicking(page, "CTA_command-Ethereum"); + + await whenExecuteDeviceAction(page, "Sign transaction", [ + { + inputField: "input-text_derivationPath", + inputValue: "44'/60'/0'/0/0", + }, + { + inputField: "input-text_transaction", + inputValue: rawTransactionHex, + }, + ]); + }); + + await test.step("Then verify the response with different address index is successful and contains a different signed message", async () => { + await page.waitForTimeout(1000); + + const responseWithDefaultDerivationPath = + (await getLastDeviceResponseContent(page)) as SignTransactionResponse; + + await whenExecute("device-action")(page, "Sign transaction", [ + { + inputField: "input-text_derivationPath", + inputValue: "44'/60'/0'/0/1", + }, + { + inputField: "input-text_transaction", + inputValue: rawTransactionHex, + }, + ]); + + await page.waitForTimeout(1000); + + const responseWithSecondDerivationPath = + (await getLastDeviceResponseContent(page)) as SignTransactionResponse; + + expect(responseWithDefaultDerivationPath?.output?.r).toBeDefined(); + expect(responseWithDefaultDerivationPath?.output?.s).toBeDefined(); + expect(responseWithSecondDerivationPath?.output?.r).toBeDefined(); + expect(responseWithSecondDerivationPath?.output?.s).toBeDefined(); + + expect(responseWithDefaultDerivationPath?.output?.r).not.toBe( + responseWithSecondDerivationPath?.output?.r, + ); + expect(responseWithDefaultDerivationPath?.output?.s).not.toBe( + responseWithSecondDerivationPath?.output?.s, + ); + }); + }); +}); diff --git a/apps/sample/playwright/cases/eth_sign-transaction_unhappy-paths.spec.ts b/apps/sample/playwright/cases/eth_sign-transaction_unhappy-paths.spec.ts new file mode 100644 index 000000000..35b79dd58 --- /dev/null +++ b/apps/sample/playwright/cases/eth_sign-transaction_unhappy-paths.spec.ts @@ -0,0 +1,98 @@ +/* eslint-disable no-restricted-imports */ +import { expect, test } from "@playwright/test"; + +import { thenDeviceIsConnected } from "../utils/thenHandlers"; +import { getLastDeviceResponseContent } from "../utils/utils"; +import { + whenClicking, + whenConnectingDevice, + whenExecute, + whenExecuteDeviceAction, + whenNavigateTo, +} from "../utils/whenHandlers"; + +interface SignTransactionResponse { + status: string; + output?: { + r: string; + s: string; + v: string; + }; + error?: object; + pending?: object; +} + +test.describe("ETH Signer: sign transaction, unhappy paths", () => { + test.beforeEach(async ({ page }) => { + await page.goto("http://localhost:3000/"); + }); + + test("device should return error if derivation path is malformed when signing a transaction", async ({ + page, + }) => { + await test.step("Given first device is connected", async () => { + await whenConnectingDevice(page); + + await thenDeviceIsConnected(page, 0); + }); + + await test.step("When execute ETH: sign transaction with malformed derivation paths", async () => { + await whenNavigateTo(page, "/signer"); + await whenClicking(page, "CTA_command-Ethereum"); + + const malformedDerivationPaths = [ + "aa'/60'/0'/0/0", + "44'/aa'/0'/0/0", + "44'/60'/aa'/0/0", + "44'/60'/0'/aa/0", + "44'/60'/0'/0/aa", + ]; + + const transactionHex = + "0x02f8b4018325554c847735940085022d0b7c608307a12094dac17f958d2ee523a2206206994597c13d831ec780b844a9059cbb000000000000000000000000920ab45225b3057293e760a3c2d74643ad696a1b000000000000000000000000000000000000000000000000000000012a05f200c080a009e2ef5a2c4b7a1d7f0d868388f3949a00a1bdc5669c59b73e57b2a4e7c5e29fa0754aa9f4f1acc99561678492a20c31e01da27d648e69665f7768f96db39220ca"; + + await whenExecuteDeviceAction(page, "Sign transaction", [ + { + inputField: "input-text_derivationPath", + inputValue: malformedDerivationPaths[0], + }, + { + inputField: "input-text_transaction", + inputValue: transactionHex, + }, + ]); + + await page.waitForTimeout(1000); + + expect( + ((await getLastDeviceResponseContent(page)) as SignTransactionResponse) + .status, + ).toBe("error"); + + for (let i = 1; i < malformedDerivationPaths.length; i++) { + const path = malformedDerivationPaths[i]; + + await whenExecute("device-action")(page, "Sign transaction", [ + { + inputField: "input-text_derivationPath", + inputValue: path, + }, + { + inputField: "input-text_transaction", + inputValue: transactionHex, + }, + ]); + + await page.waitForTimeout(1000); + + expect( + ( + (await getLastDeviceResponseContent( + page, + )) as SignTransactionResponse + ).status, + ).toBe("error"); + } + }); + }); +}); diff --git a/apps/sample/playwright/cases/eth_sign-typed-message-happy-paths.spec.ts b/apps/sample/playwright/cases/eth_sign-typed-message-happy-paths.spec.ts new file mode 100644 index 000000000..233231a19 --- /dev/null +++ b/apps/sample/playwright/cases/eth_sign-typed-message-happy-paths.spec.ts @@ -0,0 +1,197 @@ +/* eslint-disable no-restricted-imports */ +import { expect, test } from "@playwright/test"; + +import { thenDeviceIsConnected } from "../utils/thenHandlers"; +import { getLastDeviceResponseContent, isValid256BitHex } from "../utils/utils"; +import { + whenClicking, + whenConnectingDevice, + whenExecute, + whenExecuteDeviceAction, + whenNavigateTo, +} from "../utils/whenHandlers"; + +interface SignEIP712MessageResponse { + status: string; + output?: { + r: string; + s: string; + v: string; + }; + error?: object; + pending?: object; +} + +test.describe("ETH Signer: sign EIP712 message, happy paths", () => { + test.beforeEach(async ({ page }) => { + await page.goto("http://localhost:3000/"); + }); + + test("device should sign an EIP712 message when fed default derivation path", async ({ + page, + }) => { + await test.step("Given first device is connected", async () => { + await whenConnectingDevice(page); + + await thenDeviceIsConnected(page, 0); + }); + + await test.step("When execute ETH: sign EIP712 message", async () => { + await whenNavigateTo(page, "/signer"); + await whenClicking(page, "CTA_command-Ethereum"); + + await whenExecuteDeviceAction(page, "Sign typed message", [ + { + inputField: "input-text_derivationPath", + inputValue: "44'/60'/0'/0/0", + }, + { + inputField: "input-text_message", + inputValue: + '{"domain":{"name":"USD Coin","verifyingContract":"0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48","chainId":1,"version":"2"},"primaryType":"Permit","message":{"deadline":1718992051,"nonce":0,"spender":"0x111111125421ca6dc452d289314280a0f8842a65","owner":"0x6cbcd73cd8e8a42844662f0a0e76d7f79afd933d","value":"115792089237316195423570985008687907853269984665640564039457584007913129639935"},"types":{"EIP712Domain":[{"name":"name","type":"string"},{"name":"version","type":"string"},{"name":"chainId","type":"uint256"},{"name":"verifyingContract","type":"address"}],"Permit":[{"name":"owner","type":"address"},{"name":"spender","type":"address"},{"name":"value","type":"uint256"},{"name":"nonce","type":"uint256"},{"name":"deadline","type":"uint256"}]}}', + }, + ]); + }); + + await test.step("Then verify the response is successful and contains signed message", async () => { + await page.waitForTimeout(1000); + + const response = (await getLastDeviceResponseContent( + page, + )) as SignEIP712MessageResponse; + + expect(response.status).toBe("completed"); + expect(isValid256BitHex(response?.output?.r || "")).toBe(true); + expect(isValid256BitHex(response?.output?.s || "")).toBe(true); + }); + }); + + test("device should output a different result when fed a different derivation path", async ({ + page, + }) => { + await test.step("Given first device is connected", async () => { + await whenConnectingDevice(page); + + await thenDeviceIsConnected(page, 0); + }); + + await test.step("When execute ETH: sign EIP712 message", async () => { + await whenNavigateTo(page, "/signer"); + await whenClicking(page, "CTA_command-Ethereum"); + + await whenExecuteDeviceAction(page, "Sign typed message", [ + { + inputField: "input-text_derivationPath", + inputValue: "44'/60'/0'/0/0", + }, + { + inputField: "input-text_message", + inputValue: + '{"domain":{"name":"USD Coin","verifyingContract":"0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48","chainId":1,"version":"2"},"primaryType":"Permit","message":{"deadline":1718992051,"nonce":0,"spender":"0x111111125421ca6dc452d289314280a0f8842a65","owner":"0x6cbcd73cd8e8a42844662f0a0e76d7f79afd933d","value":"115792089237316195423570985008687907853269984665640564039457584007913129639935"},"types":{"EIP712Domain":[{"name":"name","type":"string"},{"name":"version","type":"string"},{"name":"chainId","type":"uint256"},{"name":"verifyingContract","type":"address"}],"Permit":[{"name":"owner","type":"address"},{"name":"spender","type":"address"},{"name":"value","type":"uint256"},{"name":"nonce","type":"uint256"},{"name":"deadline","type":"uint256"}]}}', + }, + ]); + }); + + await test.step("Then verify the response with different address index is successful and contains a different signed message", async () => { + await page.waitForTimeout(1000); + + const responseWithDefaultDerivationPath = + (await getLastDeviceResponseContent(page)) as SignEIP712MessageResponse; + + await whenExecute("device-action")(page, "Sign typed message", [ + { + inputField: "input-text_derivationPath", + inputValue: "44'/60'/0'/0/1", + }, + { + inputField: "input-text_message", + inputValue: + '{"domain":{"name":"USD Coin","verifyingContract":"0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48","chainId":1,"version":"2"},"primaryType":"Permit","message":{"deadline":1718992051,"nonce":0,"spender":"0x111111125421ca6dc452d289314280a0f8842a65","owner":"0x6cbcd73cd8e8a42844662f0a0e76d7f79afd933d","value":"115792089237316195423570985008687907853269984665640564039457584007913129639935"},"types":{"EIP712Domain":[{"name":"name","type":"string"},{"name":"version","type":"string"},{"name":"chainId","type":"uint256"},{"name":"verifyingContract","type":"address"}],"Permit":[{"name":"owner","type":"address"},{"name":"spender","type":"address"},{"name":"value","type":"uint256"},{"name":"nonce","type":"uint256"},{"name":"deadline","type":"uint256"}]}}', + }, + ]); + + await page.waitForTimeout(1000); + + const responseWithSecondDerivationPath = + (await getLastDeviceResponseContent(page)) as SignEIP712MessageResponse; + + expect(responseWithDefaultDerivationPath?.output?.r).toBeDefined(); + expect(responseWithDefaultDerivationPath?.output?.s).toBeDefined(); + expect(responseWithSecondDerivationPath?.output?.r).toBeDefined(); + expect(responseWithSecondDerivationPath?.output?.s).toBeDefined(); + + expect(responseWithDefaultDerivationPath?.output?.r).not.toBe( + responseWithSecondDerivationPath?.output?.r, + ); + expect(responseWithDefaultDerivationPath?.output?.s).not.toBe( + responseWithSecondDerivationPath?.output?.s, + ); + }); + }); + + test("device should output a different result when fed a different EIP712 message", async ({ + page, + }) => { + await test.step("Given first device is connected", async () => { + await whenConnectingDevice(page); + + await thenDeviceIsConnected(page, 0); + }); + + await test.step("When execute ETH: sign EIP712 message", async () => { + await whenNavigateTo(page, "/signer"); + + await whenClicking(page, "CTA_command-Ethereum"); + + await whenExecuteDeviceAction(page, "Sign typed message", [ + { + inputField: "input-text_derivationPath", + inputValue: "44'/60'/0'/0/0", + }, + { + inputField: "input-text_message", + inputValue: + '{"domain":{"name":"USD Coin","verifyingContract":"0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48","chainId":1,"version":"2"},"primaryType":"Permit","message":{"deadline":1718992051,"nonce":0,"spender":"0x111111125421ca6dc452d289314280a0f8842a65","owner":"0x6cbcd73cd8e8a42844662f0a0e76d7f79afd933d","value":"115792089237316195423570985008687907853269984665640564039457584007913129639935"},"types":{"EIP712Domain":[{"name":"name","type":"string"},{"name":"version","type":"string"},{"name":"chainId","type":"uint256"},{"name":"verifyingContract","type":"address"}],"Permit":[{"name":"owner","type":"address"},{"name":"spender","type":"address"},{"name":"value","type":"uint256"},{"name":"nonce","type":"uint256"},{"name":"deadline","type":"uint256"}]}}', + }, + ]); + }); + + await test.step("Then verify the response with different EIP712 message is successful and contains a different signed message", async () => { + await page.waitForTimeout(1000); + + const responseWithDefaultMessage = (await getLastDeviceResponseContent( + page, + )) as SignEIP712MessageResponse; + + await whenExecute("device-action")(page, "Sign typed message", [ + { + inputField: "input-text_derivationPath", + inputValue: "44'/60'/0'/0/0", + }, + { + inputField: "input-text_message", + inputValue: + '{"domain":{"name":"USD Coin","verifyingContract":"0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48","chainId":1,"version":"2"},"primaryType":"Permit","message":{"deadline":1718992051,"nonce":0,"spender":"0x111111125421ca6dc452d289314280a0f8842a65","owner":"0x6cbcd73cd8e8a42844662f0a0e76d7f79afda5cd","value":"115792089237316195423570985008687907853269984665640564039457584007913129639935"},"types":{"EIP712Domain":[{"name":"name","type":"string"},{"name":"version","type":"string"},{"name":"chainId","type":"uint256"},{"name":"verifyingContract","type":"address"}],"Permit":[{"name":"owner","type":"address"},{"name":"spender","type":"address"},{"name":"value","type":"uint256"},{"name":"nonce","type":"uint256"},{"name":"deadline","type":"uint256"}]}}', + }, + ]); + + await page.waitForTimeout(1000); + + const responseWithSecondMessage = (await getLastDeviceResponseContent( + page, + )) as SignEIP712MessageResponse; + + expect(responseWithDefaultMessage?.output?.r).toBeDefined(); + expect(responseWithDefaultMessage?.output?.s).toBeDefined(); + expect(responseWithSecondMessage?.output?.r).toBeDefined(); + expect(responseWithSecondMessage?.output?.s).toBeDefined(); + + expect(responseWithDefaultMessage?.output?.r).not.toBe( + responseWithSecondMessage?.output?.r, + ); + expect(responseWithDefaultMessage?.output?.s).not.toBe( + responseWithSecondMessage?.output?.s, + ); + }); + }); +}); diff --git a/apps/sample/playwright/cases/eth_sign-typed-message-unhappy-paths.spec.ts b/apps/sample/playwright/cases/eth_sign-typed-message-unhappy-paths.spec.ts new file mode 100644 index 000000000..81ea18096 --- /dev/null +++ b/apps/sample/playwright/cases/eth_sign-typed-message-unhappy-paths.spec.ts @@ -0,0 +1,101 @@ +/* eslint-disable no-restricted-imports */ +import { expect, test } from "@playwright/test"; + +import { thenDeviceIsConnected } from "../utils/thenHandlers"; +import { getLastDeviceResponseContent } from "../utils/utils"; +import { + whenClicking, + whenConnectingDevice, + whenExecute, + whenExecuteDeviceAction, + whenNavigateTo, +} from "../utils/whenHandlers"; + +interface SignEIP712MessageResponse { + status: string; + output?: { + r: string; + s: string; + v: string; + }; + error?: object; + pending?: object; +} + +test.describe("ETH Signer: sign EIP712 message, unhappy paths", () => { + test.beforeEach(async ({ page }) => { + await page.goto("http://localhost:3000/"); + }); + + test("device should return error if derivation path is malformed when signing a typed message", async ({ + page, + }) => { + await test.step("Given first device is connected", async () => { + await whenConnectingDevice(page); + + await thenDeviceIsConnected(page, 0); + }); + + await test.step("When execute ETH: sign typed message with malformed derivation paths", async () => { + await whenNavigateTo(page, "/signer"); + + await whenClicking(page, "CTA_command-Ethereum"); + + const malformedDerivationPaths = [ + "aa'/60'/0'/0/0", + "44'/aa'/0'/0/0", + "44'/60'/aa'/0/0", + "44'/60'/0'/aa/0", + "44'/60'/0'/0/aa", + ]; + + const message = `{"domain":{"name":"USD Coin","verifyingContract":"0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48","chainId":1,"version":"2"},"primaryType":"Permit","message":{"deadline":1718992051,"nonce":0,"spender":"0x111111125421ca6dc452d289314280a0f8842a65","owner":"0x6cbcd73cd8e8a42844662f0a0e76d7f79afd933d","value":"115792089237316195423570985008687907853269984665640564039457584007913129639935"},"types":{"EIP712Domain":[{"name":"name","type":"string"},{"name":"version","type":"string"},{"name":"chainId","type":"uint256"},{"name":"verifyingContract","type":"address"}],"Permit":[{"name":"owner","type":"address"},{"name":"spender","type":"address"},{"name":"value","type":"uint256"},{"name":"nonce","type":"uint256"},{"name":"deadline","type":"uint256"}]}}`; + + await whenExecuteDeviceAction(page, "Sign typed message", [ + { + inputField: "input-text_derivationPath", + inputValue: malformedDerivationPaths[0], + }, + { + inputField: "input-text_message", + inputValue: message, + }, + ]); + + await page.waitForTimeout(1000); + + expect( + ( + (await getLastDeviceResponseContent( + page, + )) as SignEIP712MessageResponse + ).status, + ).toBe("error"); + + for (let i = 1; i < malformedDerivationPaths.length; i++) { + const path = malformedDerivationPaths[i]; + + await whenExecute("device-action")(page, "Sign typed message", [ + { + inputField: "input-text_derivationPath", + inputValue: path, + }, + { + inputField: "input-text_message", + inputValue: message, + }, + ]); + + await page.waitForTimeout(1000); + + expect( + ( + (await getLastDeviceResponseContent( + page, + )) as SignEIP712MessageResponse + ).status, + ).toBe("error"); + } + }); + }); +}); diff --git a/apps/sample/playwright/start-servers.sh b/apps/sample/playwright/start-servers.sh new file mode 100644 index 000000000..bd62ae8ae --- /dev/null +++ b/apps/sample/playwright/start-servers.sh @@ -0,0 +1,26 @@ +#!/bin/bash + +#echo "Starting mock server..." +#(cd ../../../.. && cd device-sdk-mock-webserver && ./gradlew run) & +#MOCK_SERVER_PID=$! +# +#while ! nc -z localhost 8080; do +# echo "Waiting for mock server to start..." +# sleep 1 +#done +#echo "mock server is up!" + +echo "Starting sample app..." +(cd .. && pnpm sample dev:default-mock) & +SAMPLE_APP_PID=$! + +while ! nc -z localhost 3000; do + echo "Waiting for sample app to start..." + sleep 1 +done +echo "sample app is up!" + +# trap to kill the background processes on script exit +trap "kill $MOCK_SERVER_PID $SAMPLE_APP_PID" EXIT + +wait $MOCK_SERVER_PID $SAMPLE_APP_PID diff --git a/apps/sample/playwright/utils/thenHandlers.ts b/apps/sample/playwright/utils/thenHandlers.ts new file mode 100644 index 000000000..194fc574b --- /dev/null +++ b/apps/sample/playwright/utils/thenHandlers.ts @@ -0,0 +1,57 @@ +import { expect, type Locator, type Page } from "@playwright/test"; + +import { asyncPipe } from "@/utils/pipes"; + +const getDeviceLocator = + (deviceIndex: number = 0) => + (page: Page): Page => { + const targetChild = page + .getByTestId("container_devices") + .locator("> *") + .nth(deviceIndex); + return targetChild as unknown as Page; + }; + +const verifyDeviceConnectedStatus = async ( + locator: Locator, +): Promise => { + await expect( + locator.getByTestId("text_device-connection-status"), + ).toContainText("CONNECTED"); + await expect( + locator.getByTestId("text_device-connection-status"), + ).toBeVisible(); + return locator; +}; + +export const thenDeviceIsConnected = ( + page: Page, + deviceIndex: number = 0, +): Promise => + asyncPipe(getDeviceLocator(deviceIndex), verifyDeviceConnectedStatus)(page); + +const getAllDeviceNames = async (page: Page): Promise => { + return page + .getByTestId("container_devices") + .locator("> *") + .getByTestId("text_device-name") + .allTextContents(); +}; + +const verifyDevicesNotVisible = + (deviceNames: string[]) => + async (page: Page): Promise => { + await Promise.all( + deviceNames.map(async (deviceName) => { + await expect( + page + .getByTestId("text_device-name") + .locator(`:has-text("${deviceName}")`), + ).not.toBeVisible(); + }), + ); + return page; + }; + +export const thenNoDeviceIsConnected = (page: Page): Promise => + asyncPipe(getAllDeviceNames, verifyDevicesNotVisible)(page); diff --git a/apps/sample/playwright/utils/utils.ts b/apps/sample/playwright/utils/utils.ts new file mode 100644 index 000000000..3af053ad7 --- /dev/null +++ b/apps/sample/playwright/utils/utils.ts @@ -0,0 +1,89 @@ +import { type Locator, type Page } from "@playwright/test"; + +import { asyncPipe } from "@/utils/pipes"; + +export const getScreenshot = async ( + page: Page, + title: string = "screenshot", +): Promise => { + await page.screenshot({ + path: `./playwright/${title}.png`, + fullPage: true, + }); +}; + +const getResponses = async (page: Page): Promise => { + return page + .locator('[data-testid="box_device-commands-responses"] > *') + .all(); +}; + +const filterNonHiddenElements = async ( + responses: Locator[], +): Promise => { + const results = await Promise.all( + responses.map(async (response) => { + const isHidden = await response.evaluate((el) => { + const style = getComputedStyle(el); + return style.display === "none" || style.visibility === "hidden"; + }); + return !isHidden ? response : null; + }), + ); + return results.filter((child) => child !== null) as Locator[]; +}; + +const getLastResponse = (responses: Locator[]): Locator | null => { + return responses.length > 0 ? responses[responses.length - 1] : null; +}; + +const getLastChildOfElementByTag = + (tagType: string) => + async (element: Locator | null): Promise => { + if (!element) return null; + try { + const children = element.locator(`:scope > ${tagType}`); + const lastChild = children.last(); + await lastChild.waitFor({ state: "attached" }); + return lastChild; + } catch (error) { + console.error( + `Error getting last child of type '${tagType}' for element: ${error}`, + ); + return null; + } + }; + +const parseJSONContent = async ( + element: Locator | null, +): Promise => { + if (!element) return null; + try { + const textContent = await element.innerText(); + return JSON.parse(textContent) as T; + } catch (error) { + console.error(`Error parsing JSON content: ${error}`); + return null; + } +}; + +export const getLastDeviceResponseContent = async ( + page: Page, + tagType: string = "div > span", +): Promise => + await asyncPipe( + getResponses, + filterNonHiddenElements, + getLastResponse, + getLastChildOfElementByTag(tagType), + parseJSONContent, + )(page); + +export const isValidEthereumAddress = (address: string): boolean => + /^0x[a-fA-F0-9]{40}$/.test(address); + +export const isValidPublicKey = (publicKey: string): boolean => + /^04[a-fA-F0-9]{128}$/.test(publicKey); + +export const isValid256BitHex = (value: string): boolean => + /^0x[a-fA-F0-9]{64}$/.test(value) || /^[a-fA-F0-9]{64}$/.test(value); diff --git a/apps/sample/playwright/utils/whenHandlers.ts b/apps/sample/playwright/utils/whenHandlers.ts new file mode 100644 index 000000000..5c2c0a113 --- /dev/null +++ b/apps/sample/playwright/utils/whenHandlers.ts @@ -0,0 +1,107 @@ +import { type Locator, type Page } from "@playwright/test"; + +import { asyncPipe } from "@/utils/pipes"; + +type DeviceCommandParams = { + inputField?: string; + inputValue?: string; +}; + +const clickByTestId = + (testId: string) => + async (page: Page): Promise => { + await page.getByTestId(testId).click(); + return page; + }; + +const clickBySelector = + (selector: string) => + async (page: Page): Promise => { + await page.locator(selector).click(); + return page; + }; + +const waitForNavigation = + (route: string) => + async (page: Page): Promise => { + const expectedURL = `http://localhost:3000${route}`; + await page.waitForURL(expectedURL, { timeout: 10000 }); + await page.waitForLoadState("networkidle"); + return page; + }; + +const fillInputFields = + (params: DeviceCommandParams | DeviceCommandParams[]) => + async (page: Page): Promise => { + const paramsArray = Array.isArray(params) ? params : [params]; + for (const { inputField, inputValue } of paramsArray) { + if (inputField && inputValue) { + const input = page.getByTestId(inputField); + await input.waitFor({ state: "visible" }); + await input.fill(inputValue); + } + } + return page; + }; + +export const whenConnectingDevice = (page: Page): Promise => + clickByTestId("CTA_select-device")(page); + +export const whenClicking = (page: Page, ctaSelector: string): Promise => + clickByTestId(ctaSelector)(page); + +export const whenNavigateTo = (page: Page, route: string): Promise => + asyncPipe( + clickByTestId(`CTA_route-to-${route}`), + waitForNavigation(route), + )(page); + +const executeClickCommand = + (command: string, navigate: boolean) => + async (page: Page): Promise => { + if (navigate) { + await page.getByTestId(`CTA_command-${command}`).click(); + } + return page; + }; + +const executeClickSend = + (type: string) => + async (page: Page): Promise => { + await page.getByTestId(`CTA_send-${type}`).click(); + return page; + }; + +export const whenExecute = + (type: string, navigate: boolean = false) => + ( + page: Page, + command: string, + params: DeviceCommandParams | DeviceCommandParams[] = [], + ): Promise => + asyncPipe( + executeClickCommand(command, navigate), + fillInputFields(params), + executeClickSend(type), + )(page); + +export const whenExecuteDeviceAction = whenExecute("device-action", true); +export const whenExecuteDeviceCommand = whenExecute("device-command", true); + +const getFirstDevice = (page: Page): Locator => + page.getByTestId("container_devices").locator("> *").first(); + +const clickDeviceOptionAndDisconnect = async (page: Page): Promise => { + await page.getByTestId("dropdown_device-option").click(); + await page.getByTestId("CTA_disconnect-device").click(); + return page; +}; + +export const whenDisconnectDevice = (page: Page): Promise => + asyncPipe(getFirstDevice, clickDeviceOptionAndDisconnect)(page); + +const drawerCloseButtonSelector = + 'svg path[d="M20.328 18.84L13.488 12l6.84-6.84-1.536-1.44L12 10.512 5.208 3.72 3.672 5.16l6.84 6.84-6.84 6.84 1.536 1.44L12 13.488l6.792 6.792 1.536-1.44z"]'; + +export const whenCloseDrawer = (page: Page): Promise => + clickBySelector(drawerCloseButtonSelector)(page); diff --git a/apps/sample/src/app/client-layout.tsx b/apps/sample/src/app/client-layout.tsx index fd1f44cfc..d3fff05f8 100644 --- a/apps/sample/src/app/client-layout.tsx +++ b/apps/sample/src/app/client-layout.tsx @@ -9,14 +9,16 @@ */ "use client"; -import React, { PropsWithChildren } from "react"; +import React, { type PropsWithChildren } from "react"; import { Flex, StyleProvider } from "@ledgerhq/react-ui"; -import styled, { DefaultTheme } from "styled-components"; +import styled, { type DefaultTheme } from "styled-components"; import { Header } from "@/components/Header"; import { Sidebar } from "@/components/Sidebar"; -import { SdkProvider } from "@/providers/DeviceSdkProvider"; +import { DmkProvider } from "@/providers/DeviceManagementKitProvider"; import { DeviceSessionsProvider } from "@/providers/DeviceSessionsProvider"; +import { DmkConfigProvider } from "@/providers/DmkConfig"; +import { SignerEthProvider } from "@/providers/SignerEthProvider"; import { GlobalStyle } from "@/styles/globalstyles"; const Root = styled(Flex)` @@ -36,22 +38,26 @@ const PageContainer = styled(Flex)` const ClientRootLayout: React.FC = ({ children }) => { return ( - - - - - - - - -
- {children} - - - - - - + + + + + + + + + + +
+ {children} + + + + + + + + ); }; diff --git a/apps/sample/src/app/global-error.tsx b/apps/sample/src/app/global-error.tsx index d8ff57341..8ac209f14 100644 --- a/apps/sample/src/app/global-error.tsx +++ b/apps/sample/src/app/global-error.tsx @@ -2,7 +2,7 @@ import React, { useEffect } from "react"; import * as Sentry from "@sentry/nextjs"; -import Error, { ErrorProps } from "next/error"; +import Error, { type ErrorProps } from "next/error"; export default function GlobalError({ error }: { error: ErrorProps }) { useEffect(() => { diff --git a/apps/sample/src/app/keyring/ethereum/page.tsx b/apps/sample/src/app/keyring/ethereum/page.tsx deleted file mode 100644 index 26cb849dd..000000000 --- a/apps/sample/src/app/keyring/ethereum/page.tsx +++ /dev/null @@ -1,11 +0,0 @@ -"use client"; -import React from "react"; - -import { KeyringEthView } from "@/components/KeyringEthView"; -import { SessionIdWrapper } from "@/components/SessionIdWrapper"; - -const Keyring: React.FC = () => { - return ; -}; - -export default Keyring; diff --git a/apps/sample/src/app/keyring/page.tsx b/apps/sample/src/app/keyring/page.tsx deleted file mode 100644 index 134e7bf48..000000000 --- a/apps/sample/src/app/keyring/page.tsx +++ /dev/null @@ -1,10 +0,0 @@ -"use client"; -import React from "react"; - -import { KeyringView } from "@/components/KeyringView"; - -const Keyring: React.FC = () => { - return ; -}; - -export default Keyring; diff --git a/apps/sample/src/app/layout.tsx b/apps/sample/src/app/layout.tsx index ba1b346ab..caa868bab 100644 --- a/apps/sample/src/app/layout.tsx +++ b/apps/sample/src/app/layout.tsx @@ -6,7 +6,7 @@ * Combines Styled Components Registry with the ClientRootLayout * for rendering the application. */ -import React, { PropsWithChildren } from "react"; +import React, { type PropsWithChildren } from "react"; import { StyledComponentsRegistry } from "@/lib/registry"; diff --git a/apps/sample/src/app/mock/page.tsx b/apps/sample/src/app/mock/page.tsx new file mode 100644 index 000000000..44dc7df13 --- /dev/null +++ b/apps/sample/src/app/mock/page.tsx @@ -0,0 +1,10 @@ +"use client"; +import React from "react"; + +import { MockView } from "@/components/MockView"; + +const Mock: React.FC = () => { + return ; +}; + +export default Mock; diff --git a/apps/sample/src/app/signer/ethereum/page.tsx b/apps/sample/src/app/signer/ethereum/page.tsx new file mode 100644 index 000000000..c297848d3 --- /dev/null +++ b/apps/sample/src/app/signer/ethereum/page.tsx @@ -0,0 +1,11 @@ +"use client"; +import React from "react"; + +import { SessionIdWrapper } from "@/components/SessionIdWrapper"; +import { SignerEthView } from "@/components/SignerEthView"; + +const Signer: React.FC = () => { + return ; +}; + +export default Signer; diff --git a/apps/sample/src/app/signer/page.tsx b/apps/sample/src/app/signer/page.tsx new file mode 100644 index 000000000..5231d75af --- /dev/null +++ b/apps/sample/src/app/signer/page.tsx @@ -0,0 +1,10 @@ +"use client"; +import React from "react"; + +import { SignerView } from "@/components/SignerView"; + +const Signer: React.FC = () => { + return ; +}; + +export default Signer; diff --git a/apps/sample/src/app/signer/solana/page.tsx b/apps/sample/src/app/signer/solana/page.tsx new file mode 100644 index 000000000..85b74a573 --- /dev/null +++ b/apps/sample/src/app/signer/solana/page.tsx @@ -0,0 +1,11 @@ +"use client"; +import React from "react"; + +import { SessionIdWrapper } from "@/components/SessionIdWrapper"; +import { SignerSolanaView } from "@/components/SignerSolanaView"; + +const Signer: React.FC = () => { + return ; +}; + +export default Signer; diff --git a/apps/sample/src/components/ApduView/index.tsx b/apps/sample/src/components/ApduView/index.tsx index d7344fb04..11d409736 100644 --- a/apps/sample/src/components/ApduView/index.tsx +++ b/apps/sample/src/components/ApduView/index.tsx @@ -1,10 +1,10 @@ import React, { useCallback, useState } from "react"; -import { ApduResponse } from "@ledgerhq/device-management-kit"; +import { type ApduResponse } from "@ledgerhq/device-management-kit"; import { Button, Divider, Flex, Grid, Input, Text } from "@ledgerhq/react-ui"; -import styled, { DefaultTheme } from "styled-components"; +import styled, { type DefaultTheme } from "styled-components"; import { useApduForm } from "@/hooks/useApduForm"; -import { useSdk } from "@/providers/DeviceSdkProvider"; +import { useDmk } from "@/providers/DeviceManagementKitProvider"; import { useDeviceSessionsContext } from "@/providers/DeviceSessionsProvider"; const Root = styled(Flex).attrs({ mx: 15, mt: 10, mb: 5 })` @@ -69,7 +69,7 @@ export const ApduView: React.FC = () => { useApduForm(); const [loading, setLoading] = useState(false); const [apduResponse, setApduResponse] = useState(); - const sdk = useSdk(); + const dmk = useDmk(); const { state: { selectedId: selectedSessionId }, } = useDeviceSessionsContext(); @@ -78,7 +78,7 @@ export const ApduView: React.FC = () => { setLoading(true); let rawApduResponse; try { - rawApduResponse = await sdk.sendApdu({ + rawApduResponse = await dmk.sendApdu({ sessionId: selectedSessionId ?? "", apdu: getRawApdu(values), }); @@ -89,7 +89,7 @@ export const ApduView: React.FC = () => { setLoading(false); } }, - [getRawApdu, sdk, selectedSessionId], + [getRawApdu, dmk, selectedSessionId], ); return ( diff --git a/apps/sample/src/components/AvailableDevices/index.tsx b/apps/sample/src/components/AvailableDevices/index.tsx new file mode 100644 index 000000000..02fbb759f --- /dev/null +++ b/apps/sample/src/components/AvailableDevices/index.tsx @@ -0,0 +1,94 @@ +import React, { useCallback, useState } from "react"; +import { type DiscoveredDevice } from "@ledgerhq/device-management-kit"; +import { Flex, Icons, Text } from "@ledgerhq/react-ui"; +import styled from "styled-components"; + +import { AvailableDevice } from "@/components/Device"; +import { useAvailableDevices } from "@/hooks/useAvailableDevices"; +import { useDmk } from "@/providers/DeviceManagementKitProvider"; +import { useDeviceSessionsContext } from "@/providers/DeviceSessionsProvider"; + +const Title = styled(Text)<{ disabled: boolean }>` + :hover { + user-select: none; + text-decoration: ${(p) => (p.disabled ? "none" : "underline")}; + cursor: ${(p) => (p.disabled ? "default" : "pointer")}; + } +`; + +export const AvailableDevices: React.FC> = () => { + const discoveredDevices = useAvailableDevices(); + const noDevice = discoveredDevices.length === 0; + + const [unfolded, setUnfolded] = useState(false); + + const toggleUnfolded = useCallback(() => { + setUnfolded((prev) => !prev); + }, []); + + return ( + <> + + + Available devices ({discoveredDevices.length}) + + + {unfolded ? ( + + ) : ( + + )} + + + + {unfolded + ? discoveredDevices.map((device) => ( + + )) + : null} + + + ); +}; + +const KnownDevice: React.FC = ( + device, +) => { + const { deviceModel, connected } = device; + const dmk = useDmk(); + const { dispatch } = useDeviceSessionsContext(); + const connectToDevice = useCallback(() => { + dmk.connect({ device }).then((sessionId) => { + dispatch({ + type: "add_session", + payload: { + sessionId, + connectedDevice: dmk.getConnectedDevice({ sessionId }), + }, + }); + }); + }, [dmk, device, dispatch]); + + return ( + + + + ); +}; diff --git a/apps/sample/src/components/CalView/CalAvailabilityResponse.tsx b/apps/sample/src/components/CalView/CalAvailabilityResponse.tsx index f26f1300d..69cae7b03 100644 --- a/apps/sample/src/components/CalView/CalAvailabilityResponse.tsx +++ b/apps/sample/src/components/CalView/CalAvailabilityResponse.tsx @@ -1,7 +1,7 @@ import React from "react"; import { Box, Flex, Icons, InfiniteLoader, Text } from "@ledgerhq/react-ui"; -import { Descriptor } from "./CalNetworkDataSource"; +import { type Descriptor } from "./CalNetworkDataSource"; type CalAvailabilityResponseProps = { loading: boolean; diff --git a/apps/sample/src/components/CalView/CalCheckDappDrawer.tsx b/apps/sample/src/components/CalView/CalCheckDappDrawer.tsx new file mode 100644 index 000000000..cd75a04bd --- /dev/null +++ b/apps/sample/src/components/CalView/CalCheckDappDrawer.tsx @@ -0,0 +1,169 @@ +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { + Button, + Divider, + Flex, + Icons, + InfiniteLoader, +} from "@ledgerhq/react-ui"; + +import { Block } from "@/components/Block"; +import { + CommandForm, + type ValueSelector, +} from "@/components/CommandsView/CommandForm"; +import { type FieldType } from "@/hooks/useForm"; +import { useCalConfig } from "@/providers/SignerEthProvider"; + +import { CalAvailabilityResponseComponent } from "./CalAvailabilityResponse"; +import { + checkContractAvailability, + type Descriptor, +} from "./CalNetworkDataSource"; + +export type CalCheckDappDrawerProps< + _, + Input extends Record | void, +> = { + title: string; + description: string; + initialValues: Input; + validateValues?: (args: Input) => boolean; + valueSelector?: ValueSelector; +}; + +type Response = { + searchAddress: string; + date: Date; + responseType: string; + result: Descriptor[]; // Store the result from the API + loading: false; + id: number; +}; + +export function CalCheckDappDrawer< + Output, + Input extends Record, +>(props: CalCheckDappDrawerProps) { + const { initialValues, valueSelector, validateValues } = props; + + const nonce = useRef(-1); + const [values, setValues] = useState(initialValues); + const [valuesInvalid, setValuesInvalid] = useState(false); + const [responses, setResponses] = useState([]); + const [loading, setLoading] = useState(false); + const { calConfig } = useCalConfig(); + const handleClickExecute = useCallback(() => { + setLoading(true); + const id = ++nonce.current; + + const fetchData = async () => { + try { + console.log("Trigger Request to checkContractAvailability"); + + const response = await checkContractAvailability( + values.smartContractAddress.toString(), + calConfig.url, + calConfig.branch, + ); + + setResponses((prev) => [ + ...prev, + { + searchAddress: values.smartContractAddress.toString(), + date: new Date(), + responseType: response.responseType, + result: response.descriptors, // Store the result from the API + loading: false, + id, + }, + ]); + + setLoading(false); + } catch (_error) { + setLoading(false); + } + }; + + fetchData(); + }, [values]); + + const handleClickClear = useCallback(() => { + setResponses([]); + }, []); + + const responseBoxRef = useRef(null); + + useEffect(() => { + if (responseBoxRef.current) { + responseBoxRef.current.scrollTop = responseBoxRef.current.scrollHeight; + } + }, [responses]); + + useEffect(() => { + if (validateValues) { + setValuesInvalid(!validateValues(values)); + } + }, [validateValues, values]); + + return ( + <> + + + + + + + + + + + + {responses.slice().map((response, key) => ( + + ))} + + + + + ); +} diff --git a/apps/sample/src/components/CalView/CalNetworkDataSource.ts b/apps/sample/src/components/CalView/CalNetworkDataSource.ts index 506030b34..b8db79382 100644 --- a/apps/sample/src/components/CalView/CalNetworkDataSource.ts +++ b/apps/sample/src/components/CalView/CalNetworkDataSource.ts @@ -156,7 +156,7 @@ async function fetchRequest( endpoint: string, branch: string, ): Promise { - const path = `${endpoint}/v1/dapps?ref=branch%3A${branch}&output=${output}&chain_id=1&contracts=${contractName}`; + const path = `${endpoint}/dapps?ref=branch%3A${branch}&output=${output}&chain_id=1&contracts=${contractName}`; const response = await fetch(path); if (!response.ok) { diff --git a/apps/sample/src/components/CalView/CalSettingsDrawer.tsx b/apps/sample/src/components/CalView/CalSettingsDrawer.tsx new file mode 100644 index 000000000..0f7f31682 --- /dev/null +++ b/apps/sample/src/components/CalView/CalSettingsDrawer.tsx @@ -0,0 +1,88 @@ +import React, { useCallback, useState } from "react"; +import { type ContextModuleCalConfig } from "@ledgerhq/context-module"; +import { Button, Divider, Flex } from "@ledgerhq/react-ui"; + +import { Block } from "@/components/Block"; +import { + CommandForm, + type ValueSelector, +} from "@/components/CommandsView/CommandForm"; +import { type FieldType } from "@/hooks/useForm"; +import { useCalConfig } from "@/providers/SignerEthProvider"; + +type CalSettingsDrawerProps = { + onClose: () => void; +}; + +export function CalSettingsDrawer({ onClose }: CalSettingsDrawerProps) { + const { calConfig, setCalConfig } = useCalConfig(); + const [values, setValues] = useState>(calConfig); + const valueSelector: ValueSelector = { + mode: [ + { label: "Production", value: "prod" }, + { label: "Testing", value: "test" }, + ], + branch: [ + { label: "Main", value: "main" }, + { label: "Next", value: "next" }, + { label: "Demo", value: "demo" }, + ], + }; + const labelSelector: Record = { + url: "CAL URL", + mode: "Mode", + branch: "Branch reference", + }; + + const onSettingsUpdate = useCallback(() => { + const { url, mode, branch } = values; + const isMode = (test: unknown): test is "prod" | "test" => + test === "prod" || test === "test"; + const isBranch = (test: unknown): test is "main" | "next" | "demo" => + test === "main" || test === "next" || test === "demo"; + + console.log("Updating settings", values); + if (!url || typeof url !== "string" || !url.startsWith("http")) { + console.error("Invalid CAL URL", url); + return; + } + + if (!mode || !isMode(mode)) { + console.error("Invalid mode", mode); + return; + } + + if (!branch || !isBranch(branch)) { + console.error("Invalid branch reference", branch); + return; + } + + const newSettings: ContextModuleCalConfig = { + url, + mode, + branch, + }; + + setCalConfig(newSettings); + onClose(); + }, [onClose, setCalConfig, values]); + + return ( + + + + + + + + + + ); +} diff --git a/apps/sample/src/components/CalView/index.tsx b/apps/sample/src/components/CalView/index.tsx index 1c9c60dff..6875670f1 100644 --- a/apps/sample/src/components/CalView/index.tsx +++ b/apps/sample/src/components/CalView/index.tsx @@ -1,221 +1,84 @@ -import React, { useCallback, useEffect, useRef, useState } from "react"; -import { - Button, - Divider, - Flex, - Grid, - Icons, - InfiniteLoader, -} from "@ledgerhq/react-ui"; +import React, { useCallback, useState } from "react"; +import { Grid } from "@ledgerhq/react-ui"; -import { Block } from "@/components/Block"; import { ClickableListItem } from "@/components/ClickableListItem"; -import { - CommandForm, - ValueSelector, -} from "@/components/CommandsView/CommandForm"; import { PageWithHeader } from "@/components/PageWithHeader"; import { StyledDrawer } from "@/components/StyledDrawer"; -import { FieldType } from "@/hooks/useForm"; -import { CalAvailabilityResponseComponent } from "./CalAvailabilityResponse"; -import { checkContractAvailability, Descriptor } from "./CalNetworkDataSource"; +import { CalCheckDappDrawer } from "./CalCheckDappDrawer"; +import { CalSettingsDrawer } from "./CalSettingsDrawer"; -const CAL_SERVICE_ENTRIES = [ - { - title: "Check dApp availability", - description: "Check dApp availability in Crypto Asset List", - }, -]; - -export type CalActionProps< - _, - Input extends Record | void, -> = { - title: string; - description: string; - initialValues: Input; - validateValues?: (args: Input) => boolean; - valueSelector?: ValueSelector; -}; - -type Response = { - searchAddress: string; - date: Date; - responseType: string; - result: Descriptor[]; // Store the result from the API - loading: false; - id: number; -}; - -export function CalActionDrawer< - Output, - Input extends Record, ->(props: CalActionProps) { - const { initialValues, valueSelector, validateValues } = props; - - const nonce = useRef(-1); - const [values, setValues] = useState(initialValues); - const [valuesInvalid, setValuesInvalid] = useState(false); - const [responses, setResponses] = useState([]); - const [loading, setLoading] = useState(false); - const handleClickExecute = useCallback(() => { - setLoading(true); - const id = ++nonce.current; - - const fetchData = async () => { - try { - console.log("Trigger Request to checkContractAvailability"); - - const response = await checkContractAvailability( - values.smartContractAddress.toString(), - values.calUrl.toString(), - values.branch.toString(), - ); - - setResponses((prev) => [ - ...prev, - { - searchAddress: values.smartContractAddress.toString(), - date: new Date(), - responseType: response.responseType, - result: response.descriptors, // Store the result from the API - loading: false, - id, - }, - ]); - - setLoading(false); - } catch (_error) { - setLoading(false); - } - }; - - fetchData(); - }, [values]); - - const handleClickClear = useCallback(() => { - setResponses([]); +export const CalView = () => { + const [isCheckDappOpen, setIsCheckDappOpen] = useState(false); + const [isSettingsOpen, setIsSettingsOpen] = useState(false); + const openCheckDapp = useCallback(() => { + setIsCheckDappOpen(true); }, []); - const responseBoxRef = useRef(null); - - useEffect(() => { - if (responseBoxRef.current) { - responseBoxRef.current.scrollTop = responseBoxRef.current.scrollHeight; - } - }, [responses]); - - useEffect(() => { - if (validateValues) { - setValuesInvalid(!validateValues(values)); - } - }, [validateValues, values]); - - return ( - <> - - - - - - - - - - - - {responses.slice().map((response, key) => ( - - ))} - - - - - ); -} - -export const CalView = () => { - const [isOpen, setIsOpen] = useState(false); - const openDrawer = useCallback(() => { - setIsOpen(true); + const openSettings = useCallback(() => { + setIsSettingsOpen(true); }, []); - const closeDrawer = useCallback(() => { - setIsOpen(false); + const closeDrawers = useCallback(() => { + setIsCheckDappOpen(false); + setIsSettingsOpen(false); }, []); - const title = "Check dApp availability"; - const description = "Check descriptor availability on the CAL"; + const entries = [ + { + title: "Settings", + description: "Settings for the Crypto Asset List", + onClick: openSettings, + }, + { + title: "Check dApp availability", + description: "Check dApp availability in Crypto Asset List", + onClick: openCheckDapp, + }, + ]; + + const pageTitle = "Check dApp availability"; + const pageDescription = "Check descriptor availability on the CAL"; return ( - - {CAL_SERVICE_ENTRIES.map(({ title, description }) => ( + + {entries.map(({ title, description, onClick }) => ( ))} - + + + ); }; diff --git a/apps/sample/src/components/ClickableListItem.tsx b/apps/sample/src/components/ClickableListItem.tsx index 5c2ce4b04..8b4237b81 100644 --- a/apps/sample/src/components/ClickableListItem.tsx +++ b/apps/sample/src/components/ClickableListItem.tsx @@ -1,5 +1,6 @@ import React from "react"; import { Flex, Icons, Text } from "@ledgerhq/react-ui"; +import { type BaseStyledProps } from "@ledgerhq/react-ui/components/styled"; import styled from "styled-components"; const ListItemWrapper = styled(Flex)` @@ -12,12 +13,14 @@ const ListItemWrapper = styled(Flex)` cursor: pointer; `; -export const ClickableListItem: React.FC<{ - title: string; - description: string; - onClick(): void; - icon?: React.ReactNode; -}> = ({ title, description, onClick, icon }) => { +export const ClickableListItem: React.FC< + { + title: string; + description: string; + onClick(): void; + icon?: React.ReactNode; + } & BaseStyledProps +> = ({ title, description, onClick, icon, ...styleProps }) => { return ( {icon} diff --git a/apps/sample/src/components/CommandsView/Command.tsx b/apps/sample/src/components/CommandsView/Command.tsx index c773c7c61..06311b8b8 100644 --- a/apps/sample/src/components/CommandsView/Command.tsx +++ b/apps/sample/src/components/CommandsView/Command.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useEffect, useState } from "react"; import { - CommandResult, + type CommandResult, isSuccessCommandResult, } from "@ledgerhq/device-management-kit"; import { Button, Flex, Icons, InfiniteLoader } from "@ledgerhq/react-ui"; @@ -8,10 +8,10 @@ import { Button, Flex, Icons, InfiniteLoader } from "@ledgerhq/react-ui"; import { Block } from "@/components/Block"; import { ClickableListItem } from "@/components/ClickableListItem"; import { StyledDrawer } from "@/components/StyledDrawer"; -import { FieldType } from "@/hooks/useForm"; +import { type FieldType } from "@/hooks/useForm"; -import { CommandForm, ValueSelector } from "./CommandForm"; -import { CommandResponse, CommandResponseProps } from "./CommandResponse"; +import { CommandForm, type ValueSelector } from "./CommandForm"; +import { CommandResponse, type CommandResponseProps } from "./CommandResponse"; export type CommandProps< CommandArgs extends Record | void, @@ -79,6 +79,13 @@ export function Command< ]; }); }) + .catch((error) => { + setLoading(false); + setResponses((prev) => [ + ...prev.slice(0, -1), + { args: values, date: new Date(), loading: false, response: error }, + ]); + }) .finally(() => { setLoading(false); }); @@ -111,7 +118,7 @@ export function Command< title={title} description={description} > - + loading ? : } + data-testid="CTA_send-device-command" > Send @@ -135,6 +143,7 @@ export function Command< rowGap={4} flex={1} overflowY="scroll" + data-testid="box_device-commands-responses" > {responses.map(({ args, date, response, loading }, index) => ( = Record< string, @@ -26,11 +26,13 @@ export function CommandForm>({ initialValues, onChange, valueSelector, + labelSelector, disabled, }: { initialValues: Args; onChange: (values: Args) => void; valueSelector?: ValueSelector; + labelSelector?: Record; disabled?: boolean; }) { const { formValues, setFormValue } = useForm(initialValues); @@ -53,7 +55,7 @@ export function CommandForm>({ > {typeof value === "boolean" ? null : ( - {key} + {labelSelector && labelSelector[key] ? labelSelector[key] : key} )} {valueSelector?.[key] ? ( @@ -69,13 +71,15 @@ export function CommandForm>({ /> ) : typeof value === "boolean" ? ( - setFormValue(key, !value)} - disabled={disabled} - label={key} - /> +
+ setFormValue(key, !value)} + disabled={disabled} + label={key} + /> +
) : typeof value === "string" ? ( >({ placeholder={key} onChange={(newVal) => setFormValue(key, newVal)} disabled={disabled} + data-testid={`input-text_${key}`} /> ) : ( setFormValue(key, newVal ?? 0)} + onChange={(newVal) => + setFormValue(key, parseInt(newVal.toString(), 10) ?? 0) + } type="number" disabled={disabled} /> diff --git a/apps/sample/src/components/CommandsView/CommandResponse.tsx b/apps/sample/src/components/CommandsView/CommandResponse.tsx index fee3f4c6c..3f7584dae 100644 --- a/apps/sample/src/components/CommandsView/CommandResponse.tsx +++ b/apps/sample/src/components/CommandsView/CommandResponse.tsx @@ -1,11 +1,11 @@ import React from "react"; import { - CommandResult, + type CommandResult, isSuccessCommandResult, } from "@ledgerhq/device-management-kit"; import { Flex, InfiniteLoader, Text, Tooltip } from "@ledgerhq/react-ui"; -import { FieldType } from "@/hooks/useForm"; +import { type FieldType } from "@/hooks/useForm"; export type CommandResponseProps = { args: Record; diff --git a/apps/sample/src/components/CommandsView/index.tsx b/apps/sample/src/components/CommandsView/index.tsx index bde4df4e2..2e76861c0 100644 --- a/apps/sample/src/components/CommandsView/index.tsx +++ b/apps/sample/src/components/CommandsView/index.tsx @@ -1,34 +1,34 @@ import React, { useMemo } from "react"; import { + BatteryStatusType, CloseAppCommand, GetAppAndVersionCommand, - GetAppAndVersionResponse, - GetBatteryStatusArgs, + type GetAppAndVersionResponse, + type GetBatteryStatusArgs, GetBatteryStatusCommand, - GetBatteryStatusResponse, + type GetBatteryStatusResponse, GetOsVersionCommand, - GetOsVersionResponse, - ListAppsArgs, + type GetOsVersionResponse, + type ListAppsArgs, ListAppsCommand, - ListAppsErrorCodes, - ListAppsResponse, - OpenAppArgs, + type ListAppsErrorCodes, + type ListAppsResponse, + type OpenAppArgs, OpenAppCommand, - OpenAppErrorCodes, + type OpenAppErrorCodes, } from "@ledgerhq/device-management-kit"; -import { BatteryStatusType } from "@ledgerhq/device-management-kit/src/api/command/os/GetBatteryStatusCommand.js"; import { Grid } from "@ledgerhq/react-ui"; import { PageWithHeader } from "@/components/PageWithHeader"; -import { useSdk } from "@/providers/DeviceSdkProvider"; +import { useDmk } from "@/providers/DeviceManagementKitProvider"; -import { Command, CommandProps } from "./Command"; +import { Command, type CommandProps } from "./Command"; import { getValueSelectorFromEnum } from "./CommandForm"; export const CommandsView: React.FC<{ sessionId: string }> = ({ sessionId: selectedSessionId, }) => { - const sdk = useSdk(); + const dmk = useDmk(); // eslint-disable-next-line @typescript-eslint/no-explicit-any const commands: CommandProps[] = useMemo( @@ -38,7 +38,7 @@ export const CommandsView: React.FC<{ sessionId: string }> = ({ description: "List all apps on the device", sendCommand: ({ isContinue }) => { const command = new ListAppsCommand({ isContinue }); - return sdk.sendCommand({ + return dmk.sendCommand({ sessionId: selectedSessionId, command, }); @@ -54,7 +54,7 @@ export const CommandsView: React.FC<{ sessionId: string }> = ({ description: "Launch an app on the device", sendCommand: ({ appName }) => { const command = new OpenAppCommand({ appName }); - return sdk.sendCommand({ + return dmk.sendCommand({ sessionId: selectedSessionId, command, }); @@ -67,7 +67,7 @@ export const CommandsView: React.FC<{ sessionId: string }> = ({ description: "Close the currently open app", sendCommand: () => { const command = new CloseAppCommand(); - return sdk.sendCommand({ + return dmk.sendCommand({ sessionId: selectedSessionId, command, }); @@ -79,7 +79,7 @@ export const CommandsView: React.FC<{ sessionId: string }> = ({ description: "Get the currently open app and its version", sendCommand: () => { const command = new GetAppAndVersionCommand(); - return sdk.sendCommand({ + return dmk.sendCommand({ sessionId: selectedSessionId, command, }); @@ -91,7 +91,7 @@ export const CommandsView: React.FC<{ sessionId: string }> = ({ description: "Get the OS version of the device", sendCommand: () => { const command = new GetOsVersionCommand(); - return sdk.sendCommand({ + return dmk.sendCommand({ sessionId: selectedSessionId, command, }); @@ -103,7 +103,7 @@ export const CommandsView: React.FC<{ sessionId: string }> = ({ description: "Get the battery status of the device", sendCommand: ({ statusType }) => { const command = new GetBatteryStatusCommand({ statusType }); - return sdk.sendCommand({ + return dmk.sendCommand({ sessionId: selectedSessionId, command, }); @@ -116,7 +116,7 @@ export const CommandsView: React.FC<{ sessionId: string }> = ({ }, } satisfies CommandProps, ], - [selectedSessionId, sdk], + [selectedSessionId, dmk], ); return ( diff --git a/apps/sample/src/components/Device/StatusText.tsx b/apps/sample/src/components/Device/StatusText.tsx index cd39121f5..6c033a63c 100644 --- a/apps/sample/src/components/Device/StatusText.tsx +++ b/apps/sample/src/components/Device/StatusText.tsx @@ -1,6 +1,6 @@ import { DeviceStatus } from "@ledgerhq/device-management-kit"; import { Text } from "@ledgerhq/react-ui"; -import styled, { DefaultTheme } from "styled-components"; +import styled, { type DefaultTheme } from "styled-components"; const getColorFromState = ({ state, diff --git a/apps/sample/src/components/Device/index.tsx b/apps/sample/src/components/Device/index.tsx index 09f7f2f1b..580dd8a34 100644 --- a/apps/sample/src/components/Device/index.tsx +++ b/apps/sample/src/components/Device/index.tsx @@ -1,13 +1,21 @@ import React from "react"; import { - ConnectionType, + type ConnectionType, DeviceModelId, - DeviceSessionId, + type DeviceSessionId, } from "@ledgerhq/device-management-kit"; -import { Box, DropdownGeneric, Flex, Icons, Text } from "@ledgerhq/react-ui"; -import styled, { DefaultTheme } from "styled-components"; +import { + Box, + Button, + DropdownGeneric, + Flex, + Icons, + Text, +} from "@ledgerhq/react-ui"; +import styled, { type DefaultTheme } from "styled-components"; import { useDeviceSessionState } from "@/hooks/useDeviceSessionState"; +import { useDeviceSessionsContext } from "@/providers/DeviceSessionsProvider"; import { StatusText } from "./StatusText"; @@ -15,6 +23,10 @@ const Root = styled(Flex).attrs({ p: 5, mb: 8, borderRadius: 2 })` background: ${({ theme }: { theme: DefaultTheme }) => theme.colors.neutral.c30}; align-items: center; + border: ${({ active, theme }: { theme: DefaultTheme; active: boolean }) => + `1px solid ${active ? theme.colors.success.c40 : "transparent"}`}; + cursor: ${({ active }: { active: boolean }) => + active ? "normal" : "pointer"}; `; const IconContainer = styled(Flex).attrs({ p: 4, mr: 3, borderRadius: 100 })` @@ -41,6 +53,7 @@ type DeviceProps = { sessionId: DeviceSessionId; model: DeviceModelId; onDisconnect: () => Promise; + onSelect: () => void; }; function getIconComponent(model: DeviceModelId) { @@ -61,21 +74,31 @@ export const Device: React.FC = ({ type, model, onDisconnect, + onSelect, sessionId, }) => { const sessionState = useDeviceSessionState(sessionId); + const { + state: { selectedId }, + } = useDeviceSessionsContext(); const IconComponent = getIconComponent(model); + const isActive = selectedId === sessionId; return ( - + - {name} + + {name} + {sessionState && ( <> - + {sessionState.deviceStatus} â€ĸ @@ -86,14 +109,57 @@ export const Device: React.FC = ({ - - +
+ + + + Disconnect + + + + +
+
+ ); +}; + +type AvailableDeviceProps = { + model: DeviceModelId; + name: string; + type: ConnectionType; + connected: boolean; + onConnect: () => void; +}; + +export const AvailableDevice: React.FC = ({ + model, + name, + type, + onConnect, + connected, +}) => { + const IconComponent = getIconComponent(model); + return ( + + + + + + {name} + - Disconnect + {type} - - - + + + ); }; diff --git a/apps/sample/src/components/DeviceActionsView/AllDeviceActions.tsx b/apps/sample/src/components/DeviceActionsView/AllDeviceActions.tsx index 962b791f4..dea6ed349 100644 --- a/apps/sample/src/components/DeviceActionsView/AllDeviceActions.tsx +++ b/apps/sample/src/components/DeviceActionsView/AllDeviceActions.tsx @@ -1,44 +1,44 @@ import React from "react"; import { useMemo } from "react"; import { - GetDeviceStatusDAError, - GetDeviceStatusDAInput, - GetDeviceStatusDAIntermediateValue, - GetDeviceStatusDAOutput, + type GetDeviceStatusDAError, + type GetDeviceStatusDAInput, + type GetDeviceStatusDAIntermediateValue, + type GetDeviceStatusDAOutput, GetDeviceStatusDeviceAction, - GoToDashboardDAError, - GoToDashboardDAInput, - GoToDashboardDAIntermediateValue, - GoToDashboardDAOutput, + type GoToDashboardDAError, + type GoToDashboardDAInput, + type GoToDashboardDAIntermediateValue, + type GoToDashboardDAOutput, GoToDashboardDeviceAction, - ListAppsDAError, - ListAppsDAInput, - ListAppsDAIntermediateValue, - ListAppsDAOutput, + type ListAppsDAError, + type ListAppsDAInput, + type ListAppsDAIntermediateValue, + type ListAppsDAOutput, ListAppsDeviceAction, - ListAppsWithMetadataDAError, - ListAppsWithMetadataDAInput, - ListAppsWithMetadataDAIntermediateValue, - ListAppsWithMetadataDAOutput, + type ListAppsWithMetadataDAError, + type ListAppsWithMetadataDAInput, + type ListAppsWithMetadataDAIntermediateValue, + type ListAppsWithMetadataDAOutput, ListAppsWithMetadataDeviceAction, - OpenAppDAError, - OpenAppDAInput, - OpenAppDAIntermediateValue, - OpenAppDAOutput, + type OpenAppDAError, + type OpenAppDAInput, + type OpenAppDAIntermediateValue, + type OpenAppDAOutput, OpenAppDeviceAction, } from "@ledgerhq/device-management-kit"; -import { useSdk } from "@/providers/DeviceSdkProvider"; +import { useDmk } from "@/providers/DeviceManagementKitProvider"; import { DeviceActionsList, UNLOCK_TIMEOUT } from "./DeviceActionsList"; -import { DeviceActionProps } from "./DeviceActionTester"; +import { type DeviceActionProps } from "./DeviceActionTester"; export const AllDeviceActions: React.FC<{ sessionId: string }> = ({ sessionId, }) => { - const sdk = useSdk(); + const dmk = useDmk(); - const deviceModelId = sdk.getConnectedDevice({ + const deviceModelId = dmk.getConnectedDevice({ sessionId, }).modelId; @@ -49,17 +49,17 @@ export const AllDeviceActions: React.FC<{ sessionId: string }> = ({ title: "Open app", description: "Perform all the actions necessary to open an app on the device", - executeDeviceAction: ({ appName }, inspect) => { + executeDeviceAction: ({ appName, unlockTimeout }, inspect) => { const deviceAction = new OpenAppDeviceAction({ - input: { appName }, + input: { appName, unlockTimeout }, inspect, }); - return sdk.executeDeviceAction({ + return dmk.executeDeviceAction({ sessionId, deviceAction, }); }, - initialValues: { appName: "" }, + initialValues: { appName: "", unlockTimeout: UNLOCK_TIMEOUT }, deviceModelId, } satisfies DeviceActionProps< OpenAppDAOutput, @@ -76,7 +76,7 @@ export const AllDeviceActions: React.FC<{ sessionId: string }> = ({ input: { unlockTimeout }, inspect, }); - return sdk.executeDeviceAction({ + return dmk.executeDeviceAction({ sessionId, deviceAction, }); @@ -92,12 +92,12 @@ export const AllDeviceActions: React.FC<{ sessionId: string }> = ({ { title: "Go to dashboard", description: "Navigate to the dashboard", - executeDeviceAction: (_, inspect) => { + executeDeviceAction: ({ unlockTimeout }, inspect) => { const deviceAction = new GoToDashboardDeviceAction({ - input: { unlockTimeout: UNLOCK_TIMEOUT }, + input: { unlockTimeout }, inspect, }); - return sdk.executeDeviceAction({ + return dmk.executeDeviceAction({ sessionId, deviceAction, }); @@ -113,12 +113,12 @@ export const AllDeviceActions: React.FC<{ sessionId: string }> = ({ { title: "List apps", description: "List all applications installed on the device", - executeDeviceAction: (_, inspect) => { + executeDeviceAction: ({ unlockTimeout }, inspect) => { const deviceAction = new ListAppsDeviceAction({ - input: { unlockTimeout: UNLOCK_TIMEOUT }, + input: { unlockTimeout }, inspect, }); - return sdk.executeDeviceAction({ + return dmk.executeDeviceAction({ sessionId, deviceAction, }); @@ -135,12 +135,12 @@ export const AllDeviceActions: React.FC<{ sessionId: string }> = ({ title: "List apps with metadata", description: "List all applications installed on the device with additional metadata", - executeDeviceAction: (_, inspect) => { + executeDeviceAction: ({ unlockTimeout }, inspect) => { const deviceAction = new ListAppsWithMetadataDeviceAction({ - input: { unlockTimeout: UNLOCK_TIMEOUT }, + input: { unlockTimeout }, inspect, }); - return sdk.executeDeviceAction({ + return dmk.executeDeviceAction({ sessionId, deviceAction, }); @@ -154,7 +154,7 @@ export const AllDeviceActions: React.FC<{ sessionId: string }> = ({ ListAppsWithMetadataDAIntermediateValue >, ], - [deviceModelId, sdk, sessionId], + [deviceModelId, dmk, sessionId], ); return ( diff --git a/apps/sample/src/components/DeviceActionsView/DeviceActionResponse.tsx b/apps/sample/src/components/DeviceActionsView/DeviceActionResponse.tsx index 39032d106..e143bd473 100644 --- a/apps/sample/src/components/DeviceActionsView/DeviceActionResponse.tsx +++ b/apps/sample/src/components/DeviceActionsView/DeviceActionResponse.tsx @@ -8,7 +8,7 @@ import { import { Flex, Icons, Tag, Text, Tooltip } from "@ledgerhq/react-ui"; import styled from "styled-components"; -import { FieldType } from "@/hooks/useForm"; +import { type FieldType } from "@/hooks/useForm"; export type DeviceActionResponseProps = { args: Record; diff --git a/apps/sample/src/components/DeviceActionsView/DeviceActionTester.tsx b/apps/sample/src/components/DeviceActionsView/DeviceActionTester.tsx index 2fd722438..ea78a83d7 100644 --- a/apps/sample/src/components/DeviceActionsView/DeviceActionTester.tsx +++ b/apps/sample/src/components/DeviceActionsView/DeviceActionTester.tsx @@ -2,10 +2,10 @@ import React from "react"; import { useCallback, useEffect, useRef, useState } from "react"; import type { DeviceActionIntermediateValue, + DmkError, ExecuteDeviceActionReturnType, - SdkError, } from "@ledgerhq/device-management-kit"; -import { DeviceModelId } from "@ledgerhq/device-management-kit"; +import { type DeviceModelId } from "@ledgerhq/device-management-kit"; import { Button, Divider, @@ -22,20 +22,20 @@ import { Block } from "@/components/Block"; import { ClickableListItem } from "@/components/ClickableListItem"; import { CommandForm, - ValueSelector, + type ValueSelector, } from "@/components/CommandsView/CommandForm"; -import { FieldType } from "@/hooks/useForm"; +import { type FieldType } from "@/hooks/useForm"; import { DeviceActionResponse, - DeviceActionResponseProps, + type DeviceActionResponseProps, } from "./DeviceActionResponse"; import { DeviceActionUI } from "./DeviceActionUI"; export type DeviceActionProps< Output, Input extends Record | void, - Error extends SdkError, + Error extends DmkError, IntermediateValue extends DeviceActionIntermediateValue, > = { title: string; @@ -85,7 +85,7 @@ const BoxHeader: React.FC<{ children: string; hint: string }> = ({ export function DeviceActionTester< Output, Input extends Record, - Error extends SdkError, + Error extends DmkError, IntermediateValue extends DeviceActionIntermediateValue, >(props: DeviceActionProps) { const { @@ -196,7 +196,7 @@ export function DeviceActionTester< return ( - + Device Action input loading ? : } + data-testid="CTA_send-device-action" > Execute @@ -264,6 +265,7 @@ export function DeviceActionTester< overflowY="scroll" height="100%" flex={1} + data-testid="box_device-commands-responses" > {responses.map((response, index, arr) => { const isLatest = index === arr.length - 1; @@ -273,7 +275,10 @@ export function DeviceActionTester< key={`${response.date.toISOString()}-index-${index}`} > - {isLatest ? null : } + ); })} @@ -295,7 +300,7 @@ export function DeviceActionTester< export function DeviceActionRow< Output, Input extends Record, - Error extends SdkError, + Error extends DmkError, IntermediateValue extends DeviceActionIntermediateValue, >( props: DeviceActionProps & { diff --git a/apps/sample/src/components/DeviceActionsView/DeviceActionUI.tsx b/apps/sample/src/components/DeviceActionsView/DeviceActionUI.tsx index faeb74e65..e20141661 100644 --- a/apps/sample/src/components/DeviceActionsView/DeviceActionUI.tsx +++ b/apps/sample/src/components/DeviceActionsView/DeviceActionUI.tsx @@ -34,7 +34,7 @@ import * as ContinueOnLedgerStaxDark from "./lotties/stax/04_STAX_DARK_CONTINUE_ import * as SignTransactionStaxDark from "./lotties/stax/05_STAX_DARK_SIGN_TRANSACTION.json"; import * as FrontViewStaxDark from "./lotties/stax/06_STAX_DARK_FRONT_VIEW.json"; import { - DeviceActionResponseProps, + type DeviceActionResponseProps, deviceActionStatusToColor, } from "./DeviceActionResponse"; diff --git a/apps/sample/src/components/DeviceActionsView/DeviceActionsList.tsx b/apps/sample/src/components/DeviceActionsView/DeviceActionsList.tsx index fe8db18d9..aa17e982d 100644 --- a/apps/sample/src/components/DeviceActionsView/DeviceActionsList.tsx +++ b/apps/sample/src/components/DeviceActionsView/DeviceActionsList.tsx @@ -6,7 +6,7 @@ import styled from "styled-components"; import { PageWithHeader } from "@/components/PageWithHeader"; import { - DeviceActionProps, + type DeviceActionProps, DeviceActionRow, DeviceActionTester, } from "./DeviceActionTester"; diff --git a/apps/sample/src/components/Header/index.tsx b/apps/sample/src/components/Header/index.tsx index 83fbd02eb..b0e746020 100644 --- a/apps/sample/src/components/Header/index.tsx +++ b/apps/sample/src/components/Header/index.tsx @@ -1,6 +1,19 @@ -import React from "react"; -import { Flex, Icons } from "@ledgerhq/react-ui"; -import styled, { DefaultTheme } from "styled-components"; +import React, { useCallback, useEffect, useState } from "react"; +import { BuiltinTransports } from "@ledgerhq/device-management-kit"; +import { FlipperPluginManager } from "@ledgerhq/device-management-kit-flipper-plugin-client"; +import { + Button, + Divider, + DropdownGeneric, + Flex, + Icons, + Input, + Switch, + Text, +} from "@ledgerhq/react-ui"; +import styled, { type DefaultTheme } from "styled-components"; + +import { useDmkConfigContext } from "@/providers/DmkConfig"; const Root = styled(Flex).attrs({ py: 3, px: 10, gridGap: 8 })` color: ${({ theme }: { theme: DefaultTheme }) => theme.colors.neutral.c90}; @@ -13,21 +26,119 @@ const Actions = styled(Flex)` align-items: center; flex: 1 0 0; `; + const IconBox = styled(Flex).attrs({ p: 3 })` cursor: pointer; align-items: center; opacity: 0.7; `; -export const Header = () => ( - - - - - - - - - - -); +const UrlInput = styled(Input)` + align-items: center; +`; + +export const Header = () => { + const { + dispatch, + state: { transport, mockServerUrl }, + } = useDmkConfigContext(); + const onToggleMockServer = useCallback(() => { + dispatch({ + type: "set_transport", + payload: { + transport: + transport === BuiltinTransports.MOCK_SERVER + ? BuiltinTransports.USB + : BuiltinTransports.MOCK_SERVER, + }, + }); + }, [dispatch, transport]); + const [mockServerStateUrl, setMockServerStateUrl] = + useState(mockServerUrl); + const mockServerEnabled = transport === BuiltinTransports.MOCK_SERVER; + + const validateServerUrl = useCallback( + () => + dispatch({ + type: "set_mock_server_url", + payload: { mockServerUrl: mockServerStateUrl }, + }), + [dispatch, mockServerStateUrl], + ); + + const onClickConnectFlipperClient = useCallback(() => { + /** + * This is useful in case the Flipper server is started after the app and + * we want to connect to it without reloading the app, to keep the app state + * and the logs. + * */ + FlipperPluginManager.getInstance().attemptInitialization(); + }, []); + + const [flipperClientConnected, setFlipperClientConnected] = + useState(false); + + useEffect(() => { + const subscription = FlipperPluginManager.getInstance() + .observeIsConnected() + .subscribe((connected: boolean) => { + setFlipperClientConnected(connected); + }); + return () => { + subscription.unsubscribe(); + }; + }, []); + + return ( + + + + + + + + + +
+ + + Mock server: +
+ +
+ + {mockServerEnabled && ( + setMockServerStateUrl(url)} + renderRight={() => ( + + + + )} + /> + )} + + + Flipper ({flipperClientConnected ? "Connected" : "Disconnected"}): + + +
+
+
+
+ ); +}; diff --git a/apps/sample/src/components/MainView/ConnectDeviceActions.tsx b/apps/sample/src/components/MainView/ConnectDeviceActions.tsx new file mode 100644 index 000000000..2334173ee --- /dev/null +++ b/apps/sample/src/components/MainView/ConnectDeviceActions.tsx @@ -0,0 +1,98 @@ +import React, { useCallback } from "react"; +import { + BuiltinTransports, + type DmkError, +} from "@ledgerhq/device-management-kit"; +import { Button, Flex } from "@ledgerhq/react-ui"; +import styled from "styled-components"; + +import { useDmk } from "@/providers/DeviceManagementKitProvider"; +import { useDeviceSessionsContext } from "@/providers/DeviceSessionsProvider"; +import { useDmkConfigContext } from "@/providers/DmkConfig"; + +type ConnectDeviceActionsProps = { + onError: (error: DmkError | null) => void; +}; + +const ConnectButton = styled(Button).attrs({ mx: 3 })``; + +export const ConnectDeviceActions = ({ + onError, +}: ConnectDeviceActionsProps) => { + const { + state: { transport }, + } = useDmkConfigContext(); + const { dispatch: dispatchDeviceSession } = useDeviceSessionsContext(); + const dmk = useDmk(); + + const onSelectDeviceClicked = useCallback( + (selectedTransport: BuiltinTransports) => { + onError(null); + dmk.startDiscovering({ transport: selectedTransport }).subscribe({ + next: (device) => { + dmk + .connect({ device }) + .then((sessionId) => { + console.log( + `đŸĻ– Response from connect: ${JSON.stringify(sessionId)} 🎉`, + ); + dispatchDeviceSession({ + type: "add_session", + payload: { + sessionId, + connectedDevice: dmk.getConnectedDevice({ sessionId }), + }, + }); + }) + .catch((error) => { + onError(error); + console.error(`Error from connection or get-version`, error); + }); + }, + error: (error) => { + console.error(error); + }, + }); + }, + [dispatchDeviceSession, onError, dmk], + ); + + // This implementation gives the impression that working with the mock server + // is a special case, when in fact it's just a transport like the others + // TODO: instead of toggling between mock & regular config, we should + // just have a menu to select the active transports (where the active menu) + // and this here should be a list of one buttons for each active transport + // also we should not have a different appearance when the mock server is enabled + // we should just display the list of active transports somewhere in the sidebar, discreetly + + return transport === BuiltinTransports.MOCK_SERVER ? ( + onSelectDeviceClicked(BuiltinTransports.MOCK_SERVER)} + variant="main" + backgroundColor="main" + size="large" + data-testid="CTA_select-device" + > + Select a device + + ) : ( + + onSelectDeviceClicked(BuiltinTransports.USB)} + variant="main" + backgroundColor="main" + size="large" + > + Select a USB device + + onSelectDeviceClicked(BuiltinTransports.BLE)} + variant="main" + backgroundColor="main" + size="large" + > + Select a BLE device + + + ); +}; diff --git a/apps/sample/src/components/MainView/index.tsx b/apps/sample/src/components/MainView/index.tsx index 5a62bfc05..45aa638fb 100644 --- a/apps/sample/src/components/MainView/index.tsx +++ b/apps/sample/src/components/MainView/index.tsx @@ -1,10 +1,10 @@ -import React, { useCallback, useEffect } from "react"; -import { Button, Flex, Text } from "@ledgerhq/react-ui"; +import React, { useEffect, useState } from "react"; +import { type DmkError } from "@ledgerhq/device-management-kit"; +import { Badge, Flex, Icon, Notification, Text } from "@ledgerhq/react-ui"; import Image from "next/image"; -import styled, { DefaultTheme } from "styled-components"; +import styled, { type DefaultTheme } from "styled-components"; -import { useSdk } from "@/providers/DeviceSdkProvider"; -import { useDeviceSessionsContext } from "@/providers/DeviceSessionsProvider"; +import { ConnectDeviceActions } from "./ConnectDeviceActions"; const Root = styled(Flex)` flex: 1; @@ -12,6 +12,11 @@ const Root = styled(Flex)` align-items: center; flex-direction: column; `; +const ErrorNotification = styled(Notification)` + position: absolute; + bottom: 10px; + width: 70%; +`; const Description = styled(Text).attrs({ my: 6 })` color: ${({ theme }: { theme: DefaultTheme }) => theme.colors.neutral.c70}; @@ -22,44 +27,21 @@ const NanoLogo = styled(Image).attrs({ mb: 8 })` `; export const MainView: React.FC = () => { - const sdk = useSdk(); - const { dispatch } = useDeviceSessionsContext(); - - // Example starting the discovery on a user action - const onSelectDeviceClicked = useCallback(() => { - sdk.startDiscovering().subscribe({ - next: (device) => { - sdk - .connect({ deviceId: device.id }) - .then((sessionId) => { - console.log( - `đŸĻ– Response from connect: ${JSON.stringify(sessionId)} 🎉`, - ); - dispatch({ - type: "add_session", - payload: { - sessionId, - connectedDevice: sdk.getConnectedDevice({ sessionId }), - }, - }); - }) - .catch((error) => { - console.error(`Error from connection or get-version`, error); - }); - }, - error: (error) => { - console.error(error); - }, - }); - }, [sdk, dispatch]); + const [connectionError, setConnectionError] = useState(null); useEffect(() => { + let timeoutId: NodeJS.Timeout; + if (connectionError) { + timeoutId = setTimeout(() => { + setConnectionError(null); + }, 3000); + } return () => { - // Example cleaning up the discovery - sdk.stopDiscovering(); + if (timeoutId) { + clearTimeout(timeoutId); + } }; - }, [sdk]); - + }, [connectionError]); return ( { Use this application to test Ledger hardware device features. - + + {connectionError && ( + } + /> + } + hasBackground + title="Error" + description={ + connectionError.message || + (connectionError.originalError as Error | undefined)?.message + } + /> + )} ); }; diff --git a/apps/sample/src/components/Menu/index.tsx b/apps/sample/src/components/Menu/index.tsx index def37f53e..e8f8979ce 100644 --- a/apps/sample/src/components/Menu/index.tsx +++ b/apps/sample/src/components/Menu/index.tsx @@ -1,8 +1,11 @@ import React from "react"; +import { BuiltinTransports } from "@ledgerhq/device-management-kit"; import { Flex, Icons, Link } from "@ledgerhq/react-ui"; import { useRouter } from "next/navigation"; import styled from "styled-components"; +import { useDmkConfigContext } from "@/providers/DmkConfig"; + const MenuItem = styled(Flex).attrs({ p: 3, pl: 5 })` align-items: center; `; @@ -15,6 +18,9 @@ const MenuTitle = styled(Link).attrs({ export const Menu: React.FC = () => { const router = useRouter(); + const { + state: { transport }, + } = useDmkConfigContext(); return ( <> @@ -24,17 +30,30 @@ export const Menu: React.FC = () => { - router.push("/commands")}>Commands + router.push("/commands")} + > + Commands + - router.push("device-actions")}> + router.push("device-actions")} + > Device actions - router.push("/apdu")}>APDU + router.push("/apdu")} + > + APDU + @@ -42,12 +61,28 @@ export const Menu: React.FC = () => { - router.push("/keyring")}>Keyrings + router.push("/signer")} + > + Signers + router.push("/cal")}>Crypto Assets + {transport === BuiltinTransports.MOCK_SERVER && ( + + + router.push("/mock")} + > + Mock Settings + + + )} ); }; diff --git a/apps/sample/src/components/MockView/MockDeviceDrawer.tsx b/apps/sample/src/components/MockView/MockDeviceDrawer.tsx new file mode 100644 index 000000000..1176e5453 --- /dev/null +++ b/apps/sample/src/components/MockView/MockDeviceDrawer.tsx @@ -0,0 +1,210 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { + type Mock, + type MockClient, + type Session, +} from "@ledgerhq/device-transport-kit-mock-client"; +import { Button, Divider, Flex, Input, Text } from "@ledgerhq/react-ui"; +import styled from "styled-components"; + +import { MockItem } from "@/components/MockView/MockItem"; +import { StyledDrawer } from "@/components/StyledDrawer"; + +type MockDeviceDrawerProps = { + currentSession: Session | null; + isOpen: boolean; + onClose: () => void; + client: MockClient; + onDeviceDeleted: () => void; +}; + +const MockButton = styled(Button).attrs({ + variant: "main", + color: "neutral.c00", + mx: 5, +})``; + +const inputContainerProps = { style: { borderRadius: 4 } }; + +export const MockDeviceDrawer: React.FC = ({ + currentSession, + isOpen, + onClose, + client, + onDeviceDeleted, +}) => { + const [mocks, setMocks] = useState([]); + const [currentPrefix, setCurrentPrefix] = useState("b001"); + const [currentResponse, setCurrentResponse] = useState("6700"); + const [editMockIndex, setEditMockIndex] = useState(-1); + + const fetchMocks = useCallback( + async (session: Session) => { + try { + const response = await client.getMocks(session.id); + setMocks(response); + } catch (error) { + console.error(error); + } + }, + [client], + ); + const sendMock = useCallback( + async (prefix: string, response: string) => { + if (!currentSession) { + return; + } + try { + const resp = await client.addMock(currentSession.id, prefix, response); + setEditMockIndex(-1); + if (!resp) { + console.log("Failed to add the mock"); + } else { + await fetchMocks(currentSession); + } + } catch (error) { + console.error(error); + } + }, + [currentSession, client, fetchMocks], + ); + const handleAddMockClick = useCallback(async () => { + if (!currentSession) { + return; + } + await sendMock(currentPrefix, currentResponse); + }, [currentPrefix, currentResponse, currentSession, sendMock]); + + const handleRemoveMocksClick = async () => { + if (!currentSession) { + return; + } + try { + const response = await client.deleteMocks(currentSession.id); + if (!response) { + console.log("Failed to delete the mocks"); + } else { + fetchMocks(currentSession).catch(console.error); + } + } catch (error) { + console.error(error); + } + }; + + const handleRemoveDeviceClick = useCallback( + async (sessionId: string) => { + try { + const response = await client.disconnect(sessionId); + if (!response) { + console.log("Failed to disconnect device"); + } else { + onDeviceDeleted(); + } + } catch (error) { + console.error(error); + } + }, + [client, onDeviceDeleted], + ); + + useEffect(() => { + if (isOpen && currentSession) { + fetchMocks(currentSession); + } + }, [isOpen, currentSession, fetchMocks]); + + return ( + + +
+ + + Prefix + + + Response + + + + +
+ {mocks.map((mock, index) => ( + setEditMockIndex(index)} + onSubmit={sendMock} + /> + ))} +
+ + + + + + + + + + + Add + + + +
+ + + Remove all mocks + + + currentSession ? handleRemoveDeviceClick(currentSession.id) : null + } + > + Remove device + + +
+
+ ); +}; diff --git a/apps/sample/src/components/MockView/MockItem.tsx b/apps/sample/src/components/MockView/MockItem.tsx new file mode 100644 index 000000000..78c3668a1 --- /dev/null +++ b/apps/sample/src/components/MockView/MockItem.tsx @@ -0,0 +1,83 @@ +import React, { useState } from "react"; +import { type Mock } from "@ledgerhq/device-transport-kit-mock-client"; +import { Button, Flex, Icons, Input, Text } from "@ledgerhq/react-ui"; + +type MockItemProps = { + mock: Mock; + editable: boolean; + onEdit: () => void; + onSubmit: (prefix: string, response: string) => void; +}; + +export const MockItem: React.FC = ({ + mock, + editable, + onEdit, + onSubmit, +}) => { + const [currentResponse, setCurrentResponse] = useState(mock.response); + const [currentPrefix, setCurrentPrefix] = useState(mock.prefix); + + return ( + + {editable ? ( + <> + + + + + + + + ) : ( + <> + + {mock.prefix} + + + {mock.response} + + + )} + +