feat(apps): pricing validation + clients table + D4 regsub extension (stories v2.1.2, v2.4.1, v2.5.0)

Батчем все контрактные сторы эпиков v2.1/v2.4 + implicit D4-расширение
из эпика v2.5, чтобы CA-сторона могла строить write-port'а против
финальных on-chain сигнатур, не возвращаясь в Mono.

Story v2.1.2 — setpricing валидация:
  - hourly_rate.amount > 0 (eosio_assert)
  - package_id и plan непустые
  - snapshot policy: НЕ трогаем subs (оплаченные периоды не пересчитываются
    при смене тарифа), audit-snapshot — off-chain (journal-less invariant)

Story v2.4.1 — clients table (D3) + regclient/delclient:
  - scope = catalog_operator, PK = client_coopname
  - regclient: строгий insert (eosio_assert на дубль), RAM payer = operator
  - delclient: erase с eosio_assert на отсутствие
  - в MVP всегда voskhod-as-operator, но scope-based не блокирует replica

Story v2.5.0 (implicit, D4) — regsub extension + idempotent extend:
  - sub.attempt: uint8 = 0 — billing-retry counter
  - sub.last_charge_intent_id: checksum256 = 0 — UUIDv5 последнего charge
  - extendsub(operator, subscriber, package_id, period_seconds, intent_id):
    идемпотентный extend, eosio_assert("already extended") при дубле intent_id;
    end_at += period_seconds, attempt → 0, обновляет updated_at
  - setattempt(operator, subscriber, package_id, attempt) — для retry-watcher
  - migration safe (subs пустая в dev/MVP; pre-deploy guard
    scripts/v2-migration-guard.ts блокирует прод при count > 0)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
