Перейти к основному содержимому

Токены: счета и балансы

Работу с токенами 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-режим.