Add Chain 138 Snap: deploy/verify scripts, runbook, CI, security, version/health, token list validation

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
defiQUG
2026-02-11 12:45:43 -08:00
parent 0ade1c0c80
commit 8421c47b1c
90 changed files with 25862 additions and 0 deletions

View File

@@ -0,0 +1,9 @@
root = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true

View File

@@ -0,0 +1,42 @@
module.exports = {
root: true,
parserOptions: {
sourceType: 'module',
},
extends: ['@metamask/eslint-config'],
overrides: [
{
files: ['*.js'],
extends: ['@metamask/eslint-config-nodejs'],
},
{
files: ['*.ts', '*.tsx'],
extends: ['@metamask/eslint-config-typescript'],
},
{
files: ['*.test.ts', '*.test.js'],
extends: ['@metamask/eslint-config-jest'],
rules: {
'@typescript-eslint/no-shadow': [
'error',
{ allow: ['describe', 'expect', 'it'] },
],
},
},
],
ignorePatterns: [
'!.prettierrc.js',
'**/!.eslintrc.js',
'**/dist*/',
'**/*__GENERATED__*',
'**/build',
'**/public',
'**/.cache',
],
};

4
chain138-snap/.github/CODEOWNERS vendored Normal file
View File

@@ -0,0 +1,4 @@
# Lines starting with '#' are comments.
# Each line is a file pattern followed by one or more owners.
* @MetaMask/snaps-devs

15
chain138-snap/.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,15 @@
# Please see the documentation for all configuration options:
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
version: 2
updates:
- package-ecosystem: 'npm'
directory: '/'
schedule:
interval: 'daily'
time: '06:00'
allow:
- dependency-name: '@metamask/*'
target-branch: 'main'
versioning-strategy: 'increase-if-necessary'
open-pull-requests-limit: 10

View File

@@ -0,0 +1,110 @@
name: Build, Lint, and Test
on:
workflow_call:
jobs:
prepare:
name: Prepare
runs-on: ubuntu-latest
steps:
- name: Checkout and setup environment
uses: MetaMask/action-checkout-and-setup@v1
with:
is-high-risk-environment: false
cache-node-modules: true
- name: Enable pnpm
run: corepack enable && corepack prepare pnpm@9.15.0 --activate
- name: Install dependencies
run: pnpm install --frozen-lockfile
build:
name: Build
runs-on: ubuntu-latest
needs:
- prepare
steps:
- name: Checkout and setup environment
uses: MetaMask/action-checkout-and-setup@v1
with:
is-high-risk-environment: false
- name: Enable pnpm
run: corepack enable && corepack prepare pnpm@9.15.0 --activate
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build
run: pnpm run build
- name: Upload Snap build artifact
uses: actions/upload-artifact@v4
with:
name: snap-${{ runner.os }}-${{ github.sha }}
path: ./packages/snap/dist
retention-days: 1
- name: Require clean working directory
shell: bash
run: |
if ! git diff --exit-code; then
echo "Working tree dirty at end of job"
exit 1
fi
lint:
name: Lint
runs-on: ubuntu-latest
needs:
- prepare
steps:
- name: Checkout and setup environment
uses: MetaMask/action-checkout-and-setup@v1
with:
is-high-risk-environment: false
- name: Enable pnpm
run: corepack enable && corepack prepare pnpm@9.15.0 --activate
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Lint
run: pnpm run lint
- name: Require clean working directory
shell: bash
run: |
if ! git diff --exit-code; then
echo "Working tree dirty at end of job"
exit 1
fi
e2e-test:
name: End-to-end Test
runs-on: ubuntu-latest
needs:
- prepare
- build
steps:
- name: Checkout and setup environment
uses: MetaMask/action-checkout-and-setup@v1
with:
is-high-risk-environment: false
- name: Download Snap build artifact
uses: actions/download-artifact@v4
with:
name: snap-${{ runner.os }}-${{ github.sha }}
path: ./packages/snap/dist
- name: Enable pnpm
run: corepack enable && corepack prepare pnpm@9.15.0 --activate
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run Snap unit tests
run: pnpm --filter snap run test
- name: Install Playwright browsers
run: npx playwright install chromium --with-deps
- name: Run Playwright E2E (companion site)
run: pnpm run test:e2e
env:
CI: true
timeout-minutes: 8
- name: Require clean working directory
shell: bash
run: |
if ! git diff --exit-code; then
echo "Working tree dirty at end of job"
exit 1
fi

View File

@@ -0,0 +1,49 @@
# Build Snap companion site (pathPrefix /snap). Optional: set repository variable
# SNAP_VERIFY_BASE_URL (e.g. https://explorer.d-bis.org) to run verify-snap-site-vmid5000.sh after build.
# Optional: set secret GATSBY_SNAP_API_BASE_URL for production API in build.
name: Deploy Snap Site
on:
push:
branches: [main]
paths:
- 'packages/site/**'
- 'packages/snap/**'
- 'scripts/deploy-snap-site-to-vmid5000.sh'
- 'scripts/verify-snap-site-vmid5000.sh'
- '.github/workflows/deploy-snap-site.yml'
workflow_dispatch:
env:
GATSBY_PATH_PREFIX: /snap
jobs:
build-and-verify:
name: Build site and verify
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Enable pnpm
run: corepack enable && corepack prepare pnpm@9.15.0 --activate
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build Snap site (pathPrefix /snap)
run: pnpm --filter site run build
env:
GATSBY_PATH_PREFIX: /snap
GATSBY_SNAP_API_BASE_URL: ${{ secrets.GATSBY_SNAP_API_BASE_URL }}
- name: Upload site artifact
uses: actions/upload-artifact@v4
with:
name: snap-site-${{ github.sha }}
path: packages/site/public/
retention-days: 7
- name: Verify deployed Snap site (smoke)
if: ${{ vars.SNAP_VERIFY_BASE_URL != '' && vars.SNAP_VERIFY_BASE_URL != null }}
run: ./scripts/verify-snap-site-vmid5000.sh "${{ vars.SNAP_VERIFY_BASE_URL }}"

View File

