[@ant] feat(migrator): per-coop self-service миграции agreements + L3 (story 2.5/3.6/3.7)

Три миграции через ledger3-релиз rollout:

- 047_migrate_agreements_to_wallet.ts — Эпик 2 / story 2.5
  agreements3.program_id>0 (status=confirmed) → wallet::migrate3.
  Идемпотентно, без удаления исходных записей (Эпик 4 финального
  deploy soviet).

- 048_migrate_phase1_minshare.ts — Эпик 3 / story 3.6 (ADR-008)
  participants[status=accepted] → ledger2::migrate3 на w.reg.minshr.
  amount = coop.org_minimum для type=organization, иначе coop.minimum.
  w.reg.entry не затрагивается — он COOPERATIVE без L3-разбивки.

- 049_migrate_phase2_progwallets.ts — Эпик 3 / story 3.7
  progwallets → ledger2::migrate3 с маппингом program_id → wallet_name:
    1 (ЦК)        → w.wal.share (membership_contribution=0 на prod)
    3 (Генератор) → w.cap.gen
    4 (Благорост) → w.cap.blago
  Pre-flight gate (NFR18) выполняется внешним скриптом сравнения
  Σ progwallets vs Σ userwallets перед production rollout.

src/utils:
- listCoops — обход registrator::coops с фильтром is_cooperative+active
- fetchAllRows — пагинированное чтение get_table_rows с lower_bound

