diff --git a/.changeset/README.md b/.changeset/README.md new file mode 100644 index 0000000000..e5b6d8d6a6 --- /dev/null +++ b/.changeset/README.md @@ -0,0 +1,8 @@ +# Changesets + +Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works +with multi-package repos, or single-package repos to help you version and publish your code. You can +find the full documentation for it [in our repository](https://github.com/changesets/changesets) + +We have a quick list of common questions to get you started engaging with this project in +[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) diff --git a/.changeset/config.json b/.changeset/config.json new file mode 100644 index 0000000000..d7a0dbc6f8 --- /dev/null +++ b/.changeset/config.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://unpkg.com/@changesets/config@3.1.1/schema.json", + "changelog": "@changesets/cli/changelog", + "commit": false, + "fixed": [], + "linked": [], + "access": "public", + "baseBranch": "naga", + "updateInternalDependencies": "minor", + "ignore": [ + "@lit-protocol/wrapped-keys", + "@lit-protocol/wrapped-keys-lit-actions" + ] +} diff --git a/.changeset/fruity-lands-help.md b/.changeset/fruity-lands-help.md new file mode 100644 index 0000000000..bfbd49585b --- /dev/null +++ b/.changeset/fruity-lands-help.md @@ -0,0 +1,6 @@ +--- +'@lit-protocol/contracts': minor +'@lit-protocol/networks': minor +--- + +Add `naga` and `naga-proto` networks. Create per-network entrypoints and subpath exports (naga, naga-production, naga-proto, naga-staging, naga-test, naga-dev, naga-local) for better tree-shaking diff --git a/.changeset/gold-ducks-repeat.md b/.changeset/gold-ducks-repeat.md new file mode 100644 index 0000000000..f2fc0c9242 --- /dev/null +++ b/.changeset/gold-ducks-repeat.md @@ -0,0 +1,6 @@ +--- +'@lit-protocol/networks': patch +'@lit-protocol/e2e': patch +--- + +PKP signing now auto-hashes Cosmos payloads, exposes a documented bypassAutoHashing option, and ships with a new e2e suite plus docs so builders can rely on every listed curve working out of the box. diff --git a/.changeset/warm-lizards-fry.md b/.changeset/warm-lizards-fry.md new file mode 100644 index 0000000000..d4717317db --- /dev/null +++ b/.changeset/warm-lizards-fry.md @@ -0,0 +1,7 @@ +--- +'@lit-protocol/lit-client': patch +'@lit-protocol/networks': patch +'@lit-protocol/e2e': patch +--- + +SDK exposes typed Shiva env helpers (`createShivaEnvVars`, `waitForTestnetInfo`, `SUPPORTED_NETWORKS`) so QA suites can spin up testnets without bespoke env plumbing, and the new `executeWithHandshake` runner automatically retry failures for more stable Lit action execution. diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000..e8baa1de71 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,33 @@ +# dependencies & artifacts +node_modules +**/node_modules +dist +**/dist +tmp +e2e/dist +coverage + +# vcs & editor +.git +.gitignore +.vscode +.idea + +# env and secrets (mount or --env-file at runtime instead) +**/.env* +.env* + +out +**/out + +# allow lit-auth-server build output for docker image +!dist/apps/lit-auth-server/** +!dist/apps/lit-auth-server + +# allow lit-login-server build output for docker image +!dist/apps/lit-login-server/** +!dist/apps/lit-login-server + +# allow explorer build output for docker image +!apps/explorer/dist/** +!apps/explorer/dist diff --git a/.env.ci b/.env.ci deleted file mode 100644 index b1e7e0b844..0000000000 --- a/.env.ci +++ /dev/null @@ -1,19 +0,0 @@ -#Tinny ENV Vars -MAX_ATTEMTPS=1 -NETWORK=custom -DEBUG=true -WAIT_FOR_KEY_INTERVAL=3000 -TIME_TO_RELEASE_KEY=10000 -RUN_IN_BAND=true -RUN_IN_BAND_INTERVAL=5000 -NO_SETUP=false -USE_SHIVA=true -NETWORK_CONFIG=./networkContext.json -TEST_TIMEOUT=45000 - -#Shiva Client ENV Vars -STOP_TESTNET=false -TESTNET_MANAGER_URL=http://127.0.0.1:8000 -USE_LIT_BINARIES=true -LIT_NODE_BINARY_PATH=/usr/bin/lit_node -LIT_ACTION_BINARY_PATH=/usr/bin/lit_actions diff --git a/.env.sample b/.env.sample index f25e351f3b..bb3fe93752 100644 --- a/.env.sample +++ b/.env.sample @@ -1,21 +1,9 @@ -#Tinny ENV Vars -MAX_ATTEMPTS=1 -NETWORK=datil-dev -DEBUG=true -WAIT_FOR_KEY_INTERVAL=3000 -LIT_OFFICAL_RPC=https://chain-rpc.litprotocol.com/http -TIME_TO_RELEASE_KEY=10000 -RUN_IN_BAND=true -RUN_IN_BAND_INTERVAL=5000 -PRIVATE_KEYS="0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d,0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a,0x7c852118294e51e653712a81e05800f419141751be58f605c371e15141b007a6,0x47e179ec197488593b187f80a00eb0da91f1b9d0b13f8733639f19c30a34926a,0x8b3a350cf5c34c9194ca85829a2df0ec3153be0318b5e2d3348e872092edffba,0x92db14e403b83dfe3df233f83dfa3a0d7096f21ca9b0d6d6b8d88b2b4ec1564e,0x4bbbf85ce3377467afe5d46f804f221813b2bb87f24d81f60f1fcdbf7cbf4356,0xdbda1821b80551c9d65939329250298aa3472ba22feea921c0cf5d620ea67b97,0x2a871d0798f97d79848a013d4936a73bf4cc922c825d33c1cf7073dff6d409c6" -NO_SETUP=false -USE_SHIVA=false -NETWORK_CONFIG=./networkContext.json -TEST_TIMEOUT=45000 +# LIT_YELLOWSTONE_PRIVATE_RPC_URL= +# LOCAL_RPC_URL= -#Shiva Client ENV Vars -STOP_TESTNET=false -TESTNET_MANAGER_URL=http://0.0.0.0:8000 -USE_LIT_BINARIES=true -LIT_NODE_BINARY_PATH=/path/to/lit_node/binary -LIT_ACTION_BINARY_PATH=/path/to/lit_action_binary +LOG_LEVEL=silent +LIVE_MASTER_ACCOUNT= +LOCAL_MASTER_ACCOUNT=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 + +NODE_NO_WARNINGS=1 +NODE_OPTIONS=--no-deprecation diff --git a/.eslintignore b/.eslintignore index 4157000266..9a1058ea30 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,3 +1,5 @@ node_modules packages/wasm/rust/**/* packages/wasm/src/pkg/* +**/*.spec.ts +**/*.test.ts \ No newline at end of file diff --git a/.eslintrc.json b/.eslintrc.json index a6d5acb28d..3bf9d0f502 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,12 +1,12 @@ { "root": true, "ignorePatterns": ["**/*"], - "plugins": ["@nrwl/nx", "import"], + "plugins": ["@nx", "import"], "overrides": [ { "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], "rules": { - "@nrwl/nx/enforce-module-boundaries": [ + "@nx/enforce-module-boundaries": [ "error", { "enforceBuildableLibDependency": true, @@ -23,14 +23,14 @@ }, { "files": ["*.ts", "*.tsx"], - "extends": ["plugin:@nrwl/nx/typescript"], + "extends": ["plugin:@nx/typescript"], "rules": { "@typescript-eslint/no-inferrable-types": "off" } }, { "files": ["*.js", "*.jsx"], - "extends": ["plugin:@nrwl/nx/javascript"], + "extends": ["plugin:@nx/javascript"], "rules": {} } ], diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 1a9a2627a3..0000000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,146 +0,0 @@ -name: Testing -on: - push: - branches: - - master - pull_request: - branches: - - master - - staging/** - - feat/** - - feature/** - - staging/** -jobs: - unit-tests: - runs-on: warp-ubuntu-latest-x64-16x - timeout-minutes: 30 - steps: - - name: Checkout repo - uses: actions/checkout@v2 - with: - fetch-depth: 0 - - name: Set up Node.js - uses: actions/setup-node@v3 - with: - node-version: '20' - cache: 'yarn' - - name: Install rust - uses: dtolnay/rust-toolchain@1.76.0 - - uses: jetli/wasm-pack-action@v0.4.0 - with: - # Optional version of wasm-pack to install(eg. 'v0.9.1', 'latest') - version: 'latest' - - name: Install project dependencies - run: yarn --frozen-lockfile - - uses: nrwl/nx-set-shas@v3 - with: - main-branch-name: 'master' - - name: Build - run: yarn build:dev - - name: Run Unit tests - run: yarn tools --test --unit - integration-tests: - runs-on: ubuntu-latest - timeout-minutes: 30 - steps: - - name: Checkout repo - uses: actions/checkout@v2 - with: - fetch-depth: 0 - - name: Find latest datil commit hash for last successful "rust/lit-node-build-commit-hash" workflow in the Lit Assets repo - uses: LIT-Protocol/last-successful-build-action@372ea3325a894558ee74d970217ca421ea562fba - id: last-successful-build - with: - token: "${{ secrets.GH_PAT_FOR_SHIVA }}" - branch: "datil" - workflow: "rust/lit-node-build-commit-hash" - repo: LIT-Protocol/lit-assets - # this outputs to dollarSign{{ steps.last-successful-build.outputs.lastSuccessfulBuildSha }} - - name: Checkout Lit Assets - uses: actions/checkout@v4 - id: checkout - with: - fetch-depth: 0 - repository: LIT-Protocol/lit-assets - ref: ${{ steps.last-successful-build.outputs.lastSuccessfulBuildSha }} - token: ${{secrets.GH_PAT_FOR_SHIVA}} - path: ${{ github.workspace }}/lit-assets/ - submodules: false - sparse-checkout: | - blockchain - rust/lit-node - - name: Check LA dir - run: ls -la ${{github.workspace}}/lit-assets - - name: Install LA Blockchain Dependencies - run: npm i - working-directory: ${{github.workspace}}/lit-assets/blockchain/contracts - - name: Docker login - id: login - run: docker login ghcr.io/ -u ${{ github.actor }} --password ${{secrets.GH_PAT_FOR_SHIVA}} - - name: Pull Shiva Container - id: shiva-pull - run: docker pull ghcr.io/lit-protocol/shiva:latest - - name: Run Shiva Container - id: shiva-runner - run: docker run -d -m 32g -p 8000:8000 -p 8545:8545 -p 7470:7470 -p 7471:7471 -p 7472:7472 -p 7473:7473 -p 7474:7474 -p 7475:7475 -v ${{github.workspace}}/lit-assets:/data -e GH_PAT=${{secrets.GH_PAT_FOR_SHIVA}} -e HASH=${{ steps.last-successful-build.outputs.lastSuccessfulBuildSha }} -e IPFS_API_KEY=${{secrets.IPFS_API_KEY}} --name shiva ghcr.io/lit-protocol/shiva:latest - - name: Set up Node.js - uses: actions/setup-node@v3 - with: - node-version: '20' - - uses: jetli/wasm-pack-action@v0.4.0 - with: - # Optional version of wasm-pack to install(eg. 'v0.9.1', 'latest') - version: 'latest' - - name: Install project dependencies - run: yarn --frozen-lockfile - - uses: nrwl/nx-set-shas@v3 - with: - main-branch-name: 'master' - - name: Build packages - id: build - run: yarn build:dev - - name: Copy ENV File - run: cp .env.ci .env - - name: Run End to End Tests - if: steps.build.outputs.exit_code == 0 - run: yarn test:local --filter=testUseEoaSessionSigsToExecuteJsSigning,testUseEoaSessionSigsToPkpSign,testUsePkpSessionSigsToExecuteJsSigning,testUsePkpSessionSigsToPkpSign,testUseValidLitActionCodeGeneratedSessionSigsToPkpSign,testUseValidLitActionCodeGeneratedSessionSigsToExecuteJsSigning,testDelegatingCapacityCreditsNFTToAnotherWalletToExecuteJs,testEthAuthSigToEncryptDecryptString,testExecuteJsSignAndCombineEcdsa,testExecutJsDecryptAndCombine,testExecuteJsBroadcastAndCollect --exclude=Parallel - - name: Get Container Logs - if: always() - run: docker logs shiva - - name: Post Pull Shiva Container - id: container-stop - if: steps.shiva-pull.outputs.exit_code == 0 - run: docker stop shiva && docker rm shiva - - name: Post Pull Shiva Image - if: steps.shiva-pull.outputs.exit_code == 0 - run: docker rmi ghcr.io/lit-protocol/shiva - ping-lit-configuration-guides: - runs-on: ubuntu-latest - # needs: [unit-tests, integration-tests] # Make sure this job runs after others complete - steps: - - name: Get PR labels - id: pr-labels - uses: actions/github-script@v6 - if: github.event_name == 'pull_request' - with: - script: | - const labels = context.payload.pull_request.labels - .map(label => label.name) - .filter(name => name.startsWith('tag:')) - .map(name => name.split(':')[1]); - if (labels.length > 0) { - core.setOutput('tag', labels[0]); - } else { - core.setOutput('skip', 'true'); - } - - name: Trigger dependencies bot in lit-configuration-guides - if: steps.pr-labels.outputs.skip != 'true' - run: | - TAG="${{ steps.pr-labels.outputs.tag }}" - curl -X POST \ - -H "Accept: application/vnd.github.everest-preview+json" \ - -H "Authorization: token ${{ secrets.GH_PAT_LIT_CONFIGURATION_GUIDES_REPO }}" \ - https://api.github.com/repos/LIT-Protocol/lit-configuration-guides/dispatches \ - -d "{\"event_type\":\"dependency_update\", \"client_payload\": {\"labels\": [\"$TAG\"]}}" - env: - GH_PAT_LIT_CONFIGURATION_GUIDES_REPO: ${{ secrets.GH_PAT_LIT_CONFIGURATION_GUIDES_REPO }} diff --git a/.github/workflows/e2e-naga.yml b/.github/workflows/e2e-naga.yml new file mode 100644 index 0000000000..231fa7e70a --- /dev/null +++ b/.github/workflows/e2e-naga.yml @@ -0,0 +1,186 @@ +name: E2E - Naga (matrix) + +on: + push: + branches: [naga, canary-naga] + pull_request: + +permissions: + contents: write + pull-requests: write + +jobs: + build: + name: Build once + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions-rs/toolchain@v1 + with: + toolchain: stable + override: true + components: rust-std + - uses: jetli/wasm-pack-action@v0.4.0 + with: + version: latest + - uses: actions/setup-node@v4 + with: + node-version: 22.18.0 + registry-url: https://registry.npmjs.org + - name: Enable corepack + pin pnpm + run: | + corepack enable + corepack prepare pnpm@9.15.0 --activate + - run: pnpm install --frozen-lockfile + - run: pnpm build + - name: Upload build output + uses: actions/upload-artifact@v4 + with: + name: build-output + path: | + dist + packages/wasm/src/pkg + if-no-files-found: error + + e2e: + name: E2E (${{ matrix.network }}) + needs: build + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - environment: naga-dev + network: naga-dev + privateKey: LIVE_MASTER_ACCOUNT_NAGA_DEV + # - environment: naga-staging + # network: naga-staging + # privateKey: LIVE_MASTER_ACCOUNT_NAGA_STAGING + - environment: naga-test + network: naga-test + privateKey: LIVE_MASTER_ACCOUNT_NAGA_TEST + env: + LOG_LEVEL: debug2 + LIVE_MASTER_ACCOUNT: ${{ secrets[matrix.privateKey] }} + LOCAL_MASTER_ACCOUNT: ${{ secrets[matrix.privateKey] }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22.18.0 + registry-url: https://registry.npmjs.org + - name: Enable corepack + pin pnpm + run: | + corepack enable + corepack prepare pnpm@9.15.0 --activate + - name: Install Dependencies + run: pnpm install --frozen-lockfile + - name: Download build output + uses: actions/download-artifact@v4 + with: + name: build-output + path: build-output + - name: Restore build artifacts + run: | + mkdir -p dist + mkdir -p packages/wasm/src/pkg + if [ -d build-output/dist ]; then + cp -a build-output/dist/. dist/ + fi + if [ -d build-output/packages/wasm/src/pkg ]; then + cp -a build-output/packages/wasm/src/pkg/. packages/wasm/src/pkg/ + fi + rm -rf build-output + - name: Verify required secrets + run: | + if [ -z "${LIVE_MASTER_ACCOUNT}" ]; then + echo "LIVE_MASTER_ACCOUNT is not set for network ${{ matrix.network }}" >&2 + exit 1 + fi + if [ -z "${LOCAL_MASTER_ACCOUNT}" ]; then + echo "LOCAL_MASTER_ACCOUNT is not set for network ${{ matrix.network }}" >&2 + exit 1 + fi + - name: Run health check (${{ matrix.network }}) + run: NETWORK=${{ matrix.network }} pnpm run test:e2e:ci -- packages/e2e/src/e2e.spec.ts --testNamePattern "^all " + timeout-minutes: 10 + + release: + name: Release + needs: e2e + runs-on: ubuntu-latest + if: github.event_name == 'push' && needs.e2e.result == 'success' + steps: + - name: Check NPM Token + run: | + if [ -z "${{ secrets.NODE_AUTH_TOKEN }}" ]; then + echo "❌ NODE_AUTH_TOKEN secret is not set. Please add it to repository secrets." + exit 1 + else + echo "✅ NODE_AUTH_TOKEN secret is available." + fi + + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ github.ref }} + + - uses: pnpm/action-setup@v4 + with: + version: 9.15.0 + + - uses: actions/setup-node@v4 + with: + node-version: 22.18.0 + registry-url: https://registry.npmjs.org + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + + - name: Enable corepack + pin pnpm + run: | + corepack enable + corepack prepare pnpm@9.15.0 --activate + + - uses: actions-rs/toolchain@v1 + with: + toolchain: stable + override: true + components: rust-std + + - uses: jetli/wasm-pack-action@v0.4.0 + with: + version: latest + + - name: Install Dependencies + run: pnpm install --frozen-lockfile + + - name: Build packages + run: pnpm run build + + - name: Create Release Pull Request or Publish to npm + id: changesets + uses: changesets/action@v1 + with: + branch: ${{ github.ref_name }} + version: pnpm changeset version + publish: pnpm changeset publish --access public + commit: "chore(release): version packages" + title: "chore(release): version packages" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }} + + - name: Sync docs changelog + run: pnpm sync:docs-changelog + + - name: Commit docs changelog + run: | + if git diff --quiet docs/changelog.mdx; then + echo "Docs changelog already up to date." + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add docs/changelog.mdx + git commit -m "chore: sync docs changelog" + git push origin ${{ github.ref_name }} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index c1c28d0cef..f4388c8afc 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -3,28 +3,41 @@ on: pull_request: push: branches: - - master + - naga + - canary-naga jobs: linter: runs-on: ubuntu-latest timeout-minutes: 10 steps: - name: Checkout repo - uses: actions/checkout@v2 + uses: actions/checkout@v4 with: fetch-depth: 0 - - name: Set up Node.js - uses: actions/setup-node@v3 + + - name: Setup Node + uses: actions/setup-node@v4 with: - node-version: '18' - cache: 'yarn' + node-version: 22.18.0 + registry-url: https://registry.npmjs.org + + - name: Enable corepack + pin pnpm + run: | + corepack enable + corepack prepare pnpm@9.15.0 --activate + - name: Install rust uses: dtolnay/rust-toolchain@1.76.0 - - uses: jetli/wasm-pack-action@v0.4.0 + + - name: Install wasm-pack + uses: jetli/wasm-pack-action@v0.4.0 with: - # Optional version of wasm-pack to install(eg. 'v0.9.1', 'latest') version: 'latest' + - name: Install project dependencies - run: yarn install + run: pnpm install --frozen-lockfile + - name: Lint - run: yarn nx format:check --all + run: | + echo "Running workspace format check..." + NX_DAEMON=false pnpm run format:check diff --git a/.github/workflows/naga-health-check.yml b/.github/workflows/naga-health-check.yml new file mode 100644 index 0000000000..7c4138cbb8 --- /dev/null +++ b/.github/workflows/naga-health-check.yml @@ -0,0 +1,114 @@ +name: Naga Health Checks + +on: + push: + branches: + - feature/jss-29-feature-add-naga-uptime-bot + schedule: + - cron: '*/5 * * * *' + workflow_dispatch: + inputs: + naga_branch: + description: 'Branch to run health checks from (optional)' + required: true + default: 'naga' + network: + description: 'Specific network to test (leave empty for all)' + required: false + type: choice + options: + - naga-dev + - naga-test + +env: + LIT_STATUS_WRITE_KEY: ${{ secrets.LIT_STATUS_WRITE_KEY }} + LIT_STATUS_BACKEND_URL: ${{ vars.LIT_STATUS_BACKEND_URL }} + +jobs: + naga-health-check: + runs-on: ubuntu-latest + environment: Health Check + strategy: + fail-fast: false + matrix: + network: [naga-dev, naga-test] + env: + NETWORK: ${{ matrix.network }} + LIVE_MASTER_ACCOUNT: ${{ matrix.network == 'naga-dev' && secrets.LIVE_MASTER_ACCOUNT_NAGA_DEV || secrets.LIVE_MASTER_ACCOUNT_NAGA_TEST }} + LIT_YELLOWSTONE_PRIVATE_RPC_URL: ${{ vars.LIT_YELLOWSTONE_PRIVATE_RPC_URL }} + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.naga_branch || github.ref }} + fetch-depth: 1 + + - name: Install rust + uses: actions-rs/toolchain@v1 + with: + toolchain: stable + override: true + components: rust-std + + - name: Install wasm-pack + uses: jetli/wasm-pack-action@v0.4.0 + with: + version: latest + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22.18.0' + registry-url: 'https://registry.npmjs.org' + + - name: Enable corepack and setup pnpm + run: | + corepack enable + corepack prepare pnpm@9.15.0 --activate + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build project + run: pnpm build + + - name: Verify required environment variables + run: | + echo "Checking environment variables for ${{ matrix.network }}..." + if [ -z "${LIVE_MASTER_ACCOUNT}" ]; then + echo "❌ LIVE_MASTER_ACCOUNT is not set for ${{ matrix.network }}" + exit 1 + fi + if [ -z "${LIT_STATUS_WRITE_KEY}" ]; then + echo "❌ LIT_STATUS_WRITE_KEY is not set" + exit 1 + fi + if [ -z "${LIT_STATUS_BACKEND_URL}" ]; then + echo "❌ LIT_STATUS_BACKEND_URL is not set" + exit 1 + fi + echo "✅ All required environment variables are set" + + - name: Run health check for ${{ matrix.network }} + if: ${{ github.event_name != 'workflow_dispatch' || github.event.inputs.network == '' || github.event.inputs.network == matrix.network }} + run: pnpm run ci:health + timeout-minutes: 10 + env: + NETWORK: ${{ matrix.network }} + LIVE_MASTER_ACCOUNT: ${{ matrix.network == 'naga-dev' && secrets.LIVE_MASTER_ACCOUNT_NAGA_DEV || secrets.LIVE_MASTER_ACCOUNT_NAGA_TEST }} + LIT_STATUS_WRITE_KEY: ${{ secrets.LIT_STATUS_WRITE_KEY }} + LIT_STATUS_BACKEND_URL: ${{ vars.LIT_STATUS_BACKEND_URL }} + LIT_YELLOWSTONE_PRIVATE_RPC_URL: ${{ vars.LIT_YELLOWSTONE_PRIVATE_RPC_URL }} + LOG_LEVEL: info + + - name: Health check summary + if: always() + run: | + if [[ "${{ job.status }}" == "success" ]]; then + echo "✅ Health check passed for ${{ matrix.network }}" + echo "Time: $(date -u +'%Y-%m-%d %H:%M:%S UTC')" + else + echo "❌ Health check failed for ${{ matrix.network }}" + echo "Time: $(date -u +'%Y-%m-%d %H:%M:%S UTC')" + echo "Please check the logs above for details" + fi diff --git a/.github/workflows/release-docker-images.yml b/.github/workflows/release-docker-images.yml new file mode 100644 index 0000000000..870f1f336c --- /dev/null +++ b/.github/workflows/release-docker-images.yml @@ -0,0 +1,108 @@ +name: Release Docker Images + +on: + workflow_dispatch: + inputs: + auth-server-released: + description: 'Set to true to push docker images.' + required: true + type: boolean + default: false + custom-tag: + description: 'Optional tag name to apply in addition to ref/sha tags.' + required: false + default: '' + +permissions: + contents: read + packages: write + +env: + NODE_VERSION: '22.18.0' + PNPM_VERSION: 9.15.0 + +jobs: + docker-images: + name: Build and Push + if: ${{ github.event.inputs.auth-server-released == 'true' }} + runs-on: ubuntu-latest + strategy: + matrix: + app: [lit-auth-server, lit-login-server] + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: Setup PNPM + uses: pnpm/action-setup@v4 + with: + version: ${{ env.PNPM_VERSION }} + + - name: Install rust + uses: actions-rs/toolchain@v1 + with: + toolchain: stable + override: true + components: rust-std + + - name: Install wasm-pack + uses: jetli/wasm-pack-action@v0.4.0 + with: + version: 'latest' + + - name: Install project dependencies + run: pnpm install --frozen-lockfile + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ secrets.GHCR_USERNAME || github.repository_owner }} + password: ${{ secrets.GHCR_TOKEN || secrets.GITHUB_TOKEN }} + + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/lit-protocol/${{ matrix.app }} + tags: | + type=ref,event=branch + type=ref,event=tag + type=sha + type=raw,value=latest + + - name: Build image with Nx target + run: pnpm nx run ${{ matrix.app }}:docker-build + + - name: Tag and push image + env: + IMAGE_NAME: ${{ matrix.app }} + TAGS: ${{ steps.meta.outputs.tags }} + CUSTOM_TAG: ${{ github.event.inputs.custom-tag }} + run: | + tags_to_push="$TAGS" + if [ -n "$CUSTOM_TAG" ]; then + tags_to_push="$tags_to_push"$'\n'"ghcr.io/lit-protocol/${IMAGE_NAME}:$CUSTOM_TAG" + fi + echo "$tags_to_push" | while IFS= read -r tag; do + [ -z "$tag" ] && continue + docker tag "$IMAGE_NAME" "$tag" + docker push "$tag" + done + + skip: + name: Skip Docker Release + if: ${{ github.event.inputs.auth-server-released != 'true' }} + runs-on: ubuntu-latest + steps: + - run: echo "Skipping docker image publish because auth-server release flag is false." diff --git a/.gitignore b/.gitignore index 37858e19f6..da87bd13bb 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ /out-tsc **/dist **/out-tsc +local-tests/**/*.js # dependencies node_modules @@ -74,3 +75,27 @@ local-tests/build .env packages/wrapped-keys-lit-actions/src/generated + +digest +generate-digest.ts + +.cursor/rules +packages/networks/src/networks/vDatil +lit-auth-storage +.ctx +packages/auth-services/lit-auth-* +pkp-tokens +pkp-tokens-bob +lit-cache +lit-auth-local +artillery-state.json +artillery-pkp-tokens +lit-auth-artillery +alice-auth-manager-data + +.plans +.e2e +alice-auth-manager-data +!/packages/contracts/dist/ +!/packages/contracts/dist/dev +.secret \ No newline at end of file diff --git a/.prettierignore b/.prettierignore index 86d942ab30..90eac4f170 100644 --- a/.prettierignore +++ b/.prettierignore @@ -15,3 +15,5 @@ tools /packages/wasm/rust/* /packages/wasm/src/pkg/* **/*/dist +/docs +/packages/**/CHANGELOG.md \ No newline at end of file diff --git a/.prettierrc b/.prettierrc index 1962ea2e60..6476721dd9 100644 --- a/.prettierrc +++ b/.prettierrc @@ -6,7 +6,6 @@ "insertPragma": false, "singleAttributePerLine": false, "bracketSameLine": false, - "jsxBracketSameLine": false, "jsxSingleQuote": false, "printWidth": 80, "proseWrap": "preserve", diff --git a/.vscode/settings.json b/.vscode/settings.json index 0145107988..5809d279a4 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,35 +1,27 @@ { - "liveServer.settings.port": 5502, - "todo-tree.tree.scanMode": "workspace", - "conventionalCommits.scopes": [ - "contracts-sdk", - "lit-node-client-nodejs", - "core", - ], - // "restoreTerminals.terminals": [ - // { - // "splitTerminals": [ - // // { - // // "name": "nx graph", - // // "commands": ["yarn graph"] - // // }, - // { - // "name": "nodejs", - // "commands": ["yarn nx run nodejs:serve"] - // }, - // { - // "name": "html", - // "commands": ["yarn nx run html:serve"] - // }, - // { - // "name": "react", - // "commands": ["yarn nx run react:serve"] - // }, - // { - // "name": "custom", - // "commands": ["clear"] - // } - // ] - // } - // ] -} \ No newline at end of file + "liveServer.settings.port": 5502, + "todo-tree.tree.scanMode": "workspace", + "conventionalCommits.scopes": ["contracts-sdk", "lit-node-client", "core"], + "workbench.colorCustomizations": { + "actiÇvityBar.background": "#2f7c47", + "activityBar.activeBackground": "#e7520f", + "activityBar.background": "#e7520f", + "activityBar.foreground": "#e7e7e7", + "activityBar.inactiveForeground": "#e7e7e799", + "activityBarBadge.background": "#065a20", + "activityBarBadge.foreground": "#e7e7e7", + "commandCenter.border": "#e7e7e799", + "sash.hoverBorder": "#e7520f", + "statusBar.background": "#b7410c", + "statusBar.foreground": "#e7e7e7", + "statusBarItem.hoverBackground": "#e7520f", + "statusBarItem.remoteBackground": "#b7410c", + "statusBarItem.remoteForeground": "#e7e7e7", + "titleBar.activeBackground": "#b7410c", + "titleBar.activeForeground": "#e7e7e7", + "titleBar.inactiveBackground": "#b7410c99", + "titleBar.inactiveForeground": "#e7e7e799" + }, + "peacock.color": "#215732", + "peacock.remoteColor": "#B7410C" +} diff --git a/.yarnrc.yml b/.yarnrc.yml deleted file mode 100644 index 3186f3f079..0000000000 --- a/.yarnrc.yml +++ /dev/null @@ -1 +0,0 @@ -nodeLinker: node-modules diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index c699a7ae16..0000000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,116 +0,0 @@ -# Changelog - -All notable changes to this project will be documented in this file. - -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - -# [5.2.1] - 2024-05-15 - -- [remove multiformats/cid package](https://github.com/LIT-Protocol/js-sdk/pull/467) - -# [3.2.6] - 2024-03-13 - -- [staging/3.2.6](https://github.com/LIT-Protocol/js-sdk/pull/396) - -# [3.2.2] - 2024-02-27 - -- [staging/3.2.2](https://github.com/LIT-Protocol/js-sdk/pull/382) - -# [3.2.0] - 2024-02-20 - -- [staging/3.2.0](https://github.com/LIT-Protocol/js-sdk/pull/370) - -# [3.1.3] - 2024-02-13 - -- [staging/2024-02-13](https://github.com/LIT-Protocol/js-sdk/pull/344) - -# [3.1.2] - 2024-02-06 - -- [staging/2024-02-06](https://github.com/LIT-Protocol/js-sdk/pull/340) - -# [3.0.18] - 2023-11-10 - -- [feature/lit-1859-example-of-setting-permission-scopes](https://github.com/LIT-Protocol/js-sdk/pull/253) - -# [3.0.0] - 2023-09-25 - -- [https://github.com/LIT-Protocol/js-sdk/pull/199](https://github.com/LIT-Protocol/js-sdk/pull/199) - -# [2.2.39] - 2023-07-06 - -- [a0d88bc](https://github.com/LIT-Protocol/js-sdk/pull/167) Add [Backpack wallet 🎒](https://www.backpack.app/) support - -# [2.2.33] - 2023-06-27 - -- [95c7258](https://github.com/LIT-Protocol/js-sdk/commit/95c725850de44e17f70a9365dc13e46f6bd841de) Removed wallet connect from lit-connect-modal temporarily - -# [2.2.20] - 2023-05-31 - -- [#106](https://github.com/LIT-Protocol/js-sdk/pull/106) New `pkp-walletconnect` package to connect PKPs and dApps using WalletConnect V2 - -# [2.2.15] - 2023-05-30 - -- [#122](https://github.com/LIT-Protocol/js-sdk/pull/122) Added demo for email/sms -- [#123](https://github.com/LIT-Protocol/js-sdk/pull/123) Added Apple JWT Auth Provider - -## [2.2.0] - 2023-05-12 - -- [#88](https://github.com/LIT-Protocol/js-sdk/pull/88) Breaking change introduced to `lit-node-client-nodejs` regarding session signature generation and usage. Introduced `auth-helpers` package, which contains objects for working with session capability objects for session signatures. - -## [2.1.160] - 2023-05-05 - -- [#90](https://github.com/LIT-Protocol/js-sdk/issues/90) Fixed the issue where it was unable to regenerate the authSig when it had expired. - -## [2.1.156] - 2023-05-04 - -- [#67](https://github.com/LIT-Protocol/js-sdk/pull/67) Introduced the `lit-auth-client` package, enabling social logins, Ethereum wallet signing, WebAuthn registration and authentication, and management of PKPs tied to auth methods - -- [#57](https://github.com/LIT-Protocol/js-sdk/pull/57) Introducing the pkp-client package, which serves as an abstraction of the pkp-ether, pkp-cosmos, and pkp-base packages. This enables the creation of Ether and Cosmos signers through PKPClient. - -- [#97](https://github.com/LIT-Protocol/js-sdk/pull/97) Fixed `warn - ./node_modules/@lit-protocol/ecdsa-sdk/src/lib/ecdsa-sdk.js Critical dependency: the request of a dependency is an expression` - -## [2.1.114] - 2023-04-08 - -- [#77](https://github.com/LIT-Protocol/js-sdk/pull/77) Added support for Leap Cosmos wallet - -## [2.1.100] - 2023-03-29 - -- [#40](https://github.com/LIT-Protocol/js-sdk/pull/54) Added sessionSigs support to the remaining SDK functions. Now users have the option to use sessionSigs in place of authSigs. - -## [2.1.94] - 2023-03-21 - -- [#40](https://github.com/LIT-Protocol/js-sdk/pull/40) Simplified the multi-step process of encrypting & decrypting static content and storing all its metadata on IPFS in a single function `encryptToIPFS` & `decryptFromIpfs`. - -## [2.1.84] - 2023-03-16 - -- [#47](https://github.com/LIT-Protocol/js-sdk/pull/47) Upgraded `@walletconnect/ethereum-provider` to version `2.5.1` and added `@web3modal/standalone` as a depdency to the `auth-browser` repo - -## [2.1.63] - 2023-03-13 - -- [[#44](https://github.com/LIT-Protocol/js-sdk/pull/44)] Separated Node Code into its own repository `@lit-protocol/lit-node-client-nodejs`, from which `@lit-protocol/lit-node-client` will extend, so there are no breaking changes for existing customers. - -### Added - -- `yarn v` to check the current npm version -- `yarn bump` to update `patch` version in `lerna.json` and `version.ts` -- `yarn bump:minor` to update `minor` version in `lerna.json` and `version.ts` -- `yarn bump:major` to update `major` version in `lerna.json` and `version.ts` -- Logs will now include version number eg. `[LitJsSdk v2.1.63]` -- `yarn tool:e2e` will now serve the react app and launch Cypress E2E testing automatically - -## [3.0.0] - 2023-09-26 - -- [[#199](https://github.com/LIT-Protocol/js-sdk/pull/199)] `Cayenne` network upgrade bumps `packages` to `3.0.0` - -### Added - -- [#145](https://github.com/LIT-Protocol/js-sdk/pull/145) ACC-based JWT Signing (V2) -- `computePubKey` to `lit-core` which wraps an implementation in `crypto` for interfacing with a new wasm module for deriving HD public keys -- Addition of `claimKeyId` method on `lit-node-client-nodejs` for deriving a key from an `authMethod` - - Supports a new `MintCallBack` which is defined as `async (params: ClaimKeyResponse): Promise` which is called to route derived keys from a claim operation on chain. - -### Updates - -- Update `SIGTYPE` to include new ecdsa types -- [#107](https://github.com/LIT-Protocol/js-sdk/pull/107) Adds support for new ECDSA implementations for signature recombine diff --git a/README.md b/README.md index 147bb24a8e..6f78e54240 100644 --- a/README.md +++ b/README.md @@ -1,395 +1,251 @@
-

