Перейти к основному содержимому
Блокчейн59ГЛАВА 59Смарт-контракт для Fabric: зачётка на TypeScript@Transaction()Zavesti(ctx, id)putState(id, ...)ball: 5ball: 3payloads do not match

Смарт-контракт для Fabric: зачётка на TypeScript

Прочитано0%
Квизы0/4
Тренажёрыне пройден0/1
Уровень 1 · Новичок

Зачем это нужно​

Смарт-контракт в Fabric называется чейнкодом. Отличие от Solidity начинается с языка: здесь это обычный TypeScript, Go или Java — не специальный язык, а тот же, на котором пишут остальную часть системы.

Второе отличие важнее. В Ethereum контракт живёт в виртуальной машине, у него есть свои переменные, и эти переменные — часть цепочки. В Fabric контракт — это программа рядом с реестром. У неё нет «своих полей»: она читает и пишет в общее хранилище пар «ключ — значение» через переданный объект.

Эта глава — про то, как устроен такой контракт, и про главную ловушку, которой в Solidity не бывает вовсе: код исполняется несколькими пирами независимо, и результаты обязаны совпасть до последнего байта.

Устройство контракта​

КОНТРАКТ — ЭТО КЛАСС, А ХРАНИЛИЩЕ — ЭТО CTX.STUBclass Zachetka extends Contract@Transaction()async Zavesti(ctx, id, fio)@Transaction(false)async Pokazat(ctx, id)публичные методы = функции контрактаctx.stub — вход в реестрgetState(id)прочитать значениеputState(id, bytes)записать значениеdeleteState(id)пометить удалённымgetHistoryForKey(id)все версии ключавсё хранилище — пары «ключ → набор байт»; JSON туда кладут руками, через строкуаргументы функций — всегда строки: числа разбирают внутри и проверяют там же
Класс с публичными методами — снаружи это функции контракта; хранилище — через ctx.stub

Заготовка проекта — четыре файла: package.json, tsconfig.json, src/index.ts и сам контракт. Начнём с описания зачётки:

import { Context, Contract, Info, Returns, Transaction } from 'fabric-contract-api';

interface Zapis {
id: string;
fio: string;
gruppa: string;
ocenki: { predmet: string; ball: number }[];
}

@Info({ title: 'Zachetka', description: 'Зачётные книжки студентов' })
export class ZachetkaContract extends Contract {
@Transaction()
async Zavesti(ctx: Context, id: string, fio: string, gruppa: string): Promise<void> {
const est = await ctx.stub.getState(id);
if (est && est.length > 0) {
throw new Error(`Зачётка ${id} уже заведена`);
}
const zapis: Zapis = { id, fio, gruppa, ocenki: [] };
await ctx.stub.putState(id, Buffer.from(JSON.stringify(zapis)));
}
}
Нажми на часть выражения
Важно

getState для несуществующего ключа возвращает пустой буфер, а не null и не ошибку. Проверка «если существует» пишется как est && est.length > 0. Забыть про длину — самая частая ошибка новичка: пустой буфер истинный.

Проверки — внутри, всегда​

В Solidity проверки пишут через require. Здесь — обычным throw. Всё, что вылетело из функции, становится отказом в подтверждении:

@Transaction()
async PostavitOcenku(ctx: Context, id: string, predmet: string, ball: string): Promise<void> {
const zapis = await this.prochitat(ctx, id);
const n = Number(ball);
if (!Number.isInteger(n) || n < 2 || n > 5) {
throw new Error(`Балл ${ball} вне диапазона 2..5`);
}
zapis.ocenki.push({ predmet, ball: n });
await ctx.stub.putState(id, Buffer.from(JSON.stringify(zapis)));
}

private async prochitat(ctx: Context, id: string): Promise<Zapis> {
const bytes = await ctx.stub.getState(id);
if (!bytes || bytes.length === 0) {
throw new Error(`Зачётки ${id} не существует`);
}
return JSON.parse(bytes.toString()) as Zapis;
}

