Reference States Solution
An example of a sales proposal and acceptance
So, you worked on the exercise on your own before landing here and looking at this example solution, right? This solution comprises the following parts:
- A new
SalesProposal
state found here. - A new
SalesProposalContract
contract with 3 commandsOffer
,Reject
andAccept
, found here. - 3 new flow pairs found here:
SalesProposalOfferFlows
SalesProposalRejectFlows
SalesProposalAcceptFlows
To follow along with IntelliJ, you need to open the 050-ref-state
folder as a project. Let's review these in order.
SalesProposal
state
Here is its CDL:
It makes sense to declare it as a LinearState
as it has a linear lifecycle. It declares the following important fields:
@NotNull
private final StaticPointer<NonFungibleToken> asset;
@NotNull
private final AbstractParty buyer;
@NotNull
private final Amount<IssuedTokenType> price;
In other words:
I am willing to sell this
asset
tobuyer
at the givenprice
in the statedIssuedTokenType
currency.
Who is willing to sell it? Well, the car token holder obviously. However just the pointer is not enough to access the underlying NonFungibleToken
, so the data is declared, for information only, explicitly:
@NotNull
private final AbstractParty seller;
For similar reasons, it also expects the underlying asset id:
@NotNull
private final UniqueIdentifier assetId;
Notice that the asset
is of type StaticPointer<NonFungibleToken>
, not just NonFungibleToken
, so as to facilitate cross-checks with the state referenced in the transaction. Neither is it of type LinearId
which would be a weak cross-check, even with a reference state. Additionally, it is unfalsifiable.
Admittedly, an error could have been made when instantiating the SalesProposal
, whereby the pointed asset may not match the assetId
and seller
. In this case, the resolved asset
shall be authoritative. To mitigate this type of of mistakes, there is an additional constructor that takes in a fully-resolved StateAndRef<NonFungibleToken> asset
before passing on to the flat constructor. To avoid confusion (which constructor?) at deserialisation, the flat constructor is annotated with @ConstructorForDeserialization
.
Why not use a fully resolved StateAndRef<NonFungibleToken> asset
? It would work, at the cost of double serialisation of the NonFungibleToken
content, itself and as part of a SalesProposal
. A static pointer is more elegant and parcimonious.
Unremarkably, the participants are the buyer and the seller:
@NotNull
@Override
public List<AbstractParty> getParticipants() {
return ImmutableList.copyOf(Arrays.asList(seller, buyer));
}
Finally, there is an extra function to facilitate later identification of the asset
:
public boolean isSameAsset(@NotNull final StateAndRef<? extends ContractState> asset) {
final ContractState aToken = asset.getState().getData();
if (!(aToken instanceof NonFungibleToken)) return false;
final NonFungibleToken token = (NonFungibleToken) aToken;
return this.asset.getPointer().equals(asset.getRef())
&& this.assetId.equals(token.getLinearId())
&& this.seller.equals(token.getHolder());
}
SalesProposalContract
Here is its CDL:
It defines 3 commands:
Offer
, whereby the seller declares its intention to sell the verified asset.Reject
, whereby either party rejects the offer.Accept
, whereby the buyer declares its acceptance of the offer and executes the purchase at the same time.
So, it makes sense for the contract to perform the following checks:
On Offer
A SalesProposal
is created, and the asset is passed as a reference:
There is a single reference state:
req.using("There should be a single reference input token on offer", inRefs.size() == 1);
Ooh! Something new. The referenced token should match the asset for sale:
final StateAndRef<AbstractToken> refToken = inRefs.get(0); req.using("The reference token should match the sales proposal output asset", proposal.isSameAsset(refToken));
It is here that the benefit of reference states is delivered. You are sure that:
- The asset exists, for real.
- The seller owns the token.
- The asset is not sold yet. It is unconsumed.
On Reject
The proposal is consumed, and the asset remains untouched:
On Accept
The proposal is consumed, and the asset changes hands for the agreed price.
The asset, which was passed as a reference state on
Offer
, has to be a normal input this time:final List<StateAndRef<NonFungibleToken>> candidates = inNFTokens.stream() .filter(proposal::isSameAsset) .collect(Collectors.toList()); req.using("The asset should be an input on accept", candidates.size() == 1);
As an important side node, this means that if the seller had created 2 proposals for the same asset, then only 1 proposal can be accepted. Also, because the asset would have changed hands, so the seller would not be able to sign the asset off.
The buyer should own the asset on output:
final List<NonFungibleToken> boughtAsset = outNFTokens.stream() .map(it -> it.getState().getData()) .filter(it -> it.getLinearId().equals(proposal.getAssetId())) .collect(Collectors.toList()); req.using("The asset should be held by buyer in output on accept", boughtAsset.size() == 1 && boughtAsset.get(0).getHolder().equals(proposal.getBuyer()));
Note that there is only a need to check the holder, and no need to check that it is referring to the same underlying asset as this part is taken care of by the token contract itself.
The seller should be paid the agreed amount in return:
final long sellerPayment = outFTokens.stream() .map(it -> it.getState().getData()) .filter(it -> it.getHolder().equals(proposal.getSeller())) .filter(it -> it.getIssuedTokenType().equals(proposal.getPrice().getToken())) .map(it -> it.getAmount().getQuantity()) .reduce(0L, Math::addExact); req.using("The seller should be paid the agreed amount in the agreed issued token on accept", proposal.getPrice().getQuantity() <= sellerPayment);
Note the absence of checks on the input
FungibleToken
s. This contract does not verify that the buyer paid. It also leaves the possibility open to atomically mix it with other states and contracts.
If your SalesProposal
contract does not verify these parts:
- That the asset changed hands on
Accept
. - That the seller was paid on
Accept
.
It is entirely fine as the purpose of the SalesProposal
is to encapsulate an offer by the seller, and nothing more really. It is not a breakage of the ledger layer if the asset did not change hands or changed hands with the wrong payment. Plus, armed with a proposal whose creation is it itself signed, the seller's responder flow can run these mechanical checks.
This is to say that here there is some leeway as to what you decide to include in the contract. The decision was made here to off-load the responder flow of those checks, at the expense of future transaction flexibility.
Tests
Once more, there is 1 test file per command, and each failure point is tested. .tweak
is used to make explicit which point is the failure point.
Time to move on to flows.
The SalesProposal
offer flows
This is launched by the seller. The fact that the seller decides to initiate a run of this flow indicates intent to sell the asset. From there, the presence of a SalesProposal
state is proof enough of the original intent.
There are actually 2 flow pairs in the SalesProposalOfferFlows
set, as can be seen here:
OfferFlow
, an inlined flow that expects perfect information and executes the mechanical parts of the flow to issue a sales proposal.- And its handler
OfferHandlerFlow
, an inlined flow.
- And its handler
OfferSimpleFlow
, an initiating flow that takes easy information before passing on toOfferFlow
.- And its automatic handler
OfferSimpleHandlerFlow
.
- And its automatic handler
OfferFlow
This inlined flow handles the mechanical parts to issue an offer. It is started by the seller, and the potential buyer only has to finalize a complete transaction to receive the SalesProposal
.
Then it starts creating the transaction:
final TransactionBuilder builder = new TransactionBuilder(asset.getState().getNotary())
.addOutputState(proposal)
.addReferenceState(new ReferencedStateAndRef<>(asset))
.addCommand(new SalesProposalContract.Commands.Offer(),
Collections.singletonList(proposal.getSeller().getOwningKey()));
Note how:
- It picks the notary from the
asset
. - It adds the proposal and lets the system select the right contract.
- It adds the
asset
as aReferencedStateAndRef
.
Then it needs to resolve the host of the buyer's public key before informing them:
final Party buyerHost = getServiceHub().getIdentityService()
.requireWellKnownPartyFromAnonymous(proposal.getBuyer());
final FlowSession buyerSession = initiateFlow(buyerHost);
It preemptively informs the buyer's host about who the seller is:
subFlow(new SyncKeyMappingFlow(buyerSession, Collections.singletonList(proposal.getSeller())));
And finishes with finalisation:
return subFlow(new FinalityFlow(
offerTx,
Collections.singletonList(buyerSession),
FINALISING_TRANSACTION.childProgressTracker()));
As part of the finalisation, the notary will confirm that the reference state of the asset, and if applicable the reference state of the asset type, are the correct, i.e. latest, ones.
As you can see, this is a very simple flow that uses a single reference and does not require a remote signature.
OfferSimpleFlow
This initiating flow is meant to make life simpler by requiring simplified information before passing on to OfferFlow
:
public OfferSimpleFlow(@NotNull final UniqueIdentifier assetId,
@NotNull final Party buyer,
final long price,
@NotNull final String currencyCode,
@NotNull final Party issuer,
@NotNull final ProgressTracker progressTracker) {
You will recognize how these fields will be combined. First, the asset needs to be fetched from the vault:
final QueryCriteria assetCriteria = new QueryCriteria.LinearStateQueryCriteria()
.withUuid(Collections.singletonList(assetId.getId()));
final List<StateAndRef<NonFungibleToken>> assets = getServiceHub().getVaultService()
.queryBy(NonFungibleToken.class, assetCriteria)
.getStates();
if (assets.size() != 1) throw new FlowException("Wrong number of assets found");
final StateAndRef<NonFungibleToken> asset = assets.get(0);
With this, it is just a matter of passing it on to OfferFlow
:
return subFlow(new OfferFlow(asset, buyer,
AmountUtilitiesKt.amount(price, new IssuedTokenType(issuer, currency)),
PASSING_ON.childProgressTracker()));
With the handler simply being a child class of OfferFlowHandler
:
@InitiatedBy(OfferSimpleFlow.class)
class OfferSimpleHandlerFlow extends OfferHandlerFlow {
Offer tests
In the tests there are 2 happy paths and 1 failed path. Take some time to look at the failed path. Here is what happens in it:
- The car is issued to its owner.
- The DMV changes the mileage on the car but fails to inform anyone.
- The car owner cannot create a sales proposal for it.
That's because the StateAndRef<CarTokenType
has changed, and been recorded so by the notary. The host alice only has the old version in her vault. This is a side-effect of the fact that the state pointers are resolved when a state is added as a reference.
The second happy path confirms that the seller can create a sales proposal if it has been informed of the new car state.
The SalesProposal
reject flows
This is launched by either the seller or the buyer. The one starting the flow is called the rejecter, the other, the rejectee.
In fact there are 2 flow pairs which can be found here:
RejectFlow
, an inlined flow that expects perfect information and executes the mechanical parts of the flow to reject a sales proposal.- And its handler
RejectHandlerFlow
, an inlined flow.
- And its handler
RejectSimpleFlow
, an initiating flow that takes easy information before passing on toRejectFlow
.- And its automatic handler
RejectSimpleHandlerFlow
.
- And its automatic handler
RejectFlow
This inlined flow handles the mechanical parts of a rejection. It expects exact information:
public RejectFlow(@NotNull final StateAndRef<SalesProposal> proposal,
// The rejecter is either the buyer or the seller.
@NotNull final AbstractParty rejecter,
@NotNull final ProgressTracker progressTracker) {
And deduces the rejectee
:
this.rejectee = proposal.getState().getData().getParticipants().stream()
.filter(it -> !it.equals(rejecter))
.collect(Collectors.toList())
.get(0);
Either party of the SalesProposal
. Yes, it means that the seller can shut the door on the buyer up to the last minute. You will see in the next chapter how further assurances can be extended to the buyer against such tactics.
RejectSimpleFlow
Similarly to what you saw with OfferSimpleFlow
, it takes simplified parameters:
public RejectSimpleFlow(
@NotNull final UniqueIdentifier proposalId,
@NotNull final AbstractParty rejecter,
@NotNull final ProgressTracker progressTracker) {
Reject tests
They naturally check that either party can reject, which should inform the other.
The SalesProposal
accept flows
This is launched by the buyer. The fact that the buyer decides to initiate a run of this flow indicates intent to buy the asset.
There are actually 2 flow pairs in the SalesProposalAccepFlows
set, as can be seen here:
AcceptFlow
, anabstract
inlined flow that expects perfect information and executes the mechanical parts of the flow to accept the sales proposal and affect the sale.- And its handler
AcceptHandlerFlow
, an inlined flow.
- And its handler
AcceptSimpleFlow
, an initiating flow that takes easy information before passing on toAcceptFlow
.- And its automatic handler
AcceptSimpleHandlerFlow
.
- And its automatic handler
AcceptFlow
It is abstract in the same way that AtomicSaleAccountsSafe.CarSellerFlow
is, so as to give the ability to pick payment tokens:
abstract protected QueryCriteria getHeldByBuyer(
@NotNull final IssuedTokenType issuedCurrency,
@NotNull final AbstractParty buyer) throws FlowException;
Other than that, it looks very much like CarSellerFlow
. It takes the expected information:
public AcceptFlow(@NotNull final StateAndRef<SalesProposal> proposalRef,
@NotNull final ProgressTracker progressTracker) {
Then, it moves on to send the token states to the seller session, so that the seller can verify:
final Party sellerHost = getServiceHub().getIdentityService()
.requireWellKnownPartyFromAnonymous(proposal.getSeller());
final FlowSession sellerSession = initiateFlow(sellerHost);
// Send potentially missing StateRefs blindly.
subFlow(new SendStateAndRefFlow(sellerSession, moniesInOut.getFirst()));
With the potentially missing keys used in them:
final List<AbstractParty> moniesKeys = moniesInOut.getFirst().stream()
.map(it -> it.getState().getData().getHolder())
.collect(Collectors.toList());
subFlow(new SyncKeyMappingFlow(sellerSession, moniesKeys));
Then, it collects all the keys relevant to the buyer to sign locally:
final List<PublicKey> ourKeys = moniesKeys.stream()
.map(AbstractParty::getOwningKey)
.collect(Collectors.toList());
ourKeys.add(proposal.getBuyer().getOwningKey());
final SignedTransaction acceptTx = getServiceHub().signInitialTransaction(builder, ourKeys);
You will notice that the seller can still reject the sale, simply by refusing to sign this transaction.
Speaking of which, on the AcceptHandlerFlow
:
Then, it prepares itself to sign the sale transaction:
final SecureHash txId = subFlow(new SignTransactionFlow(buyerSession) {
In which it verifies that there is a single Accept
command:
final List<Command<?>> commands = stx.getTx().getCommands().stream()
.filter(it -> it.getValue() instanceof SalesProposalContract.Commands.Accept)
.collect(Collectors.toList());
if (commands.size() != 1)
throw new FlowException("There is no accept command");
The above covers a potentially sneaky fraud. Imagine that Alice, the seller, made 2 proposals, 1 for Bob at 10k and 1 for Carly at 11k. By hook or crook, Carly got hold of Bob's proposal from Alice and she wants to purchase the car at the cheaper price.
Carly could try to send a transaction with Bob's proposal, a Reject
command with Alice as the required signer and 10k of tokens from Carly.
Because the Accept
command requires a signature from the buyer, only Bob can sign an Accept
on his proposal.
If the command verification above was missing, Alice's handler flow would be unaware of the undercover switch.
It then assembles the different types of input states expected:
final List<SalesProposal> proposals = new ArrayList<>(1);
final List<NonFungibleToken> assetsIn = new ArrayList<>(1);
final List<FungibleToken> moniesIn = new ArrayList<>(stx.getInputs().size());
for (final StateRef ref : stx.getInputs()) {
final ContractState state = getServiceHub().toStateAndRef(ref).getState().getData();
if (state instanceof SalesProposal)
proposals.add((SalesProposal) state);
else if (state instanceof NonFungibleToken)
assetsIn.add((NonFungibleToken) state);
else if (state instanceof FungibleToken)
moniesIn.add((FungibleToken) state);
else
throw new FlowException("Unexpected state class: " + state.getClass());
}
if (proposals.size() != 1) throw new FlowException("There should be a single sales proposal in");
if (assetsIn.size() != 1) throw new FlowException("There should be a single asset in");
final SalesProposal proposal = proposals.get(0);
final NonFungibleToken assetIn = assetsIn.get(0);
Before confirming that no other key is required for signature:
final List<PublicKey> allInputKeys = moniesIn.stream()
.map(it -> it.getHolder().getOwningKey())
.collect(Collectors.toList());
allInputKeys.add(assetIn.getHolder().getOwningKey());
final List<PublicKey> myKeys = StreamSupport.stream(
getServiceHub().getKeyManagementService().filterMyKeys(allInputKeys).spliterator(),
false)
.collect(Collectors.toList());
if (myKeys.size() != 1) throw new FlowException("There are not the expected keys of mine");
if (!myKeys.get(0).equals(proposal.getSeller().getOwningKey()))
throw new FlowException("The key of mine is not the seller");
And, that the buyer is not trying to have the seller "pay themselves" for the privilege:
final List<FungibleToken> myInMonies = moniesIn.stream()
.filter(it -> it.getHolder().equals(proposal.getSeller()))
.collect(Collectors.toList());
if (!myInMonies.isEmpty())
throw new FlowException("There is a FungibleToken of mine in input");
Note that the checks that the seller got paid is taken care of by the contract.
AcceptSimpleFlow
Similarly to RejectSimpleFlow
, it takes in minimal information:
public AcceptSimpleFlow(
@NotNull final UniqueIdentifier proposalId,
@NotNull final ProgressTracker progressTracker) {
Then, passing it on to AcceptFlow
. Not to forget to provide the criteria to pick tokens:
return subFlow(new AcceptFlow(proposal, PASSING_ON.childProgressTracker()) {
@NotNull
@Override
protected QueryCriteria getHeldByBuyer(
@NotNull final IssuedTokenType issuedCurrency,
@NotNull final AbstractParty buyer) throws FlowException {
return QueryUtilitiesKt.heldTokenAmountCriteria(issuedCurrency.getTokenType(), buyer);
}
});
On the other end, AcceptSimpleHandlerFlow
is just a child class of AcceptHandlerFlow
:
class AcceptSimpleHandlerFlow extends AcceptHandlerFlow {
Accept tests
They test:
* That a [sale is possible](https://github.com/corda/corda-training-code/blob/master/050-ref-state/workflows/src/test/java/com/template/proposal/flow/SalesProposalAcceptFlowsTests.java#L159) when all conditions are present.
* That a sale is not possible if the token type has changed [without informing the buyer](https://github.com/corda/corda-training-code/blob/master/050-ref-state/workflows/src/test/java/com/template/proposal/flow/SalesProposalAcceptFlowsTests.java#L241).
* That a sale is not possible if the buyer [does not have enough dollars](https://github.com/corda/corda-training-code/blob/master/050-ref-state/workflows/src/test/java/com/template/proposal/flow/SalesProposalAcceptFlowsTests.java#L291).
Conclusion
As you have seen, the intent of selling and buying is split into 2 transactions. The SalesProposal
, by its very existence, proves the intent of the seller with regards to asset, price and currency. Therefore, the AcceptHandlerFlow
on the seller side can recognize its own intent, and is satisfied with classic mechanical verifications against fraud.
It was a design decision to keep a lot of verifications in the contract on accept, but a lighter version is also possible, where the fraud verifications are shifted to the flows. In this case, the SalesProposal
serves only as the repository for the seller's intent.
As an additional note, now that you have a safe atomic sale mechanism, which expresses a desired price, the time has come to remove the price
from CarTokenType
.
In the next exercise, you will look at how you can protect the buyer from having the door shut too early by the seller. This assures the buyer that the offer is indeed open to acceptance.