From c4048c20b63109441b02747329f5dd067c3355cf Mon Sep 17 00:00:00 2001 From: djmil Date: Wed, 30 Aug 2023 20:43:31 +0200 Subject: [PATCH] Corda: GameProposal --- corda/build.gradle | 2 +- corda/contracts/build.gradle | 4 +- .../contracts/GameProposalContract.java | 71 ++++++++++ .../states/GameProposalState.java | 78 +++++++++++ corda/workflows/build.gradle | 4 +- .../gameproposal/CreateFlow.java | 125 ++++++++++++++++++ .../gameproposal/CreateFlowArgs.java | 18 +++ .../gameproposal/CreateResponder.java | 72 ++++++++++ .../cordacheckers/gameproposal/ListFlow.java | 41 ++++++ .../cordacheckers/gameproposal/ListItem.java | 35 +++++ .../gameproposal/RejectFlow.java | 5 + 11 files changed, 450 insertions(+), 5 deletions(-) create mode 100644 corda/contracts/src/main/java/djmil/cordacheckers/contracts/GameProposalContract.java create mode 100644 corda/contracts/src/main/java/djmil/cordacheckers/states/GameProposalState.java create mode 100644 corda/workflows/src/main/java/djmil/cordacheckers/gameproposal/CreateFlow.java create mode 100644 corda/workflows/src/main/java/djmil/cordacheckers/gameproposal/CreateFlowArgs.java create mode 100644 corda/workflows/src/main/java/djmil/cordacheckers/gameproposal/CreateResponder.java create mode 100644 corda/workflows/src/main/java/djmil/cordacheckers/gameproposal/ListFlow.java create mode 100644 corda/workflows/src/main/java/djmil/cordacheckers/gameproposal/ListItem.java create mode 100644 corda/workflows/src/main/java/djmil/cordacheckers/gameproposal/RejectFlow.java diff --git a/corda/build.gradle b/corda/build.gradle index e852ddb..c4e9019 100644 --- a/corda/build.gradle +++ b/corda/build.gradle @@ -10,7 +10,7 @@ plugins { } allprojects { - group 'com.r3.developers.csdetemplate' + group 'djmil.cordacheckers' version '1.0-SNAPSHOT' def javaVersion = VERSION_11 diff --git a/corda/contracts/build.gradle b/corda/contracts/build.gradle index da26473..3ae9495 100644 --- a/corda/contracts/build.gradle +++ b/corda/contracts/build.gradle @@ -67,9 +67,9 @@ cordapp { // The cordapp section contains either a workflow or contract subsection depending on the type of component. // Declares the type and metadata of the CPK (this CPB has one CPK). contract { - name "ContractsModuleNameHere" + name "CordaCkeckersContracts" versionId 1 - vendor "VendorNameHere" + vendor "djmil" } } diff --git a/corda/contracts/src/main/java/djmil/cordacheckers/contracts/GameProposalContract.java b/corda/contracts/src/main/java/djmil/cordacheckers/contracts/GameProposalContract.java new file mode 100644 index 0000000..11e35b1 --- /dev/null +++ b/corda/contracts/src/main/java/djmil/cordacheckers/contracts/GameProposalContract.java @@ -0,0 +1,71 @@ +package djmil.cordacheckers.contracts; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import djmil.cordacheckers.states.GameProposalState; +import net.corda.v5.base.exceptions.CordaRuntimeException; +import net.corda.v5.ledger.utxo.Command; +import net.corda.v5.ledger.utxo.transaction.UtxoLedgerTransaction; + +public class GameProposalContract implements net.corda.v5.ledger.utxo.Contract { + + private final static Logger log = LoggerFactory.getLogger(GameProposalContract.class); + + public static class Create implements Command { } + public static class Accept implements Command { } + public static class Reject implements Command { } + public static class Cancle implements Command { } + + @Override + public void verify(UtxoLedgerTransaction trx) { + log.info("GameProposalContract.verify() called"); + + requireThat(trx.getCommands().size() == 1, REQUIRE_SINGLE_COMMAND); + Command command = trx.getCommands().get(0); + + if (command instanceof Create) { + requireThat(trx.getInputContractStates().isEmpty(), CREATE_INPUT_STATE); + requireThat(trx.getOutputContractStates().size() == 1, CREATE_OUTPUT_STATE); + + GameProposalState outputState = trx.getOutputStates(GameProposalState.class).get(0); + requireThat(outputState != null, CREATE_OUTPUT_STATE); + + requireThat(outputState.getYouPlayAs() != null, CREATE_NOT_NULL_YOU_PLAY_AS); + } else + if (command instanceof Accept) { + // TODO outputState -> Game + throw new CordaRuntimeException("Unimplemented!"); + } else + if (command instanceof Reject) { + requireThat(trx.getInputContractStates().size() == 1, REJECT_INPUT_STATE); + requireThat(trx.getOutputContractStates().isEmpty(), REJECT_OUTPUT_STATE); + + GameProposalState inputState = trx.getInputStates(GameProposalState.class).get(0); + requireThat(inputState != null, REJECT_INPUT_STATE); + } else + if (command instanceof Cancle) { + // TODO cancle game state + throw new CordaRuntimeException("Unimplemented!"); + } else { + throw new CordaRuntimeException(UNKNOWN_COMMAND); + } + } + + private void requireThat(boolean asserted, String errorMessage) { + if(!asserted) { + throw new CordaRuntimeException("Failed requirement: " + errorMessage); + } + } + + static final String REQUIRE_SINGLE_COMMAND = "Require a single command"; + static final String UNKNOWN_COMMAND = "Unsupported command"; + + static final String CREATE_INPUT_STATE = "Create command should have no input states"; + static final String CREATE_OUTPUT_STATE = "Create command should output exactly one GameProposal state"; + static final String CREATE_NOT_NULL_YOU_PLAY_AS = "GameProposal.youPlayAs field can not be null"; + + static final String REJECT_INPUT_STATE = "Reject command should have exactly one GameProposal state"; + static final String REJECT_OUTPUT_STATE = "Reject command should have no output states"; + +} diff --git a/corda/contracts/src/main/java/djmil/cordacheckers/states/GameProposalState.java b/corda/contracts/src/main/java/djmil/cordacheckers/states/GameProposalState.java new file mode 100644 index 0000000..cf935b5 --- /dev/null +++ b/corda/contracts/src/main/java/djmil/cordacheckers/states/GameProposalState.java @@ -0,0 +1,78 @@ +package djmil.cordacheckers.states; + +import java.security.PublicKey; +import java.util.List; +import java.util.UUID; + +import djmil.cordacheckers.contracts.GameProposalContract; +import net.corda.v5.base.annotations.ConstructorForDeserialization; +import net.corda.v5.base.annotations.CordaSerializable; +import net.corda.v5.base.types.MemberX500Name; +import net.corda.v5.ledger.utxo.BelongsToContract; +import net.corda.v5.ledger.utxo.ContractState; + +@BelongsToContract(GameProposalContract.class) +public class GameProposalState implements ContractState { + + @CordaSerializable + public enum Color { + WHITE, + BLACK, + } + + public final MemberX500Name sender; + public final MemberX500Name recipient; + public final Color youPlayAs; + public final String additionalMessage; + public final UUID id; + public final List participants; + + // Allows serialisation and to use a specified UUID + @ConstructorForDeserialization + public GameProposalState( + MemberX500Name sender, + MemberX500Name recipient, + Color youPlayAs, + String additionalMessage, + UUID id, + List participants) { + this.sender = sender; + this.recipient = recipient; + this.youPlayAs = youPlayAs; + this.additionalMessage = additionalMessage; + this.id = id; + this.participants = participants; + } + + public MemberX500Name getSender() { + return sender; + } + + public MemberX500Name getRecipient() { + return recipient; + } + + public Color getYouPlayAs() { + return youPlayAs; + } + + public String getAdditionalMessage() { + return additionalMessage; + } + + public UUID getId() { + return id; + } + + public List getParticipants() { + return participants; + } + + public String getRecipientCommonName() { + return recipient == null ? "" : recipient.getCommonName(); + } + + public String getSenderCommonName() { + return sender == null ? "" : sender.getCommonName(); + } +} diff --git a/corda/workflows/build.gradle b/corda/workflows/build.gradle index ddc1790..aad49d5 100644 --- a/corda/workflows/build.gradle +++ b/corda/workflows/build.gradle @@ -69,9 +69,9 @@ cordapp { // The cordapp section contains either a workflow or contract subsection depending on the type of component. // Declares the type and metadata of the CPK (this CPB has one CPK). workflow { - name "WorkflowsModuleNameHere" + name "CordaCkeckers" versionId 1 - vendor "VendorNameHere" + vendor "djmil" } } diff --git a/corda/workflows/src/main/java/djmil/cordacheckers/gameproposal/CreateFlow.java b/corda/workflows/src/main/java/djmil/cordacheckers/gameproposal/CreateFlow.java new file mode 100644 index 0000000..2678a35 --- /dev/null +++ b/corda/workflows/src/main/java/djmil/cordacheckers/gameproposal/CreateFlow.java @@ -0,0 +1,125 @@ +package djmil.cordacheckers.gameproposal; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import djmil.cordacheckers.contracts.GameProposalContract; +import djmil.cordacheckers.states.GameProposalState; +import net.corda.v5.application.flows.ClientRequestBody; +import net.corda.v5.application.flows.ClientStartableFlow; +import net.corda.v5.application.flows.CordaInject; +import net.corda.v5.application.flows.FlowEngine; +import net.corda.v5.application.flows.InitiatingFlow; +import net.corda.v5.application.marshalling.JsonMarshallingService; +import net.corda.v5.application.membership.MemberLookup; +import net.corda.v5.application.messaging.FlowMessaging; +import net.corda.v5.application.messaging.FlowSession; +import net.corda.v5.base.annotations.Suspendable; +import net.corda.v5.base.exceptions.CordaRuntimeException; +import net.corda.v5.base.types.MemberX500Name; +import net.corda.v5.ledger.common.NotaryLookup; +import net.corda.v5.ledger.utxo.UtxoLedgerService; +import net.corda.v5.ledger.utxo.transaction.UtxoSignedTransaction; +import net.corda.v5.ledger.utxo.transaction.UtxoTransactionBuilder; +import net.corda.v5.membership.MemberInfo; +import net.corda.v5.membership.NotaryInfo; + +import static java.util.Objects.requireNonNull; + +import java.time.Duration; +import java.time.Instant; +import java.util.Arrays; +import java.util.List; +import java.util.UUID; + +@InitiatingFlow(protocol = "game-proposal-create") +public class CreateFlow implements ClientStartableFlow{ + + private final static Logger log = LoggerFactory.getLogger(CreateFlow.class); + + @CordaInject + public JsonMarshallingService jsonMarshallingService; + + @CordaInject + public MemberLookup memberLookup; + + @CordaInject + public NotaryLookup notaryLookup; + + @CordaInject + public UtxoLedgerService ledgerService; + + @CordaInject + public FlowEngine flowEngine; + + @CordaInject + public FlowMessaging flowMessaging; + + @Suspendable + @Override + public String call(ClientRequestBody requestBody) { + try { + log.info("flow: Create Game Proposal"); + + GameProposalState gameProposal = buildGameProposalStateFrom(requestBody); + String result = doTrunsaction(gameProposal); + + return result; + } + catch (Exception e) { + log.warn("CreateGameProposal flow failed to process utxo request body " + requestBody + " because: " + e.getMessage()); + throw new CordaRuntimeException(e.getMessage()); + } + } + + @Suspendable + private GameProposalState buildGameProposalStateFrom(ClientRequestBody requestBody) { + CreateFlowArgs args = requestBody.getRequestBodyAs(jsonMarshallingService, CreateFlowArgs.class); + + GameProposalState.Color opponentColor = GameProposalState.Color.valueOf(args.opponentColor); + if (opponentColor == null) { + throw new RuntimeException("Allowed values for opponentColor are: " + + GameProposalState.Color.WHITE.name() +", " + GameProposalState.Color.BLACK.name() + + ". Actual value was: " + args.opponentColor); + } + + MemberInfo myInfo = memberLookup.myInfo(); + MemberInfo opponentInfo = requireNonNull( + memberLookup.lookup(MemberX500Name.parse(args.opponentName)), + "MemberLookup can't find opponentName specified in flow arguments: " + args.opponentName + ); + + return new GameProposalState( + myInfo.getName(), + opponentInfo.getName(), + GameProposalState.Color.valueOf(args.opponentColor), + args.additionalMessage, + UUID.randomUUID(), + Arrays.asList(myInfo.getLedgerKeys().get(0), opponentInfo.getLedgerKeys().get(0)) + ); + } + + @Suspendable + private String doTrunsaction(GameProposalState gameProposal) { + NotaryInfo notary = notaryLookup.getNotaryServices().iterator().next(); + + UtxoTransactionBuilder txBuilder = ledgerService.createTransactionBuilder() + .setNotary(notary.getName()) + .setTimeWindowBetween(Instant.now(), Instant.now().plusMillis(Duration.ofDays(1).toMillis())) + .addOutputState(gameProposal) + .addCommand(new GameProposalContract.Create()) + .addSignatories(gameProposal.getParticipants()); + + UtxoSignedTransaction signedTransaction = txBuilder.toSignedTransaction(); + + FlowSession session = flowMessaging.initiateFlow(gameProposal.getRecipient()); + + List sessionsList = Arrays.asList(session); + + UtxoSignedTransaction finalizedSignedTransaction = ledgerService + .finalize(signedTransaction, sessionsList) + .getTransaction(); + + return finalizedSignedTransaction.getId().toString(); + } +} diff --git a/corda/workflows/src/main/java/djmil/cordacheckers/gameproposal/CreateFlowArgs.java b/corda/workflows/src/main/java/djmil/cordacheckers/gameproposal/CreateFlowArgs.java new file mode 100644 index 0000000..9c7928c --- /dev/null +++ b/corda/workflows/src/main/java/djmil/cordacheckers/gameproposal/CreateFlowArgs.java @@ -0,0 +1,18 @@ +package djmil.cordacheckers.gameproposal; + +public class CreateFlowArgs { + public final String opponentName; + public final String opponentColor; + public final String additionalMessage; + + public CreateFlowArgs(String opponentName, String opponentColor, String additionalMessage) { + this.opponentName = opponentName; + this.opponentColor = opponentColor; + this.additionalMessage = additionalMessage; + } + + // Serialisation service requires a default constructor + public CreateFlowArgs() { + this(null, null, null); + } +} diff --git a/corda/workflows/src/main/java/djmil/cordacheckers/gameproposal/CreateResponder.java b/corda/workflows/src/main/java/djmil/cordacheckers/gameproposal/CreateResponder.java new file mode 100644 index 0000000..157dda7 --- /dev/null +++ b/corda/workflows/src/main/java/djmil/cordacheckers/gameproposal/CreateResponder.java @@ -0,0 +1,72 @@ +package djmil.cordacheckers.gameproposal; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import djmil.cordacheckers.states.GameProposalState; +import net.corda.v5.application.flows.CordaInject; +import net.corda.v5.application.flows.InitiatedBy; +import net.corda.v5.application.flows.ResponderFlow; +import net.corda.v5.application.membership.MemberLookup; +import net.corda.v5.application.messaging.FlowSession; +import net.corda.v5.base.annotations.Suspendable; +import net.corda.v5.base.exceptions.CordaRuntimeException; +import net.corda.v5.base.types.MemberX500Name; +import net.corda.v5.ledger.utxo.UtxoLedgerService; +import net.corda.v5.ledger.utxo.transaction.UtxoSignedTransaction; +import net.corda.v5.ledger.utxo.transaction.UtxoTransactionValidator; + +@InitiatedBy(protocol = "game-proposal-create") +public class CreateResponder implements ResponderFlow { + private final static Logger log = LoggerFactory.getLogger(CreateResponder.class); + + @CordaInject + public MemberLookup memberLookup; + + @CordaInject + public UtxoLedgerService utxoLedgerService; + + @Suspendable + @Override + public void call(FlowSession session) { + try { + // Defines the lambda validator used in receiveFinality below. + UtxoTransactionValidator txValidator = ledgerTransaction -> { + GameProposalState gameProposal = (GameProposalState) ledgerTransaction.getOutputContractStates().get(0); + // Uses checkForBannedWords() and checkMessageFromMatchesCounterparty() functions + // to check whether to sign the transaction. + if (!checkParticipants(gameProposal, session.getCounterparty())) { + throw new CordaRuntimeException("Failed verification"); + } + + log.info("Verified the transaction - " + ledgerTransaction.getId()); + }; + + // Calls receiveFinality() function which provides the responder to the finalise() function + // in the Initiating Flow. Accepts a lambda validator containing the business logic to decide whether + // responder should sign the Transaction. + UtxoSignedTransaction finalizedSignedTransaction = utxoLedgerService + .receiveFinality(session, txValidator) + .getTransaction(); + + log.info("Finished responder flow - " + finalizedSignedTransaction.getId()); + } + // Soft fails the flow and log the exception. + catch(Exception e) + { + log.warn("Exceptionally finished responder flow", e); + } + } + + @Suspendable + Boolean checkParticipants(GameProposalState gameProposal, MemberX500Name counterpartyName) { + MemberX500Name myName = memberLookup.myInfo().getName(); + + if (gameProposal.getRecipient().compareTo(myName) == 0 && + gameProposal.getSender().compareTo(counterpartyName) == 0) + return true; + + return false; + } + +} diff --git a/corda/workflows/src/main/java/djmil/cordacheckers/gameproposal/ListFlow.java b/corda/workflows/src/main/java/djmil/cordacheckers/gameproposal/ListFlow.java new file mode 100644 index 0000000..21741aa --- /dev/null +++ b/corda/workflows/src/main/java/djmil/cordacheckers/gameproposal/ListFlow.java @@ -0,0 +1,41 @@ +package djmil.cordacheckers.gameproposal; + +import java.util.stream.Collectors; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import djmil.cordacheckers.states.GameProposalState; +import net.corda.v5.application.flows.ClientRequestBody; +import net.corda.v5.application.flows.ClientStartableFlow; +import net.corda.v5.application.flows.CordaInject; +import net.corda.v5.application.marshalling.JsonMarshallingService; +import net.corda.v5.ledger.utxo.StateAndRef; +import net.corda.v5.ledger.utxo.UtxoLedgerService; + +public class ListFlow implements ClientStartableFlow { + + private final static Logger log = LoggerFactory.getLogger(ListFlow.class); + + @CordaInject + public UtxoLedgerService utxoLedgerService; + + @CordaInject + public JsonMarshallingService jsonMarshallingService; + + @Override + public String call(ClientRequestBody requestBody) { + log.info("ListChatsFlow.call() called"); + + // Queries the VNode's vault for unconsumed states and converts the result to a serializable DTO. + java.util.List> states = utxoLedgerService.findUnconsumedStatesByType(GameProposalState.class); + + java.util.List results = states.stream().map( stateAndRef -> + new ListItem(stateAndRef.getState().getContractState()) + ).collect(Collectors.toList()); + + // Uses the JsonMarshallingService's format() function to serialize the DTO to Json. + return jsonMarshallingService.format(results); + } + +} diff --git a/corda/workflows/src/main/java/djmil/cordacheckers/gameproposal/ListItem.java b/corda/workflows/src/main/java/djmil/cordacheckers/gameproposal/ListItem.java new file mode 100644 index 0000000..f1c1c79 --- /dev/null +++ b/corda/workflows/src/main/java/djmil/cordacheckers/gameproposal/ListItem.java @@ -0,0 +1,35 @@ +package djmil.cordacheckers.gameproposal; + +import java.util.UUID; + +import djmil.cordacheckers.states.GameProposalState; + +// Class to hold results of the List flow. +// The GameProposal(s) cannot be returned directly as the JsonMarshallingService can only serialize simple classes +// that the underlying Jackson serializer recognises, hence creating a DTO style object which consists only of Strings +// and a UUID. It is possible to create custom serializers for the JsonMarshallingService in the future. + +public class ListItem { + public final String sender; + public final String recipient; + public final String youPlayAs; + public final String additionalMessage; + public final UUID id; + + // Serialisation service requires a default constructor + public ListItem() { + this.sender = null; + this.recipient = null; + this.youPlayAs = null; + this.additionalMessage = null; + this.id = null; + } + + public ListItem(GameProposalState state) { + this.sender = state.getSenderCommonName(); + this.recipient = state.getRecipientCommonName(); + this.youPlayAs = state.getYouPlayAs().name(); + this.additionalMessage = state.getAdditionalMessage(); + this.id = state.getId(); + } +} diff --git a/corda/workflows/src/main/java/djmil/cordacheckers/gameproposal/RejectFlow.java b/corda/workflows/src/main/java/djmil/cordacheckers/gameproposal/RejectFlow.java new file mode 100644 index 0000000..908b8dd --- /dev/null +++ b/corda/workflows/src/main/java/djmil/cordacheckers/gameproposal/RejectFlow.java @@ -0,0 +1,5 @@ +package djmil.cordacheckers.gameproposal; + +public class RejectFlow { + +}