Lit Protocol Javascript/Typescript SDK V7.x.x

- - -
- -
-
-The Lit JavaScript SDK provides developers with a framework for implementing Lit functionality into their own applications. Find installation instructions in the docs to get started with the Lit SDK based on your use case: -
-
- - - - -https://developer.litprotocol.com/SDK/Explanation/installation - - +

Lit Protocol SDK

+ + +
+ + +

+
+ Explore the docs » | +
+
+ Explorer + · + E2E Test Dapp + · + Report Bug + · + Request Feature +

-
- -# Quick Start - -### NodeJS Exclusive - -Removed browser-specific methods, e.g., checkAndSignAuthSig - -``` -yarn add @lit-protocol/lit-node-client-nodejs -``` +# Prerequisite -or.. - -### Isomorphic Implementation +- node (v20.x or above) +- rust (v1.70.00 or above) +- [wasm-pack](https://github.com/rustwasm/wasm-pack) -Operable in both Node.js and the browser +# Getting started ``` -yarn add @lit-protocol/lit-node-client +pnpm install && pnpm build ``` -
- -
- -# Packages - -📝 If you're looking to use the Lit SDK, you're probably all set with just the lit-node-client .
Get started with interacting with Lit network! - - - -| Package | Category | Download | -| -------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| [@lit-protocol/lit-node-client-nodejs](https://github.com/LIT-Protocol/js-sdk/tree/master/packages/lit-node-client-nodejs) | ![lit-node-client-nodejs](https://img.shields.io/badge/-nodejs-2E8B57 'lit-node-client-nodejs') | | -| [@lit-protocol/lit-node-client](https://github.com/LIT-Protocol/js-sdk/tree/master/packages/lit-node-client) | ![lit-node-client](https://img.shields.io/badge/-universal-8A6496 'lit-node-client') | | - -If you're a tech-savvy user and wish to utilize only specific submodules that our main module relies upon, you can find individual packages listed below. This way, you can import only the necessary packages that cater to your specific use case:: - -| Package | Category | Download | -| -------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| [@lit-protocol/access-control-conditions](https://github.com/LIT-Protocol/js-sdk/tree/master/packages/access-control-conditions) | ![access-control-conditions](https://img.shields.io/badge/-universal-8A6496 'access-control-conditions') | | -| [@lit-protocol/auth-helpers](https://github.com/LIT-Protocol/js-sdk/tree/master/packages/auth-helpers) | ![auth-helpers](https://img.shields.io/badge/-universal-8A6496 'auth-helpers') | | -| [@lit-protocol/constants](https://github.com/LIT-Protocol/js-sdk/tree/master/packages/constants) | ![constants](https://img.shields.io/badge/-universal-8A6496 'constants') | | -| [@lit-protocol/contracts-sdk](https://github.com/LIT-Protocol/js-sdk/tree/master/packages/contracts-sdk) | ![contracts-sdk](https://img.shields.io/badge/-universal-8A6496 'contracts-sdk') | | -| [@lit-protocol/core](https://github.com/LIT-Protocol/js-sdk/tree/master/packages/core) | ![core](https://img.shields.io/badge/-universal-8A6496 'core') | | -| [@lit-protocol/crypto](https://github.com/LIT-Protocol/js-sdk/tree/master/packages/crypto) | ![crypto](https://img.shields.io/badge/-universal-8A6496 'crypto') | | -| [@lit-protocol/encryption](https://github.com/LIT-Protocol/js-sdk/tree/master/packages/encryption) | ![encryption](https://img.shields.io/badge/-universal-8A6496 'encryption') | | -| [@lit-protocol/event-listener](https://github.com/LIT-Protocol/js-sdk/tree/master/packages/event-listener) | ![event-listener](https://img.shields.io/badge/-universal-8A6496 'event-listener') | | -| [@lit-protocol/logger](https://github.com/LIT-Protocol/js-sdk/tree/master/packages/logger) | ![logger](https://img.shields.io/badge/-universal-8A6496 'logger') | | -| [@lit-protocol/misc](https://github.com/LIT-Protocol/js-sdk/tree/master/packages/misc) | ![misc](https://img.shields.io/badge/-universal-8A6496 'misc') | | -| [@lit-protocol/nacl](https://github.com/LIT-Protocol/js-sdk/tree/master/packages/nacl) | ![nacl](https://img.shields.io/badge/-universal-8A6496 'nacl') | | -| [@lit-protocol/pkp-base](https://github.com/LIT-Protocol/js-sdk/tree/master/packages/pkp-base) | ![pkp-base](https://img.shields.io/badge/-universal-8A6496 'pkp-base') | | -| [@lit-protocol/pkp-cosmos](https://github.com/LIT-Protocol/js-sdk/tree/master/packages/pkp-cosmos) | ![pkp-cosmos](https://img.shields.io/badge/-universal-8A6496 'pkp-cosmos') | | -| [@lit-protocol/pkp-ethers](https://github.com/LIT-Protocol/js-sdk/tree/master/packages/pkp-ethers) | ![pkp-ethers](https://img.shields.io/badge/-universal-8A6496 'pkp-ethers') | | -| [@lit-protocol/pkp-sui](https://github.com/LIT-Protocol/js-sdk/tree/master/packages/pkp-sui) | ![pkp-sui](https://img.shields.io/badge/-universal-8A6496 'pkp-sui') | | -| [@lit-protocol/pkp-walletconnect](https://github.com/LIT-Protocol/js-sdk/tree/master/packages/pkp-walletconnect) | ![pkp-walletconnect](https://img.shields.io/badge/-universal-8A6496 'pkp-walletconnect') | | -| [@lit-protocol/types](https://github.com/LIT-Protocol/js-sdk/tree/master/packages/types) | ![types](https://img.shields.io/badge/-universal-8A6496 'types') | | -| [@lit-protocol/uint8arrays](https://github.com/LIT-Protocol/js-sdk/tree/master/packages/uint8arrays) | ![uint8arrays](https://img.shields.io/badge/-universal-8A6496 'uint8arrays') | | -| [@lit-protocol/wasm](https://github.com/LIT-Protocol/js-sdk/tree/master/packages/wasm) | ![wasm](https://img.shields.io/badge/-universal-8A6496 'wasm') | | -| [@lit-protocol/wrapped-keys](https://github.com/LIT-Protocol/js-sdk/tree/master/packages/wrapped-keys) | ![wrapped-keys](https://img.shields.io/badge/-universal-8A6496 'wrapped-keys') | | -| [@lit-protocol/wrapped-keys-lit-actions](https://github.com/LIT-Protocol/js-sdk/tree/master/packages/wrapped-keys-lit-actions) | ![wrapped-keys-lit-actions](https://img.shields.io/badge/-universal-8A6496 'wrapped-keys-lit-actions') | | -| [@lit-protocol/auth-browser](https://github.com/LIT-Protocol/js-sdk/tree/master/packages/auth-browser) | ![auth-browser](https://img.shields.io/badge/-browser-E98869 'auth-browser') | | -| [@lit-protocol/misc-browser](https://github.com/LIT-Protocol/js-sdk/tree/master/packages/misc-browser) | ![misc-browser](https://img.shields.io/badge/-browser-E98869 'misc-browser') | | - - - -## API Doc - -| Version | Link | -| ------------ | -------------------------------------------------------- | -| V7 (Current) | [7.x.x docs](https://v7-api-doc-lit-js-sdk.vercel.app/) | -| V6 | [6.x.x docs](https://v6-api-doc-lit-js-sdk.vercel.app/) | -| V5 | [5.x.x docs](https://v3.api-docs.getlit.dev/) | -| V2 | [2.x.x docs](http://docs.lit-js-sdk-v2.litprotocol.com/) | - -
- -# Contributing and developing to this SDK - -## Prerequisite - -- node (v19.x or above) -- rust (v1.70.00 or above) -- [wasm-pack](https://github.com/rustwasm/wasm-pack) - -## Recommended - -- NX Console: https://nx.dev/core-features/integrate-with-editors +# Running E2E Tests -# Quick Start +## Required Environment Variables -The following commands will help you start developing with this repository. +```bash +# (Optional) Request a private rpc url from +# https://hub.conduit.xyz/chronicle-yellowstone-testnet-9qgmzfcohk +LIT_YELLOWSTONE_PRIVATE_RPC_URL= +# (Optional) Mainnet RPC override for naga-proto / naga +LIT_MAINNET_RPC_URL= -First, install the dependencies via yarn: +# For live networks (naga-dev, naga-staging) +LIVE_MASTER_ACCOUNT= -``` -yarn +# For local network (naga-local) (default Anvil account) +LOCAL_MASTER_ACCOUNT=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 ``` -## Building +When `NETWORK` is set to `naga-proto` or `naga`, the test helpers only top up generated accounts with `0.01` LIT and deposit `0.01` LIT into the Lit Ledger to avoid locking up excess mainnet funds. -You can build the project with the following commands: +## Command +```bash +// eg. naga-dev +NETWORK= pnpm run test:e2e all ``` -// for local development - It stripped away operations that don't matter for local dev -yarn build:dev -// you should never need to use yarn build unless you want to test or publish it -yarn build -``` +### Target a specific spec -## Run unit tests +Use `test:target` when you only need to exercise one file: +```bash +pnpm run test:target packages/e2e/src/tickets/delegation.spec.ts ``` -yarn test:unit -``` - -## Run E2E tests in nodejs -``` -yarn test:local -``` +Append additional Jest flags after the path if you need finer filtering. -# Advanced +## QA Starter Kit workflow -## Creating a new library +When you need to validate SDK integrations against backend or node features, lean on the [QA Starter Kit](https://github.com/LIT-Protocol/QA-kit). That repo installs published packages, so it mirrors how downstream teams will consume the SDK. -`nx generate @nx/js:library` +1. The node team opens a feature branch for their service. +2. Create a matching SDK branch and build the integration for that node change. +3. Publish a snapshot (prerelease) of the SDK packages so the QA Starter Kit can install them from npm. +4. Point the QA Starter Kit to that snapshot to perform the e2e flow before promoting the release. -## Create a new react demo app using the Lit JS SDK +This keeps QA aligned with the packages that will actually ship and avoids the drift that comes with local linking. -```sh -yarn tools --create --react contracts-sdk --demo -``` +# Running it against a local network -## Deleting a package or app +Generate a fresh `networkContext.json` for local nodes with `pnpm run gen:local-network-context` before running the e2e tests against the `naga-local` local network. -``` -// delete an app from ./app/ -yarn delete:app +## Required Environment Variables -// delete a package from ./packages/ -yarn delete:package -``` +```bash +# path to the networkContext.json file +NETWORK_CONFIG=//lit-assets/blockchain/contracts/networkContext.json -## Building +# name of the output file +NETWORK_NAME=naga-develop -```sh -yarn build +# target directory +DIRECTORY_NAME=naga-local ``` -### Building target package +## Command -```sh -yarn nx run :build +```bash +NETWORK=naga-local pnpm run test:e2e all ``` -## Building Local Changes +# Artillery Load Testing -During development you may wish to build your code changes in `packages/` in a client application to test the correctness of the functionality. +Use the standalone Artillery project under `packages/artillery` -If you would like to establish a dependency between packages within this monorepo and an external client application that consumes these packages: +## Preparation -1. Run `npm link` at the root of the specific package you are making code changes in. +```bash +# from the repo root +pnpm install -``` -cd ./packages/*/ -npm link +# pick your target network: naga-dev | naga-staging | naga-test | naga-local +export NETWORK=naga-staging +export LOG_LEVEL=info # optional: debug | debug2 | silent ``` -2. Build the packages with or without dependencies +For live networks that read ABI data from the `networks` repo (for example `naga-staging`), run the sync script before firing Artillery so the contracts and addresses are up to date: -``` -yarn build -# or -yarn nx run lit-node-client-nodejs:build --with-deps=false +```bash +pnpm run sync:contracts # requires GH_API_KEY in your environment ``` -3. In the client application, run `npm link --save` to ensure that the `package.json` of the client application is updated with a `file:` link to the dependency. This effectively creates a symlink in the `node_modules` of the client application to the local dependency in this repository. +Testing a custom local network? Point the runner at your generated `networkContext.json` and RPC URL. (/lit-assets/blockchain/contracts/networkContext.json) +```ts +const networkModule = nagaLocal + .withLocalContext({ + networkContextPath: + '/Users//Projects/lit-assets/blockchain/contracts/networkContext.json', + networkName: 'naga-local', + }) + .withOverrides({ rpcUrl: process.env.LOCAL_RPC_URL }); ``` -cd path/to/client-application -npm link --save -``` - -Having done this setup, this is what the development cycle looks like moving forward: - -1. Make code change -2. Rebuild specific package -3. Rebuild client application. - -### Building changes to Rust source - -If changes are made to `packages/wasm` see [here](./packages/wasm/README.md) for info on building from source. - -## Publishing - -You must have at least nodejs v18 to do this. - -1. Install the latest packages with `yarn install` - -2. Run `yarn bump` to bump the version - -3. Build all the packages with `yarn build` -4. Run the unit tests with `yarn test:unit` & e2e node tests `yarn test:local` locally & ensure that they pass +If you want Artillery Cloud reports, set `ARTILLERY_KEY=` in `.env` before running a scenario. -5. Update the docs with `yarn gen:docs --push` +## One-time initialisation -6. Finally, publish with `yarn publish:packages` +Master account, auth data and PKP info are written to this file: +`packages/artillery/artillery-state.json`. -7. Commit these changes "Published version X.X.X" - -## Testing - -### Quick Start on E2E Testing +```bash +pnpm nx run artillery:init +``` -The following will serve the react testing app and launch the cypress e2e testing after +(optional) Check master balances before blasting a load test: -```sh -yarn test:local +```bash +pnpm nx run artillery:balance-status ``` -### Unit Tests +## Run a workload + +Each scenario is exposed as an Nx target. Use the `run:` prefixed name: -```sh -yarn test:unit +```bash +pnpm nx run artillery:run:pkp-sign # PKP signing focus +pnpm nx run artillery:run:encrypt-decrypt # Encryption/decryption focus +pnpm nx run artillery:run:execute # Lit Action execution +pnpm nx run artillery:run:mix # Mixed workload +pnpm nx run artillery:run:sign-session-key # Session key signing ``` -## Testing with a Local Lit Node +# Manual Publishing -First, deploy your Lit Node Contracts, since the correct addresses will be pulled from the `../lit-assets/blockchain/contracts/deployed-lit-node-contracts-temp.json` file. +```bash +# Generate a changeset +pnpm changeset -Set these two env vars: +# Version the changeset +pnpm changeset version -```sh -export LIT_JS_SDK_LOCAL_NODE_DEV="true" -export LIT_JS_SDK_FUNDED_WALLET_PRIVATE_KEY="putAFundedPrivateKeyOnChronicleHere" -``` +# Build the packages +pnpm build -Run: +# Commit the changes +git add . +git commit -m "chore: release v0.0.1" -```sh -yarn update:contracts-sdk --fetch -yarn update:contracts-sdk --gen -yarn build:packages +# Publish the packages +pnpm changeset publish ``` -To run manual tests: +# Apps -```sh - yarn nx run nodejs:serve -``` +This monorepo contains two apps: [Lit Auth Server](./apps/lit-auth-server/README.md) and [Lit Login Server](./apps/lit-login-server/README.md).Both apps support Docker builds. -## ENV Vars +## Releasing Docker Images -- LIT_JS_SDK_GITHUB_ACCESS_TOKEN - a github access token to get the contract ABIs from a private repo -- LIT_JS_SDK_LOCAL_NODE_DEV - set to true to use a local node -- LIT_JS_SDK_FUNDED_WALLET_PRIVATE_KEY - set to a funded wallet on Chronicle Testnet +- Trigger the `Release Docker Images` GitHub Action (`.github/workflows/release-docker-images.yml`) from the Actions tab once the desired changes are on the branch you want to release from. +- When starting the workflow, select the branch ref, set `auth-server-released` to true, and optionally provide a `custom-tag` to add an extra image tag alongside the branch/commit/`latest` tags. +- The job installs the Rust toolchain and `wasm-pack`, builds both `lit-auth-server` and `lit-login-server` via their Nx `docker-build` targets, and pushes images to `ghcr.io/lit-protocol/` using the repo's `GITHUB_TOKEN` (or the `GHCR_USERNAME`/`GHCR_TOKEN` secrets if you supply them). +- Published images live under: + - `lit-auth-server`: https://github.com/LIT-Protocol/js-sdk/pkgs/container/lit-auth-server + - `lit-login-server`: https://github.com/LIT-Protocol/js-sdk/pkgs/container/lit-login-server +- Leave `auth-server-released` unchecked to perform a no-op dry run and confirm the workflow is available without publishing images. -# Error Handling +## One Click Deployable Images -This SDK uses custom error classes derived from [@openagenda/verror](https://github.com/OpenAgenda/verror) to handle errors between packages and to the SDK consumers. -Normal error handling is also supported as VError extends the native Error class, but using VError allows for better error composition and information propagation. -You can check their documentation for the extra fields that are added to the error object and methods on how to handle them in a safe way. +### Lit Auth Server -## Example +[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/OYOevk?referralCode=RP1REI&utm_medium=integration&utm_source=template&utm_campaign=generic) -```ts -import { VError } from '@openagenda/verror'; -import { LitNodeClientBadConfigError } from '@lit-protocol/constants'; - -try { - const someNativeError = new Error('some native error'); - - throw new LitNodeClientBadConfigError( - { - cause: someNativeError, - info: { - foo: 'bar', - }, - meta: { - baz: 'qux', - }, - }, - 'some useful message' - ); -} catch (e) { - console.log(e.name); // LitNodeClientBadConfigError - console.log(e.message); // some useful message: some native error - console.log(e.info); // { foo: 'bar' } - console.log(e.baz); // qux - // VError.cause(e) is someNativeError - // VError.info(e) is { foo: 'bar' } - // VError.meta(e) is { baz: 'qux', code: 'lit_node_client_bad_config_error', kind: 'Config' } - // Verror.fullStack(e) is the full stack trace composed of the error chain including the causes -} -``` +### Lit Login Server -## Creating a new error +[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/RO0wsZ?referralCode=RP1REI&utm_medium=integration&utm_source=template&utm_campaign=generic) -In file `packages/constants/src/lib/errors.ts` you can find the list of errors that are currently supported and add new ones if needed. +#### Environment configuration -To create and use a new error, you need to: +- `ORIGIN`: required for OAuth callbacks. Railway asks for a value during deploy—drop in a placeholder like `http://localhost:3000`, let the app spin up, then replace it with the generated public HTTPS domain (or your custom domain) so Google and Discord redirect URIs match. Leaving it empty keeps the local-only default and will break production flows. -1. Add the error information to the `LIT_ERROR` object in `packages/constants/src/lib/errors.ts` -2. Export the error from the `errors.ts` file at the end of the file -3. Import the error where you need it -4. Throw the error in your code adding all the information a user might need to know about the error such as the cause, the info, etc. +## Keeping the contract address and ABIs in sync with the latest changes -# Dockerfile +This command must be run manually and is NOT part of the build process, as it requires a GitHub API key. -...coming soon +```shell +DEV_BRANCH=develop GH_API_KEY=github_pat_xxx pnpm run sync:contracts +``` -## Other Commands +## Keeping the docs changelog in sync with the public site -### Interactive graph dependencies using NX +Use the `sync:docs-changelog` script to refresh the changelog that powers [naga.developer.litprotocol.com/changelog](https://naga.developer.litprotocol.com/changelog). +```shell +pnpm run sync:docs-changelog ``` -yarn graph -``` - -![](https://i.ibb.co/2dLyMTW/Screenshot-2022-11-15-at-15-18-46.png) - -# FAQs & Common Errors -
-(React) Failed to parse source map from +> Note: we currently run this manually after the Changeset PR lands in the `naga` main branch, though we expect to automate it in CI in the future. -In your React package.json, add `GENERATE_SOURCEMAP=false` to your start script +The script collates the latest entries from `packages/*/CHANGELOG.md` and rewrites the target `changelog.mdx`. Commit and publish the regenerated file in the docs repo so the public changelog stays current. -eg. +--- -``` - "scripts": { - "start": "GENERATE_SOURCEMAP=false react-scripts start", - "build": "react-scripts build", - "test": "react-scripts test", - "eject": "react-scripts eject" - }, -``` +# Legacy Documentation for V7 and Earlier -
- -
-Reference Error: crypto is not defined - -```js -import crypto, { createHash } from 'crypto'; -Object.defineProperty(globalThis, 'crypto', { - value: { - getRandomValues: (arr: any) => crypto.randomBytes(arr.length), - subtle: { - digest: (algorithm: string, data: Uint8Array) => { - return new Promise((resolve, reject) => - resolve( - createHash(algorithm.toLowerCase().replace('-', '')) - .update(data) - .digest() - ) - ); - }, - }, - }, -}); -``` +| Version | Link | +| ------- | -------------------------------------------------------- | +| V7 | [7.x.x docs](https://v7-api-doc-lit-js-sdk.vercel.app/) | +| V6 | [6.x.x docs](https://v6-api-doc-lit-js-sdk.vercel.app/) | +| V5 | [5.x.x docs](https://v3.api-docs.getlit.dev/) | +| V2 | [2.x.x docs](http://docs.lit-js-sdk-v2.litprotocol.com/) | -
-
-error Command failed with exit code 13. + -Make sure your node version is above v18.0.0 +# Contact -
+You can reach the Lit Protocol team through [Telegram](https://t.me/+aa73FAF9Vp82ZjJh), [Discord](https://litgateway.com/discord), or [X](https://x.com/litprotocol). diff --git a/apps/explorer/.eslintrc.json b/apps/explorer/.eslintrc.json new file mode 100644 index 0000000000..8852e20bce --- /dev/null +++ b/apps/explorer/.eslintrc.json @@ -0,0 +1,10 @@ +{ + "extends": ["../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + } + ] +} diff --git a/apps/explorer/.gitignore b/apps/explorer/.gitignore new file mode 100644 index 0000000000..da9532592f --- /dev/null +++ b/apps/explorer/.gitignore @@ -0,0 +1,28 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? +.vercel +.cursor +.env +.plan \ No newline at end of file diff --git a/apps/explorer/Dockerfile b/apps/explorer/Dockerfile new file mode 100644 index 0000000000..cfe83e38f6 --- /dev/null +++ b/apps/explorer/Dockerfile @@ -0,0 +1,21 @@ +# This file is generated by Nx. +# +# Build the docker image with `pnpm nx run explorer:docker-build`. +# Tip: Modify "docker-build" options in project.json to change docker build args. +# +# Run the container with `docker run -p 4173:80 -t explorer`. +FROM --platform=linux/amd64 docker.io/nginx:alpine + +ENV NODE_ENV=production + +# Remove default nginx site content +RUN rm -rf /usr/share/nginx/html/* + +# Copy SPA routing config +COPY apps/explorer/nginx.conf /etc/nginx/conf.d/default.conf + +# Copy the static assets produced by `nx run explorer:build` +COPY apps/explorer/dist /usr/share/nginx/html + +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] diff --git a/apps/explorer/README.md b/apps/explorer/README.md new file mode 100644 index 0000000000..032bfc89f0 --- /dev/null +++ b/apps/explorer/README.md @@ -0,0 +1,72 @@ +# Lit Explorer Naga + +Lit Explorer Naga is an application that allows you to authenticate with Lit Protocol using the native Lit auth methods. + +# Env vars + +```bash +# Global Settings +VITE_LOGIN_SERVICE_URL=https://login.litgateway.com +VITE_LOGIN_DISCORD_CLIENT_ID=1052874239658692668 + +# Network-Specific Auth Service URLs +VITE_AUTH_SERVICE_URL_NAGA_DEV=https://naga-dev-auth-service.getlit.dev +VITE_AUTH_SERVICE_URL_NAGA_TEST=https://naga-test-auth-service.getlit.dev +VITE_AUTH_SERVICE_URL_NAGA=https://naga-auth-service.getlit.dev +``` + +## Getting started + +``` +pnpm install +pnpm nx run explorer:dev +``` + +## Docker + +To produce a deployable container: + +``` +pnpm nx run explorer:build +pnpm nx run explorer:docker-build +docker run -p 4173:80 explorer +``` + +The image serves the built assets via Nginx and includes SPA routing so client-side navigation works. + +## Adding Lit Action examples + +- Add a new file in `src/lit-action-examples/entries/` that default-exports a `LitActionExample`. The `id` must be unique. +- Use `String.raw` to define multiline snippets, e.g. ``const code = String.raw\`...\`;`` and fill in `title`, optional `description`, `order`, and any default `jsParams`. +- The registry auto-loads every file in that directory, so your example will appear in the Lit Action editor once you save and refresh the app. +- Prefer small, focused samples that demonstrate a single concept; link to docs inside the description if extra context is needed. + +## FAQs + +### What "logged-in" means here + +- You are considered "logged-in" when both a PKP and an auth context exist in state. + +### How you become "logged-in" + +After authenticating with a method (Google, Discord, WebAuthn, EOA, Stytch, Custom), either: + +- You select an existing PKP in the PKP selection section +- You mint a new PKP and immediately create `authContext`, then set `user` + +### What redirect happens and when + +The `` does not redirect on successful login. It simply closes the modal once user is set and isAuthenticated becomes true. + +The dashboard is always the index route for `/`, and it conditionally renders based on auth state from context. When the user logs in, React re-renders the same component with different UI. + +Inside `LoggedInDashboard`, it reads user from `useLitAuth()`. If there’s no user, it shows a sign-in experience and, in popup mode, auto-opens the modal. + +# Screenshots + +## Login Modal + +![Login Modal](./public/screenshot-1.png) + +## Logged in Dashboard +![Logged in Dashboard](./public/screenshot-2.png) diff --git a/apps/explorer/check-deps.js b/apps/explorer/check-deps.js new file mode 100644 index 0000000000..5abdfed29e --- /dev/null +++ b/apps/explorer/check-deps.js @@ -0,0 +1,169 @@ +#!/usr/bin/env node + +/** + * Dependency Checker Script + * + * This script scans node_modules for missing dependencies that could cause + * "Failed to resolve module specifier" errors in production builds. + * + * Usage: node check-deps.js + */ + +import fs from 'fs'; +import path from 'path'; +import { execSync } from 'child_process'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +console.log('🔍 Scanning for missing dependencies...\n'); + +// Read current package.json +const packageJson = JSON.parse(fs.readFileSync('package.json', 'utf8')); +const currentDeps = { + ...packageJson.dependencies || {}, + ...packageJson.devDependencies || {} +}; + +// Function to extract require/import statements +function extractDependencies(filePath) { + try { + const content = fs.readFileSync(filePath, 'utf8'); + const requireMatches = content.match(/require\(["']([^"']+)["']\)/g) || []; + const importMatches = content.match(/import\s+.*?\s+from\s+["']([^"']+)["']/g) || []; + + const deps = new Set(); + + // Extract require dependencies + requireMatches.forEach(match => { + const dep = match.match(/require\(["']([^"']+)["']\)/)[1]; + if (!dep.startsWith('.') && !dep.startsWith('/')) { + // Get package name (handle scoped packages) + const packageName = dep.startsWith('@') + ? dep.split('/').slice(0, 2).join('/') + : dep.split('/')[0]; + deps.add(packageName); + } + }); + + // Extract import dependencies + importMatches.forEach(match => { + const dep = match.match(/from\s+["']([^"']+)["']/)[1]; + if (!dep.startsWith('.') && !dep.startsWith('/')) { + const packageName = dep.startsWith('@') + ? dep.split('/').slice(0, 2).join('/') + : dep.split('/')[0]; + deps.add(packageName); + } + }); + + return Array.from(deps); + } catch (error) { + return []; + } +} + +// Function to scan directory recursively +function scanDirectory(dir, extensions = ['.js', '.ts', '.tsx', '.jsx']) { + const allDeps = new Set(); + + function scanRecursive(currentDir) { + try { + const items = fs.readdirSync(currentDir); + + for (const item of items) { + const fullPath = path.join(currentDir, item); + const stat = fs.statSync(fullPath); + + if (stat.isDirectory() && !item.startsWith('.') && item !== 'node_modules') { + scanRecursive(fullPath); + } else if (stat.isFile() && extensions.some(ext => item.endsWith(ext))) { + const deps = extractDependencies(fullPath); + deps.forEach(dep => allDeps.add(dep)); + } + } + } catch (error) { + // Skip directories we can't read + } + } + + scanRecursive(dir); + return Array.from(allDeps); +} + +// Scan specific directories for dependencies +const directories = [ + 'node_modules/@lit-protocol', + 'src' +]; + +const allFoundDeps = new Set(); + +directories.forEach(dir => { + if (fs.existsSync(dir)) { + console.log(`📁 Scanning ${dir}...`); + const deps = scanDirectory(dir); + deps.forEach(dep => allFoundDeps.add(dep)); + } +}); + +// Filter out built-in Node.js modules and current dependencies +const builtInModules = new Set([ + 'fs', 'path', 'crypto', 'stream', 'buffer', 'util', 'events', 'os', 'url', + 'http', 'https', 'querystring', 'zlib', 'worker_threads', 'fs/promises' +]); + +const missingDeps = Array.from(allFoundDeps).filter(dep => + !currentDeps[dep] && + !builtInModules.has(dep) && + dep !== 'react' && // Common false positives + dep !== 'node_modules' +); + +console.log('\n📊 Results:'); +console.log(`Total dependencies found: ${allFoundDeps.size}`); +console.log(`Missing dependencies: ${missingDeps.length}`); + +if (missingDeps.length > 0) { + console.log('\n❌ Missing Dependencies:'); + console.log('Add these to your package.json:\n'); + + const suggestions = {}; + + // Common version suggestions + const versionMap = { + 'siwe': '^2.3.2', + 'siwe-recap': '^0.0.2-alpha.0', + 'jose': '^4.14.4', + 'ethers': '5.7.2', + 'viem': '^2.29.4', + '@noble/curves': '^1.2.0', + '@noble/hashes': '^1.3.0', + 'base64url': '^3.0.1', + 'cbor-web': '^9.0.2', + 'elysia': '^1.2.25', + 'tslib': '^2.3.0', + 'zod-validation-error': '^3.4.0', + '@openagenda/verror': '^3.1.4', + '@simplewebauthn/browser': '^7.2.0' + }; + + missingDeps.forEach(dep => { + const version = versionMap[dep] || '^latest'; + suggestions[dep] = version; + console.log(` "${dep}": "${version}",`); + }); + + console.log('\n💡 Or run this command to install them all:'); + const installCmd = `pnpm add ${missingDeps.map(dep => `${dep}@${versionMap[dep] || 'latest'}`).join(' ')}`; + console.log(`\n${installCmd}\n`); + +} else { + console.log('\n✅ All dependencies appear to be present!'); +} + +console.log('\n🔧 Current package.json dependencies:'); +Object.keys(currentDeps).sort().forEach(dep => { + console.log(` ${dep}: ${currentDeps[dep]}`); +}); diff --git a/apps/explorer/index.html b/apps/explorer/index.html new file mode 100644 index 0000000000..8df6991c59 --- /dev/null +++ b/apps/explorer/index.html @@ -0,0 +1,78 @@ + + + + + + + Lit Protocol Explorer:: Naga + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + diff --git a/apps/explorer/nginx.conf b/apps/explorer/nginx.conf new file mode 100644 index 0000000000..88232ea683 --- /dev/null +++ b/apps/explorer/nginx.conf @@ -0,0 +1,20 @@ +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + # Cache busted assets can be cached aggressively + location /assets/ { + try_files $uri =404; + access_log off; + expires 1y; + add_header Cache-Control "public, max-age=31536000, immutable"; + } + + # SPA fallback to index.html for client-side routing + location / { + try_files $uri /index.html; + } +} diff --git a/apps/explorer/package.json b/apps/explorer/package.json new file mode 100644 index 0000000000..3611e7a4d4 --- /dev/null +++ b/apps/explorer/package.json @@ -0,0 +1,59 @@ +{ + "name": "@lit-protocol/explorer", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "lint": "eslint \"src/**/*.{ts,tsx}\" \"*.{ts,tsx,js}\" --report-unused-disable-directives --max-warnings 0", + "start": "vite preview --host 0.0.0.0 --port ${PORT:-4173}", + "preview": "vite preview", + "clean": "rimraf dist node_modules bun.lock package-lock.json pnpm-lock.yaml", + "outdated": "pnpm outdated --filter '@lit-protocol/*'", + "update:lit": "pnpm update --latest --filter '@lit-protocol/*'" + }, + "dependencies": { + "@lit-protocol/access-control-conditions": "workspace:*", + "@lit-protocol/auth": "workspace:*", + "@lit-protocol/lit-client": "workspace:*", + "@lit-protocol/naga-la-types": "0.1.0", + "@lit-protocol/networks": "workspace:*", + "@monaco-editor/react": "^4.7.0", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-hover-card": "^1.1.15", + "@radix-ui/react-navigation-menu": "^1.2.14", + "@rainbow-me/rainbowkit": "^2.2.4", + "@tailwindcss/vite": "^4.1.12", + "@tanstack/react-query": "^5.69.0", + "@wagmi/core": "^2.17.1", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "buffer": "^6.0.3", + "lucide-react": "^0.542.0", + "prism-react-renderer": "^2.4.1", + "prismjs": "^1.30.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^7.6.0", + "tailwind-merge": "^3.3.1", + "tailwindcss": "^4.1.12", + "viem": "2.38.x", + "wagmi": "^2.14.11", + "zod": "^3.24.2" + }, + "devDependencies": { + "@types/react": "^18.2.66", + "@types/react-dom": "^18.2.22", + "@typescript-eslint/eslint-plugin": "^7.2.0", + "@typescript-eslint/parser": "^7.2.0", + "@vitejs/plugin-react-swc": "^3.5.0", + "eslint": "^8.57.0", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-react-refresh": "^0.4.6", + "rimraf": "^6.0.1", + "tw-animate-css": "^1.3.8", + "typescript": "^5.2.2", + "vite": "^5.2.0" + } +} diff --git a/apps/explorer/plan.md b/apps/explorer/plan.md new file mode 100644 index 0000000000..ae55bffd48 --- /dev/null +++ b/apps/explorer/plan.md @@ -0,0 +1,74 @@ +## Goal + +Create a single source of truth for explorer network metadata (slugs, modules, tokens, auth URLs, default chains) so future networks can be onboarded by editing one object instead of touching multiple components. + +## Files to update / add + +1. `apps/explorer/src/domain/lit/networks.ts` (new) +2. `apps/explorer/src/domain/lit/networkDefaults.ts` +3. `apps/explorer/src/domain/lit/litChainConfig.ts` +4. `apps/explorer/src/hooks/useLitServiceSetup.ts` +5. `apps/explorer/src/lit-login-modal/LitAuthProvider.tsx` +6. `apps/explorer/src/_config.ts` +7. `apps/explorer/src/lit-login-modal/components/AuthSettingsPanel.tsx` +8. `apps/explorer/src/lit-logged-page/LoggedInDashboard.tsx` +9. `apps/explorer/src/lit-login-modal/PKPSelectionSection.tsx` +10. `apps/explorer/src/lit-logged-page/protectedApp/components/pkp/PKPInfoCard.tsx` +11. `apps/explorer/src/lit-logged-page/protectedApp/components/PaymentManagement/PaymentManagementDashboard.tsx` +12. `apps/explorer/src/main.tsx` + +## Planned changes + +### 1. `domain/lit/networks.ts` (new) +- **Before**: No registry; constants are duplicated across multiple files. +- **After**: Export a `NETWORKS` object keyed by `SupportedNetworkName`. Each entry contains `label`, `isTestnet`, `chainSlug`, `ledgerSymbol`, `authEnvVar`, and `moduleKey`. Also export derived helpers (`SUPPORTED_NETWORKS`, `getNetworkMeta`, `getAuthUrlForNetwork(env)`, etc.). + +### 2. `domain/lit/networkDefaults.ts` +- **Before**: Manually defines `DEFAULT_CHAIN_BY_NETWORK` and `TESTNET_NETWORKS`. +- **After**: Re-export `SupportedNetworkName`, `SUPPORTED_NETWORKS`, `getDefaultChainForNetwork`, and `isTestnetNetwork` from the new registry so there is no bespoke map here. + +### 3. `domain/lit/litChainConfig.ts` +- **Before**: Hard-codes Lit chain RPC/Explorer URLs and viem config. +- **After**: Export a `getLitChainConfig(env)` helper that reads env overrides once and feeds both the Ledger registry and wagmi config. Remove duplicate Chronicle definitions from other files by exporting `chronicleChainConfig`. + +### 4. `hooks/useLitServiceSetup.ts` +- **Before**: Imports `@lit-protocol/networks` directly, builds `NETWORK_MODULES` locally, relies on literal strings. +- **After**: Import `LIT_NETWORK_MODULES` from the registry, so the hook simply looks up the module by `config.networkName`. Removes duplicate destructuring and string arrays. + +### 5. `lit-login-modal/LitAuthProvider.tsx` +- **Before**: Contains its own `NETWORK_MODULES` and re-creates the `supportedNetworks` list, auth-service default map, and testnet logic. +- **After**: Consume the registry helpers. `supportedNetworks` defaults to `SUPPORTED_NETWORKS`. `forceNetworkSelection` uses `LIT_NETWORK_MODULES`. `authServiceUrlMap` seeds from `AppEnv.authUrls`. `shouldDisplayNetworkMessage` calls `isTestnetNetwork`. + +### 6. `_config.ts` +- **Before**: Reads env vars inline and exports `APP_INFO`. +- **After**: Import `AppEnv` (new helper) so URLs are sourced from one place. Optionally expose `networkAuthServiceUrls` directly from the registry to avoid per-file duplication. + +### 7. `lit-login-modal/components/AuthSettingsPanel.tsx` +- **Before**: Builds the network tab list from props and hard-coded labels. +- **After**: Map over `SUPPORTED_NETWORKS` and read labels/testnet badges from the registry. This ensures new networks automatically appear with correct metadata. + +### 8. `lit-logged-page/LoggedInDashboard.tsx` +- **Before**: Tracks `selectedChain` default via inline `useEffect` + `getDefaultChainForNetwork`. +- **After**: Initialize state from `NETWORKS[currentNetworkName].chainSlug` and rely on the registry for network badges (e.g., the header pill). + +### 9. `lit-login-modal/PKPSelectionSection.tsx` +- **Before**: Determines ledger symbol/testnet state via manual checks. +- **After**: Use the registry meta to decide which RPC to hit and which ledger symbol to display, avoiding local ternaries. + +### 10. `lit-logged-page/protectedApp/components/pkp/PKPInfoCard.tsx` +- **Before**: Defines `ledgerUnit` using hard-coded comparisons. +- **After**: Import `NETWORKS` meta and read `ledgerSymbol`, so the UI automatically reflects any future testnet/mainnet differences. + +### 11. `lit-logged-page/protectedApp/components/PaymentManagement/PaymentManagementDashboard.tsx` +- **Before**: Similar duplicate logic for ledger symbol and chain labels. +- **After**: Use registry meta for chain labels/ledger unit, keeping UI consistent. + +### 12. `main.tsx` +- **Before**: Repeats Chronicle/Lit RPC constants and logs env vars manually. +- **After**: Consume `chronicleChainConfig` / `getLitChainConfig` plus `AppEnv` for logging. Wagmi config becomes data-driven. + +## Testing + +1. `pnpm nx run explorer:lint` +2. `pnpm nx run explorer:build` +3. Smoke-test the explorer dev server (`pnpm nx serve explorer`) to ensure the network selector and auth modal show the new options and ledger balances display correct tokens. diff --git a/apps/explorer/project.json b/apps/explorer/project.json new file mode 100644 index 0000000000..9c61a9cec2 --- /dev/null +++ b/apps/explorer/project.json @@ -0,0 +1,49 @@ +{ + "name": "explorer", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "apps/explorer/src", + "projectType": "application", + "targets": { + "dev": { + "executor": "nx:run-commands", + "cache": false, + "options": { + "command": "pnpm --dir apps/explorer dev", + "forwardAllArgs": true + } + }, + "build": { + "executor": "nx:run-commands", + "dependsOn": [], + "outputs": [ + "{projectRoot}/dist" + ], + "options": { + "command": "pnpm --dir apps/explorer build" + } + }, + "lint": { + "executor": "nx:run-commands", + "options": { + "command": "pnpm --dir apps/explorer lint" + } + }, + "start": { + "executor": "nx:run-commands", + "dependsOn": [ + "build" + ], + "options": { + "command": "pnpm --dir apps/explorer start", + "forwardAllArgs": true + } + }, + "docker-build": { + "dependsOn": [ + "build" + ], + "command": "docker build -f apps/explorer/Dockerfile . -t explorer" + } + }, + "tags": [] +} diff --git a/apps/explorer/public/logo.svg b/apps/explorer/public/logo.svg new file mode 100644 index 0000000000..6acb0efa8c --- /dev/null +++ b/apps/explorer/public/logo.svg @@ -0,0 +1,256 @@ + + + + diff --git a/apps/explorer/public/screenshot-1.png b/apps/explorer/public/screenshot-1.png new file mode 100644 index 0000000000..662f6e83ec Binary files /dev/null and b/apps/explorer/public/screenshot-1.png differ diff --git a/apps/explorer/public/screenshot-2.png b/apps/explorer/public/screenshot-2.png new file mode 100644 index 0000000000..eb2555cdf9 Binary files /dev/null and b/apps/explorer/public/screenshot-2.png differ diff --git a/apps/explorer/public/thumbnail.png b/apps/explorer/public/thumbnail.png new file mode 100644 index 0000000000..6bdbaa6e39 Binary files /dev/null and b/apps/explorer/public/thumbnail.png differ diff --git a/apps/explorer/src/Header.tsx b/apps/explorer/src/Header.tsx new file mode 100644 index 0000000000..87b0bfdc65 --- /dev/null +++ b/apps/explorer/src/Header.tsx @@ -0,0 +1,33 @@ +import { Link } from "react-router-dom"; +import litPrimaryOrangeIcon from "./assets/lit-primary-orange.svg"; +import { useOptionalLitAuth } from "./lit-login-modal/LitAuthProvider"; +import { AppHeader } from "@layout"; + +export const Header = () => { + const litAuth = useOptionalLitAuth(); + return ( + + Lit logo + + } + centerSlot={
} + rightSlot={ + litAuth?.isAuthenticated ? ( + + ) : null + } + /> + ); +}; diff --git a/apps/explorer/src/_config.ts b/apps/explorer/src/_config.ts new file mode 100644 index 0000000000..39e937683d --- /dev/null +++ b/apps/explorer/src/_config.ts @@ -0,0 +1,86 @@ +/** + * Application Configuration + * + * This file centralizes all configurable settings for the application. + * Modify these values to customize the application behavior. + */ + +// WalletConnect Configuration +export const WALLET_CONNECT = { + projectId: "YOUR_WALLETCONNECT_PROJECT_ID", // Replace with your actual WalletConnect Project ID +}; + +// Application Information +export const APP_INFO = { + copyright: "Lit Protocol", + + // Global service URLs (with defaults) + litLoginServer: + import.meta.env.VITE_LOGIN_SERVICE_URL || "https://login.litgateway.com", + litAuthServer: + import.meta.env.VITE_AUTH_SERVICE_URL || "https://auth-api.litprotocol.com", + + // Network-specific auth service URLs + authServiceUrls: { + "naga-dev": + import.meta.env.VITE_AUTH_SERVICE_URL_NAGA_DEV || + "https://auth-api.litprotocol.com", + "naga-test": + import.meta.env.VITE_AUTH_SERVICE_URL_NAGA_TEST || + "https://auth-api.litprotocol.com", + "naga-proto": + import.meta.env.VITE_AUTH_SERVICE_URL_NAGA_PROTO || + "https://auth-api.litprotocol.com", + naga: + import.meta.env.VITE_AUTH_SERVICE_URL_NAGA || + "https://auth-api.litprotocol.com", + }, + + litAuthServerApiKey: import.meta.env.VITE_AUTH_SERVICE_API_KEY, + + // Discord configuration + discordClientId: + import.meta.env.VITE_LOGIN_DISCORD_CLIENT_ID || "1052874239658692668", + + // Other URLs + faucetUrl: "https://chronicle-yellowstone-faucet.getlit.dev/naga", + defaultPrivateKey: + "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + nagaLitActionsDocs: "https://naga.actions-docs.litprotocol.com", +} as const; + +// Blockchain Network Configuration +export const NETWORKS = { + chronicleYellowstone: { + id: 175188, + name: "Chronicle Yellowstone", + network: "chronicle-yellowstone", + iconUrl: "/logo.svg", // Add icon path here + iconBackground: "#27233B", + }, + enabled: [ + "Chronicle Yellowstone", // This must match the name above + // "mainnet", + // "sepolia", + // "base", + "arbitrum", + ], +}; + +// Wallet Configuration +export const WALLETS = { + recommended: ["rainbow", "metamask", "coinbase"], + others: ["injected", "walletConnect"], +}; + +// Layout Configuration +export const LAYOUT = { + maxWidth: "48rem", // max-w-3xl + contentPadding: "24px", +}; + +// Application Features Toggle +export const FEATURES = { + showWalletBalance: true, + enableFlameAnimation: true, +}; diff --git a/apps/explorer/src/assets/2fa.svg b/apps/explorer/src/assets/2fa.svg new file mode 100644 index 0000000000..31c1c74062 --- /dev/null +++ b/apps/explorer/src/assets/2fa.svg @@ -0,0 +1,13 @@ + + + + + + + + + \ No newline at end of file diff --git a/apps/explorer/src/assets/copy.svg b/apps/explorer/src/assets/copy.svg new file mode 100644 index 0000000000..aa0a515ecb --- /dev/null +++ b/apps/explorer/src/assets/copy.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/explorer/src/assets/discord.png b/apps/explorer/src/assets/discord.png new file mode 100644 index 0000000000..db85fdfe94 Binary files /dev/null and b/apps/explorer/src/assets/discord.png differ diff --git a/apps/explorer/src/assets/email.svg b/apps/explorer/src/assets/email.svg new file mode 100644 index 0000000000..8dc3c7f416 --- /dev/null +++ b/apps/explorer/src/assets/email.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/explorer/src/assets/global.d.ts b/apps/explorer/src/assets/global.d.ts new file mode 100644 index 0000000000..80948ccef9 --- /dev/null +++ b/apps/explorer/src/assets/global.d.ts @@ -0,0 +1,457 @@ +declare namespace Lit { + export namespace Actions { + /** + * Check if a given IPFS ID is permitted to sign using a given PKP tokenId + * @function isPermittedAction + * @param {Object} params + * @param {string} params.tokenId The tokenId to check + * @param {string} params.ipfsId The IPFS ID of some JS code (a lit action) + * @returns {Promise} A boolean indicating whether the IPFS ID is permitted to sign using the PKP tokenId + */ + function isPermittedAction({ + tokenId, + ipfsId, + }: { + tokenId: string; + ipfsId: string; + }): Promise; + + /** + * Check if a given wallet address is permitted to sign using a given PKP tokenId + * @function isPermittedAddress + * @param {Object} params + * @param {string} params.tokenId The tokenId to check + * @param {string} params.address The wallet address to check + * @returns {Promise} A boolean indicating whether the wallet address is permitted to sign using the PKP tokenId + */ + function isPermittedAddress({ + tokenId, + address, + }: { + tokenId: string; + address: string; + }): Promise; + + /** + * Check if a given auth method is permitted to sign using a given PKP tokenId + * @function isPermittedAuthMethod + * @param {Object} params + * @param {string} params.tokenId The tokenId to check + * @param {number} params.authMethodType The auth method type. This is an integer. This mapping shows the initial set but this set may be expanded over time without updating this contract: https://github.com/LIT-Protocol/LitNodeContracts/blob/main/contracts/PKPPermissions.sol#L25 + * @param {Uint8Array} params.userId The id of the auth method to check expressed as an array of unsigned 8-bit integers (a Uint8Array) + * @returns {Promise} A boolean indicating whether the auth method is permitted to sign using the PKP tokenId + */ + function isPermittedAuthMethod({ + tokenId, + authMethodType, + userId, + }: { + tokenId: string; + authMethodType: number; + userId: Uint8Array; + }): Promise; + + /** + * Get the full list of actions that are permitted to sign using a given PKP tokenId + * @function getPermittedActions + * @param {Object} params + * @param {string} params.tokenId The tokenId to check + * @returns {Promise>} An array of IPFS IDs of lit actions that are permitted to sign using the PKP tokenId + */ + function getPermittedActions({ + tokenId, + }: { + tokenId: string; + }): Promise>; + + /** + * Get the full list of addresses that are permitted to sign using a given PKP tokenId + * @function getPermittedAddresses + * @param {Object} params + * @param {string} params.tokenId The tokenId to check + * @returns {Promise>} An array of addresses that are permitted to sign using the PKP tokenId + */ + function getPermittedAddresses({ + tokenId, + }: { + tokenId: string; + }): Promise>; + + /** + * Get the full list of auth methods that are permitted to sign using a given PKP tokenId + * @function getPermittedAuthMethods + * @param {Object} params + * @param {string} params.tokenId The tokenId to check + * @returns {Promise>} An array of auth methods that are permitted to sign using the PKP tokenId. Each auth method is an object with the following properties: auth_method_type, id, and user_pubkey (used for web authn, this is the pubkey of the user's authentication keypair) + */ + function getPermittedAuthMethods({ tokenId }: { tokenId: string }): Promise< + Array<{ + auth_method_type: number; + id: string; + user_pubkey: string; + }> + >; + + /** + * Get the permitted auth method scopes for a given PKP tokenId and auth method type + id + * @function getPermittedAuthMethodScopes + * @param {Object} params + * @param {string} params.tokenId The tokenId to check + * @param {number} params.authMethodType The auth method type to look up + * @param {Uint8Array} params.userId The id of the auth method to check expressed as an array of unsigned 8-bit integers (a Uint8Array) + * @param {number} params.maxScopeId The maximum scope id to check. This is an integer. + * @returns {Promise>} An array of booleans that define if a given scope id is turned on. The index of the array is the scope id. For example, if the array is [true, false, true], then scope ids 0 and 2 are turned on, but scope id 1 is turned off. + */ + function getPermittedAuthMethodScopes({ + tokenId, + authMethodType, + userId, + maxScopeId, + }: { + tokenId: string; + authMethodType: number; + userId: Uint8Array; + maxScopeId: number; + }): Promise>; + + /** + * Converts a PKP public key to a PKP token ID by hashing it with keccak256 + * @function pubkeyToTokenId + * @param {Object} params + * @param {string} params.publicKey The public key to convert + * @returns {Promise} The token ID as a string + */ + function pubkeyToTokenId({ + publicKey, + }: { + publicKey: string; + }): Promise; + + /** + * Gets latest nonce for the given address on a supported chain + * @function getLatestNonce + * @param {Object} params + * @param {string} params.address The wallet address for getting the nonce + * @param {string} params.chain The chain of which the nonce is fetched + * @returns {Promise} The token ID as a string + */ + function getLatestNonce({ + address, + chain, + }: { + address: string; + chain: string; + }): Promise; + + /** + * Ask the Lit Node to sign any data using the ECDSA Algorithm with it's private key share. The resulting signature share will be returned to the Lit JS SDK which will automatically combine the shares and give you the full signature to use. + * @function signEcdsa + * @param {Object} params + * @param {Uint8Array} params.toSign The data to sign. Should be an array of 8-bit integers. + * @param {string} params.publicKey The public key of the PKP you wish to sign with + * @param {string} params.sigName You can put any string here. This is used to identify the signature in the response by the Lit JS SDK. This is useful if you are signing multiple messages at once. When you get the final signature out, it will be in an object with this signature name as the key. + * @returns {Promise} This function will return the string "success" if it works. The signature share is returned behind the scenes to the Lit JS SDK which will automatically combine the shares and give you the full signature to use. + */ + function signEcdsa({ + toSign, + publicKey, + sigName, + }: { + toSign: Uint8Array | number[]; + publicKey: string; + sigName: string; + }): Promise; + + /** + * Ask the Lit Node to sign a message using the eth_personalSign algorithm. The resulting signature share will be returned to the Lit JS SDK which will automatically combine the shares and give you the full signature to use. + * @function ethPersonalSignMessageEcdsa + * @param {Object} params + * @param {string} params.message The message to sign. Should be a string. + * @param {string} params.publicKey The public key of the PKP you wish to sign with + * @param {string} params.sigName You can put any string here. This is used to identify the signature in the response by the Lit JS SDK. This is useful if you are signing multiple messages at once. When you get the final signature out, it will be in an object with this signature name as the key. + * @returns {Promise} This function will return the string "success" if it works. The signature share is returned behind the scenes to the Lit JS SDK which will automatically combine the shares and give you the full signature to use. + */ + function ethPersonalSignMessageEcdsa({ + message, + publicKey, + sigName, + }: { + message: string; + publicKey: string; + sigName: string; + }): Promise; + + /** + * Checks a condition using the Lit condition checking engine. This is the same engine that powers our Access Control product. You can use this to check any condition that you can express in our condition language. This is a powerful tool that allows you to build complex conditions that can be checked in a decentralized way. Visit https://developer.litprotocol.com and click on the "Access Control" section to learn more. + * @function checkConditions + * @param {Object} params + * @param {Array} params.conditions An array of access control condition objects + * @param {Object} params.authSig The AuthSig to use for the condition check. For example, if you were checking for NFT ownership, this AuthSig would be the signature from the NFT owner's wallet. + * @param {string} params.chain The chain this AuthSig comes from + * @returns {Promise} A boolean indicating whether the condition check passed or failed + */ + function checkConditions({ + conditions, + authSig, + chain, + }: { + conditions: Array; + authSig: any; + chain: string; + }): Promise; + + /** + * Set the response returned to the client + * @function setResponse + * @param {Object} params + * @param {string} params.response The response to send to the client. You can put any string here, like you could use JSON.stringify on a JS object and send it here. + */ + function setResponse({ response }: { response: string }): void; + + /** + * Call a child Lit Action + * @function call + * @param {Object} params + * @param {string} params.ipfsId The IPFS ID of the Lit Action to call + * @param {Object=} params.params Optional parameters to pass to the child Lit Action + * @returns {Promise} The response from the child Lit Action. Note that any signatures performed by the child Lit Action will be automatically combined and returned with the parent Lit Action to the Lit JS SDK client. + */ + function call({ + ipfsId, + params, + }: { + ipfsId: string; + params?: any; + }): Promise; + + /** + * Call a smart contract + * @function callContract + * @param {Object} params + * @param {string} params.chain The name of the chain to use. Check out the lit docs "Supported Blockchains" page to find the name. For example, "ethereum" + * @param {string} params.txn The RLP Encoded txn, as a hex string + * @returns {Promise} The response from calling the contract + */ + function callContract({ + chain, + txn, + }: { + chain: string; + txn: string; + }): Promise; + + /** + * Convert a Uint8Array to a string. This is a re-export of this function: https://www.npmjs.com/package/Uint8Arrays#tostringarray-encoding--utf8 + * @function Uint8ArrayToString + * @param {Uint8Array} array The Uint8Array to convert + * @param {string} encoding The encoding to use. Defaults to "utf8" + * @returns {string} The string representation of the Uint8Array + */ + function Uint8ArrayToString(array: Uint8Array, encoding?: string): string; + + /** + * Convert a string to a Uint8Array. This is a re-export of this function: https://www.npmjs.com/package/Uint8Arrays#fromstringstring-encoding--utf8 + * @function Uint8ArrayFromString + * @param {string} string The string to convert + * @param {string} encoding The encoding to use. Defaults to "utf8" + * @returns {Uint8Array} The Uint8Array representation of the string + */ + function Uint8ArrayFromString( + string: string, + encoding?: string + ): Uint8Array; + + function aesDecrypt({ + symmetricKey, + ciphertext, + }: { + symmetricKey: any; + ciphertext: any; + }): any; + + /** + * Claim a key through a key identifier, the result of the claim will be added to `claim_id` + * under the `keyId` given. + * @param {Object} params + * @param {string} params.keyId user id of the claim + */ + function claimKey({ keyId }: { keyId: string }): any; + + /** + * Broadcast a message to all connected clients and collect their responses + * @param {Object} params + * @param {string} params.name The name of the broadcast + * @param {string} params.value The value to broadcast + * @returns {Promise} The collected responses as a json array + */ + function broadcastAndCollect({ + name, + value, + }: { + name: string; + value: string; + }): Promise; + + /** + * Decrypt and combine the provided + * @param {Object} params + * @param {string} params.accessControlConditions The access control conditions + * @param {string} params.ciphertext The ciphertext to decrypt + * @param {string} params.dataToEncryptHash The hash of the data to encrypt + * @param {string} params.authSig The auth signature + * @param {string} params.chain The chain + * @returns {Promise} The combined data + */ + function decryptAndCombine({ + accessControlConditions, + ciphertext, + dataToEncryptHash, + authSig, + chain, + }: { + accessControlConditions: string; + ciphertext: string; + dataToEncryptHash: string; + authSig: string; + chain: string; + }): Promise; + + /** + * Decrypt to a single node. + * @param {Object} params + * @param {string} params.accessControlConditions The access control conditions + * @param {string} params.ciphertext The ciphertext to decrypt + * @param {string} params.dataToEncryptHash The hash of the data to encrypt + * @param {string} params.authSig The auth signature + * @param {string} params.chain The chain + * @returns {Promise} The combined data + */ + function decryptToSingleNode({ + accessControlConditions, + ciphertext, + dataToEncryptHash, + authSig, + chain, + }: { + accessControlConditions: string; + ciphertext: string; + dataToEncryptHash: string; + authSig: string; + chain: string; + }): Promise; + + /** + * Sign and combine ECDSA signatures + * @param {Object} params + * @param {Uint8Array} params.toSign the message to sign + * @param {string} params.publicKey the public key of the PKP + * @param {string} params.sigName the name of the signature + * @returns {Promise} The resulting signature + */ + function signAndCombineEcdsa({ + toSign, + publicKey, + sigName, + }: { + toSign: Uint8Array; + publicKey: string; + sigName: string; + }): Promise; + + /** + * Run a function once + * @param {Object} params + * @param {boolean} params.waitForResponse Whether to wait for a response or not - if false, the function will return immediately. + * @param {string} params.name A unique name for this run + * @param {Function} async_fn The async function to run + * @returns {Promise} Whether the node can run the code in the next block or not. + */ + function runOnce( + { + waitForResponse, + name, + }: { + waitForResponse: boolean; + name: string; + }, + async_fn: () => Promise + ): Promise; + + /** + * Get the RPC URL for a chain + * @param {Object} params + * @param {string} params.chain The chain to get the RPC URL for + * @returns {Promise} The RPC URL for the chain + */ + function getRpcUrl({ chain }: { chain: string }): Promise; + + /** + * Encrypt data + * @param {Object} params + * @param {string} params.accessControlConditions The access control conditions + * @param {string} params.to_encrypt The message to encrypt + * @returns {Promise<{ciphertext: string; dataToEncryptHash: string}>} Contains two items: The ciphertext result after encryption, named "ciphertext" and the dataToEncryptHash, named "dataToEncryptHash" + */ + function encrypt({ + accessControlConditions, + to_encrypt, + }: { + accessControlConditions: string; + to_encrypt: string; + }): Promise<{ + ciphertext: string; + dataToEncryptHash: string; + }>; + } + + export namespace Auth { + /** + * Array of action IPFS IDs. + * @type {Array<`Qm${string}` | string>} + */ + const actionIpfsIds: Array<`Qm${string}` | string>; + + /** + * Array of authentication method contexts. + * @type {Array<{ + * userId: string; + * appId: string; + * authMethodType: number; + * lastRetrievedAt: string; + * expiration: number; + * usedForSignSessionKeyRequest: boolean; + * }>} + */ + const authMethodContexts: { + userId: string; + appId: string; + authMethodType: number; + lastRetrievedAt: string; + expiration: number; + usedForSignSessionKeyRequest: boolean; + }[]; + + /** + * Array of resources. + * @type {Array} + */ + const resources: Array; + + /** + * Custom authentication resource. + * @type {string | `"\\(true,${string})\\"`} + */ + const customAuthResource: string | `"\\(true,${string})\\"`; + } +} + +// Add ethers global declaration +declare const ethers: typeof import("ethers"); + +// Global LOG_LEVEL +declare global { + interface Window { + LOG_LEVEL?: 'silent' | 'error' | 'warn' | 'info' | 'debug'; + } +} +export {}; diff --git a/apps/explorer/src/assets/google.png b/apps/explorer/src/assets/google.png new file mode 100644 index 0000000000..0f7e3e772f Binary files /dev/null and b/apps/explorer/src/assets/google.png differ diff --git a/apps/explorer/src/assets/lit-primary-orange.svg b/apps/explorer/src/assets/lit-primary-orange.svg new file mode 100644 index 0000000000..8bc001472f --- /dev/null +++ b/apps/explorer/src/assets/lit-primary-orange.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/explorer/src/assets/loading-lit.svg b/apps/explorer/src/assets/loading-lit.svg new file mode 100644 index 0000000000..d63fac983d --- /dev/null +++ b/apps/explorer/src/assets/loading-lit.svg @@ -0,0 +1,184 @@ + + Lit — Loading + Animated “LIT” loading screen with flame fill, subtle wobble, embers, and progress underline. + + + + + + + + + + + LIT + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + LIT + + + + + + + + + + + + + + + + + + + + + + + + + + + ... + + + \ No newline at end of file diff --git a/apps/explorer/src/assets/passkey.svg b/apps/explorer/src/assets/passkey.svg new file mode 100644 index 0000000000..5c15f8fe8b --- /dev/null +++ b/apps/explorer/src/assets/passkey.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/explorer/src/assets/phone.svg b/apps/explorer/src/assets/phone.svg new file mode 100644 index 0000000000..4536d0bd7c --- /dev/null +++ b/apps/explorer/src/assets/phone.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/explorer/src/assets/react.svg b/apps/explorer/src/assets/react.svg new file mode 100644 index 0000000000..6c87de9bb3 --- /dev/null +++ b/apps/explorer/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/explorer/src/assets/slash.svg b/apps/explorer/src/assets/slash.svg new file mode 100644 index 0000000000..1ad0467ff7 --- /dev/null +++ b/apps/explorer/src/assets/slash.svg @@ -0,0 +1,14 @@ + + + \ No newline at end of file diff --git a/apps/explorer/src/assets/web3-wallet.svg b/apps/explorer/src/assets/web3-wallet.svg new file mode 100644 index 0000000000..b642e02a4e --- /dev/null +++ b/apps/explorer/src/assets/web3-wallet.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/explorer/src/assets/whatsapp.svg b/apps/explorer/src/assets/whatsapp.svg new file mode 100644 index 0000000000..70a6571d4f --- /dev/null +++ b/apps/explorer/src/assets/whatsapp.svg @@ -0,0 +1,12 @@ + + + + + + \ No newline at end of file diff --git a/apps/explorer/src/domain/lit/chains.ts b/apps/explorer/src/domain/lit/chains.ts new file mode 100644 index 0000000000..60cea0d8ea --- /dev/null +++ b/apps/explorer/src/domain/lit/chains.ts @@ -0,0 +1,212 @@ +import { + LIT_CHAIN_EXPLORER_URL, + LIT_CHAIN_ID, + LIT_CHAIN_NAME, + LIT_CHAIN_RPC_URL, + LIT_CHAIN_SYMBOL, +} from "./litChainConfig"; + +export interface LitChainConfig { + id: number; + name: string; + symbol: string; + rpcUrl: string; + explorerUrl: string; + litIdentifier: string; + testnet: boolean; +} + +export const SUPPORTED_CHAIN_ID = 2888; +const CUSTOM_CHAINS_STORAGE_KEY = "chains.custom.v1"; + +const LIT_CHAIN_BASE_CONFIG: LitChainConfig = { + id: LIT_CHAIN_ID, + name: LIT_CHAIN_NAME, + symbol: LIT_CHAIN_SYMBOL, + rpcUrl: LIT_CHAIN_RPC_URL, + explorerUrl: LIT_CHAIN_EXPLORER_URL, + litIdentifier: "lit-chain", + testnet: false, +}; + +export const DEFAULT_CHAINS: Record = { + yellowstone: { + id: 175188, + name: "Chronicle Yellowstone", + symbol: "tstLPX", + rpcUrl: "https://yellowstone-rpc.litprotocol.com/", + explorerUrl: "https://yellowstone-explorer.litprotocol.com/", + litIdentifier: "yellowstone", + testnet: true, + }, + "naga-proto": { + ...LIT_CHAIN_BASE_CONFIG, + name: "Lit Chain (naga-proto)", + litIdentifier: "naga-proto", + }, + naga: { + ...LIT_CHAIN_BASE_CONFIG, + name: "Lit Chain (naga)", + litIdentifier: "naga", + }, + ethereum: { + id: 1, + name: "Ethereum", + symbol: "ETH", + rpcUrl: "https://eth.llamarpc.com", + explorerUrl: "https://etherscan.io/", + litIdentifier: "ethereum", + testnet: false, + }, + sepolia: { + id: 11155111, + name: "Sepolia Testnet", + symbol: "ETH", + rpcUrl: "https://ethereum-sepolia-rpc.publicnode.com", + explorerUrl: "https://sepolia.etherscan.io/", + litIdentifier: "sepolia", + testnet: true, + }, + polygon: { + id: 137, + name: "Polygon", + symbol: "MATIC", + rpcUrl: "https://polygon-bor-rpc.publicnode.com", + explorerUrl: "https://polygonscan.com/", + litIdentifier: "polygon", + testnet: false, + }, + arbitrum: { + id: 42161, + name: "Arbitrum", + symbol: "AETH", + rpcUrl: "https://arbitrum-one-rpc.publicnode.com", + explorerUrl: "https://arbiscan.io/", + litIdentifier: "arbitrum", + testnet: false, + }, + base: { + id: 8453, + name: "Base", + symbol: "ETH", + rpcUrl: "https://base-rpc.publicnode.com", + explorerUrl: "https://basescan.org/", + litIdentifier: "base", + testnet: false, + }, + optimism: { + id: 10, + name: "Optimism", + symbol: "ETH", + rpcUrl: "https://optimism-rpc.publicnode.com", + explorerUrl: "https://optimistic.etherscan.io/", + litIdentifier: "optimism", + testnet: false, + }, +}; + +export const SUPPORTED_CHAINS: Record = DEFAULT_CHAINS; + +function isValidUrl(url: string): boolean { + try { + new URL(url); + return true; + } catch { + return false; + } +} + +export function validateChainConfig( + cfg: LitChainConfig, + _allChainsById?: Map +): { ok: true } | { ok: false; error: string } { + if (!cfg) return { ok: false, error: "Missing chain config" }; + if (!Number.isInteger(cfg.id) || cfg.id <= 0) + return { ok: false, error: "id must be a positive integer" }; + if (!cfg.name || cfg.name.trim().length === 0) + return { ok: false, error: "name is required" }; + if (!cfg.symbol || cfg.symbol.trim().length === 0) + return { ok: false, error: "symbol is required" }; + if (!cfg.rpcUrl || !isValidUrl(cfg.rpcUrl)) + return { ok: false, error: "rpcUrl must be a valid URL" }; + if (cfg.explorerUrl && !isValidUrl(cfg.explorerUrl)) + return { ok: false, error: "explorerUrl must be a valid URL if provided" }; + if (typeof cfg.testnet !== "boolean") + return { ok: false, error: "testnet must be a boolean" }; + if (!cfg.litIdentifier || cfg.litIdentifier.trim().length === 0) + return { ok: false, error: "litIdentifier is required" }; + + return { ok: true }; +} + +export function loadCustomChains(): Record { + if (typeof window === "undefined") return {}; + try { + const raw = window.localStorage.getItem(CUSTOM_CHAINS_STORAGE_KEY); + if (!raw) return {}; + const parsed = JSON.parse(raw) as unknown; + if (!parsed || typeof parsed !== "object") return {}; + return parsed as Record; + } catch { + return {}; + } +} + +export function saveCustomChains(chains: Record): void { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem( + CUSTOM_CHAINS_STORAGE_KEY, + JSON.stringify(chains) + ); + } catch { + // ignore + } +} + +export function getCustomChains(): Record { + return loadCustomChains(); +} + +export function getAllChains(): Record { + return { ...DEFAULT_CHAINS, ...getCustomChains() }; +} + +export function isCustomChain(slug: string): boolean { + const custom = getCustomChains(); + return Object.prototype.hasOwnProperty.call(custom, slug); +} + +export function addCustomChain( + slug: string, + cfg: LitChainConfig +): { ok: true } | { ok: false; error: string } { + const existingCustom = getCustomChains(); + + if (!slug || slug.trim().length === 0) + return { ok: false, error: "slug is required" }; + const safeSlug = slug.trim(); + if (Object.prototype.hasOwnProperty.call(DEFAULT_CHAINS, safeSlug)) { + return { ok: false, error: "Slug collides with a default chain" }; + } + if (Object.prototype.hasOwnProperty.call(existingCustom, safeSlug)) { + return { ok: false, error: "Slug already exists" }; + } + + const valid = validateChainConfig(cfg); + if (!("ok" in valid) || valid.ok !== true) return valid; + + const updated = { + ...existingCustom, + [safeSlug]: cfg, + } as Record; + saveCustomChains(updated); + return { ok: true }; +} + +export function removeCustomChain(slug: string): void { + const existingCustom = getCustomChains(); + if (!Object.prototype.hasOwnProperty.call(existingCustom, slug)) return; + const { [slug]: _removed, ...rest } = existingCustom; + saveCustomChains(rest); +} diff --git a/apps/explorer/src/domain/lit/litChainConfig.ts b/apps/explorer/src/domain/lit/litChainConfig.ts new file mode 100644 index 0000000000..66294f4d82 --- /dev/null +++ b/apps/explorer/src/domain/lit/litChainConfig.ts @@ -0,0 +1,40 @@ +import type { Chain } from "viem"; + +const DEFAULT_LIT_CHAIN_RPC_URL = "https://lit-chain-rpc.litprotocol.com/"; +const DEFAULT_LIT_CHAIN_EXPLORER_URL = + "https://lit-chain-explorer.litprotocol.com/"; +const DEFAULT_LIT_CHAIN_EXPLORER_NAME = "Lit Chain Explorer"; + +export const LIT_CHAIN_ID = 175200; +export const LIT_CHAIN_NAME = "Lit Chain"; +export const LIT_CHAIN_SYMBOL = "LITKEY"; + +export const LIT_CHAIN_RPC_URL = + import.meta.env.VITE_LIT_CHAIN_RPC_URL || DEFAULT_LIT_CHAIN_RPC_URL; +export const LIT_CHAIN_EXPLORER_URL = + import.meta.env.VITE_LIT_CHAIN_EXPLORER_URL || + DEFAULT_LIT_CHAIN_EXPLORER_URL; +export const LIT_CHAIN_EXPLORER_NAME = + import.meta.env.VITE_LIT_CHAIN_EXPLORER_NAME || + DEFAULT_LIT_CHAIN_EXPLORER_NAME; + +export const litChainViemConfig: Chain = { + id: LIT_CHAIN_ID, + name: LIT_CHAIN_NAME, + nativeCurrency: { + name: LIT_CHAIN_NAME, + symbol: LIT_CHAIN_SYMBOL, + decimals: 18, + }, + rpcUrls: { + default: { http: [LIT_CHAIN_RPC_URL] }, + public: { http: [LIT_CHAIN_RPC_URL] }, + }, + blockExplorers: { + default: { + name: LIT_CHAIN_EXPLORER_NAME, + url: LIT_CHAIN_EXPLORER_URL, + }, + }, + testnet: false, +}; diff --git a/apps/explorer/src/domain/lit/networkDefaults.ts b/apps/explorer/src/domain/lit/networkDefaults.ts new file mode 100644 index 0000000000..22586f8c34 --- /dev/null +++ b/apps/explorer/src/domain/lit/networkDefaults.ts @@ -0,0 +1,43 @@ +import type { SupportedNetworkName } from "@/lit-login-modal/types"; + +const KNOWN_NETWORKS = [ + "naga-dev", + "naga-test", + "naga-proto", + "naga", +] as const; + +const TESTNET_NETWORKS = new Set([ + "naga-dev", + "naga-test", +]); + +const DEFAULT_CHAIN_BY_NETWORK: Record = { + "naga-dev": "yellowstone", + "naga-test": "yellowstone", + "naga-proto": "naga-proto", + naga: "naga", +}; + +function isSupportedNetworkName(value: string): value is SupportedNetworkName { + return (KNOWN_NETWORKS as readonly string[]).includes(value); +} + +export function normalizeNetworkName( + networkName?: string +): SupportedNetworkName { + const candidate = (networkName?.toLowerCase() ?? "naga-dev") as string; + return isSupportedNetworkName(candidate) + ? (candidate as SupportedNetworkName) + : "naga-dev"; +} + +export function getDefaultChainForNetwork(networkName?: string): string { + const normalized = normalizeNetworkName(networkName); + return DEFAULT_CHAIN_BY_NETWORK[normalized]; +} + +export function isTestnetNetwork(networkName?: string): boolean { + const normalized = normalizeNetworkName(networkName); + return TESTNET_NETWORKS.has(normalized); +} diff --git a/apps/explorer/src/hooks/useLitServiceSetup.ts b/apps/explorer/src/hooks/useLitServiceSetup.ts new file mode 100644 index 0000000000..6eb6bc8de8 --- /dev/null +++ b/apps/explorer/src/hooks/useLitServiceSetup.ts @@ -0,0 +1,150 @@ +/** + * useLitServiceSetup.ts + * + * React hook for setting up Lit Protocol services with proper configuration. + * Handles network setup, auth manager creation, and storage plugin configuration. + */ + +import React, { useState, useCallback, useRef } from "react"; +import { createLitClient } from "@lit-protocol/lit-client"; +import { createAuthManager, storagePlugins } from "@lit-protocol/auth"; +import { nagaDev, nagaTest, nagaProto, naga } from "@lit-protocol/networks"; + +// Configuration constants at the top +const DEFAULT_APP_NAME = "lit-auth-app"; +type NetworkModule = + | typeof nagaDev + | typeof nagaTest + | typeof nagaProto + | typeof naga; +const NETWORK_MODULES: Record = { + "naga-dev": nagaDev, + "naga-test": nagaTest, + "naga-proto": nagaProto, + naga, +}; + +interface LitServiceSetupConfig { + appName?: string; + networkName?: string; + network?: NetworkModule; + autoSetup?: boolean; +} + +export interface LitServices { + litClient: Awaited>; + authManager: Awaited>; +} + +interface UseLitServiceSetupReturn { + services: LitServices | null; + isInitializing: boolean; + error: string | null; + setupServices: () => Promise; + clearServices: () => void; + isReady: boolean; +} + +/** + * Hook for setting up Lit Protocol services + * + * @param config Configuration options for the setup + * @returns Object containing services, setup state, and control functions + */ +export const useLitServiceSetup = ( + config: LitServiceSetupConfig = {} +): UseLitServiceSetupReturn => { + const [services, setServices] = useState(null); + const [isInitializing, setIsInitializing] = useState(false); + const [error, setError] = useState(null); + + // Use ref to track if services are being initialized to prevent multiple calls + const initializingRef = useRef(false); + + const setupServices = useCallback(async (): Promise => { + // Prevent multiple simultaneous initialization attempts + if (initializingRef.current) { + throw new Error("Services are already being initialized"); + } + + try { + initializingRef.current = true; + setIsInitializing(true); + setError(null); + + console.log("🚀 Starting Lit Protocol service setup..."); + + // Step 1: Create Lit Client with singleton pattern + console.log(`📡 Creating Lit Client...`); + const networkModule: NetworkModule | undefined = + config.network || + (config.networkName ? NETWORK_MODULES[config.networkName] : undefined); + if (!networkModule) { + throw new Error( + `Unknown or unsupported network configuration. Provide a 'network' instance or a valid 'networkName'.` + ); + } + const litClient = await createLitClient({ + network: networkModule as unknown as Parameters< + typeof createLitClient + >[0]["network"], + }); + console.log("✅ Lit Client created successfully"); + + // Step 2: Create Auth Manager with storage configuration + console.log("🔐 Creating Auth Manager..."); + if (!config.networkName) { + throw new Error( + "No networkName provided for storage configuration. Pass 'networkName' to useLitServiceSetup." + ); + } + const authManager = createAuthManager({ + storage: storagePlugins.localStorage({ + appName: config.appName || DEFAULT_APP_NAME, + networkName: config.networkName, + }), + }); + console.log("✅ Auth Manager created successfully"); + + const newServices = { litClient, authManager }; + setServices(newServices); + + console.log( + `🎉 All Lit Protocol services initialized successfully. Network: ${config.networkName}` + ); + return newServices; + } catch (err: any) { + const errorMessage = `Failed to initialize Lit Protocol services: ${ + err.message || err + }`; + console.error("❌", errorMessage, err); + setError(errorMessage); + throw new Error(errorMessage); + } finally { + setIsInitializing(false); + initializingRef.current = false; + } + }, [config]); + + const clearServices = useCallback(() => { + console.log("🧹 Clearing Lit Protocol services..."); + setServices(null); + setError(null); + }, []); + + // Auto-setup on mount if requested + React.useEffect(() => { + if (config.autoSetup && !services && !isInitializing) { + setupServices().catch(console.error); + } + }, [config.autoSetup, services, isInitializing, setupServices]); + + return { + services, + isInitializing, + error, + setupServices, + clearServices, + isReady: !!(services?.litClient && services?.authManager), + }; +}; diff --git a/apps/explorer/src/index.tsx b/apps/explorer/src/index.tsx new file mode 100644 index 0000000000..44f4784f18 --- /dev/null +++ b/apps/explorer/src/index.tsx @@ -0,0 +1,90 @@ +import { useState } from "react"; +import { Outlet } from "react-router-dom"; +import { APP_INFO } from "./_config"; +import { LitAuthProvider } from "./lit-login-modal/LitAuthProvider"; +import { Header } from "@/Header"; + +interface ErrorDisplayProps { + error: string | null; + isVisible: boolean; + onClear: () => void; +} + +const ErrorDisplay = ({ error, isVisible, onClear }: ErrorDisplayProps) => { + if (!error || !isVisible) { + return null; + } + + return ( +
+
+
+ ❌ +
+
+

