@@ -27,6 +27,27 @@ interface ITandaManager {
2727 function requestRandomnessForTanda (uint256 tandaId ) external ;
2828}
2929
30+ /// @notice Soulbound Pass NFT entry points the Tanda calls on join /
31+ /// defaulter mark. Singleton; address snapshotted at init.
32+ interface IMitandaPassNFT {
33+ function mint (address participant , uint256 tandaId ) external returns (uint256 );
34+ function markDefaulted (address participant , uint256 tandaId ) external ;
35+ }
36+
37+ /// @notice Transferable Receipt NFT entry point used on each cycle
38+ /// payout. Singleton; address snapshotted at init.
39+ interface IMitandaReceiptNFT {
40+ function mintReceipt (address recipient , uint256 tandaId , uint256 cycle , uint256 collectionId )
41+ external
42+ returns (uint256 );
43+ }
44+
45+ /// @notice Soulbound Completion NFT entry point used at tanda
46+ /// completion. Singleton; address snapshotted at init.
47+ interface IMitandaCompletionNFT {
48+ function batchMint (address [] calldata participants , uint256 tandaId ) external returns (uint256 [] memory );
49+ }
50+
3051/// @title Tanda
3152/// @author Mi Tanda
3253/// @notice Per-tanda state machine. **EIP-1167 implementation contract**:
@@ -166,6 +187,17 @@ contract Tanda is ITanda, Initializable, ReentrancyGuardUpgradeable, EIP712Upgra
166187 /// calls backed by a creator-signed EIP-712 invite.
167188 TandaPrivacy public privacy;
168189
190+ /// @notice Singleton NFT contracts wired by the Manager at clone
191+ /// init. Stored as regular slots (not immutable — clones
192+ /// can't have immutables). Set once in `initialize` and
193+ /// never reassigned. NFT calls are unconditional: if any
194+ /// of these is zero or misconfigured, every lifecycle
195+ /// action that touches the NFT will revert — the intended
196+ /// failure mode.
197+ address public passNFT;
198+ address public receiptNFT;
199+ address public completionNFT;
200+
169201 // ─────────────────────────────────────────────────────────────────────
170202 // Storage — lifecycle state
171203 // ─────────────────────────────────────────────────────────────────────
@@ -288,6 +320,9 @@ contract Tanda is ITanda, Initializable, ReentrancyGuardUpgradeable, EIP712Upgra
288320 if (params.manager == address (0 )) revert ZeroAddress ();
289321 if (params.creator == address (0 )) revert ZeroAddress ();
290322 if (params.contributionAmount == 0 ) revert ZeroAmount ();
323+ if (params.passNFT == address (0 )) revert ZeroAddress ();
324+ if (params.receiptNFT == address (0 )) revert ZeroAddress ();
325+ if (params.completionNFT == address (0 )) revert ZeroAddress ();
291326
292327 __ReentrancyGuard_init ();
293328 __EIP712_init ("MiTanda " , "1 " );
@@ -303,6 +338,9 @@ contract Tanda is ITanda, Initializable, ReentrancyGuardUpgradeable, EIP712Upgra
303338 sponsoredCollectionId = params.sponsoredCollectionId;
304339 scheduledStart = params.scheduledStart;
305340 privacy = params.privacy;
341+ passNFT = params.passNFT;
342+ receiptNFT = params.receiptNFT;
343+ completionNFT = params.completionNFT;
306344
307345 state = TandaState.OPEN;
308346 }
@@ -432,7 +470,11 @@ contract Tanda is ITanda, Initializable, ReentrancyGuardUpgradeable, EIP712Upgra
432470
433471 emit ParticipantJoined (participant, block .timestamp );
434472
435- // TODO(NFT): passNFT.mint(participant, tandaId) — soulbound EIP-5192.
473+ // Soulbound EIP-5192 Pass NFT — minted BEFORE the token transfer
474+ // so the NFT existence tracks join-attempt, not transfer-success.
475+ // If the safeTransferFrom below reverts, the whole tx (incl. this
476+ // mint) rolls back atomically.
477+ IMitandaPassNFT (passNFT).mint (participant, tandaId);
436478
437479 IERC20 (token).safeTransferFrom (participant, address (this ), chargeAmount);
438480
@@ -601,8 +643,13 @@ contract Tanda is ITanda, Initializable, ReentrancyGuardUpgradeable, EIP712Upgra
601643
602644 emit ParticipantDefaulted (participant, currentCycle, forfeitedInsurance, block .timestamp );
603645
604- // TODO(NFT): passNFT.markDefaulted(participant, tandaId) — flags
605- // pass NFT's tokenURI status to "Defaulted" for off-chain display.
646+ // Pass NFT flag last: if it fails (it shouldn't — the Pass NFT's
647+ // `markDefaulted` is a silent no-op when the pass doesn't exist),
648+ // Tanda's own `isActive = false` state has already been written
649+ // and the event emitted. The pass NFT itself stays soulbound on
650+ // the participant as reputation evidence; only its `isDefaulted`
651+ // flag flips.
652+ IMitandaPassNFT (passNFT).markDefaulted (participant, tandaId);
606653 }
607654
608655 /// @dev Remove `participantIndex` from `payoutOrder` IF its slot is
@@ -650,7 +697,11 @@ contract Tanda is ITanda, Initializable, ReentrancyGuardUpgradeable, EIP712Upgra
650697 currentCycle = cyclePaid + 1 ;
651698 _settleCyclePayout (recipient, treasuryAddr, recipientAmount, platformAmount, organizerAmount, cyclePaid);
652699
653- // TODO(NFT): receiptNFT.mintReceipt(recipient, tandaId, cyclePaid, sponsoredCollectionId)
700+ // Receipt NFT mint with frozen-at-mint baseURI + ERC-2981 royalty.
701+ // `cyclePaid` is the cycle that just paid out (NOT the new
702+ // `currentCycle`). `sponsoredCollectionId` may be 0 — Receipt
703+ // NFT handles go-dark via its default fallback URI.
704+ IMitandaReceiptNFT (receiptNFT).mintReceipt (recipient, tandaId, cyclePaid, sponsoredCollectionId);
654705
655706 if (currentCycle > payoutOrder.length ) {
656707 _completeTanda ();
@@ -697,7 +748,24 @@ contract Tanda is ITanda, Initializable, ReentrancyGuardUpgradeable, EIP712Upgra
697748 _distributeSlashPool ();
698749 }
699750 emit TandaCompleted (block .timestamp );
700- // TODO(NFT): completionNFT.batchMint(activeParticipants, tandaId)
751+
752+ // Completion NFTs minted LAST so all financial state is final
753+ // before badges are issued. Build the active-participants array
754+ // by scanning `participants` once and collecting `isActive`
755+ // addresses. `activeParticipantCount == 0` produces an empty
756+ // array, which `MitandaCompletionNFT.batchMint` handles as a
757+ // no-op — no defensive branching needed here.
758+ uint256 activeCount = activeParticipantCount;
759+ address [] memory actives = new address [](activeCount);
760+ uint256 outIdx = 0 ;
761+ uint256 len = participants.length ;
762+ for (uint256 i = 0 ; i < len; i++ ) {
763+ if (participants[i].isActive) {
764+ actives[outIdx] = participants[i].addr;
765+ outIdx++ ;
766+ }
767+ }
768+ IMitandaCompletionNFT (completionNFT).batchMint (actives, tandaId);
701769 }
702770
703771 /// @dev Refund each active participant's insurance balance + any
0 commit comments