Розробка системи управління документооборотом на блокчейні
Класична задача: нотаріально завірена контракт, акт приймання або фінансовий документ повинні бути верифіковані будь-якою стороною без звертання до центральної організації. Існуючі рішення — централізовані реєстри або PKI-інфраструктура з CA — працюють, поки організації довіряють один одному та центральному регулятору. У cross-border сценаріях або при спорах ця умова перестає виконуватися.
Блокчейн тут не замінює систему зберігання документів — він замінює нотаріуса. Документ живе в захищеному сховищі (IPFS, S3), а факт його існування у конкретний момент часу, неїзмінність та список авторизованих підписантів фіксуються on-chain. Цей принциповий розподіл потрібно розуміти з самого початку.
Криптографічні основи документообороту
Document Commitment
Будь-яка система proof-of-existence побудована на одному принципі: hash(document) публікується on-chain. Але реалізація важлива в деталях.
Простий хеш (anti-pattern):
mapping(bytes32 => uint256) public timestamps;
function notarize(bytes32 docHash) external {
timestamps[docHash] = block.timestamp;
}
Проблема: якщо два документи відрізняються тільки timestamp'ом — вони дадуть різні хеші, обидва будуть «нотаріально завірені». Проблема глибша: якщо документ відозваний — немає механізму це відразити. Немає зв'язку між хешем та підписантом — хто завгодно може нотаріально завірити чужий документ.
Правильна структура:
struct DocumentRecord {
bytes32 contentHash; // keccak256 від вмісту
bytes32 metadataHash; // хеш від метаданих (без вмісту)
address registrant; // хто зареєстрував
uint256 registeredAt;
DocumentStatus status;
bytes32[] signatories; // DID хеші підписантів
uint256 expiresAt; // 0 = бессрочно
}
enum DocumentStatus { PENDING, ACTIVE, REVOKED, EXPIRED }
mapping(bytes32 => DocumentRecord) public documents;
// docId = keccak256(contentHash + registrant + registeredAt)
event DocumentRegistered(
bytes32 indexed docId,
bytes32 indexed contentHash,
address indexed registrant
);
event DocumentSigned(
bytes32 indexed docId,
bytes32 indexed signatoryDid,
bytes signature
);
event DocumentRevoked(
bytes32 indexed docId,
address revokedBy,
string reason
);
Криптографічна часова мітка (RFC 3161 on-chain)
Для юридично значимих документів важливий не просто факт публікації хеша, а доказ що документ існував у момент T. Проблема: block.timestamp в EVM можна маніпулювати майнером/валідатором в межах ~15 секунд. Для більшості use cases це несущественно, але для юридичних документів краще використовувати:
-
Block hash commitment: публікуємо
keccak256(docHash || blockHash(N-1))— привʼязка до конкретного блоку, не просто timestamp - Chainlink або інший VRF oracle для додаткового джерела ентропії
- Anchor to Bitcoin через OP_RETURN — Bitcoin timestamping через сервіси типу OpenTimestamps
function registerWithBlockCommitment(
bytes32 contentHash,
bytes32 metadataHash
) external returns (bytes32 docId) {
// Привʼязуємо до хеша попереднього блоку
bytes32 blockCommitment = keccak256(abi.encodePacked(
contentHash,
blockhash(block.number - 1),
block.timestamp,
msg.sender
));
docId = keccak256(abi.encodePacked(contentHash, msg.sender, block.timestamp));
documents[docId] = DocumentRecord({
contentHash: contentHash,
metadataHash: metadataHash,
registrant: msg.sender,
registeredAt: block.timestamp,
status: DocumentStatus.ACTIVE,
signatories: new bytes32[](0),
expiresAt: 0
});
emit DocumentRegistered(docId, contentHash, msg.sender);
return docId;
}
Електронний підпис та multi-party signing workflow
EIP-712 структуровані підписи
Для підписування документів замість простого ecrecover використовуємо EIP-712 — типізовані структуровані дані. Це дає читаємі prompt'и в MetaMask та захист від cross-chain replay:
bytes32 private constant DOCUMENT_SIGNING_TYPEHASH = keccak256(
"DocumentSigning(bytes32 docId,bytes32 contentHash,uint256 signedAt,string signatoryRole)"
);
struct DocumentSigning {
bytes32 docId;
bytes32 contentHash;
uint256 signedAt;
string signatoryRole;
}
function signDocument(
bytes32 docId,
string calldata signatoryRole,
uint256 signedAt,
bytes calldata signature
) external {
DocumentRecord storage doc = documents[docId];
require(doc.status == DocumentStatus.ACTIVE, "Document not active");
require(signedAt <= block.timestamp, "Future timestamp");
require(block.timestamp - signedAt < 3600, "Signature too old");
// Восстанавливаємо підписанта з EIP-712 підпису
bytes32 structHash = keccak256(abi.encode(
DOCUMENT_SIGNING_TYPEHASH,
docId,
doc.contentHash,
signedAt,
keccak256(bytes(signatoryRole))
));
bytes32 digest = _hashTypedDataV4(structHash);
address signer = ECDSA.recover(digest, signature);
require(isAuthorizedSignatory(signer, docId), "Not authorized signatory");
require(!hasAlreadySigned(signer, docId), "Already signed");
doc.signatories.push(keccak256(abi.encode(signer)));
emit DocumentSigned(docId, keccak256(abi.encode(signer)), signature);
// Перевіряємо завершення
if (_checkSigningComplete(docId)) {
doc.status = DocumentStatus.ACTIVE;
emit DocumentFullySigned(docId);
}
}
Workflow multi-party approval
Для складних документів (договір купівлі-продажу, акт приймання) потрібен workflow з порядком підписання:
struct SigningWorkflow {
bytes32 docId;
SigningStep[] steps;
uint256 currentStep;
bool isSequential; // послідовна або паралельна підпис
}
struct SigningStep {
address[] requiredSigners;
uint256 threshold; // скільки з requiredSigners повинні підписати
uint256 deadline;
bool completed;
}
Приклад: акт приймання робіт
- Виконавець підписує (крок 1, обов'язково)
- Технадзор замовника підписує (крок 2, обов'язково)
- Фінансовий директор замовника підписує АБО будь-який з двох авторизованих представників (крок 3, поріг 1 з 2)
Зберігання документів: гібридна архітектура
IPFS + Encryption
Документи зберігаються зашифрованими в IPFS. Ключ шифрування управляється окремо — через on-chain access control:
import { create } from 'ipfs-http-client'
import { encrypt, decrypt } from '@metamask/eth-sig-util'
async function uploadEncryptedDocument(
file: Buffer,
docId: string,
authorizedAddresses: string[]
): Promise<string> {
// Генеруємо симетричний ключ
const aesKey = crypto.getRandomValues(new Uint8Array(32))
// Шифруємо документ
const iv = crypto.getRandomValues(new Uint8Array(12))
const cipher = await crypto.subtle.importKey('raw', aesKey, 'AES-GCM', false, ['encrypt'])
const encrypted = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, cipher, file)
// Шифруємо AES ключ публічними ключами кожної авторизованої адреси
const encryptedKeys: Record<string, string> = {}
for (const address of authorizedAddresses) {
const pubKey = await getPublicEncryptionKey(address) // з MetaMask
encryptedKeys[address] = encryptForPublicKey(pubKey, Buffer.from(aesKey))
}
// Публікуємо в IPFS: зашифрований документ + encrypted keys bundle
const ipfs = create({ url: process.env.IPFS_API })
const { cid } = await ipfs.add(JSON.stringify({
encrypted: Buffer.from(encrypted).toString('base64'),
iv: Buffer.from(iv).toString('base64'),
encryptedKeys,
}))
return cid.toString()
}
Версіонування документів
Документи в business workflow редагуються. Потрібна ланцюг версій з можливістю довести що версія V2 є апдейтом V1:
struct DocumentVersion {
bytes32 contentHash;
bytes32 previousVersionId; // 0x0 для першої версії
address updatedBy;
uint256 updatedAt;
string changeDescription;
}
mapping(bytes32 => bytes32[]) public documentVersions; // docId → version hashes
mapping(bytes32 => DocumentVersion) public versionDetails;
function publishNewVersion(
bytes32 docId,
bytes32 newContentHash,
string calldata changeDescription
) external onlyDocumentOwner(docId) {
bytes32[] storage versions = documentVersions[docId];
bytes32 previousHash = versions.length > 0 ? versions[versions.length - 1] : bytes32(0);
bytes32 versionId = keccak256(abi.encodePacked(
docId, newContentHash, block.timestamp, versions.length
));
versionDetails[versionId] = DocumentVersion({
contentHash: newContentHash,
previousVersionId: previousHash,
updatedBy: msg.sender,
updatedAt: block.timestamp,
changeDescription: changeDescription
});
versions.push(versionId);
emit DocumentVersionPublished(docId, versionId, versions.length - 1);
}
Access Control та конфіденціальність
Zero-Knowledge disclosure
У деяких випадках потрібно довести факт про документ без розкриття його вмісту. Приклад: довести що сума в контракті > X, не розкриваючи точну суму.
Вирішується через ZK-proof (наприклад, Groth16 або PLONK):
// Circom circuit: доказ що сума контракту > threshold
pragma circom 2.0.0;
template ContractAmountProof() {
signal input contractAmount; // приватний інпут
signal input threshold; // публічний
signal input documentHash; // публічний (верифікатор знає документ)
signal output isAboveThreshold;
// Перевіряємо що сума > порогу
component gt = GreaterThan(64);
gt.in[0] <== contractAmount;
gt.in[1] <== threshold;
isAboveThreshold <== gt.out;
// Привʼязка до конкретного документу (commitment)
// Верифікатор упевнений що ми знаємо contractAmount для цього документу
}
Selective disclosure через NFC/QR
Для фізичних документів (паспорт транспортного засобу, сертифікат якості): QR-код містить docId. Сканування QR, верифікатор запитує on-chain запис та отримує тільки публічні метаданні. Для доступу до повного документу потрібен авторизований гаманець.
Інтеграція з legacy системами
Більшість enterprise-клієнтів мають ERP/ECM системи (SAP, 1C, OpenText). Інтеграція через:
Webhook підхід: при створенні документу в ECM — викликається webhook → oracle service → on-chain реєстрація. Прозоро для користувачів ERP.
OpenAPI adapter:
openapi: "3.0.0"
info:
title: Document Notarization API
paths:
/documents/register:
post:
summary: Register document hash on blockchain
requestBody:
content:
application/json:
schema:
type: object
properties:
documentId: { type: string }
contentHash: { type: string, pattern: "^0x[0-9a-f]{64}$" }
metadata: { type: object }
signatories:
type: array
items: { type: string } # Ethereum addresses
Blockchain-агностичний шар: розробляємо абстракцію поверх конкретного блокчейну. Клієнт отримує documentId — UUID у звичному форматі. Маппинг UUID → on-chain docId зберігається в PostgreSQL. Це дозволяє мігрувати між блокчейнами без змін API контрактів.
Юридичні аспекти
Blockchain-нотаризація має різний правовий статус в різних юрисдикціях:
- EU (eIDAS 2.0) — визнає електронні підписи, але blockchain timestamp ≠ кваліфікована електронна підпис автоматично
- США — ESIGN Act визнає електронні підписи, але для specific use cases (іпотека, нотаріат) вимагає більше
- ОАЕ — Dubai Blockchain Strategy, активне прийняття blockchain-based документів
Для юридично обов'язуючих документів розробляємо гібридний підхід: blockchain timestamp + кваліфікована електронна підпис (КЭП) через сертифікований CA.
Етапи проекту
| Фаза | Вміст | Тривалість |
|---|---|---|
| Requirements | Аналіз документообороту, типи документів, учасники, юрисдикція | 2–3 тижні |
| Core contracts | Document registry, signing workflow, access control | 3–4 тижні |
| Storage layer | IPFS integration, encryption, versioning | 2–3 тижні |
| API & Integration | REST API, webhooks, ERP-коннектори | 3–4 тижні |
| Frontend | Інтерфейс підписання, верифікації, управління | 3–4 тижні |
| Security audit | Смарт-контракти + backend | 2–3 тижні |
| Pilot | Запуск з реальними документами, зворотний зв'язок | 2–3 тижні |
Всього: 17–24 тижні. Критичний шлях — інтеграція з ERP-системами та юридична експертиза в конкретній юрисдикції.