+ Error +

+
+ {error} +
+
+ +
+
+ ); +}; + +export const HomePage = () => { + // Error state management + const [error, setError] = useState(null); + const [isErrorVisible, setIsErrorVisible] = useState(false); + + // Function to clear error + const clearError = () => { + setError(null); + setIsErrorVisible(false); + }; + + return ( + + {/* ---------- Header ---------- */} +
+ + {/* ---------- Main Content ---------- */} +
+ + +
+ + {/* ---------- Footer ---------- */} +
+

+ © {new Date().getFullYear()} {APP_INFO.copyright} +

+
+ + ); +}; + +export default HomePage; diff --git a/apps/explorer/src/layout/AppHeader.tsx b/apps/explorer/src/layout/AppHeader.tsx new file mode 100644 index 0000000000..39add233bb --- /dev/null +++ b/apps/explorer/src/layout/AppHeader.tsx @@ -0,0 +1,36 @@ +/** + * AppHeader + * + * Sticky top header shell with left logo slot, centre slot, and right actions slot. + * Mirrors current spacing and borders used in the app header. + */ + +import React from "react"; + +interface AppHeaderProps { + leftSlot?: React.ReactNode; // e.g., logo/link + centerSlot?: React.ReactNode; // e.g., search + rightSlot?: React.ReactNode; // e.g., auth actions +} + +export const AppHeader: React.FC = ({ leftSlot, centerSlot, rightSlot }) => { + return ( +
+
+
+
+
+
+
{leftSlot}
+
{centerSlot}
+
{rightSlot}
+
+
+
+
+
+
+ ); +}; + + diff --git a/apps/explorer/src/layout/BurgerMenu.tsx b/apps/explorer/src/layout/BurgerMenu.tsx new file mode 100644 index 0000000000..f79740c00e --- /dev/null +++ b/apps/explorer/src/layout/BurgerMenu.tsx @@ -0,0 +1,50 @@ +/** + * BurgerMenu + * + * Small-screen floating menu button (top-right). Consumer passes the menu content. + * Visible only below a breakpoint (default: lg). + */ + +import React from "react"; + +interface BurgerMenuProps { + menu: React.ReactNode; + buttonAriaLabel?: string; + positionClass?: string; // default fixed top-2 right-2 sm:top-3 sm:right-3 + visibleBelowBreakpoint?: 'lg' | 'md' | 'xl'; +} + +export const BurgerMenu: React.FC = ({ + menu, + buttonAriaLabel = "Open menu", + positionClass = "fixed top-2 right-2 sm:top-3 sm:right-3", + visibleBelowBreakpoint = 'lg', +}) => { + const [open, setOpen] = React.useState(false); + const visibilityClass = visibleBelowBreakpoint === 'lg' ? 'block lg:hidden' : visibleBelowBreakpoint === 'md' ? 'block md:hidden' : 'block xl:hidden'; + return ( +
+ + {open && ( +
+ {menu} +
+ )} +
+ ); +}; + + diff --git a/apps/explorer/src/layout/GlobalMessage.tsx b/apps/explorer/src/layout/GlobalMessage.tsx new file mode 100644 index 0000000000..802a8bfccf --- /dev/null +++ b/apps/explorer/src/layout/GlobalMessage.tsx @@ -0,0 +1,40 @@ +/** + * GlobalMessage + * + * Sticky, dismiss-less message bar that appears below header/nav on md+ screens. + * Responsive: not sticky on small screens by default (keeps layout simple). + * + * Usage: + * + */ + +import React from "react"; + +interface GlobalMessageProps { + visible: boolean; + message: string; + className?: string; + /** Tailwind position classes controlling sticky offset on md+ */ + stickyOffsetClass?: string; // default "md:sticky md:top-28" +} + +export const GlobalMessage: React.FC = ({ + visible, + message, + className, + stickyOffsetClass = "md:sticky md:top-28", +}) => { + if (!visible) return null; + return ( +
+ {message} +
+ ); +}; + + diff --git a/apps/explorer/src/layout/StickySidebarLayout.tsx b/apps/explorer/src/layout/StickySidebarLayout.tsx new file mode 100644 index 0000000000..cdb3bd8643 --- /dev/null +++ b/apps/explorer/src/layout/StickySidebarLayout.tsx @@ -0,0 +1,53 @@ +/** + * StickySidebarLayout + * + * Two-column layout where the left sidebar is sticky and the right content scrolls. + * - Sidebar hidden below a breakpoint (default: lg) + * - Matches spacing, widths, and offsets used by the current app + * + * Usage: + * }> + * + * + */ + +import React from "react"; + +interface StickySidebarLayoutProps { + sidebar: React.ReactNode; + children: React.ReactNode; + /** Tailwind class controlling sidebar width */ + sidebarWidthClass?: string; // default w-[18rem] + /** Breakpoint at which sidebar becomes hidden */ + hideAtBreakpoint?: 'lg' | 'md' | 'xl'; + /** Inline style top offset for the sticky sidebar */ + sidebarTopOffsetPx?: number; // default matches current header+nav height +} + +export const StickySidebarLayout: React.FC = ({ + sidebar, + children, + sidebarWidthClass = "w-[18rem]", + hideAtBreakpoint = "lg", + sidebarTopOffsetPx = 7 * 16 + 38, // 7rem + 38px +}) => { + const hiddenClass = hideAtBreakpoint === 'lg' ? 'hidden lg:block' : hideAtBreakpoint === 'md' ? 'hidden md:block' : 'hidden xl:block'; + return ( +
+
+
+ +
+ {children} +
+
+
+ ); +}; + + diff --git a/apps/explorer/src/layout/TopNavBar.tsx b/apps/explorer/src/layout/TopNavBar.tsx new file mode 100644 index 0000000000..d338bbd6a1 --- /dev/null +++ b/apps/explorer/src/layout/TopNavBar.tsx @@ -0,0 +1,62 @@ +/** + * TopNavBar + * + * Sticky tab navigation bar that sits below the page header. + * - Tabs scroll horizontally on small screens + * - Sticks under a standard sticky header height + * - Provides a right-side slot for actions (e.g., account/burger menu) + * + * Usage: + * } /> + */ + +import React from "react"; + +export interface TopNavTab { + id: string; + label: string; +} + +interface TopNavBarProps { + tabs: TopNavTab[]; + activeTab: string; + onTabChange: (id: string) => void; + rightSlot?: React.ReactNode; + /** Override sticky offsets if your header height differs */ + stickyClassName?: string; // e.g. "sticky top-14 sm:top-16" +} + +export const TopNavBar: React.FC = ({ + tabs, + activeTab, + onTabChange, + rightSlot, + stickyClassName = "sticky top-14 sm:top-16", +}) => { + return ( + + ); +}; + + diff --git a/apps/explorer/src/layout/index.ts b/apps/explorer/src/layout/index.ts new file mode 100644 index 0000000000..d75209f6c5 --- /dev/null +++ b/apps/explorer/src/layout/index.ts @@ -0,0 +1,7 @@ +export { TopNavBar, type TopNavTab } from './TopNavBar'; +export { GlobalMessage } from './GlobalMessage'; +export { StickySidebarLayout } from './StickySidebarLayout'; +export { BurgerMenu } from './BurgerMenu'; +export { AppHeader } from './AppHeader'; + + diff --git a/apps/explorer/src/lit-action-examples/entries/crypto-jwt.ts b/apps/explorer/src/lit-action-examples/entries/crypto-jwt.ts new file mode 100644 index 0000000000..14bd45a209 --- /dev/null +++ b/apps/explorer/src/lit-action-examples/entries/crypto-jwt.ts @@ -0,0 +1,79 @@ +import type { LitActionExample } from "../types"; + +const code = String.raw`(async () => { + // Shim + const { Buffer: NodeBuffer } = await import('node:buffer'); + globalThis.Buffer = NodeBuffer; + + // Crypto + const text = 'Hello, crypto world!'; + const encoder = new TextEncoder(); + const data = encoder.encode(text); + const hashBuffer = await globalThis.crypto.subtle.digest('SHA-256', data); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + const hashBase64 = Buffer.from(hashArray).toString('base64url'); + console.log(hashBase64); + + // JWT + const secret = Buffer.from('mysecret', 'utf8'); + const payload = { userId: 123 }; + + // sign short-lived token + const token = jwt.sign(payload, secret, { expiresIn: '2s' }); + console.log("token:", token); + + // header sanity (alg/typ) + const [hdrB64] = token.split('.'); + const jwtHeader = JSON.parse(Buffer.from(hdrB64, 'base64url').toString('utf8')); + console.log("jwtHeader:", jwtHeader); + + // verify immediately + let jwtVerify; + try { + jwtVerify = jwt.verify(token, secret); + } catch (err) { + jwtVerify = { error: err?.message, name: err?.name }; + } + + console.log("jwtVerify:", jwtVerify); + + // decode (no verify()) + const jwtDecode = jwt.decode(token); + console.log("jwtDecode:", jwtDecode); + + // verify + let jwtFail; + try { + jwt.verify(token, 'wrongsecret'); + } catch (e) { + jwtFail = JSON.stringify(e, null, 2); + console.log("Fail(expected)", JSON.stringify(e, null, 2)); + } + + // set results to JSON so we can parse it as JSON to see: + const result = { + hashBase64, + token, + jwtHeader, + jwtVerify, + jwtDecode, + jwtFail, + }; + + Lit.Actions.setResponse({ + response: JSON.stringify( + result, + null, + 2 + ), + }); +})();`; + +export default { + id: "crypto-jwt", + title: "Crypto & JWT", + description: + "Hashes a string, signs a short-lived JWT, and verifies/decodes it inside a Lit Action runtime.", + order: 30, + code, +} satisfies LitActionExample; diff --git a/apps/explorer/src/lit-action-examples/entries/custom-auth.ts b/apps/explorer/src/lit-action-examples/entries/custom-auth.ts new file mode 100644 index 0000000000..7d9f167536 --- /dev/null +++ b/apps/explorer/src/lit-action-examples/entries/custom-auth.ts @@ -0,0 +1,37 @@ +import type { LitActionExample } from "../types"; + +const code = String.raw`(async () => { + const dAppUniqueAuthMethodType = "0x..."; + const { publicKey, username, password, authMethodId } = jsParams; + + // Custom validation logic + const EXPECTED_USERNAME = 'alice'; + const EXPECTED_PASSWORD = 'lit'; + const userIsValid = username === EXPECTED_USERNAME && password === EXPECTED_PASSWORD; + + // Check PKP permissions + const tokenId = await Lit.Actions.pubkeyToTokenId({ publicKey: publicKey }); + const permittedAuthMethods = await Lit.Actions.getPermittedAuthMethods({ tokenId }); + + const isPermitted = permittedAuthMethods.some((permittedAuthMethod) => { + return permittedAuthMethod["auth_method_type"] === dAppUniqueAuthMethodType && + permittedAuthMethod["id"] === authMethodId; + }); + + const isValid = isPermitted && userIsValid; + LitActions.setResponse({ response: isValid ? "true" : "false" }); +})();`; + +export default { + id: "custom-auth-check", + title: "Custom Auth Validation", + description: + "Validate username/password, ensure the auth method is permitted, and return a boolean result.", + order: 20, + code, + jsParams: { + username: "alice", + password: "lit", + authMethodId: "example-auth-method", + }, +} satisfies LitActionExample; diff --git a/apps/explorer/src/lit-action-examples/entries/decrypt-and-combine.ts b/apps/explorer/src/lit-action-examples/entries/decrypt-and-combine.ts new file mode 100644 index 0000000000..025a8a881b --- /dev/null +++ b/apps/explorer/src/lit-action-examples/entries/decrypt-and-combine.ts @@ -0,0 +1,161 @@ +import type { LitActionExample } from "../types"; + +const code = String.raw`(async () => { + const results = { + step1_getCurrentCid: null, + step2_generateEntropy: null, + step3_encrypt: null, + step4_decrypt: null, + step5_verify: null, + }; + + try { + // Step 1: Get current action IPFS CID + const currentCid = Lit.Auth.actionIpfsIdStack[0]; + results.step1_getCurrentCid = { + success: true, + cid: currentCid, + }; + + // Step 2: Generate entropy (32 random bytes) + // const entropy = ethers.utils.randomBytes(32); + // const entropyHex = ethers.utils.hexlify(entropy); + const entropyHex = await Lit.Actions.runOnce( + { waitForResponse: true, name: "generateEntropy" }, + async () => { + return ethers.utils.hexlify(ethers.utils.randomBytes(32)); + } + ); + const entropy = ethers.utils.arrayify(entropyHex); + results.step2_generateEntropy = { + success: true, + entropy: entropyHex, + entropyLength: entropy.length, + }; + + // Step 3: Encrypt with access control locked to current IPFS CID + // When method is empty, it uses check_condition_via_signature which does string comparison + const accessControlConditions = [ + { + contractAddress: "", + standardContractType: "", + chain: "ethereum", + method: "", + parameters: [":currentActionIpfsId"], + returnValueTest: { + comparator: "=", + value: currentCid, + }, + }, + ]; + + let encryptResult = await Lit.Actions.runOnce( + { waitForResponse: true, name: "encrypt" }, + async () => { + return JSON.stringify( + await Lit.Actions.encrypt({ + accessControlConditions, + to_encrypt: ethers.utils.toUtf8Bytes(entropyHex), + }) + ); + } + ); + encryptResult = JSON.parse(encryptResult); + console.log("encryptResult", encryptResult); + + // Convert ciphertext to base64 for transmission + let ciphertextStr; + if (encryptResult.ciphertext instanceof Uint8Array) { + const binaryStr = Array.from(encryptResult.ciphertext) + .map((byte) => String.fromCharCode(byte)) + .join(""); + ciphertextStr = btoa(binaryStr); + } else { + ciphertextStr = encryptResult.ciphertext; + } + + // Convert dataToEncryptHash to hex + let dataHashStr; + if (encryptResult.dataToEncryptHash instanceof Uint8Array) { + dataHashStr = ethers.utils.hexlify(encryptResult.dataToEncryptHash); + } else { + dataHashStr = encryptResult.dataToEncryptHash; + } + + results.step3_encrypt = { + success: true, + ciphertextLength: ciphertextStr.length, + ciphertext: ciphertextStr, + dataToEncryptHash: dataHashStr, + }; + + // Step 4: Decrypt using decryptAndCombine + // Note: In production, you'd fetch ciphertext + dataToEncryptHash from the smart contract + // Here we're using the original encrypt result formats (not the converted strings) + const decryptResult = await Lit.Actions.decryptAndCombine({ + accessControlConditions, + ciphertext: encryptResult.ciphertext, // Use original format + dataToEncryptHash: encryptResult.dataToEncryptHash, // Use original format + authSig: null, + chain: "ethereum", + }); + + // Convert decrypted result to hex for comparison + let decryptedHex; + if (decryptResult instanceof Uint8Array) { + decryptedHex = ethers.utils.hexlify(decryptResult); + } else if (typeof decryptResult === "string") { + decryptedHex = decryptResult; + } else { + decryptedHex = "unknown format"; + } + + results.step4_decrypt = { + success: true, + decryptedData: decryptedHex, + decryptedLength: decryptResult.length, + }; + + // Step 5: Verify original matches decrypted + const matches = entropyHex === decryptedHex; + results.step5_verify = { + success: true, + matches, + original: entropyHex, + decrypted: decryptedHex, + }; + + Lit.Actions.setResponse({ + response: JSON.stringify( + { + success: true, + currentCid, + results, + }, + null, + 2 + ), + }); + } catch (error) { + Lit.Actions.setResponse({ + response: JSON.stringify( + { + success: false, + error: error.message, + results, + }, + null, + 2 + ), + }); + } +})();`; + +export default { + id: "decrypt-and-combine", + title: "Encrypt, Decrypt, and Verify", + description: + "Encrypt data tied to the current action CID, decrypt it with decryptAndCombine, and verify the round trip.", + order: 30, + code, +} satisfies LitActionExample; diff --git a/apps/explorer/src/lit-action-examples/entries/sign-basic.ts b/apps/explorer/src/lit-action-examples/entries/sign-basic.ts new file mode 100644 index 0000000000..f00177cbee --- /dev/null +++ b/apps/explorer/src/lit-action-examples/entries/sign-basic.ts @@ -0,0 +1,28 @@ +import type { LitActionExample } from "../types"; + +const code = String.raw`const { sigName, toSign, publicKey, } = jsParams; +const { keccak256, arrayify } = ethers.utils; + +(async () => { + const toSignBytes = new TextEncoder().encode(toSign); + const toSignBytes32 = keccak256(toSignBytes); + const toSignBytes32Array = arrayify(toSignBytes32); + + await Lit.Actions.signEcdsa({ + toSign: toSignBytes32Array, + publicKey, + sigName, + }); +})();`; + +export default { + id: "sign-basic", + title: "Sign Message", + description: "Hash a string and sign it with your PKP using ECDSA.", + order: 10, + code, + jsParams: { + sigName: "sig1", + toSign: "Hello from Lit Action", + }, +} satisfies LitActionExample; diff --git a/apps/explorer/src/lit-action-examples/index.ts b/apps/explorer/src/lit-action-examples/index.ts new file mode 100644 index 0000000000..7fa15043a8 --- /dev/null +++ b/apps/explorer/src/lit-action-examples/index.ts @@ -0,0 +1,49 @@ +import type { + LitActionExample, + LitActionExampleModule, +} from "./types"; + +const modules = import.meta.glob( + "./entries/**/*.ts", + { eager: true } +); + +const dedupe = new Map(); + +for (const mod of Object.values(modules)) { + const example = mod?.default; + if (!example) continue; + + if (dedupe.has(example.id)) { + // Later files override earlier ones to make local iteration easier. + console.warn( + `[lit-action-examples] Duplicate example id detected: ${example.id}. ` + + "Overriding with the most recently evaluated module." + ); + } + + dedupe.set(example.id, { + ...example, + // Normalise code to always be a string (helps when snippets accidentally export undefined). + code: example.code ?? "", + }); +} + +const examples = Array.from(dedupe.values()).sort((a, b) => { + const orderA = a.order ?? Number.MAX_SAFE_INTEGER; + const orderB = b.order ?? Number.MAX_SAFE_INTEGER; + if (orderA !== orderB) return orderA - orderB; + return a.title.localeCompare(b.title); +}); + +const exampleMap = new Map(examples.map((example) => [example.id, example])); + +export const litActionExamples = examples; + +export function getLitActionExample(id: string): LitActionExample | undefined { + return exampleMap.get(id); +} + +export function getDefaultLitActionExample(): LitActionExample | undefined { + return examples[0]; +} diff --git a/apps/explorer/src/lit-action-examples/types.ts b/apps/explorer/src/lit-action-examples/types.ts new file mode 100644 index 0000000000..7c9c2b10fb --- /dev/null +++ b/apps/explorer/src/lit-action-examples/types.ts @@ -0,0 +1,34 @@ +export interface LitActionExample { + /** + * Unique identifier used for lookups and navigation. + */ + id: string; + /** + * Human friendly title displayed in the UI. + */ + title: string; + /** + * Short description to help users understand what the example does. + */ + description?: string; + /** + * JavaScript code that will populate the Monaco editor. + */ + code: string; + /** + * Optional order override. Lower numbers appear first. + */ + order?: number; + /** + * Lightweight tagging for future filtering or search. + */ + tags?: string[]; + /** + * Optional default parameters to merge into the example execution. + */ + jsParams?: Record; +} + +export interface LitActionExampleModule { + default: LitActionExample; +} diff --git a/apps/explorer/src/lit-actions.d.ts b/apps/explorer/src/lit-actions.d.ts new file mode 100644 index 0000000000..0f36e5d239 --- /dev/null +++ b/apps/explorer/src/lit-actions.d.ts @@ -0,0 +1,616 @@ +declare namespace Lit { + export namespace Actions { + /** + * Check if a given IPFS ID is permitted to sign using a given PKP tokenId + * @function isPermittedAction + * @param {Object} params + * @param {string} params.tokenId The tokenId to check + * @param {string} params.ipfsId The IPFS ID of some JS code (a lit action) + * @returns {Promise} A boolean indicating whether the IPFS ID is permitted to sign using the PKP tokenId + */ + function isPermittedAction({ + tokenId, + ipfsId, + }: { + tokenId: string; + ipfsId: string; + }): Promise; + /** + * Check if a given wallet address is permitted to sign using a given PKP tokenId + * @function isPermittedAddress + * @param {Object} params + * @param {string} params.tokenId The tokenId to check + * @param {string} params.address The wallet address to check + * @returns {Promise} A boolean indicating whether the wallet address is permitted to sign using the PKP tokenId + */ + function isPermittedAddress({ + tokenId, + address, + }: { + tokenId: string; + address: string; + }): Promise; + /** + * Check if a given auth method is permitted to sign using a given PKP tokenId + * @function isPermittedAuthMethod + * @param {Object} params + * @param {string} params.tokenId The tokenId to check + * @param {number} params.authMethodType The auth method type. This is an integer. This mapping shows the initial set but this set may be expanded over time without updating this contract: https://github.com/LIT-Protocol/LitNodeContracts/blob/main/contracts/PKPPermissions.sol#L25 + * @param {Uint8Array} params.userId The id of the auth method to check expressed as an array of unsigned 8-bit integers (a Uint8Array) + * @returns {Promise} A boolean indicating whether the auth method is permitted to sign using the PKP tokenId + */ + function isPermittedAuthMethod({ + tokenId, + authMethodType, + userId, + }: { + tokenId: string; + authMethodType: number; + userId: Uint8Array; + }): Promise; + /** + * Get the full list of actions that are permitted to sign using a given PKP tokenId + * @function getPermittedActions + * @param {Object} params + * @param {string} params.tokenId The tokenId to check + * @returns {Promise>} An array of IPFS IDs of lit actions that are permitted to sign using the PKP tokenId + */ + function getPermittedActions({ + tokenId, + }: { + tokenId: string; + }): Promise>; + /** + * Get the full list of addresses that are permitted to sign using a given PKP tokenId + * @function getPermittedAddresses + * @param {Object} params + * @param {string} params.tokenId The tokenId to check + * @returns {Promise>} An array of addresses that are permitted to sign using the PKP tokenId + */ + function getPermittedAddresses({ + tokenId, + }: { + tokenId: string; + }): Promise>; + /** + * Get the full list of auth methods that are permitted to sign using a given PKP tokenId + * @function getPermittedAuthMethods + * @param {Object} params + * @param {string} params.tokenId The tokenId to check + * @returns {Promise>} An array of auth methods that are permitted to sign using the PKP tokenId. Each auth method is an object with the following properties: auth_method_type, id, and user_pubkey (used for web authn, this is the pubkey of the user's authentication keypair) + */ + function getPermittedAuthMethods({ + tokenId, + }: { + tokenId: string; + }): Promise>; + /** + * Get the permitted auth method scopes for a given PKP tokenId and auth method type + id + * @function getPermittedAuthMethodScopes + * @param {Object} params + * @param {string} params.tokenId The tokenId to check + * @param {string} params.authMethodType The auth method type to look up + * @param {Uint8Array} params.userId The id of the auth method to check expressed as an array of unsigned 8-bit integers (a Uint8Array) + * @param {number} params.maxScopeId The maximum scope id to check. This is an integer. + * @returns {Promise>} An array of booleans that define if a given scope id is turned on. The index of the array is the scope id. For example, if the array is [true, false, true], then scope ids 0 and 2 are turned on, but scope id 1 is turned off. + */ + function getPermittedAuthMethodScopes({ + tokenId, + authMethodType, + userId, + maxScopeId, + }: { + tokenId: string; + authMethodType: string; + userId: Uint8Array; + maxScopeId: number; + }): Promise>; + /** + * Converts a PKP public key to a PKP token ID by hashing it with keccak256 + * @function pubkeyToTokenId + * @param {Object} params + * @param {string} params.publicKey The public key to convert + * @returns {Promise} The token ID as a string + */ + function pubkeyToTokenId({ + publicKey, + }: { + publicKey: string; + }): Promise; + /** + * Gets latest nonce for the given address on a supported chain + * @function getLatestNonce + * @param {Object} params + * @param {string} params.address The wallet address for getting the nonce + * @param {string} params.chain The chain of which the nonce is fetched + * @returns {Promise} The token ID as a string + */ + function getLatestNonce({ + address, + chain, + }: { + address: string; + chain: string; + }): Promise; + /** + * Ask the Lit Node to sign any data using the ECDSA Algorithm with it's private key share. The resulting signature share will be returned to the Lit JS SDK which will automatically combine the shares and give you the full signature to use. + * @function signEcdsa + * @param {Object} params + * @param {Uint8Array} params.toSign The data to sign. Should be an array of 8-bit integers. + * @param {string} params.publicKey The public key of the PKP you wish to sign with + * @param {string} params.sigName You can put any string here. This is used to identify the signature in the response by the Lit JS SDK. This is useful if you are signing multiple messages at once. When you get the final signature out, it will be in an object with this signature name as the key. + * @returns {Promise} This function will return the string "success" if it works. The signature share is returned behind the scenes to the Lit JS SDK which will automatically combine the shares and give you the full signature to use. + */ + function signEcdsa({ + toSign, + publicKey, + sigName, + }: { + toSign: Uint8Array; + publicKey: string; + sigName: string; + }): Promise; + /** + * @param {Uint8array} toSign the message to sign + * @param {string} publicKey the public key of the PKP + * @param {string} sigName the name of the signature + * @param {string} signingScheme the name of the signing scheme + * one of the following + * "EcdsaK256Sha256" + * "EcdsaP256Sha256" + * "EcdsaP384Sha384" + * "SchnorrEd25519Sha512" + * "SchnorrK256Sha256" + * "SchnorrP256Sha256" + * "SchnorrP384Sha384" + * "SchnorrRistretto25519Sha512" + * "SchnorrEd448Shake256" + * "SchnorrRedJubjubBlake2b512" + * "SchnorrK256Taproot" + * "SchnorrRedDecaf377Blake2b512" + * "SchnorrkelSubstrate" + * "Bls12381G1ProofOfPossession" + * @returns {Uint8array} The resulting signature share + */ + function sign({ + toSign, + publicKey, + sigName, + signingScheme, + }: Uint8array): Uint8array; + /** + * Sign data using the Lit Action's own cryptographic identity derived from its IPFS CID. + * This allows actions to sign as themselves (not as a PKP), enabling autonomous agent behavior, + * action-to-action authentication, and verifiable computation results. + * + * The action's keypair is deterministically derived from: keccak256("lit_action_" + actionIpfsCid) + * The same action IPFS CID always generates the same keypair across all nodes. + * + * @function signAsAction + * @param {Object} params + * @param {Uint8Array} params.toSign The message to sign as an array of 8-bit integers + * @param {string} params.sigName The name to identify this signature in the response + * @param {string} params.signingScheme The signing algorithm to use. Must be one of: + * "EcdsaK256Sha256", "EcdsaP256Sha256", "EcdsaP384Sha384", + * "SchnorrEd25519Sha512", "SchnorrK256Sha256", "SchnorrP256Sha256", "SchnorrP384Sha384", + * "SchnorrRistretto25519Sha512", "SchnorrEd448Shake256", "SchnorrRedJubjubBlake2b512", + * "SchnorrK256Taproot", "SchnorrRedDecaf377Blake2b512", "SchnorrkelSubstrate", + * "Bls12381G1ProofOfPossession" + * @returns {Promise} The resulting signature that can be verified using verifyActionSignature + */ + function signAsAction({ + toSign, + sigName, + signingScheme, + }: { + toSign: Uint8Array; + sigName: string; + signingScheme: string; + }): Promise; + /** + * Get the public key for a Lit Action's cryptographic identity. + * This can be used to verify signatures created by signAsAction, or to get the public key + * of any action (including actions you didn't create) for verification purposes. + * + * The public key is deterministically derived from: keccak256("lit_action_" + actionIpfsCid) + * and will always be the same for a given action IPFS CID and signing scheme. + * + * @function getActionPublicKey + * @param {Object} params + * @param {string} params.signingScheme The signing algorithm. Must be one of: + * "EcdsaK256Sha256", "EcdsaP256Sha256", "EcdsaP384Sha384", + * "SchnorrEd25519Sha512", "SchnorrK256Sha256", "SchnorrP256Sha256", "SchnorrP384Sha384", + * "SchnorrRistretto25519Sha512", "SchnorrEd448Shake256", "SchnorrRedJubjubBlake2b512", + * "SchnorrK256Taproot", "SchnorrRedDecaf377Blake2b512", "SchnorrkelSubstrate", + * "Bls12381G1ProofOfPossession" + * @param {string} params.actionIpfsCid The IPFS CID of the Lit Action + * @returns {Promise} The public key for the action + */ + function getActionPublicKey({ + signingScheme, + actionIpfsCid, + }: { + signingScheme: string; + actionIpfsCid: string; + }): Promise; + /** + * Verify that a signature was created by a specific Lit Action using signAsAction. + * This enables action-to-action authentication, verifiable computation, and building trust chains + * between actions without requiring PKP ownership. + * + * @function verifyActionSignature + * @param {Object} params + * @param {string} params.signingScheme The signing algorithm. Must be one of: + * "EcdsaK256Sha256", "EcdsaP256Sha256", "EcdsaP384Sha384", + * "SchnorrEd25519Sha512", "SchnorrK256Sha256", "SchnorrP256Sha256", "SchnorrP384Sha384", + * "SchnorrRistretto25519Sha512", "SchnorrEd448Shake256", "SchnorrRedJubjubBlake2b512", + * "SchnorrK256Taproot", "SchnorrRedDecaf377Blake2b512", "SchnorrkelSubstrate", + * "Bls12381G1ProofOfPossession" + * @param {string} params.actionIpfsCid The IPFS CID of the Lit Action that should have created the signature + * @param {Uint8Array} params.toSign The message that was signed + * @param {string} params.signOutput The signature output from signAsAction (as a string) + * @returns {Promise} true if the signature was created by the specified action, false otherwise + */ + function verifyActionSignature({ + signingScheme, + actionIpfsCid, + toSign, + signOutput, + }: { + signingScheme: string; + actionIpfsCid: string; + toSign: Uint8Array; + signOutput: string; + }): Promise; + /** + * Ask the Lit Node to sign a message using the eth_personalSign algorithm. The resulting signature share will be returned to the Lit JS SDK which will automatically combine the shares and give you the full signature to use. + * @function ethPersonalSignMessageEcdsa + * @param {Object} params + * @param {string} params.message The message to sign. Should be a string. + * @param {string} params.publicKey The public key of the PKP you wish to sign with + * @param {string} params.sigName You can put any string here. This is used to identify the signature in the response by the Lit JS SDK. This is useful if you are signing multiple messages at once. When you get the final signature out, it will be in an object with this signature name as the key. + * @returns {Promise} This function will return the string "success" if it works. The signature share is returned behind the scenes to the Lit JS SDK which will automatically combine the shares and give you the full signature to use. + */ + function ethPersonalSignMessageEcdsa({ + message, + publicKey, + sigName, + }: { + message: string; + publicKey: string; + sigName: string; + }): Promise; + /** + * Checks a condition using the Lit condition checking engine. This is the same engine that powers our Access Control product. You can use this to check any condition that you can express in our condition language. This is a powerful tool that allows you to build complex conditions that can be checked in a decentralized way. Visit https://developer.litprotocol.com and click on the "Access Control" section to learn more. + * @function checkConditions + * @param {Object} params + * @param {Array} params.conditions An array of access control condition objects + * @param {Object} params.authSig The AuthSig to use for the condition check. For example, if you were checking for NFT ownership, this AuthSig would be the signature from the NFT owner's wallet. + * @param {string} params.chain The chain this AuthSig comes from + * @returns {Promise} A boolean indicating whether the condition check passed or failed + */ + function checkConditions({ + conditions, + authSig, + chain, + }: { + conditions: Array; + authSig: any; + chain: string; + }): Promise; + /** + * Set the response returned to the client + * @function setResponse + * @param {Object} params + * @param {string} params.response The response to send to the client. You can put any string here, like you could use JSON.stringify on a JS object and send it here. + */ + function setResponse({ response }: { response: string }): any; + /** + * Call a child Lit Action + * @function call + * @param {Object} params + * @param {string} params.ipfsId The IPFS ID of the Lit Action to call + * @param {Object=} params.params Optional parameters to pass to the child Lit Action + * @returns {Promise} The response from the child Lit Action. Note that any signatures performed by the child Lit Action will be automatically combined and returned with the parent Lit Action to the Lit JS SDK client. + */ + function call({ + ipfsId, + params, + }: { + ipfsId: string; + params?: any | undefined; + }): Promise; + /** + * Call a smart contract + * @function callContract + * @param {Object} params + * @param {string} params.chain The name of the chain to use. Check out the lit docs "Supported Blockchains" page to find the name. For example, "ethereum" + * @param {string} params.txn The RLP Encoded txn, as a hex string + * @returns {Promise} The response from calling the contract + */ + function callContract({ + chain, + txn, + }: { + chain: string; + txn: string; + }): Promise; + /** + * Convert a Uint8Array to a string. This is a re-export of this function: https://www.npmjs.com/package/uint8arrays#tostringarray-encoding--utf8 + * @function uint8arrayToString + * @param {Uint8Array} array The Uint8Array to convert + * @param {string} encoding The encoding to use. Defaults to "utf8" + * @returns {string} The string representation of the Uint8Array + */ + function uint8arrayToString(...args: any[]): string; + /** + * Convert a string to a Uint8Array. This is a re-export of this function: https://www.npmjs.com/package/uint8arrays#fromstringstring-encoding--utf8 + * @function uint8arrayFromString + * @param {string} string The string to convert + * @param {string} encoding The encoding to use. Defaults to "utf8" + * @returns {Uint8Array} The Uint8Array representation of the string + */ + function uint8arrayFromString(...args: any[]): Uint8Array; + /** + * Decrypt data using AES with a symmetric key + * @function aesDecrypt + * @param {Object} params + * @param {Uint8Array} params.symmetricKey The AES symmetric key + * @param {Uint8Array} params.ciphertext The ciphertext to decrypt + * @returns {Promise} The decrypted plaintext + */ + function aesDecrypt({ + symmetricKey, + ciphertext, + }: { + symmetricKey: Uint8Array; + ciphertext: Uint8Array; + }): Promise; + /** + * Claim a key through a key identifier, the result of the claim will be added to `claim_id` + * under the `keyId` given. + * @param {Object} params + * @param {string} params.keyId user id of the claim + */ + function claimKey({ keyId }: { keyId: string }): any; + /** + * Broadcast a message to all connected clients and collect their responses + * @function broadcastAndCollect + * @param {Object} params + * @param {string} params.name The name of the broadcast + * @param {string} params.value The value to broadcast + * @returns {Promise} The collected responses as a json array + */ + function broadcastAndCollect({ + name, + value, + }: { + name: string; + value: string; + }): Promise; + /** + * Decrypt and combine the provided ciphertext + * @function decryptAndCombine + * @param {Object} params + * @param {Array} params.accessControlConditions The access control conditions + * @param {string} params.ciphertext The ciphertext to decrypt + * @param {string} params.dataToEncryptHash The hash of the data to encrypt + * @param {Object} params.authSig The auth signature + * @param {string} params.chain The chain + * @returns {Promise} The decrypted and combined data + */ + function decryptAndCombine({ + accessControlConditions, + ciphertext, + dataToEncryptHash, + authSig, + chain, + }: { + accessControlConditions: Array; + ciphertext: string; + dataToEncryptHash: string; + authSig: any; + chain: string; + }): Promise; + /** + * Decrypt to a single node + * @function decryptToSingleNode + * @param {Object} params + * @param {Array} params.accessControlConditions The access control conditions + * @param {string} params.ciphertext The ciphertext to decrypt + * @param {string} params.dataToEncryptHash The hash of the data to encrypt + * @param {Object} params.authSig The auth signature + * @param {string} params.chain The chain + * @returns {Promise} The decrypted data + */ + function decryptToSingleNode({ + accessControlConditions, + ciphertext, + dataToEncryptHash, + authSig, + chain, + }: { + accessControlConditions: Array; + ciphertext: string; + dataToEncryptHash: string; + authSig: any; + chain: string; + }): Promise; + /** + * Sign with ECDSA and automatically combine signature shares from all nodes into a complete signature + * @function signAndCombineEcdsa + * @param {Object} params + * @param {Uint8Array} params.toSign The message to sign + * @param {string} params.publicKey The public key of the PKP + * @param {string} params.sigName The name of the signature + * @returns {Promise} The resulting combined signature + */ + function signAndCombineEcdsa({ + toSign, + publicKey, + sigName, + }: { + toSign: Uint8Array; + publicKey: string; + sigName: string; + }): Promise; + /** + * Sign with any signing scheme and automatically combine signature shares from all nodes into a complete signature + * @function signAndCombine + * @param {Object} params + * @param {Uint8Array} params.toSign The message to sign + * @param {string} params.publicKey The public key of the PKP + * @param {string} params.sigName The name of the signature + * @param {string} params.signingScheme The signing scheme. Must be one of: + * "EcdsaK256Sha256", "EcdsaP256Sha256", "EcdsaP384Sha384", + * "SchnorrEd25519Sha512", "SchnorrK256Sha256", "SchnorrP256Sha256", "SchnorrP384Sha384", + * "SchnorrRistretto25519Sha512", "SchnorrEd448Shake256", "SchnorrRedJubjubBlake2b512", + * "SchnorrK256Taproot", "SchnorrRedDecaf377Blake2b512", "SchnorrkelSubstrate", + * "Bls12381G1ProofOfPossession" + * @returns {Promise} The resulting combined signature + */ + function signAndCombine({ + toSign, + publicKey, + sigName, + signingScheme, + }: { + toSign: Uint8Array; + publicKey: string; + sigName: string; + signingScheme: string; + }): Promise; + /** + * Run a function only once across all nodes using leader election + * @function runOnce + * @param {Object} params + * @param {boolean} params.waitForResponse Whether to wait for a response or not - if false, the function will return immediately + * @param {string} params.name Optional name for this runOnce invocation + * @param {Function} async_fn The async function to run on the leader node + * @returns {Promise} The response from the function if waitForResponse is true + */ + function runOnce( + { + waitForResponse, + name, + }: { + waitForResponse: boolean; + name: string; + }, + async_fn: Function, + ): Promise; + /** + * Get the RPC URL for a given blockchain + * @function getRpcUrl + * @param {Object} params + * @param {string} params.chain The chain to get the RPC URL for + * @returns {Promise} The RPC URL for the chain + */ + function getRpcUrl({ chain }: { chain: string }): Promise; + /** + * Encrypt data using BLS encryption with access control conditions + * @function encrypt + * @param {Object} params + * @param {Array} params.accessControlConditions The access control conditions that must be met to decrypt + * @param {string} params.to_encrypt The message to encrypt + * @returns {Promise<{ciphertext: string, dataToEncryptHash: string}>} An object containing the ciphertext and the hash of the data that was encrypted + */ + function encrypt({ + accessControlConditions, + to_encrypt, + }: { + accessControlConditions: Array; + to_encrypt: string; + }): Promise<{ + ciphertext: string; + dataToEncryptHash: string; + }>; + } + + export namespace Auth { + /** + * Stack of action IPFS IDs tracking the call hierarchy. + * When a parent action calls a child action, the child's IPFS ID is pushed onto this stack. + * @type {Array} + */ + const actionIpfsIdStack: Array; + + /** + * The address from the authentication signature. + * @type {string | null} + */ + const authSigAddress: string | null; + + /** + * Array of authentication method contexts. + * @type {Array<{ + * userId: string; + * appId: string; + * authMethodType: number; + * lastRetrievedAt: string; + * expiration: number; + * usedForSignSessionKeyRequest: boolean; + * }>} + */ + const authMethodContexts: { + userId: string; + appId: string; + authMethodType: number; + lastRetrievedAt: string; + expiration: number; + usedForSignSessionKeyRequest: boolean; + }[]; + + /** + * Array of resources from the SIWE message or session signature. + * @type {Array} + */ + const resources: Array; + + /** + * Custom authentication resource string. + * @type {string | `"\\(true,${string})\\"`} + */ + const customAuthResource: string | `"\\(true,${string})\\"`; + } +} + +/** + * Global reference to Lit.Actions namespace for convenience. + * This is identical to using Lit.Actions. + */ +declare const LitActions: typeof Lit.Actions; + +/** + * Global reference to Lit.Auth namespace for convenience. + * This is identical to using Lit.Auth. + */ +declare const LitAuth: typeof Lit.Auth; + +/** + * The ethers.js v5 library for interacting with Ethereum and other EVM chains. + * Includes utilities for wallets, contracts, providers, and cryptographic operations. + * See https://docs.ethers.io/v5/ for full documentation. + * + * For full type definitions, install: npm install --save-dev ethers@5 + * Then import types with: import type { ethers } from 'ethers'; + */ +declare const ethers: typeof import("ethers"); + +/** + * The jsonwebtoken library for JWT encoding, decoding, and verification. + * See https://github.com/auth0/node-jsonwebtoken for full documentation. + */ +declare const jwt: { + decode: (token: string, options?: any) => any; + verify: ( + token: string, + secretOrPublicKey: string | Buffer, + options?: any, + ) => any; + sign: ( + payload: string | object | Buffer, + secretOrPrivateKey: string | Buffer, + options?: any, + ) => string; +}; diff --git a/apps/explorer/src/lit-logged-page/LoggedInDashboard.tsx b/apps/explorer/src/lit-logged-page/LoggedInDashboard.tsx new file mode 100644 index 0000000000..937c2b4563 --- /dev/null +++ b/apps/explorer/src/lit-logged-page/LoggedInDashboard.tsx @@ -0,0 +1,759 @@ +import { useState, useEffect, useRef, useMemo } from "react"; +import { useLitAuth } from "../lit-login-modal/LitAuthProvider"; +import { + PKPPermissionsProvider, + PermissionsDashboard, + WalletOperationsDashboard, + PaymentManagementDashboard, + TransactionToastContainer, + BalanceInfo, + TransactionToast, + TransactionResult, + getAllChains, + PKPInfoCard, +} from "./protectedApp/index"; +import { + TopNavBar, + GlobalMessage, + StickySidebarLayout, + type TopNavTab, +} from "@layout"; +import { useLocation, useNavigate } from "react-router-dom"; + +import PKPSelectionModal from "./PKPSelectionModal"; +import { useState as useReactState } from "react"; +import copyIcon from "../assets/copy.svg"; +import { getAddress } from "viem"; +import { formatPublicKey } from "./protectedApp/utils"; +import { PKPData } from "@lit-protocol/schemas"; +import { APP_INFO } from "@/_config"; +import { getDefaultChainForNetwork } from "@/domain/lit/networkDefaults"; + +enum LOGIN_STYLE { + button = "button", + popup = "popup", +} + +const LOGIN_METHOD = LOGIN_STYLE.popup; + +export default function LoggedInDashboard() { + const { + user, + services, + initiateAuthentication, + isInitializingServices, + isServicesReady, + authServiceBaseUrl, + currentNetworkName, + shouldDisplayNetworkMessage, + autoLoginWithDefaultKey, + isAutoLoggingIn, + forceNetworkSelection, + autoLoginStatus, + } = useLitAuth(); + + const hasAutoStartedRef = useRef(false); + const shareAutoLoginTriggeredRef = useRef(false); + const hasInitialBalanceRefetch = useRef(false); + const blockWatcherCleanupRef = useRef void)>(null); + const lastBalanceUpdateAtRef = useRef(0); + + const navigate = useNavigate(); + const location = useLocation(); + const autoLoginRequested = useMemo(() => { + const params = new URLSearchParams(location.search); + const flag = params.get("autoLogin"); + return flag === "1" || flag === "true"; + }, [location.search]); + + useEffect(() => { + if (autoLoginRequested) return; + if ( + LOGIN_METHOD === LOGIN_STYLE.popup && + !user && + !hasAutoStartedRef.current + ) { + hasAutoStartedRef.current = true; + initiateAuthentication(); + } + }, [user, initiateAuthentication, autoLoginRequested]); + + useEffect(() => { + if (!autoLoginRequested) return; + if (user) return; + if (shareAutoLoginTriggeredRef.current) return; + + shareAutoLoginTriggeredRef.current = true; + + autoLoginWithDefaultKey({ forceNetwork: "naga-dev" }) + .then((success) => { + if (!success) { + initiateAuthentication(); + } + }) + .catch(() => { + initiateAuthentication(); + }); + }, [ + autoLoginRequested, + user, + autoLoginWithDefaultKey, + initiateAuthentication, + ]); + + useEffect(() => { + if (!autoLoginRequested) return; + if (!user) return; + if (currentNetworkName === "naga-dev") return; + + forceNetworkSelection("naga-dev").catch((error) => { + console.error("Failed to switch network for share link:", error); + }); + }, [ + autoLoginRequested, + user, + currentNetworkName, + forceNetworkSelection, + ]); + + // Core state + const [showPkpModal, setShowPkpModal] = useState(false); + const [selectedPkp, setSelectedPkp] = useState( + user?.pkpInfo || null + ); + const [balance, setBalance] = useState(null); + const [isLoadingBalance, setIsLoadingBalance] = useState(false); + const [selectedChain, setSelectedChain] = useState("yellowstone"); + const [, setStatus] = useState(""); + + useEffect(() => { + setSelectedChain(getDefaultChainForNetwork(currentNetworkName)); + }, [currentNetworkName]); + + // Transaction toast state + const [transactionToasts, setTransactionToasts] = useState< + TransactionToast[] + >([]); + + // Tab configuration + const tabs: TopNavTab[] = [ + { id: "playground", label: "Playground" }, + { id: "permissions", label: "PKP Permissions" }, + { id: "payment-management", label: "Payment Management" }, + ]; + + // URL-driven tab state + const pathToTab: Record = { + "/playground": "playground", + "/pkp-permissions": "permissions", + "/payment-management": "payment-management", + }; + const tabToPath: Record = { + playground: "/playground", + permissions: "/pkp-permissions", + "payment-management": "/payment-management", + }; + const activeTabId = pathToTab[location.pathname] ?? "playground"; + + // Toast management + const addTransactionToast = ( + message: string, + txHash: string, + type: "success" | "error" = "success" + ) => { + const toast: TransactionToast = { + id: Math.random().toString(36).substr(2, 9), + message, + txHash, + type, + timestamp: Date.now(), + }; + setTransactionToasts((prev) => [...prev, toast]); + + setTimeout(() => { + setTransactionToasts((prev) => prev.filter((t) => t.id !== toast.id)); + }, 8000); + }; + + const removeTransactionToast = (id: string) => { + setTransactionToasts((prev) => prev.filter((t) => t.id !== id)); + }; + + // Handle transaction completion + const handleTransactionComplete = (result: TransactionResult) => { + console.log("Transaction completed:", result); + addTransactionToast("Transaction sent successfully!", result.hash); + + setTimeout(() => { + loadBalance({ silent: true }); + }, 2000); + }; + + // Load balance function + const loadBalance = async (options?: { silent?: boolean }) => { + const silent = options?.silent ?? false; + if (!selectedPkp?.ethAddress || !services?.litClient) return; + + if (!silent) setIsLoadingBalance(true); + try { + const { createPublicClient, http } = await import("viem"); + const allChains = getAllChains(); + const chainInfo = allChains[selectedChain as keyof typeof allChains]; + if (!chainInfo) throw new Error(`Unknown chain: ${selectedChain}`); + + const chainConfig = { + id: chainInfo.id, + name: chainInfo.name, + nativeCurrency: { + name: chainInfo.name, + symbol: chainInfo.symbol, + decimals: 18, + }, + rpcUrls: { + default: { http: [chainInfo.rpcUrl] }, + public: { http: [chainInfo.rpcUrl] }, + }, + }; + + const client = createPublicClient({ + chain: chainConfig, + transport: http(chainInfo.rpcUrl), + }); + + const balance = await client.getBalance({ + address: selectedPkp.ethAddress as `0x${string}`, + }); + + setBalance({ + balance: (Number(balance) / 1e18).toFixed(6), + symbol: chainInfo.symbol, + chainId: chainInfo.id, + }); + } catch (error) { + console.error("Failed to load balance:", error); + setBalance(null); + } finally { + if (!silent) setIsLoadingBalance(false); + } + }; + + // Load balance when PKP or chain changes + useEffect(() => { + if (selectedPkp) { + loadBalance(); + } + }, [selectedPkp, selectedChain]); + + // Ensure balance is (re)fetched after hot refresh when services become ready + useEffect(() => { + if (hasInitialBalanceRefetch.current) return; + if (isServicesReady && selectedPkp) { + hasInitialBalanceRefetch.current = true; + loadBalance(); + } + }, [isServicesReady, selectedPkp]); + + // Live balance updates: refetch on new blocks (polling if ws not available) + useEffect(() => { + // Clean up any previous watcher + if (blockWatcherCleanupRef.current) { + try { + blockWatcherCleanupRef.current(); + } catch { + // ignore + } + blockWatcherCleanupRef.current = null; + } + + // Preconditions + if (!isServicesReady || !selectedPkp?.ethAddress) return; + + let cancelled = false; + (async () => { + try { + const { createPublicClient, http } = await import("viem"); + const allChains = getAllChains(); + const chainInfo = allChains[selectedChain as keyof typeof allChains]; + if (!chainInfo) return; + + const chainConfig = { + id: chainInfo.id, + name: chainInfo.name, + nativeCurrency: { + name: chainInfo.name, + symbol: chainInfo.symbol, + decimals: 18, + }, + rpcUrls: { + default: { http: [chainInfo.rpcUrl] }, + public: { http: [chainInfo.rpcUrl] }, + }, + } as const; + + const client = createPublicClient({ + chain: chainConfig, + transport: http(chainInfo.rpcUrl), + }); + + if (cancelled) return; + + const unwatch = client.watchBlockNumber({ + poll: true, + pollingInterval: 5_000, + emitOnBegin: true, + onBlockNumber: () => { + const now = Date.now(); + // simple debounce to avoid overlapping calls + if (now - lastBalanceUpdateAtRef.current < 2_500) return; + lastBalanceUpdateAtRef.current = now; + loadBalance({ silent: true }); + }, + onError: (err) => { + console.error("Block watch error:", err); + }, + }); + blockWatcherCleanupRef.current = unwatch; + } catch (err) { + console.error("Failed to start block watcher:", err); + } + })(); + + return () => { + cancelled = true; + if (blockWatcherCleanupRef.current) { + try { + blockWatcherCleanupRef.current(); + } catch { + // ignore + } + blockWatcherCleanupRef.current = null; + } + }; + }, [isServicesReady, selectedPkp?.ethAddress, selectedChain]); + + // Sync selectedPkp with user.pkpInfo + useEffect(() => { + if (user?.pkpInfo) { + const mappedPkp = { + tokenId: user.pkpInfo.tokenId || "unknown", + pubkey: user.pkpInfo.pubkey || "", + ethAddress: user.pkpInfo.ethAddress || "", + }; + setSelectedPkp(mappedPkp as PKPData); + } else { + setSelectedPkp(null); + } + }, [user?.pkpInfo]); + + // PKP selection handler + const handlePkpSelected = (pkpInfo: PKPData) => { + console.log("PKP selected:", pkpInfo); + setSelectedPkp(pkpInfo); + setStatus(`Selected PKP: ${pkpInfo.ethAddress}`); + }; + + // Authentication and loading states + if (!user) { + if (autoLoginRequested) { + return ( +
+
+

Loading shared playground…

+

+ {autoLoginStatus || + (isAutoLoggingIn + ? "Automatically signing you in with the development wallet…" + : "Preparing your session…")} +

+
+ ); + } + + if (LOGIN_METHOD === LOGIN_STYLE.popup) { + return ( +
+

Starting sign-in

+

Launching the authentication popup…

+
+ ); + } + return ( +
+

Not authenticated

+

Please sign in to continue.

+ +
+ ); + } + + if (user && !isServicesReady) { + return ( +
+
+

Initialising Lit Protocol Services

+

+ {isInitializingServices + ? "Setting up your authentication context..." + : "Loading your PKP wallet..."} +

+
+ ); + } + + return ( + + navigate(tabToPath[id] ?? "/playground")} + rightSlot={ +
+ + Network: + {currentNetworkName} + + setShowPkpModal(true)} + /> +
+ } + /> + +
+ + + + setShowPkpModal(true)} + userMethod={user.method} + selectedChain={selectedChain} + onChainChange={setSelectedChain} + /> + +
Resources
+ + + } + > + {activeTabId === "playground" && ( + + )} + + {activeTabId === "permissions" && } + + {activeTabId === "payment-management" && ( + + )} +
+ + {/* Status Display */} + {/* setStatus("")} /> */} + + {/* Tab Navigation moved to top nav bar */} + + {/* Tab Content moved inside DashboardContent main area */} + + {/* Transaction Toast Notifications */} + + + {/* PKP Selection Modal */} + {services && ( + setShowPkpModal(false)} + authData={user.authData} + authMethodName={user.method} + services={services} + disabled={false} + authServiceBaseUrl={authServiceBaseUrl} + onPkpSelected={(pkpInfo) => { + handlePkpSelected(pkpInfo); + setShowPkpModal(false); + }} + /> + )} + + {/* Tailwind handles animations; no inline keyframes needed */} +
+ ); +} + +function AccountMenu({ + selectedChain, + onChainChange, + onShowPkpModal, +}: { + selectedChain: string; + onChainChange: (chain: string) => void; + onShowPkpModal: () => void; +}) { + const { user, logout } = useLitAuth(); + const [open, setOpen] = useReactState(false); + const [copiedField, setCopiedField] = useReactState(null); + if (!user) return null; + const pkp = user.pkpInfo || {}; + const publicKey: string = pkp.pubkey || ""; + const ethAddress: string = pkp.ethAddress || ""; + + const handleCopy = async (value: string, field: string) => { + try { + await navigator.clipboard.writeText(value); + setCopiedField(field); + setTimeout(() => setCopiedField(null), 1500); + } catch {} + }; + return ( +
+ + {open && ( +
+
PKP
+
+ {Boolean( + pkp.tokenId && + typeof pkp.tokenId === "bigint" && + pkp.tokenId.toString() !== "0n" + ) && ( +
+
+ Token ID +
+ +
+ )} + {publicKey && typeof publicKey === "string" && publicKey !== "" && ( +
+
+ Public Key +
+ +
+ )} + {ethAddress && + typeof ethAddress === "string" && + ethAddress !== "" && ( +
+
+ ETH +
+ +
+ )} +
+
+
+ Actions +
+
+
+
+ Chain +
+ +
+ +
+
+ +
+ )} +
+ ); +} diff --git a/apps/explorer/src/lit-logged-page/PKPSelectionModal.tsx b/apps/explorer/src/lit-logged-page/PKPSelectionModal.tsx new file mode 100644 index 0000000000..8ad6cad8a2 --- /dev/null +++ b/apps/explorer/src/lit-logged-page/PKPSelectionModal.tsx @@ -0,0 +1,82 @@ +import React from "react"; +import PKPSelectionSection from "../lit-login-modal/PKPSelectionSection"; +import { PKPData } from "@lit-protocol/schemas"; +import { LitServices } from "@/hooks/useLitServiceSetup"; + +/** + * PKPSelectionModal + * + * A reusable modal component that displays the PKP selection flow. + * + * Usage: + * void} + * authData={any} + * authMethodName={string} + * services={{ litClient: any; authManager: any } | null} + * disabled={boolean} + * authServiceBaseUrl={string} + * onPkpSelected={(pkpInfo: PkpInfo) => void} + * /> + */ + +// Configurable constants +const MODAL_Z_INDEX = 1000; + +export interface PKPSelectionModalProps { + isOpen: boolean; + onClose: () => void; + authData: any; + authMethodName: string; + services: LitServices; + disabled?: boolean; + authServiceBaseUrl: string; + onPkpSelected: (pkpInfo: PKPData) => void; +} + +const PKPSelectionModal: React.FC = ({ + isOpen, + onClose, + authData, + authMethodName, + services, + disabled = false, + authServiceBaseUrl, + onPkpSelected, +}) => { + if (!isOpen) return null; + + return ( +
{ + if (e.target === e.currentTarget) { + onClose(); + } + }} + > +
+
+ +
+ + +
+
+ ); +}; + +export default PKPSelectionModal; diff --git a/apps/explorer/src/lit-logged-page/protectedApp/components/PaymentManagement/AccountMethodSelector.tsx b/apps/explorer/src/lit-logged-page/protectedApp/components/PaymentManagement/AccountMethodSelector.tsx new file mode 100644 index 0000000000..a837cbe104 --- /dev/null +++ b/apps/explorer/src/lit-logged-page/protectedApp/components/PaymentManagement/AccountMethodSelector.tsx @@ -0,0 +1,320 @@ +/** + * AccountMethodSelector.tsx + * + * A reusable component for selecting and creating accounts using either: + * - Private key (viem account) + * - Connected wallet (wallet client) + * + * Default method is connected wallet for better UX. + */ + +import { ConnectButton } from "@rainbow-me/rainbowkit"; +import { useState } from "react"; +import { privateKeyToAccount } from "viem/accounts"; +import { useWalletClient } from "wagmi"; +import { APP_INFO, FEATURES } from "../../../../_config"; + +// Code snippets for documentation +export const CREATE_ACCOUNT_PRIVATE_KEY_CODE = ` +import { privateKeyToAccount } from 'viem/accounts'; + +const myAccount = privateKeyToAccount( + process.env.PRIVATE_KEY as \`0x\${string}\` +);`; + +export const CREATE_ACCOUNT_WALLET_CLIENT_CODE = ` +import { useWalletClient } from 'wagmi'; + +// Use your connected wallet as the account +const { data: myAccount } = useWalletClient();`; + +interface AccountMethodSelectorProps { + onAccountCreated: (account: any) => void; + onMethodChange: (method: "privateKey" | "walletClient") => void; + setStatus: (status: string) => void; + showError?: (error: string) => void; + showSuccess?: (actionId: string) => void; + disabled?: boolean; + successActionIds?: { + createAccount?: string; + getWalletAccount?: string; + }; + successActions?: Set; +} + +export default function AccountMethodSelector({ + onAccountCreated, + onMethodChange, + setStatus, + showError, + showSuccess, + disabled = false, + successActionIds = { + createAccount: "create-account", + getWalletAccount: "get-wallet-account", + }, + successActions = new Set(), +}: AccountMethodSelectorProps) { + const { data: walletClient } = useWalletClient(); + + const [isCreatingAccount, setIsCreatingAccount] = useState(false); + const [accountMethod, setAccountMethod] = useState< + "privateKey" | "walletClient" + >("walletClient"); // Default to wallet client + const [privateKey, setPrivateKey] = useState(APP_INFO.defaultPrivateKey); + + // Utility function to format error messages properly + const formatErrorMessage = (prefix: string, error: any): string => { + let errorMessage = prefix; + if (error?.message) { + errorMessage += error.message; + } else if (typeof error === "object") { + errorMessage += JSON.stringify(error, null, 2); + } else { + errorMessage += String(error); + } + return errorMessage; + }; + + const createAccountFromPrivateKey = async () => { + try { + setIsCreatingAccount(true); + setStatus("Creating viem account from private key..."); + + if (!privateKey.startsWith("0x") || privateKey.length !== 66) { + throw new Error( + "Invalid private key format. Must be a hex string starting with 0x and 66 characters long." + ); + } + + const myAccount = privateKeyToAccount(privateKey as `0x${string}`); + onAccountCreated(myAccount); + setStatus(`Successfully created account: ${myAccount.address}`); + showSuccess?.(successActionIds.createAccount!); + } catch (error: any) { + console.error("Error creating account:", error); + const errorMessage = formatErrorMessage( + "Failed to create account: ", + error + ); + setStatus(errorMessage); + showError?.(errorMessage); + } finally { + setIsCreatingAccount(false); + } + }; + + const createAccountFromWalletClient = async () => { + try { + setIsCreatingAccount(true); + setStatus("Getting account from connected wallet..."); + + if (!walletClient || !walletClient.account) { + throw new Error( + "No wallet connected. Please connect your wallet first." + ); + } + + onAccountCreated(walletClient); + setStatus( + `Successfully got account from wallet: ${walletClient.account.address}` + ); + showSuccess?.(successActionIds.getWalletAccount!); + } catch (error: any) { + console.error("Error getting wallet account:", error); + const errorMessage = formatErrorMessage( + "Failed to get wallet account: ", + error + ); + setStatus(errorMessage); + showError?.(errorMessage); + } finally { + setIsCreatingAccount(false); + } + }; + + const createAccount = async () => { + if (accountMethod === "privateKey") { + return createAccountFromPrivateKey(); + } else { + return createAccountFromWalletClient(); + } + }; + + const handleMethodChange = (method: "privateKey" | "walletClient") => { + setAccountMethod(method); + onMethodChange(method); + }; + const successKey = + accountMethod === "privateKey" + ? successActionIds.createAccount + : successActionIds.getWalletAccount; + const hasCompletedAction = successKey + ? Boolean(successActions?.has(successKey)) + : false; + + return ( +
+ {/* Account Method Selector */} +
+ +
+ + +
+
+ + {/* Private Key Input (only show when private key method is selected) */} + {accountMethod === "privateKey" && ( +
+ + setPrivateKey(e.target.value)} + placeholder="0x..." + disabled={disabled} + className="placeholder-black/70 text-black" + style={{ + width: "100%", + padding: "8px 12px", + border: "1px solid #ddd", + borderRadius: "4px", + fontFamily: "monospace", + fontSize: "14px", + opacity: disabled ? 0.6 : 1, + color: "#000", + }} + /> + + Default test private key is provided. Replace with your own for + production use. + +
+ )} + + {/* Wallet Client Info (only show when wallet client method is selected) */} + {accountMethod === "walletClient" && ( +
+

+ Using Connected Wallet:{" "} + This will use your currently connected wallet account (e.g., + MetaMask). +

+

+ Make sure your wallet is connected and you have test tokens. Need + tokens? Visit the{" "} + + Chronicle Yellowstone Faucet + +

+ +
+

+
+ )} + + + {hasCompletedAction && ( +

+ Account linked successfully. +

+ )} +
+ ); +} + +// Export the account method type for consumers +export type AccountMethod = "privateKey" | "walletClient"; diff --git a/apps/explorer/src/lit-logged-page/protectedApp/components/PaymentManagement/PaymentManagementDashboard.tsx b/apps/explorer/src/lit-logged-page/protectedApp/components/PaymentManagement/PaymentManagementDashboard.tsx new file mode 100644 index 0000000000..385a1e592b --- /dev/null +++ b/apps/explorer/src/lit-logged-page/protectedApp/components/PaymentManagement/PaymentManagementDashboard.tsx @@ -0,0 +1,1047 @@ +import React, { useState, useEffect } from "react"; +import { UIPKP, TransactionResult, LedgerBalanceInfo } from "../../types"; +import AccountMethodSelector from "./AccountMethodSelector"; +import { useOptionalLitAuth } from "../../../../lit-login-modal/LitAuthProvider"; +import { triggerLedgerRefresh } from "../../utils/ledgerRefresh"; +import { usePaymentManagerInstance } from "../../hooks/usePaymentManagerInstance"; +import { useLedgerBalance } from "../../hooks/useLedgerBalance"; +import { useWithdrawStatus } from "../../hooks/useWithdrawStatus"; +import { getAllChains } from "@/domain/lit/chains"; + +interface PaymentManagementDashboardProps { + selectedPkp: UIPKP | null; + selectedChain: string; + disabled?: boolean; + onTransactionComplete?: (result: TransactionResult) => void; + services?: any; + initialSource?: "pkp" | "eoa"; + presetRecipientAddress?: string; + onBalanceChange?: (balance: LedgerBalanceInfo | null) => void; + // When true, disables using PKP as the account source (EOA-only) + disablePkpOption?: boolean; + // Show only deposit-for-PKP and show balance for the PKP address + fundPkOnly?: boolean; + // Override user address for balance and deposit-for-user (use PKP address) + targetUserAddress?: string; + // Hide the account selection section and show as streamlined steps + hideAccountSelection?: boolean; +} + +export const PaymentManagementDashboard: React.FC< + PaymentManagementDashboardProps +> = ({ + selectedPkp, + selectedChain, + disabled = false, + onTransactionComplete, + services, + initialSource, + presetRecipientAddress, + onBalanceChange, + disablePkpOption = false, + fundPkOnly = false, + targetUserAddress, + hideAccountSelection = false, +}) => { + // const { data: walletClient } = useWalletClient(); + const optionalAuth = useOptionalLitAuth(); + const user = optionalAuth?.user; + const litServices = optionalAuth?.services; + const currentNetworkName = (optionalAuth as any)?.currentNetworkName as + | string + | undefined; + + // Determine the correct unit for Lit Ledger balance + const isTestnet = currentNetworkName === "naga-dev" || currentNetworkName === "naga-test"; + const ledgerUnit = isTestnet ? "tstLPX" : "LITKEY"; + + // Account state + const [account, setAccount] = useState(null); + const [accountSource, setAccountSource] = useState<"pkp" | "eoa">( + initialSource || "pkp" + ); + + // Step/tab state for hideAccountSelection mode + const [currentStep, setCurrentStep] = useState<1 | 2>(1); + const [fundingSuccess, setFundingSuccess] = useState(false); + const [fundingTxHash, setFundingTxHash] = useState(""); + + // Enforce EOA-only when PKP option is disabled + useEffect(() => { + if (disablePkpOption && accountSource === "pkp") { + setAccountSource("eoa"); + } + }, [disablePkpOption, accountSource]); + + // Reset funding success state when target address changes (new PKP selected) + useEffect(() => { + if (hideAccountSelection && targetUserAddress) { + setFundingSuccess(false); + setFundingTxHash(""); + setCurrentStep(1); + } + }, [hideAccountSelection, targetUserAddress]); + + // Balance preferences + const [autoRefreshBalance, setAutoRefreshBalance] = useState(true); + + // Deposit state + const [depositAmount, setDepositAmount] = useState(""); + const [isDepositing, setIsDepositing] = useState(false); + const [depositForUserAddress, setDepositForUserAddress] = useState(""); + const [depositForUserAmount, setDepositForUserAmount] = useState(""); + const [isDepositingForUser, setIsDepositingForUser] = useState(false); + + // Withdrawal state + const [withdrawAmount, setWithdrawAmount] = useState(""); + const [isRequestingWithdraw, setIsRequestingWithdraw] = useState(false); + const [isExecutingWithdraw, setIsExecutingWithdraw] = useState(false); + + // Success feedback + const [successActions, setSuccessActions] = useState>(new Set()); + const [error, setError] = useState(""); + const resolvedAccountAddress = + targetUserAddress || account?.address || account?.account?.address; + const allChains = getAllChains(); + const selectedChainInfo = selectedChain + ? allChains[selectedChain as keyof typeof allChains] + : undefined; + const activeChainLabel = + selectedChainInfo?.name || + (selectedChain ? selectedChain.replace(/-/g, " ") : "unknown"); + + // Success feedback helper + const showSuccess = (actionId: string) => { + setSuccessActions((prev) => new Set([...prev, actionId])); + setTimeout(() => { + setSuccessActions((prev) => { + const newSet = new Set(prev); + newSet.delete(actionId); + return newSet; + }); + }, 3000); + }; + + // Error handling + const showError = (message: string) => { + setError(message); + setTimeout(() => setError(""), 5000); + }; + + const clearError = () => setError(""); + + const { + paymentManager, + withdrawDelay, + isInitializingPaymentManager, + } = usePaymentManagerInstance({ + account, + services: services || litServices || null, + onBeforeInit: clearError, + onError: showError, + }); + + const { + balanceInfo, + isLoadingBalance, + loadBalance: refreshLedgerBalance, + } = useLedgerBalance({ + paymentManager, + userAddress: resolvedAccountAddress, + autoRefresh: autoRefreshBalance, + onBalanceChange, + onError: showError, + }); + + const { + withdrawInfo, + canExecuteInfo, + isCheckingWithdraw, + loadWithdrawalStatus, + setWithdrawInfo, + setCanExecuteInfo, + } = useWithdrawStatus({ + paymentManager, + userAddress: resolvedAccountAddress, + onError: showError, + }); + + // Create a PKP viem account when PKP is selected as the source + useEffect(() => { + const hasAuthContext = Boolean(user?.authContext); + const pkpPublicKey = selectedPkp?.pubkey || user?.pkpInfo?.pubkey; + const targetServices = services || litServices; + const canUsePkp = Boolean( + targetServices?.litClient && hasAuthContext && pkpPublicKey + ); + + if (accountSource !== "pkp" || disablePkpOption) { + return; + } + + // Reset current account when switching to PKP + setAccount(null); + + if (!canUsePkp) { + return; + } + + let cancelled = false; + const derivePkpAccount = async () => { + try { + clearError(); + const chainConfig = + targetServices!.litClient.getChainConfig().viemConfig; + const pkpViemAccount = + await targetServices!.litClient.getPkpViemAccount({ + pkpPublicKey, + authContext: user!.authContext, + chainConfig, + }); + if (!cancelled) { + setAccount(pkpViemAccount); + } + } catch (e: any) { + console.error("Failed to create PKP viem account:", e); + if (!cancelled) { + showError(`Failed to create PKP viem account: ${e?.message || e}`); + setAccount(null); + } + } + }; + derivePkpAccount(); + return () => { + cancelled = true; + }; + }, [accountSource, services, litServices, user, selectedPkp]); + + useEffect(() => { + if (presetRecipientAddress) { + setDepositForUserAddress(presetRecipientAddress); + } + }, [presetRecipientAddress]); + + // Format time remaining + const formatTimeRemaining = (seconds: string) => { + const secs = parseInt(seconds); + const hours = Math.floor(secs / 3600); + const minutes = Math.floor((secs % 3600) / 60); + const remainingSeconds = secs % 60; + + if (hours > 0) { + return `${hours}h ${minutes}m ${remainingSeconds}s`; + } else if (minutes > 0) { + return `${minutes}m ${remainingSeconds}s`; + } else { + return `${remainingSeconds}s`; + } + }; + + // Deposit handlers + const handleDeposit = async () => { + if (!paymentManager || !depositAmount) return; + + try { + setIsDepositing(true); + clearError(); + + const result = await paymentManager.deposit({ + amountInEth: depositAmount, + }); + showSuccess("deposit"); + onTransactionComplete?.(result); + setDepositAmount(""); + + // Refresh balance after deposit + setTimeout(refreshLedgerBalance, 2000); + try { + if (resolvedAccountAddress) { + triggerLedgerRefresh(resolvedAccountAddress); + } + } catch {} + } catch (error: any) { + console.error("Deposit failed:", error); + showError(`Deposit failed: ${error.message}`); + } finally { + setIsDepositing(false); + } + }; + + const handleDepositForUser = async () => { + if (!paymentManager || !depositForUserAmount || !depositForUserAddress) + return; + + try { + setIsDepositingForUser(true); + clearError(); + + const result = await paymentManager.depositForUser({ + userAddress: depositForUserAddress, + amountInEth: depositForUserAmount, + }); + showSuccess("deposit-for-user"); + onTransactionComplete?.(result); + setDepositForUserAmount(""); + + // Show success message in hideAccountSelection mode + if (hideAccountSelection) { + setFundingSuccess(true); + // Extract transaction hash from result + const txHash = result?.transactionHash || result?.hash || ""; + setFundingTxHash(txHash); + } + setDepositForUserAddress(""); + try { + const addr = targetUserAddress || depositForUserAddress; + if (addr) triggerLedgerRefresh(addr); + } catch {} + } catch (error: any) { + console.error("Deposit for user failed:", error); + showError(`Deposit for user failed: ${error.message}`); + } finally { + setIsDepositingForUser(false); + } + }; + + // Withdrawal handlers + const handleRequestWithdraw = async () => { + if (!paymentManager || !withdrawAmount) return; + + try { + setIsRequestingWithdraw(true); + clearError(); + + const result = await paymentManager.requestWithdraw({ + amountInEth: withdrawAmount, + }); + showSuccess("request-withdraw"); + onTransactionComplete?.(result); + setWithdrawAmount(""); + + // Refresh withdrawal status + setTimeout(loadWithdrawalStatus, 2000); + try { + if (resolvedAccountAddress) { + triggerLedgerRefresh(resolvedAccountAddress); + } + } catch {} + } catch (error: any) { + console.error("Withdrawal request failed:", error); + showError(`Withdrawal request failed: ${error.message}`); + } finally { + setIsRequestingWithdraw(false); + } + }; + + const handleExecuteWithdraw = async () => { + if (!paymentManager || !withdrawInfo) return; + + try { + setIsExecutingWithdraw(true); + clearError(); + + const result = await paymentManager.withdraw({ + amountInEth: withdrawInfo.amount, + }); + showSuccess("execute-withdraw"); + onTransactionComplete?.(result); + + // Clear withdrawal info and refresh balance + setWithdrawInfo(null); + setCanExecuteInfo(null); + setTimeout(refreshLedgerBalance, 2000); + try { + if (resolvedAccountAddress) { + triggerLedgerRefresh(resolvedAccountAddress); + } + } catch {} + } catch (error: any) { + console.error("Withdrawal execution failed:", error); + showError(`Withdrawal execution failed: ${error.message}`); + } finally { + setIsExecutingWithdraw(false); + } + }; + + // Quick amount buttons + const quickAmounts = ["0.001", "0.01", "0.1", "1.0"]; + + return ( + <> + {/* Error Display */} + {error && ( +
+ ⚠️ {error} +
+ )} +
+ Active chain: {activeChainLabel} +
+ + {/* Account Setup - Conditional rendering based on hideAccountSelection */} + {!hideAccountSelection ? ( +
+

Select a Payment Manager Account

+ + {/* Account source selector: PKP (default) or EOA */} +
+ {!disablePkpOption && ( + + )} + +
+ + {/* EOA account manual selection */} + {accountSource === "eoa" && ( + {}} + setStatus={() => {}} + showError={showError} + showSuccess={() => {}} + successActionIds={{ + createAccount: "pm-create-account", + getWalletAccount: "pm-get-wallet-account", + }} + successActions={successActions} + disabled={disabled} + /> + )} + + {account && ( +
+
+ Connected Account:{" "} + {account.address || account.account?.address} +
+
+ PaymentManager:{" "} + {paymentManager + ? "✅ Ready" + : isInitializingPaymentManager + ? "⏳ Loading..." + : "❌ Failed to load"} +
+
+ )} +
+ ) : ( + /* Streamlined tab-based flow for funding modal */ +
+ {/* Tab Navigation */} +
+ + +
+ + {/* Step 1: Choose Account Method */} + {currentStep === 1 && ( +
+

+ Choose Account Method +

+

+ Select how you want to fund the Lit Ledger for this PKP +

+ { + setAccount(acc); + }} + onMethodChange={() => {}} + setStatus={() => {}} + showError={showError} + showSuccess={() => {}} + successActionIds={{ + createAccount: "pm-create-account", + getWalletAccount: "pm-get-wallet-account", + }} + successActions={successActions} + disabled={disabled} + /> + {account && ( +
+
+ Connected Account:{" "} + {account.address || account.account?.address} +
+
+ PaymentManager:{" "} + {paymentManager + ? "✅ Ready" + : isInitializingPaymentManager + ? "⏳ Loading..." + : "❌ Failed to load"} +
+
+ )} + {account && paymentManager && ( +
+ +
+ )} +
+ )} + + {/* Step 2: Deposit for PKP */} + {currentStep === 2 && account && paymentManager && ( +
+ {fundingSuccess ? ( + /* Success Message */ +
+
+ + + +
+

+ ✅ Funding Successful! +

+

+ Your PKP's Lit Ledger has been funded successfully. You can now close this modal and log in to your PKP account. +

+ + {/* Transaction Hash */} + {fundingTxHash && ( +
+
Transaction Hash
+
+ + {fundingTxHash.slice(0, 10)}...{fundingTxHash.slice(-8)} + + + View on Explorer + + + + +
+
+ )} + +
+ +
+
+ ) : ( + /* Deposit Form */ + <> +

+ Deposit for PKP +

+

+ Enter the amount you want to deposit to the PKP's Lit Ledger +

+
+ + setDepositForUserAddress(e.target.value)} + disabled + className="w-full px-3 py-2 rounded-lg text-sm border bg-gray-50 border-gray-300 text-black" + /> +
+
+ + setDepositForUserAmount(e.target.value)} + className="w-full px-3 py-2 rounded-lg text-sm border bg-white border-gray-300 text-black" + /> +

+ Need test tokens? Visit the{" "} + + Chronicle Yellowstone Faucet + +

+
+ + + )} +
+ )} +
+ )} + + {/* Balance Section */} + {paymentManager && !hideAccountSelection && ( +
+
+

PKP Lit Ledger Balance

+
+ + +
+
+ + {balanceInfo ? ( +
+
+
+ Total Balance +
+
+ {balanceInfo.totalBalance} {ledgerUnit} +
+
+ {balanceInfo.raw.totalBalance.toString()} Wei +
+
+
+
+ Available Balance +
+
+ {balanceInfo.availableBalance} {ledgerUnit} +
+
+ {balanceInfo.raw.availableBalance.toString()} Wei +
+
+
+ ) : ( +
+ Click refresh to load your balance +
+ )} +
+ )} + + {/* Operations Grid */} + {paymentManager && !fundPkOnly && !hideAccountSelection && ( +
+ {/* Deposit Section */} +
+

💰 Deposit Funds

+ + {/* Quick amounts */} +
+
Quick amounts:
+
+ {quickAmounts.map((amount) => ( + + ))} +
+
+ +
+ setDepositAmount(e.target.value)} + disabled={!account} + className={`w-full px-3 py-2 rounded-lg text-sm border ${ + !account ? "bg-gray-50" : "bg-white" + } border-gray-300 text-black`} + /> +
+ + + + {/* Deposit for others */} +
+

+ 👥 Deposit for Others +

+ +
+ setDepositForUserAddress(e.target.value)} + disabled={!account} + className={`w-full px-3 py-2 rounded-lg text-sm border ${ + !account ? "bg-gray-50" : "bg-white" + } border-gray-300 text-black`} + /> +
+ +
+ setDepositForUserAmount(e.target.value)} + disabled={!account} + className={`w-full px-3 py-2 rounded-lg text-sm border ${ + !account ? "bg-gray-50" : "bg-white" + } border-gray-300 text-black`} + /> +
+ + +
+
+ + {/* Withdrawal Section */} +
+

