Cookbook
Collected here are the typical tasks that arise in almost every hlf-core project, along with short solutions to them — each reads in a minute and relies on concepts already covered rather than introducing new ones. If you need the full path of assembling a command in its entirety, it is described in the tutorial; here are the individual techniques you come back to as the project grows.
Your Own Signing Algorithm
The task — the chaincode must accept a signature with an algorithm that is not among the ready-made
packages. Since signing in hlf-core is lifted to the application level and hidden behind
ITransportCryptoManager, adding an algorithm touches neither the commands nor the handlers — all you need
is a new manager and one line of registration.
The manager is responsible for three things — the algorithm name, signing, and verification. The canonical
string that both sides sign is assembled by the base TransportCryptoManager.toSign, so the manager only
has to apply its own cryptographic primitive to it:
import { ITransportCommand, ITransportCryptoManager, ISignature, TransportCryptoManager } from '@ts-core/common';
export class TransportCryptoManagerMyAlgorithm extends TransportCryptoManager implements ITransportCryptoManager {
public static ALGORITHM = 'MyAlgorithm';
public get algorithm(): string {
return TransportCryptoManagerMyAlgorithm.ALGORITHM;
}
public async sign<U>(command: ITransportCommand<U>, nonce: string, privateKey: string): Promise<string> {
let message = TransportCryptoManager.toSign(command, nonce);
return MyPrimitive.sign(message, privateKey);
}
public async verify<U>(command: ITransportCommand<U>, signature: ISignature): Promise<boolean> {
let message = TransportCryptoManager.toSign(command, signature.nonce);
return MyPrimitive.verify(message, signature.value, signature.publicKey);
}
}
Next, the manager is added to the receiver's cryptoManagers array, and that is enough — the chaincode
starts accepting commands signed with this algorithm, while an unknown algorithm is still rejected:
const transport = new TransportFabricChaincodeReceiver(logger, {
cryptoManagers: [new TransportCryptoManagerEd25519(), new TransportCryptoManagerMyAlgorithm()],
});
The choice of manager during verification goes by the signature.algorithm field, so on the client it is
enough to sign the command with the same manager — nothing else needs to be coordinated. Details on the
canonical string and the ready-made algorithms — in the Cryptography section.
A New Token with an Initial Emission
The task — to issue a coin and immediately credit an initial amount to accounts. Application code usually does not touch the balance manager directly, but goes through a service that validates the input, performs the operation, and dispatches events. The coin itself is created by a factory with a zero balance, after which the initial emission is credited to it:
let item = CoinFactory.transform({ uid, balance: { held: '0', inUse: '0', burned: '0', emitted: '0' } });
let manager = new CoinManager(logger, holder.stub);
await manager.add(item);
await holder.stub.dispatch(new CoinAddedEvent({ coin: item, initiatorUid: params.initiatorUid }));
for (let { object, value } of params.emission ?? []) {
if (MathUtil.greaterThan(value, '0')) {
await manager.emitInitial(item, object, value);
await holder.stub.dispatch(new CoinEmittedEvent({ coinUid: item.uid, value, object, initiatorUid: params.initiatorUid }));
}
}
Two points that are easy to miss. First, the coin is created with a zero balance, and the initial amount is
credited as a separate emitInitial step, with each crediting accompanied by its own event. Second, amounts
everywhere are strings and go through MathUtil, so the check greaterThan(value, '0') is a string
comparison, not a numeric one. The full account and balance model — in the Tokens
section.
A Guard Linking publicKey and userId
The task — to make sure that the command was sent by exactly the user it claims to be from. The transport
verifies that the signature is mathematically valid and corresponds to the publicKey, but it does not know
whose key it is — the link "the key belongs to this userId" lives in application state, and it must be closed
by a guard before the business logic. The canonical recipe loads the user by stub.user.id, checks the
status, and matches the key recorded in the ledger against the signing key:
async function validateUserStubHolder<U>(logger: ILogger, item: IUserStubHolder<U>, options: IUserGuardOptions): Promise<void> {
if (_.isEmpty(options) || !options.isNeedCheck) {
return;
}
let { id } = item.stub.user;
let { user } = item;
if (_.isNil(user)) {
item.manager = new UserManager(logger, item.stub);
item.user = user = await item.manager.get(id, ['cryptoKey']);
}
if (_.isNil(user)) {
throw new UserNotFoundError(id);
}
let { status, cryptoKey } = user;
if (status !== UserStatus.ACTIVE) {
throw new UserStatusForbiddenError({ value: status, expected: UserStatus.ACTIVE });
}
if (_.isNil(cryptoKey)) {
throw new UserCryptoKeyNotFoundError(id);
}
if (cryptoKey.value !== item.stub.user.publicKey) {
throw new UserCryptoKeyInvalidError(id);
}
}
The key line is the last one: cryptoKey.value !== item.stub.user.publicKey. This is exactly where the key
the command was signed with is matched against the key recorded for the user in state. Without this check,
someone else's valid signature could be used to impersonate another userId. For reading commands, which
don't need the user, the check is turned off with the @UserGuard({ isNeedCheck: false }) option, and the
role check is kept separate — rolesCheck(holder, ...roles) right in execute. The full breakdown — in the
Command Handlers and Security sections.
Moving an Operation into a Batch
The task — there are many commands, each a small one, and the overhead of a separate transaction starts to outweigh the work itself. A batch gathers several commands and carries them out in a single transaction; the switch is made on two sides and does not require rewriting the commands themselves.
On the chaincode side the mode is set by the choice of receiver — when batch settings are present, the batch receiver is used:
useFactory: (logger: Logger) => {
if (_.isNil(settings.batch)) {
let item = new TransportFabricChaincodeReceiver(logger, settings);
item.createStubFactory = (logger, stub, payload, transport) =>
new TransportFabricStubStateProxy(logger, stub, payload.id, payload.options, transport);
return item;
}
return new TransportFabricChaincodeReceiverBatch(logger, settings);
}
On the client side, batch mode corresponds to TransportFabricBatch, and so as not to guess whether it is
enabled, the chaincode exposes its metadata via a readonly command — the client chooses the transport based
on it:
const metadata = await transport.sendListen(new ChaincodeMetadataGetCommand());
// from metadata.mode and metadata.batch the client picks TransportFabric or TransportFabricBatch
The main thing to remember is the price of the decision — the response to a mutating command does not come immediately, but later, from the block, as a map "command hash → response", and readonly commands do not go into the batch at all. This is a deliberate trade of response immediacy for throughput. More details — in the Batch Mode section.