diff --git a/.agents/debug-ext-with-mcp.md b/.agents/debug-ext-with-mcp.md new file mode 100644 index 000000000..034091343 --- /dev/null +++ b/.agents/debug-ext-with-mcp.md @@ -0,0 +1,152 @@ +# Debugging the Extension with Chrome DevTools MCP + +This guide explains how to set up Chrome DevTools MCP so that Claude Code (or other AI coding agents) can interact with, debug, and take screenshots of the Layerz Wallet Chrome extension. + +## Why Chrome for Testing? + +Chrome 137+ removed `--load-extension` and related flags from branded Chrome builds for security reasons. Extensions cannot be loaded into automated Chrome sessions. **Chrome for Testing** is a dedicated build that retains all automation-friendly flags and is the recommended solution. + +## Prerequisites + +- Node.js v20.19+ +- npm +- Claude Code (or another MCP-compatible client) + +## Step 1: Install Chrome for Testing + +From the `ext/` directory: + +```bash +cd ext +npx @puppeteer/browsers install chrome@stable +``` + +This downloads Chrome for Testing into `ext/chrome/`. The binary path will be printed, e.g.: + +``` +chrome@145.0.7632.46 /Users//z/layerzwallet/ext/chrome/mac_arm-145.0.7632.46/chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing +``` + +Save this path for the next step. + +> **Note:** The `ext/chrome/` directory is gitignored. + +## Step 2: Build the Extension + +Make sure you have a fresh build: + +```bash +cd ext +npm start # dev server (watches for changes) +# or +npm run build # one-time production build +``` + +The built extension lives in `ext/build/`. + +## Step 3: Configure Chrome DevTools MCP + +Add the MCP server to Claude Code (user-scoped so it works across projects): + +```bash +claude mcp add chrome-devtools --scope user -- npx chrome-devtools-mcp@latest \ + --executable-path="/Users//z/layerzwallet/ext/chrome/mac_arm-/chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing" \ + --chrome-arg=--enable-unsafe-extension-debugging \ + --chrome-arg=--disable-extensions-except=/Users//z/layerzwallet/ext/build \ + --chrome-arg=--load-extension=/Users//z/layerzwallet/ext/build \ + --chrome-arg=--window-size=400,600 +``` + +Replace `` and `` with your actual username and Chrome for Testing version. + +Or manually edit `~/.claude.json` and add to the `mcpServers` section: + +```json +{ + "mcpServers": { + "chrome-devtools": { + "type": "stdio", + "command": "npx", + "args": [ + "chrome-devtools-mcp@latest", + "--executable-path=/Users//z/layerzwallet/ext/chrome/mac_arm-/chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing", + "--chrome-arg=--enable-unsafe-extension-debugging", + "--chrome-arg=--disable-extensions-except=/Users//z/layerzwallet/ext/build", + "--chrome-arg=--load-extension=/Users//z/layerzwallet/ext/build", + "--chrome-arg=--window-size=400,600" + ], + "env": {} + } + } +} +``` + +### Config Explained + +| Flag | Purpose | +|------|---------| +| `--executable-path` | Points to Chrome for Testing binary instead of branded Chrome | +| `--enable-unsafe-extension-debugging` | Re-enables extension loading in automated Chrome | +| `--disable-extensions-except` | Whitelists only the Layerz Wallet extension | +| `--load-extension` | Auto-loads the extension from `ext/build/` on startup | +| `--window-size=400,600` | Sets viewport to approximate the extension popup size | + +## Step 4: Restart Claude Code + +After changing the MCP config, restart Claude Code for the changes to take effect. + +## Step 5: Use It + +Once configured, Claude Code can: + +- **Navigate** to the extension popup: `chrome-extension://jfkjdddajnobopldmhfpgblcidgohkak/popup.html` +- **Take screenshots** of the extension UI +- **Read console errors** and network requests +- **Execute JavaScript** in the extension context +- **Click buttons**, fill forms, and interact with the UI +- **Record performance traces** + +### Example Prompts + +``` +Open the Layerz Wallet extension and take a screenshot +``` + +``` +Check for console errors in the extension +``` + +``` +Navigate to the send screen and inspect the network requests +``` + +## Troubleshooting + +### Extension not loading (list empty on chrome://extensions) + +- Make sure you're using **Chrome for Testing**, not branded Chrome +- Verify `ext/build/` exists and contains `manifest.json` and `popup.html` +- Each `--chrome-arg` must be a **separate** array entry (don't combine multiple flags into one string) + +### `--chrome-arg` not working + +Test with a simple visual flag like `--chrome-arg=--window-size=400,600`. If the window size doesn't change, the args aren't being passed. + +### Extension ID different than expected + +When loading an unpacked extension, Chrome assigns the ID based on the extension's path. The ID `jfkjdddajnobopldmhfpgblcidgohkak` is stable as long as the `ext/build/` path stays the same. If the ID changes, navigate to `chrome://extensions` to find the new one. + +### Branded Chrome won't load extensions + +This is expected on Chrome 137+. Use Chrome for Testing as described above. See the [Chromium RFC](https://groups.google.com/a/chromium.org/g/chromium-extensions/c/aEHdhDZ-V0E) for background. + +### Updating Chrome for Testing + +To update to a newer version: + +```bash +cd ext +npx @puppeteer/browsers install chrome@stable +``` + +Then update the `--executable-path` in your MCP config to point to the new version. diff --git a/CLAUDE.md b/CLAUDE.md index 65d310df0..0f5fecd99 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -372,6 +372,7 @@ To add a new Layer2 network: Detailed feature architecture docs live in `.agents/`: - `.agents/swap.md` — Transfer/Swap cross-chain system (TransferServiceManager, providers, UI flow) +- `.agents/debug-ext-with-mcp.md` — How to set up Chrome DevTools MCP for debugging the browser extension with AI agents ## Mobile MCP (Simulator Interaction) diff --git a/ext/package-lock.json b/ext/package-lock.json index 414c714eb..71af6eae2 100644 --- a/ext/package-lock.json +++ b/ext/package-lock.json @@ -24,6 +24,8 @@ "@stacks/blockchain-api-client": "8.15.3", "@stacks/transactions": "7.4.0", "@stacks/wallet-sdk": "7.2.0", + "@utexo/rgb-sdk-core": "1.0.0-beta.3", + "@utexo/rgb-sdk-web": "1.0.0-beta.9", "assert": "2.1.0", "bignumber.js": "9.3.1", "bip21": "3.0.0", @@ -273,7 +275,6 @@ "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", @@ -864,7 +865,6 @@ "integrity": "sha512-D+OrJumc9McXNEBI/JmFnc/0uCM2/Y3PEBG3gfV3QIYkKv5pvnpzFrl1kYCrcHJP8nOeFB/SHi1IHz29pNGuew==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, @@ -1709,7 +1709,6 @@ "integrity": "sha512-61bxqhiRfAACulXSLd/GxqmAedUSrRZIu/cbaT18T1CetkTmtDN15it7i80ru4DVqRK1WMxQhXs+Lf9kajm5Ow==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", "@babel/helper-module-imports": "^7.28.6", @@ -2194,6 +2193,12 @@ "node": ">=6.9.0" } }, + "node_modules/@bitcoindevkit/bdk-wallet-web": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@bitcoindevkit/bdk-wallet-web/-/bdk-wallet-web-0.2.0.tgz", + "integrity": "sha512-aa6AOJeYMpGakrA/a2JKMkIp6YF87RDILmFJ6CMHNq3zYKVZKkP6MQacyO7unMgME3uhxwHM9Fd97rvmY5XVSg==", + "license": "MIT OR Apache-2.0" + }, "node_modules/@bitcoinerlab/descriptors-scure": { "version": "3.1.7", "resolved": "https://registry.npmjs.org/@bitcoinerlab/descriptors-scure/-/descriptors-scure-3.1.7.tgz", @@ -2279,7 +2284,6 @@ "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", "license": "MIT", - "peer": true, "engines": { "node": ">= 20.19.0" }, @@ -2292,7 +2296,6 @@ "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.2.0.tgz", "integrity": "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg==", "license": "MIT", - "peer": true, "funding": { "url": "https://paulmillr.com/funding/" } @@ -2302,7 +2305,6 @@ "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-2.2.0.tgz", "integrity": "sha512-zFr7t2F+a9+5tB7QbarF2HQNYrgjCNaoLAupZdKkrFMYMozJf5zqH2WJCQibMzm1qQ0QogrxVGO3qXfQDYMaQg==", "license": "MIT", - "peer": true, "dependencies": { "@noble/curves": "2.2.0", "@noble/hashes": "2.2.0", @@ -2317,7 +2319,8 @@ "resolved": "https://registry.npmjs.org/base-x/-/base-x-5.0.1.tgz", "integrity": "sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/@bitcoinerlab/descriptors-scure/node_modules/bip32": { "version": "5.0.1", @@ -2325,6 +2328,7 @@ "integrity": "sha512-PWlHIAgYCfVhwqNpZyeakHXuLAGyN6rEQZnhxHxKI3BoFJRVWLl26455fhRlHsmbYcV986HqtPnt33Edu5sTCw==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@noble/hashes": "^1.2.0", "@scure/base": "^1.1.1", @@ -2342,6 +2346,7 @@ "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", "license": "MIT", "optional": true, + "peer": true, "engines": { "node": "^14.21.3 || >=16" }, @@ -2355,6 +2360,7 @@ "integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==", "license": "MIT", "optional": true, + "peer": true, "funding": { "url": "https://paulmillr.com/funding/" } @@ -2365,6 +2371,7 @@ "integrity": "sha512-xS6+s8e0Xbx++5/0L+yyexukU7pz//Yg6IHg3BKhXotg1JcYtgxVcUctQ0HxLByiJzpAkNFawz1Nz5Xadzo82g==", "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">=14.0.0" } @@ -2375,6 +2382,7 @@ "integrity": "sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "base-x": "^5.0.0" } @@ -2385,6 +2393,7 @@ "integrity": "sha512-FsGDOnFg9aVI9erdriULkd/JjEWONV/lQE5aYziB5PoBsXRind56lh8doIZIc9X4HoxT5x4bLjMWN1/NB8Zp5g==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@noble/hashes": "^1.2.0", "bs58": "^6.0.0" @@ -2396,6 +2405,7 @@ "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", "license": "MIT", "optional": true, + "peer": true, "engines": { "node": "^14.21.3 || >=16" }, @@ -2427,6 +2437,7 @@ "integrity": "sha512-iFzrC/9ne740qFbNjTZ2FciSRJlHIXoxqk/Y5EnE08QOXu1WjJyCCswwDTYbohAOEnlCtLaAAQBhyaLRFh2hMA==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "bs58check": "^4.0.0" } @@ -5806,7 +5817,6 @@ "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "*", "@types/json-schema": "*" @@ -5960,7 +5970,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.9.tgz", "integrity": "sha512-/rpCXHlCWeqClNBwUhDcusJxXYDjZTyE8v5oTO7WbL8eij2nKhUeU89/6xgjU7N4/Vh3He0BtyhJdQbDyhiXAw==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -5992,7 +6001,6 @@ "integrity": "sha512-Qec1E3mhALmaspIrhWt9jkQMNdw6bReVu64mjvhbhq2NFPftLPVr+l1SZgmw/66WwBNpDh7ao5AT6gF5v41PFA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -6174,7 +6182,6 @@ "integrity": "sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "5.62.0", "@typescript-eslint/types": "5.62.0", @@ -6484,6 +6491,167 @@ "dev": true, "license": "ISC" }, + "node_modules/@utexo/rgb-lib-wasm": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/@utexo/rgb-lib-wasm/-/rgb-lib-wasm-1.0.0-beta.2.tgz", + "integrity": "sha512-EN/3xLlEejoFQ4dfiw8r/xe2R0JUbIKuJYjQtxlf3YqaHmdiGR2eX/8MQ6PJw+YF0NlenC/UAPsCHtytwhrQjg==" + }, + "node_modules/@utexo/rgb-sdk-core": { + "version": "1.0.0-beta.3", + "resolved": "https://registry.npmjs.org/@utexo/rgb-sdk-core/-/rgb-sdk-core-1.0.0-beta.3.tgz", + "integrity": "sha512-TlHnTOp2/sHmjspXI6XYgt/ca3wDtOMXGESdACDY0ocGu6WyGHgI3ia1a5eRwq7H4Q+6Zjx4vT2rurujFKjNdA==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "^2.0.1", + "@noble/secp256k1": "3.0.0", + "@scure/bip32": "^2.0.1", + "@scure/bip39": "^2.0.1", + "axios": "^1.8.4" + } + }, + "node_modules/@utexo/rgb-sdk-core/node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@utexo/rgb-sdk-core/node_modules/@noble/secp256k1": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@noble/secp256k1/-/secp256k1-3.0.0.tgz", + "integrity": "sha512-NJBaR352KyIvj3t6sgT/+7xrNyF9Xk9QlLSIqUGVUYlsnDTAUqY8LOmwpcgEx4AMJXRITQ5XEVHD+mMaPfr3mg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@utexo/rgb-sdk-core/node_modules/@scure/base": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.2.0.tgz", + "integrity": "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@utexo/rgb-sdk-core/node_modules/@scure/bip32": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-2.2.0.tgz", + "integrity": "sha512-zFr7t2F+a9+5tB7QbarF2HQNYrgjCNaoLAupZdKkrFMYMozJf5zqH2WJCQibMzm1qQ0QogrxVGO3qXfQDYMaQg==", + "license": "MIT", + "dependencies": { + "@noble/curves": "2.2.0", + "@noble/hashes": "2.2.0", + "@scure/base": "2.2.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@utexo/rgb-sdk-core/node_modules/@scure/bip39": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.2.0.tgz", + "integrity": "sha512-T/Bj/YvYMNkIPq6EENO6/rcs2e7qTNuyoUXf0KBFDmp0ZDu0H2X4Lq6yC3i0c8PcWkov5EbW+yQZZbdMmk154A==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.2.0", + "@scure/base": "2.2.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@utexo/rgb-sdk-web": { + "version": "1.0.0-beta.9", + "resolved": "https://registry.npmjs.org/@utexo/rgb-sdk-web/-/rgb-sdk-web-1.0.0-beta.9.tgz", + "integrity": "sha512-NkKXIVCp33redRPIUrHWT9FMQDfo7SUl9dzKlkRt17BDVoNgKT4EeuUuHxnIzLJpHRZ56MNGeQPAFhi99BjIrA==", + "license": "MIT", + "dependencies": { + "@bitcoindevkit/bdk-wallet-web": "^0.2.0", + "@noble/hashes": "^2.0.1", + "@scure/btc-signer": "^2.0.1", + "@utexo/rgb-lib-wasm": "^1.0.0-beta.2", + "@utexo/rgb-sdk-core": "^1.0.0-beta.3", + "bitcoinjs-lib": "^6.1.7" + } + }, + "node_modules/@utexo/rgb-sdk-web/node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@utexo/rgb-sdk-web/node_modules/bip174": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bip174/-/bip174-2.1.1.tgz", + "integrity": "sha512-mdFV5+/v0XyNYXjBS6CQPLo9ekCx4gtKZFnJm5PMto7Fs9hTTDpkkzOB7/FtluRI6JbUUAu+snTYfJRgHLZbZQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@utexo/rgb-sdk-web/node_modules/bitcoinjs-lib": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/bitcoinjs-lib/-/bitcoinjs-lib-6.1.7.tgz", + "integrity": "sha512-tlf/r2DGMbF7ky1MgUqXHzypYHakkEnm0SZP23CJKIqNY/5uNAnMbFhMJdhjrL/7anfb/U8+AlpdjPWjPnAalg==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.2.0", + "bech32": "^2.0.0", + "bip174": "^2.1.1", + "bs58check": "^3.0.1", + "typeforce": "^1.11.3", + "varuint-bitcoin": "^1.1.2" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@utexo/rgb-sdk-web/node_modules/bitcoinjs-lib/node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@utexo/rgb-sdk-web/node_modules/bs58check": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/bs58check/-/bs58check-3.0.1.tgz", + "integrity": "sha512-hjuuJvoWEybo7Hn/0xOrczQKKEKD63WguEjlhLExYs2wUBcebDC1jDNK17eEAD2lYfw82d5ASC1d7K3SWszjaQ==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.2.0", + "bs58": "^5.0.0" + } + }, + "node_modules/@utexo/rgb-sdk-web/node_modules/bs58check/node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@vitest/expect": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", @@ -6947,7 +7115,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -6984,13 +7151,24 @@ "integrity": "sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==", "license": "MIT" }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/ajv": { "version": "8.17.1", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -7391,6 +7569,12 @@ "tslib": "^2.4.0" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -7416,6 +7600,18 @@ "node": ">=4" } }, + "node_modules/axios": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz", + "integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, "node_modules/axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", @@ -7652,7 +7848,6 @@ "resolved": "https://registry.npmjs.org/bare-buffer/-/bare-buffer-3.6.0.tgz", "integrity": "sha512-/maRWEQ2eBkVNMbNFVsq1pHXJYVj4Y3AixwruB24eKZDs5Gtu0fixzvjYmBIuTsBMtVH5Yb27pQO9BhFa+IlIQ==", "license": "Apache-2.0", - "peer": true, "engines": { "bare": ">=1.20.0" } @@ -7882,7 +8077,6 @@ "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.3.tgz", "integrity": "sha512-Kccpc7ACfXaxfeInfqKcZtW4pT5YBn1mesc4sCsun6sRwtbJ4h+sNOaksUpYEJUKfN65YWC6Bw2OJEFiKxq8nQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "bare-path": "^3.0.0" } @@ -8072,7 +8266,6 @@ "resolved": "https://registry.npmjs.org/bitcoinjs-lib/-/bitcoinjs-lib-7.0.1.tgz", "integrity": "sha512-vwEmpL5Tpj0I0RBdNkcDMXePoaYSTeKY6mL6/l5esbnTs+jGdPDuLp4NY1hSh6Zk5wSgePygZ4Wx5JJao30Pww==", "license": "MIT", - "peer": true, "dependencies": { "@noble/hashes": "^1.2.0", "bech32": "^2.0.0", @@ -8530,7 +8723,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -9020,6 +9212,18 @@ "dev": true, "license": "MIT" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/commander": { "version": "10.0.1", "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", @@ -9777,6 +9981,15 @@ "node": ">=6" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -10344,7 +10557,6 @@ "resolved": "https://registry.npmjs.org/ecpair/-/ecpair-3.0.1.tgz", "integrity": "sha512-uz8wMFvtdr58TLrXnAesBsoMEyY8UudLOfApcyg40XfZjP+gt1xO4cuZSIkZ8hTMTQ8+ETgt7xSIV4eM7M6VNw==", "license": "MIT", - "peer": true, "dependencies": { "uint8array-tools": "^0.0.8", "valibot": "^1.2.0", @@ -10694,7 +10906,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -10848,7 +11059,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -10905,7 +11115,6 @@ "integrity": "sha512-iI1f+D2ViGn+uvv5HuHVUamg8ll4tN+JRHGc6IJi4TP9Kl976C57fzPXgseXNs8v0iA8aSJpHsTWjDb9QJamGQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -12136,10 +12345,9 @@ "license": "ISC" }, "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", - "dev": true, + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "funding": [ { "type": "individual", @@ -12171,6 +12379,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -12570,7 +12794,6 @@ "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.14.0.tgz", "integrity": "sha512-BBvQ/406p+4CZbTpCbVPSxfzrZrbnuWSP1ELYgyS6B+hNeKzgrdB4JczCa5VZUBQrDa9hUngm0KnexY6pJRN5Q==", "license": "MIT", - "peer": true, "engines": { "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" } @@ -12978,6 +13201,19 @@ "dev": true, "license": "MIT" }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/husky": { "version": "9.1.7", "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", @@ -14637,7 +14873,6 @@ "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -14647,7 +14882,6 @@ "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, "license": "MIT", "dependencies": { "mime-db": "1.52.0" @@ -15892,7 +16126,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -16060,7 +16293,6 @@ "integrity": "sha512-yEPsovQfpxYfgWNhCfECjG5AQaO+K3dp6XERmOepyPDVqcJm+bjyCVO3pmU+nAPe0N5dDvekfGezt/EIiRe1TA==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -16193,6 +16425,15 @@ "node": ">= 0.10" } }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/public-encrypt": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/public-encrypt/-/public-encrypt-4.0.3.tgz", @@ -16393,7 +16634,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -16403,7 +16643,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -16417,7 +16656,6 @@ "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -17022,7 +17260,6 @@ "integrity": "sha512-y5LWb0IlbO4e97Zr7c3mlpabcbBtS+ieiZ9iwDooShpFKWXf62zz5pEPdwrLYm+Bxn1fnbwFGzHuCLSA9tBmrw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.0.2", @@ -18614,7 +18851,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -18897,8 +19133,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD", - "peer": true + "license": "0BSD" }, "node_modules/tsutils": { "version": "3.21.0", @@ -18929,7 +19164,6 @@ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -18989,7 +19223,6 @@ "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", "dev": true, "license": "(MIT OR CC0-1.0)", - "peer": true, "engines": { "node": ">=10" }, @@ -19099,7 +19332,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -19438,7 +19670,6 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -19555,7 +19786,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -19712,7 +19942,6 @@ "integrity": "sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -19762,7 +19991,6 @@ "integrity": "sha512-MfwFQ6SfwinsUVi0rNJm7rHZ31GyTcpVE5pgVA3hwFRb7COD4TzjUUwhGWKfO50+xdc2MQPuEBBJoqIMGt3JDw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@discoveryjs/json-ext": "^0.6.1", "@webpack-cli/configtest": "^3.0.1", @@ -19873,7 +20101,6 @@ "integrity": "sha512-9Gyu2F7+bg4Vv+pjbovuYDhHX+mqdqITykfzdM9UyKqKHlsE5aAjRhR+oOEfXW5vBeu8tarzlJFIZva4ZjAdrQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/bonjour": "^3.5.13", "@types/connect-history-api-fallback": "^1.5.4", @@ -19998,7 +20225,6 @@ "integrity": "sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10.13.0" } @@ -20257,7 +20483,6 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", "license": "MIT", - "peer": true, "engines": { "node": ">=10.0.0" }, diff --git a/ext/package.json b/ext/package.json index 623801d66..a82daa398 100755 --- a/ext/package.json +++ b/ext/package.json @@ -36,6 +36,8 @@ "@stacks/blockchain-api-client": "8.15.3", "@stacks/transactions": "7.4.0", "@stacks/wallet-sdk": "7.2.0", + "@utexo/rgb-sdk-core": "1.0.0-beta.3", + "@utexo/rgb-sdk-web": "1.0.0-beta.9", "assert": "2.1.0", "bignumber.js": "9.3.1", "bip21": "3.0.0", diff --git a/ext/src/modules/background-caller.ts b/ext/src/modules/background-caller.ts index 09b3c708a..f1214d225 100644 --- a/ext/src/modules/background-caller.ts +++ b/ext/src/modules/background-caller.ts @@ -3,7 +3,8 @@ import { GetBtcSendDataResponse, IBackgroundCaller, MessageType, GetCommonTransa import { ENCRYPTED_PREFIX, STORAGE_KEY_MNEMONIC, STORAGE_KEY_SEED_VERIFIED } from '@shared/types/IStorage'; import { LayerzStorage } from '../class/layerz-storage'; import { SecureStorage } from '../class/secure-storage'; -import { NETWORK_SPARK } from '@shared/types/networks'; +import { NETWORK_RGB, NETWORK_RGB_TESTNET, NETWORK_SPARK } from '@shared/types/networks'; +import { RgbWallet } from '@shared/class/wallets/rgb-wallet'; import { SparkWallet } from '@shared/class/wallets/spark-wallet'; import { lazyInitWallet as lazyInitWalletOrig, @@ -71,6 +72,13 @@ export const BackgroundCaller: IBackgroundCaller = { return String(await sp.getOffchainReceiveAddress()); } + if (network === NETWORK_RGB || network === NETWORK_RGB_TESTNET) { + // RGB SDK loads WASM + uses IndexedDB, both unavailable in the MV3 service worker. + const rw = await BackgroundCaller.lazyInitWallet(network, accountNumber); + assert(rw instanceof RgbWallet); + return await rw.getOffchainReceiveAddress(); + } + return await Messenger.sendGenericMessageToBackground(MessageType.GET_ADDRESS, params); }, @@ -146,6 +154,13 @@ export const BackgroundCaller: IBackgroundCaller = { }, async getCommonTransactions(...params): Promise { + const [network, accountNumber] = params; + if (network === NETWORK_RGB || network === NETWORK_RGB_TESTNET) { + // RGB SDK is WASM/IndexedDB-bound; has to run in Popup, not the SW. + const rw = await BackgroundCaller.lazyInitWallet(network, accountNumber); + assert(rw instanceof RgbWallet); + return await rw.getCommonTransactions(); + } return await Messenger.sendGenericMessageToBackground(MessageType.GET_COMMON_TRANSACTIONS, params); }, diff --git a/ext/src/modules/background-message-controller.ts b/ext/src/modules/background-message-controller.ts index 0940c85b8..878167c1d 100644 --- a/ext/src/modules/background-message-controller.ts +++ b/ext/src/modules/background-message-controller.ts @@ -8,7 +8,7 @@ import { getDeviceID } from '@shared/modules/device-id'; import { clearWalletCache, lazyInitWallet, sanitizeAndValidateMnemonic, saveBitcoinXpubs, saveWalletState, setMasterSeed, getMasterSeed } from '@shared/modules/wallet-utils'; import { IBackgroundCaller, MessageType, MessageTypeMap, OpenPopupRequest, ProcessRPCRequest } from '@shared/types/IBackgroundCaller'; import { ENCRYPTED_PREFIX, STORAGE_KEY_EVM_XPUB, STORAGE_KEY_MNEMONIC } from '@shared/types/IStorage'; -import { NETWORK_ARK, NETWORK_ARK_MUTINYNET, NETWORK_BITCOIN, NETWORK_LIQUID, NETWORK_LIQUID_TESTNET, NETWORK_SPARK, NETWORK_STACKS } from '@shared/types/networks'; +import { NETWORK_ARK, NETWORK_ARK_MUTINYNET, NETWORK_BITCOIN, NETWORK_LIQUID, NETWORK_LIQUID_TESTNET, NETWORK_RGB, NETWORK_RGB_TESTNET, NETWORK_SPARK, NETWORK_STACKS } from '@shared/types/networks'; import { Csprng } from '../../src/class/rng'; import { LayerzStorage } from '../class/layerz-storage'; import { SecureStorage } from '../class/secure-storage'; @@ -87,6 +87,8 @@ export const BackgroundExtensionExecutor: Pick = { return await aw.getOffchainReceiveAddress(); } else if (network === NETWORK_SPARK) { throw new Error('this should never happen: temporarily executed on the spot in the BackgroundCaller'); // fixme + } else if (network === NETWORK_RGB || network === NETWORK_RGB_TESTNET) { + throw new Error('this should never happen: RGB runs in the Popup context (WASM + IndexedDB) via BackgroundCaller'); } else if (network === NETWORK_LIQUID || network === NETWORK_LIQUID_TESTNET) { const wallet = await lazyInitWallet(network, accountNumber, LayerzStorage, SecureStorage); assert(wallet instanceof BreezWallet); diff --git a/ext/src/modules/rgb-adapter.ts b/ext/src/modules/rgb-adapter.ts new file mode 100644 index 000000000..d9ed7fe0e --- /dev/null +++ b/ext/src/modules/rgb-adapter.ts @@ -0,0 +1,24 @@ +import { UTEXOWallet, restoreUtxoWalletFromVss } from '@utexo/rgb-sdk-web'; + +import type { IRgbAdapter, IRgbAdapterCreateParams, IRgbWallet } from '@shared/types/rgb-adapter'; + +// The web SDK persists wallet state in IndexedDB, which is scoped per-origin +// by the browser — so a per-mnemonic subdirectory (as the mobile adapter uses) +// isn't needed here. Switching mnemonic requires clearing the IndexedDB store +// via the SDK's own dispose/restore flow, not a filesystem path trick. +class RgbAdapter implements IRgbAdapter { + readonly capabilities = { lightning: false } as const; + + async createWallet({ mnemonic, network, vssServerUrl }: IRgbAdapterCreateParams): Promise { + const wallet = new UTEXOWallet(mnemonic, { network, vssServerUrl }); + await wallet.initialize(); + return wallet; + } + + async restoreFromVss({ mnemonic, network, vssServerUrl }: IRgbAdapterCreateParams): Promise { + await restoreUtxoWalletFromVss({ mnemonic, networkPreset: network, vssServerUrl }); + return this.createWallet({ mnemonic, network, vssServerUrl }); + } +} + +globalThis.rgbAdapter = new RgbAdapter(); diff --git a/ext/src/pages/Popup/Home.tsx b/ext/src/pages/Popup/Home.tsx index 6eae3cbaf..b85ab254e 100644 --- a/ext/src/pages/Popup/Home.tsx +++ b/ext/src/pages/Popup/Home.tsx @@ -18,6 +18,8 @@ import { NETWORK_LIGHTNING_TESTNET, NETWORK_LIQUID, NETWORK_LIQUID_TESTNET, + NETWORK_RGB, + NETWORK_RGB_TESTNET, NETWORK_ROOTSTOCK, NETWORK_SPARK, NETWORK_STACKS, @@ -30,6 +32,7 @@ import { BackgroundCaller } from '../../modules/background-caller'; import Balance from './components/Balance'; import NftsView from './components/NftsView'; import PartnersView from './components/PartnersView'; +import RgbBackupBanner from './components/RgbBackupBanner'; import SwapInterfaceView from './components/SwapInterfaceView'; import SwapListView from './components/SwapListView'; import TokensView from './components/TokensView'; @@ -59,6 +62,10 @@ const Home: React.FC = () => { }, [network]); const handleReceive = () => { + if (network === NETWORK_RGB || network === NETWORK_RGB_TESTNET) { + navigate('/receive-rgb-token'); + return; + } navigate('/receive'); }; @@ -81,6 +88,10 @@ const Home: React.FC = () => { case NETWORK_LIGHTNING_TESTNET: navigate('/send-lightning'); break; + case NETWORK_RGB: + case NETWORK_RGB_TESTNET: + navigate('/send-rgb'); + break; default: navigate('/send-evm'); } @@ -241,6 +252,8 @@ const Home: React.FC = () => { + + {showSwapInterface ? ( ) : ( diff --git a/ext/src/pages/Popup/OnboardingCreatePassword.tsx b/ext/src/pages/Popup/OnboardingCreatePassword.tsx index 76d80dd63..0b5b0f0d6 100644 --- a/ext/src/pages/Popup/OnboardingCreatePassword.tsx +++ b/ext/src/pages/Popup/OnboardingCreatePassword.tsx @@ -25,8 +25,19 @@ export default function OnboardingCreatePassword() { try { await BackgroundCaller.encryptMnemonic(pass1); - setStep(EStep.TOS); - navigate('/onboarding-tos'); + // On the import path we run a one-time VSS reachability probe before + // letting the user reach the wallet. Create-wallet skips it (no + // backup to restore from). Flag is set in OnboardingImportWallet. + // The gate route is registered in BOTH EStep.PASSWORD and EStep.TOS + // Routes blocks so this navigate matches without first transitioning + // EStep — the gate itself bumps EStep to TOS when it proceeds. + const justImported = sessionStorage.getItem('rgb.justImported') === '1'; + if (justImported) { + navigate('/onboarding-verifying-rgb-backup'); + } else { + setStep(EStep.TOS); + navigate('/onboarding-tos'); + } } catch (error) { setValidationError('An error occurred'); setIsLoading(false); diff --git a/ext/src/pages/Popup/OnboardingImportWallet.tsx b/ext/src/pages/Popup/OnboardingImportWallet.tsx index f0c034989..b3dbba1af 100644 --- a/ext/src/pages/Popup/OnboardingImportWallet.tsx +++ b/ext/src/pages/Popup/OnboardingImportWallet.tsx @@ -35,6 +35,9 @@ export default function OnboardingImport() { return; } else { await BackgroundCaller.setMasterSeed(sanitizedSeed); + // Marks this onboarding flow as restore-from-seed so the + // post-password step routes through the VSS reachability gate. + sessionStorage.setItem('rgb.justImported', '1'); setStep(EStep.PASSWORD); } }; diff --git a/ext/src/pages/Popup/OnboardingVerifyingRgbBackup.tsx b/ext/src/pages/Popup/OnboardingVerifyingRgbBackup.tsx new file mode 100644 index 000000000..1e327366f --- /dev/null +++ b/ext/src/pages/Popup/OnboardingVerifyingRgbBackup.tsx @@ -0,0 +1,124 @@ +import { CloudOff, Loader2, AlertTriangle } from 'lucide-react'; +import React, { useCallback, useContext, useEffect, useState } from 'react'; +import { useNavigate } from 'react-router'; + +import { RgbBackupServerUnreachableError } from '@shared/class/wallets/rgb-wallet'; +import { EStep, InitializationContext } from '@shared/hooks/InitializationContext'; +import { NETWORK_RGB_TESTNET } from '@shared/types/networks'; + +import { BackgroundCaller } from '../../modules/background-caller'; +import { ThemedText } from '../../components/ThemedText'; +import { Button, WideButton } from './DesignSystem'; + +const TARGET_NEXT = '/onboarding-tos'; +const PENDING_FLAG = 'rgb.justImported'; + +type Status = 'probing' | 'failed'; + +/** + * Restore-from-seed gate. Sits between password and TOS on the import path. + * + * The new init() flow refuses to silently create a fresh RGB wallet when VSS + * is unreachable (would overwrite the real backup with empty state). We + * surface that *during onboarding* — not on first RGB tap — so a user + * restoring on a flaky network gets a clear "VSS server unreachable, retry?" + * before they're dropped into the wallet UI. + * + * On Skip the user proceeds to TOS; subsequent RGB inits still fail with + * the same typed error, so the safety net is never bypassed. + * + * See tasks/ship-rgb.md. + */ +const OnboardingVerifyingRgbBackup: React.FC = () => { + const navigate = useNavigate(); + const { setStep } = useContext(InitializationContext); + const [status, setStatus] = useState('probing'); + const [errorMessage, setErrorMessage] = useState(null); + const [errorKind, setErrorKind] = useState<'unreachable' | 'other'>('unreachable'); + + const proceed = useCallback(() => { + sessionStorage.removeItem(PENDING_FLAG); + // Same race avoidance as the password screen: navigate first, then bump + // the EStep so the new Routes block is mounted with the URL already at + // /onboarding-tos. + navigate(TARGET_NEXT, { replace: true }); + setStep(EStep.TOS); + }, [navigate, setStep]); + + const probe = useCallback(async () => { + setStatus('probing'); + setErrorMessage(null); + try { + // Testnet is intentional — VSS server URL is shared with mainnet so + // reachability is correlated, and a successful init pre-warms the + // cache for the first RGB tap. + await BackgroundCaller.lazyInitWallet(NETWORK_RGB_TESTNET, 0); + proceed(); + } catch (e: any) { + if (e instanceof RgbBackupServerUnreachableError) { + setErrorKind('unreachable'); + setErrorMessage('Backup server is unreachable. We can’t verify your RGB backup right now.'); + } else { + setErrorKind('other'); + setErrorMessage(typeof e?.message === 'string' ? e.message : 'Could not verify your RGB backup.'); + } + setStatus('failed'); + } + }, [proceed]); + + useEffect(() => { + probe(); + }, [probe]); + + const accent = errorKind === 'unreachable' ? 'rgba(255, 255, 255, 0.9)' : '#ffb86b'; + + return ( +
+
+ {status === 'probing' ? ( + + ) : errorKind === 'unreachable' ? ( + + ) : ( + + )} +
+ + {status === 'probing' ? 'Verifying RGB backup…' : 'Backup not verified'} + +

