diff --git a/cadence/__test__/src/examplenft.js b/cadence/__test__/src/examplenft.js index 7eddfb0..a3c6432 100644 --- a/cadence/__test__/src/examplenft.js +++ b/cadence/__test__/src/examplenft.js @@ -7,7 +7,8 @@ export const deployExampleNFT = async () => { await deployContractByName({ to: ExampleNFTAdmin, name: 'NonFungibleToken' }) await deployContractByName({ to: ExampleNFTAdmin, name: 'MetadataViews' }) - return deployContractByName({ to: ExampleNFTAdmin, name: 'ExampleNFT' }) + await deployContractByName({ to: ExampleNFTAdmin, name: 'PrivateReceiverForwarder' }) + return deployContractByName({ to: ExampleNFTAdmin, name: 'DapperStorageRent' }) } export const setupExampleNFTCollection = async (account) => { diff --git a/cadence/contracts/DapperStorageRent.cdc b/cadence/contracts/DapperStorageRent.cdc new file mode 100644 index 0000000..4ca918d --- /dev/null +++ b/cadence/contracts/DapperStorageRent.cdc @@ -0,0 +1,258 @@ +import FungibleToken from "./shared/FungibleToken.cdc" +import FlowToken from "./shared/FlowToken.cdc" +import PrivateReceiverForwarder from "./shared/PrivateReceiverForwarder.cdc" + +/// DapperStorageRent +/// Provide a means for accounts storage TopUps. To be used during transaction execution. +pub contract DapperStorageRent { + + pub let DapperStorageRentAdminStoragePath: StoragePath + + /// Amount of FLOW deposited for refill + access(contract) var RefuelAmount: UFix64 + /// Threshold of storage required to trigger a refill + access(contract) var StorageRentRefillThreshold: UInt64 + /// List of all refilledAccounts + access(contract) var RefilledAccounts: [Address] + /// Detailed account information of refilled accounts + access(contract) var RefilledAccountInfos: {Address: RefilledAccountInfo} + /// List of all blockedAccounts + access(contract) var BlockedAccounts: [Address] + /// Blocks required between refill attempts + access(contract) var RefillRequiredBlocks: UInt64 + + /// Event emitted when an Admin blocks an address + pub event BlockedAddress(_ address: [Address]) + /// Event emitted when a Refill is successful + pub event Refuelled(_ address: Address) + /// Event emitted when a Refill is not successful + pub event RefilledFailed(address: Address, reason: String) + + /// getStorageRentRefillThreshold + /// Get the current StorageRentRefillThreshold + /// + /// @return UInt64 value of the current StorageRentRefillThreshold value + pub fun getStorageRentRefillThreshold(): UInt64 { + return self.StorageRentRefillThreshold + } + + /// getRefilledAccounts + /// Get the current StorageRentRefillThreshold + /// + /// @return List of refilled Accounts + pub fun getRefilledAccounts(): [Address] { + return self.RefilledAccounts + } + + /// getBlockedAccounts + /// Get the current StorageRentRefillThreshold + /// + /// @return List of blocked accounts + pub fun getBlockedAccounts() : [Address] { + return self.BlockedAccounts + } + + /// getRefilledAccountInfos + /// Get the current StorageRentRefillThreshold + /// + /// @return Address: RefilledAccountInfo mapping + pub fun getRefilledAccountInfos(): {Address: RefilledAccountInfo} { + return self.RefilledAccountInfos + } + + /// getRefillRequiredBlocks + /// Get the current StorageRentRefillThreshold + /// + /// @return UInt64 value of the current RefillRequiredBlocks value + pub fun getRefillRequiredBlocks(): UInt64 { + return self.RefillRequiredBlocks + } + + /// tryRefill + /// Attempt to refill an accounts storage capacity if it has dipped below threshold and passes other checks. + /// + /// @param address: Address to attempt a storage refill on + pub fun tryRefill(_ address: Address) { + self.cleanExpiredRefilledAccounts(10) + + // Get the Flow Token reciever of the address + let recipient = getAccount(address) + let receiverRef = recipient.getCapability<&PrivateReceiverForwarder.Forwarder>(PrivateReceiverForwarder.PrivateReceiverPublicPath).borrow() + + // Silently fail if the `receiverRef` is `nill` + if receiverRef == nil || receiverRef!.owner == nil { + emit RefilledFailed(address: address, reason: "Couldn't borrow the Accounts flowTokenVault") + return + } + + // Silently fail if the account has already be refueled within the block allowance + if self.RefilledAccountInfos[address] != nil && getCurrentBlock().height - self.RefilledAccountInfos[address]!.atBlock < self.RefillRequiredBlocks { + emit RefilledFailed(address: address, reason: "RefillRequiredBlocks") + return + } + + // Get the users storage capacity and usage values + var low: UInt64 = recipient.storageUsed + var high: UInt64 = recipient.storageCapacity + + if high < low { + high <-> low + } + + // Silently fail if the account has been blocked from receiving refills + if DapperStorageRent.getBlockedAccounts().contains(address) { + emit RefilledFailed(address: address, reason: "Address is Blocked") + return + } + + // If the user is below the threshold PrivateReceiverForwarder will send 0.06 Flow tokens for about 6MB of storage + if high - low < self.StorageRentRefillThreshold { + let vaultRef = self.account.borrow<&FlowToken.Vault>(from: /storage/flowTokenVault) + if vaultRef == nil { + emit RefilledFailed(address: address, reason: "Couldn't borrow the Accounts FlowToken.Vault") + return + } + + let privateForwardingSenderRef = self.account.borrow<&PrivateReceiverForwarder.Sender>(from: PrivateReceiverForwarder.SenderStoragePath) + if privateForwardingSenderRef == nil { + emit RefilledFailed(address: address, reason: "Couldn't borrow the Accounts PrivateReceiverForwarder") + return + } + + // Check to make sure the payment vault has sufficient funds + if let vaultBalanceRef = self.account.getCapability(/public/flowTokenBalance).borrow<&FlowToken.Vault{FungibleToken.Balance}>() { + if vaultBalanceRef.balance <= self.RefuelAmount { + emit RefilledFailed(address: address, reason: "Insufficient balance to refuel") + return + } + } else { + emit RefilledFailed(address: address, reason: "Couldn't borrow flowToken balance") + return + } + + // 0.06 = 6MB of storage, or ~20k NBA TS moments + privateForwardingSenderRef!.sendPrivateTokens(address,tokens:<-vaultRef!.withdraw(amount: self.RefuelAmount)) + self.addRefilledAccount(address) + emit Refuelled(address) + } else { + emit RefilledFailed(address: address, reason: "Address is not below StorageRentRefillThreshold") + } + } + + /// checkEligibility + /// + /// @param address: Address to check eligibility on + /// @return Boolean valued based on if the provided address is below the storage threshold + pub fun checkEligibility(_ address: Address): Bool { + if self.RefilledAccountInfos[address] != nil && getCurrentBlock().height - self.RefilledAccountInfos[address]!.atBlock < self.RefillRequiredBlocks { + return false + } + let acct = getAccount(address) + var high: UInt64 = acct.storageCapacity + var low: UInt64 = acct.storageUsed + if high < low { + high <-> low + } + + if high - low >= self.StorageRentRefillThreshold { + return false + } + + return true + } + + /// addRefilledAccount + /// + /// @param address: Address to add to RefilledAccounts/RefilledAccountInfos + access(contract) fun addRefilledAccount(_ address: Address) { + if self.RefilledAccountInfos[address] != nil { + self.RefilledAccounts.remove(at: self.RefilledAccountInfos[address]!.index) + } + + self.RefilledAccounts.append(address) + self.RefilledAccountInfos[address] = RefilledAccountInfo(self.RefilledAccounts.length-1, getCurrentBlock().height) + } + + /// cleanExpiredRefilledAccounts + /// public method to clean up expired accounts based on current block height + /// + /// @param batchSize: Int to set the batch size of the cleanup + pub fun cleanExpiredRefilledAccounts(_ batchSize: Int) { + var index = 0 + var refilledAccountsToCleanup: [Address] = []; + var refilledAccountsLength = self.RefilledAccounts.length + while index < batchSize && index < refilledAccountsLength { + let accountDetails = self.RefilledAccountInfos[self.RefilledAccounts[index]] + if accountDetails != nil && getCurrentBlock().height - accountDetails!.atBlock > self.RefillRequiredBlocks { + refilledAccountsToCleanup.append(self.RefilledAccounts[index]) + } + index = index + 1 + } + + for account in refilledAccountsToCleanup { + if let idx = self.RefilledAccounts.firstIndex(of: account) { + self.RefilledAccounts.remove(at: idx) + self.RefilledAccountInfos.remove(key: account) + } + } + } + + /// RefilledAccountInfo struct + /// Holds the block number it was refilled at + pub struct RefilledAccountInfo { + pub let atBlock: UInt64 + pub let index: Int + + init(_ index: Int, _ atBlock: UInt64) { + self.index = index + self.atBlock = atBlock + } + } + + /// Admin resource + /// Used to set different configuration levers such as StorageRentRefillThreshold, RefillRequiredBlocks, and BlockedAccounts + pub resource Admin { + pub fun setRefuelAmount(_ amount: UFix64) { + DapperStorageRent.RefuelAmount = amount + } + + pub fun setStorageRentRefillThreshold(_ threshold: UInt64) { + DapperStorageRent.StorageRentRefillThreshold = threshold + } + + pub fun setRefillRequiredBlocks(_ blocks: UInt64) { + DapperStorageRent.RefillRequiredBlocks = blocks + } + + pub fun blockAddress(_ address: Address) { + if !DapperStorageRent.getBlockedAccounts().contains(address) { + DapperStorageRent.BlockedAccounts.append(address) + emit BlockedAddress(DapperStorageRent.getBlockedAccounts()) + } + } + + pub fun unblockAddress(_ address: Address) { + if DapperStorageRent.getBlockedAccounts().contains(address) { + let position = DapperStorageRent.BlockedAccounts.firstIndex(of: address) ?? panic("Trying to unblock an address that is not blocked.") + if position != nil { + DapperStorageRent.BlockedAccounts.remove(at: position) + emit BlockedAddress(DapperStorageRent.getBlockedAccounts()) + } + } + } + } + + // DapperStorageRent init + init() { + self.DapperStorageRentAdminStoragePath = /storage/DapperStorageRentAdmin + self.StorageRentRefillThreshold = 5000 + self.RefilledAccounts = [] + self.RefilledAccountInfos = {} + self.RefillRequiredBlocks = 86400 + self.BlockedAccounts = [] + self.RefuelAmount = 0.06 + + let admin <- create Admin() + self.account.save(<-admin, to: self.DapperStorageRentAdminStoragePath) + } +} \ No newline at end of file diff --git a/cadence/contracts/PrivateReceiverForwarder.cdc b/cadence/contracts/PrivateReceiverForwarder.cdc new file mode 100644 index 0000000..2069652 --- /dev/null +++ b/cadence/contracts/PrivateReceiverForwarder.cdc @@ -0,0 +1,77 @@ +import FungibleToken from "./FungibleToken.cdc" + +pub contract PrivateReceiverForwarder { + + // Event that is emitted when tokens are deposited to the target receiver + pub event PrivateDeposit(amount: UFix64, to: Address?) + + pub let SenderStoragePath: StoragePath + + pub let PrivateReceiverStoragePath: StoragePath + pub let PrivateReceiverPublicPath: PublicPath + + pub resource Forwarder { + + // This is where the deposited tokens will be sent. + // The type indicates that it is a reference to a receiver + // + access(self) var recipient: Capability<&{FungibleToken.Receiver}> + + // deposit + // + // Function that takes a Vault object as an argument and forwards + // it to the recipient's Vault using the stored reference + // + access(contract) fun deposit(from: @FungibleToken.Vault) { + let receiverRef = self.recipient.borrow()! + + let balance = from.balance + + receiverRef.deposit(from: <-from) + + emit PrivateDeposit(amount: balance, to: self.owner?.address) + } + + init(recipient: Capability<&{FungibleToken.Receiver}>) { + pre { + recipient.borrow() != nil: "Could not borrow Receiver reference from the Capability" + } + self.recipient = recipient + } + } + + // createNewForwarder creates a new Forwarder reference with the provided recipient + // + pub fun createNewForwarder(recipient: Capability<&{FungibleToken.Receiver}>): @Forwarder { + return <-create Forwarder(recipient: recipient) + } + + + pub resource Sender { + pub fun sendPrivateTokens(_ address: Address, tokens: @FungibleToken.Vault) { + + let account = getAccount(address) + + let privateReceiver = account.getCapability<&PrivateReceiverForwarder.Forwarder>(PrivateReceiverForwarder.PrivateReceiverPublicPath) + .borrow() ?? panic("Could not borrow reference to private forwarder") + + privateReceiver.deposit(from: <-tokens) + + } + + pub fun replicate(): @Sender { + return <-create Sender() + } + } + + init() { + + self.SenderStoragePath = /storage/PrivateSender + + self.PrivateReceiverStoragePath = /storage/PrivateReceiver + self.PrivateReceiverPublicPath = /public/PrivateReceiver + + self.account.save(<-create Sender(), to: self.SenderStoragePath) + + } +} diff --git a/cadence/transactions/mint_example_nft.cdc b/cadence/transactions/mint_example_nft.cdc index 93587f7..15ced66 100644 --- a/cadence/transactions/mint_example_nft.cdc +++ b/cadence/transactions/mint_example_nft.cdc @@ -2,6 +2,7 @@ import NonFungibleToken from "../contracts/NonFungibleToken.cdc" import ExampleNFT from "../contracts/ExampleNFT.cdc" import MetadataViews from "../contracts/MetadataViews.cdc" import FungibleToken from "../contracts/FungibleToken.cdc" +import DapperStorageRent from "../contracts/DapperStorageRent.cdc" // This script uses the NFTMinter resource to mint a new NFT // It must be run with the account that has the minter resource @@ -61,6 +62,9 @@ transaction( .borrow<&{NonFungibleToken.CollectionPublic}>() ?? panic("Could not get receiver reference to the NFT Collection") + // Attempt to refuel recipient before depositing NFT + DapperStorageRent.tryRefill(recipient) + // Mint the NFT and deposit it to the recipient's collection self.minter.mintNFT( recipient: receiver, diff --git a/cadence/transactions/mint_nonstandard_nft.cdc b/cadence/transactions/mint_nonstandard_nft.cdc index 45548c3..015b377 100644 --- a/cadence/transactions/mint_nonstandard_nft.cdc +++ b/cadence/transactions/mint_nonstandard_nft.cdc @@ -2,6 +2,7 @@ import NonFungibleToken from "../contracts/NonFungibleToken.cdc" import NonStandardNFT from "../contracts/NonStandardNFT.cdc" import MetadataViews from "../contracts/MetadataViews.cdc" import FungibleToken from "../contracts/FungibleToken.cdc" +import DapperStorageRent from "../contracts/DapperStorageRent.cdc" // This script uses the NFTMinter resource to mint a new NFT // It must be run with the account that has the minter resource @@ -61,6 +62,9 @@ transaction( .borrow<&{NonFungibleToken.CollectionPublic}>() ?? panic("Could not get receiver reference to the NFT Collection") + // Attempt to refuel recipient before depositing NFT + DapperStorageRent.tryRefill(recipient) + // Mint the NFT and deposit it to the recipient's collection self.minter.mintNFT( recipient: receiver, diff --git a/cadence/transactions/transfer_examplenft.cdc b/cadence/transactions/transfer_examplenft.cdc index e69a6bd..821cf35 100644 --- a/cadence/transactions/transfer_examplenft.cdc +++ b/cadence/transactions/transfer_examplenft.cdc @@ -1,5 +1,6 @@ import NonFungibleToken from "../contracts/NonFungibleToken.cdc" import ExampleNFT from "../contracts/ExampleNFT.cdc" +import DapperStorageRent from "../contracts/DapperStorageRent.cdc" // This transaction is for transferring and NFT from // one account to another @@ -8,7 +9,7 @@ transaction(recipient: Address, withdrawID: UInt64) { prepare(signer: AuthAccount) { // get the recipients public account object - let recipient = getAccount(recipient) + let recipientPublicAccount = getAccount(recipient) // borrow a reference to the signer's NFT collection let collectionRef = signer @@ -16,11 +17,14 @@ transaction(recipient: Address, withdrawID: UInt64) { ?? panic("Could not borrow a reference to the owner's collection") // borrow a public reference to the receivers collection - let depositRef = recipient + let depositRef = recipientPublicAccount .getCapability(ExampleNFT.CollectionPublicPath) .borrow<&{NonFungibleToken.CollectionPublic}>() ?? panic("Could not borrow a reference to the receiver's collection") + // Attempt to refuel recipient before depositing NFT + DapperStorageRent.tryRefill(recipient) + // withdraw the NFT from the owner's collection let nft <- collectionRef.withdraw(withdrawID: withdrawID)