Compare commits

..

6 Commits

Author SHA1 Message Date
Ravi Khadiwala
aa5ac70ad3 Select lifecycle-manager based on environment variable (like in FoundationDbClusterExtension)
Some checks are pending
Update Documentation / build (push) Waiting to run
Service CI / build (push) Waiting to run
2026-06-25 17:42:04 -04:00
Jon Chambers
2abf55e395
Don't trigger state transitions inside retryable transactions 2026-06-25 17:41:33 -04:00
Jon Chambers
808bb16103 Add basic read/acknowledgement counters to RedisDynamoDbMessageStream 2026-06-25 17:41:11 -04:00
Ameya Lokare
4743abcfbd Port OneTimeDonationController to gRPC 2026-06-25 17:40:49 -04:00
Katherine
1b09529ece
Use V2 key transparency query RPCs 2026-06-24 15:02:34 -07:00
Chris Eager
0c3c390a0b
Update Donation-Permit header parameter documentation 2026-06-24 14:51:46 -07:00
26 changed files with 1289 additions and 307 deletions

View File

@ -176,6 +176,7 @@ import org.whispersystems.textsecuregcm.grpc.MessageDispatcher;
import org.whispersystems.textsecuregcm.grpc.MessagesAnonymousGrpcService;
import org.whispersystems.textsecuregcm.grpc.MessagesGrpcService;
import org.whispersystems.textsecuregcm.grpc.MetricServerInterceptor;
import org.whispersystems.textsecuregcm.grpc.OneTimeDonationsGrpcService;
import org.whispersystems.textsecuregcm.grpc.PaymentsGrpcService;
import org.whispersystems.textsecuregcm.grpc.ProfileAnonymousGrpcService;
import org.whispersystems.textsecuregcm.grpc.ProfileGrpcService;
@ -1095,7 +1096,10 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
new CredentialsAnonymousGrpcService(accountsManager, ExternalServiceDefinitions.SVR.generatorFactory().apply(config, Clock.systemUTC())),
new SubscriptionsGrpcService(clock, config.getSubscription(), config.getOneTimeDonations(), subscriptionManager,
donationPermitsManager, stripeManager, braintreeManager, googlePlayBillingManager, appleAppStoreManager,
profileBadgeConverter, bankMandateTranslator, dynamicConfigurationManager))
profileBadgeConverter, bankMandateTranslator, dynamicConfigurationManager),
new OneTimeDonationsGrpcService(config.getOneTimeDonations(), stripeManager, braintreeManager,
payPalDonationsTranslator, oneTimeDonationsManager, issuedReceiptsManager,
zkReceiptOperations, clock, rateLimiters, donationPermitsManager))
.map(bindableService -> ServerInterceptors.intercept(bindableService,
// Note: interceptors run in the reverse order they are added; the remote deprecation filter
// depends on the user-agent context so it has to come first here!

View File

@ -33,6 +33,8 @@ import org.glassfish.jersey.server.ManagedAsync;
import org.signal.keytransparency.client.AciMonitorRequest;
import org.signal.keytransparency.client.E164MonitorRequest;
import org.signal.keytransparency.client.E164SearchRequest;
import org.signal.keytransparency.client.MonitorResponseV2;
import org.signal.keytransparency.client.SearchResponseV2;
import org.signal.keytransparency.client.UsernameHashMonitorRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@ -101,15 +103,23 @@ public class KeyTransparencyController {
.build()
));
return new KeyTransparencySearchResponse(
keyTransparencyServiceClient.search(
ByteString.copyFrom(request.aci().toCompactByteArray()),
ByteString.copyFrom(request.aciIdentityKey().serialize()),
request.usernameHash().map(ByteString::copyFrom),
maybeE164SearchRequest,
request.lastTreeHeadSize(),
request.distinguishedTreeHeadSize())
.toByteArray());
final SearchResponseV2 searchResponse = keyTransparencyServiceClient.search(
ByteString.copyFrom(request.aci().toCompactByteArray()),
ByteString.copyFrom(request.aciIdentityKey().serialize()),
request.usernameHash().map(ByteString::copyFrom),
maybeE164SearchRequest,
request.lastTreeHeadSize(),
request.distinguishedTreeHeadSize());
if (searchResponse.hasPermissionDenied()) {
throw new StatusRuntimeException(Status.PERMISSION_DENIED);
}
if (!searchResponse.hasSearchResponse()) {
throw new StatusRuntimeException(Status.UNAVAILABLE.withDescription("Missing search response"));
}
return new KeyTransparencySearchResponse(searchResponse.getSearchResponse().toByteArray());
} catch (final StatusRuntimeException exception) {
handleKeyTransparencyServiceError(exception);
}
@ -163,13 +173,22 @@ public class KeyTransparencyController {
.setCommitmentIndex(ByteString.copyFrom(e164.commitmentIndex()))
.build());
return new KeyTransparencyMonitorResponse(keyTransparencyServiceClient.monitor(
final MonitorResponseV2 monitorResponse = keyTransparencyServiceClient.monitor(
aciMonitorRequest,
usernameHashMonitorRequest,
e164MonitorRequest,
request.lastNonDistinguishedTreeHeadSize(),
request.lastDistinguishedTreeHeadSize())
.toByteArray());
request.lastDistinguishedTreeHeadSize());
if (monitorResponse.hasPermissionDenied()) {
throw new StatusRuntimeException(Status.PERMISSION_DENIED);
}
if (!monitorResponse.hasMonitorResponse()) {
throw new StatusRuntimeException(Status.UNAVAILABLE.withDescription("Missing monitor response"));
}
return new KeyTransparencyMonitorResponse(monitorResponse.getMonitorResponse().toByteArray());
} catch (final StatusRuntimeException exception) {
handleKeyTransparencyServiceError(exception);
}

View File

