@dpa-oss/dpa 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +378 -0
- package/contracts/DPA.sol +740 -0
- package/contracts/examples/AssetDPA.sol +81 -0
- package/contracts/shared/Errors.sol +50 -0
- package/contracts/shared/IDPA.sol +112 -0
- package/contracts/shared/Types.sol +18 -0
- package/hardhat.config.ts +23 -0
- package/package.json +24 -0
- package/tsconfig.json +15 -0
|
@@ -0,0 +1,740 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
pragma solidity ^0.8.20;
|
|
3
|
+
|
|
4
|
+
import "erc721a/contracts/ERC721A.sol";
|
|
5
|
+
import "@openzeppelin/contracts/access/Ownable.sol";
|
|
6
|
+
import "@openzeppelin/contracts/utils/Pausable.sol";
|
|
7
|
+
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
|
|
8
|
+
import "./shared/Types.sol";
|
|
9
|
+
import "./shared/Errors.sol";
|
|
10
|
+
import "./shared/IDPA.sol";
|
|
11
|
+
import "@openzeppelin/contracts/utils/introspection/IERC165.sol";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @title DPA (Digital Public Asset)
|
|
15
|
+
* @dev Abstract contract for creating digital public assets with ERC721A
|
|
16
|
+
* Features:
|
|
17
|
+
* - Orchestrator-only minting (changeable by owner)
|
|
18
|
+
* - Token content storage as generic bytes (decoded by implementers)
|
|
19
|
+
* - Batch minting support
|
|
20
|
+
* - Protocol-enforced revision linked-list for full traceability
|
|
21
|
+
* - On-chain events for all operations
|
|
22
|
+
*/
|
|
23
|
+
abstract contract DPA is ERC721A, Ownable, Pausable, ReentrancyGuard {
|
|
24
|
+
// ============ State Variables ============
|
|
25
|
+
|
|
26
|
+
/// @notice The orchestrator address (only this address can mint/revise)
|
|
27
|
+
address public orchestrator;
|
|
28
|
+
|
|
29
|
+
/// @dev Token ID -> Token URI
|
|
30
|
+
mapping(uint256 => string) private _tokenURIs;
|
|
31
|
+
|
|
32
|
+
/// @dev Token ID -> Token content (encoded bytes, decoded by implementers)
|
|
33
|
+
mapping(uint256 => bytes) private _tokenContents;
|
|
34
|
+
|
|
35
|
+
/// @dev Token ID -> Revision record
|
|
36
|
+
mapping(uint256 => Types.RevisionRecord) private _revisions;
|
|
37
|
+
|
|
38
|
+
/// @dev Token ID -> Child token ID (single child for linear chain)
|
|
39
|
+
mapping(uint256 => uint256) private _childToken;
|
|
40
|
+
|
|
41
|
+
/// @dev Token ID -> name hash -> linked DPA contract address
|
|
42
|
+
mapping(uint256 => mapping(bytes32 => address)) private _linkedDPAs;
|
|
43
|
+
|
|
44
|
+
/// @dev Token ID -> array of link name hashes (for enumeration)
|
|
45
|
+
mapping(uint256 => bytes32[]) private _linkedDPANames;
|
|
46
|
+
|
|
47
|
+
/// @dev Counter for total unique digital public assets (origin tokens only)
|
|
48
|
+
uint256 private _totalAssets;
|
|
49
|
+
|
|
50
|
+
// ============ Events ============
|
|
51
|
+
|
|
52
|
+
/// @notice Emitted when a new token is minted
|
|
53
|
+
event TokenMinted(uint256 indexed tokenId, address indexed to, string uri);
|
|
54
|
+
|
|
55
|
+
/// @notice Emitted when tokens are batch minted
|
|
56
|
+
event BatchMinted(
|
|
57
|
+
uint256 indexed startTokenId,
|
|
58
|
+
uint256 quantity,
|
|
59
|
+
address indexed to
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
/// @notice Emitted when a token is revised (full reason string for off-chain indexing)
|
|
63
|
+
event TokenRevised(
|
|
64
|
+
uint256 indexed newTokenId,
|
|
65
|
+
uint256 indexed parentTokenId,
|
|
66
|
+
uint256 indexed originTokenId,
|
|
67
|
+
string reason
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
/// @notice Emitted when orchestrator is changed
|
|
71
|
+
event OrchestratorUpdated(
|
|
72
|
+
address indexed previousOrchestrator,
|
|
73
|
+
address indexed newOrchestrator
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
/// @notice Emitted when a DPA contract is linked to a token
|
|
77
|
+
event DPALinked(
|
|
78
|
+
uint256 indexed tokenId,
|
|
79
|
+
bytes32 indexed nameHash,
|
|
80
|
+
address indexed dpaContract,
|
|
81
|
+
string name
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
/// @notice Emitted when a DPA contract is unlinked from a token
|
|
85
|
+
event DPAUnlinked(
|
|
86
|
+
uint256 indexed tokenId,
|
|
87
|
+
bytes32 indexed nameHash,
|
|
88
|
+
address indexed dpaContract
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
// ============ Modifiers ============
|
|
92
|
+
|
|
93
|
+
/// @dev Restricts function access to orchestrator only
|
|
94
|
+
modifier onlyOrchestrator() {
|
|
95
|
+
if (msg.sender != orchestrator) {
|
|
96
|
+
revert Errors.NotOrchestrator();
|
|
97
|
+
}
|
|
98
|
+
_;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/// @dev Restricts function to only operate on latest version tokens
|
|
102
|
+
modifier onlyLatestVersion(uint256 tokenId) {
|
|
103
|
+
if (!_exists(tokenId)) {
|
|
104
|
+
revert Errors.InvalidTokenId();
|
|
105
|
+
}
|
|
106
|
+
if (_childToken[tokenId] != 0) {
|
|
107
|
+
revert Errors.NotLatestVersion();
|
|
108
|
+
}
|
|
109
|
+
_;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ============ Constructor ============
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* @param name_ Token collection name
|
|
116
|
+
* @param symbol_ Token collection symbol
|
|
117
|
+
* @param orchestrator_ Initial orchestrator address
|
|
118
|
+
*/
|
|
119
|
+
constructor(
|
|
120
|
+
string memory name_,
|
|
121
|
+
string memory symbol_,
|
|
122
|
+
address orchestrator_
|
|
123
|
+
) ERC721A(name_, symbol_) Ownable(msg.sender) {
|
|
124
|
+
if (orchestrator_ == address(0)) {
|
|
125
|
+
revert Errors.ZeroAddress();
|
|
126
|
+
}
|
|
127
|
+
orchestrator = orchestrator_;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ============ Admin Functions ============
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* @notice Updates the orchestrator address
|
|
134
|
+
* @param newOrchestrator New orchestrator address
|
|
135
|
+
*/
|
|
136
|
+
function setOrchestrator(address newOrchestrator) external onlyOwner {
|
|
137
|
+
if (newOrchestrator == address(0)) {
|
|
138
|
+
revert Errors.ZeroAddress();
|
|
139
|
+
}
|
|
140
|
+
address previous = orchestrator;
|
|
141
|
+
orchestrator = newOrchestrator;
|
|
142
|
+
emit OrchestratorUpdated(previous, newOrchestrator);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* @notice Pauses all minting and revision operations
|
|
147
|
+
*/
|
|
148
|
+
function pause() external onlyOwner {
|
|
149
|
+
_pause();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* @notice Unpauses all minting and revision operations
|
|
154
|
+
*/
|
|
155
|
+
function unpause() external onlyOwner {
|
|
156
|
+
_unpause();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ============ Minting Functions ============
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* @notice Mints a single token with URI and content
|
|
163
|
+
* @param to Recipient address
|
|
164
|
+
* @param uri Token URI (metadata location)
|
|
165
|
+
* @param content Encoded content data (decoded by derived contract)
|
|
166
|
+
* @return tokenId The minted token ID
|
|
167
|
+
*/
|
|
168
|
+
function mint(
|
|
169
|
+
address to,
|
|
170
|
+
string calldata uri,
|
|
171
|
+
bytes calldata content
|
|
172
|
+
)
|
|
173
|
+
external
|
|
174
|
+
onlyOrchestrator
|
|
175
|
+
whenNotPaused
|
|
176
|
+
nonReentrant
|
|
177
|
+
returns (uint256 tokenId)
|
|
178
|
+
{
|
|
179
|
+
tokenId = _nextTokenId();
|
|
180
|
+
|
|
181
|
+
// Validate content through derived contract
|
|
182
|
+
_validateContent(content);
|
|
183
|
+
|
|
184
|
+
// Mint the token
|
|
185
|
+
_mint(to, 1);
|
|
186
|
+
|
|
187
|
+
// Store URI and content
|
|
188
|
+
_tokenURIs[tokenId] = uri;
|
|
189
|
+
_tokenContents[tokenId] = content;
|
|
190
|
+
|
|
191
|
+
// Create initial revision record (no parent, version 1)
|
|
192
|
+
_revisions[tokenId] = Types.RevisionRecord({
|
|
193
|
+
previousTokenId: 0,
|
|
194
|
+
originTokenId: tokenId, // Self-reference for origin
|
|
195
|
+
version: 1, // Origin is version 1
|
|
196
|
+
reasonHash: bytes32(0), // No reason for initial creation
|
|
197
|
+
timestamp: block.timestamp,
|
|
198
|
+
actor: msg.sender
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
// Increment total unique assets counter
|
|
202
|
+
_totalAssets++;
|
|
203
|
+
|
|
204
|
+
emit TokenMinted(tokenId, to, uri);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* @notice Mints a single token with explicit owner specification
|
|
209
|
+
* @param to Recipient address
|
|
210
|
+
* @param owner_ Token owner for record
|
|
211
|
+
* @param uri Token URI
|
|
212
|
+
* @param content Encoded content data
|
|
213
|
+
* @return tokenId The minted token ID
|
|
214
|
+
*/
|
|
215
|
+
function mintWithOwner(
|
|
216
|
+
address to,
|
|
217
|
+
address owner_,
|
|
218
|
+
string calldata uri,
|
|
219
|
+
bytes calldata content
|
|
220
|
+
)
|
|
221
|
+
external
|
|
222
|
+
onlyOrchestrator
|
|
223
|
+
whenNotPaused
|
|
224
|
+
nonReentrant
|
|
225
|
+
returns (uint256 tokenId)
|
|
226
|
+
{
|
|
227
|
+
tokenId = _nextTokenId();
|
|
228
|
+
|
|
229
|
+
_validateContent(content);
|
|
230
|
+
_mint(to, 1);
|
|
231
|
+
|
|
232
|
+
_tokenURIs[tokenId] = uri;
|
|
233
|
+
_tokenContents[tokenId] = content;
|
|
234
|
+
|
|
235
|
+
_revisions[tokenId] = Types.RevisionRecord({
|
|
236
|
+
previousTokenId: 0,
|
|
237
|
+
originTokenId: tokenId,
|
|
238
|
+
version: 1, // Origin is version 1
|
|
239
|
+
reasonHash: bytes32(0),
|
|
240
|
+
timestamp: block.timestamp,
|
|
241
|
+
actor: owner_ // Use explicit owner as actor
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
// Increment total unique assets counter
|
|
245
|
+
_totalAssets++;
|
|
246
|
+
|
|
247
|
+
emit TokenMinted(tokenId, to, uri);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* @notice Batch mints tokens with URIs and contents
|
|
252
|
+
* @param to Recipient address (same for all tokens in batch)
|
|
253
|
+
* @param uris Array of token URIs
|
|
254
|
+
* @param contents Array of encoded content data
|
|
255
|
+
* @return startTokenId The first token ID in the batch
|
|
256
|
+
*/
|
|
257
|
+
function batchMint(
|
|
258
|
+
address to,
|
|
259
|
+
string[] calldata uris,
|
|
260
|
+
bytes[] calldata contents
|
|
261
|
+
)
|
|
262
|
+
external
|
|
263
|
+
onlyOrchestrator
|
|
264
|
+
whenNotPaused
|
|
265
|
+
nonReentrant
|
|
266
|
+
returns (uint256 startTokenId)
|
|
267
|
+
{
|
|
268
|
+
uint256 quantity = uris.length;
|
|
269
|
+
|
|
270
|
+
if (quantity != contents.length) {
|
|
271
|
+
revert Errors.ArrayLengthMismatch();
|
|
272
|
+
}
|
|
273
|
+
if (quantity == 0) {
|
|
274
|
+
revert Errors.BatchMintFailed();
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
startTokenId = _nextTokenId();
|
|
278
|
+
|
|
279
|
+
// Validate all content first
|
|
280
|
+
for (uint256 i = 0; i < quantity; i++) {
|
|
281
|
+
_validateContent(contents[i]);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Batch mint all tokens
|
|
285
|
+
_mint(to, quantity);
|
|
286
|
+
|
|
287
|
+
// Store URIs, contents, and create revision records
|
|
288
|
+
for (uint256 i = 0; i < quantity; i++) {
|
|
289
|
+
uint256 tokenId = startTokenId + i;
|
|
290
|
+
_tokenURIs[tokenId] = uris[i];
|
|
291
|
+
_tokenContents[tokenId] = contents[i];
|
|
292
|
+
|
|
293
|
+
_revisions[tokenId] = Types.RevisionRecord({
|
|
294
|
+
previousTokenId: 0,
|
|
295
|
+
originTokenId: tokenId,
|
|
296
|
+
version: 1, // Origin is version 1
|
|
297
|
+
reasonHash: bytes32(0),
|
|
298
|
+
timestamp: block.timestamp,
|
|
299
|
+
actor: msg.sender
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
emit TokenMinted(tokenId, to, uris[i]);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Increment total unique assets counter by batch size
|
|
306
|
+
_totalAssets += quantity;
|
|
307
|
+
|
|
308
|
+
emit BatchMinted(startTokenId, quantity, to);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// ============ Revision Functions ============
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* @notice Creates a new revision of an existing token (protocol-enforced)
|
|
315
|
+
* @param parentTokenId The token being revised (must be latest version)
|
|
316
|
+
* @param uri New token URI
|
|
317
|
+
* @param content New encoded content
|
|
318
|
+
* @param reason Human-readable reason (stored as hash, emitted in full)
|
|
319
|
+
* @return newTokenId The newly minted revision token
|
|
320
|
+
*/
|
|
321
|
+
function revise(
|
|
322
|
+
uint256 parentTokenId,
|
|
323
|
+
string calldata uri,
|
|
324
|
+
bytes calldata content,
|
|
325
|
+
string calldata reason
|
|
326
|
+
)
|
|
327
|
+
external
|
|
328
|
+
onlyOrchestrator
|
|
329
|
+
whenNotPaused
|
|
330
|
+
nonReentrant
|
|
331
|
+
returns (uint256 newTokenId)
|
|
332
|
+
{
|
|
333
|
+
newTokenId = _revise(parentTokenId, uri, content, reason);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* @dev Internal revision function - NOT virtual (protocol-enforced, cannot be overridden)
|
|
338
|
+
*/
|
|
339
|
+
function _revise(
|
|
340
|
+
uint256 parentTokenId,
|
|
341
|
+
string calldata uri,
|
|
342
|
+
bytes calldata content,
|
|
343
|
+
string calldata reason
|
|
344
|
+
) internal onlyLatestVersion(parentTokenId) returns (uint256 newTokenId) {
|
|
345
|
+
// Note: _exists check is now in onlyLatestVersion modifier
|
|
346
|
+
|
|
347
|
+
_validateContent(content);
|
|
348
|
+
|
|
349
|
+
newTokenId = _nextTokenId();
|
|
350
|
+
|
|
351
|
+
// Mint new revision token to same owner as parent
|
|
352
|
+
address parentOwner = ownerOf(parentTokenId);
|
|
353
|
+
_mint(parentOwner, 1);
|
|
354
|
+
|
|
355
|
+
// Store new token data
|
|
356
|
+
_tokenURIs[newTokenId] = uri;
|
|
357
|
+
_tokenContents[newTokenId] = content;
|
|
358
|
+
|
|
359
|
+
// Find origin token (root of chain)
|
|
360
|
+
uint256 originId = _revisions[parentTokenId].originTokenId;
|
|
361
|
+
if (originId == 0) {
|
|
362
|
+
originId = parentTokenId;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Get parent version and increment
|
|
366
|
+
uint256 newVersion = _revisions[parentTokenId].version + 1;
|
|
367
|
+
|
|
368
|
+
// Create revision record with links (reason stored as hash for gas efficiency)
|
|
369
|
+
_revisions[newTokenId] = Types.RevisionRecord({
|
|
370
|
+
previousTokenId: parentTokenId,
|
|
371
|
+
originTokenId: originId,
|
|
372
|
+
version: newVersion,
|
|
373
|
+
reasonHash: keccak256(bytes(reason)),
|
|
374
|
+
timestamp: block.timestamp,
|
|
375
|
+
actor: msg.sender
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
// Update parent's child (single child for linear chain)
|
|
379
|
+
_childToken[parentTokenId] = newTokenId;
|
|
380
|
+
|
|
381
|
+
// Emit full reason for off-chain indexing
|
|
382
|
+
emit TokenRevised(newTokenId, parentTokenId, originId, reason);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// ============ Query Functions ============
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* @notice Returns the token URI
|
|
389
|
+
* @param tokenId Token to query
|
|
390
|
+
*/
|
|
391
|
+
function tokenURI(
|
|
392
|
+
uint256 tokenId
|
|
393
|
+
) public view virtual override returns (string memory) {
|
|
394
|
+
if (!_exists(tokenId)) {
|
|
395
|
+
revert Errors.InvalidTokenId();
|
|
396
|
+
}
|
|
397
|
+
return _tokenURIs[tokenId];
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* @notice Returns the raw encoded content for a token
|
|
402
|
+
* @param tokenId Token to query
|
|
403
|
+
*/
|
|
404
|
+
function tokenContent(
|
|
405
|
+
uint256 tokenId
|
|
406
|
+
) external view returns (bytes memory) {
|
|
407
|
+
if (!_exists(tokenId)) {
|
|
408
|
+
revert Errors.InvalidTokenId();
|
|
409
|
+
}
|
|
410
|
+
return _tokenContents[tokenId];
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* @notice Returns the revision record for a token
|
|
415
|
+
* @param tokenId Token to query
|
|
416
|
+
*/
|
|
417
|
+
function getRevisionRecord(
|
|
418
|
+
uint256 tokenId
|
|
419
|
+
) external view returns (Types.RevisionRecord memory) {
|
|
420
|
+
if (!_exists(tokenId)) {
|
|
421
|
+
revert Errors.InvalidTokenId();
|
|
422
|
+
}
|
|
423
|
+
return _revisions[tokenId];
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* @notice Returns the full revision chain from origin to the given token
|
|
428
|
+
* @param tokenId Token to query (any token in the chain)
|
|
429
|
+
* @return chain Array of token IDs from origin to this token
|
|
430
|
+
*/
|
|
431
|
+
function getRevisionChain(
|
|
432
|
+
uint256 tokenId
|
|
433
|
+
) external view returns (uint256[] memory chain) {
|
|
434
|
+
if (!_exists(tokenId)) {
|
|
435
|
+
revert Errors.InvalidTokenId();
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// First, count the chain length
|
|
439
|
+
uint256 length = 1;
|
|
440
|
+
uint256 current = tokenId;
|
|
441
|
+
while (_revisions[current].previousTokenId != 0) {
|
|
442
|
+
current = _revisions[current].previousTokenId;
|
|
443
|
+
length++;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// Build the chain array (origin first)
|
|
447
|
+
chain = new uint256[](length);
|
|
448
|
+
current = tokenId;
|
|
449
|
+
for (uint256 i = length; i > 0; i--) {
|
|
450
|
+
chain[i - 1] = current;
|
|
451
|
+
current = _revisions[current].previousTokenId;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* @notice Returns the latest version in a revision chain
|
|
457
|
+
* @param originTokenId The origin token of the chain
|
|
458
|
+
* @return latestTokenId The leaf token (latest version)
|
|
459
|
+
*/
|
|
460
|
+
function getLatestVersion(
|
|
461
|
+
uint256 originTokenId
|
|
462
|
+
) external view returns (uint256 latestTokenId) {
|
|
463
|
+
if (!_exists(originTokenId)) {
|
|
464
|
+
revert Errors.InvalidTokenId();
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
latestTokenId = originTokenId;
|
|
468
|
+
while (_childToken[latestTokenId] != 0) {
|
|
469
|
+
latestTokenId = _childToken[latestTokenId];
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* @notice Returns the child token of a given token (0 if none)
|
|
475
|
+
* @param tokenId Token to query
|
|
476
|
+
* @return childTokenId The child token ID (0 if latest version)
|
|
477
|
+
*/
|
|
478
|
+
function getChildToken(
|
|
479
|
+
uint256 tokenId
|
|
480
|
+
) external view returns (uint256 childTokenId) {
|
|
481
|
+
if (!_exists(tokenId)) {
|
|
482
|
+
revert Errors.InvalidTokenId();
|
|
483
|
+
}
|
|
484
|
+
return _childToken[tokenId];
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* @notice Returns the total count of unique digital public assets
|
|
489
|
+
* @dev Only counts origin tokens (new DPAs), not revisions
|
|
490
|
+
* @return total The total number of unique DPAs
|
|
491
|
+
*/
|
|
492
|
+
function getTotalAssets() external view returns (uint256 total) {
|
|
493
|
+
return _totalAssets;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* @notice Checks if a token is the latest version in its chain
|
|
498
|
+
* @param tokenId Token to check
|
|
499
|
+
* @return isLatest True if the token has no child
|
|
500
|
+
*/
|
|
501
|
+
function isLatestVersion(
|
|
502
|
+
uint256 tokenId
|
|
503
|
+
) public view returns (bool isLatest) {
|
|
504
|
+
if (!_exists(tokenId)) {
|
|
505
|
+
revert Errors.InvalidTokenId();
|
|
506
|
+
}
|
|
507
|
+
return _childToken[tokenId] == 0;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* @notice Returns the origin token ID for any token in a revision chain
|
|
512
|
+
* @param tokenId Token to query (can be any token in the chain)
|
|
513
|
+
* @return originTokenId The root/origin token of the chain
|
|
514
|
+
*/
|
|
515
|
+
function getOriginToken(
|
|
516
|
+
uint256 tokenId
|
|
517
|
+
) external view returns (uint256 originTokenId) {
|
|
518
|
+
if (!_exists(tokenId)) {
|
|
519
|
+
revert Errors.InvalidTokenId();
|
|
520
|
+
}
|
|
521
|
+
originTokenId = _revisions[tokenId].originTokenId;
|
|
522
|
+
if (originTokenId == 0) {
|
|
523
|
+
originTokenId = tokenId;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* @notice Returns the version number of a token
|
|
529
|
+
* @param tokenId Token to query
|
|
530
|
+
* @return version The version number (1 for origin, increments on each revision)
|
|
531
|
+
*/
|
|
532
|
+
function getVersion(
|
|
533
|
+
uint256 tokenId
|
|
534
|
+
) external view returns (uint256 version) {
|
|
535
|
+
if (!_exists(tokenId)) {
|
|
536
|
+
revert Errors.InvalidTokenId();
|
|
537
|
+
}
|
|
538
|
+
return _revisions[tokenId].version;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// ============ Linked DPA Functions ============
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* @notice Links an external DPA contract to a token with a unique name
|
|
545
|
+
* @param tokenId Token to add the link to
|
|
546
|
+
* @param name Unique name for this link (per token, cannot be empty)
|
|
547
|
+
* @param dpaContract Address of the DPA contract to link (must be a contract, not self)
|
|
548
|
+
*/
|
|
549
|
+
function linkDPA(
|
|
550
|
+
uint256 tokenId,
|
|
551
|
+
string calldata name,
|
|
552
|
+
address dpaContract
|
|
553
|
+
) external onlyOrchestrator whenNotPaused nonReentrant {
|
|
554
|
+
if (!_exists(tokenId)) {
|
|
555
|
+
revert Errors.InvalidTokenId();
|
|
556
|
+
}
|
|
557
|
+
if (dpaContract == address(0)) {
|
|
558
|
+
revert Errors.ZeroAddress();
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// Security: Validate name is not empty
|
|
562
|
+
if (bytes(name).length == 0) {
|
|
563
|
+
revert Errors.EmptyLinkName();
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// Security: Cannot link to self
|
|
567
|
+
if (dpaContract == address(this)) {
|
|
568
|
+
revert Errors.SelfLink();
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// Security: Must be a contract, not an EOA
|
|
572
|
+
if (dpaContract.code.length == 0) {
|
|
573
|
+
revert Errors.NotAContract();
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// Security: Must implement IDPA interface (ERC-165 check)
|
|
577
|
+
try
|
|
578
|
+
IERC165(dpaContract).supportsInterface(type(IDPA).interfaceId)
|
|
579
|
+
returns (bool supported) {
|
|
580
|
+
if (!supported) {
|
|
581
|
+
revert Errors.NotADPAContract();
|
|
582
|
+
}
|
|
583
|
+
} catch {
|
|
584
|
+
revert Errors.NotADPAContract();
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
bytes32 nameHash = keccak256(bytes(name));
|
|
588
|
+
|
|
589
|
+
// Check if name already exists for this token
|
|
590
|
+
if (_linkedDPAs[tokenId][nameHash] != address(0)) {
|
|
591
|
+
revert Errors.DuplicateLinkName();
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// Store the link
|
|
595
|
+
_linkedDPAs[tokenId][nameHash] = dpaContract;
|
|
596
|
+
_linkedDPANames[tokenId].push(nameHash);
|
|
597
|
+
|
|
598
|
+
emit DPALinked(tokenId, nameHash, dpaContract, name);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* @notice Removes a linked DPA contract by name
|
|
603
|
+
* @param tokenId Token to remove the link from
|
|
604
|
+
* @param name Name of the link to remove
|
|
605
|
+
*/
|
|
606
|
+
function unlinkDPA(
|
|
607
|
+
uint256 tokenId,
|
|
608
|
+
string calldata name
|
|
609
|
+
) external onlyOrchestrator whenNotPaused nonReentrant {
|
|
610
|
+
if (!_exists(tokenId)) {
|
|
611
|
+
revert Errors.InvalidTokenId();
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
bytes32 nameHash = keccak256(bytes(name));
|
|
615
|
+
address dpaContract = _linkedDPAs[tokenId][nameHash];
|
|
616
|
+
|
|
617
|
+
if (dpaContract == address(0)) {
|
|
618
|
+
revert Errors.LinkNotFound();
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// Remove from mapping
|
|
622
|
+
delete _linkedDPAs[tokenId][nameHash];
|
|
623
|
+
|
|
624
|
+
// Remove from names array (swap and pop for gas efficiency)
|
|
625
|
+
bytes32[] storage names = _linkedDPANames[tokenId];
|
|
626
|
+
for (uint256 i = 0; i < names.length; i++) {
|
|
627
|
+
if (names[i] == nameHash) {
|
|
628
|
+
names[i] = names[names.length - 1];
|
|
629
|
+
names.pop();
|
|
630
|
+
break;
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
emit DPAUnlinked(tokenId, nameHash, dpaContract);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* @notice Returns the linked DPA contract address by name
|
|
639
|
+
* @param tokenId Token to query
|
|
640
|
+
* @param name Name of the link
|
|
641
|
+
* @return dpaContract The linked DPA contract address (zero if not found)
|
|
642
|
+
*/
|
|
643
|
+
function getLinkedDPA(
|
|
644
|
+
uint256 tokenId,
|
|
645
|
+
string calldata name
|
|
646
|
+
) external view returns (address dpaContract) {
|
|
647
|
+
if (!_exists(tokenId)) {
|
|
648
|
+
revert Errors.InvalidTokenId();
|
|
649
|
+
}
|
|
650
|
+
bytes32 nameHash = keccak256(bytes(name));
|
|
651
|
+
return _linkedDPAs[tokenId][nameHash];
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* @notice Returns all link name hashes for a token
|
|
656
|
+
* @param tokenId Token to query
|
|
657
|
+
* @return nameHashes Array of name hashes
|
|
658
|
+
*/
|
|
659
|
+
function getLinkedDPANameHashes(
|
|
660
|
+
uint256 tokenId
|
|
661
|
+
) external view returns (bytes32[] memory nameHashes) {
|
|
662
|
+
if (!_exists(tokenId)) {
|
|
663
|
+
revert Errors.InvalidTokenId();
|
|
664
|
+
}
|
|
665
|
+
return _linkedDPANames[tokenId];
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* @notice Returns the number of linked DPAs for a token
|
|
670
|
+
* @param tokenId Token to query
|
|
671
|
+
* @return count The number of linked DPAs
|
|
672
|
+
*/
|
|
673
|
+
function getLinkedDPACount(
|
|
674
|
+
uint256 tokenId
|
|
675
|
+
) external view returns (uint256 count) {
|
|
676
|
+
if (!_exists(tokenId)) {
|
|
677
|
+
revert Errors.InvalidTokenId();
|
|
678
|
+
}
|
|
679
|
+
return _linkedDPANames[tokenId].length;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/**
|
|
683
|
+
* @notice Checks if a token has any linked DPAs
|
|
684
|
+
* @param tokenId Token to check
|
|
685
|
+
* @return hasLinks True if the token has linked DPAs
|
|
686
|
+
*/
|
|
687
|
+
function hasLinkedDPAs(
|
|
688
|
+
uint256 tokenId
|
|
689
|
+
) external view returns (bool hasLinks) {
|
|
690
|
+
if (!_exists(tokenId)) {
|
|
691
|
+
revert Errors.InvalidTokenId();
|
|
692
|
+
}
|
|
693
|
+
return _linkedDPANames[tokenId].length > 0;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// ============ Internal Functions ============
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* @dev Validates encoded content - override in derived contracts
|
|
700
|
+
* @param content The encoded content to validate
|
|
701
|
+
*/
|
|
702
|
+
function _validateContent(bytes calldata content) internal view virtual {
|
|
703
|
+
// Default: no validation. Override for specific validation logic.
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/**
|
|
707
|
+
* @dev Override to start token IDs at 1 instead of 0
|
|
708
|
+
*/
|
|
709
|
+
function _startTokenId() internal view virtual override returns (uint256) {
|
|
710
|
+
return 1;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/**
|
|
714
|
+
* @dev Override to prevent burning - DPA tokens are immutable public records
|
|
715
|
+
*/
|
|
716
|
+
function _beforeTokenTransfers(
|
|
717
|
+
address from,
|
|
718
|
+
address to,
|
|
719
|
+
uint256 startTokenId,
|
|
720
|
+
uint256 quantity
|
|
721
|
+
) internal virtual override {
|
|
722
|
+
super._beforeTokenTransfers(from, to, startTokenId, quantity);
|
|
723
|
+
|
|
724
|
+
// Prevent burning (to == address(0) and from != address(0))
|
|
725
|
+
if (to == address(0) && from != address(0)) {
|
|
726
|
+
revert Errors.BurnDisabled();
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/**
|
|
731
|
+
* @dev Override to support ERC-165 interface detection for IDPA
|
|
732
|
+
*/
|
|
733
|
+
function supportsInterface(
|
|
734
|
+
bytes4 interfaceId
|
|
735
|
+
) public view virtual override returns (bool) {
|
|
736
|
+
return
|
|
737
|
+
interfaceId == type(IDPA).interfaceId ||
|
|
738
|
+
super.supportsInterface(interfaceId);
|
|
739
|
+
}
|
|
740
|
+
}
|