@@ -0,0 +1,53 @@
name: Main
on:
push:
branches: [main]
pull_request:
jobs:
check-workflows:
name: Check workflows
runs-on: ubuntu-latest
steps:
- name: Checkout and setup environment
uses: MetaMask/action-checkout-and-setup@v1
with:
is-high-risk-environment: false
- name: Download actionlint
id: download-actionlint
run: bash <(curl https://raw.githubusercontent.com/rhysd/actionlint/7fdc9630cc360ea1a469eed64ac6d78caeda1234/scripts/download-actionlint.bash) 1.6.23
shell: bash
- name: Check workflow files
run: ${{ steps.download-actionlint.outputs.executable }} -color
shell: bash
build-lint-test:
name: Build, lint, and test
uses: ./.github/workflows/build-lint-test.yml
all-jobs-completed:
name: All jobs completed
runs-on: ubuntu-latest
needs:
- check-workflows
- build-lint-test
outputs:
PASSED: ${{ steps.set-output.outputs.PASSED }}
steps:
- name: Set PASSED output
id: set-output
run: echo "PASSED=true" >> "$GITHUB_OUTPUT"
all-jobs-pass:
name: All jobs pass
if: ${{ always() }}
runs-on: ubuntu-latest
needs: all-jobs-completed
steps:
- name: Check that all jobs have passed
run: |
passed="${{ needs.all-jobs-completed.outputs.PASSED }}"
if [[ $passed != "true" ]]; then
exit 1
fi

View File

@@ -0,0 +1,46 @@
name: MetaMask Security Code Scanner
on:
push:
branches:
- main
pull_request:
branches:
- main
workflow_dispatch:
jobs:
run-security-scan:
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
security-events: write
steps:
- name: MetaMask Security Code Scanner
uses: MetaMask/action-security-code-scanner@v1
with:
repo: ${{ github.repository }}
paths_ignored: |
.storybook/
'**/__snapshots__/'
'**/*.snap'
'**/*.stories.js'
'**/*.stories.tsx'
'**/*.test.browser.ts*'
'**/*.test.js*'
'**/*.test.ts*'
'**/fixtures/'
'**/jest.config.js'
'**/jest.environment.js'
'**/mocks/'
'**/test*/'
docs/
e2e/
merged-packages/
node_modules
storybook/
test*/
rules_excluded: example
project_metrics_token: ${{ secrets.SECURITY_SCAN_METRICS_TOKEN }}
slack_webhook: ${{ secrets.APPSEC_BOT_SLACK_WEBHOOK }}

82
chain138-snap/.gitignore vendored Normal file
View File

@@ -0,0 +1,82 @@
.DS_Store
dist/
build/
coverage/
.cache/
public/
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# Playwright
test-results/
playwright-report/
playwright/.cache/
# dotenv environment variables file
.env
.env.test
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*

5
chain138-snap/.npmrc Normal file
View File

@@ -0,0 +1,5 @@
# pnpm (default package manager). Yarn is also supported.
auto-install-peers=true
strict-peer-dependencies=false
# Use pnpm-lock.yaml; do not upgrade to npm/yarn lockfiles
package-lock=false

1
chain138-snap/.nvmrc Normal file
View File

@@ -0,0 +1 @@
lts/*

View File

@@ -0,0 +1,11 @@
// All of these are defaults except singleQuote, but we specify them
// for explicitness
const config = {
quoteProps: 'as-needed',
singleQuote: true,
tabWidth: 2,
trailingComma: 'all',
plugins: ['prettier-plugin-packagejson'],
};
export default config;

8
chain138-snap/.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,8 @@
{
"json.schemas": [
{
"fileMatch": ["snap.manifest.json"],
"url": "https://raw.githubusercontent.com/MetaMask/SIPs/main/assets/sip-9/snap.manifest.schema.json"
}
]
}

View File

@@ -0,0 +1,9 @@
/* eslint-disable */
//prettier-ignore
module.exports = {
name: "@yarnpkg/plugin-allow-scripts",
factory: function (require) {
var plugin=(()=>{var l=Object.defineProperty;var s=Object.getOwnPropertyDescriptor;var a=Object.getOwnPropertyNames;var c=Object.prototype.hasOwnProperty;var p=(t=>typeof require<"u"?require:typeof Proxy<"u"?new Proxy(t,{get:(o,e)=>(typeof require<"u"?require:o)[e]}):t)(function(t){if(typeof require<"u")return require.apply(this,arguments);throw new Error('Dynamic require of "'+t+'" is not supported')});var u=(t,o)=>{for(var e in o)l(t,e,{get:o[e],enumerable:!0})},f=(t,o,e,r)=>{if(o&&typeof o=="object"||typeof o=="function")for(let i of a(o))!c.call(t,i)&&i!==e&&l(t,i,{get:()=>o[i],enumerable:!(r=s(o,i))||r.enumerable});return t};var m=t=>f(l({},"__esModule",{value:!0}),t);var g={};u(g,{default:()=>d});var n=p("@yarnpkg/shell"),x={hooks:{afterAllInstalled:async()=>{let t=await(0,n.execute)("yarn run allow-scripts");t!==0&&process.exit(t)}}},d=x;return m(g);})();
return plugin;
}
};

File diff suppressed because one or more lines are too long

786
chain138-snap/.yarn/releases/yarn-3.2.1.cjs vendored Executable file

File diff suppressed because one or more lines are too long

17
chain138-snap/.yarnrc.yml Normal file
View File

@@ -0,0 +1,17 @@
enableScripts: false
enableTelemetry: 0
logFilters:
- code: YN0004
level: discard
nodeLinker: node-modules
plugins:
- path: .yarn/plugins/@yarnpkg/plugin-workspace-tools.cjs
spec: '@yarnpkg/plugin-workspace-tools'
- path: .yarn/plugins/@yarnpkg/plugin-allow-scripts.cjs
spec: 'https://raw.githubusercontent.com/LavaMoat/LavaMoat/main/packages/yarn-plugin-allow-scripts/bundles/@yarnpkg/plugin-allow-scripts.js'
yarnPath: .yarn/releases/yarn-3.2.1.cjs

View File

@@ -0,0 +1,129 @@
# Deploy Chain 138 Snap site to VMID 5000 (Production)
The Snap companion site can be published on the same host as the explorer (VMID 5000) at **https://explorer.d-bis.org/snap/** and is linked from the explorer navbar ("MetaMask Snap").
## Prerequisites
- Site built with **pathPrefix** `/snap` (so assets load under `/snap/`).
- Access to Proxmox host that runs VMID 5000 (e.g. `pct` or SSH to `PROXMOX_HOST_R630_02`).
## 1. Build for production (pathPrefix /snap)
From the Chain 138 Snap repo root:
```bash
GATSBY_PATH_PREFIX=/snap pnpm --filter site run build
```
This writes the static site into `packages/site/public/` with asset paths prefixed by `/snap/`.
## 2. Deploy to VMID 5000
**Option A build and deploy in one go:**
```bash
./scripts/deploy-snap-site-to-vmid5000.sh --build
```
**Production build (market/bridge/swap from live API):** set `GATSBY_SNAP_API_BASE_URL` when building:
```bash
GATSBY_SNAP_API_BASE_URL=https://your-token-aggregation-api.com ./scripts/deploy-snap-site-to-vmid5000.sh --build
```
**Option B deploy an existing build:**
```bash
./scripts/deploy-snap-site-to-vmid5000.sh
```
The script:
- Builds the site (only if `--build` is passed).
- Packs `packages/site/public/` into a tarball and deploys it to **/var/www/html/snap/** on VMID 5000.
- Works when run: **inside** the VM (direct), **on the Proxmox host** (`pct exec`), or **from a remote machine** (SSH to Proxmox, then `pct`). Set `PROXMOX_HOST_R630_02` (default `192.168.11.12`) when running remotely.
## 3. Nginx on VMID 5000
Nginx must serve the `/snap/` path so `/snap` and `/snap/` return 200. **One command from the Proxmox host** (or from a machine with SSH to the host):
```bash
cd explorer-monorepo
./scripts/apply-nginx-snap-vmid5000.sh
```
This runs `fix-nginx-serve-custom-frontend.sh` inside VMID 5000 (via `pct` or SSH). Alternatively, run the fix script **inside VMID 5000**:
- **`explorer-monorepo/scripts/fix-nginx-serve-custom-frontend.sh`** run inside the VM. It configures (among other things):
- `location = /snap` and `location /snap/` → alias `/var/www/html/snap/`, SPA fallback to `/snap/index.html`.
If you manage nginx by hand, add inside the HTTPS `server` block for explorer.d-bis.org:
```nginx
location /snap/ {
alias /var/www/html/snap/;
try_files $uri $uri/ /snap/index.html;
add_header Cache-Control "no-store, no-cache, must-revalidate";
}
```
Then reload nginx.
## 4. Explorer integration
The explorer frontend (`explorer-monorepo/frontend/public/index.html`) has a nav link **"MetaMask Snap"** pointing to `/snap/`. After deploying the explorer frontend to VMID 5000 (e.g. `explorer-monorepo/scripts/deploy-frontend-to-vmid5000.sh`), that link will open the Snap site at https://explorer.d-bis.org/snap/.
## 5. Production Snap and API URL
- **Snap**: For production, the Snap is typically installed from the npm package or a published Snap ID; the companion site at `/snap/` is the install/connect page.
- **API**: Set `GATSBY_SNAP_API_BASE_URL` when building the site (e.g. in `packages/site/.env.production`) to your token-aggregation API base URL so the Market data, Bridge, and Swap quote cards work. Rebuild and redeploy after changing it.
- **Token / bridge list URLs:** If you use GitHub (or other) JSON URLs for token list, bridge list, or networks, pin them to a tag or commit SHA for reproducible deploys. Validate: `./scripts/validate-token-lists.sh <URL1> [URL2] ...`.
## Verification checks
After deploy, the script runs:
- `/var/www/html/snap/index.html` exists in the VM
- Nginx config has `location /snap/`
- `http://localhost/snap/` returns 200
- Response body contains Snap app content (Connect|Snap|MetaMask)
**Standalone verify (Snap only):**
```bash
cd metamask-integration/chain138-snap
./scripts/verify-snap-site-vmid5000.sh [BASE_URL]
# BASE_URL defaults to https://explorer.d-bis.org
```
**All VMID 5000 checks (explorer + API + Snap):**
```bash
cd explorer-monorepo
./scripts/verify-vmid5000-all.sh [BASE_URL]
```
This runs: Blockscout port 4000, nginx `/api/` and `/snap/`, public `/api/v2/stats`, `/api/v2/blocks`, `/api/v2/transactions`, explorer root `/`, Snap site `/snap/` (200 + content), and nginx config for `/snap/`.
## Quick reference
| Step | Command |
|------|--------|
| Build site (pathPrefix /snap) | `GATSBY_PATH_PREFIX=/snap pnpm --filter site run build` |
| Deploy (with build) | `./scripts/deploy-snap-site-to-vmid5000.sh --build` |
| Deploy (existing build) | `./scripts/deploy-snap-site-to-vmid5000.sh` |
| Update nginx on VMID 5000 | From host: `explorer-monorepo/scripts/apply-nginx-snap-vmid5000.sh` (or run `fix-nginx-serve-custom-frontend.sh` inside the VM) |
| Deploy explorer (incl. Snap link) | `explorer-monorepo/scripts/deploy-frontend-to-vmid5000.sh` |
| Verify Snap only | `./scripts/verify-snap-site-vmid5000.sh` |
| Verify all (explorer + API + Snap) | `explorer-monorepo/scripts/verify-vmid5000-all.sh` |
**URLs:** https://explorer.d-bis.org/snap/ and http://192.168.11.140/snap/ (replace IP if your VM differs). **Version/health:** https://explorer.d-bis.org/snap/version.json (build version and buildTime).
## CI (GitHub Actions)
The workflow **`.github/workflows/deploy-snap-site.yml`** runs on push to `main` (when site/snap/scripts change): it builds the site and uploads the artifact. To **run the Snap site smoke verify** after deploy, set a **repository variable** in GitHub:
- **Name:** `SNAP_VERIFY_BASE_URL`
- **Value:** e.g. `https://explorer.d-bis.org` (no trailing slash)
Then the workflow will run `verify-snap-site-vmid5000.sh` against that URL. Optional: set secret **`GATSBY_SNAP_API_BASE_URL`** so the CI build uses your production API URL.

View File

@@ -0,0 +1,136 @@
# E2E Preparation — Token-Aggregation and Companion Site
Complete these steps so that full end-to-end testing (market data, bridge routes, swap quotes) succeeds.
---
## 1. Token-aggregation service
The Snap and companion site call the token-aggregation API for networks, token list, bridge routes, and swap quotes. You need the service running and reachable.
### Option A: Local (recommended for E2E)
1. **Prerequisites**
- Node.js 20+
- PostgreSQL 14+ with TimescaleDB
- RPC URLs for Chain 138 and 651940 (e.g. from `.env.example`)
2. **Setup**
```bash
cd smom-dbis-138/services/token-aggregation
cp .env.example .env
# Edit .env: set DATABASE_URL, CHAIN_138_RPC_URL, CHAIN_651940_RPC_URL
```
3. **Database**
- Apply migrations (see `QUICK_START.md` or `QUICK_START_COMPLETE.md` in that repo), e.g.:
```bash
psql $DATABASE_URL -f ../../explorer-monorepo/backend/database/migrations/0011_token_aggregation_schema.up.sql
# plus 0012 if used
```
4. **Run**
```bash
npm install
npm run build
npm run dev
```
Service will listen on **http://localhost:3000**.
Verify: `curl http://localhost:3000/health` and `curl http://localhost:3000/api/v1/networks`.
### Option B: Docker
```bash
cd smom-dbis-138/services/token-aggregation
# Ensure .env exists with DATABASE_URL etc.
docker-compose up -d
# API on http://localhost:3000
```
### Option C: Deployed / staging
Use your deployed token-aggregation base URL (e.g. `https://your-token-aggregation-api.com`). Ensure CORS allows the Snap/site origin and the endpoints below respond:
- `GET /api/v1/networks`
- `GET /api/v1/report/token-list`
- `GET /api/v1/bridge/routes`
- `GET /api/v1/quote`
- `GET /api/v1/tokens` (for market summary)
---
## 2. Companion site environment
The companion site passes `apiBaseUrl` to the Snap so that market data, bridge, and swap quote cards work.
1. **Create env file**
```bash
cd metamask-integration/chain138-snap/packages/site
cp .env.production.dist .env
# or .env.production for production build
```
2. **Set API base URL**
- Local token-aggregation: `GATSBY_SNAP_API_BASE_URL=http://localhost:3000`
- Deployed: `GATSBY_SNAP_API_BASE_URL=https://your-token-aggregation-api.com`
Do not add a trailing slash.
3. **Restart the site** if it is already running so the variable is picked up (Gatsby reads env at build/start).
---
## 3. Run Snap + site
From the Chain 138 Snap monorepo root:
```bash
cd metamask-integration/chain138-snap
pnpm run start
```
- Site and Snap are served at **http://localhost:8000**.
- Open that URL in a browser where **MetaMask Flask** is installed.
---
## 4. Manual E2E checklist
Use the [E2E testing checklist (MetaMask Flask)](TESTING_INSTRUCTIONS.md#e2e-testing-checklist-metamask-flask) in `TESTING_INSTRUCTIONS.md` and complete every item (environment, install Snap, RPC methods, companion site cards).
---
## 5. Optional: automated E2E (Playwright)
To run the automated E2E tests (site loads and shows Snap Connect UI):
```bash
cd metamask-integration/chain138-snap
pnpm install
npx playwright install
pnpm run test:e2e
```
- First time: run `npx playwright install` to install browser binaries.
- `test:e2e` starts the dev server if needed and runs Playwright against http://localhost:8000.
- The **site must compile successfully** for E2E to pass (if Gatsby fails to build, fix the dev environment first).
- It does **not** drive MetaMask Flask; for full install/connect flow you must run the manual checklist.
- Optional: `pnpm run test:e2e:ui` to open the Playwright UI.
---
## 6. Run E2E against deployed Snap site
To run Playwright (or manual checks) against the **deployed** Snap site (e.g. https://explorer.d-bis.org/snap/):
1. **Playwright against a URL:** Set the base URL and run tests (if your Playwright config supports it), e.g.:
- Add a second config or override in `playwright.config.ts`: `baseURL: process.env.SNAP_BASE_URL || 'http://localhost:8000'`.
- Run: `SNAP_BASE_URL=https://explorer.d-bis.org/snap pnpm run test:e2e` (after adding `baseURL` to the config).
- Or run the manual E2E checklist in TESTING_INSTRUCTIONS.md while opening https://explorer.d-bis.org/snap/ in the browser.
2. **Environment:** The deployed site must be built with `GATSBY_SNAP_API_BASE_URL` set to the production token-aggregation URL for Market, Bridge, and Swap cards to work. If not set, those cards will show "Set GATSBY_SNAP_API_BASE_URL".
3. **CI:** You can run `scripts/verify-snap-site-vmid5000.sh https://explorer.d-bis.org` in CI as a smoke test after deploy (no MetaMask required). Set repo variable `SNAP_VERIFY_BASE_URL` to enable the verify step in `.github/workflows/deploy-snap-site.yml`.
---
**Last updated:** 2026-02-11

View File

@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2022 ConsenSys Software Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View File

@@ -0,0 +1,16 @@
MIT No Attribution
Copyright 2022 ConsenSys Software Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy of this
software and associated documentation files (the "Software"), to deal in the Software
without restriction, including without limitation the rights to use, copy, modify,
merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@@ -0,0 +1,29 @@
# Package manager
**Default:** [pnpm](https://pnpm.io) (fast, disk-efficient, single lockfile).
**Preferred alternative:** [Yarn](https://yarnpkg.com) is supported; use it if you prefer.
## Setup
1. **pnpm (default)**
- Ensure [Corepack](https://nodejs.org/api/corepack.html) is enabled: `corepack enable`
- Install: `pnpm install`
- Scripts: `pnpm run build`, `pnpm run start`, `pnpm run test`, etc.
- Commit `pnpm-lock.yaml` so CI and others use the same dependency tree.
2. **Yarn (alternative)**
- Use existing `.yarnrc.yml` and Yarn 3.2.1.
- Install: `yarn install`
- Scripts: `yarn build`, `yarn start`, `yarn test`, etc.
- Root `packageManager` is set to pnpm; if you use Yarn only, run `yarn install` and ignore `pnpm-lock.yaml`.
## CI
GitHub Actions use **pnpm** by default: `corepack prepare pnpm@9.15.0 --activate` then `pnpm install --frozen-lockfile` and `pnpm run build` / `pnpm run lint` / `pnpm --filter snap run test`. **Before the first CI run** (or after adding dependencies), run `pnpm install` locally and commit `pnpm-lock.yaml` so CI has a lockfile.
## Recommendations
- Use **pnpm** for consistency with CI and docs.
- Use **Yarn** if your tooling or team already standardizes on it; scripts in `package.json` are written for pnpm but Yarn can run the same targets (`yarn build`, etc.) from the root.
- Do not mix lockfiles in the same branch: use either `pnpm-lock.yaml` or `yarn.lock`, not both, to avoid drift.

65
chain138-snap/README.md Normal file
View File

@@ -0,0 +1,65 @@
# Chain 138 Snap (MetaMask)
This Snap provides **Chain 138** (DeFi Oracle Meta Mainnet) and **ALL Mainnet** (651940) support in MetaMask: network params, token list, market data (prices), swap quotes, and CCIP bridge routes. It reads configuration from a **token-aggregation** (or compatible) API.
For detailed development and testing, see [TESTING_INSTRUCTIONS.md](TESTING_INSTRUCTIONS.md). For implementation phases and backend APIs, see [docs/04-configuration/metamask/SNAP_IMPLEMENTATION_ROADMAP.md](../../docs/04-configuration/metamask/SNAP_IMPLEMENTATION_ROADMAP.md) in the repo root.
**Integrators:** Market data, swap quote, and bridge route features require the dApp to pass `apiBaseUrl` (the token-aggregation service base URL) when invoking the Snap. Set `GATSBY_SNAP_API_BASE_URL` on the companion site so the demo page works.
This Snap targets the **latest stable MetaMask Snap SDK** (`@metamask/snaps-sdk`).
## Snaps is pre-release software
To interact with (your) Snaps, you will need to install [MetaMask Flask](https://metamask.io/flask/),
a canary distribution for developers that provides access to upcoming features.
## Getting Started
Clone the template-snap repository [using this template](https://github.com/MetaMask/template-snap-monorepo/generate)
and set up the development environment.
**Default (pnpm):**
```shell
pnpm install && pnpm start
```
**Alternative (yarn):**
```shell
yarn install && yarn start
```
See [PACKAGE_MANAGER.md](PACKAGE_MANAGER.md) for details.
## Cloning
This repository contains GitHub Actions that you may find useful, see
`.github/workflows` and [Releasing & Publishing](https://github.com/MetaMask/template-snap-monorepo/edit/main/README.md#releasing--publishing)
below for more information.
If you clone or create this repository outside the MetaMask GitHub organization,
you probably want to run `./scripts/cleanup.sh` to remove some files that will
not work properly outside the MetaMask GitHub organization.
If you don't wish to use any of the existing GitHub actions in this repository,
simply delete the `.github/workflows` directory.
## Contributing
### Testing and Linting
**pnpm (default):** `pnpm run test`, `pnpm run lint`, `pnpm run lint:fix`
**yarn:** `yarn test`, `yarn lint`, `yarn lint:fix`
- **Unit tests:** `pnpm run test` (Snap Jest tests).
- **E2E (Playwright):** `pnpm run test:e2e` — starts the dev server if needed and runs companion-site E2E tests. First time run `npx playwright install`. See [TESTING_INSTRUCTIONS.md](TESTING_INSTRUCTIONS.md) and [E2E_PREPARATION.md](E2E_PREPARATION.md) for full manual E2E (MetaMask Flask) and token-aggregation setup.
### Using NPM packages with scripts
Scripts are disabled by default for security reasons. If you need to use NPM
packages with scripts, run **pnpm run allow-scripts** (or **yarn allow-scripts auto**) and enable the
script in the `lavamoat.allowScripts` section of `package.json`.
See the documentation for [@lavamoat/allow-scripts](https://github.com/LavaMoat/LavaMoat/tree/main/packages/allow-scripts)
for more information.

49
chain138-snap/RUNBOOK.md Normal file
View File

@@ -0,0 +1,49 @@
# Chain 138 Snap Runbook
Quick reference for building, deploying, and verifying the Snap companion site.
## Build
```bash
GATSBY_PATH_PREFIX=/snap GATSBY_BUILD_SHA=$(git rev-parse --short HEAD) pnpm --filter site run build
# Production API (market/bridge/swap):
# GATSBY_SNAP_API_BASE_URL=https://your-token-aggregation-api.com
```
## Deploy to VMID 5000
```bash
./scripts/deploy-snap-site-to-vmid5000.sh --build # build + deploy
GATSBY_SNAP_API_BASE_URL=https://your-api.com ./scripts/deploy-snap-site-to-vmid5000.sh --build # production API
./scripts/deploy-snap-site-to-vmid5000.sh # deploy existing build
```
## Verify
```bash
./scripts/verify-snap-site-vmid5000.sh [BASE_URL]
# Full explorer + API + Snap:
# cd ../explorer-monorepo && ./scripts/verify-vmid5000-all.sh [BASE_URL]
```
## Rollback
See `explorer-monorepo/RUNBOOK.md` (rollback from VM or from host using `/tmp/snap-site-last.tar`).
## Nginx
From Proxmox host: `cd explorer-monorepo && ./scripts/apply-nginx-snap-vmid5000.sh`. Or inside VMID 5000: `explorer-monorepo/scripts/fix-nginx-serve-custom-frontend.sh`. Ensures `/snap` and `/snap/` return 200 with content.
## Token / bridge list validation
```bash
./scripts/validate-token-lists.sh [URL1] [URL2] ...
# Or set TOKEN_LIST_JSON_URL, BRIDGE_LIST_JSON_URL, NETWORKS_JSON_URL
```
## URLs
- Production: https://explorer.d-bis.org/snap/
- Version/health: https://explorer.d-bis.org/snap/version.json
See also `DEPLOY_VMID5000.md` and `explorer-monorepo/RUNBOOK.md`.

33
chain138-snap/SECURITY.md Normal file
View File

@@ -0,0 +1,33 @@
# Security
## HTTPS only
- The Snap companion site is intended to be served over **HTTPS** (e.g. https://explorer.d-bis.org/snap/). Avoid mixed content: ensure all API and asset URLs use HTTPS when the page is loaded over HTTPS.
- Explorer and token-aggregation API should be HTTPS in production.
## Token-aggregation API (public)
If the token-aggregation API is publicly reachable:
- **CORS:** Configure allowed origins; avoid `*` in production if you need to restrict callers.
- **Rate limiting:** Consider rate limiting per IP or per key to reduce abuse.
- **Secrets:** Do not put API keys or admin credentials in the Snap or companion site frontend. Use env or a secrets manager for deploy/CI.
## Snap permissions
The Snap requests minimal permissions. Current (see `packages/snap/snap.manifest.json`):
- **snap_dialog:** Show dialogs (e.g. bridge routes, token list URL).
- **endowment:rpc** (dapps: true, snaps: false): Handle RPC from dApps.
- **endowment:network-access:** Fetch token list, bridge routes, market data from the configured API/URLs.
Only add permissions that the Snap actually needs. Document why each permission is required (e.g. in this file or in the Snap description).
## Dependency and supply chain
- Keep dependencies up to date (`pnpm update`, Renovate/Dependabot).
- Run `pnpm run lint` and tests before release. Watch for MetaMask Snaps API changes.
## Reporting
Report security issues privately (e.g. maintainer contact or security policy) rather than in public issues.

View File

@@ -0,0 +1,386 @@
# Chain 138 Snap Testing Instructions
**Date:** 2026-01-30
**Status:** Built and ready for testing
---
## Prerequisites
1. **MetaMask Flask** (development version of MetaMask)
- Download: https://metamask.io/flask/
- Install as separate browser extension (won't conflict with regular MetaMask)
2. **Snap Development Server Running**
```bash
cd metamask-integration/chain138-snap
pnpm run start
```
(Or use **yarn start** if you prefer Yarn; see [PACKAGE_MANAGER.md](PACKAGE_MANAGER.md).)
- Server will start on http://localhost:8000
- Keep this terminal open
3. **For full E2E (API-dependent features):** Token-aggregation service and companion site env. See [E2E Preparation](E2E_PREPARATION.md).
---
## E2E Preparation
For full end-to-end success (market data, bridge routes, swap quotes), complete these before running the checklist:
1. **Start token-aggregation** (local or use a deployed URL).
- See [E2E_PREPARATION.md](E2E_PREPARATION.md) for steps (database, env, `npm run dev` or Docker).
- Note the API base URL (e.g. `http://localhost:3000` for local).
2. **Configure companion site env.**
- In `packages/site`, copy `.env.production.dist` to `.env` or `.env.production`.
- Set `GATSBY_SNAP_API_BASE_URL` to the token-aggregation base URL (e.g. `http://localhost:3000`).
- Restart the site if it is already running so the variable is picked up.
3. **Run Snap + site:** From repo root, `pnpm run start` (serves site and Snap on http://localhost:8000).
4. **Install MetaMask Flask** and use the checklist in [E2E testing checklist (MetaMask Flask)](#e2e-testing-checklist-metamask-flask) below.
**Automated E2E (optional):** Run `pnpm run test:e2e` to start the dev server (if needed) and run Playwright tests against the companion site. See [E2E_PREPARATION.md](E2E_PREPARATION.md#5-optional-automated-e2e-playwright). This does not drive MetaMask Flask.
---
## Testing Steps
### 1. Install the Snap
1. Open browser with MetaMask Flask installed
2. Navigate to **http://localhost:8000**
3. You should see the Snap installation page
4. Click **"Connect"** to install the Snap
5. MetaMask Flask will prompt for permissions - approve them
### 2. Test RPC Methods
Once installed, you can test the Snap's RPC methods:
#### Test `get_networks`
Pass `apiBaseUrl` (your token-aggregation service URL). Open browser console:
```javascript
await ethereum.request({
method: 'wallet_invokeSnap',
params: {
snapId: 'local:http://localhost:8000',
request: {
method: 'get_networks',
params: { apiBaseUrl: 'https://your-token-aggregation-api.com' },
},
},
});
```
**Expected response:** `{ version, networks: [ ... ] }` with full EIP-3085 params for Chain 138, Ethereum Mainnet, and ALL Mainnet.
**Optional:** You can pass `networksUrl` instead of (or without) `apiBaseUrl` to fetch networks from a JSON URL (e.g. GitHub raw):
```javascript
params: { networksUrl: 'https://raw.githubusercontent.com/org/repo/main/networks.json' }
```
#### Test `get_chain138_config`
Requires `apiBaseUrl`. Returns Chain 138 config from the API:
```javascript
await ethereum.request({
method: 'wallet_invokeSnap',
params: {
snapId: 'local:http://localhost:8000',
request: {
method: 'get_chain138_config',
params: { apiBaseUrl: 'https://your-token-aggregation-api.com' },
},
},
});
```
**Expected response:** Chain 138 params (chainId, chainName, rpcUrls, nativeCurrency, blockExplorerUrls, oracles).
**Optional:** Pass `networksUrl` instead of `apiBaseUrl` to use a remote networks JSON.
#### Test `get_chain138_market_chains`
```javascript
await ethereum.request({
method: 'wallet_invokeSnap',
params: {
snapId: 'local:http://localhost:8000',
request: {
method: 'get_chain138_market_chains',
params: {
apiBaseUrl: 'https://your-token-aggregation-api.com', // When deployed
},
},
},
});
```
**Expected response:**
```json
[
{
"chainId": 138,
"name": "DeFi Oracle Meta Mainnet",
"nativeToken": { "symbol": "ETH", "decimals": 18 },
"rpcUrl": "https://rpc-http-pub.d-bis.org",
"explorerUrl": "https://explorer.d-bis.org"
}
]
```
#### Test `get_market_summary`
Requires `apiBaseUrl` (token-aggregation service URL). Fetches tokens with optional market data (price, volume) for a chain. Optional `chainId` (default 138).
```javascript
await ethereum.request({
method: 'wallet_invokeSnap',
params: {
snapId: 'local:http://localhost:8000',
request: {
method: 'get_market_summary',
params: {
apiBaseUrl: 'https://your-token-aggregation-api.com',
chainId: 138, // optional, default 138
},
},
},
});
```
**Expected response:** `{ tokens: [ { symbol, name, address, market?: { priceUsd, volume24h } } ] }` or `{ error, tokens: [] }` on failure.
#### Test `show_market_data`
Requires `apiBaseUrl`. Opens a Snap dialog listing token symbols and USD prices from the token-aggregation API.
```javascript
await ethereum.request({
method: 'wallet_invokeSnap',
params: {
snapId: 'local:http://localhost:8000',
request: {
method: 'show_market_data',
params: {
apiBaseUrl: 'https://your-token-aggregation-api.com',
chainId: 138, // optional
},
},
},
});
```
**Expected:** A MetaMask dialog showing "Market data (Chain 138)" and token lines with prices. Without `apiBaseUrl`, the Snap shows an alert asking to pass it.
#### Test `get_token_list` and `get_token_list_url`
With `apiBaseUrl`: same pattern as above; the Snap calls `${apiBaseUrl}/api/v1/report/token-list` (optional `chainId` in params).
**Optional:** Pass `tokenListUrl` to fetch the token list from a JSON URL (e.g. GitHub raw):
```javascript
params: { tokenListUrl: 'https://raw.githubusercontent.com/org/repo/main/token-list.json', chainId: 138 }
```
#### Test `get_bridge_routes`
Requires `apiBaseUrl` or `bridgeListUrl`. Returns CCIP bridge routes (WETH9 / WETH10) and Chain 138 bridge addresses.
```javascript
await ethereum.request({
method: 'wallet_invokeSnap',
params: {
snapId: 'local:http://localhost:8000',
request: {
method: 'get_bridge_routes',
params: { apiBaseUrl: 'https://your-token-aggregation-api.com' },
},
},
});
```
**Expected response:** `{ routes: { weth9: {...}, weth10: {...} }, chain138Bridges: { weth9, weth10 } }`.
**Optional:** Pass `bridgeListUrl` instead of `apiBaseUrl` to fetch bridge list from a JSON URL.
#### Test `show_bridge_routes`
Requires `apiBaseUrl` or `bridgeListUrl`. Opens a Snap dialog with bridge route summary (WETH9/WETH10 → Ethereum Mainnet).
```javascript
await ethereum.request({
method: 'wallet_invokeSnap',
params: {
snapId: 'local:http://localhost:8000',
request: {
method: 'show_bridge_routes',
params: { apiBaseUrl: 'https://your-token-aggregation-api.com' },
},
},
});
```
#### Test `get_swap_quote`
Requires `apiBaseUrl`, `tokenIn`, `tokenOut`, `amountIn` (raw amount string). Optional `chainId` (default 138).
```javascript
await ethereum.request({
method: 'wallet_invokeSnap',
params: {
snapId: 'local:http://localhost:8000',
request: {
method: 'get_swap_quote',
params: {
apiBaseUrl: 'https://your-token-aggregation-api.com',
chainId: 138,
tokenIn: '0x...',
tokenOut: '0x...',
amountIn: '1000000000000000000',
},
},
},
});
```
**Expected response:** `{ amountOut: string | undefined, error?: string, poolAddress?: string }`.
#### Test `show_swap_quote`
Same params as `get_swap_quote`. Opens a Snap dialog with the quote (In/Out raw amounts).
#### Test `hello` (basic test)
```javascript
await ethereum.request({
method: 'wallet_invokeSnap',
params: {
snapId: 'local:http://localhost:8000',
request: {
method: 'hello',
},
},
});
```
**Expected response:**
```json
"Hello from Chain 138 Snap!"
```
---
## E2E testing checklist (MetaMask Flask)
Use this checklist for full manual E2E testing:
1. **Environment**
- [ ] MetaMask Flask installed
- [ ] Snap dev server running: `pnpm run start` (or `yarn start`) in `metamask-integration/chain138-snap`
- [ ] For API-dependent tests: token-aggregation service reachable. Set `apiBaseUrl` to your deployment (e.g. `https://your-token-aggregation-api.com`) or a local/staging URL (e.g. `http://localhost:3000` if running token-aggregation locally).
2. **Install Snap**
- [ ] Open http://localhost:8000 in the browser
- [ ] Click **Connect** and approve Snap installation in MetaMask Flask
3. **RPC methods (apiBaseUrl or optional networksUrl / tokenListUrl / bridgeListUrl)**
- [ ] `hello`
- [ ] `get_networks` (apiBaseUrl or networksUrl)
- [ ] `get_chain138_config` (apiBaseUrl or networksUrl)
- [ ] `get_chain138_market_chains` (apiBaseUrl)
- [ ] `get_token_list`, `get_token_list_url` (apiBaseUrl or tokenListUrl; optional chainId)
- [ ] `get_oracles` (apiBaseUrl), `show_dynamic_info` (apiBaseUrl or networksUrl/tokenListUrl)
- [ ] `get_market_summary`, `show_market_data` (apiBaseUrl; optional chainId)
- [ ] `get_bridge_routes`, `show_bridge_routes` (apiBaseUrl or bridgeListUrl)
- [ ] `get_swap_quote`, `show_swap_quote` (apiBaseUrl, tokenIn, tokenOut, amountIn; optional chainId)
4. **Companion site cards**
- [ ] Set `GATSBY_SNAP_API_BASE_URL` in `.env` (copy from `.env.production.dist` and fill) so the site passes apiBaseUrl to the Snap.
- [ ] **Market data:** "Show market data" opens Snap dialog; "Fetch market summary" displays tokens/prices below.
- [ ] **Bridge:** "Show bridge routes" opens Snap dialog with CCIP routes.
- [ ] **Swap quote:** Enter token In/Out addresses and amount (raw), then "Get quote" shows amountOut; "Show quote in Snap" opens dialog.
---
## Troubleshooting
### Snap not appearing in MetaMask Flask
- Ensure dev server is running on port 8000
- Check browser console for errors
- Try refreshing the page
### Permission errors
- Snap needs `endowment:network-access` for API calls
- Check `snap.manifest.json` has correct permissions
### API calls failing
- Ensure `apiBaseUrl` is provided for methods that need it, or use the optional URL params: `networksUrl`, `tokenListUrl`, `bridgeListUrl` (see RPC method sections above).
- On the companion site, set `GATSBY_SNAP_API_BASE_URL` in `.env` or `.env.production` (see `packages/site/.env.production.dist`) so Market data and other API-dependent cards work.
- Check CORS settings on the token-aggregation API server (it uses `cors()` by default).
- Verify API endpoint is accessible (e.g. token-aggregation and, for full testing, bridge/quote endpoints).
---
## Next Steps
### After Testing
1. **Fix any bugs** found during testing
2. **Submit to Snap directory** when ready (see Publishing below)
### Publishing
**Checklist before publishing:**
- [ ] All manual E2E checklist items above completed and passing.
- [ ] Token-aggregation (or your API) deployed and stable; production `apiBaseUrl` known.
- [ ] Snap built with no errors; `prepublishOnly` has run (updates manifest shasum).
- [ ] `packages/snap/package.json`: `name` and `publishConfig` (e.g. `"access": "public"`) correct for npm.
- [ ] Integrator docs updated: dApps/site must pass `apiBaseUrl` (or optional `networksUrl` / `tokenListUrl` / `bridgeListUrl`) for market data, bridge, and swap quote.
**Snap directory submission checklist:**
- [ ] All manual E2E checklist items in this doc completed and passing.
- [ ] Snap built with `pnpm run build`; `prepublishOnly` has run (manifest shasum updated).
- [ ] `packages/snap/package.json`: `name`, `version`, `publishConfig` (e.g. `"access": "public"`) correct for npm.
- [ ] `snap.manifest.json`: `description`, `proposedName`, `initialPermissions` match what the Snap uses; `source.location.npm` will be valid after publish.
- [ ] Snap package published to npm so the manifest npm source is resolvable.
- [ ] [MetaMask Snap publishing guide](https://docs.metamask.io/snaps/how-to/publish-a-snap/) followed: register Snap (package name or bundle URL), description, category, permissions.
- [ ] Integrator docs: dApps/site must pass `apiBaseUrl` (or optional `networksUrl` / `tokenListUrl` / `bridgeListUrl`) for market data, bridge, swap quote.
**Steps to publish to MetaMask Snap directory:**
1. **Build:** Run `pnpm run build` (or **yarn build**; see [PACKAGE_MANAGER.md](PACKAGE_MANAGER.md)). The `prepublishOnly` script updates the manifest shasum.
2. **Publish package:** Publish the Snap package to npm (e.g. from `packages/snap`) so `source.location.npm` in `snap.manifest.json` is valid.
3. **Snap directory:** Follow the [MetaMask Snap publishing guide](https://docs.metamask.io/snaps/how-to/publish-a-snap/) to register the Snap (package name or bundle URL, description, category, permissions).
**Production use:** After the Snap is published, the production Snap ID will be the npm package name (e.g. `npm:snap` or `npm:@your-org/chain138-snap`). For market data, swap quote, and bridge routes to work, dApps (and the companion site) must pass `apiBaseUrl` (your token-aggregation service URL) or the optional URLs when invoking the Snap. Document this for integrators; see "API calls failing" in Troubleshooting.
---
## Snap Capabilities
### Current Features
- ✅ Get Chain 138 configuration (`get_chain138_config`, `get_networks`)
- ✅ Token list and token list URL (`get_token_list`, `get_token_list_url`)
- ✅ Market data: `get_market_summary` (tokens with prices), `show_market_data` (dialog)
- ✅ Oracles config (`get_oracles`), dynamic info dialog (`show_dynamic_info`)
- ✅ Bridge routes (`get_bridge_routes`, `show_bridge_routes`) when bridge API is available
- ✅ Swap quote (`get_swap_quote`, `show_swap_quote`) when quote API is available
---
**Last updated:** 2026-02-11
**Status:** Ready for manual testing in MetaMask Flask; Playwright E2E available via `pnpm run test:e2e`

View File

@@ -0,0 +1,17 @@
import { test, expect } from '@playwright/test';
test.describe('Chain 138 Snap companion site', () => {
test('loads and shows Connect, Install, or Reconnect', async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded' });
// Connect and Reconnect are buttons; Install MetaMask Flask is a link
const connectOrReconnect = page.getByRole('button', { name: /Connect|Reconnect/i });
const installLink = page.getByRole('link', { name: /Install MetaMask Flask/i });
await expect(connectOrReconnect.or(installLink).first()).toBeVisible({ timeout: 30_000 });
});
test('page has Snap-related content', async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded' });
const body = page.locator('body');
await expect(body).toContainText(/Connect|template-snap|Get started|Snap|Install|Reconnect/i, { timeout: 30_000 });
});
});

View File

@@ -0,0 +1,69 @@
import base, { createConfig } from '@metamask/eslint-config';
import browser from '@metamask/eslint-config-browser';
import jest from '@metamask/eslint-config-jest';
import nodejs from '@metamask/eslint-config-nodejs';
import typescript from '@metamask/eslint-config-typescript';
const config = createConfig([
{
ignores: [
'**/build/',
'**/.cache/',
'**/dist/',
'**/docs/',
'**/public/',
'.yarn/',
],
},
{
extends: base,
languageOptions: {
sourceType: 'module',
parserOptions: {
tsconfigRootDir: import.meta.dirname,
},
},
settings: {
'import-x/extensions': ['.js', '.mjs'],
},
},
{
files: ['**/*.ts', '**/*.tsx'],
extends: typescript,
rules: {
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/no-shadow': ['error', { allow: ['Text'] }],
},
},
{
files: ['**/*.js', '**/*.cjs', 'packages/snap/snap.config.ts'],
extends: nodejs,
languageOptions: {
sourceType: 'script',
},
},
{
files: ['**/*.test.ts', '**/*.test.tsx', '**/*.test.js'],
extends: [jest, nodejs],
rules: {
'@typescript-eslint/unbound-method': 'off',
'jest/unbound-method': 'off',
},
},
{
files: ['packages/site/src/**'],
extends: [browser],
},
]);
export default config;

