Accounts Solution
- Reading time: 12 mins
- Discuss on Slack
In the previous chapter, you worked on your project to make sure that your CorDapp is account-safe and account-aware. Now, compare your work to an example solution.
Confirm the token flows
Here, you made your existing issue, move and redeem flows account safe. You can confirm this with unit tests.
Preparation
Preparing your network starts to be a bit involved, given all the CorDapps you need to include, along with the preferred notary:
return new MockNetworkParameters()
.withNotarySpecs(Collections.singletonList(new MockNetworkNotarySpec(CarTokenTypeConstants.NOTARY)))
.withCordappsForAllNodes(ImmutableList.of(
TestCordapp.findCordapp("com.r3.corda.lib.accounts.contracts"),
TestCordapp.findCordapp("com.r3.corda.lib.accounts.workflows"),
TestCordapp.findCordapp("com.r3.corda.lib.tokens.contracts"),
TestCordapp.findCordapp("com.r3.corda.lib.tokens.workflows"),
TestCordapp.findCordapp("com.r3.corda.lib.tokens.money"),
TestCordapp.findCordapp("com.r3.corda.lib.tokens.selection"),
TestCordapp.findCordapp("com.r3.corda.lib.ci.workflows"),
TestCordapp.findCordapp("com.template.car.state"),
TestCordapp.findCordapp("com.template.car.flow")))
.withNetworkParameters(ParametersUtilitiesKt.testNetworkParameters(
Collections.emptyList(), 4
));
Plus, the specific nodes:
network = new MockNetwork(prepareMockNetworkParameters());
notary = network.getDefaultNotaryNode();
dmv = network.createNode(new MockNodeParameters()
.withLegalName(CarTokenTypeConstants.DMV));
bmwDealer = network.createNode(new MockNodeParameters()
.withLegalName(CarTokenTypeConstants.BMW_DEALER));
alice = network.createNode();
bob = network.createNode();
Passing public keys around
Since you want to make your flows account-safe only, you do not expect them to request keys around. Instead, you need to prepopulate them with the keys that will be used when calling the flows. That is the purpose of this inform
function that uses SyncKeyMappingInitiator
:
private void inform(
@NotNull final StartedMockNode host,
@NotNull final PublicKey who,
@NotNull final List<StartedMockNode> others) throws Exception {
final AccountService accountService = host.getServices()
.cordaService(KeyManagementBackedAccountService.class);
final StateAndRef<AccountInfo> accountInfo = accountService.accountInfo(who);
if (!host.getInfo().getLegalIdentities().get(0).equals(accountInfo.getState().getData().getHost())) {
throw new IllegalArgumentException("hosts do not match");
}
for (StartedMockNode other : others) {
final CordaFuture future = host.startFlow(new SyncKeyMappingInitiator(
other.getInfo().getLegalIdentities().get(0),
Collections.singletonList(new AnonymousParty(who))));
network.runNetwork();
future.get();
}
}
Testing with account keys
Now it is time to look at the unit tests that confirm our flows are safe:
- Confirm that the node needs to know about keys beforehand:
accountNeedsToBeKnownToHoldCar
. - Confirm that the node can issue a car to an anonymous holder:
accountDanCanHoldCar
. - Confirm that the node can move a car from one anonymous holder to another:
accountDanCanGiveCarAwayToEmma
.
Fix the AtomicSale
The previous atomic sale example suffers from 2 problems:
- The buyer automatically accepts the sales without checking whether this is desirable. You will fix this in a later chapter.
- The flow is not account-safe.
The example has 2 versions of the atomic sale flow pair where point 2 has been fixed:
- An account-safe version of the flows.
- An account-aware version of the flows.
In fact, there is an abstract
account-safe version of the flows that is sub-flowed by 2 further implementations.
AtomicSaleAccountsSafe
This file contains 4 flows:
- Inlined
abstract class CarSellerFlow extends FlowLogic<SignedTransaction>
. - Inlined
abstract class CarBuyerFlow extends FlowLogic<SignedTransaction>
, which is the handler of the above. - Initiating
class CarSeller extends FlowLogic<SignedTransaction>
, which sub-flowsCarSellerFlow
. class CarBuyer extends FlowLogic<SignedTransaction>
, which is initiated byCarSeller
, and which sub-flowsAtomicSaleAccountsSafe.CarBuyerFlow
..
The naming convention of initiating CarSeller
/ inlined CarSellerFlow
is commonly found in Corda.
CarSellerFlow
and CarBuyerFlow
Let’s go through these flows':
- Attributes.
- Abstract functions.
- Action steps when they differ from the original unsafe
AtomicSale
.
Attributes
CarSellerFlow
takes in:
@NotNull
private final TokenPointer<CarTokenType> car;
@NotNull
private final FlowSession buyerSession;
@NotNull
private final IssuedTokenType issuedCurrency;
Notice that it does not take the identity of the buyer. This is so in order to be flexible with regard to accounts, or not.
CarBuyerFlow
takes in the classic:
@NotNull
private final FlowSession sellerSession;
Abstract functions
So, how does the seller flow identify the future holder of the car token? It has to be an AbstractParty
. So it needs both the seller and the buyer to agree on it. That’s the role of the first abstract functions:
-
On the seller’s side:
@NotNull abstract protected FlowLogic<AbstractParty> getSyncBuyerPartyFlow();
-
On the buyer’s side:
@NotNull abstract protected FlowLogic<AbstractParty> getSyncBuyerPartyHandlerFlow();
Do you notice the naming symmetry? It hints at the fact that these 2 functions should return inlined flows that are compatible with each. For instance:
- A
.send
here and a.receive
there. - Or a
RequestKeyFlow
here and aProvideKeyFlow
there.
As you will see, this is exactly what the initiating flows do. For the avoidance of doubt, both functions return an instance of a flow, ready to be used in a subFlow
command. Also, both of the flow instances return an AbstractParty
, which has to be identical or the flow will fail.
The other abstract function is on the buyer side only:
@NotNull
abstract protected QueryCriteria getHeldByBuyer(
@NotNull final IssuedTokenType issuedCurrency,
@NotNull final AbstractParty buyer) throws FlowException;
To be able to search by account, or not.
Action!
The first thing the flow does is agree on both sides about who the buyer is:
final AbstractParty buyer = subFlow(getSyncBuyerPartyFlow());
// and
final AbstractParty buyer = subFlow(getSyncBuyerPartyHandlerFlow());
It continues, similarly, to what you have already seen in AtomicSale
, also fetching the seller, note it is not getOurIdentity()
:
final AbstractParty seller = heldCarTokens.get(0).getState().getData().getHolder();
The buyer side collects fungible input tokens that belong to the buyer:
// The abstract function
final QueryCriteria heldByBuyer = getHeldByBuyer(issuedCurrency, buyer);
...
This means the dollar states may come from more than 1 holder public key, but they would all belong to the buyer in any case. The buyer side is also careful about giving the new tokens to the seller, and not the counterparty node, and the change to the agreed buyer:
[...] tokenSelection.generateMove(
Collections.singletonList(new Pair<>(
heldCarToken.getState().getData().getHolder(), priceInCurrency)),
buyer,
[...]
Then, when the seller’s host receives the fungible input tokens, it verifies that it is not being swindled:
.filter(it -> it.getState().getData().getHolder().equals(seller))
Also, because it received dollar states from potentially unknown accounts:
final List<AbstractParty> missingKeys = currencyInputs.stream()
.map(it -> it.getState().getData().getHolder())
.filter(it -> getServiceHub().getIdentityService()
.wellKnownPartyFromAnonymous(it) == null)
.collect(Collectors.toList());
The seller’s host needs to ask the buyer’s host to resolve them, by asking for the minimum necessary to proceed:
buyerSession.send(missingKeys);
subFlow(new SyncKeyMappingFlowHandler(buyerSession));
Back on the buyer’s host, it trusts but verifies, first by collecting the keys that might be missing from the seller:
final Set<AbstractParty> potentiallyMissingKeys = inputsAndOutputs.getFirst().stream()
.map(it -> it.getState().getData().getHolder())
.collect(Collectors.toSet());
Then, again thinking adversarially, confirms that the seller is not trying to learn about more keys than necessary:
final List<AbstractParty> missingKeys = (List<AbstractParty>) sellerSession
.receive(List.class).unwrap(it -> it);
if (!potentiallyMissingKeys.containsAll(missingKeys))
throw new FlowException("A missing key is not in the potentially missing keys");
And finally obliges:
subFlow(new SyncKeyMappingFlow(sellerSession, missingKeys));
After that, the buyer’s host continues verification of the output states, then signs the transaction:
final SignedTransaction partSignedTx = getServiceHub().signInitialTransaction(txBuilder,
seller.getOwningKey());
// ^ Yes, the seller
Then asks the buyer’s side for the same:
final SignedTransaction fullySignedTx = subFlow(new CollectSignaturesFlow(partSignedTx,
Collections.singletonList(buyerSession),
Collections.singleton(seller.getOwningKey())));
If it had not resolved the missing keys, CollectSignaturesFlow
would have failed because it would not have been able to resolve which node hosts the missing keys. Meanwhile, on the buyer’s node, it obliges:
final SecureHash signedTxId = subFlow(new SignTransactionFlow(sellerSession) [...]
The buyer’s node does the same checks as in the old AtomicSale
and of course, checks that the car will belong to the new owner:
if (!outputHeldCar.getHolder().equals(buyer))
throw new FlowException("The car is not held by the buyer in output");
Then, finalisation happens uneventfully.
Your 3 main take aways are that:
getOurIdentity()
was never called as both hosts only cared about ensuring the stated seller and buyer are used.- The dollar states have to be taken broadly because the buyer may have enough fungible tokens to pay but they may be scattered across various public keys.
- Missing keys needed to be resolved.
CarSeller
and CarBuyer
As mentioned earlier, these 2 flows use the 2 flows you walked through above. How do they achieve that?
- They have relevant attributes.
- They do some preparation.
- They sub-flow and override the abstract functions.
CarSeller
The CarSeller
predictably needs:
@NotNull
private final TokenPointer<CarTokenType> car;
@NotNull
private final AbstractParty buyer;
@NotNull
private final IssuedTokenType issuedCurrency;
Compared to the old AtomicSale
, the buyer
is now an AbstractParty
. Remember, these 2 flows are still account-safe, not account-aware. Alone, buyer
does not inform about where the buyer
is hosted. It has to get that from its vault:
final Party buyerHost = getServiceHub().getIdentityService()
.requireWellKnownPartyFromAnonymous(buyer);
Observe that it is .requireWell...
. This means that if the buyer
cannot be resolved, it will fail there and then. This flow assumes that the relevant buyer information has already been populated into the vault.
From there, it is only a matter of calling subFlow
on the inlined flow, with an override:
return subFlow(new CarSellerFlow(car, buyerSession, issuedCurrency) {
@NotNull
@Override
protected FlowLogic<AbstractParty> getSyncBuyerPartyFlow() {
return new FlowLogic<AbstractParty>() {
@Suspendable
@NotNull
@Override
public AbstractParty call() {
buyerSession.send(buyer);
return buyer;
}
};
}
});
Exactly. This special flow only sends the buyer
. After all, it is already known… This hints at what getSyncBuyerPartyHandlerFlow
needs to return on the buyer’s host.
CarBuyer
As it always is with an @InitiatedBy
flow, it only keeps a:
@NotNull
private final FlowSession sellerSession;
It then dives straight into subFlow
:
return subFlow(new CarBuyerFlow(sellerSession) {
@NotNull
@Override
protected FlowLogic<AbstractParty> getSyncBuyerPartyHandlerFlow() {
return new FlowLogic<AbstractParty>() {
@Suspendable
@NotNull
@Override
public AbstractParty call() throws FlowException {
return sellerSession.receive(AbstractParty.class).unwrap(it -> it);
}
};
}
[...]
Where indeed, it .receive
s the buyer information, given it was .send
from the seller’s host. Don’t forget that it has to override the query criteria function too:
[...]
@NotNull
@Override
protected QueryCriteria getHeldByBuyer(
@NotNull IssuedTokenType issuedCurrency,
@NotNull final AbstractParty buyer) {
return QueryUtilitiesKt.heldTokenAmountCriteria(
issuedCurrency.getTokenType(), buyer);
}
});
}
This query formula is taken from the old AtomicSale
where getOurIdentity()
is replaced with buyer
.
Tests
The tests confirm that:
-
It works with well-known parties, see
partiesCanDoAtomicSaleAccountsSafe
. -
It works with account keys as well, see
accountsCanDoAtomicSaleAccountsSafe
. Notice how:- the dealer is informed about Dan the car holder beforehand:
inform(alice, danParty.getOwningKey(), Collections.singletonList(bmwDealer));
- the seller’s host and the mint are informed about Emma the buyer beforehand:
inform(bob, emmaParty.getOwningKey(), Arrays.asList(alice, usMint));
The mint needs to know too in order to send the minted dollars to the buyer’s host.
This concludes the review of an account-safe atomic sale. To recap:
- The buyer is resolved, and it’s host identified.
- Information is exchanged.
- Missing keys are resolved.
Time to move to the account-aware atomic sale.
AtomicSaleAccounts
This file contains 2 flows:
- Initiating
class CarSeller extends FlowLogic<SignedTransaction>
, which sub-flowsAtomicSaleAccountsSafe.CarSellerFlow
. class CarBuyer extends FlowLogic<SignedTransaction>
, which is initiated byCarSeller
and which sub-flowsAtomicSaleAccountsSafe.CarBuyerFlow
.
Let’s review them.
CarSeller
and CarBuyer
They are not extraordinary:
- They have relevant attributes.
- They do some preparation.
- They sub-flow and override the abstract functions.
CarSeller
It is meant to be account-aware, which explains why it takes a UUID
in its attributes:
@NotNull
private final TokenPointer<CarTokenType> car;
@NotNull
private final UUID buyer;
@NotNull
private final IssuedTokenType issuedCurrency;
On its own, it does not reveal much. That’s why it resolves it with:
final AccountService accountService = UtilitiesKt.getAccountService(this);
final StateAndRef<AccountInfo> buyerAccount = accountService.accountInfo(buyer);
if (buyerAccount == null)
throw new FlowException("This buyer account is unknown: " + buyer);
Here again, the account information needs to have been informed to the seller’s host prior to launching this flow. The flow can now inform the buyer’s side about which account this is for. It is important for the buyer’s side to know which account is the buyer so that it can create the proper public key, and collect the proper dollar states:
buyerSession.send(buyer);
Boom. With this done, it dives straight into sub-flow with the account-safe inlined flow:
return subFlow(new AtomicSaleAccountsSafe.CarSellerFlow(car, buyerSession, issuedCurrency) {
@NotNull
@Override
protected FlowLogic<AbstractParty> getSyncBuyerPartyFlow() {
return new FlowLogic<AbstractParty>() {
@NotNull
@Suspendable
@Override
public AbstractParty call() throws FlowException {
return subFlow(new RequestKeyFlow(
buyerSession, buyerAccount.getState().getData().getLinearId().getId()));
}
};
}
});
Why the choice of RequestKeyFlow
instead of the more account-idiomatic RequestKeyForAccountFlow
? That’s because, at the current version, the handler SendKeyForAccountFlow
does not return the created key but Unit
(a.k.a. void
) instead. And we need the buyer’s host to be precisely informed about which account will receive the car, so that it can be checked.
Why not just return new RequestKeyFlow([...]
? Unfortunately, RequestKeyFlow
extends FlowLogic<AnonymousParty>
and we would have to change our return type to FlowLogic<? extends AbstractParty>
for compilation to pass. A minor inconvenience, really.
This RequestKeyFlow
hints at what getSyncBuyerPartyHandlerFlow
needs to return.
CarBuyer
Again, because it is @InitiatedBy
, it has only:
@NotNull
private final FlowSession sellerSession;
You saw in the seller’s side that it sends the buyer id, so to follow the choreography:
final UUID buyer = sellerSession.receive(UUID.class).unwrap(it -> it);
Does its own checks, including that it is the buyer’s host:
if (!buyerAccount.getState().getData().getHost().equals(getOurIdentity()))
throw new FlowException("We are not this account's host");
With this done, it can call the account-safe sub-flow:
return subFlow(new AtomicSaleAccountsSafe.CarBuyerFlow(sellerSession) {
@NotNull
@Override
protected FlowLogic<AbstractParty> getSyncBuyerPartyHandlerFlow() {
return new FlowLogic<AbstractParty>() {
@Suspendable
@NotNull
@Override
public AbstractParty call() throws FlowException {
return subFlow(new ProvideKeyFlow(sellerSession));
}
};
}
[...]
Using ProvideKeyFlow
as expected, and overriding the query criteria to use the one you saw in the accounts chapter:
[...]
@NotNull
@Override
protected QueryCriteria getHeldByBuyer(
@NotNull IssuedTokenType issuedCurrency,
@NotNull final AbstractParty buyerParty) {
return new QueryCriteria.VaultQueryCriteria()
.withExternalIds(Collections.singletonList(buyer));
}
});
Tests
The only test confirms that it works with accounts, see accountsCanDoAtomicSaleAccounts
. Notice how:
-
The dealer is informed about Dan the car holder, beforehand, but only about the public key:
informKeys(alice, Collections.singletonList(danParty.getOwningKey()), Collections.singletonList(bmwDealer));
-
Emma, the buyer, has 2 tokens:
- These tokens cover the price of the car only together (2 * 15,000 > 25,000):
final Amount<IssuedTokenType> amountOfUsd = AmountUtilitiesKt .amount(15_000L, usMintUsd);
- Each token is held by a different public key:
final FungibleToken usdTokenEmma1 = new FungibleToken( amountOfUsd, emmaParty1, null); final FungibleToken usdTokenEmma2 = new FungibleToken( amountOfUsd, emmaParty2, null); final IssueTokens flow = new IssueTokens( Arrays.asList(usdTokenBob, usdTokenEmma1, usdTokenEmma2), Collections.emptyList());
-
The mint is informed about Emma the buyer, but only on her public keys, beforehand:
informKeys(bob, Arrays.asList(emmaParty1.getOwningKey(), emmaParty2.getOwningKey()), Collections.singletonList(usMint));
The mint needs to know too so it can send the minted dollars to the buyer’s host.
-
The seller is informed about Emma the buyer, but only about her account:
informAccounts(bob, Collections.singletonList(emma), Collections.singletonList(alice));
The assertions verify that:
-
The newly created key for Emma holds the car and the dollar change.
-
Dan holds 25,000 of the dollars:
assertEquals(AmountUtilitiesKt.amount(25_000L, usdTokenType).getQuantity(), paidToDan);
You could decide to have Dan create a new public key to hold the dollar states. This is left for you as an exercise.
This concludes the review of an account-aware atomic sale. To recap:
- The buyer id is resolved, and it’s host identified.
- The buyer keys are resolved.
- Information is exchanged.
- Missing keys are resolved too.