Приватный метод prochitat не помечен декоратором — значит, снаружи он не виден и функцией контракта не является. Это удобный способ вынести общий код.

Проверим на живой сети:

peer chaincode invoke ... -c '{"function":"PostavitOcenku","Args":["z-1","Физкультура","7"]}'
Error: endorsement failure during invoke. response: status:500
message:"\320\221\320\260\320\273\320\273 7 \320\262\320\275\320\265 \320\264\320\270\320\260\320\277\320\260\320\267\320\276\320\275\320\260 2..5"
Совет

Кириллицу утилита peer печатает восьмеричными последовательностями — это не порча текста, а способ показать не-ASCII в терминале. Сообщение целое, просто закодировано. Если такие тексты будут читать глазами через CLI, пишите их латиницей.

Отвечено вопросов: 0 из 3
Что возвращает getState для несуществующего ключа?
Как в чейнкоде отказывают в операции?
Почему аргументы функций — строки?
Отвечено верно: 0 из 3

Главная ловушка: код считают несколько пиров​

Вот место, где Fabric отличается от всего, что было раньше в треке.

Когда клиент отправляет вызов, он отправляет его каждому пиру, чья подпись нужна по политике. Каждый пир выполняет функцию у себя, получает набор изменений и подписывает его. Клиент собирает подписи и сверяет: результаты обязаны совпасть побайтово.

ОДИН И ТОТ ЖЕ ВЫЗОВ СЧИТАЮТ ДВА ПИРА НЕЗАВИСИМОpeer0.org1"ball": 4peer0.org2"ball": 3Error: could not assemble transaction:ProposalResponsePayloads do not matchсделка не собралась: до упорядочивателя она не доехалаMath.random()ломается всегдаDate.now()ломается через раз — если пиры попали в разные миллисекундыctx.stub.getTxTimestamp()время берётся из самой сделки и у всех одинаковое
Два пира посчитали разное — сделка не собралась и до упорядочивателя не доехала

Добавим в контракт функцию со случайным баллом — так, как её написал бы человек, не знающий про это правило:

@Transaction()
async PostavitSluchaino(ctx: Context, id: string, predmet: string): Promise<void> {
const zapis = await this.prochitat(ctx, id);
const ball = 3 + Math.floor(Math.random() * 3);
zapis.ocenki.push({ predmet, ball });
await ctx.stub.putState(id, Buffer.from(JSON.stringify(zapis)));
}

Вызов:

Error: could not assemble transaction: ProposalResponsePayloads do not match (base64):
'...eyJwcmVkbWV0Ijoi0KTQuNC70L7RgdC+0YTQuNGPIiwiYmFsbCI6NH1dfQ...'
vs
'...eyJwcmVkbWV0Ijoi0KTQuNC70L7RgdC+0YTQuNGPIiwiYmFsbCI6M31dfQ...'

В конце обеих длинных строк видно расхождение: у одного пира "ball":4, у другого "ball":3. Сделка не собралась — она даже не дошла до упорядочивателя и в цепочку не попала.

Важно

Сообщение ProposalResponsePayloads do not match означает ровно одно: ваш код недетерминирован. Не ищите ошибку в сети — ищите в функции всё, что может дать разный ответ на разных машинах.

Почему Date.now() хуже, чем Math.random()​

Со случайным числом всё честно: ломается сразу и всегда. А теперь вторая функция — отметка времени:

@Transaction()
async OtmetitVremya(ctx: Context, id: string): Promise<void> {
const zapis = await this.prochitat(ctx, id);
zapis.gruppa = `${zapis.gruppa} @ ${Date.now()}`;
await ctx.stub.putState(id, Buffer.from(JSON.stringify(zapis)));
}

Три вызова подряд на живой сети:

1) INFO [chaincodeCmd] chaincodeInvokeOrQuery -> Chaincode invoke successful. result: status:200
2) Error: could not assemble transaction: ProposalResponsePayloads do not match ...
3) Error: could not assemble transaction: ProposalResponsePayloads do not match ...

Первый вызов прошёл. Два пира успели попасть в одну и ту же миллисекунду, строки совпали, сделка собралась и записалась. Следующие два не успели.