View File

@@ -0,0 +1,91 @@
{
"name": "root",
"version": "0.1.0",
"private": true,
"description": "",
"homepage": "https://github.com/MetaMask/template-snap-monorepo#readme",
"bugs": {
"url": "https://github.com/MetaMask/template-snap-monorepo/issues"
},
"repository": {
"type": "git",
"url": "git+https://github.com/MetaMask/template-snap-monorepo.git"
},
"license": "(MIT-0 OR Apache-2.0)",
"author": "",
"workspaces": [
"packages/*"
],
"scripts": {
"allow-scripts": "pnpm exec allow-scripts",
"build": "pnpm -r run build",
"lint": "pnpm run lint:eslint && pnpm run lint:misc --check",
"lint:eslint": "eslint . --cache",
"lint:fix": "pnpm run lint:eslint --fix && pnpm run lint:misc --write",
"lint:misc": "prettier '**/*.json' '**/*.md' '**/*.yml' '!.yarnrc.yml' --ignore-path .gitignore --no-error-on-unmatched-pattern",
"start": "pnpm -r --parallel run start",
"test": "pnpm --filter snap run build && pnpm --filter snap run test",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui"
},
"devDependencies": {
"@lavamoat/allow-scripts": "^3.4.2",
"@metamask/eslint-config": "^15.0.0",
"@metamask/eslint-config-browser": "^15.0.0",
"@metamask/eslint-config-jest": "^15.0.0",
"@metamask/eslint-config-nodejs": "^15.0.0",
"@metamask/eslint-config-typescript": "^15.0.0",
"@playwright/test": "^1.58.2",
"eslint": "^9.39.2",
"eslint-config-prettier": "^9.1.0",
"eslint-import-resolver-typescript": "^4.4.4",
"eslint-plugin-import-x": "^4.16.1",
"eslint-plugin-jest": "^28.8.3",
"eslint-plugin-jsdoc": "^50.2.4",
"eslint-plugin-n": "^17.23.2",
"eslint-plugin-prettier": "^5.5.5",
"eslint-plugin-promise": "^7.2.1",
"prettier": "^3.8.1",
"prettier-plugin-packagejson": "^3.0.0",
"sharp": "^0.34.5",
"typescript": "~5.9.3",
"typescript-eslint": "^8.54.0"
},
"packageManager": "pnpm@10.28.2",
"engines": {
"node": ">=18.6.0"
},
"pnpm": {
"onlyBuiltDependencies": [
"gatsby",
"sharp"
],
"peerDependencyRules": {
"allowedVersions": {
"eslint": "9",
"react": "18",
"@metamask/providers": "22",
"@metamask/snaps-controllers": "17",
"@typescript-eslint/eslint-plugin": "5",
"@typescript-eslint/parser": "5",
"eslint-plugin-jest": "28"
}
},
"overrides": {
"cookie": "^0.7.1",
"eslint-import-resolver-typescript": "^3.6.3",
"path-to-regexp@0.1.10": "^0.1.12",
"sharp": "^0.33.5",
"socket.io": "^4.8.1",
"ws": "^8.17.1",
"gatsby>eslint": "^7.32.0",
"eslint-config-react-app>eslint": "^7.32.0",
"eslint-plugin-flowtype>eslint": "^7.32.0"
}
},
"lavamoat": {
"allowScripts": {
"sharp": true
}
}
}

View File

@@ -0,0 +1,14 @@
/**
* Copy to .env or .env.production and set values.
* Required for E2E: GATSBY_SNAP_API_BASE_URL so Market data, Bridge, and Swap quote cards work.
*/
SNAP_ORIGIN=
# Token-aggregation API base URL for Snap (market data, token list, bridge routes, quotes).
# E2E (local): http://localhost:3000
# Production: set to your live token-aggregation API (e.g. https://api.example.com). No trailing slash.
GATSBY_SNAP_API_BASE_URL=
# Optional: build version shown in footer and in /snap/version.json (e.g. git short SHA).
# GATSBY_BUILD_SHA=abc1234
# GATSBY_APP_VERSION=1.0.0

View File

