feat(contracts/apps): smart-contract каталога приложений + docker pipeline

Контракт `apps` (координационная плоскость для apps-catalog):
- 4 таблицы: packages, releases, subs, coops; ABI 21 KB, WASM 70 KB
- 11 actions: regpackage, transferpkg, setrelease, reactivate, withdraw,
  cleanup, regsub, expsub, regcoop, setcoop, migrate
- subs.chain_id и coops.chain_id — каноническая идентификация подсети
- coops.signing_key — отдельный subnet-signing-key (не active),
  ротация через setcoop без потери ранее выпущенных JWT
- releases с TTL retention 90 дней + inline cleanup до 50 записей
- atomic supersede в setrelease — выполняет FR8 «approve → ACTIVE»
  на стороне blockchain'а

CI контейнер `dicoop/contracts`:
- упаковывает все 21 контракт (15 user + 6 system eosio.*) в alpine 10.8 MB
- список контрактов берётся из CMakeLists (single source of truth)
- триггер push на dev/testnet/main + workflow_dispatch
- маппинг ветка→tag: dev/testnet/main + sha-pinned tag для воспроизводимости
- manifest.json с sha256 каждого артефакта внутри образа
- entrypoint: copy [name] | manifest | sha256 | list | ls
- multi-stage потребление в ke-bootstrap = 0 MB на VPS

