Skip to content

Commit 71b4410

Browse files
GeObtsclaude
andcommitted
fix(tanda): auto-complete on defaulter pruning; full-collapse handler
Critical correctness fix surfaced by edge-case testing. Bug: when future-slot defaulter pruning drove payoutOrder.length below currentCycle, the tanda became stuck in ACTIVE. The next triggerPayout would access payoutOrder[currentCycle-1] out-of-bounds and revert with a Solidity Panic(0x32). Funds (insurance pool, contributions, survivor's would-be payout) were permanently locked with no recovery path. Fix has two pieces, both routed through _completeTanda for consistency: 1. markDefaulter auto-complete trigger When pruning drives payoutOrder.length below currentCycle, the contract auto-completes in the same transaction. Defensive payoutOrderAssigned guard prevents premature completion in the pre-VRF window. 2. _completeTanda zero-active branch Early-return into new _fullCollapse helper when activeParticipantCount is zero. Sweeps the entire token balance to treasury (lender-of-last- resort rule). Forfeits all prior pendingWithdrawals and insurance balances. No Completion NFTs minted. FullCollapse event emitted. Also fixes a related stranded-funds bug: the existing slash-pool guard 'slashedPool > 0 && activeParticipantCount > 0' silently stranded the slash pool when natural completion happened with zero actives. Routing all zero-active completions through _fullCollapse handles both entry points uniformly. Tests: - Renamed and rewrote test_KNOWN_BUG_allFutureSlotDefaulters_triggersOOB to test_allFutureSlotDefaulters_autoCompletes. Asserts exact survivor breakdown: 475 cycle-1 payout + 20 insurance refund + 100 excess contribution refund + 38 slash share = 633 USDC. - New test_fullCollapse_allDefaulters_everythingToTreasury: 5 marks, final mark triggers full collapse, treasury sweeps full 550 USDC balance, all prior credits wiped, contract drains to zero. 85 tests pass, 0 regressions. Existing test_defaulter_pastSlot_keptInOrder unchanged — confirms the Case 4 'inactive recipient at current slot' scenario considered during design is unreachable by construction. CLAUDE.md: new 'Auto-completion on defaulter pruning' subsection documenting both the partial-survival and full-collapse paths. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 5ab81e8 commit 71b4410

3 files changed

Lines changed: 692 additions & 2 deletions

File tree

CLAUDE.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,15 @@ Defensive: a balance assertion checks `balanceOf(this) >= totalPendingCredits +
9999

100100
Honest participants get their own insurance back plus a pro-rata share of defaulters' forfeited insurance — a real cash reward for staying paid up. Defaulters lose only their accumulated premiums (not their past contributions, which were already cycled through).
101101

102+
### Auto-completion on defaulter pruning
103+
104+
`markDefaulter` auto-completes the tanda when pruning drives `payoutOrder.length < currentCycle`. Without this, future-slot pruning could shrink `payoutOrder` below the next index `triggerPayout` would read, causing an out-of-bounds panic and locking the tanda in ACTIVE forever. Two sub-paths, both inside `_completeTanda`:
105+
106+
- **Partial survival** (`activeParticipantCount > 0`): normal completion runs — surviving actives get insurance refund, excess-contribution refund (for cycles they pre-paid that won't run), slash-pool 95/2/3 share, and Completion NFTs.
107+
- **Full collapse** (`activeParticipantCount == 0`): every participant defaulted. Platform is lender-of-last-resort — the entire token balance sweeps to treasury via `_fullCollapse`. All prior `pendingWithdrawals` (including past cycle-recipient credits not yet withdrawn) and insurance balances are zeroed and absorbed into the treasury credit. No Completion NFTs minted. `FullCollapse(treasury, amount)` event emitted.
108+
109+
`_completeTanda` routes both call sites (existing natural completion via `triggerPayout`, and the new `markDefaulter` auto-complete) through the same early-return branch on `activeParticipantCount == 0`, so any future code path that produces a zero-active completion gets correct full-collapse semantics for free.
110+
102111
## Tanda privacy & scheduled start
103112

104113
### Scheduled start

src/Tanda.sol

Lines changed: 74 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,11 @@ contract Tanda is ITanda, Initializable, ReentrancyGuardUpgradeable, EIP712Upgra
292292
uint256 totalPool, uint256 perActiveParticipant, uint256 treasuryShare, uint256 creatorShare, uint256 dust
293293
);
294294
event TandaCompleted(uint256 completionTimestamp);
295+
/// @notice Emitted when a tanda completes via full collapse — every
296+
/// participant defaulted, no honest survivors. The treasury
297+
/// absorbs the entire remaining token balance; all prior
298+
/// pendingWithdrawals and insurance balances are forfeited.
299+
event FullCollapse(address indexed treasury, uint256 amount);
295300
event Withdrawn(address indexed claimant, uint256 amount);
296301

297302
// ─────────────────────────────────────────────────────────────────────
@@ -609,12 +614,22 @@ contract Tanda is ITanda, Initializable, ReentrancyGuardUpgradeable, EIP712Upgra
609614
/// is moved to `slashedPool`, and (if their slot in
610615
/// `payoutOrder` is still pending) it is removed via O(n)
611616
/// left-shift.
617+
/// @dev If pruning drives `payoutOrder.length < currentCycle`
618+
/// (no remaining cycles can pay out), the tanda
619+
/// auto-completes in the same transaction via
620+
/// `_completeTanda`. With surviving actives, normal
621+
/// completion runs; with zero actives, full collapse
622+
/// sweeps the entire token balance to treasury
623+
/// (`FullCollapse` event). Defensive: the
624+
/// `payoutOrderAssigned` guard prevents auto-completion
625+
/// in the (unreachable in practice) window before the
626+
/// VRF callback assigns the payout order.
612627
/// @custom:reverts WrongTandaState if not ACTIVE.
613628
/// @custom:reverts NotParticipant if `participant` isn't in the tanda.
614629
/// @custom:reverts AlreadyMarkedDefaulter if already inactive.
615630
/// @custom:reverts NotDefaulter if `paidUntilCycle >= currentCycle`.
616631
/// @custom:reverts GracePeriodNotExpired if called before deadline + grace.
617-
/// @custom:emits ParticipantDefaulted.
632+
/// @custom:emits ParticipantDefaulted, plus TandaCompleted or FullCollapse if auto-completion fires.
618633
function markDefaulter(address participant) external onlyInState(TandaState.ACTIVE) {
619634
uint256 idxPlus1 = addressToParticipantIndex[participant];
620635
if (idxPlus1 == 0) revert NotParticipant();
@@ -650,6 +665,16 @@ contract Tanda is ITanda, Initializable, ReentrancyGuardUpgradeable, EIP712Upgra
650665
// the participant as reputation evidence; only its `isDefaulted`
651666
// flag flips.
652667
IMitandaPassNFT(passNFT).markDefaulted(participant, tandaId);
668+
669+
// Auto-complete: pruning may have driven `payoutOrder.length`
670+
// below `currentCycle`, meaning no payouts remain. Without this,
671+
// the next `triggerPayout` would access `payoutOrder[currentCycle-1]`
672+
// out of bounds and the tanda would be stuck in ACTIVE forever.
673+
// The `payoutOrderAssigned` clause prevents auto-completion in
674+
// the (in-practice unreachable) window before VRF callback fires.
675+
if (payoutOrderAssigned && payoutOrder.length < currentCycle) {
676+
_completeTanda();
677+
}
653678
}
654679

655680
/// @dev Remove `participantIndex` from `payoutOrder` IF its slot is
@@ -741,10 +766,23 @@ contract Tanda is ITanda, Initializable, ReentrancyGuardUpgradeable, EIP712Upgra
741766
return true;
742767
}
743768

769+
/// @dev Two completion paths:
770+
/// 1. Normal (`activeParticipantCount > 0`): refund insurance
771+
/// and excess contributions to actives, distribute slash
772+
/// pool 95/2/3, mint Completion NFTs.
773+
/// 2. Full collapse (`activeParticipantCount == 0`): every
774+
/// participant defaulted. Existing pendingWithdrawals and
775+
/// insurance balances are forfeited; the entire token
776+
/// balance sweeps to treasury. No Completion NFTs minted.
744777
function _completeTanda() internal {
778+
if (activeParticipantCount == 0) {
779+
_fullCollapse();
780+
return;
781+
}
782+
745783
state = TandaState.COMPLETED;
746784
_refundActiveParticipants();
747-
if (slashedPool > 0 && activeParticipantCount > 0) {
785+
if (slashedPool > 0) {
748786
_distributeSlashPool();
749787
}
750788
emit TandaCompleted(block.timestamp);
@@ -768,6 +806,40 @@ contract Tanda is ITanda, Initializable, ReentrancyGuardUpgradeable, EIP712Upgra
768806
IMitandaCompletionNFT(completionNFT).batchMint(actives, tandaId);
769807
}
770808

809+
/// @dev Full-collapse settlement: every participant defaulted.
810+
/// Platform is lender-of-last-resort — the entire token
811+
/// balance (including any pendingWithdrawals that were
812+
/// credited but never withdrawn) sweeps to treasury. All
813+
/// prior credits and insurance balances are zeroed.
814+
function _fullCollapse() internal {
815+
address treasuryAddr = ITandaManager(manager).treasury();
816+
817+
// Zero every per-address credit and insurance balance — full
818+
// collapse forfeits every existing claim. The token balance
819+
// is the only thing that matters; credits are rebuilt from it.
820+
uint256 n = participants.length;
821+
for (uint256 i = 0; i < n; i++) {
822+
address p = participants[i].addr;
823+
delete pendingWithdrawals[p];
824+
delete insuranceBalance[p];
825+
}
826+
delete pendingWithdrawals[creator];
827+
delete pendingWithdrawals[treasuryAddr];
828+
829+
// Sweep entire token balance to treasury.
830+
uint256 sweepable = IERC20(token).balanceOf(address(this));
831+
pendingWithdrawals[treasuryAddr] = sweepable;
832+
totalPendingCredits = sweepable;
833+
834+
slashedPool = 0;
835+
totalInsuranceReserve = 0;
836+
837+
state = TandaState.COMPLETED;
838+
839+
emit FullCollapse(treasuryAddr, sweepable);
840+
// No Completion NFTs minted — by definition nobody completed honestly.
841+
}
842+
771843
/// @dev Refund each active participant's insurance balance + any
772844
/// contribution paid for cycles that didn't run.
773845
function _refundActiveParticipants() internal {

0 commit comments

Comments
 (0)