@@ -0,0 +1,43 @@
# TypeScript Example Snap Front-end
This project was bootstrapped with [Gatsby](https://www.gatsbyjs.com/).
## Available Scripts
In the project directory, you can run (from repo root: **pnpm** is default, **yarn** is supported):
### `pnpm run start` (or `yarn start`)
Runs the app in the development mode.\
Open [http://localhost:8000](http://localhost:8000) to view it in the browser.
The page will reload if you make edits.\
You will also see any lint errors in the console.
### `pnpm run build` (or `yarn build`)
Builds the app for production to the `public` folder.\
It correctly bundles React in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes.\
Your app is ready to be deployed!
See the section about [deployment](https://www.gatsbyjs.com/docs/how-to/previews-deploys-hosting/) for more information.
## Environment variables
Gatsby has built-in support for loading environment variables into the browser and Functions. Loading environment variables into Node.js requires a small code snippet.
In development, Gatsby will load environment variables from a file named `.env.development`. For builds, it will load from `.env.production`.
By default you can use the `SNAP_ORIGIN` variable (used in `src/config/snap.ts`) to define a production origin for you snap (eg. `npm:MyPackageName`). If not defined it will defaults to `local:http://localhost:8080`.
A `.env` file template is available, to use it rename `.env.production.dist` to `.env.production`
To learn more visit [Gatsby documentation](https://www.gatsbyjs.com/docs/how-to/local-development/environment-variables/)
## Learn More
You can learn more in the [Gatsby documentation](https://www.gatsbyjs.com/docs/).
To learn React, check out the [React documentation](https://reactjs.org/).

View File

@@ -0,0 +1,17 @@
import type { GatsbyBrowser } from 'gatsby';
import { StrictMode } from 'react';
import { App } from './src/App';
import { Root } from './src/Root';
export const wrapRootElement: GatsbyBrowser['wrapRootElement'] = ({
element,
}) => (
<StrictMode>
<Root>{element}</Root>
</StrictMode>
);
export const wrapPageElement: GatsbyBrowser['wrapPageElement'] = ({
element,
}) => <App>{element}</App>;

View File

@@ -0,0 +1,27 @@
import type { GatsbyConfig } from 'gatsby';
const config: GatsbyConfig = {
// When deployed under explorer at /snap/, set pathPrefix so assets load correctly.
pathPrefix: process.env.GATSBY_PATH_PREFIX || '/',
// This is required to make use of the React 17+ JSX transform.
jsxRuntime: 'automatic',
plugins: [
'gatsby-plugin-svgr',
'gatsby-plugin-styled-components',
{
resolve: 'gatsby-plugin-manifest',
options: {
name: 'Template Snap',
icon: 'src/assets/logo.svg',
/* eslint-disable @typescript-eslint/naming-convention */
theme_color: '#6F4CFF',
background_color: '#FFFFFF',
/* eslint-enable @typescript-eslint/naming-convention */
display: 'standalone',
},
},
],
};
export default config;

View File

@@ -0,0 +1,15 @@
import type { GatsbySSR } from 'gatsby';
import { StrictMode } from 'react';
import { App } from './src/App';
import { Root } from './src/Root';
export const wrapRootElement: GatsbySSR['wrapRootElement'] = ({ element }) => (
<StrictMode>
<Root>{element}</Root>
</StrictMode>
);
export const wrapPageElement: GatsbySSR['wrapPageElement'] = ({ element }) => (
<App>{element}</App>
);

View File

@@ -0,0 +1,58 @@
{
"name": "site",
"version": "0.1.0",
"private": true,
"license": "(MIT-0 OR Apache-2.0)",
"scripts": {
"allow-scripts": "pnpm --filter root run allow-scripts",
"prebuild": "node -e \"const fs=require('fs');const p=require('path');const d=p.join(__dirname,'static');if(!fs.existsSync(d))fs.mkdirSync(d,{recursive:true});const v=process.env.GATSBY_BUILD_SHA||process.env.GATSBY_APP_VERSION||'dev';fs.writeFileSync(p.join(d,'version.json'),JSON.stringify({version:v,buildTime:new Date().toISOString()}));\"",
"build": "GATSBY_TELEMETRY_DISABLED=1 gatsby build",
"clean": "rimraf public",
"lint": "pnpm run lint:eslint && pnpm run lint:misc --check",
"lint:eslint": "eslint . --cache",
"lint:fix": "pnpm run lint:eslint --fix && pnpm run lint:misc --write",
"lint:misc": "prettier '**/*.json' '**/*.md' '**/*.yml' '!.yarnrc.yml' --ignore-path ../../.gitignore --no-error-on-unmatched-pattern",
"start": "GATSBY_TELEMETRY_DISABLED=1 gatsby develop"
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"dependencies": {
"@metamask/providers": "^22.1.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-is": "^18.3.1",
"styled-components": "^5.3.11"
},
"devDependencies": {
"@svgr/webpack": "^6.5.1",
"@testing-library/dom": "^8.20.1",
"@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0",
"@types/jest": "^27.5.2",
"@types/react": "^18.2.4",
"@types/react-dom": "^18.2.4",
"@types/styled-components": "^5.1.36",
"caniuse-lite": "^1.0.30001767",
"eslint": "^9.39.2",
"gatsby": "^5.16.0",
"gatsby-plugin-manifest": "^5.16.0",
"gatsby-plugin-styled-components": "^6.14.0",
"gatsby-plugin-svgr": "3.0.0-beta.0",
"rimraf": "^5.0.5",
"typescript": "~5.9.3"
},
"engines": {
"node": ">=18.6.0"
}
}

View File

@@ -0,0 +1,34 @@
import type { FunctionComponent, ReactNode } from 'react';
import { useContext } from 'react';
import styled from 'styled-components';
import { Footer, Header } from './components';
import { GlobalStyle } from './config/theme';
import { ToggleThemeContext } from './Root';
const Wrapper = styled.div`
display: flex;
flex-direction: column;
width: 100%;
min-height: 100vh;
max-width: 100vw;
`;
export type AppProps = {
children: ReactNode;
};
export const App: FunctionComponent<AppProps> = ({ children }) => {
const toggleTheme = useContext(ToggleThemeContext);
return (
<>
<GlobalStyle />
<Wrapper>
<Header handleToggleClick={toggleTheme} />
{children}
<Footer />
</Wrapper>
</>
);
};

View File

@@ -0,0 +1,34 @@
import type { FunctionComponent, ReactNode } from 'react';
import { createContext, useState } from 'react';
import { ThemeProvider } from 'styled-components';
import { dark, light } from './config/theme';
import { MetaMaskProvider } from './hooks';
import { getThemePreference, setLocalStorage } from './utils';
export type RootProps = {
children: ReactNode;
};
type ToggleTheme = () => void;
export const ToggleThemeContext = createContext<ToggleTheme>(
(): void => undefined,
);
export const Root: FunctionComponent<RootProps> = ({ children }) => {
const [darkTheme, setDarkTheme] = useState(getThemePreference());
const toggleTheme: ToggleTheme = () => {
setLocalStorage('theme', darkTheme ? 'light' : 'dark');
setDarkTheme(!darkTheme);
};
return (
<ToggleThemeContext.Provider value={toggleTheme}>
<ThemeProvider theme={darkTheme ? dark : light}>
<MetaMaskProvider>{children}</MetaMaskProvider>
</ThemeProvider>
</ToggleThemeContext.Provider>
);
};

View File

@@ -0,0 +1,123 @@
<svg width="21" height="18" viewBox="0 0 21 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11.7263 9.8073L12.0438 5.43543L13.339 2.32193H7.79883L9.09409 5.43543L9.41153 9.8073L9.51117 11.1859L9.51812 14.5793H11.6174L11.6244 11.1859L11.7263 9.8073Z" fill="url(#paint0_linear_205_1983)"/>
<path d="M18.7542 8.96532L14.7896 7.80643L15.9875 9.61763L14.1987 13.1036L16.5645 13.0735H20.0819L18.7542 8.96532Z" fill="url(#paint1_linear_205_1983)"/>
<path d="M6.35065 7.80643L2.38609 8.96532L1.06766 13.0735H4.58502L6.94383 13.1036L5.15503 9.61763L6.35065 7.80643Z" fill="url(#paint2_linear_205_1983)"/>
<path d="M12.1203 11.9979L11.6198 14.5817L11.9836 14.8315L14.1964 13.1035L14.2636 11.3687L12.1203 11.9979Z" fill="url(#paint3_linear_205_1983)"/>
<path d="M6.88132 11.3687L6.94156 13.1035L9.15439 14.8315L9.51818 14.5817L9.01768 11.9979L6.88132 11.3687Z" fill="url(#paint4_linear_205_1983)"/>
<path d="M19.5952 5.98827L20.2324 2.89559L19.2778 0.0249634L11.9836 5.43543L14.7061 7.87581L18.7541 8.96762L19.6254 7.9452L19.2453 7.67225L19.8524 7.11941L19.389 6.75624L19.9961 6.29361L19.5952 5.98827Z" fill="url(#paint5_linear_205_1983)"/>
<path d="M1.54501 5.98827L0.907806 2.89559L1.86245 0.0249634L9.15669 5.43543L6.43409 7.87581L2.38612 8.9653L1.51489 7.94289L1.89489 7.66994L1.28781 7.11709L1.75123 6.75393L1.14415 6.2913L1.54501 5.98827Z" fill="url(#paint6_linear_205_1983)"/>
<path d="M5.15271 9.61761L6.88127 11.3687L6.94151 13.1035L5.15271 9.61761Z" fill="url(#paint7_linear_205_1983)"/>
<path d="M15.9875 9.61761L14.1987 13.1035L14.2659 11.3687L15.9875 9.61761Z" fill="url(#paint8_linear_205_1983)"/>
<path d="M14.5532 16.0806L11.9836 14.8315L12.1875 16.5062L12.1643 17.2117L14.5532 16.0806Z" fill="#FF9F5A"/>
<path d="M6.58707 16.0805L8.976 17.2094L8.95978 16.5039L9.15673 14.8291L6.58707 16.0805Z" fill="#FF9F5A"/>
<path d="M16.5621 13.0735L14.5532 16.0806L18.8514 17.2626L20.0795 13.0735H16.5621Z" fill="url(#paint9_linear_205_1983)"/>
<path d="M1.06766 13.0735L2.28877 17.2626L6.58699 16.0806L4.58502 13.0735H1.06766Z" fill="url(#paint10_linear_205_1983)"/>
<path d="M1.86243 0.0249634L9.15666 5.43543L8.01201 2.32193L1.86243 0.0249634Z" fill="url(#paint11_linear_205_1983)"/>
<path d="M13.1282 2.32193L11.9836 5.43543L19.2778 0.0249634L13.1282 2.32193Z" fill="url(#paint12_linear_205_1983)"/>
<path d="M6.35065 7.80644L5.15271 9.61764L9.41386 9.80732L9.15666 5.43546L6.35065 7.80644Z" fill="url(#paint13_linear_205_1983)"/>
<path d="M14.7895 7.80644L11.9835 5.43546L11.7263 9.80732L15.9875 9.61764L14.7895 7.80644Z" fill="url(#paint14_linear_205_1983)"/>
<path d="M6.58707 16.0806L9.15673 14.8315L6.9439 13.1035L6.58707 16.0806Z" fill="#9383FA"/>
<path d="M11.9836 14.8315L14.5532 16.0806L14.1964 13.1035L11.9836 14.8315Z" fill="#9383FA"/>
<path d="M14.1987 13.1035L14.5556 16.0806L16.5645 13.0735L14.1987 13.1035Z" fill="url(#paint15_linear_205_1983)"/>
<path d="M6.94157 13.1035L6.58473 16.0806L4.57581 13.0735L6.94157 13.1035Z" fill="url(#paint16_linear_205_1983)"/>
<path d="M15.9875 9.61761L11.7263 9.80729L12.1202 11.9978L12.7505 10.6794L14.2659 11.3687L15.9875 9.61761Z" fill="#9C5ADD"/>
<path d="M6.88127 11.3687L8.3897 10.6794L9.01995 11.9978L9.41386 9.80729L5.15271 9.61761L6.88127 11.3687Z" fill="#9C5ADD"/>
<path d="M12.1203 11.9979L11.7264 9.80731L11.6267 11.186L11.6198 14.5817L12.1203 11.9979Z" fill="url(#paint17_linear_205_1983)"/>
<path d="M9.02002 11.9979L9.52051 14.5817L9.51356 11.186L9.41393 9.80731L9.02002 11.9979Z" fill="url(#paint18_linear_205_1983)"/>
<path d="M12.1805 17.2001L12.1898 16.5038L11.9998 16.3373H9.14048L8.95743 16.5038L8.97365 17.2094L6.58472 16.0805L7.41887 16.7629L9.11731 17.938H12.0206L13.7191 16.7629L14.5532 16.0805L12.1805 17.2001Z" fill="#DF7554"/>
<path d="M11.9835 14.8315L11.6197 14.5816H9.52046L9.15667 14.8315L8.95972 16.5062L9.14277 16.3396H11.9998L12.1898 16.5062L11.9835 14.8315Z" fill="#161616" stroke="#161616" stroke-width="0.00668686" stroke-miterlimit="10" stroke-linejoin="round"/>
<path d="M12.7505 10.6794L12.1202 11.9978L14.2659 11.3687L12.7505 10.6794Z" fill="#161616"/>
<path d="M8.38975 10.6794L9.02 11.9978L6.88132 11.3687L8.38975 10.6794Z" fill="#161616"/>
<defs>
<linearGradient id="paint0_linear_205_1983" x1="10.5701" y1="2.32082" x2="10.5701" y2="14.5805" gradientUnits="userSpaceOnUse">
<stop stop-color="#FB7FE4"/>
<stop offset="1" stop-color="#BCABFB"/>
</linearGradient>
<linearGradient id="paint1_linear_205_1983" x1="17.1393" y1="7.80661" x2="17.1393" y2="13.103" gradientUnits="userSpaceOnUse">
<stop stop-color="#B65FE5"/>
<stop offset="1" stop-color="#ADA2FC"/>
</linearGradient>
<linearGradient id="paint2_linear_205_1983" x1="4.00472" y1="7.80661" x2="4.00472" y2="13.103" gradientUnits="userSpaceOnUse">
<stop stop-color="#B65FE5"/>
<stop offset="1" stop-color="#ADA2FC"/>
</linearGradient>
<linearGradient id="paint3_linear_205_1983" x1="11.6201" y1="13.0992" x2="14.266" y2="13.0992" gradientUnits="userSpaceOnUse">
<stop stop-color="#C8A8F7"/>
<stop offset="1" stop-color="#BAAAFB"/>
</linearGradient>
<linearGradient id="paint4_linear_205_1983" x1="6.88185" y1="13.0992" x2="9.52015" y2="13.0992" gradientUnits="userSpaceOnUse">
<stop stop-color="#C8A8F7"/>
<stop offset="1" stop-color="#BAAAFB"/>
</linearGradient>
<linearGradient id="paint5_linear_205_1983" x1="13.3344" y1="7.76991" x2="21.1442" y2="3.25322" gradientUnits="userSpaceOnUse">
<stop stop-color="#541758"/>
<stop offset="0.4286" stop-color="#4F206C"/>
<stop offset="0.62" stop-color="#4D2577"/>
<stop offset="1" stop-color="#8B45B6"/>
</linearGradient>
<linearGradient id="paint6_linear_205_1983" x1="6.4016" y1="7.72852" x2="-0.325901" y2="2.07384" gradientUnits="userSpaceOnUse">
<stop stop-color="#541758"/>
<stop offset="0.4286" stop-color="#4F206C"/>
<stop offset="0.62" stop-color="#4D2577"/>
<stop offset="1" stop-color="#8B45B6"/>
</linearGradient>
<linearGradient id="paint7_linear_205_1983" x1="5.15329" y1="11.3603" x2="6.94246" y2="11.3603" gradientUnits="userSpaceOnUse">
<stop stop-color="#BA86F3"/>
<stop offset="0.5281" stop-color="#B786F4"/>
<stop offset="0.8987" stop-color="#AE86F5"/>
<stop offset="1" stop-color="#AA86F6"/>
</linearGradient>
<linearGradient id="paint8_linear_205_1983" x1="14.1978" y1="11.3603" x2="15.987" y2="11.3603" gradientUnits="userSpaceOnUse">
<stop stop-color="#BA86F3"/>
<stop offset="0.5281" stop-color="#B786F4"/>
<stop offset="0.8987" stop-color="#AE86F5"/>
<stop offset="1" stop-color="#AA86F6"/>
</linearGradient>
<linearGradient id="paint9_linear_205_1983" x1="17.3174" y1="13.0727" x2="17.3174" y2="17.2628" gradientUnits="userSpaceOnUse">
<stop stop-color="#906EF7"/>
<stop offset="1" stop-color="#575ADE"/>
</linearGradient>
<linearGradient id="paint10_linear_205_1983" x1="3.82656" y1="13.0727" x2="3.82656" y2="17.2628" gradientUnits="userSpaceOnUse">
<stop stop-color="#906EF7"/>
<stop offset="1" stop-color="#575ADE"/>
</linearGradient>
<linearGradient id="paint11_linear_205_1983" x1="1.86302" y1="2.72998" x2="9.1562" y2="2.72998" gradientUnits="userSpaceOnUse">
<stop stop-color="#BB65ED"/>
<stop offset="1" stop-color="#E560E3"/>
</linearGradient>
<linearGradient id="paint12_linear_205_1983" x1="11.984" y1="2.72998" x2="19.2772" y2="2.72998" gradientUnits="userSpaceOnUse">
<stop stop-color="#E560E3"/>
<stop offset="0.2946" stop-color="#DE61E5"/>
<stop offset="0.7098" stop-color="#CC63E9"/>
<stop offset="1" stop-color="#BB65ED"/>
</linearGradient>
<linearGradient id="paint13_linear_205_1983" x1="7.28362" y1="5.43502" x2="7.28362" y2="9.80699" gradientUnits="userSpaceOnUse">
<stop stop-color="#DC69E6"/>
<stop offset="1" stop-color="#C289F3"/>
</linearGradient>
<linearGradient id="paint14_linear_205_1983" x1="13.8566" y1="5.43502" x2="13.8566" y2="9.80699" gradientUnits="userSpaceOnUse">
<stop stop-color="#DC69E6"/>
<stop offset="1" stop-color="#C289F3"/>
</linearGradient>
<linearGradient id="paint15_linear_205_1983" x1="15.3805" y1="9.23862" x2="15.3805" y2="17.035" gradientUnits="userSpaceOnUse">
<stop stop-color="#6848BA"/>
<stop offset="0.3363" stop-color="#6356D5"/>
</linearGradient>
<linearGradient id="paint16_linear_205_1983" x1="5.75984" y1="13.0726" x2="5.75984" y2="16.0807" gradientUnits="userSpaceOnUse">
<stop stop-color="#6848BA"/>
<stop offset="0.3363" stop-color="#6356D5"/>
</linearGradient>
<linearGradient id="paint17_linear_205_1983" x1="11.8703" y1="14.5805" x2="11.8703" y2="9.80701" gradientUnits="userSpaceOnUse">
<stop stop-color="#BA86F3"/>
<stop offset="0.5281" stop-color="#B786F4"/>
<stop offset="0.8987" stop-color="#AE86F5"/>
<stop offset="1" stop-color="#AA86F6"/>
</linearGradient>
<linearGradient id="paint18_linear_205_1983" x1="9.01979" y1="12.1938" x2="9.52017" y2="12.1938" gradientUnits="userSpaceOnUse">
<stop stop-color="#BA86F3"/>
<stop offset="0.5281" stop-color="#B786F4"/>
<stop offset="0.8987" stop-color="#AE86F5"/>
<stop offset="1" stop-color="#AA86F6"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 8.7 KiB

View File

@@ -0,0 +1,3 @@
<svg width="125" height="125" viewBox="0 0 125 125" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M88.5473 0.415527H36.5005C13.893 0.415527 0.415527 13.9484 0.415527 36.5446V88.5034C0.415527 111.1 13.893 124.57 36.5005 124.57H88.4852C111.093 124.57 124.57 111.1 124.57 88.5034V36.5446C124.632 13.9484 111.155 0.415527 88.5473 0.415527ZM59.8533 92.2281C59.8533 94.0904 58.9216 95.7665 57.3068 96.7597C56.4373 97.3184 55.5057 97.5667 54.5119 97.5667C53.7045 97.5667 52.8971 97.3805 52.0897 97.008L30.3518 86.1445C27.2464 84.5305 25.2589 81.3645 25.2589 77.8261V57.2785C25.2589 55.4161 26.1905 53.7401 27.8053 52.7468C29.4201 51.7536 31.3455 51.6915 33.0224 52.4985L54.7604 63.3621C57.9279 64.9761 59.9154 68.142 59.9154 71.6804V92.2281H59.8533ZM58.549 59.0166L35.1962 46.4149C33.5193 45.4838 32.4635 43.6835 32.4635 41.635C32.4635 39.6485 33.5193 37.7862 35.1962 36.855L58.549 24.2533C61.0333 22.9496 63.9524 22.9496 66.4368 24.2533L89.7895 36.855C91.4664 37.7862 92.5223 39.5864 92.5223 41.635C92.5223 43.6835 91.4664 45.4838 89.7895 46.4149L66.4368 59.0166C65.1946 59.6995 63.8282 60.0099 62.4618 60.0099C61.0954 60.0099 59.7912 59.6995 58.549 59.0166ZM99.789 77.8261C99.789 81.3645 97.8015 84.5925 94.634 86.1445L72.896 97.008C72.1507 97.3805 71.3433 97.5667 70.4738 97.5667C69.4801 97.5667 68.5484 97.3184 67.6789 96.7597C66.0641 95.7665 65.1325 94.0904 65.1325 92.2281V71.6804C65.1325 68.142 67.1199 64.914 70.2875 63.3621L92.0254 52.4985C93.7023 51.6915 95.6277 51.7536 97.2425 52.7468C98.8573 53.7401 99.789 55.4161 99.789 57.2785V77.8261Z" fill="#24272A"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,31 @@
<svg width="27" height="26" viewBox="0 0 27 26" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M25.2148 0.942841L15.1025 8.58272L16.983 4.08028L25.2148 0.942841Z" fill="#E17726" stroke="#E17726" stroke-width="0.269643" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M1.88965 0.942841L11.9119 8.65402L10.1215 4.08028L1.88965 0.942841Z" fill="#E27625" stroke="#E27625" stroke-width="0.269643" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M21.5739 18.6572L18.8833 22.854L24.6446 24.4737L26.2949 18.7488L21.5739 18.6572Z" fill="#E27625" stroke="#E27625" stroke-width="0.269643" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M0.819336 18.7488L2.4597 24.4737L8.21097 22.854L5.53038 18.6572L0.819336 18.7488Z" fill="#E27625" stroke="#E27625" stroke-width="0.269643" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7.90089 11.5674L6.30054 14.0325L12.0018 14.2974L11.8118 8.03265L7.90089 11.5674Z" fill="#E27625" stroke="#E27625" stroke-width="0.269643" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M19.2034 11.5674L15.2326 7.96133L15.1025 14.2973L20.8038 14.0325L19.2034 11.5674Z" fill="#E27625" stroke="#E27625" stroke-width="0.269643" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8.21094 22.854L11.6617 21.1529L8.69104 18.7896L8.21094 22.854Z" fill="#E27625" stroke="#E27625" stroke-width="0.269643" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M15.4426 21.1529L18.8834 22.854L18.4133 18.7896L15.4426 21.1529Z" fill="#E27625" stroke="#E27625" stroke-width="0.269643" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M18.8834 22.8541L15.4426 21.1529L15.7227 23.4347L15.6927 24.4024L18.8834 22.8541Z" fill="#D5BFB2" stroke="#D5BFB2" stroke-width="0.269643" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8.21094 22.8541L11.4116 24.4024L11.3916 23.4347L11.6617 21.1529L8.21094 22.8541Z" fill="#D5BFB2" stroke="#D5BFB2" stroke-width="0.269643" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M11.4717 17.282L8.61108 16.4263L10.6315 15.479L11.4717 17.282Z" fill="#233447" stroke="#233447" stroke-width="0.269643" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M15.6326 17.282L16.4728 15.479L18.5032 16.4263L15.6326 17.282Z" fill="#233447" stroke="#233447" stroke-width="0.269643" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8.21111 22.854L8.71122 18.6572L5.53052 18.7488L8.21111 22.854Z" fill="#CC6228" stroke="#CC6228" stroke-width="0.269643" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M18.3933 18.6572L18.8834 22.854L21.574 18.7488L18.3933 18.6572Z" fill="#CC6228" stroke="#CC6228" stroke-width="0.269643" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M20.8038 14.0325L15.1025 14.2974L15.6327 17.282L16.4728 15.479L18.5033 16.4264L20.8038 14.0325Z" fill="#CC6228" stroke="#CC6228" stroke-width="0.269643" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8.61117 16.4264L10.6316 15.479L11.4718 17.282L12.0019 14.2974L6.30066 14.0325L8.61117 16.4264Z" fill="#CC6228" stroke="#CC6228" stroke-width="0.269643" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6.30066 14.0325L8.69119 18.7896L8.61117 16.4264L6.30066 14.0325Z" fill="#E27525" stroke="#E27525" stroke-width="0.269643" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M18.5035 16.4264L18.4135 18.7896L20.804 14.0325L18.5035 16.4264Z" fill="#E27525" stroke="#E27525" stroke-width="0.269643" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12.0019 14.2974L11.4718 17.282L12.1419 20.8066L12.292 16.1615L12.0019 14.2974Z" fill="#E27525" stroke="#E27525" stroke-width="0.269643" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M15.1027 14.2974L14.8226 16.1513L14.9627 20.8066L15.6328 17.282L15.1027 14.2974Z" fill="#E27525" stroke="#E27525" stroke-width="0.269643" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M15.6327 17.282L14.9625 20.8065L15.4426 21.1529L18.4133 18.7896L18.5033 16.4263L15.6327 17.282Z" fill="#F5841F" stroke="#F5841F" stroke-width="0.269643" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8.61108 16.4263L8.6911 18.7896L11.6618 21.1529L12.1419 20.8065L11.4717 17.282L8.61108 16.4263Z" fill="#F5841F" stroke="#F5841F" stroke-width="0.269643" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M15.6926 24.4024L15.7226 23.4346L15.4625 23.2105H11.6417L11.3916 23.4346L11.4116 24.4024L8.21094 22.854L9.33118 23.7912L11.6017 25.3904H15.4925L17.773 23.7912L18.8833 22.854L15.6926 24.4024Z" fill="#C0AC9D" stroke="#C0AC9D" stroke-width="0.269643" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M15.4426 21.1529L14.9625 20.8065H12.1419L11.6618 21.1529L11.3917 23.4347L11.6418 23.2106H15.4626L15.7227 23.4347L15.4426 21.1529Z" fill="#161616" stroke="#161616" stroke-width="0.269643" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M25.6449 9.08186L26.4951 4.86464L25.2148 0.942841L15.4426 8.32806L19.2035 11.5674L24.5146 13.1463L25.6849 11.7507L25.1748 11.3738L25.985 10.62L25.3648 10.1311L26.175 9.4995L25.6449 9.08186Z" fill="#763E1A" stroke="#763E1A" stroke-width="0.269643" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M0.609375 4.86464L1.46957 9.08186L0.919443 9.4995L1.73962 10.1311L1.11949 10.62L1.92967 11.3738L1.41955 11.7507L2.58981 13.1463L7.90099 11.5674L11.6618 8.32806L1.88966 0.942841L0.609375 4.86464Z" fill="#763E1A" stroke="#763E1A" stroke-width="0.269643" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M24.5147 13.1463L19.2035 11.5674L20.8039 14.0325L18.4133 18.7896L21.574 18.7488H26.2951L24.5147 13.1463Z" fill="#F5841F" stroke="#F5841F" stroke-width="0.269643" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7.9009 11.5674L2.58973 13.1463L0.819336 18.7488H5.53038L8.69107 18.7896L6.30055 14.0325L7.9009 11.5674Z" fill="#F5841F" stroke="#F5841F" stroke-width="0.269643" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M15.1024 14.2974L15.4425 8.32806L16.9829 4.08029H10.1213L11.6617 8.32806L12.0018 14.2974L12.1318 16.1717L12.1418 20.8065H14.9624L14.9724 16.1717L15.1024 14.2974Z" fill="#F5841F" stroke="#F5841F" stroke-width="0.269643" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 6.1 KiB

View File

@@ -0,0 +1,124 @@
import type { ComponentProps } from 'react';
import styled from 'styled-components';
import { ReactComponent as FlaskFox } from '../assets/flask_fox.svg';
import { useMetaMask, useRequestSnap } from '../hooks';
import { shouldDisplayReconnectButton } from '../utils';
const Link = styled.a`
display: flex;
align-self: flex-start;
align-items: center;
justify-content: center;
font-size: ${(props) => props.theme.fontSizes.small};
border-radius: ${(props) => props.theme.radii.button};
border: 1px solid ${(props) => props.theme.colors.background?.inverse};
background-color: ${(props) => props.theme.colors.background?.inverse};
color: ${(props) => props.theme.colors.text?.inverse};
text-decoration: none;
font-weight: bold;
padding: 1rem;
cursor: pointer;
transition: all 0.2s ease-in-out;
&:hover {
background-color: transparent;
border: 1px solid ${(props) => props.theme.colors.background?.inverse};
color: ${(props) => props.theme.colors.text?.default};
}
${({ theme }) => theme.mediaQueries.small} {
width: 100%;
box-sizing: border-box;
}
`;
export const Button = styled.button`
display: flex;
align-self: flex-start;
align-items: center;
justify-content: center;
margin-top: auto;
${({ theme }) => theme.mediaQueries.small} {
width: 100%;
}
`;
const ButtonText = styled.span`
margin-left: 1rem;
`;
const ConnectedContainer = styled.div`
display: flex;
align-self: flex-start;
align-items: center;
justify-content: center;
font-size: ${(props) => props.theme.fontSizes.small};
border-radius: ${(props) => props.theme.radii.button};
border: 1px solid ${(props) => props.theme.colors.background?.inverse};
background-color: ${(props) => props.theme.colors.background?.inverse};
color: ${(props) => props.theme.colors.text?.inverse};
font-weight: bold;
padding: 1.2rem;
`;
const ConnectedIndicator = styled.div`
content: ' ';
width: 10px;
height: 10px;
border-radius: 50%;
background-color: green;
`;
export const InstallFlaskButton = () => (
<Link href="https://metamask.io/flask/" target="_blank">
<FlaskFox />
<ButtonText>Install MetaMask Flask</ButtonText>
</Link>
);
export const ConnectButton = (props: ComponentProps<typeof Button>) => {
return (
<Button {...props}>
<FlaskFox />
<ButtonText>Connect</ButtonText>
</Button>
);
};
export const ReconnectButton = (props: ComponentProps<typeof Button>) => {
return (
<Button {...props}>
<FlaskFox />
<ButtonText>Reconnect</ButtonText>
</Button>
);
};
export const SendHelloButton = (props: ComponentProps<typeof Button>) => {
return <Button {...props}>Send message</Button>;
};
export const HeaderButtons = () => {
const requestSnap = useRequestSnap();
const { isFlask, installedSnap } = useMetaMask();
if (!isFlask && !installedSnap) {
return <InstallFlaskButton />;
}
if (!installedSnap) {
return <ConnectButton onClick={requestSnap} />;
}
if (shouldDisplayReconnectButton(installedSnap)) {
return <ReconnectButton onClick={requestSnap} />;
}
return (
<ConnectedContainer>
<ConnectedIndicator />
<ButtonText>Connected</ButtonText>
</ConnectedContainer>
);
};

View File

@@ -0,0 +1,60 @@
import type { ReactNode } from 'react';
import styled from 'styled-components';
type CardProps = {
content: {
title?: string;
description: ReactNode;
button?: ReactNode;
};
disabled?: boolean;
fullWidth?: boolean;
};
const CardWrapper = styled.div<{
fullWidth?: boolean | undefined;
disabled?: boolean | undefined;
}>`
display: flex;
flex-direction: column;
width: ${({ fullWidth }) => (fullWidth ? '100%' : '250px')};
background-color: ${({ theme }) => theme.colors.card?.default};
margin-top: 2.4rem;
margin-bottom: 2.4rem;
padding: 2.4rem;
border: 1px solid ${({ theme }) => theme.colors.border?.default};
border-radius: ${({ theme }) => theme.radii.default};
box-shadow: ${({ theme }) => theme.shadows.default};
filter: opacity(${({ disabled }) => (disabled ? '.4' : '1')});
align-self: stretch;
${({ theme }) => theme.mediaQueries.small} {
width: 100%;
margin-top: 1.2rem;
margin-bottom: 1.2rem;
padding: 1.6rem;
}
`;
const Title = styled.h2`
font-size: ${({ theme }) => theme.fontSizes.large};
margin: 0;
${({ theme }) => theme.mediaQueries.small} {
font-size: ${({ theme }) => theme.fontSizes.text};
}
`;
const Description = styled.div`
margin-top: 2.4rem;
margin-bottom: 2.4rem;
`;
export const Card = ({ content, disabled = false, fullWidth }: CardProps) => {
const { title, description, button } = content;
return (
<CardWrapper fullWidth={fullWidth} disabled={disabled}>
{title && <Title>{title}</Title>}
<Description>{description}</Description>
{button}
</CardWrapper>
);
};

View File

@@ -0,0 +1,60 @@
import styled, { useTheme } from 'styled-components';
import { getBuildVersion } from '../config';
import { MetaMask } from './MetaMask';
import { PoweredBy } from './PoweredBy';
import { ReactComponent as MetaMaskFox } from '../assets/metamask_fox.svg';
const FooterWrapper = styled.footer`
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 1rem;
padding-top: 2.4rem;
padding-bottom: 2.4rem;
border-top: 1px solid ${(props) => props.theme.colors.border?.default};
`;
const VersionSpan = styled.span`
font-size: ${({ theme }) => theme.fontSizes.small};
color: ${(props) => props.theme.colors.text?.muted};
`;
const PoweredByButton = styled.a`
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
padding: 1.2rem;
border-radius: ${({ theme }) => theme.radii.button};
box-shadow: ${({ theme }) => theme.shadows.button};
background-color: ${({ theme }) => theme.colors.background?.alternative};
`;
const PoweredByContainer = styled.div`
display: flex;
flex-direction: column;
margin-left: 1rem;
`;
export const Footer = () => {
const theme = useTheme();
const buildVersion = getBuildVersion();
return (
<FooterWrapper>
<PoweredByButton href="https://docs.metamask.io/" target="_blank">
<MetaMaskFox />
<PoweredByContainer>
<PoweredBy color={theme.colors.text?.muted} />
<MetaMask color={theme.colors.text?.default} />
</PoweredByContainer>
</PoweredByButton>
{buildVersion ? (
<VersionSpan title="Build version">v{buildVersion}</VersionSpan>
) : null}
</FooterWrapper>
);
};

View File

@@ -0,0 +1,61 @@
import styled, { useTheme } from 'styled-components';
import { HeaderButtons } from './Buttons';
import { SnapLogo } from './SnapLogo';
import { Toggle } from './Toggle';
import { getThemePreference } from '../utils';
const HeaderWrapper = styled.header`
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
padding: 2.4rem;
border-bottom: 1px solid ${(props) => props.theme.colors.border?.default};
`;
const Title = styled.p`
font-size: ${(props) => props.theme.fontSizes.title};
font-weight: bold;
margin: 0;
margin-left: 1.2rem;
${({ theme }) => theme.mediaQueries.small} {
display: none;
}
`;
const LogoWrapper = styled.div`
display: flex;
flex-direction: row;
align-items: center;
`;
const RightContainer = styled.div`
display: flex;
flex-direction: row;
align-items: center;
`;
export const Header = ({
handleToggleClick,
}: {
handleToggleClick: () => void;
}) => {
const theme = useTheme();
return (
<HeaderWrapper>
<LogoWrapper>
<SnapLogo color={theme.colors.icon?.default} size={36} />
<Title>template-snap</Title>
</LogoWrapper>
<RightContainer>
<Toggle
onToggle={handleToggleClick}
defaultChecked={getThemePreference()}
/>
<HeaderButtons />
</RightContainer>
</HeaderWrapper>
);
};

View File

@@ -0,0 +1,42 @@
export const MetaMask = ({ color }: { color?: string | undefined }) => (
<svg
width="98"
height="12"
viewBox="0 0 98 12"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M81.8298 5.64075C81.1373 5.22444 80.3734 4.92839 79.6503 4.55833C79.1818 4.3178 78.6828 4.10501 78.2754 3.79971C77.5828 3.28163 77.7254 2.26397 78.4485 1.8199C79.4874 1.1908 81.2086 1.54236 81.3919 2.82831C81.3919 2.85607 81.4224 2.87457 81.453 2.87457H83.0214C83.0622 2.87457 83.0927 2.84681 83.0825 2.80981C83.0011 1.92167 82.6242 1.18155 81.9317 0.709728C81.2697 0.256407 80.516 0.0158691 79.7114 0.0158691C75.5663 0.0158691 75.1894 4.00325 77.4199 5.26144C77.6745 5.40947 79.8642 6.40862 80.6382 6.84344C81.4123 7.27826 81.6567 8.07389 81.3206 8.70298C81.015 9.27658 80.2206 9.67439 79.4262 9.62813C78.5605 9.58187 77.8884 9.15631 77.6541 8.4902C77.6134 8.36993 77.593 8.13865 77.593 8.03688C77.593 8.00913 77.5625 7.98137 77.5319 7.98137H75.8311C75.8005 7.98137 75.77 8.00913 75.77 8.03688C75.77 9.15631 76.0755 9.77615 76.9106 10.3405C77.6949 10.8771 78.5504 11.0991 79.4364 11.0991C81.7585 11.0991 82.9603 9.90567 83.2048 8.66598C83.4186 7.45404 83.0214 6.36237 81.8298 5.64075Z"
fill={color}
/>
<path
d="M7.98096 0.219391H7.2273H6.40234C6.37179 0.219391 6.35142 0.237894 6.34123 0.256397L4.94594 4.43805C4.92557 4.49356 4.84409 4.49356 4.82372 4.43805L3.42842 0.256397C3.41824 0.228642 3.39787 0.219391 3.36732 0.219391H2.54236H1.7887H0.770236C0.739682 0.219391 0.709127 0.247145 0.709127 0.2749V10.9511C0.709127 10.9788 0.739682 11.0066 0.770236 11.0066H2.47107C2.50162 11.0066 2.53218 10.9788 2.53218 10.9511V2.83755C2.53218 2.77279 2.63402 2.76354 2.65439 2.81905L4.05987 7.02846L4.16172 7.32451C4.1719 7.35226 4.19227 7.36152 4.22283 7.36152H5.52646C5.55701 7.36152 5.57738 7.34301 5.58757 7.32451L5.68941 7.02846L7.09489 2.81905C7.11526 2.75429 7.21711 2.77279 7.21711 2.83755V10.9511C7.21711 10.9788 7.24766 11.0066 7.27822 11.0066H8.97905C9.00961 11.0066 9.04016 10.9788 9.04016 10.9511V0.2749C9.04016 0.247145 9.00961 0.219391 8.97905 0.219391H7.98096Z"
fill={color}
/>
<path
d="M55.7876 0.219391C55.7571 0.219391 55.7367 0.237894 55.7265 0.256397L54.3312 4.43805C54.3109 4.49356 54.2294 4.49356 54.209 4.43805L52.8137 0.256397C52.8035 0.228642 52.7832 0.219391 52.7526 0.219391H50.1657C50.1352 0.219391 50.1046 0.247145 50.1046 0.2749V10.9511C50.1046 10.9788 50.1352 11.0066 50.1657 11.0066H51.8666C51.8971 11.0066 51.9277 10.9788 51.9277 10.9511V2.83755C51.9277 2.77279 52.0295 2.76354 52.0499 2.81905L53.4554 7.02846L53.5572 7.32451C53.5674 7.35226 53.5878 7.36152 53.6183 7.36152H54.922C54.9525 7.36152 54.9729 7.34301 54.9831 7.32451L55.0849 7.02846L56.4904 2.81905C56.5108 2.75429 56.6126 2.77279 56.6126 2.83755V10.9511C56.6126 10.9788 56.6432 11.0066 56.6737 11.0066H58.3745C58.4051 11.0066 58.4357 10.9788 58.4357 10.9511V0.2749C58.4357 0.247145 58.4051 0.219391 58.3745 0.219391H55.7876Z"
fill={color}
/>
<path
d="M33.8499 0.219391H30.6825H28.9817H25.8142C25.7837 0.219391 25.7531 0.247145 25.7531 0.2749V1.60711C25.7531 1.63486 25.7837 1.66262 25.8142 1.66262H28.9206V10.9511C28.9206 10.9788 28.9511 11.0066 28.9817 11.0066H30.6825C30.7131 11.0066 30.7436 10.9788 30.7436 10.9511V1.66262H33.8499C33.8805 1.66262 33.911 1.63486 33.911 1.60711V0.2749C33.911 0.247145 33.8907 0.219391 33.8499 0.219391Z"
fill={color}
/>
<path
d="M43.8819 11.0066H45.4299C45.4707 11.0066 45.5012 10.9696 45.491 10.9326L42.2931 0.219409C42.2829 0.191655 42.2625 0.182404 42.2319 0.182404H41.6412H40.6024H40.0117C39.9811 0.182404 39.9608 0.200906 39.9506 0.219409L36.7526 10.9326C36.7424 10.9696 36.773 11.0066 36.8137 11.0066H38.3618C38.3923 11.0066 38.4127 10.9881 38.4229 10.9696L39.3497 7.85186C39.3599 7.82411 39.3802 7.81485 39.4108 7.81485H42.8328C42.8634 7.81485 42.8838 7.83336 42.8939 7.85186L43.8208 10.9696C43.8309 10.9881 43.8615 11.0066 43.8819 11.0066ZM39.8182 6.28836L41.0607 2.11596C41.0811 2.06045 41.1626 2.06045 41.1829 2.11596L42.4255 6.28836C42.4356 6.32537 42.4051 6.36238 42.3643 6.36238H39.8793C39.8386 6.36238 39.808 6.32537 39.8182 6.28836Z"
fill={color}
/>
<path
d="M70.2805 11.0066H71.8286C71.8693 11.0066 71.8999 10.9696 71.8897 10.9326L68.6917 0.219409C68.6815 0.191655 68.6611 0.182404 68.6306 0.182404H68.0399H67.001H66.4103C66.3798 0.182404 66.3594 0.200906 66.3492 0.219409L63.1512 10.9326C63.1411 10.9696 63.1716 11.0066 63.2123 11.0066H64.7604C64.791 11.0066 64.8113 10.9881 64.8215 10.9696L65.7483 7.85186C65.7585 7.82411 65.7789 7.81485 65.8094 7.81485H69.2315C69.262 7.81485 69.2824 7.83336 69.2926 7.85186L70.2194 10.9696C70.2296 10.9881 70.2499 11.0066 70.2805 11.0066ZM66.2168 6.28836L67.4594 2.11596C67.4797 2.06045 67.5612 2.06045 67.5816 2.11596L68.8241 6.28836C68.8343 6.32537 68.8037 6.36238 68.763 6.36238H66.2779C66.2372 6.36238 66.2066 6.32537 66.2168 6.28836Z"
fill={color}
/>
<path
d="M15.9453 9.42455V6.11253C15.9453 6.08478 15.9759 6.05702 16.0064 6.05702H20.5386C20.5692 6.05702 20.5997 6.02927 20.5997 6.00151V4.6693C20.5997 4.64155 20.5692 4.6138 20.5386 4.6138H16.0064C15.9759 4.6138 15.9453 4.58604 15.9453 4.55829V1.72734C15.9453 1.69959 15.9759 1.67183 16.0064 1.67183H21.1599C21.1904 1.67183 21.221 1.64408 21.221 1.61633V0.284116C21.221 0.256361 21.1904 0.228607 21.1599 0.228607H15.9453H14.1834C14.1528 0.228607 14.1223 0.256361 14.1223 0.284116V1.67183V4.62305V6.06628V9.48931V10.951C14.1223 10.9788 14.1528 11.0066 14.1834 11.0066H15.9453H21.3738C21.4043 11.0066 21.4349 10.9788 21.4349 10.951V9.54482C21.4349 9.51707 21.4043 9.48931 21.3738 9.48931H15.9963C15.9657 9.48006 15.9453 9.46156 15.9453 9.42455Z"
fill={color}
/>
<path
d="M97.3716 10.914L91.4849 5.39092C91.4645 5.37242 91.4645 5.33541 91.4849 5.31691L96.7809 0.321122C96.8216 0.284116 96.7911 0.228607 96.7402 0.228607H94.5708C94.5505 0.228607 94.5403 0.237859 94.5301 0.24711L90.0387 4.48428C89.9979 4.52128 89.9368 4.49353 89.9368 4.44727V0.284116C89.9368 0.256361 89.9063 0.228607 89.8757 0.228607H88.1749C88.1443 0.228607 88.1138 0.256361 88.1138 0.284116V10.9603C88.1138 10.9881 88.1443 11.0158 88.1749 11.0158H89.8757C89.9063 11.0158 89.9368 10.9881 89.9368 10.9603V6.26056C89.9368 6.2143 90.0081 6.18655 90.0387 6.22355L95.131 10.9973C95.1412 11.0066 95.1615 11.0158 95.1717 11.0158H97.3411C97.3818 11.0066 97.4124 10.9418 97.3716 10.914Z"
fill={color}
/>
</svg>
);

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,20 @@
export const SnapLogo = ({
color,
size,
}: {
color?: string | undefined;
size: number;
}) => (
<svg
width={size}
height={size}
viewBox={`0 0 125 125`}
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M88.5473 0.415527H36.5005C13.893 0.415527 0.415527 13.9484 0.415527 36.5446V88.5034C0.415527 111.1 13.893 124.57 36.5005 124.57H88.4852C111.093 124.57 124.57 111.1 124.57 88.5034V36.5446C124.632 13.9484 111.155 0.415527 88.5473 0.415527ZM59.8533 92.2281C59.8533 94.0904 58.9216 95.7665 57.3068 96.7597C56.4373 97.3184 55.5057 97.5667 54.5119 97.5667C53.7045 97.5667 52.8971 97.3805 52.0897 97.008L30.3518 86.1445C27.2464 84.5305 25.2589 81.3645 25.2589 77.8261V57.2785C25.2589 55.4161 26.1905 53.7401 27.8053 52.7468C29.4201 51.7536 31.3455 51.6915 33.0224 52.4985L54.7604 63.3621C57.9279 64.9761 59.9154 68.142 59.9154 71.6804V92.2281H59.8533ZM58.549 59.0166L35.1962 46.4149C33.5193 45.4838 32.4635 43.6835 32.4635 41.635C32.4635 39.6485 33.5193 37.7862 35.1962 36.855L58.549 24.2533C61.0333 22.9496 63.9524 22.9496 66.4368 24.2533L89.7895 36.855C91.4664 37.7862 92.5223 39.5864 92.5223 41.635C92.5223 43.6835 91.4664 45.4838 89.7895 46.4149L66.4368 59.0166C65.1946 59.6995 63.8282 60.0099 62.4618 60.0099C61.0954 60.0099 59.7912 59.6995 58.549 59.0166ZM99.789 77.8261C99.789 81.3645 97.8015 84.5925 94.634 86.1445L72.896 97.008C72.1507 97.3805 71.3433 97.5667 70.4738 97.5667C69.4801 97.5667 68.5484 97.3184 67.6789 96.7597C66.0641 95.7665 65.1325 94.0904 65.1325 92.2281V71.6804C65.1325 68.142 67.1199 64.914 70.2875 63.3621L92.0254 52.4985C93.7023 51.6915 95.6277 51.7536 97.2425 52.7468C98.8573 53.7401 99.789 55.4161 99.789 57.2785V77.8261Z"
fill={color}
/>
</svg>
);

View File

@@ -0,0 +1,121 @@
import { useState } from 'react';
import styled from 'styled-components';
type CheckedProps = {
readonly checked: boolean;
};
const ToggleWrapper = styled.div`
touch-action: pan-x;
display: inline-block;
position: relative;
cursor: pointer;
background-color: transparent;
border: 0;
padding: 0;
-webkit-touch-callout: none;
-webkit-user-select: none;
-ms-user-select: none;
user-select: none;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
-webkit-tap-highlight-color: transparent;
margin-right: 2.4rem;
${({ theme }) => theme.mediaQueries.small} {
margin-right: 2.4rem;
}
`;
const ToggleInput = styled.input`
border: 0;
clip: rect(0 0 0 0);
height: 1px;
margin: -1px;
overflow: hidden;
padding: 0;
position: absolute;
width: 1px;
`;
const IconContainer = styled.div`
position: absolute;
width: 22px;
height: 22px;
top: 0;
bottom: 0;
margin-top: auto;
margin-bottom: auto;
line-height: 0;
opacity: 0;
transition: opacity 0.25s ease;
& > * {
align-items: center;
display: flex;
height: 22px;
justify-content: center;
position: relative;
width: 22px;
}
`;
const CheckedContainer = styled(IconContainer)<CheckedProps>`
opacity: ${({ checked }) => (checked ? 1 : 0)};
left: 10px;
`;
const UncheckedContainer = styled(IconContainer)<CheckedProps>`
opacity: ${({ checked }) => (checked ? 0 : 1)};
right: 10px;
`;
const ToggleContainer = styled.div`
width: 68px;
height: 36px;
padding: 0;
border-radius: 36px;
background-color: ${({ theme }) => theme.colors.background?.alternative};
transition: all 0.2s ease;
`;
const ToggleCircle = styled.div<CheckedProps>`
transition: all 0.5s cubic-bezier(0.23, 1, 0.32, 1) 0ms;
position: absolute;
top: 4px;
left: ${({ checked }) => (checked ? '36px' : '4px')};
width: 28px;
height: 28px;
box-shadow: 0px 2px 8px rgba(0, 0, 0, 0.14);
border-radius: 50%;
background-color: #ffffff;
box-sizing: border-box;
transition: all 0.25s ease;
`;
export const Toggle = ({
onToggle,
defaultChecked = false,
}: {
onToggle: () => void;
defaultChecked?: boolean;
}) => {
const [checked, setChecked] = useState(defaultChecked);
const handleChange = () => {
onToggle();
setChecked(!checked);
};
return (
<ToggleWrapper onClick={handleChange}>
<ToggleContainer>
<CheckedContainer checked={checked}>
<span>🌞</span>
</CheckedContainer>
<UncheckedContainer checked={checked}>
<span>🌜</span>
</UncheckedContainer>
</ToggleContainer>
<ToggleCircle checked={checked} />
<ToggleInput type="checkbox" aria-label="Toggle Button" />
</ToggleWrapper>
);
};

View File

@@ -0,0 +1,8 @@
export * from './Buttons';
export * from './Card';
export * from './Footer';
export * from './Header';
export * from './MetaMask';
export * from './PoweredBy';
export * from './SnapLogo';
export * from './Toggle';

View File

@@ -0,0 +1,16 @@
export { defaultSnapOrigin } from './snap';
/**
* Token-aggregation (or market) API base URL for Snap RPCs.
* Set GATSBY_SNAP_API_BASE_URL in .env or .env.production for production.
*/
export const getSnapApiBaseUrl = (): string =>
(typeof process !== 'undefined' &&
process.env?.GATSBY_SNAP_API_BASE_URL) ||
'';
/** Build ID or git SHA for support/debug (set at build: GATSBY_BUILD_SHA, GATSBY_APP_VERSION). */
export const getBuildVersion = (): string =>
(typeof process !== 'undefined' &&
(process.env?.GATSBY_APP_VERSION || process.env?.GATSBY_BUILD_SHA)) ||
'';

View File

@@ -0,0 +1,11 @@
/**
* The snap origin to use.
* Will default to the local hosted snap if no value is provided in environment.
*
* You may be tempted to change this to the URL where your production snap is hosted, but please
* don't. Instead, rename `.env.production.dist` to `.env.production` and set the production URL
* there. Running `yarn build` will automatically use the production environment variables.
*/
export const defaultSnapOrigin =
// eslint-disable-next-line no-restricted-globals
process.env.SNAP_ORIGIN ?? `local:http://localhost:8080`;

View File

@@ -0,0 +1,187 @@
import type { DefaultTheme } from 'styled-components';
import { createGlobalStyle } from 'styled-components';
const breakpoints = ['600px', '768px', '992px'];
/**
* Common theme properties.
*/
const theme = {
fonts: {
default:
'-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif',
code: 'ui-monospace,Menlo,Monaco,"Cascadia Mono","Segoe UI Mono","Roboto Mono","Oxygen Mono","Ubuntu Monospace","Source Code Pro","Fira Mono","Droid Sans Mono","Courier New", monospace',
},
fontSizes: {
heading: '5.2rem',
mobileHeading: '3.6rem',
title: '2.4rem',
large: '2rem',
text: '1.6rem',
small: '1.4rem',
},
radii: {
default: '24px',
button: '8px',
},
breakpoints,
mediaQueries: {
small: `@media screen and (max-width: ${breakpoints[0] as string})`,
medium: `@media screen and (min-width: ${breakpoints[1] as string})`,
large: `@media screen and (min-width: ${breakpoints[2] as string})`,
},
shadows: {
default: '0px 7px 42px rgba(0, 0, 0, 0.1)',
button: '0px 0px 16.1786px rgba(0, 0, 0, 0.15);',
},
};
/**
* Light theme color properties.
*/
export const light: DefaultTheme = {
colors: {
background: {
default: '#FFFFFF',
alternative: '#F2F4F6',
inverse: '#141618',
},
icon: {
default: '#141618',
alternative: '#BBC0C5',
},
text: {
default: '#24272A',
muted: '#6A737D',
alternative: '#535A61',
inverse: '#FFFFFF',
},
border: {
default: '#BBC0C5',
},
primary: {
default: '#6F4CFF',
inverse: '#FFFFFF',
},
card: {
default: '#FFFFFF',
},
error: {
default: '#d73a49',
alternative: '#b92534',
muted: '#d73a4919',
},
},
...theme,
};
/**
* Dark theme color properties
*/
export const dark: DefaultTheme = {
colors: {
background: {
default: '#24272A',
alternative: '#141618',
inverse: '#FFFFFF',
},
icon: {
default: '#FFFFFF',
alternative: '#BBC0C5',
},
text: {
default: '#FFFFFF',
muted: '#FFFFFF',
alternative: '#D6D9DC',
inverse: '#24272A',
},
border: {
default: '#848C96',
},
primary: {
default: '#6F4CFF',
inverse: '#FFFFFF',
},
card: {
default: '#141618',
},
error: {
default: '#d73a49',
alternative: '#b92534',
muted: '#d73a4919',
},
},
...theme,
};
/**
* Default style applied to the app.
*
* @param props - Styled Components props.
* @returns Global style React component.
*/
export const GlobalStyle = createGlobalStyle`
html {
/* 62.5% of the base size of 16px = 10px.*/
font-size: 62.5%;
}
body {
background-color: ${(props) => props.theme.colors.background?.default};
color: ${(props) => props.theme.colors.text?.default};
font-family: ${(props) => props.theme.fonts.default};
font-size: ${(props) => props.theme.fontSizes.text};
margin: 0;
}
* {
transition: background-color .1s linear;
}
h1, h2, h3, h4, h5, h6 {
font-size: ${(props) => props.theme.fontSizes.heading};
${(props) => props.theme.mediaQueries.small} {
font-size: ${(props) => props.theme.fontSizes.mobileHeading};
}
}
code {
background-color: ${(props) => props.theme.colors.background?.alternative};
font-family: ${(props) => props.theme.fonts.code};
padding: 1.2rem;
font-weight: normal;
font-size: ${(props) => props.theme.fontSizes.text};
}
button {
font-size: ${(props) => props.theme.fontSizes.small};
border-radius: ${(props) => props.theme.radii.button};
background-color: ${(props) => props.theme.colors.background?.inverse};
color: ${(props) => props.theme.colors.text?.inverse};
border: 1px solid ${(props) => props.theme.colors.background?.inverse};
font-weight: bold;
padding: 1rem;
min-height: 4.2rem;
cursor: pointer;
transition: all .2s ease-in-out;
&:hover {
background-color: transparent;
border: 1px solid ${(props) => props.theme.colors.background?.inverse};
color: ${(props) => props.theme.colors.text?.default};
}
&:disabled,
&[disabled] {
border: 1px solid ${(props) => props.theme.colors.background?.inverse};
cursor: not-allowed;
}
&:disabled:hover,
&[disabled]:hover {
background-color: ${(props) => props.theme.colors.background?.inverse};
color: ${(props) => props.theme.colors.text?.inverse};
border: 1px solid ${(props) => props.theme.colors.background?.inverse};
}
}
`;

View File

@@ -0,0 +1,74 @@
import type { MetaMaskInpageProvider } from '@metamask/providers';
import type { ReactNode } from 'react';
import { createContext, useContext, useEffect, useState } from 'react';
import type { Snap } from '../types';
import { getSnapsProvider } from '../utils';
type MetaMaskContextType = {
provider: MetaMaskInpageProvider | null;
installedSnap: Snap | null;
error: Error | null;
setInstalledSnap: (snap: Snap | null) => void;
setError: (error: Error) => void;
};
export const MetaMaskContext = createContext<MetaMaskContextType>({
provider: null,
installedSnap: null,
error: null,
setInstalledSnap: () => {
/* no-op */
},
setError: () => {
/* no-op */
},
});
/**
* MetaMask context provider to handle MetaMask and snap status.
*
* @param props - React Props.
* @param props.children - React component to be wrapped by the Provider.
* @returns JSX.
*/
export const MetaMaskProvider = ({ children }: { children: ReactNode }) => {
const [provider, setProvider] = useState<MetaMaskInpageProvider | null>(null);
const [installedSnap, setInstalledSnap] = useState<Snap | null>(null);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
getSnapsProvider().then(setProvider).catch(console.error);
}, []);
useEffect(() => {
if (error) {
const timeout = setTimeout(() => {
setError(null);
}, 10000);
return () => {
clearTimeout(timeout);
};
}
return undefined;
}, [error]);
return (
<MetaMaskContext.Provider
value={{ provider, error, setError, installedSnap, setInstalledSnap }}
>
{children}
</MetaMaskContext.Provider>
);
};
/**
* Utility hook to consume the MetaMask context.
*
* @returns The MetaMask context.
*/
export function useMetaMaskContext() {
return useContext(MetaMaskContext);
}

View File

@@ -0,0 +1,5 @@
export * from './MetamaskContext';
export * from './useInvokeSnap';
export * from './useMetaMask';
export * from './useRequest';
export * from './useRequestSnap';

View File

@@ -0,0 +1,37 @@
import { useRequest } from './useRequest';
import { defaultSnapOrigin } from '../config';
export type InvokeSnapParams = {
method: string;
params?: Record<string, unknown>;
};
/**
* Utility hook to wrap the `wallet_invokeSnap` method.
*
* @param snapId - The Snap ID to invoke. Defaults to the snap ID specified in the
* config.
* @returns The invokeSnap wrapper method.
*/
export const useInvokeSnap = (snapId = defaultSnapOrigin) => {
const request = useRequest();
/**
* Invoke the requested Snap method.
*
* @param params - The invoke params.
* @param params.method - The method name.
* @param params.params - The method params.
* @returns The Snap response.
*/
const invokeSnap = async ({ method, params }: InvokeSnapParams) =>
request({
method: 'wallet_invokeSnap',
params: {
snapId,
request: params ? { method, params } : { method },
},
});
return invokeSnap;
};

View File

@@ -0,0 +1,57 @@
import { useEffect, useState } from 'react';
import { useMetaMaskContext } from './MetamaskContext';
import { useRequest } from './useRequest';
import { defaultSnapOrigin } from '../config';
import type { GetSnapsResponse } from '../types';
/**
* A hook to retrieve useful data from MetaMask.
*
* @returns The information.
*/
export const useMetaMask = () => {
const { provider, setInstalledSnap, installedSnap } = useMetaMaskContext();
const request = useRequest();
const [isFlask, setIsFlask] = useState(false);
const snapsDetected = provider !== null;
/**
* Detect if the version of MetaMask is Flask.
*/
const detectFlask = async () => {
const clientVersion = await request({
method: 'web3_clientVersion',
});
const isFlaskDetected = (clientVersion as string[])?.includes('flask');
setIsFlask(isFlaskDetected);
};
/**
* Get the Snap informations from MetaMask.
*/
const getSnap = async () => {
const snaps = (await request({
method: 'wallet_getSnaps',
})) as GetSnapsResponse;
setInstalledSnap(snaps[defaultSnapOrigin] ?? null);
};
useEffect(() => {
const detect = async () => {
if (provider) {
await detectFlask();
await getSnap();
}
};
detect().catch(console.error);
}, [provider]);
return { isFlask, snapsDetected, installedSnap, getSnap };
};

View File

@@ -0,0 +1,40 @@
import type { RequestArguments } from '@metamask/providers';
import { useMetaMaskContext } from './MetamaskContext';
export type Request = (params: RequestArguments) => Promise<unknown | null>;
/**
* Utility hook to consume the provider `request` method with the available provider.
*
* @returns The `request` function.
*/
export const useRequest = () => {
const { provider, setError } = useMetaMaskContext();
/**
* `provider.request` wrapper.
*
* @param params - The request params.
* @param params.method - The method to call.
* @param params.params - The method params.
* @returns The result of the request.
*/
const request: Request = async ({ method, params }) => {
try {
const data =
(await provider?.request({
method,
params,
} as RequestArguments)) ?? null;
return data;
} catch (requestError: any) {
setError(requestError);
return null;
}
};
return request;
};

View File

@@ -0,0 +1,37 @@
import { useMetaMaskContext } from './MetamaskContext';
import { useRequest } from './useRequest';
import { defaultSnapOrigin } from '../config';
import type { Snap } from '../types';
/**
* Utility hook to wrap the `wallet_requestSnaps` method.
*
* @param snapId - The requested Snap ID. Defaults to the snap ID specified in the
* config.
* @param version - The requested version.
* @returns The `wallet_requestSnaps` wrapper.
*/
export const useRequestSnap = (
snapId = defaultSnapOrigin,
version?: string,
) => {
const request = useRequest();
const { setInstalledSnap } = useMetaMaskContext();
/**
* Request the Snap.
*/
const requestSnap = async () => {
const snaps = (await request({
method: 'wallet_requestSnaps',
params: {
[snapId]: version ? { version } : {},
},
})) as Record<string, Snap>;
// Updates the `installedSnap` context variable since we just installed the Snap.
setInstalledSnap(snaps?.[snapId] ?? null);
};
return requestSnap;
};

View File

@@ -0,0 +1,496 @@
import styled from 'styled-components';
import { useState } from 'react';
import {
Button,
ConnectButton,
InstallFlaskButton,
ReconnectButton,
SendHelloButton,
Card,
} from '../components';
import { defaultSnapOrigin, getSnapApiBaseUrl } from '../config';
import {
useMetaMask,
useInvokeSnap,
useMetaMaskContext,
useRequestSnap,
} from '../hooks';
import { isLocalSnap, shouldDisplayReconnectButton } from '../utils';
const Container = styled.div`
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
margin-top: 7.6rem;
margin-bottom: 7.6rem;
${({ theme }) => theme.mediaQueries.small} {
padding-left: 2.4rem;
padding-right: 2.4rem;
margin-top: 2rem;
margin-bottom: 2rem;
width: auto;
}
`;
const Heading = styled.h1`
margin-top: 0;
margin-bottom: 2.4rem;
text-align: center;
`;
const Span = styled.span`
color: ${(props) => props.theme.colors.primary?.default};
`;
const Subtitle = styled.p`
font-size: ${({ theme }) => theme.fontSizes.large};
font-weight: 500;
margin-top: 0;
margin-bottom: 0;
${({ theme }) => theme.mediaQueries.small} {
font-size: ${({ theme }) => theme.fontSizes.text};
}
`;
const CardContainer = styled.div`
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: space-between;
max-width: 64.8rem;
width: 100%;
height: 100%;
margin-top: 1.5rem;
`;
const Notice = styled.div`
background-color: ${({ theme }) => theme.colors.background?.alternative};
border: 1px solid ${({ theme }) => theme.colors.border?.default};
color: ${({ theme }) => theme.colors.text?.alternative};
border-radius: ${({ theme }) => theme.radii.default};
padding: 2.4rem;
margin-top: 2.4rem;
max-width: 60rem;
width: 100%;
& > * {
margin: 0;
}
${({ theme }) => theme.mediaQueries.small} {
margin-top: 1.2rem;
padding: 1.6rem;
}
`;
const ErrorMessage = styled.div`
background-color: ${({ theme }) => theme.colors.error?.muted};
border: 1px solid ${({ theme }) => theme.colors.error?.default};
color: ${({ theme }) => theme.colors.error?.alternative};
border-radius: ${({ theme }) => theme.radii.default};
padding: 2.4rem;
margin-bottom: 2.4rem;
margin-top: 2.4rem;
max-width: 60rem;
width: 100%;
${({ theme }) => theme.mediaQueries.small} {
padding: 1.6rem;
margin-bottom: 1.2rem;
margin-top: 1.2rem;
max-width: 100%;
}
`;
const MarketSummaryList = styled.div`
font-size: ${({ theme }) => theme.fontSizes.small};
max-height: 12rem;
overflow-y: auto;
margin-top: 0.8rem;
`;
const Index = () => {
const { error } = useMetaMaskContext();
const { isFlask, snapsDetected, installedSnap } = useMetaMask();
const requestSnap = useRequestSnap();
const invokeSnap = useInvokeSnap();
const apiBaseUrl = getSnapApiBaseUrl();
const [marketSummary, setMarketSummary] = useState<{
tokens: Array<{
symbol?: string;
name?: string;
address?: string;
market?: { priceUsd?: number; volume24h?: number };
}>;
error?: string;
} | null>(null);
const [swapQuote, setSwapQuote] = useState<{
amountOut?: string;
error?: string;
poolAddress?: string | null;
} | null>(null);
const [swapTokenIn, setSwapTokenIn] = useState('');
const [swapTokenOut, setSwapTokenOut] = useState('');
const [swapAmountIn, setSwapAmountIn] = useState('');
const isMetaMaskReady = isLocalSnap(defaultSnapOrigin)
? isFlask
: snapsDetected;
const snapParams = apiBaseUrl ? { apiBaseUrl } : undefined;
const handleSendHelloClick = async () => {
await invokeSnap(
snapParams ? { method: 'hello', params: snapParams } : { method: 'hello' },
);
};
const handleShowMarketData = async () => {
if (!apiBaseUrl) {
return;
}
await invokeSnap({
method: 'show_market_data',
params: { apiBaseUrl },
});
};
const handleGetMarketSummary = async () => {
if (!apiBaseUrl) {
setMarketSummary({ tokens: [], error: 'Set GATSBY_SNAP_API_BASE_URL' });
return;
}
try {
const result = (await invokeSnap({
method: 'get_market_summary',
params: { apiBaseUrl },
})) as {
tokens?: Array<{
symbol?: string;
name?: string;
address?: string;
market?: { priceUsd?: number; volume24h?: number };
}>;
error?: string;
};
if (result?.error) {
setMarketSummary({ tokens: [], error: result.error });
} else {
setMarketSummary({ tokens: result?.tokens ?? [] });
}
} catch (e) {
setMarketSummary({
tokens: [],
error: e instanceof Error ? e.message : 'Failed to fetch',
});
}
};
const handleShowBridgeRoutes = async () => {
if (!apiBaseUrl) {
return;
}
await invokeSnap({
method: 'show_bridge_routes',
params: { apiBaseUrl },
});
};
const handleGetSwapQuote = async () => {
if (!apiBaseUrl) {
setSwapQuote({ error: 'Set GATSBY_SNAP_API_BASE_URL' });
return;
}
if (!swapTokenIn || !swapTokenOut || !swapAmountIn) {
setSwapQuote({ error: 'Enter tokenIn, tokenOut, and amountIn' });
return;
}
try {
const result = (await invokeSnap({
method: 'get_swap_quote',
params: {
apiBaseUrl,
chainId: 138,
tokenIn: swapTokenIn.trim(),
tokenOut: swapTokenOut.trim(),
amountIn: swapAmountIn.trim(),
},
})) as {
amountOut?: string;
error?: string;
poolAddress?: string | null;
};
setSwapQuote({
amountOut: result?.amountOut,
error: result?.error,
poolAddress: result?.poolAddress,
});
} catch (e) {
setSwapQuote({
error: e instanceof Error ? e.message : 'Failed to fetch quote',
});
}
};
const handleShowSwapQuote = async () => {
if (!apiBaseUrl || !swapTokenIn || !swapTokenOut || !swapAmountIn) {
return;
}
await invokeSnap({
method: 'show_swap_quote',
params: {
apiBaseUrl,
chainId: 138,
tokenIn: swapTokenIn.trim(),
tokenOut: swapTokenOut.trim(),
amountIn: swapAmountIn.trim(),
},
});
};
return (
<Container>
<Heading>
Welcome to <Span>template-snap</Span>
</Heading>
<Subtitle>
Get started by editing <code>src/index.tsx</code>
</Subtitle>
<Notice>
<p>
<strong>How to use:</strong> Install MetaMask (or MetaMask Flask for
Snaps) Connect your wallet Install the Snap Use the Market,
Bridge, and Swap cards below (set GATSBY_SNAP_API_BASE_URL for
production API).
</p>
</Notice>
<CardContainer>
{error && (
<ErrorMessage>
<b>An error happened:</b> {error.message}
</ErrorMessage>
)}
{!isMetaMaskReady && (
<Card
content={{
title: 'Install',
description:
'Snaps is pre-release software only available in MetaMask Flask, a canary distribution for developers with access to upcoming features.',
button: <InstallFlaskButton />,
}}
fullWidth
/>
)}
{!installedSnap && (
<Card
content={{
title: 'Connect',
description:
'Get started by connecting to and installing the example snap.',
button: (
<ConnectButton
onClick={requestSnap}
disabled={!isMetaMaskReady}
/>
),
}}
disabled={!isMetaMaskReady}
/>
)}
{shouldDisplayReconnectButton(installedSnap) && (
<Card
content={{
title: 'Reconnect',
description:
'While connected to a local running snap this button will always be displayed in order to update the snap if a change is made.',
button: (
<ReconnectButton
onClick={requestSnap}
disabled={!installedSnap}
/>
),
}}
disabled={!installedSnap}
/>
)}
<Card
content={{
title: 'Send Hello message',
description:
'Display a custom message within a confirmation screen in MetaMask.',
button: (
<SendHelloButton
onClick={handleSendHelloClick}
disabled={!installedSnap}
/>
),
}}
disabled={!installedSnap}
fullWidth={
isMetaMaskReady &&
Boolean(installedSnap) &&
!shouldDisplayReconnectButton(installedSnap)
}
/>
<Card
content={{
title: 'Market data',
description: apiBaseUrl
? 'Show token prices in a Snap dialog (from token-aggregation API).'
: 'Set GATSBY_SNAP_API_BASE_URL to show market data.',
button: (
<Button
onClick={handleShowMarketData}
disabled={!installedSnap || !apiBaseUrl}
>
Show market data
</Button>
),
}}
disabled={!installedSnap}
/>
<Card
content={{
title: 'Market summary',
description: apiBaseUrl
? 'Fetch token list with prices and display below.'
: 'Set GATSBY_SNAP_API_BASE_URL to fetch market summary.',
button: (
<Button
onClick={handleGetMarketSummary}
disabled={!installedSnap || !apiBaseUrl}
>
Fetch market summary
</Button>
),
}}
disabled={!installedSnap}
/>
<Card
content={{
title: 'Bridge',
description: apiBaseUrl
? 'Show CCIP bridge routes (WETH9 / WETH10) in a Snap dialog. Use explorer for executing transfers.'
: 'Set GATSBY_SNAP_API_BASE_URL to show bridge routes.',
button: (
<Button
onClick={handleShowBridgeRoutes}
disabled={!installedSnap || !apiBaseUrl}
>
Show bridge routes
</Button>
),
}}
disabled={!installedSnap}
/>
<Card
fullWidth
content={{
title: 'Swap quote',
description: apiBaseUrl ? (
<div>
<div style={{ marginBottom: '0.5rem' }}>
<input
type="text"
placeholder="Token In (address)"
value={swapTokenIn}
onChange={(e) => setSwapTokenIn(e.target.value)}
style={{ width: '100%', marginBottom: '0.25rem' }}
/>
<input
type="text"
placeholder="Token Out (address)"
value={swapTokenOut}
onChange={(e) => setSwapTokenOut(e.target.value)}
style={{ width: '100%', marginBottom: '0.25rem' }}
/>
<input
type="text"
placeholder="Amount In (raw)"
value={swapAmountIn}
onChange={(e) => setSwapAmountIn(e.target.value)}
style={{ width: '100%' }}
/>
</div>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
<Button
onClick={handleGetSwapQuote}
disabled={!installedSnap}
>
Get quote
</Button>
<Button
onClick={handleShowSwapQuote}
disabled={
!installedSnap ||
!swapTokenIn ||
!swapTokenOut ||
!swapAmountIn
}
>
Show quote in Snap
</Button>
</div>
{swapQuote && (
<div style={{ marginTop: '0.8rem', fontSize: '0.9rem' }}>
{swapQuote.error ? (
<span style={{ color: 'var(--color-error-default)' }}>
{swapQuote.error}
</span>
) : (
<>
Amount Out: {swapQuote.amountOut ?? '—'}
{swapQuote.poolAddress && (
<> (pool: {swapQuote.poolAddress.slice(0, 10)}...)</>
)}
</>
)}
</div>
)}
</div>
) : (
'Set GATSBY_SNAP_API_BASE_URL and enter token addresses + amount (raw).'
),
button: null,
}}
disabled={!installedSnap}
/>
{marketSummary && (
<Card
fullWidth
content={{
title: 'Market summary result',
description: marketSummary.error ? (
<span style={{ color: 'var(--color-error-default)' }}>
{marketSummary.error}
</span>
) : (
<MarketSummaryList>
{marketSummary.tokens.length === 0
? 'No tokens.'
: marketSummary.tokens.map((t, i) => (
<div key={i}>
{t.symbol ?? t.name ?? 'Token'}
{t.market?.priceUsd != null &&
`$${Number(t.market.priceUsd).toFixed(4)}`}
</div>
))}
</MarketSummaryList>
),
}}
/>
)}
<Notice>
<p>
Please note that the <b>snap.manifest.json</b> and{' '}
<b>package.json</b> must be located in the server root directory and
the bundle must be hosted at the location specified by the location
field.
</p>
</Notice>
</CardContainer>
</Container>
);
};
export default Index;

View File

@@ -0,0 +1,25 @@
import type {
EIP6963AnnounceProviderEvent,
EIP6963RequestProviderEvent,
MetaMaskInpageProvider,
} from '@metamask/providers';
/*
* Window type extension to support ethereum
*/
declare global {
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
interface Window {
ethereum: MetaMaskInpageProvider & {
setProvider?: (provider: MetaMaskInpageProvider) => void;
detected?: MetaMaskInpageProvider[];
providers?: MetaMaskInpageProvider[];
};
}
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
interface WindowEventMap {
'eip6963:requestProvider': EIP6963RequestProviderEvent;
'eip6963:announceProvider': EIP6963AnnounceProviderEvent;
}
}

View File

@@ -0,0 +1 @@
export { type GetSnapsResponse, type Snap } from './snap';

View File

@@ -0,0 +1,8 @@
export type GetSnapsResponse = Record<string, Snap>;
export type Snap = {
permissionName: string;
id: string;
version: string;
initialPermissions: Record<string, unknown>;
};

View File

@@ -0,0 +1,19 @@
/* eslint-disable import-x/no-unassigned-import */
import 'styled-components';
/**
* styled-component default theme extension
*/
declare module 'styled-components' {
/* eslint-disable @typescript-eslint/consistent-type-definitions */
export interface DefaultTheme {
fonts: Record<string, string>;
fontSizes: Record<string, string>;
breakpoints: string[];
mediaQueries: Record<string, string>;
radii: Record<string, string>;
shadows: Record<string, string>;
colors: Record<string, Record<string, string>>;
}
}

View File

@@ -0,0 +1,7 @@
/* eslint-disable import-x/unambiguous */
declare module '*.svg' {
import type { FunctionComponent, SVGProps } from 'react';
export const ReactComponent: FunctionComponent<SVGProps<SVGSVGElement>>;
}

View File

@@ -0,0 +1,5 @@
import { isLocalSnap } from './snap';
import type { Snap } from '../types';
export const shouldDisplayReconnectButton = (installedSnap: Snap | null) =>
installedSnap && isLocalSnap(installedSnap?.id);

View File

@@ -0,0 +1,5 @@
export * from './metamask';
export * from './snap';
export * from './theme';
export * from './localStorage';
export * from './button';

View File

@@ -0,0 +1,33 @@
/**
* Get a local storage key.
*
* @param key - The local storage key to access.
* @returns The value stored at the key provided if the key exists.
*/
export const getLocalStorage = (key: string) => {
const { localStorage: ls } = window;
if (ls !== null) {
const data = ls.getItem(key);
return data;
}
throw new Error('Local storage is not available.');
};
/**
* Set a value to local storage at a certain key.
*
* @param key - The local storage key to set.
* @param value - The value to set.
*/
export const setLocalStorage = (key: string, value: string) => {
const { localStorage: ls } = window;
if (ls !== null) {
ls.setItem(key, value);
return;
}
throw new Error('Local storage is not available.');
};

View File

@@ -0,0 +1,120 @@
import type {
EIP6963AnnounceProviderEvent,
MetaMaskInpageProvider,
} from '@metamask/providers';
/**
* Check if the current provider supports snaps by calling `wallet_getSnaps`.
*
* @param provider - The provider to use to check for snaps support. Defaults to
* `window.ethereum`.
* @returns True if the provider supports snaps, false otherwise.
*/
export async function hasSnapsSupport(
provider: MetaMaskInpageProvider = window.ethereum,
) {
try {
await provider.request({
method: 'wallet_getSnaps',
});
return true;
} catch {
return false;
}
}
/**
* Get a MetaMask provider using EIP6963. This will return the first provider
* reporting as MetaMask. If no provider is found after 500ms, this will
* return null instead.
*
* @returns A MetaMask provider if found, otherwise null.
*/
export async function getMetaMaskEIP6963Provider() {
return new Promise<MetaMaskInpageProvider | null>((resolve) => {
// Timeout looking for providers after 500ms
const timeout = setTimeout(() => {
resolveWithCleanup(null);
}, 500);
/**
* Resolve the promise with a MetaMask provider and clean up.
*
* @param provider - A MetaMask provider if found, otherwise null.
*/
function resolveWithCleanup(provider: MetaMaskInpageProvider | null) {
window.removeEventListener(
'eip6963:announceProvider',
onAnnounceProvider,
);
clearTimeout(timeout);
resolve(provider);
}
/**
* Listener for the EIP6963 announceProvider event.
*
* Resolves the promise if a MetaMask provider is found.
*
* @param event - The EIP6963 announceProvider event.
* @param event.detail - The details of the EIP6963 announceProvider event.
*/
function onAnnounceProvider({ detail }: EIP6963AnnounceProviderEvent) {
if (!detail) {
return;
}
const { info, provider } = detail;
if (info.rdns.includes('io.metamask')) {
resolveWithCleanup(provider);
}
}
window.addEventListener('eip6963:announceProvider', onAnnounceProvider);
window.dispatchEvent(new Event('eip6963:requestProvider'));
});
}
/**
* Get a provider that supports snaps. This will loop through all the detected
* providers and return the first one that supports snaps.
*
* @returns The provider, or `null` if no provider supports snaps.
*/
export async function getSnapsProvider() {
if (typeof window === 'undefined') {
return null;
}
if (await hasSnapsSupport()) {
return window.ethereum;
}
if (window.ethereum?.detected) {
for (const provider of window.ethereum.detected) {
if (await hasSnapsSupport(provider)) {
return provider;
}
}
}
if (window.ethereum?.providers) {
for (const provider of window.ethereum.providers) {
if (await hasSnapsSupport(provider)) {
return provider;
}
}
}
const eip6963Provider = await getMetaMaskEIP6963Provider();
if (eip6963Provider && (await hasSnapsSupport(eip6963Provider))) {
return eip6963Provider;
}
return null;
}

View File

@@ -0,0 +1,7 @@
/**
* Check if a snap ID is a local snap ID.
*
* @param snapId - The snap ID.
* @returns True if it's a local Snap, or false otherwise.
*/
export const isLocalSnap = (snapId: string) => snapId.startsWith('local:');

View File

@@ -0,0 +1,27 @@
import { getLocalStorage, setLocalStorage } from './localStorage';
/**
* Get the user's preferred theme in local storage.
* Will default to the browser's preferred theme if there is no value in local storage.
*
* @returns True if the theme is "dark" otherwise, false.
*/
export const getThemePreference = () => {
if (typeof window === 'undefined') {
return false;
}
const darkModeSystem = window?.matchMedia(
'(prefers-color-scheme: dark)',
).matches;
const localStoragePreference = getLocalStorage('theme');
const systemPreference = darkModeSystem ? 'dark' : 'light';
const preference = localStoragePreference ?? systemPreference;
if (!localStoragePreference) {
setLocalStorage('theme', systemPreference);
}
return preference === 'dark';
};

View File

@@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

View File

@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"baseUrl": "./",
"resolveJsonModule": true,
"jsx": "preserve"
},
"include": ["src", "gatsby-browser.tsx", "gatsby-config.ts", "gatsby-ssr.tsx"]
}

View File

@@ -0,0 +1,12 @@
# TypeScript Example Snap
This snap demonstrates how to develop a snap with TypeScript. It is a simple
snap that displays a confirmation dialog when the `hello` JSON-RPC method is
called.
## Testing
The snap comes with some basic tests, to demonstrate how to write tests for
snaps. To test the snap, run **pnpm run test** (or **yarn test**) from the repo root, or `pnpm run test` / `yarn test` in this directory. This will use
[`@metamask/snaps-jest`](https://github.com/MetaMask/snaps/tree/main/packages/snaps-jest)
to run the tests in `src/index.test.ts`.

View File

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#6366f1"/>
<stop offset="100%" style="stop-color:#4f46e5"/>
</linearGradient>
</defs>
<rect width="64" height="64" rx="12" fill="url(#bg)"/>
<text x="32" y="42" font-family="system-ui, sans-serif" font-size="28" font-weight="700" fill="white" text-anchor="middle">138</text>
</svg>

After

Width:  |  Height:  |  Size: 498 B

View File

@@ -0,0 +1,6 @@
module.exports = {
preset: '@metamask/snaps-jest',
transform: {
'^.+\\.(t|j)sx?$': 'ts-jest',
},
};

View File

@@ -0,0 +1,53 @@
{
"name": "snap",
"version": "0.1.0",
"description": "Chain 138 (DeFi Oracle Meta Mainnet) and ALL Mainnet Snap: networks, token list, market data, swap quotes, CCIP bridge routes for MetaMask.",
"repository": {
"type": "git",
"url": "https://github.com/MetaMask/template-snap-monorepo.git"
},
"license": "(MIT-0 OR Apache-2.0)",
"main": "./dist/bundle.js",
"files": [
"dist/",
"images/",
"snap.manifest.json"
],
"scripts": {
"allow-scripts": "pnpm --filter root run allow-scripts",
"build": "mm-snap build",
"build:clean": "pnpm run clean && pnpm run build",
"clean": "rimraf dist",
"lint": "yarn lint:eslint && yarn lint:misc --check",
"lint:eslint": "eslint . --cache --ext js,ts",
"lint:fix": "pnpm run lint:eslint --fix && pnpm run lint:misc --write",
"lint:misc": "prettier '**/*.json' '**/*.md' '**/*.yml' '!.yarnrc.yml' --ignore-path ../../.gitignore --no-error-on-unmatched-pattern",
"prepublishOnly": "mm-snap manifest",
"serve": "mm-snap serve",
"start": "mm-snap watch",
"test": "jest"
},
"dependencies": {
"@metamask/snaps-sdk": "10.3.0"
},
"devDependencies": {
"@jest/globals": "^29.5.0",
"@metamask/providers": "^22.1.0",
"@metamask/snaps-cli": "^8.3.0",
"@metamask/snaps-jest": "^9.8.0",
"@types/react": "18.2.4",
"@types/react-dom": "18.2.4",
"eslint": "^9.39.2",
"jest": "^29.5.0",
"rimraf": "^5.0.5",
"ts-jest": "^29.1.0",
"typescript": "~5.9.3"
},
"engines": {
"node": ">=18.6.0"
},
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
}
}

View File

@@ -0,0 +1,14 @@
import type { SnapConfig } from '@metamask/snaps-cli';
import { resolve } from 'path';
const config: SnapConfig = {
input: resolve(__dirname, 'src/index.tsx'),
server: {
port: 8080,
},
polyfills: {
buffer: true,
},
};
export default config;

View File

@@ -0,0 +1,30 @@
{
"version": "0.1.0",
"description": "Chain 138 (DeFi Oracle Meta Mainnet) and ALL Mainnet: networks, token list, market data, swap quotes, and CCIP bridge routes for MetaMask.",
"proposedName": "Chain 138",
"repository": {
"type": "git",
"url": "https://github.com/MetaMask/template-snap-monorepo.git"
},
"source": {
"shasum": "gLlFZx1q+vaYM4tYKOg0hHlIDB9p6GdoZ4jw3ZU7jYg=",
"location": {
"npm": {
"filePath": "dist/bundle.js",
"iconPath": "images/icon.svg",
"packageName": "snap",
"registry": "https://registry.npmjs.org/"
}
}
},
"initialPermissions": {
"snap_dialog": {},
"endowment:rpc": {
"dapps": true,
"snaps": false
},
"endowment:network-access": {}
},
"platformVersion": "10.3.0",
"manifestVersion": "0.1"
}

View File

@@ -0,0 +1,56 @@
import { expect } from '@jest/globals';
import type { SnapConfirmationInterface } from '@metamask/snaps-jest';
import { installSnap } from '@metamask/snaps-jest';
import { Box, Text, Bold } from '@metamask/snaps-sdk/jsx';
type SnapsJestExpect = {
toRender: (expected: unknown) => void;
toRespondWith: (expected: unknown) => void;
toRespondWithError: (expected: unknown) => void;
};
describe('onRpcRequest', () => {
describe('hello', () => {
it('shows a confirmation dialog', async () => {
const { request } = await installSnap();
const origin = 'Jest';
const response = request({
method: 'hello',
origin,
});
const ui = (await response.getInterface()) as SnapConfirmationInterface;
expect(ui.type).toBe('confirmation');
(expect(ui) as unknown as SnapsJestExpect).toRender(
<Box>
<Text>
Hello, <Bold>{origin}</Bold>!
</Text>
<Text>Chain 138 (DeFi Oracle Meta Mainnet) Snap.</Text>
<Text>
Use get_networks or get_chain138_config for wallet_addEthereumChain.
</Text>
</Box>,
);
await ui.ok();
(expect(await response) as unknown as SnapsJestExpect).toRespondWith(true);
});
});
it('throws an error if the requested method does not exist', async () => {
const { request } = await installSnap();
const response = await request({
method: 'foo',
});
(expect(response) as unknown as SnapsJestExpect).toRespondWithError({
code: -32603,
message: 'Method not found.',
stack: expect.any(String),
});
});
});

View File

@@ -0,0 +1,741 @@
import type { OnRpcRequestHandler } from '@metamask/snaps-sdk';
import { Box, Text, Bold } from '@metamask/snaps-sdk/jsx';
/** Token-aggregation or market API base URL; dapp passes apiBaseUrl for dynamic config */
const DEFAULT_MARKET_API_BASE = '';
/** RPC params: apiBaseUrl and optional override URLs for networks, token list, bridge list */
export type SnapRpcParams = {
apiBaseUrl?: string;
/** When set, get_networks / get_chain138_config fetch this URL instead of apiBaseUrl */
networksUrl?: string;
/** When set, get_token_list / get_token_list_url use this URL instead of apiBaseUrl */
tokenListUrl?: string;
/** When set, get_bridge_routes / show_bridge_routes fetch this URL instead of apiBaseUrl */
bridgeListUrl?: string;
chainId?: number;
[key: string]: unknown;
};
/**
* Get API base URL from request params, trimmed of trailing slash.
*
* @param params - Request params with optional apiBaseUrl.
* @returns API base URL string.
*/
function getApiBase(params: { apiBaseUrl?: string } | undefined): string {
return (params?.apiBaseUrl ?? DEFAULT_MARKET_API_BASE).replace(/\/$/u, '');
}
/**
* Fetch networks from token-aggregation API.
*
* @param apiBase - API base URL.
* @returns Networks response with version and networks array.
*/
async function fetchNetworks(apiBase: string) {
const res = await fetch(`${apiBase}/api/v1/networks`);
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
return res.json() as Promise<{
version?: string;
networks?: {
chainId: string;
chainIdDecimal: number;
chainName: string;
rpcUrls: string[];
nativeCurrency: { name: string; symbol: string; decimals: number };
blockExplorerUrls: string[];
iconUrls?: string[];
oracles?: { name: string; address: string }[];
}[];
}>;
}
/**
* Handle incoming JSON-RPC requests, sent through `wallet_invokeSnap`.
*
* @param options - RPC request options.
* @param options.origin - Origin of the request.
* @param options.request - JSON-RPC request object.
* @returns Response for the RPC method.
*/
export const onRpcRequest: OnRpcRequestHandler = async ({
origin,
request,
}) => {
const params = (request.params as SnapRpcParams | undefined) ?? {};
const base = getApiBase(params);
const networksUrl = params.networksUrl?.trim();
const tokenListUrl = params.tokenListUrl?.trim();
const bridgeListUrl = params.bridgeListUrl?.trim();
switch (request.method) {
case 'get_networks': {
if (networksUrl) {
try {
const res = await fetch(networksUrl);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = (await res.json()) as { version?: string; networks?: unknown[] };
return { version: data.version, networks: data.networks ?? [] };
} catch (error) {
return {
error: error instanceof Error ? error.message : 'Failed to fetch networks URL',
version: undefined,
networks: [],
};
}
}
if (!base) {
return {
error:
'Pass apiBaseUrl or networksUrl to fetch networks',
version: undefined,
networks: [],
};
}
try {
const data = await fetchNetworks(base);
return { version: data.version, networks: data.networks ?? [] };
} catch (error) {
return {
error:
error instanceof Error ? error.message : 'Failed to fetch networks',
version: undefined,
networks: [],
};
}
}
case 'get_chain138_config': {
const loadNetworks = async () => {
if (networksUrl) {
const res = await fetch(networksUrl);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = (await res.json()) as { networks?: { chainIdDecimal?: number }[] };
return data.networks ?? [];
}
if (base) {
const data = await fetchNetworks(base);
return data.networks ?? [];
}
return null;
};
try {
const networks = await loadNetworks();
if (networks === null) {
return { error: 'Pass apiBaseUrl or networksUrl to fetch chain config' };
}
const chain138 = networks.find((net: { chainIdDecimal?: number }) => net.chainIdDecimal === 138);
if (!chain138) {
return { error: 'Chain 138 not found in networks response' };
}
return {
chainId: (chain138 as { chainId?: string }).chainId,
chainIdDecimal: chain138.chainIdDecimal,
chainName: (chain138 as { chainName?: string }).chainName,
rpcUrls: (chain138 as { rpcUrls?: string[] }).rpcUrls,
nativeCurrency: (chain138 as { nativeCurrency?: unknown }).nativeCurrency,
blockExplorerUrls: (chain138 as { blockExplorerUrls?: string[] }).blockExplorerUrls,
oracles: (chain138 as { oracles?: unknown }).oracles,
};
} catch (error) {
return {
error:
error instanceof Error
? error.message
: 'Failed to fetch chain config',
};
}
}
case 'get_chain138_market_chains': {
if (!base) {
return {
error:
'Pass apiBaseUrl (token-aggregation service URL) to fetch market chains',
chains: [],
};
}
try {
const res = await fetch(`${base}/api/v1/chains`);
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
const data = await res.json();
return data;
} catch (error) {
return {
error:
error instanceof Error ? error.message : 'Failed to fetch chains',
chains: [],
};
}
}
case 'get_token_list_url': {
if (tokenListUrl) {
return {
tokenListUrl,
description:
'Add this URL in MetaMask Settings -> Token list to get dynamic token list for Chain 138 and ALL Mainnet.',
};
}
if (!base) {
return {
error: 'Pass apiBaseUrl or tokenListUrl to get token list URL',
tokenListUrl: undefined,
};
}
return {
tokenListUrl: `${base}/api/v1/report/token-list`,
description:
'Add this URL in MetaMask Settings -> Token list to get dynamic token list for Chain 138 and ALL Mainnet.',
};
}
case 'get_token_list': {
if (tokenListUrl) {
const chainIdParam = params?.chainId as number | undefined;
const url = chainIdParam
? `${tokenListUrl}${tokenListUrl.includes('?') ? '&' : '?'}chainId=${chainIdParam}`
: tokenListUrl;
try {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
return data;
} catch (error) {
return {
error: error instanceof Error ? error.message : 'Failed to fetch token list URL',
tokens: [],
};
}
}
if (!base) {
return {
error: 'Pass apiBaseUrl or tokenListUrl to fetch token list',
tokens: [],
};
}
const chainIdParam = params?.chainId as number | undefined;
const url = chainIdParam
? `${base}/api/v1/report/token-list?chainId=${chainIdParam}`
: `${base}/api/v1/report/token-list`;
try {
const res = await fetch(url);
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
const data = await res.json();
return data;
} catch (error) {
return {
error:
error instanceof Error
? error.message
: 'Failed to fetch token list',
tokens: [],
};
}
}
case 'get_oracles': {
if (!base) {
return { error: 'Pass apiBaseUrl to fetch oracles config', chains: [] };
}
const chainIdParam = params?.chainId as number | undefined;
const configUrl = chainIdParam
? `${base}/api/v1/config?chainId=${chainIdParam}`
: `${base}/api/v1/config`;
try {
const res = await fetch(configUrl);
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
return await res.json();
} catch (error) {
return {
error:
error instanceof Error ? error.message : 'Failed to fetch oracles',
chains: [],
};
}
}
case 'show_dynamic_info': {
const hasAny = base || networksUrl || tokenListUrl;
if (!hasAny) {
return snap.request({
method: 'snap_dialog',
params: {
type: 'alert',
content: (
<Box>
<Text>
Pass apiBaseUrl, networksUrl, or tokenListUrl to see dynamic networks and token list URL.
</Text>
</Box>
),
},
});
}
try {
let chainNames = 'N/A';
if (networksUrl) {
const res = await fetch(networksUrl);
if (res.ok) {
const data = (await res.json()) as { networks?: { chainName?: string; chainIdDecimal?: number }[] };
const nets = data.networks ?? [];
chainNames = nets.map((n) => `${n.chainName ?? ''} (${n.chainIdDecimal ?? ''})`).join(', ') || 'None';
}
} else if (base) {
const data = await fetchNetworks(base);
const networks = data.networks ?? [];
chainNames = networks.map((net) => `${net.chainName} (${net.chainIdDecimal})`).join(', ') || 'None';
}
const displayTokenListUrl = tokenListUrl || (base ? `${base}/api/v1/report/token-list` : '');
return snap.request({
method: 'snap_dialog',
params: {
type: 'confirmation',
content: (
<Box>
<Text>
<Bold>Dynamic networks</Bold>
</Text>
<Text>{chainNames}</Text>
<Text>
<Bold>Token list URL</Bold>
</Text>
<Text>{displayTokenListUrl || 'N/A'}</Text>
<Text>
Add the URL in MetaMask Settings - Token list for
auto-updating tokens.
</Text>
</Box>
),
},
});
} catch (error) {
return snap.request({
method: 'snap_dialog',
params: {
type: 'alert',
content: (
<Box>
<Text>
Failed to fetch:{' '}
{error instanceof Error ? error.message : 'Unknown error'}
</Text>
</Box>
),
},
});
}
}
case 'get_market_summary': {
if (!base) {
return {
error:
'Pass apiBaseUrl (token-aggregation service URL) to fetch market summary',
tokens: [],
};
}
const chainIdParam = (params?.chainId as number | undefined) ?? 138;
try {
const res = await fetch(
`${base}/api/v1/tokens?chainId=${chainIdParam}&limit=50`,
);
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
const data = (await res.json()) as {
tokens?: Array<{
symbol?: string;
name?: string;
address?: string;
market?: { priceUsd?: number; volume24h?: number };
}>;
};
const tokens = (data.tokens ?? []).map((t) => ({
symbol: t.symbol,
name: t.name,
address: t.address,
market: t.market
? { priceUsd: t.market.priceUsd, volume24h: t.market.volume24h }
: undefined,
}));
return { tokens };
} catch (error) {
return {
error:
error instanceof Error
? error.message
: 'Failed to fetch market summary',
tokens: [],
};
}
}
case 'get_swap_quote': {
if (!base) {
return {
error:
'Pass apiBaseUrl (token-aggregation service URL) to fetch swap quote',
amountOut: undefined,
};
}
const chainIdParam = (params?.chainId as number | undefined) ?? 138;
const tokenIn = params?.tokenIn as string | undefined;
const tokenOut = params?.tokenOut as string | undefined;
const amountIn = params?.amountIn as string | undefined;
if (!tokenIn || !tokenOut || amountIn === undefined) {
return {
error: 'Missing params: tokenIn, tokenOut, amountIn',
amountOut: undefined,
};
}
try {
const url = new URL(`${base}/api/v1/quote`);
url.searchParams.set('chainId', String(chainIdParam));
url.searchParams.set('tokenIn', tokenIn);
url.searchParams.set('tokenOut', tokenOut);
url.searchParams.set('amountIn', String(amountIn));
const res = await fetch(url.toString());
const data = (await res.json()) as {
amountOut?: string | null;
error?: string;
poolAddress?: string | null;
};
if (!res.ok) {
return {
error: data.error ?? `HTTP ${res.status}`,
amountOut: undefined,
};
}
return {
amountOut: data.amountOut ?? undefined,
error: data.error,
poolAddress: data.poolAddress,
};
} catch (error) {
return {
error:
error instanceof Error
? error.message
: 'Failed to fetch swap quote',
amountOut: undefined,
};
}
}
case 'show_swap_quote': {
if (!base) {
return snap.request({
method: 'snap_dialog',
params: {
type: 'alert',
content: (
<Box>
<Text>
Pass apiBaseUrl to see swap quote (tokenIn, tokenOut,
amountIn).
</Text>
</Box>
),
},
});
}
const chainIdParam = (params?.chainId as number | undefined) ?? 138;
const tokenIn = params?.tokenIn as string | undefined;
const tokenOut = params?.tokenOut as string | undefined;
const amountIn = params?.amountIn as string | undefined;
if (!tokenIn || !tokenOut || amountIn === undefined) {
return snap.request({
method: 'snap_dialog',
params: {
type: 'alert',
content: (
<Box>
<Text>
Missing params: tokenIn, tokenOut, amountIn. Pass them to
show_swap_quote.
</Text>
</Box>
),
},
});
}
try {
const url = new URL(`${base}/api/v1/quote`);
url.searchParams.set('chainId', String(chainIdParam));
url.searchParams.set('tokenIn', tokenIn);
url.searchParams.set('tokenOut', tokenOut);
url.searchParams.set('amountIn', String(amountIn));
const res = await fetch(url.toString());
const data = (await res.json()) as {
amountOut?: string | null;
error?: string;
};
if (!res.ok || data.error) {
return snap.request({
method: 'snap_dialog',
params: {
type: 'alert',
content: (
<Box>
<Text>
<Bold>Swap quote</Bold>
</Text>
<Text>{data.error ?? `HTTP ${res.status}`}</Text>
</Box>
),
},
});
}
const out = data.amountOut ?? '0';
return snap.request({
method: 'snap_dialog',
params: {
type: 'alert',
content: (
<Box>
<Text>
<Bold>Swap quote</Bold>
</Text>
<Text>
In: {amountIn} (raw) Out: ~{out} (raw)
</Text>
<Text>Use explorer or dApp to execute swap.</Text>
</Box>
),
},
});
} catch (error) {
return snap.request({
method: 'snap_dialog',
params: {
type: 'alert',
content: (
<Box>
<Text>
Failed to fetch quote:{' '}
{error instanceof Error ? error.message : 'Unknown error'}
</Text>
</Box>
),
},
});
}
}
case 'get_bridge_routes': {
const bridgeUrl = bridgeListUrl || (base ? `${base}/api/v1/bridge/routes` : '');
if (!bridgeUrl) {
return {
error: 'Pass apiBaseUrl or bridgeListUrl to fetch bridge routes',
routes: undefined,
chain138Bridges: undefined,
};
}
try {
const res = await fetch(bridgeUrl);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = (await res.json()) as {
routes?: Record<string, Record<string, string>>;
chain138Bridges?: Record<string, string>;
};
return {
routes: data.routes,
chain138Bridges: data.chain138Bridges,
};
} catch (error) {
return {
error:
error instanceof Error
? error.message
: 'Failed to fetch bridge routes',
routes: undefined,
chain138Bridges: undefined,
};
}
}
case 'show_bridge_routes': {
const showBridgeUrl = bridgeListUrl || (base ? `${base}/api/v1/bridge/routes` : '');
if (!showBridgeUrl) {
return snap.request({
method: 'snap_dialog',
params: {
type: 'alert',
content: (
<Box>
<Text>
Pass apiBaseUrl or bridgeListUrl to see bridge routes (CCIP WETH9 / WETH10).
</Text>
</Box>
),
},
});
}
try {
const res = await fetch(showBridgeUrl);
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
const data = (await res.json()) as {
routes?: Record<string, Record<string, string>>;
chain138Bridges?: Record<string, string>;
};
const lines: string[] = [];
if (data.chain138Bridges) {
lines.push(
`WETH9 Bridge (138): ${String(data.chain138Bridges.weth9 ?? '').slice(0, 10)}...`,
);
lines.push(
`WETH10 Bridge (138): ${String(data.chain138Bridges.weth10 ?? '').slice(0, 10)}...`,
);
}
if (data.routes?.weth9?.['Ethereum Mainnet (1)']) {
lines.push('WETH9 → Ethereum Mainnet');
}
if (data.routes?.weth10?.['Ethereum Mainnet (1)']) {
lines.push('WETH10 → Ethereum Mainnet');
}
lines.push('');
lines.push('Use the explorer to execute bridge transfers.');
return snap.request({
method: 'snap_dialog',
params: {
type: 'alert',
content: (
<Box>
<Text>
<Bold>CCIP Bridge Routes</Bold>
</Text>
<Text>{lines.join('\n')}</Text>
</Box>
),
},
});
} catch (error) {
return snap.request({
method: 'snap_dialog',
params: {
type: 'alert',
content: (
<Box>
<Text>
Failed to fetch bridge routes:{' '}
{error instanceof Error ? error.message : 'Unknown error'}
</Text>
</Box>
),
},
});
}
}
case 'show_market_data': {
if (!base) {
return snap.request({
method: 'snap_dialog',
params: {
type: 'alert',
content: (
<Box>
<Text>
Pass apiBaseUrl to see market data (prices and tokens).
</Text>
</Box>
),
},
});
}
const chainIdParam = (params?.chainId as number | undefined) ?? 138;
try {
const res = await fetch(
`${base}/api/v1/tokens?chainId=${chainIdParam}&limit=20`,
);
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
const data = (await res.json()) as {
tokens?: Array<{
symbol?: string;
name?: string;
market?: { priceUsd?: number };
}>;
};
const tokens = data.tokens ?? [];
const lines =
tokens.length === 0
? 'No tokens with market data.'
: tokens
.map((t) => {
const price =
t.market?.priceUsd != null
? `$${Number(t.market.priceUsd).toFixed(4)}`
: '—';
return `${t.symbol ?? t.name ?? 'Token'}: ${price}`;
})
.join('\n');
return snap.request({
method: 'snap_dialog',
params: {
type: 'alert',
content: (
<Box>
<Text>
<Bold>Market data (Chain {String(chainIdParam)})</Bold>
</Text>
<Text>{lines}</Text>
</Box>
),
},
});
} catch (error) {
return snap.request({
method: 'snap_dialog',
params: {
type: 'alert',
content: (
<Box>
<Text>
Failed to fetch market data:{' '}
{error instanceof Error ? error.message : 'Unknown error'}
</Text>
</Box>
),
},
});
}
}
case 'hello':
return snap.request({
method: 'snap_dialog',
params: {
type: 'confirmation',
content: (
<Box>
<Text>
Hello, <Bold>{origin}</Bold>!
</Text>
<Text>Chain 138 (DeFi Oracle Meta Mainnet) Snap.</Text>
<Text>
Use get_networks or get_chain138_config for
wallet_addEthereumChain.
</Text>
</Box>
),
},
});
default:
throw new Error('Method not found.');
}
};

View File

@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"baseUrl": "./",
"jsx": "react-jsx",
"jsxImportSource": "@metamask/snaps-sdk"
},
"include": ["**/*.ts", "**/*.tsx"]
}

View File

@@ -0,0 +1,27 @@
import { defineConfig, devices } from '@playwright/test';
/**
* E2E tests for the Chain 138 Snap companion site.
* Does not drive MetaMask Flask; for full install/connect flow use the manual checklist in TESTING_INSTRUCTIONS.md.
* Run: pnpm run test:e2e
*/
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: 1,
reporter: 'html',
use: {
baseURL: 'http://localhost:8000',
trace: 'on-first-retry',
},
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
webServer: {
command: 'pnpm run start',
url: 'http://localhost:8000',
reuseExistingServer: !process.env.CI,
timeout: 240_000,
},
timeout: 60_000,
});

