
com.quorum.tessera.q2t.TransactionResource Maven / Gradle / Ivy
package com.quorum.tessera.q2t;
import static jakarta.ws.rs.core.MediaType.*;
import com.quorum.tessera.api.*;
import com.quorum.tessera.api.constraint.PrivacyValid;
import com.quorum.tessera.config.constraints.ValidBase64;
import com.quorum.tessera.data.MessageHash;
import com.quorum.tessera.enclave.PrivacyGroup;
import com.quorum.tessera.enclave.PrivacyMode;
import com.quorum.tessera.encryption.PublicKey;
import com.quorum.tessera.privacygroup.PrivacyGroupManager;
import com.quorum.tessera.transaction.TransactionManager;
import io.swagger.v3.oas.annotations.Hidden;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.ArraySchema;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.ExampleObject;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.UriBuilder;
import java.net.URI;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Provides endpoints for dealing with transactions, including:
*
* - creating new transactions and distributing them - deleting transactions - fetching
* transactions - resending old transactions
*/
@Tag(name = "quorum-to-tessera")
@Path("/")
public class TransactionResource {
private static final Logger LOGGER = LoggerFactory.getLogger(TransactionResource.class);
private final TransactionManager transactionManager;
private final PrivacyGroupManager privacyGroupManager;
private final Base64.Decoder base64Decoder = Base64.getDecoder();
private final Base64.Encoder base64Encoder = Base64.getEncoder();
public TransactionResource(
TransactionManager transactionManager, PrivacyGroupManager privacyGroupManager) {
this.transactionManager = Objects.requireNonNull(transactionManager);
this.privacyGroupManager = Objects.requireNonNull(privacyGroupManager);
}
// hide this operation from swagger generation; the /send operation is overloaded and must be
// documented in a single place
@Hidden
@POST
@Path("send")
@Consumes(APPLICATION_JSON)
@Produces(APPLICATION_JSON)
public Response send(@NotNull @Valid @PrivacyValid final SendRequest sendRequest) {
final PublicKey sender =
Optional.ofNullable(sendRequest.getFrom())
.map(base64Decoder::decode)
.map(PublicKey::from)
.orElseGet(transactionManager::defaultPublicKey);
final Optional optionalPrivacyGroup =
Optional.ofNullable(sendRequest.getPrivacyGroupId()).map(PrivacyGroup.Id::fromBase64String);
final List recipientList =
optionalPrivacyGroup
.map(privacyGroupManager::retrievePrivacyGroup)
.map(PrivacyGroup::getMembers)
.orElse(
Stream.of(sendRequest)
.filter(sr -> Objects.nonNull(sr.getTo()))
.flatMap(s -> Stream.of(s.getTo()))
.map(base64Decoder::decode)
.map(PublicKey::from)
.collect(Collectors.toList()));
final Set affectedTransactions =
Stream.ofNullable(sendRequest.getAffectedContractTransactions())
.flatMap(Arrays::stream)
.map(base64Decoder::decode)
.map(MessageHash::new)
.collect(Collectors.toSet());
final byte[] execHash =
Optional.ofNullable(sendRequest.getExecHash()).map(String::getBytes).orElse(new byte[0]);
final PrivacyMode privacyMode = PrivacyMode.fromFlag(sendRequest.getPrivacyFlag());
final com.quorum.tessera.transaction.SendRequest.Builder requestBuilder =
com.quorum.tessera.transaction.SendRequest.Builder.create()
.withRecipients(recipientList)
.withSender(sender)
.withPayload(sendRequest.getPayload())
.withExecHash(execHash)
.withPrivacyMode(privacyMode)
.withAffectedContractTransactions(affectedTransactions);
optionalPrivacyGroup.ifPresent(requestBuilder::withPrivacyGroupId);
final com.quorum.tessera.transaction.SendResponse response =
transactionManager.send(requestBuilder.build());
final String encodedKey =
Optional.of(response)
.map(com.quorum.tessera.transaction.SendResponse::getTransactionHash)
.map(MessageHash::getHashBytes)
.map(base64Encoder::encodeToString)
.get();
final SendResponse sendResponse =
Optional.of(response)
.map(com.quorum.tessera.transaction.SendResponse::getTransactionHash)
.map(MessageHash::getHashBytes)
.map(base64Encoder::encodeToString)
.map(messageHash -> new SendResponse(messageHash, null, null))
.get();
final URI location =
UriBuilder.fromPath("transaction")
.path(URLEncoder.encode(encodedKey, StandardCharsets.UTF_8))
.build();
return Response.status(Response.Status.CREATED)
.type(APPLICATION_JSON)
.location(location)
.entity(sendResponse)
.build();
}
// hide this operation from swagger generation; the /sendsignedtx operation is overloaded and must
// be documented in a single place
@Hidden
@POST
@Path("sendsignedtx")
@Consumes(APPLICATION_OCTET_STREAM)
@Produces(TEXT_PLAIN)
public Response sendSignedTransactionStandard(
@Parameter(
description =
"comma-separated list of recipient public keys (for application/octet-stream requests)",
schema = @Schema(format = "base64"))
@HeaderParam("c11n-to")
final String recipientKeys,
@Valid @NotNull @Size(min = 1) final byte[] signedTransaction) {
final List recipients =
Stream.ofNullable(recipientKeys)
.filter(s -> !Objects.equals("", s))
.map(v -> v.split(","))
.flatMap(Arrays::stream)
.map(base64Decoder::decode)
.map(PublicKey::from)
.collect(Collectors.toList());
final com.quorum.tessera.transaction.SendSignedRequest request =
com.quorum.tessera.transaction.SendSignedRequest.Builder.create()
.withRecipients(recipients)
.withSignedData(signedTransaction)
.withPrivacyMode(PrivacyMode.STANDARD_PRIVATE)
.withAffectedContractTransactions(Collections.emptySet())
.withExecHash(new byte[0])
.build();
final com.quorum.tessera.transaction.SendResponse response =
transactionManager.sendSignedTransaction(request);
final String encodedTransactionHash =
base64Encoder.encodeToString(response.getTransactionHash().getHashBytes());
LOGGER.debug("Encoded key: {}", encodedTransactionHash);
URI location =
UriBuilder.fromPath("transaction")
.path(URLEncoder.encode(encodedTransactionHash, StandardCharsets.UTF_8))
.build();
return Response.status(Response.Status.OK)
.entity(encodedTransactionHash)
.location(location)
.build();
}
// hide this operation from swagger generation; the /sendsignedtx operation is overloaded and must
// be documented in a single place
@Hidden
@POST
@Path("sendsignedtx")
@Consumes(APPLICATION_JSON)
@Produces(APPLICATION_JSON)
public Response sendSignedTransactionEnhanced(
@NotNull @Valid @PrivacyValid final SendSignedRequest sendSignedRequest) {
final Optional privacyGroupId =
Optional.ofNullable(sendSignedRequest.getPrivacyGroupId())
.map(PrivacyGroup.Id::fromBase64String);
final List recipients =
privacyGroupId
.map(privacyGroupManager::retrievePrivacyGroup)
.map(PrivacyGroup::getMembers)
.orElse(
Optional.ofNullable(sendSignedRequest.getTo())
.map(Arrays::stream)
.orElse(Stream.empty())
.map(base64Decoder::decode)
.map(PublicKey::from)
.collect(Collectors.toList()));
final PrivacyMode privacyMode = PrivacyMode.fromFlag(sendSignedRequest.getPrivacyFlag());
final Set affectedTransactions =
Stream.ofNullable(sendSignedRequest.getAffectedContractTransactions())
.flatMap(Arrays::stream)
.map(base64Decoder::decode)
.map(MessageHash::new)
.collect(Collectors.toSet());
final byte[] execHash =
Optional.ofNullable(sendSignedRequest.getExecHash())
.map(String::getBytes)
.orElse(new byte[0]);
final com.quorum.tessera.transaction.SendSignedRequest.Builder requestBuilder =
com.quorum.tessera.transaction.SendSignedRequest.Builder.create()
.withSignedData(sendSignedRequest.getHash())
.withRecipients(recipients)
.withPrivacyMode(privacyMode)
.withAffectedContractTransactions(affectedTransactions)
.withExecHash(execHash);
privacyGroupId.ifPresent(requestBuilder::withPrivacyGroupId);
final com.quorum.tessera.transaction.SendResponse response =
transactionManager.sendSignedTransaction(requestBuilder.build());
final String endcodedTransactionHash =
Optional.of(response)
.map(com.quorum.tessera.transaction.SendResponse::getTransactionHash)
.map(MessageHash::getHashBytes)
.map(base64Encoder::encodeToString)
.get();
LOGGER.debug("Encoded key: {}", endcodedTransactionHash);
URI location =
UriBuilder.fromPath("transaction")
.path(URLEncoder.encode(endcodedTransactionHash, StandardCharsets.UTF_8))
.build();
SendResponse sendResponse = new SendResponse();
sendResponse.setKey(endcodedTransactionHash);
return Response.status(Response.Status.CREATED)
.type(APPLICATION_JSON)
.location(location)
.entity(sendResponse)
.build();
}
@Operation(
summary = "/sendraw",
operationId = "encryptStoreAndSendOctetStream",
description =
"encrypts a payload, stores result in database, and publishes result to recipients")
@ApiResponse(
responseCode = "200",
description = "encrypted payload hash",
content =
@Content(
schema =
@Schema(
type = "string",
format = "base64",
description = "encrypted payload hash")))
@POST
@Path("sendraw")
@Consumes(APPLICATION_OCTET_STREAM)
@Produces(TEXT_PLAIN)
public Response sendRaw(
@HeaderParam("c11n-from")
@Parameter(
description =
"public key identifying the server's key pair that will be used in the encryption; if not set, default used",
schema = @Schema(format = "base64"))
@Valid
@ValidBase64
final String sender,
@HeaderParam("c11n-to")
@Parameter(
description = "comma-separated list of recipient public keys",
schema = @Schema(format = "base64"))
final String recipientKeys,
@Schema(description = "data to be encrypted") @NotNull @Size(min = 1) @Valid
final byte[] payload) {
final PublicKey senderKey =
Optional.ofNullable(sender)
.filter(Predicate.not(String::isEmpty))
.map(base64Decoder::decode)
.map(PublicKey::from)
.orElseGet(transactionManager::defaultPublicKey);
final List recipients =
Stream.of(recipientKeys)
.filter(Objects::nonNull)
.filter(s -> !Objects.equals("", s))
.map(v -> v.split(","))
.flatMap(Arrays::stream)
.map(base64Decoder::decode)
.map(PublicKey::from)
.collect(Collectors.toList());
final com.quorum.tessera.transaction.SendRequest request =
com.quorum.tessera.transaction.SendRequest.Builder.create()
.withSender(senderKey)
.withRecipients(recipients)
.withPayload(payload)
.withPrivacyMode(PrivacyMode.STANDARD_PRIVATE)
.withAffectedContractTransactions(Collections.emptySet())
.withExecHash(new byte[0])
.build();
final com.quorum.tessera.transaction.SendResponse sendResponse =
transactionManager.send(request);
final String encodedTransactionHash =
Optional.of(sendResponse)
.map(com.quorum.tessera.transaction.SendResponse::getTransactionHash)
.map(MessageHash::getHashBytes)
.map(base64Encoder::encodeToString)
.get();
LOGGER.debug("Encoded key: {}", encodedTransactionHash);
URI location =
UriBuilder.fromPath("transaction")
.path(URLEncoder.encode(encodedTransactionHash, StandardCharsets.UTF_8))
.build();
return Response.status(Response.Status.OK)
.entity(encodedTransactionHash)
.location(location)
.build();
}
// hide this operation from swagger generation; the /transaction/{hash} operation is overloaded
// and must be documented in a single place
@Hidden
@GET
@Path("/transaction/{hash}")
@Produces(APPLICATION_JSON)
public Response receive(
@Parameter(
description = "hash indicating encrypted payload to retrieve from database",
schema = @Schema(format = "base64"))
@Valid
@ValidBase64
@PathParam("hash")
final String hash,
@Parameter(
description =
"(optional) public key of recipient of the encrypted payload; used in decryption; if not provided, decryption is attempted with all known recipient keys in turn",
schema = @Schema(format = "base64"))
@QueryParam("to")
final String toStr,
@Parameter(
description =
"(optional) indicates whether the payload is raw; determines which database the payload is retrieved from; possible values\n* true - for pre-stored payloads in the \"raw\" database\n* false (default) - for already sent payloads in \"standard\" database")
@Valid
@Pattern(flags = Pattern.Flag.CASE_INSENSITIVE, regexp = "^(true|false)$")
@QueryParam("isRaw")
final String isRaw) {
final PublicKey recipient =
Optional.ofNullable(toStr)
.filter(Predicate.not(String::isEmpty))
.map(base64Decoder::decode)
.map(PublicKey::from)
.orElse(null);
final MessageHash transactionHash =
Optional.of(hash).map(base64Decoder::decode).map(MessageHash::new).get();
final com.quorum.tessera.transaction.ReceiveRequest request =
com.quorum.tessera.transaction.ReceiveRequest.Builder.create()
.withRecipient(recipient)
.withTransactionHash(transactionHash)
.withRaw(Boolean.valueOf(isRaw))
.build();
com.quorum.tessera.transaction.ReceiveResponse response = transactionManager.receive(request);
final ReceiveResponse receiveResponse = new ReceiveResponse();
receiveResponse.setPayload(response.getUnencryptedTransactionData());
receiveResponse.setAffectedContractTransactions(
response.getAffectedTransactions().stream()
.map(MessageHash::getHashBytes)
.map(base64Encoder::encodeToString)
.toArray(String[]::new));
Optional.ofNullable(response.getExecHash())
.map(String::new)
.ifPresent(receiveResponse::setExecHash);
receiveResponse.setPrivacyFlag(response.getPrivacyMode().getPrivacyFlag());
response
.getPrivacyGroupId()
.map(PrivacyGroup.Id::getBase64)
.ifPresent(receiveResponse::setPrivacyGroupId);
return Response.status(Response.Status.OK)
.type(APPLICATION_JSON)
.entity(receiveResponse)
.build();
}
@Operation(
summary = "/receiveraw",
operationId = "getDecryptedPayloadOctetStream",
description = "get payload from database, decrypt, and return")
@ApiResponse(
responseCode = "200",
description = "decrypted ciphertext payload",
content =
@Content(
array =
@ArraySchema(
schema =
@Schema(
type = "string",
format = "byte",
description = "decrypted ciphertext payload"))))
@GET
@Path("receiveraw")
@Consumes(APPLICATION_OCTET_STREAM)
@Produces(APPLICATION_OCTET_STREAM)
public Response receiveRaw(
@Schema(
description = "hash indicating encrypted payload to retrieve from database",
format = "base64")
@ValidBase64
@NotNull
@HeaderParam(value = "c11n-key")
String hash,
@Schema(
description =
"(optional) public key of recipient of the encrypted payload; used in decryption; if not provided, decryption is attempted with all known recipient keys in turn",
format = "base64")
@ValidBase64
@HeaderParam(value = "c11n-to")
String recipientKey) {
LOGGER.debug("Received receiveraw request for hash : {}, recipientKey: {}", hash, recipientKey);
MessageHash transactionHash =
Optional.of(hash).map(base64Decoder::decode).map(MessageHash::new).get();
PublicKey recipient =
Optional.ofNullable(recipientKey)
.map(base64Decoder::decode)
.map(PublicKey::from)
.orElse(null);
com.quorum.tessera.transaction.ReceiveRequest request =
com.quorum.tessera.transaction.ReceiveRequest.Builder.create()
.withTransactionHash(transactionHash)
.withRecipient(recipient)
.build();
com.quorum.tessera.transaction.ReceiveResponse receiveResponse =
transactionManager.receive(request);
byte[] payload = receiveResponse.getUnencryptedTransactionData();
return Response.status(Response.Status.OK).entity(payload).build();
}
@Deprecated
@Operation(
summary = "/delete",
operationId = "deleteDeprecated",
description = "delete payload from database")
@ApiResponse(
responseCode = "200",
description = "delete successful",
content =
@Content(
schema = @Schema(type = "string"),
examples = @ExampleObject(value = "Delete successful")))
@POST
@Path("delete")
@Consumes(APPLICATION_JSON)
@Produces(TEXT_PLAIN)
public Response delete(@Valid final DeleteRequest deleteRequest) {
LOGGER.debug("Received deprecated delete request");
MessageHash messageHash =
Optional.of(deleteRequest)
.map(DeleteRequest::getKey)
.map(base64Decoder::decode)
.map(MessageHash::new)
.get();
transactionManager.delete(messageHash);
return Response.status(Response.Status.OK).entity("Delete successful").build();
}
}