From c9ddb6132e43677e5ba9214a73febb036e3752ac Mon Sep 17 00:00:00 2001 From: Sid Vishnoi <8426945+sidvishnoi@users.noreply.github.com> Date: Fri, 1 Nov 2024 19:02:58 +0530 Subject: [PATCH 01/24] fix(background)!: better payment confirmation message --- src/_locales/en/messages.json | 7 ++++ src/background/services/monetization.ts | 25 ++++++++++++-- src/background/services/openPayments.ts | 40 ++++++++++++++++++++++- src/background/services/paymentSession.ts | 2 +- 4 files changed, 70 insertions(+), 4 deletions(-) diff --git a/src/_locales/en/messages.json b/src/_locales/en/messages.json index bb084cc4..bddd981f 100755 --- a/src/_locales/en/messages.json +++ b/src/_locales/en/messages.json @@ -51,6 +51,13 @@ "pay_error_notEnoughFunds": { "message": "Insufficient funds to complete the payment." }, + "pay_error_outgoingPaymentFailed": { + "message": "Payment failed." + }, + "pay_error_outgoingPaymentCompletionLimitReached": { + "message": "Payment not finished yet.", + "description": "We were polling for completion, but it's not finished yet" + }, "pay_error_invalidReceivers": { "message": "At the moment, you cannot pay this website.", "description": "We cannot send money (probable cause: un-peered wallets)" diff --git a/src/background/services/monetization.ts b/src/background/services/monetization.ts index c2c578d2..25e7e2ec 100644 --- a/src/background/services/monetization.ts +++ b/src/background/services/monetization.ts @@ -265,17 +265,38 @@ export class MonetizationService { 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( + let results = await Promise.allSettled( payableSessions.map((session) => session.pay(splitAmount)), ); + const signal = AbortSignal.timeout(8_000); // can use other signals as well, such as popup closed etc. + results = await Promise.allSettled( + results.map((e) => { + if (e.status !== 'fulfilled') throw e.reason; + if (!e.value) return e.value; + const outgoingPaymentId = e.value.id; + return this.openPaymentsService.outgoingPaymentWaitForCompletion( + outgoingPaymentId, + { signal, maxAttempts: 10 }, + ); + }), + ); const totalSentAmount = results .filter((e) => e.status === 'fulfilled') - .reduce((acc, curr) => acc + BigInt(curr.value?.value ?? 0), 0n); + .reduce( + (acc, curr) => acc + BigInt(curr.value?.debitAmount?.value ?? 0), + 0n, + ); if (totalSentAmount === 0n) { const isNotEnoughFunds = results .filter((e) => e.status === 'rejected') .some((e) => isOutOfBalanceError(e.reason)); + // TODO: If sentAmount is zero in all outgoing payments, and + // pay_error_outgoingPaymentCompletionLimitReached, it also likely means + // we don't have enough funds. + // + // TODO: If sentAmount is non-zero but not equal to debitAmount, show + // warning that not entire payment went through (yet?) if (isNotEnoughFunds) { throw new Error(this.t('pay_error_notEnoughFunds')); } diff --git a/src/background/services/openPayments.ts b/src/background/services/openPayments.ts index 635306f9..43dca4d8 100644 --- a/src/background/services/openPayments.ts +++ b/src/background/services/openPayments.ts @@ -32,6 +32,7 @@ import { errorWithKeyToJSON, getWalletInformation, isErrorWithKey, + sleep, withResolvers, type ErrorWithKeyLike, } from '@/shared/helpers'; @@ -694,7 +695,7 @@ export class OpenPaymentsService { }, { type: 'outgoing-payment', - actions: ['create'], + actions: ['create', 'read'], identifier: walletAddress.id, limits: { debitAmount: { @@ -881,6 +882,43 @@ export class OpenPaymentsService { return outgoingPayment; } + async outgoingPaymentWaitForCompletion( + outgoingPaymentId: OutgoingPayment['id'], + { + signal, + maxAttempts = 10, + }: Partial<{ signal: AbortSignal; maxAttempts: number }> = {}, + ) { + let attempt = 0; + let outgoingPayment: undefined | OutgoingPayment; + while (++attempt <= maxAttempts) { + signal?.throwIfAborted(); + try { + outgoingPayment = await this.client!.outgoingPayment.get({ + url: outgoingPaymentId, + accessToken: this.token.value, + }); + if (outgoingPayment.failed) { + throw new ErrorWithKey('pay_error_outgoingPaymentFailed'); + } + if ( + outgoingPayment.debitAmount.value === outgoingPayment.sentAmount.value + ) { + return outgoingPayment; + } + signal?.throwIfAborted(); + await sleep(1500); + } catch (error) { + if (isTokenExpiredError(error)) { + await this.rotateToken(); + } else { + throw error; + } + } + } + throw new ErrorWithKey('pay_error_outgoingPaymentCompletionLimitReached'); + } + async probeDebitAmount( amount: AmountValue, incomingPayment: IncomingPayment['id'], diff --git a/src/background/services/paymentSession.ts b/src/background/services/paymentSession.ts index a9207185..aaaf1dcb 100644 --- a/src/background/services/paymentSession.ts +++ b/src/background/services/paymentSession.ts @@ -421,7 +421,7 @@ export class PaymentSession { } } - return outgoingPayment?.debitAmount; + return outgoingPayment; } private setAmount(amount: bigint): void { From 05efcb045629d101944614a49d0539d8ee2309da Mon Sep 17 00:00:00 2001 From: Sid Vishnoi <8426945+sidvishnoi@users.noreply.github.com> Date: Mon, 4 Nov 2024 14:44:12 +0530 Subject: [PATCH 02/24] wait before first attempt; rename function --- src/background/services/monetization.ts | 8 ++++---- src/background/services/openPayments.ts | 5 ++++- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/background/services/monetization.ts b/src/background/services/monetization.ts index 25e7e2ec..f79fd71c 100644 --- a/src/background/services/monetization.ts +++ b/src/background/services/monetization.ts @@ -274,10 +274,10 @@ export class MonetizationService { if (e.status !== 'fulfilled') throw e.reason; if (!e.value) return e.value; const outgoingPaymentId = e.value.id; - return this.openPaymentsService.outgoingPaymentWaitForCompletion( - outgoingPaymentId, - { signal, maxAttempts: 10 }, - ); + return this.openPaymentsService.pollOutgoingPayment(outgoingPaymentId, { + signal, + maxAttempts: 10, + }); }), ); diff --git a/src/background/services/openPayments.ts b/src/background/services/openPayments.ts index 43dca4d8..5e73cccb 100644 --- a/src/background/services/openPayments.ts +++ b/src/background/services/openPayments.ts @@ -882,7 +882,8 @@ export class OpenPaymentsService { return outgoingPayment; } - async outgoingPaymentWaitForCompletion( + /** Polls for the completion of an outgoing payment */ + async pollOutgoingPayment( outgoingPaymentId: OutgoingPayment['id'], { signal, @@ -891,6 +892,7 @@ export class OpenPaymentsService { ) { let attempt = 0; let outgoingPayment: undefined | OutgoingPayment; + await sleep(2500); while (++attempt <= maxAttempts) { signal?.throwIfAborted(); try { @@ -904,6 +906,7 @@ export class OpenPaymentsService { if ( outgoingPayment.debitAmount.value === outgoingPayment.sentAmount.value ) { + // completed return outgoingPayment; } signal?.throwIfAborted(); From 244ab38c0e878290dd1606929a51c6f58e97c7ef Mon Sep 17 00:00:00 2001 From: Sid Vishnoi <8426945+sidvishnoi@users.noreply.github.com> Date: Mon, 4 Nov 2024 16:34:25 +0530 Subject: [PATCH 03/24] make PaymentSession.pay always return OutgoingPayment; retry/throw otherwise --- src/background/services/monetization.ts | 3 +- src/background/services/paymentSession.ts | 55 +++++++++++------------ 2 files changed, 26 insertions(+), 32 deletions(-) diff --git a/src/background/services/monetization.ts b/src/background/services/monetization.ts index f79fd71c..bc14e172 100644 --- a/src/background/services/monetization.ts +++ b/src/background/services/monetization.ts @@ -272,7 +272,6 @@ export class MonetizationService { results = await Promise.allSettled( results.map((e) => { if (e.status !== 'fulfilled') throw e.reason; - if (!e.value) return e.value; const outgoingPaymentId = e.value.id; return this.openPaymentsService.pollOutgoingPayment(outgoingPaymentId, { signal, @@ -284,7 +283,7 @@ export class MonetizationService { const totalSentAmount = results .filter((e) => e.status === 'fulfilled') .reduce( - (acc, curr) => acc + BigInt(curr.value?.debitAmount?.value ?? 0), + (acc, curr) => acc + BigInt(curr.value.debitAmount?.value ?? 0), 0n, ); if (totalSentAmount === 0n) { diff --git a/src/background/services/paymentSession.ts b/src/background/services/paymentSession.ts index aaaf1dcb..4904416c 100644 --- a/src/background/services/paymentSession.ts +++ b/src/background/services/paymentSession.ts @@ -368,7 +368,7 @@ export class PaymentSession { return incomingPayment; } - async pay(amount: number) { + async pay(amount: number): Promise { if (this.isDisabled) { throw new Error('Attempted to send a payment to a disabled session.'); } @@ -377,51 +377,46 @@ export class PaymentSession { (error) => { if (isKeyRevokedError(error)) { this.events.emit('open_payments.key_revoked'); - return; } throw error; }, ); - if (!incomingPayment) return; - - let outgoingPayment: OutgoingPayment | undefined; try { - outgoingPayment = await this.openPaymentsService.createOutgoingPayment({ - walletAddress: this.sender, - incomingPaymentId: incomingPayment.id, - amount: (amount * 10 ** this.sender.assetScale).toFixed(0), + const outgoingPayment = + await this.openPaymentsService.createOutgoingPayment({ + walletAddress: this.sender, + incomingPaymentId: incomingPayment.id, + amount: (amount * 10 ** this.sender.assetScale).toFixed(0), + }); + + this.sendMonetizationEvent({ + requestId: this.requestId, + details: { + amountSent: { + currency: outgoingPayment.receiveAmount.assetCode, + value: transformBalance( + outgoingPayment.receiveAmount.value, + outgoingPayment.receiveAmount.assetScale, + ), + }, + incomingPayment: outgoingPayment.receiver, + paymentPointer: this.receiver.id, + }, }); + + return outgoingPayment; } catch (e) { if (isKeyRevokedError(e)) { this.events.emit('open_payments.key_revoked'); + throw e; } else if (isTokenExpiredError(e)) { await this.openPaymentsService.rotateToken(); + return await this.pay(amount); } else { throw e; } - } finally { - if (outgoingPayment) { - const { receiveAmount, receiver: incomingPayment } = outgoingPayment; - - this.sendMonetizationEvent({ - requestId: this.requestId, - details: { - amountSent: { - currency: receiveAmount.assetCode, - value: transformBalance( - receiveAmount.value, - receiveAmount.assetScale, - ), - }, - incomingPayment, - paymentPointer: this.receiver.id, - }, - }); - } } - - return outgoingPayment; } private setAmount(amount: bigint): void { From 2148d3fadf6601fbbaa1377da70169b3af27f057 Mon Sep 17 00:00:00 2001 From: Sid Vishnoi <8426945+sidvishnoi@users.noreply.github.com> Date: Mon, 4 Nov 2024 16:41:54 +0530 Subject: [PATCH 04/24] try different signature: get last outgoingPayment regardless of error --- src/background/services/monetization.ts | 16 ++++++++++------ src/background/services/openPayments.ts | 21 +++++++++++++-------- 2 files changed, 23 insertions(+), 14 deletions(-) diff --git a/src/background/services/monetization.ts b/src/background/services/monetization.ts index bc14e172..33ebfa8a 100644 --- a/src/background/services/monetization.ts +++ b/src/background/services/monetization.ts @@ -270,13 +270,17 @@ export class MonetizationService { ); const signal = AbortSignal.timeout(8_000); // can use other signals as well, such as popup closed etc. results = await Promise.allSettled( - results.map((e) => { + results.map(async (e) => { if (e.status !== 'fulfilled') throw e.reason; - const outgoingPaymentId = e.value.id; - return this.openPaymentsService.pollOutgoingPayment(outgoingPaymentId, { - signal, - maxAttempts: 10, - }); + const [err, outgoingPayment] = + await this.openPaymentsService.pollOutgoingPayment(e.value, { + signal, + maxAttempts: 10, + }); + if (outgoingPayment) { + return outgoingPayment; + } + throw err; }), ); diff --git a/src/background/services/openPayments.ts b/src/background/services/openPayments.ts index 5e73cccb..17877b5f 100644 --- a/src/background/services/openPayments.ts +++ b/src/background/services/openPayments.ts @@ -884,20 +884,21 @@ export class OpenPaymentsService { /** Polls for the completion of an outgoing payment */ async pollOutgoingPayment( - outgoingPaymentId: OutgoingPayment['id'], + outgoingPayment: OutgoingPayment, { signal, maxAttempts = 10, }: Partial<{ signal: AbortSignal; maxAttempts: number }> = {}, - ) { + ): Promise< + [error?: Error, outgoingPayment?: OutgoingPayment] | [error: Error] + > { let attempt = 0; - let outgoingPayment: undefined | OutgoingPayment; await sleep(2500); while (++attempt <= maxAttempts) { - signal?.throwIfAborted(); try { + signal?.throwIfAborted(); outgoingPayment = await this.client!.outgoingPayment.get({ - url: outgoingPaymentId, + url: outgoingPayment.id, accessToken: this.token.value, }); if (outgoingPayment.failed) { @@ -907,7 +908,7 @@ export class OpenPaymentsService { outgoingPayment.debitAmount.value === outgoingPayment.sentAmount.value ) { // completed - return outgoingPayment; + return [undefined, outgoingPayment]; } signal?.throwIfAborted(); await sleep(1500); @@ -915,11 +916,15 @@ export class OpenPaymentsService { if (isTokenExpiredError(error)) { await this.rotateToken(); } else { - throw error; + return [error, outgoingPayment]; } } } - throw new ErrorWithKey('pay_error_outgoingPaymentCompletionLimitReached'); + + return [ + new ErrorWithKey('pay_error_outgoingPaymentCompletionLimitReached'), + outgoingPayment, + ]; } async probeDebitAmount( From 465ba9c468db517686e79eced857d30a54a1b926 Mon Sep 17 00:00:00 2001 From: Sid Vishnoi <8426945+sidvishnoi@users.noreply.github.com> Date: Thu, 7 Nov 2024 17:15:09 +0530 Subject: [PATCH 05/24] use async generator in polling; handle more errors; more abstraction --- src/background/config.ts | 5 ++ src/background/services/monetization.ts | 88 +++++++++++++++-------- src/background/services/openPayments.ts | 35 +++++---- src/background/services/paymentSession.ts | 3 + 4 files changed, 87 insertions(+), 44 deletions(-) diff --git a/src/background/config.ts b/src/background/config.ts index b0248b46..49809135 100644 --- a/src/background/config.ts +++ b/src/background/config.ts @@ -7,3 +7,8 @@ export const MAX_RATE_OF_PAY = '100'; export const EXCHANGE_RATES_URL = 'https://telemetry-exchange-rates.s3.amazonaws.com/exchange-rates-usd.json'; + +export const OUTGOING_PAYMENT_POLLING_MAX_DURATION = 8_000; +export const OUTGOING_PAYMENT_POLLING_INITIAL_DELAY = 2500; +export const OUTGOING_PAYMENT_POLLING_INTERVAL = 1500; +export const OUTGOING_PAYMENT_POLLING_MAX_ATTEMPTS = 8; diff --git a/src/background/services/monetization.ts b/src/background/services/monetization.ts index 33ebfa8a..8c615a48 100644 --- a/src/background/services/monetization.ts +++ b/src/background/services/monetization.ts @@ -6,11 +6,18 @@ import { } from '@/shared/messages'; import { PaymentSession } from './paymentSession'; import { computeRate, getSender, getTabId } from '../utils'; -import { isOutOfBalanceError } from './openPayments'; -import { isOkState, removeQueryParams } from '@/shared/helpers'; +import { + isMissingGrantPermissionsError, + isOutOfBalanceError, +} from './openPayments'; +import { + OUTGOING_PAYMENT_POLLING_MAX_ATTEMPTS, + OUTGOING_PAYMENT_POLLING_MAX_DURATION, +} from '@/background/config'; +import { isErrorWithKey, isOkState, removeQueryParams } from '@/shared/helpers'; import type { AmountValue, PopupStore, Storage } from '@/shared/types'; import type { Cradle } from '../container'; - +import type { OutgoingPayment } from '@interledger/open-payments'; export class MonetizationService { private logger: Cradle['logger']; private t: Cradle['t']; @@ -265,46 +272,65 @@ export class MonetizationService { const splitAmount = Number(amount) / payableSessions.length; // TODO: handle paying across two grants (when one grant doesn't have enough funds) - let results = await Promise.allSettled( + const results = await Promise.allSettled( payableSessions.map((session) => session.pay(splitAmount)), ); - const signal = AbortSignal.timeout(8_000); // can use other signals as well, such as popup closed etc. - results = await Promise.allSettled( - results.map(async (e) => { - if (e.status !== 'fulfilled') throw e.reason; - const [err, outgoingPayment] = - await this.openPaymentsService.pollOutgoingPayment(e.value, { - signal, - maxAttempts: 10, - }); - if (outgoingPayment) { - return outgoingPayment; - } - throw err; - }), + + const outgoingPayments = new Map( + payableSessions.map((s, i) => [ + s.id, + results[i].status === 'fulfilled' ? results[i].value : null, + ]), + ); + this.logger.debug('polling outgoing payments for completion'); + const signal = AbortSignal.timeout(OUTGOING_PAYMENT_POLLING_MAX_DURATION); // can use other signals as well, such as popup closed etc. + const pollingResults = await Promise.allSettled( + [...outgoingPayments] + .filter(([, outgoingPayment]) => outgoingPayment !== null) + .map(async ([sessionId, outgoingPaymentInitial]) => { + for await (const outgoingPayment of this.openPaymentsService.pollOutgoingPayment( + outgoingPaymentInitial!, + { signal, maxAttempts: OUTGOING_PAYMENT_POLLING_MAX_ATTEMPTS }, + )) { + outgoingPayments.set(sessionId, outgoingPayment); + } + return outgoingPayments.get(sessionId)!; + }), ); - const totalSentAmount = results - .filter((e) => e.status === 'fulfilled') - .reduce( - (acc, curr) => acc + BigInt(curr.value.debitAmount?.value ?? 0), - 0n, - ); + const totalSentAmount = [...outgoingPayments.values()].reduce( + (acc, op) => acc + BigInt(op?.sentAmount?.value ?? 0), + 0n, + ); if (totalSentAmount === 0n) { + const pollingErrors = pollingResults + .filter((e) => e.status === 'rejected') + .map((e) => e.reason); + if (pollingErrors.some((e) => isMissingGrantPermissionsError(e))) { + // This permission request to read outgoing payments was added at a + // later time, so existing connected wallets won't have this permission. + // Assume as success for backward compatibility. + return; + } + const isNotEnoughFunds = results .filter((e) => e.status === 'rejected') .some((e) => isOutOfBalanceError(e.reason)); - // TODO: If sentAmount is zero in all outgoing payments, and - // pay_error_outgoingPaymentCompletionLimitReached, it also likely means - // we don't have enough funds. - // - // TODO: If sentAmount is non-zero but not equal to debitAmount, show - // warning that not entire payment went through (yet?) - if (isNotEnoughFunds) { + const isPollingLimitReached = pollingErrors.some( + (err) => + (isErrorWithKey(err) && + err.key === 'pay_error_outgoingPaymentCompletionLimitReached') || + (err instanceof DOMException && err.name === 'TimeoutError'), + ); + + if (isNotEnoughFunds || isPollingLimitReached) { throw new Error(this.t('pay_error_notEnoughFunds')); } throw new Error('Could not facilitate payment for current website.'); } + + // TODO: If sentAmount is non-zero but less than to debitAmount, show + // warning that not entire payment went through (yet?) } private canTryPayment( diff --git a/src/background/services/openPayments.ts b/src/background/services/openPayments.ts index 17877b5f..e57c1ea6 100644 --- a/src/background/services/openPayments.ts +++ b/src/background/services/openPayments.ts @@ -45,6 +45,8 @@ import { DEFAULT_RATE_OF_PAY, MAX_RATE_OF_PAY, MIN_RATE_OF_PAY, + OUTGOING_PAYMENT_POLLING_INTERVAL, + OUTGOING_PAYMENT_POLLING_INITIAL_DELAY, } from '../config'; import { OPEN_PAYMENTS_REDIRECT_URL } from '@/shared/defines'; import type { Cradle } from '@/background/container'; @@ -883,17 +885,15 @@ export class OpenPaymentsService { } /** Polls for the completion of an outgoing payment */ - async pollOutgoingPayment( + async *pollOutgoingPayment( outgoingPayment: OutgoingPayment, { signal, maxAttempts = 10, }: Partial<{ signal: AbortSignal; maxAttempts: number }> = {}, - ): Promise< - [error?: Error, outgoingPayment?: OutgoingPayment] | [error: Error] - > { + ): AsyncGenerator { let attempt = 0; - await sleep(2500); + await sleep(OUTGOING_PAYMENT_POLLING_INITIAL_DELAY); while (++attempt <= maxAttempts) { try { signal?.throwIfAborted(); @@ -901,6 +901,7 @@ export class OpenPaymentsService { url: outgoingPayment.id, accessToken: this.token.value, }); + yield outgoingPayment; if (outgoingPayment.failed) { throw new ErrorWithKey('pay_error_outgoingPaymentFailed'); } @@ -908,23 +909,22 @@ export class OpenPaymentsService { outgoingPayment.debitAmount.value === outgoingPayment.sentAmount.value ) { // completed - return [undefined, outgoingPayment]; + return outgoingPayment; } signal?.throwIfAborted(); - await sleep(1500); + await sleep(OUTGOING_PAYMENT_POLLING_INTERVAL); } catch (error) { - if (isTokenExpiredError(error)) { + if (isMissingGrantPermissionsError(error)) { + throw error; + } else if (isTokenExpiredError(error)) { await this.rotateToken(); } else { - return [error, outgoingPayment]; + throw error; } } } - return [ - new ErrorWithKey('pay_error_outgoingPaymentCompletionLimitReached'), - outgoingPayment, - ]; + throw new ErrorWithKey('pay_error_outgoingPaymentCompletionLimitReached'); } async probeDebitAmount( @@ -1069,6 +1069,15 @@ export const isOutOfBalanceError = (error: any) => { return error.status === 403 && error.description === 'unauthorized'; }; +export const isMissingGrantPermissionsError = (error: any) => { + if (!isOpenPaymentsClientError(error)) return false; + return ( + error.status === 403 && + (error.description === 'Insufficient Grant' /* Fynbos */ || + error.description === 'Inactive Token') + ); +}; + export const isInvalidReceiverError = (error: any) => { if (!isOpenPaymentsClientError(error)) return false; return error.status === 400 && error.description === 'invalid receiver'; diff --git a/src/background/services/paymentSession.ts b/src/background/services/paymentSession.ts index 4904416c..0351b30c 100644 --- a/src/background/services/paymentSession.ts +++ b/src/background/services/paymentSession.ts @@ -9,6 +9,7 @@ import { transformBalance } from '@/popup/lib/utils'; import { isInvalidReceiverError, isKeyRevokedError, + isMissingGrantPermissionsError, isNonPositiveAmountError, isOutOfBalanceError, isTokenExpiredError, @@ -410,6 +411,8 @@ export class PaymentSession { if (isKeyRevokedError(e)) { this.events.emit('open_payments.key_revoked'); throw e; + } else if (isMissingGrantPermissionsError(e)) { + throw e; } else if (isTokenExpiredError(e)) { await this.openPaymentsService.rotateToken(); return await this.pay(amount); From 52abeaaaa70967454298f70e2cdbd4d2b584486e Mon Sep 17 00:00:00 2001 From: Sid Vishnoi <8426945+sidvishnoi@users.noreply.github.com> Date: Thu, 7 Nov 2024 19:24:14 +0530 Subject: [PATCH 06/24] require only outgoingPaymentId in polling In case we decide to do the polling separately, we don't need to store entire initial outgoingPayment for reference. --- src/background/services/monetization.ts | 2 +- src/background/services/openPayments.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/background/services/monetization.ts b/src/background/services/monetization.ts index 8c615a48..9b8a83b2 100644 --- a/src/background/services/monetization.ts +++ b/src/background/services/monetization.ts @@ -289,7 +289,7 @@ export class MonetizationService { .filter(([, outgoingPayment]) => outgoingPayment !== null) .map(async ([sessionId, outgoingPaymentInitial]) => { for await (const outgoingPayment of this.openPaymentsService.pollOutgoingPayment( - outgoingPaymentInitial!, + outgoingPaymentInitial!.id, { signal, maxAttempts: OUTGOING_PAYMENT_POLLING_MAX_ATTEMPTS }, )) { outgoingPayments.set(sessionId, outgoingPayment); diff --git a/src/background/services/openPayments.ts b/src/background/services/openPayments.ts index e57c1ea6..1313a131 100644 --- a/src/background/services/openPayments.ts +++ b/src/background/services/openPayments.ts @@ -886,7 +886,7 @@ export class OpenPaymentsService { /** Polls for the completion of an outgoing payment */ async *pollOutgoingPayment( - outgoingPayment: OutgoingPayment, + outgoingPaymentId: OutgoingPayment['id'], { signal, maxAttempts = 10, @@ -897,8 +897,8 @@ export class OpenPaymentsService { while (++attempt <= maxAttempts) { try { signal?.throwIfAborted(); - outgoingPayment = await this.client!.outgoingPayment.get({ - url: outgoingPayment.id, + const outgoingPayment = await this.client!.outgoingPayment.get({ + url: outgoingPaymentId, accessToken: this.token.value, }); yield outgoingPayment; From c96aa904d4b9b2f0bd568c7affdb42fca5dcc7a7 Mon Sep 17 00:00:00 2001 From: Sid Vishnoi <8426945+sidvishnoi@users.noreply.github.com> Date: Thu, 7 Nov 2024 19:25:14 +0530 Subject: [PATCH 07/24] nit [ci skip] --- src/background/services/openPayments.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/background/services/openPayments.ts b/src/background/services/openPayments.ts index 1313a131..76e36154 100644 --- a/src/background/services/openPayments.ts +++ b/src/background/services/openPayments.ts @@ -908,8 +908,7 @@ export class OpenPaymentsService { if ( outgoingPayment.debitAmount.value === outgoingPayment.sentAmount.value ) { - // completed - return outgoingPayment; + return outgoingPayment; // completed } signal?.throwIfAborted(); await sleep(OUTGOING_PAYMENT_POLLING_INTERVAL); From 48cf7032e46599dbfea775d50fdd6a6c3e4ff5ce Mon Sep 17 00:00:00 2001 From: Sid Vishnoi <8426945+sidvishnoi@users.noreply.github.com> Date: Thu, 7 Nov 2024 19:26:05 +0530 Subject: [PATCH 08/24] nit: style/import [ci skip] --- src/background/services/monetization.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/background/services/monetization.ts b/src/background/services/monetization.ts index 9b8a83b2..0338a127 100644 --- a/src/background/services/monetization.ts +++ b/src/background/services/monetization.ts @@ -16,8 +16,9 @@ import { } from '@/background/config'; import { isErrorWithKey, isOkState, removeQueryParams } from '@/shared/helpers'; import type { AmountValue, PopupStore, Storage } from '@/shared/types'; -import type { Cradle } from '../container'; import type { OutgoingPayment } from '@interledger/open-payments'; +import type { Cradle } from '../container'; + export class MonetizationService { private logger: Cradle['logger']; private t: Cradle['t']; From 6f93b1c2469719f2b4e5791e06d0fff645af9a4b Mon Sep 17 00:00:00 2001 From: Sid Vishnoi <8426945+sidvishnoi@users.noreply.github.com> Date: Fri, 8 Nov 2024 15:06:46 +0530 Subject: [PATCH 09/24] ui & message improvements --- src/_locales/en/messages.json | 19 +++++- src/background/services/background.ts | 2 +- src/background/services/monetization.ts | 53 +++++++++++++--- src/background/services/openPayments.ts | 4 +- src/popup/components/PayWebsiteForm.tsx | 81 ++++++++++++++----------- src/shared/helpers.ts | 5 +- src/shared/messages.ts | 9 ++- 7 files changed, 121 insertions(+), 52 deletions(-) diff --git a/src/_locales/en/messages.json b/src/_locales/en/messages.json index bddd981f..d6f364d5 100755 --- a/src/_locales/en/messages.json +++ b/src/_locales/en/messages.json @@ -48,15 +48,21 @@ "keyRevoked_action_reconnectBtn": { "message": "Reconnect" }, + "pay_action_pay": { + "message": "Send now" + }, "pay_error_notEnoughFunds": { "message": "Insufficient funds to complete the payment." }, "pay_error_outgoingPaymentFailed": { "message": "Payment failed." }, - "pay_error_outgoingPaymentCompletionLimitReached": { - "message": "Payment not finished yet.", - "description": "We were polling for completion, but it's not finished yet" + "pay_warn_pay_warn_outgoingPaymentPollingIncomplete": { + "message": "We could not verify if the payment was completed. Please ensure you've enough funds in your wallet.", + "description": "We were polling for completion, but it's not finished yet - could mean it'll never succeed or it'll take a long time to settle." + }, + "pay_error_general": { + "message": "We were unable to process this transaction. Please try again!" }, "pay_error_invalidReceivers": { "message": "At the moment, you cannot pay this website.", @@ -65,6 +71,13 @@ "pay_error_notMonetized": { "message": "This website is not monetized." }, + "pay_state_success": { + "message": "Thanks for your support! $AMOUNT$ was sent to $WEBSITE_URL$.", + "placeholders": { + "AMOUNT": { "content": "$1", "example": "$2.05" }, + "WEBSITE_URL": { "content": "$2", "example": "https://example.com" } + } + }, "outOfFunds_error_title": { "message": "Out of funds" }, diff --git a/src/background/services/background.ts b/src/background/services/background.ts index 932af933..b88ad5a4 100644 --- a/src/background/services/background.ts +++ b/src/background/services/background.ts @@ -239,7 +239,7 @@ export class Background { case 'PAY_WEBSITE': return success( - await this.monetizationService.pay(message.payload.amount), + await this.monetizationService.pay(message.payload), ); // endregion diff --git a/src/background/services/monetization.ts b/src/background/services/monetization.ts index 0338a127..200d748a 100644 --- a/src/background/services/monetization.ts +++ b/src/background/services/monetization.ts @@ -1,5 +1,7 @@ import type { Runtime, Tabs } from 'webextension-polyfill'; -import { +import type { + PayWebsitePayload, + PayWebsiteResponse, ResumeMonetizationPayload, StartMonetizationPayload, StopMonetizationPayload, @@ -14,7 +16,14 @@ import { OUTGOING_PAYMENT_POLLING_MAX_ATTEMPTS, OUTGOING_PAYMENT_POLLING_MAX_DURATION, } from '@/background/config'; -import { isErrorWithKey, isOkState, removeQueryParams } from '@/shared/helpers'; +import { + ErrorWithKey, + formatCurrency, + isErrorWithKey, + isOkState, + removeQueryParams, +} from '@/shared/helpers'; +import { transformBalance } from '@/popup/lib/utils'; import type { AmountValue, PopupStore, Storage } from '@/shared/types'; import type { OutgoingPayment } from '@interledger/open-payments'; import type { Cradle } from '../container'; @@ -257,7 +266,7 @@ export class MonetizationService { } } - async pay(amount: string) { + async pay({ amount }: PayWebsitePayload): Promise { const tab = await this.windowState.getCurrentTab(); if (!tab || !tab.id) { throw new Error('Unexpected error: could not find active tab.'); @@ -271,6 +280,9 @@ export class MonetizationService { throw new Error(this.t('pay_error_notMonetized')); } + const { walletAddress } = await this.storage.get(['walletAddress']); + const { url: tabUrl } = this.tabState.getPopupTabData(tab); + 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( @@ -307,11 +319,23 @@ export class MonetizationService { const pollingErrors = pollingResults .filter((e) => e.status === 'rejected') .map((e) => e.reason); + if (pollingErrors.some((e) => isMissingGrantPermissionsError(e))) { // This permission request to read outgoing payments was added at a // later time, so existing connected wallets won't have this permission. // Assume as success for backward compatibility. - return; + const totalDebitAmount = [...outgoingPayments.values()].reduce( + (acc, op) => acc + BigInt(op?.debitAmount?.value ?? 0), + 0n, + ); + const { assetScale, assetCode } = walletAddress!; + const sentAmount = transformBalance(totalDebitAmount, assetScale); + return { + type: 'success', + sentAmount: sentAmount, + sentAmountFormatted: formatCurrency(sentAmount, assetCode), + url: tabUrl, + }; } const isNotEnoughFunds = results @@ -320,18 +344,31 @@ export class MonetizationService { const isPollingLimitReached = pollingErrors.some( (err) => (isErrorWithKey(err) && - err.key === 'pay_error_outgoingPaymentCompletionLimitReached') || + err.key === 'pay_warn_pay_warn_outgoingPaymentPollingIncomplete') || (err instanceof DOMException && err.name === 'TimeoutError'), ); - if (isNotEnoughFunds || isPollingLimitReached) { - throw new Error(this.t('pay_error_notEnoughFunds')); + if (isNotEnoughFunds) { + throw new ErrorWithKey('pay_error_notEnoughFunds'); } - throw new Error('Could not facilitate payment for current website.'); + if (isPollingLimitReached) { + throw new ErrorWithKey( + 'pay_warn_pay_warn_outgoingPaymentPollingIncomplete', + ); + } + throw new ErrorWithKey('pay_error_general'); } // TODO: If sentAmount is non-zero but less than to debitAmount, show // warning that not entire payment went through (yet?) + const { assetCode, assetScale } = walletAddress!; + const sentAmount = transformBalance(totalSentAmount, assetScale); + return { + type: 'success', + sentAmount: sentAmount, + sentAmountFormatted: formatCurrency(sentAmount, assetCode), + url: tabUrl, + }; } private canTryPayment( diff --git a/src/background/services/openPayments.ts b/src/background/services/openPayments.ts index 76e36154..af49f837 100644 --- a/src/background/services/openPayments.ts +++ b/src/background/services/openPayments.ts @@ -923,7 +923,9 @@ export class OpenPaymentsService { } } - throw new ErrorWithKey('pay_error_outgoingPaymentCompletionLimitReached'); + throw new ErrorWithKey( + 'pay_warn_pay_warn_outgoingPaymentPollingIncomplete', + ); } async probeDebitAmount( diff --git a/src/popup/components/PayWebsiteForm.tsx b/src/popup/components/PayWebsiteForm.tsx index 6af670d4..baa858dd 100644 --- a/src/popup/components/PayWebsiteForm.tsx +++ b/src/popup/components/PayWebsiteForm.tsx @@ -1,10 +1,8 @@ import React from 'react'; import { AnimatePresence, m } from 'framer-motion'; import { Button } from '@/popup/components/ui/Button'; -import { Spinner } from '@/popup/components/Icons'; -import { ErrorMessage } from '@/popup/components/ErrorMessage'; import { InputAmount } from '@/popup/components/InputAmount'; -import { cn, ErrorWithKeyLike } from '@/shared/helpers'; +import { cn, type ErrorWithKeyLike } from '@/shared/helpers'; import { useMessage, usePopupState, useTranslation } from '@/popup/lib/context'; import { roundWithPrecision } from '@/popup/lib/utils'; @@ -12,12 +10,6 @@ type ErrorInfo = { message: string; info?: ErrorWithKeyLike }; type ErrorsParams = 'amount' | 'pay'; type Errors = Record; -const BUTTON_STATE = { - idle: 'Send now', - loading: , - success: 'Payment successful', -}; - export const PayWebsiteForm = () => { const t = useTranslation(); const message = useMessage(); @@ -42,30 +34,36 @@ export const PayWebsiteForm = () => { const form = React.useRef(null); const [isSubmitting, setIsSubmitting] = React.useState(false); - const [buttonState, setButtonState] = - React.useState('idle'); - const isIdle = React.useMemo(() => buttonState === 'idle', [buttonState]); + const [msg, setMsg] = React.useState(null); const onSubmit = async (ev: React.FormEvent) => { ev.preventDefault(); - if (buttonState !== 'idle') return; + if (isSubmitting) return; setErrors({ amount: null, pay: null }); + setMsg(null); - setButtonState('loading'); setIsSubmitting(true); const response = await message.send('PAY_WEBSITE', { amount }); if (!response.success) { - setButtonState('idle'); - setErrors((prev) => ({ ...prev, pay: toErrorInfo(response.message) })); + setErrors((prev) => ({ + ...prev, + pay: toErrorInfo(response.error || response.message), + })); } else { - setButtonState('success'); setAmount(''); + const { type, url, sentAmountFormatted } = response.payload; + const msg = t('pay_state_success', [sentAmountFormatted, url]); + if (type === 'success') { + setMsg({ type: 'success', message: msg }); + } else { + setMsg({ type: 'warn', message: msg }); + } form.current?.reset(); - setTimeout(() => { - setButtonState('idle'); - }, 3000); } setIsSubmitting(false); }; @@ -73,23 +71,35 @@ export const PayWebsiteForm = () => { return (
- {errors.pay ? ( + {errors.pay || !!msg ? ( - +
+
{msg?.message || errors.pay?.message}
+
) : null}
@@ -112,6 +122,7 @@ export const PayWebsiteForm = () => { errorMessage={errors.amount?.message} onChange={(amountValue) => { setErrors({ pay: null, amount: null }); + setMsg(null); setAmount(amountValue); }} onError={(error) => @@ -121,13 +132,10 @@ export const PayWebsiteForm = () => { diff --git a/src/shared/helpers.ts b/src/shared/helpers.ts index a30c77b5..13641897 100644 --- a/src/shared/helpers.ts +++ b/src/shared/helpers.ts @@ -12,7 +12,10 @@ import type { Storage, RepeatingInterval, AmountValue } from './types'; export type TranslationKeys = keyof typeof import('../_locales/en/messages.json'); -export type ErrorKeys = Extract; +export type ErrorKeys = Extract< + TranslationKeys, + `${string}_${'error' | 'warn'}_${string}` +>; export const cn = (...inputs: CxOptions) => { return twMerge(cx(inputs)); diff --git a/src/shared/messages.ts b/src/shared/messages.ts index 191450cc..d48595ab 100644 --- a/src/shared/messages.ts +++ b/src/shared/messages.ts @@ -106,6 +106,13 @@ export interface PayWebsitePayload { amount: string; } +export interface PayWebsiteResponse { + type: 'success' | 'warn'; + url: string; + sentAmount: string; + sentAmountFormatted: string; +} + export interface UpdateRateOfPayPayload { rateOfPay: string; } @@ -147,7 +154,7 @@ export type PopupToBackgroundMessage = { }; PAY_WEBSITE: { input: PayWebsitePayload; - output: never; + output: PayWebsiteResponse; }; UPDATE_RATE_OF_PAY: { input: UpdateRateOfPayPayload; From d4e89fd66ae2aeff92e7259d29161b2c6bd1730c Mon Sep 17 00:00:00 2001 From: Sid Vishnoi <8426945+sidvishnoi@users.noreply.github.com> Date: Fri, 8 Nov 2024 15:08:52 +0530 Subject: [PATCH 10/24] use same error message for `OutgoingPayment.failed = true` --- src/_locales/en/messages.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/_locales/en/messages.json b/src/_locales/en/messages.json index d6f364d5..fcceba18 100755 --- a/src/_locales/en/messages.json +++ b/src/_locales/en/messages.json @@ -55,7 +55,8 @@ "message": "Insufficient funds to complete the payment." }, "pay_error_outgoingPaymentFailed": { - "message": "Payment failed." + "message": "We were unable to process this transaction. Please try again!", + "description": "OutgoingPayment.failed === true" }, "pay_warn_pay_warn_outgoingPaymentPollingIncomplete": { "message": "We could not verify if the payment was completed. Please ensure you've enough funds in your wallet.", From 4b13c73f9b8d515b84fdaebc72a9bb1b005c0d51 Mon Sep 17 00:00:00 2001 From: Sid Vishnoi <8426945+sidvishnoi@users.noreply.github.com> Date: Mon, 11 Nov 2024 17:50:38 +0530 Subject: [PATCH 11/24] reduce polling initial delay from 2.5s to 1.5s --- src/background/config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/background/config.ts b/src/background/config.ts index 49809135..2f2ffc55 100644 --- a/src/background/config.ts +++ b/src/background/config.ts @@ -9,6 +9,6 @@ export const EXCHANGE_RATES_URL = 'https://telemetry-exchange-rates.s3.amazonaws.com/exchange-rates-usd.json'; export const OUTGOING_PAYMENT_POLLING_MAX_DURATION = 8_000; -export const OUTGOING_PAYMENT_POLLING_INITIAL_DELAY = 2500; +export const OUTGOING_PAYMENT_POLLING_INITIAL_DELAY = 1500; export const OUTGOING_PAYMENT_POLLING_INTERVAL = 1500; export const OUTGOING_PAYMENT_POLLING_MAX_ATTEMPTS = 8; From a0768ee7be482440f3657e5cd93d56a1b3f8d0bf Mon Sep 17 00:00:00 2001 From: Sid Vishnoi <8426945+sidvishnoi@users.noreply.github.com> Date: Mon, 11 Nov 2024 17:52:21 +0530 Subject: [PATCH 12/24] walletAddress check --- src/background/services/monetization.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/background/services/monetization.ts b/src/background/services/monetization.ts index 200d748a..93a033e5 100644 --- a/src/background/services/monetization.ts +++ b/src/background/services/monetization.ts @@ -281,6 +281,11 @@ export class MonetizationService { } const { walletAddress } = await this.storage.get(['walletAddress']); + if (!walletAddress) { + throw new Error('Unexpected: wallet address not found.'); + } + const { assetCode, assetScale } = walletAddress; + const { url: tabUrl } = this.tabState.getPopupTabData(tab); const splitAmount = Number(amount) / payableSessions.length; @@ -328,7 +333,6 @@ export class MonetizationService { (acc, op) => acc + BigInt(op?.debitAmount?.value ?? 0), 0n, ); - const { assetScale, assetCode } = walletAddress!; const sentAmount = transformBalance(totalDebitAmount, assetScale); return { type: 'success', @@ -361,7 +365,6 @@ export class MonetizationService { // TODO: If sentAmount is non-zero but less than to debitAmount, show // warning that not entire payment went through (yet?) - const { assetCode, assetScale } = walletAddress!; const sentAmount = transformBalance(totalSentAmount, assetScale); return { type: 'success', From e11cf1391ccfe62538126dc7bda225a64493d6b1 Mon Sep 17 00:00:00 2001 From: Sid Vishnoi <8426945+sidvishnoi@users.noreply.github.com> Date: Mon, 11 Nov 2024 17:53:05 +0530 Subject: [PATCH 13/24] fix typo in error key name --- src/_locales/en/messages.json | 2 +- src/background/services/monetization.ts | 6 ++---- src/background/services/openPayments.ts | 4 +--- 3 files changed, 4 insertions(+), 8 deletions(-) diff --git a/src/_locales/en/messages.json b/src/_locales/en/messages.json index fcceba18..b8c535a8 100755 --- a/src/_locales/en/messages.json +++ b/src/_locales/en/messages.json @@ -58,7 +58,7 @@ "message": "We were unable to process this transaction. Please try again!", "description": "OutgoingPayment.failed === true" }, - "pay_warn_pay_warn_outgoingPaymentPollingIncomplete": { + "pay_warn_outgoingPaymentPollingIncomplete": { "message": "We could not verify if the payment was completed. Please ensure you've enough funds in your wallet.", "description": "We were polling for completion, but it's not finished yet - could mean it'll never succeed or it'll take a long time to settle." }, diff --git a/src/background/services/monetization.ts b/src/background/services/monetization.ts index 93a033e5..0b7d23e7 100644 --- a/src/background/services/monetization.ts +++ b/src/background/services/monetization.ts @@ -348,7 +348,7 @@ export class MonetizationService { const isPollingLimitReached = pollingErrors.some( (err) => (isErrorWithKey(err) && - err.key === 'pay_warn_pay_warn_outgoingPaymentPollingIncomplete') || + err.key === 'pay_warn_outgoingPaymentPollingIncomplete') || (err instanceof DOMException && err.name === 'TimeoutError'), ); @@ -356,9 +356,7 @@ export class MonetizationService { throw new ErrorWithKey('pay_error_notEnoughFunds'); } if (isPollingLimitReached) { - throw new ErrorWithKey( - 'pay_warn_pay_warn_outgoingPaymentPollingIncomplete', - ); + throw new ErrorWithKey('pay_warn_outgoingPaymentPollingIncomplete'); } throw new ErrorWithKey('pay_error_general'); } diff --git a/src/background/services/openPayments.ts b/src/background/services/openPayments.ts index af49f837..564a033e 100644 --- a/src/background/services/openPayments.ts +++ b/src/background/services/openPayments.ts @@ -923,9 +923,7 @@ export class OpenPaymentsService { } } - throw new ErrorWithKey( - 'pay_warn_pay_warn_outgoingPaymentPollingIncomplete', - ); + throw new ErrorWithKey('pay_warn_outgoingPaymentPollingIncomplete'); } async probeDebitAmount( From 3df3acd321b2b03e16cdef64e30bf40d89e03a41 Mon Sep 17 00:00:00 2001 From: Sid Vishnoi <8426945+sidvishnoi@users.noreply.github.com> Date: Mon, 11 Nov 2024 17:55:45 +0530 Subject: [PATCH 14/24] comment on null assertion --- src/background/services/monetization.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/background/services/monetization.ts b/src/background/services/monetization.ts index 0b7d23e7..734aa505 100644 --- a/src/background/services/monetization.ts +++ b/src/background/services/monetization.ts @@ -307,6 +307,7 @@ export class MonetizationService { .filter(([, outgoingPayment]) => outgoingPayment !== null) .map(async ([sessionId, outgoingPaymentInitial]) => { for await (const outgoingPayment of this.openPaymentsService.pollOutgoingPayment( + // Null assertion: https://github.com/microsoft/TypeScript/issues/41173 outgoingPaymentInitial!.id, { signal, maxAttempts: OUTGOING_PAYMENT_POLLING_MAX_ATTEMPTS }, )) { From cba0301880da736561cfcb439fada02200cc2e39 Mon Sep 17 00:00:00 2001 From: Sid Vishnoi <8426945+sidvishnoi@users.noreply.github.com> Date: Mon, 11 Nov 2024 17:58:23 +0530 Subject: [PATCH 15/24] use payStatus full/partial instead of success/warn; also in bg --- src/background/services/monetization.ts | 4 ++-- src/popup/components/PayWebsiteForm.tsx | 22 +++++++++------------- src/shared/messages.ts | 2 +- 3 files changed, 12 insertions(+), 16 deletions(-) diff --git a/src/background/services/monetization.ts b/src/background/services/monetization.ts index 734aa505..87ef086c 100644 --- a/src/background/services/monetization.ts +++ b/src/background/services/monetization.ts @@ -336,7 +336,7 @@ export class MonetizationService { ); const sentAmount = transformBalance(totalDebitAmount, assetScale); return { - type: 'success', + type: 'full', sentAmount: sentAmount, sentAmountFormatted: formatCurrency(sentAmount, assetCode), url: tabUrl, @@ -366,7 +366,7 @@ export class MonetizationService { // warning that not entire payment went through (yet?) const sentAmount = transformBalance(totalSentAmount, assetScale); return { - type: 'success', + type: 'full', sentAmount: sentAmount, sentAmountFormatted: formatCurrency(sentAmount, assetCode), url: tabUrl, diff --git a/src/popup/components/PayWebsiteForm.tsx b/src/popup/components/PayWebsiteForm.tsx index baa858dd..b3e0eeaa 100644 --- a/src/popup/components/PayWebsiteForm.tsx +++ b/src/popup/components/PayWebsiteForm.tsx @@ -34,8 +34,8 @@ export const PayWebsiteForm = () => { const form = React.useRef(null); const [isSubmitting, setIsSubmitting] = React.useState(false); - const [msg, setMsg] = React.useState(null); @@ -43,7 +43,7 @@ export const PayWebsiteForm = () => { ev.preventDefault(); if (isSubmitting) return; setErrors({ amount: null, pay: null }); - setMsg(null); + setPayStatus(null); setIsSubmitting(true); @@ -58,11 +58,7 @@ export const PayWebsiteForm = () => { setAmount(''); const { type, url, sentAmountFormatted } = response.payload; const msg = t('pay_state_success', [sentAmountFormatted, url]); - if (type === 'success') { - setMsg({ type: 'success', message: msg }); - } else { - setMsg({ type: 'warn', message: msg }); - } + setPayStatus({ type, message: msg }); form.current?.reset(); } setIsSubmitting(false); @@ -78,7 +74,7 @@ export const PayWebsiteForm = () => { onSubmit={onSubmit} > - {errors.pay || !!msg ? ( + {errors.pay || !!payStatus ? ( {
-
{msg?.message || errors.pay?.message}
+
{payStatus?.message || errors.pay?.message}
) : null} @@ -122,7 +118,7 @@ export const PayWebsiteForm = () => { errorMessage={errors.amount?.message} onChange={(amountValue) => { setErrors({ pay: null, amount: null }); - setMsg(null); + setPayStatus(null); setAmount(amountValue); }} onError={(error) => diff --git a/src/shared/messages.ts b/src/shared/messages.ts index d48595ab..ab720add 100644 --- a/src/shared/messages.ts +++ b/src/shared/messages.ts @@ -107,7 +107,7 @@ export interface PayWebsitePayload { } export interface PayWebsiteResponse { - type: 'success' | 'warn'; + type: 'full' | 'partial'; url: string; sentAmount: string; sentAmountFormatted: string; From 6560b413dc8430f328ac2220689348ad84e39bbd Mon Sep 17 00:00:00 2001 From: Sid Vishnoi <8426945+sidvishnoi@users.noreply.github.com> Date: Fri, 15 Nov 2024 17:28:50 +0530 Subject: [PATCH 16/24] update comment for isMissingGrantPermissionsError --- src/background/services/openPayments.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/background/services/openPayments.ts b/src/background/services/openPayments.ts index 564a033e..e768d685 100644 --- a/src/background/services/openPayments.ts +++ b/src/background/services/openPayments.ts @@ -1070,9 +1070,12 @@ export const isOutOfBalanceError = (error: any) => { export const isMissingGrantPermissionsError = (error: any) => { if (!isOpenPaymentsClientError(error)) return false; + // providers using Rafiki <= v1.0.0-alpha.15 show "Insufficient Grant" error, + // but Rafiki >= v1.0.0-alpha.16 shows "Inactive Token" (due to + // https://github.com/interledger/rafiki/pull/2788) return ( error.status === 403 && - (error.description === 'Insufficient Grant' /* Fynbos */ || + (error.description === 'Insufficient Grant' || error.description === 'Inactive Token') ); }; From 387cc08d4e729c5634b800367ce0b0eb8a4e28f9 Mon Sep 17 00:00:00 2001 From: Sid Vishnoi <8426945+sidvishnoi@users.noreply.github.com> Date: Fri, 15 Nov 2024 17:34:39 +0530 Subject: [PATCH 17/24] handle case isMissingGrantPermissionsError is actually isTokenInactiveError --- src/background/services/openPayments.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/background/services/openPayments.ts b/src/background/services/openPayments.ts index e768d685..10e9a9e4 100644 --- a/src/background/services/openPayments.ts +++ b/src/background/services/openPayments.ts @@ -893,6 +893,7 @@ export class OpenPaymentsService { }: Partial<{ signal: AbortSignal; maxAttempts: number }> = {}, ): AsyncGenerator { let attempt = 0; + let tokenRotated = false; await sleep(OUTGOING_PAYMENT_POLLING_INITIAL_DELAY); while (++attempt <= maxAttempts) { try { @@ -914,8 +915,15 @@ export class OpenPaymentsService { await sleep(OUTGOING_PAYMENT_POLLING_INTERVAL); } catch (error) { if (isMissingGrantPermissionsError(error)) { + if (isTokenInactiveError(error) && !tokenRotated) { + // isMissingGrantPermissionError has same error msg as isTokenInactiveError + tokenRotated = true; + await this.rotateToken(); + continue; + } throw error; - } else if (isTokenExpiredError(error)) { + } else if (isTokenExpiredError(error) && !tokenRotated) { + tokenRotated = true; await this.rotateToken(); } else { throw error; @@ -1075,8 +1083,7 @@ export const isMissingGrantPermissionsError = (error: any) => { // https://github.com/interledger/rafiki/pull/2788) return ( error.status === 403 && - (error.description === 'Insufficient Grant' || - error.description === 'Inactive Token') + (error.description === 'Insufficient Grant' || isTokenInactiveError(error)) ); }; From 715746e22ddd9ea6e1d170a05fa79522a3ce8974 Mon Sep 17 00:00:00 2001 From: Sid Vishnoi <8426945+sidvishnoi@users.noreply.github.com> Date: Fri, 15 Nov 2024 17:53:29 +0530 Subject: [PATCH 18/24] update success/failure states; add partial status; update msgs --- src/_locales/en/messages.json | 2 +- src/background/services/monetization.ts | 13 ++++++------- src/background/services/openPayments.ts | 5 ++++- src/popup/components/PayWebsiteForm.tsx | 9 ++++----- 4 files changed, 15 insertions(+), 14 deletions(-) diff --git a/src/_locales/en/messages.json b/src/_locales/en/messages.json index b8c535a8..e64b2a7c 100755 --- a/src/_locales/en/messages.json +++ b/src/_locales/en/messages.json @@ -59,7 +59,7 @@ "description": "OutgoingPayment.failed === true" }, "pay_warn_outgoingPaymentPollingIncomplete": { - "message": "We could not verify if the payment was completed. Please ensure you've enough funds in your wallet.", + "message": "It looks like this is taking long. Check your wallet later to ensure the payment went through.", "description": "We were polling for completion, but it's not finished yet - could mean it'll never succeed or it'll take a long time to settle." }, "pay_error_general": { diff --git a/src/background/services/monetization.ts b/src/background/services/monetization.ts index 87ef086c..e59550c6 100644 --- a/src/background/services/monetization.ts +++ b/src/background/services/monetization.ts @@ -321,6 +321,11 @@ export class MonetizationService { (acc, op) => acc + BigInt(op?.sentAmount?.value ?? 0), 0n, ); + const totalDebitAmount = [...outgoingPayments.values()].reduce( + (acc, op) => acc + BigInt(op?.debitAmount?.value ?? 0), + 0n, + ); + if (totalSentAmount === 0n) { const pollingErrors = pollingResults .filter((e) => e.status === 'rejected') @@ -330,10 +335,6 @@ export class MonetizationService { // This permission request to read outgoing payments was added at a // later time, so existing connected wallets won't have this permission. // Assume as success for backward compatibility. - const totalDebitAmount = [...outgoingPayments.values()].reduce( - (acc, op) => acc + BigInt(op?.debitAmount?.value ?? 0), - 0n, - ); const sentAmount = transformBalance(totalDebitAmount, assetScale); return { type: 'full', @@ -362,11 +363,9 @@ export class MonetizationService { throw new ErrorWithKey('pay_error_general'); } - // TODO: If sentAmount is non-zero but less than to debitAmount, show - // warning that not entire payment went through (yet?) const sentAmount = transformBalance(totalSentAmount, assetScale); return { - type: 'full', + type: totalSentAmount < totalDebitAmount ? 'partial' : 'full', sentAmount: sentAmount, sentAmountFormatted: formatCurrency(sentAmount, assetCode), url: tabUrl, diff --git a/src/background/services/openPayments.ts b/src/background/services/openPayments.ts index 10e9a9e4..64d2e63e 100644 --- a/src/background/services/openPayments.ts +++ b/src/background/services/openPayments.ts @@ -903,7 +903,10 @@ export class OpenPaymentsService { accessToken: this.token.value, }); yield outgoingPayment; - if (outgoingPayment.failed) { + if ( + outgoingPayment.failed && + outgoingPayment.sentAmount.value === '0' + ) { throw new ErrorWithKey('pay_error_outgoingPaymentFailed'); } if ( diff --git a/src/popup/components/PayWebsiteForm.tsx b/src/popup/components/PayWebsiteForm.tsx index b3e0eeaa..c7653e0c 100644 --- a/src/popup/components/PayWebsiteForm.tsx +++ b/src/popup/components/PayWebsiteForm.tsx @@ -85,12 +85,11 @@ export const PayWebsiteForm = () => {
From 2979f04fe4013d484edf738b50c6b8df4a1b0274 Mon Sep 17 00:00:00 2001 From: Sid Vishnoi <8426945+sidvishnoi@users.noreply.github.com> Date: Fri, 15 Nov 2024 18:05:53 +0530 Subject: [PATCH 19/24] update success message to remove URL --- src/_locales/en/messages.json | 5 ++--- src/background/services/monetization.ts | 4 ---- src/popup/components/PayWebsiteForm.tsx | 4 ++-- src/shared/messages.ts | 1 - 4 files changed, 4 insertions(+), 10 deletions(-) diff --git a/src/_locales/en/messages.json b/src/_locales/en/messages.json index e64b2a7c..070e626c 100755 --- a/src/_locales/en/messages.json +++ b/src/_locales/en/messages.json @@ -73,10 +73,9 @@ "message": "This website is not monetized." }, "pay_state_success": { - "message": "Thanks for your support! $AMOUNT$ was sent to $WEBSITE_URL$.", + "message": "Thank you for your $AMOUNT$ support!", "placeholders": { - "AMOUNT": { "content": "$1", "example": "$2.05" }, - "WEBSITE_URL": { "content": "$2", "example": "https://example.com" } + "AMOUNT": { "content": "$1", "example": "$2.05" } } }, "outOfFunds_error_title": { diff --git a/src/background/services/monetization.ts b/src/background/services/monetization.ts index e59550c6..81febfa5 100644 --- a/src/background/services/monetization.ts +++ b/src/background/services/monetization.ts @@ -286,8 +286,6 @@ export class MonetizationService { } const { assetCode, assetScale } = walletAddress; - const { url: tabUrl } = this.tabState.getPopupTabData(tab); - 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( @@ -340,7 +338,6 @@ export class MonetizationService { type: 'full', sentAmount: sentAmount, sentAmountFormatted: formatCurrency(sentAmount, assetCode), - url: tabUrl, }; } @@ -368,7 +365,6 @@ export class MonetizationService { type: totalSentAmount < totalDebitAmount ? 'partial' : 'full', sentAmount: sentAmount, sentAmountFormatted: formatCurrency(sentAmount, assetCode), - url: tabUrl, }; } diff --git a/src/popup/components/PayWebsiteForm.tsx b/src/popup/components/PayWebsiteForm.tsx index c7653e0c..81e09383 100644 --- a/src/popup/components/PayWebsiteForm.tsx +++ b/src/popup/components/PayWebsiteForm.tsx @@ -56,8 +56,8 @@ export const PayWebsiteForm = () => { })); } else { setAmount(''); - const { type, url, sentAmountFormatted } = response.payload; - const msg = t('pay_state_success', [sentAmountFormatted, url]); + const { type, sentAmountFormatted } = response.payload; + const msg = t('pay_state_success', [sentAmountFormatted]); setPayStatus({ type, message: msg }); form.current?.reset(); } diff --git a/src/shared/messages.ts b/src/shared/messages.ts index ab720add..b9223772 100644 --- a/src/shared/messages.ts +++ b/src/shared/messages.ts @@ -108,7 +108,6 @@ export interface PayWebsitePayload { export interface PayWebsiteResponse { type: 'full' | 'partial'; - url: string; sentAmount: string; sentAmountFormatted: string; } From 3021b4189afc1cadebb832c8eb25287d32559585 Mon Sep 17 00:00:00 2001 From: Sid Vishnoi <8426945+sidvishnoi@users.noreply.github.com> Date: Fri, 22 Nov 2024 19:14:14 +0530 Subject: [PATCH 20/24] use opacity animation on message, slide is distracting --- src/popup/components/PayWebsiteForm.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/popup/components/PayWebsiteForm.tsx b/src/popup/components/PayWebsiteForm.tsx index 81e09383..7e6f48ec 100644 --- a/src/popup/components/PayWebsiteForm.tsx +++ b/src/popup/components/PayWebsiteForm.tsx @@ -77,9 +77,9 @@ export const PayWebsiteForm = () => { {errors.pay || !!payStatus ? (
Date: Fri, 22 Nov 2024 19:16:16 +0530 Subject: [PATCH 21/24] fix disabled styles --- src/popup/components/PayWebsiteForm.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/popup/components/PayWebsiteForm.tsx b/src/popup/components/PayWebsiteForm.tsx index 7e6f48ec..0e63e069 100644 --- a/src/popup/components/PayWebsiteForm.tsx +++ b/src/popup/components/PayWebsiteForm.tsx @@ -127,7 +127,10 @@ export const PayWebsiteForm = () => {