Skip to content

Commit

Permalink
Merge pull request #566 from cardano-foundation/feature/multiple-rece…
Browse files Browse the repository at this point in the history
…ipts-request

feat: add api/vote/receipts endpoint
  • Loading branch information
jimcase authored Aug 13, 2024
2 parents 9713848 + d0a5e70 commit 323c12a
Show file tree
Hide file tree
Showing 7 changed files with 190 additions and 2 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ max.pending.verification.attempts=${MAX_PENDING_VERIFICATION_ATTEMPTS:5}
spring.h2.console.enabled=${H2_CONSOLE_ENABLED:true}

phone.number.salt=${SALT:67274569c9671a4ae3f753b9647ca719}
discord.bot.eventId.binding=${DISCORD_BOT_EVENT_ID_BINDING:CF_SUMMIT_2024_8BCC}
discord.bot.eventId.binding=${DISCORD_BOT_EVENT_ID_BINDING:CF_SUMMIT_2024_9BCC}

discord.bot.username=${DISCORD_BOT_USERNAME:discord_bot}
discord.bot.password=${DISCORD_BOT_PASSWORD:test}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,9 +85,12 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
// SECURED by JWT auth
.requestMatchers(new AntPathRequestMatcher("/api/vote/receipt/**", GET.name())).authenticated()
.requestMatchers(new AntPathRequestMatcher("/api/vote/receipt/**", HEAD.name())).authenticated()

// SECURED by Web3 auth
.requestMatchers(new AntPathRequestMatcher("/api/vote/receipt", GET.name())).authenticated()
.requestMatchers(new AntPathRequestMatcher("/api/vote/receipt", HEAD.name())).authenticated()
.requestMatchers(new AntPathRequestMatcher("/api/vote/receipts", GET.name())).authenticated()
.requestMatchers(new AntPathRequestMatcher("/api/vote/receipts", HEAD.name())).authenticated()
// SECURED by Web3 auth
.requestMatchers(new AntPathRequestMatcher("/api/auth/login", GET.name())).authenticated()
// SECURED by JWT auth
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ Optional<Vote> findByEventIdAndCategoryIdAndWalletTypeAndWalletId(String eventId
WalletType walletType,
String walletId);

@Query("SELECT v FROM Vote v WHERE v.eventId = :eventId AND v.walletType = :walletType AND v.walletId = :walletId ORDER BY v.votedAtSlot, v.idNumericHash ASC")
List<Vote> findByEventIdAndWalletTypeAndWalletId(@Param("eventId") String eventId,
@Param("walletType") WalletType walletType,
@Param("walletId") String walletId);

@Query("SELECT COUNT(v) AS totalVoteCount, SUM(v.votingPower) AS totalVotingPower FROM Vote v WHERE v.eventId = :eventId")
List<HighLevelEventVoteCount> getHighLevelEventStats(@Param("eventId") String eventId);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,63 @@ public ResponseEntity<?> getVoteReceipt(Authentication authentication) {
});
}

@RequestMapping(value = "/receipts", method = { GET, HEAD }, produces = "application/json")
@Timed(value = "resource.vote.receipts", histogram = true)
@Operation(
summary = "Retrieve all vote receipts for the authenticated user",
description = "Allows users to retrieve all vote receipts for their votes. Requires JWT authentication.",
responses = {
@ApiResponse(
responseCode = "200",
description = "Vote receipts retrieved successfully.",
content = @Content(
mediaType = "application/json", schema = @Schema(implementation = VoteReceipt.class)
)
),
@ApiResponse(
responseCode = "400",
description = "Bad request, possibly due to missing JWT authentication.",
content = {
@Content(mediaType = "application/json",
schema = @Schema(implementation = Problem.class))
}
),
@ApiResponse(responseCode = "500", description = "Internal server error")
}
)
public ResponseEntity<?> getAllVoteReceipts(Authentication authentication) {
if (!(authentication instanceof JwtAuthenticationToken jwtAuth)) {
var problem = Problem.builder()
.withTitle("JWT_REQUIRED")
.withDetail("JWT auth token needed!")
.withStatus(BAD_REQUEST)
.build();

return ResponseEntity
.status(problem.getStatus().getStatusCode())
.body(problem);
}

return voteService.voteReceipts(jwtAuth)
.fold(problem -> {
log.warn("Failed to retrieve vote receipts, problem: {}", problem);

return ResponseEntity
.status(problem.getStatus().getStatusCode())
.body(problem);
},
voteReceipts -> {
var cacheControl = CacheControl.maxAge(1, MINUTES)
.noTransform()
.mustRevalidate();

return ResponseEntity
.ok()
.cacheControl(cacheControl)
.body(voteReceipts);
});
}