Закрывает блокер Story 6.x (KE bootstrap) для apps-catalog.
This commit is contained in:
coopops
2026-04-30 07:55:06 +00:00
parent 0cb8197cab
commit 5865f6fc14
25 changed files with 1699 additions and 1 deletions
+206
View File
@@ -0,0 +1,206 @@
name: Build contracts container
# Сборка и публикация docker-образа `dicoop/contracts`, который содержит
# wasm/abi всех контрактов кооперативной экономики (apps, marketplace,
# ledger, meet, loan, wallet, capital, branch, contributor, fund, draft,
# soviet, registrator, gateway, system, test). Образ потребляется
# ke-bootstrap'ом (отдельный артефакт), который при старте ноды деплоит
# контракты через `cleos set contract`.
#
# Триггер — push в одну из веток-окружений (dev / testnet / main).
# Маппинг:
# dev → IS_TESTNET=ON, tag=dev
# testnet → IS_TESTNET=ON, tag=testnet
# main → IS_TESTNET=OFF, tag=main + latest
#
# Сам compile идёт через CDT-образ `dicoop/blockchain_v5.1.1:dev`
# (тот же, что использует `components/contracts/build.sh|build-all.sh`).
# Артефакты копируются в slim alpine-образ.
#
# Секреты:
# DOCKERHUB_USERNAME / DOCKERHUB_TOKEN — публикация в dicoop/*
# TELEGRAM_BOT_TOKEN / TELEGRAM_CHAT_ID — уведомления
on:
push:
branches: [dev, testnet, main]
paths:
- 'components/contracts/cpp/**'
- 'components/contracts/CMakeLists.txt'
- 'components/contracts/build.sh'
- 'components/contracts/build-all.sh'
- 'components/contracts/docker/**'
- '.github/workflows/build-contracts.yaml'
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Determine build mode and docker tag
run: |
case "${{ github.ref_name }}" in
main)
echo "BUILD_MODE=prod" >> $GITHUB_ENV
echo "DOCKER_TAG=main" >> $GITHUB_ENV
echo "EXTRA_TAG=latest" >> $GITHUB_ENV
;;
testnet)
echo "BUILD_MODE=test" >> $GITHUB_ENV
echo "DOCKER_TAG=testnet" >> $GITHUB_ENV
echo "EXTRA_TAG=" >> $GITHUB_ENV
;;
dev)
echo "BUILD_MODE=test" >> $GITHUB_ENV
echo "DOCKER_TAG=dev" >> $GITHUB_ENV
echo "EXTRA_TAG=" >> $GITHUB_ENV
;;
*)
echo "Unsupported branch ${{ github.ref_name }}" >&2
exit 1
;;
esac
- name: Pull CDT toolchain image
run: docker pull dicoop/blockchain_v5.1.1:dev
- name: Compile all contracts
working-directory: components/contracts
run: |
./build-all.sh "$BUILD_MODE"
echo "--- build/contracts ---"
ls -la build/contracts/
- name: Stage docker context
working-directory: components/contracts
run: |
ROOT="docker/.context"
rm -rf "$ROOT"
mkdir -p "$ROOT/contracts"
# Имена контрактов берём из CMakeLists (`add_contract_build(<name>)`)
# — единая точка истины. Любое изменение списка там автоматически
# подтянется в образ.
# Имена контрактов = аргументы add_contract_build(...) только в
# незакомменченных строках (CMake-комментарий начинается с `#`).
NAMES=$(grep -E '^[[:space:]]*add_contract_build\(' CMakeLists.txt \
| sed -E 's/^[[:space:]]*add_contract_build\(([^)]*)\).*/\1/')
echo "Contracts to package:"
echo "$NAMES"
MANIFEST="$ROOT/contracts/manifest.json"
# Хелпер: упаковывает одну пару (wasm, abi) в /contracts/<name>/
# и добавляет JSON-запись в STDOUT (caller склеивает в массив).
pack_one() {
local NAME="$1" WASM="$2" ABI="$3"
if [ ! -f "$WASM" ] || [ ! -f "$ABI" ]; then
echo "::warning::Skipping $NAME: artifacts missing ($WASM / $ABI)" >&2
return 1
fi
mkdir -p "$ROOT/contracts/$NAME"
cp "$WASM" "$ROOT/contracts/$NAME/$NAME.wasm"
cp "$ABI" "$ROOT/contracts/$NAME/$NAME.abi"
local SHA_WASM=$(sha256sum "$WASM" | awk '{print $1}')
local SHA_ABI=$(sha256sum "$ABI" | awk '{print $1}')
printf ' {"name":"%s","sha256_wasm":"%s","sha256_abi":"%s"}' \
"$NAME" "$SHA_WASM" "$SHA_ABI"
}
{
printf '{\n'
printf ' "build_mode": "%s",\n' "$BUILD_MODE"
printf ' "git_sha": "%s",\n' "$GITHUB_SHA"
printf ' "ref": "%s",\n' "$GITHUB_REF_NAME"
printf ' "built_at": "%s",\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
printf ' "contracts": [\n'
FIRST=1
emit() {
entry=$(pack_one "$@") || return 0
[ $FIRST -eq 0 ] && printf ',\n'
FIRST=0
printf '%s' "$entry"
}
for c in $NAMES; do
if [ "$c" = "system" ]; then
# У system многоконтрактная сборка: разносим под-контракты
# eosio.* как полноценные элементы каталога, а сам "system"
# как имя пропускаем. test_contracts/* не упаковываем —
# это тестовые stub'ы, не нужны на проде.
for sub in build/contracts/system/contracts/eosio.*/; do
[ -d "$sub" ] || continue
sub_name=$(basename "$sub")
emit "$sub_name" "$sub/$sub_name.wasm" "$sub/$sub_name.abi"
done
else
emit "$c" "build/contracts/$c/$c.wasm" "build/contracts/$c/$c.abi"
fi
done
printf '\n ]\n'
printf '}\n'
} > "$MANIFEST"
cp docker/Dockerfile "$ROOT/Dockerfile"
cp docker/entrypoint.sh "$ROOT/entrypoint.sh"
chmod +x "$ROOT/entrypoint.sh"
echo "--- manifest.json ---"
cat "$MANIFEST"
echo "--- context tree ---"
find "$ROOT" -maxdepth 3 -type f | sort
- name: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push image
working-directory: components/contracts
run: |
IMAGE="dicoop/contracts"
docker build \
--label "org.opencontainers.image.revision=${{ github.sha }}" \
--label "org.opencontainers.image.created=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
--label "build.mode=$BUILD_MODE" \
-t "$IMAGE:$DOCKER_TAG" \
./docker/.context
docker push "$IMAGE:$DOCKER_TAG"
if [ -n "$EXTRA_TAG" ]; then
docker tag "$IMAGE:$DOCKER_TAG" "$IMAGE:$EXTRA_TAG"
docker push "$IMAGE:$EXTRA_TAG"
fi
# SHA-tag для воспроизводимости (если нужно запинить конкретный
# коммит, а не двигающийся branch-tag).
SHORT_SHA="${GITHUB_SHA::7}"
docker tag "$IMAGE:$DOCKER_TAG" "$IMAGE:$DOCKER_TAG-$SHORT_SHA"
docker push "$IMAGE:$DOCKER_TAG-$SHORT_SHA"
- name: Verify pushed image
run: |
docker run --rm dicoop/contracts:$DOCKER_TAG list
echo "---"
docker run --rm dicoop/contracts:$DOCKER_TAG sha256
- name: Telegram notify success
if: ${{ success() }}
run: |
COUNT=$(docker run --rm dicoop/contracts:$DOCKER_TAG list | wc -l)
curl -s -X POST https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendMessage \
-d chat_id=${{ secrets.TELEGRAM_CHAT_ID }} \
-d text="✅ [GITHUB MONO] dicoop/contracts:$DOCKER_TAG ($COUNT контрактов, mode=$BUILD_MODE, sha=${GITHUB_SHA::7})"
- name: Telegram notify failure
if: ${{ failure() }}
run: |
curl -s -X POST https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendMessage \
-d chat_id=${{ secrets.TELEGRAM_CHAT_ID }} \
-d text="❌ [GITHUB MONO] Ошибка сборки dicoop/contracts на ${{ github.ref_name }} (sha=${GITHUB_SHA::7})"
+1
View File
@@ -57,6 +57,7 @@ add_contract_build(soviet)
add_contract_build(registrator)
add_contract_build(gateway)
add_contract_build(marketplace)
add_contract_build(apps)
add_contract_build(system)
# Опции для тестов
@@ -0,0 +1,14 @@
cmake_minimum_required(VERSION 3.5)
# Проект
project(apps)
find_package(cdt)
# Добавление контракта
add_contract(apps apps apps.cpp)
if(IS_TESTNET)
target_compile_definitions(apps PUBLIC IS_TESTNET=1)
else()
# Не определяем IS_TESTNET для продакшн режима
endif()
+75
View File
@@ -0,0 +1,75 @@
# Контракт `apps` — каталог приложений
Координационная плоскость платформы Каталога приложений ВОСХОД
(`apps-catalog`). Деплоится на корневой KE-цепи и обслуживает несколько
подсетей кооперативов одновременно.
## Что хранит
| Таблица | PK | Назначение |
| ---------- | ----------------- | ----------------------------------------------------------- |
| `packages` | `package_id` | Реестр зарегистрированных пакетов (Antelope `name`) |
| `releases` | auto-inc | Текущие active + недавние superseded релизы (TTL 90 дней) |
| `subs` | auto-inc | Подписки `(coopname × package_id × chain_id)` на пакеты |
| `coops` | `coopname` | Кооперативы каталога: chain_id подсети + subnet-signing-key |
## Жизненный цикл релиза
```
regpackage(pkg, owner, subnets)
┌─────────┐ setrelease(scope=all) ┌────────────┐
│ packages│ ─────────────────────────────► │ releases │
│ pkg │ (creates active, supersedes │ active │
└─────────┘ prior active с тем же scope) └────────────┘
setrelease(новая) │
┌────────────┐
│ superseded │ TTL 90d
│ + auto-rm │ inline cleanup
└────────────┘
│ reactivate(version=старая)
┌────────────┐
│ active │
└────────────┘
withdraw(version) ┌────────────┐
─────────────────────────►│ withdrawn │ не подпадает под TTL
└────────────┘
```
## Authorization (MVP)
- Все mutating actions: `<coopname>@active`.
- `cleanup` — без auth (доброкачественная операция).
- `migrate``apps@active`.
## Authorization (план)
- `regsub`/`expsub` → выделенный `<coopname>@billing`-permission, когда
биллинг отделится от провайдера каталога.
- `regcoop`/`setcoop``<coopname>@subnet`-permission, когда onboarding
кооперативов формализуется отдельным процессом.
Миграция через `eosio.msig`-предложение.
## Локальная сборка
```bash
# из components/contracts/
./build.sh apps prod # production
./build.sh apps test # с #define IS_TESTNET=1
```
Артефакт — `build/contracts/apps/apps.{wasm,abi}`. Упаковка артефактов
всех контрактов в docker-образ — см. `components/contracts/docker/`.
## Якоря
- `apps-catalog` epics.md Story 6.x — KE bootstrap.
- `apps-catalog` docs/architecture.md §"KE blockchain (контракт apps)".
- Memory `project_subnet_identification` — chain_id как идентификатор подсети.
+24
View File
@@ -0,0 +1,24 @@
#include "apps.hpp"
#include <eosio/transaction.hpp>
#include "src/regpackage.cpp"
#include "src/transferpkg.cpp"
#include "src/setrelease.cpp"
#include "src/reactivate.cpp"
#include "src/withdraw.cpp"
#include "src/cleanup.cpp"
#include "src/regsub.cpp"
#include "src/expsub.cpp"
#include "src/regcoop.cpp"
#include "src/setcoop.cpp"
/**
* \brief Миграция контракта.
* \details Пустышка для CDT-апгрейдов. Реальные миграции состояния
* выполняются через `eosio.msig`-предложение с конкретным
* планом (drop таблиц, переезд данных).
* \note Авторизация: @ apps @ active.
*/
[[eosio::action]] void apps::migrate() {
require_auth(_apps);
}
+253
View File
@@ -0,0 +1,253 @@
#pragma once
#include <eosio/asset.hpp>
#include <eosio/contract.hpp>
#include <eosio/crypto.hpp>
#include <eosio/eosio.hpp>
#include <eosio/multi_index.hpp>
#include <eosio/system.hpp>
#include <eosio/time.hpp>
#include <optional>
#include "../lib/index.hpp"
using namespace Apps;
/**
\defgroup public_apps Контракт APPS
* Смарт-контракт каталога приложений `apps` — координационная плоскость
* для платформы Каталога приложений ВОСХОД (`apps-catalog`). Контракт
* деплоится на корневой KE-цепи и обслуживает несколько подсетей
* (одна установка `CA-auth` → N подсетей кооперативов).
*
* Что хранит:
* - `packages` — реестр зарегистрированных пакетов (PK = `name`).
* - `releases` — текущие active + недавние superseded релизы (TTL 90 дней).
* - `subs` — подписки кооперативов на пакеты (`coopname × package_id × chain_id`).
* - `coops` — кооперативы, подключённые к каталогу, со своими
* subnet-signing-key и chain_id подсети.
*
* Чего контракт НЕ делает (намеренно):
* - Не проверяет ECDSA-подписи `signed-request`'ов от DC — это off-chain
* в `CA-auth`, который читает `coops.signing_key` через RPC.
* - Не выпускает JWT — это HS256 в `CA-auth`.
* - Не валидирует tarball — `tarball_sha256` хранится для аудита.
* - Не считает биллинг — биллинг живёт в кабинете ВОСХОД и `pay.coopenomics`,
* сюда приходит уже факт «подписка активна».
* - Не пишет audit-log — Antelope сам пишет в trace; `CA` ведёт свой
* `audit_log_admin` для off-chain событий.
*
* Authorization model (MVP, упрощённая):
* - Все mutating actions требуют `<coopname>@active`.
* - Разделение прав на `@billing`, `@subnet-operator` и т.п. — TODO,
* введём через `eosio.msig` миграцию когда биллинг отделится от
* провайдера каталога.
* - `cleanup` — без auth: операция идемпотентна и удаляет только
* TTL-просроченные `superseded` записи.
*
* \see /home/admin/apps-catalog/docs/architecture.md
* \see epics.md Story 6.x (KE bootstrap)
*/
/**
\defgroup public_apps_processes Процессы
\ingroup public_apps
*/
/**
\defgroup public_apps_actions Действия
\ingroup public_apps
*/
/**
\defgroup public_apps_tables Таблицы
\ingroup public_apps
*/
/**
\defgroup public_apps_consts Константы
\ingroup public_apps
*/
namespace Apps {
/**
* Окно retention для `superseded`-релизов: 90 дней. Записи старше
* этого окна удаляются inline в `setrelease` или явным `cleanup`.
* `withdrawn` под TTL не подпадают — отзыв делается сознательно
* и должен быть видим до явного purge.
*/
static constexpr uint64_t RELEASE_RETENTION_SECS = 90 * 86400;
/**
* Бюджет cleanup'а внутри одного вызова `setrelease`: не удалять
* больше N записей за раз, чтобы не упереться в CPU-limit транзакции.
* Остаток подметёт следующий `setrelease` или явный `cleanup(package_id)`.
*/
static constexpr uint64_t CLEANUP_BUDGET_PER_CALL = 50;
} // namespace Apps
class [[eosio::contract(APPS)]] apps : public eosio::contract {
public:
apps(eosio::name receiver, eosio::name code,
eosio::datastream<const char *> ds)
: eosio::contract(receiver, code, ds) {}
[[eosio::action]] void migrate();
// ─── packages ───────────────────────────────────────────────────────
/**
* \brief Зарегистрировать новый пакет в каталоге.
* \param coopname провайдер каталога (тот, кто подписал транзакцию).
* \param package_id кодовое имя пакета (Antelope name, ≤12 chars).
* \param package_name внешнее имя (npm/go/oci) — может содержать `@scope/name`.
* \param owner username владельца пакета.
* \param compatible_subnets human-labels подсетей, в которых пакет совместим.
* \note Авторизация: @p coopname @ active. Идемпотентность: фейл, если
* package_id уже зарегистрирован (нужен явный transferpkg/withdraw
* перед re-регистрацией).
*/
[[eosio::action]] void regpackage(eosio::name coopname,
eosio::name package_id,
std::string package_name,
eosio::name owner,
std::vector<eosio::name> compatible_subnets);
/**
* \brief Передать владение пакетом другому пользователю (FR3).
* \note Авторизация: @p coopname @ active.
*/
[[eosio::action]] void transferpkg(eosio::name coopname,
eosio::name package_id,
eosio::name new_owner);
// ─── releases ───────────────────────────────────────────────────────
/**
* \brief Опубликовать новый release пакета.
*
* Атомарно (FR8 «approve → release.ACTIVE»):
* 1. Создаёт row в `releases` со `status=active`, `superseded_at=0`.
* 2. Любой существующий `active` с тем же `(package_id, scope)` →
* `superseded`, `superseded_at=now`.
* 3. Если `scope.kind == "all"` — обновляет `packages.last_active_version`.
* 4. Inline-cleanup: удаляет до `CLEANUP_BUDGET_PER_CALL` самых старых
* `superseded`-записей с `superseded_at < now - RELEASE_RETENTION_SECS`.
*
* \param scope область видимости (см. `scope_t`).
* \note Авторизация: @p coopname @ active.
*/
[[eosio::action]] void setrelease(eosio::name coopname,
eosio::name package_id,
std::string version,
Apps::scope_t scope,
eosio::checksum256 tarball_sha256,
eosio::name moderated_by,
std::string meta);
/**
* \brief Реактивировать предыдущую версию пакета (FR43).
* \details Находит release по `(package_id, version)`; ставит ему
* `status=active`, `superseded_at=0`. Текущий `active` с тем же
* scope → `superseded`. Если scope=all — обновляет
* `packages.last_active_version`.
* \note Авторизация: @p coopname @ active.
*/
[[eosio::action]] void reactivate(eosio::name coopname,
eosio::name package_id,
std::string version);
/**
* \brief Отозвать релиз (CVE / нарушение / юридический отзыв).
* \details Ставит `status=withdrawn`. Если это был `active` со scope=all —
* `packages.last_active_version` обнуляется ("") до следующего
* setrelease/reactivate.
* \note Авторизация: @p coopname @ active.
*/
[[eosio::action]] void withdraw(eosio::name coopname,
eosio::name package_id,
std::string version,
std::string reason);
/**
* \brief Ручной cleanup TTL-просроченных `superseded`-записей пакета.
* \details Удаляет до `CLEANUP_BUDGET_PER_CALL` записей с
* `superseded_at < now - RELEASE_RETENTION_SECS`. Идемпотентен.
* \note Без авторизации: операция доброкачественная, удаляет только
* уже невидимые в продакшене записи. Любой может вызвать
* (и заплатить за CPU).
*/
[[eosio::action]] void cleanup(eosio::name package_id);
// ─── subscriptions ──────────────────────────────────────────────────
/**
* \brief Зарегистрировать или продлить подписку кооператива на пакет.
* \details Idempotent по (coopname, package_id):
* - row не существует → создаём с `active=true`.
* - row существует → обновляем `plan`, `end_at` = max(текущее, новое),
* `active=true`, `chain_id` (на случай переноса
* между подсетями).
* \param coopname провайдер каталога (тот, кто выписывает подписку).
* \param subscriber кооператив-подписчик.
* \param chain_id подсеть, в которой действует подписка.
* \note Авторизация: @p coopname @ active.
*/
[[eosio::action]] void regsub(eosio::name coopname,
eosio::name subscriber,
eosio::name package_id,
eosio::checksum256 chain_id,
eosio::name plan,
eosio::time_point_sec start_at,
eosio::time_point_sec end_at);
/**
* \brief Деактивировать подписку (`active=false`); row не удаляется
* (нужен для аудита истечения).
* \note Авторизация: @p coopname @ active.
*/
[[eosio::action]] void expsub(eosio::name coopname,
eosio::name subscriber,
eosio::name package_id);
// ─── coops ──────────────────────────────────────────────────────────
/**
* \brief Зарегистрировать кооператив в каталоге.
* \details Этот action — часть процесса подключения коопа к каталогу
* приложений на Восходе (вне scope текущего MVP). Кооператив
* сам подписывает транзакцию (`require_auth(coopname)`),
* подтверждая, что владеет приватной частью `signing_key`.
* \param coopname кооператив, который регистрируется.
* \param chain_id подсеть, к которой принадлежит коопе.
* \param subnet_label human-label подсети ("ru","by",...).
* \param signing_key subnet-signing-key (отдельный от @ active).
* \note Авторизация: @p coopname @ active. Идемпотентность: фейл, если
* уже зарегистрирован — для обновления использовать `setcoop`.
*/
[[eosio::action]] void regcoop(eosio::name coopname,
eosio::checksum256 chain_id,
eosio::name subnet_label,
eosio::public_key signing_key);
/**
* \brief Обновить параметры кооператива (ротация ключа, перенос подсети,
* деактивация).
* \details Все параметры опциональны — обновляются только переданные.
* При смене `signing_key` обновляется `key_rotated_at=now`.
* Ранее выпущенные JWT (TTL 14 дней) остаются валидны;
* новые signed-request'ы должны идти от нового приват-ключа.
* \note Авторизация: @p coopname @ active.
*/
[[eosio::action]] void setcoop(eosio::name coopname,
std::optional<eosio::checksum256> chain_id,
std::optional<eosio::name> subnet_label,
std::optional<eosio::public_key> signing_key,
std::optional<bool> active);
// ─── service tables ─────────────────────────────────────────────────
struct [[eosio::table, eosio::contract(APPS)]] counts : counts_base {};
};
@@ -0,0 +1,41 @@
/**
* \brief Ручной cleanup TTL-просроченных `superseded`-записей пакета.
* \ingroup public_apps_actions
*
* Используется когда `setrelease` для пакета давно не вызывался, и
* inline-cleanup в нём не подметает хвост. Удаляет до
* `CLEANUP_BUDGET_PER_CALL` записей с `status=superseded` и
* `superseded_at < now - RELEASE_RETENTION_SECS`.
*
* Идемпотентно. Многократные вызовы безопасны: каждый вызов чистит свой
* batch до budget'а.
*
* \note БЕЗ авторизации: операция доброкачественная, удаляет только
* уже невидимые в продакшене записи, не меняет логику. Любой
* может вызвать (и заплатить за CPU). Это снижает оперативную
* нагрузку на оператора каталога.
*/
void apps::cleanup(eosio::name package_id) {
releases_index releases(_apps, _apps.value);
auto by_pkgstat = releases.get_index<"bypkgstat"_n>();
auto now_sec = eosio::current_time_point().sec_since_epoch();
uint64_t threshold = now_sec > Apps::RELEASE_RETENTION_SECS
? now_sec - Apps::RELEASE_RETENTION_SECS
: 0;
uint128_t key_super = ((uint128_t)package_id.value << 64) | "superseded"_n.value;
uint64_t removed = 0;
auto it = by_pkgstat.lower_bound(key_super);
while (it != by_pkgstat.end()
&& ((uint128_t)it->package_id.value << 64 | it->status.value) == key_super
&& removed < Apps::CLEANUP_BUDGET_PER_CALL) {
if (it->superseded_at.sec_since_epoch() < threshold) {
it = by_pkgstat.erase(it);
++removed;
} else {
++it;
}
}
}
@@ -0,0 +1,31 @@
/**
* \brief Деактивировать подписку.
* \ingroup public_apps_actions
*
* Ставит `active=false`. Запись остаётся в таблице — она нужна для
* аудита истечения и для повторной активации (если кооператив снова
* подпишется, пойдёт через тот же row через `regsub`-upsert).
*
* Cleanup физически просроченных записей (например, истёкших год
* назад и более не нужных в RAM) — задача отдельного периодического
* действия. Вне scope MVP.
*
* \note Авторизация: @p coopname @ active.
*/
void apps::expsub(eosio::name coopname,
eosio::name subscriber,
eosio::name package_id) {
require_auth(coopname);
subs_index subs(_apps, _apps.value);
auto by_cooppkg = subs.get_index<"bycooppkg"_n>();
uint128_t key = ((uint128_t)subscriber.value << 64) | package_id.value;
auto sub_it = by_cooppkg.find(key);
eosio::check(sub_it != by_cooppkg.end(), "Подписка не найдена");
auto now_tps = eosio::time_point_sec(eosio::current_time_point().sec_since_epoch());
by_cooppkg.modify(sub_it, coopname, [&](auto &s) {
s.active = false;
s.updated_at = now_tps;
});
}
@@ -0,0 +1,79 @@
/**
* \brief Реактивировать предыдущую версию пакета (FR43).
* \ingroup public_apps_actions
*
* Используется для отката broken release без full re-publish:
* - Находит существующий release `(package_id, version)` со статусом
* `superseded` или `withdrawn`.
* - Текущий active с тем же `scope` → `superseded`.
* - Найденный release → `active`, `superseded_at=0`.
* - Если scope=all — обновляется `packages.last_active_version`.
*
* Окно реактивации ограничено TTL retention (90 дней): за пределами окна
* superseded-записи уже удалены, и реактивация не возможна — нужен
* полноценный re-publish.
*
* \note Авторизация: @p coopname @ active.
*/
void apps::reactivate(eosio::name coopname,
eosio::name package_id,
std::string version) {
require_auth(coopname);
packages_index packages(_apps, _apps.value);
auto pkg_it = packages.find(package_id.value);
eosio::check(pkg_it != packages.end(), "Пакет не зарегистрирован");
releases_index releases(_apps, _apps.value);
auto by_pkg = releases.get_index<"bypackage"_n>();
auto now_tps = eosio::time_point_sec(eosio::current_time_point().sec_since_epoch());
// Найти release под реактивацию.
auto target = by_pkg.end();
for (auto rit = by_pkg.lower_bound(package_id.value);
rit != by_pkg.end() && rit->package_id == package_id;
++rit) {
if (rit->version == version) {
target = rit;
break;
}
}
eosio::check(target != by_pkg.end(),
"Релиз для реактивации не найден — возможно вышел за TTL");
eosio::check(target->status != "active"_n,
"Релиз уже active");
Apps::scope_t target_scope = target->scope;
// Supersede current active с тем же scope.
auto by_pkgstat = releases.get_index<"bypkgstat"_n>();
uint128_t key_active = ((uint128_t)package_id.value << 64) | "active"_n.value;
auto act_it = by_pkgstat.lower_bound(key_active);
while (act_it != by_pkgstat.end()
&& ((uint128_t)act_it->package_id.value << 64 | act_it->status.value) == key_active) {
if (act_it->scope.kind == target_scope.kind && act_it->scope.targets == target_scope.targets) {
auto stash = act_it;
++act_it;
releases.modify(*stash, coopname, [&](auto &r) {
r.status = "superseded"_n;
r.superseded_at = now_tps;
});
} else {
++act_it;
}
}
// Поднять target в active.
by_pkg.modify(target, coopname, [&](auto &r) {
r.status = "active"_n;
r.superseded_at = eosio::time_point_sec(0);
});
if (target_scope.kind == "all"_n) {
packages.modify(pkg_it, coopname, [&](auto &p) {
p.last_active_version = version;
p.updated_at = now_tps;
});
}
}
@@ -0,0 +1,42 @@
/**
* \brief Регистрация кооператива в каталоге.
* \ingroup public_apps_actions
*
* Кооператив сам подписывает транзакцию своим `@active`-ключом и
* передаёт публичную часть `signing_key` (отдельный ключ для подписи
* signed-request'ов от DC-оркестратора в `CA-auth`).
*
* Идемпотентность: если такой `coopname` уже зарегистрирован — фейл.
* Для обновления полей — `setcoop`.
*
* \note Контекст: этот action — часть процесса подключения кооператива
* к каталогу приложений на Восходе (вне scope текущего MVP
* apps-catalog). Реальный flow: кабинет ВОСХОД проводит
* onboarding, генерирует/принимает signing keypair, подписывает
* и отправляет эту транзакцию.
*
* \note Авторизация: @p coopname @ active.
*/
void apps::regcoop(eosio::name coopname,
eosio::checksum256 chain_id,
eosio::name subnet_label,
eosio::public_key signing_key) {
require_auth(coopname);
eosio::check(subnet_label.value != 0, "subnet_label обязателен");
coops_index coops(_apps, _apps.value);
auto it = coops.find(coopname.value);
eosio::check(it == coops.end(), "Кооператив уже зарегистрирован — используйте setcoop");
auto now_tps = eosio::time_point_sec(eosio::current_time_point().sec_since_epoch());
coops.emplace(coopname, [&](auto &c) {
c.coopname = coopname;
c.chain_id = chain_id;
c.subnet_label = subnet_label;
c.signing_key = signing_key;
c.active = true;
c.registered_at = now_tps;
c.key_rotated_at = now_tps;
});
}
@@ -0,0 +1,44 @@
/**
* \brief Регистрация нового пакета в каталоге.
* \ingroup public_apps_actions
*
* Контракт операции:
* - PK = `package_id`. Если такой уже существует — фейлим («имя занято»).
* - `package_name` — внешнее имя; не уникально (теоретически два пакета
* могут иметь одинаковое npm-имя, но разные `package_id`; на практике
* это ловится UI/правилами кабинета ВОСХОД).
* - `compatible_subnets` — может быть пустой; в этом случае пакет
* считается совместимым со всеми подсетями (resolver на стороне `CA`
* обрабатывает пустой набор как «нет ограничений»).
* - `last_active_version` инициализируется пустой строкой; заполнится
* первым `setrelease` со scope=all.
*
* \note Авторизация: @p coopname @ active.
*/
void apps::regpackage(eosio::name coopname,
eosio::name package_id,
std::string package_name,
eosio::name owner,
std::vector<eosio::name> compatible_subnets) {
require_auth(coopname);
eosio::check(package_id.value != 0, "package_id не может быть пустым");
eosio::check(owner.value != 0, "owner не может быть пустым");
eosio::check(!package_name.empty(), "package_name не может быть пустым");
eosio::check(package_name.size() <= 256, "package_name слишком длинный (макс 256)");
packages_index packages(_apps, _apps.value);
auto it = packages.find(package_id.value);
eosio::check(it == packages.end(), "Пакет с таким package_id уже зарегистрирован");
auto now = eosio::current_time_point().sec_since_epoch();
packages.emplace(coopname, [&](auto &p) {
p.package_id = package_id;
p.package_name = package_name;
p.owner = owner;
p.compatible_subnets = compatible_subnets;
p.last_active_version = "";
p.created_at = eosio::time_point_sec(now);
p.updated_at = eosio::time_point_sec(now);
});
}
@@ -0,0 +1,74 @@
/**
* \brief Регистрация или продление подписки кооператива на пакет.
* \ingroup public_apps_actions
*
* Idempotent по `(subscriber, package_id)`:
* - Row нет → создаём с `active=true`, `start_at`, `end_at`, `plan`, `chain_id`.
* - Row есть → обновляем `plan`, `end_at` = `max(текущее, новое)`,
* `active=true`, `chain_id` (на случай миграции коопа
* между подсетями), `start_at` оставляем как был
* (это маркер «когда вообще началась подписка»).
*
* Это поведение специально сознательное: биллинг кабинета ВОСХОД при
* retry / повторной отправке не должен бояться дубликатов. История
* выписки подписок остаётся в blockchain trace'ах (Antelope action history),
* а не в таблице `subs`.
*
* \param coopname провайдер каталога (тот, кто выписывает подписку).
* \param subscriber кооператив-подписчик.
* \param chain_id подсеть, в которой действует подписка.
*
* \note Авторизация: @p coopname @ active. Проверки `chain_id` против
* `coops.chain_id` НЕ делаем, потому что:
* 1) `coops` row может ещё не существовать при первой выдаче подписки;
* 2) подписка теоретически может быть выдана коопу из другой подсети
* (миграция). Лучше доверять биллингу.
*/
void apps::regsub(eosio::name coopname,
eosio::name subscriber,
eosio::name package_id,
eosio::checksum256 chain_id,
eosio::name plan,
eosio::time_point_sec start_at,
eosio::time_point_sec end_at) {
require_auth(coopname);
eosio::check(subscriber.value != 0, "subscriber не может быть пустым");
eosio::check(plan.value != 0, "plan не может быть пустым");
eosio::check(end_at >= start_at, "end_at должен быть >= start_at");
packages_index packages(_apps, _apps.value);
auto pkg_it = packages.find(package_id.value);
eosio::check(pkg_it != packages.end(), "Пакет не зарегистрирован");
subs_index subs(_apps, _apps.value);
auto by_cooppkg = subs.get_index<"bycooppkg"_n>();
uint128_t key = ((uint128_t)subscriber.value << 64) | package_id.value;
auto sub_it = by_cooppkg.find(key);
auto now_tps = eosio::time_point_sec(eosio::current_time_point().sec_since_epoch());
if (sub_it == by_cooppkg.end()) {
uint64_t new_id = get_global_id(_apps, "sub"_n);
subs.emplace(coopname, [&](auto &s) {
s.id = new_id;
s.coopname = subscriber;
s.package_id = package_id;
s.chain_id = chain_id;
s.plan = plan;
s.active = true;
s.start_at = start_at;
s.end_at = end_at;
s.created_at = now_tps;
s.updated_at = now_tps;
});
} else {
by_cooppkg.modify(sub_it, coopname, [&](auto &s) {
s.chain_id = chain_id;
s.plan = plan;
s.active = true;
s.end_at = end_at > s.end_at ? end_at : s.end_at;
s.updated_at = now_tps;
});
}
}
@@ -0,0 +1,53 @@
/**
* \brief Обновление параметров кооператива.
* \ingroup public_apps_actions
*
* Все параметры опциональны — обновляются только переданные. Сценарии
* использования:
* - Ротация подписи: `setcoop(coopname, signing_key=<new>)`. Меняет
* `signing_key` и `key_rotated_at=now`.
* - Деактивация: `setcoop(coopname, active=false)`. CA-auth перестанет
* отвечать на запросы от этого коопа.
* - Миграция между подсетями: `setcoop(coopname, chain_id=<new>, subnet_label=<new>)`.
* - Обновление human-label: `setcoop(coopname, subnet_label=<new>)`.
*
* \note Ранее выпущенные JWT (TTL 14 дней в CA-auth) остаются валидны
* после ротации `signing_key`. Только новые signed-request'ы должны
* идти от нового приват-ключа. Это даёт zero-downtime ротации.
*
* \note Авторизация: @p coopname @ active. Если `@active`-ключ
* коопаккаунта скомпрометирован, эта операция не доступна злоумышленнику —
* сначала кооператив должен ротировать `@active` через
* `eosio::updateauth` (это отдельная операция, владелец-ключ).
*/
void apps::setcoop(eosio::name coopname,
std::optional<eosio::checksum256> chain_id,
std::optional<eosio::name> subnet_label,
std::optional<eosio::public_key> signing_key,
std::optional<bool> active) {
require_auth(coopname);
coops_index coops(_apps, _apps.value);
auto it = coops.find(coopname.value);
eosio::check(it != coops.end(), "Кооператив не зарегистрирован — используйте regcoop");
bool nothing_to_change = !chain_id.has_value()
&& !subnet_label.has_value()
&& !signing_key.has_value()
&& !active.has_value();
eosio::check(!nothing_to_change, "Нет полей для обновления");
auto now_tps = eosio::time_point_sec(eosio::current_time_point().sec_since_epoch());
coops.modify(it, coopname, [&](auto &c) {
if (chain_id.has_value()) c.chain_id = *chain_id;
if (subnet_label.has_value()) {
eosio::check(subnet_label->value != 0, "subnet_label не может быть пустым");
c.subnet_label = *subnet_label;
}
if (signing_key.has_value()) {
c.signing_key = *signing_key;
c.key_rotated_at = now_tps;
}
if (active.has_value()) c.active = *active;
});
}
@@ -0,0 +1,124 @@
/**
* \brief Опубликовать новый release пакета — атомарный transition.
* \ingroup public_apps_actions
*
* Алгоритм (выполняется в одной транзакции):
*
* 1. Проверки: пакет существует; статус — известный; scope согласован
* (kind=all → targets пустой; kind=subnet/canary → targets непустой).
* 2. Выполнить supersede предыдущего active с тем же scope (точное
* совпадение `kind` + `targets`). Записывается `superseded_at=now`.
* Если кто-то уже superseded — пропускаем.
* 3. Создать новый row со `status=active`, `superseded_at=0`.
* 4. Если `scope.kind == "all"_n` — обновить `packages.last_active_version`.
* 5. Inline-cleanup: пройтись по `bysuperseded`-индексу для этого
* пакета и удалить до `CLEANUP_BUDGET_PER_CALL` записей с
* `superseded_at < now - RELEASE_RETENTION_SECS`.
*
* Это ядро «atomic moderation approve» (FR8): индекс CA + KE.releases
* — либо обе, либо ни одна. Со стороны blockchain'а — гарантия атомарности
* в пределах одной транзакции.
*
* \note Re-publish одной и той же `version` запрещён: если releases
* уже содержит row для (package_id, version) — фейл. Версии
* публикуются монотонно вверх; для отката используется
* `reactivate(version=старая)`.
*
* \note Авторизация: @p coopname @ active.
*/
void apps::setrelease(eosio::name coopname,
eosio::name package_id,
std::string version,
Apps::scope_t scope,
eosio::checksum256 tarball_sha256,
eosio::name moderated_by,
std::string meta) {
require_auth(coopname);
eosio::check(!version.empty() && version.size() <= 64, "Некорректная version");
eosio::check(scope.kind == "all"_n || scope.kind == "subnet"_n || scope.kind == "canary"_n,
"scope.kind должен быть all|subnet|canary");
if (scope.kind == "all"_n) {
eosio::check(scope.targets.empty(), "Для scope=all targets должен быть пустым");
} else {
eosio::check(!scope.targets.empty(), "Для scope=subnet|canary targets обязателен");
}
packages_index packages(_apps, _apps.value);
auto pkg_it = packages.find(package_id.value);
eosio::check(pkg_it != packages.end(), "Пакет не зарегистрирован");
releases_index releases(_apps, _apps.value);
auto by_pkgstat = releases.get_index<"bypkgstat"_n>();
auto now_sec = eosio::current_time_point().sec_since_epoch();
auto now_tps = eosio::time_point_sec(now_sec);
// Запретить дубликат version — пройтись по всем релизам пакета.
auto by_pkg = releases.get_index<"bypackage"_n>();
for (auto rit = by_pkg.lower_bound(package_id.value);
rit != by_pkg.end() && rit->package_id == package_id;
++rit) {
eosio::check(rit->version != version,
"Релиз с такой version уже опубликован — используйте reactivate");
}
// Supersede существующих active с тем же scope.
uint128_t key_active = ((uint128_t)package_id.value << 64) | "active"_n.value;
auto act_it = by_pkgstat.lower_bound(key_active);
while (act_it != by_pkgstat.end()
&& ((uint128_t)act_it->package_id.value << 64 | act_it->status.value) == key_active) {
if (act_it->scope.kind == scope.kind && act_it->scope.targets == scope.targets) {
// Найден active с тем же scope → supersede.
auto stash = act_it;
++act_it;
releases.modify(*stash, coopname, [&](auto &r) {
r.status = "superseded"_n;
r.superseded_at = now_tps;
});
} else {
++act_it;
}
}
// Создать новый active.
uint64_t new_id = get_global_id(_apps, "release"_n);
releases.emplace(coopname, [&](auto &r) {
r.id = new_id;
r.package_id = package_id;
r.version = version;
r.scope = scope;
r.status = "active"_n;
r.published_at = now_tps;
r.superseded_at = eosio::time_point_sec(0);
r.moderated_by = moderated_by;
r.tarball_sha256 = tarball_sha256;
r.meta = meta;
});
// Обновить last_active_version только для scope=all.
if (scope.kind == "all"_n) {
packages.modify(pkg_it, coopname, [&](auto &p) {
p.last_active_version = version;
p.updated_at = now_tps;
});
}
// Inline-cleanup TTL-просроченных superseded для этого пакета.
uint128_t key_super = ((uint128_t)package_id.value << 64) | "superseded"_n.value;
uint64_t threshold = now_sec > Apps::RELEASE_RETENTION_SECS
? now_sec - Apps::RELEASE_RETENTION_SECS
: 0;
uint64_t removed = 0;
auto sup_it = by_pkgstat.lower_bound(key_super);
while (sup_it != by_pkgstat.end()
&& ((uint128_t)sup_it->package_id.value << 64 | sup_it->status.value) == key_super
&& removed < Apps::CLEANUP_BUDGET_PER_CALL) {
if (sup_it->superseded_at.sec_since_epoch() < threshold) {
sup_it = by_pkgstat.erase(sup_it);
++removed;
} else {
++sup_it;
}
}
}
@@ -0,0 +1,28 @@
/**
* \brief Передача владения пакетом другому пользователю (FR3).
* \ingroup public_apps_actions
*
* Меняет `owner` на `new_owner`. Прав на сами релизы это не меняет —
* `setrelease` всё равно выполняется провайдером каталога (`coopname`),
* `owner` — это поле для аудита и UI «чей это пакет».
*
* \note Авторизация: @p coopname @ active.
*/
void apps::transferpkg(eosio::name coopname,
eosio::name package_id,
eosio::name new_owner) {
require_auth(coopname);
eosio::check(new_owner.value != 0, "new_owner не может быть пустым");
packages_index packages(_apps, _apps.value);
auto it = packages.find(package_id.value);
eosio::check(it != packages.end(), "Пакет не найден");
eosio::check(it->owner != new_owner, "Пакет уже принадлежит этому владельцу");
auto now = eosio::current_time_point().sec_since_epoch();
packages.modify(it, coopname, [&](auto &p) {
p.owner = new_owner;
p.updated_at = eosio::time_point_sec(now);
});
}
@@ -0,0 +1,62 @@
/**
* \brief Отозвать релиз (CVE / нарушение / юридический отзыв).
* \ingroup public_apps_actions
*
* Семантика отзыва:
* - Релиз становится `withdrawn` и НЕ может быть установлен через CA-auth.
* - Запись остаётся в таблице — TTL на withdrawn не действует.
* - Если withdrawn-релиз был active со scope=all, `last_active_version`
* обнуляется ("") — UI должен показать «нет активной версии», пока
* оператор не выпустит новый setrelease или не реактивирует старую.
* - Если был active со scope=subnet/canary, `last_active_version` не
* трогается — она показывает версию для основной массы кооперативов.
*
* \note Авторизация: @p coopname @ active.
*/
void apps::withdraw(eosio::name coopname,
eosio::name package_id,
std::string version,
std::string reason) {
require_auth(coopname);
eosio::check(reason.size() <= 512, "reason слишком длинный (макс 512)");
packages_index packages(_apps, _apps.value);
auto pkg_it = packages.find(package_id.value);
eosio::check(pkg_it != packages.end(), "Пакет не зарегистрирован");
releases_index releases(_apps, _apps.value);
auto by_pkg = releases.get_index<"bypackage"_n>();
auto target = by_pkg.end();
for (auto rit = by_pkg.lower_bound(package_id.value);
rit != by_pkg.end() && rit->package_id == package_id;
++rit) {
if (rit->version == version) {
target = rit;
break;
}
}
eosio::check(target != by_pkg.end(), "Релиз не найден");
eosio::check(target->status != "withdrawn"_n, "Релиз уже отозван");
bool was_active_all = target->status == "active"_n
&& target->scope.kind == "all"_n;
auto now_tps = eosio::time_point_sec(eosio::current_time_point().sec_since_epoch());
by_pkg.modify(target, coopname, [&](auto &r) {
r.status = "withdrawn"_n;
// superseded_at оставляем как есть; meta обогащаем reason'ом.
if (!reason.empty()) {
if (!r.meta.empty()) r.meta += "\n";
r.meta += "withdraw_reason: " + reason;
}
});
if (was_active_all) {
packages.modify(pkg_it, coopname, [&](auto &p) {
p.last_active_version = "";
p.updated_at = now_tps;
});
}
}
+4 -1
View File
@@ -138,6 +138,7 @@ static constexpr uint64_t _capital_program_id = 4;
#define LOAN "loan"
#define MEET "meet"
#define LEDGER "ledger"
#define APPS "apps"
/**
@@ -161,6 +162,7 @@ static constexpr uint64_t _capital_program_id = 4;
static constexpr eosio::name _loan = "loan"_n;
static constexpr eosio::name _meet = "meet"_n;
static constexpr eosio::name _ledger = "ledger"_n;
static constexpr eosio::name _apps = "apps"_n;
static constexpr eosio::name _power_account = "eosio.power"_n;
static constexpr eosio::name _saving_account = "eosio.saving"_n;
@@ -187,7 +189,8 @@ static constexpr uint64_t _capital_program_id = 4;
"eosio.power"_n,
"eosio.saving"_n,
"ledger"_n,
"capital"_n
"capital"_n,
"apps"_n
// Добавьте остальные стандартные или пользовательские контракты по необходимости
};
@@ -60,3 +60,9 @@
#include "table_marketplace_requests.hpp"
#include "table_marketplace_segments.hpp"
#include "table_marketplace_shipments.hpp"
// apps (каталог приложений)
#include "table_apps_packages.hpp"
#include "table_apps_releases.hpp"
#include "table_apps_subs.hpp"
#include "table_apps_coops.hpp"
@@ -0,0 +1,62 @@
#pragma once
#include <eosio/eosio.hpp>
#include <eosio/crypto.hpp>
#include "../consts.hpp"
namespace Apps {
using namespace eosio;
/**
* \brief Запись каталога: кооператив, зарегистрированный в каталоге.
*
* Регистрация кооператива в каталоге — это часть процесса подключения
* к каталогу приложений на Восходе (вне scope текущего MVP). Шаги:
*
* 1. Кооператив проходит onboarding в кабинете ВОСХОД.
* 2. Кабинет генерирует/принимает выделенный subnet-signing-keypair.
* Приватная часть остаётся у DC-оркестратора кооператива.
* 3. Кабинет вызывает `regcoop(coopname, chain_id, subnet_label, signing_key)`
* с публичной частью.
*
* Поле `signing_key` — это **отдельный** ключ, не равный `eosio::active`-ключу
* аккаунта. Семантика:
*
* - Используется только off-chain в `CA-auth` для проверки подписи
* `signed-request`-ов от DC-оркестратора (заголовок `X-Signature`).
* - НЕ имеет on-chain прав (`active` остаётся отдельно).
* - Ротация — отдельным action `setcoop(signing_key=new)`. Компрометация
* `active` не лопает signed-request-цепочку и наоборот.
* - Ранее выпущенные JWT (TTL 14 дней) остаются валидны после ротации;
* только новые signed-request'ы должны идти от нового приват-ключа.
*
* `chain_id` (`checksum256`) — каноническая идентификация подсети, в которой
* зарегистрирован кооператив (как в memory `subnet_identification` в
* apps-catalog). `subnet_label` (`ru`/`by`/...) — human-label, удобный
* для UI, scope-фильтрации в `releases`, и логирования.
*
* Контракт сам подпись не проверяет — он только хранит публичный ключ.
* Проверка ECDSA secp256k1 — на стороне `CA-auth` через RPC
* `get_table_rows apps coops`.
*/
struct [[eosio::table, eosio::contract(APPS)]] coop {
name coopname; // PK = Antelope name
checksum256 chain_id; // подсеть (каноническая идентификация)
name subnet_label; // human-label "ru"/"by" для UI/scope
public_key signing_key; // subnet-signing-key (НЕ active)
bool active;
time_point_sec registered_at;
time_point_sec key_rotated_at; // последняя ротация signing_key
uint64_t primary_key() const { return coopname.value; }
uint64_t by_subnet() const { return subnet_label.value; }
};
typedef eosio::multi_index<
"coops"_n, coop,
eosio::indexed_by<"bysubnet"_n, eosio::const_mem_fun<coop, uint64_t, &coop::by_subnet>>>
coops_index;
} // namespace Apps
@@ -0,0 +1,63 @@
#pragma once
#include <eosio/eosio.hpp>
#include <eosio/crypto.hpp>
#include <string>
#include <vector>
#include "../consts.hpp"
namespace Apps {
using namespace eosio;
/**
* \brief Запись каталога: один зарегистрированный пакет.
*
* Идентификация: PK = `package_id` (Antelope `name`, ≤12 символов, [a-z1-5.]).
* Имя выбирает разработчик при публикации; уникальность гарантируется блокчейном
* (`emplace` фейлится, если такой `package_id` уже зарегистрирован).
*
* Имена пакета:
* - `package_id` — кодовое неизменяемое имя в каталоге (этот PK).
* - `package_name` — внешнее человекочитаемое имя (например, npm
* `@voskhod/sports-club-tracker`, go-module path или OCI-image name).
* Меняется через `setpkgname` (если потребуется) или re-publish; в текущей
* схеме допускается изменение через rename-action в будущем.
*
* `compatible_subnets` — human-labels подсетей (`ru`, `by`, ...). Реальная
* идентификация подсети — через `chain_id`, который хранится в `coops`/`subs`;
* здесь labels достаточно, потому что они используются только для display
* и грубой фильтрации в UI.
*
* `last_active_version` — semver самого свежего active-релиза c `scope.kind=all`.
* Поле обновляется только при `setrelease` со scope=all и используется для
* быстрого ответа `GET /v1/package/:id` без сканирования таблицы `releases`.
* Canary-релизы это поле не трогают — оно отражает «версию по умолчанию»
* для кооперативов вне canary-целей.
*
* \see lib/domain/table_apps_releases.hpp — связанные релизы (FK by_package).
*/
struct [[eosio::table, eosio::contract(APPS)]] package {
name package_id; // PK, кодовое имя
std::string package_name; // внешнее имя (npm/go/oci)
name owner; // владелец (username разработчика)
std::vector<name> compatible_subnets; // labels подсетей: "ru","by",...
std::string last_active_version; // semver последнего scope=all active
time_point_sec created_at;
time_point_sec updated_at;
uint64_t primary_key() const { return package_id.value; }
uint64_t by_owner() const { return owner.value; }
uint64_t by_subnet() const {
return compatible_subnets.empty() ? 0 : compatible_subnets[0].value;
}
};
typedef eosio::multi_index<
"packages"_n, package,
eosio::indexed_by<"byowner"_n, eosio::const_mem_fun<package, uint64_t, &package::by_owner>>,
eosio::indexed_by<"bysubnet"_n, eosio::const_mem_fun<package, uint64_t, &package::by_subnet>>>
packages_index;
} // namespace Apps
@@ -0,0 +1,95 @@
#pragma once
#include <eosio/eosio.hpp>
#include <eosio/crypto.hpp>
#include <string>
#include <vector>
#include "../consts.hpp"
#include "../core/utils.hpp"
namespace Apps {
using namespace eosio;
/**
* \brief Описание области видимости релиза.
*
* Возможные `kind`:
* - `"all"_n` — релиз доступен всем кооперативам всех совместимых подсетей.
* `targets` — пустой.
* - `"subnet"_n` — релиз ограничен набором подсетей (по human-label
* `subnet_label` из `coops`). `targets` — список labels.
* - `"canary"_n` — релиз раскатывается на конкретные кооперативы.
* `targets` — список `coopname`.
*
* Контракт scope-resolver не делает: он хранит scope как есть, а правило
* приоритизации `canary > subnet > all` применяется на стороне `CA-auth`
* при выпуске JWT.
*/
struct scope_t {
name kind; // "all" | "subnet" | "canary"
std::vector<name> targets; // labels подсетей или coopname'ы
EOSLIB_SERIALIZE(scope_t, (kind)(targets))
};
/**
* \brief Запись каталога: один релиз пакета.
*
* Жизненный цикл записи:
*
* 1. `setrelease` создаёт row со `status=active`, `superseded_at=0`.
* 2. Любой существующий active с тем же `(package_id, scope)` переходит
* в `superseded`, `superseded_at=now`.
* 3. `withdraw` ставит `status=withdrawn` (релиз скомпрометирован /
* отозван юридически). `withdrawn` НЕ удаляются по TTL — храним до
* явной чистки.
* 4. TTL retention: `superseded` старше 90 дней удаляются inline в
* `setrelease` (до 50 за вызов) или по явному `cleanup(package_id)`.
*
* Решение НЕ хранить полную историю принято намеренно: каталог — это
* source-of-truth текущего состояния, история живёт в git/docker registry.
* RAM-budget при 1000 пакетов × ~10 релизов в окне = ~40K записей.
*
* Indexing strategy:
* - PK `id` — auto-inc через `get_global_id(_apps, "release"_n)`.
* - `bypackage` — выборка всех релизов пакета (`CA` для scope-resolver).
* - `bypkgstat` — выборка active/superseded/withdrawn внутри пакета
* (composite `package_id<<8 | status_code`); удобно для cleanup
* «найти все superseded для пакета X».
* - `bysuperseded` — sorted by `superseded_at`; используется в cleanup
* для нахождения самых старых записей под удаление.
*
* \note `tarball_sha256` — содержательный хэш артефакта, нужен для
* integrity-check при скачивании. Сама проверка — на `CA-auth`,
* контракт хэш только хранит.
*
* \see lib/domain/table_apps_packages.hpp
*/
struct [[eosio::table, eosio::contract(APPS)]] release {
uint64_t id;
name package_id; // FK → packages.package_id
std::string version; // semver
scope_t scope;
name status; // "active" | "superseded" | "withdrawn"
time_point_sec published_at;
time_point_sec superseded_at; // 0 пока active
name moderated_by; // имя модератора (audit)
checksum256 tarball_sha256;
std::string meta; // JSON-строка: changelog, релиз-нотес и т.п.
uint64_t primary_key() const { return id; }
uint64_t by_package() const { return package_id.value; }
uint128_t by_pkg_status() const { return ((uint128_t)package_id.value << 64) | status.value; }
uint64_t by_superseded() const { return superseded_at.sec_since_epoch(); }
};
typedef eosio::multi_index<
"releases"_n, release,
eosio::indexed_by<"bypackage"_n, eosio::const_mem_fun<release, uint64_t, &release::by_package>>,
eosio::indexed_by<"bypkgstat"_n, eosio::const_mem_fun<release, uint128_t, &release::by_pkg_status>>,
eosio::indexed_by<"bysuperseded"_n, eosio::const_mem_fun<release, uint64_t, &release::by_superseded>>>
releases_index;
} // namespace Apps
@@ -0,0 +1,72 @@
#pragma once
#include <eosio/eosio.hpp>
#include <eosio/crypto.hpp>
#include "../consts.hpp"
#include "../core/utils.hpp"
namespace Apps {
using namespace eosio;
/**
* \brief Запись каталога: подписка кооператива на пакет.
*
* Идентификация подписки — composite `(coopname, package_id, chain_id)`:
* - `coopname` — кто подписан (Antelope name).
* - `package_id` — на какой пакет.
* - `chain_id` — в какой подсети действует подписка. Контракт `apps`
* деплоится на корневой координационной KE-цепи и обслуживает
* несколько подсетей; chain_id привязывает подписку к конкретной
* подсети, в которой кооператив находится.
*
* `plan` — имя плана (`basic`, `premium`, ...). Это **только метка**:
* биллинг (расчёт стоимости, приём оплаты, выставление счёта) — задача
* кабинета ВОСХОД и контракта `pay.coopenomics`. Сюда приходит уже факт
* «подписка активна с X по Y по плану Z».
*
* `regsub` идемпотентно по `(coopname, package_id)`: повторный вызов с
* тем же ключом продлевает `end_at` (= `max(текущее, новое)`) и
* обновляет `plan`/`active`. Это поведение сознательное — биллинг при
* retry не должен бояться дубликатов.
*
* `expsub` НЕ удаляет row, а ставит `active=false`. Запись остаётся
* для аудита истечения подписки. Cleanup просроченных записей — задача
* отдельного периодического действия (вне MVP).
*
* Indexing:
* - PK `id` — auto-inc.
* - `bycooppkg` — composite (coopname, package_id) для idempotent upsert
* и проверки «есть ли активная подписка коопа X на пакет Y».
* - `bycoop` — все подписки одного кооператива.
* - `byexpires` — sorted by `end_at` для будущего auto-cleanup.
*
* \see lib/domain/table_apps_coops.hpp — chain_id ↔ кооператив.
*/
struct [[eosio::table, eosio::contract(APPS)]] sub {
uint64_t id;
name coopname;
name package_id;
checksum256 chain_id;
name plan;
bool active;
time_point_sec start_at;
time_point_sec end_at;
time_point_sec created_at;
time_point_sec updated_at;
uint64_t primary_key() const { return id; }
uint128_t by_cooppkg() const { return ((uint128_t)coopname.value << 64) | package_id.value; }
uint64_t by_coop() const { return coopname.value; }
uint64_t by_expires() const { return end_at.sec_since_epoch(); }
};
typedef eosio::multi_index<
"subs"_n, sub,
eosio::indexed_by<"bycooppkg"_n, eosio::const_mem_fun<sub, uint128_t, &sub::by_cooppkg>>,
eosio::indexed_by<"bycoop"_n, eosio::const_mem_fun<sub, uint64_t, &sub::by_coop>>,
eosio::indexed_by<"byexpires"_n, eosio::const_mem_fun<sub, uint64_t, &sub::by_expires>>>
subs_index;
} // namespace Apps
+44
View File
@@ -0,0 +1,44 @@
# Образ-артефакт для всей пачки контрактов кооперативной экономики.
#
# Назначение: переносить wasm/abi всех контрактов между этапами CI/CD
# и потребителями (ke-bootstrap, локальные dev-окружения, аудит).
# Сам по себе образ ничего не запускает на блокчейне — его пара —
# отдельный образ ke-bootstrap, который при старте ноды деплоит
# контракты на неё через `cleos set contract`.
#
# Содержимое:
# /contracts/<name>/<name>.wasm
# /contracts/<name>/<name>.abi
# /contracts/manifest.json — список контрактов + sha256 + build_mode
#
# Способы потребления:
#
# 1) Multi-stage в bootstrap-образе (production):
# FROM dicoop/contracts:testnet AS contracts
# ...
# COPY --from=contracts /contracts /work/contracts
#
# 2) Volume-mount (debug / ad-hoc):
# docker run --rm -v $PWD/out:/out dicoop/contracts:testnet
# → ./out/<name>/{wasm,abi} + manifest.json
#
# 3) Точечно один контракт:
# docker run --rm -v $PWD/out:/out dicoop/contracts:testnet copy apps
# → ./out/apps/{wasm,abi}
#
# Сборка артефактов идёт в CI до docker build через CDT-образ
# `dicoop/blockchain_v5.1.1:dev` (см. components/contracts/build-all.sh).
# Этот Dockerfile только упаковывает результат в slim alpine.
FROM alpine:3.20
RUN mkdir -p /contracts
COPY contracts/ /contracts/
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
LABEL org.opencontainers.image.title="coopenomics-contracts"
LABEL org.opencontainers.image.description="WASM+ABI артефакты всех контрактов кооперативной экономики"
LABEL org.opencontainers.image.source="https://github.com/coopenomics/mono"
ENTRYPOINT ["/entrypoint.sh"]
CMD ["copy"]
+111
View File
@@ -0,0 +1,111 @@
# `dicoop/contracts` — образ-артефакт всех контрактов
Контейнер собирает в один slim alpine-образ wasm/abi всех контрактов
кооперативной экономики (`apps, marketplace, ledger, meet, loan, wallet,
capital, branch, contributor, fund, draft, soviet, registrator, gateway,
system, test`).
## Зачем отдельный образ
- Нода (`dicoop/blockchain_v5.1.1`) — это runtime с `nodeos`. Она
одна и не должна повторно собираться вместе с контрактами.
- Сборка контрактов (через CDT) — отдельный шаг CI, выполняется в том
же `dicoop/blockchain_v5.1.1`-образе но в режиме toolchain.
- Деплой — третий артефакт (`ke-bootstrap`), который читает контракты
отсюда и применяет их к ноде через `cleos set contract`.
Эта декомпозиция даёт переиспользование: одна нода + N версий контрактов
+ один bootstrap.
## Layout внутри образа
```
/contracts/
├── apps/
│ ├── apps.wasm
│ └── apps.abi
├── marketplace/
│ ├── marketplace.wasm
│ └── marketplace.abi
├── ...
└── manifest.json # имена + sha256 + build_mode + git sha + timestamp
```
## Тэги
| Ветка | Tag | `IS_TESTNET` |
| --- | --- | --- |
| `dev` | `dicoop/contracts:dev` | ON |
| `testnet` | `dicoop/contracts:testnet` | ON |
| `main` | `dicoop/contracts:main` + `:latest` | OFF |
Дополнительно для воспроизводимости каждый push публикуется как
`dicoop/contracts:<tag>-<short_sha>`.
## Использование
### 1. Multi-stage в bootstrap-образе (production)
```dockerfile
FROM dicoop/contracts:testnet AS contracts
FROM dicoop/blockchain_v5.1.1:dev
COPY --from=contracts /contracts /work/contracts
# ... bootstrap-script использует /work/contracts при cleos set contract
```
### 2. Volume-mount (debug, локальные dev-окружения)
```bash
mkdir -p ./out
docker run --rm -v "$PWD/out:/out" dicoop/contracts:testnet
ls out/
# apps/ marketplace/ ledger/ ... manifest.json
```
### 3. Точечно один контракт
```bash
docker run --rm -v "$PWD/out:/out" dicoop/contracts:testnet copy apps
ls out/apps/
# apps.wasm apps.abi
```
### 4. Аудит / диагностика
```bash
# Список всех контрактов в образе:
docker run --rm dicoop/contracts:testnet list
# Контрольные суммы:
docker run --rm dicoop/contracts:testnet sha256
# Манифест с метаданными сборки:
docker run --rm dicoop/contracts:testnet manifest | jq .
```
## Локальная сборка
```bash
# 1. Скомпилировать контракты:
cd components/contracts
./build-all.sh prod # или ./build-all.sh test для testnet-режима
# 2. Подготовить docker context:
mkdir -p docker/.context/contracts
for c in apps marketplace ledger meet loan wallet capital branch \
contributor fund draft soviet registrator gateway system test; do
if [ -f build/contracts/$c/$c.wasm ]; then
mkdir -p docker/.context/contracts/$c
cp build/contracts/$c/$c.wasm docker/.context/contracts/$c/
cp build/contracts/$c/$c.abi docker/.context/contracts/$c/
fi
done
cp docker/Dockerfile docker/entrypoint.sh docker/.context/
# 3. Build:
docker build -t dicoop/contracts:local ./docker/.context
```
В CI этот pipeline автоматизирован — см.
`.github/workflows/build-contracts.yaml`.
+91
View File
@@ -0,0 +1,91 @@
#!/bin/sh
# Entrypoint образа dicoop/contracts.
# Команды:
# copy [<name>] — копировать в $OUT_DIR (по умолчанию /out):
# без имени — всё дерево /contracts;
# с именем — только /contracts/<name>/.
# manifest — вывести manifest.json в stdout.
# sha256 [<name>] — sha256 wasm/abi для всех или одного контракта.
# ls [<name>] — листинг /contracts (рекурсивно для всего, или один).
# list — печать имён контрактов через newline (для скриптов).
#
# Default: copy (всё дерево).
set -eu
cmd="${1:-copy}"
shift || true
list_names() {
# Имена контрактов = имена subdirectories в /contracts.
for d in /contracts/*/; do
[ -d "$d" ] || continue
basename "$d"
done
}
assert_contract() {
name="$1"
if [ ! -d "/contracts/$name" ]; then
echo "Контракт '$name' не найден в образе. Доступные:" >&2
list_names >&2
exit 65
fi
}
case "$cmd" in
copy)
DEST="${OUT_DIR:-/out}"
mkdir -p "$DEST"
if [ $# -ge 1 ]; then
name="$1"
assert_contract "$name"
mkdir -p "$DEST/$name"
cp /contracts/"$name"/*.wasm "$DEST/$name/" 2>/dev/null || true
cp /contracts/"$name"/*.abi "$DEST/$name/" 2>/dev/null || true
echo "[contracts] copied $name$DEST/$name"
else
cp -R /contracts/. "$DEST/"
echo "[contracts] copied all artifacts → $DEST"
fi
ls -la "$DEST" 2>/dev/null || true
;;
manifest)
cat /contracts/manifest.json
;;
sha256)
if [ $# -ge 1 ]; then
name="$1"
assert_contract "$name"
sha256sum /contracts/"$name"/*.wasm /contracts/"$name"/*.abi
else
find /contracts -name '*.wasm' -o -name '*.abi' | sort | xargs sha256sum
fi
;;
ls)
if [ $# -ge 1 ]; then
name="$1"
assert_contract "$name"
ls -la /contracts/"$name"/
else
ls -la /contracts/
echo "---"
find /contracts -mindepth 2 -maxdepth 2 | sort
fi
;;
list)
list_names
;;
*)
cat >&2 <<EOF
Usage: docker run --rm dicoop/contracts <cmd> [name]
Commands:
copy [<name>] Copy all contracts (or one) to \$OUT_DIR (default: /out).
manifest Print manifest.json (list of contracts, sha256, build mode).
sha256 [<name>] sha256 of wasm/abi (all or one contract).
ls [<name>] List directory contents.
list Print contract names, one per line.
EOF
exit 64
;;
esac