[CL-1180] L-4: escrow refund off-chain revert (trade-unwind) — open question РЕШЁН, готов к реализации #1176

Closed
opened 2026-07-20 18:30:29 +00:00 by andrei · 1 comment
Owner

Находка бэкенд-аудита L-4 — escrow refund не ревертит off-chain движения трейда

Severity: LOW (узкий exception-путь), но money-correctness.
Статус: design-ready spec, требует dedicated single-agent реализации + CEO review (money hot-path + Prisma schema, Rule 118).

Проблема

On-chain escrow держит и доли, и платёж (depositSharesdepositPaymentsettle). Off-chain settlement при match — источник истины (полностью в trade-tx до создания escrow). При REFUNDED/EXPIRED escrow on-chain откатывается, off-chain остаётся settled → дрейф по обеим ногам. Реальный residual: income-distribution/preparation.ts:135 начисляет дивиденды на phantom-доли покупателя из REFUNDED escrow (до ручной reconciliation-коррекции).

Почему не сделано автономно

  • sellerPP (проп. cost-basis, вычтенный из purchasePrice продавца при match) не персистится → корректный revert невозможен из текущих данных (приближение = порча налоговой/gains-базы).
  • Корректный buyer-refund различается по типу ордера: resting BUY дебетуется при СОЗДАНИИ ордера (lockedBalance), taker — при fill. Правка hot match-path (execute-trade/auto-match) с риском регрессии на КАЖДОМ трейде.

План (полная модель — в spec)

  1. Миграция (nullable, additive): p2p_escrows.revertPlan JSONB, revertAppliedAt TIMESTAMP (-- APPROVED:).
  2. Match-пути (execute-trade.ts + auto-match.ts) пишут revertPlan из уже вычисленных дельт (additive, без изменения debit/credit-логики).
  3. refund.ts: applyEscrowRefundRevert в $transaction + FOR UPDATE (buyer/seller/SYSTEM, sorted) + reversing ledger + идемпотентность (revertAppliedAt), backward-safe (plan=null → skip).
  4. Тесты: taker/resting-BUY/partial-fill/идемпотентность/backward-compat.

⚠️ Открытый вопрос (решить ДО кода)

buyer-refund для resting BUY: покупатель дебетован при создании ордера (lockedBalance), не при match. Revert возвращает на balance И/ИЛИ восстанавливает lockedBalance ордера? Ордер уже COMPLETED. Уточнить взаимодействие с order-lifecycle.

Промежуточная безопасная альтернатива (если полный revert отложен)

Распространить существующий getUnsettledEscrowShares settled-gate на income-distribution — закрывает дивиденд-leak без разворота трейда.

Источник: BACKEND-AUDIT сессия 2026-07-20. Полный spec: L4-escrow-refund-revert-SPEC.md.

## Находка бэкенд-аудита L-4 — escrow refund не ревертит off-chain движения трейда **Severity:** LOW (узкий exception-путь), но money-correctness. **Статус:** design-ready spec, требует dedicated single-agent реализации + CEO review (money hot-path + Prisma schema, Rule 118). ### Проблема On-chain escrow держит **и доли, и платёж** (`depositShares`→`depositPayment`→`settle`). Off-chain settlement при match — источник истины (полностью в trade-tx до создания escrow). При REFUNDED/EXPIRED escrow on-chain откатывается, off-chain остаётся settled → дрейф по обеим ногам. Реальный residual: `income-distribution/preparation.ts:135` начисляет дивиденды на phantom-доли покупателя из REFUNDED escrow (до ручной reconciliation-коррекции). ### Почему не сделано автономно - `sellerPP` (проп. cost-basis, вычтенный из purchasePrice продавца при match) **не персистится** → корректный revert невозможен из текущих данных (приближение = порча налоговой/gains-базы). - Корректный buyer-refund **различается по типу ордера**: resting BUY дебетуется при СОЗДАНИИ ордера (lockedBalance), taker — при fill. Правка hot match-path (execute-trade/auto-match) с риском регрессии на КАЖДОМ трейде. ### План (полная модель — в spec) 1. Миграция (nullable, additive): `p2p_escrows.revertPlan JSONB`, `revertAppliedAt TIMESTAMP` (`-- APPROVED:`). 2. Match-пути (execute-trade.ts + auto-match.ts) пишут revertPlan из уже вычисленных дельт (additive, без изменения debit/credit-логики). 3. refund.ts: `applyEscrowRefundRevert` в `$transaction` + FOR UPDATE (buyer/seller/SYSTEM, sorted) + reversing ledger + идемпотентность (`revertAppliedAt`), backward-safe (plan=null → skip). 4. Тесты: taker/resting-BUY/partial-fill/идемпотентность/backward-compat. ### ⚠️ Открытый вопрос (решить ДО кода) buyer-refund для resting BUY: покупатель дебетован при создании ордера (lockedBalance), не при match. Revert возвращает на balance И/ИЛИ восстанавливает lockedBalance ордера? Ордер уже COMPLETED. Уточнить взаимодействие с order-lifecycle. ### Промежуточная безопасная альтернатива (если полный revert отложен) Распространить существующий `getUnsettledEscrowShares` settled-gate на income-distribution — закрывает дивиденд-leak без разворота трейда. _Источник: BACKEND-AUDIT сессия 2026-07-20. Полный spec: L4-escrow-refund-revert-SPEC.md._
Author
Owner

РЕШЕНИЕ open question (сессия 2026-07-22, по поручению CEO)

Buyer-refund для resting BUY при unwind REFUNDED/EXPIRED эскроу: возврат на свободный balance/coinBalance (по currency ордера). lockedBalance НЕ восстанавливать, ордер НЕ реанимировать.

