From a85226e0aa9fe6ffa90ff292140fd920aeb0f078 Mon Sep 17 00:00:00 2001 From: loic1 <17323063+loic1@users.noreply.github.com> Date: Tue, 15 Oct 2024 16:33:24 -0600 Subject: [PATCH 1/8] add unlockWithAuthorizedDeposit (wip) --- locked-nft/contracts/NFTLocker.cdc | 273 +++++++++++++++--- locked-nft/lib/go/test/lockednft_test.go | 54 ++++ locked-nft/lib/go/test/templates.go | 20 ++ locked-nft/lib/go/test/test.go | 37 ++- locked-nft/lib/go/test/transactions.go | 22 ++ .../admin_add_escrow_receiver.cdc | 57 ++++ locked-nft/transactions/admin_unlock_nft.cdc | 2 +- .../unlock_with_authorized_deposit.cdc | 65 +++++ 8 files changed, 490 insertions(+), 40 deletions(-) create mode 100644 locked-nft/transactions/admin_add_escrow_receiver.cdc create mode 100644 locked-nft/transactions/unlock_with_authorized_deposit.cdc diff --git a/locked-nft/contracts/NFTLocker.cdc b/locked-nft/contracts/NFTLocker.cdc index d826b21..00297dc 100644 --- a/locked-nft/contracts/NFTLocker.cdc +++ b/locked-nft/contracts/NFTLocker.cdc @@ -38,6 +38,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 +53,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 +93,171 @@ access(all) contract NFTLocker { return false } - /// The path to the NFTLocker Admin resource belonging to the Account - /// which the contract is deployed on - access(all) view fun GetAdminStoragePath(): StoragePath { + /// 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()) + } + + /// 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 { + /// The deposit method for the receiver + /// + access(all) var depositMethod: fun(@{NonFungibleToken.NFT}, LockedData, {String: AnyStruct}) + + /// 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( + depositMethod: fun(@{NonFungibleToken.NFT}, LockedData, {String: AnyStruct}), + eligibleNFTTypes: {Type: Bool} + ) { + self.depositMethod = depositMethod + self.eligibleNFTTypes = eligibleNFTTypes + self.metadata = {} + } + } + + /// ReceiverCollector resource + /// + /// Note: This resource is used to store receivers and corresponding deposit methods; 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 new deposit method for a given NFT type + /// + access(Operate) fun addReceiver( + name: String, + depositMethod: fun(@{NonFungibleToken.NFT}, LockedData, {String: AnyStruct}), + eligibleNFTTypes: {Type: Bool} + ) { + pre { + !self.receiversByName.containsKey(name): "Receiver with the same name already exists" + } + + // Add the receiver + self.receiversByName[name] = Receiver( + depositMethod: depositMethod, + 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} + } + } + } + + /// 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) + } + + /// Get the deposit method 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 new ReceiverCollector resource + /// + access(all) fun createReceiverCollector(): @ReceiverCollector { + return <- create ReceiverCollector() + } + } + + /// Expire lock if the locked NFT is eligible for force unlock and deposit by authorized receiver + /// + access(contract) 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){ + locker.insert( + key: id, + LockedData( id: id, owner: oldLockedData.owner, duration: 0, nftType: nftType ) - locker.insert(key: id, lockedData) - } + ) } } } @@ -123,52 +271,93 @@ 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 { + /// 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" } + // Get the locked token let token <- self.lockedNFTs[nftType]?.remove(key: id)!! + // Remove the locked data if let lockedTokens = &NFTLocker.lockedTokens[nftType] as auth(Remove) &{UInt64: NFTLocker.LockedData}? { lockedTokens.remove(key: id) } - NFTLocker.totalLockedTokens = NFTLocker.totalLockedTokens - 1 - emit NFTUnlocked( - id: token.id, - from: self.owner?.address, - nftType: nftType - ) + // Decrement the total locked tokens + NFTLocker.totalLockedTokens = NFTLocker.totalLockedTokens - 1 + // Emit events + emit NFTUnlocked(id: token.id, from: self.owner?.address, nftType: nftType) emit Withdraw(id: token.id, from: self.owner?.address) return <-token } + /// 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} + ) { + // 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 the receiver collector, panic if it doesn't exist + let receiverCollector = NFTLocker.borrowAdminReceiverCollectorPublic() + ?? panic("No receiver collector found") + + // Get the receiver name for the given NFT type, panic if it doesn't exist + let receiverNames = receiverCollector.getReceiverNamesByNFTType(nftType: nftType) + ?? panic("No authorized receiver for the given NFT type") + + // Verify that the receiver is authorized to receive the NFT + assert( + receiverNames[receiverName] == true, + message: "Provided receiver does not exist or is not authorized for the given NFT type" + ) + + // Expire the lock + NFTLocker.expireLock(id: id, nftType: nftType) + + // Unlock and deposit the NFT using the receiver's deposit method + receiverCollector.getReceiver(name: receiverName)!.depositMethod( + nft: <- self.unlock(id: id, nftType: nftType), + lockedTokenDetails: lockedTokenDetails, + passThruParams: passThruParams, + ) + } + /// Lock an NFT of a given type /// access(Operate) fun lock(token: @{NonFungibleToken.NFT}, duration: UInt64) { - let id: UInt64 = token.id + let nftId: UInt64 = token.id let nftType: Type = token.getType() if NFTLocker.lockedTokens[nftType] == nil { @@ -178,23 +367,31 @@ access(all) contract NFTLocker { if self.lockedNFTs[nftType] == nil { self.lockedNFTs[nftType] <-! {} } + + // Get a reference to the nested map let ref = &self.lockedNFTs[nftType] as auth(Insert) &{UInt64: {NonFungibleToken.NFT}}? - let oldToken <- ref!.insert(key: id, <- token) + let oldToken <- ref!.insert(key: nftId, <- token) let nestedLockRef = &NFTLocker.lockedTokens[nftType] as auth(Insert) &{UInt64: NFTLocker.LockedData}? + + // Create a new 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 + nestedLockRef!.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 +399,41 @@ access(all) contract NFTLocker { nftType: nftType ) - emit Deposit(id: id, to: self.owner?.address) + emit Deposit(id: nftId, to: self.owner?.address) + destroy oldToken } + /// 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..fc60825 100644 --- a/locked-nft/lib/go/test/lockednft_test.go +++ b/locked-nft/lib/go/test/lockednft_test.go @@ -311,6 +311,60 @@ func testUnlockNFT( } +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) + + exampleNftID := mintExampleNFT( + t, + b, + contracts, + false, + userAddress.String(), + ) + + adminAddReceiver( + t, + b, + contracts, + false, + ) + + 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) + +} + func TestAdminUnLockNFT(t *testing.T) { b := newEmulator() contracts := NFTLockerDeployContracts(t, b) diff --git a/locked-nft/lib/go/test/templates.go b/locked-nft/lib/go/test/templates.go index 491c667..9adde67 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" @@ -45,6 +47,7 @@ const ( GetInventoryScriptPath = ScriptsRootPath + "/inventory.cdc" LockNFTTxPath = TransactionsRootPath + "/lock_nft.cdc" UnlockNFTTxPath = TransactionsRootPath + "/unlock_nft.cdc" + AdminAddReceiverTxPath = TransactionsRootPath + "/admin_add_escrow_receiver.cdc" AdminUnlockNFTTxPath = TransactionsRootPath + "/admin_unlock_nft.cdc" ) @@ -60,6 +63,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 +78,15 @@ func LoadNFTLockerContract(nftAddress flow.Address, metadataViewsAddress flow.Ad return code } +func LoadEscrowContract(nftAddress flow.Address, metadataViewsAddress flow.Address) []byte { + code := readFile(EscrowPath) + + nftRe := regexp.MustCompile(nftAddressPlaceholder) + code = nftRe.ReplaceAll(code, []byte("0x"+nftAddress.String())) + + return code +} + func LoadExampleNFTContract(nftAddress flow.Address, metadataViewsAddress flow.Address) []byte { code := readFile(ExampleNFTPath) @@ -126,6 +139,13 @@ func unlockNFTTransaction(contracts Contracts) []byte { ) } +func adminAddReceiverTransaction(contracts Contracts) []byte { + return replaceAddresses( + readFile(AdminAddReceiverTxPath), + contracts, + ) +} + func adminUnlockNFTTransaction(contracts Contracts) []byte { return replaceAddresses( readFile(AdminUnlockNFTTxPath), diff --git a/locked-nft/lib/go/test/test.go b/locked-nft/lib/go/test/test.go index c8ff5ec..440ed0b 100644 --- a/locked-nft/lib/go/test/test.go +++ b/locked-nft/lib/go/test/test.go @@ -75,6 +75,8 @@ func NFTLockerDeployContracts(t *testing.T, b *emulator.Blockchain) Contracts { ExampleNFTCode := nftcontracts.ExampleNFT(nftAddress, metadataViewsAddr, resolverAddress) + EscrowCode := LoadEscrowContract(nftAddress, metadataViewsAddr) + NFTLockerAddress, err := adapter.CreateAccount( context.Background(), []*flow.AccountKey{NFTLockerAccountKey}, @@ -82,6 +84,9 @@ func NFTLockerDeployContracts(t *testing.T, b *emulator.Blockchain) Contracts { ) require.NoError(t, err) + 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..a84d9fa 100644 --- a/locked-nft/lib/go/test/transactions.go +++ b/locked-nft/lib/go/test/transactions.go @@ -121,6 +121,28 @@ func unlockNFT( fmt.Println(txResult) } +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 adminUnlockNFT( t *testing.T, b *emulator.Blockchain, 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..d2c1a01 --- /dev/null +++ b/locked-nft/transactions/admin_add_escrow_receiver.cdc @@ -0,0 +1,57 @@ +import NonFungibleToken from "NonFungibleToken" +import NFTLocker from "NFTLocker" +import Escrow from "Escrow" +import ExampleNFT from "ExampleNFT" + +/// This transaction creates a new ReceiverCollector resource and adds a new receiver to it with a deposit method that adds an NFT to an escrow leaderboard. +/// +transaction() { + // Auhtorized reference to the NFTLocker ReceiverCollector resource + let receiverCollectorRed: auth(NFTLocker.Operate) &NFTLocker.ReceiverCollector + + // Deposit method to be added to the ReceiverCollector resource + let depositMethod: fun(@{NonFungibleToken.NFT}, NFTLocker.LockedData, {String: AnyStruct}) + + prepare(admin: auth(SaveValue, BorrowValue) &Account) { + // Check if the ReceiverCollector resource does not exist + 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 new ReceiverCollector resource and save it in storage + admin.storage.save(<- adminRef.createReceiverCollector(), to: NFTLocker.getReceiverCollectorStoragePath()) + } + + // Borrow an authorized reference to the NFTLocker ReceiverCollector resource + self.receiverCollectorRef = admin.storage + .borrow(from: NFTLocker.getReceiverCollectorStoragePath()) + ?? panic("Could not borrow a reference to the owner's collection") + + // Define the deposit method to be used by the Receiver + self.depositMethod = fun(nft: @{NonFungibleToken.NFT}, lockedTokenDetails: NFTLocker.LockedData, passThruParams: {String: AnyStruct}) { + // Get leaderboard name from pass-thru parameters + let leaderboardName = passThruParams["leaderboardName"] as? String + ?? panic("Missing or invalid leaderboard name") + + // Get the Escrow contract account + let escrowAccount = getAccount(Address.fromString(Type().identifier.slice(from: 2, upTo: 18))!) + + // Get the Escrow Collection public reference + let escrowCollectionPublic = escrowAccount.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: lockedTokenDetails.owner) + } + } + + execute { + // Add a new receiver to the ReceiverCollector with the provided deposit method and accepted NFT types + receiverCollectorRef.addReceiver( + name: "add-entry-to-escrow-leaderboard", + depositMethod: self.depositMethod, + eligibleNFTTypes: {Type<@ExampleNFT.NFT>(): true} + ) + } +} \ No newline at end of file diff --git a/locked-nft/transactions/admin_unlock_nft.cdc b/locked-nft/transactions/admin_unlock_nft.cdc index e4853cd..f12d8f5 100644 --- a/locked-nft/transactions/admin_unlock_nft.cdc +++ b/locked-nft/transactions/admin_unlock_nft.cdc @@ -6,7 +6,7 @@ transaction(id: UInt64) { prepare(signer: auth(BorrowValue) &Account) { self.adminRef = signer.storage - .borrow<&NFTLocker.Admin>(from: NFTLocker.GetAdminStoragePath()) + .borrow<&NFTLocker.Admin>(from: NFTLocker.getAdminStoragePath()) ?? panic("Could not borrow a reference to the owner's collection") } diff --git a/locked-nft/transactions/unlock_with_authorized_deposit.cdc b/locked-nft/transactions/unlock_with_authorized_deposit.cdc new file mode 100644 index 0000000..55b4c66 --- /dev/null +++ b/locked-nft/transactions/unlock_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 adds NFTs to an escrow leaderboard, unlocking them if necessary. +/// +transaction(leaderboardName: String, nftIDs: [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 + + // Get the public leaderboard collection + let escrowAccount = getAccount({{0xEscrowAddress}}) + self.collectionPublic = escrowAccount.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 each NFT to the leaderboard + for nftID in nftIDs { + // Check if the NFT is locked + 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: userLockerCollection!.unlock(id: nftID, nftType: nftType), + leaderboardName: leaderboardName, + ownerAddress: self.ownerAddress, + ) + } else { + 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, + ) + } + } + } +} \ No newline at end of file From 59f369ba39282d9e76645f2618564c6b4d191118 Mon Sep 17 00:00:00 2001 From: loic1 <17323063+loic1@users.noreply.github.com> Date: Tue, 15 Oct 2024 16:49:04 -0600 Subject: [PATCH 2/8] fix typo --- locked-nft/transactions/admin_add_escrow_receiver.cdc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/locked-nft/transactions/admin_add_escrow_receiver.cdc b/locked-nft/transactions/admin_add_escrow_receiver.cdc index d2c1a01..bb906a6 100644 --- a/locked-nft/transactions/admin_add_escrow_receiver.cdc +++ b/locked-nft/transactions/admin_add_escrow_receiver.cdc @@ -7,7 +7,7 @@ import ExampleNFT from "ExampleNFT" /// transaction() { // Auhtorized reference to the NFTLocker ReceiverCollector resource - let receiverCollectorRed: auth(NFTLocker.Operate) &NFTLocker.ReceiverCollector + let receiverCollectorRef: auth(NFTLocker.Operate) &NFTLocker.ReceiverCollector // Deposit method to be added to the ReceiverCollector resource let depositMethod: fun(@{NonFungibleToken.NFT}, NFTLocker.LockedData, {String: AnyStruct}) @@ -48,7 +48,7 @@ transaction() { execute { // Add a new receiver to the ReceiverCollector with the provided deposit method and accepted NFT types - receiverCollectorRef.addReceiver( + self.receiverCollectorRef.addReceiver( name: "add-entry-to-escrow-leaderboard", depositMethod: self.depositMethod, eligibleNFTTypes: {Type<@ExampleNFT.NFT>(): true} From 10aaed601c01d639d38c8ae84484ce677295abad Mon Sep 17 00:00:00 2001 From: loic1 <17323063+loic1@users.noreply.github.com> Date: Tue, 15 Oct 2024 18:03:21 -0600 Subject: [PATCH 3/8] functions are not storeable, use struct interface instead --- escrow/contracts/Escrow.cdc | 17 ++++ locked-nft/contracts/NFTLocker.cdc | 28 +++++-- locked-nft/lib/go/test/lockednft_test.go | 81 ++++++++++++++++++- locked-nft/lib/go/test/templates.go | 33 ++++++-- locked-nft/lib/go/test/test.go | 4 +- locked-nft/lib/go/test/transactions.go | 59 +++++++++++++- .../admin_add_escrow_receiver.cdc | 24 +----- .../testutils/create_leaderboard.cdc | 17 ++++ .../unlock_nft_with_authorized_deposit.cdc | 65 +++++++++++++++ .../unlock_with_authorized_deposit.cdc | 65 --------------- 10 files changed, 285 insertions(+), 108 deletions(-) create mode 100644 locked-nft/transactions/testutils/create_leaderboard.cdc create mode 100644 locked-nft/transactions/unlock_nft_with_authorized_deposit.cdc delete mode 100644 locked-nft/transactions/unlock_with_authorized_deposit.cdc 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/locked-nft/contracts/NFTLocker.cdc b/locked-nft/contracts/NFTLocker.cdc index 00297dc..165b111 100644 --- a/locked-nft/contracts/NFTLocker.cdc +++ b/locked-nft/contracts/NFTLocker.cdc @@ -111,14 +111,20 @@ access(all) contract NFTLocker { 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 { - /// The deposit method for the receiver + /// Handler for depositing NFTs to the receiver /// - access(all) var depositMethod: fun(@{NonFungibleToken.NFT}, LockedData, {String: AnyStruct}) + access(all) var authorizedDepositHandler: {IAuthorizedDepositHandler} /// The eligible NFT types for the receiver /// @@ -131,15 +137,21 @@ access(all) contract NFTLocker { /// Initialize Receiver struct /// view init( - depositMethod: fun(@{NonFungibleToken.NFT}, LockedData, {String: AnyStruct}), + authorizedDepositHandler: {IAuthorizedDepositHandler}, eligibleNFTTypes: {Type: Bool} ) { - self.depositMethod = depositMethod + self.authorizedDepositHandler = authorizedDepositHandler self.eligibleNFTTypes = eligibleNFTTypes self.metadata = {} } } + /// Get the receiver by name + /// + access(all) fun getReceiver(name: String): Receiver? { + return NFTLocker.borrowAdminReceiverCollectorPublic()!.getReceiver(name: name) + } + /// ReceiverCollector resource /// /// Note: This resource is used to store receivers and corresponding deposit methods; currently, only @@ -163,7 +175,7 @@ access(all) contract NFTLocker { /// access(Operate) fun addReceiver( name: String, - depositMethod: fun(@{NonFungibleToken.NFT}, LockedData, {String: AnyStruct}), + authorizedDepositHandler: {IAuthorizedDepositHandler}, eligibleNFTTypes: {Type: Bool} ) { pre { @@ -172,7 +184,7 @@ access(all) contract NFTLocker { // Add the receiver self.receiversByName[name] = Receiver( - depositMethod: depositMethod, + authorizedDepositHandler: authorizedDepositHandler, eligibleNFTTypes: eligibleNFTTypes ) @@ -347,9 +359,9 @@ access(all) contract NFTLocker { NFTLocker.expireLock(id: id, nftType: nftType) // Unlock and deposit the NFT using the receiver's deposit method - receiverCollector.getReceiver(name: receiverName)!.depositMethod( + receiverCollector.getReceiver(name: receiverName)!.authorizedDepositHandler.deposit( nft: <- self.unlock(id: id, nftType: nftType), - lockedTokenDetails: lockedTokenDetails, + ownerAddress: lockedTokenDetails.owner, passThruParams: passThruParams, ) } diff --git a/locked-nft/lib/go/test/lockednft_test.go b/locked-nft/lib/go/test/lockednft_test.go index fc60825..c3cdc18 100644 --- a/locked-nft/lib/go/test/lockednft_test.go +++ b/locked-nft/lib/go/test/lockednft_test.go @@ -332,6 +332,52 @@ func testAdminAddReceiver( 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, @@ -347,6 +393,40 @@ func testAdminAddReceiver( 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 { @@ -362,7 +442,6 @@ func testAdminAddReceiver( return err }() assert.Error(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 9adde67..3915df7 100644 --- a/locked-nft/lib/go/test/templates.go +++ b/locked-nft/lib/go/test/templates.go @@ -43,12 +43,16 @@ const ( MetadataNFTReplaceAddress = `"NonFungibleToken"` // NFTLocker - 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" - 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" + UnlockNFTWithAuthorizedDepositTxPath = TransactionsRootPath + "/unlock_nft_with_authorized_deposit.cdc" + AdminUnlockNFTTxPath = TransactionsRootPath + "/admin_unlock_nft.cdc" + + // Escrow + CreateLeaderboardTxPath = TransactionsRootPath + "/testutils/create_leaderboard.cdc" ) // ------------------------------------------------------------ @@ -78,11 +82,12 @@ func LoadNFTLockerContract(nftAddress flow.Address, metadataViewsAddress flow.Ad return code } -func LoadEscrowContract(nftAddress flow.Address, metadataViewsAddress flow.Address) []byte { +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 } @@ -146,6 +151,13 @@ func adminAddReceiverTransaction(contracts Contracts) []byte { ) } +func unlockNFTWithAuthorizedDepositTransaction(contracts Contracts) []byte { + return replaceAddresses( + readFile(UnlockNFTWithAuthorizedDepositTxPath), + contracts, + ) +} + func adminUnlockNFTTransaction(contracts Contracts) []byte { return replaceAddresses( readFile(AdminUnlockNFTTxPath), @@ -153,6 +165,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 440ed0b..da88af0 100644 --- a/locked-nft/lib/go/test/test.go +++ b/locked-nft/lib/go/test/test.go @@ -75,8 +75,6 @@ func NFTLockerDeployContracts(t *testing.T, b *emulator.Blockchain) Contracts { ExampleNFTCode := nftcontracts.ExampleNFT(nftAddress, metadataViewsAddr, resolverAddress) - EscrowCode := LoadEscrowContract(nftAddress, metadataViewsAddr) - NFTLockerAddress, err := adapter.CreateAccount( context.Background(), []*flow.AccountKey{NFTLockerAccountKey}, @@ -84,6 +82,8 @@ 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) diff --git a/locked-nft/lib/go/test/transactions.go b/locked-nft/lib/go/test/transactions.go index a84d9fa..8bb679a 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,12 @@ 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, ) - fmt.Println(txResult) } func adminAddReceiver( @@ -143,6 +142,36 @@ func adminAddReceiver( ) } +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, + ) +} + func adminUnlockNFT( t *testing.T, b *emulator.Blockchain, @@ -166,3 +195,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 index bb906a6..23452fe 100644 --- a/locked-nft/transactions/admin_add_escrow_receiver.cdc +++ b/locked-nft/transactions/admin_add_escrow_receiver.cdc @@ -9,9 +9,6 @@ transaction() { // Auhtorized reference to the NFTLocker ReceiverCollector resource let receiverCollectorRef: auth(NFTLocker.Operate) &NFTLocker.ReceiverCollector - // Deposit method to be added to the ReceiverCollector resource - let depositMethod: fun(@{NonFungibleToken.NFT}, NFTLocker.LockedData, {String: AnyStruct}) - prepare(admin: auth(SaveValue, BorrowValue) &Account) { // Check if the ReceiverCollector resource does not exist if NFTLocker.borrowAdminReceiverCollectorPublic() == nil { @@ -27,30 +24,13 @@ transaction() { self.receiverCollectorRef = admin.storage .borrow(from: NFTLocker.getReceiverCollectorStoragePath()) ?? panic("Could not borrow a reference to the owner's collection") - - // Define the deposit method to be used by the Receiver - self.depositMethod = fun(nft: @{NonFungibleToken.NFT}, lockedTokenDetails: NFTLocker.LockedData, passThruParams: {String: AnyStruct}) { - // Get leaderboard name from pass-thru parameters - let leaderboardName = passThruParams["leaderboardName"] as? String - ?? panic("Missing or invalid leaderboard name") - - // Get the Escrow contract account - let escrowAccount = getAccount(Address.fromString(Type().identifier.slice(from: 2, upTo: 18))!) - - // Get the Escrow Collection public reference - let escrowCollectionPublic = escrowAccount.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: lockedTokenDetails.owner) - } } execute { - // Add a new receiver to the ReceiverCollector with the provided deposit method and accepted NFT types + // Add a new receiver to the ReceiverCollector with the provided deposit wrapper and accepted NFT types self.receiverCollectorRef.addReceiver( name: "add-entry-to-escrow-leaderboard", - depositMethod: self.depositMethod, + authorizedDepositHandler: Escrow.DepositHandler(), eligibleNFTTypes: {Type<@ExampleNFT.NFT>(): true} ) } 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..6ddd1f5 --- /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") + + // let escrowAccount = getAccount({{0xEscrowAddress}}) + 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, + ) + } + } +} \ No newline at end of file diff --git a/locked-nft/transactions/unlock_with_authorized_deposit.cdc b/locked-nft/transactions/unlock_with_authorized_deposit.cdc deleted file mode 100644 index 55b4c66..0000000 --- a/locked-nft/transactions/unlock_with_authorized_deposit.cdc +++ /dev/null @@ -1,65 +0,0 @@ -import NonFungibleToken from "NonFungibleToken" -import ExampleNFT from "ExampleNFT" -import NFTLocker from "NFTLocker" -import Escrow from "Escrow" - -/// This transaction adds NFTs to an escrow leaderboard, unlocking them if necessary. -/// -transaction(leaderboardName: String, nftIDs: [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 - - // Get the public leaderboard collection - let escrowAccount = getAccount({{0xEscrowAddress}}) - self.collectionPublic = escrowAccount.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 each NFT to the leaderboard - for nftID in nftIDs { - // Check if the NFT is locked - 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: userLockerCollection!.unlock(id: nftID, nftType: nftType), - leaderboardName: leaderboardName, - ownerAddress: self.ownerAddress, - ) - } else { - 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, - ) - } - } - } -} \ No newline at end of file From f57418d56373f74a47c2b68dcda2381ecc7f1c9b Mon Sep 17 00:00:00 2001 From: loic1 <17323063+loic1@users.noreply.github.com> Date: Tue, 15 Oct 2024 18:21:47 -0600 Subject: [PATCH 4/8] update comments, fix tests --- escrow/lib/go/test/templates.go | 16 ++++++++- escrow/lib/go/test/test.go | 34 ++++++++++++++++--- locked-nft/contracts/NFTLocker.cdc | 12 ++----- .../admin_add_escrow_receiver.cdc | 4 +-- .../unlock_nft_with_authorized_deposit.cdc | 2 +- 5 files changed, 51 insertions(+), 17 deletions(-) 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 165b111..8686f21 100644 --- a/locked-nft/contracts/NFTLocker.cdc +++ b/locked-nft/contracts/NFTLocker.cdc @@ -122,7 +122,7 @@ access(all) contract NFTLocker { /// Receivers are entities that can receive locked NFTs and deposit them using a specific deposit method /// access(all) struct Receiver { - /// Handler for depositing NFTs to the receiver + /// Handler for depositing NFTs for the receiver /// access(all) var authorizedDepositHandler: {IAuthorizedDepositHandler} @@ -146,16 +146,10 @@ access(all) contract NFTLocker { } } - /// Get the receiver by name - /// - access(all) fun getReceiver(name: String): Receiver? { - return NFTLocker.borrowAdminReceiverCollectorPublic()!.getReceiver(name: name) - } - /// ReceiverCollector resource /// - /// Note: This resource is used to store receivers and corresponding deposit methods; currently, only - /// the admin account can add or remove receivers - in the future, a ReceiverProvider resource could + /// 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 { diff --git a/locked-nft/transactions/admin_add_escrow_receiver.cdc b/locked-nft/transactions/admin_add_escrow_receiver.cdc index 23452fe..0ce3fbf 100644 --- a/locked-nft/transactions/admin_add_escrow_receiver.cdc +++ b/locked-nft/transactions/admin_add_escrow_receiver.cdc @@ -6,7 +6,7 @@ import ExampleNFT from "ExampleNFT" /// This transaction creates a new ReceiverCollector resource and adds a new receiver to it with a deposit method that adds an NFT to an escrow leaderboard. /// transaction() { - // Auhtorized reference to the NFTLocker ReceiverCollector resource + // Authorized reference to the NFTLocker ReceiverCollector resource let receiverCollectorRef: auth(NFTLocker.Operate) &NFTLocker.ReceiverCollector prepare(admin: auth(SaveValue, BorrowValue) &Account) { @@ -34,4 +34,4 @@ transaction() { eligibleNFTTypes: {Type<@ExampleNFT.NFT>(): true} ) } -} \ No newline at end of file +} diff --git a/locked-nft/transactions/unlock_nft_with_authorized_deposit.cdc b/locked-nft/transactions/unlock_nft_with_authorized_deposit.cdc index 6ddd1f5..d7e1cd6 100644 --- a/locked-nft/transactions/unlock_nft_with_authorized_deposit.cdc +++ b/locked-nft/transactions/unlock_nft_with_authorized_deposit.cdc @@ -62,4 +62,4 @@ transaction(leaderboardName: String, nftID: UInt64) { ) } } -} \ No newline at end of file +} From 1c5c8eb59f3957eaa8c085f5e61a6dee13f30ddf Mon Sep 17 00:00:00 2001 From: loic1 <17323063+loic1@users.noreply.github.com> Date: Wed, 16 Oct 2024 08:11:16 -0600 Subject: [PATCH 5/8] revert GetAdminStoragePath naming fix, update comments --- locked-nft/contracts/NFTLocker.cdc | 74 ++++++++++--------- .../admin_add_escrow_receiver.cdc | 12 +-- locked-nft/transactions/admin_unlock_nft.cdc | 2 +- 3 files changed, 46 insertions(+), 42 deletions(-) diff --git a/locked-nft/contracts/NFTLocker.cdc b/locked-nft/contracts/NFTLocker.cdc index 8686f21..137b77f 100644 --- a/locked-nft/contracts/NFTLocker.cdc +++ b/locked-nft/contracts/NFTLocker.cdc @@ -95,7 +95,7 @@ access(all) contract NFTLocker { /// The path to the Admin resource belonging to the account where this contract is deployed /// - access(all) view fun getAdminStoragePath(): StoragePath { + access(all) view fun GetAdminStoragePath(): StoragePath { return /storage/NFTLockerAdmin } @@ -165,7 +165,7 @@ access(all) contract NFTLocker { /// access(self) let metadata: {String: AnyStruct} - /// Add a new deposit method for a given NFT type + /// Add a deposit handler for given NFT types /// access(Operate) fun addReceiver( name: String, @@ -211,7 +211,7 @@ access(all) contract NFTLocker { self.receiversByName.remove(key: name) } - /// Get the deposit method for the given name if it exists + /// Get the receiver for the given name if it exists /// access(all) view fun getReceiver(name: String): Receiver? { return self.receiversByName[name] @@ -241,19 +241,22 @@ access(all) contract NFTLocker { NFTLocker.expireLock(id: id, nftType: nftType) } - /// Create and return a new ReceiverCollector resource + /// Create and return a ReceiverCollector resource /// access(all) fun createReceiverCollector(): @ReceiverCollector { return <- create ReceiverCollector() } } - /// Expire lock if the locked NFT is eligible for force unlock and deposit by authorized receiver + /// 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 { - // remove old locked data and insert new one with duration 0 + // Update locked data's duration to 0 if let oldLockedData = locker.remove(key: id){ locker.insert( key: id, @@ -292,7 +295,7 @@ access(all) contract NFTLocker { /// An NFT Collection /// access(all) resource Collection: LockedCollection, LockProvider { - /// Locked NFTs + /// This collection's locked NFTs /// access(all) var lockedNFTs: @{Type: {UInt64: {NonFungibleToken.NFT}}} @@ -303,22 +306,19 @@ access(all) contract NFTLocker { NFTLocker.canUnlockToken(id: id, nftType: nftType): "locked duration has not been met" } - // Get the locked token - let token <- self.lockedNFTs[nftType]?.remove(key: id)!! - - // Remove the locked data + // Remove the token's locked data if let lockedTokens = &NFTLocker.lockedTokens[nftType] as auth(Remove) &{UInt64: NFTLocker.LockedData}? { lockedTokens.remove(key: id) } - // Decrement the total locked tokens + // Decrement the locked tokens count NFTLocker.totalLockedTokens = NFTLocker.totalLockedTokens - 1 // Emit events - emit NFTUnlocked(id: token.id, from: self.owner?.address, nftType: nftType) - emit Withdraw(id: token.id, from: self.owner?.address) + emit NFTUnlocked(id: id, from: self.owner?.address, nftType: nftType) + emit Withdraw(id: id, from: self.owner?.address) - return <-token + return <- self.lockedNFTs[nftType]?.remove(key: id)!! } /// Force unlock the NFT with the given id and type, and deposit it using the receiver's deposit method; @@ -335,21 +335,21 @@ access(all) contract NFTLocker { let lockedTokenDetails = NFTLocker.getNFTLockerDetails(id: id, nftType: nftType) ?? panic("No locked token found for the given id and NFT type") - // Get the receiver collector, panic if it doesn't exist + // 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 name for the given NFT type, panic if it doesn't exist - let receiverNames = receiverCollector.getReceiverNamesByNFTType(nftType: nftType) + // 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 is authorized to receive the NFT + // Verify that the receiver with the given name is authorized assert( - receiverNames[receiverName] == true, + nftTypeReceivers[receiverName] == true, message: "Provided receiver does not exist or is not authorized for the given NFT type" ) - // Expire the lock + // Expire the NFT's lock NFTLocker.expireLock(id: id, nftType: nftType) // Unlock and deposit the NFT using the receiver's deposit method @@ -360,28 +360,34 @@ access(all) contract NFTLocker { ) } - /// Lock an NFT of a given type + /// Lock the given NFT for the specified duration /// access(Operate) fun lock(token: @{NonFungibleToken.NFT}, duration: UInt64) { + // Get the NFT's id and type let nftId: UInt64 = token.id let nftType: Type = token.getType() - if NFTLocker.lockedTokens[nftType] == nil { - NFTLocker.lockedTokens[nftType] = {} - } - + // Initialize the collection's locked NFTs for the given type if it doesn't exist if self.lockedNFTs[nftType] == nil { self.lockedNFTs[nftType] <-! {} } - // Get a reference to the nested map - let ref = &self.lockedNFTs[nftType] as auth(Insert) &{UInt64: {NonFungibleToken.NFT}}? + // Initialize the contract's locked tokens data for the given type if it doesn't exist + if NFTLocker.lockedTokens[nftType] == nil { + NFTLocker.lockedTokens[nftType] = {} + } - let oldToken <- ref!.insert(key: nftId, <- token) + // Get a reference to this collection's locked NFTs map + let collectionLockedNFTsRef = &self.lockedNFTs[nftType] as auth(Insert) &{UInt64: {NonFungibleToken.NFT}}? - let nestedLockRef = &NFTLocker.lockedTokens[nftType] as auth(Insert) &{UInt64: NFTLocker.LockedData}? + // 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) - // Create a new locked data + // 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") + + // Create locked data let lockedData = NFTLocker.LockedData( id: nftId, owner: self.owner!.address, @@ -390,7 +396,7 @@ access(all) contract NFTLocker { ) // Insert the locked data - nestedLockRef!.insert(key: nftId, lockedData) + lockedTokensDataRef.insert(key: nftId, lockedData) // Increment the total locked tokens NFTLocker.totalLockedTokens = NFTLocker.totalLockedTokens + 1 @@ -406,8 +412,6 @@ access(all) contract NFTLocker { ) emit Deposit(id: nftId, to: self.owner?.address) - - destroy oldToken } /// Get the ids of NFTs locked for a given type @@ -437,7 +441,7 @@ access(all) contract NFTLocker { self.CollectionPublicPath = /public/NFTLockerCollection // Create and save the admin resource - self.account.storage.save(<- create Admin(), to: NFTLocker.getAdminStoragePath()) + self.account.storage.save(<- create Admin(), to: NFTLocker.GetAdminStoragePath()) // Set contract variables self.totalLockedTokens = 0 diff --git a/locked-nft/transactions/admin_add_escrow_receiver.cdc b/locked-nft/transactions/admin_add_escrow_receiver.cdc index 0ce3fbf..d3fcb51 100644 --- a/locked-nft/transactions/admin_add_escrow_receiver.cdc +++ b/locked-nft/transactions/admin_add_escrow_receiver.cdc @@ -3,31 +3,31 @@ import NFTLocker from "NFTLocker" import Escrow from "Escrow" import ExampleNFT from "ExampleNFT" -/// This transaction creates a new ReceiverCollector resource and adds a new receiver to it with a deposit method that adds an NFT to an escrow leaderboard. +/// 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 prepare(admin: auth(SaveValue, BorrowValue) &Account) { - // Check if the ReceiverCollector resource does not exist + // 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()) + let adminRef = admin.storage.borrow<&NFTLocker.Admin>(from: NFTLocker.GetAdminStoragePath()) ?? panic("Could not borrow a reference to the owner's collection") - // Create a new ReceiverCollector resource and save it in storage + // Create a ReceiverCollector resource and save it in storage admin.storage.save(<- adminRef.createReceiverCollector(), to: NFTLocker.getReceiverCollectorStoragePath()) } - // Borrow an authorized reference to the NFTLocker ReceiverCollector resource + // 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 new receiver to the ReceiverCollector with the provided deposit wrapper and accepted NFT types + // Add a receiver to the ReceiverCollector with the provided namw, deposit handler, and accepted NFT types self.receiverCollectorRef.addReceiver( name: "add-entry-to-escrow-leaderboard", authorizedDepositHandler: Escrow.DepositHandler(), diff --git a/locked-nft/transactions/admin_unlock_nft.cdc b/locked-nft/transactions/admin_unlock_nft.cdc index f12d8f5..e4853cd 100644 --- a/locked-nft/transactions/admin_unlock_nft.cdc +++ b/locked-nft/transactions/admin_unlock_nft.cdc @@ -6,7 +6,7 @@ transaction(id: UInt64) { prepare(signer: auth(BorrowValue) &Account) { self.adminRef = signer.storage - .borrow<&NFTLocker.Admin>(from: NFTLocker.getAdminStoragePath()) + .borrow<&NFTLocker.Admin>(from: NFTLocker.GetAdminStoragePath()) ?? panic("Could not borrow a reference to the owner's collection") } From 991a9d61e56740a303438dfc35daa388a667d844 Mon Sep 17 00:00:00 2001 From: loic1 <17323063+loic1@users.noreply.github.com> Date: Wed, 16 Oct 2024 11:06:19 -0600 Subject: [PATCH 6/8] add test, update events --- locked-nft/contracts/NFTLocker.cdc | 49 +++++--- locked-nft/lib/go/test/lockednft_test.go | 108 +++++++++++++++++- locked-nft/lib/go/test/templates.go | 8 ++ locked-nft/lib/go/test/transactions.go | 22 ++++ .../admin_remove_escrow_receiver.cdc | 23 ++++ 5 files changed, 194 insertions(+), 16 deletions(-) create mode 100644 locked-nft/transactions/admin_remove_escrow_receiver.cdc diff --git a/locked-nft/contracts/NFTLocker.cdc b/locked-nft/contracts/NFTLocker.cdc index 137b77f..b92d974 100644 --- a/locked-nft/contracts/NFTLocker.cdc +++ b/locked-nft/contracts/NFTLocker.cdc @@ -22,8 +22,11 @@ access(all) contract NFTLocker { access(all) event NFTUnlocked( id: UInt64, from: Address?, - nftType: Type + nftType: Type, + isAuthorizedDeposit: Bool ) + access(all) event ReceiverAdded(name: String, eligibleNFTTypes: {Type: Bool}) + access(all) event ReceiverRemoved(name: String, eligibleNFTTypes: {Type: Bool}) /// Named Paths /// @@ -191,6 +194,9 @@ access(all) contract NFTLocker { self.receiverNamesByNFTType[nftType] = {name: true} } } + + // Emit event + emit ReceiverAdded(name: name, eligibleNFTTypes: eligibleNFTTypes) } /// Remove a deposit method for a given NFT type @@ -209,6 +215,9 @@ access(all) contract NFTLocker { // 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 @@ -306,19 +315,7 @@ access(all) contract NFTLocker { NFTLocker.canUnlockToken(id: id, nftType: nftType): "locked duration has not been met" } - // 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: id, from: self.owner?.address, nftType: nftType) - emit Withdraw(id: id, from: self.owner?.address) - - return <- self.lockedNFTs[nftType]?.remove(key: id)!! + return <- self.withdrawFromLockedNFTs(id: id, nftType: nftType, isAuthorizedDeposit: false) } /// Force unlock the NFT with the given id and type, and deposit it using the receiver's deposit method; @@ -331,6 +328,10 @@ access(all) contract NFTLocker { 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") @@ -354,12 +355,30 @@ access(all) contract NFTLocker { // Unlock and deposit the NFT using the receiver's deposit method receiverCollector.getReceiver(name: receiverName)!.authorizedDepositHandler.deposit( - nft: <- self.unlock(id: id, nftType: nftType), + nft: <- self.withdrawFromLockedNFTs(id: id, nftType: nftType, isAuthorizedDeposit: true), 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, isAuthorizedDeposit: Bool): @{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: id, from: self.owner?.address, nftType: nftType, isAuthorizedDeposit: isAuthorizedDeposit) + emit Withdraw(id: id, from: self.owner?.address) + + return <- self.lockedNFTs[nftType]?.remove(key: id)!! + } + /// Lock the given NFT for the specified duration /// access(Operate) fun lock(token: @{NonFungibleToken.NFT}, duration: UInt64) { diff --git a/locked-nft/lib/go/test/lockednft_test.go b/locked-nft/lib/go/test/lockednft_test.go index c3cdc18..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,7 @@ func testUnlockNFT( return err }() assert.Error(t, err) - + assert.True(t, strings.Contains(err.Error(), "cadence.Value is nil")) } func TestAdminAddReceiver(t *testing.T) { @@ -442,6 +443,111 @@ func testUnlockWithAuthorizedDeposit( 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 3915df7..9a2ffa6 100644 --- a/locked-nft/lib/go/test/templates.go +++ b/locked-nft/lib/go/test/templates.go @@ -48,6 +48,7 @@ const ( 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" @@ -151,6 +152,13 @@ func adminAddReceiverTransaction(contracts Contracts) []byte { ) } +func adminRemoveReceiverTransaction(contracts Contracts) []byte { + return replaceAddresses( + readFile(AdminRemoveReceiverTxPath), + contracts, + ) +} + func unlockNFTWithAuthorizedDepositTransaction(contracts Contracts) []byte { return replaceAddresses( readFile(UnlockNFTWithAuthorizedDepositTxPath), diff --git a/locked-nft/lib/go/test/transactions.go b/locked-nft/lib/go/test/transactions.go index 8bb679a..6f8c433 100644 --- a/locked-nft/lib/go/test/transactions.go +++ b/locked-nft/lib/go/test/transactions.go @@ -142,6 +142,28 @@ func adminAddReceiver( ) } +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, 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..6076977 --- /dev/null +++ b/locked-nft/transactions/admin_remove_escrow_receiver.cdc @@ -0,0 +1,23 @@ +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 + + prepare(admin: auth(BorrowValue) &Account) { + // 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: "add-entry-to-escrow-leaderboard", + ) + } +} From 72d46cb4dc83c6d054ff821cb138ca87b75f2936 Mon Sep 17 00:00:00 2001 From: loic1 <17323063+loic1@users.noreply.github.com> Date: Wed, 16 Oct 2024 11:14:27 -0600 Subject: [PATCH 7/8] clean up comment --- locked-nft/transactions/unlock_nft_with_authorized_deposit.cdc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/locked-nft/transactions/unlock_nft_with_authorized_deposit.cdc b/locked-nft/transactions/unlock_nft_with_authorized_deposit.cdc index d7e1cd6..7e3ea3c 100644 --- a/locked-nft/transactions/unlock_nft_with_authorized_deposit.cdc +++ b/locked-nft/transactions/unlock_nft_with_authorized_deposit.cdc @@ -24,7 +24,7 @@ transaction(leaderboardName: String, nftID: UInt64) { let escrowAdress = Address.fromString("0x".concat(Type().identifier.slice(from: 2, upTo: 18))) ?? panic("Could not convert the address") - // let escrowAccount = getAccount({{0xEscrowAddress}}) + // 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") From b1df87ccc3f19338946180dc0b862843948e381a Mon Sep 17 00:00:00 2001 From: loic1 <17323063+loic1@users.noreply.github.com> Date: Tue, 22 Oct 2024 15:21:01 -0600 Subject: [PATCH 8/8] add event param --- locked-nft/contracts/NFTLocker.cdc | 22 ++++++++++++++----- .../admin_add_escrow_receiver.cdc | 12 +++++++++- .../admin_remove_escrow_receiver.cdc | 14 +++++++++--- 3 files changed, 39 insertions(+), 9 deletions(-) diff --git a/locked-nft/contracts/NFTLocker.cdc b/locked-nft/contracts/NFTLocker.cdc index b92d974..6e2d335 100644 --- a/locked-nft/contracts/NFTLocker.cdc +++ b/locked-nft/contracts/NFTLocker.cdc @@ -23,7 +23,8 @@ access(all) contract NFTLocker { id: UInt64, from: Address?, nftType: Type, - isAuthorizedDeposit: Bool + 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}) @@ -315,7 +316,7 @@ access(all) contract NFTLocker { NFTLocker.canUnlockToken(id: id, nftType: nftType): "locked duration has not been met" } - return <- self.withdrawFromLockedNFTs(id: id, nftType: nftType, isAuthorizedDeposit: false) + 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; @@ -355,7 +356,12 @@ access(all) contract NFTLocker { // Unlock and deposit the NFT using the receiver's deposit method receiverCollector.getReceiver(name: receiverName)!.authorizedDepositHandler.deposit( - nft: <- self.withdrawFromLockedNFTs(id: id, nftType: nftType, isAuthorizedDeposit: true), + nft: <- self.withdrawFromLockedNFTs( + id: id, + nftType: nftType, + receiverName: receiverName, + lockedUntilBeforeEarlyUnlock: lockedTokenDetails.lockedUntil + ), ownerAddress: lockedTokenDetails.owner, passThruParams: passThruParams, ) @@ -363,7 +369,7 @@ access(all) contract NFTLocker { /// Withdraw the NFT with the given id and type, used in the unlock and unlockWithAuthorizedDeposit functions /// - access(self) fun withdrawFromLockedNFTs(id: UInt64, nftType: Type, isAuthorizedDeposit: Bool): @{NonFungibleToken.NFT} { + 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) @@ -373,7 +379,13 @@ access(all) contract NFTLocker { NFTLocker.totalLockedTokens = NFTLocker.totalLockedTokens - 1 // Emit events - emit NFTUnlocked(id: id, from: self.owner?.address, nftType: nftType, isAuthorizedDeposit: isAuthorizedDeposit) + emit NFTUnlocked( + id: id, + from: self.owner?.address, + nftType: nftType, + receiverName: receiverName, + lockedUntilBeforeEarlyUnlock: lockedUntilBeforeEarlyUnlock + ) emit Withdraw(id: id, from: self.owner?.address) return <- self.lockedNFTs[nftType]?.remove(key: id)!! diff --git a/locked-nft/transactions/admin_add_escrow_receiver.cdc b/locked-nft/transactions/admin_add_escrow_receiver.cdc index d3fcb51..be33176 100644 --- a/locked-nft/transactions/admin_add_escrow_receiver.cdc +++ b/locked-nft/transactions/admin_add_escrow_receiver.cdc @@ -9,7 +9,13 @@ 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 @@ -29,9 +35,13 @@ transaction() { execute { // Add a receiver to the ReceiverCollector with the provided namw, deposit handler, and accepted NFT types self.receiverCollectorRef.addReceiver( - name: "add-entry-to-escrow-leaderboard", + 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 index 6076977..a0c25a8 100644 --- a/locked-nft/transactions/admin_remove_escrow_receiver.cdc +++ b/locked-nft/transactions/admin_remove_escrow_receiver.cdc @@ -7,7 +7,13 @@ 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()) @@ -16,8 +22,10 @@ transaction() { execute { // Add a receiver to the ReceiverCollector with the provided namw, deposit handler, and accepted NFT types - self.receiverCollectorRef.removeReceiver( - name: "add-entry-to-escrow-leaderboard", - ) + self.receiverCollectorRef.removeReceiver(name: self.receiverName) + } + + post { + self.receiverCollectorRef.getReceiver(name: self.receiverName) == nil : "Receiver not removed" } }