Skip to content

Commit da2bc95

Browse files
authored
IPFS Resolution for NBA Assets (#301)
* IPFS resolution for NBA assets * sharded dicts * fix comment * read write admin txs * fix num buckets * fix ci * fix ci * fix dict copy performance * fix ci
1 parent 10eca81 commit da2bc95

12 files changed

Lines changed: 328 additions & 20 deletions

File tree

contracts/TopShot.cdc

Lines changed: 37 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ import MetadataViews from 0xMETADATAVIEWSADDRESS
4949
import TopShotLocking from 0xTOPSHOTLOCKINGADDRESS
5050
import ViewResolver from 0xVIEWRESOLVERADDRESS
5151
import CrossVMMetadataViews from 0xCROSSVMMETADATAVIEWSADDRESS
52+
import TopShotIPFSResolver from 0xTOPSHOTIPFSRESOLVERADDRESS
5253
import EVM from 0xEVMADDRESS
5354

5455
access(all) contract TopShot: NonFungibleToken {
@@ -816,22 +817,44 @@ access(all) contract TopShot: NonFungibleToken {
816817
case Type<MetadataViews.Traits>():
817818
return self.resolveTraitsView()
818819
case Type<MetadataViews.Medias>():
819-
return MetadataViews.Medias(
820-
[
821-
MetadataViews.Media(
822-
file: MetadataViews.HTTPFile(
823-
url: self.mediumimage()
824-
),
825-
mediaType: "image/jpeg"
820+
var items = [
821+
MetadataViews.Media(
822+
file: MetadataViews.HTTPFile(
823+
url: self.mediumimage()
826824
),
827-
MetadataViews.Media(
828-
file: MetadataViews.HTTPFile(
829-
url: self.video()
830-
),
831-
mediaType: "video/mp4"
825+
mediaType: "image/jpeg"
826+
),
827+
MetadataViews.Media(
828+
file: MetadataViews.HTTPFile(
829+
url: self.video()
830+
),
831+
mediaType: "video/mp4"
832+
)
833+
]
834+
let subeditionID = TopShot.getMomentsSubedition(nftID: self.id) ?? 0
835+
if let ipfsCIDs = TopShotIPFSResolver.getCIDs(setID: self.data.setID, playID: self.data.playID, subeditionID: subeditionID) {
836+
for mediaType in ipfsCIDs.keys {
837+
let cid = ipfsCIDs[mediaType]!
838+
items.append(
839+
MetadataViews.Media(
840+
file: MetadataViews.IPFSFile(
841+
cid: cid,
842+
path: nil
843+
),
844+
mediaType: mediaType
845+
)
832846
)
833-
]
834-
)
847+
items.append(
848+
MetadataViews.Media(
849+
file: MetadataViews.HTTPFile(
850+
url: TopShotIPFSResolver.gateway.concat(cid)
851+
),
852+
mediaType: mediaType
853+
)
854+
)
855+
}
856+
}
857+
return MetadataViews.Medias(items)
835858
}
836859
return nil
837860
}

contracts/TopShotIPFSResolver.cdc

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
access(all) contract TopShotIPFSResolver {
2+
3+
access(all) event CIDSet(setID: UInt32, playID: UInt32, subeditionID: UInt32, mediaType: String, cid: String)
4+
access(all) event GatewayUpdated(gateway: String)
5+
6+
access(all) let numBuckets: UInt64
7+
8+
// Each shard is a resource stored at its own path so writes
9+
// borrow a reference and mutate in place — no dict copy.
10+
access(all) resource Shard {
11+
access(all) let entries: {String: {String: String}}
12+
13+
init() {
14+
self.entries = {}
15+
}
16+
17+
access(contract) fun setCID(key: String, mediaType: String, cid: String) {
18+
var inner = self.entries[key] ?? {}
19+
inner[mediaType] = cid
20+
self.entries[key] = inner
21+
}
22+
}
23+
24+
// IPFS gateway base URL (e.g., "https://ipfs.dapperlabs.com/ipfs/")
25+
access(all) var gateway: String
26+
27+
access(all) let AdminStoragePath: StoragePath
28+
29+
access(all) resource Admin {
30+
access(all) fun setCID(setID: UInt32, playID: UInt32, subeditionID: UInt32, mediaType: String, cid: String) {
31+
let key = TopShotIPFSResolver.buildKey(setID: setID, playID: playID, subeditionID: subeditionID)
32+
let bucket = TopShotIPFSResolver.getBucket(setID: setID, playID: playID, subeditionID: subeditionID)
33+
let shard = TopShotIPFSResolver.borrowShard(bucket)
34+
shard.setCID(key: key, mediaType: mediaType, cid: cid)
35+
emit CIDSet(setID: setID, playID: playID, subeditionID: subeditionID, mediaType: mediaType, cid: cid)
36+
}
37+
38+
access(all) fun setGateway(gateway: String) {
39+
TopShotIPFSResolver.gateway = gateway
40+
emit GatewayUpdated(gateway: gateway)
41+
}
42+
43+
access(all) fun clearAllCIDs() {
44+
var i: UInt64 = 0
45+
while i < TopShotIPFSResolver.numBuckets {
46+
let path = TopShotIPFSResolver.shardPath(i)
47+
if let existing <- TopShotIPFSResolver.account.storage.load<@Shard>(from: path) {
48+
destroy existing
49+
}
50+
i = i + 1
51+
}
52+
}
53+
}
54+
55+
access(all) view fun getCIDs(setID: UInt32, playID: UInt32, subeditionID: UInt32): {String: String}? {
56+
let key = self.buildKey(setID: setID, playID: playID, subeditionID: subeditionID)
57+
let bucket = self.getBucket(setID: setID, playID: playID, subeditionID: subeditionID)
58+
let path = self.shardPath(bucket)
59+
if let shard = self.account.storage.borrow<&Shard>(from: path) {
60+
if let entry = shard.entries[key] {
61+
// Return a copy, not a reference
62+
let result: {String: String} = {}
63+
for mediaType in entry.keys {
64+
result[mediaType] = entry[mediaType]
65+
}
66+
return result
67+
}
68+
}
69+
return nil
70+
}
71+
72+
access(contract) fun borrowShard(_ bucket: UInt64): &Shard {
73+
let path = self.shardPath(bucket)
74+
if let shard = self.account.storage.borrow<&Shard>(from: path) {
75+
return shard
76+
}
77+
self.account.storage.save(<- create Shard(), to: path)
78+
return self.account.storage.borrow<&Shard>(from: path)!
79+
}
80+
81+
access(all) view fun shardPath(_ bucket: UInt64): StoragePath {
82+
return StoragePath(identifier: "TopShotIPFSShard_".concat(bucket.toString()))!
83+
}
84+
85+
access(all) view fun getBucket(setID: UInt32, playID: UInt32, subeditionID: UInt32): UInt64 {
86+
return (UInt64(setID) + UInt64(playID) + UInt64(subeditionID)) % self.numBuckets
87+
}
88+
89+
access(all) view fun buildKey(setID: UInt32, playID: UInt32, subeditionID: UInt32): String {
90+
return setID.toString()
91+
.concat("_")
92+
.concat(playID.toString())
93+
.concat("_")
94+
.concat(subeditionID.toString())
95+
}
96+
97+
init() {
98+
self.numBuckets = 31
99+
self.gateway = "https://ipfs.dapperlabs.com/ipfs/"
100+
self.AdminStoragePath = /storage/TopShotIPFSResolverAdmin
101+
self.account.storage.save<@Admin>(<- create Admin(), to: self.AdminStoragePath)
102+
}
103+
}

lib/go/contracts/contracts.go

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,13 @@ const (
3535
defaultEVMBaseURI = "${EVMBASEURI}"
3636
fastBreakFile = "FastBreakV1.cdc"
3737
crossVMMetadataViewsFile = "imports/CrossVMMetadataViews.cdc"
38+
topShotIPFSResolverFile = "TopShotIPFSResolver.cdc"
39+
defaultTopShotIPFSResolverAddress = "TOPSHOTIPFSRESOLVERADDRESS"
3840
)
3941

4042
// GenerateTopShotContract returns a copy
4143
// of the topshot contract with the import addresses updated
42-
func GenerateTopShotContract(ftAddr, nftAddr, metadataViewsAddr, viewResolverAddr, crossVMMetadataViewsAddr, evmAddr, topShotLockingAddr, royaltyAddr, network, flowEvmContractAddr, evmBaseURI string) []byte {
44+
func GenerateTopShotContract(ftAddr, nftAddr, metadataViewsAddr, viewResolverAddr, crossVMMetadataViewsAddr, evmAddr, topShotLockingAddr, royaltyAddr, network, flowEvmContractAddr, evmBaseURI, topShotIPFSResolverAddr string) []byte {
4345

4446
topShotCode := assets.MustAssetString(topshotFile)
4547

@@ -65,7 +67,9 @@ func GenerateTopShotContract(ftAddr, nftAddr, metadataViewsAddr, viewResolverAdd
6567

6668
codeWithEVMBaseURI := strings.ReplaceAll(codeWithEVMContractAddress, defaultEVMBaseURI, evmBaseURI)
6769

68-
return []byte(codeWithEVMBaseURI)
70+
codeWithIPFSResolverAddr := strings.ReplaceAll(codeWithEVMBaseURI, defaultTopShotIPFSResolverAddress, topShotIPFSResolverAddr)
71+
72+
return []byte(codeWithIPFSResolverAddr)
6973
}
7074

7175
// GenerateTopShotShardedCollectionContract returns a copy
@@ -152,6 +156,10 @@ func GenerateFastBreakContract(nftAddr string, topshotAddr string, metadataViews
152156
return []byte(code)
153157
}
154158

159+
func GenerateTopShotIPFSResolverContract() []byte {
160+
return []byte(assets.MustAssetString(topShotIPFSResolverFile))
161+
}
162+
155163
func GenerateCrossVMMetadataViewsContract(evmAddr string, viewResolverAddr string) []byte {
156164
crossVMMetadataViewsCode := assets.MustAssetString(crossVMMetadataViewsFile)
157165
codeWithEVMAddr := strings.ReplaceAll(crossVMMetadataViewsCode, defaultEVMAddress, evmAddr)

lib/go/contracts/contracts_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ var flowEvmContractAddr = "0x1234565789012345657890123456578901234565"
2020
var evmBaseURI = "https://base.uri/moment/"
2121

2222
func TestTopShotContract(t *testing.T) {
23-
contract := contracts.GenerateTopShotContract(addrA, addrA, addrA, addrA, addrA, addrA, addrA, addrA, network, flowEvmContractAddr, evmBaseURI)
23+
contract := contracts.GenerateTopShotContract(addrA, addrA, addrA, addrA, addrA, addrA, addrA, addrA, network, flowEvmContractAddr, evmBaseURI, addrA)
2424
assert.NotNil(t, contract)
2525
}
2626

lib/go/contracts/internal/assets/assets.go

Lines changed: 26 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)