Основания (из кода, не из общих соображений)

  1. Терминальность статусов. Все легальные переходы обнуляют locked-поля при терминале: full fill → status: COMPLETED + locked=0 (execute-trade.tsbuildOrderFillUpdate), cancel → CANCELLED, lockedShares:0, lockedBalance:0 (cancel-order.ts:86), expire — аналогично. COMPLETED-ордер с lockedBalance>0 — состояние, недостижимое ни одним переходом: его не видит ни cancel-путь, ни expire-cron → у этих денег не существует пути возврата пользователю. Вечная заморозка.
  2. Семантика lockedBalance = активная оферта. Деньги в lockedBalance — обязательство купить по pricePerShare ордера. Восстановление = молчаливое пере-выставление оферты по устаревшей цене без волеизъявления пользователя (авто-rematch по протухшей цене = прямой ущерб).
  3. Симметрия ног. Unwind возвращает продавцу доли в свободное владение — покупателю зеркально возвращаются свободные деньги. Трейд аннулируется, а не переигрывается.
  4. Ledger-пара. Дебет при создании ордера записан как Transaction WITHDRAWAL (create-order.ts:139-148); возврат = reversing Transaction (REFUND, с escrowId/tradeId в description/details). Возврат в lockedBalance пары в существующей ledger-семантике не имеет.
  5. Partial-fill (ордер ещё OPEN). То же единое правило: кредит на balance, filledShares/remainingShares НЕ корректировать (fill был; unwind — отдельное reversing-событие поверх, не «отмена fill»). Единое правило исключает ветвление семантики по статусу ордера.

Следствия для спеки (уточнение плана)

  • revertPlan buyer-нога: { userId, amount, currency, creditTo: currency==='EURT' ? 'coinBalance' : 'balance' } — суммы из уже вычисленных дельт match-пути (tradeTotalPrice, seller-нога — sellerProceeds + sellerPP из персистируемого plan).
  • applyEscrowRefundRevert: reversing Transaction обеим сторонам, FOR UPDATE (buyer/seller/SYSTEM, sorted), идемпотентность revertAppliedAt — как в спеке, без изменений.
  • Order-lifecycle НЕ трогается вообще — снят весь класс рисков «взаимодействие с order-lifecycle» из open question.

Блокер снят — можно приступать к реализации полной модели (миграция + match-пути + refund.ts + тесты) по плану из шапки.

## РЕШЕНИЕ open question (сессия 2026-07-22, по поручению CEO) **Buyer-refund для resting BUY при unwind REFUNDED/EXPIRED эскроу: возврат на свободный `balance`/`coinBalance` (по currency ордера). `lockedBalance` НЕ восстанавливать, ордер НЕ реанимировать.** ### Основания (из кода, не из общих соображений) 1. **Терминальность статусов.** Все легальные переходы обнуляют locked-поля при терминале: full fill → `status: COMPLETED` + locked=0 (`execute-trade.ts` → `buildOrderFillUpdate`), cancel → `CANCELLED, lockedShares:0, lockedBalance:0` (`cancel-order.ts:86`), expire — аналогично. COMPLETED-ордер с `lockedBalance>0` — состояние, недостижимое ни одним переходом: его не видит ни cancel-путь, ни expire-cron → **у этих денег не существует пути возврата пользователю. Вечная заморозка.** 2. **Семантика lockedBalance = активная оферта.** Деньги в `lockedBalance` — обязательство купить по `pricePerShare` ордера. Восстановление = молчаливое пере-выставление оферты по устаревшей цене без волеизъявления пользователя (авто-rematch по протухшей цене = прямой ущерб). 3. **Симметрия ног.** Unwind возвращает продавцу доли в свободное владение — покупателю зеркально возвращаются свободные деньги. Трейд аннулируется, а не переигрывается. 4. **Ledger-пара.** Дебет при создании ордера записан как Transaction WITHDRAWAL (`create-order.ts:139-148`); возврат = reversing Transaction (REFUND, с `escrowId`/`tradeId` в description/details). Возврат в lockedBalance пары в существующей ledger-семантике не имеет. 5. **Partial-fill (ордер ещё OPEN).** То же единое правило: кредит на balance, `filledShares`/`remainingShares` НЕ корректировать (fill был; unwind — отдельное reversing-событие поверх, не «отмена fill»). Единое правило исключает ветвление семантики по статусу ордера. ### Следствия для спеки (уточнение плана) - `revertPlan` buyer-нога: `{ userId, amount, currency, creditTo: currency==='EURT' ? 'coinBalance' : 'balance' }` — суммы из уже вычисленных дельт match-пути (`tradeTotalPrice`, seller-нога — `sellerProceeds` + `sellerPP` из персистируемого plan). - `applyEscrowRefundRevert`: reversing Transaction обеим сторонам, `FOR UPDATE` (buyer/seller/SYSTEM, sorted), идемпотентность `revertAppliedAt` — как в спеке, без изменений. - Order-lifecycle НЕ трогается вообще — снят весь класс рисков «взаимодействие с order-lifecycle» из open question. Блокер снят — можно приступать к реализации полной модели (миграция + match-пути + refund.ts + тесты) по плану из шапки.
andrei changed title from [CL-1180] L-4: escrow refund off-chain revert (trade-unwind) — design-ready, требует go по open question to [CL-1180] L-4: escrow refund off-chain revert (trade-unwind) — open question РЕШЁН, готов к реализации 2026-07-22 00:00:22 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
europa-tech-srl/europatech#1176
No description provided.