Это хуже случайного числа, потому что такая ошибка проходит тестирование. На ненагруженной машине пиры часто попадают в одну миллисекунду, и разработчик делает вывод «работает». Ломаться начнёт под нагрузкой и у заказчика.

Правильный способ взять время — из самой сделки:

const ts = ctx.stub.getTxTimestamp();

Эта отметка проставлена клиентом один раз и приезжает всем пирам одинаковой. То же правило касается любых других источников расхождения.

Чего не должно быть в чейнкоде:

  • Math.random() и любой другой источник случайности;
  • Date.now(), new Date() — только ctx.stub.getTxTimestamp();
  • обращений к внешним службам по сети: два пира получат разные ответы;
  • чтения файлов, переменных окружения, имени машины;
  • перебора ключей объекта там, где порядок не гарантирован, если от порядка зависит результат.
Отвечено вопросов: 0 из 3
Что означает «ProposalResponsePayloads do not match»?
Почему Date.now() в чейнкоде опаснее Math.random()?
Как правильно взять время в чейнкоде?
Отвечено верно: 0 из 3

История ключа: то, чего нет в открытых сетях​

Хранилище состояния держит одно значение на ключ. Но сама цепочка помнит все записи, и Fabric даёт к ним прямой доступ:

@Transaction(false)
@Returns('string')
async Istoriya(ctx: Context, id: string): Promise<string> {
const it = await ctx.stub.getHistoryForKey(id);
const versii: unknown[] = [];
for (let r = await it.next(); !r.done; r = await it.next()) {
const v = r.value;
versii.push({
txId: v.txId,
udalena: v.isDelete,
znachenie: v.value.length ? JSON.parse(v.value.toString()) : null,
});
}
await it.close();
return JSON.stringify(versii);
}
Нажми на часть выражения

Вызов после четырёх записей выдаёт всю историю:

[{"txId":"a23931d6…","udalena":false,"znachenie":{…"ocenki":[три оценки]}},
{"txId":"4b9288b1…","udalena":false,"znachenie":{…"ocenki":[две оценки]}},
{"txId":"be7d7a83…","udalena":false,"znachenie":{…"ocenki":[одна оценка]}},
{"txId":"8d102bdb…","udalena":false,"znachenie":{…"ocenki":[]}}]
СОСТОЯНИЕ — ОДНО, ИСТОРИЯ — ВСЯСостояние (быстрая база)z-1 → три оценкиодно значение на ключ,читается мгновенноИстория (сама цепочка)txId a23931… → три оценкиtxId 4b9288… → две оценкиtxId be7d7a… → одна оценкаtxId 8d102b… → пустая зачёткаудалили ключ — из состояния он исчез, из истории нет: там останется запись с пометкой «удалён»историю выдаёт getHistoryForKey — в открытых сетях такого вызова нет вообщеименно это делает Fabric удобным для журналов: кто, когда и чем поменял запись, видно из коробки
Состояние отвечает «как сейчас», история — «как было и кто менял»
Интересный факт

В Ethereum такого вызова нет. Там, чтобы узнать историю поля, пишут события и потом собирают их снаружи. Здесь история ключа — встроенная возможность, и именно она делает Fabric естественным выбором для журналов: дипломов, поставок, медицинских карт.

Сборка​

Fabric запускает чейкод в контейнере и вызывает npm start. Для TypeScript это значит: собрать проект заранее, а в package.json указать собранный файл.

{
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"start": "fabric-chaincode-node start"
},
"dependencies": {
"fabric-contract-api": "^2.5.4",
"fabric-shim": "^2.5.4"
}
}

И точка входа, которая перечисляет контракты:

import { ZachetkaContract } from './zachetka';
export { ZachetkaContract } from './zachetka';
export const contracts = [ZachetkaContract];
Нажми на часть выражения

Проверка перед упаковкой — обычная:

npm run build
npx tsc --noEmit

Типичные ошибки​

Ошибка 1. Проверка существования без длины.

