Skip to content

Commit 10aa788

Browse files
GeObtsclaude
andcommitted
feat(nfts): three NFT contracts for membership, completion, and receipts
- MitandaPassNFT — soulbound proof-of-membership, EIP-5192 locked. Auto-minted on join. Tracks defaulter status without burning. approve/setApprovalForAll revert to prevent wallet confusion. - MitandaCompletionNFT — soulbound completion badge, EIP-5192 locked. Batch-minted at completion for active participants only. Stackable for reputation: reputationScore(holder) returns balanceOf(holder). - MitandaReceiptNFT — transferable payout receipt with ERC-2981 royalties. Frozen-at-mint pattern: each receipt snapshots its sponsored collection's baseURI, royaltyReceiver, and royaltyBps into per-token storage. Receipts NEVER query the Manager after mint — tokenURI and royaltyInfo read from the frozen snapshot. Rotating active collections do not change existing receipts. All three are singletons (not cloned). Validated against Manager's isTanda(address) for onlyTanda access control. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d7d5347 commit 10aa788

3 files changed

Lines changed: 880 additions & 0 deletions

File tree

src/MitandaCompletionNFT.sol

Lines changed: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,289 @@
1+
// SPDX-License-Identifier: MIT
2+
pragma solidity 0.8.20;
3+
4+
import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol";
5+
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
6+
import {Strings} from "@openzeppelin/contracts/utils/Strings.sol";
7+
8+
import "./MitandaErrors.sol";
9+
10+
/// @notice Minimum slice of `TandaManager` that this NFT contract reads
11+
/// at runtime. Singleton lives at `manager`, set in the
12+
/// constructor and immutable thereafter.
13+
interface IMitandaManager {
14+
function isTanda(address candidate) external view returns (bool);
15+
}
16+
17+
/// @title MitandaCompletionNFT
18+
/// @author Mi Tanda
19+
/// @notice Soulbound proof-of-completion token. Batch-minted by `Tanda`
20+
/// clones inside `_completeTanda` for every still-active
21+
/// participant. Defaulters never receive a completion badge by
22+
/// construction — `Tanda` only includes `isActive == true`
23+
/// addresses in the call. Cannot be transferred (EIP-5192
24+
/// locked); approval calls revert.
25+
/// @dev Singleton — deployed once per chain, not cloned. Trusted
26+
/// minters are validated via the Manager's `isTanda` view.
27+
/// **Append-only**: once minted, a completion badge cannot be
28+
/// modified or burned. Stackable across tandas — each holder's
29+
/// `balanceOf` IS their `reputationScore`.
30+
contract MitandaCompletionNFT is ERC721, Ownable {
31+
// ─────────────────────────────────────────────────────────────────────
32+
// Immutables / storage
33+
// ─────────────────────────────────────────────────────────────────────
34+
35+
/// @notice Address of the `TandaManager` whose registry gates the
36+
/// `onlyTanda` modifier. Immutable.
37+
address public immutable manager;
38+
39+
/// @notice Next token ID to assign. Pre-incremented so IDs start at
40+
/// 1 (0 is reserved as "no badge" in `participantTandaToTokenId`).
41+
uint256 public nextTokenId;
42+
43+
/// @notice `keccak256(abi.encode(participant, tandaId))` → tokenId.
44+
/// Zero means no completion badge has been minted for that pair.
45+
mapping(bytes32 => uint256) public participantTandaToTokenId;
46+
47+
/// @notice tokenId → participant address.
48+
mapping(uint256 => address) public completionParticipant;
49+
50+
/// @notice tokenId → originating tandaId.
51+
mapping(uint256 => uint256) public completionTandaId;
52+
53+
/// @notice tokenId → originating `Tanda` clone address.
54+
mapping(uint256 => address) public completionTandaAddress;
55+
56+
string private _baseTokenURI;
57+
58+
// ─────────────────────────────────────────────────────────────────────
59+
// Events
60+
// ─────────────────────────────────────────────────────────────────────
61+
62+
event CompletionMinted(
63+
uint256 indexed tokenId, address indexed participant, uint256 indexed tandaId, address tandaAddress
64+
);
65+
66+
event BaseURIUpdated(string newBaseURI);
67+
68+
/// @notice EIP-5192: emitted on mint to signal the token is locked
69+
/// for life. No `Unlocked` event is ever emitted.
70+
event Locked(uint256 tokenId);
71+
72+
// ─────────────────────────────────────────────────────────────────────
73+
// Constructor
74+
// ─────────────────────────────────────────────────────────────────────
75+
76+
/// @param managerAddress Address of the deployed `TandaManager`.
77+
/// @param initialOwner Owner of this NFT contract (controls
78+
/// `setBaseURI`).
79+
constructor(address managerAddress, address initialOwner)
80+
ERC721("Mi Tanda Completion", "MTCOMP")
81+
Ownable(initialOwner)
82+
{
83+
if (managerAddress == address(0)) revert ZeroAddress();
84+
// OZ v5 `Ownable` already rejects zero `initialOwner` via
85+
// `OwnableInvalidOwner`. The explicit check below is redundant
86+
// but consistent with the codebase's defense-in-depth style.
87+
if (initialOwner == address(0)) revert ZeroAddress();
88+
manager = managerAddress;
89+
}
90+
91+
// ─────────────────────────────────────────────────────────────────────
92+
// Modifiers
93+
// ─────────────────────────────────────────────────────────────────────
94+
95+
modifier onlyTanda() {
96+
if (!IMitandaManager(manager).isTanda(msg.sender)) revert CallerNotTanda();
97+
_;
98+
}
99+
100+
// ─────────────────────────────────────────────────────────────────────
101+
// Tanda-only: batchMint
102+
// ─────────────────────────────────────────────────────────────────────
103+
104+
/// @notice Mint a completion badge to every address in `participants`
105+
/// for `tandaId`. Called by `Tanda._completeTanda`.
106+
/// @dev Empty `participants` array is a no-op (returns an empty
107+
/// tokenIds array). The loop performs the duplicate check
108+
/// independently per iteration; a duplicate within the
109+
/// batch reverts the entire tx, rolling back all prior
110+
/// iterations' writes — there's no half-batch state in
111+
/// storage. State writes for each badge happen BEFORE
112+
/// `_safeMint`, preserving the same atomicity invariant as
113+
/// `MitandaPassNFT.mint`.
114+
/// @param participants Addresses receiving badges. Order matters
115+
/// only for the returned `tokenIds` array.
116+
/// @param tandaId ID of the completing tanda.
117+
/// @return tokenIds Same-length array of newly-minted token IDs.
118+
/// @custom:reverts CallerNotTanda if `msg.sender` isn't a Tanda.
119+
/// @custom:reverts CompletionAlreadyMinted if any `(participants[i], tandaId)`
120+
/// pair already has a badge.
121+
/// @custom:emits CompletionMinted, Locked (one of each per mint).
122+
function batchMint(address[] calldata participants, uint256 tandaId)
123+
external
124+
onlyTanda
125+
returns (uint256[] memory tokenIds)
126+
{
127+
uint256 n = participants.length;
128+
tokenIds = new uint256[](n);
129+
130+
for (uint256 i = 0; i < n; i++) {
131+
address participant = participants[i];
132+
bytes32 key = keccak256(abi.encode(participant, tandaId));
133+
if (participantTandaToTokenId[key] != 0) {
134+
revert CompletionAlreadyMinted(participant, tandaId);
135+
}
136+
137+
uint256 tokenId = ++nextTokenId;
138+
139+
participantTandaToTokenId[key] = tokenId;
140+
completionParticipant[tokenId] = participant;
141+
completionTandaId[tokenId] = tandaId;
142+
completionTandaAddress[tokenId] = msg.sender;
143+
144+
_safeMint(participant, tokenId);
145+
146+
tokenIds[i] = tokenId;
147+
148+
emit CompletionMinted(tokenId, participant, tandaId, msg.sender);
149+
emit Locked(tokenId);
150+
}
151+
}
152+
153+
// ─────────────────────────────────────────────────────────────────────
154+
// Owner: setBaseURI
155+
// ─────────────────────────────────────────────────────────────────────
156+
157+
/// @notice Update the base URI prefix used by `tokenURI`.
158+
/// @dev Owner-only. Stored verbatim; no trailing-slash
159+
/// normalization. The metadata server is expected to
160+
/// compute appropriate per-badge metadata.
161+
/// @custom:reverts EmptyBaseURI if `bytes(newBaseURI).length == 0`.
162+
/// @custom:emits BaseURIUpdated.
163+
function setBaseURI(string calldata newBaseURI) external onlyOwner {
164+
if (bytes(newBaseURI).length == 0) revert EmptyBaseURI();
165+
_baseTokenURI = newBaseURI;
166+
emit BaseURIUpdated(newBaseURI);
167+
}
168+
169+
/// @notice Read the current base URI prefix.
170+
function baseURI() external view returns (string memory) {
171+
return _baseTokenURI;
172+
}
173+
174+
// ─────────────────────────────────────────────────────────────────────
175+
// ERC-721 overrides — soulbound enforcement
176+
// ─────────────────────────────────────────────────────────────────────
177+
178+
/// @dev Central OZ v5 hook for all balance / ownership state
179+
/// transitions. Soulbound: blocks any transfer (where both
180+
/// `from` and `to` are non-zero). Mints (`from == 0`) and
181+
/// burns (`to == 0`) pass through — though we expose no
182+
/// `burn` function, so burns are unreachable.
183+
function _update(address to, uint256 tokenId, address auth) internal virtual override returns (address) {
184+
address from = _ownerOf(tokenId);
185+
if (from != address(0) && to != address(0)) {
186+
revert SoulboundTransferDisabled();
187+
}
188+
return super._update(to, tokenId, auth);
189+
}
190+
191+
/// @dev Approval is meaningless for a soulbound token (no transfer
192+
/// path exists). Reverting here keeps wallets and marketplaces
193+
/// from showing users a hollow "approve" button.
194+
function approve(
195+
address,
196+
/*to*/
197+
uint256 /*tokenId*/
198+
)
199+
public
200+
pure
201+
override
202+
{
203+
revert SoulboundTransferDisabled();
204+
}
205+
206+
/// @dev Operator approvals are likewise meaningless for soulbound
207+
/// tokens.
208+
function setApprovalForAll(
209+
address,
210+
/*operator*/
211+
bool /*approved*/
212+
)
213+
public
214+
pure
215+
override
216+
{
217+
revert SoulboundTransferDisabled();
218+
}
219+
220+
// ─────────────────────────────────────────────────────────────────────
221+
// ERC-721 metadata
222+
// ─────────────────────────────────────────────────────────────────────
223+
224+
/// @notice URI of `tokenId`'s metadata.
225+
/// Format: `<baseURI>/<tokenId>.json`.
226+
/// @custom:reverts ERC721NonexistentToken if `tokenId` doesn't exist.
227+
function tokenURI(uint256 tokenId) public view override returns (string memory) {
228+
_requireOwned(tokenId);
229+
return string.concat(_baseTokenURI, "/", Strings.toString(tokenId), ".json");
230+
}
231+
232+
// ─────────────────────────────────────────────────────────────────────
233+
// EIP-5192
234+
// ─────────────────────────────────────────────────────────────────────
235+
236+
/// @notice EIP-5192: returns true if `tokenId` is locked. Always
237+
/// true here — every completion badge is soulbound for life.
238+
/// @custom:reverts ERC721NonexistentToken if `tokenId` doesn't exist.
239+
function locked(uint256 tokenId) external view returns (bool) {
240+
_requireOwned(tokenId);
241+
return true;
242+
}
243+
244+
/// @notice Adds EIP-5192 (`0xb45a3c0e`) to the supported interface set.
245+
function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool) {
246+
return interfaceId == 0xb45a3c0e // EIP-5192
247+
|| super.supportsInterface(interfaceId);
248+
}
249+
250+
// ─────────────────────────────────────────────────────────────────────
251+
// View helpers
252+
// ─────────────────────────────────────────────────────────────────────
253+
254+
/// @notice Returns the tokenId for `(participant, tandaId)`, or 0
255+
/// if no badge has been minted for that pair.
256+
function getCompletionId(address participant, uint256 tandaId) external view returns (uint256) {
257+
return participantTandaToTokenId[keccak256(abi.encode(participant, tandaId))];
258+
}
259+
260+
/// @notice True if a completion badge has been minted for the pair.
261+
function hasCompletion(address participant, uint256 tandaId) external view returns (bool) {
262+
return participantTandaToTokenId[keccak256(abi.encode(participant, tandaId))] != 0;
263+
}
264+
265+
/// @notice Bundled view for a badge's full state.
266+
/// @custom:reverts ERC721NonexistentToken if `tokenId` doesn't exist.
267+
function getCompletionInfo(uint256 tokenId)
268+
external
269+
view
270+
returns (address participant, uint256 tandaId, address tandaAddress)
271+
{
272+
_requireOwned(tokenId);
273+
participant = completionParticipant[tokenId];
274+
tandaId = completionTandaId[tokenId];
275+
tandaAddress = completionTandaAddress[tokenId];
276+
}
277+
278+
/// @notice Reputation primitive: total count of completion badges
279+
/// held by `holder`. Each badge represents one successful
280+
/// tanda completion. Stackable across tandas.
281+
/// @dev Simple alias for `balanceOf(holder)`. Kept as a distinct
282+
/// function so the API surface is stable if a future
283+
/// version adopts a weighted formula (by tanda size,
284+
/// recency, etc.). Reverts with OZ's `ERC721InvalidOwner`
285+
/// if `holder == address(0)` — matches `balanceOf`.
286+
function reputationScore(address holder) external view returns (uint256) {
287+
return balanceOf(holder);
288+
}
289+
}

0 commit comments

Comments
 (0)