diff --git a/escrow/contracts/Escrow.cdc b/escrow/contracts/Escrow.cdc index 1005241..b94a8d0 100644 --- a/escrow/contracts/Escrow.cdc +++ b/escrow/contracts/Escrow.cdc @@ -8,6 +8,7 @@ */ import NonFungibleToken from "NonFungibleToken" +import NFTLocker from "NFTLocker" access(all) contract Escrow { // Event emitted when a new leaderboard is created. @@ -231,6 +232,22 @@ access(all) contract Escrow { } } + // Handler for depositing NFTs to the Escrow Collection, used by the NFTLocker contract. + access(all) struct DepositHandler: NFTLocker.IAuthorizedDepositHandler { + access(all) fun deposit(nft: @{NonFungibleToken.NFT}, ownerAddress: Address, passThruParams: {String: AnyStruct}) { + // Get leaderboard name from pass-thru parameters + let leaderboardName = passThruParams["leaderboardName"] as! String? + ?? panic("Missing or invalid 'leaderboardName' entry in pass-thru parameters map") + + // Get the Escrow Collection public reference + let escrowCollectionPublic = Escrow.account.capabilities.borrow<&Escrow.Collection>(Escrow.CollectionPublicPath) + ?? panic("Could not borrow a reference to the public leaderboard collection") + + // Add the NFT to the escrow leaderboard + escrowCollectionPublic.addEntryToLeaderboard(nft: <-nft, leaderboardName: leaderboardName, ownerAddress: ownerAddress) + } + } + // Escrow contract initializer. init() { // Initialize paths. diff --git a/escrow/lib/go/test/templates.go b/escrow/lib/go/test/templates.go index 69763fc..145e5c8 100644 --- a/escrow/lib/go/test/templates.go +++ b/escrow/lib/go/test/templates.go @@ -16,12 +16,14 @@ const ( AllDayAddressPlaceholder = "\"AllDay\"" royaltyAddressPlaceholder = "0xALLDAYROYALTYADDRESS" escrowAddressPlaceholder = "\"Escrow\"" + nftLockerAddressPlaceholder = "\"NFTLocker\"" escrowAddressPlaceholderBis = "0xf8d6e0586b0a20c7" ) const ( EscrowPath = "../../../contracts/Escrow.cdc" AllDayPath = "../../../contracts/AllDay.cdc" + NFTLockerPath = "../../../../locked-nft/contracts/NFTLocker.cdc" EscrowTransactionsRootPath = "../../../transactions" EscrowScriptsRootPath = "../../../scripts" @@ -111,12 +113,24 @@ func LoadAllDay(nftAddress, metaAddress, viewResolverAddress, royaltyAddress flo return code } -func LoadEscrow(nftAddress flow.Address) []byte { +func LoadEscrow(nftAddress, nftLockerAddress flow.Address) []byte { code := readFile(EscrowPath) nftRe := regexp.MustCompile(nftAddressPlaceholder) code = nftRe.ReplaceAll(code, []byte("0x"+nftAddress.String())) + nftLockerRe := regexp.MustCompile(nftLockerAddressPlaceholder) + code = nftLockerRe.ReplaceAll(code, []byte("0x"+nftLockerAddress.String())) + + return code +} + +func LoadNFTLockerContract(nftAddress flow.Address) []byte { + code := readFile(NFTLockerPath) + + nftRe := regexp.MustCompile(nftAddressPlaceholder) + code = nftRe.ReplaceAll(code, []byte("0x"+nftAddress.String())) + return code } diff --git a/escrow/lib/go/test/test.go b/escrow/lib/go/test/test.go index 76da9f0..1afcaf5 100644 --- a/escrow/lib/go/test/test.go +++ b/escrow/lib/go/test/test.go @@ -115,9 +115,35 @@ func EscrowContracts(t *testing.T, b *emulator.Blockchain) Contracts { _, err = b.CommitBlock() require.NoError(t, err) - EscrowCode := LoadEscrow(nftAddress) + NFTLockerCode := LoadNFTLockerContract(nftAddress) - tx1 = sdktemplates.AddAccountContract( + tx2 := sdktemplates.AddAccountContract( + AllDayAddress, + sdktemplates.Contract{ + Name: "NFTLocker", + Source: string(NFTLockerCode), + }, + ) + + tx2. + SetComputeLimit(100). + SetProposalKey(b.ServiceKey().Address, b.ServiceKey().Index, b.ServiceKey().SequenceNumber). + SetPayer(b.ServiceKey().Address) + + require.NoError(t, err) + signAndSubmit( + t, b, tx2, + []flow.Address{b.ServiceKey().Address, AllDayAddress}, + []crypto.Signer{signer, AllDaySigner}, + false, + ) + + _, err = b.CommitBlock() + require.NoError(t, err) + + EscrowCode := LoadEscrow(nftAddress, AllDayAddress) + + tx3 := sdktemplates.AddAccountContract( AllDayAddress, sdktemplates.Contract{ Name: "Escrow", @@ -125,7 +151,7 @@ func EscrowContracts(t *testing.T, b *emulator.Blockchain) Contracts { }, ) - tx1. + tx3. SetComputeLimit(100). SetProposalKey(b.ServiceKey().Address, b.ServiceKey().Index, b.ServiceKey().SequenceNumber). SetPayer(b.ServiceKey().Address) @@ -133,7 +159,7 @@ func EscrowContracts(t *testing.T, b *emulator.Blockchain) Contracts { signer, err = b.ServiceKey().Signer() require.NoError(t, err) signAndSubmit( - t, b, tx1, + t, b, tx3, []flow.Address{b.ServiceKey().Address, AllDayAddress}, []crypto.Signer{signer, AllDaySigner}, false, diff --git a/locked-nft/contracts/NFTLocker.cdc b/locked-nft/contracts/NFTLocker.cdc index d826b21..6e2d335 100644 --- a/locked-nft/contracts/NFTLocker.cdc +++ b/locked-nft/contracts/NFTLocker.cdc @@ -22,8 +22,12 @@ access(all) contract NFTLocker { access(all) event NFTUnlocked( id: UInt64, from: Address?, - nftType: Type + nftType: Type, + receiverName: String?, // receiver name if unlocked with authorized deposit, nil otherwise + lockedUntilBeforeEarlyUnlock: UInt64? // lockedUntil if unlocked with authorized deposit, nil otherwise ) + access(all) event ReceiverAdded(name: String, eligibleNFTTypes: {Type: Bool}) + access(all) event ReceiverRemoved(name: String, eligibleNFTTypes: {Type: Bool}) /// Named Paths /// @@ -38,6 +42,10 @@ access(all) contract NFTLocker { /// access(self) let lockedTokens: {Type: {UInt64: LockedData}} + /// Entitlement that grants the ability to operate authorized functions + /// + access(all) entitlement Operate + /// Data describing characteristics of the locked NFT /// access(all) struct LockedData { @@ -49,7 +57,7 @@ access(all) contract NFTLocker { access(all) let nftType: Type access(all) let extension: {String: AnyStruct} - init (id: UInt64, owner: Address, duration: UInt64, nftType: Type) { + view init (id: UInt64, owner: Address, duration: UInt64, nftType: Type) { if let lockedToken = (NFTLocker.lockedTokens[nftType]!)[id] { self.id = id self.owner = lockedToken.owner @@ -89,27 +97,186 @@ access(all) contract NFTLocker { return false } - /// The path to the NFTLocker Admin resource belonging to the Account - /// which the contract is deployed on + /// The path to the Admin resource belonging to the account where this contract is deployed + /// access(all) view fun GetAdminStoragePath(): StoragePath { return /storage/NFTLockerAdmin } + /// The path to the ReceiverCollector resource belonging to the account where this contract is deployed + /// + access(all) view fun getReceiverCollectorStoragePath(): StoragePath { + return /storage/NFTLockerAdminReceiverCollector + } + + /// Return an unauthorized reference to the admin's ReceiverCollector resource if it exists + /// + access(all) view fun borrowAdminReceiverCollectorPublic(): &ReceiverCollector? { + return self.account.storage.borrow<&ReceiverCollector>(from: NFTLocker.getReceiverCollectorStoragePath()) + } + + /// Interface for depositing NFTs to authorized receivers + /// + access(all) struct interface IAuthorizedDepositHandler { + access(all) fun deposit(nft: @{NonFungibleToken.NFT}, ownerAddress: Address, passThruParams: {String: AnyStruct}) + } + + /// Struct that defines a Receiver + /// + /// Receivers are entities that can receive locked NFTs and deposit them using a specific deposit method + /// + access(all) struct Receiver { + /// Handler for depositing NFTs for the receiver + /// + access(all) var authorizedDepositHandler: {IAuthorizedDepositHandler} + + /// The eligible NFT types for the receiver + /// + access(all) let eligibleNFTTypes: {Type: Bool} + + /// Extension map for additional data + /// + access(all) let metadata: {String: AnyStruct} + + /// Initialize Receiver struct + /// + view init( + authorizedDepositHandler: {IAuthorizedDepositHandler}, + eligibleNFTTypes: {Type: Bool} + ) { + self.authorizedDepositHandler = authorizedDepositHandler + self.eligibleNFTTypes = eligibleNFTTypes + self.metadata = {} + } + } + + /// ReceiverCollector resource + /// + /// Note: This resource is used to store receivers and corresponding authorized deposit handlers; currently, + /// only the admin account can add or remove receivers - in the future, a ReceiverProvider resource could + /// be added to provide this capability to separate authorized accounts. + /// + access(all) resource ReceiverCollector { + /// Map of receivers by name + /// + access(self) let receiversByName: {String: Receiver} + + /// Map of receiver names by NFT type for lookup + /// + access(self) let receiverNamesByNFTType: {Type: {String: Bool}} + + /// Extension map for additional data + /// + access(self) let metadata: {String: AnyStruct} + + /// Add a deposit handler for given NFT types + /// + access(Operate) fun addReceiver( + name: String, + authorizedDepositHandler: {IAuthorizedDepositHandler}, + eligibleNFTTypes: {Type: Bool} + ) { + pre { + !self.receiversByName.containsKey(name): "Receiver with the same name already exists" + } + + // Add the receiver + self.receiversByName[name] = Receiver( + authorizedDepositHandler: authorizedDepositHandler, + eligibleNFTTypes: eligibleNFTTypes + ) + + // Add the receiver to the lookup map + for nftType in eligibleNFTTypes.keys { + if let namesMap = self.receiverNamesByNFTType[nftType] { + namesMap[name] = true + self.receiverNamesByNFTType[nftType] = namesMap + } else { + self.receiverNamesByNFTType[nftType] = {name: true} + } + } + + // Emit event + emit ReceiverAdded(name: name, eligibleNFTTypes: eligibleNFTTypes) + } + + /// Remove a deposit method for a given NFT type + /// + access(Operate) fun removeReceiver(name: String) { + // Get the receiver + let receiver = self.receiversByName[name] + ?? panic("Receiver with the given name does not exist") + + // Remove the receiver from the lookup map + for nftType in receiver.eligibleNFTTypes.keys { + if self.receiverNamesByNFTType.containsKey(nftType) { + self.receiverNamesByNFTType[nftType]!.remove(key: name) + } + } + + // Remove the receiver + self.receiversByName.remove(key: name) + + // Emit event + emit ReceiverRemoved(name: name, eligibleNFTTypes: receiver.eligibleNFTTypes) + } + + /// Get the receiver for the given name if it exists + /// + access(all) view fun getReceiver(name: String): Receiver? { + return self.receiversByName[name] + } + + /// Get the receiver names for the given NFT type if it exists + /// + access(all) view fun getReceiverNamesByNFTType(nftType: Type): {String: Bool}? { + return self.receiverNamesByNFTType[nftType] + } + + /// Initialize ReceiverCollector struct + /// + view init() { + self.receiversByName = {} + self.receiverNamesByNFTType = {} + self.metadata = {} + } + } + /// Admin resource + /// access(all) resource Admin { + /// Expire lock + /// access(all) fun expireLock(id: UInt64, nftType: Type) { - if let locker = &NFTLocker.lockedTokens[nftType] as auth(Mutate) &{UInt64: NFTLocker.LockedData}?{ - if locker[id] != nil { - // remove old locked data and insert new one with duration 0 - if let oldLockedData = locker.remove(key: id){ - let lockedData = NFTLocker.LockedData( + NFTLocker.expireLock(id: id, nftType: nftType) + } + + /// Create and return a ReceiverCollector resource + /// + access(all) fun createReceiverCollector(): @ReceiverCollector { + return <- create ReceiverCollector() + } + } + + /// Expire lock + /// + /// This can be called either by the admin or by the user unlockWithAuthorizedDeposit, if the locked NFT + /// type is eligible. + /// + access(contract) fun expireLock(id: UInt64, nftType: Type) { + if let locker = &NFTLocker.lockedTokens[nftType] as auth(Mutate) &{UInt64: NFTLocker.LockedData}?{ + if locker[id] != nil { + // Update locked data's duration to 0 + if let oldLockedData = locker.remove(key: id){ + locker.insert( + key: id, + LockedData( id: id, owner: oldLockedData.owner, duration: 0, nftType: nftType ) - locker.insert(key: id, lockedData) - } + ) } } } @@ -123,78 +290,151 @@ access(all) contract NFTLocker { access(all) view fun getIDs(nftType: Type): [UInt64]? access(Operate) fun lock(token: @{NonFungibleToken.NFT}, duration: UInt64) access(Operate) fun unlock(id: UInt64, nftType: Type): @{NonFungibleToken.NFT} + access(Operate) fun unlockWithAuthorizedDeposit( + id: UInt64, + nftType: Type, + receiverName: String, + passThruParams: {String: AnyStruct} + ) } /// Deprecated in favor of Operate entitlement /// access(all) resource interface LockProvider: LockedCollection {} - /// Entitlement that grants the ability to operate the NFTLocker Collection - access(all) entitlement Operate - /// An NFT Collection /// access(all) resource Collection: LockedCollection, LockProvider { + /// This collection's locked NFTs + /// access(all) var lockedNFTs: @{Type: {UInt64: {NonFungibleToken.NFT}}} /// Unlock an NFT of a given type /// access(Operate) fun unlock(id: UInt64, nftType: Type): @{NonFungibleToken.NFT} { pre { - NFTLocker.canUnlockToken( - id: id, - nftType: nftType - ) == true : "locked duration has not been met" + NFTLocker.canUnlockToken(id: id, nftType: nftType): "locked duration has not been met" } - let token <- self.lockedNFTs[nftType]?.remove(key: id)!! + return <- self.withdrawFromLockedNFTs(id: id, nftType: nftType, receiverName: nil, lockedUntilBeforeEarlyUnlock: nil) + } + + /// Force unlock the NFT with the given id and type, and deposit it using the receiver's deposit method; + /// additional function parameters may be required by the receiver's deposit method and are passed in the + /// passThruParams map. + /// + access(Operate) fun unlockWithAuthorizedDeposit( + id: UInt64, + nftType: Type, + receiverName: String, + passThruParams: {String: AnyStruct} + ) { + pre { + !NFTLocker.canUnlockToken(id: id, nftType: nftType): "locked duration has been met, use unlock() instead" + } + + // Get the locked token details, panic if it doesn't exist + let lockedTokenDetails = NFTLocker.getNFTLockerDetails(id: id, nftType: nftType) + ?? panic("No locked token found for the given id and NFT type") + + // Get a public reference to the admin's receiver collector, panic if it doesn't exist + let receiverCollector = NFTLocker.borrowAdminReceiverCollectorPublic() + ?? panic("No receiver collector found") + // Get the receiver names for the given NFT type, panic if there is no record + let nftTypeReceivers = receiverCollector.getReceiverNamesByNFTType(nftType: nftType) + ?? panic("No authorized receiver for the given NFT type") + + // Verify that the receiver with the given name is authorized + assert( + nftTypeReceivers[receiverName] == true, + message: "Provided receiver does not exist or is not authorized for the given NFT type" + ) + + // Expire the NFT's lock + NFTLocker.expireLock(id: id, nftType: nftType) + + // Unlock and deposit the NFT using the receiver's deposit method + receiverCollector.getReceiver(name: receiverName)!.authorizedDepositHandler.deposit( + nft: <- self.withdrawFromLockedNFTs( + id: id, + nftType: nftType, + receiverName: receiverName, + lockedUntilBeforeEarlyUnlock: lockedTokenDetails.lockedUntil + ), + ownerAddress: lockedTokenDetails.owner, + passThruParams: passThruParams, + ) + } + + /// Withdraw the NFT with the given id and type, used in the unlock and unlockWithAuthorizedDeposit functions + /// + access(self) fun withdrawFromLockedNFTs(id: UInt64, nftType: Type, receiverName: String?, lockedUntilBeforeEarlyUnlock: UInt64?): @{NonFungibleToken.NFT} { + // Remove the token's locked data if let lockedTokens = &NFTLocker.lockedTokens[nftType] as auth(Remove) &{UInt64: NFTLocker.LockedData}? { lockedTokens.remove(key: id) } + + // Decrement the locked tokens count NFTLocker.totalLockedTokens = NFTLocker.totalLockedTokens - 1 + // Emit events emit NFTUnlocked( - id: token.id, + id: id, from: self.owner?.address, - nftType: nftType + nftType: nftType, + receiverName: receiverName, + lockedUntilBeforeEarlyUnlock: lockedUntilBeforeEarlyUnlock ) + emit Withdraw(id: id, from: self.owner?.address) - emit Withdraw(id: token.id, from: self.owner?.address) - - return <-token + return <- self.lockedNFTs[nftType]?.remove(key: id)!! } - /// Lock an NFT of a given type + /// Lock the given NFT for the specified duration /// access(Operate) fun lock(token: @{NonFungibleToken.NFT}, duration: UInt64) { - let id: UInt64 = token.id + // Get the NFT's id and type + let nftId: UInt64 = token.id let nftType: Type = token.getType() + // Initialize the collection's locked NFTs for the given type if it doesn't exist + if self.lockedNFTs[nftType] == nil { + self.lockedNFTs[nftType] <-! {} + } + + // Initialize the contract's locked tokens data for the given type if it doesn't exist if NFTLocker.lockedTokens[nftType] == nil { NFTLocker.lockedTokens[nftType] = {} } - if self.lockedNFTs[nftType] == nil { - self.lockedNFTs[nftType] <-! {} - } - let ref = &self.lockedNFTs[nftType] as auth(Insert) &{UInt64: {NonFungibleToken.NFT}}? + // Get a reference to this collection's locked NFTs map + let collectionLockedNFTsRef = &self.lockedNFTs[nftType] as auth(Insert) &{UInt64: {NonFungibleToken.NFT}}? + + // Deposit the provided NFT in this collection's locked NFTs map - Cadence design requires destroying the resource-typed return value + destroy <- collectionLockedNFTsRef!.insert(key: nftId, <- token) - let oldToken <- ref!.insert(key: id, <- token) + // Get a reference to the contract's nested map containing locked tokens data + let lockedTokensDataRef = &NFTLocker.lockedTokens[nftType] as auth(Insert) &{UInt64: NFTLocker.LockedData}? + ?? panic("Could not get a reference to the locked tokens data") - let nestedLockRef = &NFTLocker.lockedTokens[nftType] as auth(Insert) &{UInt64: NFTLocker.LockedData}? + // Create locked data let lockedData = NFTLocker.LockedData( - id: id, + id: nftId, owner: self.owner!.address, duration: duration, nftType: nftType ) - nestedLockRef!.insert(key: id, lockedData) + // Insert the locked data + lockedTokensDataRef.insert(key: nftId, lockedData) + + // Increment the total locked tokens NFTLocker.totalLockedTokens = NFTLocker.totalLockedTokens + 1 + // Emit events emit NFTLocked( - id: id, + id: nftId, to: self.owner?.address, lockedAt: lockedData.lockedAt, lockedUntil: lockedData.lockedUntil, @@ -202,31 +442,39 @@ access(all) contract NFTLocker { nftType: nftType ) - emit Deposit(id: id, to: self.owner?.address) - destroy oldToken + emit Deposit(id: nftId, to: self.owner?.address) } + /// Get the ids of NFTs locked for a given type + /// access(all) view fun getIDs(nftType: Type): [UInt64]? { return self.lockedNFTs[nftType]?.keys } + /// Initialize Collection resource + /// view init() { self.lockedNFTs <- {} } } + /// Create and return an empty collection + /// access(all) fun createEmptyCollection(): @Collection { return <- create Collection() } + /// Contract initializer + /// init() { + // Set paths self.CollectionStoragePath = /storage/NFTLockerCollection self.CollectionPublicPath = /public/NFTLockerCollection - // Create an admin resource - let admin <- create Admin() - self.account.storage.save(<-admin, to: NFTLocker.GetAdminStoragePath()) + // Create and save the admin resource + self.account.storage.save(<- create Admin(), to: NFTLocker.GetAdminStoragePath()) + // Set contract variables self.totalLockedTokens = 0 self.lockedTokens = {} } diff --git a/locked-nft/lib/go/test/lockednft_test.go b/locked-nft/lib/go/test/lockednft_test.go index b9f923f..bd11428 100644 --- a/locked-nft/lib/go/test/lockednft_test.go +++ b/locked-nft/lib/go/test/lockednft_test.go @@ -1,6 +1,7 @@ package test import ( + "strings" "testing" "time" @@ -308,7 +309,245 @@ func testUnlockNFT( return err }() assert.Error(t, err) + assert.True(t, strings.Contains(err.Error(), "cadence.Value is nil")) +} + +func TestAdminAddReceiver(t *testing.T) { + b := newEmulator() + contracts := NFTLockerDeployContracts(t, b) + t.Run("Should be able to add a receiver", func(t *testing.T) { + testAdminAddReceiver( + t, + b, + contracts, + ) + }) +} + +func testAdminAddReceiver( + t *testing.T, + b *emulator.Blockchain, + contracts Contracts, +) { + userAddress, userSigner := createAccount(t, b) + setupNFTLockerAccount(t, b, userAddress, userSigner, contracts) + setupExampleNFT(t, b, userAddress, userSigner, contracts) + + mintExampleNFT( + t, + b, + contracts, + false, + userAddress.String(), + ) + + adminAddReceiver( + t, + b, + contracts, + false, + ) +} + +func TestUnlockWithAuthorizedDeposit(t *testing.T) { + b := newEmulator() + contracts := NFTLockerDeployContracts(t, b) + + t.Run("Should be able to unlock with authorized deposit", func(t *testing.T) { + testUnlockWithAuthorizedDeposit( + t, + b, + contracts, + ) + }) +} +func testUnlockWithAuthorizedDeposit( + t *testing.T, + b *emulator.Blockchain, + contracts Contracts, +) { + userAddress, userSigner := createAccount(t, b) + setupNFTLockerAccount(t, b, userAddress, userSigner, contracts) + setupExampleNFT(t, b, userAddress, userSigner, contracts) + + mintExampleNFT( + t, + b, + contracts, + false, + userAddress.String(), + ) + + exampleNftID := mintExampleNFT( + t, + b, + contracts, + false, + userAddress.String(), + ) + + adminAddReceiver( + t, + b, + contracts, + false, + ) + + leaderboardName := "test-leaderboard-name" + + createLeaderboard( + t, + b, + contracts, + leaderboardName, + ) + + var duration uint64 = 10000000000 + + lockedAt, lockedUntil := lockNFT( + t, + b, + contracts, + false, + userAddress, + userSigner, + exampleNftID, + duration, + ) + assert.Equal(t, lockedAt+duration, lockedUntil) + + unlockNFTWithAuthorizedDeposit( + t, + b, + contracts, + false, + userAddress, + userSigner, + leaderboardName, + exampleNftID, + ) + + err := func() (err error) { + defer func() { + if r := recover(); r != nil { + err = r.(error) + } + }() + _ = getLockedTokenData( + t, + b, + contracts, + exampleNftID, + ) + return err + }() + assert.Error(t, err) + assert.True(t, strings.Contains(err.Error(), "cadence.Value is nil")) +} + +func TestAdminRemoveReceiver(t *testing.T) { + b := newEmulator() + contracts := NFTLockerDeployContracts(t, b) + + t.Run("Should be able to remove a receiver and fail to unlock with de-authorized deposit handler", func(t *testing.T) { + testAdminRemoveReceiver( + t, + b, + contracts, + ) + }) +} + +func testAdminRemoveReceiver( + t *testing.T, + b *emulator.Blockchain, + contracts Contracts, +) { + userAddress, userSigner := createAccount(t, b) + setupNFTLockerAccount(t, b, userAddress, userSigner, contracts) + setupExampleNFT(t, b, userAddress, userSigner, contracts) + + mintExampleNFT( + t, + b, + contracts, + false, + userAddress.String(), + ) + + exampleNftID := mintExampleNFT( + t, + b, + contracts, + false, + userAddress.String(), + ) + + adminAddReceiver( + t, + b, + contracts, + false, + ) + + leaderboardName := "test-leaderboard-name" + + createLeaderboard( + t, + b, + contracts, + leaderboardName, + ) + + var duration uint64 = 10000000000 + + lockedAt, lockedUntil := lockNFT( + t, + b, + contracts, + false, + userAddress, + userSigner, + exampleNftID, + duration, + ) + assert.Equal(t, lockedAt+duration, lockedUntil) + + adminRemoveReceiver( + t, + b, + contracts, + false, + ) + + // should fail to unlock with authorized deposit + unlockNFTWithAuthorizedDeposit( + t, + b, + contracts, + true, + userAddress, + userSigner, + leaderboardName, + exampleNftID, + ) + + err := func() (err error) { + defer func() { + if r := recover(); r != nil { + err = r.(error) + } + }() + _ = getLockedTokenData( + t, + b, + contracts, + exampleNftID, + ) + return err + }() + assert.NoError(t, err) } func TestAdminUnLockNFT(t *testing.T) { diff --git a/locked-nft/lib/go/test/templates.go b/locked-nft/lib/go/test/templates.go index 491c667..9a2ffa6 100644 --- a/locked-nft/lib/go/test/templates.go +++ b/locked-nft/lib/go/test/templates.go @@ -16,10 +16,12 @@ const ( NFTLockerAddressPlaceholder = "\"NFTLocker\"" metadataViewsAddressPlaceholder = "\"MetadataViews\"" exampleNFTAddressPlaceholder = "\"ExampleNFT\"" + escrowAddressPlaceholder = "\"Escrow\"" ) const ( NFTLockerPath = "../../../contracts/NFTLocker.cdc" + EscrowPath = "../../../../escrow/contracts/Escrow.cdc" NFTLockerV2Path = "../../../contracts/NFTLockerNew.cdc" ExampleNFTPath = "../../../contracts/ExampleNFT.cdc" MetadataViewsInterfaceFilePath = "../../../contracts/imports/MetadataViews.cdc" @@ -41,11 +43,17 @@ const ( MetadataNFTReplaceAddress = `"NonFungibleToken"` // NFTLocker - GetLockedTokenByIDScriptPath = ScriptsRootPath + "/get_locked_token.cdc" - GetInventoryScriptPath = ScriptsRootPath + "/inventory.cdc" - LockNFTTxPath = TransactionsRootPath + "/lock_nft.cdc" - UnlockNFTTxPath = TransactionsRootPath + "/unlock_nft.cdc" - AdminUnlockNFTTxPath = TransactionsRootPath + "/admin_unlock_nft.cdc" + GetLockedTokenByIDScriptPath = ScriptsRootPath + "/get_locked_token.cdc" + GetInventoryScriptPath = ScriptsRootPath + "/inventory.cdc" + LockNFTTxPath = TransactionsRootPath + "/lock_nft.cdc" + UnlockNFTTxPath = TransactionsRootPath + "/unlock_nft.cdc" + AdminAddReceiverTxPath = TransactionsRootPath + "/admin_add_escrow_receiver.cdc" + AdminRemoveReceiverTxPath = TransactionsRootPath + "/admin_remove_escrow_receiver.cdc" + UnlockNFTWithAuthorizedDepositTxPath = TransactionsRootPath + "/unlock_nft_with_authorized_deposit.cdc" + AdminUnlockNFTTxPath = TransactionsRootPath + "/admin_unlock_nft.cdc" + + // Escrow + CreateLeaderboardTxPath = TransactionsRootPath + "/testutils/create_leaderboard.cdc" ) // ------------------------------------------------------------ @@ -60,6 +68,7 @@ func replaceAddresses(code []byte, contracts Contracts) []byte { code = []byte(strings.ReplaceAll(string(code), metadataViewsAddressPlaceholder, "0x"+contracts.MetadataViewsAddress.String())) code = []byte(strings.ReplaceAll(string(code), exampleNFTAddressPlaceholder, "0x"+contracts.NFTLockerAddress.String())) + code = []byte(strings.ReplaceAll(string(code), escrowAddressPlaceholder, "0x"+contracts.NFTLockerAddress.String())) return code } @@ -74,6 +83,16 @@ func LoadNFTLockerContract(nftAddress flow.Address, metadataViewsAddress flow.Ad return code } +func LoadEscrowContract(nftAddress, metadataViewsAddress, nftLocker flow.Address) []byte { + code := readFile(EscrowPath) + + nftRe := regexp.MustCompile(nftAddressPlaceholder) + code = nftRe.ReplaceAll(code, []byte("0x"+nftAddress.String())) + code = []byte(strings.ReplaceAll(string(code), NFTLockerAddressPlaceholder, "0x"+nftLocker.String())) + + return code +} + func LoadExampleNFTContract(nftAddress flow.Address, metadataViewsAddress flow.Address) []byte { code := readFile(ExampleNFTPath) @@ -126,6 +145,27 @@ func unlockNFTTransaction(contracts Contracts) []byte { ) } +func adminAddReceiverTransaction(contracts Contracts) []byte { + return replaceAddresses( + readFile(AdminAddReceiverTxPath), + contracts, + ) +} + +func adminRemoveReceiverTransaction(contracts Contracts) []byte { + return replaceAddresses( + readFile(AdminRemoveReceiverTxPath), + contracts, + ) +} + +func unlockNFTWithAuthorizedDepositTransaction(contracts Contracts) []byte { + return replaceAddresses( + readFile(UnlockNFTWithAuthorizedDepositTxPath), + contracts, + ) +} + func adminUnlockNFTTransaction(contracts Contracts) []byte { return replaceAddresses( readFile(AdminUnlockNFTTxPath), @@ -133,6 +173,13 @@ func adminUnlockNFTTransaction(contracts Contracts) []byte { ) } +func createLeaderboardTransaction(contracts Contracts) []byte { + return replaceAddresses( + readFile(CreateLeaderboardTxPath), + contracts, + ) +} + func DownloadFile(url string) ([]byte, error) { // Get the data resp, err := http.Get(url) diff --git a/locked-nft/lib/go/test/test.go b/locked-nft/lib/go/test/test.go index c8ff5ec..da88af0 100644 --- a/locked-nft/lib/go/test/test.go +++ b/locked-nft/lib/go/test/test.go @@ -82,6 +82,11 @@ func NFTLockerDeployContracts(t *testing.T, b *emulator.Blockchain) Contracts { ) require.NoError(t, err) + EscrowCode := LoadEscrowContract(nftAddress, metadataViewsAddr, NFTLockerAddress) + + signer, err := b.ServiceKey().Signer() + assert.NoError(t, err) + tx1 := sdktemplates.AddAccountContract( NFTLockerAddress, sdktemplates.Contract{ @@ -95,6 +100,16 @@ func NFTLockerDeployContracts(t *testing.T, b *emulator.Blockchain) Contracts { SetProposalKey(b.ServiceKey().Address, b.ServiceKey().Index, b.ServiceKey().SequenceNumber). SetPayer(b.ServiceKey().Address) + signAndSubmit( + t, b, tx1, + []flow.Address{b.ServiceKey().Address, NFTLockerAddress}, + []crypto.Signer{signer, NFTLockerSigner}, + false, + ) + + _, err = b.CommitBlock() + require.NoError(t, err) + tx2 := sdktemplates.AddAccountContract( NFTLockerAddress, sdktemplates.Contract{ @@ -105,14 +120,11 @@ func NFTLockerDeployContracts(t *testing.T, b *emulator.Blockchain) Contracts { tx2. SetComputeLimit(100). - SetProposalKey(b.ServiceKey().Address, b.ServiceKey().Index, b.ServiceKey().SequenceNumber+1). + SetProposalKey(b.ServiceKey().Address, b.ServiceKey().Index, b.ServiceKey().SequenceNumber). SetPayer(b.ServiceKey().Address) - signer, err := b.ServiceKey().Signer() - assert.NoError(t, err) - signAndSubmit( - t, b, tx1, + t, b, tx2, []flow.Address{b.ServiceKey().Address, NFTLockerAddress}, []crypto.Signer{signer, NFTLockerSigner}, false, @@ -121,8 +133,21 @@ func NFTLockerDeployContracts(t *testing.T, b *emulator.Blockchain) Contracts { _, err = b.CommitBlock() require.NoError(t, err) + tx3 := sdktemplates.AddAccountContract( + NFTLockerAddress, + sdktemplates.Contract{ + Name: "Escrow", + Source: string(EscrowCode), + }, + ) + + tx3. + SetComputeLimit(100). + SetProposalKey(b.ServiceKey().Address, b.ServiceKey().Index, b.ServiceKey().SequenceNumber). + SetPayer(b.ServiceKey().Address) + signAndSubmit( - t, b, tx2, + t, b, tx3, []flow.Address{b.ServiceKey().Address, NFTLockerAddress}, []crypto.Signer{signer, NFTLockerSigner}, false, diff --git a/locked-nft/lib/go/test/transactions.go b/locked-nft/lib/go/test/transactions.go index 057ca92..6f8c433 100644 --- a/locked-nft/lib/go/test/transactions.go +++ b/locked-nft/lib/go/test/transactions.go @@ -1,7 +1,6 @@ package test import ( - "fmt" "strings" "testing" @@ -9,6 +8,7 @@ import ( "github.com/onflow/flow-emulator/emulator" "github.com/onflow/flow-go-sdk" "github.com/onflow/flow-go-sdk/crypto" + "github.com/stretchr/testify/require" ) // ------------------------------------------------------------ @@ -112,13 +112,86 @@ func unlockNFT( tx.AddArgument(cadence.UInt64(nftId)) signer, _ := b.ServiceKey().Signer() - txResult := signAndSubmit( + signAndSubmit( + t, b, tx, + []flow.Address{b.ServiceKey().Address, userAddress}, + []crypto.Signer{signer, userSigner}, + shouldRevert, + ) +} + +func adminAddReceiver( + t *testing.T, + b *emulator.Blockchain, + contracts Contracts, + shouldRevert bool, +) { + tx := flow.NewTransaction(). + SetScript(adminAddReceiverTransaction(contracts)). + SetGasLimit(100). + SetProposalKey(b.ServiceKey().Address, b.ServiceKey().Index, b.ServiceKey().SequenceNumber). + SetPayer(b.ServiceKey().Address). + AddAuthorizer(contracts.NFTLockerAddress) + + signer, _ := b.ServiceKey().Signer() + signAndSubmit( + t, b, tx, + []flow.Address{b.ServiceKey().Address, contracts.NFTLockerAddress}, + []crypto.Signer{signer, contracts.NFTLockerSigner}, + shouldRevert, + ) +} + +func adminRemoveReceiver( + t *testing.T, + b *emulator.Blockchain, + contracts Contracts, + shouldRevert bool, +) { + tx := flow.NewTransaction(). + SetScript(adminRemoveReceiverTransaction(contracts)). + SetGasLimit(100). + SetProposalKey(b.ServiceKey().Address, b.ServiceKey().Index, b.ServiceKey().SequenceNumber). + SetPayer(b.ServiceKey().Address). + AddAuthorizer(contracts.NFTLockerAddress) + + signer, _ := b.ServiceKey().Signer() + signAndSubmit( + t, b, tx, + []flow.Address{b.ServiceKey().Address, contracts.NFTLockerAddress}, + []crypto.Signer{signer, contracts.NFTLockerSigner}, + shouldRevert, + ) +} + +func unlockNFTWithAuthorizedDeposit( + t *testing.T, + b *emulator.Blockchain, + contracts Contracts, + shouldRevert bool, + userAddress flow.Address, + userSigner crypto.Signer, + leaderboardName string, + nftId uint64, +) { + tx := flow.NewTransaction(). + SetScript(unlockNFTWithAuthorizedDepositTransaction(contracts)). + SetGasLimit(100). + SetProposalKey(b.ServiceKey().Address, b.ServiceKey().Index, b.ServiceKey().SequenceNumber). + SetPayer(b.ServiceKey().Address). + AddAuthorizer(userAddress) + + leaderboardNameCadence, _ := cadence.NewString(leaderboardName) + tx.AddArgument(leaderboardNameCadence) + tx.AddArgument(cadence.UInt64(nftId)) + + signer, _ := b.ServiceKey().Signer() + signAndSubmit( t, b, tx, []flow.Address{b.ServiceKey().Address, userAddress}, []crypto.Signer{signer, userSigner}, shouldRevert, ) - fmt.Println(txResult) } func adminUnlockNFT( @@ -144,3 +217,27 @@ func adminUnlockNFT( shouldRevert, ) } + +func createLeaderboard( + t *testing.T, + b *emulator.Blockchain, + contracts Contracts, + leaderboardName string, +) { + tx := flow.NewTransaction(). + SetScript(createLeaderboardTransaction(contracts)). + SetComputeLimit(100). + SetProposalKey(b.ServiceKey().Address, b.ServiceKey().Index, b.ServiceKey().SequenceNumber). + SetPayer(b.ServiceKey().Address). + AddAuthorizer(contracts.NFTLockerAddress) + tx.AddArgument(cadence.String(leaderboardName)) + + signer, err := b.ServiceKey().Signer() + require.NoError(t, err) + signAndSubmit( + t, b, tx, + []flow.Address{b.ServiceKey().Address, contracts.NFTLockerAddress}, + []crypto.Signer{signer, contracts.NFTLockerSigner}, + false, + ) +} diff --git a/locked-nft/transactions/admin_add_escrow_receiver.cdc b/locked-nft/transactions/admin_add_escrow_receiver.cdc new file mode 100644 index 0000000..be33176 --- /dev/null +++ b/locked-nft/transactions/admin_add_escrow_receiver.cdc @@ -0,0 +1,47 @@ +import NonFungibleToken from "NonFungibleToken" +import NFTLocker from "NFTLocker" +import Escrow from "Escrow" +import ExampleNFT from "ExampleNFT" + +/// This transaction creates a ReceiverCollector resource and adds an escrow leaderboard deposit handler to it. +/// +transaction() { + // Authorized reference to the NFTLocker ReceiverCollector resource + let receiverCollectorRef: auth(NFTLocker.Operate) &NFTLocker.ReceiverCollector + + // Name of the receiver to be added to the ReceiverCollector + let receiverName: String + + prepare(admin: auth(SaveValue, BorrowValue) &Account) { + // Set receiver name + self.receiverName = "add-entry-to-escrow-leaderboard" + + // Create a ReceiverCollector resource if it does not exist yet in admin storage + if NFTLocker.borrowAdminReceiverCollectorPublic() == nil { + // Borrow a reference to the NFTLocker Admin resource + let adminRef = admin.storage.borrow<&NFTLocker.Admin>(from: NFTLocker.GetAdminStoragePath()) + ?? panic("Could not borrow a reference to the owner's collection") + + // Create a ReceiverCollector resource and save it in storage + admin.storage.save(<- adminRef.createReceiverCollector(), to: NFTLocker.getReceiverCollectorStoragePath()) + } + + // Borrow an authorized reference to the admin's ReceiverCollector resource + self.receiverCollectorRef = admin.storage + .borrow(from: NFTLocker.getReceiverCollectorStoragePath()) + ?? panic("Could not borrow a reference to the owner's collection") + } + + execute { + // Add a receiver to the ReceiverCollector with the provided namw, deposit handler, and accepted NFT types + self.receiverCollectorRef.addReceiver( + name: self.receiverName, + authorizedDepositHandler: Escrow.DepositHandler(), + eligibleNFTTypes: {Type<@ExampleNFT.NFT>(): true} + ) + } + + post { + self.receiverCollectorRef.getReceiver(name: self.receiverName) != nil : "Receiver not added" + } +} diff --git a/locked-nft/transactions/admin_remove_escrow_receiver.cdc b/locked-nft/transactions/admin_remove_escrow_receiver.cdc new file mode 100644 index 0000000..a0c25a8 --- /dev/null +++ b/locked-nft/transactions/admin_remove_escrow_receiver.cdc @@ -0,0 +1,31 @@ +import NonFungibleToken from "NonFungibleToken" +import NFTLocker from "NFTLocker" + +/// This transaction removes an escrow leaderboard deposit handler from the admin's ReceiverCollector resource. +/// +transaction() { + // Authorized reference to the NFTLocker ReceiverCollector resource + let receiverCollectorRef: auth(NFTLocker.Operate) &NFTLocker.ReceiverCollector + + // Name of the receiver to be removed from the ReceiverCollector + let receiverName: String + + prepare(admin: auth(BorrowValue) &Account) { + // Set receiver name + self.receiverName = "add-entry-to-escrow-leaderboard" + + // Borrow an authorized reference to the admin's ReceiverCollector resource + self.receiverCollectorRef = admin.storage + .borrow(from: NFTLocker.getReceiverCollectorStoragePath()) + ?? panic("Could not borrow a reference to the owner's collection") + } + + execute { + // Add a receiver to the ReceiverCollector with the provided namw, deposit handler, and accepted NFT types + self.receiverCollectorRef.removeReceiver(name: self.receiverName) + } + + post { + self.receiverCollectorRef.getReceiver(name: self.receiverName) == nil : "Receiver not removed" + } +} diff --git a/locked-nft/transactions/testutils/create_leaderboard.cdc b/locked-nft/transactions/testutils/create_leaderboard.cdc new file mode 100644 index 0000000..0b922a0 --- /dev/null +++ b/locked-nft/transactions/testutils/create_leaderboard.cdc @@ -0,0 +1,17 @@ +import Escrow from "Escrow" +import ExampleNFT from "ExampleNFT" +import NonFungibleToken from "NonFungibleToken" + +// This transaction takes a name and creates a new leaderboard with that name. +transaction(leaderboardName: String) { + prepare(signer: auth(BorrowValue) &Account) { + let collectionRef = signer.storage.borrow(from: Escrow.CollectionStoragePath) + ?? panic("Could not borrow reference to the Collection resource") + + let type = Type<@ExampleNFT.NFT>() + + let newNFTCollection <- ExampleNFT.createEmptyCollection(nftType: Type<@ExampleNFT.NFT>()) + + collectionRef.createLeaderboard(name: leaderboardName, nftType: type, collection: <-newNFTCollection) + } +} diff --git a/locked-nft/transactions/unlock_nft_with_authorized_deposit.cdc b/locked-nft/transactions/unlock_nft_with_authorized_deposit.cdc new file mode 100644 index 0000000..7e3ea3c --- /dev/null +++ b/locked-nft/transactions/unlock_nft_with_authorized_deposit.cdc @@ -0,0 +1,65 @@ +import NonFungibleToken from "NonFungibleToken" +import ExampleNFT from "ExampleNFT" +import NFTLocker from "NFTLocker" +import Escrow from "Escrow" + +/// This transaction unlocks the NFT with provided ID and adds to an escrow leaderboard, unlocking them if necessary. +/// +transaction(leaderboardName: String, nftID: UInt64) { + let ownerAddress: Address + let collectionRef: auth(NonFungibleToken.Withdraw) &ExampleNFT.Collection + let collectionPublic: &Escrow.Collection + let userLockerCollection: auth(NFTLocker.Operate) &NFTLocker.Collection? + + prepare(owner: auth(Storage, Capabilities) &Account) { + // Borrow a reference to the user's NFT collection as a Provider + self.collectionRef = owner.storage + .borrow(from: ExampleNFT.CollectionStoragePath) + ?? panic("Could not borrow a reference to the owner's collection") + + // Save the owner's address + self.ownerAddress = owner.address + + // Extract escrow address from contract import + let escrowAdress = Address.fromString("0x".concat(Type().identifier.slice(from: 2, upTo: 18))) + ?? panic("Could not convert the address") + + // Get the public leaderboard collection + self.collectionPublic = getAccount(escrowAdress).capabilities.borrow<&Escrow.Collection>(Escrow.CollectionPublicPath) + ?? panic("Could not borrow a reference to the public leaderboard collection") + + // Borrow a reference to the user's NFTLocker collection + self.userLockerCollection = owner.storage + .borrow(from: NFTLocker.CollectionStoragePath) + } + + execute { + // Prepare the NFT type + let nftType: Type = Type<@ExampleNFT.NFT>() + + // Add NFT to the leaderboard, unlocking it if necessary + if self.userLockerCollection != nil && NFTLocker.getNFTLockerDetails(id: nftID, nftType: nftType) != nil { + // Unlock the NFT normally if it has met the unlock conditions, otherwise force unlock (depositing to escrow allows bypassing the unlock conditions) + if NFTLocker.canUnlockToken(id: nftID, nftType: nftType) { + self.collectionPublic.addEntryToLeaderboard( + nft: <- self.userLockerCollection!.unlock(id: nftID, nftType: nftType), + leaderboardName: leaderboardName, + ownerAddress: self.ownerAddress, + ) + } else { + self.userLockerCollection!.unlockWithAuthorizedDeposit( + id: nftID, + nftType: nftType, + receiverName: "add-entry-to-escrow-leaderboard", + passThruParams: {"leaderboardName": leaderboardName}, + ) + } + } else { + self.collectionPublic.addEntryToLeaderboard( + nft: <- self.collectionRef.withdraw(withdrawID: nftID), + leaderboardName: leaderboardName, + ownerAddress: self.ownerAddress, + ) + } + } +}