if (est) { throw new Error('уже есть'); } // сработает ВСЕГДА

Пустой буфер истинный. Нужно est && est.length > 0.

Ошибка 2. Забыли await.

getState и putState асинхронные. Без await функция завершится раньше записи, и в набор изменений ничего не попадёт — молча, без ошибки.

Ошибка 3. Число пришло строкой.

zapis.ocenki.push({ predmet, ball }); // ball === "5", а не 5

Из сети всё приходит строками. Забыли Number(...) — в реестр уедет строка, и первое же сравнение ball > 4 начнёт врать.

Ошибка 4. Не собрали проект перед упаковкой.

Пакет соберётся, установится и даже одобрится. Упадёт только на первом вызове — потому что dist/index.js в нём нет.

Ошибка 5. Забыли export const contracts.

Error: endorsement failure during invoke. response: status:500
message:"Contract name is not known"
Отвечено вопросов: 0 из 3
Чем @Transaction(false) отличается от @Transaction()?
Что даёт getHistoryForKey?
Функция без await у putState завершилась без ошибки, но запись не появилась. Почему?
Отвечено верно: 0 из 3

Шпаргалка по главе​

Что нужноКак
Прочитать значениеawait ctx.stub.getState(id) → байты, пустые если нет
Записать значениеawait ctx.stub.putState(id, Buffer.from(JSON.stringify(v)))
Удалитьawait ctx.stub.deleteState(id) — из состояния, не из истории
Все версии ключаawait ctx.stub.getHistoryForKey(id)
Время сделкиctx.stub.getTxTimestamp()
Кто вызвалctx.clientIdentity.getMSPID()
Пометить чтение@Transaction(false) над методом
Собрать проектnpm run build → dist/index.js

Словарь главы​

ТерминЧто это
ЧейнкодСмарт-контракт Fabric: обычная программа на TypeScript, Go или Java
Контекст (ctx)Первый аргумент любой функции контракта; внутри него stub
stubЕдинственный вход в реестр: чтение, запись, история, время сделки
СостояниеБыстрая база «ключ → значение»: одно текущее значение на ключ
История ключаВсе версии значения с номерами сделок; хранится в самой цепочке
ДетерминизмТребование: одинаковый вызов даёт одинаковый результат на всех пирах
Набор измененийТо, что пир подписывает: какие ключи прочитаны и какие записаны
@Transaction(false)Пометка «только чтение»: блок для такого вызова не нужен

Цель: скорость ≥ 95 зн/мин, точность ≥ 88%

export class ZachetkaContract extends Contract {

Что должен уметь после главы​

  • написать контракт-класс с функциями чтения и записи;
  • правильно проверить существование ключа и разобрать числовой аргумент;
  • объяснить, почему код исполняется несколькими пирами и что такое детерминизм;
  • узнать недетерминизм по сообщению ProposalResponsePayloads do not match;
  • взять время сделки правильным способом;
  • достать историю ключа и объяснить, чем она отличается от состояния;
  • собрать проект так, чтобы среда исполнения нашла точку входа.

Проверка по главе​

Сводная проверка по всей главе: 10 вопросов, 5:00 на всё, без подсказок по ходу. Вопросы показываются по одному, ответ изменить нельзя. Если время выйдет — экзамен завершится с тем, что успел ответить. Пересдавать можно сколько угодно раз.

Куда дальше: проверенные ресурсы​

Видео по теме

ВидеоJavaScript Smart Contracts for Hyperledger Fabric - Part 2 | ConvectorCovalent
ВидеоHyperledger Fabric: Coding the Fabric with NodeBlockchain Technology Conference
ВидеоTech Tuesday: How to Write and Deploy Chaincode for Hyperledger FabricKaleido
ВидеоIntro to Smart Contract Development in Hyperledger Fabric with CC-ToolsGoLedger
ВидеоBlockchain Tutorials | Hyperledger Fabric | ChainCodeStubInterface IntroductionUpskilltym

Комментарии

Обсуждение живёт в GitHub Discussions и грузится с чужого домена — поэтому только по нажатию.