+ {status === 'probing' + ? 'Checking that your RGB backup is reachable before we restore your wallet. This is a one-time step.' + : (errorMessage ?? 'Unknown error') + ' You can skip RGB for now and try again later from the home banner.'} +

+ + {status === 'failed' ? ( +
+ + Retry + +
+ +
+
+ ) : null} + + +
+ ); +}; + +export default OnboardingVerifyingRgbBackup; diff --git a/ext/src/pages/Popup/Popup.tsx b/ext/src/pages/Popup/Popup.tsx index db95de44b..3ab8bec84 100644 --- a/ext/src/pages/Popup/Popup.tsx +++ b/ext/src/pages/Popup/Popup.tsx @@ -5,6 +5,7 @@ import { SWRConfig } from 'swr'; import '../../modules/breeze-adapter'; // needed to be imported before we can use BreezWallet import '../../modules/error-handler'; +import '../../modules/rgb-adapter'; // needed to be imported before we can use RgbWallet import '../../modules/spark-adapter'; // needed to be imported before we can use SparkWallet import { AccountNumberContextProvider } from '@shared/hooks/AccountNumberContext'; @@ -26,15 +27,18 @@ import OnboardingCreateWallet from './OnboardingCreateWallet'; import OnboardingImportWallet from './OnboardingImportWallet'; import OnboardingIntro from './OnboardingIntro'; import OnboardingTos from './OnboardingTos'; +import OnboardingVerifyingRgbBackup from './OnboardingVerifyingRgbBackup'; import './Popup.css'; import Receive from './Receive'; import ReceiveLightning from './ReceiveLightning'; +import ReceiveRgbToken from './ReceiveRgbToken'; import SeedBackup from './SeedBackup'; import SendAccountBased from './SendAccountBased'; import SendBtc from './SendBtc'; import SendEvm from './SendEvm'; import SendLightning from './SendLightning'; import SendLiquid from './SendLiquid'; +import SendRgb from './SendRgb'; import SendTokenEvm from './SendTokenEvm'; import SettingsPage from './SettingsPage'; import SwapDetails from './SwapDetails'; @@ -70,6 +74,7 @@ const AppContent: React.FC = () => { } /> } /> + } /> } /> ); @@ -89,6 +94,7 @@ const AppContent: React.FC = () => { } /> } /> + } /> } /> ); @@ -100,6 +106,7 @@ const AppContent: React.FC = () => { } /> } /> } /> + } /> } /> } /> } /> @@ -107,6 +114,7 @@ const AppContent: React.FC = () => { } /> } /> } /> + } /> } /> } /> {/* we are using camel case because screen name matches one in the mobile app */} diff --git a/ext/src/pages/Popup/ReceiveRgbToken.tsx b/ext/src/pages/Popup/ReceiveRgbToken.tsx new file mode 100644 index 000000000..6ed8c931f --- /dev/null +++ b/ext/src/pages/Popup/ReceiveRgbToken.tsx @@ -0,0 +1,304 @@ +import assert from 'assert'; +import writeQR from '@paulmillr/qr'; +import BigNumber from 'bignumber.js'; +import { ArrowDownRightIcon, ClipboardCopy } from 'lucide-react'; +import React, { useContext, useEffect, useRef, useState } from 'react'; +import { useNavigate } from 'react-router'; + +import { RgbWallet } from '@shared/class/wallets/rgb-wallet'; +import { AccountNumberContext } from '@shared/hooks/AccountNumberContext'; +import { NetworkContext } from '@shared/hooks/NetworkContext'; +import { formatBalance } from '@shared/modules/string-utils'; +import { NETWORK_RGB, NETWORK_RGB_TESTNET } from '@shared/types/networks'; +import { CachedTokenInfo } from '@shared/types/token-info'; + +import { BackgroundCaller } from '../../modules/background-caller'; +import { ThemedText } from '../../components/ThemedText'; +import { Button, Input, Select, WideButton } from './DesignSystem'; + +type ReceiveResult = { + invoice: string; + type: 'blind' | 'witness'; + expirationTimestamp: number | null; +}; + +const ANY_ASSET = '__any__'; + +const qrGifDataUrl = (text: string) => { + const gifBytes = writeQR(text, 'gif', { scale: text.length > 43 ? 4 : 7 }); + const blob = new Blob([new Uint8Array(gifBytes)], { type: 'image/gif' }); + return URL.createObjectURL(blob); +}; + +const ReceiveRgbToken: React.FC = () => { + const navigate = useNavigate(); + const { network } = useContext(NetworkContext); + const { accountNumber } = useContext(AccountNumberContext); + + const [knownAssets, setKnownAssets] = useState([]); + const [selectedAssetId, setSelectedAssetId] = useState(ANY_ASSET); + const [amountStr, setAmountStr] = useState(''); + const [isGenerating, setIsGenerating] = useState(false); + const [error, setError] = useState(null); + const [result, setResult] = useState(null); + const [qrSrc, setQrSrc] = useState(''); + const [copied, setCopied] = useState(false); + + const [received, setReceived] = useState<{ symbol: string; name: string; decimals: number; delta: string } | null>(null); + const initialBalancesRef = useRef | null>(null); + const pollTimerRef = useRef(null); + + const isRgb = network === NETWORK_RGB || network === NETWORK_RGB_TESTNET; + + // Load asset list for the dropdown. + useEffect(() => { + if (!isRgb) return; + let cancelled = false; + (async () => { + try { + const wallet = await BackgroundCaller.lazyInitWallet(network, accountNumber); + if (cancelled) return; + if (!(wallet instanceof RgbWallet)) return; + await wallet.fetchTokenBalances(); + if (cancelled) return; + setKnownAssets(wallet.getTokenBalances()); + } catch (e) { + console.warn('failed to load asset list:', e); + } + })(); + return () => { + cancelled = true; + }; + }, [network, accountNumber, isRgb]); + + // Poll for incoming asset delta after invoice is generated. + useEffect(() => { + if (!result || !isRgb) return; + let cancelled = false; + (async () => { + const wallet = await BackgroundCaller.lazyInitWallet(network, accountNumber); + if (cancelled || !(wallet instanceof RgbWallet)) return; + await wallet.fetchTokenBalances(); + if (cancelled) return; + const initial = new Map(); + for (const t of wallet.getTokenBalances()) initial.set(t.id, String(t.balance ?? '0')); + initialBalancesRef.current = initial; + + const tick = async () => { + const w = await BackgroundCaller.lazyInitWallet(network, accountNumber); + if (cancelled || !(w instanceof RgbWallet)) return; + await w.fetchTokenBalances(); + if (cancelled) return; + for (const t of w.getTokenBalances()) { + const cur = new BigNumber(String(t.balance ?? '0')); + const ini = new BigNumber(initialBalancesRef.current?.get(t.id) ?? '0'); + if (cur.gt(ini)) { + setReceived({ symbol: t.symbol, name: t.name, decimals: t.decimals, delta: cur.minus(ini).toString(10) }); + if (pollTimerRef.current) clearInterval(pollTimerRef.current as number); + pollTimerRef.current = null; + return; + } + } + }; + pollTimerRef.current = setInterval(tick, 4_000); + })(); + return () => { + cancelled = true; + if (pollTimerRef.current) clearInterval(pollTimerRef.current as number); + pollTimerRef.current = null; + }; + }, [result, network, accountNumber, isRgb]); + + // Render QR whenever we get a new invoice. + useEffect(() => { + if (result?.invoice) { + setQrSrc(qrGifDataUrl(result.invoice)); + } + }, [result]); + + if (!isRgb) { + return ( +
+ Receive RGB Asset +

