Токены: счета и балансы
Работу с токенами hlf-core делит на два пакета, и это то же разделение, что и везде: отдельно
словарь, отдельно исполнение. @hlf-core/coin держит платформо-нейтральные
модели, команды и события, а @hlf-core/coin-chaincode превращает
их в реальные изменения состояния. Ниже мы разбираем именно модель и то, как на ней строится
доменная логика.
Счёт и баланс
Монету описывает Coin с полем balance, а состояние владельца — счёт. Адрес счёта это не строка, а
пара { ownerUid, id }: у одного владельца по одной монете бывает несколько счетов — OWNER,
PAYOUT, TREASURY и так далее. Объект-адрес сделан намеренно, чтобы стороны перевода нельзя
было случайно перепутать местами. Сам id берётся из enum, а не из произвольной строки:
export enum CoinAccountId {
OWNER = 'OWNER',
TREASURY = 'TREASURY',
PAYOUT = 'PAYOUT',
// ... набор счетов определяет прикладная модель
}
У баланса четыре составляющие: inUse — то, чем можно распоряжаться, held — заблокированное под
операцию, и пара аудита emitted и burned, по которой виден полный учёт выпущенного и сожжённого.
Все суммы хранятся строками, и это принципиально — финансовую точность нельзя доверять числам с
плавающей точкой, иначе на округлении неизбежно накапливается расхождение. Поэтому и арифметика идёт
через MathUtil, а перевод рублей в копейки и обратно — через 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"
Примитивы движения
Менеджер монет наследует CoinManager из @hlf-core/coin-chaincode и несёт весь набор операций над
балансами — emit и emitHeld, burn и burnHeld, hold и unhold, nullify, и целое семейство
переводов transfer, transferToHeld, transferFromHeld, transferFromToHeld. Счета читаются через
accountGet и accountList. Обычно прикладной код не трогает менеджер напрямую, а идёт через
CoinService — фасад, который валидирует вход, выполняет операцию и диспетчеризует событие. Создание
монеты с первичной эмиссией выглядит так:
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 }));
}
}
Доменная валидация: последний рубеж
Сам по себе @hlf-core/coin права не проверяет — он даёт механику, а решение «кому можно» остаётся за
прикладной логикой. И правильное место для этой логики не только backend: backend можно обойти,
поэтому последний рубеж — сам менеджер, дальше которого разрешения уже неизменны и становятся
условиями выпуска. Делается это переопределением защищённых примитивов _emit, _burn,
_hold, _transfer — в них вставляется доменная проверка перед вызовом super:
export class CoinManager<C extends ICoin> extends CoinManagerBase<C> {
protected async validate(action: CoinAction, coin: C, account?: ICoinAccount, value?: string): Promise<void> {
// доменная проверка перед операцией; «сейчас» берётся из транзакции, а не из 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);
}
}
Проверку даты здесь берут из stub.transaction.date — по той же причине детерминизма, о которой мы
говорим в «Идентификации»: у всех пиров-эндорсеров время должно быть одно.
Полиморфизм менеджеров
У разных видов активов могут быть разные правила обращения, и выбирать поведение по типу присланного действия было бы ошибкой: под чужое действие подставился бы не тот менеджер и прочитал бы данные монеты как свои. Поэтому менеджер выбирается по виду самого актива — монета сначала читается из состояния, а затем по её признакам подбирается нужный класс:
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;
// базовый менеджер подходит большинству монет, особый вид актива получает свой
let classType: ClassType<ICoinManager<T>> = CoinManager<T>;
if (CoinUtil.isSpecial(item)) {
classType = SpecialCoinManager<T>;
}
return new classType(this.logger, stub);
}
О стоимости здесь стоит помнить отдельно: один transfer делает несколько операций со state, а
accountList и удаление монеты обходят все её счета в одной транзакции — цена растёт линейно и рано
или поздно упирается в лимиты Fabric. Для сценариев с множеством операций ровно поэтому и существует
batch-режим.