Skip to content

Commit

Permalink
fix(background)!: better payment confirmation & message (#694)
Browse files Browse the repository at this point in the history
  • Loading branch information
sidvishnoi authored Nov 27, 2024
1 parent 5bc6492 commit 2e7aa96
Show file tree
Hide file tree
Showing 8 changed files with 245 additions and 53 deletions.
19 changes: 19 additions & 0 deletions src/_locales/en/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,16 +48,35 @@
"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": "We were unable to process this transaction. Please try again!"
},
"pay_warn_outgoingPaymentPollingIncomplete": {
"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": {
"message": "We were unable to process this transaction. Please try again!"
},
"pay_error_invalidReceivers": {
"message": "At the moment, you cannot pay this website.",
"description": "We cannot send money (probable cause: un-peered wallets)"
},
"pay_error_notMonetized": {
"message": "This website is not monetized."
},
"pay_state_success": {
"message": "Thank you for your $AMOUNT$ support!",
"placeholders": {
"AMOUNT": { "content": "$1", "example": "$2.05" }
}
},
"outOfFunds_error_title": {
"message": "Out of funds"
},
Expand Down
5 changes: 5 additions & 0 deletions src/background/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = 1500;
export const OUTGOING_PAYMENT_POLLING_INTERVAL = 1500;
export const OUTGOING_PAYMENT_POLLING_MAX_ATTEMPTS = 8;
2 changes: 1 addition & 1 deletion src/background/services/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
102 changes: 91 additions & 11 deletions src/background/services/monetization.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,28 @@
import type { Runtime, Tabs } from 'webextension-polyfill';
import {
import type {
PayWebsitePayload,
PayWebsiteResponse,
ResumeMonetizationPayload,
StartMonetizationPayload,
StopMonetizationPayload,
} from '@/shared/messages';
import { PaymentSession } from './paymentSession';
import { computeRate, getSender, getTabId } from '../utils';
import { isOutOfBalanceError } from './openPayments';
import { isOkState, removeQueryParams } from '@/shared/helpers';
import {
OUTGOING_PAYMENT_POLLING_MAX_ATTEMPTS,
OUTGOING_PAYMENT_POLLING_MAX_DURATION,
} from '@/background/config';
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';

export class MonetizationService {
Expand Down Expand Up @@ -249,7 +263,7 @@ export class MonetizationService {
}
}

async pay(amount: string) {
async pay({ amount }: PayWebsitePayload): Promise<PayWebsiteResponse> {
const tab = await this.windowState.getCurrentTab();
if (!tab || !tab.id) {
throw new Error('Unexpected error: could not find active tab.');
Expand All @@ -263,27 +277,93 @@ export class MonetizationService {
throw new Error(this.t('pay_error_notMonetized'));
}

const { walletAddress } = await this.storage.get(['walletAddress']);
if (!walletAddress) {
throw new Error('Unexpected: wallet address not found.');
}
const { assetCode, assetScale } = walletAddress;

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(
payableSessions.map((session) => session.pay(splitAmount)),
);

const totalSentAmount = results
.filter((e) => e.status === 'fulfilled')
.reduce(
(acc, curr) => acc + BigInt(curr.value?.debitAmount.value ?? 0),
0n,
);
const outgoingPayments = new Map<string, OutgoingPayment | null>(
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(
// Null assertion: https://github.com/microsoft/TypeScript/issues/41173
outgoingPaymentInitial!.id,
{ signal, maxAttempts: OUTGOING_PAYMENT_POLLING_MAX_ATTEMPTS },
)) {
outgoingPayments.set(sessionId, outgoingPayment);
}
return outgoingPayments.get(sessionId)!;
}),
);

const totalSentAmount = [...outgoingPayments.values()].reduce(
(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')
.map((e) => e.reason);

if (pollingErrors.some((e) => e.message === 'InsufficientGrant')) {
this.logger.warn('Insufficient grant to read outgoing payments');
// 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 sentAmount = transformBalance(totalDebitAmount, assetScale);
return {
type: 'full',
sentAmount: sentAmount,
sentAmountFormatted: formatCurrency(sentAmount, assetCode),
};
}

const isNotEnoughFunds = results
.filter((e) => e.status === 'rejected')
.some((e) => isOutOfBalanceError(e.reason));
const isPollingLimitReached = pollingErrors.some(
(err) =>
(isErrorWithKey(err) &&
err.key === 'pay_warn_outgoingPaymentPollingIncomplete') ||
(err instanceof DOMException && err.name === 'TimeoutError'),
);

if (isNotEnoughFunds) {
throw new Error(this.t('pay_error_notEnoughFunds'));
throw new ErrorWithKey('pay_error_notEnoughFunds');
}
if (isPollingLimitReached) {
throw new ErrorWithKey('pay_warn_outgoingPaymentPollingIncomplete');
}
throw new Error('Could not facilitate payment for current website.');
throw new ErrorWithKey('pay_error_general');
}

const sentAmount = transformBalance(totalSentAmount, assetScale);
return {
type: totalSentAmount < totalDebitAmount ? 'partial' : 'full',
sentAmount: sentAmount,
sentAmountFormatted: formatCurrency(sentAmount, assetCode),
};
}

private canTryPayment(
Expand Down
78 changes: 76 additions & 2 deletions src/background/services/openPayments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
errorWithKeyToJSON,
getWalletInformation,
isErrorWithKey,
sleep,
withResolvers,
type ErrorWithKeyLike,
} from '@/shared/helpers';
Expand All @@ -44,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';
Expand Down Expand Up @@ -694,7 +697,7 @@ export class OpenPaymentsService {
},
{
type: 'outgoing-payment',
actions: ['create'],
actions: ['create', 'read'],
identifier: walletAddress.id,
limits: {
debitAmount: {
Expand Down Expand Up @@ -881,6 +884,63 @@ export class OpenPaymentsService {
return outgoingPayment;
}

/** Polls for the completion of an outgoing payment */
async *pollOutgoingPayment(
outgoingPaymentId: OutgoingPayment['id'],
{
signal,
maxAttempts = 10,
}: Partial<{ signal: AbortSignal; maxAttempts: number }> = {},
): AsyncGenerator<OutgoingPayment, OutgoingPayment, void> {
let attempt = 0;
await sleep(OUTGOING_PAYMENT_POLLING_INITIAL_DELAY);
while (++attempt <= maxAttempts) {
try {
signal?.throwIfAborted();
const outgoingPayment = await this.client!.outgoingPayment.get({
url: outgoingPaymentId,
accessToken: this.token.value,
});
yield outgoingPayment;
if (
outgoingPayment.failed &&
outgoingPayment.sentAmount.value === '0'
) {
throw new ErrorWithKey('pay_error_outgoingPaymentFailed');
}
if (
outgoingPayment.debitAmount.value === outgoingPayment.sentAmount.value
) {
return outgoingPayment; // completed
}
signal?.throwIfAborted();
await sleep(OUTGOING_PAYMENT_POLLING_INTERVAL);
} catch (error) {
if (
isTokenExpiredError(error) ||
isMissingGrantPermissionsError(error)
) {
// TODO: We can remove the token `actions` check once we've proper RS
// errors in place. Then we can handle insufficient grant error
// separately clearly.
const token = await this.rotateToken();
const hasReadAccess = token.access_token.access.find(
(e) => e.type === 'outgoing-payment' && e.actions.includes('read'),
);
if (!hasReadAccess) {
throw new OpenPaymentsClientError('InsufficientGrant', {
description: error.description,
});
}
} else {
throw error;
}
}
}

throw new ErrorWithKey('pay_warn_outgoingPaymentPollingIncomplete');
}

async probeDebitAmount(
amount: AmountValue,
incomingPayment: IncomingPayment['id'],
Expand Down Expand Up @@ -966,6 +1026,7 @@ export class OpenPaymentsService {
this.storage.set({ oneTimeGrant: { ...this.grant, accessToken } });
}
this.grant = { ...this.grant, accessToken };
return newToken;
}
}

Expand Down Expand Up @@ -998,7 +1059,9 @@ export const isSignatureValidationError = (error: any) => {
);
};

export const isTokenExpiredError = (error: any) => {
export const isTokenExpiredError = (
error: any,
): error is OpenPaymentsClientError => {
if (!isOpenPaymentsClientError(error)) return false;
return isTokenInvalidError(error) || isTokenInactiveError(error);
};
Expand All @@ -1023,6 +1086,17 @@ export const isOutOfBalanceError = (error: any) => {
return error.status === 403 && error.description === 'unauthorized';
};

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' || isTokenInactiveError(error))
);
};

export const isInvalidReceiverError = (error: any) => {
if (!isOpenPaymentsClientError(error)) return false;
return error.status === 400 && error.description === 'invalid receiver';
Expand Down
Loading

0 comments on commit 2e7aa96

Please sign in to comment.