@RequestMapping(value = "/receipt/{eventId}/{categoryId}", method = { HEAD, GET }, produces = "application/json")
@Timed(value = "resource.vote.receipt.jwt", histogram = true)
@Operation(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import java.util.Objects;

import static com.bloxbean.cardano.client.util.HexUtil.encodeHexString;
import static org.cardano.foundation.voting.domain.VoteReceipt.Status.*;
Expand Down Expand Up @@ -503,6 +504,102 @@ private Either<Problem, VoteEnvelope> unwrapCastCoteEnvelope(Web3ConcreteDetails
}
}

@Override
@Transactional(readOnly = true)
@Timed(value = "service.vote.voteReceipts", histogram = true)
public Either<Problem, List<VoteReceipt>> voteReceipts(JwtAuthenticationToken auth) {
log.info("Fetching voter's receipts for the signed data...");
val jwtWalletId = auth.getWalletId();
val jwtWalletType = auth.getWalletType();
val eventDetails = auth.eventDetails();

if (auth.isActionNotAllowed(VIEW_VOTE_RECEIPT)) {
return Either.left(Problem.builder()
.withTitle("ACTION_NOT_ALLOWED")
.withDetail("Action VIEW_VOTE_RECEIPT not allowed for the role:" + auth.role().name())
.withStatus(BAD_REQUEST)
.build());
}

val votes = voteRepository.findByEventIdAndWalletTypeAndWalletId(eventDetails.id(), jwtWalletType, jwtWalletId);
if (votes.isEmpty()) {
return Either.left(Problem.builder()
.withTitle("NO_VOTES_FOUND")
.withDetail("No votes found for the wallet: " + jwtWalletId)
.withStatus(NOT_FOUND)
.build());
}

List<VoteReceipt> voteReceipts = votes.stream().map(vote -> {
val categoryM = eventDetails.categoryDetailsById(vote.getCategoryId());
if (categoryM.isEmpty()) {
log.warn("Unrecognised category, id:{}", vote.getCategoryId());
return null;
}
val category = categoryM.orElseThrow();

val proposalM = category.findProposalById(vote.getProposalId());
if (proposalM.isEmpty()) {
log.warn("Proposal not found for voteId:{}", vote.getId());
return null;
}
val proposal = proposalM.orElseThrow();
val proposalIdOrName = category.gdprProtection() ? proposal.id() : proposal.name();

val latestVoteMerkleProof = voteMerkleProofService.findLatestProof(eventDetails.id(), vote.getId());

return latestVoteMerkleProof.map(proof -> {
log.info("Latest merkle proof found for voteId:{}", vote.getId());

val transactionDetailsE = chainFollowerClient.getTransactionDetails(proof.getL1TransactionHash());
if (transactionDetailsE.isEmpty()) {
log.warn("Unable to get transaction details from chain-tip follower service, transactionHash:{}", proof.getL1TransactionHash());
return null;
}
val transactionDetailsM = transactionDetailsE.get();

val isL1CommitmentOnChain = transactionDetailsM.map(ChainFollowerClient.TransactionDetailsResponse::finalityScore);

return VoteReceipt.builder()
.id(vote.getId())
.event(eventDetails.id())
.category(category.id())
.proposal(proposalIdOrName)
.signature(vote.getSignature())
.payload(vote.getPayload())
.publicKey(vote.getPublicKey())
.votedAtSlot(Long.valueOf(vote.getVotedAtSlot()).toString())
.walletId(vote.getWalletId())
.walletType(vote.getWalletType())
.votingPower(vote.getVotingPower().map(String::valueOf))
.status(readMerkleProofStatus(proof, isL1CommitmentOnChain))
.finalityScore(isL1CommitmentOnChain)
.merkleProof(convertMerkleProof(proof, transactionDetailsM))
.build();
}).orElseGet(() -> {
log.info("Merkle proof not found yet for voteId:{}", vote.getId());

return VoteReceipt.builder()
.id(vote.getId())
.event(eventDetails.id())
.category(category.id())
.proposal(proposalIdOrName)
.signature(vote.getSignature())
.payload(vote.getPayload())
.publicKey(vote.getPublicKey())
.votedAtSlot(Long.valueOf(vote.getVotedAtSlot()).toString())
.walletId(vote.getWalletId())
.walletType(vote.getWalletType())
.votingPower(vote.getVotingPower().map(String::valueOf))
.status(BASIC)
.build();
});
}).filter(Objects::nonNull).toList();

return Either.right(voteReceipts);
}


