diff --git a/src/_locales/en/messages.json b/src/_locales/en/messages.json index 0d4b14e9..b0aab9d2 100755 --- a/src/_locales/en/messages.json +++ b/src/_locales/en/messages.json @@ -38,6 +38,13 @@ "pay_error_notEnoughFunds": { "message": "Not enough funds to facilitate payment." }, + "pay_error_invalidReceivers": { + "message": "At the moment, you can not pay this website.", + "description": "We cannot send money (probable cause: un-peered wallets)" + }, + "pay_error_notMonetized": { + "message": "This website is not monetized." + }, "outOfFunds_error_title": { "message": "Out of funds" }, @@ -83,5 +90,8 @@ }, "connectWallet_error_invalidClient": { "message": "Failed to connect. Please make sure you have added the public key to the correct wallet address." + }, + "allInvalidLinks_state_text": { + "message": "At the moment, you can not pay this website." } } diff --git a/src/background/services/events.ts b/src/background/services/events.ts index 1dc42e2e..bd4e12f8 100644 --- a/src/background/services/events.ts +++ b/src/background/services/events.ts @@ -4,6 +4,7 @@ import type { AmountValue, Storage, TabId } from '@/shared/types' interface BackgroundEvents { 'open_payments.key_revoked': void 'open_payments.out_of_funds': void + 'open_payments.invalid_receiver': { tabId: number } 'storage.rate_of_pay_update': { rate: string } 'storage.state_update': { state: Storage['state'] diff --git a/src/background/services/monetization.ts b/src/background/services/monetization.ts index 617c86ab..73106bf2 100644 --- a/src/background/services/monetization.ts +++ b/src/background/services/monetization.ts @@ -72,6 +72,7 @@ export class MonetizationService { payload.forEach((p) => { const { requestId, walletAddress: receiver } = p + // Q: How does this impact client side apps/routing? const existingSession = sessions.get(requestId) if (existingSession) { existingSession.stop() @@ -97,7 +98,8 @@ export class MonetizationService { this.events.emit('monetization.state_update', tabId) - const sessionsArr = this.tabState.getEnabledSessions(tabId) + const sessionsArr = this.tabState.getPayableSessions(tabId) + if (!sessionsArr.length) return const rate = computeRate(rateOfPay, sessionsArr.length) // Since we probe (through quoting) the debitAmount we have to await this call. @@ -162,7 +164,7 @@ export class MonetizationService { if (!rateOfPay) return if (needsAdjustAmount) { - const sessionsArr = this.tabState.getEnabledSessions(tabId) + const sessionsArr = this.tabState.getPayableSessions(tabId) this.events.emit('monetization.state_update', tabId) if (!sessionsArr.length) return const rate = computeRate(rateOfPay, sessionsArr.length) @@ -232,17 +234,21 @@ export class MonetizationService { async pay(amount: string) { const tab = await getCurrentActiveTab(this.browser) if (!tab || !tab.id) { - throw new Error('Could not find active tab.') + throw new Error('Unexpected error: could not find active tab.') } - const sessions = this.tabState.getEnabledSessions(tab.id) - if (!sessions.length) { - throw new Error('This website is not monetized.') + + const payableSessions = this.tabState.getPayableSessions(tab.id) + if (!payableSessions.length) { + if (this.tabState.getEnabledSessions(tab.id).length) { + throw new Error(this.t('pay_error_invalidReceivers')) + } + throw new Error(this.t('pay_error_notMonetized')) } - const splitAmount = Number(amount) / sessions.length + const splitAmount = Number(amount) / payableSessions.length // TODO: handle paying across two grants (when one grant doesn't have enough funds) const results = await Promise.allSettled( - sessions.map((session) => session.pay(splitAmount)) + payableSessions.map((session) => session.pay(splitAmount)) ) const totalSentAmount = results @@ -280,6 +286,7 @@ export class MonetizationService { this.onRateOfPayUpdate() this.onKeyRevoked() this.onOutOfFunds() + this.onInvalidReceiver() } private onRateOfPayUpdate() { @@ -299,7 +306,7 @@ export class MonetizationService { } for (const tabId of tabIds) { - const sessions = this.tabState.getEnabledSessions(tabId) + const sessions = this.tabState.getPayableSessions(tabId) if (!sessions.length) continue const computedRate = computeRate(rate, sessions.length) await this.adjustSessionsAmount(sessions, computedRate).catch((e) => { @@ -327,6 +334,15 @@ export class MonetizationService { }) } + private onInvalidReceiver() { + this.events.on('open_payments.invalid_receiver', async ({ tabId }) => { + if (this.tabState.tabHasAllSessionsInvalid(tabId)) { + this.logger.debug(`Tab ${tabId} has all sessions invalid`) + this.events.emit('monetization.state_update', tabId) + } + }) + } + private stopAllSessions() { for (const session of this.tabState.getAllSessions()) { session.stop() @@ -365,6 +381,9 @@ export class MonetizationService { } } const isSiteMonetized = this.tabState.isTabMonetized(tab.id!) + const hasAllSessionsInvalid = this.tabState.tabHasAllSessionsInvalid( + tab.id! + ) return { ...dataFromStorage, @@ -374,7 +393,8 @@ export class MonetizationService { oneTime: oneTimeGrant?.amount, recurring: recurringGrant?.amount }, - isSiteMonetized + isSiteMonetized, + hasAllSessionsInvalid } } diff --git a/src/background/services/paymentSession.ts b/src/background/services/paymentSession.ts index 0342cf9c..b4ec6fe1 100644 --- a/src/background/services/paymentSession.ts +++ b/src/background/services/paymentSession.ts @@ -22,6 +22,7 @@ import type { Logger } from '@/shared/logger' const HOUR_MS = 3600 * 1000 const MIN_SEND_AMOUNT = 1n // 1 unit +const MAX_INVALID_RECEIVER_ATTEMPTS = 2 type PaymentSessionSource = 'tab-change' | 'request-id-reused' | 'new-link' type IncomingPaymentSource = 'one-time' | 'continuous' @@ -29,12 +30,16 @@ type IncomingPaymentSource = 'one-time' | 'continuous' export class PaymentSession { private rate: string private active: boolean = false + /** Invalid receiver (providers not peered or other reasons) */ + private isInvalid: boolean = false + private countInvalidReceiver: number = 0 private isDisabled: boolean = false private incomingPaymentUrl: string private incomingPaymentExpiresAt: number private amount: string private intervalInMs: number private probingId: number + private shouldRetryImmediately: boolean = false private interval: ReturnType | null = null private timeout: ReturnType | null = null @@ -126,6 +131,12 @@ export class PaymentSession { } else if (isNonPositiveAmountError(e)) { amountToSend = BigInt(amountIter.next().value) continue + } else if (isInvalidReceiverError(e)) { + this.markInvalid() + this.events.emit('open_payments.invalid_receiver', { + tabId: this.tabId + }) + break } else { throw e } @@ -141,6 +152,10 @@ export class PaymentSession { return this.isDisabled } + get invalid() { + return this.isInvalid + } + disable() { this.isDisabled = true this.stop() @@ -155,6 +170,11 @@ export class PaymentSession { throw new Error('Method not implemented.') } + private markInvalid() { + this.isInvalid = true + this.stop() + } + stop() { this.active = false this.clearTimers() @@ -186,9 +206,9 @@ export class PaymentSession { async start(source: PaymentSessionSource) { this.debug( - `Attempting to start; source=${source} active=${this.active} disabled=${this.isDisabled}` + `Attempting to start; source=${source} active=${this.active} disabled=${this.isDisabled} isInvalid=${this.isInvalid}` ) - if (this.active || this.isDisabled) return + if (this.active || this.isDisabled || this.isInvalid) return this.debug(`Session started; source=${source}`) this.active = true @@ -216,12 +236,12 @@ export class PaymentSession { // Uncomment this after we perform the Rafiki test and remove the leftover // code below. // - // if (this.active && !this.isDisabled) { + // if (this.canContinuePayment) { // this.timeout = setTimeout(() => { // void this.payContinuous() // // this.interval = setInterval(() => { - // if (!this.active || this.isDisabled) { + // if (!this.canContinuePayment) { // this.clearTimers() // return // } @@ -232,26 +252,36 @@ export class PaymentSession { // Leftover const continuePayment = () => { - if (!this.active || this.isDisabled) return + if (!this.canContinuePayment) return // alternatively (leftover) after we perform the Rafiki test, we can just // skip the `.then()` here and call setTimeout recursively immediately void this.payContinuous().then(() => { - this.timeout = setTimeout(() => { - continuePayment() - }, this.intervalInMs) + this.timeout = setTimeout( + () => { + continuePayment() + }, + this.shouldRetryImmediately ? 0 : this.intervalInMs + ) }) } - if (this.active && !this.isDisabled) { - this.timeout = setTimeout(() => { - void this.payContinuous() - this.timeout = setTimeout(() => { - continuePayment() - }, this.intervalInMs) + if (this.canContinuePayment) { + this.timeout = setTimeout(async () => { + await this.payContinuous() + this.timeout = setTimeout( + () => { + continuePayment() + }, + this.shouldRetryImmediately ? 0 : this.intervalInMs + ) }, waitTime) } } + private get canContinuePayment() { + return this.active && !this.isDisabled && !this.isInvalid + } + private async setIncomingPaymentUrl(reset?: boolean) { if (this.incomingPaymentUrl && !reset) return @@ -426,21 +456,37 @@ export class PaymentSession { intervalInMs: this.intervalInMs }) } + this.shouldRetryImmediately = false } catch (e) { if (isKeyRevokedError(e)) { this.events.emit('open_payments.key_revoked') } else if (isTokenExpiredError(e)) { await this.openPaymentsService.rotateToken() + this.shouldRetryImmediately = true } else if (isOutOfBalanceError(e)) { const switched = await this.openPaymentsService.switchGrant() if (switched === null) { this.events.emit('open_payments.out_of_funds') + } else { + this.shouldRetryImmediately = true } } else if (isInvalidReceiverError(e)) { if (Date.now() >= this.incomingPaymentExpiresAt) { await this.setIncomingPaymentUrl(true) + this.shouldRetryImmediately = true } else { - throw e + ++this.countInvalidReceiver + if ( + this.countInvalidReceiver >= MAX_INVALID_RECEIVER_ATTEMPTS && + !this.isInvalid + ) { + this.markInvalid() + this.events.emit('open_payments.invalid_receiver', { + tabId: this.tabId + }) + } else { + this.shouldRetryImmediately = true + } } } else { throw e diff --git a/src/background/services/tabEvents.ts b/src/background/services/tabEvents.ts index 70f3107c..70577307 100644 --- a/src/background/services/tabEvents.ts +++ b/src/background/services/tabEvents.ts @@ -99,15 +99,21 @@ export class TabEvents { tabId: TabId, isTabMonetized: boolean = tabId ? this.tabState.isTabMonetized(tabId) + : false, + hasTabAllSessionsInvalid: boolean = tabId + ? this.tabState.tabHasAllSessionsInvalid(tabId) : false ) => { const { enabled, state } = await this.storage.get(['enabled', 'state']) const { path, title, isMonetized } = this.getIconAndTooltip({ enabled, state, - isTabMonetized + isTabMonetized, + hasTabAllSessionsInvalid }) + this.sendToPopup.send('SET_IS_MONETIZED', isMonetized) + this.sendToPopup.send('SET_ALL_SESSIONS_INVALID', hasTabAllSessionsInvalid) await this.setIconAndTooltip(path, title, tabId) } @@ -124,15 +130,17 @@ export class TabEvents { private getIconAndTooltip({ enabled, state, - isTabMonetized + isTabMonetized, + hasTabAllSessionsInvalid }: { enabled: Storage['enabled'] state: Storage['state'] isTabMonetized: boolean + hasTabAllSessionsInvalid: boolean }) { let title = this.t('appName') let iconData = ICONS.default - if (!isOkState(state)) { + if (!isOkState(state) || hasTabAllSessionsInvalid) { iconData = enabled ? ICONS.enabled_warn : ICONS.disabled_warn const tabStateText = this.t('icon_state_actionRequired') title = `${title} - ${tabStateText}` diff --git a/src/background/services/tabState.ts b/src/background/services/tabState.ts index de24914c..a74c9078 100644 --- a/src/background/services/tabState.ts +++ b/src/background/services/tabState.ts @@ -95,10 +95,19 @@ export class TabState { return [...this.getSessions(tabId).values()].filter((s) => !s.disabled) } + getPayableSessions(tabId: TabId) { + return this.getEnabledSessions(tabId).filter((s) => !s.invalid) + } + isTabMonetized(tabId: TabId) { return this.getEnabledSessions(tabId).length > 0 } + tabHasAllSessionsInvalid(tabId: TabId) { + const sessions = this.getEnabledSessions(tabId) + return sessions.length > 0 && sessions.every((s) => s.invalid) + } + getAllSessions() { return [...this.sessions.values()].flatMap((s) => [...s.values()]) } diff --git a/src/background/utils.test.ts b/src/background/utils.test.ts index bbf85e72..ffaba9c7 100644 --- a/src/background/utils.test.ts +++ b/src/background/utils.test.ts @@ -65,4 +65,17 @@ describe('getNextSendableAmount', () => { '80' ]) }) + + it('from assetScale 2 to 2', () => { + expect(take(getNextSendableAmount(2, 2), 8)).toEqual([ + '1', + '2', + '4', + '8', + '15', + '27', + '47', + '80' + ]) + }) }) diff --git a/src/popup/components/AllSessionsInvalid.tsx b/src/popup/components/AllSessionsInvalid.tsx new file mode 100644 index 00000000..6b172c1d --- /dev/null +++ b/src/popup/components/AllSessionsInvalid.tsx @@ -0,0 +1,15 @@ +import React from 'react' +import { WarningSign } from '@/popup/components/Icons' +import { useTranslation } from '@/popup/lib/context' + +export const AllSessionsInvalid = () => { + const t = useTranslation() + return ( +
+
+ +
+

{t('allInvalidLinks_state_text')}

+
+ ) +} diff --git a/src/popup/components/WarningMessage.tsx b/src/popup/components/WarningMessage.tsx new file mode 100644 index 00000000..aac52ac1 --- /dev/null +++ b/src/popup/components/WarningMessage.tsx @@ -0,0 +1,32 @@ +import React from 'react' +import { WarningSign } from './Icons' +import { cn } from '@/shared/helpers' + +interface WarningMessageProps extends React.HTMLAttributes { + warning?: string +} +export const WarningMessage = React.forwardRef< + HTMLDivElement, + WarningMessageProps +>(({ warning, className, children, ...props }, ref) => { + if (!warning) return null + + return ( +
+ +
+ {warning} + {children} +
+
+ ) +}) + +WarningMessage.displayName = 'WarningMessage' diff --git a/src/popup/pages/Home.tsx b/src/popup/pages/Home.tsx index 3f808fcf..1cb8db40 100644 --- a/src/popup/pages/Home.tsx +++ b/src/popup/pages/Home.tsx @@ -13,6 +13,7 @@ import { PayWebsiteForm } from '../components/PayWebsiteForm' import { SiteNotMonetized } from '@/popup/components/SiteNotMonetized' import { debounceAsync } from '@/shared/helpers' import { Switch } from '../components/ui/Switch' +import { AllSessionsInvalid } from '@/popup/components/AllSessionsInvalid' const updateRateOfPay = debounceAsync(updateRateOfPay_, 1000) @@ -26,7 +27,8 @@ export const Component = () => { maxRateOfPay, balance, walletAddress, - url + url, + hasAllSessionsInvalid }, dispatch } = React.useContext(PopupStateContext) @@ -68,6 +70,10 @@ export const Component = () => { return } + if (hasAllSessionsInvalid) { + return + } + return (
{enabled ? ( diff --git a/src/shared/messages.ts b/src/shared/messages.ts index 36330fd6..440a0bec 100644 --- a/src/shared/messages.ts +++ b/src/shared/messages.ts @@ -206,6 +206,7 @@ export interface BackgroundToPopupMessagesMap { SET_BALANCE: Record<'recurring' | 'oneTime' | 'total', AmountValue> SET_IS_MONETIZED: boolean SET_STATE: { state: Storage['state']; prevState: Storage['state'] } + SET_ALL_SESSIONS_INVALID: boolean } export type BackgroundToPopupMessage = { diff --git a/src/shared/types.ts b/src/shared/types.ts index c7737415..1ee2863c 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -101,6 +101,7 @@ export type PopupStore = Omit< oneTime: OneTimeGrant['amount'] recurring: RecurringGrant['amount'] }> + hasAllSessionsInvalid: boolean } export type DeepNonNullable = {