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