diff --git a/backend/src/main/java/djmil/cordacheckers/cordaclient/CordaClient.java b/backend/src/main/java/djmil/cordacheckers/cordaclient/CordaClient.java index 016df80..176c6f2 100644 --- a/backend/src/main/java/djmil/cordacheckers/cordaclient/CordaClient.java +++ b/backend/src/main/java/djmil/cordacheckers/cordaclient/CordaClient.java @@ -24,6 +24,7 @@ import djmil.cordacheckers.cordaclient.dao.flow.RequestBody; import djmil.cordacheckers.cordaclient.dao.flow.ResponseBody; import djmil.cordacheckers.cordaclient.dao.flow.arguments.CreateGameProposal; import djmil.cordacheckers.cordaclient.dao.flow.arguments.Empty; +import djmil.cordacheckers.cordaclient.dao.flow.arguments.GameProposalAction; @Service public class CordaClient { @@ -100,6 +101,29 @@ public class CordaClient { return createdGameProposalUuid; } + public String rejectGameProposal( + HoldingIdentity self, + String gameProposalUuid + ) { + final GameProposalAction rejectGameProposal = new GameProposalAction( + gameProposalUuid, + GameProposalAction.Action.REJECT + ); + + final RequestBody requestBody = new RequestBody( + "reject-" + UUID.randomUUID(), + "djmil.cordacheckers.gameproposal.ActionFlow", + rejectGameProposal + ); + + final String createdGameProposalUuid = cordaFlowExecute( + self, + requestBody + ); + + return createdGameProposalUuid; + } + private String cordaFlowExecute(HoldingIdentity holdingIdentity, RequestBody requestBody) { try { diff --git a/backend/src/main/java/djmil/cordacheckers/cordaclient/dao/flow/arguments/GameProposalAction.java b/backend/src/main/java/djmil/cordacheckers/cordaclient/dao/flow/arguments/GameProposalAction.java new file mode 100644 index 0000000..c1f3650 --- /dev/null +++ b/backend/src/main/java/djmil/cordacheckers/cordaclient/dao/flow/arguments/GameProposalAction.java @@ -0,0 +1,9 @@ +package djmil.cordacheckers.cordaclient.dao.flow.arguments; + +public record GameProposalAction(String gameProposalUuid, Action action) { + public enum Action { + ACCEPT, + REJECT, + CANCEL + } +} diff --git a/backend/src/test/java/djmil/cordacheckers/GameProposalControllerTests.java b/backend/src/test/java/djmil/cordacheckers/GameProposalControllerTests.java new file mode 100644 index 0000000..589eb49 --- /dev/null +++ b/backend/src/test/java/djmil/cordacheckers/GameProposalControllerTests.java @@ -0,0 +1,10 @@ +package djmil.cordacheckers; + +import org.junit.jupiter.api.Test; + +public class GameProposalControllerTests { + @Test + void testFindAllUnconsumed() { + + } +} diff --git a/backend/src/test/java/djmil/cordacheckers/cordaclient/CordaClientTest.java b/backend/src/test/java/djmil/cordacheckers/cordaclient/CordaClientTest.java index 7431ea0..4548e9f 100644 --- a/backend/src/test/java/djmil/cordacheckers/cordaclient/CordaClientTest.java +++ b/backend/src/test/java/djmil/cordacheckers/cordaclient/CordaClientTest.java @@ -63,4 +63,37 @@ public class CordaClientTest { assertThat(listResSender).contains(gpUuid); assertThat(listResReceiver).contains(gpUuid); } + + @Test + void testGemeProposalReject() { + final String gpSender = "alice"; + final String gpReceiver = "bob"; + final Color gpReceiverColor = Color.WHITE; + final String gpMessage = "GameProposal create test"; + + final String gpUuid = cordaClient.createGameProposal( + holdingIdentityResolver.getByUsername(gpSender), + holdingIdentityResolver.getByUsername(gpReceiver), + gpReceiverColor, + gpMessage + ); + + System.out.println("Create GP UUID "+ gpUuid); + + String listResSender = cordaClient.listGameProposals( + holdingIdentityResolver.getByUsername(gpSender)); + + String listResReceiver = cordaClient.listGameProposals( + holdingIdentityResolver.getByUsername(gpReceiver)); + + assertThat(listResSender).contains(gpUuid); + assertThat(listResReceiver).contains(gpUuid); + + final String rejectRes = cordaClient.rejectGameProposal( + holdingIdentityResolver.getByUsername(gpReceiver), + "1ed70601-c79a-486d-b907-8537f317083a" + ); + + assertThat(rejectRes).isEqualToIgnoringCase("REJECTED"); + } } diff --git a/corda/contracts/src/main/java/djmil/cordacheckers/contracts/GameProposalContract.java b/corda/contracts/src/main/java/djmil/cordacheckers/contracts/GameProposalContract.java index 0ecd7b1..c94b456 100644 --- a/corda/contracts/src/main/java/djmil/cordacheckers/contracts/GameProposalContract.java +++ b/corda/contracts/src/main/java/djmil/cordacheckers/contracts/GameProposalContract.java @@ -3,6 +3,7 @@ package djmil.cordacheckers.contracts; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import djmil.cordacheckers.states.GameProposalResolutionState; import djmil.cordacheckers.states.GameProposalState; import net.corda.v5.base.exceptions.CordaRuntimeException; import net.corda.v5.ledger.utxo.Command; @@ -15,7 +16,7 @@ public class GameProposalContract implements net.corda.v5.ledger.utxo.Contract { public static class Create implements Command { } public static class Accept implements Command { } public static class Reject implements Command { } - public static class Cancle implements Command { } + public static class Cancel implements Command { } @Override public void verify(UtxoLedgerTransaction trx) { @@ -39,14 +40,27 @@ public class GameProposalContract implements net.corda.v5.ledger.utxo.Contract { } else if (command instanceof Reject) { requireThat(trx.getInputContractStates().size() == 1, REJECT_INPUT_STATE); - requireThat(trx.getOutputContractStates().isEmpty(), REJECT_OUTPUT_STATE); + requireThat(trx.getOutputContractStates().size() == 1, REJECT_OUTPUT_STATE); GameProposalState inputState = trx.getInputStates(GameProposalState.class).get(0); requireThat(inputState != null, REJECT_INPUT_STATE); + + GameProposalResolutionState outputState = trx.getOutputStates(GameProposalResolutionState.class).get(0); + requireThat(outputState != null, REJECT_OUTPUT_STATE); + + requireThat(outputState.outcome == GameProposalResolutionState.Resolution.REJECT, REJECT_OUTPUT_OUTCOME); } else - if (command instanceof Cancle) { - // TODO cancle game state - throw new CordaRuntimeException("Unimplemented!"); + if (command instanceof Cancel) { + requireThat(trx.getInputContractStates().size() == 1, CANCEL_INPUT_STATE); + requireThat(trx.getOutputContractStates().size() == 1, CANCEL_OUTPUT_STATE); + + GameProposalState inputState = trx.getInputStates(GameProposalState.class).get(0); + requireThat(inputState != null, CANCEL_INPUT_STATE); + + GameProposalResolutionState outputState = trx.getOutputStates(GameProposalResolutionState.class).get(0); + requireThat(outputState != null, CANCEL_OUTPUT_STATE); + + requireThat(outputState.outcome == GameProposalResolutionState.Resolution.CANCEL, CANCEL_OUTPUT_OUTCOME); } else { throw new CordaRuntimeException(UNKNOWN_COMMAND); } @@ -65,7 +79,11 @@ public class GameProposalContract implements net.corda.v5.ledger.utxo.Contract { static final String CREATE_OUTPUT_STATE = "Create command should output exactly one GameProposal state"; static final String NON_NULL_RECIPIENT_COLOR = "GameProposal.recipientColor 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"; + static final String REJECT_INPUT_STATE = "Reject command should have exactly one GameProposal input state"; + static final String REJECT_OUTPUT_STATE = "Reject command should have exactly one GameProposalResolution output states"; + static final String REJECT_OUTPUT_OUTCOME = "Reject output state should have Resolution value set to REJECT"; + static final String CANCEL_INPUT_STATE = "Cancel command should have exactly one GameProposal input state"; + static final String CANCEL_OUTPUT_STATE = "Cancel command should have exactly one GameProposalResolution output states"; + static final String CANCEL_OUTPUT_OUTCOME = "Cancel output state should have Resolution value set to CANCEL"; } diff --git a/corda/contracts/src/main/java/djmil/cordacheckers/states/GameProposalResolutionState.java b/corda/contracts/src/main/java/djmil/cordacheckers/states/GameProposalResolutionState.java new file mode 100644 index 0000000..9a80b07 --- /dev/null +++ b/corda/contracts/src/main/java/djmil/cordacheckers/states/GameProposalResolutionState.java @@ -0,0 +1,42 @@ +package djmil.cordacheckers.states; + +import java.security.PublicKey; +import java.util.List; + +import djmil.cordacheckers.contracts.GameProposalContract; +import net.corda.v5.base.annotations.ConstructorForDeserialization; +import net.corda.v5.base.annotations.CordaSerializable; +import net.corda.v5.ledger.utxo.BelongsToContract; +import net.corda.v5.ledger.utxo.ContractState; + +@BelongsToContract(GameProposalContract.class) +public class GameProposalResolutionState implements ContractState { + + @CordaSerializable + public enum Resolution { + ACCEPT, + REJECT, + CANCEL + } + + public final Resolution outcome; + public final List participants; + + @ConstructorForDeserialization + public GameProposalResolutionState( + Resolution outcome, + List participants + ) { + this.outcome = outcome; + this.participants = participants; + } + + public Resolution getOutcome() { + return outcome; + } + + public List getParticipants() { + return this.participants; + } + +} diff --git a/corda/contracts/src/main/java/djmil/cordacheckers/states/GameProposalState.java b/corda/contracts/src/main/java/djmil/cordacheckers/states/GameProposalState.java index c1bae80..82ff887 100644 --- a/corda/contracts/src/main/java/djmil/cordacheckers/states/GameProposalState.java +++ b/corda/contracts/src/main/java/djmil/cordacheckers/states/GameProposalState.java @@ -30,12 +30,13 @@ public class GameProposalState implements ContractState { // Allows serialisation and to use a specified UUID @ConstructorForDeserialization public GameProposalState( - MemberX500Name sender, - MemberX500Name recipient, - Color recipientColor, - String message, - UUID id, - List participants) { + MemberX500Name sender, + MemberX500Name recipient, + Color recipientColor, + String message, + UUID id, + List participants + ) { this.sender = sender; this.recipient = recipient; this.recipientColor = recipientColor; diff --git a/corda/workflows/src/main/java/djmil/cordacheckers/gameproposal/ActionFlow.java b/corda/workflows/src/main/java/djmil/cordacheckers/gameproposal/ActionFlow.java new file mode 100644 index 0000000..e1ac9b7 --- /dev/null +++ b/corda/workflows/src/main/java/djmil/cordacheckers/gameproposal/ActionFlow.java @@ -0,0 +1,108 @@ +package djmil.cordacheckers.gameproposal; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +import djmil.cordacheckers.contracts.GameProposalContract; +import djmil.cordacheckers.states.GameProposalResolutionState; +import djmil.cordacheckers.states.GameProposalState; +import djmil.cordacheckers.states.GameProposalResolutionState.Resolution; +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.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.ledger.utxo.Command; +import net.corda.v5.ledger.utxo.StateAndRef; +import net.corda.v5.ledger.utxo.TransactionState; +import net.corda.v5.ledger.utxo.UtxoLedgerService; +import net.corda.v5.ledger.utxo.transaction.UtxoSignedTransaction; +import net.corda.v5.ledger.utxo.transaction.UtxoTransactionBuilder; + +import static java.util.Map.entry; +import static java.util.stream.Collectors.toList; + +import java.time.Duration; +import java.time.Instant; + +@InitiatingFlow(protocol = "game-proposal-action") +public class ActionFlow implements ClientStartableFlow { + + @CordaInject + public JsonMarshallingService jsonMarshallingService; + + @CordaInject + public UtxoLedgerService ledgerService; + + @CordaInject + public FlowMessaging flowMessaging; + + @CordaInject + public MemberLookup memberLookup; + + private final static Map resoultion2command = Map.ofEntries( + entry(GameProposalResolutionState.Resolution.CANCEL, new GameProposalContract.Cancel()), + entry(GameProposalResolutionState.Resolution.REJECT, new GameProposalContract.Reject()), + entry(GameProposalResolutionState.Resolution.ACCEPT, new GameProposalContract.Accept()) + ); + + @Override + @Suspendable + public String call(ClientRequestBody requestBody) { + ActionFlowArgs args = requestBody.getRequestBodyAs(jsonMarshallingService, ActionFlowArgs.class); + + /* + * Look up the latest unconsumed ChatState with the given id. + * Note, this code brings all unconsumed states back, then filters them. This is an + * inefficient way to perform this operation when there are a large number of chats + */ + List> stateAndRefs = this.ledgerService + .findUnconsumedStatesByType(GameProposalState.class); + + List> stateAndRefsWithId = stateAndRefs + .stream() + .filter(sar -> sar.getState().getContractState().getId().equals(args.gameProposalUuid)) + .collect(toList()); + if (stateAndRefsWithId.size() != 1) + throw new CordaRuntimeException("Multiple or zero GameProposal states with id " + args.gameProposalUuid + " found"); + + StateAndRef stateAndRef = stateAndRefsWithId.get(0); + TransactionState trxState = stateAndRef.getState(); + GameProposalState state = trxState.getContractState(); + + GameProposalResolutionState outputState = new GameProposalResolutionState( + GameProposalResolutionState.Resolution.valueOf(args.action), + state.getParticipants() + ); + + /* + * Build draft trx + */ + UtxoTransactionBuilder txBuilder = ledgerService.createTransactionBuilder() + .setNotary(trxState.getNotaryName()) + .setTimeWindowBetween(Instant.now(), Instant.now().plusMillis(Duration.ofDays(1).toMillis())) + .addInputState(stateAndRef.getRef()) + .addOutputState(outputState) + .addCommand(resoultion2command.get(outputState.outcome)) + .addSignatories(state.getParticipants()); + + UtxoSignedTransaction signedTransaction = txBuilder.toSignedTransaction(); + + FlowSession session = flowMessaging.initiateFlow( + outputState.outcome == Resolution.CANCEL ? state.getRecipient() : state.getSender() // TODO: readability + ); + + List sessionsList = Arrays.asList(session); + + ledgerService.finalize(signedTransaction, sessionsList); + + return args.action+"ED"; // REJECT+ED + } + +} diff --git a/corda/workflows/src/main/java/djmil/cordacheckers/gameproposal/ActionFlowArgs.java b/corda/workflows/src/main/java/djmil/cordacheckers/gameproposal/ActionFlowArgs.java new file mode 100644 index 0000000..e15d9aa --- /dev/null +++ b/corda/workflows/src/main/java/djmil/cordacheckers/gameproposal/ActionFlowArgs.java @@ -0,0 +1,15 @@ +package djmil.cordacheckers.gameproposal; + +import java.util.UUID; + +public class ActionFlowArgs { + public final UUID gameProposalUuid; + public final String action; + + // Serialisation service requires a default constructor + public ActionFlowArgs() { + this.gameProposalUuid = null; + this.action = null; + } + +} diff --git a/corda/workflows/src/main/java/djmil/cordacheckers/gameproposal/ActionResponder.java b/corda/workflows/src/main/java/djmil/cordacheckers/gameproposal/ActionResponder.java new file mode 100644 index 0000000..1578922 --- /dev/null +++ b/corda/workflows/src/main/java/djmil/cordacheckers/gameproposal/ActionResponder.java @@ -0,0 +1,86 @@ +package djmil.cordacheckers.gameproposal; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import djmil.cordacheckers.contracts.GameProposalContract.Accept; +import djmil.cordacheckers.contracts.GameProposalContract.Cancel; +import djmil.cordacheckers.contracts.GameProposalContract.Reject; +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.Command; +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-action") +public class ActionResponder 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.getInputContractStates().get(0); + // Uses checkForBannedWords() and checkMessageFromMatchesCounterparty() functions + // to check whether to sign the transaction. + + Command command = ledgerTransaction.getCommands(Command.class).get(0); + + if (!checkParticipants(gameProposal, session.getCounterparty(), command)) { + 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, Command command) { + MemberX500Name myName = memberLookup.myInfo().getName(); +log.info("Responder validation:\n command "+command+"\n me " + myName.toString() + "\n opponent " + counterpartyName.toString()); + if (command instanceof Reject || command instanceof Accept) { + if (gameProposal.getRecipient().compareTo(counterpartyName) == 0 && + gameProposal.getSender().compareTo(myName) == 0) + return true; + } + + if (command instanceof Cancel) { + 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/RejectFlow.java b/corda/workflows/src/main/java/djmil/cordacheckers/gameproposal/RejectFlow.java deleted file mode 100644 index 908b8dd..0000000 --- a/corda/workflows/src/main/java/djmil/cordacheckers/gameproposal/RejectFlow.java +++ /dev/null @@ -1,5 +0,0 @@ -package djmil.cordacheckers.gameproposal; - -public class RejectFlow { - -}