From 437e7915739d856af6492c0621e3e4245e21f095 Mon Sep 17 00:00:00 2001 From: Scott Nonnenberg Date: Thu, 10 Jul 2025 07:34:42 +1000 Subject: [PATCH] Donations: Make workflow more robust --- ts/background.ts | 3 + ts/services/donationReceiptsLoader.ts | 5 +- ts/services/donations.ts | 1031 ++++++++++++++++------- ts/state/ducks/donations.ts | 33 +- ts/textsecure/WebAPI.ts | 19 +- ts/types/Donations.ts | 47 +- ts/types/Storage.d.ts | 1 + ts/util/isProtoBinaryEncodingEnabled.ts | 2 +- ts/windows/main/preload_test.ts | 1 + 9 files changed, 826 insertions(+), 316 deletions(-) diff --git a/ts/background.ts b/ts/background.ts index 3ad76e4116..025e10d74c 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -219,6 +219,7 @@ import { isLocalBackupsEnabled } from './util/isLocalBackupsEnabled'; import { NavTab } from './state/ducks/nav'; import { Page } from './components/Preferences'; import { EditState } from './components/ProfileEditor'; +import { runDonationWorkflow } from './services/donations'; import { MessageRequestResponseSource } from './types/MessageRequestResponseEvent'; const log = createLogger('background'); @@ -2195,6 +2196,8 @@ export async function startApp(): Promise { drop(ReleaseNotesFetcher.init(window.Whisper.events, newVersion)); + drop(runDonationWorkflow()); + if (isFromMessageReceiver) { drop( (async () => { diff --git a/ts/services/donationReceiptsLoader.ts b/ts/services/donationReceiptsLoader.ts index 8b687d710a..9f5886fb47 100644 --- a/ts/services/donationReceiptsLoader.ts +++ b/ts/services/donationReceiptsLoader.ts @@ -4,6 +4,8 @@ import { DataReader } from '../sql/Client'; import { strictAssert } from '../util/assert'; +import { _getWorkflowFromStorage } from './donations'; + import type { DonationReceipt } from '../types/Donations'; import type { DonationsStateType } from '../state/ducks/donations'; @@ -19,7 +21,8 @@ export function getDonationReceiptsForRedux(): DonationsStateType { 'donation receipts have not been loaded' ); return { - currentWorkflow: undefined, + currentWorkflow: _getWorkflowFromStorage(), + lastError: undefined, receipts: donationReceipts, }; } diff --git a/ts/services/donations.ts b/ts/services/donations.ts index b1a567764a..8ea3eb8042 100644 --- a/ts/services/donations.ts +++ b/ts/services/donations.ts @@ -1,6 +1,8 @@ // Copyright 2025 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only +/* eslint-disable no-await-in-loop */ + import { v4 as uuid } from 'uuid'; import { ClientZkReceiptOperations, @@ -12,17 +14,31 @@ import { } from '@signalapp/libsignal-client/zkgroup'; import * as Bytes from '../Bytes'; -import { donationStateSchema } from '../types/Donations'; -import type { - CardDetail, - DonationReceipt, - DonationWorkflow, - ReceiptContext, -} from '../types/Donations'; +import * as Errors from '../types/errors'; import { getRandomBytes, sha256 } from '../Crypto'; import { DataWriter } from '../sql/Client'; import { createLogger } from '../logging/log'; import { donationValidationCompleteRoute } from '../util/signalRoutes'; +import { safeParseStrict, safeParseUnknown } from '../util/schemas'; +import { missingCaseError } from '../util/missingCaseError'; +import { exponentialBackoffSleepTime } from '../util/exponentialBackoff'; +import { sleeper } from '../util/sleeper'; +import { isInPast, isOlderThan } from '../util/timestamp'; +import { DAY, DurationInSeconds } from '../util/durations'; +import { waitForOnline } from '../util/waitForOnline'; +import { + donationErrorTypeSchema, + donationStateSchema, + donationWorkflowSchema, +} from '../types/Donations'; + +import type { + CardDetail, + DonationErrorType, + DonationReceipt, + DonationWorkflow, + ReceiptContext, +} from '../types/Donations'; const { createDonationReceipt } = DataWriter; @@ -32,17 +48,88 @@ function redactId(id: string) { return `[REDACTED]${id.slice(-4)}`; } -function hashIdToIdempotencyKey(id: string) { - const idBytes = Bytes.fromString(id); +function hashIdToIdempotencyKey(id: string, apiCallName: string) { + const idBytes = Bytes.fromString(id + apiCallName); const hashed = sha256(idBytes); return Buffer.from(hashed).toString('hex'); } const RECEIPT_SERIAL_LENGTH = 16; +const BOOST_LEVEL = 1; +const WORKFLOW_STORAGE_KEY = 'donationWorkflow'; +const MAX_CREDENTIAL_EXPIRATION_IN_DAYS = 90; +let runDonationAbortController: AbortController | undefined; +let isInternalDonationInProgress = false; let isDonationInProgress = false; -export async function internalDoDonation({ +// Public API + +// These are the four moments the user provides input to the donation workflow. So, +// UI calls these methods directly; everything else happens automatically. + +export async function startDonation({ + currencyType, + paymentAmount, +}: { + currencyType: string; + paymentAmount: number; +}): Promise { + const workflow = await _createPaymentIntent({ + currencyType, + paymentAmount, + workflow: _getWorkflowFromRedux(), + }); + + // We don't run the workflow, because there's nothing else to do after this first step + await _saveWorkflow(workflow); +} + +export async function finishDonationWithCard( + paymentDetail: CardDetail +): Promise { + const existing = _getWorkflowFromRedux(); + if (!existing) { + throw new Error( + 'finishDonationWithCard: Cannot finish nonexistent workflow!' + ); + } + let workflow: DonationWorkflow; + + try { + workflow = await _createPaymentMethodForIntent(existing, paymentDetail); + } catch (error) { + if (error.code >= 400 && error.code <= 499) { + await failDonation(donationErrorTypeSchema.Enum.PaymentDeclined); + } else { + await failDonation(donationErrorTypeSchema.Enum.GeneralError); + } + + throw error; + } + + // We run the workflow; it might be that no further user input is required! + await _saveAndRunWorkflow(workflow); +} + +export async function finish3dsValidation(token: string): Promise { + const existing = _getWorkflowFromRedux(); + if (!existing) { + throw new Error('finish3dsValidation: Cannot finish nonexistent workflow!'); + } + + const workflow = await _completeValidationRedirect(existing, token); + await _saveAndRunWorkflow(workflow); +} + +export async function clearDonation(): Promise { + runDonationAbortController?.abort(); + await _saveWorkflow(undefined); +} + +// For testing + +export async function _internalDoDonation({ currencyType, paymentAmount, paymentDetail, @@ -51,140 +138,667 @@ export async function internalDoDonation({ paymentAmount: number; paymentDetail: CardDetail; }): Promise { - if (isDonationInProgress) { + if (isInternalDonationInProgress) { throw new Error("Can't proceed because a donation is in progress."); } try { - isDonationInProgress = true; + isInternalDonationInProgress = true; let workflow: DonationWorkflow; - workflow = await createPaymentIntent({ + workflow = await _createPaymentIntent({ currencyType, paymentAmount, + workflow: undefined, }); - window.reduxActions.donations.updateWorkflow(workflow); + await _saveWorkflow(workflow); - workflow = await createPaymentMethodForIntent(workflow, paymentDetail); - window.reduxActions.donations.updateWorkflow(workflow); - - workflow = await confirmPayment(workflow); - window.reduxActions.donations.updateWorkflow(workflow); - - workflow = await getReceipt(workflow); - window.reduxActions.donations.updateWorkflow(workflow); - - workflow = await redeemReceipt(workflow); - window.reduxActions.donations.updateWorkflow(workflow); - - workflow = await saveReceipt(workflow); - window.reduxActions.donations.updateWorkflow(workflow); + workflow = await _createPaymentMethodForIntent(workflow, paymentDetail); + await _saveAndRunWorkflow(workflow); } finally { - isDonationInProgress = false; + isInternalDonationInProgress = false; } } -export async function createPaymentIntent({ +// High-level functions to move things forward + +export async function _saveAndRunWorkflow( + workflow: DonationWorkflow | undefined +): Promise { + const logId = `_saveAndRunWorkflow(${workflow?.id ? redactId(workflow.id) : 'NONE'}`; + await _saveWorkflow(workflow); + + if (isDonationInProgress) { + log.info( + `${logId}: Donation workflow is already running; not calling it again` + ); + return; + } + if (!workflow) { + log.info(`${logId}: No need to start workflow; it's been cleared`); + } + + await runDonationWorkflow(); +} + +// There's one place where this is called outside this file - when starting up, in the +// onEmpty handler in background.ts. +export async function runDonationWorkflow(): Promise { + let logId = 'runDonationWorkflow'; + + let totalCount = 0; + let backoffCount = 0; + + try { + if (isDonationInProgress) { + log.warn(`${logId}: Can't proceed because a donation is in progress.`); + return; + } + isDonationInProgress = true; + runDonationAbortController = new AbortController(); + + // We will loop until we explicitly return or throw + // eslint-disable-next-line no-constant-condition + while (true) { + const existing = _getWorkflowFromRedux(); + const idForLog = existing?.id ? redactId(existing.id) : 'NONE'; + logId = `runDonationWorkflow(${idForLog})`; + + if (!existing) { + log.info(`${logId}: No workflow to process. Returning.`); + return; + } + + const { type, timestamp } = existing; + if (isOlderThan(timestamp, DAY * 90)) { + log.info( + `${logId}: Workflow timestamp is more than 90 days ago. Clearing.` + ); + await failDonation(donationErrorTypeSchema.Enum.GeneralError); + return; + } + + totalCount += 1; + if (totalCount === 1) { + log.info(`${logId}: Starting, with state of ${type}...`); + } else { + log.info( + `${logId}: Continuing at count ${totalCount}, with state of ${type}...` + ); + } + + if (runDonationAbortController?.signal.aborted) { + log.info(`${logId}: abortController is aborted. Returning`); + return; + } + + if (!window.textsecure.server?.isOnline()) { + log.info(`${logId}: We are not online; waiting until we are online`); + await waitForOnline(); + log.info(`${logId}: We are back online; starting up again`); + } + + backoffCount += 1; + const sleepTime = exponentialBackoffSleepTime(backoffCount); + if (sleepTime > 0) { + const detail = `${logId}: sleeping for backoff for ${type}, backoff count is ${backoffCount}`; + log.info(detail); + await sleeper.sleep(sleepTime, detail); + } + + try { + let updated: DonationWorkflow; + + if (type === donationStateSchema.Enum.INTENT) { + log.info(`${logId}: Waiting for payment details. Returning.`); + return; + } + if (type === donationStateSchema.Enum.INTENT_METHOD) { + log.info(`${logId}: Attempting to confirm payment`); + updated = await _confirmPayment(existing); + // continuing + } else if (type === donationStateSchema.Enum.INTENT_REDIRECT) { + log.info( + `${logId}: Waiting for user to return from confirmation URL. Returning.` + ); + return; + } else if (type === donationStateSchema.Enum.INTENT_CONFIRMED) { + log.info(`${logId}: Attempting to get receipt`); + updated = await _getReceipt(existing); + // continuing + } else if (type === donationStateSchema.Enum.RECEIPT) { + log.info(`${logId}: Attempting to redeem receipt`); + updated = await _redeemReceipt(existing); + // continuing + } else if (type === donationStateSchema.Enum.DONE) { + log.info(`${logId}: Workflow is complete. Returning.`); + return; + } else { + throw missingCaseError(type); + } + + const isAborted = runDonationAbortController?.signal.aborted; + if (isAborted) { + log.info(`${logId}: abortController is aborted. Returning`); + return; + } + + if (updated.type !== type) { + backoffCount = 0; + } + + await _saveWorkflow(updated); + } catch (error) { + if ( + error.name === 'HTTPError' && + error.code >= 400 && + error.code <= 499 + ) { + log.warn(`${logId}: Got a ${error.code} error. Failing donation.`); + if (type === donationStateSchema.Enum.INTENT_METHOD) { + await failDonation(donationErrorTypeSchema.Enum.PaymentDeclined); + } else { + await failDonation( + donationErrorTypeSchema.Enum.DonationProcessingError + ); + } + throw error; + } + + if (error.name === 'HTTPError' && typeof error.code === 'number') { + log.warn(`${logId}: Got a ${error.code} error, retrying donation`); + // continuing + } else { + log.warn( + `${logId}: Donation step threw unexpectedly. Failing donation. ${Errors.toLogFormat(error)}` + ); + await failDonation( + donationErrorTypeSchema.Enum.DonationProcessingError + ); + throw error; + } + } + } + } finally { + isDonationInProgress = false; + runDonationAbortController = undefined; + } +} + +// Workflow steps + +let isDonationStepInProgress = false; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +async function withConcurrencyCheck Promise>( + name: string, + fn: T +): Promise> { + if (isDonationStepInProgress) { + throw new Error( + `${name}: Can't proceed because a donation step is already in progress.` + ); + } + isDonationStepInProgress = true; + + try { + return fn(); + } finally { + isDonationStepInProgress = false; + } +} + +export async function _createPaymentIntent({ currencyType, paymentAmount, + workflow, }: { currencyType: string; paymentAmount: number; + workflow: DonationWorkflow | undefined; }): Promise { - if (!window.textsecure.server) { - throw new Error( - 'createPaymentIntent: window.textsecure.server is not available!' - ); - } - const id = uuid(); - const logId = `createPaymentIntent(${redactId(id)})`; - log.info(`${logId}: Creating new workflow`); + const logId = `_createPaymentIntent(${redactId(id)})`; - const payload = { - currency: currencyType, - amount: paymentAmount, - level: 1, - paymentMethod: 'CARD', - }; - const { clientSecret } = - await window.textsecure.server.createBoostPaymentIntent(payload); - const paymentIntentId = clientSecret.split('_secret_')[0]; + return withConcurrencyCheck(logId, async () => { + if (workflow && workflow.type !== donationStateSchema.Enum.DONE) { + throw new Error( + `${logId}: existing workflow at type ${workflow.type} is not at type DONE, unable to create payment intent` + ); + } + if (!window.textsecure.server) { + throw new Error(`${logId}: window.textsecure.server is not available!`); + } - log.info(`${logId}: Successfully transitioned to INTENT`); + log.info(`${logId}: Creating new workflow`); - return { - type: donationStateSchema.Enum.INTENT, - id, - currencyType, - paymentAmount, - paymentIntentId, - clientSecret, - returnToken: uuid(), - timestamp: Date.now(), - }; + const payload = { + currency: currencyType, + amount: paymentAmount, + level: 1, + paymentMethod: 'CARD', + }; + const { clientSecret } = + await window.textsecure.server.createBoostPaymentIntent(payload); + const paymentIntentId = clientSecret.split('_secret_')[0]; + + log.info(`${logId}: Successfully transitioned to INTENT`); + + return { + type: donationStateSchema.Enum.INTENT, + id, + currencyType, + paymentAmount, + paymentIntentId, + clientSecret, + returnToken: uuid(), + timestamp: Date.now(), + }; + }); } -export async function createPaymentMethodForIntent( +export async function _createPaymentMethodForIntent( workflow: DonationWorkflow, cardDetail: CardDetail ): Promise { - const logId = `createPaymentMethodForIntent(${redactId(workflow.id)})`; + const logId = `_createPaymentMethodForIntent(${redactId(workflow.id)})`; - if (workflow.type !== donationStateSchema.Enum.INTENT) { - throw new Error( - `${logId}: workflow at type ${workflow?.type} is not at type INTENT, unable to create payment method` - ); - } - if (!window.textsecure.server) { - throw new Error(`${logId}: window.textsecure.server is not available!`); - } + return withConcurrencyCheck(logId, async () => { + // We need to handle INTENT_METHOD so user can fix their payment info and try again + if ( + workflow.type !== donationStateSchema.Enum.INTENT && + workflow.type !== donationStateSchema.Enum.INTENT_METHOD + ) { + throw new Error( + `${logId}: workflow at type ${workflow?.type} is not at type INTENT or INTENT_METHOD, unable to create payment method` + ); + } + if (!window.textsecure.server) { + throw new Error(`${logId}: window.textsecure.server is not available!`); + } - log.info(`${logId}: Starting`); + log.info(`${logId}: Starting`); - const { id: paymentMethodId } = - await window.textsecure.server.createPaymentMethodWithStripe({ - cardDetail, - }); + const { id: paymentMethodId } = + await window.textsecure.server.createPaymentMethodWithStripe({ + cardDetail, + }); - log.info(`${logId}: Successfully transitioned to INTENT_METHOD`); + log.info(`${logId}: Successfully transitioned to INTENT_METHOD`); - return { - ...workflow, - type: donationStateSchema.Enum.INTENT_METHOD, - timestamp: Date.now(), - paymentMethodId, - }; + return { + ...workflow, + type: donationStateSchema.Enum.INTENT_METHOD, + timestamp: Date.now(), + paymentMethodId, + }; + }); } -export async function confirmPayment( +export async function _confirmPayment( workflow: DonationWorkflow ): Promise { - const logId = `confirmPayment(${redactId(workflow.id)})`; + const logId = `_confirmPayment(${redactId(workflow.id)})`; - if (workflow.type !== donationStateSchema.Enum.INTENT_METHOD) { + return withConcurrencyCheck(logId, async () => { + if (workflow.type !== donationStateSchema.Enum.INTENT_METHOD) { + throw new Error( + `${logId}: workflow at type ${workflow?.type} is not at type INTENT_METHOD, unable to confirm payment` + ); + } + if (!window.textsecure.server) { + throw new Error(`${logId}: window.textsecure.server is not available!`); + } + + log.info(`${logId}: Starting`); + + const receiptContext = getReceiptContext(); + + const { clientSecret, paymentIntentId, paymentMethodId, id } = workflow; + const idempotencyKey = hashIdToIdempotencyKey( + id, + `confirmPayment/${paymentMethodId}` + ); + const returnUrl = donationValidationCompleteRoute + .toAppUrl({ token: workflow.returnToken }) + .toString(); + const options = { + clientSecret, + idempotencyKey, + paymentIntentId, + paymentMethodId, + returnUrl, + }; + + const { next_action: nextAction } = + await window.textsecure.server.confirmIntentWithStripe(options); + + if (nextAction && nextAction.type === 'redirect_to_url') { + const { redirect_to_url: redirectDetails } = nextAction; + + if (!redirectDetails || !redirectDetails.url) { + throw new Error( + `${logId}: nextAction type was redirect_to_url, but no url was supplied!` + ); + } + + log.info(`${logId}: Successfully transitioned to INTENT_REDIRECT`); + + return { + ...workflow, + ...receiptContext, + type: donationStateSchema.Enum.INTENT_REDIRECT, + timestamp: Date.now(), + redirectTarget: redirectDetails.url, + }; + } + + if (nextAction) { + throw new Error( + `${logId}: Unsupported nextAction type ${nextAction.type}!` + ); + } + + log.info(`${logId}: Successfully transitioned to INTENT_CONFIRMED`); + + return { + ...workflow, + ...receiptContext, + type: donationStateSchema.Enum.INTENT_CONFIRMED, + timestamp: Date.now(), + }; + }); +} + +export async function _completeValidationRedirect( + workflow: DonationWorkflow, + token: string +): Promise { + const logId = `_completeValidationRedirect(${redactId(workflow.id)})`; + + return withConcurrencyCheck(logId, async () => { + if (workflow.type !== donationStateSchema.Enum.INTENT_REDIRECT) { + throw new Error( + `${logId}: workflow at type ${workflow?.type} is not type INTENT_REDIRECT, unable to complete redirect` + ); + } + if (!window.textsecure.server) { + throw new Error(`${logId}: window.textsecure.server is not available!`); + } + + log.info(`${logId}: Starting`); + + if (token !== workflow.returnToken) { + throw new Error(`${logId}: The provided token did not match saved token`); + } + + log.info(`${logId}: Successfully transitioned to INTENT_CONFIRMED`); + + return { + ...workflow, + type: donationStateSchema.Enum.INTENT_CONFIRMED, + timestamp: Date.now(), + }; + }); +} + +export async function _getReceipt( + workflow: DonationWorkflow +): Promise { + const logId = `_getReceipt(${redactId(workflow.id)})`; + + return withConcurrencyCheck(logId, async () => { + if (workflow.type !== donationStateSchema.Enum.INTENT_CONFIRMED) { + throw new Error( + `${logId}: workflow at type ${workflow?.type} not type INTENT_CONFIRMED, unable to get receipt` + ); + } + if (!window.textsecure.server) { + throw new Error(`${logId}: window.textsecure.server is not available!`); + } + + log.info(`${logId}: Starting`); + + const { + paymentIntentId, + receiptCredentialRequestBase64, + receiptCredentialRequestContextBase64, + } = workflow; + const jsonPayload = { + paymentIntentId, + receiptCredentialRequest: receiptCredentialRequestBase64, + processor: 'STRIPE', + }; + + // Payment could ultimately fail here, especially with other payment types + // If 204, use exponential backoff - payment hasn't gone through yet + // if 409, something has gone strangely wrong - we're using a different + // credentialRequest for the same paymentIntentId + let responseWithDetails; + try { + responseWithDetails = + await window.textsecure.server.createBoostReceiptCredentials( + jsonPayload + ); + } catch (error) { + if (error.code === 409) { + // Save for the user's tax records even if something went wrong with credential + await saveReceipt(workflow, logId); + throw new Error( + `${logId}: Got 409 when attempting to get receipt; failing donation` + ); + } + + throw error; + } + + if (responseWithDetails.response.status === 204) { + log.info( + `${logId}: Payment is still processing, leaving workflow at INTENT_CONFIRMED` + ); + return workflow; + } + const { receiptCredentialResponse: receiptCredentialResponseBase64 } = + responseWithDetails.data; + + const receiptCredential = generateCredential( + receiptCredentialResponseBase64, + receiptCredentialRequestContextBase64 + ); + + const isValid = isCredentialValid(receiptCredential); + if (!isValid) { + // Save for the user's tax records even if something went wrong with credential + await saveReceipt(workflow, logId); + throw new Error( + `${logId}: Credential returned for donation is invalid; failing donation` + ); + } + + log.info(`${logId}: Successfully transitioned to RECEIPT`); + + // At this point we know that the payment went through, so we save the receipt now. + // If the redemption never happens, or fails, the user has it for their tax records. + + await saveReceipt(workflow, logId); + return { + ...workflow, + type: donationStateSchema.Enum.RECEIPT, + timestamp: Date.now(), + receiptCredentialBase64: Bytes.toBase64(receiptCredential.serialize()), + }; + }); +} + +export async function _redeemReceipt( + workflow: DonationWorkflow +): Promise { + const logId = `_redeemReceipt(${redactId(workflow.id)})`; + + return withConcurrencyCheck(logId, async () => { + if (workflow.type !== donationStateSchema.Enum.RECEIPT) { + throw new Error( + `${logId}: workflow at type ${workflow?.type} not type RECEIPT, unable to redeem receipt` + ); + } + if (!window.textsecure.server) { + throw new Error(`${logId}: window.textsecure.server is not available!`); + } + + log.info(`${logId}: Starting`); + + const receiptCredentialPresentation = generateReceiptCredentialPresentation( + workflow.receiptCredentialBase64 + ); + const receiptCredentialPresentationBase64 = Bytes.toBase64( + receiptCredentialPresentation.serialize() + ); + const jsonPayload = { + receiptCredentialPresentation: receiptCredentialPresentationBase64, + visible: false, + primary: false, + }; + + await window.textsecure.server.redeemReceipt(jsonPayload); + + log.info(`${logId}: Successfully transitioned to DONE`); + + return { + type: donationStateSchema.Enum.DONE, + id: workflow.id, + timestamp: Date.now(), + }; + }); +} + +// Helper functions + +async function failDonation(errorType: DonationErrorType): Promise { + const workflow = _getWorkflowFromRedux(); + + // We clear the workflow if we didn't just get user input + if ( + workflow && + workflow.type !== donationStateSchema.Enum.INTENT_METHOD && + workflow.type !== donationStateSchema.Enum.INTENT + ) { + await _saveWorkflow(undefined); + } + + log.info(`failDonation: Failing with type ${errorType}`); + window.reduxActions.donations.updateLastError(errorType); +} +async function _saveWorkflow( + workflow: DonationWorkflow | undefined +): Promise { + await _saveWorkflowToStorage(workflow); + _saveWorkflowToRedux(workflow); +} +export function _getWorkflowFromRedux(): DonationWorkflow | undefined { + return window.reduxStore.getState().donations.currentWorkflow; +} +export function _saveWorkflowToRedux( + workflow: DonationWorkflow | undefined +): void { + window.reduxActions.donations.updateWorkflow(workflow); +} + +export function _getWorkflowFromStorage(): DonationWorkflow | undefined { + const logId = '_getWorkflowFromStorage'; + const workflowJson = window.storage.get(WORKFLOW_STORAGE_KEY); + + if (!workflowJson) { + log.info(`${logId}: No workflow found in window.storage`); + return undefined; + } + + const workflowData = JSON.parse(workflowJson) as unknown; + const result = safeParseUnknown(donationWorkflowSchema, workflowData); + if (!result.success) { + log.error( + `${logId}: Workflow from window.storage was malformed: ${result.error.flatten()}` + ); + return undefined; + } + + const workflow = result.data; + if (workflow.type === donationStateSchema.Enum.INTENT) { + log.info(`${logId}: Found existing workflow at type INTENT, dropping.`); + return undefined; + } + + log.info(`${logId}: Found existing workflow from window.storage`); + return workflow; +} +export async function _saveWorkflowToStorage( + workflow: DonationWorkflow | undefined +): Promise { + const logId = `_saveWorkflowToStorage(${workflow?.id ? redactId(workflow.id) : 'NONE'}`; + if (!workflow) { + log.info(`${logId}: Clearing workflow`); + await window.storage.remove(WORKFLOW_STORAGE_KEY); + return; + } + + const result = safeParseStrict(donationWorkflowSchema, workflow); + if (!result.success) { + log.error( + `${logId}: Provided workflow was malformed: ${result.error.flatten()}` + ); + throw result.error; + } + + await window.storage.put(WORKFLOW_STORAGE_KEY, JSON.stringify(workflow)); + log.info(`${logId}: Saved workflow to window.storage`); +} + +async function saveReceipt(workflow: DonationWorkflow, logId: string) { + if ( + workflow.type !== donationStateSchema.Enum.RECEIPT && + workflow.type !== donationStateSchema.Enum.INTENT_CONFIRMED + ) { throw new Error( - `${logId}: workflow at type ${workflow?.type} is not at type INTENT_METHOD, unable to confirm payment` + `${logId}: Cannot save receipt from workflow at type ${workflow?.type}` ); } - if (!window.textsecure.server) { - throw new Error(`${logId}: window.textsecure.server is not available!`); - } + const donationReceipt: DonationReceipt = { + id: workflow.id, + currencyType: workflow.currencyType, + paymentAmount: workflow.paymentAmount, + // This will be when we transitioned to INTENT_CONFIRMED, most likely. It may be close + // to when the user clicks the Donate button, or delayed by a bit. + timestamp: workflow.timestamp, + }; - log.info(`${logId}: Starting`); + await createDonationReceipt(donationReceipt); + window.reduxActions.donations.addReceipt(donationReceipt); - const serverPublicParams = new ServerPublicParams( + log.info(`${logId}: Successfully saved receipt`); +} + +// Working with zkgroup receipts + +function getServerPublicParams(): ServerPublicParams { + return new ServerPublicParams( Buffer.from(window.getServerPublicParams(), 'base64') ); - const zkReceipt = new ClientZkReceiptOperations(serverPublicParams); +} + +function getZkReceiptOperations(): ClientZkReceiptOperations { + const serverPublicParams = getServerPublicParams(); + return new ClientZkReceiptOperations(serverPublicParams); +} + +function getReceiptContext(): ReceiptContext { + const zkReceipt = getZkReceiptOperations(); const receiptSerialData = getRandomBytes(RECEIPT_SERIAL_LENGTH); const receiptSerial = new ReceiptSerial(Buffer.from(receiptSerialData)); const receiptCredentialRequestContext = zkReceipt.createReceiptCredentialRequestContext(receiptSerial); const receiptCredentialRequest = receiptCredentialRequestContext.getRequest(); - const receiptContext: ReceiptContext = { + return { receiptCredentialRequestContextBase64: Bytes.toBase64( receiptCredentialRequestContext.serialize() ), @@ -192,225 +806,68 @@ export async function confirmPayment( receiptCredentialRequest.serialize() ), }; - - const { clientSecret, paymentIntentId, paymentMethodId, id } = workflow; - const idempotencyKey = hashIdToIdempotencyKey(id); - const returnUrl = donationValidationCompleteRoute - .toAppUrl({ token: workflow.returnToken }) - .toString(); - const options = { - clientSecret, - idempotencyKey, - paymentIntentId, - paymentMethodId, - returnUrl, - }; - - const { next_action: nextAction } = - await window.textsecure.server.confirmIntentWithStripe(options); - - // TODO: Support Redirect to URL - if (nextAction && nextAction.type === 'redirect_to_url') { - const { redirect_to_url: redirectDetails } = nextAction; - - if (!redirectDetails || !redirectDetails.url) { - throw new Error( - `${logId}: nextAction type was redirect_to_url, but no url was supplied!` - ); - } - - log.info(`${logId}: Successfully transitioned to INTENT_REDIRECT`); - - return { - ...workflow, - ...receiptContext, - type: donationStateSchema.Enum.INTENT_REDIRECT, - timestamp: Date.now(), - redirectTarget: redirectDetails.url, - }; - } - - if (nextAction) { - throw new Error( - `${logId}: Unsupported nextAction type ${nextAction.type}!` - ); - } - - log.info(`${logId}: Successfully transitioned to INTENT_CONFIRMED`); - - return { - ...workflow, - ...receiptContext, - type: donationStateSchema.Enum.INTENT_CONFIRMED, - timestamp: Date.now(), - }; } -export async function completeValidationRedirect( - workflow: DonationWorkflow, - token: string -): Promise { - const logId = `completeValidationRedirect(${redactId(workflow.id)})`; - - if (workflow.type !== donationStateSchema.Enum.INTENT_REDIRECT) { - throw new Error( - `${logId}: workflow at type ${workflow?.type} is not type INTENT_REDIRECT, unable to complete redirect` - ); - } - if (!window.textsecure.server) { - throw new Error(`${logId}: window.textsecure.server is not available!`); - } - log.info(`${logId}: Starting`); - - if (token !== workflow.returnToken) { - throw new Error(`${logId}: The provided token did not match saved token`); - } - - log.info( - `${logId}: Successfully transitioned to INTENT_CONFIRMED for workflow ${redactId(workflow.id)}` - ); - - return { - ...workflow, - type: donationStateSchema.Enum.INTENT_CONFIRMED, - timestamp: Date.now(), - }; -} - -export async function getReceipt( - workflow: DonationWorkflow -): Promise { - const logId = `getReceipt(${redactId(workflow.id)})`; - - if (workflow.type !== donationStateSchema.Enum.INTENT_CONFIRMED) { - throw new Error( - `${logId}: workflow at type ${workflow?.type} not type INTENT_CONFIRMED, unable to get receipt` - ); - } - if (!window.textsecure.server) { - throw new Error(`${logId}: window.textsecure.server is not available!`); - } - log.info(`${logId}: Starting`); - - const { - paymentIntentId, - receiptCredentialRequestBase64, - receiptCredentialRequestContextBase64, - } = workflow; - const jsonPayload = { - paymentIntentId, - receiptCredentialRequest: receiptCredentialRequestBase64, - processor: 'STRIPE', - }; - - // Payment could ultimately fail here, especially with other payment types - // If 204, use exponential backoff - payment hasn't gone through yet - // if 409, something has gone strangely wrong - we're using a different - // credentialRequest for the same paymentIntentId - const { receiptCredentialResponse: receiptCredentialResponseBase64 } = - await window.textsecure.server.createBoostReceiptCredentials(jsonPayload); - +function generateCredential( + receiptCredentialResponseBase64: string, + receiptCredentialRequestContextBase64: string +) { + const zkReceipt = getZkReceiptOperations(); const receiptCredentialResponse = new ReceiptCredentialResponse( Buffer.from(receiptCredentialResponseBase64, 'base64') ); const receiptCredentialRequestContext = new ReceiptCredentialRequestContext( Buffer.from(receiptCredentialRequestContextBase64, 'base64') ); - const serverPublicParams = new ServerPublicParams( - Buffer.from(window.getServerPublicParams(), 'base64') - ); - const zkReceipt = new ClientZkReceiptOperations(serverPublicParams); - const receiptCredential = zkReceipt.receiveReceiptCredential( + + return zkReceipt.receiveReceiptCredential( receiptCredentialRequestContext, receiptCredentialResponse ); - - // TODO: Validate receiptCredential.level and expiration - - log.info( - `${logId}: Successfully transitioned to RECEIPT for workflow ${redactId(workflow.id)}` - ); - - return { - ...workflow, - type: donationStateSchema.Enum.RECEIPT, - timestamp: Date.now(), - receiptCredentialBase64: Bytes.toBase64(receiptCredential.serialize()), - }; } -export async function redeemReceipt( - workflow: DonationWorkflow -): Promise { - const logId = `redeemReceipt(${redactId(workflow.id)})`; - - if (workflow.type !== donationStateSchema.Enum.RECEIPT) { - throw new Error( - `${logId}: workflow at type ${workflow?.type} not type RECEIPT, unable to redeem receipt` - ); - } - if (!window.textsecure.server) { - throw new Error(`${logId}: window.textsecure.server is not available!`); - } - log.info(`${logId}: Starting`); - - const serverPublicParams = new ServerPublicParams( - Buffer.from(window.getServerPublicParams(), 'base64') - ); - const zkReceipt = new ClientZkReceiptOperations(serverPublicParams); - const { receiptCredentialBase64 } = workflow; +function generateReceiptCredentialPresentation( + receiptCredentialBase64: string +) { + const zkReceipt = getZkReceiptOperations(); const receiptCredential = new ReceiptCredential( Buffer.from(receiptCredentialBase64, 'base64') ); const receiptCredentialPresentation = zkReceipt.createReceiptCredentialPresentation(receiptCredential); - const receiptCredentialPresentationBase64 = Bytes.toBase64( - receiptCredentialPresentation.serialize() - ); - const jsonPayload = { - receiptCredentialPresentation: receiptCredentialPresentationBase64, - visible: false, - primary: false, - }; - - await window.textsecure.server.redeemReceipt(jsonPayload); - - log.info(`${logId}: Successfully transitioned to RECEIPT_REDEEMED`); - - return { - ...workflow, - type: donationStateSchema.Enum.RECEIPT_REDEEMED, - timestamp: Date.now(), - }; + return receiptCredentialPresentation; } -export async function saveReceipt( - workflow: DonationWorkflow -): Promise { - const logId = `saveReceipt(${redactId(workflow.id)})`; +function isCredentialValid(credential: ReceiptCredential): boolean { + const logId = 'isCredentialValid'; - if (workflow.type !== donationStateSchema.Enum.RECEIPT_REDEEMED) { - throw new Error( - `${logId}: workflow at type ${workflow?.type} is not ready to save receipt` - ); + const level = credential.getReceiptLevel(); + if (level !== BigInt(BOOST_LEVEL)) { + log.warn(`${logId}: Expected level to be ${BOOST_LEVEL}, but was ${level}`); + return false; } - log.info(`${logId}: Starting`); - const donationReceipt: DonationReceipt = { - id: workflow.id, - currencyType: workflow.currencyType, - paymentAmount: workflow.paymentAmount, - timestamp: workflow.timestamp, - }; + const expirationTime = DurationInSeconds.toMillis( + DurationInSeconds.fromSeconds(credential.getReceiptExpirationTime()) + ); + if (expirationTime % DAY !== 0) { + log.warn( + `${logId}: Expiration of ${expirationTime} was not divisible by ${DAY}` + ); + return false; + } + if (isInPast(expirationTime)) { + log.warn(`${logId}: Expiration of ${expirationTime} is in the past`); + return false; + } - await createDonationReceipt(donationReceipt); + const maxExpiration = Date.now() + DAY * MAX_CREDENTIAL_EXPIRATION_IN_DAYS; + if (expirationTime > maxExpiration) { + log.warn( + `${logId}: Expiration of ${expirationTime} is greater than max expiration: ${maxExpiration}` + ); + return false; + } - log.info(`${logId}: Successfully saved receipt`); - - window.reduxActions.donations.addReceipt(donationReceipt); - - return { - id: workflow.id, - type: donationStateSchema.Enum.DONE, - }; + return true; } diff --git a/ts/state/ducks/donations.ts b/ts/state/ducks/donations.ts index 4e0cfe304a..670bde83bd 100644 --- a/ts/state/ducks/donations.ts +++ b/ts/state/ducks/donations.ts @@ -12,6 +12,7 @@ import { isStagingServer } from '../../util/isStagingServer'; import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions'; import type { CardDetail, + DonationErrorType, DonationReceipt, DonationWorkflow, } from '../../types/Donations'; @@ -25,6 +26,7 @@ const log = createLogger('donations'); export type DonationsStateType = ReadonlyDeep<{ currentWorkflow: DonationWorkflow | undefined; + lastError: DonationErrorType | undefined; receipts: Array; }>; @@ -33,6 +35,7 @@ export type DonationsStateType = ReadonlyDeep<{ export const ADD_RECEIPT = 'donations/ADD_RECEIPT'; export const SUBMIT_DONATION = 'donations/SUBMIT_DONATION'; export const UPDATE_WORKFLOW = 'donations/UPDATE_WORKFLOW'; +export const UPDATE_LAST_ERROR = 'donations/UPDATE_LAST_ERROR'; export type AddReceiptAction = ReadonlyDeep<{ type: typeof ADD_RECEIPT; @@ -48,13 +51,21 @@ export type SubmitDonationAction = ReadonlyDeep<{ }; }>; +export type UpdateLastErrorAction = ReadonlyDeep<{ + type: typeof UPDATE_LAST_ERROR; + payload: { lastError: DonationErrorType | undefined }; +}>; + export type UpdateWorkflowAction = ReadonlyDeep<{ type: typeof UPDATE_WORKFLOW; payload: { nextWorkflow: DonationWorkflow | undefined }; }>; export type DonationsActionType = ReadonlyDeep< - AddReceiptAction | SubmitDonationAction | UpdateWorkflowAction + | AddReceiptAction + | SubmitDonationAction + | UpdateLastErrorAction + | UpdateWorkflowAction >; // Action Creators @@ -105,7 +116,7 @@ function submitDonation({ } try { - await donations.internalDoDonation({ + await donations._internalDoDonation({ currencyType, paymentAmount, paymentDetail, @@ -123,6 +134,15 @@ function clearWorkflow(): UpdateWorkflowAction { }; } +function updateLastError( + lastError: DonationErrorType | undefined +): UpdateLastErrorAction { + return { + type: UPDATE_LAST_ERROR, + payload: { lastError }, + }; +} + function updateWorkflow( nextWorkflow: DonationWorkflow | undefined ): UpdateWorkflowAction { @@ -137,6 +157,7 @@ export const actions = { clearWorkflow, internalAddDonationReceipt, submitDonation, + updateLastError, updateWorkflow, }; @@ -149,6 +170,7 @@ export const useDonationsActions = (): BoundActionCreatorsMapObject< export function getEmptyState(): DonationsStateType { return { currentWorkflow: undefined, + lastError: undefined, receipts: [], }; } @@ -164,6 +186,13 @@ export function reducer( }; } + if (action.type === UPDATE_LAST_ERROR) { + return { + ...state, + lastError: action.payload.lastError, + }; + } + if (action.type === UPDATE_WORKFLOW) { return { ...state, diff --git a/ts/textsecure/WebAPI.ts b/ts/textsecure/WebAPI.ts index 01e60fd245..a243e40453 100644 --- a/ts/textsecure/WebAPI.ts +++ b/ts/textsecure/WebAPI.ts @@ -472,6 +472,8 @@ async function _promiseAjax( try { if (DEBUG && !isSuccess(response.status)) { result = await response.text(); + // eslint-disable-next-line no-console + console.error(result); } else if ( (options.responseType === 'json' || options.responseType === 'jsonwithdetails') && @@ -1189,6 +1191,9 @@ export type ConfirmIntentWithStripeOptionsType = Readonly<{ returnUrl: string; }>; const ConfirmIntentWithStripeResultSchema = z.object({ + // https://docs.stripe.com/api/payment_intents/object#payment_intent_object-status + status: z.string(), + // https://docs.stripe.com/api/payment_intents/object#payment_intent_object-next_action next_action: z .object({ type: z.string(), @@ -1200,6 +1205,14 @@ const ConfirmIntentWithStripeResultSchema = z.object({ .nullable(), }) .nullable(), + // https://docs.stripe.com/api/payment_intents/object#payment_intent_object-last_payment_error + last_payment_error: z + .object({ + type: z.string(), + advice_code: z.string().nullable(), + message: z.string().nullable(), + }) + .nullable(), }); type ConfirmIntentWithStripeResultType = z.infer< typeof ConfirmIntentWithStripeResultSchema @@ -1580,7 +1593,7 @@ export type WebAPIType = { getAvatar: (path: string) => Promise; createBoostReceiptCredentials: ( options: CreateBoostReceiptCredentialsOptionsType - ) => Promise; + ) => Promise>; redeemReceipt: (options: RedeemReceiptOptionsType) => Promise; getHasSubscription: (subscriberId: Uint8Array) => Promise; getGroup: (options: GroupCredentialsType) => Promise; @@ -4735,14 +4748,14 @@ export function initialize({ async function createBoostReceiptCredentials( options: CreateBoostReceiptCredentialsOptionsType - ): Promise { + ): Promise> { return _ajax({ unauthenticated: true, host: 'chatService', call: 'boostReceiptCredentials', httpType: 'POST', jsonData: options, - responseType: 'json', + responseType: 'jsonwithdetails', zodSchema: CreateBoostReceiptCredentialsResultSchema, }); } diff --git a/ts/types/Donations.ts b/ts/types/Donations.ts index aae3172702..0ab5ca7569 100644 --- a/ts/types/Donations.ts +++ b/ts/types/Donations.ts @@ -9,10 +9,21 @@ export const donationStateSchema = z.enum([ 'INTENT_CONFIRMED', 'INTENT_REDIRECT', 'RECEIPT', - 'RECEIPT_REDEEMED', 'DONE', ]); +export const donationErrorTypeSchema = z.enum([ + // Any 4xx error when adding payment method or confirming intent + 'PaymentDeclined', + // Only used if we can't support 3DS validation for our first release + 'CardNotSupported', + // Any other HTTPError during the process + 'DonationProcessingError', + // Any other error + 'GeneralError', +]); +export type DonationErrorType = z.infer; + const coreDataSchema = z.object({ // Guid used to prevent duplicates at stripe and in our db id: z.string(), @@ -23,8 +34,7 @@ const coreDataSchema = z.object({ // Cents as whole numbers, so multiply by 100 paymentAmount: z.number(), - // The last time we transitioned into a new state. So the timestamp shown to the user - // will be when we redeem the receipt, not when they initiated the donation. + // The last time we transitioned into a new state. timestamp: z.number(), }); export type CoreData = z.infer; @@ -71,7 +81,7 @@ export const donationReceiptSchema = z.object({ }); export type DonationReceipt = z.infer; -const donationWorkflowSchema = z.discriminatedUnion('type', [ +export const donationWorkflowSchema = z.discriminatedUnion('type', [ z.object({ // Track that user has chosen currency and amount, and we've successfully fetched an // intent. There is no need to persist this, because we'd need to update @@ -82,11 +92,10 @@ const donationWorkflowSchema = z.discriminatedUnion('type', [ }), z.object({ - // Generally this should be a very short-lived state. The user has entered payment - // details and pressed the button to make the payment, and we have sent that to - // stripe. The next step is to use those details to confirm payment. No other - // user interaction is required after this point to continue the process - unless - // 3ds validation is needed - see INTENT_REDIRECT. + // Once we are here, we can proceed without further user input. The user has entered + // payment details and pressed the button to make the payment, and we have sent that + // to stripe, which has saved that data behind a paymentMethodId. The only thing + // that might require further user interaction: 3ds validation - see INTENT_REDIRECT. type: z.literal(donationStateSchema.Enum.INTENT_METHOD), // Stripe persists the user's payment information for us, behind this id @@ -97,10 +106,10 @@ const donationWorkflowSchema = z.discriminatedUnion('type', [ }), z.object({ - // After we confirm payment details with Stripe, this state represents - // Stripe's acknowledgement. However it will take some time (usually seconds, - // sometimes minutes or 1 day) to finalize the transaction. We will only know - // when we request a receipt credential from the chat server. + // By this point, Stripe is attempting to charge the user's provided payment method. + // However it will take some time (usually seconds, sometimes minutes or 1 day) to + // finalize the transaction. We will only know when we successfully get a receipt + // credential from the chat server. type: z.literal(donationStateSchema.Enum.INTENT_CONFIRMED), ...coreDataSchema.shape, @@ -129,26 +138,20 @@ const donationWorkflowSchema = z.discriminatedUnion('type', [ // successfully; we just need to redeem it on the server anonymously. type: z.literal(donationStateSchema.Enum.RECEIPT), - // the result of mixing the receiptCredentialResponse from the API from our + // The result of mixing the receiptCredentialResponse from the API from our // previously-generated receiptCredentialRequestContext receiptCredentialBase64: z.string(), ...coreDataSchema.shape, }), - z.object({ - // A short-lived state, but we'll be in this state until we successfully save a new - // receipt field in the database and add to redux. - type: z.literal(donationStateSchema.Enum.RECEIPT_REDEEMED), - ...coreDataSchema.shape, - }), - z.object({ // After everything is done, we should notify the user the donation succeeded. // After we show a notification, or if the user initiates a new donation, // then this workflow can be deleted. type: z.literal(donationStateSchema.Enum.DONE), - id: z.string(), + id: coreDataSchema.shape.id, + timestamp: coreDataSchema.shape.timestamp, }), ]); diff --git a/ts/types/Storage.d.ts b/ts/types/Storage.d.ts index 847e812bea..ed4e5568ca 100644 --- a/ts/types/Storage.d.ts +++ b/ts/types/Storage.d.ts @@ -131,6 +131,7 @@ export type StorageAccessType = { linkPreviews: boolean; universalExpireTimer: number; retryPlaceholders: ReadonlyArray; + donationWorkflow: string; chromiumRegistrationDoneEver: ''; chromiumRegistrationDone: ''; phoneNumberSharingMode: PhoneNumberSharingMode; diff --git a/ts/util/isProtoBinaryEncodingEnabled.ts b/ts/util/isProtoBinaryEncodingEnabled.ts index 9083e6f645..64cb1ddc06 100644 --- a/ts/util/isProtoBinaryEncodingEnabled.ts +++ b/ts/util/isProtoBinaryEncodingEnabled.ts @@ -8,6 +8,6 @@ export function isProtoBinaryEncodingEnabled(): boolean { return true; } - // TODO: https://signalmessenger.atlassian.net/browse/DESKTOP-8938 + // TODO: DESKTOP-8938 return false; } diff --git a/ts/windows/main/preload_test.ts b/ts/windows/main/preload_test.ts index b65e3cc229..c8f68db46e 100644 --- a/ts/windows/main/preload_test.ts +++ b/ts/windows/main/preload_test.ts @@ -127,6 +127,7 @@ window.testUtilities = { storyDistributionLists: [], donations: { currentWorkflow: undefined, + lastError: undefined, receipts: [], }, stickers: {