@ -39,6 +39,7 @@ import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.Map;
@ -55,6 +56,7 @@ import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;
import org.whispersystems.textsecuregcm.auth.DonationPermitHeader;
import org.whispersystems.textsecuregcm.configuration.OneTimeDonationConfiguration;
import org.whispersystems.textsecuregcm.grpc.OneTimeDonationUtil;
import org.whispersystems.textsecuregcm.grpc.SubscriptionsUtil;
import org.whispersystems.textsecuregcm.limits.RateLimitedByIp;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
@ -72,11 +74,9 @@ import org.whispersystems.textsecuregcm.subscriptions.PaymentMethod;
import org.whispersystems.textsecuregcm.subscriptions.PaymentProvider;
import org.whispersystems.textsecuregcm.subscriptions.PaymentStatus;
import org.whispersystems.textsecuregcm.subscriptions.StripeManager;
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionCurrencyUtil;
import org.whispersystems.textsecuregcm.util.ExactlySize;
import org.whispersystems.textsecuregcm.util.HeaderUtils;
/**
* Endpoints for making one-time donation payments (boost and gift)
* <p>
@ -90,8 +90,6 @@ public class OneTimeDonationController {
private static final Logger logger = LoggerFactory.getLogger(OneTimeDonationController.class);
private static final String EURO_CURRENCY_CODE = "EUR";
private final Clock clock;
private final OneTimeDonationConfiguration oneTimeDonationConfiguration;
private final StripeManager stripeManager;
@ -103,15 +101,15 @@ public class OneTimeDonationController {
private final DonationPermitsManager donationPermitsManager;
public OneTimeDonationController(
Clock clock,
OneTimeDonationConfiguration oneTimeDonationConfiguration,
StripeManager stripeManager,
BraintreeManager braintreeManager,
PayPalDonationsTranslator payPalDonationsTranslator,
ServerZkReceiptOperations zkReceiptOperations,
IssuedReceiptsManager issuedReceiptsManager,
OneTimeDonationsManager oneTimeDonationsManager,
DonationPermitsManager donationPermitsManager) {
final Clock clock,
final OneTimeDonationConfiguration oneTimeDonationConfiguration,
final StripeManager stripeManager,
final BraintreeManager braintreeManager,
final PayPalDonationsTranslator payPalDonationsTranslator,
final ServerZkReceiptOperations zkReceiptOperations,
final IssuedReceiptsManager issuedReceiptsManager,
final OneTimeDonationsManager oneTimeDonationsManager,
final DonationPermitsManager donationPermitsManager) {
this.clock = Objects.requireNonNull(clock);
this.oneTimeDonationConfiguration = Objects.requireNonNull(oneTimeDonationConfiguration);
this.stripeManager = Objects.requireNonNull(stripeManager);
@ -151,10 +149,10 @@ public class OneTimeDonationController {
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Create a Stripe payment intent", description = """
Create a Stripe PaymentIntent and return a client secret that can be used to complete the payment.
Once the payment is complete, the paymentIntentId can be used at /v1/subscriptions/receipt_credentials
""")
Create a Stripe PaymentIntent and return a client secret that can be used to complete the payment.
Once the payment is complete, the paymentIntentId can be used at /v1/subscriptions/receipt_credentials
""")
@ApiResponse(responseCode = "200", description = "Payment Intent created", content = @Content(schema = @Schema(implementation = CreateBoostResponse.class)))
@ApiResponse(responseCode = "403", description = "The request was made on an authenticated channel")
@ApiResponse(responseCode = "400", description = """
@ -171,13 +169,12 @@ public class OneTimeDonationController {
@ApiResponse(responseCode = "401", description = "Donation permit was invalid or already spent")
@RateLimitedByIp(RateLimiters.For.ONE_TIME_DONATION)
public CompletableFuture<Response> createBoostPaymentIntent(
@Auth Optional<AuthenticatedDevice> authenticatedAccount,
@Auth final Optional<AuthenticatedDevice> authenticatedAccount,
@Parameter(description="A base64-encoded donation permit retrieved from POST /v1/donation/permit")
@HeaderParam(HeaderUtils.DONATION_PERMIT)
final Optional<DonationPermitHeader> donationPermitHeader,
@Parameter(description = "A base64-encoded donation permit retrieved from POST /v1/donation/permit")
@HeaderParam(HeaderUtils.DONATION_PERMIT) final Optional<DonationPermitHeader> donationPermitHeader,
@NotNull @Valid CreateBoostRequest request,
@NotNull @Valid final CreateBoostRequest request,
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent) {
if (authenticatedAccount.isPresent()) {
@ -189,7 +186,7 @@ public class OneTimeDonationController {
permitHeader -> {
try {
return SubscriptionsUtil.verifyAndSpendDonationPermit(permitHeader.permit(), donationPermitsManager, clock);
} catch (VerificationFailedException e) {
} catch (final VerificationFailedException e) {
return false;
}
})
@ -206,47 +203,34 @@ public class OneTimeDonationController {
}
/**
* Validates that the request level is valid, the currency is supported by the {@code manager} and {@code request.paymentMethod},
* and that the amount meets minimum and maximum constraints.
* Validates that the request level is valid, the currency is supported by the {@code manager} and
* {@code request.paymentMethod}, and that the amount meets minimum and maximum constraints.
*
* @throws BadRequestException indicates validation failed. Inspect {@code response.error} for details
*/
private void validateRequestCurrencyAmount(final CreateBoostRequest request, final BigDecimal amount,
final CustomerAwareSubscriptionPaymentProcessor manager) {
if (!(request.level == oneTimeDonationConfiguration.gift().level()
|| request.level == oneTimeDonationConfiguration.boost().level())) {
final Map<String, String> errorBody = switch (OneTimeDonationUtil.validateOneTimeDonationRequest(request.currency,
amount, request.level, request.paymentMethod, oneTimeDonationConfiguration, manager)) {
case OneTimeDonationUtil.OneTimeDonationRequestValidationResult.UnsupportedLevel _ ->
Map.of("error", "invalid_level");
case OneTimeDonationUtil.OneTimeDonationRequestValidationResult.UnsupportedCurrency _ ->
Map.of("error", "unsupported_currency");
case OneTimeDonationUtil.OneTimeDonationRequestValidationResult.AmountBelowMinimum(final BigDecimal min) ->
Map.of("error", "amount_below_currency_minimum",
"minimum", min.toString());
case OneTimeDonationUtil.OneTimeDonationRequestValidationResult.AmountAboveSepaLimit(final BigDecimal max) ->
Map.of("error", "amount_above_sepa_limit",
"maximum", max.toString());
case OneTimeDonationUtil.OneTimeDonationRequestValidationResult.Success _ -> Collections.emptyMap();
};
if (!errorBody.isEmpty()) {
throw new BadRequestException(
Response.status(Response.Status.BAD_REQUEST).entity(Map.of("error", "invalid_level")).build());
Response.status(Response.Status.BAD_REQUEST).entity(errorBody).build());
}
if (!manager.getSupportedCurrenciesForPaymentMethod(request.paymentMethod)
.contains(request.currency.toLowerCase(Locale.ROOT))) {
throw new BadRequestException(Response.status(Response.Status.BAD_REQUEST)
.entity(Map.of("error", "unsupported_currency")).build());
}
final BigDecimal minCurrencyAmountMajorUnits = oneTimeDonationConfiguration.currencies()
.get(request.currency.toLowerCase(Locale.ROOT)).minimum();
final BigDecimal minCurrencyAmountMinorUnits = SubscriptionCurrencyUtil.convertConfiguredAmountToApiAmount(
request.currency,
minCurrencyAmountMajorUnits);
if (minCurrencyAmountMinorUnits.compareTo(amount) > 0) {
throw new BadRequestException(Response.status(Response.Status.BAD_REQUEST)
.entity(Map.of(
"error", "amount_below_currency_minimum",
"minimum", minCurrencyAmountMajorUnits.toString())).build());
}
if (request.paymentMethod == PaymentMethod.SEPA_DEBIT &&
amount.compareTo(SubscriptionCurrencyUtil.convertConfiguredAmountToApiAmount(
EURO_CURRENCY_CODE,
oneTimeDonationConfiguration.sepaMaximumEuros())) > 0) {
throw new BadRequestException(Response.status(Response.Status.BAD_REQUEST)
.entity(Map.of(
"error", "amount_above_sepa_limit",
"maximum", oneTimeDonationConfiguration.sepaMaximumEuros().toString())).build());
}
}
public static class CreatePayPalBoostRequest extends CreateBoostRequest {
@ -268,9 +252,9 @@ public class OneTimeDonationController {
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public CompletableFuture<Response> createPayPalBoost(
@Auth Optional<AuthenticatedDevice> authenticatedAccount,
@NotNull @Valid CreatePayPalBoostRequest request,
@Context ContainerRequestContext containerRequestContext) {
@Auth final Optional<AuthenticatedDevice> authenticatedAccount,
@NotNull @Valid final CreatePayPalBoostRequest request,
@Context final ContainerRequestContext containerRequestContext) {
if (authenticatedAccount.isPresent()) {
throw new ForbiddenException("must not use authenticated connection for one-time donation operations");
@ -281,21 +265,11 @@ public class OneTimeDonationController {
.thenCompose(_ -> {
final List<Locale> acceptableLanguages =
HeaderUtils.getAcceptableLanguagesForRequest(containerRequestContext);
// These two localizations are a best-effort, and it's possible that the first `locale` and the localized line
// item name will not match. We could try to align with the locales PayPal documents <https://developer.paypal.com/reference/locale-codes/#supported-locale-codes>
// but that's a moving target, and we can hopefully have one of them be better for the user by selecting
// independently.
final Locale locale = acceptableLanguages.stream()
.filter(l -> !"*".equals(l.getLanguage()))
.findFirst()
.orElse(Locale.US);
final String localizedLineItemName = payPalDonationsTranslator.translate(acceptableLanguages,
PayPalDonationsTranslator.ONE_TIME_DONATION_LINE_ITEM_KEY);
final OneTimeDonationUtil.LocalizedPayPalDonationLineItem localizedLineItem = OneTimeDonationUtil.localizePayPalDonationLineItem(
payPalDonationsTranslator, acceptableLanguages);
return braintreeManager.createOneTimePayment(request.currency.toUpperCase(Locale.ROOT), request.amount,
locale.toLanguageTag(),
request.returnUrl, request.cancelUrl, localizedLineItemName);
localizedLineItem.locale().toLanguageTag(),
request.returnUrl, request.cancelUrl, localizedLineItem.itemName());
})
.thenApply(approvalDetails -> Response.ok(
new CreatePayPalBoostResponse(approvalDetails.approvalUrl(), approvalDetails.paymentId())).build());
@ -322,8 +296,8 @@ public class OneTimeDonationController {
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public CompletableFuture<Response> confirmPayPalBoost(
@Auth Optional<AuthenticatedDevice> authenticatedAccount,
@NotNull @Valid ConfirmPayPalBoostRequest request,
@Auth final Optional<AuthenticatedDevice> authenticatedAccount,
@NotNull @Valid final ConfirmPayPalBoostRequest request,
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent) {
if (authenticatedAccount.isPresent()) {
@ -366,7 +340,7 @@ public class OneTimeDonationController {
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public CompletableFuture<Response> createBoostReceiptCredentials(
@Auth Optional<AuthenticatedDevice> authenticatedAccount,
@Auth final Optional<AuthenticatedDevice> authenticatedAccount,
@NotNull @Valid final CreateBoostReceiptCredentialsRequest request,
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent) {
@ -374,68 +348,56 @@ public class OneTimeDonationController {
throw new ForbiddenException("must not use authenticated connection for one-time donation operations");
}
final CompletableFuture<PaymentDetails> paymentDetailsFut = switch (request.processor) {
final CompletableFuture<Optional<PaymentDetails>> paymentDetailsFut = switch (request.processor) {
case STRIPE -> stripeManager.getPaymentDetails(request.paymentIntentId);
case BRAINTREE -> braintreeManager.getPaymentDetails(request.paymentIntentId);
case GOOGLE_PLAY_BILLING -> throw new BadRequestException("cannot use play billing for one-time donations");
case APPLE_APP_STORE -> throw new BadRequestException("cannot use app store purchases for one-time donations");
};
return paymentDetailsFut.thenApply(paymentDetails -> {
if (paymentDetails == null) {
return paymentDetailsFut.thenApply(maybePaymentDetails -> {
if (maybePaymentDetails.isEmpty()) {
throw new WebApplicationException(Response.Status.NOT_FOUND);
} else if (paymentDetails.status() == PaymentStatus.PROCESSING) {
}
final PaymentDetails paymentDetails = maybePaymentDetails.get();
if (paymentDetails.status() == PaymentStatus.PROCESSING) {
return Response.noContent().build();
} else if (paymentDetails.status() != PaymentStatus.SUCCEEDED) {
}
if (paymentDetails.status() != PaymentStatus.SUCCEEDED) {
throw new WebApplicationException(Response.status(Response.Status.PAYMENT_REQUIRED)
.entity(new CreateBoostReceiptCredentialsErrorResponse(paymentDetails.chargeFailure())).build());
}
// The payment was successful, try to issue the receipt credential
long level = oneTimeDonationConfiguration.boost().level();
if (paymentDetails.customMetadata() != null) {
String levelMetadata = paymentDetails.customMetadata()
.getOrDefault("level", Long.toString(oneTimeDonationConfiguration.boost().level()));
try {
level = Long.parseLong(levelMetadata);
} catch (NumberFormatException e) {
logger.error("failed to parse level metadata ({}) on payment intent {}", levelMetadata,
paymentDetails.id(), e);
throw new WebApplicationException(Response.Status.INTERNAL_SERVER_ERROR);
}
}
Duration levelExpiration;
if (oneTimeDonationConfiguration.boost().level() == level) {
levelExpiration = oneTimeDonationConfiguration.boost().expiration();
} else if (oneTimeDonationConfiguration.gift().level() == level) {
levelExpiration = oneTimeDonationConfiguration.gift().expiration();
} else {
logger.error("level ({}) returned from payment intent that is unknown to the server", level);
final OneTimeDonationUtil.DonationLevelDetails levelDetails;
try {
levelDetails = OneTimeDonationUtil.getLevelDetails(paymentDetails, oneTimeDonationConfiguration);
} catch (OneTimeDonationUtil.InvalidLevelException _) {
throw new WebApplicationException(Response.Status.INTERNAL_SERVER_ERROR);
}
ReceiptCredentialRequest receiptCredentialRequest;
final ReceiptCredentialRequest receiptCredentialRequest;
try {
receiptCredentialRequest = new ReceiptCredentialRequest(request.receiptCredentialRequest);
} catch (InvalidInputException e) {
} catch (final InvalidInputException e) {
throw new BadRequestException("invalid receipt credential request", e);
}
final long finalLevel = level;
try {
issuedReceiptsManager.recordIssuance(paymentDetails.id(), request.processor,
receiptCredentialRequest, clock.instant());
} catch (final WriteConflictException _) {
} catch (WriteConflictException _) {
throw new WebApplicationException(Response.Status.CONFLICT);
}
final Instant paidAt = oneTimeDonationsManager.getPaidAt(paymentDetails.id(), paymentDetails.created());
final Instant expiration = paidAt
.plus(levelExpiration)
.plus(levelDetails.levelExpiration())
.truncatedTo(ChronoUnit.DAYS)
.plus(1, ChronoUnit.DAYS);
final ReceiptCredentialResponse receiptCredentialResponse;
try {
receiptCredentialResponse = zkReceiptOperations.issueReceiptCredential(
receiptCredentialRequest, expiration.getEpochSecond(), finalLevel);
receiptCredentialRequest, expiration.getEpochSecond(), levelDetails.level());
} catch (final VerificationFailedException e) {
throw new BadRequestException("receipt credential request failed verification", e);
}

View File

@ -8,6 +8,7 @@ package org.whispersystems.textsecuregcm.controllers;
import static org.whispersystems.textsecuregcm.grpc.SubscriptionsUtil.buildCurrencyConfiguration;
import static org.whispersystems.textsecuregcm.grpc.SubscriptionsUtil.buildDonationLevelsConfiguration;
import static org.whispersystems.textsecuregcm.grpc.SubscriptionsUtil.getClientPlatform;
import static org.whispersystems.textsecuregcm.grpc.SubscriptionsUtil.getPayPalLocale;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonInclude.Include;
@ -216,7 +217,7 @@ public class SubscriptionController {
public Response updateSubscriber(
@Auth Optional<AuthenticatedDevice> authenticatedAccount,
@Parameter(description="A base64-encoded donation permit retrieved from POST /v1/donation/permit")
@Parameter(description="A base64-encoded donation permit retrieved from POST /v1/donation/permit. Not required if the subscriber already exists.")
@HeaderParam(HeaderUtils.DONATION_PERMIT)
final Optional<DonationPermitHeader> donationPermitHeader,
@ -318,10 +319,7 @@ public class SubscriptionController {
final SubscriberCredentials subscriberCredentials =
SubscriberCredentials.process(authenticatedAccount, subscriberId, clock);
final Locale locale = HeaderUtils.getAcceptableLanguagesForRequest(containerRequestContext).stream()
.filter(l -> !"*".equals(l.getLanguage()))
.findFirst()
.orElse(Locale.US);
final Locale locale = getPayPalLocale(HeaderUtils.getAcceptableLanguagesForRequest(containerRequestContext));
final BraintreeManager.PayPalBillingAgreementApprovalDetails billingAgreementApprovalDetails = subscriptionManager.addPaymentMethodToCustomer(
subscriberCredentials,

View File

@ -15,8 +15,10 @@ import org.signal.keytransparency.client.E164MonitorRequest;
import org.signal.keytransparency.client.E164SearchRequest;
import org.signal.keytransparency.client.MonitorRequest;
import org.signal.keytransparency.client.MonitorResponse;
import org.signal.keytransparency.client.MonitorResponseV2;
import org.signal.keytransparency.client.SearchRequest;
import org.signal.keytransparency.client.SearchResponse;
import org.signal.keytransparency.client.SearchResponseV2;
import org.signal.keytransparency.client.SimpleKeyTransparencyQueryServiceGrpc;
import org.signal.keytransparency.client.UsernameHashMonitorRequest;
import org.whispersystems.textsecuregcm.controllers.AccountController;
@ -39,102 +41,51 @@ public class KeyTransparencyGrpcService extends
}
@Override
public SearchResponse search(final SearchRequest request) throws RateLimitExceededException {
public SearchResponseV2 searchV2(final SearchRequest request) throws RateLimitExceededException {
rateLimiters.getKeyTransparencySearchLimiter().validate(RequestAttributesUtil.getRemoteAddress().getHostAddress());
return client.search(validateSearchRequest(request));
}
@Override
public MonitorResponse monitor(final MonitorRequest request) throws RateLimitExceededException {
public MonitorResponseV2 monitorV2(final MonitorRequest request) throws RateLimitExceededException {
rateLimiters.getKeyTransparencyMonitorLimiter().validate(RequestAttributesUtil.getRemoteAddress().getHostAddress());
return client.monitor(validateMonitorRequest(request));
}
@Override
public DistinguishedResponse distinguished(final DistinguishedRequest request) throws RateLimitExceededException {
public DistinguishedResponse distinguishedV2(final DistinguishedRequest request) throws RateLimitExceededException {
rateLimiters.getKeyTransparencyDistinguishedLimiter().validate(RequestAttributesUtil.getRemoteAddress().getHostAddress());
// A client's very first distinguished request will not have a "last" parameter
if (request.hasLast() && request.getLast() <= 0) {
throw Status.INVALID_ARGUMENT.withDescription("Last tree head size must be positive").asRuntimeException();
}
return client.distinguished(request);
}
private SearchRequest validateSearchRequest(final SearchRequest request) {
validateAci(request.getAci().toByteArray());
if (request.hasE164SearchRequest()) {
final E164SearchRequest e164SearchRequest = request.getE164SearchRequest();
if (e164SearchRequest.getUnidentifiedAccessKey().isEmpty() != e164SearchRequest.getE164().isEmpty()) {
throw Status.INVALID_ARGUMENT.withDescription("Unidentified access key and E164 must be provided together or not at all").asRuntimeException();
throw GrpcExceptions.fieldViolation("e164_search_request", "Unidentified access key and E164 must be provided together or not at all");
}
}
if (!request.getConsistency().hasDistinguished()) {
throw Status.INVALID_ARGUMENT.withDescription("Must provide distinguished tree head size").asRuntimeException();
}
validateConsistencyParameters(request.getConsistency());
return request;
}
private void validateAci(final byte[] aci) {
try {
AciServiceIdentifier.fromBytes(aci);
} catch (IllegalArgumentException e) {
throw GrpcExceptions.fieldViolation("aci", "Invalid ACI");
}
}
private MonitorRequest validateMonitorRequest(final MonitorRequest request) {
final AciMonitorRequest aciMonitorRequest = request.getAci();
validateAci(request.getAci().getAci().toByteArray());
try {
AciServiceIdentifier.fromBytes(aciMonitorRequest.getAci().toByteArray());
} catch (IllegalArgumentException e) {
throw Status.INVALID_ARGUMENT.withDescription("Invalid ACI").asRuntimeException();
}
if (aciMonitorRequest.getEntryPosition() <= 0) {
throw Status.INVALID_ARGUMENT.withDescription("Aci entry position must be positive").asRuntimeException();
}
if (aciMonitorRequest.getCommitmentIndex().size() != COMMITMENT_INDEX_LENGTH) {
throw Status.INVALID_ARGUMENT.withDescription("Aci commitment index must be 32 bytes").asRuntimeException();
if (!request.getConsistency().hasLast()) {
throw GrpcExceptions.fieldViolation("consistency_last", "Must provide distinguished and last tree head sizes");
}
if (request.hasUsernameHash()) {
final UsernameHashMonitorRequest usernameHashMonitorRequest = request.getUsernameHash();
if (usernameHashMonitorRequest.getUsernameHash().isEmpty()) {
throw Status.INVALID_ARGUMENT.withDescription("Username hash cannot be empty").asRuntimeException();
}
if (usernameHashMonitorRequest.getUsernameHash().size() != AccountController.USERNAME_HASH_LENGTH) {
throw Status.INVALID_ARGUMENT.withDescription("Invalid username hash length").asRuntimeException();
}
if (usernameHashMonitorRequest.getEntryPosition() <= 0) {
throw Status.INVALID_ARGUMENT.withDescription("Username hash entry position must be positive").asRuntimeException();
}
if (usernameHashMonitorRequest.getCommitmentIndex().size() != COMMITMENT_INDEX_LENGTH) {
throw Status.INVALID_ARGUMENT.withDescription("Username hash commitment index must be 32 bytes").asRuntimeException();
}
}
if (request.hasE164()) {
final E164MonitorRequest e164MonitorRequest = request.getE164();
if (e164MonitorRequest.getE164().isEmpty()) {
throw Status.INVALID_ARGUMENT.withDescription("E164 cannot be empty").asRuntimeException();
}
if (e164MonitorRequest.getEntryPosition() <= 0) {
throw Status.INVALID_ARGUMENT.withDescription("E164 entry position must be positive").asRuntimeException();
}
if (e164MonitorRequest.getCommitmentIndex().size() != COMMITMENT_INDEX_LENGTH) {
throw Status.INVALID_ARGUMENT.withDescription("E164 commitment index must be 32 bytes").asRuntimeException();
}
}
if (!request.getConsistency().hasDistinguished() || !request.getConsistency().hasLast()) {
throw Status.INVALID_ARGUMENT.withDescription("Must provide distinguished and last tree head sizes").asRuntimeException();
}
validateConsistencyParameters(request.getConsistency());
return request;
}
private static void validateConsistencyParameters(final ConsistencyParameters consistency) {
if (consistency.getDistinguished() <= 0) {
throw Status.INVALID_ARGUMENT.withDescription("Distinguished tree head size must be positive").asRuntimeException();
}
if (consistency.hasLast() && consistency.getLast() <= 0) {
throw Status.INVALID_ARGUMENT.withDescription("Last tree head size must be positive").asRuntimeException();
}
}
}

View File

@ -0,0 +1,130 @@
package org.whispersystems.textsecuregcm.grpc;
import java.math.BigDecimal;
import java.time.Duration;
import java.util.List;
import java.util.Locale;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.configuration.OneTimeDonationConfiguration;
import org.whispersystems.textsecuregcm.subscriptions.CustomerAwareSubscriptionPaymentProcessor;
import org.whispersystems.textsecuregcm.subscriptions.PayPalDonationsTranslator;
import org.whispersystems.textsecuregcm.subscriptions.PaymentDetails;
import org.whispersystems.textsecuregcm.subscriptions.PaymentMethod;
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionCurrencyUtil;
public class OneTimeDonationUtil {
private static final String EURO_CURRENCY_CODE = "EUR";
private static final Logger LOGGER = LoggerFactory.getLogger(OneTimeDonationUtil.class);
/// Thrown if a one time donation level cannot be parsed or if it is not found in configuration
public static class InvalidLevelException extends Exception {
public InvalidLevelException(final String message) {
super(message);
}
}
public record LocalizedPayPalDonationLineItem(Locale locale, String itemName){}
public record DonationLevelDetails(long level, Duration levelExpiration){}
public sealed interface OneTimeDonationRequestValidationResult permits OneTimeDonationRequestValidationResult.Success,
OneTimeDonationRequestValidationResult.UnsupportedCurrency,
OneTimeDonationRequestValidationResult.UnsupportedLevel,
OneTimeDonationRequestValidationResult.AmountBelowMinimum,
OneTimeDonationRequestValidationResult.AmountAboveSepaLimit {
record Success() implements OneTimeDonationRequestValidationResult {}
record UnsupportedCurrency() implements OneTimeDonationRequestValidationResult {}
record UnsupportedLevel() implements OneTimeDonationRequestValidationResult {}
record AmountBelowMinimum(BigDecimal minimum) implements OneTimeDonationRequestValidationResult {}
record AmountAboveSepaLimit(BigDecimal maximum) implements OneTimeDonationRequestValidationResult {}
}
public static OneTimeDonationRequestValidationResult validateOneTimeDonationRequest(
final String currency,
final BigDecimal amount,
final long level,
final PaymentMethod paymentMethod,
final OneTimeDonationConfiguration oneTimeDonationConfiguration,
final CustomerAwareSubscriptionPaymentProcessor manager
) {
if (!(level == oneTimeDonationConfiguration.gift().level()
|| level == oneTimeDonationConfiguration.boost().level())) {
return new OneTimeDonationRequestValidationResult.UnsupportedLevel();
}
if (!manager.getSupportedCurrenciesForPaymentMethod(paymentMethod)
.contains(currency.toLowerCase(Locale.ROOT))) {
return new OneTimeDonationRequestValidationResult.UnsupportedCurrency();
}
final BigDecimal minCurrencyAmountMajorUnits = oneTimeDonationConfiguration.currencies()
.get(currency.toLowerCase(Locale.ROOT)).minimum();
final BigDecimal minCurrencyAmountMinorUnits = SubscriptionCurrencyUtil.convertConfiguredAmountToApiAmount(
currency,
minCurrencyAmountMajorUnits);
if (minCurrencyAmountMinorUnits.compareTo(amount) > 0) {
return new OneTimeDonationRequestValidationResult.AmountBelowMinimum(minCurrencyAmountMajorUnits);
}
if (paymentMethod == PaymentMethod.SEPA_DEBIT &&
amount.compareTo(SubscriptionCurrencyUtil.convertConfiguredAmountToApiAmount(
EURO_CURRENCY_CODE,
oneTimeDonationConfiguration.sepaMaximumEuros())) > 0) {
return new OneTimeDonationRequestValidationResult.AmountAboveSepaLimit(
oneTimeDonationConfiguration.sepaMaximumEuros());
}
return new OneTimeDonationRequestValidationResult.Success();
}
public static LocalizedPayPalDonationLineItem localizePayPalDonationLineItem(
final PayPalDonationsTranslator payPalDonationsTranslator, final List<Locale> acceptableLocales) {
// These two localizations are a best-effort, and it's possible that the first `locale` and the localized line
// item name will not match. We could try to align with the locales PayPal documents <https://developer.paypal.com/reference/locale-codes/#supported-locale-codes>
// but that's a moving target, and we can hopefully have one of them be better for the user by selecting
// independently.
final Locale locale = SubscriptionsUtil.getPayPalLocale(acceptableLocales);
final String localizedLineItemName = payPalDonationsTranslator.translate(acceptableLocales,
org.whispersystems.textsecuregcm.subscriptions.PayPalDonationsTranslator.ONE_TIME_DONATION_LINE_ITEM_KEY);
return new LocalizedPayPalDonationLineItem(locale, localizedLineItemName);
}
public static DonationLevelDetails getLevelDetails(final PaymentDetails paymentDetails,
final OneTimeDonationConfiguration oneTimeDonationConfiguration)
throws InvalidLevelException {
long level = oneTimeDonationConfiguration.boost().level();
if (paymentDetails.customMetadata() != null) {
final String levelMetadata = paymentDetails.customMetadata()
.getOrDefault("level", Long.toString(oneTimeDonationConfiguration.boost().level()));
try {
level = Long.parseLong(levelMetadata);
} catch (final NumberFormatException e) {
LOGGER.error("failed to parse level metadata ({}) on payment intent {}", levelMetadata,
paymentDetails.id(), e);
throw new InvalidLevelException("failed to parse level metadata");
}
}
final Duration levelExpiration;
if (level == oneTimeDonationConfiguration.boost().level()) {
levelExpiration = oneTimeDonationConfiguration.boost().expiration();
} else if (level == oneTimeDonationConfiguration.gift().level()) {
levelExpiration = oneTimeDonationConfiguration.gift().expiration();
} else {
LOGGER.error("level ({}) returned from payment intent that is unknown to the server", level);
throw new InvalidLevelException("unrecognized level");
}
return new DonationLevelDetails(level, levelExpiration);
}
}

View File

@ -0,0 +1,326 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.grpc;
import static org.whispersystems.textsecuregcm.grpc.SubscriptionsUtil.getClientPlatform;
import static org.whispersystems.textsecuregcm.grpc.SubscriptionsUtil.toChargeFailure;
import com.google.protobuf.ByteString;
import java.math.BigDecimal;
import java.time.Clock;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
import io.micrometer.core.instrument.Metrics;
import io.micrometer.core.instrument.Tag;
import io.micrometer.core.instrument.Tags;
import org.signal.chat.errors.FailedPrecondition;
import org.signal.chat.errors.FailedZkAuthentication;
import org.signal.chat.errors.NotFound;
import org.signal.chat.one_time_donations.AmountAboveSepaLimitError;
import org.signal.chat.one_time_donations.AmountBelowMinimumError;
import org.signal.chat.one_time_donations.ConfirmPayPalBoostRequest;
import org.signal.chat.one_time_donations.ConfirmPayPalBoostResponse;
import org.signal.chat.one_time_donations.CreateBoostReceiptCredentialsRequest;
import org.signal.chat.one_time_donations.CreateBoostReceiptCredentialsResponse;
import org.signal.chat.one_time_donations.CreateBoostRequest;
import org.signal.chat.one_time_donations.CreateBoostResponse;
import org.signal.chat.one_time_donations.CreatePayPalBoostRequest;
import org.signal.chat.one_time_donations.CreatePayPalBoostResponse;
import org.signal.chat.one_time_donations.SimpleOneTimeDonationsGrpc;
import org.signal.chat.subscriptions.PaymentRequired;
import org.signal.libsignal.zkgroup.InvalidInputException;
import org.signal.libsignal.zkgroup.VerificationFailedException;
import org.signal.libsignal.zkgroup.donation.DonationPermit;
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialRequest;
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialResponse;
import org.signal.libsignal.zkgroup.receipts.ServerZkReceiptOperations;
import org.whispersystems.textsecuregcm.configuration.OneTimeDonationConfiguration;
import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
import org.whispersystems.textsecuregcm.storage.DonationPermitsManager;
import org.whispersystems.textsecuregcm.storage.IssuedReceiptsManager;
import org.whispersystems.textsecuregcm.storage.OneTimeDonationsManager;
import org.whispersystems.textsecuregcm.storage.WriteConflictException;
import org.whispersystems.textsecuregcm.subscriptions.BraintreeManager;
import org.whispersystems.textsecuregcm.subscriptions.PayPalDonationsTranslator;
import org.whispersystems.textsecuregcm.subscriptions.PaymentDetails;
import org.whispersystems.textsecuregcm.subscriptions.PaymentProvider;
import org.whispersystems.textsecuregcm.subscriptions.PaymentStatus;
import org.whispersystems.textsecuregcm.subscriptions.StripeManager;
public class OneTimeDonationsGrpcService extends SimpleOneTimeDonationsGrpc.OneTimeDonationsImplBase {
private final OneTimeDonationConfiguration oneTimeDonationConfiguration;
private final StripeManager stripeManager;
private final BraintreeManager braintreeManager;
private final PayPalDonationsTranslator payPalDonationsTranslator;
private final OneTimeDonationsManager oneTimeDonationsManager;
private final IssuedReceiptsManager issuedReceiptsManager;
private final ServerZkReceiptOperations zkReceiptOperations;
private final Clock clock;
private final RateLimiters rateLimiters;
private final DonationPermitsManager donationPermitsManager;
public OneTimeDonationsGrpcService(
final OneTimeDonationConfiguration oneTimeDonationConfiguration,
final StripeManager stripeManager,
final BraintreeManager braintreeManager,
final PayPalDonationsTranslator payPalDonationsTranslator,
final OneTimeDonationsManager oneTimeDonationsManager,
final IssuedReceiptsManager issuedReceiptsManager,
final ServerZkReceiptOperations zkReceiptOperations,
final Clock clock,
final RateLimiters rateLimiters,
final DonationPermitsManager donationPermitsManager) {
this.oneTimeDonationConfiguration = oneTimeDonationConfiguration;
this.stripeManager = stripeManager;
this.braintreeManager = braintreeManager;
this.payPalDonationsTranslator = payPalDonationsTranslator;
this.oneTimeDonationsManager = oneTimeDonationsManager;
this.issuedReceiptsManager = issuedReceiptsManager;
this.zkReceiptOperations = zkReceiptOperations;
this.clock = clock;
this.rateLimiters = rateLimiters;
this.donationPermitsManager = donationPermitsManager;
}
@Override
public CreateBoostResponse createBoost(final CreateBoostRequest request) throws RateLimitExceededException {
RateLimitUtil.rateLimitByRemoteAddress(rateLimiters.forDescriptor(RateLimiters.For.ONE_TIME_DONATION));
try {
final DonationPermit donationPermit = new DonationPermit(request.getDonationPermit().toByteArray());
if (!donationPermitsManager.spend(donationPermit)) {
return CreateBoostResponse.newBuilder()
.setPermitRejected(FailedZkAuthentication.newBuilder()
.setDescription("donation permit rejected")
.build())
.build();
}
} catch (InvalidInputException | VerificationFailedException _) {
return CreateBoostResponse.newBuilder()
.setPermitRejected(FailedZkAuthentication.newBuilder()
.setDescription("donation permit rejected")
.build())
.build();
}
final org.whispersystems.textsecuregcm.subscriptions.PaymentMethod paymentMethod =
switch (request.getPaymentMethod()) {
case PAYMENT_METHOD_CARD -> org.whispersystems.textsecuregcm.subscriptions.PaymentMethod.CARD;
case PAYMENT_METHOD_SEPA_DEBIT -> org.whispersystems.textsecuregcm.subscriptions.PaymentMethod.SEPA_DEBIT;
case PAYMENT_METHOD_IDEAL -> org.whispersystems.textsecuregcm.subscriptions.PaymentMethod.IDEAL;
default -> throw GrpcExceptions.fieldViolation("payment_method", "Unsupported payment method");
};
final OneTimeDonationUtil.OneTimeDonationRequestValidationResult validationResult =
OneTimeDonationUtil.validateOneTimeDonationRequest(
request.getCurrency(),
BigDecimal.valueOf(request.getAmount()),
request.getLevel(),
paymentMethod,
oneTimeDonationConfiguration,
stripeManager);
return switch (validationResult) {
case OneTimeDonationUtil.OneTimeDonationRequestValidationResult.UnsupportedLevel _ ->
CreateBoostResponse.newBuilder()
.setUnsupportedLevel(FailedPrecondition.getDefaultInstance()).build();
case OneTimeDonationUtil.OneTimeDonationRequestValidationResult.UnsupportedCurrency _ ->
CreateBoostResponse.newBuilder()
.setUnsupportedCurrency(FailedPrecondition.getDefaultInstance()).build();
case final OneTimeDonationUtil.OneTimeDonationRequestValidationResult.AmountBelowMinimum r ->
CreateBoostResponse.newBuilder()
.setAmountBelowMinimum(AmountBelowMinimumError.newBuilder()
.setMinimum(r.minimum().toString()).build()).build();
case final OneTimeDonationUtil.OneTimeDonationRequestValidationResult.AmountAboveSepaLimit r ->
CreateBoostResponse.newBuilder()
.setAmountAboveSepaLimit(AmountAboveSepaLimitError.newBuilder()
.setMaximum(r.maximum().toString()).build()).build();
case OneTimeDonationUtil.OneTimeDonationRequestValidationResult.Success _ -> {
final com.stripe.model.PaymentIntent paymentIntent = stripeManager.createPaymentIntent(
request.getCurrency(), request.getAmount(), request.getLevel(),
getClientPlatform(RequestAttributesUtil.getUserAgent().orElse(null))).join();
yield CreateBoostResponse.newBuilder().setClientSecret(paymentIntent.getClientSecret()).build();
}
};
}
@Override
public CreatePayPalBoostResponse createPayPalBoost(final CreatePayPalBoostRequest request) {
final OneTimeDonationUtil.OneTimeDonationRequestValidationResult validationResult =
OneTimeDonationUtil.validateOneTimeDonationRequest(
request.getCurrency(),
BigDecimal.valueOf(request.getAmount()),
request.getLevel(),
org.whispersystems.textsecuregcm.subscriptions.PaymentMethod.PAYPAL,
oneTimeDonationConfiguration,
braintreeManager);
return switch (validationResult) {
case OneTimeDonationUtil.OneTimeDonationRequestValidationResult.UnsupportedLevel _ ->
CreatePayPalBoostResponse.newBuilder()
.setUnsupportedLevel(FailedPrecondition.getDefaultInstance()).build();
case OneTimeDonationUtil.OneTimeDonationRequestValidationResult.UnsupportedCurrency _ ->
CreatePayPalBoostResponse.newBuilder()
.setUnsupportedCurrency(FailedPrecondition.getDefaultInstance()).build();
case final OneTimeDonationUtil.OneTimeDonationRequestValidationResult.AmountBelowMinimum r ->
CreatePayPalBoostResponse.newBuilder()
.setAmountBelowMinimum(AmountBelowMinimumError.newBuilder()
.setMinimum(r.minimum().toString()).build()).build();
case OneTimeDonationUtil.OneTimeDonationRequestValidationResult.AmountAboveSepaLimit _ ->
throw new IllegalStateException("SEPA limit should not trigger for PayPal");
case OneTimeDonationUtil.OneTimeDonationRequestValidationResult.Success _ -> {
final List<Locale> acceptableLocales = RequestAttributesUtil.getAvailableAcceptedLocales();
final OneTimeDonationUtil.LocalizedPayPalDonationLineItem localizedLineItem = OneTimeDonationUtil.localizePayPalDonationLineItem(
payPalDonationsTranslator, acceptableLocales);
final BraintreeManager.PayPalOneTimePaymentApprovalDetails approvalDetails =
braintreeManager.createOneTimePayment(
request.getCurrency().toUpperCase(Locale.ROOT), request.getAmount(),
localizedLineItem.locale().toLanguageTag(), request.getReturnUrl(), request.getCancelUrl(),
localizedLineItem.itemName()).join();
yield CreatePayPalBoostResponse.newBuilder()
.setResult(CreatePayPalBoostResponse.CreatePayPalBoostResult.newBuilder()
.setApprovalUrl(approvalDetails.approvalUrl())
.setPaymentId(approvalDetails.paymentId()).build()).build();
}
};
}
@Override
public ConfirmPayPalBoostResponse confirmPayPalBoost(final ConfirmPayPalBoostRequest request)
throws RateLimitExceededException {
RateLimitUtil.rateLimitByRemoteAddress(rateLimiters.forDescriptor(RateLimiters.For.ONE_TIME_DONATION));
final OneTimeDonationUtil.OneTimeDonationRequestValidationResult validationResult =
OneTimeDonationUtil.validateOneTimeDonationRequest(
request.getCurrency(),
BigDecimal.valueOf(request.getAmount()),
request.getLevel(),
org.whispersystems.textsecuregcm.subscriptions.PaymentMethod.PAYPAL,
oneTimeDonationConfiguration,
braintreeManager);
return switch (validationResult) {
case OneTimeDonationUtil.OneTimeDonationRequestValidationResult.UnsupportedLevel _ ->
ConfirmPayPalBoostResponse.newBuilder()
.setUnsupportedLevel(FailedPrecondition.getDefaultInstance()).build();
case OneTimeDonationUtil.OneTimeDonationRequestValidationResult.UnsupportedCurrency _ ->
ConfirmPayPalBoostResponse.newBuilder()
.setUnsupportedCurrency(FailedPrecondition.getDefaultInstance()).build();
case final OneTimeDonationUtil.OneTimeDonationRequestValidationResult.AmountBelowMinimum r ->
ConfirmPayPalBoostResponse.newBuilder()
.setAmountBelowMinimum(AmountBelowMinimumError.newBuilder()
.setMinimum(r.minimum().toString()).build()).build();
case OneTimeDonationUtil.OneTimeDonationRequestValidationResult.AmountAboveSepaLimit _ ->
throw new IllegalStateException("SEPA limit should not trigger for PayPal");
case OneTimeDonationUtil.OneTimeDonationRequestValidationResult.Success _ -> {
final BraintreeManager.PayPalChargeSuccessDetails chargeSuccessDetails =
braintreeManager.captureOneTimePayment(
request.getPayerId(), request.getPaymentId(), request.getPaymentToken(),
request.getCurrency(), request.getAmount(), request.getLevel(),
getClientPlatform(RequestAttributesUtil.getUserAgent().orElse(null))).join();
oneTimeDonationsManager.putPaidAt(chargeSuccessDetails.paymentId(), clock.instant());
yield ConfirmPayPalBoostResponse.newBuilder()
.setResult(ConfirmPayPalBoostResponse.ConfirmPayPalBoostResult.newBuilder()
.setPaymentId(chargeSuccessDetails.paymentId()).build()).build();
}
};
}
@Override
public CreateBoostReceiptCredentialsResponse createBoostReceiptCredentials(
final CreateBoostReceiptCredentialsRequest request) {
final PaymentProvider processor;
final Optional<PaymentDetails> maybePaymentDetails;
switch (request.getProcessor()) {
case PAYMENT_PROVIDER_STRIPE -> {
processor = PaymentProvider.STRIPE;
maybePaymentDetails = stripeManager.getPaymentDetails(request.getPaymentIntentId()).join();
}
case PAYMENT_PROVIDER_BRAINTREE -> {
processor = PaymentProvider.BRAINTREE;
maybePaymentDetails = braintreeManager.getPaymentDetails(request.getPaymentIntentId()).join();
}
default -> throw GrpcExceptions.fieldViolation("processor", "Unsupported payment processor");
}
if (maybePaymentDetails.isEmpty()) {
return CreateBoostReceiptCredentialsResponse.newBuilder()
.setPaymentNotFound(NotFound.getDefaultInstance()).build();
}
final PaymentDetails paymentDetails = maybePaymentDetails.get();
if (paymentDetails.status() == PaymentStatus.PROCESSING) {
return CreateBoostReceiptCredentialsResponse.newBuilder()
.setPaymentStillProcessing(FailedPrecondition.getDefaultInstance()).build();
}
if (paymentDetails.status() != PaymentStatus.SUCCEEDED) {
final PaymentRequired.Builder paymentRequiredBuilder = PaymentRequired.newBuilder();
if (paymentDetails.chargeFailure() != null) {
paymentRequiredBuilder.setChargeFailure(toChargeFailure(processor, paymentDetails.chargeFailure()));
}
return CreateBoostReceiptCredentialsResponse.newBuilder()
.setPaymentRequired(paymentRequiredBuilder).build();
}
final OneTimeDonationUtil.DonationLevelDetails levelDetails;
try {
levelDetails = OneTimeDonationUtil.getLevelDetails(paymentDetails, oneTimeDonationConfiguration);
} catch (final OneTimeDonationUtil.InvalidLevelException e) {
throw GrpcExceptions.unavailable(e.getMessage());
}
final ReceiptCredentialRequest receiptCredentialRequest;
try {
receiptCredentialRequest = new ReceiptCredentialRequest(
request.getReceiptCredentialRequest().toByteArray());
} catch (final InvalidInputException e) {
throw GrpcExceptions.fieldViolation("receipt_credential_request", "invalid receipt credential request");
}
try {
issuedReceiptsManager.recordIssuance(
paymentDetails.id(), processor, receiptCredentialRequest, clock.instant());
} catch (final WriteConflictException e) {
return CreateBoostReceiptCredentialsResponse.newBuilder()
.setReceiptAlreadyIssued(FailedPrecondition.getDefaultInstance()).build();
}
final Instant paidAt = oneTimeDonationsManager.getPaidAt(paymentDetails.id(), paymentDetails.created());
final Instant expiration = paidAt
.plus(levelDetails.levelExpiration())
.truncatedTo(ChronoUnit.DAYS)
.plus(1, ChronoUnit.DAYS);
final ReceiptCredentialResponse receiptCredentialResponse;
try {
receiptCredentialResponse = zkReceiptOperations.issueReceiptCredential(
receiptCredentialRequest, expiration.getEpochSecond(), levelDetails.level());
} catch (final VerificationFailedException e) {
throw GrpcExceptions.fieldViolation("receipt_credential_request",
"receipt credential request failed verification");
}
Metrics.counter(SubscriptionsGrpcService.RECEIPT_ISSUED_COUNTER_NAME,
Tags.of(
Tag.of(SubscriptionsGrpcService.PROCESSOR_TAG_NAME, processor.toString()),
Tag.of(SubscriptionsGrpcService.TYPE_TAG_NAME, "boost"),
UserAgentTagUtil.getPlatformTag(RequestAttributesUtil.getUserAgent().orElse(null))))
.increment();
return CreateBoostReceiptCredentialsResponse.newBuilder()
.setResult(CreateBoostReceiptCredentialsResponse.CreateBoostReceiptCredentialsResult.newBuilder()
.setReceiptCredentialResponse(ByteString.copyFrom(receiptCredentialResponse.serialize()))
.build())
.build();
}
}

View File

@ -1,6 +1,7 @@
package org.whispersystems.textsecuregcm.grpc;
import static org.whispersystems.textsecuregcm.grpc.SubscriptionsUtil.getClientPlatform;
import static org.whispersystems.textsecuregcm.grpc.SubscriptionsUtil.getPayPalLocale;
import com.google.protobuf.ByteString;
import com.google.protobuf.Empty;
@ -11,6 +12,9 @@ import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
import io.micrometer.core.instrument.Metrics;
import io.micrometer.core.instrument.Tag;
import io.micrometer.core.instrument.Tags;
import org.signal.chat.errors.FailedPrecondition;
import org.signal.chat.errors.FailedUnidentifiedAuthorization;
import org.signal.chat.errors.FailedZkAuthentication;
@ -30,6 +34,7 @@ import org.signal.chat.subscriptions.GetReceiptCredentialsResponse;
import org.signal.chat.subscriptions.GetSubscriptionInformationRequest;
import org.signal.chat.subscriptions.GetSubscriptionInformationResponse;
import org.signal.chat.subscriptions.PaymentMethod;
import org.signal.chat.subscriptions.PaymentRequired;
import org.signal.chat.subscriptions.SetDefaultPaymentMethodRequest;
import org.signal.chat.subscriptions.SetDefaultPaymentMethodResponse;
import org.signal.chat.subscriptions.SetIapSubscriptionRequest;
@ -50,6 +55,8 @@ import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfigurati
import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;
import org.whispersystems.textsecuregcm.entities.Badge;
import org.whispersystems.textsecuregcm.entities.PurchasableBadge;
import org.whispersystems.textsecuregcm.metrics.MetricsUtil;
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
import org.whispersystems.textsecuregcm.storage.DonationPermitsManager;
import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;
import org.whispersystems.textsecuregcm.storage.SubscriberCredentials;
@ -59,11 +66,9 @@ import org.whispersystems.textsecuregcm.subscriptions.AppleAppStoreManager;
import org.whispersystems.textsecuregcm.subscriptions.BankMandateTranslator;
import org.whispersystems.textsecuregcm.subscriptions.BankTransferType;
import org.whispersystems.textsecuregcm.subscriptions.BraintreeManager;
import org.whispersystems.textsecuregcm.subscriptions.ChargeFailure;
import org.whispersystems.textsecuregcm.subscriptions.CurrencyConfiguration;
import org.whispersystems.textsecuregcm.subscriptions.CustomerAwareSubscriptionPaymentProcessor;
import org.whispersystems.textsecuregcm.subscriptions.GooglePlayBillingManager;
import org.whispersystems.textsecuregcm.subscriptions.PaymentProvider;
import org.whispersystems.textsecuregcm.subscriptions.ProcessorCustomer;
import org.whispersystems.textsecuregcm.subscriptions.StripeManager;
import org.whispersystems.textsecuregcm.subscriptions.SubscriberIdCreationNotPermittedException;
@ -97,6 +102,11 @@ public class SubscriptionsGrpcService extends SimpleSubscriptionsGrpc.Subscripti
private final BankMandateTranslator bankMandateTranslator;
private final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager;
static final String RECEIPT_ISSUED_COUNTER_NAME = MetricsUtil.name(SubscriptionsGrpcService.class, "receiptIssued");
static final String PROCESSOR_TAG_NAME = "processor";
static final String TYPE_TAG_NAME = "type";
private static final String SUBSCRIPTION_TYPE_TAG_NAME = "subscriptionType";
public SubscriptionsGrpcService(final Clock clock, final SubscriptionConfiguration subscriptionConfiguration,
final OneTimeDonationConfiguration oneTimeDonationConfiguration, final SubscriptionManager subscriptionManager,
final DonationPermitsManager donationPermitsManager, final StripeManager stripeManager,
@ -136,7 +146,7 @@ public class SubscriptionsGrpcService extends SimpleSubscriptionsGrpc.Subscripti
}
} catch (final InvalidInputException _) {
throw GrpcExceptions.invalidArguments("invalid donation permit");
} catch (final VerificationFailedException _ ) {
} catch (final VerificationFailedException _) {
return UpdateSubscriberResponse.newBuilder()
.setPermitRejected(FailedZkAuthentication.newBuilder()
.setDescription("donation permit failed verification")
@ -198,7 +208,7 @@ public class SubscriptionsGrpcService extends SimpleSubscriptionsGrpc.Subscripti
}
} catch (final InvalidInputException _) {
throw GrpcExceptions.invalidArguments("invalid donation permit");
} catch (final VerificationFailedException _ ) {
} catch (final VerificationFailedException _) {
return CreatePaymentMethodResponse.newBuilder()
.setPermitRejected(FailedZkAuthentication.getDefaultInstance())
.build();
@ -227,8 +237,7 @@ public class SubscriptionsGrpcService extends SimpleSubscriptionsGrpc.Subscripti
public CreatePayPalPaymentMethodResponse createPayPalPaymentMethod(final CreatePayPalPaymentMethodRequest request) {
final SubscriberCredentials subscriberCredentials = SubscriberCredentials.process(
request.getSubscriberId().toByteArray(), clock);
final Locale locale = RequestAttributesUtil.getAcceptableLanguages().stream().filter(r -> !"*".equals(r.getRange()))
.findFirst().map(r -> Locale.forLanguageTag(r.getRange())).orElse(Locale.US);
final Locale locale = getPayPalLocale(RequestAttributesUtil.getAvailableAcceptedLocales());
try {
final BraintreeManager.PayPalBillingAgreementApprovalDetails details = subscriptionManager.addPaymentMethodToCustomer(
subscriberCredentials, braintreeManager, getClientPlatform(RequestAttributesUtil.getUserAgent().orElse(null)),
@ -363,7 +372,7 @@ public class SubscriptionsGrpcService extends SimpleSubscriptionsGrpc.Subscripti
.build();
} catch (final SubscriptionProcessorException e) {
return SetSubscriptionLevelResponse.newBuilder()
.setChargeFailure(toChargeFailure(e.getProcessor(), e.getChargeFailure())).build();
.setChargeFailure(SubscriptionsUtil.toChargeFailure(e.getProcessor(), e.getChargeFailure())).build();
} catch (final SubscriptionPaymentRequiresActionException e) {
return SetSubscriptionLevelResponse.newBuilder().setPaymentRequiresAction(FailedPrecondition.newBuilder().build())
.build();
@ -443,7 +452,7 @@ public class SubscriptionsGrpcService extends SimpleSubscriptionsGrpc.Subscripti
subscription.setBillingCycleAnchor(info.billingCycleAnchor().getEpochSecond());
}
if (info.chargeFailure() != null) {
subscription.setChargeFailure(toChargeFailure(info.paymentProvider(), info.chargeFailure()));
subscription.setChargeFailure(SubscriptionsUtil.toChargeFailure(info.paymentProvider(), info.chargeFailure()));
}
return GetSubscriptionInformationResponse.newBuilder().setSuccess(subscription.build()).build();
@ -458,6 +467,15 @@ public class SubscriptionsGrpcService extends SimpleSubscriptionsGrpc.Subscripti
final SubscriptionManager.ReceiptResult result = subscriptionManager.createReceiptCredentials(
subscriberCredentials, request.getReceiptCredentialRequest().toByteArray(),
r -> SubscriptionsUtil.receiptExpirationWithGracePeriod(subscriptionConfiguration, r));
Metrics.counter(RECEIPT_ISSUED_COUNTER_NAME,
Tags.of(
Tag.of(PROCESSOR_TAG_NAME, result.paymentProvider().toString()),
Tag.of(TYPE_TAG_NAME, "subscription"),
Tag.of(SUBSCRIPTION_TYPE_TAG_NAME,
subscriptionConfiguration.getSubscriptionLevel(result.receiptItem().level()).type().name()
.toLowerCase(Locale.ROOT)),
UserAgentTagUtil.getPlatformTag(RequestAttributesUtil.getUserAgent().orElse(null))))
.increment();
return GetReceiptCredentialsResponse.newBuilder().setSuccess(
GetReceiptCredentialsResponse.GetReceiptCredentialsResult.newBuilder()
.setReceiptCredentialResponse(ByteString.copyFrom(result.receiptCredentialResponse().serialize()))
@ -467,11 +485,11 @@ public class SubscriptionsGrpcService extends SimpleSubscriptionsGrpc.Subscripti
.build();
} catch (final SubscriptionChargeFailurePaymentRequiredException e) {
return GetReceiptCredentialsResponse.newBuilder().setPaymentRequired(
GetReceiptCredentialsResponse.PaymentRequired.newBuilder()
.setChargeFailure(toChargeFailure(e.getProcessor(), e.getChargeFailure())).build()).build();
PaymentRequired.newBuilder()
.setChargeFailure(SubscriptionsUtil.toChargeFailure(e.getProcessor(), e.getChargeFailure())).build()).build();
} catch (final SubscriptionPaymentRequiredException e) {
return GetReceiptCredentialsResponse.newBuilder()
.setPaymentRequired(GetReceiptCredentialsResponse.PaymentRequired.newBuilder().build()).build();
.setPaymentRequired(PaymentRequired.newBuilder().build()).build();
} catch (final SubscriptionInvalidArgumentsException e) {
throw GrpcExceptions.invalidArguments(e.errorDetail().orElse(""));
} catch (final SubscriptionReceiptAlreadyRedeemedException e) {
@ -589,20 +607,4 @@ public class SubscriptionsGrpcService extends SimpleSubscriptionsGrpc.Subscripti
return GetBankMandateResponse.newBuilder().setMandate(mandate).build();
}
private static org.signal.chat.subscriptions.ChargeFailure toChargeFailure(final PaymentProvider processor,
final ChargeFailure chargeFailure) {
final org.signal.chat.subscriptions.ChargeFailure.Builder builder = org.signal.chat.subscriptions.ChargeFailure.newBuilder()
.setProcessor(processor.toProto()).setCode(chargeFailure.code()).setMessage(chargeFailure.message());
if (chargeFailure.outcomeNetworkStatus() != null) {
builder.setOutcomeNetworkStatus(chargeFailure.outcomeNetworkStatus());
}
if (chargeFailure.outcomeReason() != null) {
builder.setOutcomeReason(chargeFailure.outcomeReason());
}
if (chargeFailure.outcomeType() != null) {
builder.setOutcomeType(chargeFailure.outcomeType());
}
return builder.build();
}
}

View File

@ -25,10 +25,12 @@ import org.whispersystems.textsecuregcm.entities.PurchasableBadge;
import org.whispersystems.textsecuregcm.metrics.MetricsUtil;
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
import org.whispersystems.textsecuregcm.storage.DonationPermitsManager;
import org.whispersystems.textsecuregcm.subscriptions.ChargeFailure;
import org.whispersystems.textsecuregcm.subscriptions.CurrencyConfiguration;
import org.whispersystems.textsecuregcm.subscriptions.CustomerAwareSubscriptionPaymentProcessor;
import org.whispersystems.textsecuregcm.subscriptions.LevelConfiguration;
import org.whispersystems.textsecuregcm.subscriptions.PaymentMethod;
import org.whispersystems.textsecuregcm.subscriptions.PaymentProvider;
import org.whispersystems.textsecuregcm.util.ua.ClientPlatform;
import org.whispersystems.textsecuregcm.util.ua.UnrecognizedUserAgentException;
import org.whispersystems.textsecuregcm.util.ua.UserAgentUtil;
@ -160,4 +162,26 @@ public class SubscriptionsUtil {
.increment();
}
public static org.signal.chat.subscriptions.ChargeFailure toChargeFailure(final PaymentProvider processor,
final ChargeFailure chargeFailure) {
final org.signal.chat.subscriptions.ChargeFailure.Builder builder = org.signal.chat.subscriptions.ChargeFailure.newBuilder()
.setProcessor(processor.toProto()).setCode(chargeFailure.code()).setMessage(chargeFailure.message());
if (chargeFailure.outcomeNetworkStatus() != null) {
builder.setOutcomeNetworkStatus(chargeFailure.outcomeNetworkStatus());
}
if (chargeFailure.outcomeReason() != null) {
builder.setOutcomeReason(chargeFailure.outcomeReason());
}
if (chargeFailure.outcomeType() != null) {
builder.setOutcomeType(chargeFailure.outcomeType());
}
return builder.build();
}
public static Locale getPayPalLocale(final List<Locale> acceptableLocales) {
return acceptableLocales.stream()
.filter(l -> !"*".equals(l.getLanguage()))
.findFirst()
.orElse(Locale.US);
}
}

View File

@ -29,8 +29,10 @@ import org.signal.keytransparency.client.E164SearchRequest;
import org.signal.keytransparency.client.KeyTransparencyQueryServiceGrpc;
import org.signal.keytransparency.client.MonitorRequest;
import org.signal.keytransparency.client.MonitorResponse;
import org.signal.keytransparency.client.MonitorResponseV2;
import org.signal.keytransparency.client.SearchRequest;
import org.signal.keytransparency.client.SearchResponse;
import org.signal.keytransparency.client.SearchResponseV2;
import org.signal.keytransparency.client.UsernameHashMonitorRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@ -110,7 +112,7 @@ public class KeyTransparencyServiceClient implements Managed {
}
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
public SearchResponse search(
public SearchResponseV2 search(
final ByteString aci,
final ByteString aciIdentityKey,
final Optional<ByteString> usernameHash,
@ -132,13 +134,13 @@ public class KeyTransparencyServiceClient implements Managed {
return search(searchRequestBuilder.build());
}
public SearchResponse search(final SearchRequest request) {
public SearchResponseV2 search(final SearchRequest request) {
return stub.withDeadline(toDeadline(KEY_TRANSPARENCY_RPC_TIMEOUT))
.search(request);
.searchV2(request);
}
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
public MonitorResponse monitor(final AciMonitorRequest aciMonitorRequest,
public MonitorResponseV2 monitor(final AciMonitorRequest aciMonitorRequest,
final Optional<UsernameHashMonitorRequest> usernameHashMonitorRequest,
final Optional<E164MonitorRequest> e164MonitorRequest,
final long lastTreeHeadSize,
@ -155,12 +157,11 @@ public class KeyTransparencyServiceClient implements Managed {
return monitor(monitorRequestBuilder.build());
}
public MonitorResponse monitor(final MonitorRequest request) {
public MonitorResponseV2 monitor(final MonitorRequest request) {
return stub.withDeadline(toDeadline(KEY_TRANSPARENCY_RPC_TIMEOUT))
.monitor(request);
.monitorV2(request);
}
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
public DistinguishedResponse getDistinguishedKey(final Optional<Long> lastTreeHeadSize) {
final DistinguishedRequest request = lastTreeHeadSize.map(
@ -171,7 +172,7 @@ public class KeyTransparencyServiceClient implements Managed {
public DistinguishedResponse distinguished(final DistinguishedRequest request) {
return stub.withDeadline(toDeadline(KEY_TRANSPARENCY_RPC_TIMEOUT))
.distinguished(request);
.distinguishedV2(request);
}
private static Deadline toDeadline(final Duration timeout) {

View File

@ -28,6 +28,7 @@ public class DonationPermitsManager {
/// Verifies and then spends the given {@link DonationPermit}
///
/// @return whether the spend was successful
/// @throws VerificationFailedException if the permit was not valid (expired or otherwise)
public boolean spend(final DonationPermit donationPermit) throws VerificationFailedException {
// Permits must be verified with the key pair that issued them, which can be re-derived from the embedded expiration

View File

@ -9,10 +9,14 @@ import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Flow;
import com.google.common.annotations.VisibleForTesting;
import org.whispersystems.textsecuregcm.entities.MessageProtos;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.Metrics;
import org.whispersystems.textsecuregcm.push.RedisMessageAvailabilityManager;
import org.whispersystems.textsecuregcm.util.UUIDUtil;
import org.whispersystems.textsecuregcm.util.Util;
import reactor.adapter.JdkFlowAdapter;
import static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;
/// A [MessageStream] implementation that produces message from a joint DynamoDB/Redis message store.
public class RedisDynamoDbMessageStream implements MessageStream {
@ -25,6 +29,14 @@ public class RedisDynamoDbMessageStream implements MessageStream {
private final RedisDynamoDbMessagePublisher messagePublisher;
private static final String MESSAGE_READ_COUNTER_NAME =
name(RedisDynamoDbMessageStream.class, "messagesRead");
private static final String MESSAGE_ACKNOWLEDGED_COUNTER_NAME =
name(RedisDynamoDbMessageStream.class, "messagesAcknowledged");
private static final String UUID_VERSION_TAG = "uuidVersion";
public RedisDynamoDbMessageStream(final MessagesDynamoDb messagesDynamoDb,
final MessagesCache messagesCache,
final RedisMessageAvailabilityManager redisMessageAvailabilityManager,
@ -54,11 +66,22 @@ public class RedisDynamoDbMessageStream implements MessageStream {
@Override
public Flow.Publisher<MessageStreamEntry> getMessages() {
return messagePublisher;
return JdkFlowAdapter.publisherToFlowPublisher(JdkFlowAdapter.flowPublisherToFlux(messagePublisher)
.doOnNext(messageStreamEntry -> {
if (messageStreamEntry instanceof MessageStreamEntry.Envelope(org.whispersystems.textsecuregcm.entities.MessageProtos.Envelope message)) {
final UUID messageGuid = UUIDUtil.fromByteString(message.getServerGuid());
Metrics.counter(MESSAGE_READ_COUNTER_NAME, UUID_VERSION_TAG, String.valueOf(messageGuid.version()))
.increment();
}
}));
}
@Override
public CompletableFuture<Void> acknowledgeMessage(final UUID messageGuid, final long serverTimestamp) {
Metrics.counter(MESSAGE_ACKNOWLEDGED_COUNTER_NAME, UUID_VERSION_TAG, String.valueOf(messageGuid.version()))
.increment();
return messagesCache.remove(accountIdentifier, device.getId(), messageGuid)
.thenCompose(removed -> removed.map(_ -> CompletableFuture.<Void>completedFuture(null))
.orElseGet(() ->

View File

@ -85,7 +85,7 @@ class FoundationDbMessagePublisher {
/// Tracks the current state of the publisher state machine. Initial state presumes that messages are available in the queue.
private State state = State.MESSAGES_AVAILABLE;
/// Reference to the sink we publishes messages to.
/// Reference to the sink we publish messages to.
private volatile FluxSink<FoundationDbMessageStreamEntry.Message> emitter;
/// Future that completes when the watch for {@link #messagesAvailableWatchKey} triggers.
private CompletableFuture<Void> watchFuture;
@ -192,6 +192,7 @@ class FoundationDbMessagePublisher {
setState(State.MESSAGES_AVAILABLE, event);
transitionStateOnEvent(Event.INTERNAL_TRIGGER);
}
case MESSAGE_AVAILABLE_WATCH_TRIGGERED -> setState(State.MESSAGE_AVAILABLE_SIGNAL_BUFFERED, event);
case FETCH_OR_PUBLISH_ERROR_OCCURRED -> setState(State.ERROR, event);
default -> knownTransition = false;
}
@ -247,27 +248,33 @@ class FoundationDbMessagePublisher {
///
/// @return a future of a list of [FoundationDbMessageStreamEntry.Message] with a max size of [#maxMessagesPerScan]
private CompletableFuture<List<FoundationDbMessageStreamEntry.Message>> getMessagesBatch() {
final Consumer<Transaction> doBeforePageFetch = beforePageFetch.get();
final Consumer<Transaction> doBeforePageFetch = beforePageFetch.get();
return database.runAsync(transaction -> {
doBeforePageFetch.accept(transaction);
return getItemsInRange(transaction, beginKeyCursor, endKeyExclusive, maxMessagesPerScan)
.thenApply(lastKeyReadAndItems -> {
// Set our beginning key to just past the last key read so that we're ready for our next fetch
lastKeyReadAndItems.first()
.ifPresent(lastKeyRead -> beginKeyCursor = KeySelector.firstGreaterThan(lastKeyRead));
final List<FoundationDbMessageStreamEntry.Message> items = lastKeyReadAndItems.second();
if (items.size() < maxMessagesPerScan) {
transitionStateOnEvent(Event.FETCHED_ALL_AVAILABLE_MESSAGES);
if (!terminateOnQueueEmpty) {
setWatch(transaction);
}
if (lastKeyReadAndItems.second().size() < maxMessagesPerScan && !terminateOnQueueEmpty) {
setWatch(transaction);
}
return items;
});
}
);
return lastKeyReadAndItems;
});
})
// Defer any state mutations until after the transaction has been committed. The transaction block can
// fail/retry, and we don't want to trigger spurious state transitions when that happens.
.thenApply(lastKeyReadAndItems -> {
// Set our beginning key to just past the last key read so that we're ready for our next fetch
lastKeyReadAndItems.first()
.ifPresent(lastKeyRead -> beginKeyCursor = KeySelector.firstGreaterThan(lastKeyRead));
if (lastKeyReadAndItems.second().size() < maxMessagesPerScan) {
transitionStateOnEvent(Event.FETCHED_ALL_AVAILABLE_MESSAGES);
}
return lastKeyReadAndItems.second();
});
}
/// Fetch messages in the range between `begin` and `end` limited to a batch size of `maxMessagesPerSccan`

View File

@ -128,7 +128,7 @@ public class BraintreeManager implements CustomerAwareSubscriptionPaymentProcess
return paymentMethod == PaymentMethod.PAYPAL;
}
public CompletableFuture<PaymentDetails> getPaymentDetails(final String paymentId) {
public CompletableFuture<Optional<PaymentDetails>> getPaymentDetails(final String paymentId) {
return CompletableFuture.supplyAsync(() -> {
try {
final Transaction transaction = braintreeGateway.transaction().find(paymentId);
@ -136,14 +136,14 @@ public class BraintreeManager implements CustomerAwareSubscriptionPaymentProcess
if (!getPaymentStatus(transaction.getStatus()).equals(PaymentStatus.SUCCEEDED)) {
chargeFailure = createChargeFailure(transaction);
}
return new PaymentDetails(transaction.getGraphQLId(),
return Optional.of(new PaymentDetails(transaction.getGraphQLId(),
transaction.getCustomFields(),
getPaymentStatus(transaction.getStatus()),
transaction.getCreatedAt().toInstant(),
chargeFailure);
chargeFailure));
} catch (final NotFoundException e) {
return null;
return Optional.empty();
}
}, executor);
}

View File

@ -242,7 +242,7 @@ public class StripeManager implements CustomerAwareSubscriptionPaymentProcessor
}, executor);
}
public CompletableFuture<PaymentDetails> getPaymentDetails(String paymentIntentId) {
public CompletableFuture<Optional<PaymentDetails>> getPaymentDetails(final String paymentIntentId) {
return CompletableFuture.supplyAsync(() -> {
try {
final PaymentIntent paymentIntent = getPaymentIntent(paymentIntentId);
@ -255,14 +255,14 @@ public class StripeManager implements CustomerAwareSubscriptionPaymentProcessor
}
}
return new PaymentDetails(paymentIntent.getId(),
return Optional.of(new PaymentDetails(paymentIntent.getId(),
paymentIntent.getMetadata() == null ? Collections.emptyMap() : paymentIntent.getMetadata(),
getPaymentStatusForStatus(paymentIntent.getStatus()),
Instant.ofEpochSecond(paymentIntent.getCreated()),
chargeFailure);
chargeFailure));
} catch (StripeException e) {
if (e.getStatusCode() == 404) {
return null;
return Optional.empty();
} else {
throw new CompletionException(e);
}

View File

@ -34,17 +34,17 @@ service KeyTransparencyQueryService {
* subsequent Search and Monitor requests. It should be the first key
* transparency RPC a client calls.
*/
rpc Distinguished(DistinguishedRequest) returns (DistinguishedResponse) {}
rpc DistinguishedV2(DistinguishedRequest) returns (DistinguishedResponse) {}
/**
* An endpoint used by clients to search for one or more identifiers in the transparency log.
* The server returns proof that the identifier(s) exist in the log.
*/
rpc Search(SearchRequest) returns (SearchResponse) {}
rpc SearchV2(SearchRequest) returns (SearchResponseV2) {}
/**
* An endpoint that allows users to monitor a group of identifiers by returning proof that the log continues to be
* constructed correctly in later entries for those identifiers.
*/
rpc Monitor(MonitorRequest) returns (MonitorResponse) {}
rpc MonitorV2(MonitorRequest) returns (MonitorResponseV2) {}
}
message SearchRequest {
@ -112,6 +112,19 @@ message SearchResponse {
optional CondensedTreeSearchResponse username_hash = 4;
}
/**
* Indicates that the client is not authorized to make the request because
* it did not provide the correct set of data.
*/
message PermissionDenied {}
message SearchResponseV2 {
oneof response {
SearchResponse search_response = 1;
PermissionDenied permission_denied = 2;
}
}
/**
* The tree head size(s) to prove consistency against. A client's very first
* key transparency request should be looking up the "distinguished" key;
@ -124,13 +137,11 @@ message ConsistencyParameters {
* This field may be omitted if the client is looking up an identifier
* for the first time.
*/
optional uint64 last = 1;
optional uint64 last = 1 [(org.signal.chat.require.range) = {min: 1}];
/**
* The distinguished tree head size to prove consistency against.
* This field may be omitted when the client is looking up the
* "distinguished" key for the very first time.
*/
optional uint64 distinguished = 2;
uint64 distinguished = 2 [(org.signal.chat.require.range) = {min: 1}];
}
/**
@ -143,7 +154,7 @@ message DistinguishedRequest {
* exception of a client's very first request, this field should always be
* set.
*/
optional uint64 last = 1;
optional uint64 last = 1 [(org.signal.chat.require.range) = {min: 1}];
}
/**
@ -343,20 +354,20 @@ message MonitorRequest {
message AciMonitorRequest {
bytes aci = 1 [(org.signal.chat.require.exactlySize) = 16];
uint64 entry_position = 2;
uint64 entry_position = 2 [(org.signal.chat.require.range) = {min: 1}];
bytes commitment_index = 3 [(org.signal.chat.require.exactlySize) = 32];
}
message UsernameHashMonitorRequest {
bytes username_hash = 1 [(org.signal.chat.require.exactlySize) = 0, (org.signal.chat.require.exactlySize) = 32];
uint64 entry_position = 2;
bytes commitment_index = 3 [(org.signal.chat.require.exactlySize) = 0, (org.signal.chat.require.exactlySize) = 32];
bytes username_hash = 1 [(org.signal.chat.require.exactlySize) = 32];
uint64 entry_position = 2 [(org.signal.chat.require.range) = {min: 1}];
bytes commitment_index = 3 [(org.signal.chat.require.exactlySize) = 32];
}
message E164MonitorRequest {
optional string e164 = 1 [(org.signal.chat.require.e164) = true];
uint64 entry_position = 2;
bytes commitment_index = 3 [(org.signal.chat.require.exactlySize) = 0, (org.signal.chat.require.exactlySize) = 32];
string e164 = 1 [(org.signal.chat.require.e164) = true];
uint64 entry_position = 2 [(org.signal.chat.require.range) = {min: 1}];
bytes commitment_index = 3 [(org.signal.chat.require.exactlySize) = 32];
}
message MonitorProof {
@ -397,3 +408,10 @@ message MonitorResponse {
*/
repeated bytes inclusion = 5;
}
message MonitorResponseV2 {
oneof response {
MonitorResponse monitor_response = 1;
PermissionDenied permission_denied = 2;
}
}

View File

@ -0,0 +1,167 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
syntax = "proto3";
option java_multiple_files = true;
package org.signal.chat.one_time_donations;
import "org/signal/chat/require.proto";
import "org/signal/chat/errors.proto";
import "org/signal/chat/tag.proto";
import "org/signal/chat/subscriptions.proto";
// Service for making one-time donation payments (boost and gift)
//
// Note that these are siblings of the RPCs in the Subscriptions service. One-time payments do
// not require the subscription management methods in that service, though the configuration at
// Subscriptions.GetConfiguration is shared between subscription and one-time payments.
service OneTimeDonations {
option (require.auth) = AUTH_ONLY_ANONYMOUS;
// Create a Stripe payment intent and return a client secret that can be used to complete the payment.
// Once the payment is complete, the paymentIntentId can be used with CreateBoostReceiptCredentials
rpc CreateBoost(CreateBoostRequest) returns (CreateBoostResponse) {}
// Create a PayPal one-time payment.
// Once the payment is complete, call ConfirmPayPalBoost with the payment ID and token
rpc CreatePayPalBoost(CreatePayPalBoostRequest) returns (CreatePayPalBoostResponse) {}
// Confirm a PayPal one-time payment
rpc ConfirmPayPalBoost(ConfirmPayPalBoostRequest) returns (ConfirmPayPalBoostResponse) {}
// Obtain a ZK receipt credential for a completed one-time donation payment.
// The receipt credential can then be used to redeem the one-time donation entitlement
// via Donations.RedeemReceipt
rpc CreateBoostReceiptCredentials(CreateBoostReceiptCredentialsRequest) returns (CreateBoostReceiptCredentialsResponse) {}
}
// The amount is below the minimum for the currency.
message AmountBelowMinimumError {
// The minimum amount for the currency
string minimum = 1;
}
// The SEPA Direct Debit amount exceeds the allowed maximum.
message AmountAboveSepaLimitError {
// The maximum amount for a SEPA transaction
string maximum = 1;
}
message CreateBoostRequest {
// ISO 4217 currency code, case-insensitive (e.g. "usd", "EUR")
string currency = 1 [(require.exactlySize) = 3];
// The amount to pay in the [currency's minor unit](https://docs.stripe.com/currencies#minor-units)
uint64 amount = 2 [(require.range).min = 1];
// The level for the boost payment
uint64 level = 3 [(require.range).min = 1];
// The payment method
subscriptions.PaymentMethod payment_method = 4 [(require.specified) = true];
// A donation permit retrieved from Donations.createDonationPermit
bytes donation_permit = 5 [(require.nonEmpty) = true];
}
message CreateBoostResponse {
oneof response {
// A client secret that can be used to complete a stripe PaymentIntent
string client_secret = 1;
// The amount is below the minimum for the currency
AmountBelowMinimumError amount_below_minimum = 2 [(tag.reason) = "amount_below_minimum"];
// The amount exceeds the maximum for SEPA Direct Debit
AmountAboveSepaLimitError amount_above_sepa_limit = 3 [(tag.reason) = "amount_above_sepa_limit"];
// The requested currency is not supported for the given payment method
errors.FailedPrecondition unsupported_currency = 4 [(tag.reason) = "unsupported_currency"];
// The requested level is not a valid one-time donation level
errors.FailedPrecondition unsupported_level = 5 [(tag.reason) = "unsupported_level"];
// Donation permit was invalid or already spent
errors.FailedZkAuthentication permit_rejected = 6 [(tag.reason) = "permit_rejected"];
}
}
message CreatePayPalBoostRequest {
// ISO 4217 currency code, case-insensitive (e.g. "usd", "EUR")
string currency = 1 [(require.exactlySize) = 3];
// Amount in the currency's minor unit (e.g. cents for USD), must be >= 1
uint64 amount = 2 [(require.range).min = 1];
// Donation level.
uint64 level = 3 [(require.range).min = 1];
// URL to redirect the user to after PayPal approval
string return_url = 4 [(require.nonEmpty) = true];
// URL to redirect the user to if they cancel
string cancel_url = 5 [(require.nonEmpty) = true];
}
message CreatePayPalBoostResponse {
message CreatePayPalBoostResult {
string approval_url = 1;
string payment_id = 2;
}
oneof response {
CreatePayPalBoostResult result = 1;
// The amount is below the minimum for the currency
AmountBelowMinimumError amount_below_minimum = 2 [(tag.reason) = "amount_below_minimum"];
// The requested currency is not supported for PayPal
errors.FailedPrecondition unsupported_currency = 3 [(tag.reason) = "unsupported_currency"];
// The requested level is not a valid one-time donation level
errors.FailedPrecondition unsupported_level = 4 [(tag.reason) = "unsupported_level"];
}
}
message ConfirmPayPalBoostRequest {
// ISO 4217 currency code, case-insensitive (e.g. "usd", "EUR")
string currency = 1 [(require.exactlySize) = 3];
// Amount in the currency's minor unit, must be >= 1
uint64 amount = 2 [(require.range).min = 1];
// Donation level.
uint64 level = 3 [(require.range).min = 1];
// PayPal payer ID from the approval redirect
string payer_id = 4 [(require.nonEmpty) = true];
// PayPal payment ID (PAYID-) from CreatePayPalBoost
string payment_id = 5 [(require.nonEmpty) = true];
// PayPal payment token (EC-) from the approval redirect
string payment_token = 6 [(require.nonEmpty) = true];
}
message ConfirmPayPalBoostResponse {
message ConfirmPayPalBoostResult {
string payment_id = 1;
}
oneof response {
ConfirmPayPalBoostResult result = 1;
// The amount is below the minimum for the currency
AmountBelowMinimumError amount_below_minimum = 2 [(tag.reason) = "amount_below_minimum"];
// The requested currency is not supported for PayPal
errors.FailedPrecondition unsupported_currency = 3 [(tag.reason) = "unsupported_currency"];
// The requested level is not a valid one-time donation level
errors.FailedPrecondition unsupported_level = 4 [(tag.reason) = "unsupported_level"];
}
}
message CreateBoostReceiptCredentialsRequest {
// a payment ID from the processor
string payment_intent_id = 1 [(require.nonEmpty) = true];
// ZK blind-signature receipt credential request bytes
bytes receipt_credential_request = 2 [(require.nonEmpty) = true];
// The processor that handled the payment
subscriptions.PaymentProvider processor = 3 [(require.specified) = true];
}
message CreateBoostReceiptCredentialsResponse {
message CreateBoostReceiptCredentialsResult {
bytes receipt_credential_response = 1;
}
oneof response {
CreateBoostReceiptCredentialsResult result = 1;
// Payment is still processing; client should retry
errors.FailedPrecondition payment_still_processing = 2 [(tag.reason) = "payment_still_processing"];
// Payment failed
subscriptions.PaymentRequired payment_required = 3 [(tag.reason) = "payment_required"];
// Payment intent not found
errors.NotFound payment_not_found = 4 [(tag.reason) = "payment_not_found"];
// A receipt credential was already issued for this payment
errors.FailedPrecondition receipt_already_issued = 5 [(tag.reason) = "receipt_already_issued"];
}
}

View File

@ -109,8 +109,8 @@ service Subscriptions {
message UpdateSubscriberRequest {
bytes subscriber_id = 1 [(require.exactlySize) = 32];
// A libsignal DonationPermit from rpc Donations.CreateDonationPermit,
// required if the subscriber ID is new
// A libsignal DonationPermit from rpc Donations.CreateDonationPermit.
// Not required if the subscriber already exists.
bytes donation_permit = 2;
}
@ -295,6 +295,10 @@ message ChargeFailure {
optional string outcome_type = 6;
}
message PaymentRequired {
optional ChargeFailure charge_failure = 1;
}
message SetSubscriptionLevelResponse {
message SetSubscriptionLevelResult {
uint64 level = 1;
@ -367,10 +371,6 @@ message GetReceiptCredentialsResponse {
bytes receiptCredentialResponse = 1;
}
message PaymentRequired {
optional ChargeFailure charge_failure = 1;
}
oneof response {
GetReceiptCredentialsResult success = 1;
errors.NotFound subscriber_not_found = 2 [(tag.reason) = "subscriber_not_found"];

View File

@ -10,27 +10,31 @@ import com.apple.foundationdb.FDB;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonTypeName;
import java.io.IOException;
import org.whispersystems.textsecuregcm.storage.FoundationDbDatabaseLifecycleManager;
import org.whispersystems.textsecuregcm.storage.ServiceContainerFoundationDbDatabaseLifecycleManager;
import org.whispersystems.textsecuregcm.storage.TestcontainersFoundationDbDatabaseLifecycleManager;
@JsonTypeName("local")
public class LocalFoundationDbDatabaseFactory implements FoundationDbDatabaseFactory {
@JsonIgnore
private final TestcontainersFoundationDbDatabaseLifecycleManager testcontainersFoundationDbDatabaseLifecycleManager;
@JsonIgnore
private Database database;
private LocalFoundationDbDatabaseFactory() {
this.testcontainersFoundationDbDatabaseLifecycleManager = new TestcontainersFoundationDbDatabaseLifecycleManager();
}
@Override
public synchronized Database build(final FDB fdb) throws IOException {
if (database == null) {
Runtime.getRuntime().addShutdownHook(new Thread(testcontainersFoundationDbDatabaseLifecycleManager::closeDatabase));
testcontainersFoundationDbDatabaseLifecycleManager.initializeDatabase(fdb);
database = testcontainersFoundationDbDatabaseLifecycleManager.getDatabase();
final String serviceContainerNamePrefix = System.getProperty("foundationDb.serviceContainerNamePrefix");
final FoundationDbDatabaseLifecycleManager lifecycleManager = serviceContainerNamePrefix != null
? new ServiceContainerFoundationDbDatabaseLifecycleManager(serviceContainerNamePrefix + "0")
: new TestcontainersFoundationDbDatabaseLifecycleManager();
Runtime.getRuntime().addShutdownHook(new Thread(lifecycleManager::closeDatabase));
lifecycleManager.initializeDatabase(fdb);
database = lifecycleManager.getDatabase();
}
return database;

View File

@ -54,8 +54,10 @@ import org.signal.keytransparency.client.DistinguishedResponse;
import org.signal.keytransparency.client.E164SearchRequest;
import org.signal.keytransparency.client.FullTreeHead;
import org.signal.keytransparency.client.MonitorResponse;
import org.signal.keytransparency.client.MonitorResponseV2;
import org.signal.keytransparency.client.SearchProof;
import org.signal.keytransparency.client.SearchResponse;
import org.signal.keytransparency.client.SearchResponseV2;
import org.signal.keytransparency.client.UpdateValue;
import org.signal.libsignal.protocol.IdentityKey;
import org.signal.libsignal.protocol.ecc.ECKeyPair;
@ -143,7 +145,10 @@ public class KeyTransparencyControllerTest {
usernameHash.ifPresent(ignored -> searchResponseBuilder.setUsernameHash(CondensedTreeSearchResponse.getDefaultInstance()));
when(keyTransparencyServiceClient.search(any(), any(), any(), any(), any(), anyLong()))
.thenReturn(searchResponseBuilder.build());
.thenReturn(SearchResponseV2.newBuilder()
.setSearchResponse(searchResponseBuilder.build())
.build()
);
final Invocation.Builder request = resources.getJerseyTest()
.target("/v1/key-transparency/search")
@ -291,7 +296,9 @@ public class KeyTransparencyControllerTest {
@Test
void monitorSuccess() {
when(keyTransparencyServiceClient.monitor(any(), any(), any(), anyLong(), anyLong()))
.thenReturn(MonitorResponse.getDefaultInstance());
.thenReturn(MonitorResponseV2.newBuilder()
.setMonitorResponse(MonitorResponse.getDefaultInstance())
.build());
final Invocation.Builder request = resources.getJerseyTest()
.target("/v1/key-transparency/monitor")
@ -306,7 +313,7 @@ public class KeyTransparencyControllerTest {
final KeyTransparencyMonitorResponse keyTransparencyMonitorResponse = response.readEntity(
KeyTransparencyMonitorResponse.class);
assertNotNull(keyTransparencyMonitorResponse.serializedResponse());
assertArrayEquals(MonitorResponse.getDefaultInstance().toByteArray(), keyTransparencyMonitorResponse.serializedResponse());
verify(keyTransparencyServiceClient, times(1)).monitor(
any(), any(), any(), eq(3L), eq(4L));

View File

@ -25,6 +25,7 @@ import java.util.Base64;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.stream.Stream;
@ -239,12 +240,12 @@ class OneTimeDonationControllerTest extends AbstractV1SubscriptionControllerTest
@ParameterizedTest
@MethodSource
void createBoostReceiptPaymentRequired(final ChargeFailure chargeFailure, boolean expectChargeFailure) {
when(STRIPE_MANAGER.getPaymentDetails(any())).thenReturn(CompletableFuture.completedFuture(new PaymentDetails(
when(STRIPE_MANAGER.getPaymentDetails(any())).thenReturn(CompletableFuture.completedFuture(Optional.of(new PaymentDetails(
"id",
Collections.emptyMap(),
PaymentStatus.FAILED,
Instant.now(),
chargeFailure)
chargeFailure))
));
try (Response response = RESOURCE_EXTENSION.target("/v1/subscription/boost/receipt_credentials")
.request()
@ -322,12 +323,12 @@ class OneTimeDonationControllerTest extends AbstractV1SubscriptionControllerTest
ServerSecretParams.generate().getPublicParams()).createReceiptCredentialRequestContext(
new ReceiptSerial(new byte[ReceiptSerial.SIZE])).getRequest();
when(STRIPE_MANAGER.getPaymentDetails(any())).thenReturn(CompletableFuture.completedFuture(new PaymentDetails(
when(STRIPE_MANAGER.getPaymentDetails(any())).thenReturn(CompletableFuture.completedFuture(Optional.of(new PaymentDetails(
"id",
Collections.emptyMap(),
PaymentStatus.SUCCEEDED,
Instant.now(),
null)));
null))));
doThrow(WriteConflictException.class).when(ISSUED_RECEIPTS_MANAGER).recordIssuance(any(), any(), any(), any());
try (Response response = RESOURCE_EXTENSION.target("/v1/subscription/boost/receipt_credentials")

View File

@ -23,9 +23,9 @@ import org.signal.keytransparency.client.E164MonitorRequest;
import org.signal.keytransparency.client.E164SearchRequest;
import org.signal.keytransparency.client.KeyTransparencyQueryServiceGrpc;
import org.signal.keytransparency.client.MonitorRequest;
import org.signal.keytransparency.client.MonitorResponse;
import org.signal.keytransparency.client.MonitorResponseV2;
import org.signal.keytransparency.client.SearchRequest;
import org.signal.keytransparency.client.SearchResponse;
import org.signal.keytransparency.client.SearchResponseV2;
import org.signal.keytransparency.client.UsernameHashMonitorRequest;
import org.signal.libsignal.protocol.IdentityKey;
import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;
@ -56,7 +56,7 @@ import static org.whispersystems.textsecuregcm.grpc.GrpcTestUtils.assertRateLimi
import static org.whispersystems.textsecuregcm.grpc.GrpcTestUtils.assertStatusException;
import static org.whispersystems.textsecuregcm.grpc.KeyTransparencyGrpcService.COMMITMENT_INDEX_LENGTH;
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
@SuppressWarnings({"OptionalUsedAsFieldOrParameterType", "ThrowableNotThrown", "ResultOfMethodCallIgnored"})
public class KeyTransparencyGrpcServiceTest extends SimpleBaseGrpcTest<KeyTransparencyGrpcService, KeyTransparencyQueryServiceGrpc.KeyTransparencyQueryServiceBlockingStub>{
@Mock
private KeyTransparencyServiceClient keyTransparencyServiceClient;
@ -80,7 +80,7 @@ public class KeyTransparencyGrpcServiceTest extends SimpleBaseGrpcTest<KeyTransp
@Test
void searchSuccess() throws RateLimitExceededException {
when(keyTransparencyServiceClient.search(any())).thenReturn(SearchResponse.getDefaultInstance());
when(keyTransparencyServiceClient.search(any())).thenReturn(SearchResponseV2.getDefaultInstance());
Mockito.doNothing().when(rateLimiter).validate(any(String.class));
final SearchRequest request = SearchRequest.newBuilder()
.setAci(ByteString.copyFrom(ACI.toCompactByteArray()))
@ -90,7 +90,7 @@ public class KeyTransparencyGrpcServiceTest extends SimpleBaseGrpcTest<KeyTransp
.build())
.build();
assertDoesNotThrow(() -> unauthenticatedServiceStub().search(request));
assertDoesNotThrow(() -> unauthenticatedServiceStub().searchV2(request));
verify(keyTransparencyServiceClient, times(1)).search(eq(request));
}
@ -121,7 +121,7 @@ public class KeyTransparencyGrpcServiceTest extends SimpleBaseGrpcTest<KeyTransp
lastTreeHeadSize.ifPresent(consistencyBuilder::setLast);
requestBuilder.setConsistency(consistencyBuilder.build());
assertStatusException(Status.INVALID_ARGUMENT, () -> unauthenticatedServiceStub().search(requestBuilder.build()));
assertStatusException(Status.INVALID_ARGUMENT, () -> unauthenticatedServiceStub().searchV2(requestBuilder.build()));
verifyNoInteractions(keyTransparencyServiceClient);
}
@ -152,13 +152,13 @@ public class KeyTransparencyGrpcServiceTest extends SimpleBaseGrpcTest<KeyTransp
.setDistinguished(10)
.build())
.build();
assertRateLimitExceeded(retryAfterDuration, () -> unauthenticatedServiceStub().search(request));
assertRateLimitExceeded(retryAfterDuration, () -> unauthenticatedServiceStub().searchV2(request));
verifyNoInteractions(keyTransparencyServiceClient);
}
@Test
void monitorSuccess() {
when(keyTransparencyServiceClient.monitor(any())).thenReturn(MonitorResponse.getDefaultInstance());
when(keyTransparencyServiceClient.monitor(any())).thenReturn(MonitorResponseV2.getDefaultInstance());
when(rateLimiter.validateReactive(any(String.class)))
.thenReturn(Mono.empty());
final AciMonitorRequest aciMonitorRequest = AciMonitorRequest.newBuilder()
@ -175,7 +175,7 @@ public class KeyTransparencyGrpcServiceTest extends SimpleBaseGrpcTest<KeyTransp
.build())
.build();
assertDoesNotThrow(() -> unauthenticatedServiceStub().monitor(request));
assertDoesNotThrow(() -> unauthenticatedServiceStub().monitorV2(request));
verify(keyTransparencyServiceClient, times(1)).monitor(eq(request));
}
@ -199,7 +199,7 @@ public class KeyTransparencyGrpcServiceTest extends SimpleBaseGrpcTest<KeyTransp
requestBuilder.setConsistency(consistencyBuilder.build());
assertStatusException(Status.INVALID_ARGUMENT, () -> unauthenticatedServiceStub().monitor(requestBuilder.build()));
assertStatusException(Status.INVALID_ARGUMENT, () -> unauthenticatedServiceStub().monitorV2(requestBuilder.build()));
}
private static Stream<Arguments> monitorInvalidRequest() {
@ -218,9 +218,9 @@ public class KeyTransparencyGrpcServiceTest extends SimpleBaseGrpcTest<KeyTransp
Arguments.argumentSet("Invalid username hash length", validAciMonitorRequest, Optional.empty(), Optional.of(constructUsernameHashMonitorRequest(new byte[31], new byte[32], 10)), Optional.of(4L), Optional.of(4L)),
Arguments.argumentSet("Invalid commitment index on username hash monitor request", validAciMonitorRequest, Optional.empty(), Optional.of(constructUsernameHashMonitorRequest(USERNAME_HASH, new byte[31], 10)), Optional.of(4L), Optional.of(4L)),
Arguments.argumentSet("Invalid entry position on username hash monitor request", validAciMonitorRequest, Optional.empty(), Optional.of(constructUsernameHashMonitorRequest(USERNAME_HASH, new byte[32], 0)), Optional.of(4L), Optional.of(4L)),
Arguments.argumentSet("consistency.last must be provided", validAciMonitorRequest, Optional.empty(), Optional.empty(), Optional.empty(), Optional.of(4L),
Arguments.argumentSet("consistency.last must be provided", validAciMonitorRequest, Optional.empty(), Optional.empty(), Optional.empty(), Optional.of(4L)),
Arguments.argumentSet("consistency.last must be positive", validAciMonitorRequest, Optional.empty(), Optional.empty(), Optional.of(0L), Optional.of(4L)),
Arguments.argumentSet("consistency.distinguished must be provided", validAciMonitorRequest, Optional.empty(), Optional.empty(), Optional.of(4L)), Optional.empty()),
Arguments.argumentSet("consistency.distinguished must be provided", validAciMonitorRequest, Optional.empty(), Optional.empty(), Optional.of(4L), Optional.empty()),
Arguments.argumentSet("consistency.distinguished must be positive", validAciMonitorRequest, Optional.empty(), Optional.empty(), Optional.of(4L), Optional.of(0L))
);
}
@ -243,7 +243,7 @@ public class KeyTransparencyGrpcServiceTest extends SimpleBaseGrpcTest<KeyTransp
.setLast(10)
.build())
.build();
assertRateLimitExceeded(retryAfterDuration, () -> unauthenticatedServiceStub().monitor(request));
assertRateLimitExceeded(retryAfterDuration, () -> unauthenticatedServiceStub().monitorV2(request));
verifyNoInteractions(keyTransparencyServiceClient);
}
@ -254,7 +254,7 @@ public class KeyTransparencyGrpcServiceTest extends SimpleBaseGrpcTest<KeyTransp
.thenReturn(Mono.empty());
final DistinguishedRequest request = DistinguishedRequest.newBuilder().build();
assertDoesNotThrow(() -> unauthenticatedServiceStub().distinguished(request));
assertDoesNotThrow(() -> unauthenticatedServiceStub().distinguishedV2(request));
verify(keyTransparencyServiceClient, times(1)).distinguished(eq(request));
}
@ -264,7 +264,7 @@ public class KeyTransparencyGrpcServiceTest extends SimpleBaseGrpcTest<KeyTransp
.setLast(0)
.build();
assertStatusException(Status.INVALID_ARGUMENT, () -> unauthenticatedServiceStub().distinguished(request));
assertStatusException(Status.INVALID_ARGUMENT, () -> unauthenticatedServiceStub().distinguishedV2(request));
verifyNoInteractions(keyTransparencyServiceClient);
}
@ -277,7 +277,7 @@ public class KeyTransparencyGrpcServiceTest extends SimpleBaseGrpcTest<KeyTransp
.setLast(10)
.build();
assertRateLimitExceeded(retryAfterDuration, () -> unauthenticatedServiceStub().distinguished(request));
assertRateLimitExceeded(retryAfterDuration, () -> unauthenticatedServiceStub().distinguishedV2(request));
verifyNoInteractions(keyTransparencyServiceClient);
}

View File

@ -0,0 +1,336 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.grpc;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import com.google.common.net.InetAddresses;
import com.google.protobuf.ByteString;
import com.stripe.model.PaymentIntent;
import jakarta.annotation.Nullable;
import java.time.Instant;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.stream.Stream;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.mockito.Mock;
import org.signal.chat.one_time_donations.ConfirmPayPalBoostRequest;
import org.signal.chat.one_time_donations.ConfirmPayPalBoostResponse;
import org.signal.chat.one_time_donations.CreateBoostReceiptCredentialsRequest;
import org.signal.chat.one_time_donations.CreateBoostReceiptCredentialsResponse;
import org.signal.chat.one_time_donations.CreateBoostRequest;
import org.signal.chat.one_time_donations.CreateBoostResponse;
import org.signal.chat.one_time_donations.CreatePayPalBoostRequest;
import org.signal.chat.one_time_donations.CreatePayPalBoostResponse;
import org.signal.chat.one_time_donations.OneTimeDonationsGrpc;
import org.signal.chat.subscriptions.PaymentMethod;
import org.signal.chat.subscriptions.PaymentProvider;
import org.signal.libsignal.zkgroup.ServerSecretParams;
import org.signal.libsignal.zkgroup.VerificationFailedException;
import org.signal.libsignal.zkgroup.donation.DonationPermit;
import org.signal.libsignal.zkgroup.donation.DonationPermitRequest;
import org.signal.libsignal.zkgroup.donation.DonationPermitRequestContext;
import org.signal.libsignal.zkgroup.donation.DonationPermitResponse;
import org.signal.libsignal.zkgroup.receipts.ClientZkReceiptOperations;
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialRequest;
import org.signal.libsignal.zkgroup.receipts.ReceiptSerial;
import org.signal.libsignal.zkgroup.receipts.ServerZkReceiptOperations;
import org.whispersystems.textsecuregcm.configuration.OneTimeDonationConfiguration;
import org.whispersystems.textsecuregcm.limits.RateLimiter;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.storage.DonationPermits;
import org.whispersystems.textsecuregcm.storage.DonationPermitsManager;
import org.whispersystems.textsecuregcm.storage.IssuedReceiptsManager;
import org.whispersystems.textsecuregcm.storage.OneTimeDonationsManager;
import org.whispersystems.textsecuregcm.storage.WriteConflictException;
import org.whispersystems.textsecuregcm.subscriptions.BraintreeManager;
import org.whispersystems.textsecuregcm.subscriptions.ChargeFailure;
import org.whispersystems.textsecuregcm.subscriptions.PayPalDonationsTranslator;
import org.whispersystems.textsecuregcm.subscriptions.PaymentDetails;
import org.whispersystems.textsecuregcm.subscriptions.PaymentStatus;
import org.whispersystems.textsecuregcm.subscriptions.StripeManager;
import org.whispersystems.textsecuregcm.tests.util.SubscriptionConfigTestHelper;
import org.whispersystems.textsecuregcm.util.TestClock;
public class OneTimeDonationsGrpcServiceTest extends
SimpleBaseGrpcTest<OneTimeDonationsGrpcService, OneTimeDonationsGrpc.OneTimeDonationsBlockingStub> {
private final TestClock clock = TestClock.pinned(Instant.now());
private final OneTimeDonationConfiguration oneTimeDonationConfiguration =
SubscriptionConfigTestHelper.getOneTimeConfig();
@Mock
private DonationPermits donationPermits;
private static final ServerSecretParams DONATION_PERMITS_SECRET_PARAMS = ServerSecretParams.generate();
private DonationPermitsManager donationPermitsManager;
@Mock
private StripeManager stripeManager;
@Mock
private BraintreeManager braintreeManager;
@Mock
private PayPalDonationsTranslator payPalDonationsTranslator;
@Mock
private OneTimeDonationsManager oneTimeDonationsManager;
@Mock
private IssuedReceiptsManager issuedReceiptsManager;
@Mock
private ServerZkReceiptOperations zkReceiptOperations;
@Mock
private RateLimiters rateLimiters;
@Mock
private RateLimiter rateLimiter;
@Override
protected OneTimeDonationsGrpcService createServiceBeforeEachTest() {
getMockRequestAttributesInterceptor().setRequestAttributes(
new RequestAttributes(InetAddresses.forString("127.0.0.1"), null, "en-us"));
donationPermitsManager = new DonationPermitsManager(donationPermits, DONATION_PERMITS_SECRET_PARAMS, clock);
// spendIds are spend-once
final Set<String> spent = new HashSet<>();
when(donationPermits.spend(any(byte[].class), any(Instant.class)))
.thenAnswer(answer -> spent.add(new String(answer.getArgument(0, byte[].class))));
when(rateLimiters.forDescriptor(RateLimiters.For.ONE_TIME_DONATION)).thenReturn(rateLimiter);
return new OneTimeDonationsGrpcService(
oneTimeDonationConfiguration,
stripeManager,
braintreeManager,
payPalDonationsTranslator,
oneTimeDonationsManager,
issuedReceiptsManager,
zkReceiptOperations,
clock,
rateLimiters,
donationPermitsManager);
}
@Test
void createBoost() {
final PaymentIntent paymentIntent = mock(PaymentIntent.class);
when(paymentIntent.getClientSecret()).thenReturn("test-client-secret");
when(stripeManager.getSupportedCurrenciesForPaymentMethod(
org.whispersystems.textsecuregcm.subscriptions.PaymentMethod.CARD))
.thenReturn(Set.of("usd"));
when(stripeManager.createPaymentIntent(any(), anyLong(), anyLong(), any()))
.thenReturn(CompletableFuture.completedFuture(paymentIntent));
final CreateBoostResponse response = unauthenticatedServiceStub().createBoost(
CreateBoostRequest.newBuilder()
.setCurrency("usd")
.setAmount(500)
.setLevel(1)
.setPaymentMethod(PaymentMethod.PAYMENT_METHOD_CARD)
.setDonationPermit(ByteString.copyFrom(getDonationPermit().serialize()))
.build());
assertEquals(CreateBoostResponse.ResponseCase.CLIENT_SECRET, response.getResponseCase());
assertEquals("test-client-secret", response.getClientSecret());
}
@Test
void createBoostPermitAlreadySpent() {
when(donationPermits.spend(any(byte[].class), any(Instant.class))).thenReturn(false);
final CreateBoostResponse response = unauthenticatedServiceStub().createBoost(
CreateBoostRequest.newBuilder()
.setCurrency("usd")
.setAmount(500)
.setLevel(1)
.setPaymentMethod(PaymentMethod.PAYMENT_METHOD_CARD)
.setDonationPermit(ByteString.copyFrom(getDonationPermit().serialize()))
.build());
assertEquals(CreateBoostResponse.ResponseCase.PERMIT_REJECTED, response.getResponseCase());
}
@ParameterizedTest
@MethodSource
void createBoostValidationErrors(
final String currency,
final long amount,
final PaymentMethod paymentMethod,
final CreateBoostResponse.ResponseCase expectedResponseCase) {
when(stripeManager.getSupportedCurrenciesForPaymentMethod(
org.whispersystems.textsecuregcm.subscriptions.PaymentMethod.CARD))
.thenReturn(Set.of("usd"));
when(stripeManager.getSupportedCurrenciesForPaymentMethod(
org.whispersystems.textsecuregcm.subscriptions.PaymentMethod.SEPA_DEBIT))
.thenReturn(Set.of("eur"));
final CreateBoostResponse response = unauthenticatedServiceStub().createBoost(
CreateBoostRequest.newBuilder()
.setCurrency(currency)
.setAmount(amount)
.setLevel(1)
.setPaymentMethod(paymentMethod)
.setDonationPermit(ByteString.copyFrom(getDonationPermit().serialize()))
.build());
assertEquals(expectedResponseCase, response.getResponseCase());
}
static Stream<Arguments> createBoostValidationErrors() {
return Stream.of(
Arguments.of("usd", 249L, PaymentMethod.PAYMENT_METHOD_CARD,
CreateBoostResponse.ResponseCase.AMOUNT_BELOW_MINIMUM),
Arguments.of("eur", 1000001L, PaymentMethod.PAYMENT_METHOD_SEPA_DEBIT,
CreateBoostResponse.ResponseCase.AMOUNT_ABOVE_SEPA_LIMIT),
// USD is not supported for SEPA_DEBIT
Arguments.of("usd", 3000L, PaymentMethod.PAYMENT_METHOD_SEPA_DEBIT,
CreateBoostResponse.ResponseCase.UNSUPPORTED_CURRENCY)
);
}
@Test
void createPayPalBoost() {
final BraintreeManager.PayPalOneTimePaymentApprovalDetails approvalDetails =
mock(BraintreeManager.PayPalOneTimePaymentApprovalDetails.class);
when(approvalDetails.approvalUrl()).thenReturn("test-approval-url");
when(approvalDetails.paymentId()).thenReturn("test-id");
when(braintreeManager.getSupportedCurrenciesForPaymentMethod(
org.whispersystems.textsecuregcm.subscriptions.PaymentMethod.PAYPAL))
.thenReturn(Set.of("usd"));
when(payPalDonationsTranslator.translate(any(), any()))
.thenReturn("Donation to Signal Technology Foundation");
when(braintreeManager.createOneTimePayment(anyString(), anyLong(), anyString(), anyString(), anyString(),
anyString()))
.thenReturn(CompletableFuture.completedFuture(approvalDetails));
final CreatePayPalBoostResponse response = unauthenticatedServiceStub().createPayPalBoost(
CreatePayPalBoostRequest.newBuilder()
.setCurrency("usd")
.setAmount(300)
.setLevel(1)
.setReturnUrl("returnUrl")
.setCancelUrl("cancelUrl")
.build());
assertEquals(CreatePayPalBoostResponse.ResponseCase.RESULT, response.getResponseCase());
assertEquals("test-approval-url", response.getResult().getApprovalUrl());
assertEquals("test-id", response.getResult().getPaymentId());
}
@Test
void confirmPayPalBoost() {
when(braintreeManager.getSupportedCurrenciesForPaymentMethod(
org.whispersystems.textsecuregcm.subscriptions.PaymentMethod.PAYPAL))
.thenReturn(Set.of("usd"));
when(braintreeManager.captureOneTimePayment(anyString(), anyString(), anyString(), anyString(), anyLong(),
anyLong(), any()))
.thenReturn(CompletableFuture.completedFuture(
new BraintreeManager.PayPalChargeSuccessDetails("test-id")));
final ConfirmPayPalBoostResponse response = unauthenticatedServiceStub().confirmPayPalBoost(
ConfirmPayPalBoostRequest.newBuilder()
.setCurrency("usd")
.setAmount(300)
.setLevel(1)
.setPayerId("test-payer-id")
.setPaymentId("test-payment-id")
.setPaymentToken("test-payment-token")
.build());
assertEquals(ConfirmPayPalBoostResponse.ResponseCase.RESULT, response.getResponseCase());
assertEquals("test-id", response.getResult().getPaymentId());
}
@ParameterizedTest
@MethodSource
void createBoostReceiptCredentialsPaymentRequired(
@Nullable final ChargeFailure chargeFailure,
final boolean expectChargeFailure) {
when(stripeManager.getPaymentDetails(any())).thenReturn(CompletableFuture.completedFuture(
Optional.of(new PaymentDetails("id", Collections.emptyMap(), PaymentStatus.FAILED,
clock.instant(), chargeFailure))));
final CreateBoostReceiptCredentialsResponse response =
unauthenticatedServiceStub().createBoostReceiptCredentials(
CreateBoostReceiptCredentialsRequest.newBuilder()
.setPaymentIntentId("test-payment-intent-id")
.setReceiptCredentialRequest(ByteString.copyFromUtf8("abcd"))
.setProcessor(PaymentProvider.PAYMENT_PROVIDER_STRIPE)
.build());
assertEquals(CreateBoostReceiptCredentialsResponse.ResponseCase.PAYMENT_REQUIRED,
response.getResponseCase());
if (expectChargeFailure) {
assertEquals("generic_decline", response.getPaymentRequired().getChargeFailure().getCode());
} else {
assertFalse(response.getPaymentRequired().hasChargeFailure());
}
}
static Stream<Arguments> createBoostReceiptCredentialsPaymentRequired() {
return Stream.of(
Arguments.of(new ChargeFailure("generic_decline", "some failure message", null, null, null), true),
Arguments.of(null, false)
);
}
@Test
void createBoostReceiptCredentialsAlreadyRedeemed() throws Exception {
final ReceiptCredentialRequest receiptCredentialRequest = new ClientZkReceiptOperations(
ServerSecretParams.generate().getPublicParams()).createReceiptCredentialRequestContext(
new ReceiptSerial(new byte[ReceiptSerial.SIZE])).getRequest();
when(stripeManager.getPaymentDetails(any())).thenReturn(CompletableFuture.completedFuture(
Optional.of(new PaymentDetails("id", Collections.emptyMap(), PaymentStatus.SUCCEEDED,
clock.instant(), null))));
doThrow(WriteConflictException.class).when(issuedReceiptsManager)
.recordIssuance(any(), any(), any(), any());
final CreateBoostReceiptCredentialsResponse response =
unauthenticatedServiceStub().createBoostReceiptCredentials(
CreateBoostReceiptCredentialsRequest.newBuilder()
.setPaymentIntentId("test-payment-intent-id")
.setReceiptCredentialRequest(ByteString.copyFrom(receiptCredentialRequest.serialize()))
.setProcessor(PaymentProvider.PAYMENT_PROVIDER_STRIPE)
.build());
assertEquals(CreateBoostReceiptCredentialsResponse.ResponseCase.RECEIPT_ALREADY_ISSUED,
response.getResponseCase());
}
private DonationPermit getDonationPermit() {
final DonationPermitRequestContext context = DonationPermitRequestContext.forCount(1);
final DonationPermitRequest permitRequest = context.request();
final DonationPermitResponse permitResponse = donationPermitsManager.issue(permitRequest);
try {
final List<DonationPermit> permits = context.receive(
permitResponse, DONATION_PERMITS_SECRET_PARAMS.getPublicParams(), clock.instant());
return permits.getFirst();
} catch (final VerificationFailedException e) {
throw new AssertionError("The permit was correctly requested and issued in this method", e);
}
}
}

View File

@ -97,6 +97,7 @@ import org.whispersystems.textsecuregcm.subscriptions.SubscriptionInvalidArgumen
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionInvalidIdempotencyKeyException;
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionInvalidLevelException;
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionNotFoundException;
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionPaymentProcessor;
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionPaymentRequiredException;
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionPaymentRequiresActionException;
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionPrice;
@ -594,7 +595,7 @@ public class SubscriptionsGrpcServiceTest extends
final byte[] responseBytes = TestRandomUtil.nextBytes(16);
when(receiptCredentialResponse.serialize()).thenReturn(responseBytes);
when(subscriptionManager.createReceiptCredentials(any(), any(), any()))
.thenReturn(new SubscriptionManager.ReceiptResult(receiptCredentialResponse, null, PaymentProvider.STRIPE));
.thenReturn(new SubscriptionManager.ReceiptResult(receiptCredentialResponse, new SubscriptionPaymentProcessor.ReceiptItem("test-item-id", null, 5), PaymentProvider.STRIPE));
final GetReceiptCredentialsResponse response = unauthenticatedServiceStub().getReceiptCredentials(
GetReceiptCredentialsRequest.newBuilder()
.setSubscriberId(SUBSCRIBER_ID)

View File

@ -9,7 +9,7 @@ import com.apple.foundationdb.Database;
import com.apple.foundationdb.FDB;
import java.io.IOException;
interface FoundationDbDatabaseLifecycleManager {
public interface FoundationDbDatabaseLifecycleManager {
void initializeDatabase(final FDB fdb) throws IOException;

View File

@ -16,7 +16,7 @@ import org.slf4j.LoggerFactory;
/**
* Manages the lifecycle of a database connected to a FoundationDB instance running as an external service container.
*/
class ServiceContainerFoundationDbDatabaseLifecycleManager implements FoundationDbDatabaseLifecycleManager {
public class ServiceContainerFoundationDbDatabaseLifecycleManager implements FoundationDbDatabaseLifecycleManager {
private final String foundationDbServiceContainerName;
@ -24,7 +24,7 @@ class ServiceContainerFoundationDbDatabaseLifecycleManager implements Foundation
private static final Logger log = LoggerFactory.getLogger(ServiceContainerFoundationDbDatabaseLifecycleManager.class);
ServiceContainerFoundationDbDatabaseLifecycleManager(final String foundationDbServiceContainerName) {
public ServiceContainerFoundationDbDatabaseLifecycleManager(final String foundationDbServiceContainerName) {
log.info("Using FoundationDB service container: {}", foundationDbServiceContainerName);
this.foundationDbServiceContainerName = foundationDbServiceContainerName;
}