Skip to content

Commit 9d6ffaa

Browse files
GeObtsclaude
andcommitted
feat(nfts): wire pass, receipt, and completion NFT calls into Tanda lifecycle
Replace the four TODO(NFT) markers in Tanda.sol with actual calls into the three NFT singleton contracts. Manager changes: - Three new immutables (passNFT, receiptNFT, completionNFT), set in constructor with zero-address validation - createTanda forwards NFT addresses to ITanda.InitParams ITanda changes: - InitParams struct extends with three NFT address fields at end Tanda changes: - Three new storage variables (not immutable; clones can't have them) - Three local NFT interfaces declared at the top of Tanda.sol - Pass NFT minted in _joinInternal after emit, before token transfer - Pass NFT defaulter flag set in markDefaulter as last step - Receipt NFT minted in triggerPayout after _settleCyclePayout, before the completion check - Completion NFT batch-minted in _completeTanda as last step, with active-participants array constructed via single-pass loop NFT calls are unconditional — misconfiguration fails loudly rather than silently skipping. Init-time zero checks ensure call sites can safely assume non-zero addresses. All four call sites preserve CEI ordering. Existing nonReentrant modifiers cover all sites against reentrancy via NFT callbacks. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 39839ed commit 9d6ffaa

3 files changed

Lines changed: 121 additions & 7 deletions

File tree

src/Tanda.sol

Lines changed: 73 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -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

src/TandaManager.sol

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,21 @@ contract TandaManager is VRFConsumerBaseV2Plus, Pausable {
8181
/// delegate to. Deployed once per chain; cannot be changed.
8282
address public immutable tandaImplementation;
8383

84+
/// @notice Soulbound Pass NFT contract. Auto-minted on `join` /
85+
/// `joinWithInvite` and flagged on `markDefaulter`.
86+
/// Immutable — set once at Manager deployment.
87+
address public immutable passNFT;
88+
89+
/// @notice Transferable Receipt NFT contract. Minted on each
90+
/// payout to the cycle's recipient with frozen-at-mint
91+
/// sponsored-collection metadata. Immutable.
92+
address public immutable receiptNFT;
93+
94+
/// @notice Soulbound Completion NFT contract. Batch-minted at
95+
/// tanda completion to every still-active participant.
96+
/// Immutable.
97+
address public immutable completionNFT;
98+
8499
// ─────────────────────────────────────────────────────────────────────
85100
// Storage — treasury
86101
// ─────────────────────────────────────────────────────────────────────
@@ -248,20 +263,35 @@ contract TandaManager is VRFConsumerBaseV2Plus, Pausable {
248263
/// call back into this contract.
249264
/// @param _treasury Initial treasury address (2% platform
250265
/// fee recipient). Owner-configurable later.
266+
/// @param _passNFT Soulbound Pass NFT contract address.
267+
/// Immutable; deployed once per chain.
268+
/// @param _receiptNFT Transferable Receipt NFT contract
269+
/// address. Immutable.
270+
/// @param _completionNFT Soulbound Completion NFT contract
271+
/// address. Immutable.
251272
constructor(
252273
address _tandaImplementation,
253274
address _vrfCoordinator,
254275
uint256 _subscriptionId,
255276
bytes32 _gasLane,
256277
uint32 _callbackGasLimit,
257-
address _treasury
278+
address _treasury,
279+
address _passNFT,
280+
address _receiptNFT,
281+
address _completionNFT
258282
) VRFConsumerBaseV2Plus(_vrfCoordinator) {
259283
if (_tandaImplementation == address(0)) revert ZeroAddress();
260284
if (_treasury == address(0)) revert ZeroAddress();
261285
if (_callbackGasLimit == 0) revert ZeroAmount();
286+
if (_passNFT == address(0)) revert ZeroAddress();
287+
if (_receiptNFT == address(0)) revert ZeroAddress();
288+
if (_completionNFT == address(0)) revert ZeroAddress();
262289
// _vrfCoordinator zero-check is handled by VRFConsumerBaseV2Plus.
263290

264291
tandaImplementation = _tandaImplementation;
292+
passNFT = _passNFT;
293+
receiptNFT = _receiptNFT;
294+
completionNFT = _completionNFT;
265295
treasury = _treasury;
266296

267297
subscriptionId = _subscriptionId;
@@ -583,7 +613,10 @@ contract TandaManager is VRFConsumerBaseV2Plus, Pausable {
583613
creator: msg.sender,
584614
sponsoredCollectionId: collectionId,
585615
scheduledStart: scheduledStart,
586-
privacy: privacyMode
616+
privacy: privacyMode,
617+
passNFT: passNFT,
618+
receiptNFT: receiptNFT,
619+
completionNFT: completionNFT
587620
})
588621
);
589622

src/interfaces/ITanda.sol

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,19 @@ interface ITanda {
6363
uint256 sponsoredCollectionId;
6464
uint256 scheduledStart;
6565
TandaPrivacy privacy;
66+
/// @custom:doc passNFT Soulbound Pass NFT singleton.
67+
/// Auto-minted on `join` /
68+
/// `joinWithInvite`; flagged on
69+
/// `markDefaulter`.
70+
address passNFT;
71+
/// @custom:doc receiptNFT Transferable Receipt NFT singleton.
72+
/// Minted on each cycle payout with
73+
/// frozen-at-mint sponsored metadata.
74+
address receiptNFT;
75+
/// @custom:doc completionNFT Soulbound Completion NFT singleton.
76+
/// Batch-minted at tanda completion
77+
/// to every still-active participant.
78+
address completionNFT;
6679
}
6780

6881
/// @notice One-shot initializer called by `TandaManager.createTanda`

0 commit comments

Comments
 (0)