View File

@@ -0,0 +1,3 @@
# pnpm workspace (default package manager). Yarn is also supported.
packages:
- 'packages/*'

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended"],
"packageRules": [
{
"matchPackagePatterns": ["^@metamask/"],
"groupName": "MetaMask Snap packages",
"schedule": ["before 6am on monday"]
}
],
"description": "Keep @metamask/snaps-sdk and related Snap packages up to date; run CI on dependency updates."
}

View File

@@ -0,0 +1,9 @@
#!/bin/bash
set -u
set -o pipefail
rm .github/CODEOWNERS
rm .github/workflows/security-code-scanner.yml
rm -f scripts/cleanup.sh
git commit -am "Clean up undesired MetaMask GitHub files"

View File

@@ -0,0 +1,144 @@
#!/bin/bash
# Deploy Chain 138 Snap companion site to VMID 5000 (explorer host).
# Serves the site at https://explorer.d-bis.org/snap/
# Requires: built site (run with --build to build first), Proxmox host with pct or SSH.
set -euo pipefail
VMID=5000
VM_IP="${EXPLORER_VM_IP:-192.168.11.140}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SNAP_REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
SITE_PUBLIC="${SNAP_REPO_ROOT}/packages/site/public"
PROXMOX_HOST="${PROXMOX_HOST_R630_02:-192.168.11.12}"
BUILD_FIRST=false
for arg in "$@"; do
[ "$arg" = "--build" ] && BUILD_FIRST=true
done
echo "=========================================="
echo "Deploy Chain 138 Snap site to VMID $VMID"
echo "=========================================="
echo ""
if [ "$BUILD_FIRST" = true ]; then
echo "=== Building site (pathPrefix=/snap) ==="
BUILD_ENV="GATSBY_PATH_PREFIX=/snap GATSBY_BUILD_SHA=$(git rev-parse --short HEAD 2>/dev/null || echo 'unknown')"
[ -n "${GATSBY_SNAP_API_BASE_URL:-}" ] && BUILD_ENV="$BUILD_ENV GATSBY_SNAP_API_BASE_URL=$GATSBY_SNAP_API_BASE_URL"
(cd "$SNAP_REPO_ROOT" && eval "$BUILD_ENV" pnpm --filter site run build)
echo ""
fi
if [ ! -f "${SITE_PUBLIC}/index.html" ]; then
echo "❌ Site not built. Run from repo root: GATSBY_PATH_PREFIX=/snap pnpm --filter site run build"
echo " Or run this script with: $0 --build"
echo " For production API (market/bridge/swap): GATSBY_SNAP_API_BASE_URL=https://your-api.com $0 --build"
exit 1
fi
# Detect run context
if [ -f "/proc/1/cgroup" ] && grep -q "lxc" /proc/1/cgroup 2>/dev/null; then
echo "Running inside VMID $VMID"
DEPLOY_METHOD="direct"
run_in_vm() { "$@"; }
elif command -v pct &>/dev/null; then
echo "Running from Proxmox host (pct exec $VMID)"
DEPLOY_METHOD="pct"
run_in_vm() { pct exec $VMID -- "$@"; }
else
echo "Running from remote (SSH to $PROXMOX_HOST, then pct to $VMID)"
DEPLOY_METHOD="remote"
run_in_vm() { ssh -o ConnectTimeout=10 -o StrictHostKeyChecking=no root@"${PROXMOX_HOST}" "pct exec $VMID -- $*"; }
fi
echo "=== Creating tarball of site ==="
TARBALL="/tmp/snap-site-deploy-$$.tar"
(cd "$SITE_PUBLIC" && tar -cf "$TARBALL" .)
cleanup_tarball() { rm -f "$TARBALL"; }
trap cleanup_tarball EXIT
echo "✅ Tarball: $TARBALL"
# Keep last tarball for rollback (on host: /tmp/snap-site-last.tar; in VM: previous files overwritten)
LAST_TARBALL="/tmp/snap-site-last.tar"
cp "$TARBALL" "$LAST_TARBALL" 2>/dev/null || true
echo "✅ Rollback tarball saved: $LAST_TARBALL"
echo ""
echo "=== Deploying to /var/www/html/snap/ ==="
# Optional: backup current deploy for rollback (inside VM)
run_in_vm "mkdir -p /var/www/html/snap"
run_in_vm "tar -cf /var/www/html/snap-rollback.tar -C /var/www/html/snap . 2>/dev/null || true"
if [ "$DEPLOY_METHOD" = "direct" ]; then
tar -xf "$TARBALL" -C /var/www/html/snap
chown -R www-data:www-data /var/www/html/snap
elif [ "$DEPLOY_METHOD" = "remote" ]; then
TARNAME="$(basename "$TARBALL")"
scp -o ConnectTimeout=10 -o StrictHostKeyChecking=no "$TARBALL" root@"${PROXMOX_HOST}":/tmp/
ssh -o ConnectTimeout=10 -o StrictHostKeyChecking=no root@"${PROXMOX_HOST}" "pct push $VMID /tmp/$TARNAME /tmp/snap-deploy.tar"
run_in_vm "tar -xf /tmp/snap-deploy.tar -C /var/www/html/snap"
run_in_vm "rm -f /tmp/snap-deploy.tar"
run_in_vm "chown -R www-data:www-data /var/www/html/snap"
ssh -o ConnectTimeout=10 -o StrictHostKeyChecking=no root@"${PROXMOX_HOST}" "rm -f /tmp/$TARNAME"
else
pct push $VMID "$TARBALL" /tmp/snap-deploy.tar
run_in_vm "tar -xf /tmp/snap-deploy.tar -C /var/www/html/snap"
run_in_vm "rm -f /tmp/snap-deploy.tar"
run_in_vm "chown -R www-data:www-data /var/www/html/snap"
fi
echo "✅ Files deployed"
echo ""
echo "=== Nginx: ensure /snap/ is served ==="
if run_in_vm "grep -q 'location /snap/' /etc/nginx/sites-available/blockscout 2>/dev/null"; then
echo "✅ Nginx already has location /snap/"
run_in_vm "nginx -t && systemctl reload nginx" 2>/dev/null || true
else
echo "⚠️ Add location /snap/ to nginx on VMID $VMID (e.g. run explorer-monorepo scripts/fix-nginx-serve-custom-frontend.sh inside the VM)"
fi
echo ""
echo "=== Verification checks ==="
VERIFY_FAIL=0
if run_in_vm "test -f /var/www/html/snap/index.html"; then
echo "✅ /var/www/html/snap/index.html exists"
else
echo "❌ /var/www/html/snap/index.html missing"
VERIFY_FAIL=1
fi
if run_in_vm "grep -q 'location /snap/' /etc/nginx/sites-available/blockscout 2>/dev/null"; then
echo "✅ Nginx config has location /snap/"
else
echo "❌ Nginx config missing location /snap/"
VERIFY_FAIL=1
fi
SNAP_CODE="$(run_in_vm "curl -sS -o /dev/null -w '%{http_code}' --connect-timeout 5 http://127.0.0.1/snap/ 2>/dev/null" 2>/dev/null || echo "000")"
if [ "$SNAP_CODE" = "200" ]; then
echo "✅ http://localhost/snap/ returns 200"
else
echo "❌ http://localhost/snap/ returned $SNAP_CODE (expected 200)"
VERIFY_FAIL=1
fi
SNAP_BODY="$(run_in_vm "curl -sS --connect-timeout 5 http://127.0.0.1/snap/ 2>/dev/null | head -c 4096" 2>/dev/null || true)"
if echo "$SNAP_BODY" | grep -qE 'Connect|template-snap|Snap|MetaMask'; then
echo "✅ /snap/ response contains Snap app content"
else
echo "⚠️ /snap/ response may not contain expected content (check in browser)"
fi
if [ "$VERIFY_FAIL" -eq 1 ]; then
echo ""
echo "⚠️ Some checks failed; see above. Snap may still work if nginx is updated."
fi
echo ""
echo "=========================================="
echo "Deployment complete"
echo "=========================================="
echo "Snap site should be available at:"
echo " - https://explorer.d-bis.org/snap/"
echo " - http://${VM_IP}/snap/"
echo ""
echo "Run full verification: metamask-integration/chain138-snap/scripts/verify-snap-site-vmid5000.sh"
echo "Or explorer + snap: explorer-monorepo/scripts/verify-vmid5000-all.sh"
echo ""
echo "Rollback: re-deploy previous build with: run_in_vm 'tar -xf /var/www/html/snap-rollback.tar -C /var/www/html/snap' (or use $LAST_TARBALL from host)."
echo ""