Switch to an RGB network to receive assets.

+
+ ); + } + + const generate = async () => { + setError(null); + setIsGenerating(true); + try { + const wallet = await BackgroundCaller.lazyInitWallet(network, accountNumber); + assert(wallet instanceof RgbWallet, 'Wallet is not an RgbWallet'); + const n = Number(amountStr); + if (!amountStr.trim() || !Number.isFinite(n) || n <= 0 || !Number.isSafeInteger(n)) { + setError('Amount is required and must be a positive integer (in base units).'); + setIsGenerating(false); + return; + } + const params: { assetId?: string; amount: number } = { amount: n }; + if (selectedAssetId !== ANY_ASSET) params.assetId = selectedAssetId; + const r = await wallet.requestReceive(params); + setResult({ invoice: r.invoice, type: r.type, expirationTimestamp: r.expirationTimestamp }); + } catch (e: any) { + console.warn('requestReceive failed:', e); + setError(e?.message ?? 'Failed to generate invoice'); + } finally { + setIsGenerating(false); + } + }; + + const copyInvoice = async () => { + if (!result) return; + await navigator.clipboard.writeText(result.invoice); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + // Success card — incoming token detected. + if (received) { + const display = formatBalance(received.delta, received.decimals, 8); + return ( +
+ Asset Received +
+
+

+ + +{display} {received.symbol} + +

+
{received.name}
+ navigate('/')}>Back to Wallet +
+
+ ); + } + + // Generated invoice view. + if (result) { + return ( +
+ RGB Invoice +
+ + {result.type === 'blind' ? 'Private (blind)' : 'Witness invoice'} + +
+ +
+ {qrSrc && invoice qr} +
+ +
+ {result.invoice} +
+ + + +