coopops
2026-05-08 17:04:43 +00:00
parent 1c573bf131
commit f051196754
10 changed files with 369 additions and 9 deletions
+4
View File
@@ -13,6 +13,10 @@
#include "src/setcoop.cpp"
#include "src/setpricing.cpp"
#include "src/setglobals.cpp"
#include "src/regclient.cpp"
#include "src/delclient.cpp"
#include "src/extendsub.cpp"
#include "src/setattempt.cpp"
/**
* \brief Миграция контракта.
+82
View File
@@ -288,6 +288,88 @@ public:
uint32_t lead_time_seconds,
uint8_t retry_max);
// ─── clients (multi-tenant onboarding, v2.4) ────────────────────────
/**
* \brief Зарегистрировать кооператив-клиента каталога (FR7, D3).
* \details `clients`-row {scope=catalog_operator, PK=client_coopname}
* с `registered_at = now`. Идемпотентность строгая: повторный
* вызов на ту же пару → `eosio_assert("client already registered")`.
* RAM payer — `catalog_operator` (ВОСХОД оплачивает onboarding).
* \param catalog_operator оператор каталога, подписывает транзакцию
* и платит RAM (в MVP всегда `voskhod`).
* \param client_coopname кооператив, который подключается к каталогу.
* \note Авторизация: `require_auth(catalog_operator)`.
*/
[[eosio::action]] void regclient(eosio::name catalog_operator,
eosio::name client_coopname);
/**
* \brief Отозвать кооператив-клиента каталога (FR8, D3).
* \details Удаляет row по PK; row нет → `eosio_assert("client not found")`
* (не идемпотентно сознательно — отличаем «уже отзывали» от
* «никогда не регистрировали»). Эффекты на CA-стороне:
* инвалидация membership-cache (D9 hybrid TTL) + добавление
* активных JWT в `JwtRevokeList` (FR15) живут off-chain.
* \note Авторизация: `require_auth(catalog_operator)`.
*/
[[eosio::action]] void delclient(eosio::name catalog_operator,
eosio::name client_coopname);
// ─── subscription extend / retry-counter (D4, v2.5/v2.6) ────────────
/**
* \brief Идемпотентно продлить подписку через `charge_intent_id` (FR24, D4, D5).
* \details Находит `regsub` по `(subscriber, package_id)`; проверяет,
* что `last_charge_intent_id != charge_intent_id`. Иначе —
* `eosio_assert("already extended")` (это **ok-сигнал** для
* recovery-worker'а: дубль ack'а из кабинета или повтор после
* CA-crash). На успешном пути:
* - `end_at += period_seconds` (extend от текущей границы,
* не от now — important для непрерывности подписки),
* - `last_charge_intent_id = charge_intent_id`,
* - `attempt = 0` (счётчик retry'ев сбрасывается на удачном
* charge'е),
* - `updated_at = now`.
*
* Story v2.5.2 (TS write-port `extendByChargeIntent`) ожидает
* именно эту семантику; `AlreadyExtendedError` маппится с
* on-chain assert'а.
*
* \param catalog_operator оператор каталога (платит RAM, подписывает).
* \param subscriber кооператив-подписчик.
* \param package_id пакет.
* \param period_seconds на сколько продлить (в секундах). Должен быть > 0.
* \param charge_intent_id UUIDv5 от `(coopname, package_id, period_start_at)`.
* \note Авторизация: `require_auth(catalog_operator)`.
*/
[[eosio::action]] void extendsub(eosio::name catalog_operator,
eosio::name subscriber,
eosio::name package_id,
uint32_t period_seconds,
eosio::checksum256 charge_intent_id);
/**
* \brief Установить `attempt`-счётчик для подписки (D4 retry-management).
* \details Простая запись uint8 в существующую `regsub`-row. Используется
* pricing-watcher'ом CA, когда `ack=declined` или charge провалился
* (типичный путь: `setattempt(coopname, package_id, current+1)`).
* Каскад с `extendsub` не нужен — последний сам сбросит attempt
* в 0 при успехе.
*
* Альтернатива (on-chain incrattempt без передачи нового значения)
* могла бы избежать сравнения counter'ов на гонках, но усложняет
* контракт и редко полезна: pricing-watcher tick — единственный
* путь, и он сериализован per-(coopname, package_id) на стороне
* watcher-state в Postgres.
*
* \note Авторизация: `require_auth(catalog_operator)`.
*/
[[eosio::action]] void setattempt(eosio::name catalog_operator,
eosio::name subscriber,
eosio::name package_id,
uint8_t attempt);
// ─── service tables ─────────────────────────────────────────────────
struct [[eosio::table, eosio::contract(APPS)]] counts : counts_base {};
@@ -0,0 +1,38 @@
/**
* \brief Отозвать кооператив-клиента каталога (Story v2.4.1, FR8).
* \ingroup public_apps_actions
*
* Что делает действие:
* 1. Требует `catalog_operator @ active` — `require_auth(catalog_operator)`.
* 2. Валидирует, что `client_coopname` непустое.
* 3. Удаляет row по primary key. Если row нет → `eosio_assert("client not found")`.
* Идемпотентность сознательно НЕ соблюдается: повторный `delclient`
* должен вернуть явную ошибку, чтобы вышестоящие watcher'ы
* (cache-invalidation, JWT-revocation в CA) могли отличить «уже
* отзывали» от «никогда не было».
*
* **Эффекты на TS-стороне** (живут в `apps-catalog`, не на цепи):
* - membership-cache для `client_coopname` инвалидируется через
* hybrid-TTL (D9): критический путь забирает уже актуальный ответ
* из chain'а, read-API получает stale-ответ ≤ 60s.
* - JWT с `coopname=client_coopname` помещаются в `JwtRevokeList`
* (FR15): пока не истечёт TTL токена — он отозван по jti.
*
* RAM payer возвращается `catalog_operator`. `subs` записи кооператива
* НЕ удаляются автоматически — это решение оператора, и история
* подписок остаётся в `subs` для аудита (флаг `active=false`
* выставляется отдельным `expsub`-вызовом).
*/
void apps::delclient(eosio::name catalog_operator,
eosio::name client_coopname) {
require_auth(catalog_operator);
eosio::check(catalog_operator.value != 0, "catalog_operator must not be empty");
eosio::check(client_coopname.value != 0, "client_coopname must not be empty");
clients_index clients(get_self(), catalog_operator.value);
auto it = clients.find(client_coopname.value);
eosio::check(it != clients.end(), "client not found");
clients.erase(it);
}
@@ -0,0 +1,57 @@
/**
* \brief Идемпотентное продление подписки через `charge_intent_id`
* (D4, D5; story v2.5/v2.6, FR24).
* \ingroup public_apps_actions
*
* Что делает действие:
* 1. Требует подпись `catalog_operator @ active`.
* 2. Валидирует входы: `subscriber`, `package_id` непустые,
* `period_seconds > 0`.
* 3. Находит `regsub` по `(subscriber, package_id)` через secondary
* index `bycooppkg`. Если нет — `eosio_assert("subscription not found")`
* (вызов до `regsub` — программная ошибка CA-стороны, а не штатный
* control-flow).
* 4. Сверяет `last_charge_intent_id != charge_intent_id`. Если уже
* этот intent был применён — `eosio_assert("already extended")`.
* Это и есть double-emit prevention уровня цепи (D5):
* UUIDv5-детерминированный id + on-chain unique-by-last проверка
* закрывают двойное продление при race'ах watcher-recovery
* или повторных webhook'ах из кабинета.
* 5. На успешном пути модифицирует `regsub`:
* - `end_at += period_seconds` (extend ОТ текущей границы, не от now —
* это важно для непрерывности оплаченных интервалов; см. AR12).
* - `last_charge_intent_id = charge_intent_id`.
* - `attempt = 0` (счётчик retry'ев сбрасывается на удачном charge'е).
* - `updated_at = now`.
*
* RAM payer — `catalog_operator` (оператор оплачивает координацию;
* клиент-кооператив за `regsub`-row не платит).
*/
void apps::extendsub(eosio::name catalog_operator,
eosio::name subscriber,
eosio::name package_id,
uint32_t period_seconds,
eosio::checksum256 charge_intent_id) {
require_auth(catalog_operator);
eosio::check(subscriber.value != 0, "subscriber must not be empty");
eosio::check(package_id.value != 0, "package_id must not be empty");
eosio::check(period_seconds > 0, "period_seconds must be positive");
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 it = by_cooppkg.find(key);
eosio::check(it != by_cooppkg.end(), "subscription not found");
eosio::check(it->last_charge_intent_id != charge_intent_id,
"already extended");
auto now = eosio::time_point_sec(eosio::current_time_point().sec_since_epoch());
by_cooppkg.modify(it, catalog_operator, [&](auto &s) {
s.end_at = eosio::time_point_sec(s.end_at.sec_since_epoch() + period_seconds);
s.last_charge_intent_id = charge_intent_id;
s.attempt = 0;
s.updated_at = now;
});
}
@@ -0,0 +1,45 @@
/**
* \brief Зарегистрировать кооператив-клиента каталога (Story v2.4.1).
* \ingroup public_apps_actions
*
* Что делает действие:
* 1. Требует подпись `catalog_operator @ active` —
* `require_auth(catalog_operator)`. В MVP это всегда `voskhod`,
* но контракт не хардкодит имя — на проверке authority единственное
* допустимое подписавшее лицо это и есть scope записи.
* 2. Валидирует, что `client_coopname` непустое (eosio::name(0) —
* особое значение «отсутствие», не валидное имя).
* 3. Идемпотентно с проверкой: row нет → `emplace`. Row есть →
* `eosio_assert("client already registered")`. Это намеренный
* «строгий» upsert — повторный `regclient` без явного `delclient`
* должен ловить ошибки оператора (двойной вызов из-за UI-сбоя).
*
* RAM payer — `catalog_operator`. ВОСХОД оплачивает RAM записи как
* часть стоимости подключения кооператива; клиент за хранение `clients`-row
* не платит.
*
* Что **не делает** (выносится в TS-сторону, Story v2.4.5):
* - Не валидирует наличие `coops`-записи у `client_coopname`. Кооператив
* может быть подключён к каталогу до того, как оформлен в `coops`
* (signing_key, chain_id — это уровень subnet-операций). Каталог
* проверит это сам перед issue'ем JWT.
* - Не выпускает события / webhook'и. Audit-trail off-chain в
* `audit_log_admin` через `regsub`-watcher CA.
*/
void apps::regclient(eosio::name catalog_operator,
eosio::name client_coopname) {
require_auth(catalog_operator);
eosio::check(catalog_operator.value != 0, "catalog_operator must not be empty");
eosio::check(client_coopname.value != 0, "client_coopname must not be empty");
clients_index clients(get_self(), catalog_operator.value);
auto it = clients.find(client_coopname.value);
eosio::check(it == clients.end(), "client already registered");
auto now = eosio::time_point_sec(eosio::current_time_point().sec_since_epoch());
clients.emplace(catalog_operator, [&](auto &c) {
c.client_coopname = client_coopname;
c.registered_at = now;
});
}
@@ -0,0 +1,39 @@
/**
* \brief Установить `attempt`-счётчик в `regsub` (D4 retry-management).
* \ingroup public_apps_actions
*
* Pricing-watcher CA вызывает это действие, когда `ack=declined` или
* charge не дошёл, для отметки факта повторной попытки. Контракт
* не вычисляет `current+1` сам — watcher держит state в Postgres
* (charge_intents.attempt) и передаёт нужное значение явно. Так проще:
* любая ошибочная гонка фиксируется на уровне Postgres-инкремента,
* а контракт остаётся stateless относительно retry-логики.
*
* - `extendsub` сам сбрасывает `attempt=0` при удачном charge'е, поэтому
* pricing-watcher не должен дёргать `setattempt(0)` после успеха.
* - При `attempt > globals.retry_max` watcher эмитит
* `SubscriptionExpiredError` (story v2.6.9) и прекращает попытки;
* контракт это знание не дублирует — `retry_max` хранится в
* `globals` singleton и читается только off-chain.
*/
void apps::setattempt(eosio::name catalog_operator,
eosio::name subscriber,
eosio::name package_id,
uint8_t attempt) {
require_auth(catalog_operator);
eosio::check(subscriber.value != 0, "subscriber must not be empty");
eosio::check(package_id.value != 0, "package_id must not be empty");
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 it = by_cooppkg.find(key);
eosio::check(it != by_cooppkg.end(), "subscription not found");
auto now = eosio::time_point_sec(eosio::current_time_point().sec_since_epoch());
by_cooppkg.modify(it, catalog_operator, [&](auto &s) {
s.attempt = attempt;
s.updated_at = now;
});
}
@@ -1,19 +1,37 @@
/**
* \brief Установка ставки плана для пакета (per-plan upsert).
* \brief Установка ставки плана для пакета (per-plan upsert) с валидацией (Story v2.1.2).
* \ingroup public_apps_actions
*
* Story v2.1.1: каркас без бизнес-валидации. Что делает действие:
* Что делает действие:
* 1. Требует `apps@active` — `require_auth(get_self())`.
* 2. В таблице `pricings` (scope = `package_id`) находит запись по `plan`:
* 2. Валидирует входы: `package_id` и `plan` непустые (FR4 — сама длина
* ≤ 13 уже гарантирована типом `eosio::name`, но `name(0)` — особое
* значение «отсутствие», его явно отсекаем для удобной ошибки).
* 3. Валидирует `hourly_rate.amount > 0` (FR1) — отрицательная или
* нулевая ставка не имеет экономического смысла и блокирует
* корректный расчёт `charge.required` в watcher'е.
* 4. В таблице `pricings` (scope = `package_id`) находит запись по `plan`:
* - row нет → `emplace` с переданными `hourly_rate` и `updated_at = now`.
* - row есть → `modify`: обновляет `hourly_rate` и `updated_at`.
*
* Что **не делает** (выносится в Story v2.1.2):
* - Валидация `hourly_rate.amount > 0` и `is_amount_within_range`.
* - Валидация символа против `_root_govern_symbol` (ожидается `RUB,4`).
* - Whitelist допустимых имён `plan` (в MVP только `default`).
* - Snapshot предыдущей ставки в audit_log_admin (off-chain;
* journal-less invariant держит контракт без on-chain history).
* **Snapshot policy (FR2, journal-less invariant).** Действие НИКОГДА не
* трогает таблицу `subs`: поле `regsub.end_at` существующих подписок
* остаётся как есть — перерасчёта прошлого нет. Текущий оплаченный
* период подписок зафиксирован в момент `regsub`/`extendsub` по тогдашней
* ставке, и ретро-изменения тарифа на него не влияют. Snapshot для
* audit-журнала живёт off-chain в `audit_log_admin` (журнал-less
* инвариант — на цепи нет log-таблиц, история восстанавливается из
* trace'ов Antelope).
*
* Что **намеренно не валидируется здесь:**
* - Символ `hourly_rate.symbol` против `_root_govern_symbol` (`RUB,4`).
* Cross-currency расчёт не предусмотрен MVP, но жёсткая привязка
* к одному символу даст ложноотрицательные ошибки при будущих
* multi-currency сценариях. Адаптер CA-стороны нормализует символ
* до отправки.
* - Whitelist имён `plan`. В MVP только `default`, но on-chain
* хранилище должно оставаться открытым для будущих tier-планов
* без миграции контракта.
*
* RAM payer — `get_self()` (`apps`-контракт). Фактически платит ВОСХОД
* как owner аккаунта `apps`; pricing-таблицы — часть координационной
@@ -24,6 +42,10 @@ void apps::setpricing(eosio::name package_id,
eosio::asset hourly_rate) {
require_auth(get_self());
eosio::check(package_id.value != 0, "package_id must not be empty");
eosio::check(plan.value != 0, "plan must not be empty");
eosio::check(hourly_rate.amount > 0, "hourly_rate must be positive");
pricings_index pricings(get_self(), package_id.value);
auto it = pricings.find(plan.value);
auto now = eosio::time_point_sec(eosio::current_time_point().sec_since_epoch());
@@ -68,3 +68,4 @@
#include "table_apps_coops.hpp"
#include "table_apps_pricings.hpp"
#include "table_apps_globals.hpp"
#include "table_apps_clients.hpp"
@@ -0,0 +1,50 @@
#pragma once
#include <eosio/eosio.hpp>
#include <eosio/time.hpp>
#include "../consts.hpp"
namespace Apps {
using namespace eosio;
/**
* \brief Реестр кооперативов-клиентов каталога приложений (D3, FR7, FR8, AR3).
*
* Хранит факт «оператор каталога подключил кооператив `client_coopname`
* к каталогу». Сама подписка пакета хранится в `subs`; `clients` —
* это уровень onboarding'а, отвечающий на вопрос «может ли вообще
* этот кооператив пользоваться каталогом и его API».
*
* Идентификация:
* - scope = `catalog_operator` (`eosio::name`). В MVP всегда `voskhod`
* (single-operator forever по решению brief'а), но scope-based
* layout не блокирует replica-режим: другой operator → другой scope,
* миграция данных не нужна.
* - PK = `client_coopname.value` (один кооператив = одна запись
* в scope; повторный `regclient` → `eosio_assert("client already registered")`).
*
* RAM payer — `catalog_operator` (тот, кто подписал транзакцию).
* Это ВОСХОД в MVP — он оплачивает «координацию», а не клиенты.
* `delclient` освобождает RAM назад оператору.
*
* Зачем отдельная таблица, а не флаг в общей `coops`. `coops` —
* не «общая таблица», а сущность из системы регистратора и совет-плоскости
* (subnet-signing-keys, chain_id подсетей). Класть туда catalog-specific
* флаги — ломать инкапсуляцию контракта; будущая ротация владельца коопа
* не должна зависеть от состояния каталога приложений.
*
* \see architecture-v2 D3 — выбор scope-per-operator vs single-table.
* \see architecture-v2 D4 — interaction с `regsub` (membership check).
*/
struct [[eosio::table, eosio::contract(APPS)]] client {
name client_coopname; ///< primary key — кооператив-клиент
time_point_sec registered_at; ///< таймштамп `regclient`-вызова
uint64_t primary_key() const { return client_coopname.value; }
};
typedef eosio::multi_index<"clients"_n, client> clients_index;
} // namespace Apps
@@ -35,6 +35,24 @@ using namespace eosio;
* для аудита истечения подписки. Cleanup просроченных записей — задача
* отдельного периодического действия (вне MVP).
*
* **D4 v2-extension** — два новых поля для координации pull-биллинга
* (Story v2.6 / D4 / D5):
* - `attempt` — billing-retry counter уровня `regsub`. Инкрементируется
* pricing-watcher'ом CA через `setattempt`, когда `ack=declined`
* или charge провалился. Сбрасывается в `0` при успешном `extendsub`.
* - `last_charge_intent_id` — UUIDv5 последнего применённого
* charge.intent. Используется `extendsub` для double-emit prevention:
* повторный вызов с тем же id отсекается `eosio_assert("already extended")`.
* Дефолт — нулевой checksum256 (т.е. ещё ни одного extend не было).
*
* Поля добавлены в конец struct'а как nullable-by-default — старые
* MVP-row'ы (если бы они были) интерпретировались бы с `attempt=0` и
* нулевым `last_charge_intent_id`. Pre-deploy guard
* `scripts/v2-migration-guard.ts` (Story v2.7.5) блокирует прод-деплой
* при count > 0 production-scope записей, чтобы исключить
* deserialization-сюрпризы; в MVP/dev таблица пустая и миграция
* безопасна.
*
* Indexing:
* - PK `id` — auto-inc.
* - `bycooppkg` — composite (coopname, package_id) для idempotent upsert
@@ -43,6 +61,8 @@ using namespace eosio;
* - `byexpires` — sorted by `end_at` для будущего auto-cleanup.
*
* \see lib/domain/table_apps_coops.hpp — chain_id ↔ кооператив.
* \see architecture-v2 D4 — runtime-nullable migration policy.
* \see architecture-v2 D5 — UUIDv5-based double-emit prevention.
*/
struct [[eosio::table, eosio::contract(APPS)]] sub {
uint64_t id;
@@ -55,6 +75,8 @@ struct [[eosio::table, eosio::contract(APPS)]] sub {
time_point_sec end_at;
time_point_sec created_at;
time_point_sec updated_at;
uint8_t attempt = 0; ///< D4: billing-retry counter
checksum256 last_charge_intent_id = {}; ///< D4: UUIDv5 последнего extendsub
uint64_t primary_key() const { return id; }
uint128_t by_cooppkg() const { return ((uint128_t)coopname.value << 64) | package_id.value; }