View File

@@ -0,0 +1,74 @@
#!/usr/bin/env bash
# Validate that token/bridge/networks list URLs return valid JSON.
# Usage: ./validate-token-lists.sh [URL1] [URL2] ...
# If no URLs given, reads from env: TOKEN_LIST_JSON_URL, BRIDGE_LIST_JSON_URL, NETWORKS_JSON_URL.
# Requires: curl, jq (or python3 for fallback).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
VALID=0
INVALID=0
validate_url() {
local url="$1"
local name="${2:-$url}"
local body
if ! body="$(curl -sS -L --connect-timeout 15 --max-time 30 "$url" 2>/dev/null)"; then
echo "$name: failed to fetch"
((INVALID++)) || true
return 1
fi
if [ -z "$body" ]; then
echo "$name: empty response"
((INVALID++)) || true
return 1
fi
if command -v jq &>/dev/null; then
if echo "$body" | jq . &>/dev/null; then
echo "$name: valid JSON"
((VALID++)) || true
return 0
fi
else
if echo "$body" | python3 -c "import sys,json; json.load(sys.stdin)" 2>/dev/null; then
echo "$name: valid JSON"
((VALID++)) || true
return 0
fi
fi
echo "$name: invalid JSON"
((INVALID++)) || true
return 1
}
echo "=============================================="
echo "Token / bridge / networks list JSON validation"
echo "=============================================="
echo ""
if [ $# -gt 0 ]; then
for url in "$@"; do
validate_url "$url" "$url"
done
else
for var in TOKEN_LIST_JSON_URL BRIDGE_LIST_JSON_URL NETWORKS_JSON_URL; do
url="${!var:-}"
if [ -n "$url" ]; then
validate_url "$url" "$var"
else
echo "$var not set, skipping"
fi
done
if [ "$VALID" -eq 0 ] && [ "$INVALID" -eq 0 ]; then
echo "Set TOKEN_LIST_JSON_URL, BRIDGE_LIST_JSON_URL, or NETWORKS_JSON_URL, or pass URLs as arguments."
exit 0
fi
fi
echo ""
echo "Result: $VALID valid, $INVALID invalid"
if [ "$INVALID" -gt 0 ]; then
exit 1
fi
exit 0

View File

@@ -0,0 +1,82 @@
#!/usr/bin/env bash
# Verify Chain 138 Snap site deployment on VMID 5000.
# Usage: ./verify-snap-site-vmid5000.sh [BASE_URL]
# BASE_URL defaults to https://explorer.d-bis.org (or use http://192.168.11.140 for LAN)
set -euo pipefail
BASE_URL="${1:-https://explorer.d-bis.org}"
BASE_URL="${BASE_URL%/}"
VMID=5000
VM_IP="${EXPLORER_VM_IP:-192.168.11.140}"
PROXMOX_HOST="${PROXMOX_HOST_R630_02:-192.168.11.12}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PASS=0
FAIL=0
check() {
local name="$1"
if eval "$2"; then
echo "$name"
((PASS++)) || true
return 0
else
echo "$name"
((FAIL++)) || true
return 1
fi
}
echo "=============================================="
echo "Snap site (VMID $VMID) verification"
echo "BASE_URL=$BASE_URL"
echo "=============================================="
echo ""
# 1) Public URL /snap/ returns 200 (follow redirects)
HTTP_CODE="$(curl -sS -L -o /dev/null -w "%{http_code}" --connect-timeout 10 "$BASE_URL/snap/" 2>/dev/null || echo 000)"
check "$BASE_URL/snap/ returns 200" "[ \"$HTTP_CODE\" = \"200\" ]"
# 2) /snap/ response contains Snap app content (follow redirects)
SNAP_BODY="$(curl -sS -L --connect-timeout 10 "$BASE_URL/snap/" 2>/dev/null | head -c 8192)" || true
check "/snap/ contains Snap app content (Connect|Snap|MetaMask)" "echo \"$SNAP_BODY\" | grep -qE 'Connect|template-snap|Snap|MetaMask'"
# 3) /snap/index.html returns 200 (follow redirects)
HTTP_CODE="$(curl -sS -L -o /dev/null -w "%{http_code}" --connect-timeout 10 "$BASE_URL/snap/index.html" 2>/dev/null || echo 000)"
check "$BASE_URL/snap/index.html returns 200" "[ \"$HTTP_CODE\" = \"200\" ]"
# 4) Optional: /snap/version.json returns 200 and valid JSON (build version/health)
VERSION_CODE="$(curl -sS -L -o /dev/null -w "%{http_code}" --connect-timeout 5 "$BASE_URL/snap/version.json" 2>/dev/null || echo 000)"
if [ "$VERSION_CODE" = "200" ]; then
echo "$BASE_URL/snap/version.json returns 200 (build version/health)"
((PASS++)) || true
else
echo "$BASE_URL/snap/version.json returned $VERSION_CODE (optional; set prebuild to generate)"
fi
# 6) Optional: when pct or SSH available, check inside VM
if command -v pct &>/dev/null; then
if pct exec $VMID -- test -f /var/www/html/snap/index.html 2>/dev/null; then
echo "✅ /var/www/html/snap/index.html exists in VM"
((PASS++)) || true
fi
if pct exec $VMID -- grep -q 'location /snap/' /etc/nginx/sites-available/blockscout 2>/dev/null; then
echo "✅ Nginx has location /snap/ in VM"
((PASS++)) || true
fi
elif ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=no root@"${PROXMOX_HOST}" "pct exec $VMID -- test -f /var/www/html/snap/index.html" 2>/dev/null; then
echo "✅ /var/www/html/snap/index.html exists in VM (via SSH)"
((PASS++)) || true
fi
echo ""
echo "=============================================="
echo "Result: $PASS passed, $FAIL failed"
echo "=============================================="
if [ "$FAIL" -gt 0 ]; then
echo ""
echo "See: $SCRIPT_DIR/../DEPLOY_VMID5000.md"
exit 1
fi
exit 0

View File

@@ -0,0 +1,16 @@
{
"compilerOptions": {
"esModuleInterop": true,
"exactOptionalPropertyTypes": true,
"forceConsistentCasingInFileNames": true,
"lib": ["ES2020", "DOM"],
"module": "CommonJS",
"moduleResolution": "node",
"noEmit": true,
"noErrorTruncation": true,
"noUncheckedIndexedAccess": true,
"skipLibCheck": true,
"strict": true,
"target": "es2020"
}
}

19930
chain138-snap/yarn.lock Normal file

File diff suppressed because it is too large Load Diff