Port DonationController to gRPC

This commit is contained in:
Ameya Lokare
2026-06-03 09:50:17 -07:00
committed by Jon Chambers
parent dd93833324
commit 32befd7c9a
7 changed files with 314 additions and 7 deletions
@@ -156,6 +156,7 @@ import org.whispersystems.textsecuregcm.grpc.ChallengeGrpcService;
import org.whispersystems.textsecuregcm.grpc.CredentialsAnonymousGrpcService;
import org.whispersystems.textsecuregcm.grpc.CredentialsGrpcService;
import org.whispersystems.textsecuregcm.grpc.DevicesGrpcService;
import org.whispersystems.textsecuregcm.grpc.DonationsGrpcService;
import org.whispersystems.textsecuregcm.grpc.ErrorConformanceInterceptor;
import org.whispersystems.textsecuregcm.grpc.ErrorMappingInterceptor;
import org.whispersystems.textsecuregcm.grpc.ExternalServiceDefinitions;
@@ -1004,7 +1005,8 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
new AttachmentsGrpcService(experimentEnrollmentManager, rateLimiters,
gcsAttachmentGenerator, tusAttachmentGenerator, config.getAttachments().maxAttachmentUploadSizeInBytes()),
new PaymentsGrpcService(currencyManager),
new ChallengeGrpcService(accountsManager, rateLimitChallengeManager, challengeConstraintChecker))
new ChallengeGrpcService(accountsManager, rateLimitChallengeManager, challengeConstraintChecker),
new DonationsGrpcService(clock, zkReceiptOperations, redeemedReceiptsManager, accountsManager, config.getBadges(), ReceiptCredentialPresentation::new))
.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!
@@ -34,15 +34,12 @@ import org.whispersystems.textsecuregcm.entities.RedeemReceiptRequest;
import org.whispersystems.textsecuregcm.storage.AccountBadge;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.storage.RedeemedReceiptsManager;
import org.whispersystems.textsecuregcm.subscriptions.ReceiptCredentialPresentationFactory;
@Path("/v1/donation")
@Tag(name = "Donations")
public class DonationController {
public interface ReceiptCredentialPresentationFactory {
ReceiptCredentialPresentation build(byte[] bytes) throws InvalidInputException;
}
private final Clock clock;
private final ServerZkReceiptOperations serverZkReceiptOperations;
private final RedeemedReceiptsManager redeemedReceiptsManager;
@@ -0,0 +1,104 @@
package org.whispersystems.textsecuregcm.grpc;
import com.google.protobuf.Empty;
import java.time.Clock;
import java.time.Instant;
import org.signal.chat.donations.RedeemReceiptRequest;
import org.signal.chat.donations.RedeemReceiptResponse;
import org.signal.chat.donations.SimpleDonationsGrpc;
import org.signal.chat.errors.FailedPrecondition;
import org.signal.chat.errors.FailedZkAuthentication;
import org.signal.libsignal.zkgroup.InvalidInputException;
import org.signal.libsignal.zkgroup.VerificationFailedException;
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialPresentation;
import org.signal.libsignal.zkgroup.receipts.ReceiptSerial;
import org.signal.libsignal.zkgroup.receipts.ServerZkReceiptOperations;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.auth.grpc.AuthenticatedDevice;
import org.whispersystems.textsecuregcm.auth.grpc.AuthenticationUtil;
import org.whispersystems.textsecuregcm.configuration.BadgesConfiguration;
import org.whispersystems.textsecuregcm.storage.AccountBadge;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.storage.RedeemedReceiptsManager;
import org.whispersystems.textsecuregcm.subscriptions.ReceiptCredentialPresentationFactory;
public class DonationsGrpcService extends SimpleDonationsGrpc.DonationsImplBase {
private final Clock clock;
private final ServerZkReceiptOperations serverZkReceiptOperations;
private final RedeemedReceiptsManager redeemedReceiptsManager;
private final AccountsManager accountsManager;
private final BadgesConfiguration badgesConfiguration;
private final ReceiptCredentialPresentationFactory receiptCredentialPresentationFactory;
private static final Logger LOGGER = LoggerFactory.getLogger(DonationsGrpcService.class);
public DonationsGrpcService(
final Clock clock,
final ServerZkReceiptOperations serverZkReceiptOperations,
final RedeemedReceiptsManager redeemedReceiptsManager,
final AccountsManager accountsManager,
final BadgesConfiguration badgesConfiguration,
final ReceiptCredentialPresentationFactory receiptCredentialPresentationFactory) {
this.clock = clock;
this.serverZkReceiptOperations = serverZkReceiptOperations;
this.redeemedReceiptsManager = redeemedReceiptsManager;
this.accountsManager = accountsManager;
this.badgesConfiguration = badgesConfiguration;
this.receiptCredentialPresentationFactory = receiptCredentialPresentationFactory;
}
@Override
public RedeemReceiptResponse redeemReceipt(final RedeemReceiptRequest request) {
try {
final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice();
final ReceiptCredentialPresentation receiptCredentialPresentation = receiptCredentialPresentationFactory
.build(request.getReceiptCredentialPresentation().toByteArray());
serverZkReceiptOperations.verifyReceiptCredentialPresentation(receiptCredentialPresentation);
final ReceiptSerial receiptSerial = receiptCredentialPresentation.getReceiptSerial();
final Instant receiptExpiration = Instant.ofEpochSecond(receiptCredentialPresentation.getReceiptExpirationTime());
final long receiptLevel = receiptCredentialPresentation.getReceiptLevel();
final String badgeId = badgesConfiguration.getReceiptLevels().get(receiptLevel);
if (badgeId == null) {
// Since the receipt presentation checked out, the server messed up because it doesn't recognize a receipt level it previously issued.
LOGGER.error("Server doesn't recognize previously issued receipt level; please check badgesConfiguration for issues");
throw GrpcExceptions.unavailable("server does not recognize the requested receipt level");
}
final boolean receiptMatched = redeemedReceiptsManager.put(
receiptSerial, receiptExpiration.getEpochSecond(), receiptLevel, authenticatedDevice.accountIdentifier());
if (!receiptMatched) {
return RedeemReceiptResponse.newBuilder()
.setAlreadyRedeemed(FailedPrecondition.newBuilder()
.setDescription("receipt has already been redeemed")
.build())
.build();
}
accountsManager.update(authenticatedDevice.accountIdentifier(), a -> {
a.addBadge(clock, new AccountBadge(badgeId, receiptExpiration, request.getVisible()));
if (request.getPrimary()) {
a.makeBadgePrimaryIfExists(clock, badgeId);
}
});
return RedeemReceiptResponse.newBuilder()
.setSuccess(Empty.getDefaultInstance())
.build();
} catch (final InvalidInputException e) {
return RedeemReceiptResponse.newBuilder()
.setFailedAuthentication(FailedZkAuthentication.newBuilder()
.setDescription("invalid receipt credential presentation")
.build())
.build();
} catch (final VerificationFailedException e) {
return RedeemReceiptResponse.newBuilder()
.setFailedAuthentication(FailedZkAuthentication.newBuilder()
.setDescription("receipt credential presentation verification failed")
.build())
.build();
}
}
}
@@ -0,0 +1,9 @@
package org.whispersystems.textsecuregcm.subscriptions;
import org.signal.libsignal.zkgroup.InvalidInputException;
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialPresentation;
public interface ReceiptCredentialPresentationFactory {
ReceiptCredentialPresentation build(byte[] bytes) throws InvalidInputException;
}
@@ -0,0 +1,45 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
syntax = "proto3";
option java_multiple_files = true;
package org.signal.chat.donations;
import "google/protobuf/empty.proto";
import "org/signal/chat/require.proto";
import "org/signal/chat/errors.proto";
import "org/signal/chat/tag.proto";
service Donations {
option (require.auth) = AUTH_ONLY_AUTHENTICATED;
// Redeem a receipt acquired from Subscriptions.CreateSubscriptionReceiptCredentials
// to add a badge to the account. After successful redemption, profile
// responses will include the corresponding badge (if configured as visible)
// until the expiration time on the receipt.
rpc RedeemReceipt(RedeemReceiptRequest) returns (RedeemReceiptResponse) {}
}
message RedeemReceiptRequest {
// Presentation of the ZK receipt acquired when the subscription was created
bytes receiptCredentialPresentation = 1 [(require.exactlySize) = 329];
// If true, the corresponding badge should be visible on the profile
bool visible = 2;
// If true, and the new badge is visible, it should be the primary badge on the profile
bool primary = 3;
}
message RedeemReceiptResponse {
oneof response {
// The receipt was successfully redeemed
google.protobuf.Empty success = 1;
// The provided presentation is invalid
errors.FailedZkAuthentication failed_authentication = 2 [(tag.reason) = "failed_authentication"];
// The receipt was already redeemed for a different account
errors.FailedPrecondition already_redeemed = 3 [(tag.reason) = "already_redeemed"];
}
}
@@ -39,6 +39,7 @@ import org.whispersystems.textsecuregcm.entities.RedeemReceiptRequest;
import org.whispersystems.textsecuregcm.storage.AccountBadge;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.storage.RedeemedReceiptsManager;
import org.whispersystems.textsecuregcm.subscriptions.ReceiptCredentialPresentationFactory;
import org.whispersystems.textsecuregcm.tests.util.AccountsHelper;
import org.whispersystems.textsecuregcm.tests.util.AuthHelper;
import org.whispersystems.textsecuregcm.util.TestClock;
@@ -70,7 +71,7 @@ class DonationControllerTest {
byte[] receiptSerialBytes;
ReceiptSerial receiptSerial;
byte[] presentation;
DonationController.ReceiptCredentialPresentationFactory receiptCredentialPresentationFactory;
ReceiptCredentialPresentationFactory receiptCredentialPresentationFactory;
ReceiptCredentialPresentation receiptCredentialPresentation;
ResourceExtension resources;
@@ -82,7 +83,7 @@ class DonationControllerTest {
AccountsHelper.setupMockUpdate(accountsManager);
receiptSerial = new ReceiptSerial(TestRandomUtil.nextBytes(ReceiptSerial.SIZE));
presentation = TestRandomUtil.nextBytes(25);
receiptCredentialPresentationFactory = mock(DonationController.ReceiptCredentialPresentationFactory.class);
receiptCredentialPresentationFactory = mock(ReceiptCredentialPresentationFactory.class);
receiptCredentialPresentation = mock(ReceiptCredentialPresentation.class);
try {
@@ -0,0 +1,149 @@
package org.whispersystems.textsecuregcm.grpc;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import com.google.protobuf.ByteString;
import java.time.Clock;
import java.time.Instant;
import java.util.Map;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junitpioneer.jupiter.cartesian.CartesianTest;
import org.mockito.Mock;
import org.signal.chat.donations.DonationsGrpc;
import org.signal.chat.donations.RedeemReceiptRequest;
import org.signal.chat.donations.RedeemReceiptResponse;
import org.signal.libsignal.zkgroup.InvalidInputException;
import org.signal.libsignal.zkgroup.VerificationFailedException;
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialPresentation;
import org.signal.libsignal.zkgroup.receipts.ReceiptSerial;
import org.signal.libsignal.zkgroup.receipts.ServerZkReceiptOperations;
import org.whispersystems.textsecuregcm.configuration.BadgesConfiguration;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountBadge;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.storage.RedeemedReceiptsManager;
import org.whispersystems.textsecuregcm.subscriptions.ReceiptCredentialPresentationFactory;
import org.whispersystems.textsecuregcm.tests.util.AccountsHelper;
import org.whispersystems.textsecuregcm.util.TestClock;
import org.whispersystems.textsecuregcm.util.TestRandomUtil;
class DonationsGrpcServiceTest extends SimpleBaseGrpcTest<DonationsGrpcService, DonationsGrpc.DonationsBlockingStub> {
@Mock
private ServerZkReceiptOperations zkReceiptOperations;
@Mock
private RedeemedReceiptsManager redeemedReceiptsManager;
@Mock
private AccountsManager accountsManager;
@Mock
private Account account;
@Mock
private ReceiptCredentialPresentationFactory receiptCredentialPresentationFactory;
@Mock
private BadgesConfiguration badgesConfiguration;
@Mock
private ReceiptSerial receiptSerial;
private final Clock clock = TestClock.pinned(Instant.ofEpochSecond(100));
private static final long EXPIRATION_TIME_EPOCH_SECONDS = 200;
@Override
protected DonationsGrpcService createServiceBeforeEachTest() {
when(accountsManager.getByAccountIdentifier(AUTHENTICATED_ACI)).thenReturn(Optional.of(account));
AccountsHelper.setupMockUpdate(accountsManager);
final ReceiptCredentialPresentation receiptCredentialPresentation = mock(ReceiptCredentialPresentation.class);
try {
when(receiptCredentialPresentationFactory.build(any())).thenReturn(receiptCredentialPresentation);
} catch (final InvalidInputException e) {
throw new RuntimeException(e);
}
when(receiptCredentialPresentation.getReceiptLevel()).thenReturn(1L);
when(badgesConfiguration.getReceiptLevels()).thenReturn(Map.of(1L, "testBadge"));
when(receiptCredentialPresentation.getReceiptExpirationTime()).thenReturn(EXPIRATION_TIME_EPOCH_SECONDS);
when(receiptCredentialPresentation.getReceiptSerial()).thenReturn(receiptSerial);
return new DonationsGrpcService(
clock,
zkReceiptOperations,
redeemedReceiptsManager,
accountsManager,
badgesConfiguration,
receiptCredentialPresentationFactory
);
}
@CartesianTest
void redeemReceipt(
@CartesianTest.Values(booleans = {true, false}) final boolean isVisible,
@CartesianTest.Values(booleans = {true, false}) final boolean isPrimary) {
when(redeemedReceiptsManager.put(receiptSerial, EXPIRATION_TIME_EPOCH_SECONDS, 1, AUTHENTICATED_ACI)).thenReturn(
true);
final RedeemReceiptResponse response = authenticatedServiceStub().redeemReceipt(RedeemReceiptRequest.newBuilder()
.setVisible(isVisible)
.setPrimary(isPrimary)
.setReceiptCredentialPresentation(ByteString.copyFrom(TestRandomUtil.nextBytes(329)))
.build());
assertEquals(RedeemReceiptResponse.ResponseCase.SUCCESS, response.getResponseCase());
verify(account).addBadge(clock,
new AccountBadge("testBadge", Instant.ofEpochSecond(EXPIRATION_TIME_EPOCH_SECONDS), isVisible));
if (isPrimary) {
verify(account).makeBadgePrimaryIfExists(clock, "testBadge");
} else {
verify(account, never()).makeBadgePrimaryIfExists(any(), any());
}
}
@Test
void alreadyRedeemed() {
when(redeemedReceiptsManager.put(receiptSerial, EXPIRATION_TIME_EPOCH_SECONDS, 1, AUTHENTICATED_ACI)).thenReturn(
false);
final RedeemReceiptResponse response = authenticatedServiceStub().redeemReceipt(RedeemReceiptRequest.newBuilder()
.setVisible(true)
.setPrimary(true)
.setReceiptCredentialPresentation(ByteString.copyFrom(TestRandomUtil.nextBytes(329)))
.build());
assertEquals(RedeemReceiptResponse.ResponseCase.ALREADY_REDEEMED, response.getResponseCase());
verifyNoInteractions(account);
}
@ParameterizedTest
@CsvSource({"true,false", "false, true"})
void failedZkAuthentication(final boolean invalidPresentation, final boolean verificationFailed)
throws InvalidInputException, VerificationFailedException {
if (invalidPresentation) {
doThrow(InvalidInputException.class).when(receiptCredentialPresentationFactory).build(any());
}
if (verificationFailed) {
doThrow(VerificationFailedException.class).when(zkReceiptOperations).verifyReceiptCredentialPresentation(any());
}
final RedeemReceiptResponse response = authenticatedServiceStub().redeemReceipt(RedeemReceiptRequest.newBuilder()
.setVisible(true)
.setPrimary(true)
.setReceiptCredentialPresentation(ByteString.copyFrom(TestRandomUtil.nextBytes(329)))
.build());
assertEquals(RedeemReceiptResponse.ResponseCase.FAILED_AUTHENTICATION, response.getResponseCase());
verifyNoInteractions(account);
}
}