Смарт-контракт для Fabric: зачётка на TypeScript
- Зачем это нужно
- Устройство контракта
- Проверки — внутри, всегда
- Главная ловушка: код считают несколько пиров
- Почему
Date.now()хуже, чемMath.random() - История ключа: то, чего нет в открытых сетях
- Сборка
- Типичные ошибки
- Шпаргалка по главе
- Словарь главы
- Что должен уметь после главы
- Проверка по главе
- Куда дальше: проверенные ресурсы
Зачем это нужно
Смарт-контракт в Fabric называется чейнкодом. Отличие от Solidity начинается с языка: здесь это обычный TypeScript, Go или Java — не специальный язык, а тот же, на котором пишут остальную часть системы.
Второе отличие важнее. В Ethereum контракт живёт в виртуальной машине, у него есть свои переменные, и эти переменные — часть цепочки. В Fabric контракт — это программа рядом с реестром. У неё нет «своих полей»: она читает и пишет в общее хранилище пар «ключ — значение» через переданный объект.
Эта глава — про то, как устроен такой контракт, и про главную ловушку, которой в Solidity не бывает вовсе: код исполняется несколькими пирами независимо, и результаты обязаны совпасть до последнего байта.
Устройство контракта
Заготовка проекта — четыре файла: 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, пишите их латиницей.
Главная ловушка: код считают несколько пиров
Вот место, где Fabric отличается от всего, что было раньше в треке.
Когда клиент отправляет вызов, он отправляет его каждому пиру, чья подпись нужна по политике. Каждый пир выполняет функцию у себя, получает набор изменений и подписывает его. Клиент собирает подписи и сверяет: результаты обязаны совпасть побайтово.
Добавим в контракт функцию со случайным баллом — так, как её написал бы человек, не знающий про это правило:
@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();- обращений к внешним службам по сети: два пира получат разные ответы;
- чтения файлов, переменных окружения, имени машины;
- перебора ключей объекта там, где порядок не гарантирован, если от порядка зависит результат.
История ключа: то, чего нет в открытых сетях
Хранилище состояния держит одно значение на ключ. Но сама цепочка помнит все записи, и 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":[]}}]
В 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"
Шпаргалка по главе
| Что нужно | Как |
|---|---|
| Прочитать значение | 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 на всё, без подсказок по ходу. Вопросы показываются по одному, ответ изменить нельзя. Если время выйдет — экзамен завершится с тем, что успел ответить. Пересдавать можно сколько угодно раз.
Куда дальше: проверенные ресурсы
- Чейнкод для разработчика — интерфейс
stubцеликом. - fabric-contract-api для Node — справочник по декораторам и контексту.
- asset-transfer-basic на TypeScript — эталонный пример из поставки.
- Приватные данные — что делать, когда часть данных не должна видеть даже своя организация.
Комментарии
Обсуждение живёт в GitHub Discussions и грузится с чужого домена — поэтому только по нажатию.