+ {result.type === 'blind' + ? 'Share this invoice with the sender. Their payment will land on a UTXO known only to you.' + : 'No free allocation slot was available, so this invoice creates a fresh slot when paid. Slightly less private than a blind invoice.'} +

+ + navigate('/')}>Done +
+ ); + } + + // Form view. + return ( +
+ Receive RGB Asset +
+ Asset +
+ +
+ +
+ Amount (base units) +
+ setAmountStr(e.target.value.replace(/[^0-9]/g, ''))} data-testid="ReceiveRgb.Amount" /> +
+ + {error ? ( +
+ {error} +
+ ) : null} + +
+ + {isGenerating ? 'Generating…' : 'Generate Invoice'} + +
+ +
+ +
+
+ ); +}; + +export default ReceiveRgbToken; diff --git a/ext/src/pages/Popup/SendRgb.tsx b/ext/src/pages/Popup/SendRgb.tsx new file mode 100644 index 000000000..a77821231 --- /dev/null +++ b/ext/src/pages/Popup/SendRgb.tsx @@ -0,0 +1,283 @@ +import assert from 'assert'; +import BigNumber from 'bignumber.js'; +import { Scan, SendIcon } from 'lucide-react'; +import React, { useContext, useRef, useState } from 'react'; +import { useNavigate } from 'react-router'; + +import { RgbWallet } from '@shared/class/wallets/rgb-wallet'; +import { AccountNumberContext } from '@shared/hooks/AccountNumberContext'; +import { NetworkContext } from '@shared/hooks/NetworkContext'; +import { formatBalance } from '@shared/modules/string-utils'; +import { NETWORK_RGB, NETWORK_RGB_TESTNET } from '@shared/types/networks'; +import { CachedTokenInfo } from '@shared/types/token-info'; + +import { AskMnemonicContext } from '../../hooks/AskMnemonicContext'; +import { useScanQR } from '../../hooks/ScanQrContext'; +import { BackgroundCaller } from '../../modules/background-caller'; +import { Button, HodlButton, Input, WideButton } from './DesignSystem'; + +enum Step { + Init, + Loading, + Prepared, + Sending, + Sent, +} + +type DecodedInvoiceState = { + isInvoice: true; + token: CachedTokenInfo; + amountBase: bigint; // amount in base units; carried by the invoice +}; + +type DecodedTaprootState = { + isInvoice: false; + amountSats: number; +}; + +const SendRgb: React.FC = () => { + const scanQr = useScanQR(); + const navigate = useNavigate(); + const { network } = useContext(NetworkContext); + const { accountNumber } = useContext(AccountNumberContext); + const { askMnemonic } = useContext(AskMnemonicContext); + + const [recipient, setRecipient] = useState(''); + const [amountStr, setAmountStr] = useState(''); + const [error, setError] = useState(''); + const [step, setStep] = useState(Step.Init); + const [txid, setTxid] = useState(''); + const [vanillaSatBalance, setVanillaSatBalance] = useState(null); + const decoded = useRef(null); + const walletRef = useRef(null); + + const isRgb = network === NETWORK_RGB || network === NETWORK_RGB_TESTNET; + + if (!isRgb) { + return ( +
+

Send

+

Switch to an RGB network to send RGB assets or tBTC.

+
+ ); + } + + const looksLikeInvoice = (s: string) => s.startsWith('rgb:') || s.startsWith('utxob:'); + + const prepare = async () => { + setError(''); + setStep(Step.Loading); + try { + const trimmed = recipient.trim(); + assert(trimmed, 'Recipient is required'); + assert(RgbWallet.isAddressValid(trimmed), 'Invalid address or invoice'); + + const wallet = await BackgroundCaller.lazyInitWallet(network, accountNumber); + assert(wallet instanceof RgbWallet, 'Wallet is not an RgbWallet'); + walletRef.current = wallet; + + if (looksLikeInvoice(trimmed)) { + const d = await wallet.decodeInvoice(trimmed); + assert(d, 'Could not decode invoice'); + await wallet.fetchTokenBalances(); + const tokens = wallet.getTokenBalances(); + const matched = d.assetId ? tokens.find((t) => t.id === d.assetId) : undefined; + assert(matched, d.assetId ? `Asset ${d.assetId} not in your wallet` : 'Invoice has no asset id'); + + // The invoice must carry an amount; otherwise we can't send a token + // without prompting the user for one. Mobile takes the same shortcut. + assert(typeof d.amount === 'number' && d.amount > 0, 'Invoice has no amount; cannot send.'); + + decoded.current = { + isInvoice: true, + token: matched, + amountBase: BigInt(d.amount), + }; + } else { + // Taproot tBTC send — require an amount field from the user. + const n = Number(amountStr); + assert(!isNaN(n) && n > 0, 'Amount must be a positive number'); + const sats = new BigNumber(amountStr).multipliedBy(new BigNumber(10).pow(8)).toNumber(); + assert(Number.isInteger(sats) && sats > 0, 'Amount must be a positive integer in sats'); + + const balance = await wallet.getOffchainBalance(); + setVanillaSatBalance(balance); + assert(sats <= balance, `Not enough vanilla sats (have ${balance})`); + + decoded.current = { isInvoice: false, amountSats: sats }; + } + + await askMnemonic(); + setStep(Step.Prepared); + } catch (e: any) { + setError(e?.message ?? 'Failed to prepare transaction'); + setStep(Step.Init); + } + }; + + const broadcast = async () => { + setStep(Step.Sending); + setError(''); + try { + const wallet = walletRef.current; + const d = decoded.current; + assert(wallet, 'Internal error: wallet missing'); + assert(d, 'Internal error: nothing prepared'); + + let id: string; + if (d.isInvoice) { + id = await wallet.transferToken(d.token.id, d.amountBase, recipient.trim()); + } else { + id = await wallet.pay(recipient.trim(), d.amountSats); + } + assert(id, 'Transaction failed'); + setTxid(id); + setStep(Step.Sent); + } catch (e: any) { + setError(e?.message ?? 'Send failed'); + setStep(Step.Prepared); + } + }; + + if (step === Step.Sent) { + return ( +
+
+

Sent!

+

Your transaction is on its way.

+ {txid ?

txid: {txid}

: null} + navigate('/')}>Back to Wallet +
+ ); + } + + const isInvoiceMode = looksLikeInvoice(recipient.trim()); + + return ( +
+

Send {isInvoiceMode ? 'RGB asset' : 'tBTC'}

+ + {step !== Step.Prepared ? ( + <> +
+ Recipient address or invoice +
+
+ setRecipient(e.target.value)} /> + +
+
+ + {!isInvoiceMode && recipient.trim().length > 0 ? ( +
+ Amount (tBTC) +
+ setAmountStr(e.target.value)} data-testid="SendRgb.Amount" /> + {vanillaSatBalance !== null ?
Available: {formatBalance(String(vanillaSatBalance), 8, 8)} tBTC
: null} +
+ ) : null} + + ) : null} + + {error ? ( +
+ {error} +
+ ) : null} + + {step === Step.Loading ? Preparing… : null} + {step === Step.Sending ? Sending… : null} + + {step === Step.Init ? ( + + Continue + + ) : null} + + {step === Step.Prepared && decoded.current ? ( +
+
+ {decoded.current.isInvoice ? ( +
+
+ Asset: {decoded.current.token.symbol || decoded.current.token.name} +
+
+ Amount: {formatBalance(decoded.current.amountBase.toString(), decoded.current.token.decimals, 8)} {decoded.current.token.symbol} +
+
+ To: {recipient.trim()} +
+
+ ) : ( +
+
+ Amount: {amountStr} tBTC ({decoded.current.amountSats} sats) +
+
+ To: {recipient.trim()} +
+
+ )} +
+ + + Hold to confirm send + + + +
+ ) : null} +
+ ); +}; + +export default SendRgb; diff --git a/ext/src/pages/Popup/components/RgbBackupBanner.tsx b/ext/src/pages/Popup/components/RgbBackupBanner.tsx new file mode 100644 index 000000000..6c96a99f6 --- /dev/null +++ b/ext/src/pages/Popup/components/RgbBackupBanner.tsx @@ -0,0 +1,84 @@ +import { AlertTriangle, CloudUpload, Loader2 } from 'lucide-react'; +import React, { useContext, useState } from 'react'; + +import { AccountNumberContext } from '@shared/hooks/AccountNumberContext'; +import { NetworkContext } from '@shared/hooks/NetworkContext'; +import { useRgbBackupStatus } from '@shared/hooks/useRgbBackupStatus'; + +import { BackgroundCaller } from '../../../modules/background-caller'; + +// Persistent banner for the RGB backup ledger — see +// tasks/ship-rgb.md. +const RgbBackupBanner: React.FC = () => { + const { network } = useContext(NetworkContext); + const { accountNumber } = useContext(AccountNumberContext); + const { status, pendingCount, lastError, retry } = useRgbBackupStatus(network, accountNumber, BackgroundCaller); + const [isRetrying, setIsRetrying] = useState(false); + const [retryError, setRetryError] = useState(null); + + if (status === 'synced') return null; + + const isFailed = status === 'failed'; + const title = isFailed ? 'Backup failed' : 'Backup pending'; + const detail = isFailed + ? `${pendingCount} change${pendingCount === 1 ? '' : 's'} not yet saved to backup. Tap to retry — until then, recovery on a new device may be missing recent activity.` + : `${pendingCount} change${pendingCount === 1 ? '' : 's'} are syncing to backup. Usually clears within seconds.`; + + const handlePress = async () => { + if (isRetrying) return; + setIsRetrying(true); + setRetryError(null); + try { + const ok = await retry(); + if (!ok) setRetryError(lastError?.message ?? 'Unknown error. Try again in a moment, or check your network.'); + } catch (e: any) { + setRetryError(e?.message ?? 'Unknown error.'); + } finally { + setIsRetrying(false); + } + }; + + const accent = isFailed ? '#ffb86b' : 'rgba(255, 255, 255, 0.9)'; + + return ( +
+
+
+ {isRetrying ? : isFailed ? : } +
+
{title}
+
+
{detail}
+ {retryError ?
{retryError}
: null} + +
+ ); +}; + +export default RgbBackupBanner; diff --git a/ext/src/typings/globals.d.ts b/ext/src/typings/globals.d.ts index cfa551c96..42946a2e2 100644 --- a/ext/src/typings/globals.d.ts +++ b/ext/src/typings/globals.d.ts @@ -1,11 +1,13 @@ import { IBreezAdapter } from '@shared/class/wallets/breez-wallet'; import { ISparkAdapter } from '@shared/class/wallets/spark-wallet'; +import { IRgbAdapter } from '@shared/types/rgb-adapter'; declare function alert(message: string): void; declare global { var breezAdapter: IBreezAdapter; var sparkAdapter: ISparkAdapter; + var rgbAdapter: IRgbAdapter; var handleError: ((error: unknown, context?: string) => void | Promise) | undefined; var __DEV__: boolean | undefined; } diff --git a/mobile/app/Home.tsx b/mobile/app/Home.tsx index a3073532d..3d966bbe2 100644 --- a/mobile/app/Home.tsx +++ b/mobile/app/Home.tsx @@ -10,6 +10,7 @@ import { scheduleOnRN } from 'react-native-worklets'; import ActionButtons, { Action } from '@/components/ActionButtons'; import BackupWarning from '@/components/BackupWarning'; +import RgbBackupBanner from '@/components/RgbBackupBanner'; import Balance from '@/components/Balance'; import DashboardTiles, { LayerCard } from '@/components/DashboardTiles'; import { McpAgentDashboard } from '@/src/features/mcp/components/McpAgentDashboard'; @@ -371,6 +372,10 @@ export default function Home() { {/* Seed Backup Warning */} {hasBackedUpSeed === false && } + {/* RGB VSS Backup status banner — only renders on RGB networks + when state is `pending`/`failed`. */} + + {/* Yield Section (hidden on MCP automation account) */} {accountNumber !== MCP_BALANCE_ACCOUNT_NUMBER ? : null} diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx index f5e9f7230..67bb890ff 100644 --- a/mobile/app/_layout.tsx +++ b/mobile/app/_layout.tsx @@ -12,6 +12,7 @@ import BigNumber from 'bignumber.js'; import '../src/modules/breeze-adapter'; // needed to be imported before we can use BreezWallet import '../src/modules/spark-adapter'; // needed to be imported before we can use SparkWallet +import '../src/modules/rgb-adapter'; // needed to be imported before we can use RgbWallet import AutoClaimMonitor from '@/components/AutoClaimMonitor'; import { ErrorBoundary } from '@/components/ErrorBoundary'; diff --git a/mobile/app/issue-asset.tsx b/mobile/app/issue-asset.tsx new file mode 100644 index 000000000..3a13e679a --- /dev/null +++ b/mobile/app/issue-asset.tsx @@ -0,0 +1,257 @@ +import * as Clipboard from 'expo-clipboard'; +import * as Haptics from 'expo-haptics'; +import { Stack, useRouter } from 'expo-router'; +import React, { useContext, useState } from 'react'; +import { Alert, KeyboardAvoidingView, Platform, ScrollView, StyleSheet, TextInput, View } from 'react-native'; + +import Button from '@/components/Button'; +import RadialGradientScreen from '@/components/RadialGradientScreen'; +import ScreenHeader from '@/components/navigation/ScreenHeader'; +import { ThemedText } from '@/components/ThemedText'; +import { BackgroundExecutor } from '@/src/modules/background-executor'; +import { RgbWallet } from '@shared/class/wallets/rgb-wallet'; +import { AccountNumberContext } from '@shared/hooks/AccountNumberContext'; +import { NetworkContext } from '@shared/hooks/NetworkContext'; +import { NETWORK_RGB, NETWORK_RGB_TESTNET } from '@shared/types/networks'; + +type IssuedAsset = { assetId: string; ticker: string; name: string; precision: number }; + +// rgb-lib's "no spendable colorable UTXO available for issuance" error name. The +// RN binding surfaces it via `code` and as a substring of the message; either +// form means the user needs to run createUtxos() first. +function isNoUtxoSlots(e: unknown): boolean { + const err = e as { code?: string; message?: string }; + if (err?.code === 'InsufficientAllocationSlots') return true; + const msg = String(err?.message ?? e); + return /InsufficientAllocationSlots|insufficient.*allocation/i.test(msg); +} + +export default function IssueAssetScreen() { + const router = useRouter(); + const { network } = useContext(NetworkContext); + const { accountNumber } = useContext(AccountNumberContext); + + const [ticker, setTicker] = useState(''); + const [name, setName] = useState(''); + const [precisionStr, setPrecisionStr] = useState('8'); + const [amountStr, setAmountStr] = useState(''); + + const [error, setError] = useState(null); + const [needsUtxos, setNeedsUtxos] = useState(false); + const [isCreatingUtxos, setIsCreatingUtxos] = useState(false); + const [isIssuing, setIsIssuing] = useState(false); + const [result, setResult] = useState(null); + + if (network !== NETWORK_RGB && network !== NETWORK_RGB_TESTNET) { + return ( + + + + + Switch to an RGB network to issue assets. + + + ); + } + + const precision = Number(precisionStr); + const amount = Number(amountStr); + const trimmedTicker = ticker.trim(); + const trimmedName = name.trim(); + + const validation = + !trimmedTicker || trimmedTicker.length > 8 + ? 'Ticker must be 1-8 characters.' + : !trimmedName + ? 'Name is required.' + : !Number.isInteger(precision) || precision < 0 || precision > 18 + ? 'Precision must be an integer between 0 and 18.' + : !Number.isFinite(amount) || amount <= 0 || !Number.isSafeInteger(amount) + ? 'Amount must be a positive integer (in base units).' + : null; + + const submit = async () => { + setError(null); + setIsIssuing(true); + try { + const wallet = await BackgroundExecutor.lazyInitWallet(network, accountNumber); + if (!(wallet instanceof RgbWallet)) throw new Error('Wallet is not an RgbWallet'); + const issued = await wallet.issueAssetNia({ + ticker: trimmedTicker, + name: trimmedName, + precision, + amounts: [amount], + }); + setResult(issued); + setNeedsUtxos(false); + } catch (e: any) { + console.warn('issueAssetNia failed:', e); + if (isNoUtxoSlots(e)) { + setNeedsUtxos(true); + setError('You need a colorable UTXO before issuing an asset. Tap "Create UTXO" to make one (uses a small amount of testnet sats).'); + } else { + setError(e?.message ?? 'Failed to issue asset'); + } + } finally { + setIsIssuing(false); + } + }; + + const createUtxos = async () => { + setError(null); + setIsCreatingUtxos(true); + try { + const wallet = await BackgroundExecutor.lazyInitWallet(network, accountNumber); + if (!(wallet instanceof RgbWallet)) throw new Error('Wallet is not an RgbWallet'); + await wallet.createUtxos(); + setNeedsUtxos(false); + // Auto-retry the issuance after the UTXO-creation tx is broadcast. The + // SDK confirms this synchronously enough that issueAssetNia can proceed. + await submit(); + } catch (e: any) { + console.warn('createUtxos failed:', e); + setError(e?.message ?? 'Failed to create UTXO. Make sure the wallet has some testnet sats.'); + } finally { + setIsCreatingUtxos(false); + } + }; + + const copyAssetId = async () => { + if (!result) return; + await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + await Clipboard.setStringAsync(result.assetId); + Alert.alert('Copied', 'Asset ID copied to clipboard.'); + }; + + if (result) { + return ( + + + + + Ticker + {result.ticker} + Name + {result.name} + Asset ID + + {result.assetId} + + +