Все три миграции per-coop self-service, batched по 50 actions/tx,
идемпотентные (migrate3 — overwrite, не +=).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
coopops
2026-05-05 10:26:35 +00:00
parent 48eac68c2b
commit 9fb53901aa
5 changed files with 366 additions and 0 deletions
@@ -0,0 +1,79 @@
/**
* Миграция программных соглашений (program_id > 0) из soviet::agreements3
* в wallet::users (Эпик 2 / story 2.5; ADR-008).
*
* Per-coop self-service: для каждого coop из registrator::cooperatives2 читает
* agreements3 и для каждой подтверждённой записи с program_id > 0 вызывает
* wallet::migrate3 (идемпотентно).
*
* Не-программные соглашения (program_id == 0) остаются в soviet::agreements3.
*
* Записи в soviet::agreements3 НЕ удаляются этой миграцией — это сделает
* Эпик 4 (финальный deploy soviet); до тех пор мигрированные записи там
* остаются как избыточная копия (контракт их игнорирует — sndagreement/
* confirmagree/declineagree уже отказывают для program_id > 0).
*/
import { SovietContract, WalletContract } from 'cooptypes';
import type { Migration } from '../src/migration_interface';
import { api } from '../src/eos';
import { listCoops } from '../src/utils/listCoops';
import { fetchAllRows } from '../src/utils/fetchAllRows';
const BATCH = 50; // максимум migrate3 actions в одной tx
export class MigrateAgreementsToWallet implements Migration {
async run(): Promise<void> {
const coops = await listCoops();
console.log(`[047] Кооперативов найдено: ${coops.length}`);
let totalMigrated = 0;
for (const coopname of coops) {
const agreements = await fetchAllRows<SovietContract.Tables.Agreements.IAgreement>({
code: SovietContract.contractName.production,
scope: coopname,
table: SovietContract.Tables.Agreements.tableName,
});
const programmatic = agreements.filter((a) =>
Number(a.program_id) > 0 && a.status === 'confirmed'
);
if (programmatic.length === 0) {
console.log(`[047] ${coopname}: нет программных соглашений — пропуск`);
continue;
}
console.log(`[047] ${coopname}: программных соглашений ${programmatic.length}`);
for (let i = 0; i < programmatic.length; i += BATCH) {
const chunk = programmatic.slice(i, i + BATCH);
const actions = chunk.map((a) => ({
account: WalletContract.contractName.production,
name: WalletContract.Actions.Migrate3.actionName,
authorization: [{ actor: coopname, permission: 'active' }],
data: {
coopname,
username: a.username,
program_id: Number(a.program_id),
doc_hash: a.document?.hash ?? '0000000000000000000000000000000000000000000000000000000000000000',
version: Number(a.version ?? 0),
draft_id: Number(a.draft_id ?? 0),
signed_at: a.updated_at,
},
}));
await api.transact(
{ actions },
{ blocksBehind: 3, expireSeconds: 30 }
);
totalMigrated += chunk.length;
console.log(`[047] ${coopname}: ${chunk.length} соглашений → wallet::users`);
}
}
console.log(`[047] Готово: всего мигрировано ${totalMigrated} соглашений`);
}
}
@@ -0,0 +1,125 @@
/**
* Phase 1 ускоренный инкремент (Эпик 3 / story 3.6; ADR-008): миграция
* минимальных паевых взносов w.reg.minshr per-coop из soviet::participants
* в ledger2::userwallets через ledger2::migrate3.
*
* Логика:
* Для каждого coop из registrator::coops:
* 1. Читаем coop → coop.minimum (для individual/entrepreneur)
* coop.org_minimum (для organization)
* 2. Читаем soviet::participants[scope=coopname] со status=accepted
* 3. Для каждого пайщика:
* amount = coop.org_minimum (если type=organization) либо coop.minimum
* ledger2::migrate3(coopname, w.reg.minshr, username, available=amount, blocked=0)
*
* Особенность ADR-008: w.reg.minshr — USER_SHARED, исключённый из cross-contract
* проверки соглашений (сразу при регистрации, до подписания ЦК-соглашения).
*
* w.reg.entry (вступительные, COOPERATIVE) НЕ затрагивается этой миграцией —
* он агрегатный (без L3-разбивки) и заполнен Эпиком 1 миграцией legacy::accounts.
*
* Идемпотентно: ledger2::migrate3 устанавливает значение (не +=).
*/
import { Ledger2Contract, RegistratorContract, SovietContract } from 'cooptypes';
import type { Migration } from '../src/migration_interface';
import { api, rpc } from '../src/eos';
import { listCoops } from '../src/utils/listCoops';
import { fetchAllRows } from '../src/utils/fetchAllRows';
const W_REG_MINSHR = 'w.reg.minshr';
const BATCH = 50;
interface ICoop {
username: string;
minimum: string;
org_minimum?: string;
}
async function getCoop(coopname: string): Promise<ICoop | null> {
const rows = await rpc.get_table_rows({
json: true,
code: RegistratorContract.contractName.production,
scope: RegistratorContract.contractName.production,
table: RegistratorContract.Tables.Cooperatives.tableName,
lower_bound: coopname,
upper_bound: coopname,
limit: 1,
});
return (rows.rows[0] as ICoop | undefined) ?? null;
}
function pickMinimum(coop: ICoop, participantType: string | undefined): string {
if (participantType === 'organization' && coop.org_minimum) {
return coop.org_minimum;
}
return coop.minimum;
}
function zeroOf(asset: string): string {
// "100.0000 RUB" → "0.0000 RUB"
const [amount, sym] = asset.split(' ');
const decimals = (amount.split('.')[1] || '').length;
return `${(0).toFixed(decimals)} ${sym}`;
}
export class MigratePhase1Minshare implements Migration {
async run(): Promise<void> {
const coops = await listCoops();
console.log(`[048] Кооперативов найдено: ${coops.length}`);
let totalMigrated = 0;
for (const coopname of coops) {
const coop = await getCoop(coopname);
if (!coop) {
console.warn(`[048] ${coopname}: не найден в registrator::coops — пропуск`);
continue;
}
const participants = await fetchAllRows<SovietContract.Tables.Participants.IParticipants>({
code: SovietContract.contractName.production,
scope: coopname,
table: SovietContract.Tables.Participants.tableName,
});
const accepted = participants.filter((p) => p.status === 'accepted');
if (accepted.length === 0) {
console.log(`[048] ${coopname}: нет accepted-пайщиков — пропуск`);
continue;
}
console.log(`[048] ${coopname}: accepted-пайщиков ${accepted.length}`);
const blockedZero = zeroOf(coop.minimum);
for (let i = 0; i < accepted.length; i += BATCH) {
const chunk = accepted.slice(i, i + BATCH);
const actions = chunk.map((p) => {
const available = pickMinimum(coop, (p as any).type);
return {
account: Ledger2Contract.contractName.production,
name: Ledger2Contract.Actions.Migrate3.actionName,
authorization: [{ actor: coopname, permission: 'active' }],
data: {
coopname,
wallet_name: W_REG_MINSHR,
username: p.username,
available,
blocked: blockedZero,
},
};
});
await api.transact(
{ actions },
{ blocksBehind: 3, expireSeconds: 30 }
);
totalMigrated += chunk.length;
console.log(`[048] ${coopname}: ${chunk.length} пайщиков → ${W_REG_MINSHR}`);
}
}
console.log(`[048] Готово: всего L3-записей w.reg.minshr создано ${totalMigrated}`);
}
}
@@ -0,0 +1,87 @@
/**
* Phase 2 (Эпик 3 / story 3.7): миграция soviet::progwallets → ledger2::userwallets
* через ledger2::migrate3 (per-coop self-service).
*
* Маппинг program_id → wallet_name (см. эпик 3 §6 «Migrator Phase 2»):
* 1 (ЦК) → split: w.wal.share + w.wal.member.
* На текущем prod membership_contribution = 0, поэтому всё уходит
* в w.wal.share, а w.wal.member не создаётся (no-op).
* 3 (Генератор) → w.cap.gen
* 4 (Благорост) → w.cap.blago
* 2 (marketplace) — программных кошельков нет, пропускается.
*
* Особенности:
* - Идемпотентно: ledger2::migrate3 устанавливает значение (не +=).
* - Без удаления записей из soviet::progwallets — это сделает Эпик 4
* (финальный deploy soviet); до тех пор записи остаются как избыточная
* копия (новые operations пишут только в ledger2::userwallets).
*
* Pre-flight gate (NFR18) выполняется ВНЕШНИМ скриптом на staging:
* сравнение Σ progwallets (pre) vs Σ userwallets (post) per (coopname, username,
* program_id) — дельта 0 копеек до production rollout.
*/
import { Ledger2Contract, SovietContract } from 'cooptypes';
import type { Migration } from '../src/migration_interface';
import { api } from '../src/eos';
import { listCoops } from '../src/utils/listCoops';
import { fetchAllRows } from '../src/utils/fetchAllRows';
const PROGRAM_TO_WALLET: Record<number, string> = {
1: 'w.wal.share',
3: 'w.cap.gen',
4: 'w.cap.blago',
};
const BATCH = 50;
export class MigratePhase2Progwallets implements Migration {
async run(): Promise<void> {
const coops = await listCoops();
console.log(`[049] Кооперативов найдено: ${coops.length}`);
let totalMigrated = 0;
for (const coopname of coops) {
const progwallets = await fetchAllRows<SovietContract.Tables.ProgramWallets.IProgramWallet>({
code: SovietContract.contractName.production,
scope: coopname,
table: SovietContract.Tables.ProgramWallets.tableName,
});
const eligible = progwallets.filter((pw) => PROGRAM_TO_WALLET[Number(pw.program_id)]);
if (eligible.length === 0) {
console.log(`[049] ${coopname}: нет progwallets под маппинг — пропуск`);
continue;
}
console.log(`[049] ${coopname}: progwallets ${eligible.length}`);
for (let i = 0; i < eligible.length; i += BATCH) {
const chunk = eligible.slice(i, i + BATCH);
const actions = chunk.map((pw) => ({
account: Ledger2Contract.contractName.production,
name: Ledger2Contract.Actions.Migrate3.actionName,
authorization: [{ actor: coopname, permission: 'active' }],
data: {
coopname,
wallet_name: PROGRAM_TO_WALLET[Number(pw.program_id)]!,
username: pw.username,
available: pw.available,
blocked: pw.blocked,
},
}));
await api.transact(
{ actions },
{ blocksBehind: 3, expireSeconds: 30 }
);
totalMigrated += chunk.length;
console.log(`[049] ${coopname}: ${chunk.length} progwallets → userwallets`);
}
}
console.log(`[049] Готово: всего L3-записей migrate3 ${totalMigrated}`);
}
}
@@ -0,0 +1,42 @@
import { rpc } from '../eos';
/**
* Полный обход таблицы блокчейна с пагинацией.
*
* Используется миграциями для чтения soviet::agreements3, soviet::progwallets,
* soviet::participants и аналогичных таблиц с произвольным числом записей.
*/
export async function fetchAllRows<T = any>(opts: {
code: string;
scope: string;
table: string;
index_position?: number;
key_type?: string;
limit?: number;
}): Promise<T[]> {
const result: T[] = [];
let lower_bound: string | undefined;
const limit = opts.limit ?? 200;
while (true) {
const rows = await rpc.get_table_rows({
json: true,
code: opts.code,
scope: opts.scope,
table: opts.table,
index_position: opts.index_position,
key_type: opts.key_type,
limit,
lower_bound,
});
for (const row of rows.rows as T[]) {
result.push(row);
}
if (!rows.more) break;
lower_bound = rows.next_key;
}
return result;
}
@@ -0,0 +1,33 @@
import { rpc } from '../eos';
import { RegistratorContract } from 'cooptypes';
/**
* Возвращает список coopname-ов всех активных кооперативов из
* `registrator::cooperatives2`. Используется как драйвер per-coop миграций.
*/
export async function listCoops(): Promise<string[]> {
const result: string[] = [];
let lower_bound: string | undefined;
while (true) {
const rows = await rpc.get_table_rows({
json: true,
code: RegistratorContract.contractName.production,
scope: RegistratorContract.contractName.production,
table: RegistratorContract.Tables.Cooperatives.tableName,
limit: 100,
lower_bound,
});
for (const row of rows.rows as Array<{ username: string; is_cooperative: boolean; status?: string }>) {
if (row.is_cooperative && (row.status === undefined || row.status === 'active')) {
result.push(row.username);
}
}
if (!rows.more) break;
lower_bound = rows.next_key;
}
return result;
}