├── src ├── assets │ ├── pack.js │ ├── app │ │ ├── robots.txt │ │ ├── static │ │ │ ├── icons.json │ │ │ ├── theme │ │ │ │ ├── streamgoose.css │ │ │ │ ├── minimalsats.css │ │ │ │ ├── spacequack.css │ │ │ │ ├── satoshilegacy.css │ │ │ │ └── deepoceanduck.css │ │ │ ├── 404.webp │ │ │ ├── favicon.png │ │ │ ├── icons │ │ │ │ ├── lw.png │ │ │ │ ├── lwheader.png │ │ │ │ └── blockchain-icon.png │ │ │ ├── tp │ │ │ │ ├── fonts │ │ │ │ │ └── lato │ │ │ │ │ │ ├── S6uyw4BMUTPHjx4wXg.woff2 │ │ │ │ │ │ ├── S6uyw4BMUTPHjxAwXjeu.woff2 │ │ │ │ │ │ ├── S6u9w4BMUTPHh7USSwiPGQ.woff2 │ │ │ │ │ │ ├── S6u9w4BMUTPHh7USSwaPGR_p.woff2 │ │ │ │ │ │ └── lato.css │ │ │ │ └── material-symbols │ │ │ │ │ ├── kJF1BvYX7BgnkSrUwT8OhrdQw4oELdPIeeII9v6oDMzByHX9rA6RzaxHMPdY43zj-jCxv3fzvRNU22ZXGJpEpjC_1v-p_4MrImHCIJIZrDCvHOej.woff2 │ │ │ │ │ └── material-symbols.css │ │ │ ├── specialSymbols.json │ │ │ ├── safemode.css │ │ │ ├── animations.css │ │ │ └── loadingeffect.css │ │ ├── favicon.ico │ │ ├── manifest.json │ │ ├── index.html │ │ └── liquidnotfound.html │ ├── favicon.ico │ ├── favicon.png │ ├── screenshot │ │ ├── 1.webp │ │ ├── 2.webp │ │ ├── 3.webp │ │ ├── 4.webp │ │ └── 5.webp │ └── index.html ├── less │ ├── stages │ │ ├── options.less │ │ ├── send.less │ │ ├── receive.less │ │ └── wallet.less │ └── page.less └── js │ ├── SpecialSymbols.js │ ├── utils │ ├── LinkOpener.js │ └── fetch-timeout.js │ ├── Errors.js │ ├── ui │ ├── modules │ │ ├── LogoModule.js │ │ ├── GlobalmessageModule.js │ │ ├── HeaderModule.js │ │ └── ClarityModule.js │ ├── UIStage.js │ ├── UIModule.js │ ├── stages │ │ ├── ReceiveStage.js │ │ ├── OptionsStage.js │ │ ├── SendStage.js │ │ └── WalletStage.js │ └── UI.js │ ├── storage │ ├── BrowserStore.js │ ├── MemStore.js │ ├── IDBStore.js │ ├── LocalStore.js │ └── AbstractBrowserStore.js │ ├── Constants.js │ ├── Esplora.js │ ├── VByteEstimator.js │ ├── SideSwap.js │ ├── index.js │ └── AssetProvider.js ├── githooks ├── post-commit └── pre-commit ├── prepare.sh ├── .github ├── FUNDING.yml └── workflows │ ├── format.yml │ └── build.yml ├── debug.sh ├── .gitignore ├── .vscode └── settings.json ├── Dockerfile ├── package.json ├── LICENSE ├── API.md ├── webpack.config.cjs └── README.md /src/assets/pack.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /githooks/post-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | git update-index -g -------------------------------------------------------------------------------- /src/assets/app/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: / -------------------------------------------------------------------------------- /prepare.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | npm i 3 | git config --local core.hooksPath ./githooks 4 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: ["riccardobl"] 2 | custom: ["https://getalby.com/p/rblb"] 3 | -------------------------------------------------------------------------------- /src/assets/app/static/icons.json: -------------------------------------------------------------------------------- 1 | { 2 | "unknown": "static/icons/blockchain-icon.png" 3 | } 4 | -------------------------------------------------------------------------------- /src/assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/riccardobl/anser-liquid/HEAD/src/assets/favicon.ico -------------------------------------------------------------------------------- /src/assets/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/riccardobl/anser-liquid/HEAD/src/assets/favicon.png -------------------------------------------------------------------------------- /src/assets/app/static/theme/streamgoose.css: -------------------------------------------------------------------------------- 1 | /** default theme */ 2 | @import url("../animations.css"); 3 | -------------------------------------------------------------------------------- /debug.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | NODE_ENV="development" 3 | BUILD_MODE="development" 4 | bash prepare.sh 5 | npm start -------------------------------------------------------------------------------- /src/assets/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/riccardobl/anser-liquid/HEAD/src/assets/app/favicon.ico -------------------------------------------------------------------------------- /src/assets/screenshot/1.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/riccardobl/anser-liquid/HEAD/src/assets/screenshot/1.webp -------------------------------------------------------------------------------- /src/assets/screenshot/2.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/riccardobl/anser-liquid/HEAD/src/assets/screenshot/2.webp -------------------------------------------------------------------------------- /src/assets/screenshot/3.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/riccardobl/anser-liquid/HEAD/src/assets/screenshot/3.webp -------------------------------------------------------------------------------- /src/assets/screenshot/4.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/riccardobl/anser-liquid/HEAD/src/assets/screenshot/4.webp -------------------------------------------------------------------------------- /src/assets/screenshot/5.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/riccardobl/anser-liquid/HEAD/src/assets/screenshot/5.webp -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | build 4 | src/assets/app/static/icons/lw0.png 5 | src/assets/app/static/icons/lwbg.png -------------------------------------------------------------------------------- /src/assets/app/static/404.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/riccardobl/anser-liquid/HEAD/src/assets/app/static/404.webp -------------------------------------------------------------------------------- /src/assets/app/static/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/riccardobl/anser-liquid/HEAD/src/assets/app/static/favicon.png -------------------------------------------------------------------------------- /src/assets/app/static/icons/lw.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/riccardobl/anser-liquid/HEAD/src/assets/app/static/icons/lw.png -------------------------------------------------------------------------------- /src/less/stages/options.less: -------------------------------------------------------------------------------- 1 | #liquidwallet #container.options { 2 | font-size: 100%; 3 | // max-width: 900px; 4 | } 5 | -------------------------------------------------------------------------------- /src/assets/app/static/icons/lwheader.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/riccardobl/anser-liquid/HEAD/src/assets/app/static/icons/lwheader.png -------------------------------------------------------------------------------- /src/assets/app/static/icons/blockchain-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/riccardobl/anser-liquid/HEAD/src/assets/app/static/icons/blockchain-icon.png -------------------------------------------------------------------------------- /src/assets/app/static/tp/fonts/lato/S6uyw4BMUTPHjx4wXg.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/riccardobl/anser-liquid/HEAD/src/assets/app/static/tp/fonts/lato/S6uyw4BMUTPHjx4wXg.woff2 -------------------------------------------------------------------------------- /src/assets/app/static/tp/fonts/lato/S6uyw4BMUTPHjxAwXjeu.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/riccardobl/anser-liquid/HEAD/src/assets/app/static/tp/fonts/lato/S6uyw4BMUTPHjxAwXjeu.woff2 -------------------------------------------------------------------------------- /src/assets/app/static/tp/fonts/lato/S6u9w4BMUTPHh7USSwiPGQ.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/riccardobl/anser-liquid/HEAD/src/assets/app/static/tp/fonts/lato/S6u9w4BMUTPHh7USSwiPGQ.woff2 -------------------------------------------------------------------------------- /src/assets/app/static/tp/fonts/lato/S6u9w4BMUTPHh7USSwaPGR_p.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/riccardobl/anser-liquid/HEAD/src/assets/app/static/tp/fonts/lato/S6u9w4BMUTPHh7USSwaPGR_p.woff2 -------------------------------------------------------------------------------- /src/js/SpecialSymbols.js: -------------------------------------------------------------------------------- 1 | export default { 2 | USD: "$", 3 | EUR: "€", 4 | GBP: "£", 5 | JPY: "¥", 6 | CAD: "$", 7 | AUD: "$", 8 | CNY: "¥", 9 | BTC: "₿", 10 | "L-BTC": "₿", 11 | }; 12 | -------------------------------------------------------------------------------- /src/js/utils/LinkOpener.js: -------------------------------------------------------------------------------- 1 | /** 2 | * A way to open a link that can be extended later 3 | */ 4 | 5 | export default class LinkOpener { 6 | static navigate(url) { 7 | window.open(url, "_blank"); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/assets/app/static/specialSymbols.json: -------------------------------------------------------------------------------- 1 | { 2 | "USD": "$", 3 | "EUR": "€", 4 | "GBP": "£", 5 | "JPY": "¥", 6 | "CAD": "$", 7 | "AUD": "$", 8 | "CNY": "¥", 9 | "BTC": "₿", 10 | "L-BTC": "₿" 11 | } 12 | -------------------------------------------------------------------------------- /src/less/stages/send.less: -------------------------------------------------------------------------------- 1 | /* #liquidwallet #container #sendBtn{ 2 | margin-top:auto; 3 | } */ 4 | 5 | #liquidwallet #container.send { 6 | font-size: 110%; 7 | // max-width: 900px; 8 | } 9 | 10 | /** if screen at least 1000px wide **/ 11 | -------------------------------------------------------------------------------- /src/js/Errors.js: -------------------------------------------------------------------------------- 1 | export default { 2 | "bad-txns-fee-outofrange": "Transaction fee is out of range", 3 | "bad-txns-fee-negative": "Transaction fee is negative", 4 | "bad-txns-in-belowout": "Transaction is invalid! Input value is below output value", 5 | }; 6 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.exclude": { 3 | "**/.git": true, 4 | "**/.svn": true, 5 | "**/.hg": true, 6 | "**/CVS": true, 7 | "**/.DS_Store": true, 8 | "**/Thumbs.db": true, 9 | "dist": true 10 | }, 11 | "less.compile": { 12 | "out": false 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/assets/app/static/tp/material-symbols/kJF1BvYX7BgnkSrUwT8OhrdQw4oELdPIeeII9v6oDMzByHX9rA6RzaxHMPdY43zj-jCxv3fzvRNU22ZXGJpEpjC_1v-p_4MrImHCIJIZrDCvHOej.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/riccardobl/anser-liquid/HEAD/src/assets/app/static/tp/material-symbols/kJF1BvYX7BgnkSrUwT8OhrdQw4oELdPIeeII9v6oDMzByHX9rA6RzaxHMPdY43zj-jCxv3fzvRNU22ZXGJpEpjC_1v-p_4MrImHCIJIZrDCvHOej.woff2 -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM caddy:latest 2 | 3 | RUN apk add --no-cache nodejs npm 4 | RUN mkdir /build 5 | COPY . /build 6 | ENV BUILD_MODE="production" 7 | ENV NODE_ENV="development" 8 | 9 | RUN cd /build &&\ 10 | npm install 11 | 12 | RUN cd /build &&\ 13 | echo $PATH &&\ 14 | npm run build &&\ 15 | mv /build/dist/* /usr/share/caddy &&\ 16 | cd / &&\ 17 | rm -rf /build 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /src/js/utils/fetch-timeout.js: -------------------------------------------------------------------------------- 1 | /** 2 | * A fetch that at some point gets tired. 3 | */ 4 | 5 | function fetchWithTimeout(url, options, timeout = 5000) { 6 | return Promise.race([ 7 | fetch(url, options), 8 | new Promise((_, reject) => { 9 | setTimeout(() => reject(new Error("Request timed out")), timeout); 10 | }), 11 | ]); 12 | } 13 | 14 | export default fetchWithTimeout; 15 | -------------------------------------------------------------------------------- /githooks/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | FILES=$(git diff --cached --name-only --diff-filter=ACMR | sed 's| |\\ |g') 3 | [ -z "$FILES" ] && exit 0 4 | 5 | # Prettify all selected files 6 | echo "$FILES" | xargs ./node_modules/.bin/prettier --ignore-unknown --tab-width 4 --print-width 110 --write **/**/*.{js,css} 7 | 8 | # Add back the modified/prettified files to staging 9 | echo "$FILES" | xargs git add 10 | 11 | 12 | 13 | exit 0 -------------------------------------------------------------------------------- /src/assets/app/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Anser Liquid", 3 | "short_name": "Anser Liquid", 4 | "description": "An alby companion to manage liquid assets", 5 | "start_url": "/app", 6 | "display": "standalone", 7 | "background_color": "#d8f4ef", 8 | "theme_color": "#026b79", 9 | "icons": [ 10 | { 11 | "src": "static/favicon.png", 12 | "sizes": "312x312", 13 | "type": "image/png" 14 | } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /src/js/ui/modules/LogoModule.js: -------------------------------------------------------------------------------- 1 | import UIModule from "../UIModule.js"; 2 | import Html from "../Html.js"; 3 | 4 | /** 5 | * Show the wallet header 6 | */ 7 | export default class HeaderModule extends UIModule { 8 | constructor() { 9 | super("logo"); 10 | } 11 | 12 | onLoad(stage, stageContainerEl, walletEl, lq, ui) { 13 | const logoEl = Html.$hlist(walletEl, [], "logo").setPriority(-30); 14 | logoEl.setCover("static/icons/lw.png"); 15 | setTimeout(() => { 16 | logoEl.style.display = "none"; 17 | }, 2500); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/less/stages/receive.less: -------------------------------------------------------------------------------- 1 | #liquidwallet #container.receive.stage .invoiceQr { 2 | padding: 1rem; 3 | background: var(--elementsBackground); 4 | border-radius: 15px; 5 | } 6 | 7 | #liquidwallet #container.receive.stage .invoice { 8 | } 9 | 10 | #liquidwallet #container.receive.stage .invoiceQr { 11 | width: 100%; 12 | max-width: 100%; 13 | margin: auto; 14 | flex-shrink: 1; 15 | } 16 | 17 | #liquidwallet #container.receive { 18 | font-size: 110%; 19 | // max-width: 900px; 20 | } 21 | #liquidwallet #container.receive > div:first-child { 22 | width: min(min(40vh, 100%), 512px); 23 | } 24 | -------------------------------------------------------------------------------- /src/js/ui/modules/GlobalmessageModule.js: -------------------------------------------------------------------------------- 1 | import UIModule from "../UIModule.js"; 2 | import Html from "../Html.js"; 3 | import Constants from "../../Constants.js"; 4 | 5 | /** 6 | * Show the wallet header 7 | */ 8 | export default class HeaderModule extends UIModule { 9 | constructor() { 10 | super("globalmessage"); 11 | } 12 | 13 | onLoad(stage, stageContainerEl, walletEl, lq, ui) { 14 | if (Constants.GLOBAL_MESSAGE) { 15 | const globalMessageEl = Html.$hlist(walletEl, "#globalMessage", []); 16 | globalMessageEl.setValue(Constants.GLOBAL_MESSAGE); 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /.github/workflows/format.yml: -------------------------------------------------------------------------------- 1 | name: auto-format 2 | on: 3 | push: 4 | 5 | jobs: 6 | format: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: Checkout 10 | uses: actions/checkout@v4 11 | with: 12 | fetch-depth: 0 13 | - name: Prettify code 14 | uses: creyD/prettier_action@v4.3 15 | with: 16 | prettier_options: --tab-width 4 --ignore-unknown --print-width 110 --write **/**/*.{js,css} 17 | prettier_version: "3.0.3" 18 | only_changed: True 19 | commit_message: "auto-format" 20 | -------------------------------------------------------------------------------- /src/assets/app/static/theme/minimalsats.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --primaryBackground: #f5f5f5; 3 | --secondaryBackground: #e0e0e0; 4 | --elementsBackground: #cfcfcf3a; 5 | 6 | --foreground: #212121; 7 | --elementsForeground: #212121; 8 | --headerForeground: #212121; 9 | --highlightedBackground: #cfcfcf9c; 10 | 11 | --shade: rgba(0, 0, 0, 0.1); 12 | --heavyShade: rgba(245, 245, 245, 0.9); 13 | --textShade: rgba(107, 107, 107, 0.7); 14 | 15 | --error: #ff0000; 16 | --warning: #ff9800; 17 | 18 | --outgoingTxForeground: #b76a6a; 19 | --incomingTxForeground: #6d8b6d; 20 | --popupBackground: #dadada; 21 | } 22 | 23 | #liquidwallet #logo { 24 | display: none; 25 | } 26 | -------------------------------------------------------------------------------- /src/assets/app/static/tp/material-symbols/material-symbols.css: -------------------------------------------------------------------------------- 1 | /* fallback */ 2 | @font-face { 3 | font-family: "Material Symbols Outlined"; 4 | font-style: normal; 5 | font-weight: 400; 6 | src: url(kJF1BvYX7BgnkSrUwT8OhrdQw4oELdPIeeII9v6oDMzByHX9rA6RzaxHMPdY43zj-jCxv3fzvRNU22ZXGJpEpjC_1v-p_4MrImHCIJIZrDCvHOej.woff2) 7 | format("woff2"); 8 | } 9 | 10 | .material-symbols-outlined { 11 | font-family: "Material Symbols Outlined"; 12 | font-weight: normal; 13 | font-style: normal; 14 | font-size: 24px; 15 | line-height: 1; 16 | letter-spacing: normal; 17 | text-transform: none; 18 | display: inline-block; 19 | white-space: nowrap; 20 | word-wrap: normal; 21 | direction: ltr; 22 | -webkit-font-feature-settings: "liga"; 23 | -webkit-font-smoothing: antialiased; 24 | } 25 | -------------------------------------------------------------------------------- /src/js/ui/UIStage.js: -------------------------------------------------------------------------------- 1 | export default class UIStage { 2 | constructor(name) { 3 | this.name = name; 4 | } 5 | getName() { 6 | return this.name; 7 | } 8 | 9 | /** 10 | * On stage load or reload. 11 | * Stages should be able to reload without losing state. 12 | * @param {*} containerEl 13 | * @param {*} lq 14 | * @param {*} ui 15 | */ 16 | onReload(containerEl, lq, ui) { 17 | throw new Error("Not implemented"); 18 | } 19 | /** 20 | * On stage unload. 21 | * This is used if the stage needs to clean after itself. 22 | * Normally this is not needed for simple DOM output, since the DOM is automatically 23 | * cleared when the stage is unloaded. 24 | * @param {*} containerEl 25 | * @param {*} lq 26 | * @param {*} ui 27 | */ 28 | onUnload(containerEl, lq, ui) {} 29 | } 30 | -------------------------------------------------------------------------------- /src/assets/app/static/theme/spacequack.css: -------------------------------------------------------------------------------- 1 | @import url("../animations.css"); 2 | :root { 3 | --primaryBackground: #020b1a; 4 | --secondaryBackground: #021b30; 5 | --elementsBackground: #021b30a8; 6 | --highlightedBackground: #01070ec5; 7 | 8 | --foreground: #8db0c4; 9 | --elementsForeground: #8db0c4; 10 | --headerForeground: #8db0c4; 11 | 12 | --shade: rgba(33, 46, 45, 0.575); 13 | --heavyShade: rgba(2, 10, 26, 1); 14 | --textShade: rgba(107, 107, 107, 0.349); 15 | 16 | --error: #ff0000; 17 | --warning: var(--foreground); 18 | 19 | --outgoingTxForeground: #b76a6a; 20 | --incomingTxForeground: #6d8b6d; 21 | 22 | --popupBackground: #020e24; 23 | } 24 | #liquidwallet #container .asset .cover { 25 | filter: blur(20px) grayscale(0.4) brightness(0.6); 26 | opacity: 0.4; 27 | } 28 | 29 | #liquidwallet #header .cover { 30 | opacity: 0.4; 31 | } 32 | -------------------------------------------------------------------------------- /src/assets/app/static/theme/satoshilegacy.css: -------------------------------------------------------------------------------- 1 | @import url("../animations.css"); 2 | 3 | :root { 4 | --primaryBackground: #000000; 5 | --secondaryBackground: #1a1a1a; 6 | --elementsBackground: #1a1a1ac4; 7 | --highlightedBackground: #212121; 8 | 9 | --foreground: #f7931a; 10 | --elementsForeground: #f7931a; 11 | --headerForeground: #ffffff; 12 | 13 | --shade: rgba(33, 33, 33, 0.575); 14 | --heavyShade: rgba(0, 0, 0, 1); 15 | --textShade: rgba(107, 107, 107, 0.349); 16 | 17 | --error: #ff0000; 18 | --warning: var(--foreground); 19 | 20 | --outgoingTxForeground: #b76a6a; 21 | --incomingTxForeground: #6d8b6d; 22 | --popupBackground: rgb(26, 15, 0); 23 | } 24 | #liquidwallet #container .asset .cover { 25 | filter: blur(20px) grayscale(0.4) brightness(0.6); 26 | opacity: 0.4; 27 | } 28 | 29 | #liquidwallet #header .cover { 30 | opacity: 0.1; 31 | } 32 | -------------------------------------------------------------------------------- /src/assets/app/static/theme/deepoceanduck.css: -------------------------------------------------------------------------------- 1 | @import url("../animations.css"); 2 | :root { 3 | --primaryBackground: #325763; 4 | --secondaryBackground: rgb(36, 63, 71); 5 | --elementsBackground: rgba(36, 63, 71, 0.774); 6 | --highlightedBackground: rgba(15, 26, 29, 0.774); 7 | 8 | --shade: rgba(33, 46, 45, 0.575); 9 | --heavyShade: rgba(2, 107, 121, 1); 10 | --textShade: rgba(107, 107, 107, 0.349); 11 | 12 | --foreground: #8db0c4; 13 | --elementsForeground: #8db0c4; 14 | --headerForeground: #8db0c4; 15 | 16 | --error: #b76a6a; 17 | --warning: var(--foreground); 18 | 19 | --outgoingTxForeground: #b76a6a; 20 | --incomingTxForeground: #6d8b6d; 21 | 22 | --popupBackground: rgba(33, 49, 54, 0.986); 23 | } 24 | 25 | #liquidwallet #container .asset .cover { 26 | filter: blur(20px) grayscale(0.4) brightness(0.6); 27 | opacity: 0.4; 28 | } 29 | 30 | #liquidwallet #header .cover { 31 | opacity: 0.4; 32 | } 33 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "@vulpemventures/secp256k1-zkp": "^3.1.0", 4 | "buffer": "^6.0.3", 5 | "jsqr-es6": "^1.4.0-1", 6 | "liquidjs-lib": "^6.0.2-liquid.32", 7 | "path-browserify": "^1.0.1", 8 | "qrcode": "^1.5.3", 9 | "stream-browserify": "^3.0.0", 10 | "ws-electrumx-client": "^1.0.5" 11 | }, 12 | "scripts": { 13 | "build": "webpack", 14 | "start": "WEBPACK_DEV_SERVER=1 webpack serve --open" 15 | }, 16 | "type": "module", 17 | "devDependencies": { 18 | "copy-webpack-plugin": "^11.0.0", 19 | "css-loader": "^6.8.1", 20 | "html-webpack-plugin": "^5.6.0", 21 | "less": "^4.2.0", 22 | "less-loader": "^11.1.4", 23 | "mini-css-extract-plugin": "^2.7.6", 24 | "prettier": "3.1.1", 25 | "style-loader": "^3.3.3", 26 | "webpack": "^5.94.0", 27 | "webpack-cli": "^5.1.4", 28 | "webpack-dev-server": "^4.15.1", 29 | "workbox-webpack-plugin": "^7.0.0" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/assets/app/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 10 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 23 | 24 | 25 | 26 |
27 | 28 | 29 | -------------------------------------------------------------------------------- /src/js/ui/UIModule.js: -------------------------------------------------------------------------------- 1 | export default class UIModule { 2 | constructor(name, stageWhitelist) { 3 | this.name = name; 4 | this.stageWhitelist = stageWhitelist; 5 | } 6 | 7 | getName() { 8 | return this.name; 9 | } 10 | 11 | /** 12 | * Check if the module can be enabled when a stage is loaded. 13 | * @param {*} stageName 14 | * @returns 15 | */ 16 | isEnabledForStage(stageName) { 17 | return this.stageWhitelist ? this.stageWhitelist.includes(stageName) : true; 18 | } 19 | 20 | /** 21 | * Unload the module 22 | * @param {*} stage 23 | * @param {*} stageContainerEl 24 | * @param {*} walletEl 25 | * @param {*} lq 26 | * @param {*} ui 27 | */ 28 | onUnload(stage, stageContainerEl, walletEl, lq, ui) {} 29 | 30 | /** 31 | * Load the module 32 | * @param {*} stage 33 | * @param {*} containerEl 34 | * @param {*} lq 35 | * @param {*} ui 36 | */ 37 | onLoad(stage, containerEl, lq, ui) { 38 | throw new Error("Not implemented"); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/js/ui/modules/HeaderModule.js: -------------------------------------------------------------------------------- 1 | import UIModule from "../UIModule.js"; 2 | import Html from "../Html.js"; 3 | import Constants from "../../Constants.js"; 4 | 5 | /** 6 | * Show the wallet header 7 | */ 8 | export default class HeaderModule extends UIModule { 9 | constructor() { 10 | super("header"); 11 | } 12 | 13 | onLoad(stage, stageContainerEl, walletEl, lq, ui) { 14 | const documentTitleEl = document.head.querySelector("title"); 15 | documentTitleEl.textContent = Constants.APP_NAME; 16 | 17 | const headerEl = Html.$hlist(walletEl, [], "header").setPriority(-30); 18 | headerEl.setCover("static/icons/lwheader.png"); 19 | // Html.$icon(headerEl, "#logo").setSrc("static/icons/lw.png") 20 | Html.$text(headerEl, "#title").setValue(Constants.APP_NAME); 21 | if (stage.getName() != "wallet") { 22 | Html.$icon(headerEl, "#optionsBtn") 23 | .setValue("arrow_back") 24 | .setAction(() => { 25 | ui.setStage("wallet"); 26 | }); 27 | } else { 28 | Html.$icon(headerEl, "#optionsBtn") 29 | .setValue("settings") 30 | .setAction(() => { 31 | ui.setStage("options"); 32 | }); 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/assets/app/static/safemode.css: -------------------------------------------------------------------------------- 1 | * { 2 | filter: none !important; 3 | backdrop-filter: none !important; 4 | transition: none !important; 5 | opacity: 1 !important; 6 | } 7 | 8 | .cover { 9 | background: none !important; 10 | } 11 | 12 | #logo { 13 | display: none !important; 14 | } 15 | 16 | @keyframes loading { 17 | } 18 | 19 | @keyframes appearFromVoid { 20 | 0% { 21 | transform: none; 22 | } 23 | 24 | 100% { 25 | transform: none; 26 | } 27 | } 28 | 29 | @keyframes appearFromLeft { 30 | 0% { 31 | transform: none; 32 | } 33 | 34 | 100% { 35 | transform: none; 36 | } 37 | } 38 | 39 | @keyframes appearFromRight { 40 | 0% { 41 | transform: none; 42 | } 43 | 44 | 100% { 45 | transform: none; 46 | } 47 | } 48 | 49 | @keyframes shake { 50 | 0% { 51 | transform: none; 52 | } 53 | 54 | 100% { 55 | transform: none; 56 | } 57 | } 58 | 59 | @keyframes blur { 60 | 0% { 61 | filter: none !important; 62 | } 63 | 64 | 100% { 65 | filter: none !important; 66 | } 67 | } 68 | 69 | @keyframes blurOut { 70 | 0% { 71 | filter: none !important; 72 | opacity: 1 !important; 73 | } 74 | 75 | 100% { 76 | filter: none !important; 77 | opacity: 1 !important; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/less/page.less: -------------------------------------------------------------------------------- 1 | html { 2 | /* font-size:102%; */ 3 | @media (min-width: 1800px) { 4 | font-size: 120%; 5 | } 6 | } 7 | 8 | body { 9 | margin: 0; 10 | max-height: 100vh; 11 | } 12 | 13 | * { 14 | box-sizing: border-box; 15 | min-width: 0 !important; 16 | font-family: "Lato", sans-serif; 17 | // position: relative; 18 | overflow: hidden; 19 | flex-shrink: 0; 20 | word-wrap: break-word; 21 | } 22 | 23 | /* For Webkit browsers */ 24 | ::-webkit-scrollbar { 25 | width: 5px; 26 | height: 5px; 27 | opacity: 0.4; 28 | /* width of the entire scrollbar */ 29 | } 30 | 31 | ::-webkit-scrollbar-track { 32 | background: transparent; 33 | /* color of the tracking area */ 34 | } 35 | 36 | ::-webkit-scrollbar-thumb { 37 | background: var(--elementsBackground); 38 | /* color of the scroll thumb */ 39 | border-radius: 10px; 40 | opacity: 0.4; 41 | /* roundness of the scroll thumb */ 42 | } 43 | 44 | ::-webkit-scrollbar-thumb:hover { 45 | background: #555; 46 | opacity: 0.4; 47 | /* color of the scroll thumb when hovering */ 48 | } 49 | 50 | /* For Firefox */ 51 | * { 52 | scrollbar-width: thin; 53 | scrollbar-color: var(--elementsBackground) transparent; 54 | } 55 | 56 | body, 57 | html { 58 | height: 100%; 59 | width: 100%; 60 | max-width: 100%; 61 | min-width: 100%; 62 | padding: 0rem; 63 | overflow-y: auto; 64 | background-color: var(--primaryBackground); 65 | } 66 | -------------------------------------------------------------------------------- /src/js/storage/BrowserStore.js: -------------------------------------------------------------------------------- 1 | import Constants from "../Constants.js"; 2 | import IDBStore from "./IDBStore.js"; 3 | import MemStore from "./MemStore.js"; 4 | import LocalStore from "./LocalStore.js"; 5 | /** 6 | * A browser storage class that supports several backend. 7 | * It can track memory usage, expiration and delete old entries. 8 | * Supports all serializable objects, Map, Buffer, Uint8Array, ArrayBuffer, Blob, undefined, null and primitive types. 9 | */ 10 | export default class BrowserStore { 11 | static async best(prefix = "", limit = 100 * 1024 * 1024, byspeed = false) { 12 | const methods = { 13 | IDBStore: IDBStore, 14 | MemStore: MemStore, 15 | LocalStore: LocalStore, 16 | }; 17 | for (const storageMethod of byspeed 18 | ? Constants.STORAGE_METHODS_BY_SPEED 19 | : Constants.STORAGE_METHODS) { 20 | try { 21 | const StorageMethod = methods[storageMethod]; 22 | if (StorageMethod && StorageMethod.isSupported()) { 23 | return new StorageMethod(prefix, limit); 24 | } 25 | } catch (e) { 26 | console.error(e); 27 | } 28 | } 29 | console.error("No storage methods available"); 30 | const s = methods["MemStore"]; 31 | return new s(prefix, limit); 32 | } 33 | 34 | static async fast(prefix = "", limit = 100 * 1024 * 1024) { 35 | return this.best(prefix, limit, true); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2024, Riccardo Balbo 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | 3. Neither the name of the copyright holder nor the names of its 16 | contributors may be used to endorse or promote products derived from 17 | this software without specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 20 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 21 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 23 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 24 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 25 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 27 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /src/assets/app/static/tp/fonts/lato/lato.css: -------------------------------------------------------------------------------- 1 | /* latin-ext */ 2 | @font-face { 3 | font-family: "Lato"; 4 | font-style: normal; 5 | font-weight: 300; 6 | font-display: swap; 7 | src: url(S6u9w4BMUTPHh7USSwaPGR_p.woff2) format("woff2"); 8 | unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, 9 | U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF; 10 | } 11 | 12 | /* latin */ 13 | @font-face { 14 | font-family: "Lato"; 15 | font-style: normal; 16 | font-weight: 300; 17 | font-display: swap; 18 | src: url(S6u9w4BMUTPHh7USSwiPGQ.woff2) format("woff2"); 19 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, 20 | U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; 21 | } 22 | 23 | /* latin-ext */ 24 | @font-face { 25 | font-family: "Lato"; 26 | font-style: normal; 27 | font-weight: 400; 28 | font-display: swap; 29 | src: url(S6uyw4BMUTPHjxAwXjeu.woff2) format("woff2"); 30 | unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, 31 | U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF; 32 | } 33 | 34 | /* latin */ 35 | @font-face { 36 | font-family: "Lato"; 37 | font-style: normal; 38 | font-weight: 400; 39 | font-display: swap; 40 | src: url(S6uyw4BMUTPHjx4wXg.woff2) format("woff2"); 41 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, 42 | U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; 43 | } 44 | -------------------------------------------------------------------------------- /src/js/storage/MemStore.js: -------------------------------------------------------------------------------- 1 | import AbstractBrowserStore from "./AbstractBrowserStore.js"; 2 | /** 3 | * Store in memory, when everything else fails. 4 | * Obviously, this is not persistent. 5 | * Also does no serialization, so it is pretty fast. 6 | */ 7 | export default class MemStore extends AbstractBrowserStore { 8 | static isSupported() { 9 | return true; 10 | } 11 | 12 | constructor(prefix, limit) { 13 | super(prefix, limit); 14 | this.store = {}; 15 | } 16 | 17 | async _store(key, value) { 18 | if (!value) return; 19 | if (!key) throw new Error("Key is required"); 20 | 21 | if (value instanceof Promise || key instanceof Promise) { 22 | throw new Error("Promise not allowed in db"); 23 | } 24 | 25 | let valueType; 26 | [value, valueType] = await this._serialize(value); 27 | 28 | this.store[key] = { value, valueType }; 29 | } 30 | 31 | async _retrieve(key, asDataUrl = false) { 32 | if (!key) throw new Error("Key is required"); 33 | 34 | const { value, valueType } = this.store[key] || {}; 35 | 36 | if (!value) return value; 37 | 38 | return await this._deserialize(value, valueType, asDataUrl); 39 | } 40 | 41 | async _delete(key) { 42 | if (!key) throw new Error("Key is required"); 43 | 44 | delete this.store[key]; 45 | } 46 | 47 | async _serialize(value) { 48 | let valueType = typeof value; 49 | 50 | return [value, valueType]; 51 | } 52 | 53 | async _deserialize(value, valueType, asDataUrl) { 54 | if (valueType === "Blob" && asDataUrl) { 55 | value = URL.createObjectURL(value); 56 | } 57 | return value; 58 | } 59 | 60 | async _calcSize(value) { 61 | if (!value) return 0; 62 | const valueSerialized = await this._serialize(value, typeof value); 63 | return valueSerialized.length; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/js/ui/modules/ClarityModule.js: -------------------------------------------------------------------------------- 1 | import UIModule from "../UIModule.js"; 2 | 3 | /** 4 | * When (and only when!) the wallet is used with the testnet 5 | * this module records usage data using microsoft clarity. 6 | * 7 | * This tracking code is a powerful debugging tool used during the development to 8 | * record and debug issues discovered during tests by external users 9 | * without the need of asking them to share their screen, logs or 10 | * describe accurately the steps to reproduce the issue. 11 | * 12 | * 13 | * To protect the user privacy, the tracking code activation is hardcoded and it 14 | * is NEVER enabled when using the liquid mainnet. 15 | */ 16 | 17 | export default class ClarityModule extends UIModule { 18 | constructor() { 19 | super("clarity"); 20 | } 21 | 22 | onLoad(stage, stageContainerEl, walletEl, lq, ui) { 23 | const networkName = lq.getNetworkName(); 24 | if (networkName != "testnet") return; // hardcoded disable for everything but testnet 25 | let clarityScriptEl = document.head.querySelector("script#clarity"); 26 | if (!clarityScriptEl) { 27 | (function (window, document, clarity, script, tagId) { 28 | window[clarity] = 29 | window[clarity] || 30 | function () { 31 | (window[clarity].q = window[clarity].q || []).push(arguments); 32 | }; 33 | var scriptElement = document.createElement(script); 34 | scriptElement.async = 1; 35 | scriptElement.src = "https://www.clarity.ms/tag/" + tagId; 36 | scriptElement.type = "text/javascript"; 37 | scriptElement.id = "clarity"; 38 | 39 | var firstScript = document.getElementsByTagName(script)[0]; 40 | firstScript.parentNode.insertBefore(scriptElement, firstScript); 41 | })(window, document, "clarity", "script", "kbygtg6068"); 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/assets/app/liquidnotfound.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Liquid Provider not found! 4 | 5 | 6 | 43 | 44 | 45 |

Liquid Provider not found!

46 | 47 |

48 | Sorry, it seems your browser doesn't have a Liquid Provider installed. 49 |
50 | Please install a Liquid Provider such as Alby to use this app. 51 |

52 |
53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 |
61 | 62 | 63 | -------------------------------------------------------------------------------- /src/js/Constants.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Constants. Should never change at runtime 3 | */ 4 | export default { 5 | APP_NAME: "Anser Liquid", 6 | FEE_GUARD: 2000, // everything above this value is rejected (should be manually increased to account for fee changes) 7 | HARDCODED_FEE: 0.27, // used when the fee api fails 8 | DEBOUNCE_CALLBACK_TIME: 500, // lower this value to make the app more responsive, but more resource intensive 9 | APP_ID: "lq", // The app id 10 | APP_VERSION: 10, // bumping this invalidates all cache and storage 11 | STORAGE_METHODS: ["LocalStore", "IDBStore", "MemStore"], // order matters, first is preferred 12 | STORAGE_METHODS_BY_SPEED: ["LocalStore", "IDBStore", "MemStore"], // order matters, first is fastest 13 | 14 | DISABLE_POINTER_EVENTS: false, // set to true to disable pointer for elements that are not interactive. 15 | // This has the side effect of messing with the dom picker of debug tools, so should be off in development 16 | LOCK_MODE: undefined, // Set this to lock the app to a certain mode, useful for debugging 17 | EXT_TX_VIEWER: { 18 | // external transaction viewers, currently to preserve privacy ransactions are not unblinded when shown in the external viewer 19 | // In future I might consider implementing the double "view confidential" / "view unconfidential" button like most wallets do 20 | // or unblind them client side. 21 | testnet: "https://liquid.network/testnet/tx/${tx_hash}", 22 | liquid: "https://liquid.network/tx/${tx_hash}", 23 | }, 24 | DUMMY_OUT_ADDRESS: { 25 | // This is used only to generate dummy transactions for the UI. Should always reject when sending to these addresses. 26 | testnet: 27 | "tlq1qqf5wd5h3r2tl6tlpkag34uyg9fdkh2v6gshntur7pdkqpxp8v0mk6ke5awh2vejugcrj6gf564av8xld7nmwc477eq78r2clt", 28 | liquid: "lq1qqg9xdv9v8rze8f5ax7e5sfrxvvfuyjk9qwduajuuek653myfweck9s865resnaetqnvc7j5w2rn8lx6dsyntzephy9604mchf", 29 | }, 30 | DEFAULT_THEME: "deepoceanduck", 31 | 32 | PAYURL_PREFIX: { 33 | testnet: "liquidtestnet", 34 | liquid: "liquidnetwork", 35 | }, 36 | 37 | GLOBAL_MESSAGE: undefined, 38 | 39 | BLOCK_TIME: { 40 | testnet: 60 * 1000, // 1 minute in ms 41 | liquid: 60 * 1000, // 1 minute in ms 42 | }, 43 | }; 44 | -------------------------------------------------------------------------------- /src/js/Esplora.js: -------------------------------------------------------------------------------- 1 | import Constants from "./Constants.js"; 2 | import fetch from "./utils/fetch-timeout.js"; 3 | 4 | /** 5 | * A wrapper around the blockstream ESPLORA API 6 | * Used to get fee estimates and asset info 7 | */ 8 | export default class Esplora { 9 | constructor(cache, store, esploraHttps) { 10 | this.esploraHttps = esploraHttps; 11 | this.cache = cache; 12 | this.store = store; 13 | } 14 | 15 | async query(action, params = {}, method = "GET") { 16 | let url = this.esploraHttps; 17 | if (!url.endsWith("/")) url += "/"; 18 | url += action; 19 | 20 | if (method === "GET") { 21 | const urlObj = new URL(url); 22 | for (let key in params) { 23 | urlObj.searchParams.set(key, params[key]); 24 | } 25 | url = urlObj.toString(); 26 | } 27 | const response = await fetch(url, { 28 | method: method, 29 | body: method === "GET" ? undefined : JSON.stringify(params), 30 | }).then((r) => r.json()); 31 | 32 | return response; 33 | } 34 | 35 | async getFee(priority = 1) { 36 | priority = Math.floor(priority * 10) / 10; 37 | priority = 1.0 - priority; 38 | if (priority < 0) priority = 0; 39 | if (priority > 1) priority = 1; 40 | 41 | let out = await this.cache.get("fee" + priority); 42 | if (!out) { 43 | const response = await this.query("fee-estimates"); 44 | const keys = Object.keys(response); 45 | keys.sort((a, b) => parseInt(a) - parseInt(b)); 46 | const n = keys.length; 47 | 48 | priority = Math.floor(priority * n); 49 | if (priority >= n) priority = n - 1; 50 | 51 | const selectedKey = keys[priority]; 52 | out = { 53 | blocks: Number(selectedKey), 54 | feeRate: Number(response[selectedKey]), 55 | }; 56 | 57 | if (!out.feeRate) { 58 | console.log("Can't estimate fee, use hardcoded value " + Constants.HARDCODED_FEE); 59 | out.feeRate = Constants.HARDCODED_FEE; 60 | } 61 | if (!out.blocks) { 62 | out.blocks = 60; 63 | } 64 | 65 | await this.cache.set("fee" + priority, out, 3000); 66 | } 67 | return out; 68 | } 69 | 70 | async getAssetInfo(assetId) { 71 | return await this.query("asset/" + assetId); 72 | } 73 | 74 | async getTxInfo(txId) { 75 | const info = await this.cache.get("txExtra:" + txId, false, async () => { 76 | let info = await this.query("tx/" + txId); 77 | if (info.status.confirmed) { 78 | return [info, 0]; 79 | } else { 80 | return [info, 60 * 1000]; 81 | } 82 | }); 83 | return info; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/less/stages/wallet.less: -------------------------------------------------------------------------------- 1 | #liquidwallet #container.wallet.stage #history > *.confirmed { 2 | background-color: var(--secondaryBackground); 3 | } 4 | 5 | #liquidwallet #container.wallet.stage #history > * { 6 | /* filter: grayscale(40%); */ 7 | position: relative; 8 | padding: 0.6rem; 9 | padding-left: 0.6rem; 10 | padding-right: 1rem; 11 | } 12 | 13 | #liquidwallet #container.wallet.stage #history { 14 | .tx.incoming { 15 | .txAmount { 16 | color: var(--incomingTxForeground); 17 | } 18 | .txAmount::before { 19 | content: "+ "; 20 | } 21 | } 22 | .tx.outgoing { 23 | .txAmount { 24 | color: var(--outgoingTxForeground); 25 | } 26 | .txAmount::before { 27 | content: "- "; 28 | } 29 | } 30 | } 31 | 32 | #liquidwallet #container.wallet.stage .asset.h { 33 | width: 100%; 34 | } 35 | 36 | #liquidwallet #container.wallet.stage .asset { 37 | text-align: center; 38 | width: 99%; 39 | background-color: var(--elementsBackground); 40 | color: var(--headerForeground); 41 | padding: 0.6rem; 42 | padding-left: 1rem; 43 | padding-right: 1rem; 44 | } 45 | 46 | #liquidwallet #container.wallet.stage .asset .icon { 47 | max-width: 4rem; 48 | width: 4rem; 49 | font-size: 4rem; 50 | } 51 | 52 | /* #liquidwallet #container.wallet.stage .asset > *{ 53 | padding:2rem; 54 | } */ 55 | 56 | #liquidwallet #container #assets { 57 | scroll-snap-type: x mandatory; 58 | } 59 | #liquidwallet #container #assets.no-snap { 60 | scroll-snap-type: none !important; 61 | } 62 | 63 | #liquidwallet #container #assets .asset { 64 | scroll-snap-align: center; 65 | } 66 | 67 | #liquidwallet #container #assets.no-snap .asset { 68 | scroll-snap-align: none !important; 69 | } 70 | #liquidwallet #container.wallet.stage #history > * { 71 | padding-bottom: 2rem; 72 | } 73 | #liquidwallet #container.wallet.stage .txasset { 74 | position: absolute; 75 | bottom: 0; 76 | right: 0; 77 | font-size: 1.2rem !important; 78 | width: 1.2rem; 79 | } 80 | 81 | #liquidwallet #container.wallet.stage .txasset .material-symbols-outlined.loading { 82 | font-size: 1rem; 83 | } 84 | 85 | #liquidwallet #container.wallet.stage .txstatushash { 86 | position: absolute; 87 | bottom: 0.3rem; 88 | left: 1rem; 89 | } 90 | #liquidwallet #container.wallet.stage .txblocktime { 91 | position: absolute; 92 | bottom: 0.3rem; 93 | right: 1rem; 94 | } 95 | #liquidwallet #container.wallet.stage .txstatushash, 96 | #liquidwallet #container.wallet.stage .txstatushash * { 97 | margin: 0; 98 | padding: 0; 99 | } 100 | #liquidwallet #container.wallet.stage #history .tx > *:last-child { 101 | margin-left: auto; 102 | } 103 | 104 | #liquidwallet #balanceSumCnt { 105 | padding-top: 1.5rem; 106 | padding-bottom: 1rem; 107 | } 108 | -------------------------------------------------------------------------------- /API.md: -------------------------------------------------------------------------------- 1 | # Anser API Documentation 2 | 3 | ## window.liquid 4 | 5 | ### wallet 6 | 7 | Returns an instance of LiquidWallet. 8 | 9 | ```javascript 10 | /** 11 | * Return an instance of LiquidWallet 12 | * @returns {Promise} the wallet instance 13 | */ 14 | window.liquid.wallet(): Promise 15 | ``` 16 | 17 | ### receive 18 | 19 | Generates a payment URL and QR code to receive a payment. 20 | 21 | ```javascript 22 | /** 23 | * Generate a payment url and QR code to receive a payment 24 | * @param {number} amount Hint the amount to receive as floating point number (eg. 0.001) 0 = any (N.B. this is just a hint, the sender can send any amount) 25 | * @param {string} assetHash Asset hash of the asset to receive (NB. this is just a hint, the sender can send any asset). Leave empty for any asset or L-BTC. 26 | * @returns {Promise<{url: string, qr: string}>} A promise that resolves to an object with a payment url and a qr code image url 27 | */ 28 | window.liquid.receive(amount: number, assetHash: string, qrOptions: any): Promise<{url: string, qr: string}> 29 | ``` 30 | 31 | ### send 32 | 33 | Sends an amount to an address. 34 | 35 | ```javascript 36 | /** 37 | * Send an amount to an address 38 | * @param {number} amount The amount to send as floating point number (eg. 0.001) 39 | * @param {string} assetHash Asset hash of the asset to send. 40 | * @param {string} toAddress The address to send to 41 | * @param {number} fee The fee in sats per vbyte to use. 0 or empty = auto 42 | * @returns {Promise} The txid of the transaction 43 | */ 44 | window.liquid.send(amount: number, assetHash: string, toAddress: string, fee: number): Promise 45 | ``` 46 | 47 | ### estimateFee 48 | 49 | Estimates the fee for a transaction. 50 | 51 | ```javascript 52 | /** 53 | * Estimate the fee for a transaction 54 | * @param {number} priority 0 = low, 1 = high 55 | * @returns {Promise<{feeRate: string, blocks: number}>} The fee in sats per vbyte to pay and the number of blocks that will take to confirm 56 | */ 57 | window.liquid.estimateFee(priority: number): Promise<{feeRate: string, blocks: number}> 58 | ``` 59 | 60 | ### balance 61 | 62 | Gets the balance of each owned asset. 63 | 64 | ```javascript 65 | /** 66 | * Get the balance of each owned asset 67 | * @returns {Promise<[{asset: string, value: number}]>} An array of objects with asset and value 68 | */ 69 | window.liquid.balance(): Promise<[{asset: string, value: number}]> 70 | ``` 71 | 72 | ### isAnser 73 | 74 | A boolean that is true if Anser is loaded. 75 | 76 | ```javascript 77 | window.liquid.isAnser: boolean 78 | ``` 79 | 80 | ### networkName 81 | 82 | Returns a string representing the network name (e.g., "liquid", "testnet"). 83 | 84 | ```javascript 85 | /** 86 | * Returns the network name (eg. "liquid", "testnet"...) 87 | * @returns {Promise} the network name 88 | */ 89 | window.liquid.networkName(): Promise 90 | ``` 91 | 92 | ### BTC 93 | 94 | Returns a string representing the hash for L-BTC. 95 | 96 | ```javascript 97 | /** 98 | * Returns the hash for L-BTC 99 | * @returns {Promise} the asset hash 100 | */ 101 | window.liquid.BTC(): Promise 102 | ``` 103 | 104 | ### assetInfo 105 | 106 | Returns info for the given asset. 107 | 108 | ```javascript 109 | /** 110 | * Returns info for the given asset 111 | * @param {string} assetHash asset hash 112 | * @returns {Promise<{name: string, ticker: string, precision: number, hash:string}>} The info for this asset 113 | */ 114 | window.liquid.assetInfo(assetHash: string): Promise<{name: string, ticker: string, precision: number, hash:string}> 115 | ``` 116 | 117 | ### assetIcon 118 | 119 | Gets the icon for the given asset. 120 | 121 | ```javascript 122 | /** 123 | * Get the icon for the given asset 124 | * @param {string} assetHash the asset 125 | * @returns {Promise} the url to the icon 126 | */ 127 | window.liquid.assetIcon(assetHash: string): Promise 128 | ``` 129 | 130 | ## Examples 131 | 132 | Examples are available at [CodePen](https://codepen.io/collection/aMPjgB). 133 | -------------------------------------------------------------------------------- /src/js/VByteEstimator.js: -------------------------------------------------------------------------------- 1 | import { Pset, payments, Creator, networks, address, Updater, Transaction } from "liquidjs-lib"; 2 | import * as varuint from "varuint-bitcoin"; 3 | /** 4 | * Size estimator for transactions. 5 | * This is ported from https://github.com/louisinger/marina 6 | * 7 | * MIT License 8 | * 9 | * Copyright (c) 2020 Vulpem Ventures 10 | * 11 | * Permission is hereby granted, free of charge, to any person obtaining a copy 12 | * of this software and associated documentation files (the "Software"), to deal 13 | * in the Software without restriction, including without limitation the rights 14 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 15 | * copies of the Software, and to permit persons to whom the Software is 16 | * furnished to do so, subject to the following conditions: 17 | * 18 | * The above copyright notice and this permission notice shall be included in all 19 | * copies or substantial portions of the Software. 20 | * 21 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 22 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 23 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 24 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 25 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 26 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 27 | * SOFTWARE. 28 | */ 29 | 30 | function estimateScriptSigSize(type) { 31 | switch (type) { 32 | case address.ScriptType.P2Pkh: 33 | return 108; 34 | case address.ScriptType.P2Sh: 35 | case address.ScriptType.P2Wsh: 36 | return 35; 37 | case address.ScriptType.P2Tr: 38 | case address.ScriptType.P2Wpkh: 39 | return 1; // one byte for the variable len encoding (varlen(0) = 1 byte) 40 | default: 41 | return 0; 42 | } 43 | } 44 | 45 | const INPUT_BASE_SIZE = 40; // 32 bytes for outpoint, 4 bytes for sequence, 4 for index 46 | const UNCONFIDENTIAL_OUTPUT_SIZE = 33 + 9 + 1 + 1; // 33 bytes for value, 9 bytes for asset, 1 byte for nonce, 1 byte for script length 47 | 48 | function txBaseSize(inScriptSigsSize, outNonWitnessesSize) { 49 | const inSize = inScriptSigsSize.reduce((a, b) => a + b + INPUT_BASE_SIZE, 0); 50 | const outSize = outNonWitnessesSize.reduce((a, b) => a + b, 0); 51 | return ( 52 | 9 + 53 | varuint.encodingLength(inScriptSigsSize.length) + 54 | inSize + 55 | varuint.encodingLength(outNonWitnessesSize.length + 1) + 56 | outSize 57 | ); 58 | } 59 | 60 | function txWitnessSize(inWitnessesSize, outWitnessesSize) { 61 | const inSize = inWitnessesSize.reduce((a, b) => a + b, 0); 62 | const outSize = outWitnessesSize.reduce((a, b) => a + b, 0) + 1 + 1; // add the size of proof for unconf fee output 63 | return inSize + outSize; 64 | } 65 | 66 | // estimate pset virtual size after signing, take confidential outputs into account 67 | // aims to estimate the fee amount needed to be paid before blinding or signing the pset 68 | function estimateVirtualSize(pset, withFeeOutput) { 69 | const inScriptSigsSize = []; 70 | const inWitnessesSize = []; 71 | for (const input of pset.inputs) { 72 | const utxo = input.getUtxo(); 73 | if (!utxo) throw new Error("missing input utxo, cannot estimate pset virtual size"); 74 | const type = address.getScriptType(utxo.script); 75 | const scriptSigSize = estimateScriptSigSize(type); 76 | let witnessSize = 1 + 1 + 1; // add no issuance proof + no token proof + no pegin 77 | if (input.redeemScript) { 78 | // get multisig 79 | witnessSize += varSliceSize(input.redeemScript); 80 | const pay = payments.p2ms({ output: input.redeemScript }); 81 | if (pay && pay.m) { 82 | witnessSize += pay.m * 75 + pay.m - 1; 83 | } 84 | } else { 85 | // len + witness[sig, pubkey] 86 | witnessSize += 1 + 107; 87 | } 88 | inScriptSigsSize.push(scriptSigSize); 89 | inWitnessesSize.push(witnessSize); 90 | } 91 | 92 | const outSizes = []; 93 | const outWitnessesSize = []; 94 | for (const output of pset.outputs) { 95 | let outSize = 33 + 9 + 1; // asset + value + empty nonce 96 | let witnessSize = 1 + 1; // no rangeproof + no surjectionproof 97 | if (output.needsBlinding()) { 98 | outSize = 33 + 33 + 33; // asset commitment + value commitment + nonce 99 | witnessSize = 3 + 4174 + 1 + 131; // rangeproof + surjectionproof + their sizes 100 | } 101 | outSizes.push(outSize); 102 | outWitnessesSize.push(witnessSize); 103 | } 104 | 105 | if (withFeeOutput) { 106 | outSizes.push(UNCONFIDENTIAL_OUTPUT_SIZE); 107 | outWitnessesSize.push(1 + 1); // no rangeproof + no surjectionproof 108 | } 109 | 110 | const baseSize = txBaseSize(inScriptSigsSize, outSizes); 111 | const sizeWithWitness = baseSize + txWitnessSize(inWitnessesSize, outWitnessesSize); 112 | const weight = baseSize * 3 + sizeWithWitness; 113 | return (weight + 3) / 4; 114 | } 115 | 116 | export default { estimateVirtualSize }; 117 | -------------------------------------------------------------------------------- /src/js/SideSwap.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Implements SideSwap API mostly for getting asset prices 3 | * and icons. 4 | * Could be extended to support swapping in the future, maybe. 5 | */ 6 | 7 | export default class SideSwap { 8 | constructor(cache, store, sideSwapWs) { 9 | this.id = 0; 10 | this.pending = {}; 11 | this.assetSubscriptions = {}; 12 | this.trackedAssets = []; 13 | this.sideSwapWs = sideSwapWs; 14 | this.cache = cache; 15 | this.store = store; 16 | this.assetPrices = {}; 17 | } 18 | 19 | async getAssetPrice(assetHash) { 20 | this.query("load_prices", { asset: assetHash }); 21 | return new Promise((resolve) => { 22 | const interval = setInterval(() => { 23 | if (typeof this.assetPrices[assetHash] != "undefined") { 24 | clearInterval(interval); 25 | resolve(this.assetPrices[assetHash]); 26 | } 27 | }, 100); 28 | }); 29 | } 30 | 31 | async subscribeToAssetPriceUpdate(assetHash, callback) { 32 | if (!this.assetSubscriptions[assetHash]) this.assetSubscriptions[assetHash] = []; 33 | this.assetSubscriptions[assetHash].push(callback); 34 | this.query("load_prices", { asset: assetHash }); 35 | } 36 | 37 | async unsubscribeFromAssetPriceUpdate(assetHash, callback) { 38 | if (!this.assetSubscriptions[assetHash]) return; 39 | const index = this.assetSubscriptions[assetHash].indexOf(callback); 40 | if (index < 0) return; 41 | this.assetSubscriptions[assetHash].splice(index, 1); 42 | } 43 | 44 | async getAllAssets() { 45 | const assets = await this.cache.get("sw:assets", false, async () => { 46 | const assets = {}; 47 | const availableAssets = (await this.query("assets", { embedded_icons: true })).assets; 48 | for (const asset of availableAssets) { 49 | const id = asset.asset_id; 50 | assets[id] = asset; 51 | } 52 | const availableAmpAssets = (await this.query("amp_assets", { embedded_icons: true })).assets; 53 | for (const asset of availableAmpAssets) { 54 | const id = asset.asset_id; 55 | assets[id] = asset; 56 | } 57 | return [assets, Date.now() + 1000 * 60 * 60 * 24]; 58 | }); 59 | return assets; 60 | } 61 | 62 | async query(method, params) { 63 | // check if closed 64 | if (this.sw && this.sw.readyState === WebSocket.CLOSED) this.sw = null; 65 | while (this.starting) { 66 | console.log("Waiting for websocket to start"); 67 | await new Promise((resolve) => setTimeout(resolve, 1000)); 68 | } 69 | // init websocket 70 | if (!this.sw) { 71 | this.starting = true; 72 | console.log("Connecting to ", this.sideSwapWs); 73 | this.sw = new WebSocket(this.sideSwapWs); 74 | await new Promise((resolve, reject) => { 75 | this.sw.onopen = () => { 76 | this.starting = false; 77 | resolve(); 78 | }; 79 | this.sw.onerror = (error) => { 80 | console.error(error); 81 | this.starting = false; 82 | reject(error); 83 | }; 84 | 85 | this.sw.onmessage = (event) => { 86 | // handle response 87 | this.starting = false; 88 | 89 | const response = JSON.parse(event.data); 90 | const error = response.error; 91 | if (error) { 92 | console.error(error); 93 | if (this.pending[response.id]) { 94 | this.pending[response.id][1](error); 95 | delete this.pending[response.id]; 96 | } 97 | return; 98 | } else { 99 | if (this.pending[response.id]) { 100 | this.pending[response.id][0](response.result); 101 | delete this.pending[response.id]; 102 | } 103 | } 104 | if (response.method == "load_prices") { 105 | // handle subscription 106 | const assetHash = response.result.asset; 107 | if (this.assetSubscriptions[assetHash]) { 108 | this.assetPrices[assetHash] = response.result.ind ? response.result.ind : 0; 109 | for (const cb of this.assetSubscriptions[assetHash]) { 110 | cb(response.result.ind ? response.result.ind : 0, "BTC"); 111 | } 112 | } 113 | } 114 | resolve(); 115 | }; 116 | }); 117 | } 118 | 119 | return new Promise((resolve, reject) => { 120 | const id = this.id++; 121 | this.pending[id] = [resolve, reject]; 122 | const request = { 123 | id, 124 | method, 125 | params, 126 | }; 127 | this.sw.send(JSON.stringify(request)); 128 | }); 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/js/ui/stages/ReceiveStage.js: -------------------------------------------------------------------------------- 1 | import Html from "../Html.js"; 2 | import UIStage from "../UIStage.js"; 3 | import { 4 | $vlist, 5 | $hlist, 6 | $text, 7 | $title, 8 | $list, 9 | $vsep, 10 | $hsep, 11 | $img, 12 | $icon, 13 | $button, 14 | $inputText, 15 | $inputNumber, 16 | $inputSelect, 17 | $inputSlide, 18 | } from "../Html.js"; 19 | export default class ReceiveStage extends UIStage { 20 | constructor() { 21 | super("receive"); 22 | } 23 | 24 | async renderReceive(stageCntEl, lq, ui) { 25 | stageCntEl.resetState(); 26 | const network = await lq.getNetworkName(); 27 | const store = await ui.storage(); 28 | const primaryCurrency = (await store.get(`primaryCurrency${network}`)) || lq.getBaseAsset(); 29 | const secondaryCurrency = (await store.get(`secondaryCurrency${network}`)) || "USD"; 30 | 31 | let ASSET_HASH = primaryCurrency; 32 | let ASSET_INFO = await lq.assets().getAssetInfo(primaryCurrency); 33 | let INPUT_AMOUNT = 0; 34 | 35 | let SECONDARY_CURRENCY = secondaryCurrency; 36 | let SECONDARY_INFO = await lq.assets().getAssetInfo(secondaryCurrency); 37 | 38 | const leftCnt = $vlist(stageCntEl, []).fill().makeScrollable().setAlign("center"); 39 | const rightCnt = $vlist(stageCntEl, []).fill().makeScrollable(); 40 | 41 | const assetSelector = $inputSelect(rightCnt, "Select Asset"); 42 | 43 | const invoiceQr = $hlist(leftCnt); 44 | 45 | const invoiceTx = Html.$inputText(rightCnt).setEditable(false); 46 | $text(invoiceTx, ["sub"]).setValue("Address:").setPriority(-1); 47 | $icon(invoiceTx) 48 | .setAction(() => { 49 | navigator.clipboard.writeText(invoiceTx.getValue()); 50 | ui.info("Copied to clipboard"); 51 | }) 52 | .setValue("content_copy"); 53 | 54 | $text(rightCnt, ["warning"]).setValue( 55 | ` 56 | 57 | Please ensure that the sender is on the ${await lq.getNetworkName()} network. 58 | 59 | `, 60 | true, 61 | ); 62 | 63 | // $title(rightCnt).setValue("Amount"); 64 | const amountPrimaryEl = $inputNumber(rightCnt).grow(50).setPlaceHolder("0.00"); 65 | $text(amountPrimaryEl, ["sub"]).setValue("Amount:").setPriority(-1); 66 | 67 | const tickerEl = $text(amountPrimaryEl).setValue(ASSET_INFO.ticker); 68 | 69 | const amountSecondaryEl = Html.$inputNumber(rightCnt).grow(50).setPlaceHolder("0.00"); 70 | $text(amountSecondaryEl, ["sub"]).setValue("Amount:").setPriority(-1); 71 | 72 | const tickerEl2 = $text(amountSecondaryEl).setValue(SECONDARY_INFO.ticker); 73 | 74 | const _updateInvoice = async () => { 75 | if (!ASSET_HASH || !ASSET_INFO) return; // if unset do nothing 76 | const { addr, qr } = await lq.receive(INPUT_AMOUNT, ASSET_HASH); // create invoice 77 | $img(invoiceQr, ["qr", "invoiceQr"], "qr").setSrc(qr); // show qr 78 | invoiceTx.setValue(addr); // show copyable address 79 | }; 80 | 81 | amountPrimaryEl.setAction(async (primaryValue) => { 82 | if (!primaryValue) primaryValue = 0; 83 | const primaryValueInt = await lq.v(primaryValue, ASSET_HASH).int(ASSET_HASH); 84 | INPUT_AMOUNT = primaryValueInt; 85 | 86 | const secondaryValueFloat = await lq.v(primaryValueInt, ASSET_HASH).float(SECONDARY_CURRENCY); 87 | amountSecondaryEl.setValue(secondaryValueFloat, true); 88 | 89 | _updateInvoice(); 90 | }); 91 | 92 | amountSecondaryEl.setAction(async (secondaryValue) => { 93 | if (!secondaryValue) secondaryValue = 0; 94 | const secondaryValueInt = await lq.v(secondaryValue, SECONDARY_CURRENCY).int(SECONDARY_CURRENCY); 95 | const primaryValueFloat = await lq.v(secondaryValueInt, SECONDARY_CURRENCY).float(ASSET_HASH); 96 | 97 | amountPrimaryEl.setValue(primaryValueFloat, true); 98 | 99 | const primaryValueInt = await lq.v(primaryValueFloat, ASSET_HASH).int(ASSET_HASH); 100 | INPUT_AMOUNT = primaryValueInt; 101 | _updateInvoice(); 102 | }); 103 | 104 | // // load currencies async and list for changes 105 | lq.getPinnedAssets().then((assets) => { 106 | for (const asset of assets) { 107 | lq.assets() 108 | .getAssetInfo(asset.hash) 109 | .then((info) => { 110 | const optionEl = assetSelector.addOption(asset.hash, info.ticker, async (value) => { 111 | ASSET_HASH = value; 112 | ASSET_INFO = await lq.assets().getAssetInfo(value); 113 | tickerEl.setValue(ASSET_INFO.ticker); 114 | tickerEl2.setValue(SECONDARY_INFO.ticker); 115 | _updateInvoice(); 116 | }); 117 | lq.assets() 118 | .getAssetIcon(asset.hash) 119 | .then((icon) => { 120 | optionEl.setIconSrc(icon); 121 | }); 122 | }); 123 | } 124 | }); 125 | 126 | _updateInvoice(); 127 | } 128 | 129 | onReload(walletEl, lq, ui) { 130 | this.renderReceive(walletEl, lq, ui); 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /src/js/index.js: -------------------------------------------------------------------------------- 1 | if ("serviceWorker" in navigator && location.hostname !== "localhost") { 2 | window.addEventListener("load", () => { 3 | navigator.serviceWorker.register("service-worker.js"); 4 | }); 5 | } 6 | 7 | import "../less/style.less"; 8 | import LiquidWallet from "./LiquidWallet.js"; 9 | import UI from "./ui/UI.js"; 10 | import Html from "./ui/Html.js"; 11 | import LinkOpener from "./utils/LinkOpener.js"; 12 | 13 | /** 14 | * The entry point for the Liquid Wallet APP 15 | */ 16 | 17 | async function versionCheck(ui) { 18 | try { 19 | const currentVersion = ( 20 | await fetch("version.txt?rand=" + Date.now() + "_" + Math.random(), { 21 | cache: "no-store", 22 | }).then((r) => r.text()) 23 | ).trim(); 24 | 25 | const latestGithubReleaseData = await fetch( 26 | "https://api.github.com/repos/riccardobl/anser-liquid/releases/latest", 27 | { 28 | cache: "no-store", 29 | }, 30 | ).then((r) => r.json()); 31 | 32 | if (!latestGithubReleaseData.tag_name) throw new Error("Cannot get latest version from github"); 33 | const latestVersion = latestGithubReleaseData.tag_name.trim(); 34 | console.log("Current version", currentVersion); 35 | console.log("Latest version", latestVersion); 36 | if (currentVersion != latestVersion) { 37 | const alertEl = ui.info( 38 | "New version available: " + latestVersion + ". Click here to visit the release page.", 39 | ); 40 | alertEl.addEventListener("click", () => { 41 | LinkOpener.navigate(latestGithubReleaseData.html_url); 42 | }); 43 | } 44 | } catch (e) { 45 | console.log(e); 46 | } 47 | } 48 | 49 | function capturePWAPrompt() { 50 | window.addEventListener("beforeinstallprompt", (e) => { 51 | e.preventDefault(); 52 | window.installPWA = () => { 53 | e.prompt(); 54 | e.userChoice.then(function (choiceResult) { 55 | try { 56 | console.log("PWA outcome", choiceResult.outcome); 57 | if (choiceResult.outcome == "dismissed") { 58 | console.log("PWA User cancelled home screen install"); 59 | } else { 60 | console.log("PWA User added to home screen"); 61 | } 62 | window.installPWA = null; 63 | } catch (e) { 64 | console.error(e); 65 | } 66 | }); 67 | }; 68 | }); 69 | } 70 | 71 | function renderPWAInstallPrompt(ui) { 72 | if (window.installPWA && !localStorage.getItem("pwaInstallPrompt")) { 73 | const installPromptEl = ui.perma(` 74 | Do you want to install this app on your device for a better experience? 75 | `); 76 | 77 | const acceptButtonEl = document.createElement("button"); 78 | acceptButtonEl.classList.add("button"); 79 | installPromptEl.appendChild(acceptButtonEl); 80 | acceptButtonEl.innerText = "Install"; 81 | acceptButtonEl.addEventListener("click", () => { 82 | console.log("PWA", "Install"); 83 | if (window.installPWA) window.installPWA(); 84 | }); 85 | 86 | const cancelButtonEl = document.createElement("button"); 87 | installPromptEl.appendChild(cancelButtonEl); 88 | cancelButtonEl.classList.add("button"); 89 | cancelButtonEl.innerText = "Continue without installing"; 90 | cancelButtonEl.addEventListener("click", () => { 91 | console.log("PWA", "cancel"); 92 | localStorage.setItem("pwaInstallPrompt", "dismissed"); 93 | }); 94 | } else { 95 | console.log("PWA", "custom dialog not supported or already installed"); 96 | } 97 | } 98 | 99 | async function main() { 100 | capturePWAPrompt(); 101 | 102 | try { 103 | // Get the wallet element 104 | const walletEl = document.body.querySelector("#liquidwallet"); 105 | if (!walletEl) alert("No wallet element found"); 106 | 107 | // A container that is vertical in portrait and horizontal in landscape 108 | const containerEl = Html.$list(walletEl, ["p$v", "l$h"], "container"); 109 | containerEl.classList.add("popupContainer"); 110 | 111 | // Create and start the wallet 112 | const lq = new LiquidWallet(); 113 | await lq.start(); 114 | 115 | // create the UI 116 | const ui = new UI(containerEl, walletEl, lq); 117 | ui.captureOutputs(); 118 | renderPWAInstallPrompt(ui); 119 | 120 | window.dbui = ui; 121 | window.dblq = lq; 122 | try { 123 | ui.useBrowserHistory(); // allow ui to control the browser history (this is used to support the back button) 124 | ui.setStage("wallet"); // set the initial stage 125 | lq.addRefreshCallback(() => { 126 | try { 127 | ui.reload(); 128 | } catch (e) { 129 | console.error(e); 130 | } 131 | }); // refresh the ui when the wallet data changes 132 | } catch (e) { 133 | console.error(e); 134 | } 135 | versionCheck(ui); 136 | } catch (e) { 137 | console.error(e); 138 | if (e.cause == "liquid_not_available") { 139 | window.location.href = "liquidnotfound.html"; 140 | } else { 141 | alert(e); 142 | } 143 | } 144 | } 145 | 146 | window.addEventListener("load", main); 147 | -------------------------------------------------------------------------------- /webpack.config.cjs: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const HtmlWebpackPlugin = require("html-webpack-plugin"); 3 | const webpack = require("webpack"); 4 | const CopyWebpackPlugin = require("copy-webpack-plugin"); 5 | const MiniCssExtractPlugin = require("mini-css-extract-plugin"); 6 | const WorkboxPlugin = require("workbox-webpack-plugin"); 7 | 8 | module.exports = [ 9 | // lib bundle 10 | { 11 | performance: { 12 | hints: false, 13 | }, 14 | experiments: { 15 | asyncWebAssembly: true, 16 | }, 17 | resolve: { 18 | fallback: { 19 | buffer: require.resolve("buffer/"), 20 | stream: require.resolve("stream-browserify"), 21 | path: require.resolve("path-browserify"), 22 | fs: false, 23 | }, 24 | }, 25 | node: { 26 | global: true, 27 | }, 28 | optimization: { 29 | minimize: true, 30 | }, 31 | entry: "./src/js/LiquidWallet.js", 32 | output: { 33 | filename: "liquidwallet.lib.js", 34 | path: path.resolve(__dirname, "dist", "lib"), 35 | library: { 36 | name: "LiquidWallet", 37 | type: "umd", // Universal module definition 38 | export: "LiquidWallet", 39 | }, 40 | globalObject: "this", 41 | }, 42 | plugins: [ 43 | new webpack.ProvidePlugin({ 44 | Buffer: ["buffer", "Buffer"], 45 | }), 46 | ], 47 | mode: "production", 48 | }, 49 | 50 | // extra css for embedding full page 51 | { 52 | performance: { 53 | hints: false, 54 | }, 55 | entry: "./src/less/page.less", 56 | output: { 57 | path: path.resolve(__dirname, "dist", "app"), 58 | }, 59 | module: { 60 | rules: [ 61 | { 62 | test: /\.less$/, 63 | use: [MiniCssExtractPlugin.loader, "css-loader", "less-loader"], 64 | }, 65 | ], 66 | }, 67 | plugins: [ 68 | new MiniCssExtractPlugin({ 69 | filename: "page.css", 70 | }), 71 | ], 72 | mode: "production", 73 | }, 74 | 75 | // ui bundle 76 | { 77 | // stats: { 78 | // warningsFilter: warning => { 79 | // return warning.startsWith("GenerateSW has been called multiple times") 80 | // }, 81 | // }, 82 | performance: { 83 | hints: false, 84 | }, 85 | experiments: { 86 | asyncWebAssembly: true, 87 | }, 88 | resolve: { 89 | fallback: { 90 | buffer: require.resolve("buffer/"), 91 | stream: require.resolve("stream-browserify"), 92 | path: require.resolve("path-browserify"), 93 | fs: false, 94 | }, 95 | }, 96 | node: { 97 | global: true, 98 | }, 99 | entry: "./src/js/index.js", 100 | output: { 101 | filename: "liquidwallet.js", 102 | path: path.resolve(__dirname, "dist", "app"), 103 | }, 104 | plugins: [ 105 | new webpack.ProvidePlugin({ 106 | Buffer: ["buffer", "Buffer"], 107 | }), 108 | new HtmlWebpackPlugin({ 109 | template: "./src/assets/app/index.html", 110 | filename: "index.html", 111 | }), 112 | new MiniCssExtractPlugin({ 113 | filename: "ui.css", 114 | }), 115 | new CopyWebpackPlugin({ 116 | patterns: [ 117 | { 118 | from: path.resolve(__dirname, "src", "assets", "app"), 119 | to: path.resolve(__dirname, "dist", "app"), 120 | globOptions: { 121 | ignore: ["**/index.html"], 122 | }, 123 | }, 124 | ], 125 | }), 126 | ...(!process.env.WEBPACK_DEV_SERVER 127 | ? [ 128 | new WorkboxPlugin.GenerateSW({ 129 | maximumFileSizeToCacheInBytes: 5000000, // 5MB 130 | clientsClaim: true, 131 | skipWaiting: true, 132 | }), 133 | ] 134 | : []), 135 | ], 136 | module: { 137 | rules: [ 138 | { 139 | test: /\.less$/, 140 | use: [MiniCssExtractPlugin.loader, "css-loader", "less-loader"], 141 | }, 142 | ], 143 | }, 144 | mode: process.env.BUILD_MODE === "production" ? "production" : "development", 145 | }, 146 | 147 | { 148 | performance: { 149 | hints: false, 150 | }, 151 | entry: "./src/assets/pack.js", 152 | devServer: { 153 | static: { 154 | directory: path.join(__dirname, "dist"), 155 | }, 156 | compress: false, 157 | port: 9000, 158 | }, 159 | plugins: [ 160 | new CopyWebpackPlugin({ 161 | patterns: [ 162 | { 163 | from: path.resolve(__dirname, "src", "assets"), 164 | to: path.resolve(__dirname, "dist"), 165 | globOptions: { 166 | ignore: ["**/app"], 167 | }, 168 | }, 169 | ], 170 | }), 171 | ], 172 | mode: "production", 173 | }, 174 | ]; 175 | -------------------------------------------------------------------------------- /src/assets/app/static/animations.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Make everything smooth 3 | */ 4 | #liquidwallet #container * { 5 | transition: all 0.2s ease-in-out; 6 | } 7 | 8 | #liquidwallet #container .text.loading { 9 | display: inline-block; 10 | margin-top: 0.2rem; 11 | margin-bottom: 0.2rem; 12 | width: 8rem; 13 | height: 1.4rem; 14 | border-radius: 200px; 15 | background: linear-gradient(90deg, transparent, var(--primaryBackground), transparent); 16 | background-size: 200% 200%; 17 | opacity: 0.9; 18 | animation: loading 2s ease-in-out infinite; 19 | } 20 | 21 | #liquidwallet #container .list.loading { 22 | /* box-shadow: 0 0 8px 2px var(--shade); */ 23 | width: 100%; 24 | 25 | height: 200px; 26 | background: radial-gradient( 27 | ellipse at center, 28 | var(--elementsBackground) 1%, 29 | transparent 50%, 30 | transparent 100% 31 | ); 32 | background-size: 50% 50%; 33 | opacity: 0; 34 | background-position: center center; 35 | background-repeat: no-repeat; 36 | animation: loading 2s linear infinite; 37 | } 38 | 39 | @keyframes loading { 40 | 0% { 41 | background-size: 200% 200%; 42 | opacity: 0; 43 | } 44 | 45 | 20% { 46 | opacity: 0.4; 47 | background-size: 100% 100%; 48 | } 49 | 100% { 50 | opacity: 0; 51 | background-size: 300% 300%; 52 | } 53 | } 54 | #liquidwallet #container .iconCnt.loading .icon.material-symbols-outlined, 55 | #liquidwallet #container .icon.material-symbols-outlined.loading { 56 | animation: rotate 2s linear infinite; 57 | } 58 | 59 | @keyframes rotate { 60 | 0% { 61 | transform: rotate(0deg); 62 | } 63 | 64 | 50% { 65 | transform: rotate(180deg); 66 | } 67 | 68 | 100% { 69 | transform: rotate(360deg); 70 | } 71 | } 72 | 73 | #liquidwallet #container .asset { 74 | --anim-delta: 0s; 75 | transform: scale(0); 76 | animation: appearFromVoid 0.2s ease-in-out var(--anim-delta); 77 | 78 | animation-fill-mode: forwards; 79 | } 80 | 81 | /*appearing effects*/ 82 | #liquidwallet #container input, 83 | #liquidwallet #container select, 84 | #liquidwallet #container button, 85 | #liquidwallet #container .iconCnt { 86 | animation: appearFromVoid 0.3s ease-in-out; 87 | } 88 | 89 | /*appearing effects*/ 90 | #liquidwallet #container .tx { 91 | --time-delta: 0s; 92 | } 93 | 94 | #liquidwallet #container .tx:nth-child(odd) { 95 | transform: translateX(-100%); 96 | animation: appearFromLeft 1s ease-in-out var(--anim-delta); 97 | 98 | animation-fill-mode: forwards; 99 | } 100 | 101 | #liquidwallet #container .tx:nth-child(even) { 102 | transform: translateX(100%); 103 | animation: appearFromRight 1s ease-in-out var(--anim-delta); 104 | animation-fill-mode: forwards; 105 | } 106 | 107 | @keyframes appearFromVoid { 108 | 0% { 109 | transform: scale(0); 110 | } 111 | 112 | 100% { 113 | transform: scale(1); 114 | } 115 | } 116 | 117 | @keyframes appearFromLeft { 118 | 0% { 119 | transform: translateX(-100%); 120 | } 121 | 122 | 100% { 123 | transform: translateX(0%); 124 | } 125 | } 126 | 127 | @keyframes appearFromRight { 128 | 0% { 129 | transform: translateX(100%); 130 | } 131 | 132 | 100% { 133 | transform: translateX(0%); 134 | } 135 | } 136 | 137 | #liquidwallet div.error { 138 | /*animation: shake and pause and repeat*/ 139 | animation: shake 3s linear 0s infinite; 140 | } 141 | 142 | @keyframes shake { 143 | 0% { 144 | transform: translateX(0%); 145 | } 146 | 147 | 2% { 148 | transform: translateX(-5%); 149 | } 150 | 151 | 6% { 152 | transform: translateX(5%); 153 | } 154 | 155 | 8% { 156 | transform: translateX(-5%); 157 | } 158 | 159 | 10% { 160 | transform: translateX(0%); 161 | } 162 | 163 | 100% { 164 | transform: translateX(0%); 165 | } 166 | } 167 | 168 | /* #liquidwallet #header .cover { 169 | filter: blur(0px); 170 | animation: blur 2s ease-in-out; 171 | animation-fill-mode: forwards; 172 | } */ 173 | 174 | @keyframes blur { 175 | 0% { 176 | filter: blur(0px); 177 | } 178 | 179 | 100% { 180 | filter: blur(40px); 181 | } 182 | } 183 | 184 | #liquidwallet > #logo .cover { 185 | /**infinite 3d coinflip animation */ 186 | opacity: 0.9; 187 | animation: coinflipAndBlurOut 2s linear; 188 | animation-fill-mode: forwards; 189 | } 190 | 191 | @keyframes coinflipAndBlurOut { 192 | 10% { 193 | transform: rotateY(0deg); 194 | filter: blur(0px); 195 | opacity: 0.9; 196 | } 197 | 198 | 30% { 199 | transform: rotateY(0deg); 200 | filter: blur(0px); 201 | opacity: 0.9; 202 | } 203 | 50% { 204 | transform: rotateY(180deg); 205 | } 206 | 207 | 60% { 208 | filter: blur(100px); 209 | opacity: 0; 210 | } 211 | 100% { 212 | transform: rotateY(360deg); 213 | filter: blur(100px); 214 | opacity: 0; 215 | } 216 | } 217 | 218 | #liquidwallet > #logo { 219 | animation: blurOut 0.7s linear; 220 | animation-fill-mode: forwards; 221 | animation-delay: 1.5s; 222 | } 223 | 224 | @keyframes blurOut { 225 | 0% { 226 | filter: blur(0px); 227 | opacity: 0.9; 228 | } 229 | 100% { 230 | filter: blur(100px); 231 | opacity: 0; 232 | } 233 | } 234 | 235 | #liquidwallet #sendOK .iconCnt.sendok { 236 | /** make a confirmation animation that looks good*/ 237 | animation: confirm 1s ease-in-out; 238 | animation-fill-mode: forwards; 239 | } 240 | 241 | @keyframes confirm { 242 | 0% { 243 | filter: hue-rotate(0deg) blur(100px); 244 | transform: scale(0.9); 245 | } 246 | 247 | 80% { 248 | filter: hue-rotate(180deg) blur(0px); 249 | transform: scale(1); 250 | } 251 | 252 | 100% { 253 | filter: hue-rotate(360deg) blur(0px); 254 | transform: scale(0.9); 255 | } 256 | } 257 | -------------------------------------------------------------------------------- /src/assets/app/static/loadingeffect.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Make everything smooth 3 | */ 4 | #liquidwallet #container * { 5 | transition: all 0.3s ease-in-out; 6 | } 7 | 8 | #liquidwallet #container .text.loading { 9 | display: inline-block; 10 | margin-top: 0.2rem; 11 | margin-bottom: 0.2rem; 12 | width: 8rem; 13 | height: 1.4rem; 14 | border-radius: 200px; 15 | background: linear-gradient(90deg, transparent, var(--primaryBackground), transparent); 16 | background-size: 200% 200%; 17 | opacity: 0.9; 18 | animation: loading 2s ease-in-out infinite; 19 | } 20 | 21 | #liquidwallet #container .list.loading { 22 | /* box-shadow: 0 0 8px 2px var(--shade); */ 23 | width: 100%; 24 | 25 | height: 200px; 26 | background: radial-gradient( 27 | ellipse at center, 28 | var(--elementsBackground) 1%, 29 | transparent 50%, 30 | transparent 100% 31 | ); 32 | background-size: 50% 50%; 33 | opacity: 0; 34 | background-position: center center; 35 | background-repeat: no-repeat; 36 | animation: loading 2s linear infinite; 37 | } 38 | 39 | @keyframes loading { 40 | 0% { 41 | background-size: 200% 200%; 42 | opacity: 0; 43 | } 44 | 45 | 20% { 46 | opacity: 0.4; 47 | background-size: 100% 100%; 48 | } 49 | 100% { 50 | opacity: 0; 51 | background-size: 300% 300%; 52 | } 53 | } 54 | 55 | #liquidwallet #container .icon.material-symbols-outlined.loading { 56 | animation: rotate 2s linear infinite; 57 | } 58 | 59 | @keyframes rotate { 60 | 0% { 61 | transform: rotate(0deg); 62 | } 63 | 64 | 50% { 65 | transform: rotate(180deg); 66 | } 67 | 68 | 100% { 69 | transform: rotate(360deg); 70 | } 71 | } 72 | 73 | #liquidwallet #container .asset { 74 | --anim-delta: 0s; 75 | transform: scale(0); 76 | animation: appearFromVoid 0.2s ease-in-out var(--anim-delta); 77 | 78 | animation-fill-mode: forwards; 79 | } 80 | 81 | /*appearing effects*/ 82 | #liquidwallet #container input, 83 | #liquidwallet #container select, 84 | #liquidwallet #container button, 85 | #liquidwallet #container .iconCnt { 86 | animation: appearFromVoid 0.3s ease-in-out; 87 | } 88 | 89 | /*appearing effects*/ 90 | #liquidwallet #container .tx { 91 | --time-delta: 0s; 92 | } 93 | 94 | #liquidwallet #container .tx:nth-child(odd) { 95 | transform: translateX(-100%); 96 | animation: appearFromLeft 1s ease-in-out var(--anim-delta); 97 | 98 | animation-fill-mode: forwards; 99 | } 100 | 101 | #liquidwallet #container .tx:nth-child(even) { 102 | transform: translateX(100%); 103 | animation: appearFromRight 1s ease-in-out var(--anim-delta); 104 | animation-fill-mode: forwards; 105 | } 106 | 107 | @keyframes appearFromVoid { 108 | 0% { 109 | transform: scale(0); 110 | } 111 | 112 | 100% { 113 | transform: scale(1); 114 | } 115 | } 116 | 117 | @keyframes appearFromLeft { 118 | 0% { 119 | transform: translateX(-100%); 120 | } 121 | 122 | 100% { 123 | transform: translateX(0%); 124 | } 125 | } 126 | 127 | @keyframes appearFromRight { 128 | 0% { 129 | transform: translateX(100%); 130 | } 131 | 132 | 100% { 133 | transform: translateX(0%); 134 | } 135 | } 136 | 137 | #liquidwallet div.error { 138 | /*animation: shake and pause and repeat*/ 139 | animation: shake 3s linear 0s infinite; 140 | } 141 | 142 | @keyframes shake { 143 | 0% { 144 | transform: translateX(0%); 145 | } 146 | 147 | 2% { 148 | transform: translateX(-5%); 149 | } 150 | 151 | 6% { 152 | transform: translateX(5%); 153 | } 154 | 155 | 8% { 156 | transform: translateX(-5%); 157 | } 158 | 159 | 10% { 160 | transform: translateX(0%); 161 | } 162 | 163 | 100% { 164 | transform: translateX(0%); 165 | } 166 | } 167 | 168 | /* #liquidwallet #header .cover { 169 | filter: blur(0px); 170 | animation: blur 2s ease-in-out; 171 | animation-fill-mode: forwards; 172 | } */ 173 | 174 | @keyframes blur { 175 | 0% { 176 | filter: blur(0px); 177 | } 178 | 179 | 100% { 180 | filter: blur(40px); 181 | } 182 | } 183 | 184 | #liquidwallet > #logo .cover { 185 | /**infinite 3d coinflip animation */ 186 | opacity: 0.9; 187 | animation: coinflipAndBlurOut 2s linear; 188 | animation-fill-mode: forwards; 189 | } 190 | 191 | @keyframes coinflipAndBlurOut { 192 | 0% { 193 | transform: rotateY(0deg); 194 | filter: blur(100px); 195 | opacity: 0; 196 | } 197 | 10% { 198 | transform: rotateY(0deg); 199 | filter: blur(0px); 200 | opacity: 0.9; 201 | } 202 | 203 | 30% { 204 | transform: rotateY(0deg); 205 | filter: blur(0px); 206 | opacity: 0.9; 207 | } 208 | 50% { 209 | transform: rotateY(180deg); 210 | } 211 | 212 | 60% { 213 | filter: blur(100px); 214 | opacity: 0; 215 | } 216 | 100% { 217 | transform: rotateY(360deg); 218 | filter: blur(100px); 219 | opacity: 0; 220 | } 221 | } 222 | 223 | #liquidwallet > #logo { 224 | animation: blurOut 0.7s linear; 225 | animation-fill-mode: forwards; 226 | animation-delay: 1.5s; 227 | } 228 | 229 | @keyframes blurOut { 230 | 0% { 231 | filter: blur(0px); 232 | opacity: 0.9; 233 | } 234 | 100% { 235 | filter: blur(100px); 236 | opacity: 0; 237 | } 238 | } 239 | 240 | #liquidwallet #sendOK .iconCnt.sendok { 241 | /** make a confirmation animation that looks good*/ 242 | animation: confirm 1s ease-in-out; 243 | animation-fill-mode: forwards; 244 | } 245 | 246 | @keyframes confirm { 247 | 0% { 248 | filter: hue-rotate(0deg) blur(100px); 249 | transform: scale(0.9); 250 | } 251 | 252 | 80% { 253 | filter: hue-rotate(180deg) blur(0px); 254 | transform: scale(1); 255 | } 256 | 257 | 100% { 258 | filter: hue-rotate(360deg) blur(0px); 259 | transform: scale(0.9); 260 | } 261 | } 262 | -------------------------------------------------------------------------------- /src/js/ui/stages/OptionsStage.js: -------------------------------------------------------------------------------- 1 | import LinkOpener from "../../utils/LinkOpener.js"; 2 | import { 3 | $vlist, 4 | $hlist, 5 | $text, 6 | $title, 7 | $list, 8 | $vsep, 9 | $hsep, 10 | $img, 11 | $icon, 12 | $button, 13 | $inputText, 14 | $inputNumber, 15 | $inputSelect, 16 | $inputSlide, 17 | } from "../Html.js"; 18 | import UIStage from "../UIStage.js"; 19 | 20 | export default class OptionsStage extends UIStage { 21 | constructor() { 22 | super("options"); 23 | } 24 | 25 | onReload(containerEl, lq, ui) { 26 | containerEl.resetState(); 27 | const optionsEl = $vlist(containerEl).makeScrollable().fill(); 28 | $text(optionsEl).setValue("Primary currency: "); 29 | const primaryAssetSelector = $inputSelect(optionsEl, "Select Asset"); 30 | $vsep(optionsEl); 31 | $text(optionsEl).setValue("Secondary currency: "); 32 | const secondaryAssetSelector = $inputSelect(optionsEl, "Select Asset"); 33 | $vsep(optionsEl); 34 | $text(optionsEl).setValue("Theme: "); 35 | const themeSelector = $inputSelect(optionsEl, "Select Theme"); 36 | $vsep(optionsEl); 37 | $text(optionsEl).setValue("Pinned assets: "); 38 | const pinnedAssetsSelector = $inputSelect(optionsEl, "Select Assets", [], true); 39 | $vsep(optionsEl); 40 | $text(optionsEl).setValue("Do you like the app?"); 41 | const sponsorRowEl = $hlist(optionsEl).fill(); 42 | 43 | $button(sponsorRowEl) 44 | .setValue("Zap") 45 | .setAction(() => { 46 | LinkOpener.navigate("https://getalby.com/p/rblb"); 47 | }) 48 | .setIconValue("flash_on"); 49 | 50 | $button(sponsorRowEl) 51 | .setValue("Sponsor") 52 | .setAction(() => { 53 | LinkOpener.navigate("https://github.com/sponsors/riccardobl"); 54 | }) 55 | .setIconValue("favorite"); 56 | 57 | $vsep(optionsEl); 58 | 59 | if (window.installPWA) { 60 | $button(optionsEl) 61 | .setValue("Install as progressive web app") 62 | .setAction(() => { 63 | if (window.installPWA) window.installPWA(); 64 | }); 65 | } 66 | 67 | $button(optionsEl) 68 | .setValue("Report an issue") 69 | .setAction(() => { 70 | LinkOpener.navigate("https://github.com/riccardobl/anser-liquid/issues/new"); 71 | }); 72 | 73 | $button(optionsEl) 74 | .setValue("Clear Cache") 75 | .setAction(async () => { 76 | await lq.clearCache(); 77 | alert("Cache cleared"); 78 | window.location.reload(); 79 | }); 80 | 81 | $vsep(optionsEl); 82 | const gpuModel = $text(optionsEl, ["sub"]); 83 | ui.getGPUModel().then((model) => { 84 | gpuModel.setValue(`GPU: ${model}`); 85 | }); 86 | 87 | ui.getCurrentTheme().then((currentTheme) => { 88 | const themeEl = themeSelector; 89 | themeEl.setPreferredValues([currentTheme]); 90 | for (const theme of ui.listThemes()) { 91 | themeEl.addOption(theme, theme, () => { 92 | ui.setTheme(theme); 93 | }); 94 | } 95 | }); 96 | 97 | ui.storage().then(async (store) => { 98 | const network = await lq.getNetworkName(); 99 | const primaryCurrency = (await store.get(`primaryCurrency${network}`)) || lq.getBaseAsset(); 100 | const secondaryCurrency = (await store.get(`secondaryCurrency${network}`)) || "USD"; 101 | 102 | primaryAssetSelector.setPreferredValues([primaryCurrency]); 103 | secondaryAssetSelector.setPreferredValues([secondaryCurrency]); 104 | 105 | const currencies = await lq.getAvailableCurrencies(); 106 | for (const currency of currencies) { 107 | const info = await lq.assets().getAssetInfo(currency.hash); 108 | const icon = await lq.assets().getAssetIcon(currency.hash); 109 | const optionEl = primaryAssetSelector.addOption(currency.hash, info.ticker, async () => { 110 | const store = await ui.storage(); 111 | store.set(`primaryCurrency${network}`, currency.hash); 112 | }); 113 | optionEl.setIconSrc(icon); 114 | const optionEl2 = secondaryAssetSelector.addOption(currency.hash, info.ticker, async () => { 115 | const store = await ui.storage(); 116 | store.set(`secondaryCurrency${network}`, currency.hash); 117 | }); 118 | optionEl2.setIconSrc(icon); 119 | } 120 | }); 121 | const loadAssetOptions = async () => { 122 | const inputSelEl = pinnedAssetsSelector; 123 | inputSelEl.clearOptions(); 124 | const pinned = await lq.getPinnedAssets(); 125 | const available = await lq.getAvailableCurrencies(false); 126 | for (const asset of available) { 127 | Promise.all([ 128 | lq.assets().getAssetInfo(asset.hash), 129 | lq.assets().getAssetIcon(asset.hash), 130 | ]).then(([info, icon]) => { 131 | const optionEl = inputSelEl.addOption( 132 | asset.hash, 133 | info.ticker, 134 | async (values) => { 135 | for (const k in values) { 136 | if (!values[k]) { 137 | console.log("Unpin", k); 138 | await lq.unpinAsset(k); 139 | } else { 140 | await lq.pinAsset(k); 141 | } 142 | } 143 | }, 144 | pinned.includes(asset.hash), 145 | ); 146 | optionEl.setIconSrc(icon); 147 | }); 148 | } 149 | inputSelEl.setPreferredValues(pinned.map((a) => a.hash)); 150 | }; 151 | 152 | loadAssetOptions(); 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Anser: A liquid companion for Alby 2 | 3 | [![anser](src/assets/screenshot/3.webp)](https://anser.frk.wf) 4 | 5 | Anser is a client-side web app that uses the [Alby extension](https://getalby.com/)'s 6 | liquid integration to provide a simple interface to the Liquid Network. 7 | 8 | See the [FAQ](#faq) for more details. 9 | 10 | # Usage 11 | 12 | Anser is a progressive web app that can be easily installed on your device, providing a native app experience. Alternatively, you can utilize it directly within your browser or even host your own instance. 13 | 14 | ## Stable 15 | 16 | There is a live version hosted on github pages that is automatically updated with every release, you can find it [here](https://anser.frk.wf) also available on IPfs (check the [Release Page](https://github.com/riccardobl/anser-liquid/releases) for the link). 17 | 18 | ## Dev Snapshot 19 | 20 | If you wish to test the latest updates from the master branch, you can access the snapshot [here](https://anser-snapshot.surge.sh). 21 | 22 | # Self Hosting 23 | 24 | ## Self-hosting as static files (easiest when starting from scratch) 25 | 26 | Anser is a web app that can be hosted on any web server capable of serving static files. 27 | 28 | 1. Download the latest release from the [Release Page](https://github.com/riccardobl/anser-liquid/releases). 29 | 2. Unzip the archive 30 | 3. Upload the extracted files to your web server 31 | 32 | ## Self-hosting as a docker container (best) 33 | 34 | Anser is also available as a self-contained docker container that can be run on any docker host. You can build a docker image from this repo (see [Build and run with Docker](#build-and-run-with-docker)) or you can pull the latest image from github package registry. 35 | 36 | ```bash 37 | # Pull the image ( check https://github.com/riccardobl/anser-liquid/pkgs/container/anser-liquid for the latest version ) 38 | docker pull ghcr.io/riccardobl/anser-liquid:v1.0 39 | 40 | # Run and expose on port 8080 41 | docker run -d \ 42 | --restart=always \ 43 | --name="anserliquid" \ 44 | --read-only \ 45 | --tmpfs /data \ 46 | --tmpfs /tmp \ 47 | --tmpfs /config \ 48 | -p 8080:80 \ 49 | ghcr.io/riccardobl/anser-liquid:v1.0 50 | 51 | ``` 52 | 53 | # Development 54 | 55 | ## Using Anser Library in your website 56 | 57 | One way to use Anser Library is to include the [LiquidWallet.js](src/js/LiquidWallet.js) script as a module and instantiate a LiquidWallet object. 58 | 59 | However to make things even easier, there is an additional set of simplified APIs that is automatically exported in window.liquid namespace when the script is included as a normal script tag. 60 | 61 | ```html 62 | 63 | ``` 64 | 65 | N.B. Replace {VERSION} with the latest version from the [Release Page](https://github.com/riccardobl/anser-liquid/releases). 66 | 67 | The `window.liquid` API provides common functionalities in a more intuitive way. 68 | See the documentation [here](/API.md). 69 | 70 | You can find the minified version of LiquidWallet.js that can be used both as a module and as a script tag in the [Release Page](https://github.com/riccardobl/anser-liquid/releases) or in [JsDelivr](https://www.jsdelivr.com/package/gh/riccardobl/anser-liquid). 71 | 72 | ## Build and run locally 73 | 74 | ### Test, build and run locally 75 | 76 | #### Requirements 77 | 78 | - [NodeJS and NPM](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm) 79 | - git 80 | - bash 81 | 82 | #### Setup 83 | 84 | ```bash 85 | # Clone the repo 86 | git clone https://github.com/riccardobl/anser-liquid.git 87 | 88 | # Enter the directory 89 | cd anser-liquid 90 | 91 | # (Optional) checkout a specific version 92 | # git checkout v1.0 93 | 94 | # Prepare the environment 95 | bash prepare.sh 96 | 97 | # Test (this will run a local server on port 9000) 98 | npm run start 99 | 100 | # Build (this will build a release version in the dist/ directory) 101 | BUILD_MODE="production" npm run build 102 | ``` 103 | 104 | ### Build and run with Docker 105 | 106 | #### Requirements 107 | 108 | - [Docker](https://docs.docker.com/get-docker/) 109 | 110 | #### Setup 111 | 112 | ```bash 113 | # Clone the repo 114 | git clone https://github.com/riccardobl/anser-liquid.git 115 | 116 | # Enter the directory 117 | cd anser-liquid 118 | 119 | # (Optional) checkout a specific version 120 | # git checkout v1.0 121 | 122 | # Build 123 | docker build -t anserliquid . 124 | 125 | # Run and expose on port 8080 126 | docker run -it --rm \ 127 | --name="anserliquid" \ 128 | --read-only \ 129 | --tmpfs /data \ 130 | --tmpfs /tmp \ 131 | --tmpfs /config \ 132 | -p 8080:80 \ 133 | anserliquid 134 | 135 | ``` 136 | 137 | #### TSL certificate 138 | 139 | The anser container does not provide a TSL certificate. 140 | To enable https you can use a reverse proxy like [nginx](https://www.nginx.com/) or customize the Caddyfile and rebuild the container. 141 | 142 | ## Source code structure 143 | 144 | The source code is logically divided in 2 parts: 145 | 146 | - [src/js/LiquidWallet.js](src/js/LiquidWallet.js) serves as the backend component, managing all interactions with various APIs. It provides a streamlined interface that can be easily integrated with any application. 147 | - [src/js/ui](src/js/ui) contains everything related to the UI of the web app. 148 | 149 | The entry point of the web app is 150 | 151 | - [src/js/index.js](src/js/index.js) 152 | 153 | # FAQ 154 | 155 | ### Q: Do I need Alby to use Anser? 156 | 157 | Yes, Anser relies on the Alby browser extension for key management and signing. 158 | 159 | ### Q: Can you see my private keys, transactions, or balances? 160 | 161 | No, Anser is a fully client-side app; your keys never leave the Alby extension. 162 | 163 | ### Q: Does Anser hold or transmit my funds? 164 | 165 | No, Anser serves as an interface that filters and displays data from the Liquid Network. It enables you to create valid transactions that are signed by the Alby extension and broadcasted through an Electrum node. 166 | 167 | ### Q: Who manages the Electrum node used by Anser? 168 | 169 | Anser connects to the public websocket endpoint provided by [Blockstream](https://github.com/Blockstream/esplora/blob/master/API.md). 170 | It's important to note that this node only provides access to public data of the Liquid Network, and broadcast your transactions to the other nodes. It does not hold your keys or funds and cannot sign or build transactions on your behalf. 171 | 172 | ### Q: Can I self-host Anser? 173 | 174 | Absolutely! Anser is a static web app that can be hosted on any webserver, including your local machine. 175 | -------------------------------------------------------------------------------- /src/js/storage/IDBStore.js: -------------------------------------------------------------------------------- 1 | import AbstractBrowserStore from "./AbstractBrowserStore.js"; 2 | import Constants from "../Constants.js"; 3 | /** 4 | * A backend to store to IndexedDB. 5 | * Should be slower but has larger storage limit. 6 | */ 7 | export default class IDBStore extends AbstractBrowserStore { 8 | static isSupported() { 9 | return "indexedDB" in window; 10 | } 11 | constructor(prefix, limit) { 12 | super(prefix, limit); 13 | 14 | const request = indexedDB.open(this.prefix, parseInt(Constants.APP_VERSION)); 15 | request.onupgradeneeded = function (event) { 16 | const db = event.target.result; 17 | db.createObjectStore(prefix); 18 | }; 19 | this.dbPromise = new Promise((resolve, reject) => { 20 | request.onsuccess = function (event) { 21 | resolve(event.target.result); 22 | }; 23 | request.onerror = function (event) { 24 | reject(event.target.error); 25 | }; 26 | }); 27 | } 28 | 29 | async _store(key, value) { 30 | if (!value) return; 31 | if (!key) throw new Error("Key is required"); 32 | 33 | if (value instanceof Promise || key instanceof Promise) { 34 | throw new Error("Promise not allowed in db"); 35 | } 36 | let valueType; 37 | [value, valueType] = await this._serialize(value); 38 | 39 | const db = await this.dbPromise; 40 | const transaction = db.transaction(this.prefix, "readwrite"); 41 | const store = transaction.objectStore(this.prefix); 42 | store.put({ value, valueType }, key); 43 | 44 | await new Promise((resolve, reject) => { 45 | transaction.oncomplete = resolve; 46 | transaction.onerror = reject; 47 | }); 48 | } 49 | 50 | async _retrieve(key, asDataUrl = false) { 51 | if (!key) throw new Error("Key is required"); 52 | 53 | const db = await this.dbPromise; 54 | const transaction = db.transaction(this.prefix); 55 | const store = transaction.objectStore(this.prefix); 56 | const request = store.get(key); 57 | const result = await new Promise((resolve, reject) => { 58 | request.onsuccess = () => resolve(request.result); 59 | request.onerror = reject; 60 | }); 61 | const { value, valueType } = result || {}; 62 | 63 | if (!value) return undefined; 64 | 65 | return await this._deserialize(value, valueType, asDataUrl); 66 | } 67 | 68 | async _delete(key) { 69 | if (!key) throw new Error("Key is required"); 70 | 71 | const db = await this.dbPromise; 72 | const transaction = db.transaction(this.prefix, "readwrite"); 73 | const store = transaction.objectStore(this.prefix); 74 | store.delete(key); 75 | await new Promise((resolve, reject) => { 76 | transaction.oncomplete = resolve; 77 | transaction.onerror = reject; 78 | }); 79 | } 80 | 81 | async _serialize(value) { 82 | let valueType = typeof value; 83 | if (value === undefined || value === null) { 84 | value = ""; 85 | valueType = "undefined"; 86 | } else if (value instanceof Map) { 87 | value = Array.from(value.entries()); 88 | value = JSON.stringify(value); 89 | valueType = "Map"; 90 | } else if (value instanceof Blob) { 91 | valueType = "Blob"; 92 | } else if (value instanceof Buffer) { 93 | valueType = value instanceof ArrayBuffer ? "ArrayBuffer" : "Buffer"; 94 | value = value.toString("hex"); 95 | } else if (value instanceof Uint8Array) { 96 | valueType = "Uint8Array"; 97 | value = JSON.stringify(Array.from(value)); 98 | } else if (valueType == "object") { 99 | // is array 100 | if (Array.isArray(value)) { 101 | const serializedValue = []; 102 | for (let i = 0; i < value.length; i++) { 103 | serializedValue[i] = await this._serialize(value[i], true); 104 | } 105 | value = serializedValue; 106 | valueType = "[]"; 107 | } else { 108 | const serializedValue = {}; 109 | for (const [key, val] of Object.entries(value)) { 110 | serializedValue[key] = await this._serialize(val, true); 111 | } 112 | value = serializedValue; 113 | valueType = "{}"; 114 | } 115 | 116 | value = JSON.stringify(value); 117 | } 118 | 119 | return [value, valueType]; 120 | } 121 | 122 | async _deserialize(value, valueType, asDataUrl) { 123 | if (valueType === "undefined") { 124 | value = undefined; 125 | } else if (valueType === "number") { 126 | value = parseFloat(value); 127 | } else if (valueType === "Blob" && asDataUrl) { 128 | value = URL.createObjectURL(value); 129 | } else if (valueType === "Map") { 130 | value = new Map(JSON.parse(value)); 131 | } else if (valueType === "ArrayBuffer") { 132 | value = new Uint8Array(Buffer.from(value, "hex")).buffer; 133 | } else if (valueType === "Buffer") { 134 | value = Buffer.from(value, "hex"); 135 | } else if (valueType === "Uint8Array") { 136 | value = new Uint8Array(JSON.parse(value)); 137 | } else if (valueType == "{}") { 138 | value = JSON.parse(value); 139 | // if (valueType === "{}") { 140 | const deserializedValue = {}; 141 | for (const [key, valType] of Object.entries(value)) { 142 | const [val, type] = valType; 143 | deserializedValue[key] = await this._deserialize(val, type, true); 144 | } 145 | value = deserializedValue; 146 | // } 147 | } else if (valueType == "[]") { 148 | value = JSON.parse(value); 149 | // if (valueType === "[]") { 150 | const deserializedValue = []; 151 | for (let i = 0; i < value.length; i++) { 152 | const [val, type] = value[i]; 153 | deserializedValue[i] = await this._deserialize(val, type, true); 154 | } 155 | value = deserializedValue; 156 | // } 157 | } 158 | return value; 159 | } 160 | 161 | async _calcSize(value) { 162 | if (!value) return 0; 163 | const valueSerialized = await this._serialize(value, typeof value); 164 | return valueSerialized.length; 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /src/js/storage/LocalStore.js: -------------------------------------------------------------------------------- 1 | import AbstractBrowserStore from "./AbstractBrowserStore.js"; 2 | /** 3 | * A backend to store to localStorage. 4 | * Should be faster but has smaller storage limit. 5 | */ 6 | export default class LocalStore extends AbstractBrowserStore { 7 | static isSupported() { 8 | return "localStorage" in window; 9 | } 10 | 11 | constructor(prefix, limit) { 12 | super(prefix, limit); 13 | } 14 | 15 | async _serialize(value) { 16 | let valueType = typeof value; 17 | if (value === undefined || value === null) { 18 | value = ""; 19 | valueType = "undefined"; 20 | } else if (value instanceof Map) { 21 | value = Array.from(value.entries()); 22 | value = JSON.stringify(value); 23 | valueType = "Map"; 24 | } else if (value instanceof Blob) { 25 | const blobType = value.type; 26 | const reader = new FileReader(); 27 | reader.readAsDataURL(value); 28 | const blobData = await new Promise((resolve, reject) => { 29 | reader.onloadend = () => resolve(reader.result); 30 | reader.onerror = reject; 31 | }); 32 | valueType = "Blob"; 33 | value = JSON.stringify({ blobType, blobData }); 34 | } else if (value instanceof Buffer) { 35 | valueType = value instanceof ArrayBuffer ? "ArrayBuffer" : "Buffer"; 36 | value = value.toString("hex"); 37 | } else if (value instanceof Uint8Array) { 38 | valueType = "Uint8Array"; 39 | value = JSON.stringify(Array.from(value)); 40 | } else if (valueType == "object") { 41 | if (Array.isArray(value)) { 42 | const serializedValue = []; 43 | for (let i = 0; i < value.length; i++) { 44 | serializedValue[i] = await this._serialize(value[i], true); 45 | } 46 | value = serializedValue; 47 | valueType = "[]"; 48 | } else { 49 | const serializedValue = {}; 50 | for (const [key, val] of Object.entries(value)) { 51 | serializedValue[key] = await this._serialize(val, true); 52 | } 53 | value = serializedValue; 54 | valueType = "{}"; 55 | } 56 | value = JSON.stringify(value); 57 | } 58 | 59 | return [value, valueType]; 60 | } 61 | 62 | async _deserialize(value, valueType, asDataUrl) { 63 | if (valueType === "undefined") { 64 | value = undefined; 65 | } else if (valueType === "number") { 66 | value = parseFloat(value); 67 | } else if (valueType === "Blob" && asDataUrl) { 68 | value = JSON.parse(value); 69 | const blobType = value.blobType; 70 | const blobData = value.blobData; 71 | // Convert base64 to Blob 72 | const byteCharacters = atob(blobData.split(",")[1]); 73 | const byteNumbers = new Array(byteCharacters.length); 74 | for (let i = 0; i < byteCharacters.length; i++) { 75 | byteNumbers[i] = byteCharacters.charCodeAt(i); 76 | } 77 | const byteArray = new Uint8Array(byteNumbers); 78 | const blob = new Blob([byteArray], { type: blobType }); 79 | 80 | if (asDataUrl) { 81 | return URL.createObjectURL(blob); 82 | } else { 83 | return blob; 84 | } 85 | } else if (valueType === "Map") { 86 | value = new Map(JSON.parse(value)); 87 | } else if (valueType === "ArrayBuffer") { 88 | value = new Uint8Array(Buffer.from(value, "hex")).buffer; 89 | } else if (valueType === "Buffer") { 90 | value = Buffer.from(value, "hex"); 91 | } else if (valueType === "Uint8Array") { 92 | value = new Uint8Array(JSON.parse(value)); 93 | } else if (valueType == "{}") { 94 | value = JSON.parse(value); 95 | const deserializedValue = {}; 96 | for (const [key, valType] of Object.entries(value)) { 97 | const [val, type] = valType; 98 | deserializedValue[key] = await this._deserialize(val, type, true); 99 | } 100 | value = deserializedValue; 101 | } else if (valueType == "[]") { 102 | value = JSON.parse(value); 103 | const deserializedValue = []; 104 | for (let i = 0; i < value.length; i++) { 105 | const [val, type] = value[i]; 106 | deserializedValue[i] = await this._deserialize(val, type, true); 107 | } 108 | value = deserializedValue; 109 | } 110 | return value; 111 | } 112 | 113 | async _store(key, value) { 114 | if (!value) return; 115 | if (!key) throw new Error("Key is required"); 116 | if (!this.typeTable) { 117 | this.typeTable = new Map(); 118 | const typeTable = localStorage.getItem(`${this.prefix}:s:typeTable`); 119 | if (typeTable) { 120 | const entries = JSON.parse(typeTable); 121 | for (const [key, value] of entries) { 122 | this.typeTable.set(key, value); 123 | } 124 | } 125 | } 126 | if (value instanceof Promise || key instanceof Promise) { 127 | throw new Error("Promise not allowed in db"); 128 | } 129 | 130 | let valueType; 131 | [value, valueType] = await this._serialize(value); 132 | 133 | if (valueType) { 134 | this.typeTable.set(key, valueType); 135 | localStorage.setItem( 136 | `${this.prefix}:s:typeTable`, 137 | JSON.stringify(Array.from(this.typeTable.entries())), 138 | ); 139 | } 140 | 141 | localStorage.setItem(`${this.prefix}:${key}`, value); 142 | } 143 | 144 | async _retrieve(key, asDataUrl = false) { 145 | if (!key) throw new Error("Key is required"); 146 | 147 | if (!this.typeTable) { 148 | this.typeTable = new Map(); 149 | const typeTable = localStorage.getItem(`${this.prefix}:s:typeTable`); 150 | if (typeTable) { 151 | const entries = JSON.parse(typeTable); 152 | for (const [key, value] of entries) { 153 | this.typeTable.set(key, value); 154 | } 155 | } 156 | } 157 | let value = localStorage.getItem(`${this.prefix}:${key}`); 158 | if (!value) return value; 159 | const valueType = this.typeTable.get(key); 160 | 161 | value = await this._deserialize(value, valueType, asDataUrl); 162 | return value; 163 | } 164 | 165 | async _delete(key) { 166 | if (!key) throw new Error("Key is required"); 167 | 168 | this.typeTable.delete(key); 169 | localStorage.removeItem(`${this.prefix}:${key}`); 170 | } 171 | 172 | async _calcSize(value) { 173 | if (!value) return 0; 174 | const valueSerialized = await this._serialize(value, typeof value); 175 | return valueSerialized.length; 176 | } 177 | } 178 | -------------------------------------------------------------------------------- /src/js/storage/AbstractBrowserStore.js: -------------------------------------------------------------------------------- 1 | import Constants from "../Constants.js"; 2 | /** 3 | * A browser storage class that supports several backend. 4 | * It can track memory usage, expiration and delete old entries. 5 | * Supports all serializable objects, Map, Buffer, Uint8Array, ArrayBuffer, Blob, undefined, null and primitive types. 6 | */ 7 | export default class AbstractBrowserStore { 8 | // default limit = 100 mb 9 | constructor(prefix, limit) { 10 | this.limit = limit; 11 | this.prefix = prefix; 12 | this.locks = {}; 13 | } 14 | 15 | async lock(key) { 16 | let t = 1; 17 | while (this.locks[key]) { 18 | await new Promise((resolve) => setTimeout(resolve, t)); 19 | t *= 2; 20 | if (t > 100) t = 100; 21 | } 22 | this.locks[key] = true; 23 | } 24 | 25 | unlock(key) { 26 | delete this.locks[key]; 27 | } 28 | 29 | async _init() { 30 | while (this.starting) { 31 | console.log("Waiting..."); 32 | await new Promise((res) => setTimeout(res, 100)); 33 | } 34 | if (this.ready) return; 35 | try { 36 | this.starting = true; 37 | 38 | this.accessTable = await this._retrieve("s:accessTable"); 39 | this.expirationTable = await this._retrieve("s:expirationTable"); 40 | this.sizeTable = await this._retrieve("s:sizeTable"); 41 | 42 | if (!this.accessTable) { 43 | this.accessTable = new Map(); 44 | } 45 | if (!this.expirationTable) { 46 | this.expirationTable = new Map(); 47 | } 48 | if (!this.sizeTable) { 49 | this.sizeTable = new Map(); 50 | } 51 | this.ready = true; 52 | this.starting = false; 53 | } catch (e) { 54 | alert(e); 55 | console.error(e); 56 | } finally { 57 | this.ready = true; 58 | this.starting = false; 59 | } 60 | } 61 | 62 | async _store(key, value) { 63 | throw new Error("Not implemented"); 64 | } 65 | 66 | async _retrieve(key, asDataUrl = false) { 67 | throw new Error("Not implemented"); 68 | } 69 | 70 | async _delete(key) { 71 | throw new Error("Not implemented"); 72 | } 73 | 74 | async _calcSize(key, value) { 75 | throw new Error("Not implemented"); 76 | } 77 | 78 | async getUsedMemory() { 79 | await this._init(); 80 | let size = 0; 81 | for (let [key, value] of this.sizeTable) { 82 | size += value; 83 | } 84 | return size; 85 | } 86 | 87 | async set(key, value, expiration = 0) { 88 | await this._init(); 89 | 90 | if (key.startsWith("s:")) throw new Error("Key cannot start with s:"); 91 | if (!value) { 92 | console.log("Setting " + key + " to null"); 93 | } 94 | 95 | if (!value) { 96 | await this._delete(key); 97 | await this._setAccessTime(key, undefined); 98 | await this._setExpiration(key, undefined); 99 | await this._setSize(key, undefined); 100 | } else { 101 | const entrySize = await this._calcSize(key, value); 102 | if (this.limit) { 103 | while ((await this.getUsedMemory()) + entrySize > this.limit) { 104 | await this.deleteOldestAccess(); 105 | } 106 | } 107 | await this._store(key, value); 108 | await this._setAccessTime(key, Date.now()); 109 | await this._setExpiration(key, expiration ? Date.now() + expiration : undefined); 110 | await this._setSize(key, entrySize); 111 | } 112 | } 113 | 114 | async _setAccessTime(key, time) { 115 | await this._init(); 116 | await this.lock("s:"); 117 | try { 118 | if (time) this.accessTable.set(key, time); 119 | else this.accessTable.delete(key); 120 | await this._store("s:accessTable", this.accessTable); 121 | } finally { 122 | this.unlock("s:"); 123 | } 124 | } 125 | 126 | async _setExpiration(key, time) { 127 | await this._init(); 128 | await this.lock("s:"); 129 | try { 130 | if (time) this.expirationTable.set(key, time); 131 | else this.expirationTable.delete(key); 132 | await this._store("s:expirationTable", this.expirationTable); 133 | } finally { 134 | this.unlock("s:"); 135 | } 136 | } 137 | 138 | async _setSize(key, size) { 139 | await this._init(); 140 | await this.lock("s:"); 141 | try { 142 | if (size) this.sizeTable.set(key, size); 143 | else this.sizeTable.delete(key); 144 | await this._store("s:sizeTable", this.sizeTable); 145 | } finally { 146 | this.unlock("s:"); 147 | } 148 | } 149 | 150 | async clear() { 151 | await this._init(); 152 | const keys = this.accessTable.keys(); 153 | for (const key of keys) { 154 | if (key.startsWith("s:")) continue; 155 | console.log("Clearing " + key); 156 | await this.set(key, null); 157 | } 158 | } 159 | 160 | async get(key, asDataUrl = false, refreshCallback = undefined, waitForRefresh = undefined) { 161 | await this._init(); 162 | 163 | if (key.startsWith("s:")) throw new Error("Key cannot start with s:"); 164 | 165 | let value; 166 | let exists = this.accessTable.has(key) && this.sizeTable.has(key); 167 | if (!exists) { 168 | await this.set(key, undefined); 169 | value = undefined; 170 | } else { 171 | value = await this._retrieve(key, asDataUrl); 172 | } 173 | 174 | if (value) { 175 | await this._setAccessTime(key, Date.now()); 176 | } 177 | 178 | const expire = this.expirationTable.get(key); 179 | if (!value || (expire && expire < Date.now())) { 180 | console.log("Refreshing " + key); 181 | if (refreshCallback) { 182 | let refreshed = Promise.resolve(refreshCallback()); 183 | refreshed = refreshed.then(async (data) => { 184 | if (!data) return undefined; 185 | const [value, expire] = data; 186 | if (value) { 187 | await this.set(key, value, expire); 188 | } else { 189 | await this.set(key, null); 190 | } 191 | return value; 192 | }); 193 | if (!value || waitForRefresh) { 194 | value = await refreshed; 195 | value = await this._retrieve(key, asDataUrl); 196 | } 197 | } else { 198 | if (!value) await this.set(key, null); 199 | value = null; 200 | } 201 | } 202 | // localStorage.setItem('accessTable', JSON.stringify(Array.from(this.accessTable.entries()))); 203 | return value; 204 | } 205 | 206 | async deleteOldestAccess() { 207 | await this._init(); 208 | let oldestKey = null; 209 | let oldestAccess = Infinity; 210 | for (let [key, access] of this.accessTable) { 211 | if (key.startsWith("s:")) continue; 212 | if (access < oldestAccess) { 213 | oldestAccess = access; 214 | oldestKey = key; 215 | } 216 | } 217 | await this.set(oldestKey, null); 218 | } 219 | } 220 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build and Deploy 2 | 3 | on: 4 | # Runs on pushes targeting the default branch 5 | push: 6 | branches: ["master"] 7 | release: 8 | types: [published] 9 | 10 | # Allows you to run this workflow manually from the Actions tab 11 | workflow_dispatch: 12 | 13 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 14 | permissions: 15 | contents: write 16 | pages: write 17 | id-token: write 18 | packages: write 19 | 20 | jobs: 21 | # deploy docker to github registry 22 | deployDocker: 23 | if: github.event_name == 'release' && !github.event.release.prerelease 24 | runs-on: ubuntu-latest 25 | env: 26 | REGISTRY: ghcr.io 27 | IMAGE_NAME: ${{ github.repository }} 28 | steps: 29 | - name: Checkout 30 | uses: actions/checkout@v4 31 | 32 | - name: Login to GitHub Packages 33 | uses: docker/login-action@v1 34 | with: 35 | registry: ${{ env.REGISTRY }} 36 | username: ${{ github.actor }} 37 | password: ${{ secrets.GITHUB_TOKEN }} 38 | 39 | - name: Extract metadata (tags, labels) for Docker 40 | id: meta 41 | uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7 42 | with: 43 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 44 | 45 | - name: Build and push Docker image 46 | uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4 47 | with: 48 | context: . 49 | push: true 50 | tags: ${{ steps.meta.outputs.tags }} 51 | labels: ${{ steps.meta.outputs.labels }} 52 | 53 | deployPackage: 54 | runs-on: ubuntu-latest 55 | steps: 56 | - name: Checkout 57 | uses: actions/checkout@v4 58 | 59 | - name: Build 60 | run: | 61 | bash build.sh 62 | 63 | # Deploy to GitHub action artifacts 64 | - name: Upload artifact 65 | uses: actions/upload-artifact@v4 66 | with: 67 | name: anser-static-snapshot 68 | path: "./dist/" 69 | 70 | # Deploy to GitHub Releases 71 | - name: Compress dist 72 | if: github.event_name == 'release' 73 | run: | 74 | cd dist 75 | zip -r ../anser-static-deploy.zip . 76 | cd .. 77 | 78 | - name: Deploy to release branch 79 | if: github.event_name == 'release' 80 | run: | 81 | # Commit the changes 82 | git config --global user.name "Github Actions" 83 | git config --global user.email "actions@users.noreply.github.com" 84 | 85 | git clone --single-branch --branch "releases" https://github.com/${GITHUB_REPOSITORY} releases 86 | version="`if [[ $GITHUB_REF == refs\/tags* ]]; then echo ${GITHUB_REF//refs\/tags\//}; fi`" 87 | 88 | cd releases 89 | mkdir -p ${version} 90 | mkdir -p latest 91 | cp -f ../dist/lib/liquidwallet.lib.js ${version}/liquidwallet.lib.js 92 | cp -f ../dist/lib/liquidwallet.lib.js latest/liquidwallet.lib.js 93 | 94 | git remote set-url origin https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/$GITHUB_REPOSITORY 95 | 96 | git add . || true 97 | git commit -m "update ${version}" || true 98 | git push origin releases || true 99 | 100 | - name: Deploy to GitHub Releases 101 | if: github.event_name == 'release' 102 | run: | 103 | set -e 104 | echo "${GITHUB_EVENT_PATH}" 105 | cat ${GITHUB_EVENT_PATH} 106 | releaseId=$(jq --raw-output '.release.id' ${GITHUB_EVENT_PATH}) 107 | 108 | echo "Upload to release $releaseId" 109 | 110 | filename="./anser-static-deploy.zip" 111 | url="https://uploads.github.com/repos/${GITHUB_REPOSITORY}/releases/$releaseId/assets?name=$(basename $filename)" 112 | echo "Upload to $url" 113 | curl -L \ 114 | -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ 115 | -H "Content-Type: application/zip" \ 116 | --data-binary @"$filename" \ 117 | "$url" 118 | 119 | filename="./dist/lib/liquidwallet.lib.js" 120 | url="https://uploads.github.com/repos/${GITHUB_REPOSITORY}/releases/$releaseId/assets?name=$(basename $filename)" 121 | echo "Upload to $url" 122 | curl -L \ 123 | -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ 124 | -H "Content-Type: application/javascript" \ 125 | --data-binary @"$filename" \ 126 | "$url" 127 | 128 | deployWWW: 129 | concurrency: ci-${{ github.ref }} 130 | environment: 131 | name: github-pages 132 | url: ${{ steps.deployment.outputs.page_url }} 133 | runs-on: ubuntu-latest 134 | steps: 135 | - name: Checkout 136 | uses: actions/checkout@v4 137 | 138 | - name: Setup Pages 139 | if: github.event_name == 'release' && !github.event.release.prerelease 140 | uses: actions/configure-pages@v4 141 | 142 | - name: Build 143 | run: | 144 | bash build.sh 145 | 146 | - name: Upload artifact 147 | if: github.event_name == 'release' && !github.event.release.prerelease 148 | uses: actions/upload-pages-artifact@v3 149 | with: 150 | path: "./dist/" 151 | 152 | - name: Deploy to GitHub Pages 153 | id: deployment 154 | if: github.event_name == 'release' && !github.event.release.prerelease 155 | uses: actions/deploy-pages@v4 156 | 157 | - name: Prepare Node 18 158 | uses: actions/setup-node@v2 159 | with: 160 | node-version: 18 161 | 162 | - name: Deploy to surge.sh 163 | uses: dswistowski/surge-sh-action@v1 164 | with: 165 | domain: "anser-snapshot.surge.sh" 166 | project: "./dist/" 167 | login: ${{ secrets.SURGE_LOGIN }} 168 | token: ${{ secrets.SURGE_TOKEN }} 169 | 170 | - name: Upload to ipfs 171 | if: github.event_name == 'release' 172 | run: | 173 | npm install -g @web3-storage/w3cli 174 | mkdir -p /home/runner/.config/w3access 175 | echo '${{secrets.W3_ACCESS}}' > ~/.config/w3access/w3cli.json 176 | w3 space use anser 177 | w3 up ./dist/ --json > ipfs.json 178 | cid=$(jq -r '.root["/"]' ipfs.json) 179 | echo "$cid" > deploy.log 180 | cat deploy.log 181 | 182 | - name: Write ipfs link to release description 183 | if: github.event_name == 'release' 184 | run: | 185 | set -e 186 | echo "${GITHUB_EVENT_PATH}" 187 | cat ${GITHUB_EVENT_PATH} 188 | releaseId=$(jq --raw-output '.release.id' ${GITHUB_EVENT_PATH}) 189 | 190 | cid=`cat deploy.log` 191 | 192 | deployLink="IPfs deployment:\n - ipfs://${cid}\n\nWWW gateway:\n - https://${cid}.ipfs.cf-ipfs.com" 193 | echo $deployLink 194 | 195 | # Add to release description 196 | curl -X PATCH \ 197 | -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ 198 | -H "Accept: application/vnd.github.v3+json" \ 199 | https://api.github.com/repos/${GITHUB_REPOSITORY}/releases/$releaseId \ 200 | -d "{\"body\": \"${deployLink}\"}" 201 | -------------------------------------------------------------------------------- /src/js/ui/UI.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Main UI class 3 | * A UI is composed by two logical parts: 4 | * - Stages: a stage is a part of the UI that is displayed in the main window, like a page in a website. 5 | * - Modules: a module is a global component of the ui. It can be enabled or disabled for certain stages, however it is not strictly related to a stage (eg. header, floating buttons etc..) 6 | * Modules and stages must be registered before the UI is created. 7 | * 8 | * All stages must go in 9 | * ./ui/stages 10 | * All modules must go in 11 | * ./ui/modules 12 | * And they should be name like this: 13 | * [StageName]Stage.js 14 | * [ModuleName]Module.js 15 | * Eg. 16 | * WalletStage.js is for the stage "wallet" 17 | * HeaderModule.js is for the module "header" 18 | */ 19 | 20 | import Constants from "../Constants.js"; 21 | import BrowserStore from "../storage/BrowserStore.js"; 22 | export default class UI { 23 | static async setSafeMode(v) { 24 | this.safeMode = v; 25 | if (v) console.log("Enable safe mode"); 26 | return this.safeMode; 27 | } 28 | 29 | async getGPUModel() { 30 | if (!UI.gpuModel) throw "Unknown"; 31 | return UI.gpuModel; 32 | } 33 | static async isSafeMode() { 34 | try { 35 | if (typeof this.safeMode !== "undefined") return this.safeMode; 36 | const nCores = navigator.hardwareConcurrency; 37 | if (typeof nCores !== "undefined" && nCores > 0 && nCores < 4) return this.setSafeMode(true); 38 | 39 | const isIPFs = 40 | window.location.href.startsWith("ipfs://") || window.location.href.includes(".ipfs."); 41 | if (!isIPFs) { 42 | if (!window.WebGLRenderingContext) return this.setSafeMode(true); 43 | if (!window.WebGL2RenderingContext) return this.setSafeMode(true); 44 | 45 | const canvas = document.createElement("canvas"); 46 | const gl = canvas.getContext("webgl2"); 47 | if (!gl) return this.setSafeMode(true); 48 | const debugInfo = gl.getExtension("WEBGL_debug_renderer_info"); 49 | const vendor = gl.getParameter(debugInfo.UNMASKED_VENDOR_WEBGL); 50 | const renderer = gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL); 51 | console.log("GPU", vendor, renderer); 52 | this.gpuModel = renderer + " " + vendor; 53 | } 54 | } catch (e) { 55 | return this.setSafeMode(true); 56 | } 57 | return this.setSafeMode(false); 58 | } 59 | 60 | static STAGES = []; 61 | static registerStage(stageName) { 62 | stageName = stageName[0].toUpperCase() + stageName.slice(1); 63 | this.STAGES.push( 64 | import("./stages/" + stageName + "Stage.js") 65 | .then((module) => module.default) 66 | .then((Stage) => { 67 | const stage = new Stage(); 68 | return stage; 69 | }), 70 | ); 71 | } 72 | 73 | static THEMES = {}; 74 | static registerTheme(themeName) { 75 | const fullPath = "static/theme/" + themeName + ".css"; 76 | UI.THEMES[themeName] = fullPath; 77 | } 78 | 79 | static MODULES = []; 80 | static registerModule(moduleName) { 81 | moduleName = moduleName[0].toUpperCase() + moduleName.slice(1); 82 | this.MODULES.push( 83 | import("./modules/" + moduleName + "Module.js") 84 | .then((module) => module.default) 85 | .then((Module) => { 86 | const module = new Module(); 87 | return module; 88 | }), 89 | ); 90 | } 91 | 92 | async storage() { 93 | if (!this.store) { 94 | this.store = await BrowserStore.fast("preferences"); 95 | } 96 | return this.store; 97 | } 98 | 99 | constructor(stageContainerEl, walletEl, lq) { 100 | this.stageContainerEl = stageContainerEl; 101 | this.walletEl = walletEl; 102 | this.lq = lq; 103 | this.stageChangeListeners = []; 104 | } 105 | 106 | addStageChangeListener(listener) { 107 | this.stageChangeListeners.push(listener); 108 | } 109 | 110 | reload() { 111 | this.setStage(this.stage.getName()); 112 | } 113 | 114 | useBrowserHistory() { 115 | window.addEventListener("popstate", (e) => { 116 | if (e.state && e.state.stage) { 117 | this.setStage(e.state.stage); 118 | } 119 | }); 120 | this.addStageChangeListener((stage) => { 121 | window.history.pushState({ stage: stage.getName() }, stage.getName(), "#" + stage.getName()); 122 | }); 123 | } 124 | 125 | async _reloadTheme() { 126 | let themePath = await this.getCurrentTheme(); 127 | themePath = UI.THEMES[themePath]; 128 | if (!themePath) themePath = UI.THEMES[Constants.DEFAULT_THEME]; 129 | let cssEl = document.head.querySelector("link#liquidwalletTheme"); 130 | if (!cssEl) { 131 | cssEl = document.createElement("link"); 132 | cssEl.id = "liquidwalletTheme"; 133 | cssEl.rel = "stylesheet"; 134 | cssEl.type = "text/css"; 135 | document.head.appendChild(cssEl); 136 | } 137 | if (cssEl.href !== themePath) { 138 | cssEl.href = themePath; 139 | } 140 | 141 | const useSafeMode = await UI.isSafeMode(); 142 | let safeModeEl = document.head.querySelector("link#liquidwalletSafeMode"); 143 | 144 | if (!useSafeMode) { 145 | if (safeModeEl) safeModeEl.remove(); 146 | } else { 147 | if (!safeModeEl) { 148 | safeModeEl = document.createElement("link"); 149 | safeModeEl.id = "liquidwalletSafeMode"; 150 | safeModeEl.rel = "stylesheet"; 151 | safeModeEl.type = "text/css"; 152 | document.head.appendChild(safeModeEl); 153 | } 154 | safeModeEl.href = "static/safemode.css"; 155 | } 156 | } 157 | 158 | listThemes() { 159 | return Object.keys(UI.THEMES); 160 | } 161 | 162 | async getCurrentTheme() { 163 | return (await (await this.storage()).get("theme")) || Constants.DEFAULT_THEME; 164 | } 165 | 166 | async setTheme(themeName) { 167 | if (UI.THEMES[themeName]) { 168 | await (await this.storage()).set("theme", themeName); 169 | } 170 | await this._reloadTheme(); 171 | } 172 | 173 | async setStage(stageName) { 174 | await this._reloadTheme(); 175 | 176 | const reload = this.stage && this.stage.getName() === stageName; 177 | console.log(reload ? "Reload" : "Load ", stageName); 178 | 179 | const stages = await Promise.all(UI.STAGES); 180 | const modules = await Promise.all(UI.MODULES); 181 | let stage; 182 | for (let i = 0; i < stages.length; i++) { 183 | if (stages[i].getName() === stageName) { 184 | stage = stages[i]; 185 | break; 186 | } 187 | } 188 | 189 | if (!stage) { 190 | stage = stages[0]; 191 | console.log(stages); 192 | console.error("Invalid stage", stageName, "use default stage", stage.getName()); 193 | } 194 | 195 | if (!reload) { 196 | // scroll stageContainerEl to top smoothly 197 | this.stageContainerEl.classList.remove("fadeIn"); 198 | this.stageContainerEl.classList.add("fadeOut"); 199 | await new Promise((resolve) => setTimeout(resolve, 100)); 200 | 201 | if (this.stage) { 202 | for (const module of modules) { 203 | if (module.isEnabledForStage(this.stage.getName())) { 204 | module.onUnload(this.stage, this.stageContainerEl, this.walletEl, this.lq, this); 205 | } 206 | } 207 | this.stage.onUnload(this.stageContainerEl, this.lq, this); 208 | this.stageContainerEl.classList.remove(this.stage.getName()); 209 | } 210 | 211 | this.stageContainerEl.innerHTML = ""; 212 | this.stageContainerEl.classList.add("stage"); 213 | this.stageContainerEl.classList.add(stage.getName()); 214 | } 215 | stage.onReload(this.stageContainerEl, this.lq, this); 216 | for (const listener of this.stageChangeListeners) { 217 | listener(stage); 218 | } 219 | this.stage = stage; 220 | 221 | if (!reload) { 222 | for (const module of modules) { 223 | if (module.isEnabledForStage(stage.getName())) { 224 | module.onLoad(stage, this.stageContainerEl, this.walletEl, this.lq, this); 225 | } 226 | } 227 | } 228 | 229 | await new Promise((resolve) => setTimeout(resolve, 100)); 230 | 231 | this.stageContainerEl.classList.remove("fadeOut"); 232 | this.stageContainerEl.classList.add("fadeIn"); 233 | 234 | if (!reload) { 235 | this.walletEl.querySelectorAll("*").forEach((el) => el.scrollTo({ top: 0, behavior: "smooth" })); 236 | } 237 | } 238 | 239 | captureOutputs() { 240 | if (this.infoOut || this.errorOut) throw new Error("Already capturing outputs"); 241 | this.infoOut = console.info; 242 | this.errorOut = console.error; 243 | 244 | console.info = (...args) => { 245 | this.info(...args); 246 | this.infoOut(...args); 247 | }; 248 | 249 | console.error = (...args) => { 250 | this.error(...args); 251 | this.errorOut(...args); 252 | }; 253 | } 254 | 255 | error(...args) { 256 | return this.showAlert("error", this.walletEl, ...args); 257 | } 258 | 259 | fatal(...args) { 260 | return this.showAlert("fatal", this.walletEl, ...args); 261 | } 262 | 263 | info(...args) { 264 | return this.showAlert("alert", this.walletEl, ...args); 265 | } 266 | 267 | perma(...args) { 268 | return this.showAlert("perma", this.walletEl, ...args); 269 | } 270 | 271 | showAlert(type, containerElement, ...args) { 272 | let alertContainerEl = containerElement.querySelector(".alertContainer"); 273 | if (!alertContainerEl) { 274 | alertContainerEl = document.createElement("div"); 275 | alertContainerEl.className = "alertContainer"; 276 | containerElement.appendChild(alertContainerEl); 277 | } 278 | 279 | const alertBox = document.createElement("div"); 280 | alertBox.className = `alert ${type}`; 281 | alertBox.textContent = args.join(" ").trim(); 282 | 283 | alertContainerEl.appendChild(alertBox); 284 | // scroll to bottom 285 | alertContainerEl.scrollTop = alertContainerEl.scrollHeight; 286 | 287 | let time = 5000; 288 | if (type === "error") time = 10000; 289 | if (type === "fatal") time = 60000; 290 | if (type === "perma") time = 1000 * 60 * 60; 291 | const deletionTimeout = setTimeout(() => { 292 | alertContainerEl.removeChild(alertBox); 293 | }, time); 294 | alertBox.addEventListener("click", () => { 295 | clearTimeout(deletionTimeout); 296 | alertContainerEl.removeChild(alertBox); 297 | }); 298 | 299 | return alertBox; 300 | } 301 | } 302 | 303 | UI.registerStage("wallet"); 304 | UI.registerStage("options"); 305 | UI.registerStage("send"); 306 | UI.registerStage("receive"); 307 | UI.registerModule("header"); 308 | UI.registerModule("clarity"); 309 | UI.registerModule("logo"); 310 | UI.registerModule("globalmessage"); 311 | 312 | UI.registerTheme("streamgoose"); 313 | UI.registerTheme("deepoceanduck"); 314 | UI.registerTheme("spacequack"); 315 | UI.registerTheme("satoshilegacy"); 316 | UI.registerTheme("minimalsats"); 317 | -------------------------------------------------------------------------------- /src/js/AssetProvider.js: -------------------------------------------------------------------------------- 1 | import Constants from "./Constants.js"; 2 | import fetch from "./utils/fetch-timeout.js"; 3 | import Icons from "./Icons.js"; 4 | import SpecialSymbols from "./SpecialSymbols.js"; 5 | /** 6 | * A wrapper around several apis. 7 | * Provides pricing for liquid assets, fiat currencies, their icons and other info. 8 | * Handles also pricing tracking, conversion and formatting. 9 | */ 10 | export default class AssetProvider { 11 | constructor( 12 | cache, 13 | store, 14 | sideSwap, 15 | esplora, 16 | baseAssetId, 17 | basePrecision, 18 | baseTicker, 19 | baseName, 20 | fiatTickerUrl = "https://blockchain.info/ticker", 21 | fiatTrackerTimeout = 5 * 60 * 1000, 22 | ) { 23 | this.cache = cache; 24 | this.store = store; 25 | this.sideSwap = sideSwap; 26 | this.esplora = esplora; 27 | this.baseAssetId = baseAssetId; 28 | this.basePrecision = basePrecision; 29 | this.baseTicker = baseTicker; 30 | this.baseName = baseName; 31 | this.fiatTickerUrl = fiatTickerUrl; 32 | this.fiatTrackerTimeout = fiatTrackerTimeout; 33 | this.trackedAssets = []; 34 | this.trackedFiatAssets = []; 35 | this.staticIcons = {}; 36 | this.specialSymbols = {}; 37 | } 38 | 39 | async _getFiatData() { 40 | const fiatTickerUrlDescriber = this.fiatTickerUrl.toLowerCase().replace(/[^a-z0-9]/g, ""); 41 | let fiatData = await this.cache.get("fiat:" + fiatTickerUrlDescriber); 42 | if (!fiatData || Date.now() - fiatData.timestamp > this.fiatTrackerTimeout) { 43 | try { 44 | this.fiatSyncing = true; 45 | const data = await fetch(this.fiatTickerUrl).then((r) => r.json()); 46 | const timestamp = Date.now(); 47 | fiatData = { 48 | timestamp, 49 | data, 50 | }; 51 | await this.cache.set("fiat:" + fiatTickerUrlDescriber, fiatData); 52 | this.fiatSyncing = false; 53 | } catch (e) { 54 | console.log(e); 55 | } 56 | } 57 | return fiatData; 58 | } 59 | 60 | async _getFiatPrice(fiatTicker) { 61 | await this._init(); 62 | while (this.fiatSyncing) { 63 | await new Promise((res) => setTimeout(res, 100)); 64 | } 65 | const fiatData = await this._getFiatData(); 66 | let price = fiatData && fiatData.data && fiatData.data[fiatTicker] ? fiatData.data[fiatTicker] : 0; 67 | if (price) price = price.last; 68 | 69 | if (!price || isNaN(price)) return 0; 70 | price = parseFloat(price); 71 | return price; 72 | } 73 | 74 | _isFiat(asset) { 75 | return asset.length < 4; 76 | } 77 | 78 | async _init() { 79 | while (this.starting) { 80 | console.log("Waiting..."); 81 | await new Promise((res) => setTimeout(res, 100)); 82 | } 83 | if (this.ready) return; 84 | try { 85 | this.starting = true; 86 | // restore tracked assets 87 | 88 | const trackedAssets = await this.store.get("trackedAssets"); 89 | if (trackedAssets) { 90 | for (const asset of trackedAssets) { 91 | await this.track(asset, true, true); 92 | } 93 | } 94 | 95 | const trackedFiatAssets = await this.store.get("trackedFiatAssets"); 96 | if (trackedFiatAssets) { 97 | for (const asset of trackedFiatAssets) { 98 | await this.track(asset, true, true); 99 | } 100 | } 101 | 102 | this.staticIcons = Icons; 103 | this.specialSymbols = SpecialSymbols; 104 | this.ready = true; 105 | } catch (e) { 106 | console.error(e); 107 | } finally { 108 | this.ready = true; 109 | this.starting = false; 110 | } 111 | 112 | this.starting = false; 113 | } 114 | 115 | async getAllAssets(includeFiat = true) { 116 | await this._init(); 117 | const out = []; 118 | const sideswapAsset = await this.sideSwap.getAllAssets(); 119 | for (const k in sideswapAsset) { 120 | out.push({ 121 | id: k, 122 | hash: k, 123 | assetHash: k, 124 | }); 125 | } 126 | if (includeFiat) { 127 | const fiatAssets = await this._getFiatData(); 128 | for (const k in fiatAssets.data) { 129 | out.push({ 130 | hash: k, 131 | assetHash: k, 132 | id: k, 133 | }); 134 | } 135 | } 136 | return out; 137 | } 138 | 139 | async getTrackedAssets(includeFiat = true) { 140 | await this._init(); 141 | 142 | const out = []; 143 | const tracked = [this.baseAssetId, ...this.trackedAssets]; 144 | 145 | if (includeFiat) tracked.push(...this.trackedFiatAssets); 146 | 147 | for (const asset of tracked) { 148 | const d = {}; 149 | // d.price=this.getPrice(1,asset,indexCurrency); 150 | // d.info=this.getAssetInfo(asset); 151 | // d.icon=this.getAssetIcon(asset); 152 | // d.getValue = (currencyHash, floatingPoint = true) => { 153 | // return this.assetProvider.getPrice(assetData.value, asset, currencyHash, floatingPoint); 154 | // }; 155 | d.id = asset; 156 | d.hash = asset; 157 | d.assetHash = asset; 158 | out.push(d); 159 | } 160 | return out; 161 | } 162 | 163 | async getAssetIcon(assetId) { 164 | await this._init(); 165 | let icon = this.staticIcons[assetId]; 166 | if (!icon) { 167 | icon = await this.cache.get( 168 | "icon:" + assetId, 169 | true, 170 | async () => { 171 | const assets = await this.sideSwap.getAllAssets(); 172 | const asset = assets[assetId]; 173 | if (!asset) return undefined; 174 | const iconB64 = asset.icon; 175 | const iconArrayBuffer = Uint8Array.from(atob(iconB64), (c) => c.charCodeAt(0)); 176 | const iconBlob = new Blob([iconArrayBuffer], { type: "image/png" }); 177 | return [iconBlob, 0]; 178 | }, 179 | true, 180 | ); 181 | } 182 | console.log("Not found icon,try static", this.staticIcons); 183 | if (!icon) { 184 | icon = this.staticIcons["unknown"]; 185 | } 186 | return icon; 187 | } 188 | 189 | async _getAssetPrice(assetHash) { 190 | let price = await this.cache.get("p:" + assetHash); 191 | if (!price) { 192 | price = await this.sideSwap.getAssetPrice(assetHash); 193 | await this.cache.set("p:" + assetHash, price); 194 | } 195 | return price; 196 | } 197 | 198 | async _saveTrackedAssets() { 199 | await this.store.lock("trackedAssets"); 200 | try { 201 | await this.store.set("trackedAssets", this.trackedAssets); 202 | await this.store.set("trackedFiatAssets", this.trackedFiatAssets); 203 | } finally { 204 | await this.store.unlock("trackedAssets"); 205 | } 206 | } 207 | 208 | async track(assetHash, noInit = false, noSave = false) { 209 | if (!noInit) await this._init(); 210 | if (assetHash === this.baseAssetId) return; 211 | console.log("Track", assetHash); 212 | 213 | if (this._isFiat(assetHash)) { 214 | if (this.trackedFiatAssets.indexOf(assetHash) < 0) { 215 | this.trackedFiatAssets.push(assetHash); 216 | if (!noSave) await this._saveTrackedAssets(); 217 | } 218 | return; 219 | } 220 | 221 | if (this.trackedAssets.indexOf(assetHash) >= 0) return; 222 | 223 | this.trackedAssets.push(assetHash); 224 | if (!noSave) await this._saveTrackedAssets(); 225 | 226 | let first = true; 227 | return new Promise((res, rej) => { 228 | const trackerCallback = async (price, baseAssetId) => { 229 | await this.cache.set("p:" + assetHash, price); 230 | if (first) { 231 | res(price); 232 | first = false; 233 | } 234 | }; 235 | if (!this.trackerCallbacks) this.trackerCallbacks = {}; 236 | this.trackerCallbacks[assetHash] = trackerCallback; 237 | this.sideSwap.subscribeToAssetPriceUpdate(assetHash, trackerCallback); 238 | }); 239 | } 240 | 241 | async untrack(assetHash, noSave = false) { 242 | if (assetHash === this.baseAssetId) return; 243 | 244 | if (this._isFiat(assetHash)) return; 245 | const index = this.trackedAssets.indexOf(assetHash); 246 | if (index < 0) return; 247 | this.trackedAssets.splice(index, 1); 248 | if (!noSave) await this._saveTrackedAssets(); 249 | const trackerCallback = this.trackerCallbacks[assetHash]; 250 | if (trackerCallback) { 251 | this.sideSwap.unsubscribeFromAssetPriceUpdate(assetHash, trackerCallback); 252 | delete this.trackerCallbacks[assetHash]; 253 | } 254 | } 255 | 256 | async intToFloat(amount, assetHash) { 257 | if (typeof assetHash !== "string") throw new Error("Invalid asset hash " + assetHash); 258 | await this._init(); 259 | const info = await this.getAssetInfo(assetHash); 260 | const precision = info.precision; 261 | let price = amount; 262 | price = amount / 10 ** precision; 263 | return price; 264 | } 265 | 266 | async floatToInt(amount, assetHash) { 267 | if (typeof assetHash !== "string") throw new Error("Invalid asset hash " + assetHash); 268 | await this._init(); 269 | const info = await this.getAssetInfo(assetHash); 270 | const precision = info.precision; 271 | let price = amount; 272 | price = Math.floor(amount * 10 ** precision); 273 | return price; 274 | } 275 | 276 | async floatToStringValue(v, assetHash, useSpecialSymbols = false) { 277 | const info = await this.getAssetInfo(assetHash); 278 | let symbol = info.ticker; 279 | const precision = info.precision; 280 | let symbolBeforeValue = false; 281 | 282 | if (useSpecialSymbols && this.specialSymbols[symbol]) { 283 | symbolBeforeValue = true; 284 | symbol = this.specialSymbols[symbol]; 285 | } 286 | 287 | const isFiat = this._isFiat(assetHash); 288 | // if isFiat keep only 2 decimal, otherwise keep 6 289 | let clippedV = v; 290 | if (isFiat) { 291 | clippedV = clippedV.toFixed(2); 292 | } else { 293 | clippedV = clippedV.toFixed(6); 294 | } 295 | 296 | if (Number(clippedV) == 0 && Number(v) != 0) { 297 | clippedV = "0.000001"; 298 | clippedV = "< " + clippedV; 299 | } 300 | v = clippedV; 301 | 302 | // v = Number(v) + ""; 303 | // let decs; 304 | // [v, decs] = v.split("."); 305 | // if (!decs || decs.length < 2) decs = "00"; 306 | // v = v + "." + decs; 307 | if (symbol) { 308 | v = symbolBeforeValue ? symbol + " " + v : v + " " + symbol; 309 | } 310 | return v; 311 | } 312 | 313 | /* 314 | * @param {Number} amount - amount of asset 315 | * @param {String} asset - asset hash 316 | * @param {String} targetAsset - asset hash 317 | * @param {Boolean} floatingPoint - if true, returns floating point number, otherwise integer 318 | * @param {Boolean} asString - if true, returns string, otherwise number 319 | * @returns {Number|String} price 320 | * */ 321 | async getPrice(amount, asset, targetAsset) { 322 | if (typeof asset !== "string") throw new Error("Invalid asset hash " + asset); 323 | if (!targetAsset) targetAsset = this.baseAssetId; 324 | 325 | if (typeof targetAsset !== "string") throw new Error("Invalid targetAsset hash"); 326 | 327 | await this._init(); 328 | 329 | if (asset === targetAsset) return amount; 330 | 331 | const priceOf = async (asset) => { 332 | if (asset === this.baseAssetId) { 333 | return 1 * 10 ** this.basePrecision; 334 | } 335 | let fl; 336 | if (this._isFiat(asset)) { 337 | fl = 1 / (await this._getFiatPrice(asset)); 338 | } else { 339 | fl = 1 / (await this._getAssetPrice(asset)); 340 | } 341 | if (fl < 0 || fl == Infinity || fl == NaN) return 0; 342 | return fl * 10 ** this.basePrecision; 343 | }; 344 | 345 | await this.track(asset); 346 | await this.track(targetAsset); 347 | 348 | const price1 = await this.intToFloat(await priceOf(asset), this.baseAssetId); 349 | const price2 = await this.intToFloat(await priceOf(targetAsset), this.baseAssetId); 350 | 351 | const cnvRate = price1 / price2; 352 | 353 | amount = await this.intToFloat(amount, asset); 354 | const converted = amount * cnvRate; 355 | console.log( 356 | "CONVERSION ", 357 | amount + " " + asset + " = " + converted + " " + targetAsset, 358 | "Conversion rate " + cnvRate, 359 | "Price of " + asset + " " + price1, 360 | "Price of " + targetAsset + " " + price2, 361 | ); 362 | 363 | const p = await this.floatToInt(converted, targetAsset); 364 | if (isNaN(p) || p == Infinity || p < 0) { 365 | return 0; 366 | } 367 | return p; 368 | } 369 | 370 | async getAssetInfo(assetId) { 371 | await this._init(); 372 | if (assetId === this.baseAssetId) { 373 | return { 374 | precision: this.basePrecision, 375 | ticker: this.baseTicker, 376 | name: this.baseName, 377 | hash: this.baseAssetId, 378 | }; 379 | } 380 | if (this._isFiat(assetId)) { 381 | return { 382 | precision: 2, 383 | ticker: assetId, 384 | name: assetId, 385 | hash: assetId, 386 | }; 387 | } 388 | let info = await this.cache.get("as:" + assetId); 389 | if (!info) { 390 | const response = await this.esplora.getAssetInfo(assetId); 391 | const precision = response.precision || 0; 392 | const ticker = response.ticker || "???"; 393 | const name = response.name || "???"; 394 | info = { precision, ticker, name, hash: assetId }; 395 | 396 | await this.cache.set("as:" + assetId, info); 397 | } 398 | return info; 399 | } 400 | } 401 | -------------------------------------------------------------------------------- /src/js/ui/stages/SendStage.js: -------------------------------------------------------------------------------- 1 | import Html from "../Html.js"; 2 | import UIStage from "../UIStage.js"; 3 | import Constants from "../../Constants.js"; 4 | import jsQR from "jsqr-es6"; 5 | 6 | import { 7 | $vlist, 8 | $hlist, 9 | $text, 10 | $title, 11 | $list, 12 | $vsep, 13 | $hsep, 14 | $img, 15 | $icon, 16 | $button, 17 | $newPopup, 18 | $inputText, 19 | $inputNumber, 20 | $inputSelect, 21 | $inputSlide, 22 | } from "../Html.js"; 23 | 24 | export default class SendStage extends UIStage { 25 | constructor() { 26 | super("send"); 27 | } 28 | async renderSend(walletEl, lq, ui) { 29 | walletEl.resetState(); 30 | 31 | const network = await lq.getNetworkName(); 32 | const store = await ui.storage(); 33 | const primaryCurrency = (await store.get(`primaryCurrency${network}`)) || lq.getBaseAsset(); 34 | const secondaryCurrency = (await store.get(`secondaryCurrency${network}`)) || "USD"; 35 | 36 | let ASSET_HASH = primaryCurrency; 37 | let ASSET_INFO = await lq.assets().getAssetInfo(primaryCurrency); 38 | let INPUT_AMOUNT = 0; 39 | let PRIORITY = 0.5; 40 | 41 | let SECONDARY_CURRENCY = secondaryCurrency; 42 | let SECONDARY_INFO = await lq.assets().getAssetInfo(secondaryCurrency); 43 | 44 | const DUMMY_ADDR = Constants.DUMMY_OUT_ADDRESS[network]; 45 | let TO_ADDR = DUMMY_ADDR; 46 | let FEE = 0; 47 | 48 | const parentCnt = $vlist(walletEl).fill().makeScrollable(); 49 | 50 | const assetInputEl = $inputSelect(parentCnt, "Select Asset"); 51 | $text(parentCnt, ["warning"]).setValue( 52 | ` 53 | 54 | Please ensure that the receiver address is on the ${await lq.getNetworkName()} network. 55 | 56 | `, 57 | true, 58 | ); 59 | 60 | const midCnt = $list(parentCnt, ["p$v", "l$h"]).fill().setAlign("top"); 61 | const midleftCnt = $vlist(midCnt, []).makeScrollable().fill(); 62 | const midrightCnt = $vlist(midCnt, []).makeScrollable().fill(); 63 | 64 | const addrEl = $inputText(midrightCnt).setPlaceHolder("Address"); 65 | $text(addrEl, ["sub"]).setValue("To:").setPriority(-1); 66 | 67 | $icon(addrEl) 68 | .setValue("qr_code_scanner") 69 | .setAction(async () => { 70 | const qrScanViewer = $newPopup(walletEl, "Scan QR Code", [], "qrScan"); 71 | const mediaContainer = $vlist(qrScanViewer).fill(); 72 | let videoEl = mediaContainer.querySelector("video"); 73 | if (!videoEl) { 74 | videoEl = document.createElement("video"); 75 | mediaContainer.addItem(videoEl); 76 | } 77 | 78 | const constraints = { 79 | video: { 80 | facingMode: "environment", 81 | }, 82 | }; 83 | const stream = await navigator.mediaDevices.getUserMedia(constraints); 84 | videoEl.srcObject = stream; 85 | videoEl.setAttribute("playsinline", true); 86 | videoEl.play(); 87 | 88 | $button(qrScanViewer, []) 89 | .setValue("Close") 90 | .setAction(() => { 91 | stream.getTracks().forEach((track) => track.stop()); 92 | videoEl.srcObject = null; 93 | qrScanViewer.hide(); 94 | }); 95 | 96 | const canvasEl = document.createElement("canvas"); 97 | canvasEl.width = videoEl.videoWidth; 98 | canvasEl.height = videoEl.videoHeight; 99 | const ctx = canvasEl.getContext("2d"); 100 | 101 | requestAnimationFrame(tick); 102 | 103 | async function tick() { 104 | if (videoEl.readyState === videoEl.HAVE_ENOUGH_DATA) { 105 | canvasEl.height = videoEl.videoHeight; 106 | canvasEl.width = videoEl.videoWidth; 107 | ctx.drawImage(videoEl, 0, 0, canvasEl.width, canvasEl.height); 108 | const imageData = ctx.getImageData(0, 0, canvasEl.width, canvasEl.height); 109 | const code = jsQR(imageData.data, imageData.width, imageData.height); 110 | if (code) { 111 | console.log("Found QR code", code.data); 112 | let addr = code.data.trim(); 113 | if ( 114 | !addr.startsWith(Constants.PAYURL_PREFIX[network]) && 115 | !(await lq.verifyAddress(addr)) 116 | ) { 117 | addr = undefined; 118 | } 119 | if (addr) { 120 | addrEl.setValue(addr); 121 | stream.getTracks().forEach((track) => track.stop()); 122 | videoEl.srcObject = null; 123 | qrScanViewer.hide(); 124 | return; 125 | } 126 | } 127 | } 128 | 129 | requestAnimationFrame(tick); 130 | } 131 | qrScanViewer.show(); 132 | }); 133 | 134 | $icon(addrEl) 135 | .setValue("content_paste") 136 | .setAction(async () => { 137 | const text = await navigator.clipboard.readText(); 138 | addrEl.setValue(text); 139 | }); 140 | 141 | const amountNativeEl = $inputNumber(midleftCnt).setPlaceHolder("0.00"); 142 | $text(amountNativeEl, ["sub"]).setValue("Amount:").setPriority(-1); 143 | 144 | const ticker1El = $text(amountNativeEl); 145 | 146 | const amountSecondaryEl = $inputNumber(midleftCnt).setPlaceHolder("0.00"); 147 | $text(amountSecondaryEl, ["sub"]).setValue("Amount:").setPriority(-1); 148 | const ticker2El = $text(amountSecondaryEl); 149 | 150 | const availableBalanceDataEl = $vlist(midleftCnt, ["sub"]).fill(); 151 | const availableBalanceLabelRowEl = $hlist(availableBalanceDataEl, ["sub"]); 152 | $hsep(availableBalanceLabelRowEl).grow(100); 153 | 154 | const availableBalanceTextEl = $text(availableBalanceLabelRowEl).setValue("Available balance: "); 155 | 156 | const availableBalanceEl = $hlist(availableBalanceDataEl, ["sub"]); 157 | $hsep(availableBalanceEl).grow(100); 158 | const availableBalanceValueEl = $text(availableBalanceEl); 159 | 160 | const useAllEl = $button(availableBalanceEl, ["small"]).setValue("SEND ALL"); 161 | 162 | const prioritySlideEl = $inputSlide(midrightCnt); 163 | prioritySlideEl.setLabel(0, "Low fee (slow)"); 164 | prioritySlideEl.setLabel(0.5, "Medium fee"); 165 | prioritySlideEl.setLabel(1, "High fee (fast)"); 166 | const feeDataEl = $vlist(midrightCnt, []).fill(); 167 | 168 | const feeRowEl = $hlist(feeDataEl, ["sub"]).setAlign("center-right"); 169 | $hsep(feeRowEl).grow(100); 170 | $text(feeRowEl).setValue("Fee: "); 171 | const feeValueEl = $text(feeRowEl); 172 | $hsep(feeRowEl).setValue("/"); 173 | const feeValueSecondaryEl = $text(feeRowEl); 174 | const timeRowEl = $hlist(feeDataEl, ["sub"]); 175 | $hsep(timeRowEl).grow(100); 176 | $text(timeRowEl).setValue("Confirmation time: ~"); 177 | const timeValueEl = $text(timeRowEl).setValue("10"); 178 | $hsep(timeRowEl).setValue("minutes"); 179 | 180 | const bottomCnt = $vlist(parentCnt, ["bottom"]).fill(); 181 | 182 | const errorRowEl = $vlist(bottomCnt, ["error"]); 183 | errorRowEl.hide(); 184 | 185 | const confirmBtnEl = Html.$button(bottomCnt, []).setValue("Confirm and sign"); 186 | 187 | const loading = (v) => { 188 | if (!v) { 189 | confirmBtnEl.enable(); 190 | confirmBtnEl.setValue("Confirm and sign"); 191 | } else { 192 | confirmBtnEl.disable(); 193 | confirmBtnEl.setValue(v); 194 | } 195 | }; 196 | 197 | const _updateInvoice = async (signAndSend) => { 198 | loading(!signAndSend ? "Loading..." : "Preparing transaction..."); 199 | 200 | errorRowEl.hide(); 201 | const balance = await lq.getBalance((hash) => { 202 | return hash === ASSET_HASH; 203 | }); 204 | for (const asset of balance) { 205 | lq.v(asset.value, asset.asset) 206 | .human() 207 | .then((value) => { 208 | availableBalanceValueEl.setValue(value); 209 | }); 210 | 211 | let sendAllValue = 212 | asset.asset === lq.getBaseAsset() ? asset.value - (FEE ? FEE * 2 : 0) : asset.value; 213 | lq.v(sendAllValue, asset.asset) 214 | .float(ASSET_HASH) 215 | .then((value) => { 216 | useAllEl.setAction(() => { 217 | amountNativeEl.setValue(value); 218 | }); 219 | }); 220 | } 221 | ticker1El.setValue(ASSET_INFO.ticker); 222 | ticker2El.setValue(SECONDARY_INFO.ticker); 223 | availableBalanceTextEl.setValue("Available balance (" + ASSET_INFO.ticker + "): "); 224 | const isSimulation = !signAndSend || TO_ADDR == DUMMY_ADDR; 225 | 226 | try { 227 | const feeRate = await lq.estimateFeeRate(PRIORITY); 228 | const tx = await lq.prepareTransaction( 229 | INPUT_AMOUNT, 230 | ASSET_HASH, 231 | TO_ADDR, 232 | feeRate.feeRate, 233 | 2000, 234 | isSimulation, 235 | ); 236 | FEE = tx.fee; 237 | errorRowEl.hide(); 238 | feeValueEl.setValue(await lq.v(tx.fee, lq.getBaseAsset()).human()); 239 | feeValueSecondaryEl.setValue(await lq.v(tx.fee, lq.getBaseAsset()).human(SECONDARY_CURRENCY)); 240 | 241 | const time = Constants.BLOCK_TIME[network] * feeRate.blocks; 242 | timeValueEl.setValue(time / 1000 / 60); 243 | 244 | if (signAndSend) { 245 | if (TO_ADDR != DUMMY_ADDR) { 246 | console.log("Verify"); 247 | loading("Verifying..."); 248 | await tx.verify(); 249 | console.log("Signing..."); 250 | console.log("Broadcast"); 251 | const txid = await tx.broadcast(); 252 | if (!txid) throw new Error("Transaction not broadcasted"); 253 | const sendOkPopupEl = $newPopup( 254 | walletEl, 255 | "Transaction broadcasted", 256 | ["sendOK"], 257 | "sendOK", 258 | ); 259 | $icon(sendOkPopupEl, ["sendok", "icon"]).setValue("done"); 260 | setTimeout(() => { 261 | sendOkPopupEl.hide(); 262 | ui.setStage("wallet"); 263 | }, 4000); 264 | sendOkPopupEl.show(); 265 | } else { 266 | loading(false); 267 | errorRowEl.show(); 268 | errorRowEl.setValue("Please enter a valid address"); 269 | } 270 | } else { 271 | loading(false); 272 | } 273 | } catch (e) { 274 | loading(false); 275 | console.log(e); 276 | errorRowEl.show(); 277 | errorRowEl.setValue(e.message); 278 | } 279 | }; 280 | 281 | prioritySlideEl.setAction((v) => { 282 | PRIORITY = Math.max(v, 0.001); 283 | _updateInvoice(); 284 | }); 285 | 286 | prioritySlideEl.setValue(PRIORITY, true); 287 | 288 | confirmBtnEl.setAction(async () => { 289 | await _updateInvoice(true); 290 | }); 291 | 292 | lq.getPinnedAssets().then((assets) => { 293 | for (const asset of assets) { 294 | lq.assets() 295 | .getAssetInfo(asset.hash) 296 | .then(async (info) => { 297 | const optionEl = assetInputEl.addOption(asset.hash, info.ticker, (value) => { 298 | amountNativeEl.setValue(0); 299 | amountSecondaryEl.setValue(0); 300 | ASSET_HASH = value; 301 | ASSET_INFO = info; 302 | _updateInvoice(); 303 | }); 304 | lq.assets() 305 | .getAssetIcon(asset.hash) 306 | .then((icon) => { 307 | optionEl.setIconSrc(icon); 308 | }); 309 | }); 310 | } 311 | }); 312 | 313 | addrEl.setAction(async (addr) => { 314 | if (!addr) { 315 | TO_ADDR = DUMMY_ADDR; 316 | return; 317 | } 318 | const query = {}; 319 | 320 | if (addr.startsWith("liquidnetwork:")) { 321 | addr = addr.slice("liquidnetwork:".length); 322 | const queryStart = addr.indexOf("?"); 323 | if (queryStart >= 0) { 324 | const queryStr = addr.slice(queryStart + 1); 325 | addr = addr.slice(0, queryStart); 326 | const queryParts = queryStr.split("&"); 327 | for (const queryPart of queryParts) { 328 | const [key, value] = queryPart.split("="); 329 | query[key] = value; 330 | } 331 | } 332 | } 333 | 334 | if (query.amount) { 335 | amountNativeEl.setValue(query.amount); 336 | } 337 | 338 | if (query.assetid) { 339 | assetInputEl.selectOption(query.assetid); 340 | } 341 | 342 | TO_ADDR = addr; 343 | _updateInvoice(); 344 | }); 345 | 346 | amountNativeEl.setAction(async (primaryValue) => { 347 | if (!primaryValue) primaryValue = 0; 348 | const primaryValueInt = await lq.v(primaryValue, ASSET_HASH).int(ASSET_HASH); 349 | INPUT_AMOUNT = primaryValueInt; 350 | 351 | const secondaryValueFloat = await lq.v(primaryValueInt, ASSET_HASH).float(SECONDARY_CURRENCY); 352 | amountSecondaryEl.setValue(secondaryValueFloat, true); 353 | 354 | _updateInvoice(); 355 | }); 356 | 357 | amountSecondaryEl.setAction(async (secondaryValue) => { 358 | if (!secondaryValue) secondaryValue = 0; 359 | const secondaryValueInt = await lq.v(secondaryValue, SECONDARY_CURRENCY).int(SECONDARY_CURRENCY); 360 | const primaryValueFloat = await lq.v(secondaryValueInt, SECONDARY_CURRENCY).float(ASSET_HASH); 361 | 362 | amountNativeEl.setValue(primaryValueFloat, true); 363 | 364 | const primaryValueInt = await lq.v(primaryValueFloat, ASSET_HASH).int(ASSET_HASH); 365 | INPUT_AMOUNT = primaryValueInt; 366 | 367 | _updateInvoice(); 368 | }); 369 | } 370 | 371 | onReload(walletEl, lq, ui) { 372 | this.renderSend(walletEl, lq, ui); 373 | } 374 | } 375 | -------------------------------------------------------------------------------- /src/js/ui/stages/WalletStage.js: -------------------------------------------------------------------------------- 1 | import Html from "../Html.js"; 2 | import UIStage from "../UIStage.js"; 3 | import LinkOpener from "../../utils/LinkOpener.js"; 4 | import Constants from "../../Constants.js"; 5 | import { 6 | $vlist, 7 | $hlist, 8 | $text, 9 | $title, 10 | $list, 11 | $vsep, 12 | $hsep, 13 | $img, 14 | $icon, 15 | $button, 16 | $inputText, 17 | $inputNumber, 18 | $inputSelect, 19 | $inputSlide, 20 | } from "../Html.js"; 21 | 22 | export default class WalletPage extends UIStage { 23 | constructor() { 24 | super("wallet"); 25 | } 26 | 27 | async renderAssets(parentEl, lq, filter, ui) { 28 | const network = await lq.getNetworkName(); 29 | const store = await ui.storage(); 30 | const primaryCurrency = (await store.get(`primaryCurrency${network}`)) || lq.getBaseAsset(); 31 | const secondaryCurrency = (await store.get(`secondaryCurrency${network}`)) || "USD"; 32 | 33 | const assetsEl = $list(parentEl, ["highlight", "p$h", "l$v"], "assets").makeScrollable(true, true); 34 | assetsEl.setPriority(-10); 35 | assetsEl.initUpdate(); 36 | 37 | const balance = await lq.getBalance(); 38 | let i = 0; 39 | await Promise.all( 40 | balance.map((balance) => { 41 | const id = "asset" + balance.asset.substr(0, 8) + balance.asset.substr(-8); 42 | 43 | const assetEl = $list(assetsEl, ["asset", "l$h", "p$v", "p$center", "l$right"], id); 44 | const assetC0El = $vlist(assetEl, []); 45 | const assetC1El = $vlist(assetEl, []); 46 | const assetC2El = $vlist(assetEl, ["l$right", "p$center"]); 47 | 48 | assetEl.style.setProperty("--anim-delta", i + "s"); 49 | i += 0.1; 50 | 51 | const iconEl = $icon(assetC0El, ["big"]); 52 | 53 | const tickerEl = $text(assetC1El, ["title", "ticker"]); 54 | const nameEl = $text(assetC1El, ["sub", "small", "name"]); 55 | 56 | const balanceEl = $text(assetC2El, ["balance"]); 57 | const balanceAltCntEl = $hlist(assetC2El, ["balanceAltCnt", "left"]); 58 | const balancePrimaryEl = $text(balanceAltCntEl, ["sub"]); 59 | $text(balanceAltCntEl).setValue("/"); 60 | const balanceSecondaryEl = $text(balanceAltCntEl, ["sub"]); 61 | 62 | lq.v(balance.value, balance.asset) 63 | .human(balance.asset, false) 64 | .then((value) => { 65 | balanceEl.setValue(value); 66 | }); 67 | 68 | lq.v(balance.value, balance.asset) 69 | .cint(lq.getBaseAsset()) 70 | .then((value) => { 71 | assetEl.setPriority(-value); 72 | }); 73 | 74 | lq.v(balance.value, balance.asset) 75 | .human(primaryCurrency) 76 | .then((price) => { 77 | if (price) { 78 | balancePrimaryEl.setValue(price); 79 | } else { 80 | assetEl.setPriority(0); 81 | balancePrimaryEl.setValue("-"); 82 | } 83 | }); 84 | 85 | lq.v(balance.value, balance.asset) 86 | .human(secondaryCurrency) 87 | .then((price) => { 88 | if (price) { 89 | balanceSecondaryEl.setValue(price); 90 | } else { 91 | balanceSecondaryEl.setValue("-"); 92 | } 93 | }); 94 | 95 | const loadInfoPromise = lq 96 | .assets() 97 | .getAssetInfo(balance.asset) 98 | .then((info) => { 99 | try { 100 | if (filter) { 101 | if ( 102 | !filter(balance.hash, true) && 103 | !filter(info.ticker) && 104 | !filter(info.name) 105 | ) { 106 | assetEl.remove(); 107 | return; 108 | } 109 | } 110 | tickerEl.setValue(info.ticker); 111 | nameEl.setValue(info.name); 112 | } catch (e) { 113 | console.log(e); 114 | } 115 | }); 116 | 117 | const loadIconPromise = lq 118 | .assets() 119 | .getAssetIcon(balance.asset) 120 | .then((icon) => { 121 | try { 122 | iconEl.setSrc(icon); 123 | assetEl.setCover(icon); 124 | } catch (e) { 125 | console.log(e); 126 | } 127 | }); 128 | return Promise.all([loadInfoPromise, loadIconPromise]); 129 | }), 130 | ); 131 | 132 | assetsEl.commitUpdate(); 133 | } 134 | 135 | async renderHistoryPanel(parentEl, lq, filter, ui, forceRefresh = false, limit = 100, page = 0) { 136 | filter = this.filter; 137 | const historyEl = $vlist(parentEl, ["highlight"], "history").makeScrollable(true, true).fill(); 138 | 139 | const history = await lq.getHistory(); //.slice(page * limit, page * limit + limit); TODO: pagination 140 | 141 | historyEl.initUpdate(); 142 | let animDelta = 0; 143 | for (const tx of history) { 144 | const id = "history" + (tx.tx_hash.substr(0, 8) + tx.tx_hash.substr(-8)); 145 | const txElCnt = $hlist(historyEl, ["left", "tx"], id); 146 | if (!forceRefresh && txElCnt.confirmed) continue; // never attempt to update confirmed txs 147 | 148 | if (Constants.EXT_TX_VIEWER) { 149 | const extViewer = Constants.EXT_TX_VIEWER[lq.getNetworkName()]; 150 | if (extViewer) { 151 | txElCnt.setAction(() => { 152 | LinkOpener.navigate(extViewer.replace("${tx_hash}", tx.tx_hash)); 153 | }); 154 | } 155 | } 156 | 157 | txElCnt.style.setProperty("--anim-delta", animDelta + "s"); 158 | // animDelta += 0.2; 159 | 160 | const txDirectionEl = $icon(txElCnt, ["big", "txdirection"]); 161 | const txAssetIconEl = $icon(txDirectionEl, ["txasset"]); 162 | 163 | const txSymbolEl = $text(txElCnt, ["txsymbol"]); 164 | 165 | const statusTxHashCntEl = $hlist(txElCnt, ["txstatushash", "sub"]); 166 | const txHashEl = $text(statusTxHashCntEl, ["txhash", "toolong"]); 167 | const txStatusEl = $icon(statusTxHashCntEl, ["txstatus"]); 168 | const blockTimeEl = $text(txElCnt, ["txblocktime", "sub"]); 169 | const txAmountEl = $text(txElCnt, ["txAmount"]); 170 | 171 | if (tx.confirmed) { 172 | txStatusEl.setValue("done"); 173 | txStatusEl.classList.remove("loading"); 174 | txElCnt.classList.add("confirmed"); 175 | txElCnt.confirmed = true; 176 | } else { 177 | txStatusEl.setValue("cached"); 178 | txStatusEl.classList.add("loading"); 179 | txElCnt.classList.remove("confirmed"); 180 | txElCnt.confirmed = false; 181 | } 182 | 183 | requestAnimationFrame(async () => { 184 | let txElCntWidth; 185 | // for (let i = 0; i < 100; i++) { 186 | txElCntWidth = historyEl.getBoundingClientRect().width; 187 | // if (txElCntWidth > 10) break; 188 | // await new Promise((r) => setTimeout(r, 100)); 189 | // } 190 | 191 | txHashEl.setValue( 192 | tx.tx_hash.substring( 193 | 0, 194 | Math.floor(txElCntWidth / parseFloat(getComputedStyle(txHashEl).fontSize) / 2), 195 | ) + "...", 196 | ); 197 | }); 198 | 199 | lq.getTransaction(tx.tx_hash).then((txData) => { 200 | blockTimeEl.setValue(new Date(txData.timestamp).toLocaleString()); 201 | txElCnt.setPriority(Math.floor(-(txData.timestamp / 1000))); 202 | 203 | if (!txData.info.valid) { 204 | txDirectionEl.setValue("receipt_log"); 205 | txElCnt.hide(); 206 | } else { 207 | if (txData.info.isIncoming) { 208 | txElCnt.classList.add("incoming"); 209 | txDirectionEl.setValue("arrow_downward"); 210 | txDirectionEl.classList.add("incoming"); 211 | } else { 212 | txElCnt.classList.add("outgoing"); 213 | txDirectionEl.setValue("arrow_upward"); 214 | txDirectionEl.classList.add("outgoing"); 215 | } 216 | lq.assets() 217 | .getAssetIcon(txData.info.outAsset) 218 | .then((icon) => { 219 | txAssetIconEl.setSrc(icon); 220 | }); 221 | lq.assets() 222 | .getAssetInfo(txData.info.outAsset) 223 | .then((info) => { 224 | if (filter) { 225 | if ( 226 | !filter(info.hash, true) && 227 | !filter(info.ticker) && 228 | !filter(info.name) && 229 | !filter(tx.tx_hash, true) 230 | ) { 231 | txElCnt.hide(); 232 | return; 233 | } else { 234 | txElCnt.show(); 235 | } 236 | } else { 237 | } 238 | txSymbolEl.setValue(info.ticker); 239 | }); 240 | lq.v(txData.info.outAmount, txData.info.outAsset) 241 | .human(txData.info.outAsset, false) 242 | .then((value) => { 243 | txAmountEl.setValue(value); 244 | }); 245 | } 246 | }); 247 | } 248 | historyEl.commitUpdate(); 249 | } 250 | 251 | async renderBalance(parentEl, lq, ui) { 252 | const network = await lq.getNetworkName(); 253 | const store = await ui.storage(); 254 | const primaryCurrency = (await store.get(`primaryCurrency${network}`)) || lq.getBaseAsset(); 255 | const secondaryCurrency = (await store.get(`secondaryCurrency${network}`)) || "USD"; 256 | 257 | const balanceSumCntEl = $vlist(parentEl, ["center"], "balanceSumCnt"); 258 | const balanceSumEl = $text(balanceSumCntEl, ["balanceSum", "titleBig", "center"]); 259 | const balanceSumSecondaryEl = $hlist(balanceSumCntEl, ["balanceSumAltCnt", "title", "center"]); 260 | balanceSumCntEl.setPriority(-20); 261 | 262 | let sumPrimary = 0; 263 | let sumSecondary = 0; 264 | 265 | lq.getBalance().then((assets) => { 266 | for (const asset of assets) { 267 | lq.v(asset.value, asset.asset) 268 | .float(primaryCurrency) 269 | .then(async (value) => { 270 | sumPrimary += value; 271 | const v = await lq 272 | .v(await lq.v(sumPrimary, primaryCurrency).int(primaryCurrency), primaryCurrency) 273 | .human(primaryCurrency); 274 | balanceSumEl.setValue(v < 0 || v > Infinity ? "0" : v); 275 | }); 276 | lq.v(asset.value, asset.asset) 277 | .float(secondaryCurrency) 278 | .then(async (value) => { 279 | sumSecondary += Number(value); 280 | const v = await lq 281 | .v( 282 | await lq.v(sumSecondary, secondaryCurrency).int(secondaryCurrency), 283 | secondaryCurrency, 284 | ) 285 | .human(secondaryCurrency); 286 | balanceSumSecondaryEl.setValue(v < 0 || v > Infinity ? "0" : v); 287 | }); 288 | } 289 | }); 290 | } 291 | 292 | async renderSendReceive(parentEl, lq, filter, ui) { 293 | const cntEl = $hlist(parentEl, ["buttons", "highlight"], "sendReceive").setPriority(-15).fill(); 294 | $button(cntEl, []) 295 | .setValue("Receive") 296 | .setIconValue("arrow_downward") 297 | .setAction(() => { 298 | ui.setStage("receive"); 299 | }); 300 | $button(cntEl, []) 301 | .setValue("Send") 302 | .setIconValue("arrow_upward") 303 | .setAction(() => { 304 | ui.setStage("send"); 305 | }); 306 | } 307 | 308 | async renderSearchBar(walletEl, lq, render, ui) { 309 | const searchBarParentEl = $hlist(walletEl, ["searchBar", "highlight"]).setPriority(-10).fill(); 310 | const searchInputEl = $inputText(searchBarParentEl).setPlaceHolder("Search"); 311 | searchInputEl.setAttribute("autocomplete", "off"); 312 | searchInputEl.setAttribute("autocorrect", "off"); 313 | searchInputEl.setAttribute("autocapitalize", "off"); 314 | searchInputEl.setAttribute("spellcheck", "false"); 315 | 316 | const searchIcon = $icon(searchInputEl, ["search"]).setValue("search"); 317 | 318 | searchInputEl.addEventListener("input", () => { 319 | searchIcon.setValue("cached"); 320 | searchIcon.classList.add("loading"); 321 | }); 322 | 323 | searchInputEl.setAction(async (lastValue) => { 324 | let words = ""; 325 | let partial = true; 326 | lastValue = lastValue.trim(); 327 | 328 | if (lastValue[0] === '"' && lastValue[lastValue.length - 1] === '"') { 329 | words = [lastValue.substring(1, lastValue.length - 1).trim()]; 330 | partial = false; 331 | } else { 332 | words = lastValue.split(" ").filter((w) => w.trim().length > 0); 333 | partial = true; 334 | } 335 | 336 | await render((str) => { 337 | str = str.toLowerCase().trim(); 338 | if (words.length === 0) return true; 339 | for (const word of words) { 340 | if (partial) { 341 | if (str.includes(word)) { 342 | return true; 343 | } 344 | } else { 345 | if (str === word) { 346 | return true; 347 | } 348 | } 349 | } 350 | return false; 351 | }); 352 | 353 | // remove loading icon 354 | searchIcon.setValue("search"); 355 | searchIcon.classList.remove("loading"); 356 | // }, 1000); 357 | }); 358 | } 359 | 360 | onReload(walletEl, lq, ui) { 361 | walletEl.resetState(); 362 | const c0El = $vlist(walletEl, []).grow(1).fill(); 363 | const c1El = $vlist(walletEl, []).grow(3).fill(); 364 | const render = (filter) => { 365 | if (filter) this.filter = filter; 366 | this.renderBalance(c0El, lq, ui); 367 | this.renderAssets(c0El, lq, filter, ui); 368 | 369 | this.renderSendReceive(c0El, lq, filter, ui); 370 | this.renderHistoryPanel(c1El, lq, filter, ui, !!filter); 371 | }; 372 | this.renderSearchBar(c1El, lq, render, ui); 373 | render(); 374 | 375 | if (walletEl.historyReloadCallbackTimer) { 376 | clearTimeout(walletEl.historyReloadCallbackTimer); 377 | } 378 | 379 | const historyReloadCallback = async () => { 380 | this.renderHistoryPanel(c1El, lq, undefined, ui); 381 | walletEl.historyReloadCallbackTimer = setTimeout(historyReloadCallback, 10000); 382 | }; 383 | setTimeout(historyReloadCallback, 10000); 384 | } 385 | 386 | onUnload(walletEl, lq, ui) { 387 | if (walletEl.historyReloadCallbackTimer) { 388 | clearTimeout(walletEl.historyReloadCallbackTimer); 389 | } 390 | } 391 | } 392 | -------------------------------------------------------------------------------- /src/assets/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Anser Liquid | Home 5 | 6 | 7 | 8 | 12 | 13 | 17 | 211 | 316 | 317 | 318 | 319 |
320 |
321 |

Anser

322 |

A liquid companion for Alby

323 |
324 |
325 |

326 | Anser, in conjunction with the 327 | Alby browser extension, provides a seamless 328 | and user-friendly interface to the Liquid Network directly from your browser. 329 |

330 | 331 |

Liquid in the browser

332 |

333 | Anser is a progressive web app that can be easily installed on your device, providing a native 334 | app experience. Alternatively, you can utilize it directly within your browser or even 335 | 339 | host your own instance . 341 |

342 |

343 | 344 |
345 |
346 |
347 |
348 | 357 |
358 |
359 |
360 | 361 | 363 |
364 | 365 |

For developers

366 |

367 | Anser isn't just a standalone application, it's also a versatile library that seamlessly 368 | integrates into your webpages, enabling you to harness the power of the Liquid Network within 369 | an Alby-enabled browser. 370 |

371 |

372 | Explore the 373 | documentation 374 | for comprehensive details or experiment with the examples provided below (nb. it is advised to 375 | run the test using testnet). 376 |

377 |
378 | 379 |

397 | See the Pen Anser Send 398 |

399 | 400 | 401 |

419 | See the Pen Anser Send 420 |

421 | 422 | 423 |

441 | See the Pen Anser Show 442 |

443 | 444 |

FAQ

445 |
446 |

Q: Do I need Alby to use Anser?

447 |

Yes, Anser relies on the Alby browser extension for key management and signing.

448 | 449 |

Q: Can you see my private keys, transactions, or balances?

450 |

No, Anser is a fully client-side app; your keys never leave the Alby extension.

451 | 452 |

Q: Does Anser hold or transmit my funds?

453 |

454 | No, Anser serves as an interface that filters and displays data from the Liquid Network. 455 | It enables you to create valid transactions that are signed by the Alby extension and 456 | broadcasted through an Electrum node. 457 |

458 | 459 |

Q: Who manages the Electrum node used by Anser?

460 |

461 | Anser connects to the public websocket endpoint provided by 462 | Blockstream. 465 |
466 | It's important to note that this node only provides access to public data of the Liquid 467 | Network, and broadcast your transactions to the other nodes. It does not hold your keys or 468 | funds and cannot sign or build transactions on your behalf. 469 |

470 | 471 |

Q: Is Anser open source?

472 |

473 | Yes, you can find the source code on 474 | GitHub. 475 |

476 | 477 |

Q: Can I self-host Anser?

478 |

479 | Absolutely! Anser is a static web app that can be hosted on any webserver, including your 480 | local machine. 481 |
482 | Check the 483 | documentation 486 | for more information. 487 |

488 |
489 |
490 | 491 | 506 | Fork me on GitHub 518 | 519 | 520 | --------------------------------------------------------------------------------