Smart Contract
SalesContract
A.a49cc0ee46c54bfb.SalesContract
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}