signal-desktop/ts/services/donations.ts

886 lines
26 KiB
TypeScript
Raw Normal View History

// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
2025-07-10 07:34:42 +10:00
/* 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';
2025-07-10 07:34:42 +10:00
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,
2025-07-10 07:34:42 +10:00
DonationErrorType,
DonationReceipt,
DonationWorkflow,
ReceiptContext,
StripeDonationAmount,
} from '../types/Donations';
const { createDonationReceipt } = DataWriter;
const log = createLogger('donations');
function redactId(id: string) {
return `[REDACTED]${id.slice(-4)}`;
}
2025-07-10 07:34:42 +10:00
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;
2025-07-10 07:34:42 +10:00
const BOOST_LEVEL = 1;
const WORKFLOW_STORAGE_KEY = 'donationWorkflow';
const MAX_CREDENTIAL_EXPIRATION_IN_DAYS = 90;
2025-07-10 07:34:42 +10:00
let runDonationAbortController: AbortController | undefined;
let isInternalDonationInProgress = false;
let isDonationInProgress = false;
2025-07-10 07:34:42 +10:00
// 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;
2025-07-10 07:34:42 +10:00
}): Promise<void> {
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<void> {
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<void> {
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;
2025-07-10 07:34:42 +10:00
}
await _saveAndRunWorkflow(workflow);
}
export async function clearDonation(): Promise<void> {
runDonationAbortController?.abort();
await _saveWorkflow(undefined);
}
// For testing
export async function _internalDoDonation({
currencyType,
paymentAmount,
paymentDetail,
}: {
currencyType: string;
paymentAmount: StripeDonationAmount;
paymentDetail: CardDetail;
}): Promise<void> {
2025-07-10 07:34:42 +10:00
if (isInternalDonationInProgress) {
throw new Error("Can't proceed because a donation is in progress.");
}
try {
2025-07-10 07:34:42 +10:00
isInternalDonationInProgress = true;
let workflow: DonationWorkflow;
2025-07-10 07:34:42 +10:00
workflow = await _createPaymentIntent({
currencyType,
paymentAmount,
2025-07-10 07:34:42 +10:00
workflow: undefined,
});
2025-07-10 07:34:42 +10:00
await _saveWorkflow(workflow);
2025-07-10 07:34:42 +10:00
workflow = await _createPaymentMethodForIntent(workflow, paymentDetail);
await _saveAndRunWorkflow(workflow);
} finally {
isInternalDonationInProgress = false;
}
}
2025-07-10 07:34:42 +10:00
// High-level functions to move things forward
2025-07-10 07:34:42 +10:00
export async function _saveAndRunWorkflow(
workflow: DonationWorkflow | undefined
): Promise<void> {
const logId = `_saveAndRunWorkflow(${workflow?.id ? redactId(workflow.id) : 'NONE'}`;
await _saveWorkflow(workflow);
2025-07-10 07:34:42 +10:00
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();
}
2025-07-10 07:34:42 +10:00
// 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<void> {
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;
2025-07-10 07:34:42 +10:00
runDonationAbortController = undefined;
}
}
2025-07-10 07:34:42 +10:00
// Workflow steps
let isDonationStepInProgress = false;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async function withConcurrencyCheck<T extends () => Promise<any>>(
name: string,
fn: T
): Promise<ReturnType<T>> {
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,
2025-07-10 07:34:42 +10:00
workflow,
}: {
currencyType: string;
paymentAmount: StripeDonationAmount;
2025-07-10 07:34:42 +10:00
workflow: DonationWorkflow | undefined;
}): Promise<DonationWorkflow> {
const id = uuid();
2025-07-10 07:34:42 +10:00
const logId = `_createPaymentIntent(${redactId(id)})`;
2025-07-10 07:34:42 +10:00
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!`);
}
2025-07-10 07:34:42 +10:00
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(),
};
});
}
2025-07-10 07:34:42 +10:00
export async function _createPaymentMethodForIntent(
workflow: DonationWorkflow,
cardDetail: CardDetail
): Promise<DonationWorkflow> {
2025-07-10 07:34:42 +10:00
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!`);
}
2025-07-10 07:34:42 +10:00
log.info(`${logId}: Starting`);
2025-07-10 07:34:42 +10:00
const { id: paymentMethodId } =
await window.textsecure.server.createPaymentMethodWithStripe({
cardDetail,
});
2025-07-10 07:34:42 +10:00
log.info(`${logId}: Successfully transitioned to INTENT_METHOD`);
2025-07-10 07:34:42 +10:00
return {
...workflow,
type: donationStateSchema.Enum.INTENT_METHOD,
timestamp: Date.now(),
paymentMethodId,
};
});
}
2025-07-10 07:34:42 +10:00
export async function _confirmPayment(
workflow: DonationWorkflow
): Promise<DonationWorkflow> {
2025-07-10 07:34:42 +10:00
const logId = `_confirmPayment(${redactId(workflow.id)})`;
2025-07-10 07:34:42 +10:00
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!`);
}
2025-07-10 07:34:42 +10:00
log.info(`${logId}: Starting`);
2025-07-10 07:34:42 +10:00
const receiptContext = getReceiptContext();
2025-07-10 07:34:42 +10:00
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,
};
2025-07-10 07:34:42 +10:00
const { next_action: nextAction } =
await window.textsecure.server.confirmIntentWithStripe(options);
2025-07-10 07:34:42 +10:00
if (nextAction && nextAction.type === 'redirect_to_url') {
const { redirect_to_url: redirectDetails } = nextAction;
2025-07-10 07:34:42 +10:00
if (!redirectDetails || !redirectDetails.url) {
throw new Error(
`${logId}: nextAction type was redirect_to_url, but no url was supplied!`
);
}
2025-07-10 07:34:42 +10:00
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(
2025-07-10 07:34:42 +10:00
`${logId}: Unsupported nextAction type ${nextAction.type}!`
);
}
2025-07-10 07:34:42 +10:00
log.info(`${logId}: Successfully transitioned to INTENT_CONFIRMED`);
return {
...workflow,
...receiptContext,
2025-07-10 07:34:42 +10:00
type: donationStateSchema.Enum.INTENT_CONFIRMED,
timestamp: Date.now(),
};
2025-07-10 07:34:42 +10:00
});
}
2025-07-10 07:34:42 +10:00
export async function _completeValidationRedirect(
workflow: DonationWorkflow,
token: string
): Promise<DonationWorkflow> {
2025-07-10 07:34:42 +10:00
const logId = `_completeValidationRedirect(${redactId(workflow.id)})`;
2025-07-10 07:34:42 +10:00
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!`);
}
2025-07-10 07:34:42 +10:00
log.info(`${logId}: Starting`);
2025-07-10 07:34:42 +10:00
if (token !== workflow.returnToken) {
throw new Error(`${logId}: The provided token did not match saved token`);
}
2025-07-10 07:34:42 +10:00
log.info(`${logId}: Successfully transitioned to INTENT_CONFIRMED`);
return {
...workflow,
type: donationStateSchema.Enum.INTENT_CONFIRMED,
timestamp: Date.now(),
};
});
}
2025-07-10 07:34:42 +10:00
export async function _getReceipt(
workflow: DonationWorkflow
): Promise<DonationWorkflow> {
2025-07-10 07:34:42 +10:00
const logId = `_getReceipt(${redactId(workflow.id)})`;
2025-07-10 07:34:42 +10:00
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!`);
}
2025-07-10 07:34:42 +10:00
log.info(`${logId}: Starting`);
const {
paymentIntentId,
receiptCredentialRequestBase64,
receiptCredentialRequestContextBase64,
} = workflow;
const jsonPayload = {
paymentIntentId,
receiptCredentialRequest: receiptCredentialRequestBase64,
processor: 'STRIPE',
};
2025-07-10 07:34:42 +10:00
// 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;
}
2025-07-10 07:34:42 +10:00
if (responseWithDetails.response.status === 204) {
log.info(
`${logId}: Payment is still processing, leaving workflow at INTENT_CONFIRMED`
);
return workflow;
}
const { receiptCredentialResponse: receiptCredentialResponseBase64 } =
responseWithDetails.data;
2025-07-10 07:34:42 +10:00
const receiptCredential = generateCredential(
receiptCredentialResponseBase64,
receiptCredentialRequestContextBase64
);
2025-07-10 07:34:42 +10:00
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()),
};
});
}
2025-07-10 07:34:42 +10:00
export async function _redeemReceipt(
workflow: DonationWorkflow
): Promise<DonationWorkflow> {
2025-07-10 07:34:42 +10:00
const logId = `_redeemReceipt(${redactId(workflow.id)})`;
2025-07-10 07:34:42 +10:00
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
);
2025-07-10 07:34:42 +10:00
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<void> {
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
2025-07-10 07:34:42 +10:00
) {
await _saveWorkflow(undefined);
}
2025-07-10 07:34:42 +10:00
log.info(`failDonation: Failing with type ${errorType}`);
window.reduxActions.donations.updateLastError(errorType);
}
async function _saveWorkflow(
workflow: DonationWorkflow | undefined
): Promise<void> {
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);
}
2025-07-10 07:34:42 +10:00
export function _getWorkflowFromStorage(): DonationWorkflow | undefined {
const logId = '_getWorkflowFromStorage';
const workflowJson = window.storage.get(WORKFLOW_STORAGE_KEY);
2025-07-10 07:34:42 +10:00
if (!workflowJson) {
log.info(`${logId}: No workflow found in window.storage`);
return undefined;
}
2025-07-10 07:34:42 +10:00
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;
}
2025-07-10 07:34:42 +10:00
export async function _saveWorkflowToStorage(
workflow: DonationWorkflow | undefined
): Promise<void> {
const logId = `_saveWorkflowToStorage(${workflow?.id ? redactId(workflow.id) : 'NONE'}`;
if (!workflow) {
log.info(`${logId}: Clearing workflow`);
await window.storage.remove(WORKFLOW_STORAGE_KEY);
return;
}
2025-07-10 07:34:42 +10:00
const result = safeParseStrict(donationWorkflowSchema, workflow);
if (!result.success) {
log.error(
`${logId}: Provided workflow was malformed: ${result.error.flatten()}`
);
throw result.error;
}
2025-07-10 07:34:42 +10:00
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(
2025-07-10 07:34:42 +10:00
`${logId}: Cannot save receipt from workflow at type ${workflow?.type}`
);
}
const donationReceipt: DonationReceipt = {
id: workflow.id,
currencyType: workflow.currencyType,
paymentAmount: workflow.paymentAmount,
2025-07-10 07:34:42 +10:00
// 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);
2025-07-10 07:34:42 +10:00
window.reduxActions.donations.addReceipt(donationReceipt);
log.info(`${logId}: Successfully saved receipt`);
2025-07-10 07:34:42 +10:00
}
2025-07-10 07:34:42 +10:00
// 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 {
2025-07-10 07:34:42 +10:00
receiptCredentialRequestContextBase64: Bytes.toBase64(
receiptCredentialRequestContext.serialize()
),
receiptCredentialRequestBase64: Bytes.toBase64(
receiptCredentialRequest.serialize()
),
};
}
2025-07-10 07:34:42 +10:00
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;
}