@Override
@Transactional(readOnly = true)
@Timed(value = "service.vote.voteReceipt", histogram = true)
Expand Down Expand Up @@ -543,6 +640,7 @@ public Either<Problem, VoteReceipt> voteReceipt(String categoryId,
JwtAuthenticationToken auth) {
val jwtWalletId = auth.getWalletId();
val jwtWalletType = auth.getWalletType();
val eventDetails = auth.eventDetails();

if (auth.isActionNotAllowed(VIEW_VOTE_RECEIPT)) {
return Either.left(Problem.builder()
Expand All @@ -552,7 +650,7 @@ public Either<Problem, VoteReceipt> voteReceipt(String categoryId,
.build());
}

return actualVoteReceipt(auth.eventDetails(), categoryId, jwtWalletType, jwtWalletId);
return actualVoteReceipt(eventDetails, categoryId, jwtWalletType, jwtWalletId);
}

private Either<Problem, VoteReceipt> actualVoteReceipt(ChainFollowerClient.EventDetailsResponse event,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,6 @@ public interface VoteService {

Either<Problem, VoteReceipt> voteReceipt(String categoryId, JwtAuthenticationToken auth);

Either<Problem, List<VoteReceipt>> voteReceipts(JwtAuthenticationToken auth);

}
Original file line number Diff line number Diff line change
Expand Up @@ -210,4 +210,27 @@ public void testVoteChangingAvailable() {
.statusCode(403);
}

@Test
@Order(7)
public void testGetAllVoteReceipts() {
// Get bearer token from login
String accessToken = getAccessToken();

// Get all vote receipts
Response response = given()
.header("Authorization", "Bearer " + accessToken)
.when().get(VOTE_ENDPOINT + "/receipts");

assertEquals(200, response.getStatusCode());

List<VoteReceipt> voteReceipts = response.jsonPath().getList(".", VoteReceipt.class);
Assertions.assertNotNull(voteReceipts);
Assertions.assertFalse(voteReceipts.isEmpty(), "The vote receipts list should not be empty.");

VoteReceipt firstReceipt = voteReceipts.get(0);
Assertions.assertNotNull(firstReceipt.getEvent(), "Event ID should not be null.");
Assertions.assertNotNull(firstReceipt.getCategory(), "Category should not be null.");
Assertions.assertNotNull(firstReceipt.getProposal(), "Proposal should not be null.");
Assertions.assertNotNull(firstReceipt.getVotingPower(), "Voting power should not be null.");
}
}

0 comments on commit 323c12a

Please sign in to comment.