// 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, ReceiptCredential, ReceiptCredentialRequestContext, ReceiptCredentialResponse, ReceiptSerial, ServerPublicParams, } from '@signalapp/libsignal-client/zkgroup'; import * as Bytes from '../Bytes'; 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, StripeDonationAmount, } from '../types/Donations'; const { createDonationReceipt } = DataWriter; const log = createLogger('donations'); function redactId(id: string) { return `[REDACTED]${id.slice(-4)}`; } 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; // 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: StripeDonationAmount; }): 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 { let workflow: DonationWorkflow; try { const existing = _getWorkflowFromRedux(); if (!existing) { throw new Error( 'finish3dsValidation: Cannot finish nonexistent workflow!' ); } workflow = await _completeValidationRedirect(existing, token); } catch (error) { await failDonation(donationErrorTypeSchema.Enum.Failed3dsValidation); throw error; } await _saveAndRunWorkflow(workflow); } export async function clearDonation(): Promise { runDonationAbortController?.abort(); await _saveWorkflow(undefined); } // For testing export async function _internalDoDonation({ currencyType, paymentAmount, paymentDetail, }: { currencyType: string; paymentAmount: StripeDonationAmount; paymentDetail: CardDetail; }): Promise { if (isInternalDonationInProgress) { throw new Error("Can't proceed because a donation is in progress."); } try { isInternalDonationInProgress = true; let workflow: DonationWorkflow; workflow = await _createPaymentIntent({ currencyType, paymentAmount, workflow: undefined, }); await _saveWorkflow(workflow); workflow = await _createPaymentMethodForIntent(workflow, paymentDetail); await _saveAndRunWorkflow(workflow); } finally { isInternalDonationInProgress = false; } } // 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: StripeDonationAmount; workflow: DonationWorkflow | undefined; }): Promise { const id = uuid(); const logId = `_createPaymentIntent(${redactId(id)})`; 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}: Creating new workflow`); 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( workflow: DonationWorkflow, cardDetail: CardDetail ): Promise { const logId = `_createPaymentMethodForIntent(${redactId(workflow.id)})`; 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`); const { id: paymentMethodId } = await window.textsecure.server.createPaymentMethodWithStripe({ cardDetail, }); log.info(`${logId}: Successfully transitioned to INTENT_METHOD`); return { ...workflow, type: donationStateSchema.Enum.INTENT_METHOD, timestamp: Date.now(), paymentMethodId, }; }); } export async function _confirmPayment( workflow: DonationWorkflow ): Promise { const logId = `_confirmPayment(${redactId(workflow.id)})`; 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 && workflow.type !== donationStateSchema.Enum.INTENT_REDIRECT ) { 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}: Cannot save receipt from workflow at type ${workflow?.type}` ); } 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, }; await createDonationReceipt(donationReceipt); window.reduxActions.donations.addReceipt(donationReceipt); log.info(`${logId}: Successfully saved receipt`); } // Working with zkgroup receipts function getServerPublicParams(): ServerPublicParams { return new ServerPublicParams( Buffer.from(window.getServerPublicParams(), 'base64') ); } 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(); return { receiptCredentialRequestContextBase64: Bytes.toBase64( receiptCredentialRequestContext.serialize() ), receiptCredentialRequestBase64: Bytes.toBase64( receiptCredentialRequest.serialize() ), }; } 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') ); return zkReceipt.receiveReceiptCredential( receiptCredentialRequestContext, receiptCredentialResponse ); } function generateReceiptCredentialPresentation( receiptCredentialBase64: string ) { const zkReceipt = getZkReceiptOperations(); const receiptCredential = new ReceiptCredential( Buffer.from(receiptCredentialBase64, 'base64') ); const receiptCredentialPresentation = zkReceipt.createReceiptCredentialPresentation(receiptCredential); return receiptCredentialPresentation; } function isCredentialValid(credential: ReceiptCredential): boolean { const logId = 'isCredentialValid'; const level = credential.getReceiptLevel(); if (level !== BigInt(BOOST_LEVEL)) { log.warn(`${logId}: Expected level to be ${BOOST_LEVEL}, but was ${level}`); return false; } 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; } 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; } return true; }