├── 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 |
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 | [](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 |
348 |
357 |
358 |
359 |
360 | Launch
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 |
518 |
519 |
520 |
--------------------------------------------------------------------------------