Smart Contract

SalesContract

A.a49cc0ee46c54bfb.SalesContract

Deployed

1d ago
Feb 26, 2026, 09:43:52 PM UTC

Dependents

0 imports
1import MotoGPAdmin from 0xa49cc0ee46c54bfb
2import MotoGPPack from 0xa49cc0ee46c54bfb
3import NonFungibleToken from 0x1d7e57aa55817448
4import FlowToken from 0x1654653399040a61
5import FungibleToken from 0xf233dcee88fe0abe
6import MotoGPTransfer from 0xa49cc0ee46c54bfb
7
8// The SalesContract's role is to enable on-chain sales of MotoGP packs.
9// Users buy directly from the buyPack method and get the pack deposited into their collection, if all conditions are met.
10// The contract admin manages sales by adding SKUs to the contract. A SKU is equivalent to a drop.
11//
12// Each SKU has a list of serial numbers (equivalent to print numbers), and when the user buys a pack, a serial is selected from the SKUs serial list,
13// and removed from the list. To make the serial selection hard to predict we employ a logic discussed further below.
14//
15// The buyPack method takes a signature as one of its arguments. This signature is generated when the user requests to buy a pack via the MotoGP web site. 
16// The user calls the MotoGP backend signing service. Using a private key, the signing service creates a signature which includes the user's address, a nonce unique to the address which is read from the SalesContract, and the pack type.
17// The signing service then sends the signature back to user the user, who subsequently send a transaction including the signature to the SalesContract to buy a pack.
18// Inside the buyPack method, the signature is verified using a public key which is a string field on the contract, and that only the admin can set. 
19// The key is set on the contract, rather than the account, to guarantee it is only used within this contract.
20//
21// After the signature has been verified, the first byte is read from the signature and an index is created from it, which is used to 
22// select a serial from the serial list. That serial is then removed, and a pack is minted and deposited into the users collection. In this way,
23// for every pack purchase, the serial list shrinks by one.
24//
25// The user's payment for packs comes from a Flow vault submitted in the buyPack transaction. The payment is deposited into a Flow vault at an address set on the SKU.
26//
27pub contract SalesContract {
28
29    pub fun getVersion(): String {
30        return "1.0.0"
31    }
32
33
34    pub let adminStoragePath: StoragePath
35    access(contract) let skuMap: {String : SKU}
36    
37    // account-specific nonce to prevent a user from submitting the same transaction twice
38    access(contract) let nonceMap: {Address : UInt64}
39    // the public key used to verify a buyPack signature
40    access(contract) var verificationKey: String
41    // map to track if a serial has already been added for a pack type. A duplicate would throw an
42    // exception on mint in buyPack from the Pack contract.
43    access(contract) let serialMap: { UInt64 : { UInt64 : Bool}} // Used like so : { packType : { serial: true/false } 
44
45    // An SKU is equivalent to a drop or "sale event" with start and end time, and max supply
46    pub struct SKU {
47        // Unix timestamp in seconds (not milliseconds) when the SKU starts
48        access(contract) var startTime: UInt64;
49        // Unix timestamp in seconds (not milliseconds) when the SKU ends
50        access(contract) var endTime: UInt64;
51        // max total number of NFTs which can be minted during this SKU
52        access(contract) var totalSupply: UInt64;
53        // list of serials, from which one will be chosen and removed for each NFT mint.
54        access(contract) var serialList: [UInt64]
55        // map to check that a buyer doesn't buy more than allowed max from a SKU
56        access(contract) let buyerCountMap: { Address: UInt64 }
57        // price of the NFT in FLOW tokens
58        access(contract) var price: UFix64
59        // max number of NFTs the buyer can buy
60        access(contract) var maxPerBuyer: UInt64
61        // address to deposit the payment to. Can be unique for each SKU.
62        access(contract) var payoutAddress: Address
63        // packType + serial determines a unique Pack
64        access(contract) var packType: UInt64
65
66        // SKU constructor
67        init(startTime: UInt64, endTime: UInt64, payoutAddress: Address, packType: UInt64){
68            self.startTime = startTime
69            self.endTime = endTime 
70            self.serialList = []
71            self.buyerCountMap = {}
72            self.totalSupply = UInt64(0)
73            self.maxPerBuyer = UInt64(1)
74            self.price = UFix64(0.0)
75            self.payoutAddress = payoutAddress
76            self.packType = packType
77        }
78
79        // Setters for modifications after a SKU has been created.
80        // Access via contract helper methods, never directly on the SKU
81
82        access(contract) fun setStartTime(startTime: UInt64) {
83            self.startTime = startTime
84        }
85
86        access(contract) fun setEndTime(endTime: UInt64) {
87            self.endTime = endTime
88        }
89
90        access(contract) fun setPrice(price: UFix64){
91            self.price = price
92        }
93
94        access(contract) fun setMaxPerBuyer(maxPerBuyer: UInt64) {
95            self.maxPerBuyer = maxPerBuyer
96        }
97
98        access(contract) fun setPayoutAddress(payoutAddress: Address) {
99            self.payoutAddress = payoutAddress
100        }
101
102        // The increaseSupply() method adds lists of serial numbers to a SKU.
103        // Since a SKU's serial number list length can be several thousands,
104        // increaseSupply() may need to be called multiple times to build up a SKU's supply.
105        // Given that the combination type + serial needs to be unique for a mint (else Pack contract will panic),
106        // the increaseSupply() method will panic if a serial is submitted for a type that already has it registered.
107
108        access(contract) fun increaseSupply(supplyList: [UInt64]){
109            let oldTotalSupply = UInt64(self.serialList.length)
110            self.serialList = self.serialList.concat(supplyList)
111            self.totalSupply =  UInt64(supplyList.length) + oldTotalSupply
112            
113
114            if !SalesContract.serialMap.containsKey(self.packType) {
115                SalesContract.serialMap[self.packType] = {};
116            }
117            let statusMap = SalesContract.serialMap[self.packType]!
118
119            var index: UInt64 = UInt64(0);
120            while index < UInt64(supplyList.length) {
121                let serial = supplyList[index]
122                if statusMap.containsKey(serial) && statusMap[serial]! == true {
123                    let msg = "Serial ".concat(serial.toString()).concat(" for packtype").concat(self.packType.toString()).concat(" is already added")
124                    panic(msg)
125                }
126                SalesContract.serialMap[self.packType]!.insert(key: serial, true)
127                index = index + UInt64(1)
128            }
129        }
130    }
131
132    // isCurrencySKU returns true if a SKU's startTime is in the past and it's endTime in the future, based on getCurrentBlock's timestamp.
133    // This method should not be relied on for precise time requirements on front end, as getCurrentBlock().timestamp in a read method may be quite inaccurate.
134
135    pub fun isCurrentSKU(name: String): Bool {
136        let sku = self.skuMap[name]!
137        let now = UInt64(getCurrentBlock().timestamp)
138        if sku.startTime <= now && sku.endTime > now {
139            return true
140        }
141        
142        return false
143    }
144
145    pub fun getStartTimeForSKU(name: String): UInt64 {
146        return self.skuMap[name]!.startTime
147    }
148
149    pub fun getEndTimeForSKU(name: String): UInt64 {
150        return self.skuMap[name]!.endTime
151    }
152
153    pub fun getTotalSupplyForSKU(name: String): UInt64 {
154        return self.skuMap[name]!.totalSupply
155    }
156
157
158    // Remaining supply equals the length of the serialList, since even mint event removes a serial from the list.
159
160    pub fun getRemainingSupplyForSKU(name: String): UInt64 {
161        return UInt64(self.skuMap[name]!.serialList.length)
162    }
163
164    // Helper method to check how many NFT an account has purchased for a particular SKU
165
166    pub fun getBuyCountForAddress(skuName: String, recipient: Address): UInt64 {
167        return self.skuMap[skuName]!.buyerCountMap[recipient] ?? UInt64(0)
168    }
169
170    pub fun getPriceForSKU(name: String): UFix64 {
171        return self.skuMap[name]!.price
172    }
173
174    pub fun getMaxPerBuyerForSKU(name: String): UInt64 {
175        return self.skuMap[name]!.maxPerBuyer
176    }
177
178    // Returns a list of SKUs where start time is in the past and the end time is in the future.
179    // Don't reply on it for precise time requirements.
180
181    pub fun getActiveSKUs(): [String] {
182        let activeSKUs:[String] = []
183        let keys = self.skuMap.keys
184        var index = UInt64(0)
185        while index < UInt64(keys.length) {
186            let key = keys[index]!
187            let sku = self.skuMap[key]!
188            let now = UInt64(getCurrentBlock().timestamp)
189            if sku.startTime <= now { // SKU has started
190                if sku.endTime > now  {// SKU hasn't ended
191                    activeSKUs.append(key)
192                }
193            }
194            index = index + UInt64(1)
195        }
196        return activeSKUs;
197    }
198
199    pub fun getAllSKUs(): [String] {
200        return self.skuMap.keys
201    }
202
203    pub fun removeSKU(adminRef: &Admin, skuName: String) {
204        self.skuMap.remove(key: skuName)
205    }
206
207    // The Admin resource is used as an access lock on certain setter methods
208    pub resource Admin {}
209    
210    // Sets the public key used to verify signature submitted in the buyPack request
211    pub fun setVerificationKey(adminRef: &Admin, verificationKey: String) {
212        pre {
213            adminRef != nil : "adminRef is nil."
214        }
215        self.verificationKey = verificationKey;
216    }
217
218    // helper used by buyPack method to check if a signature is valid
219    access(contract) fun isValidSignature(signature: String, message: String): Bool {
220        
221        let pk = PublicKey(
222            publicKey: self.verificationKey.decodeHex(),
223            signatureAlgorithm: SignatureAlgorithm.ECDSA_P256
224        )
225
226        let isValid = pk.verify(
227            signature: signature.decodeHex(),
228            signedData: message.utf8,
229            domainSeparationTag: "FLOW-V0.0-user",
230            hashAlgorithm: HashAlgorithm.SHA3_256
231        )
232        return isValid
233    }
234
235    // Returns a nonce per account
236    pub fun getNonce(address: Address): UInt64 {
237        return self.nonceMap[address] ?? 0 as UInt64
238    }
239
240    pub fun isActiveSKU(name: String): Bool {
241            let sku = self.skuMap[name]!
242            let now = UInt64(getCurrentBlock().timestamp)
243            if sku.startTime <= now { // SKU has started
244                if sku.endTime > now  {// SKU hasn't ended
245                    return true
246                }
247            }
248            return false
249    }
250
251    // Helper method to convert address to String (used for verificaton of signature in buyPack)
252    // public to allow testing
253
254    pub fun convertAddressToString(address: Address): String {
255        let EXPECTED_ADDRESS_LENGTH = 18
256        var addrStr = address.toString() //Cadence shortens addresses starting with 0, so 0x0123 becomes 0x123
257        if addrStr.length == EXPECTED_ADDRESS_LENGTH {
258            return addrStr
259        }
260        let prefix = addrStr.slice(from: 0, upTo: 2)
261        var suffix = addrStr.slice(from: 2, upTo: addrStr.length)
262        
263        let steps = EXPECTED_ADDRESS_LENGTH - addrStr.length
264        var index = 0
265        while index < steps {
266            suffix = "0".concat(suffix) 
267            index = index + 1
268        }
269        
270        addrStr = prefix.concat(suffix)
271        if addrStr.length != EXPECTED_ADDRESS_LENGTH {
272            panic("Padding address String is wrong length")
273        }
274        return addrStr
275    }
276
277    pub fun buyPack(signature: String, 
278                    nonce: UInt32, 
279                    packType: UInt64, 
280                    skuName: String, 
281                    recipient: Address, 
282                    paymentVaultRef: &FungibleToken.Vault, 
283                    recipientCollectionRef: &MotoGPPack.Collection{MotoGPPack.IPackCollectionPublic}) {
284
285        pre {
286            paymentVaultRef.balance >= self.skuMap[skuName]!.price : "paymentVaultRef's balance is lower than price"
287            self.isActiveSKU(name: skuName) == true : "SKU is not active"
288            self.getRemainingSupplyForSKU(name: skuName) > UInt64(0) : "No remaining supply for SKU"
289            self.skuMap[skuName]!.price >= UFix64(0.0) : "Price is zero. Admin needs to set the price"
290            self.skuMap[skuName]!.packType == packType : "Supplied packType doesn't match SKU packType"
291        }                
292
293        post {
294            self.nonceMap[recipient]! == before(self.nonceMap[recipient] ?? UInt64(0)) + UInt64(1)  : "Nonce hasn't increased by one"
295            self.skuMap[skuName]!.buyerCountMap[recipient]! == before(self.skuMap[skuName]!.buyerCountMap[recipient] ?? UInt64(0)) + UInt64(1) : "buyerCountMap hasn't increased by one"
296            self.skuMap[skuName]!.buyerCountMap[recipient]! <= self.skuMap[skuName]!.maxPerBuyer : "Max pack purchase count per buyer exceeded"
297            paymentVaultRef.balance == before(paymentVaultRef.balance) - self.skuMap[skuName]!.price : "Decrease in buyer vault balance doesn't match the price"
298        }
299
300        let sku = self.skuMap[skuName]!
301
302        let recipientStr = self.convertAddressToString(address: recipient)
303
304        let message = skuName.concat(recipientStr).concat(nonce.toString()).concat(packType.toString());
305        let isValid = self.isValidSignature(signature: signature, message: message)
306        if isValid == false {
307            panic("Signature isn't valid");
308        }
309
310        // Withdraw payment from the vault ref
311        let payment <- paymentVaultRef.withdraw(amount: sku.price) // Will panic if not enough $
312        let vault <- payment as! @FlowToken.Vault // Will panic if can't be cast
313
314        // Get recipient vault and deposit payment
315        let payoutRecipient = getAccount(sku.payoutAddress)
316        let payoutReceiver = payoutRecipient.getCapability(/public/flowTokenReceiver)
317                            .borrow<&FlowToken.Vault{FungibleToken.Receiver}>()
318                            ?? panic("Could not borrow a reference to the payout receiver")
319        payoutReceiver.deposit(from: <-vault)
320
321        // Check nonce for account isn't reused, and increment it
322        if self.nonceMap.containsKey(recipient) {
323            let oldNonce: UInt64 = self.nonceMap[recipient]!
324            let baseMessage = "Nonce ".concat(nonce.toString()).concat(" for ").concat(recipient.toString())
325            if oldNonce >= UInt64(nonce) {
326                panic(baseMessage.concat(" already used"));
327            }
328            if (oldNonce + 1 as UInt64) < UInt64(nonce) {
329                panic(baseMessage.concat(" is not next nonce"));
330            }
331            self.nonceMap[recipient] =  oldNonce + UInt64(1)
332        } else {
333            self.nonceMap[recipient] = UInt64(1)
334        }
335
336        // Use first byte of message as index to select from supply.
337        var index = UInt64(signature.decodeHex()[0]!)
338        
339        // Ensure the index falls within the serial list
340        index = index % UInt64(sku.serialList.length)
341
342        // **Remove** the selected packNumber from the packNumber list.
343        // By removing the item, we ensure that even if same index is selected again in next tx, it will refer to another item.
344        let packNumber = sku.serialList.remove(at: index);
345
346        // Mint a pack
347        let nft <- MotoGPPack.createPack(packNumber: packNumber, packType: packType);
348
349        // Update recipient's buy-count
350       
351        if sku.buyerCountMap.containsKey(recipient) {
352            let oldCount = sku.buyerCountMap[recipient]!
353            sku.buyerCountMap[recipient] = UInt64(oldCount) + UInt64(1)
354            self.skuMap[skuName] = sku
355        } else {
356            sku.buyerCountMap[recipient] = UInt64(1)
357            self.skuMap[skuName] = sku
358        }
359
360        // Deposit the purchased pack into a temporary collection, to be able to topup the buyer's Flow/storage using the MotoGPTransfer contract
361        let tempCollection <- MotoGPPack.createEmptyCollection()
362        tempCollection.deposit(token: <- nft); 
363
364        // Transfer the pack using the MotoGPTransfer contract, to do Flow/storage topup for recipient
365        MotoGPTransfer.transferPacks(fromCollection: <- tempCollection, toCollection: recipientCollectionRef, toAddress: recipient);    
366    }
367
368    // Emergency-helper method to reset a serial in the map
369
370    pub fun setSerialStatusInPackTypeMap(adminRef: &Admin, packType: UInt64, serial: UInt64, value: Bool) {
371        pre {
372            adminRef != nil : "adminRef is nil"
373        }
374        if SalesContract.serialMap.containsKey(packType) {
375            SalesContract.serialMap[packType]!.insert(key: serial, value)
376        }
377    }
378
379    // Allow Admin to add a new SKU 
380
381    pub fun addSKU(adminRef: &Admin, startTime: UInt64, endTime: UInt64, name: String, payoutAddress: Address, packType: UInt64) {
382        pre {
383            adminRef != nil : "adminRef is nil"
384        }
385        let sku = SKU(startTime: startTime, endTime: endTime, payoutAddress: payoutAddress, packType: packType);
386        self.skuMap.insert(key: name, sku)
387    }
388
389    // Add lists of serials to a SKU
390
391    pub fun increaseSupplyForSKU(adminRef: &Admin, name: String, supplyList: [UInt64]) {
392        pre {
393            adminRef != nil : "adminRef is nil"
394        }
395        let sku = self.skuMap[name]!
396        sku.increaseSupply(supplyList: supplyList)
397        self.skuMap[name] = sku
398    }
399
400    pub fun setMaxPerBuyerForSKU(adminRef: &Admin, name: String, maxPerBuyer: UInt64) {
401        pre {
402            adminRef != nil : "adminRef is nil"
403        }
404        let sku = self.skuMap[name]!
405        sku.setMaxPerBuyer(maxPerBuyer: maxPerBuyer)
406        self.skuMap[name] = sku
407    }
408
409    pub fun setPriceForSKU(adminRef: &Admin, name: String, price: UFix64) {
410        pre {
411            adminRef != nil : "adminRef is nil"
412        }
413        let sku = self.skuMap[name]!
414        sku.setPrice(price: price)
415        self.skuMap[name] = sku
416    }
417
418    pub fun setEndTimeForSKU(adminRef: &Admin, name: String, endTime: UInt64) {
419        pre {
420            adminRef != nil : "adminRef is nil"
421        }
422        let sku = self.skuMap[name]!
423        sku.setEndTime(endTime: endTime)
424        self.skuMap[name] = sku
425    }
426
427    pub fun setStartTimeForSKU(adminRef: &Admin, name: String, startTime: UInt64) {
428        pre {
429            adminRef != nil : "adminRef is nil"
430        }
431        let sku = self.skuMap[name]!
432        sku.setStartTime(startTime: startTime)
433        self.skuMap[name] = sku
434    }
435
436    pub fun setPayoutAddressForSKU(adminRef: &Admin, name: String, payoutAddress: Address) {
437        pre {
438            adminRef != nil : "adminRef is nil"
439        }
440        let sku = self.skuMap[name]!
441        sku.setPayoutAddress(payoutAddress: payoutAddress)
442        self.skuMap[name] = sku
443    }
444
445    pub fun getSKU(name: String): SKU {
446        return self.skuMap[name]!
447    }
448
449    pub fun getVerificationKey(): String {
450        return self.verificationKey
451    }
452
453    init(){
454        self.adminStoragePath = /storage/salesContractAdmin
455        self.verificationKey = ""
456        // Crete Admin resource
457        self.account.save(<- create Admin(), to: self.adminStoragePath)
458        self.skuMap = {}
459        self.nonceMap = {}
460        self.serialMap = {}
461    }
462}