🔄 Withdraw Funds

+ + {withdrawDelay && ( +
+ Security Delay: {withdrawDelay.delayHours} hours + ({withdrawDelay.delaySeconds} seconds) +
+ )} + + {/* Withdrawal Status */} + {withdrawInfo && withdrawInfo.isPending && ( +
+
+ ⏳ Pending Withdrawal +
+
+ Amount: {withdrawInfo.amount} ETH +
+
+ Requested:{" "} + {new Date( + Number(withdrawInfo.timestamp) * 1000 + ).toLocaleString()} +
+ {canExecuteInfo && ( +
+ {canExecuteInfo.canExecute ? ( + ✅ Ready to execute! + ) : ( + + ⏱️ Time remaining:{" "} + {formatTimeRemaining(canExecuteInfo.timeRemaining)} + + )} +
+ )} +
+ )} + + {/* Request Withdrawal */} + {(!withdrawInfo || !withdrawInfo.isPending) && ( + <> +
+ setWithdrawAmount(e.target.value)} + disabled={!account} + className={`w-full px-3 py-2 rounded-lg text-sm border ${ + !account ? "bg-gray-50" : "bg-white" + } border-gray-300 text-black`} + /> +
+ + + + )} + + {/* Execute Withdrawal */} + {withdrawInfo && + withdrawInfo.isPending && + canExecuteInfo?.canExecute && ( + + )} + + {/* Refresh Status Button */} + +
+
+ )} + + {/* Fund-PK-only simplified Deposit for PKP */} + {paymentManager && fundPkOnly && !hideAccountSelection && ( +
+

💰 Deposit for PKP

+
+ setDepositForUserAddress(e.target.value)} + disabled + className={`w-full px-3 py-2 rounded-lg text-sm border bg-gray-50 border-gray-300 text-black`} + /> +
+
+ setDepositForUserAmount(e.target.value)} + className={`w-full px-3 py-2 rounded-lg text-sm border bg-white border-gray-300 text-black`} + /> +
+ +
+ )} + + {/* No PaymentManager state */} + {!paymentManager && account && !isInitializingPaymentManager && ( +
+

+ ⚠️ PaymentManager Not Available +

+

+ Unable to initialize PaymentManager. Please check your account setup + and try again. +

+
+ )} + + {/* No Account state */} + {!account && ( +
+

🔐 Account Required

+

+ Please create or connect an account above to access PaymentManager + features. +

+
+ )} + + ); +}; diff --git a/apps/explorer/src/lit-logged-page/protectedApp/components/dashboard/StatusDisplay.tsx b/apps/explorer/src/lit-logged-page/protectedApp/components/dashboard/StatusDisplay.tsx new file mode 100644 index 0000000000..d1ab159d18 --- /dev/null +++ b/apps/explorer/src/lit-logged-page/protectedApp/components/dashboard/StatusDisplay.tsx @@ -0,0 +1,107 @@ +/** + * StatusDisplay Component + * + * Reusable status message display with transaction links + */ + +import React from 'react'; + +interface StatusDisplayProps { + status: string; + onDismiss: () => void; +} + +export const StatusDisplay: React.FC = ({ + status, + onDismiss, +}) => { + if (!status) return null; + + return ( +
+
+ {status.includes("Transaction:") ? ( + + ) : ( + status + )} +
+ +
+ ); +}; \ No newline at end of file diff --git a/apps/explorer/src/lit-logged-page/protectedApp/components/dashboard/index.ts b/apps/explorer/src/lit-logged-page/protectedApp/components/dashboard/index.ts new file mode 100644 index 0000000000..cc3e0ec428 --- /dev/null +++ b/apps/explorer/src/lit-logged-page/protectedApp/components/dashboard/index.ts @@ -0,0 +1,7 @@ +/** + * Dashboard Components Index + * + * Centralized exports for all dashboard orchestration components + */ + +export { StatusDisplay } from './StatusDisplay'; \ No newline at end of file diff --git a/apps/explorer/src/lit-logged-page/protectedApp/components/layout/ChainSelector.tsx b/apps/explorer/src/lit-logged-page/protectedApp/components/layout/ChainSelector.tsx new file mode 100644 index 0000000000..b5e4c4fdee --- /dev/null +++ b/apps/explorer/src/lit-logged-page/protectedApp/components/layout/ChainSelector.tsx @@ -0,0 +1,404 @@ +/** + * ChainSelector Component + * + * Purpose: + * - Render a selector for blockchain networks + * - Support both default and user-defined custom chains + * + * Usage: + * - Provide `selectedChain` as the chain slug + * - Handle `onChainChange(slug)` to persist selection upstream + * + * Notes: + * - Custom chains are stored locally via the registry utilities + */ + +import React from "react"; +import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; +import { ChevronDown } from "lucide-react"; +import { + getAllChains, + isCustomChain, + addCustomChain, + removeCustomChain, + getCustomChains, +} from "@/domain/lit/chains"; + +interface ChainSelectorProps { + selectedChain: string; + onChainChange: (chain: string) => void; + disabled?: boolean; + iconTrigger?: boolean; + triggerAriaLabel?: string; +} + +export const ChainSelector: React.FC = ({ + selectedChain, + onChainChange, + disabled = false, + iconTrigger = false, + triggerAriaLabel = 'Select chain', +}) => { + const [chains, setChains] = React.useState>({}); + const [isAddOpen, setIsAddOpen] = React.useState(false); + const [addForm, setAddForm] = React.useState({ + slug: "", + id: "", + name: "", + symbol: "", + rpcUrl: "", + explorerUrl: "", + testnet: false, + }); + const [error, setError] = React.useState(null); + + const refreshChains = React.useCallback(() => { + const all = getAllChains(); + // Map to minimal view for rendering + const minimal: Record = {}; + Object.entries(all).forEach(([k, v]) => { + minimal[k] = { name: v.name, symbol: v.symbol, testnet: v.testnet }; + }); + setChains(minimal); + }, []); + + React.useEffect(() => { + refreshChains(); + }, [refreshChains]); + + React.useEffect(() => { + const onStorage = (e: StorageEvent) => { + if (e.key && e.key.includes('chains.custom.v1')) { + refreshChains(); + } + }; + window.addEventListener('storage', onStorage); + return () => window.removeEventListener('storage', onStorage); + }, [refreshChains]); + + function handleSubmitAdd(e: React.FormEvent) { + e.preventDefault(); + setError(null); + const idNum = Number(addForm.id); + const result = addCustomChain(addForm.slug.trim(), { + id: Number.isFinite(idNum) ? idNum : -1, + name: addForm.name.trim(), + symbol: addForm.symbol.trim(), + rpcUrl: addForm.rpcUrl.trim(), + explorerUrl: addForm.explorerUrl.trim(), + litIdentifier: addForm.slug.trim(), + testnet: Boolean(addForm.testnet), + } as any); + if (!result.ok) { + setError(result.error); + return; + } + setIsAddOpen(false); + setAddForm({ slug: "", id: "", name: "", symbol: "", rpcUrl: "", explorerUrl: "", testnet: false }); + refreshChains(); + } + + function handleRemove(slug: string) { + removeCustomChain(slug); + if (selectedChain === slug) { + // If the currently selected chain was removed, keep the selection unchanged upstream. + // The parent may choose to override. We only refresh the list here. + } + refreshChains(); + } + + return ( + + + {iconTrigger ? ( + + ) : ( + + )} + + + + {!isAddOpen && ( + <> + onChainChange(value)} + > + {Object.entries(chains).map(([key, chain]) => { + const custom = isCustomChain(key); + return ( + + + {`${chain.name} (${chain.symbol})${chain.testnet ? " - Testnet" : ""}`} + + {custom && ( + + Custom + + )} + + ); + })} + + +
+
+ + )} + + {!isAddOpen ? ( +
+ { + e.preventDefault(); + setIsAddOpen(true); + }} + style={{ + padding: '6px 10px', + borderRadius: 6, + fontSize: 13, + cursor: 'pointer', + color: '#065F46', + background: '#ECFDF5', + border: '1px solid #A7F3D0', + }} + > + Add custom chain… + + + {Object.keys(getCustomChains()).length > 0 && ( + + + Remove custom chain… + + + {Object.keys(getCustomChains()).map((slug) => ( + { + e.preventDefault(); + handleRemove(slug); + }} + style={{ + padding: '6px 10px', + borderRadius: 6, + fontSize: 13, + cursor: 'pointer', + color: '#991B1B', + }} + > + Remove {slug} + + ))} + + + )} +
+ ) : ( +
e.stopPropagation()} onKeyDown={(e) => e.stopPropagation()} style={{ display: 'flex', flexDirection: 'column', gap: 6 }}> + {error && ( +
{error}
+ )} +
+ setAddForm((s) => ({ ...s, slug: e.target.value }))} + style={{ border: '1px solid #E5E7EB', borderRadius: 6, padding: '6px 8px', fontSize: 12, color: '#111827' }} /> + setAddForm((s) => ({ ...s, id: e.target.value }))} + style={{ border: '1px solid #E5E7EB', borderRadius: 6, padding: '6px 8px', fontSize: 12, color: '#111827' }} /> + setAddForm((s) => ({ ...s, name: e.target.value }))} + style={{ border: '1px solid #E5E7EB', borderRadius: 6, padding: '6px 8px', fontSize: 12, color: '#111827' }} /> + setAddForm((s) => ({ ...s, symbol: e.target.value }))} + style={{ border: '1px solid #E5E7EB', borderRadius: 6, padding: '6px 8px', fontSize: 12, color: '#111827' }} /> + setAddForm((s) => ({ ...s, rpcUrl: e.target.value }))} + style={{ gridColumn: '1 / span 2', border: '1px solid #E5E7EB', borderRadius: 6, padding: '6px 8px', fontSize: 12, color: '#111827' }} /> + setAddForm((s) => ({ ...s, explorerUrl: e.target.value }))} + style={{ gridColumn: '1 / span 2', border: '1px solid #E5E7EB', borderRadius: 6, padding: '6px 8px', fontSize: 12, color: '#111827' }} /> + +
+
+ + +
+
+ )} + + + + ); +}; diff --git a/apps/explorer/src/lit-logged-page/protectedApp/components/layout/DashboardContent.tsx b/apps/explorer/src/lit-logged-page/protectedApp/components/layout/DashboardContent.tsx new file mode 100644 index 0000000000..d6f2d0c3b4 --- /dev/null +++ b/apps/explorer/src/lit-logged-page/protectedApp/components/layout/DashboardContent.tsx @@ -0,0 +1,154 @@ +import React from "react"; +import { UIPKP, BalanceInfo } from "../../types"; +import { PKPInfoCard } from "../pkp/PKPInfoCard"; +import { APP_INFO } from "@/_config"; + +interface DashboardContentProps { + selectedPkp: UIPKP | null; + balance: BalanceInfo | null; + isLoadingBalance: boolean; + selectedChain: string; + onShowPkpModal: () => void; + onChainChange: (chain: string) => void; + userMethod: string; + children: React.ReactNode; +} + +export const DashboardContent: React.FC = ({ + selectedPkp, + balance, + isLoadingBalance, + selectedChain, + onShowPkpModal, + onChainChange, + userMethod, + children, +}) => { + return ( +
+
+
+ +
+ {children} +
+
+
+ ); +}; diff --git a/apps/explorer/src/lit-logged-page/protectedApp/components/layout/TabNavigation.tsx b/apps/explorer/src/lit-logged-page/protectedApp/components/layout/TabNavigation.tsx new file mode 100644 index 0000000000..c443cd7c1d --- /dev/null +++ b/apps/explorer/src/lit-logged-page/protectedApp/components/layout/TabNavigation.tsx @@ -0,0 +1,70 @@ +/** + * TabNavigation Component + * + * Reusable tab navigation component + */ + +import React from 'react'; + +export interface Tab { + id: string; + label: string; + icon?: string; +} + +interface TabNavigationProps { + tabs: Tab[]; + activeTab: string; + onTabChange: (tabId: string) => void; +} + +export const TabNavigation: React.FC = ({ + tabs, + activeTab, + onTabChange, +}) => { + return ( +
+
+ {tabs.map((tab) => ( + + ))} +
+
+ ); +}; \ No newline at end of file diff --git a/apps/explorer/src/lit-logged-page/protectedApp/components/layout/index.ts b/apps/explorer/src/lit-logged-page/protectedApp/components/layout/index.ts new file mode 100644 index 0000000000..d3f84766ad --- /dev/null +++ b/apps/explorer/src/lit-logged-page/protectedApp/components/layout/index.ts @@ -0,0 +1,3 @@ +export { DashboardContent } from './DashboardContent'; +export { TabNavigation, type Tab } from './TabNavigation'; +export { ChainSelector } from './ChainSelector'; \ No newline at end of file diff --git a/apps/explorer/src/lit-logged-page/protectedApp/components/permissions/AddActionForm.tsx b/apps/explorer/src/lit-logged-page/protectedApp/components/permissions/AddActionForm.tsx new file mode 100644 index 0000000000..be758a21d2 --- /dev/null +++ b/apps/explorer/src/lit-logged-page/protectedApp/components/permissions/AddActionForm.tsx @@ -0,0 +1,120 @@ +/** + * AddActionForm Component + * + * Form for adding permitted actions to a PKP + */ + +import React, { useState } from 'react'; +import { ScopeCheckboxes } from '../ui/ScopeCheckboxes'; +import { AVAILABLE_SCOPES } from '../../types'; +import { usePKPPermissions } from '../../contexts/PKPPermissionsContext'; +import { useLitAuth } from '../../../../lit-login-modal/LitAuthProvider'; +import { triggerLedgerRefresh } from '../../utils/ledgerRefresh'; + +interface AddActionFormProps { + disabled?: boolean; +} + +export const AddActionForm: React.FC = ({ disabled = false }) => { + const { addPermittedAction } = usePKPPermissions(); + const { user } = useLitAuth(); + const [newActionIpfsId, setNewActionIpfsId] = useState( + "QmSQDKRWEXZ9CGoucSTR11Mv6fhGqaytZ1MqrfHdkuS1Vg" + ); + const [newActionSelectedScopes, setNewActionSelectedScopes] = useState(["sign-anything"]); + const [isAdding, setIsAdding] = useState(false); + + const handleSubmit = async () => { + if (!newActionIpfsId.trim() || newActionSelectedScopes.length === 0) { + return; + } + + setIsAdding(true); + try { + await addPermittedAction(newActionIpfsId, newActionSelectedScopes); + + // Clear form on success + setNewActionIpfsId(""); + setNewActionSelectedScopes([]); + try { + const addr = user?.pkpInfo?.ethAddress; + if (addr) await triggerLedgerRefresh(addr); + } catch {} + } catch (error) { + console.error("Failed to add permitted action:", error); + } finally { + setIsAdding(false); + } + }; + + return ( +
+

+ ➕ Add Lit Action Permission +

+

+ Allow your PKP to execute a specific Lit Action. +

+ + setNewActionIpfsId(e.target.value)} + placeholder="IPFS ID (e.g., QmSQDKRWEXZ9CGoucSTR11Mv6fhGqaytZ1MqrfHdkuS1Vg)" + disabled={disabled || isAdding} + style={{ + width: "100%", + padding: "12px", + border: "1px solid #d1d5db", + borderRadius: "8px", + fontSize: "13px", + marginBottom: "16px", + fontFamily: "monospace", + backgroundColor: disabled ? "#f3f4f6" : "#ffffff", + color: disabled ? "#6b7280" : "#000000", + }} + /> + + + + +
+ ); +}; \ No newline at end of file diff --git a/apps/explorer/src/lit-logged-page/protectedApp/components/permissions/AddAddressForm.tsx b/apps/explorer/src/lit-logged-page/protectedApp/components/permissions/AddAddressForm.tsx new file mode 100644 index 0000000000..5c821c24bb --- /dev/null +++ b/apps/explorer/src/lit-logged-page/protectedApp/components/permissions/AddAddressForm.tsx @@ -0,0 +1,120 @@ +/** + * AddAddressForm Component + * + * Form for adding permitted addresses to a PKP + */ + +import React, { useState } from 'react'; +import { ScopeCheckboxes } from '../ui/ScopeCheckboxes'; +import { AVAILABLE_SCOPES } from '../../types'; +import { usePKPPermissions } from '../../contexts/PKPPermissionsContext'; +import { useLitAuth } from '../../../../lit-login-modal/LitAuthProvider'; +import { triggerLedgerRefresh } from '../../utils/ledgerRefresh'; + +interface AddAddressFormProps { + disabled?: boolean; +} + +export const AddAddressForm: React.FC = ({ disabled = false }) => { + const { addPermittedAddress } = usePKPPermissions(); + const { user } = useLitAuth(); + const [newPermittedAddress, setNewPermittedAddress] = useState( + "0xef3eE1bD838aF5B36482FAe8a6Fc394C68d5Fa9F" + ); + const [newAddressSelectedScopes, setNewAddressSelectedScopes] = useState(["sign-anything"]); + const [isAdding, setIsAdding] = useState(false); + + const handleSubmit = async () => { + if (!newPermittedAddress.trim() || newAddressSelectedScopes.length === 0) { + return; + } + + setIsAdding(true); + try { + await addPermittedAddress(newPermittedAddress, newAddressSelectedScopes); + + // Clear form on success + setNewPermittedAddress(""); + setNewAddressSelectedScopes([]); + try { + const addr = user?.pkpInfo?.ethAddress; + if (addr) await triggerLedgerRefresh(addr); + } catch {} + } catch (error) { + console.error("Failed to add permitted address:", error); + } finally { + setIsAdding(false); + } + }; + + return ( +
+

+ 🏠 Add Address Permission +

+

+ Allow a specific address to use your PKP. +

+ + setNewPermittedAddress(e.target.value)} + placeholder="Ethereum Address (0x...)" + disabled={disabled || isAdding} + style={{ + width: "100%", + padding: "12px", + border: "1px solid #d1d5db", + borderRadius: "8px", + fontSize: "13px", + marginBottom: "16px", + fontFamily: "monospace", + backgroundColor: disabled ? "#f3f4f6" : "#ffffff", + color: disabled ? "#6b7280" : "#000000", + }} + /> + + + + +
+ ); +}; \ No newline at end of file diff --git a/apps/explorer/src/lit-logged-page/protectedApp/components/permissions/PermissionsDangerZone.tsx b/apps/explorer/src/lit-logged-page/protectedApp/components/permissions/PermissionsDangerZone.tsx new file mode 100644 index 0000000000..c69a5dfe6e --- /dev/null +++ b/apps/explorer/src/lit-logged-page/protectedApp/components/permissions/PermissionsDangerZone.tsx @@ -0,0 +1,58 @@ +/** + * PermissionsDangerZone Component + * + * Contains dangerous operations like revoking all permissions + */ + +import React from 'react'; +import { usePKPPermissions } from '../../contexts/PKPPermissionsContext'; + +export const PermissionsDangerZone: React.FC = () => { + const { revokeAllPermissions, isRevokingAll } = usePKPPermissions(); + + return ( +
+
+

+ ⚠️ Danger Zone +

+

+ Warning: This will remove ALL permissions from your + PKP. This action cannot be undone. +

+ + +
+
+ ); +}; \ No newline at end of file diff --git a/apps/explorer/src/lit-logged-page/protectedApp/components/permissions/PermissionsDashboard.tsx b/apps/explorer/src/lit-logged-page/protectedApp/components/permissions/PermissionsDashboard.tsx new file mode 100644 index 0000000000..f0d874c577 --- /dev/null +++ b/apps/explorer/src/lit-logged-page/protectedApp/components/permissions/PermissionsDashboard.tsx @@ -0,0 +1,182 @@ +/** + * PermissionsDashboard Component + * + * Complete permissions management dashboard that combines all permission-related components + */ + +import React, { useEffect } from "react"; +import { usePKPPermissions } from "../../contexts/PKPPermissionsContext"; +import { PermissionsSummaryCards } from "./PermissionsSummaryCards"; +import { PermissionsList } from "./PermissionsList"; +import { AddActionForm } from "./AddActionForm"; +import { AddAddressForm } from "./AddAddressForm"; + +interface PermissionsDashboardProps { + disabled?: boolean; +} + +export const PermissionsDashboard: React.FC = ({ + disabled = false, +}) => { + const { + permissionsContext, + isLoadingPermissions, + permissionsError, + loadPermissionsContext, + selectedPkp, + } = usePKPPermissions(); + + // Auto-load permissions when component mounts or PKP changes + useEffect(() => { + if (selectedPkp && !permissionsContext && !isLoadingPermissions) { + console.log( + "🔄 Auto-loading permissions context for PKP:", + selectedPkp.tokenId + ); + loadPermissionsContext(); + } + }, [ + selectedPkp, + permissionsContext, + isLoadingPermissions, + loadPermissionsContext, + ]); + + return ( + <> + {/* Summary Cards */} + + + {/* Current Permissions Detail View */} +
+
+

+ 📋 Current Permissions +

+ +
+ + {!permissionsContext && !isLoadingPermissions && ( +
+ No permissions loaded.{" "} + {selectedPkp + ? "Loading automatically..." + : "Please select a PKP to view permissions."} +
+ )} + + {isLoadingPermissions && ( +
+
+ Loading permissions... +
+ )} + + {permissionsContext && } +
+ + {/* Permission Management Cards */} +
+ + +
+ + {/* Permissions Error Display */} + {permissionsError && ( +
+ ⚠️ Error: {permissionsError} +
+ )} + + {/* Danger Zone */} + {/* */} + + {/* Loading animation CSS */} + + + ); +}; diff --git a/apps/explorer/src/lit-logged-page/protectedApp/components/permissions/PermissionsList.tsx b/apps/explorer/src/lit-logged-page/protectedApp/components/permissions/PermissionsList.tsx new file mode 100644 index 0000000000..e87e40e6d4 --- /dev/null +++ b/apps/explorer/src/lit-logged-page/protectedApp/components/permissions/PermissionsList.tsx @@ -0,0 +1,473 @@ +/** + * PermissionsList Component + * + * Displays current PKP permissions with ability to remove them + */ + +import React from 'react'; +import { RemoveButton } from '../ui/RemoveButton'; +import { usePKPPermissions } from '../../contexts/PKPPermissionsContext'; +import { hexToIpfsCid, getAuthMethodTypeName } from '../../utils'; +import { AUTH_METHOD_TYPE } from '../../types'; +import { getAddress, isAddress } from 'viem'; +import { useLitAuth } from '../../../../lit-login-modal/LitAuthProvider'; +import { triggerLedgerRefresh } from '../../utils/ledgerRefresh'; + +export const PermissionsList: React.FC = () => { + const { + permissionsContext, + removingItems, + removePermittedAction, + removePermittedAddress, + removePermittedAuthMethod, + selectedPkp, + } = usePKPPermissions(); + const { user } = useLitAuth(); + + const currentAuthType: number | undefined = user?.authData?.authMethodType; + const currentAuthIdRaw: string = (user?.authData?.authMethodId || '') as string; + + const normaliseAuthId = (typeNumber: number, id?: string): string => { + if (!id) return ''; + // Normalise EVM addresses for EthWallet type; lowercase fallback for others + if (Number(typeNumber) === AUTH_METHOD_TYPE.EthWallet) { + try { + if (isAddress(id)) return getAddress(id).toLowerCase(); + } catch {} + } + return String(id).toLowerCase(); + }; + + const isCurrentSessionAuthMethod = (method: { authMethodType: number | string; id?: string }): boolean => { + const methodType = Number(method.authMethodType); + if (currentAuthType === undefined || currentAuthType === null) return false; + if (methodType !== Number(currentAuthType)) return false; + const a = normaliseAuthId(methodType, method.id); + const b = normaliseAuthId(Number(currentAuthType), currentAuthIdRaw); + return !!a && !!b && a === b; + }; + + const handleRemoveAction = async (actionCid: string) => { + const ok = window.confirm( + `Are you sure you want to remove this permitted action?\n\nIPFS CID: ${actionCid}` + ); + if (!ok) return; + await removePermittedAction(actionCid); + try { + const addr = selectedPkp?.ethAddress || user?.pkpInfo?.ethAddress; + if (addr) await triggerLedgerRefresh(addr); + } catch {} + }; + + const handleRemoveAddress = async (address: string) => { + const ok = window.confirm( + `Are you sure you want to remove this permitted address?\n\nAddress: ${address}` + ); + if (!ok) return; + await removePermittedAddress(address); + try { + const addr = selectedPkp?.ethAddress || user?.pkpInfo?.ethAddress; + if (addr) await triggerLedgerRefresh(addr); + } catch {} + }; + + const handleRemoveAuthMethod = async ( + authType: number, + authId: string, + displayId: string, + isCurrent: boolean + ) => { + const typeName = getAuthMethodTypeName(authType); + const baseMsg = `Are you sure you want to remove this auth method?\n\nType: ${typeName}\nID: ${displayId || authId}`; + const ok = window.confirm( + isCurrent + ? `${baseMsg}\n\n❗️❗️ Warning: This matches your current session's authentication and removing it will block you from authenticating this PKP with the current auth method again.` + : baseMsg + ); + if (!ok) return; + + if (isCurrent) { + const typed = window.prompt( + `Type DELETE to confirm removing the current session's auth method.` + ); + if ((typed || '').trim().toUpperCase() !== 'DELETE') return; + } + + await removePermittedAuthMethod(authType, authId); + try { + const addr = selectedPkp?.ethAddress || user?.pkpInfo?.ethAddress; + if (addr) await triggerLedgerRefresh(addr); + } catch {} + }; + + if (!permissionsContext) { + return ( +
+ No permissions loaded. Click "Refresh" to load current permissions. +
+ ); + } + + const hasPermissions = + (permissionsContext.actions && permissionsContext.actions.length > 0) || + (permissionsContext.addresses && permissionsContext.addresses.length > 0) || + (permissionsContext.authMethods && permissionsContext.authMethods.length > 0); + + if (!hasPermissions) { + return ( +
+
🔓
+
+ No Permissions Set +
+
+ This PKP has no specific permissions configured. Use the forms below to add permissions. +
+
+ ); + } + + return ( +
+ {/* Permitted Actions */} + {permissionsContext.actions && permissionsContext.actions.length > 0 && ( +
+

+ ⚡ Permitted Actions ({permissionsContext.actions.length}) +

+
+ {permissionsContext.actions.map((action: string, index: number) => ( +
+ + handleRemoveAction(action)} + isRemoving={removingItems.has(`action:${action}`)} + /> +
+ ))} +
+
+ )} + + {/* Permitted Addresses */} + {permissionsContext.addresses && permissionsContext.addresses.length > 0 && ( +
+

+ 🏠 Permitted Addresses ({(() => { + try { + const normalise = (addr: string) => { + try { + return getAddress(addr).toLowerCase(); + } catch { + return String(addr).toLowerCase(); + } + }; + const unique = Array.from(new Set(permissionsContext.addresses.map((a: string) => normalise(a)))); + return unique.length; + } catch { + return permissionsContext.addresses.length; + } + })()}) +

+
+ {(() => { + // Build unique list (case-insensitive, checksum-normalised when possible) + const addresses: string[] = permissionsContext.addresses; + const normalise = (addr: string) => { + try { + return getAddress(addr).toLowerCase(); + } catch { + return String(addr).toLowerCase(); + } + }; + const uniqueKeys = Array.from(new Set(addresses.map((a: string) => normalise(a)))); + const uniqueAddresses = uniqueKeys.map((key: string) => { + const original = addresses.find((a: string) => normalise(a) === key) as string; + return original; + }); + + return uniqueAddresses.map((address: string, index: number) => ( +
+
+
+ {address} + {(() => { + try { + if ( + selectedPkp?.ethAddress && + isAddress(address) && + isAddress(selectedPkp.ethAddress) && + getAddress(address) === getAddress(selectedPkp.ethAddress) + ) { + return ( + + (PKP Itself) + + ); + } + } catch {} + return null; + })()} +
+
+ Ethereum Address +
+
+ handleRemoveAddress(address)} + isRemoving={removingItems.has(`address:${address}`)} + /> +
+ )); + })()} +
+
+ )} + + {/* Auth Methods */} + {permissionsContext.authMethods && permissionsContext.authMethods.length > 0 && ( +
+

+ 🔑 Auth Methods ({permissionsContext.authMethods.length}) +

+
+ {permissionsContext.authMethods.map((authMethod: any, index: number) => { + const authType = Number(authMethod.authMethodType); + const isLitAction = authType === AUTH_METHOD_TYPE.LitAction; + const isEthWallet = authType === AUTH_METHOD_TYPE.EthWallet; + const displayId = (() => { + if (isLitAction && authMethod.id) return hexToIpfsCid(authMethod.id); + if (isEthWallet && authMethod.id) { + try { + return getAddress(authMethod.id); + } catch { + return authMethod.id || ""; + } + } + return authMethod.id || ""; + })(); + const isCurrent = isCurrentSessionAuthMethod(authMethod); + + return ( +
+
+
+ {isLitAction ? ( + { + e.currentTarget.style.color = "#5b21b6"; + }} + onMouseLeave={(e) => { + e.currentTarget.style.color = "#7c3aed"; + }} + > + {displayId} + + ) : ( + (() => { + const isCurrentPkpEth = (() => { + try { + return ( + isEthWallet && + selectedPkp?.ethAddress && + isAddress(authMethod.id) && + isAddress(selectedPkp.ethAddress) && + getAddress(authMethod.id) === getAddress(selectedPkp.ethAddress) + ); + } catch { + return false; + } + })(); + return ( + <> + {displayId} + {isCurrentPkpEth && ( + + (PKP Itself) + + )} + + ); + })() + )} +
+
+ Type: {getAuthMethodTypeName(authType)} + {isCurrent && ( + + (Current session) + + )} + {isLitAction && ( + + 📎 (IPFS Link) + + )} +
+ {authMethod.scopes && authMethod.scopes.length > 0 && ( +
+ Scopes:{" "} + {Array.isArray(authMethod.scopes) + ? authMethod.scopes.join(", ") + : authMethod.scopes} +
+ )} + {authMethod.scopes && authMethod.scopes.length === 0 && ( +
+ Scopes: None (no permissions) +
+ )} +
+ + handleRemoveAuthMethod( + authType, + authMethod.id, + String(displayId), + isCurrent + ) + } + isRemoving={removingItems.has(`${authType}:${authMethod.id}`)} + /> +
+ ); + })} +
+
+ )} +
+ ); +}; \ No newline at end of file diff --git a/apps/explorer/src/lit-logged-page/protectedApp/components/permissions/PermissionsSummaryCards.tsx b/apps/explorer/src/lit-logged-page/protectedApp/components/permissions/PermissionsSummaryCards.tsx new file mode 100644 index 0000000000..ea15366464 --- /dev/null +++ b/apps/explorer/src/lit-logged-page/protectedApp/components/permissions/PermissionsSummaryCards.tsx @@ -0,0 +1,167 @@ +/** + * PermissionsSummaryCards Component + * + * Google-style dashboard summary cards for permissions overview + */ + +import React from 'react'; +import { getAddress } from 'viem'; +import { usePKPPermissions } from '../../contexts/PKPPermissionsContext'; + +export const PermissionsSummaryCards: React.FC = () => { + const { permissionsContext } = usePKPPermissions(); + + if (!permissionsContext) { + return null; + } + + const uniqueAddressCount = React.useMemo(() => { + const addresses: string[] = permissionsContext?.addresses || []; + try { + const normalise = (addr: string) => { + try { + return getAddress(addr).toLowerCase(); + } catch { + return String(addr).toLowerCase(); + } + }; + return Array.from(new Set(addresses.map((a) => normalise(a)))).length; + } catch { + return addresses.length; + } + }, [permissionsContext?.addresses]); + + return ( +
+ {/* Actions Summary Card */} +
+
+ {permissionsContext?.actions?.length || 0} +
+
+ ⚡ Permitted Actions +
+
+ Lit Actions this PKP can execute +
+
+ + {/* Addresses Summary Card */} +
+
+ {uniqueAddressCount} +
+
+ 🏠 Permitted Addresses +
+
+ Addresses that can use this PKP +
+
+ + {/* Auth Methods Summary Card */} +
+
+ {permissionsContext?.authMethods?.length || 0} +
+
+ 🔑 Auth Methods +
+
+ Authentication methods linked +
+
+
+ ); +}; \ No newline at end of file diff --git a/apps/explorer/src/lit-logged-page/protectedApp/components/permissions/index.ts b/apps/explorer/src/lit-logged-page/protectedApp/components/permissions/index.ts new file mode 100644 index 0000000000..61c99d49b2 --- /dev/null +++ b/apps/explorer/src/lit-logged-page/protectedApp/components/permissions/index.ts @@ -0,0 +1,12 @@ +/** + * Permissions Components Index + * + * Centralized exports for all permission management components + */ + +export { AddActionForm } from './AddActionForm'; +export { AddAddressForm } from './AddAddressForm'; +export { PermissionsList } from './PermissionsList'; +export { PermissionsSummaryCards } from './PermissionsSummaryCards'; +export { PermissionsDangerZone } from './PermissionsDangerZone'; +export { PermissionsDashboard } from './PermissionsDashboard'; \ No newline at end of file diff --git a/apps/explorer/src/lit-logged-page/protectedApp/components/pkp/PKPInfoCard.tsx b/apps/explorer/src/lit-logged-page/protectedApp/components/pkp/PKPInfoCard.tsx new file mode 100644 index 0000000000..67f289975b --- /dev/null +++ b/apps/explorer/src/lit-logged-page/protectedApp/components/pkp/PKPInfoCard.tsx @@ -0,0 +1,536 @@ +/** + * PKPInfoCard Component + * + * Displays PKP wallet information including balance and addresses + */ + +import React, { useEffect, useRef, useState } from "react"; +import { UIPKP, BalanceInfo } from "../../types"; +import { formatPublicKey, copyToClipboard } from "../../utils"; +import copyIcon from "../../../../assets/copy.svg"; +import googleIcon from "../../../../assets/google.png"; +import discordIcon from "../../../../assets/discord.png"; +import web3WalletIcon from "../../../../assets/web3-wallet.svg"; +import passkeyIcon from "../../../../assets/passkey.svg"; +import emailIcon from "../../../../assets/email.svg"; +import phoneIcon from "../../../../assets/phone.svg"; +import whatsappIcon from "../../../../assets/whatsapp.svg"; +import tfaIcon from "../../../../assets/2fa.svg"; +import { getAddress } from "viem"; +import { ChainSelector } from "../layout"; +import { Settings } from "lucide-react"; +import { useOptionalLitAuth } from "../../../../lit-login-modal/LitAuthProvider"; +import { privateKeyToAccount } from "viem/accounts"; +import { setCurrentBalance, useLedgerRefresh } from "../../utils/ledgerRefresh"; +import { isTestnetNetwork } from "@/domain/lit/networkDefaults"; +// Replaced hover behaviour with a click-triggered menu +const account = privateKeyToAccount( + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" +); + +const AUTH_ICON_BY_METHOD: Record = { + google: googleIcon, + discord: discordIcon, + eoa: web3WalletIcon, + webauthn: passkeyIcon, + "stytch-email": emailIcon, + "stytch-sms": phoneIcon, + "stytch-whatsapp": whatsappIcon, + "stytch-totp": tfaIcon, + custom: passkeyIcon, +}; + +interface PKPInfoCardProps { + selectedPkp: UIPKP | null; + balance: BalanceInfo | null; + isLoadingBalance: boolean; + onShowPkpModal: () => void; + userMethod: string; + selectedChain: string; + onChainChange: (chain: string) => void; +} + +export const PKPInfoCard: React.FC = ({ + selectedPkp, + balance, + isLoadingBalance, + onShowPkpModal, + userMethod, + selectedChain, + onChainChange, +}) => { + const [copiedField, setCopiedField] = useState(null); + const [isChainMenuOpen, setIsChainMenuOpen] = useState(false); + const chainTriggerRef = useRef(null); + const chainMenuRef = useRef(null); + const optionalAuth = useOptionalLitAuth(); + const services = optionalAuth?.services; + const currentNetworkName = (optionalAuth as any)?.currentNetworkName as + | string + | undefined; + const isTestnet = isTestnetNetwork(currentNetworkName); + const ledgerUnit = isTestnet ? "tstLPX" : "LITKEY"; + + // PKP Lit Ledger balance state + const [ledgerError, setLedgerError] = useState(""); + const [ledgerBalance, setLedgerBalance] = useState<{ + total: string; + available: string; + } | null>(null); + + // Balance change log + type BalanceChangeLog = { + timestamp: string; + before: string; + after: string; + delta: string; + type: 'increase' | 'decrease'; + }; + const [balanceChangeLogs, setBalanceChangeLogs] = useState([]); + const [showLogs, setShowLogs] = useState(false); + const lastBalanceRef = useRef(null); + + // Ref to track background polling interval + const backgroundPollingRef = useRef(null); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + const target = event.target as Node; + if ( + isChainMenuOpen && + chainMenuRef.current && + !chainMenuRef.current.contains(target) && + chainTriggerRef.current && + !chainTriggerRef.current.contains(target) + ) { + setIsChainMenuOpen(false); + } + }; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + setIsChainMenuOpen(false); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("mousedown", handleClickOutside); + document.removeEventListener("keydown", handleKeyDown); + }; + }, [isChainMenuOpen]); + + // Helper to fetch ledger balance + const fetchLedgerBalance = async () => { + if (!selectedPkp?.ethAddress || !services?.litClient) return null; + try { + const pm = await services.litClient.getPaymentManager({ + account: account, + }); + const bal = await pm.getBalance({ + userAddress: selectedPkp.ethAddress, + }); + return { + total: bal.totalBalance, + available: bal.availableBalance, + }; + } catch (e: any) { + throw e; + } + }; + + // Event-driven polling: only polls after actions + const startActionTriggeredPolling = () => { + // Clear any existing interval + if (backgroundPollingRef.current) { + clearInterval(backgroundPollingRef.current); + } + + // Poll every 1 second for a limited time after an action + let pollCount = 0; + const maxPolls = 10; // Poll for 10 seconds after action + + backgroundPollingRef.current = setInterval(async () => { + if (!selectedPkp?.ethAddress || !services?.litClient) { + stopPolling(); + return; + } + + pollCount++; + if (pollCount > maxPolls) { + stopPolling(); + return; + } + + try { + const bal = await fetchLedgerBalance(); + if (bal) { + // Log balance change if different from last balance + if (lastBalanceRef.current && lastBalanceRef.current !== bal.available) { + const before = Number(lastBalanceRef.current); + const after = Number(bal.available); + const delta = after - before; + + if (Math.abs(delta) > 0.000001) { + const newLog: BalanceChangeLog = { + timestamp: new Date().toISOString(), + before: lastBalanceRef.current, + after: bal.available, + delta: delta.toFixed(6), + type: delta > 0 ? 'increase' : 'decrease' + }; + + setBalanceChangeLogs(prev => [newLog, ...prev].slice(0, 50)); // Keep last 50 logs + console.log('[Balance Change]', { + time: new Date().toLocaleTimeString(), + delta: `${delta > 0 ? '+' : ''}${delta.toFixed(6)} ${ledgerUnit}`, + before: lastBalanceRef.current, + after: bal.available + }); + + // Stop polling early if we detected a change + stopPolling(); + } + } + + lastBalanceRef.current = bal.available; + setLedgerBalance(bal); + + // Update global balance store + if (selectedPkp?.ethAddress && bal.available) { + setCurrentBalance(selectedPkp.ethAddress, bal.available); + } + } + } catch (e) { + // Silent fail for polling + } + }, 1000); + }; + + const stopPolling = () => { + if (backgroundPollingRef.current) { + clearInterval(backgroundPollingRef.current); + backgroundPollingRef.current = null; + } + }; + + // Load PKP Lit Ledger balance when PKP changes (initial load only, no continuous polling) + useEffect(() => { + const loadLedgerBalance = async () => { + if (!selectedPkp?.ethAddress || !services?.litClient) { + setLedgerBalance(null); + stopPolling(); + return; + } + try { + setLedgerError(""); + + const bal = await fetchLedgerBalance(); + if (!bal) { + return; + } + + // Initialize last balance ref + lastBalanceRef.current = bal.available; + setLedgerBalance(bal); + + // Update global balance store + if (selectedPkp?.ethAddress && bal.available) { + setCurrentBalance(selectedPkp.ethAddress, bal.available); + } + + // Start polling after initial login (first load is an action) + startActionTriggeredPolling(); + } catch (e: any) { + setLedgerError(e?.message || String(e)); + setLedgerBalance(null); + } + }; + loadLedgerBalance(); + + // Cleanup on unmount or PKP change + return () => { + stopPolling(); + }; + }, [selectedPkp, services?.litClient]); + + // Subscribe to ledger refresh events (triggered by actions) + useLedgerRefresh(({ address }) => { + if (!selectedPkp?.ethAddress) return; + + // Only refresh if it's for this PKP + if ((address || "").toLowerCase() === (selectedPkp.ethAddress || "").toLowerCase()) { + // Start polling after action + startActionTriggeredPolling(); + } + }); + + const refreshLedgerBalance = async () => { + if (!selectedPkp?.ethAddress || !services?.litClient) return; + + try { + const bal = await fetchLedgerBalance(); + if (!bal) return; + + setLedgerBalance(bal); + + // Update global balance store + if (selectedPkp?.ethAddress && bal.available) { + setCurrentBalance(selectedPkp.ethAddress, bal.available); + } + + // Start polling after manual refresh (it's an action) + startActionTriggeredPolling(); + } catch (e: any) { + setLedgerError(e?.message || String(e)); + } + }; + + const handleCopy = async (text: string, fieldName: string) => { + await copyToClipboard(text, setCopiedField, fieldName); + setTimeout(() => setCopiedField(null), 2000); + }; + + if (!selectedPkp) { + return ( +
+
+ No PKP selected. Click below to select a PKP wallet. +
+
+ ); + } + + return ( +
+ {/* Header row: avatar | title | actions (chain + settings) */} +
+ {/* Avatar (circular) */} +
+ {`${userMethod} +
+ + {/* Title only */} +
+
+ PKP Wallet +
+
+ + {/* Actions: Chain selector + Settings */} +
+ { + onChainChange(slug); + }} + iconTrigger + triggerAriaLabel="Select chain" + /> + + +
+
+ + {/* Balance shown outside of the info container, directly under the auth label */} +
+ {isLoadingBalance ? ( +
Loading balance...
+ ) : ( + balance && ( +
+ + {balance.balance} {balance.symbol} + + + (Chain ID: {balance.chainId}) + +
+ ) + )} + {/* PKP Lit Ledger Balance */} +
+
+ + PKP Lit Ledger Balance + + + +
+ {ledgerError ? ( +
{ledgerError}
+ ) : ledgerBalance ? ( +
+
+ + {ledgerBalance.available} {ledgerUnit} + + + (total {ledgerBalance.total} {ledgerUnit}) + +
+
+ ) : ( +
No ledger data
+ )} + + {/* Balance Change Logs */} + {showLogs && ( +
+
+ Balance Change History + {balanceChangeLogs.length > 0 && ( + + )} +
+ {balanceChangeLogs.length === 0 ? ( +
+ No balance changes recorded yet +
+ ) : ( +
+ {balanceChangeLogs.map((log, idx) => ( +
+
+ + {new Date(log.timestamp).toLocaleTimeString()} + + + {log.type === 'increase' ? '+' : ''}{log.delta} {ledgerUnit} + +
+
+ {log.before} → {log.after} +
+
+ ))} +
+ )} +
+ )} +
+
+ +
+
+
+ Token ID: + + handleCopy(selectedPkp.tokenId?.toString() || "", "tokenId") + } + title="Click to copy Token ID" + > + + {copiedField === "tokenId" + ? `✅ ${selectedPkp.tokenId?.toString()}` + : selectedPkp.tokenId?.toString() || "N/A"} + + Copy + +
+ +
+ ETH Address: + + handleCopy( + getAddress(selectedPkp.ethAddress) || "", + "ethAddress" + ) + } + title="Click to copy ETH Address" + > + + {copiedField === "ethAddress" + ? `✅ ${getAddress(selectedPkp.ethAddress)}` + : getAddress(selectedPkp.ethAddress) || "N/A"} + + Copy + +
+ +
+ Public Key: + handleCopy(selectedPkp.pubkey || "", "publicKey")} + title="Click to copy Public Key (full value)" + > + + {copiedField === "publicKey" + ? `✅ ${selectedPkp.pubkey}` + : formatPublicKey(selectedPkp.pubkey || "")} + + Copy + +
+
+
+
+ ); +}; diff --git a/apps/explorer/src/lit-logged-page/protectedApp/components/ui/LoadingSpinner.tsx b/apps/explorer/src/lit-logged-page/protectedApp/components/ui/LoadingSpinner.tsx new file mode 100644 index 0000000000..23ad9921ff --- /dev/null +++ b/apps/explorer/src/lit-logged-page/protectedApp/components/ui/LoadingSpinner.tsx @@ -0,0 +1,24 @@ +/** + * LoadingSpinner Component + * + * Reusable loading spinner with configurable size + */ + +import React from 'react'; + +interface LoadingSpinnerProps { + size?: number; +} + +export const LoadingSpinner: React.FC = ({ size = 16 }) => ( +
+); \ No newline at end of file diff --git a/apps/explorer/src/lit-logged-page/protectedApp/components/ui/RemoveButton.tsx b/apps/explorer/src/lit-logged-page/protectedApp/components/ui/RemoveButton.tsx new file mode 100644 index 0000000000..db1bec9d71 --- /dev/null +++ b/apps/explorer/src/lit-logged-page/protectedApp/components/ui/RemoveButton.tsx @@ -0,0 +1,46 @@ +/** + * RemoveButton Component + * + * Reusable button for removing items with loading state + */ + +import React from 'react'; +import { LoadingSpinner } from './LoadingSpinner'; + +interface RemoveButtonProps { + onRemove: () => void; + isRemoving: boolean; +} + +export const RemoveButton: React.FC = ({ + onRemove, + isRemoving, +}) => ( + +); \ No newline at end of file diff --git a/apps/explorer/src/lit-logged-page/protectedApp/components/ui/ScopeCheckboxes.tsx b/apps/explorer/src/lit-logged-page/protectedApp/components/ui/ScopeCheckboxes.tsx new file mode 100644 index 0000000000..ecfd8acf74 --- /dev/null +++ b/apps/explorer/src/lit-logged-page/protectedApp/components/ui/ScopeCheckboxes.tsx @@ -0,0 +1,90 @@ +/** + * ScopeCheckboxes Component + * + * Reusable component for selecting permission scopes + */ + +import React from 'react'; +import { ScopeConfig } from '../../types'; + +interface ScopeCheckboxesProps { + availableScopes: ScopeConfig[]; + selectedScopes: string[]; + onScopeChange: (scopes: string[]) => void; + disabled?: boolean; +} + +export const ScopeCheckboxes: React.FC = ({ + availableScopes, + selectedScopes, + onScopeChange, + disabled = false, +}) => ( +
+ +
+ {availableScopes.map((scope) => ( + + ))} +
+
+); \ No newline at end of file diff --git a/apps/explorer/src/lit-logged-page/protectedApp/components/ui/TransactionToastContainer.tsx b/apps/explorer/src/lit-logged-page/protectedApp/components/ui/TransactionToastContainer.tsx new file mode 100644 index 0000000000..63070cf6c0 --- /dev/null +++ b/apps/explorer/src/lit-logged-page/protectedApp/components/ui/TransactionToastContainer.tsx @@ -0,0 +1,50 @@ +/** + * TransactionToastContainer Component + * + * Displays transaction notifications with links to block explorer + */ + +import React from 'react'; +import { TransactionToast } from '../../types'; +import { formatTxHash } from '../../utils'; + +interface TransactionToastContainerProps { + toasts: TransactionToast[]; + onRemoveToast: (id: string) => void; +} + +export const TransactionToastContainer: React.FC = ({ + toasts, + onRemoveToast +}) => ( +
+ {toasts.map((toast) => ( +
+
+
+ {toast.type === 'success' ? '✅' : '❌'} {toast.message} +
+ +
+ +
+ ))} +
+); \ No newline at end of file diff --git a/apps/explorer/src/lit-logged-page/protectedApp/components/ui/index.ts b/apps/explorer/src/lit-logged-page/protectedApp/components/ui/index.ts new file mode 100644 index 0000000000..bd58576607 --- /dev/null +++ b/apps/explorer/src/lit-logged-page/protectedApp/components/ui/index.ts @@ -0,0 +1,10 @@ +/** + * UI Components Index + * + * Centralized exports for all UI components + */ + +export { LoadingSpinner } from './LoadingSpinner'; +export { RemoveButton } from './RemoveButton'; +export { TransactionToastContainer } from './TransactionToastContainer'; +export { ScopeCheckboxes } from './ScopeCheckboxes'; \ No newline at end of file diff --git a/apps/explorer/src/lit-logged-page/protectedApp/components/wallet/EncryptDecryptForm.tsx b/apps/explorer/src/lit-logged-page/protectedApp/components/wallet/EncryptDecryptForm.tsx new file mode 100644 index 0000000000..33181d1743 --- /dev/null +++ b/apps/explorer/src/lit-logged-page/protectedApp/components/wallet/EncryptDecryptForm.tsx @@ -0,0 +1,397 @@ +/** + * EncryptDecryptForm Component + * + * Form for encrypting and decrypting data with PKP + */ + +import React, { useState } from "react"; +import { useLitAuth } from "../../../../lit-login-modal/LitAuthProvider"; +import { UIPKP } from "../../types"; +import { LoadingSpinner } from "../ui/LoadingSpinner"; +import type { EncryptResponse } from "@lit-protocol/types"; + +// Default message constant +const DEFAULT_ENCRYPT_MESSAGE = "This is my secret message! 🤫"; + +interface EncryptDecryptFormProps { + selectedPkp: UIPKP | null; + disabled?: boolean; +} + +type ExtendedEncryptResponse = EncryptResponse & { + originalMessage: string; + pkpAddress: string; + timestamp: string; +}; + +export const EncryptDecryptForm: React.FC = ({ + selectedPkp, + disabled = false, +}) => { + const { user, services } = useLitAuth(); + const [messageToEncrypt, setMessageToEncrypt] = useState(DEFAULT_ENCRYPT_MESSAGE); + const [encryptedData, setEncryptedData] = useState(null); + const [decryptedMessage, setDecryptedMessage] = useState(""); + const [isEncrypting, setIsEncrypting] = useState(false); + const [isDecrypting, setIsDecrypting] = useState(false); + const [status, setStatus] = useState(""); + + const encryptData = async () => { + if ( + !services?.litClient || + !messageToEncrypt.trim() || + !user?.authContext + ) { + setStatus("No Lit client, message to encrypt, or auth context"); + return; + } + + setIsEncrypting(true); + setStatus("Encrypting data..."); + try { + const { createAccBuilder } = await import( + "@lit-protocol/access-control-conditions" + ); + + // Get the actual PKP address from the viem account + const chainConfig = services.litClient.getChainConfig().viemConfig; + const pkpViemAccount = await services.litClient.getPkpViemAccount({ + pkpPublicKey: selectedPkp?.pubkey || user?.pkpInfo?.pubkey, + authContext: user.authContext, + chainConfig: chainConfig, + }); + + // Create access control conditions using the basic pattern + const builder = createAccBuilder(); + const accs = builder + .requireWalletOwnership(pkpViemAccount.address) + .on("ethereum") + .build(); + + const encrypted = await services!.litClient.encrypt({ + dataToEncrypt: messageToEncrypt, + unifiedAccessControlConditions: accs, + chain: "ethereum", + }); + + setEncryptedData({ + ...encrypted, + originalMessage: messageToEncrypt, + pkpAddress: pkpViemAccount.address, + timestamp: new Date().toISOString(), + }); + setStatus("Data encrypted successfully!"); + } catch (error: any) { + console.error("Failed to encrypt data:", error); + setStatus(`Failed to encrypt data: ${error.message || error}`); + } finally { + setIsEncrypting(false); + } + }; + + const decryptData = async () => { + if (!user?.authData || !encryptedData || !services?.litClient) { + setStatus("No auth data, encrypted data, or Lit client"); + return; + } + + setIsDecrypting(true); + setStatus("Creating auth context for decryption..."); + try { + const { createAccBuilder } = await import( + "@lit-protocol/access-control-conditions" + ); + + // Use the same PKP address that was used for encryption + const pkpAddress = encryptedData.pkpAddress || selectedPkp?.ethAddress; + if (!pkpAddress) { + throw new Error("Cannot determine PKP address for decryption"); + } + + // Create the same access control conditions as used in encryption + const builder = createAccBuilder(); + const accs = builder + .requireWalletOwnership(pkpAddress) + .on("ethereum") + .build(); + + // Create a new authContext specifically for decryption with proper capabilities + setStatus("Creating auth context with decryption capabilities..."); + const decryptionAuthContext = + await services.authManager.createPkpAuthContext({ + authData: user.authData, + pkpPublicKey: selectedPkp?.pubkey || user?.pkpInfo?.pubkey, + authConfig: { + capabilityAuthSigs: [], + expiration: new Date( + Date.now() + 1000 * 60 * 60 * 24 + ).toISOString(), + statement: "", + domain: "", + resources: [ + ["pkp-signing", "*"], + ["lit-action-execution", "*"], + ["access-control-condition-decryption", "*"], + ], + }, + litClient: services.litClient, + }); + + setStatus("Decrypting data..."); + const decrypted = await services!.litClient.decrypt({ + data: encryptedData, + unifiedAccessControlConditions: accs, + authContext: decryptionAuthContext, + chain: "ethereum", + }); + + let decryptedText: string; + if (typeof decrypted.convertedData === "string") { + decryptedText = decrypted.convertedData; + } else if (decrypted.convertedData) { + try { + decryptedText = JSON.stringify(decrypted.convertedData); + } catch { + decryptedText = String(decrypted.convertedData); + } + } else { + decryptedText = new TextDecoder().decode(decrypted.decryptedData); + } + setDecryptedMessage(decryptedText); + setStatus("Data decrypted successfully!"); + } catch (error: any) { + console.error("Failed to decrypt data:", error); + setStatus(`Failed to decrypt data: ${error.message || error}`); + } finally { + setIsDecrypting(false); + } + }; + + return ( +
+

+ 🔐 Encrypt & Decrypt +

+

+ Encrypt data that only your PKP can decrypt. +

+ +