Tokens: accounts and balances
hlf-core splits token work into two packages, and it is the same split as everywhere else: the
vocabulary apart, the execution apart. @hlf-core/coin holds the
platform-neutral models, commands and events, while @hlf-core/coin-chaincode turns
them into real state changes. Below we look specifically at the model and how domain logic is built on
top of it.
Account and balance
A coin is described by Coin with a balance field, and the owner's state is an account. An account's
address is not a string but a { ownerUid, id } pair: a single owner can have several accounts per
coin — OWNER, PAYOUT, TREASURY and so on. The address object is deliberate, so that the two
sides of a transfer cannot accidentally be swapped. The id itself comes from an enum, not from an
arbitrary string:
export enum CoinAccountId {
OWNER = 'OWNER',
TREASURY = 'TREASURY',
PAYOUT = 'PAYOUT',
// ... the set of accounts is defined by the application model
}
A balance has four parts: inUse — what can be disposed of, held — locked for an operation, and
the audit pair emitted and burned, by which the full accounting of what was emitted and burned is
visible. All amounts are stored as strings, and this is fundamental — financial precision cannot be
entrusted to floating-point numbers, otherwise rounding inevitably accumulates a discrepancy. That is
why the arithmetic goes through MathUtil, and converting rubles to kopecks and back goes through
CoinUtil:
import { CoinUtil } from '@hlf-core/coin';
const uid = CoinUtil.createUid('RUB', 2, ownerUid); // "coin_<owner>_2_RUB"
const { decimals } = CoinUtil.decomposeUid(uid); // 2
CoinUtil.toCent('1.50', decimals); // "150"
CoinUtil.fromCent('150', decimals); // "1.5"
Movement primitives
A coin manager inherits CoinManager from @hlf-core/coin-chaincode and carries the whole set of
operations over balances — emit and emitHeld, burn and burnHeld, hold and unhold, nullify, and an entire family
of transfers transfer, transferToHeld, transferFromHeld, transferFromToHeld. Accounts are read via
accountGet and accountList. Usually application code does not touch the manager directly but goes through
CoinService — a facade that validates the input, performs the operation and dispatches an event. Creating
a coin with an initial emission looks like this:
let item = CoinFactory.transform({ uid, balance: { held: '0', inUse: '0', burned: '0', emitted: '0' } });
let manager = new CoinManager(this.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 }));
}
}
Domain validation: the last line of defense
@hlf-core/coin on its own does not check permissions — it provides the mechanics, while the decision
of "who is allowed" is left to the application logic. And the right place for this logic is not only the
backend: the backend can be bypassed, so the last line of defense is the manager itself, beyond which
permissions are already immutable and become the conditions of emission. This is done by overriding the
protected primitives _emit, _burn, _hold, _transfer — a domain check is inserted into them before
the call to super:
export class CoinManager<C extends ICoin> extends CoinManagerBase<C> {
protected async validate(action: CoinAction, coin: C, account?: ICoinAccount, value?: string): Promise<void> {
// domain check before the operation; "now" is taken from the transaction, not from Date
if (!this.isActionAllowed(action, coin, account, this.stub.transaction.date)) {
throw new CoinActionForbiddenError(coin.uid);
}
}
protected async _emit(item: ICoinAccountDetails<C>, value: string): Promise<void> {
await this.validate(CoinAction.EMIT, item.coin, item.account, value);
await super._emit(item, value);
}
}
The date check here is taken from stub.transaction.date — for the same reason of determinism that we
discuss in «Identity»: all endorsing peers must have the same time.
Manager polymorphism
Different kinds of assets may have different handling rules, and choosing behavior by the type of the action sent would be a mistake: the wrong manager would be substituted for someone else's action and would read the coin's data as its own. Therefore the manager is chosen by the kind of the asset itself — the coin is first read from state, and then the right class is picked by its attributes:
public async getManager<T extends ICoin>(stub: IStub, coin: string | ICoin): Promise<ICoinManager<T>> {
let item = _.isString(coin) ? await new CoinManager<ICoin>(this.logger, stub).get(coin) : coin;
// the base manager fits most coins, a special kind of asset gets its own
let classType: ClassType<ICoinManager<T>> = CoinManager<T>;
if (CoinUtil.isSpecial(item)) {
classType = SpecialCoinManager<T>;
}
return new classType(this.logger, stub);
}
Cost is worth keeping in mind separately here: a single transfer performs several state operations, and
accountList and deleting a coin traverse all of its accounts within one transaction — the price grows
linearly and sooner or later runs into Fabric's limits. It is precisely for scenarios with many
operations that batch mode exists.