From 87eb9095a259de57ea8e0c14fe2a4b1cd8d470f4 Mon Sep 17 00:00:00 2001 From: Will O'Beirne Date: Wed, 1 May 2019 13:11:18 -0400 Subject: [PATCH 01/11] MVP proof of concept for no-click payments. --- package.json | 1 + src/background_script/handlePassword.ts | 11 +- src/content_script/index.ts | 19 ++- src/content_script/respondWithoutPrompt.ts | 38 ++++- src/content_script/store.ts | 55 +++++++ yarn.lock | 180 ++++++++++++++++++++- 6 files changed, 292 insertions(+), 12 deletions(-) create mode 100644 src/content_script/store.ts diff --git a/package.json b/package.json index 518958e9..1d8ac58f 100755 --- a/package.json +++ b/package.json @@ -75,6 +75,7 @@ "babel-polyfill": "6.26.0", "bip39": "2.5.0", "bn.js": "4.11.8", + "bolt11": "1.2.5", "classnames": "^2.2.6", "clean-webpack-plugin": "^0.1.19", "copy-webpack-plugin": "4.6.0", diff --git a/src/background_script/handlePassword.ts b/src/background_script/handlePassword.ts index ed602fb8..f4550c6b 100644 --- a/src/background_script/handlePassword.ts +++ b/src/background_script/handlePassword.ts @@ -3,7 +3,7 @@ import { browser } from 'webextension-polyfill-ts'; let cachedPassword: string | undefined; export default function handlePassword() { - browser.runtime.onMessage.addListener((request: any) => { + browser.runtime.onMessage.addListener((request, sender) => { if (!request || request.application !== 'Joule') { return; } @@ -20,11 +20,16 @@ export default function handlePassword() { // Send the password cache back to the app if (request.getPassword) { - browser.runtime.sendMessage({ + const msg = { application: 'Joule', cachedPassword: true, data: cachedPassword, - }); + }; + if (sender.tab && sender.tab.id) { + browser.tabs.sendMessage(sender.tab.id, msg); + } else { + browser.runtime.sendMessage(msg); + } } }); } diff --git a/src/content_script/index.ts b/src/content_script/index.ts index b8d2ae9b..963aac1c 100755 --- a/src/content_script/index.ts +++ b/src/content_script/index.ts @@ -53,16 +53,27 @@ if (document) { const lightningLink = target.closest('[href^="lightning:"]'); if (lightningLink) { + ev.preventDefault(); + console.log(lightningLink); const href = lightningLink.getAttribute('href') as string; const paymentRequest = href.replace('lightning:', ''); - browser.runtime.sendMessage({ + const message = { application: 'Joule', prompt: true, type: PROMPT_TYPE.PAYMENT, origin: getOriginData(), args: { paymentRequest }, + }; + respondWithoutPrompt(message).then(didRespond => { + if (didRespond) return; + browser.runtime.sendMessage({ + application: 'Joule', + prompt: true, + type: PROMPT_TYPE.PAYMENT, + origin: getOriginData(), + args: { paymentRequest }, + }); }); - ev.preventDefault(); } }); }); @@ -74,7 +85,9 @@ if (document) { event => { // 2 = right mouse button. may be better to store in a constant if (event.button === 2) { - let paymentRequest = window.getSelection().toString(); + const selection = window.getSelection(); + if (!selection) return; + let paymentRequest = selection.toString(); // if nothing selected, try to get the text of the right-clicked element. if (!paymentRequest && event.target) { // Cast as HTMLInputElement to get the value if a form element is used diff --git a/src/content_script/respondWithoutPrompt.ts b/src/content_script/respondWithoutPrompt.ts index 547a22fa..795911e9 100644 --- a/src/content_script/respondWithoutPrompt.ts +++ b/src/content_script/respondWithoutPrompt.ts @@ -1,11 +1,15 @@ -import runSelector from './runSelector'; +import bolt11 from 'bolt11'; +import { runSelector, runAction } from './store'; import { PROMPT_TYPE } from '../webln/types'; import { selectSettings } from 'modules/settings/selectors'; +import { sendPayment } from 'modules/payment/actions'; export default async function respondWithoutPrompt(data: any): Promise { switch (data.type) { case PROMPT_TYPE.AUTHORIZE: return handleAuthorizePrompt(data); + case PROMPT_TYPE.PAYMENT: + return handleAutoPayment(data); } return false; @@ -13,7 +17,7 @@ export default async function respondWithoutPrompt(data: any): Promise async function handleAuthorizePrompt(data: any) { const { domain } = data.origin; - const settings = await runSelector(selectSettings, 'settings', 'settings'); + const settings = await runSelector(selectSettings); if (domain) { if (settings.enabledDomains.includes(domain)) { @@ -28,6 +32,36 @@ async function handleAuthorizePrompt(data: any) { return false; } +async function handleAutoPayment(data: any) { + return false; + // Pop up for non-fixed invoices + const decoded = bolt11.decode(data.args.paymentRequest); + if (!decoded.satoshis) { + return false; + } + + // Attempt to send the payment + const state = await runAction( + sendPayment({ + payment_request: data.args.paymentRequest, + fee_limit: { + fixed: '10', + }, + }), + s => + !!s.payment.sendError || + !!s.payment.sendLightningReceipt || + !!s.crypto.isRequestingPassword, + ); + + // If it failed for any reason or we need their pw, we'll just open the prompt + if (state.payment.sendError || state.crypto.isRequestingPassword) { + return false; + } else { + return true; + } +} + function postDataMessage(data: any) { window.postMessage( { diff --git a/src/content_script/store.ts b/src/content_script/store.ts new file mode 100644 index 00000000..68bfc13f --- /dev/null +++ b/src/content_script/store.ts @@ -0,0 +1,55 @@ +import { Store } from 'redux'; +import { AppState } from 'store/reducers'; +import { configureStore } from 'store/configure'; + +// Get or initialize the store. Pass true to get a fresh store. +let store: Store; +export function getStore(fresh?: boolean) { + if (!store || fresh) { + store = configureStore().store; + } + return store; +} + +// Run a selector, but ensure the store has fully synced first +type Selector = (s: AppState) => T; +export async function runSelector(selector: Selector): Promise { + const state = await waitForStoreState(s => s.sync.hasSynced); + return selector(state); +} + +// Returns a promise that only resolves once store state has hit a certain condition +type WaitStateCheckFunction = (s: AppState) => boolean; +export async function waitForStoreState( + check: WaitStateCheckFunction, + fresh?: boolean, +): Promise { + return new Promise(resolve => { + const s = getStore(fresh); + const initState = s.getState(); + if (check(initState)) { + resolve(initState); + } else { + const unsub = s.subscribe(() => { + const state = s.getState(); + if (check(state)) { + unsub(); + resolve(state); + } + }); + } + }); +} + +// Returns a promise that resolves once an action has run and the state has hit +// certain conditions. +export async function runAction( + action: any, + check: WaitStateCheckFunction, + fresh?: boolean, +): Promise { + const s = getStore(fresh); + await waitForStoreState(state => state.sync.hasSynced); + s.dispatch(action); + return waitForStoreState(check); +} diff --git a/yarn.lock b/yarn.lock index 7b5ca2a4..175a5af6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -825,6 +825,13 @@ dependencies: "@types/node" "*" +"@types/bn.js@^4.11.3": + version "4.11.5" + resolved "https://registry.yarnpkg.com/@types/bn.js/-/bn.js-4.11.5.tgz#40e36197433f78f807524ec623afcf0169ac81dc" + integrity sha512-AEAZcIZga0JgVMHNtl1CprA/hXX7/wPt79AgR4XqaDt7jyj3QWYw6LPoOiznPtugDmlubUnAahMs2PFxGcQrng== + dependencies: + "@types/node" "*" + "@types/chrome@0.0.74": version "0.0.74" resolved "https://registry.yarnpkg.com/@types/chrome/-/chrome-0.0.74.tgz#f69827c48fcf7fecc90c96089807661749a5a5e3" @@ -1522,6 +1529,13 @@ base-64@0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/base-64/-/base-64-0.1.0.tgz#780a99c84e7d600260361511c4877613bf24f6bb" +base-x@^3.0.2: + version "3.0.5" + resolved "https://registry.yarnpkg.com/base-x/-/base-x-3.0.5.tgz#d3ada59afed05b921ab581ec3112e6444ba0795a" + integrity sha512-C3picSgzPSLE+jW3tcBzJoGwitOtazb5B+5YmAxZm2ybmTi9LNgAtDO/jjVEBZwHoXmDBZ9m/IELj3elJVRBcA== + dependencies: + safe-buffer "^5.0.1" + base64-js@^1.0.2: version "1.3.0" resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.3.0.tgz#cab1e6118f051095e58b5281aea8c1cd22bfc0e3" @@ -1552,6 +1566,11 @@ bcrypt-pbkdf@^1.0.0: dependencies: tweetnacl "^0.14.3" +bech32@^1.1.2: + version "1.1.3" + resolved "https://registry.yarnpkg.com/bech32/-/bech32-1.1.3.tgz#bd47a8986bbb3eec34a56a097a84b8d3e9a2dfcd" + integrity sha512-yuVFUvrNcoJi0sv5phmqc6P+Fl1HjRDRNOOkHY2X/3LBy2bIGNSFx4fZ95HMaXHupuS7cZR15AsvtmCIF4UEyg== + bfj@^6.1.1: version "6.1.1" resolved "https://registry.yarnpkg.com/bfj/-/bfj-6.1.1.tgz#05a3b7784fbd72cfa3c22e56002ef99336516c48" @@ -1565,10 +1584,22 @@ big.js@^3.1.3: version "3.2.0" resolved "https://registry.yarnpkg.com/big.js/-/big.js-3.2.0.tgz#a5fc298b81b9e0dca2e458824784b65c52ba588e" +bigi@^1.1.0, bigi@^1.4.0: + version "1.4.2" + resolved "https://registry.yarnpkg.com/bigi/-/bigi-1.4.2.tgz#9c665a95f88b8b08fc05cfd731f561859d725825" + integrity sha1-nGZalfiLiwj8Bc/XMfVhhZ1yWCU= + binary-extensions@^1.0.0: version "1.12.0" resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-1.12.0.tgz#c2d780f53d45bba8317a8902d4ceeaf3a6385b14" +bindings@^1.2.1: + version "1.5.0" + resolved "https://registry.yarnpkg.com/bindings/-/bindings-1.5.0.tgz#10353c9e945334bc0511a6d90b38fbc7c9c504df" + integrity sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ== + dependencies: + file-uri-to-path "1.0.0" + bip39@2.5.0: version "2.5.0" resolved "https://registry.yarnpkg.com/bip39/-/bip39-2.5.0.tgz#51cbd5179460504a63ea3c000db3f787ca051235" @@ -1579,6 +1610,39 @@ bip39@2.5.0: safe-buffer "^5.0.1" unorm "^1.3.3" +bip66@^1.1.0, bip66@^1.1.3: + version "1.1.5" + resolved "https://registry.yarnpkg.com/bip66/-/bip66-1.1.5.tgz#01fa8748785ca70955d5011217d1b3139969ca22" + integrity sha1-AfqHSHhcpwlV1QESF9GzE5lpyiI= + dependencies: + safe-buffer "^5.0.1" + +bitcoin-ops@^1.3.0: + version "1.4.1" + resolved "https://registry.yarnpkg.com/bitcoin-ops/-/bitcoin-ops-1.4.1.tgz#e45de620398e22fd4ca6023de43974ff42240278" + integrity sha512-pef6gxZFztEhaE9RY9HmWVmiIHqCb2OyS4HPKkpc6CIiiOa3Qmuoylxc5P2EkU3w+5eTSifI9SEZC88idAIGow== + +bitcoinjs-lib@^3.3.1: + version "3.3.2" + resolved "https://registry.yarnpkg.com/bitcoinjs-lib/-/bitcoinjs-lib-3.3.2.tgz#780c9c53ecb1222adb463b58bef26386067b609a" + integrity sha512-l5qqvbaK8wwtANPf6oEffykycg4383XgEYdia1rI7/JpGf1jfRWlOUCvx5TiTZS7kyIvY4j/UhIQ2urLsvGkzw== + dependencies: + bech32 "^1.1.2" + bigi "^1.4.0" + bip66 "^1.1.0" + bitcoin-ops "^1.3.0" + bs58check "^2.0.0" + create-hash "^1.1.0" + create-hmac "^1.1.3" + ecurve "^1.0.0" + merkle-lib "^2.0.10" + pushdata-bitcoin "^1.0.1" + randombytes "^2.0.1" + safe-buffer "^5.0.1" + typeforce "^1.11.3" + varuint-bitcoin "^1.0.4" + wif "^2.0.1" + bizcharts-plugin-slider@^2.1.1-beta.1: version "2.1.1-beta.1" resolved "https://registry.yarnpkg.com/bizcharts-plugin-slider/-/bizcharts-plugin-slider-2.1.1-beta.1.tgz#e09a1cbee28cff897b1984a18add2a6d171c6fda" @@ -1606,7 +1670,7 @@ bluebird@^3.5.1: version "3.5.2" resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.5.2.tgz#1be0908e054a751754549c270489c1505d4ab15a" -bn.js@4.11.8, bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.1.1, bn.js@^4.4.0: +bn.js@4.11.8, bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.1.1, bn.js@^4.11.3, bn.js@^4.11.8, bn.js@^4.4.0: version "4.11.8" resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.11.8.tgz#2cde09eb5ee341f484746bb0309b3253b1b1442f" @@ -1625,6 +1689,20 @@ body-parser@1.18.3: raw-body "2.3.3" type-is "~1.6.16" +bolt11@1.2.5: + version "1.2.5" + resolved "https://registry.yarnpkg.com/bolt11/-/bolt11-1.2.5.tgz#47de493954c488789abe90eacd694d9dfe770647" + integrity sha512-NbvmUxuMqhnqprIHAdaruC5zQIJQN9gLXUgN+avPn8YLlW3+/Fb3uSyXVImK49+aSrDEIbv3FcixZoTxE5jYnw== + dependencies: + "@types/bn.js" "^4.11.3" + bech32 "^1.1.2" + bitcoinjs-lib "^3.3.1" + bn.js "^4.11.8" + coininfo "git+https://github.com/cryptocoinjs/coininfo.git#c7e003b2fc0db165b89e6f98f6d6360ad22616b2" + lodash "^4.17.4" + safe-buffer "^5.1.1" + secp256k1 "^3.4.0" + bonjour@^3.5.0: version "3.5.0" resolved "https://registry.yarnpkg.com/bonjour/-/bonjour-3.5.0.tgz#8e890a183d8ee9a2393b3844c691a42bcf7bc9f5" @@ -1670,7 +1748,7 @@ browser-process-hrtime@^0.1.2: version "0.1.3" resolved "https://registry.yarnpkg.com/browser-process-hrtime/-/browser-process-hrtime-0.1.3.tgz#616f00faef1df7ec1b5bf9cfe2bdc3170f26c7b4" -browserify-aes@^1.0.0, browserify-aes@^1.0.4: +browserify-aes@^1.0.0, browserify-aes@^1.0.4, browserify-aes@^1.0.6: version "1.2.0" resolved "http://registry.npmjs.org/browserify-aes/-/browserify-aes-1.2.0.tgz#326734642f403dabc3003209853bb70ad428ef48" dependencies: @@ -1731,6 +1809,22 @@ browserslist@^4.1.0: electron-to-chromium "^1.3.80" node-releases "^1.0.0-alpha.14" +bs58@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/bs58/-/bs58-4.0.1.tgz#be161e76c354f6f788ae4071f63f34e8c4f0a42a" + integrity sha1-vhYedsNU9veIrkBx9j806MTwpCo= + dependencies: + base-x "^3.0.2" + +bs58check@<3.0.0, bs58check@^2.0.0: + version "2.1.2" + resolved "https://registry.yarnpkg.com/bs58check/-/bs58check-2.1.2.tgz#53b018291228d82a5aa08e7d796fdafda54aebfc" + integrity sha512-0TS1jicxdU09dwJMNZtVAfzPi6Q6QeN0pM1Fkzrjn+XYHvzMKPU3pHVpva+769iNVSfIYWf7LJ6WR+BuuMf8cA== + dependencies: + bs58 "^4.0.0" + create-hash "^1.1.0" + safe-buffer "^5.1.2" + buffer-crc32@~0.2.3: version "0.2.13" resolved "https://registry.yarnpkg.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz#0d333e3f00eac50aa1454abd30ef8c2a5d9a7242" @@ -2024,6 +2118,12 @@ code-point-at@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77" +"coininfo@git+https://github.com/cryptocoinjs/coininfo.git#c7e003b2fc0db165b89e6f98f6d6360ad22616b2": + version "4.3.0" + resolved "git+https://github.com/cryptocoinjs/coininfo.git#c7e003b2fc0db165b89e6f98f6d6360ad22616b2" + dependencies: + safe-buffer "^5.1.1" + collection-visit@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/collection-visit/-/collection-visit-1.0.0.tgz#4bc0373c164bc3291b4d368c829cf1a80a59dca0" @@ -2245,7 +2345,7 @@ create-hash@^1.1.0, create-hash@^1.1.2: ripemd160 "^2.0.1" sha.js "^2.4.0" -create-hmac@^1.1.0, create-hmac@^1.1.2, create-hmac@^1.1.4: +create-hmac@^1.1.0, create-hmac@^1.1.2, create-hmac@^1.1.3, create-hmac@^1.1.4: version "1.1.7" resolved "http://registry.npmjs.org/create-hmac/-/create-hmac-1.1.7.tgz#69170c78b3ab957147b2b8b04572e47ead2243ff" dependencies: @@ -2845,6 +2945,15 @@ draft-js@^0.10.0, draft-js@~0.10.0: immutable "~3.7.4" object-assign "^4.1.0" +drbg.js@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/drbg.js/-/drbg.js-1.0.1.tgz#3e36b6c42b37043823cdbc332d58f31e2445480b" + integrity sha1-Pja2xCs3BDgjzbwzLVjzHiRFSAs= + dependencies: + browserify-aes "^1.0.6" + create-hash "^1.1.2" + create-hmac "^1.1.4" + duplexer@^0.1.1: version "0.1.1" resolved "http://registry.npmjs.org/duplexer/-/duplexer-0.1.1.tgz#ace6ff808c1ce66b57d1ebf97977acb02334cfc1" @@ -2865,6 +2974,14 @@ ecc-jsbn@~0.1.1: jsbn "~0.1.0" safer-buffer "^2.1.0" +ecurve@^1.0.0: + version "1.0.6" + resolved "https://registry.yarnpkg.com/ecurve/-/ecurve-1.0.6.tgz#dfdabbb7149f8d8b78816be5a7d5b83fcf6de797" + integrity sha512-/BzEjNfiSuB7jIWKcS/z8FK9jNjmEWvUV2YZ4RLSmcDtP7Lq0m6FvDuSnJpBlDpGRpfRQeTLGLBI8H+kEv0r+w== + dependencies: + bigi "^1.1.0" + safe-buffer "^5.0.1" + ee-first@1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" @@ -2883,7 +3000,7 @@ element-resize-detector@1.1.13: dependencies: batch-processor "^1.0.0" -elliptic@^6.0.0: +elliptic@^6.0.0, elliptic@^6.2.3: version "6.4.1" resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.4.1.tgz#c2d0b7776911b86722c632c3c06c60f2f819939a" dependencies: @@ -3230,6 +3347,11 @@ file-loader@^2.0.0: loader-utils "^1.0.2" schema-utils "^1.0.0" +file-uri-to-path@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz#553a7b8446ff6f684359c445f1e37a05dacc33dd" + integrity sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw== + filesize@^3.6.1: version "3.6.1" resolved "https://registry.yarnpkg.com/filesize/-/filesize-3.6.1.tgz#090bb3ee01b6f801a8a8be99d31710b3422bb317" @@ -4658,6 +4780,11 @@ merge-descriptors@1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61" +merkle-lib@^2.0.10: + version "2.0.10" + resolved "https://registry.yarnpkg.com/merkle-lib/-/merkle-lib-2.0.10.tgz#82b8dbae75e27a7785388b73f9d7725d0f6f3326" + integrity sha1-grjbrnXieneFOItz+ddyXQ9vMyY= + methods@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" @@ -4875,6 +5002,11 @@ nan@^2.10.0, nan@^2.9.2: version "2.11.1" resolved "https://registry.yarnpkg.com/nan/-/nan-2.11.1.tgz#90e22bccb8ca57ea4cd37cc83d3819b52eea6766" +nan@^2.2.1: + version "2.13.2" + resolved "https://registry.yarnpkg.com/nan/-/nan-2.13.2.tgz#f51dc7ae66ba7d5d55e1e6d4d8092e802c9aefe7" + integrity sha512-TghvYc72wlMGMVMluVo9WRJc0mB8KxxF/gZ4YYFy7V2ZQX9l7rgbPg7vjS9mt6U5HXODVFVI2bOduCzwOMv/lw== + nanoid@^1.0.7: version "1.3.1" resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-1.3.1.tgz#4538e1a02822b131da198d8eb17c9e3b3ac5167f" @@ -5704,6 +5836,13 @@ punycode@^2.1.0: version "2.1.1" resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec" +pushdata-bitcoin@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/pushdata-bitcoin/-/pushdata-bitcoin-1.0.1.tgz#15931d3cd967ade52206f523aa7331aef7d43af7" + integrity sha1-FZMdPNlnreUiBvUjqnMxrvfUOvc= + dependencies: + bitcoin-ops "^1.3.0" + q@^1.1.2: version "1.5.1" resolved "https://registry.yarnpkg.com/q/-/q-1.5.1.tgz#7e32f75b41381291d04611f1bf14109ac00651d7" @@ -6803,6 +6942,20 @@ scss-tokenizer@^0.2.3: js-base64 "^2.1.8" source-map "^0.4.2" +secp256k1@^3.4.0: + version "3.6.2" + resolved "https://registry.yarnpkg.com/secp256k1/-/secp256k1-3.6.2.tgz#da835061c833c74a12f75c73d2ec2e980f00dc1f" + integrity sha512-90nYt7yb0LmI4A2jJs1grglkTAXrBwxYAjP9bpeKjvJKOjG2fOeH/YI/lchDMIvjrOasd5QXwvV2jwN168xNng== + dependencies: + bindings "^1.2.1" + bip66 "^1.1.3" + bn.js "^4.11.3" + create-hash "^1.1.2" + drbg.js "^1.0.1" + elliptic "^6.2.3" + nan "^2.2.1" + safe-buffer "^5.1.0" + select-hose@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/select-hose/-/select-hose-2.0.0.tgz#625d8658f865af43ec962bfc376a37359a4994ca" @@ -7618,6 +7771,11 @@ typedarray@^0.0.6: version "0.0.6" resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" +typeforce@^1.11.3: + version "1.18.0" + resolved "https://registry.yarnpkg.com/typeforce/-/typeforce-1.18.0.tgz#d7416a2c5845e085034d70fcc5b6cc4a90edbfdc" + integrity sha512-7uc1O8h1M1g0rArakJdf0uLRSSgFcYexrVoKo+bzJd32gd4gDy2L/Z+8/FjPnU9ydY3pEnVPtr9FyscYY60K1g== + typescript@3.1.6: version "3.1.6" resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.1.6.tgz#b6543a83cfc8c2befb3f4c8fba6896f5b0c9be68" @@ -7823,6 +7981,13 @@ value-equal@^0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/value-equal/-/value-equal-0.4.0.tgz#c5bdd2f54ee093c04839d71ce2e4758a6890abc7" +varuint-bitcoin@^1.0.4: + version "1.1.0" + resolved "https://registry.yarnpkg.com/varuint-bitcoin/-/varuint-bitcoin-1.1.0.tgz#7a343f50537607af6a3059312b9782a170894540" + integrity sha512-jCEPG+COU/1Rp84neKTyDJQr478/hAfVp5xxYn09QEH0yBjbmPeMfuuQIrp+BUD83hybtYZKhr5elV3bvdV1bA== + dependencies: + safe-buffer "^5.1.1" + vary@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" @@ -8082,6 +8247,13 @@ wide-align@^1.1.0: dependencies: string-width "^1.0.2 || 2" +wif@^2.0.1: + version "2.0.6" + resolved "https://registry.yarnpkg.com/wif/-/wif-2.0.6.tgz#08d3f52056c66679299726fade0d432ae74b4704" + integrity sha1-CNP1IFbGZnkplyb63g1DKudLRwQ= + dependencies: + bs58check "<3.0.0" + window-size@0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/window-size/-/window-size-0.1.0.tgz#5438cd2ea93b202efa3a19fe8887aee7c94f9c9d" From 2e5be5d86119b34a5ffc4f1f7a7bf77f1d5db31d Mon Sep 17 00:00:00 2001 From: Will O'Beirne Date: Tue, 4 Jun 2019 17:16:25 -0400 Subject: [PATCH 02/11] Typify more stuff. --- src/app/utils/prompt.ts | 1 - src/content_script/index.ts | 19 ++-- src/content_script/respondWithoutPrompt.ts | 28 +++--- src/content_script/store.ts | 14 +-- src/util/messages.ts | 100 +++++++++++++++++++++ 5 files changed, 130 insertions(+), 32 deletions(-) create mode 100644 src/util/messages.ts diff --git a/src/app/utils/prompt.ts b/src/app/utils/prompt.ts index f3cc6cab..88256454 100644 --- a/src/app/utils/prompt.ts +++ b/src/app/utils/prompt.ts @@ -44,7 +44,6 @@ export function getPromptOrigin(): OriginData { throw new Error('Missing prompt arguments'); } const { origin } = qs.parse(window.location.search); - console.log(origin); return JSON.parse(origin as string) as OriginData; } diff --git a/src/content_script/index.ts b/src/content_script/index.ts index 963aac1c..4b391afa 100755 --- a/src/content_script/index.ts +++ b/src/content_script/index.ts @@ -4,6 +4,7 @@ import injectScript from './injectScript'; import respondWithoutPrompt from './respondWithoutPrompt'; import { PROMPT_TYPE } from '../webln/types'; import { getOriginData } from 'utils/prompt'; +import { isPromptMessage } from '../util/messages'; if (shouldInject()) { injectScript(); @@ -14,9 +15,10 @@ if (shouldInject()) { return; } - if (ev.data && ev.data.application === 'Joule' && !ev.data.response) { + const msg = ev.data; + if (isPromptMessage(msg)) { const messageWithOrigin = { - ...ev.data, + ...msg, origin: getOriginData(), }; @@ -54,25 +56,14 @@ if (document) { const lightningLink = target.closest('[href^="lightning:"]'); if (lightningLink) { ev.preventDefault(); - console.log(lightningLink); const href = lightningLink.getAttribute('href') as string; const paymentRequest = href.replace('lightning:', ''); - const message = { + browser.runtime.sendMessage({ application: 'Joule', prompt: true, type: PROMPT_TYPE.PAYMENT, origin: getOriginData(), args: { paymentRequest }, - }; - respondWithoutPrompt(message).then(didRespond => { - if (didRespond) return; - browser.runtime.sendMessage({ - application: 'Joule', - prompt: true, - type: PROMPT_TYPE.PAYMENT, - origin: getOriginData(), - args: { paymentRequest }, - }); }); } }); diff --git a/src/content_script/respondWithoutPrompt.ts b/src/content_script/respondWithoutPrompt.ts index 795911e9..40e320eb 100644 --- a/src/content_script/respondWithoutPrompt.ts +++ b/src/content_script/respondWithoutPrompt.ts @@ -3,20 +3,26 @@ import { runSelector, runAction } from './store'; import { PROMPT_TYPE } from '../webln/types'; import { selectSettings } from 'modules/settings/selectors'; import { sendPayment } from 'modules/payment/actions'; +import { + AnyPromptMessage, + AuthorizePromptMessage, + PaymentPromptMessage, +} from '../util/messages'; -export default async function respondWithoutPrompt(data: any): Promise { - switch (data.type) { +export default async function respondWithoutPrompt( + msg: AnyPromptMessage, +): Promise { + switch (msg.type) { case PROMPT_TYPE.AUTHORIZE: - return handleAuthorizePrompt(data); + return handleAuthorizePrompt(msg); case PROMPT_TYPE.PAYMENT: - return handleAutoPayment(data); + return handleAutoPayment(msg); } - return false; } -async function handleAuthorizePrompt(data: any) { - const { domain } = data.origin; +async function handleAuthorizePrompt(msg: AuthorizePromptMessage) { + const { domain } = msg.origin; const settings = await runSelector(selectSettings); if (domain) { @@ -32,10 +38,12 @@ async function handleAuthorizePrompt(data: any) { return false; } -async function handleAutoPayment(data: any) { +async function handleAutoPayment(msg: PaymentPromptMessage) { + // Disable (for now) return false; + // Pop up for non-fixed invoices - const decoded = bolt11.decode(data.args.paymentRequest); + const decoded = bolt11.decode(msg.args.paymentRequest); if (!decoded.satoshis) { return false; } @@ -43,7 +51,7 @@ async function handleAutoPayment(data: any) { // Attempt to send the payment const state = await runAction( sendPayment({ - payment_request: data.args.paymentRequest, + payment_request: msg.args.paymentRequest, fee_limit: { fixed: '10', }, diff --git a/src/content_script/store.ts b/src/content_script/store.ts index 68bfc13f..ace01b1b 100644 --- a/src/content_script/store.ts +++ b/src/content_script/store.ts @@ -11,13 +11,6 @@ export function getStore(fresh?: boolean) { return store; } -// Run a selector, but ensure the store has fully synced first -type Selector = (s: AppState) => T; -export async function runSelector(selector: Selector): Promise { - const state = await waitForStoreState(s => s.sync.hasSynced); - return selector(state); -} - // Returns a promise that only resolves once store state has hit a certain condition type WaitStateCheckFunction = (s: AppState) => boolean; export async function waitForStoreState( @@ -41,6 +34,13 @@ export async function waitForStoreState( }); } +// Run a selector, but ensure the store has fully synced first +type Selector = (s: AppState) => T; +export async function runSelector(selector: Selector): Promise { + const state = await waitForStoreState(s => s.sync.hasSynced); + return selector(state); +} + // Returns a promise that resolves once an action has run and the state has hit // certain conditions. export async function runAction( diff --git a/src/util/messages.ts b/src/util/messages.ts new file mode 100644 index 00000000..9ea5e13b --- /dev/null +++ b/src/util/messages.ts @@ -0,0 +1,100 @@ +import { RequestInvoiceArgs } from 'webln'; +import { PROMPT_TYPE } from '../webln/types'; + +export interface BaseMessage { + application: 'Joule'; +} + +// Prompt messages +export interface OriginData { + domain: string; + name: string; + icon: string; +} + +export interface PromptMessage + extends BaseMessage { + prompt: true; + type: T; + args: A; + origin: OriginData; +} + +export type AuthorizePromptMessage = PromptMessage; +export type InfoPromptMessage = PromptMessage; +export type PaymentPromptMessage = PromptMessage< + PROMPT_TYPE.PAYMENT, + { paymentRequest: string } +>; +export type InvoicePromptMessage = PromptMessage; +export type SignPromptMessage = PromptMessage; +export type VerifyPromptMessage = PromptMessage< + PROMPT_TYPE.VERIFY, + { signature: string; msg: string } +>; + +export type AnyPromptMessage = + | AuthorizePromptMessage + | InfoPromptMessage + | PaymentPromptMessage + | InvoicePromptMessage + | SignPromptMessage + | VerifyPromptMessage; + +export function isPromptMessage(msg: any): msg is AnyPromptMessage { + return msg && msg.application === 'Joule' && msg.prompt === true; +} + +// Response messages +export interface ResponseMessage extends BaseMessage { + response: true; +} + +export interface ResponseDataMessage extends ResponseMessage { + data: T; + error?: undefined; +} + +export interface ResponseErrorMessage extends ResponseMessage { + error: string; + data?: undefined; +} + +// Context menu message +export interface ContextMenuMessage extends BaseMessage { + contextMenu: true; + args: { + paymentRequest: string; + }; +} + +// Password messages +export interface SetPasswordMessage extends BaseMessage { + setPassword: true; + data: { + password: string; + }; +} + +export interface GetPasswordMessage extends BaseMessage { + getPassword: true; +} + +export interface ClearPasswordMessage extends BaseMessage { + clearPassword: true; +} + +export interface CachedPasswordMessage extends BaseMessage { + cachedPassword: true; + data: string; +} + +// Any of the above messages +export type AnyMessage = + | ResponseDataMessage + | ResponseErrorMessage + | ContextMenuMessage + | SetPasswordMessage + | GetPasswordMessage + | ClearPasswordMessage + | CachedPasswordMessage; From ba34b5a0655b6ee46bbe5d88b5b96504a2e37cdd Mon Sep 17 00:00:00 2001 From: Will O'Beirne Date: Wed, 5 Jun 2019 16:32:32 -0400 Subject: [PATCH 03/11] Add appconfigs with allowances, and a form to create, edit, and delete them. --- src/app/AppRoutes.tsx | 12 ++ src/app/components/AllowanceForm/index.less | 69 ++++++++ src/app/components/AllowanceForm/index.tsx | 169 ++++++++++++++++++++ src/app/components/Balances/index.tsx | 11 +- src/app/components/SettingsMenu.tsx | 7 + src/app/modules/appconf/actions.ts | 27 ++++ src/app/modules/appconf/index.ts | 7 + src/app/modules/appconf/reducers.ts | 49 ++++++ src/app/modules/appconf/selectors.ts | 4 + src/app/modules/appconf/types.ts | 20 +++ src/app/pages/allowances.less | 48 ++++++ src/app/pages/allowances.tsx | 133 +++++++++++++++ src/app/pages/home.less | 12 +- src/app/store/reducers.ts | 7 + src/app/style/variables.less | 38 +++-- src/app/utils/constants.ts | 15 ++ src/app/utils/formatters.ts | 4 + src/app/utils/sync.ts | 11 ++ src/app/utils/validators.ts | 4 + 19 files changed, 620 insertions(+), 27 deletions(-) create mode 100644 src/app/components/AllowanceForm/index.less create mode 100644 src/app/components/AllowanceForm/index.tsx create mode 100644 src/app/modules/appconf/actions.ts create mode 100644 src/app/modules/appconf/index.ts create mode 100644 src/app/modules/appconf/reducers.ts create mode 100644 src/app/modules/appconf/selectors.ts create mode 100644 src/app/modules/appconf/types.ts create mode 100644 src/app/pages/allowances.less create mode 100644 src/app/pages/allowances.tsx diff --git a/src/app/AppRoutes.tsx b/src/app/AppRoutes.tsx index ecb2b7ba..6414f158 100755 --- a/src/app/AppRoutes.tsx +++ b/src/app/AppRoutes.tsx @@ -15,6 +15,7 @@ import HomePage from 'pages/home'; import OnboardingPage from 'pages/onboarding'; import SettingsPage from 'pages/settings'; import BalancesPage from 'pages/balances'; +import AllowancesPage from 'pages/allowances'; import FourOhFourPage from 'pages/fourohfour'; import Template, { Props as TemplateProps } from 'components/Template'; @@ -74,6 +75,17 @@ const routeConfigs: RouteConfig[] = [ showBack: true, }, }, + { + // Allowances + route: { + path: '/allowances', + component: AllowancesPage, + }, + template: { + title: 'Allowances', + showBack: true, + }, + }, { // 404 route: { diff --git a/src/app/components/AllowanceForm/index.less b/src/app/components/AllowanceForm/index.less new file mode 100644 index 00000000..6238a566 --- /dev/null +++ b/src/app/components/AllowanceForm/index.less @@ -0,0 +1,69 @@ +.AllowanceForm { + &-header { + display: flex; + padding: 1rem 0.75rem; + justify-content: space-between; + align-items: center; + border-bottom: 1px solid rgba(#000, 0.15); + + &-domain { + font-size: 1.1rem; + font-weight: bold; + } + + &-toggle { + display: flex; + align-items: center; + + &-label { + font-size: 0.6rem; + text-transform: uppercase; + margin-right: 0.4rem; + font-weight: bold; + opacity: 0.7; + } + } + } + + &-fields { + padding: 1rem; + + &.is-inactive { + opacity: 0.2; + pointer-events: none; + } + + &-balance { + .ant-form-item-children { + display: flex; + } + + &-total { + margin-bottom: 0.5rem; + } + + &-bar { + margin-top: -1rem; + margin-left: 1rem; + + .ant-progress-text small { + font-size: 0.65rem; + opacity: 0.6; + } + } + } + + // Ant overrides + .ant-form-item { + margin-bottom: 0.5rem; + } + + .ant-form-item-label label { + font-size: 0.7rem; + text-transform: uppercase; + font-weight: bold; + letter-spacing: 0.08rem; + opacity: 0.7; + } + } +} diff --git a/src/app/components/AllowanceForm/index.tsx b/src/app/components/AllowanceForm/index.tsx new file mode 100644 index 00000000..93e872ac --- /dev/null +++ b/src/app/components/AllowanceForm/index.tsx @@ -0,0 +1,169 @@ +import React from 'react'; +import classnames from 'classnames'; +import { connect } from 'react-redux'; +import { Button, Input, Switch, Form, Progress, Row, Col, Icon, Modal } from 'antd'; +import { Allowance, AppConfig } from 'modules/appconf/types'; +import { DEFAULT_ALLOWANCE, COLORS } from 'utils/constants'; +import { setAppConfig, deleteAppConfig } from 'modules/appconf/actions'; +import { AppState } from 'store/reducers'; +import './index.less'; + +interface OwnProps { + domain: string; + appConfig: AppConfig; +} + +interface DispatchProps { + setAppConfig: typeof setAppConfig; + deleteAppConfig: typeof deleteAppConfig; +} + +type Props = OwnProps & DispatchProps; + +class AllowancesPage extends React.Component { + render() { + const { domain, appConfig } = this.props; + const allowance = appConfig.allowance || DEFAULT_ALLOWANCE; + + return ( +
+
+
{domain}
+
+ Active + +
+
+
+ +
+ + +
+ ( + <> +
{pct}%
+ {allowance.balance} sats left + + )} + /> +
+ + + + + + + + + + + + + +
+
+ ); + } + + private handleChangeAllowanceField = (ev: React.ChangeEvent) => { + const { appConfig, domain } = this.props; + const allowance = appConfig.allowance || DEFAULT_ALLOWANCE; + const { name } = ev.currentTarget; + const value = parseInt(ev.currentTarget.value, 10); + if (!value) { + return; + } + + this.props.setAppConfig(domain, { + ...appConfig, + allowance: { + ...allowance, + [name]: value, + // Set balance to the total if it's changed + balance: name === 'total' ? value : allowance.balance, + } as Allowance, + }); + }; + + private toggleAllowanceActive = (active: boolean) => { + const { appConfig, domain } = this.props; + const allowance = appConfig.allowance || DEFAULT_ALLOWANCE; + this.props.setAppConfig(domain, { + ...appConfig, + allowance: { + ...allowance, + active, + }, + }); + }; + + private refillAllowance = () => { + const { appConfig, domain } = this.props; + const allowance = appConfig.allowance || DEFAULT_ALLOWANCE; + this.props.setAppConfig(domain, { + ...appConfig, + allowance: { + ...allowance, + balance: allowance.total, + }, + }); + }; + + private promptDelete = () => { + Modal.confirm({ + title: 'Are you sure?', + content: ` + Your allowance configuration will be lost. You can always reconfigure + it later. + `, + okText: 'Confirm', + cancelText: 'Never mind', + onOk: cb => { + this.props.deleteAppConfig(this.props.domain); + cb(); + }, + }); + }; +} + +export default connect<{}, DispatchProps, OwnProps, AppState>( + undefined, + { + setAppConfig, + deleteAppConfig, + }, +)(AllowancesPage); diff --git a/src/app/components/Balances/index.tsx b/src/app/components/Balances/index.tsx index 767b2b09..a66391be 100644 --- a/src/app/components/Balances/index.tsx +++ b/src/app/components/Balances/index.tsx @@ -8,6 +8,7 @@ import { Denomination, denominationSymbols, blockchainDisplayName, + COLORS, } from 'utils/constants'; import { getNodeChain } from 'modules/node/selectors'; import { getChannels } from 'modules/channels/actions'; @@ -121,12 +122,12 @@ class Balances extends React.Component { className="Balances-chart-progress" percent={stats.channelPercent + stats.pendingPercent} type="circle" - strokeColor="#7642ff" + strokeColor={COLORS.PRIMARY} successPercent={Math.max( 0.1, 100 - stats.channelPercent - stats.onchainPercent, )} - trailColor="#ff9500" + trailColor={COLORS.BITCOIN} format={() => `${stats.spendablePercent}%`} /> @@ -135,21 +136,21 @@ class Balances extends React.Component { diff --git a/src/app/components/SettingsMenu.tsx b/src/app/components/SettingsMenu.tsx index 0b57f2fd..29937324 100644 --- a/src/app/components/SettingsMenu.tsx +++ b/src/app/components/SettingsMenu.tsx @@ -38,7 +38,14 @@ export default class SettingsMenu extends React.Component<{}, State> { Balances + + + Allowances + + + + Settings diff --git a/src/app/modules/appconf/actions.ts b/src/app/modules/appconf/actions.ts new file mode 100644 index 00000000..00e48e1e --- /dev/null +++ b/src/app/modules/appconf/actions.ts @@ -0,0 +1,27 @@ +import types, { AppConfig } from './types'; +import { AppconfState } from './reducers'; +import { normalizeDomain } from 'utils/formatters'; + +export function setAppConfig(domain: string, config: AppConfig) { + return { + type: types.SET_APP_CONFIG, + payload: { + domain: normalizeDomain(domain), + config, + }, + }; +} + +export function deleteAppConfig(domain: string) { + return { + type: types.DELETE_APP_CONFIG, + payload: normalizeDomain(domain), + }; +} + +export function setAppconf(state: Partial) { + return { + type: types.SET_APPCONF, + payload: state, + }; +} diff --git a/src/app/modules/appconf/index.ts b/src/app/modules/appconf/index.ts new file mode 100644 index 00000000..1edb9b3d --- /dev/null +++ b/src/app/modules/appconf/index.ts @@ -0,0 +1,7 @@ +import reducers, { AppconfState, INITIAL_STATE } from './reducers'; +import * as appconfActions from './actions'; +import appconfTypes from './types'; + +export { appconfActions, appconfTypes, AppconfState, INITIAL_STATE }; + +export default reducers; diff --git a/src/app/modules/appconf/reducers.ts b/src/app/modules/appconf/reducers.ts new file mode 100644 index 00000000..5ec3ad1a --- /dev/null +++ b/src/app/modules/appconf/reducers.ts @@ -0,0 +1,49 @@ +import types, { AppConfig } from './types'; + +export interface AppconfState { + configs: { [domain: string]: AppConfig }; +} + +export const INITIAL_STATE: AppconfState = { + configs: {}, +}; + +export default function channelsReducers( + state: AppconfState = INITIAL_STATE, + action: any, +): AppconfState { + switch (action.type) { + case types.SET_APP_CONFIG: + return { + ...state, + configs: { + ...state.configs, + [action.payload.domain]: { + ...action.payload.config, + }, + }, + }; + + case types.DELETE_APP_CONFIG: + return { + ...state, + configs: Object.keys(state.configs).reduce( + (prev, domain) => { + if (domain !== action.payload) { + prev[domain] = state.configs[domain]; + } + return prev; + }, + {} as AppconfState['configs'], + ), + }; + + case types.SET_APPCONF: + return { + ...state, + ...action.payload, + }; + } + + return state; +} diff --git a/src/app/modules/appconf/selectors.ts b/src/app/modules/appconf/selectors.ts new file mode 100644 index 00000000..d54440e9 --- /dev/null +++ b/src/app/modules/appconf/selectors.ts @@ -0,0 +1,4 @@ +import { AppState as S } from 'store/reducers'; + +export const selectAppconf = (s: S) => s.appconf; +export const selectAppDomains = (s: S) => Object.keys(s.appconf.configs); diff --git a/src/app/modules/appconf/types.ts b/src/app/modules/appconf/types.ts new file mode 100644 index 00000000..713555da --- /dev/null +++ b/src/app/modules/appconf/types.ts @@ -0,0 +1,20 @@ +enum AppconfTypes { + SET_APP_CONFIG = 'SET_APP_CONFIG', + DELETE_APP_CONFIG = 'DELETE_APP_CONFIG', + + SET_APPCONF = 'SET_APPCONF', +} + +export interface Allowance { + active: boolean; + total: number; + balance: number; + maxPerPayment: number; + minIntervalPerPayment: number; +} + +export interface AppConfig { + allowance: Allowance | null; +} + +export default AppconfTypes; diff --git a/src/app/pages/allowances.less b/src/app/pages/allowances.less new file mode 100644 index 00000000..f78023cd --- /dev/null +++ b/src/app/pages/allowances.less @@ -0,0 +1,48 @@ +@import '~style/variables.less'; + +.Allowances { + .full-height-flex-page(); + + &-control { + padding: 1rem 0.75rem; + display: flex; + align-items: center; + + &-select { + flex: 1; + } + + > * { + margin-right: 0.5rem; + + &:last-child { + margin-right: 0; + } + } + } + + &-form { + flex: 1; + background: #fff; + border-top: 1px solid rgba(#000, 0.15); + } + + &-add { + width: 100%; + + &-input { + .ant-select-auto-complete & .ant-input, + .ant-input-search-button { + height: 3rem; + } + + .ant-select-auto-complete & .ant-input { + font-size: 1.1rem; + } + + .ant-input-search-button { + font-size: 1.2rem; + } + } + } +} diff --git a/src/app/pages/allowances.tsx b/src/app/pages/allowances.tsx new file mode 100644 index 00000000..176db3df --- /dev/null +++ b/src/app/pages/allowances.tsx @@ -0,0 +1,133 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { Select, Button, Modal, AutoComplete, Input, Icon, message } from 'antd'; +import BigMessage from 'components/BigMessage'; +import AllowanceForm from 'components/AllowanceForm'; +import { normalizeDomain } from 'utils/formatters'; +import { isSimpleDomain } from 'utils/validators'; +import { DEFAULT_ALLOWANCE } from 'utils/constants'; +import { setAppConfig } from 'modules/appconf/actions'; +import { AppState } from 'store/reducers'; +import './allowances.less'; + +interface StateProps { + appConfigs: AppState['appconf']['configs']; + enabledDomains: AppState['settings']['enabledDomains']; +} + +interface DispatchProps { + setAppConfig: typeof setAppConfig; +} + +type Props = StateProps & DispatchProps; + +interface State { + domain: string; + isAdding: boolean; +} + +class AllowancesPage extends React.Component { + state: State = { + domain: '', + isAdding: false, + }; + + render() { + const { appConfigs, enabledDomains } = this.props; + const { domain, isAdding } = this.state; + + const config = appConfigs[domain]; + const configDomains = Object.keys(appConfigs); + + return ( +
+
+ Allowance for + + +
+ +
+ {config ? ( + + ) : ( +
+ +
+ )} +
+ + + + } + onSearch={v => this.submitAddDomain(v)} + autoFocus + /> + + +
+ ); + } + + private handleChangeDomain = (domain: string) => { + this.setState({ domain }); + }; + + private filterAddDomains = (val: string, option: any) => { + return option.key.indexOf(val.toLowerCase()) === 0; + }; + + private submitAddDomain = (domain: string) => { + if (!isSimpleDomain(domain)) { + message.warn('Invalid domain name'); + return; + } + + this.props.setAppConfig(domain, { + allowance: { ...DEFAULT_ALLOWANCE }, + }); + this.setState({ domain }); + this.closeAddModal(); + }; + + private openAddModal = () => this.setState({ isAdding: true }); + private closeAddModal = () => this.setState({ isAdding: false }); +} + +export default connect( + state => ({ + appConfigs: state.appconf.configs, + enabledDomains: state.settings.enabledDomains.map(normalizeDomain), + }), + { setAppConfig }, +)(AllowancesPage); diff --git a/src/app/pages/home.less b/src/app/pages/home.less index 8d959fd8..7ff2e766 100644 --- a/src/app/pages/home.less +++ b/src/app/pages/home.less @@ -1,18 +1,12 @@ @import '~style/variables.less'; .Home { - height: calc(100vh - @header-height); - display: flex; - flex-direction: column; - - .is-page & { - height: 100%; - } + .full-height-flex-page(); // Ant override .ant-tabs { flex: 1; - background: #FFF; + background: #fff; } .ant-tabs-bar { @@ -40,7 +34,7 @@ font-weight: normal; } } - + .ant-tabs-content { height: 100%; padding-top: 44px; // Height of sticky nav diff --git a/src/app/store/reducers.ts b/src/app/store/reducers.ts index 3dcdc242..0d146ec6 100755 --- a/src/app/store/reducers.ts +++ b/src/app/store/reducers.ts @@ -26,6 +26,10 @@ import onchain, { OnChainState, INITIAL_STATE as onchainInitialState, } from 'modules/onchain'; +import appconf, { + AppconfState, + INITIAL_STATE as appconfInitialState, +} from 'modules/appconf'; export interface AppState { crypto: CryptoState; @@ -39,6 +43,7 @@ export interface AppState { peers: PeersState; sign: SignState; onchain: OnChainState; + appconf: AppconfState; } export const combineInitialState: Partial = { @@ -53,6 +58,7 @@ export const combineInitialState: Partial = { peers: peersInitialState, sign: signInitialState, onchain: onchainInitialState, + appconf: appconfInitialState, }; export default combineReducers({ @@ -67,4 +73,5 @@ export default combineReducers({ peers, sign, onchain, + appconf, }); diff --git a/src/app/style/variables.less b/src/app/style/variables.less index 63b2b74b..c4cc82f2 100644 --- a/src/app/style/variables.less +++ b/src/app/style/variables.less @@ -1,17 +1,29 @@ // Override ant less variables -@primary-color: #7642FF; // primary color for all components -@link-color: #7642FF; // link color -@success-color: #52c41a; // success state color -@warning-color: #faad14; // warning state color -@error-color: #f5222d; // error state color -@font-size-base: 14px; // major text font size -@heading-color: rgba(0, 0, 0, .85); // heading text color -@text-color: rgba(0, 0, 0, .65); // major text color -@text-color-secondary : rgba(0, 0, 0, .45); // secondary text color +@primary-color: #7642ff; // primary color for all components +@link-color: #7642ff; // link color +@success-color: #52c41a; // success state color +@warning-color: #faad14; // warning state color +@error-color: #f5222d; // error state color +@font-size-base: 14px; // major text font size +@heading-color: rgba(0, 0, 0, 0.85); // heading text color +@text-color: rgba(0, 0, 0, 0.65); // major text color +@text-color-secondary : rgba(0, 0, 0, .45); // secondary text color -@font-family: "Chinese Quote", -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Helvetica Neue", Helvetica, Arial, sans-serif, -"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; -@code-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace; +@font-family: 'Chinese Quote', -apple-system, BlinkMacSystemFont, 'Segoe UI', + 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, + sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; +@code-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, Courier, monospace; @header-height: 2.6rem; -@drawer-header-height: 3rem; \ No newline at end of file +@drawer-header-height: 3rem; + +// Mixins +.full-height-flex-page() { + height: calc(100vh - @header-height); + display: flex; + flex-direction: column; + + .is-page & { + height: 100%; + } +} diff --git a/src/app/utils/constants.ts b/src/app/utils/constants.ts index fb75d3fd..56eb7263 100644 --- a/src/app/utils/constants.ts +++ b/src/app/utils/constants.ts @@ -3,6 +3,7 @@ import LitecoinLogo from 'static/images/litecoin.svg'; import * as React from 'react'; import { CustomIconComponentProps } from 'antd/lib/icon'; import { CHANNEL_STATUS } from 'lib/lnd-http'; +import { Allowance } from 'modules/appconf/types'; export enum NODE_TYPE { LOCAL = 'LOCAL', @@ -172,3 +173,17 @@ export const CHAIN_PREFIXES = [ 'tltc', // Litecoin Testnet 'rltc', // Litecoin Regtest ]; + +export const DEFAULT_ALLOWANCE: Allowance = { + active: true, + total: 10000, + balance: 10000, + maxPerPayment: 100, + minIntervalPerPayment: 1, +}; + +export const COLORS = { + PRIMARY: '#7642ff', + BITCOIN: '#ff9500', + NEUTRAL: '#858585', +}; diff --git a/src/app/utils/formatters.ts b/src/app/utils/formatters.ts index 8f9bdbe3..fa6ad2b5 100755 --- a/src/app/utils/formatters.ts +++ b/src/app/utils/formatters.ts @@ -84,6 +84,10 @@ export function removeDomainPrefix(domain: string) { return domain.replace(/^(?:https?:\/\/)?(?:www\.)?/i, ''); } +export function normalizeDomain(domain: string) { + return removeDomainPrefix(domain).toLowerCase(); +} + export function enumToClassName(key: string) { return key.toLowerCase().replace('_', '-'); } diff --git a/src/app/utils/sync.ts b/src/app/utils/sync.ts index 2ce0ca3c..cf6e5964 100755 --- a/src/app/utils/sync.ts +++ b/src/app/utils/sync.ts @@ -19,6 +19,9 @@ import nodeTypes from 'modules/node/types'; import { selectSettings } from 'modules/settings/selectors'; import { changeSettings } from 'modules/settings/actions'; import settingsTypes from 'modules/settings/types'; +import { selectAppconf } from 'modules/appconf/selectors'; +import { setAppconf } from 'modules/appconf/actions'; +import appconfTypes from 'modules/appconf/types'; import { AppState } from 'store/reducers'; export interface SyncConfig { @@ -90,6 +93,14 @@ export const syncConfigs: Array> = [ settingsTypes.REMOVE_REJECTED_DOMAIN, ], }, + { + key: 'appconf', + version: 1, + encrypted: false, + selector: selectAppconf, + action: setAppconf, + triggerActions: [appconfTypes.SET_APP_CONFIG, appconfTypes.DELETE_APP_CONFIG], + }, ]; function getConfigByKey(key: string) { diff --git a/src/app/utils/validators.ts b/src/app/utils/validators.ts index 9d5d07d4..0fca6e2e 100755 --- a/src/app/utils/validators.ts +++ b/src/app/utils/validators.ts @@ -67,3 +67,7 @@ export function isSegwitAddress(address: string): boolean { // check if the address starts with one of the prefixes return CHAIN_PREFIXES.some(p => addrPrefix.substring(0, p.length) === p); } + +export function isSimpleDomain(domain: string): boolean { + return /^[a-zA-Z0-9][a-zA-Z0-9-]{1,61}[a-zA-Z0-9]\.[a-zA-Z]{2,}$/.test(domain); +} From b96d4a6f42aa5790cf894f5898b2142c07749054 Mon Sep 17 00:00:00 2001 From: Will O'Beirne Date: Thu, 6 Jun 2019 16:03:40 -0400 Subject: [PATCH 04/11] Autopay using actual allowances\! --- src/app/lib/lnd-http/index.ts | 10 +++++ src/app/modules/appconf/selectors.ts | 6 +++ src/content_script/respondWithoutPrompt.ts | 48 ++++++++++++++++++---- src/content_script/store.ts | 10 ++--- 4 files changed, 61 insertions(+), 13 deletions(-) diff --git a/src/app/lib/lnd-http/index.ts b/src/app/lib/lnd-http/index.ts index 83f7362b..1ca75f53 100644 --- a/src/app/lib/lnd-http/index.ts +++ b/src/app/lib/lnd-http/index.ts @@ -264,7 +264,17 @@ export class LndHttpClient { } return { ...res, + // Convert base64 preimage to more widely used hex one payment_preimage: new Buffer(res.payment_preimage, 'base64').toString('hex'), + // Provide default values for route fees & timelock + payment_route: { + total_amt: '0', + total_amt_msat: '0', + total_fees: '0', + total_fees_msat: '0', + total_time_lock: '0', + ...res.payment_route, + }, } as T.SendPaymentResponse; }); }; diff --git a/src/app/modules/appconf/selectors.ts b/src/app/modules/appconf/selectors.ts index d54440e9..645fa058 100644 --- a/src/app/modules/appconf/selectors.ts +++ b/src/app/modules/appconf/selectors.ts @@ -1,4 +1,10 @@ import { AppState as S } from 'store/reducers'; +import { normalizeDomain } from 'utils/formatters'; +import { AppConfig } from './types'; export const selectAppconf = (s: S) => s.appconf; export const selectAppDomains = (s: S) => Object.keys(s.appconf.configs); + +export const selectConfigByDomain = (s: S, domain: string): AppConfig | undefined => { + return s.appconf.configs[normalizeDomain(domain)]; +}; diff --git a/src/content_script/respondWithoutPrompt.ts b/src/content_script/respondWithoutPrompt.ts index 40e320eb..f23715c6 100644 --- a/src/content_script/respondWithoutPrompt.ts +++ b/src/content_script/respondWithoutPrompt.ts @@ -3,6 +3,8 @@ import { runSelector, runAction } from './store'; import { PROMPT_TYPE } from '../webln/types'; import { selectSettings } from 'modules/settings/selectors'; import { sendPayment } from 'modules/payment/actions'; +import { selectConfigByDomain } from 'modules/appconf/selectors'; +import { setAppConfig } from 'modules/appconf/actions'; import { AnyPromptMessage, AuthorizePromptMessage, @@ -39,12 +41,24 @@ async function handleAuthorizePrompt(msg: AuthorizePromptMessage) { } async function handleAutoPayment(msg: PaymentPromptMessage) { - // Disable (for now) - return false; + console.log(msg); // Pop up for non-fixed invoices - const decoded = bolt11.decode(msg.args.paymentRequest); - if (!decoded.satoshis) { + const { satoshis } = bolt11.decode(msg.args.paymentRequest); + if (!satoshis) { + return false; + } + + // Grab the available allowance, if possible + const config = await runSelector(selectConfigByDomain, msg.origin.domain); + if (!config || !config.allowance || !config.allowance.active) { + return; + } + + // Check that the payment is allowed via our allowance constraints + const { allowance } = config; + console.log('allowance', allowance); + if (satoshis > allowance.maxPerPayment || satoshis > allowance.balance) { return false; } @@ -62,12 +76,30 @@ async function handleAutoPayment(msg: PaymentPromptMessage) { !!s.crypto.isRequestingPassword, ); - // If it failed for any reason or we need their pw, we'll just open the prompt - if (state.payment.sendError || state.crypto.isRequestingPassword) { + // If it failed for any reason or we need their password, we'll just open the prompt + if ( + state.payment.sendError || + state.crypto.isRequestingPassword || + !state.payment.sendLightningReceipt + ) { return false; - } else { - return true; } + + // Reduce their allowance balance by cost + fee and return true + console.log(allowance.balance); + console.log(state.payment.sendLightningReceipt.payment_route); + console.log(satoshis); + const fee = parseInt(state.payment.sendLightningReceipt.payment_route.total_fees, 10); + await runAction( + setAppConfig(msg.origin.domain, { + ...config, + allowance: { + ...allowance, + balance: allowance.balance - satoshis - fee, + }, + }), + ); + return true; } function postDataMessage(data: any) { diff --git a/src/content_script/store.ts b/src/content_script/store.ts index ace01b1b..fb8df372 100644 --- a/src/content_script/store.ts +++ b/src/content_script/store.ts @@ -35,21 +35,21 @@ export async function waitForStoreState( } // Run a selector, but ensure the store has fully synced first -type Selector = (s: AppState) => T; -export async function runSelector(selector: Selector): Promise { +type Selector = (s: AppState, ...args: any[]) => T; +export async function runSelector(selector: Selector, ...args: any[]): Promise { const state = await waitForStoreState(s => s.sync.hasSynced); - return selector(state); + return selector(state, ...args); } // Returns a promise that resolves once an action has run and the state has hit // certain conditions. export async function runAction( action: any, - check: WaitStateCheckFunction, + check?: WaitStateCheckFunction, fresh?: boolean, ): Promise { const s = getStore(fresh); await waitForStoreState(state => state.sync.hasSynced); s.dispatch(action); - return waitForStoreState(check); + return check ? waitForStoreState(check) : Promise.resolve(s.getState()); } From 36d6d59ab03169de38f3c0b030653de48bf630c3 Mon Sep 17 00:00:00 2001 From: Will O'Beirne Date: Thu, 6 Jun 2019 17:49:21 -0400 Subject: [PATCH 05/11] Notifications for autopayments. --- src/app/components/AllowanceForm/index.less | 8 ++- src/app/components/AllowanceForm/index.tsx | 65 ++++++++++++++++++-- src/app/modules/appconf/types.ts | 1 + src/app/utils/constants.ts | 1 + src/background_script/handleNotifications.ts | 23 +++++++ src/background_script/index.ts | 2 + src/content_script/notifications.ts | 47 ++++++++++++++ src/content_script/respondWithoutPrompt.ts | 64 +++++++++++++++---- src/util/messages.ts | 15 +++++ static/manifest.json | 2 +- 10 files changed, 208 insertions(+), 20 deletions(-) create mode 100644 src/background_script/handleNotifications.ts create mode 100644 src/content_script/notifications.ts diff --git a/src/app/components/AllowanceForm/index.less b/src/app/components/AllowanceForm/index.less index 6238a566..62ac6748 100644 --- a/src/app/components/AllowanceForm/index.less +++ b/src/app/components/AllowanceForm/index.less @@ -34,6 +34,8 @@ } &-balance { + padding-bottom: 0; + .ant-form-item-children { display: flex; } @@ -44,7 +46,7 @@ &-bar { margin-top: -1rem; - margin-left: 1rem; + margin-left: 1.5rem; .ant-progress-text small { font-size: 0.65rem; @@ -59,10 +61,10 @@ } .ant-form-item-label label { - font-size: 0.7rem; + font-size: 0.65rem; text-transform: uppercase; font-weight: bold; - letter-spacing: 0.08rem; + letter-spacing: 0.04rem; opacity: 0.7; } } diff --git a/src/app/components/AllowanceForm/index.tsx b/src/app/components/AllowanceForm/index.tsx index 93e872ac..6d6d7ef7 100644 --- a/src/app/components/AllowanceForm/index.tsx +++ b/src/app/components/AllowanceForm/index.tsx @@ -1,5 +1,6 @@ import React from 'react'; import classnames from 'classnames'; +import { browser } from 'webextension-polyfill-ts'; import { connect } from 'react-redux'; import { Button, Input, Switch, Form, Progress, Row, Col, Icon, Modal } from 'antd'; import { Allowance, AppConfig } from 'modules/appconf/types'; @@ -20,9 +21,25 @@ interface DispatchProps { type Props = OwnProps & DispatchProps; -class AllowancesPage extends React.Component { +interface State { + hasNotifPermission: boolean; +} + +class AllowancesPage extends React.Component { + state: State = { + hasNotifPermission: false, + }; + + async componentDidMount() { + const perms = await browser.permissions.getAll(); + this.setState({ + hasNotifPermission: (perms.permissions || []).includes('notifications'), + }); + } + render() { const { domain, appConfig } = this.props; + const { hasNotifPermission } = this.state; const allowance = appConfig.allowance || DEFAULT_ALLOWANCE; return ( @@ -51,7 +68,12 @@ class AllowancesPage extends React.Component { onChange={this.handleChangeAllowanceField} addonAfter="sats" /> - @@ -70,7 +92,7 @@ class AllowancesPage extends React.Component { /> - + { /> - + + + + + + @@ -131,6 +161,31 @@ class AllowancesPage extends React.Component { }); }; + private toggleAllowanceNotifications = async (notifications: boolean) => { + const { appConfig, domain } = this.props; + const { hasNotifPermission } = this.state; + const allowance = appConfig.allowance || DEFAULT_ALLOWANCE; + + // Request permission first, noop if they deny it + if (!hasNotifPermission) { + const granted = await browser.permissions.request({ + permissions: ['notifications'], + }); + if (!granted) { + return; + } + this.setState({ hasNotifPermission: true }); + } + + this.props.setAppConfig(domain, { + ...appConfig, + allowance: { + ...allowance, + notifications, + }, + }); + }; + private refillAllowance = () => { const { appConfig, domain } = this.props; const allowance = appConfig.allowance || DEFAULT_ALLOWANCE; diff --git a/src/app/modules/appconf/types.ts b/src/app/modules/appconf/types.ts index 713555da..6e27927d 100644 --- a/src/app/modules/appconf/types.ts +++ b/src/app/modules/appconf/types.ts @@ -7,6 +7,7 @@ enum AppconfTypes { export interface Allowance { active: boolean; + notifications: boolean; total: number; balance: number; maxPerPayment: number; diff --git a/src/app/utils/constants.ts b/src/app/utils/constants.ts index 56eb7263..6b39715d 100644 --- a/src/app/utils/constants.ts +++ b/src/app/utils/constants.ts @@ -176,6 +176,7 @@ export const CHAIN_PREFIXES = [ export const DEFAULT_ALLOWANCE: Allowance = { active: true, + notifications: true, total: 10000, balance: 10000, maxPerPayment: 100, diff --git a/src/background_script/handleNotifications.ts b/src/background_script/handleNotifications.ts new file mode 100644 index 00000000..68271072 --- /dev/null +++ b/src/background_script/handleNotifications.ts @@ -0,0 +1,23 @@ +import { browser } from 'webextension-polyfill-ts'; +import { isNotificationMessage } from '../util/messages'; + +export default function handleNotifications() { + browser.runtime.onMessage.addListener(request => { + if (!isNotificationMessage(request)) { + return; + } + + const { method, id, options } = request.args; + if (method === 'create' && options) { + browser.notifications.create(id, options); + } else if (method === 'update' && id && options) { + browser.notifications.clear(id).then(() => { + browser.notifications.create(id, options); + }); + } else if (method === 'clear' && id) { + browser.notifications.clear(id); + } else { + console.warn('Malformed notification message:', request); + } + }); +} diff --git a/src/background_script/index.ts b/src/background_script/index.ts index 9354fdcb..85391553 100755 --- a/src/background_script/index.ts +++ b/src/background_script/index.ts @@ -1,11 +1,13 @@ import handlePrompts from './handlePrompts'; import handlePassword from './handlePassword'; import handleContextMenu from './handleContextMenu'; +import handleNotifications from './handleNotifications'; function initBackground() { handlePrompts(); handlePassword(); handleContextMenu(); + handleNotifications(); } initBackground(); diff --git a/src/content_script/notifications.ts b/src/content_script/notifications.ts new file mode 100644 index 00000000..1b78e4f6 --- /dev/null +++ b/src/content_script/notifications.ts @@ -0,0 +1,47 @@ +import { browser, Notifications } from 'webextension-polyfill-ts'; +import { NotificationMessage } from '../util/messages'; + +const DEFAULT_SETTINGS = { + iconUrl: 'icon128.png', +}; + +export function createNotification( + options: Notifications.CreateNotificationOptions, + id?: string, +) { + browser.runtime.sendMessage({ + application: 'Joule', + notification: true, + args: { + method: 'create', + options: { ...DEFAULT_SETTINGS, ...options }, + id, + }, + } as NotificationMessage); +} + +export function updateNotification( + options: Notifications.CreateNotificationOptions, + id: string, +) { + browser.runtime.sendMessage({ + application: 'Joule', + notification: true, + args: { + method: 'update', + options: { ...DEFAULT_SETTINGS, ...options }, + id, + }, + } as NotificationMessage); +} + +export async function clearNotification(id: string) { + browser.runtime.sendMessage({ + application: 'Joule', + notification: true, + args: { + method: 'clear', + id, + }, + } as NotificationMessage); +} diff --git a/src/content_script/respondWithoutPrompt.ts b/src/content_script/respondWithoutPrompt.ts index f23715c6..af2b60b4 100644 --- a/src/content_script/respondWithoutPrompt.ts +++ b/src/content_script/respondWithoutPrompt.ts @@ -10,6 +10,9 @@ import { AuthorizePromptMessage, PaymentPromptMessage, } from '../util/messages'; +import { createNotification, updateNotification } from './notifications'; + +let lastPaymentAttempt = 0; export default async function respondWithoutPrompt( msg: AnyPromptMessage, @@ -25,7 +28,7 @@ export default async function respondWithoutPrompt( async function handleAuthorizePrompt(msg: AuthorizePromptMessage) { const { domain } = msg.origin; - const settings = await runSelector(selectSettings); + const settings = await runSelector(selectSettings, true); if (domain) { if (settings.enabledDomains.includes(domain)) { @@ -41,8 +44,6 @@ async function handleAuthorizePrompt(msg: AuthorizePromptMessage) { } async function handleAutoPayment(msg: PaymentPromptMessage) { - console.log(msg); - // Pop up for non-fixed invoices const { satoshis } = bolt11.decode(msg.args.paymentRequest); if (!satoshis) { @@ -52,17 +53,36 @@ async function handleAutoPayment(msg: PaymentPromptMessage) { // Grab the available allowance, if possible const config = await runSelector(selectConfigByDomain, msg.origin.domain); if (!config || !config.allowance || !config.allowance.active) { - return; + return false; } // Check that the payment is allowed via our allowance constraints const { allowance } = config; - console.log('allowance', allowance); if (satoshis > allowance.maxPerPayment || satoshis > allowance.balance) { return false; } - // Attempt to send the payment + // Don't allow payments to happen too fast + const last = lastPaymentAttempt; + const now = Date.now(); + lastPaymentAttempt = now; + if (last + allowance.minIntervalPerPayment * 1000 > now) { + console.warn('Site attempted to make payments too fast for allowance payment'); + return false; + } + + // Attempt to send the payment and show a notification + const notifId = Math.random().toString(); + console.log(notifId); + createNotification( + { + type: 'basic', + title: 'Autopaying invoice', + message: `Paying ${satoshis} from your allowance`, + }, + notifId, + ); + const state = await runAction( sendPayment({ payment_request: msg.args.paymentRequest, @@ -82,23 +102,45 @@ async function handleAutoPayment(msg: PaymentPromptMessage) { state.crypto.isRequestingPassword || !state.payment.sendLightningReceipt ) { + let message = 'An unknown error caused the payment to fail'; + if (state.crypto.isRequestingPassword) { + message = 'Joule must be unlocked'; + } else if (state.payment.sendError) { + message = state.payment.sendError.message; + } + updateNotification( + { + type: 'basic', + title: 'Autopayment failed', + message, + }, + notifId, + ); return false; } - // Reduce their allowance balance by cost + fee and return true - console.log(allowance.balance); - console.log(state.payment.sendLightningReceipt.payment_route); - console.log(satoshis); + // Reduce their allowance balance by cost + fee and show notification const fee = parseInt(state.payment.sendLightningReceipt.payment_route.total_fees, 10); + const balance = allowance.balance - satoshis - (fee || 0); await runAction( setAppConfig(msg.origin.domain, { ...config, allowance: { ...allowance, - balance: allowance.balance - satoshis - fee, + balance, }, }), ); + + updateNotification( + { + type: 'basic', + title: 'Payment complete!', + message: `${balance} sats of allowance remaining`, + }, + notifId, + ); + return true; } diff --git a/src/util/messages.ts b/src/util/messages.ts index 9ea5e13b..4ceeb506 100644 --- a/src/util/messages.ts +++ b/src/util/messages.ts @@ -1,4 +1,5 @@ import { RequestInvoiceArgs } from 'webln'; +import { Notifications } from 'webextension-polyfill-ts'; import { PROMPT_TYPE } from '../webln/types'; export interface BaseMessage { @@ -68,6 +69,20 @@ export interface ContextMenuMessage extends BaseMessage { }; } +// Notification messages +export interface NotificationMessage extends BaseMessage { + notification: true; + args: { + method: 'create' | 'update' | 'clear'; + id?: string; + options?: Notifications.CreateNotificationOptions; + }; +} + +export function isNotificationMessage(msg: any): msg is NotificationMessage { + return msg && msg.application === 'Joule' && msg.notification === true; +} + // Password messages export interface SetPasswordMessage extends BaseMessage { setPassword: true; diff --git a/static/manifest.json b/static/manifest.json index 3eec902b..542550f4 100644 --- a/static/manifest.json +++ b/static/manifest.json @@ -32,7 +32,7 @@ "persistent": true }, "web_accessible_resources": ["inpage_script.js"], - "permissions": ["storage", "clipboardWrite", "activeTab", "contextMenus"], + "permissions": ["storage", "activeTab", "contextMenus"], "optional_permissions": ["notifications", "http://*/", "https://*/"], "applications": { "gecko": { From 219ed7d485e44b6c970c30322a2166cca2774e7f Mon Sep 17 00:00:00 2001 From: Will O'Beirne Date: Thu, 6 Jun 2019 21:14:06 -0400 Subject: [PATCH 06/11] Fix switch toggling on --- src/app/components/AllowanceForm/index.tsx | 15 ++++++++++----- src/content_script/respondWithoutPrompt.ts | 3 +-- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/src/app/components/AllowanceForm/index.tsx b/src/app/components/AllowanceForm/index.tsx index 6d6d7ef7..527dc02b 100644 --- a/src/app/components/AllowanceForm/index.tsx +++ b/src/app/components/AllowanceForm/index.tsx @@ -22,24 +22,27 @@ interface DispatchProps { type Props = OwnProps & DispatchProps; interface State { + checkingNotifPermission: boolean; hasNotifPermission: boolean; } class AllowancesPage extends React.Component { state: State = { + checkingNotifPermission: true, hasNotifPermission: false, }; async componentDidMount() { const perms = await browser.permissions.getAll(); this.setState({ + checkingNotifPermission: false, hasNotifPermission: (perms.permissions || []).includes('notifications'), }); } render() { const { domain, appConfig } = this.props; - const { hasNotifPermission } = this.state; + const { checkingNotifPermission, hasNotifPermission } = this.state; const allowance = appConfig.allowance || DEFAULT_ALLOWANCE; return ( @@ -114,10 +117,12 @@ class AllowancesPage extends React.Component { - + {!checkingNotifPermission && ( + + )} diff --git a/src/content_script/respondWithoutPrompt.ts b/src/content_script/respondWithoutPrompt.ts index af2b60b4..c13590e8 100644 --- a/src/content_script/respondWithoutPrompt.ts +++ b/src/content_script/respondWithoutPrompt.ts @@ -73,12 +73,11 @@ async function handleAutoPayment(msg: PaymentPromptMessage) { // Attempt to send the payment and show a notification const notifId = Math.random().toString(); - console.log(notifId); createNotification( { type: 'basic', title: 'Autopaying invoice', - message: `Paying ${satoshis} from your allowance`, + message: `Paying ${satoshis} from your allowance...`, }, notifId, ); From 2261bd83211e864e742c25bfcdf58e2791528b08 Mon Sep 17 00:00:00 2001 From: Will O'Beirne Date: Fri, 7 Jun 2019 15:45:04 -0400 Subject: [PATCH 07/11] Greyscale when disbaled --- src/app/components/AllowanceForm/index.less | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/app/components/AllowanceForm/index.less b/src/app/components/AllowanceForm/index.less index 62ac6748..a770dd56 100644 --- a/src/app/components/AllowanceForm/index.less +++ b/src/app/components/AllowanceForm/index.less @@ -27,9 +27,11 @@ &-fields { padding: 1rem; + transition: opacity 200ms ease, filter 200ms ease; &.is-inactive { - opacity: 0.2; + opacity: 0.4; + filter: grayscale(1); pointer-events: none; } From cd93c27583b23e5377b255fac4dd2b72ec9edbaa Mon Sep 17 00:00:00 2001 From: Will O'Beirne Date: Wed, 12 Jun 2019 17:43:58 -0400 Subject: [PATCH 08/11] Show a banner on WebLN enabled pages that gives quick access to controls. --- src/app/components/ActiveAppBanner/index.less | 41 ++++++ src/app/components/ActiveAppBanner/index.tsx | 137 ++++++++++++++++++ src/app/components/AllowanceForm/index.less | 6 +- src/app/components/AllowanceForm/index.tsx | 4 +- src/app/components/SettingsMenu.tsx | 3 +- src/app/components/Tooltip.tsx | 35 +++++ src/app/modules/settings/actions.ts | 9 +- src/app/pages/allowances.less | 8 + src/app/pages/allowances.tsx | 52 +++++-- src/app/pages/home.tsx | 4 +- src/app/static/images/piggy-bank.svg | 10 ++ src/app/utils/formatters.ts | 6 + src/app/utils/validators.ts | 9 +- 13 files changed, 300 insertions(+), 24 deletions(-) create mode 100644 src/app/components/ActiveAppBanner/index.less create mode 100644 src/app/components/ActiveAppBanner/index.tsx create mode 100644 src/app/components/Tooltip.tsx create mode 100644 src/app/static/images/piggy-bank.svg diff --git a/src/app/components/ActiveAppBanner/index.less b/src/app/components/ActiveAppBanner/index.less new file mode 100644 index 00000000..82df70c9 --- /dev/null +++ b/src/app/components/ActiveAppBanner/index.less @@ -0,0 +1,41 @@ +@import '~style/variables.less'; + +.ActiveAppBanner { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0 1rem; + + &.is-enabled { + background: @primary-color; + } + + &.is-rejected { + background: @error-color; + } + + &-message { + color: #fff; + padding: 0.4rem 0; + font-size: 0.8rem; + } + + &-actions { + display: flex; + + &-btn { + color: #fff; + cursor: pointer; + padding: 0 0.4rem; + opacity: 0.7; + font-size: 1rem; + background: none; + border: none; + outline: none; + + &:hover { + opacity: 1; + } + } + } +} diff --git a/src/app/components/ActiveAppBanner/index.tsx b/src/app/components/ActiveAppBanner/index.tsx new file mode 100644 index 00000000..e3c9d383 --- /dev/null +++ b/src/app/components/ActiveAppBanner/index.tsx @@ -0,0 +1,137 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { browser } from 'webextension-polyfill-ts'; +import { Icon } from 'antd'; +import { withRouter, RouteComponentProps } from 'react-router'; +import { AppState } from 'store/reducers'; +import { + addEnabledDomain, + removeEnabledDomain, + addRejectedDomain, + removeRejectedDomain, +} from 'modules/settings/actions'; +import { shortDomain } from 'utils/formatters'; +import Tooltip from 'components/Tooltip'; +import AllowanceIcon from 'static/images/piggy-bank.svg'; +import './index.less'; + +interface StateProps { + enabledDomains: AppState['settings']['enabledDomains']; + rejectedDomains: AppState['settings']['rejectedDomains']; +} + +interface DispatchProps { + addEnabledDomain: typeof addEnabledDomain; + removeEnabledDomain: typeof removeEnabledDomain; + addRejectedDomain: typeof addRejectedDomain; + removeRejectedDomain: typeof removeRejectedDomain; +} + +type Props = StateProps & DispatchProps & RouteComponentProps; + +interface State { + currentOrigin: string; +} + +class ActiveAppBanner extends React.Component { + state: State = { + currentOrigin: '', + }; + + async componentDidMount() { + try { + const [tab] = await browser.tabs.query({ + active: true, + currentWindow: true, + }); + if (tab.url) { + this.setState({ currentOrigin: new URL(tab.url).origin }); + } + } catch (err) { + // no-op, just don't render the banner + } + } + + render() { + const { enabledDomains, rejectedDomains } = this.props; + const { currentOrigin } = this.state; + const isEnabled = enabledDomains.includes(currentOrigin); + const isRejected = rejectedDomains.includes(currentOrigin); + + // Render nothing if they're not on a tab, or it's not a webln page + if (!currentOrigin || (!isEnabled && !isRejected)) { + return null; + } + + if (isRejected) { + return ( +
+
+ {shortDomain(currentOrigin)} is rejected +
+
+ + + +
+
+ ); + } + + if (isEnabled) { + return ( +
+
+ {shortDomain(currentOrigin)} is enabled +
+
+ + + + + + +
+
+ ); + } + } + + private enable = () => { + this.props.addEnabledDomain(this.state.currentOrigin); + this.props.removeRejectedDomain(this.state.currentOrigin); + }; + + private reject = () => { + this.props.addRejectedDomain(this.state.currentOrigin); + this.props.removeEnabledDomain(this.state.currentOrigin); + }; + + private goToAllowance = () => { + this.props.history.push(`/allowances`, { domain: this.state.currentOrigin }); + }; +} + +const ConnectedActiveAppBanner = connect( + state => ({ + enabledDomains: state.settings.enabledDomains, + rejectedDomains: state.settings.rejectedDomains, + }), + { + addEnabledDomain, + removeEnabledDomain, + addRejectedDomain, + removeRejectedDomain, + }, +)(ActiveAppBanner); + +export default withRouter(ConnectedActiveAppBanner); diff --git a/src/app/components/AllowanceForm/index.less b/src/app/components/AllowanceForm/index.less index a770dd56..6d403606 100644 --- a/src/app/components/AllowanceForm/index.less +++ b/src/app/components/AllowanceForm/index.less @@ -7,7 +7,7 @@ border-bottom: 1px solid rgba(#000, 0.15); &-domain { - font-size: 1.1rem; + font-size: 1rem; font-weight: bold; } @@ -36,7 +36,9 @@ } &-balance { - padding-bottom: 0; + &.ant-form-item { + padding-bottom: 0; + } .ant-form-item-children { display: flex; diff --git a/src/app/components/AllowanceForm/index.tsx b/src/app/components/AllowanceForm/index.tsx index 527dc02b..c9a67a3f 100644 --- a/src/app/components/AllowanceForm/index.tsx +++ b/src/app/components/AllowanceForm/index.tsx @@ -95,7 +95,7 @@ class AllowancesPage extends React.Component { /> - + { /> - + {
- Allowances + Allowances diff --git a/src/app/components/Tooltip.tsx b/src/app/components/Tooltip.tsx new file mode 100644 index 00000000..cb15513d --- /dev/null +++ b/src/app/components/Tooltip.tsx @@ -0,0 +1,35 @@ +import { Tooltip as AntdTooltip } from 'antd'; + +// Overrides antd tooltip to fix buggy arrow +export default class Tooltip extends AntdTooltip { + onPopupAlign = (popup: HTMLElement, align: any) => { + const placements = this.getPlacements(); + // Get the current placement + const placement = Object.keys(placements).filter( + key => + placements[key].points[0] === align.points[0] && + placements[key].points[1] === align.points[1], + )[0]; + if (!placement) { + return; + } + + const target = (this as any).tooltip.trigger.getRootDomNode(); + const arrow: HTMLDivElement | null = popup.querySelector('.ant-tooltip-arrow'); + if (!arrow) return; + + // Get the rect of the target element. + const rect = target.getBoundingClientRect(); + + // Only the top/bottom/left/right placements should be handled + if (/^(top|bottom)$/.test(placement)) { + const { left, width } = rect; + const arrowOffset = left + width / 2 - popup.offsetLeft; + arrow.style.left = `${arrowOffset}px`; + } else if (/^(left|right)$/.test(placement)) { + const { top, height } = rect; + const arrowOffset = top + height / 2 - popup.offsetTop; + arrow.style.top = `${arrowOffset}px`; + } + }; +} diff --git a/src/app/modules/settings/actions.ts b/src/app/modules/settings/actions.ts index bbaa7ff0..1b9a494e 100644 --- a/src/app/modules/settings/actions.ts +++ b/src/app/modules/settings/actions.ts @@ -1,5 +1,6 @@ import types from './types'; import { SettingsState } from './reducers'; +import { normalizeDomain } from 'utils/formatters'; export function changeSettings(changes: Partial) { return { @@ -22,27 +23,27 @@ export function clearSettings() { export function addEnabledDomain(domain: string) { return { type: types.ADD_ENABLED_DOMAIN, - payload: domain, + payload: normalizeDomain(domain), }; } export function removeEnabledDomain(domain: string) { return { type: types.REMOVE_ENABLED_DOMAIN, - payload: domain, + payload: normalizeDomain(domain), }; } export function addRejectedDomain(domain: string) { return { type: types.ADD_REJECTED_DOMAIN, - payload: domain, + payload: normalizeDomain(domain), }; } export function removeRejectedDomain(domain: string) { return { type: types.REMOVE_REJECTED_DOMAIN, - payload: domain, + payload: normalizeDomain(domain), }; } diff --git a/src/app/pages/allowances.less b/src/app/pages/allowances.less index f78023cd..5840a236 100644 --- a/src/app/pages/allowances.less +++ b/src/app/pages/allowances.less @@ -29,6 +29,7 @@ &-add { width: 100%; + margin-top: -0.25rem; &-input { .ant-select-auto-complete & .ant-input, @@ -45,4 +46,11 @@ } } } + + &-addHint { + margin-top: 0.3rem; + margin-bottom: -0.5rem; + opacity: 0.7; + font-size: 0.7rem; + } } diff --git a/src/app/pages/allowances.tsx b/src/app/pages/allowances.tsx index 176db3df..80eaf63a 100644 --- a/src/app/pages/allowances.tsx +++ b/src/app/pages/allowances.tsx @@ -1,10 +1,11 @@ import React from 'react'; import { connect } from 'react-redux'; +import { withRouter, RouteComponentProps } from 'react-router'; import { Select, Button, Modal, AutoComplete, Input, Icon, message } from 'antd'; import BigMessage from 'components/BigMessage'; import AllowanceForm from 'components/AllowanceForm'; -import { normalizeDomain } from 'utils/formatters'; -import { isSimpleDomain } from 'utils/validators'; +import { shortDomain, removeDomainPrefix } from 'utils/formatters'; +import { isValidDomain } from 'utils/validators'; import { DEFAULT_ALLOWANCE } from 'utils/constants'; import { setAppConfig } from 'modules/appconf/actions'; import { AppState } from 'store/reducers'; @@ -19,7 +20,7 @@ interface DispatchProps { setAppConfig: typeof setAppConfig; } -type Props = StateProps & DispatchProps; +type Props = StateProps & DispatchProps & RouteComponentProps; interface State { domain: string; @@ -32,6 +33,23 @@ class AllowancesPage extends React.Component { isAdding: false, }; + componentDidMount() { + // Redirects with domain specified are set by default + const { location, appConfigs } = this.props; + if (location.state && location.state.domain) { + this.setState({ domain: location.state.domain }); + // If they don't already have an allowance, make an inactive one for them + if (!appConfigs[location.state.domain]) { + this.props.setAppConfig(location.state.domain, { + allowance: { + ...DEFAULT_ALLOWANCE, + active: false, + }, + }); + } + } + } + render() { const { appConfigs, enabledDomains } = this.props; const { domain, isAdding } = this.state; @@ -52,7 +70,7 @@ class AllowancesPage extends React.Component { > {configDomains.map(d => ( - {d} + {shortDomain(d)} ))} @@ -90,10 +108,13 @@ class AllowancesPage extends React.Component { className="Allowances-add-input" placeholder="Enter or select a domain" enterButton={} - onSearch={v => this.submitAddDomain(v)} + onSearch={v => { + setTimeout(() => this.submitAddDomain(v), 100); + }} autoFocus /> +
Must specify http:// or https://
); @@ -104,18 +125,23 @@ class AllowancesPage extends React.Component { }; private filterAddDomains = (val: string, option: any) => { - return option.key.indexOf(val.toLowerCase()) === 0; + return ( + option.key.indexOf(val.toLowerCase()) === 0 || + removeDomainPrefix(option.key).indexOf(val.toLowerCase()) === 0 + ); }; private submitAddDomain = (domain: string) => { - if (!isSimpleDomain(domain)) { + if (!isValidDomain(domain)) { message.warn('Invalid domain name'); return; } - this.props.setAppConfig(domain, { - allowance: { ...DEFAULT_ALLOWANCE }, - }); + if (!this.props.appConfigs[domain]) { + this.props.setAppConfig(domain, { + allowance: { ...DEFAULT_ALLOWANCE }, + }); + } this.setState({ domain }); this.closeAddModal(); }; @@ -124,10 +150,12 @@ class AllowancesPage extends React.Component { private closeAddModal = () => this.setState({ isAdding: false }); } -export default connect( +const ConnectedAllowancesPage = connect( state => ({ appConfigs: state.appconf.configs, - enabledDomains: state.settings.enabledDomains.map(normalizeDomain), + enabledDomains: state.settings.enabledDomains, }), { setAppConfig }, )(AllowancesPage); + +export default withRouter(ConnectedAllowancesPage); diff --git a/src/app/pages/home.tsx b/src/app/pages/home.tsx index 273e18c6..f4a1c59f 100644 --- a/src/app/pages/home.tsx +++ b/src/app/pages/home.tsx @@ -8,8 +8,9 @@ import SendForm from 'components/SendForm'; import InvoiceForm from 'components/InvoiceForm'; import TransactionInfo from 'components/TransactionInfo'; import ConnectionFailureModal from 'components/ConnectionFailureModal'; -import { AppState } from 'store/reducers'; import ChannelInfo from 'components/ChannelInfo'; +import ActiveAppBanner from 'components/ActiveAppBanner'; +import { AppState } from 'store/reducers'; import { ChannelWithNode } from 'modules/channels/types'; import { AnyTransaction } from 'modules/account/types'; import { getAccountInfo } from 'modules/account/actions'; @@ -48,6 +49,7 @@ class HomePage extends React.Component { return (
+ + + + Artboard + Created with Sketch. + + + + + diff --git a/src/app/utils/formatters.ts b/src/app/utils/formatters.ts index fa6ad2b5..ac75b601 100755 --- a/src/app/utils/formatters.ts +++ b/src/app/utils/formatters.ts @@ -84,7 +84,13 @@ export function removeDomainPrefix(domain: string) { return domain.replace(/^(?:https?:\/\/)?(?:www\.)?/i, ''); } +// Domain without trailing slash and lowercase'd, for use as a key export function normalizeDomain(domain: string) { + return domain.toLowerCase().replace(/\/$/, ''); +} + +// Domain sans prefix, lowercase'd +export function shortDomain(domain: string) { return removeDomainPrefix(domain).toLowerCase(); } diff --git a/src/app/utils/validators.ts b/src/app/utils/validators.ts index 0fca6e2e..d21bbdb6 100755 --- a/src/app/utils/validators.ts +++ b/src/app/utils/validators.ts @@ -68,6 +68,11 @@ export function isSegwitAddress(address: string): boolean { return CHAIN_PREFIXES.some(p => addrPrefix.substring(0, p.length) === p); } -export function isSimpleDomain(domain: string): boolean { - return /^[a-zA-Z0-9][a-zA-Z0-9-]{1,61}[a-zA-Z0-9]\.[a-zA-Z]{2,}$/.test(domain); +export function isValidDomain(domain: string): boolean { + try { + new URL(domain); // tslint:disable-line + return true; + } catch (err) { + return false; + } } From 81ceec611d5a55230b92e39fb8b843655d500cdf Mon Sep 17 00:00:00 2001 From: Will O'Beirne Date: Sun, 1 Mar 2020 21:00:53 -0600 Subject: [PATCH 09/11] Fix background http proxy responding to all messages --- src/background_script/handleLndHttp.ts | 42 ++++++++++++++------------ 1 file changed, 23 insertions(+), 19 deletions(-) diff --git a/src/background_script/handleLndHttp.ts b/src/background_script/handleLndHttp.ts index 1e7306b3..602080db 100644 --- a/src/background_script/handleLndHttp.ts +++ b/src/background_script/handleLndHttp.ts @@ -16,29 +16,33 @@ function isLndRequestMessage(req: any): req is LndAPIRequestMessage { + browser.runtime.onMessage.addListener((request: unknown) => { if (!isLndRequestMessage(request)) { return; } - const client = new LndHttpClient(request.url, request.macaroon); - const fn = client[request.method] as LndHttpClient[typeof request.method]; - const args = request.args as Parameters; + return new Promise(resolve => { + const client = new LndHttpClient(request.url, request.macaroon); + const fn = client[request.method] as LndHttpClient[typeof request.method]; + const args = request.args as Parameters; - return (fn as any)(...args) - .then((data: ReturnType) => { - return { - type: 'lnd-api-response', - method: request.method, - data, - } as LndAPIResponseMessage; - }) - .catch((err: LndAPIResponseError) => { - return { - type: 'lnd-api-response', - method: request.method, - error: err, - } as LndAPIResponseMessage; - }); + resolve( + (fn as any)(...args) + .then((data: ReturnType) => { + return { + type: 'lnd-api-response', + method: request.method, + data, + } as LndAPIResponseMessage; + }) + .catch((err: LndAPIResponseError) => { + return { + type: 'lnd-api-response', + method: request.method, + error: err, + } as LndAPIResponseMessage; + }), + ); + }); }); } From f86176be3b1cf3091e7a9e2acae1a0969963f11e Mon Sep 17 00:00:00 2001 From: Michael Bumann Date: Sat, 23 May 2020 22:39:19 +0200 Subject: [PATCH 10/11] Store last payment attempt in the allowance config The lastPaymentAttempt is needed to verify the cooldown period for autopayments. --- src/app/modules/appconf/types.ts | 1 + src/app/utils/constants.ts | 1 + src/content_script/respondWithoutPrompt.ts | 3 ++- 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/app/modules/appconf/types.ts b/src/app/modules/appconf/types.ts index 6e27927d..de19a0ea 100644 --- a/src/app/modules/appconf/types.ts +++ b/src/app/modules/appconf/types.ts @@ -12,6 +12,7 @@ export interface Allowance { balance: number; maxPerPayment: number; minIntervalPerPayment: number; + lastPaymentAttempt: number; } export interface AppConfig { diff --git a/src/app/utils/constants.ts b/src/app/utils/constants.ts index 72b944c7..7337edb9 100644 --- a/src/app/utils/constants.ts +++ b/src/app/utils/constants.ts @@ -256,6 +256,7 @@ export const DEFAULT_ALLOWANCE: Allowance = { balance: 10000, maxPerPayment: 100, minIntervalPerPayment: 1, + lastPaymentAttempt: 0, }; export const COLORS = { diff --git a/src/content_script/respondWithoutPrompt.ts b/src/content_script/respondWithoutPrompt.ts index c13590e8..6db3e98d 100644 --- a/src/content_script/respondWithoutPrompt.ts +++ b/src/content_script/respondWithoutPrompt.ts @@ -63,7 +63,7 @@ async function handleAutoPayment(msg: PaymentPromptMessage) { } // Don't allow payments to happen too fast - const last = lastPaymentAttempt; + const last = allowance.lastPaymentAttempt || lastPaymentAttempt; const now = Date.now(); lastPaymentAttempt = now; if (last + allowance.minIntervalPerPayment * 1000 > now) { @@ -127,6 +127,7 @@ async function handleAutoPayment(msg: PaymentPromptMessage) { allowance: { ...allowance, balance, + lastPaymentAttempt, }, }), ); From 4d888654dd6f4bfbebfb9f2ed67e49c8965ac39f Mon Sep 17 00:00:00 2001 From: Michael Bumann Date: Wed, 29 Jul 2020 23:45:16 +0200 Subject: [PATCH 11/11] Cleanup: lastPaymentAttempt is persisted in the allowance config --- src/content_script/respondWithoutPrompt.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/content_script/respondWithoutPrompt.ts b/src/content_script/respondWithoutPrompt.ts index 6db3e98d..4377eab2 100644 --- a/src/content_script/respondWithoutPrompt.ts +++ b/src/content_script/respondWithoutPrompt.ts @@ -12,8 +12,6 @@ import { } from '../util/messages'; import { createNotification, updateNotification } from './notifications'; -let lastPaymentAttempt = 0; - export default async function respondWithoutPrompt( msg: AnyPromptMessage, ): Promise { @@ -63,10 +61,8 @@ async function handleAutoPayment(msg: PaymentPromptMessage) { } // Don't allow payments to happen too fast - const last = allowance.lastPaymentAttempt || lastPaymentAttempt; const now = Date.now(); - lastPaymentAttempt = now; - if (last + allowance.minIntervalPerPayment * 1000 > now) { + if (allowance.lastPaymentAttempt + allowance.minIntervalPerPayment * 1000 > now) { console.warn('Site attempted to make payments too fast for allowance payment'); return false; } @@ -127,7 +123,7 @@ async function handleAutoPayment(msg: PaymentPromptMessage) { allowance: { ...allowance, balance, - lastPaymentAttempt, + lastPaymentAttempt: now, }, }), );