Децентрализованные хранилища: IPFS, Filecoin, Arweave, контентная адресация
Есть тема, которую в Web3 недооценивают сильнее всего. Про консенсус написаны сотни статей, про смарт-контракты — тысячи, а стоит спросить «где физически лежит картинка вашего NFT», и разговор становится неловким. Между тем именно здесь ломается больше всего реальных продуктов: контракт живёт, токен на месте, а по ссылке — 404. Причина в подмене понятий: «децентрализованное хранилище» звучит как одна технология, а на деле это три независимых свойства, которые обеспечиваются разными механизмами и стоят разных денег:
- целостность — я могу убедиться, что получил ровно те байты, а не подделку;
- доступность — прямо сейчас есть кто-то, кто их отдаст;
- долговечность — они будут доступны и через десять лет.
Контентная адресация даёт первое почти бесплатно. Второе и третье она не даёт вообще никак — их приходится покупать отдельно, деньгами или экономическими стимулами. Дальше мы разберём CID по байтам и посчитаем его руками; посмотрим, как IPFS режет файл на чанки, ищет владельцев через DHT и обменивается блоками; разберём, что именно доказывают PoRep и PoSt в Filecoin; посчитаем арифметику эндаумента Arweave и её ключевое допущение; напишем на Solidity правильное хранение ссылки. Никаких прогнозов курсов и советов, что покупать: разговор про инженерию и её честные компромиссы.
Почему данные не кладут в блокчейн
Начнём с цены. В Ethereum и EVM запись 32 байт в состояние (SSTORE с нуля) стоит 22 100 газа, килобайт — 32 слота, около 707 000 газа, а мегабайт потребовал бы порядка 700 миллионов и в блок не влезает физически. Дело не только в деньгах: состояние блокчейна реплицируется на каждую полную ноду и хранится вечно. Каждый байт, положенный туда, тысячи людей по всему миру будут таскать за собой десятилетиями. Это делает блокчейн худшим в мире хранилищем по цене за байт — и лучшим в мире реестром прав, потому что запись невозможно тихо отменить. Отсюда единственно разумная архитектура: в цепи — хеши и права, снаружи — байты.
| Куда положить | Цена за 1 КБ | Кто хранит | Как долго |
|---|---|---|---|
| Storage контракта | ~707 000 газа | каждая полная нода | пока жива сеть |
| Calldata транзакции | ~16 400 газа | архивные ноды | вся история блоков |
| Лог события | ~8 600 газа | индексаторы, архивные ноды | история блоков |
| Blob (EIP-4844) | отдельный рынок, обычно копейки | консенсус-ноды | ~18 суток, потом удаляется |
| IPFS + пиннинг | 22 100 газа разово за CID | тот, кто платит за пин | пока платите |
| Filecoin | 22 100 газа разово за CID | провайдер под залогом | срок сделки |
| Arweave | 22 100 газа разово за CID | майнеры сети | по замыслу — вечно |
Обратите внимание на нижние три строки: цена в цепи одинаковая и не зависит от размера файла, потому что в цепи лежат одни и те же 32 байта; различается только судьба байтов снаружи. Отдельно про блобы EIP-4844: их удаление через ~18 суток — не баг, а дизайн. Блобы решают задачу data availability для роллапов (об этом в «Масштабировании»): нужно гарантировать, что данные были опубликованы и любой мог их скачать, а не что они будут лежать вечно. Это отдельное свойство, и путать его с хранением — распространённая ошибка.
Контентная адресация: имя, вычисляемое из содержимого
Привычный веб адресует место: https://example.com/logo.png — это «спроси у машины, на которую указывает example.com, файл по такому пути». Что она вернёт, зависит от неё: захочет — вернёт другую картинку, уйдёт из бизнеса — не вернёт ничего, и проверить, что вам отдали «то самое», невозможно в принципе — у вас нет эталона. Контентная адресация переворачивает это: имя объекта — это хеш его содержимого. Адрес отвечает не «где лежит», а «что это». Свойства падают из определения:
- Проверяемость. Скачали байты, посчитали хеш, сравнили с именем. Совпало — данные подлинные, и неважно, от кого вы их получили: от официального сервера, от соседа по Wi-Fi или с флешки. Транспорт перестаёт быть доверенным.
- Дедупликация. Одинаковое содержимое — одно имя. Тысяча пользователей загрузила один и тот же PDF — в сети один объект.
- Кэшируемость без инвалидации. Кэш по контентному адресу не может протухнуть: содержимое по этому имени не изменится никогда.
- Неизменяемость как побочный эффект. Правка файла даёт новое имя. Это одновременно главная сила и главное неудобство: «обновить страницу по тому же адресу» напрямую нельзя, нужен отдельный слой изменяемых указателей.
Идея старше Web3: так устроен git (коммиты, деревья и блобы адресуются хешем содержимого — подробности в треке по git), так устроены Nix и Bazel, так работает дедупликация в бэкапах вроде restic и borg. Web3 добавил к ней сеть, которая ищет данные по такому имени у кого угодно.
Анатомия CID
В IPFS контентный адрес называется CID (Content Identifier). Это не просто хеш, а самоописывающая структура, из которой видно, чем хешировали, во сколько байт и как интерпретировать блок. Все три слоя ниже — из семейства спецификаций multiformats.
- multihash =
<код хеш-функции><длина дайджеста><дайджест>. Для sha2-256 это0x12 0x20 <32 байта>. Смысл: алгоритм не зашит в формат, и переход на blake3 или на постквантовый хеш не ломает парсеры. - multicodec — код перед multihash, говорящий, как читать блок:
0x55=raw(просто байты),0x70=dag-pb(protobuf-узел UnixFS),0x71=dag-cbor. - multibase — префикс текстовой записи:
b= base32 lowercase,z= base58btc,f= hex. Нужен, чтобы по строке всегда было однозначно понятно, в какой кодировке она записана.
Посчитаем CID руками, без библиотек. Код самодостаточный — запустите и сверьте с ipfs add:
import base64
import hashlib
B58 = b"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
def base58btc(data: bytes) -> str:
"""Кодировка CIDv0 — те самые имена Qm... O(n^2) по длине, но здесь n = 34 байта."""
n, out = int.from_bytes(data, "big"), b""
while n:
n, rem = divmod(n, 58)
out = B58[rem:rem + 1] + out
pad = len(data) - len(data.lstrip(b"\x00")) # ведущие нули кодируются символом '1'
return (b"1" * pad + out).decode()
def base32mb(data: bytes) -> str:
"""multibase 'b': RFC 4648 base32, нижний регистр, без padding."""
return "b" + base64.b32encode(data).decode().lower().rstrip("=")
def varint(value: int) -> bytes:
"""protobuf varint — им кодируются длины и коды в multiformats."""
out = b""
while True:
chunk, value = value & 0x7F, value >> 7
out += bytes([chunk | (0x80 if value else 0)])
if not value:
return out
payload = b"hello world"
multihash = b"\x12\x20" + hashlib.sha256(payload).digest() # sha2-256, 32 байта
# CIDv1 raw: версия 0x01, кодек raw 0x55, дальше multihash
print(base32mb(b"\x01\x55" + multihash))
# bafkreifzjut3te2nhyekklss27nh3k72ysco7y32koao5eei66wof36n5e
# Те же байты, но упакованные в UnixFS-узел — так делает `ipfs add` по умолчанию.
# UnixFS Data: поле 1 = тип (2 = File), поле 2 = байты, поле 3 = размер.
unixfs = b"\x08\x02\x12" + varint(len(payload)) + payload + b"\x18" + varint(len(payload))
# PBNode: Data — поле 1 (тег 0x0a), Links — поле 2; в кодировке они идут в обратном порядке.
mh_pb = b"\x12\x20" + hashlib.sha256(b"\x0a" + varint(len(unixfs)) + unixfs).digest()
print(base58btc(mh_pb)) # Qmf412jQZiuVUtdgnB36FXFX7xg5V6KEbSJ4dpQuhkLyfD — CIDv0
print(base32mb(b"\x01\x70" + mh_pb))
# bafybeihykld7uyxzogax6vgyvag42y7464eywpf55gxi5qpoisibh3c5wa — тот же блок как CIDv1
Контрольная точка: тот же код на пустом UnixFS-каталоге (блок 0a 02 08 01) обязан дать QmUNLLsPACCz1vLxQVkXqqLX5R1X345qqfHbsf67hvA3Nn — самый известный CID в экосистеме.
Первая ловушка: один файл, много CID
Заметили? Одиннадцать байт hello world дали три разных законных CID. Это не ошибка, а следствие того, что CID именует не «файл», а конкретный блок в конкретной упаковке. Разойтись достаточно по любому параметру:
| Параметр | Варианты | Кто по умолчанию как |
|---|---|---|
| Версия CID | v0 (base58, dag-pb) / v1 (base32, любой кодек) | ipfs add исторически v0; большинство JS-библиотек — v1 |
raw-leaves |
листья как raw или как dag-pb |
--cid-version=1 включает raw-leaves, если не выключить явно |
| Чанкер | фиксированный 256 КиБ / rabin / buzhash | фиксированный size-262144 |
| Ссылок в узле | 174 по умолчанию | зависит от реализации DAG-строителя |
| Обёртка в каталог | --wrap-with-directory |
выключено |
Практический вывод: если CID должен быть воспроизводимым — фиксируйте все флаги и записывайте их в документацию проекта. Иначе через год выяснится, что CI собирает релиз с другим CID, чем разработчик локально, и никто не понимает почему. Формулировка «CID — это хеш файла» неверна; правильно — «CID — это хеш дерева, построенного вот этими параметрами».
IPFS: что это на самом деле
Первое, что нужно выкинуть из головы: IPFS — не блокчейн и не хранилище. Там нет цепочки, консенсуса, монеты и ни одного механизма, заставляющего кого-либо хранить ваши данные. IPFS — это набор протоколов, чтобы адресовать данные по содержимому, находить, у кого они есть, и получать их с проверкой. Хранение — целиком ваша забота.
Данные: чанки и Merkle DAG
Файл не хранится целиком: он режется на блоки (по умолчанию 256 КиБ) и собирается в Merkle DAG — направленный ациклический граф, где каждый узел ссылается на детей по их CID.
фиксированные 256 КиБ"] C --> B1["chunk 0
256 КиБ"] C --> B2["chunk 1
256 КиБ"] C --> B3["chunk 2
188 КиБ"] B1 --> R["Корневой UnixFS-узел:
список CID детей + их размеры"] B2 --> R B3 --> R R --> CID["CID корня — имя всего файла.
Качаем блоками с разных пиров,
каждый проверяем отдельно"]
Почему DAG, а не дерево: одинаковые поддеревья схлопываются в один узел, и два файла с общим началом разделят все совпавшие чанки — если границы совпали. С фиксированным чанкером вставка одного байта в начало сдвигает все границы и убивает дедупликацию; для этого существует content-defined chunking (rabin, buzhash), ставящий границы по содержимому и переживающий вставки, ценой недетерминированных размеров и лишнего CPU. Сложность: построение DAG — O(n) по времени от размера файла и O(log_k n) по глубине при k = 174 ссылках на узел; выборка произвольного диапазона байт — O(log n) обращений к блокам, потому что размеры детей записаны в родителе и по ним можно навигировать, не читая сами чанки.
Маршрутизация: кто вообще держит этот блок
У вас есть CID. Как найти узел, у которого есть блок? Работает Kademlia DHT — распределённая хеш-таблица, где расстояние между идентификаторами есть XOR их битов, а поиск сходится за O(log n) шагов при n узлах. Ключевой момент, который многие упускают: в DHT лежат не данные, а «провайдер-записи» — сообщения вида «узел с таким PeerID заявляет, что у него есть блок с таким хешем». Записи имеют ограниченный срок жизни (в go-libp2p — порядка 48 часов), поэтому узел обязан периодически переанонсировать себя; в Kubo за это отвечает Reprovider.Interval. Ушедший в оффлайн узел через сутки-двое просто исчезает из поиска, даже если данные у него на диске.
Для крупных провайдеров DHT не масштабируется: анонсировать миллиарды CID по одному невозможно. Поэтому появился IPNI (InterPlanetary Network Indexer, публичный инстанс — cid.contact): провайдер отдаёт индексатору пачку объявлений одним куском, а клиенты спрашивают его по HTTP. Быстрее и дешевле — ценой того, что индексатор становится точкой, которую можно отключить или заставить фильтровать.
Передача: Bitswap и его наследники
Найдя провайдера, узел просит блоки протоколом Bitswap: клиент рассылает подключённым пирам список желаемого (WANT-HAVE — «есть ли у тебя?», WANT-BLOCK — «дай»), пиры отвечают HAVE/DONT_HAVE/блоком.
Важное отличие от BitTorrent: в Bitswap нет жёсткого tit-for-tat. В торренте вы обязаны раздавать, чтобы качать; в Bitswap узлы отдают блоки в основном из вежливости — низкий порог входа ценой отсутствия стимулов, отсюда и нестабильность раздачи. Помимо Bitswap есть GraphSync (запросить целое поддерево DAG одним запросом, экономя раунд-триппы) и trustless HTTP gateway (запросить сырой блок или CAR-файл обычным HTTP и проверить хеш самому). Последнее сейчас основной путь для браузеров: полноценный p2p-узел в вебе неудобен, а проверяемый HTTP даёт ту же гарантию целостности.
Пиннинг и сборка мусора: миф, который стоил денег многим
Вот здесь погибает большинство проектов. Формулировка «я загрузил файл в IPFS» не означает ничего. Буквально она означает: файл лежит на диске вашего узла и объявлен в DHT. Всё.
Что отсюда следует, дословно:
- Незакреплённые блоки удаляются первым же
ipfs repo gcили при достиженииStorageMax. Ваш собственный узел выкинет ваш файл. - Чужие узлы вам ничего не должны. Кто-то прочитал ваш CID через шлюз — блок осел в кэше шлюза до ближайшей уборки. Это не репликация, это кэш.
- Выключили ноутбук — файл недоступен. В сети нет копий, если вы их не оплатили.
- Популярность не создаёт копий. Никакого «чем чаще качают, тем надёжнее хранится» в протоколе нет.
Поэтому реальные проекты платят за пиннинг: свой сервер с узлом либо сервис (Pinata, Filebase, web3.storage и другие), общающийся по стандартизованному IPFS Pinning Service API. Честная формулировка звучит так: «мой файл хранится, пока я плачу конкретной компании» — по гарантиям это не сильно отличается от S3. Зато адресация остаётся контентной, и в этом реальная ценность: вы меняете хранилище, не меняя идентификаторы.
Изменяемость: IPNS, DNSLink, ENS
CID неизменяем, но сайты обновляются. Нужен слой указателей:
- IPNS — подписанная запись «ключ K сейчас указывает на CID X» с порядковым номером и TTL, публикуемая в DHT. Плюс: полностью децентрализованно. Минус: публикация и разрешение медленные (секунды-десятки секунд), запись нужно периодически переиздавать.
- DNSLink — TXT-запись
_dnslink.example.comсо значениемdnslink=/ipfs/<CID>. Быстро, понятно, работает везде — ценой возврата доверия к DNS и регистратору. - ENS contenthash (EIP-1577) — CID записан в ончейн-домен. Изменение стоит газа и видно в истории цепи, что для аудита плюс; это же поле читают браузеры с поддержкой
.eth.
Общее правило: изменяемый указатель — ровно тот компонент, где возвращается доверие. Кто владеет ключом IPNS, зоной DNS или ENS-именем, тот в любой момент подменит содержимое. Нужна честная неизменность (релизы, юридические документы, метаданные NFT) — публикуйте голый CID и не давайте себе возможности его поменять.
Шлюзы: удобство ценой централизации
Браузер не умеет ipfs://, поэтому существуют шлюзы: https://ipfs.io/ipfs/<CID> или https://<cid>.ipfs.dweb.link/. Второй вариант — subdomain gateway — предпочтительнее для веб-приложений: каждый CID получает свой origin, и браузерная изоляция не даёт одному загруженному сайту лазить в хранилище другого. Чем вы платите за шлюз:
- Шлюз видит, кто какой CID запросил. Это полноценный лог доступа, как у любого CDN.
- Шлюз может не отдать контент: публичные шлюзы применяют denylist (проект badbits — список хешей CID для блокировки нелегального контента). Данные в сети остаются, но конкретная дверь закрыта.
- Если вы не проверяете хеш на клиенте, шлюз может отдать что угодно, и вы не заметите. Вся ценность контентной адресации при этом испаряется.
Последний пункт лечится кодом. Минимальная проверка одноблочного raw-CID без внешних зависимостей:
const B32 = "abcdefghijklmnopqrstuvwxyz234567";
/** Достаём 32 байта дайджеста из текстовой формы CIDv1 в base32. */
function digestFromCidV1(cid: string): Uint8Array {
if (cid[0] !== "b") throw new Error("ожидается multibase 'b' (base32)");
let acc = 0, bits = 0;
const out: number[] = [];
for (const ch of cid.slice(1)) {
const idx = B32.indexOf(ch);
if (idx < 0) throw new Error(`недопустимый символ base32: ${ch}`);
acc = (acc << 5) | idx;
bits += 5;
if (bits >= 8) {
bits -= 8;
out.push((acc >> bits) & 0xff);
acc &= (1 << bits) - 1; // иначе acc переполнит 32-битные побитовые операции JS
}
}
const bytes = Uint8Array.from(out);
// раскладка: <0x01 версия><кодек><0x12 sha2-256><0x20 длина><32 байта дайджеста>
if (bytes[0] !== 0x01 || bytes[2] !== 0x12 || bytes[3] !== 0x20) {
throw new Error("поддержан только профиль CIDv1 / sha2-256");
}
return bytes.slice(4, 36);
}
/** Скачиваем сырой блок и проверяем сами. Шлюз здесь — просто транспорт. */
export async function fetchVerified(cid: string, gateway = "https://ipfs.io"): Promise<Uint8Array> {
// trustless-режим: отдай сырой блок, не собирай DAG за меня
const res = await fetch(`${gateway}/ipfs/${cid}`, { headers: { Accept: "application/vnd.ipld.raw" } });
if (!res.ok) throw new Error(`шлюз ответил ${res.status}`);
const bytes = new Uint8Array(await res.arrayBuffer());
const actual = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes));
const expected = digestFromCidV1(cid);
// Сравнение за постоянное время не нужно: секрета нет, проверяем только целостность.
if (actual.length !== expected.length || actual.some((b, i) => b !== expected[i])) {
throw new Error("шлюз отдал не тот контент — CID не сошёлся");
}
return bytes;
}
Для многоблочных файлов ручная проверка превращается в полноценную реализацию UnixFS, и правильный ответ здесь — библиотека: @helia/verified-fetch скачивает CAR или блоки и проверяет весь DAG локально, давая API, похожий на обычный fetch. Главное — чтобы проверка вообще была.
Filecoin: рынок долговечности
IPFS отвечает на вопрос «как назвать и как передать». Filecoin — на «как заставить незнакомца хранить и доказать, что он не врёт». Механика: клиент заключает с провайдером сделку («храни эти данные N дней за столько-то FIL»), провайдер вносит залог и обязан регулярно доказывать наличие данных; не смог — теряет часть залога. Доказательства две штуки, и разницу полезно понимать:
- PoRep (Proof of Replication) — одноразовое, при приёме данных. Провайдер «запечатывает» (seal) сектор: прогоняет данные через намеренно медленное, привязанное к его идентификатору преобразование. Смысл — доказать, что хранится отдельная физическая копия, а не ссылка на чужую; без этого десять «провайдеров» держали бы один файл на одном диске и получали десять вознаграждений. Запечатывание занимает часы и требует серьёзного железа — это цена уникальности копии.
- PoSt (Proof of Spacetime) — регулярное, на протяжении всей сделки. WindowPoSt требует доказать наличие каждого сектора раз в 24 часа: сутки разбиты на 48 получасовых «дедлайнов», в каждом провайдер отчитывается за свою часть секторов. WinningPoSt — быстрая разновидность, дающая право произвести блок.
Piece CID — это не CID из IPFS. Данные упаковываются в CAR-файл (Content Addressable aRchive — сериализация набора блоков с корнем), добиваются нулями до размера сектора (32 или 64 ГиБ) с особым выравниванием, и от результата считается CommP — корень Merkle-дерева над этим представлением. Payload CID (тот, что понимает IPFS) и Piece CID — разные идентификаторы одних данных, и путаница между ними — классическая ошибка новичка.
Filecoin доказывает хранение, но не быстроту отдачи. Данные лежат запечатанными; чтобы их выдать, сектор нужно «распечатать» (unseal) — это время и вычисления, отсюда отдельный слой retrieval-сделок и кэширующих сетей. Практическая рекомендация: держите горячую копию (пиннинг-сервис, свой узел, CDN), а Filecoin используйте как проверяемый архив — так и делают крупные датасеты.
Защищает вас экономика, а не криптография: залог, штрафы за пропущенные доказательства и штраф за досрочное прекращение сектора. Если хранить однажды станет дороже, чем стоит штраф за потерю, рациональный провайдер ваши данные потеряет. Модель угроз здесь экономическая, и оценивать её нужно как экономическую. И сделка конечна — обычно от полугода до нескольких лет, после чего провайдер вправе выкинуть сектор: «положил в Filecoin — лежит вечно» неверно, нужен ваш процесс продления. Программа Filecoin Plus с DataCap и нотариями даёт провайдерам десятикратный множитель мощности за общественно полезные данные, что удешевляет хранение почти до нуля, но срочности сделок не отменяет. С запуском FVM (Filecoin Virtual Machine, 2023) сделками стало можно управлять EVM-совместимыми контрактами: автопродление, коллективные хранилища, «фонды на хранение датасета» — тот же Solidity, что в «Смарт-контрактах», и те же классы уязвимостей из «Безопасности контрактов».
Arweave: заплатить один раз
Arweave решает ту же задачу иначе: платишь один раз — хранится всегда. Разберём, на чём это держится.
Структура. Вместо цепочки — blockweave: блок ссылается на предыдущий и дополнительно на псевдослучайно выбранный recall block из прошлого, и чтобы майнить, нужен доступ к данным этого старого блока. Итог: майнинг требует не только вычислений, но и хранения истории, а раз recall-блок случаен, рационально хранить как можно больше — особенно то, что хранят немногие, потому что конкуренция за такие блоки ниже. Механизм эволюционировал: Proof of Access → SPoRA (Succinct Proofs of Random Access, версия 2.4) → версия 2.6, где добавлен основанный на хеш-цепочке ограничитель скорости майнинга. Смысл последнего — сделать выгодными именно локально лежащие данные, а не быстрый доступ к чужому хранилищу или переспам вычислениями.
Экономика. Плата за загрузку делится: небольшая часть уходит майнеру сразу, основная — в эндаумент, из которого выплаты идут майнерам десятилетиями. Цена рассчитывается так, чтобы покрыть примерно 200 лет хранения при консервативном допущении, что стоимость хранения байта снижается на 0,5% в год. Посчитаем, что это означает:
def endowment_multiplier(decline_per_year: float, years: int = 200) -> float:
"""Во сколько «текущих годовых стоимостей хранения» обойдётся заданный срок.
Если стоимость года t равна c0 * (1 - d)^t, суммарная стоимость — геометрическая
сумма. Сложность O(years) по времени, O(1) по памяти.
"""
return sum((1.0 - decline_per_year) ** t for t in range(years))
for decline in (0.00, 0.005, 0.02, 0.10, 0.30):
print(f"{decline:>5.1%} в год -> {endowment_multiplier(decline):6.1f}x")
# 0.0% в год -> 200.0x стоимость хранения не падает — нужно 200 годовых
# 0.5% в год -> 126.6x допущение Arweave: платим ~127 годовых вперёд
# 2.0% в год -> 49.1x
# 10.0% в год -> 10.0x
# 30.0% в год -> 3.3x близко к историческому темпу удешевления дисков
Логика такая: исторически стоимость хранения падала на десятки процентов в год, поэтому реальная сумма к выплате ближе к правой части таблицы, а собирают по левой — с огромным запасом. Но это допущение, а не теорема. Остановится удешевление дисков (физические пределы плотности записи вполне реальны) — запас проедается. Это не повод не пользоваться Arweave, это повод понимать: «вечно» здесь означает «пока держится экономическая модель и её допущения», а не математическую гарантию. Формальное описание — в жёлтой книге Arweave. Инженерные детали, с которыми столкнётесь сразу:
- Бандлы ANS-104. Класть каждый мелкий файл отдельной транзакцией дорого и медленно; стандарт ANS-104 позволяет упаковать тысячи элементов в одну. Сервисы-бандлеры (Irys, бывший Bundlr) принимают загрузку, отдают квитанцию мгновенно и публикуют пачкой. Пока бандл не попал в цепь, бандлер — доверенная сторона: «загрузил» и «сохранено навсегда» разделены окном обычного корпоративного доверия.
- Шлюзы.
arweave.net— это шлюз, а не сеть. Шлюзы (сеть ar.io) индексируют данные, дают GraphQL-поиск и отдают HTTP; они же — точка цензуры: шлюз может не показать контент, который в сети присутствует. Как и в IPFS, цензура переезжает на край, к точке входа. - Неудаляемость абсолютна. Ошибочно загруженный персональный файл удалить нельзя — ни вам, ни суду. Прежде чем нажимать «загрузить», подумайте дважды, особенно если данные касаются других людей.
Сравнение: что именно гарантирует каждый вариант
Пояснения к неочевидным точкам. Calldata Ethereum неудаляема в том смысле, что входит в историю цепи, но доступность истории обеспечивают архивные ноды, а обсуждаемый механизм её истечения (EIP-4444) прямо предполагает, что рядовые узлы перестанут её хранить — забота о старых данных переедет на добровольные архивы. Объектный сторедж по SLA — самая предсказуемая вещь в таблице, и если децентрализация не нужна вам по существу задачи, это правильный ответ. Из смежных систем стоит знать Storj (стирающее кодирование и распределение по независимым узлам с централизованной координацией — фактически S3-совместимое хранилище с распределённым бэкендом), Sia, Swarm (хранилище экосистемы Ethereum со встроенным стимулированием) и Codex: дизайн-пространство шире трёх разобранных систем.
Смарт-контракты и CID: как хранить ссылку, чтобы она не умерла
Правило: в цепи — дайджест, снаружи — байты. Сначала цена вопроса: CIDv1 в base32 — строка из 59 символов, в storage это слот длины плюс два слова данных, три холодных SSTORE ≈ 66 300 газа. Тот же дайджест в bytes32 — один слот, 22 100 газа; префикс 0x01 0x55 0x12 0x20 одинаков для всех CID одного профиля и в состоянии не нужен.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
/// @title Реестр публикаций: в цепи живут 32 байта, содержимое — снаружи
/// @notice Профиль CID зафиксирован контрактом: CIDv1 / raw / sha2-256 / 32 байта.
contract ContentRegistry {
/// Постоянный префикс CID: 0x01 (версия) 0x55 (raw) 0x12 (sha2-256) 0x20 (длина).
/// Константа не занимает слот и ничего не стоит при чтении.
bytes4 public constant CID_PREFIX = 0x01551220;
struct Publication {
bytes32 digest; // слот 1: sha2-256 содержимого
uint64 publishedAt; // слот 2: 8 байт...
address author; // ...и 20 байт — упакованы в один слот
}
mapping(uint256 => Publication) private _publications;
uint256 public total;
event Published(uint256 indexed id, address indexed author, bytes32 digest);
error EmptyDigest();
error UnknownPublication(uint256 id);
/// @notice Зафиксировать публикацию. Изменить её нельзя — в этом весь смысл.
function publish(bytes32 digest) external returns (uint256 id) {
if (digest == bytes32(0)) revert EmptyDigest();
unchecked { id = ++total; }
_publications[id] = Publication(digest, uint64(block.timestamp), msg.sender);
emit Published(id, msg.sender, digest); // дешёвый канал для индексаторов и фронтенда
}
function publicationOf(uint256 id) external view returns (Publication memory p) {
p = _publications[id];
if (p.publishedAt == 0) revert UnknownPublication(id);
}
}
Обратите внимание на то, чего здесь нет: функции update. Как только появляется возможность поменять дайджест, вы возвращаетесь к обычной изменяемой ссылке, и неизменяемость превращается в обещание владельца ключа. Иногда обновляемость нужна — делайте её явной, с событием и таймлоком, чтобы пользователи успели заметить подмену. Теперь классическая беда NFT — антипаттерн и лечение (сами стандарты — в «Токенах и стандартах»):
// ПЛОХО: метаданные живут на сервере компании и меняются одним вызовом.
// Компания закрылась, домен не продлён — коллекция стала набором битых ссылок.
contract FragileNFT is ERC721 {
string private _base = "https://api.example.com/meta/";
function setBaseURI(string calldata newBase) external onlyOwner {
_base = newBase; // владелец подменяет ВСЕ метаданные разом
}
function _baseURI() internal view override returns (string memory) { return _base; }
}
// ЛУЧШЕ: baseURI — константа с CID каталога метаданных.
// tokenURI(1) даст ipfs://bafybei.../1, и содержимое проверяемо любым клиентом.
contract FrozenNFT is ERC721 {
string private constant _BASE = "ipfs://bafybeihykld7uyxzogax6vgyvag42y7464eywpf55gxi5qpoisibh3c5wa/";
function _baseURI() internal pure override returns (string memory) { return _BASE; }
}
Чек-лист «метаданные, которые переживут проект»:
ipfs://<CID>, а не URL шлюза. Записанный в контрактhttps://gateway-компании.io/ipfs/...намертво привязывает вас к чужому домену; кошельки и маркетплейсы умеют резолвитьipfs://сами.- CID каталога, а не отдельных файлов. Один CID коммитит ко всем метаданным сразу; подменить элемент незаметно нельзя.
- Внутри JSON-метаданных ссылка на изображение — тоже
ipfs://. Половина «замороженных» коллекций ломается именно здесь: CID метаданных зафиксирован, аimageвнутри указывает на живой https-домен. - Порядок деплоя: сначала загрузить и запинить метаданные, потом деплоить контракт с уже известным CID. Обратный порядок неизбежно требует изменяемого
setBaseURI. - Оплатите хранение явно — пиннинг-сервис плюс сделка Filecoin или загрузка в Arweave. Без этого пункты 1–4 дают красивую ссылку в никуда.
Риски, о которых надо говорить прямо
- «Загрузил в IPFS» ≠ «хранится». Самый дорогой миф в теме. Без пина или оплаченной сделки данные живут ровно столько, сколько работает ваш узел, и исчезают тихо — вы узнаете об этом от пользователей.
- Шлюз — точка централизации. Он логирует запросы, фильтрует по denylist и может отдать не то, если вы не проверяете хеш. Публичный шлюз в проде без локальной проверки — это обычный HTTPS с лишними шагами.
- CID нестабилен между инструментами. Разные версии, флаги и чанкеры дают разные имена одному файлу; без фиксации параметров воспроизводимость сборки иллюзорна.
- Приватности нет. Всё, что попало в публичную сеть, публично. Хуже: CID — точный отпечаток, и любой, у кого есть файл, может проверить, раздаёте ли вы именно его. Нужна приватность — шифруйте до загрузки и решайте управление ключами отдельно (см. «Кошельки и ключи»; ключ шифрования данных не должен совпадать с ключом кошелька).
- Удаление невозможно, и это юридическая проблема. «Право на забвение» и требования об удалении персональных данных несовместимы с неизменяемым публичным хранилищем. Рабочая стратегия одна — не класть туда персональные данные в открытом виде.
- Сделки Filecoin истекают, бандлеры Arweave — доверенная сторона до попадания в цепь. Нужен мониторинг продлений и проверка, что транзакция подтверждена, а не просто принята.
- Скам-паттерн: «децентрализованное хранилище» поверх одного S3-бакета. Встречается регулярно. Проверка простая: возьмите CID и достаньте данные через независимый узел или шлюз, не связанный с проектом. Не достаётся — децентрализации нет, есть маркетинг.
- Ключ от IPNS/ENS-имени — это ключ от содержимого. Его компрометация означает подмену сайта или метаданных для всех пользователей; хранить его надо как ключ от денег.
Практика: рецепты, которые работают
# Детерминированный CID: все параметры заданы явно, никаких умолчаний.
ipfs add --cid-version=1 --raw-leaves=true --chunker=size-262144 --pin=true ./release.tar.gz
# Что именно закреплено на узле (recursive — корни, которые вы держите намеренно)
ipfs pin ls --type=recursive
# Структура DAG: сколько блоков и какого размера
ipfs dag stat bafybei...
# Кто ещё в сети анонсирует этот CID — проверка доступности СНАРУЖИ вашего узла
ipfs routing findprovs bafkrei...
# Экспорт в CAR — переносимый архив блоков (то, что едет в Filecoin)
ipfs dag export bafybei... > release.car
# Независимая проверка через публичный шлюз: сравниваем с локальным хешем
curl -sL "https://ipfs.io/ipfs/bafkrei..." | sha256sum
Как выбирать хранилище — дерево решений, покрывающее большинство случаев:
Публичная сеть приватности не даёт"] C --> D B -->|"Нет"| D{"Нужна неизменяемая
проверяемая ссылка?"} D -->|"Нет"| E["Обычный объектный сторедж:
дешевле, быстрее, предсказуемее"] D -->|"Да"| F{"Объём данных"} F -->|"Единицы килобайт
и нужны контракту"| G["Прямо в цепь:
calldata или storage"] F -->|"Больше"| H["CID в цепи, байты снаружи"] --> I{"На какой срок?"} I -->|"1-3 года,
есть кому платить"| J["Пиннинг-сервис
+ свой резервный узел"] --> M I -->|"Десятилетия,
бюджет разовый"| K["Arweave
помните про допущения модели"] --> M I -->|"Терабайты, нужна
проверяемость хранения"| L["Filecoin
+ горячая копия для отдачи"] --> M M["Мониторинг: регулярно проверяйте
доступность CID снаружи"]
Пункт про мониторинг — не украшение. Единственный способ узнать, что данные ещё живы, — периодически доставать их из независимой точки. Заведите проверку в CI: раз в сутки скачать CID через сторонний шлюз и сравнить хеш. Десять строк и спасённая коллекция.
Мини-итог
- Хранение — это три разных свойства: целостность, доступность, долговечность. Контентная адресация бесплатно даёт только первое.
- CID — самоописывающая структура (multibase + версия + кодек + multihash), а не «хеш файла». Один файл законно имеет несколько CID в зависимости от упаковки; воспроизводимость требует фиксировать флаги.
- IPFS — это адресация (CID), модель данных (UnixFS/Merkle DAG), маршрутизация (Kademlia DHT и IPNI) и передача (Bitswap, GraphSync, trustless HTTP). Хранения и стимулов в нём нет; всё держится на пиннинге, за который кто-то платит.
- Шлюзы возвращают централизацию: логи, denylist, возможность подмены. Проверяйте хеш на клиенте — иначе смысл контентной адресации теряется.
- Filecoin покупает долговечность экономикой: PoRep доказывает уникальность копии, WindowPoSt — её наличие каждые 24 часа, залог и штрафы делают потерю невыгодной. Сделки срочные, отдача требует распечатки сектора.
- Arweave берёт разовую плату в эндаумент, рассчитанный на 200 лет при допущении об удешевлении хранения на 0,5% в год. Гарантия экономическая, а не математическая.
- В контракте храните
bytes32-дайджест (22 100 газа против ~66 300 за строку), схемуipfs://вместо URL шлюза, CID каталога вместо отдельных файлов — и оплатите само хранение. - Публичные хранилища неудаляемы и непубличны только если вы зашифровали данные сами. Планируйте это до первой загрузки, а не после.
Источники
- Juan Benet. IPFS — Content Addressed, Versioned, P2P File System — исходная работа, 2014.
- Спецификации multiformats: CID и multihash — точные раскладки байтов.
- Спецификация UnixFS — protobuf-схемы узлов и правила DAG.
- Bitswap Protocol и Trustless Gateway — как передаются и проверяются блоки.
- P. Maymounkov, D. Mazières. Kademlia: A Peer-to-peer Information System Based on the XOR Metric, 2002 — математика DHT.
- CARv1 Specification — формат архива блоков.
- Filecoin: A Decentralized Storage Network, спецификация протокола и документация — PoRep, PoSt, CommP, FVM.
- Arweave Yellow Paper — blockweave, доказательства доступа, модель эндаумента.
- ANS-104: Bundled Data — как в одну транзакцию помещаются тысячи файлов.
- IPFS Pinning Service API, EIP-1577 (contenthash для ENS), EIP-4844 (blob-транзакции).
Что дальше
Мы разобрались, куда девать байты, которым не место в блокчейне. Осталась зеркальная проблема: что делать, когда в блокчейн не помещаются сами транзакции. Дальше — Масштабирование: L2, роллапы, шардирование, мосты и их риски: как роллап сжимает тысячи операций в одно доказательство, чем optimistic отличается от zk, почему блобы EIP-4844 сделали L2 дешевле в разы — и почему мосты между сетями остаются местом, где произошли самые крупные кражи в истории отрасли.