--- a/pallets/fungible/src/lib.rs +++ b/pallets/fungible/src/lib.rs @@ -379,7 +379,7 @@ let balance_from = >::get((collection.id, from)) .checked_sub(amount) .ok_or(>::TokenValueTooLow)?; - let balance_to = if from != to { + let balance_to = if from != to && amount != 0 { Some( >::get((collection.id, to)) .checked_add(amount) @@ -391,16 +391,17 @@ // ========= - >::nest_if_sent_to_token( - from.clone(), - to, - collection.id, - TokenId::default(), - nesting_budget, - )?; - if let Some(balance_to) = balance_to { - // from != to + // from != to && amount != 0 + + >::nest_if_sent_to_token( + from.clone(), + to, + collection.id, + TokenId::default(), + nesting_budget, + )?; + if balance_from == 0 { >::remove((collection.id, from)); >::unnest_if_nested(from, collection.id, TokenId::default()); --- a/pallets/nonfungible/src/common.rs +++ b/pallets/nonfungible/src/common.rs @@ -291,6 +291,7 @@ >::burn_item(), ) } else { + >::check_token_immediate_ownership(self, token, &sender)?; Ok(().into()) } } @@ -320,6 +321,7 @@ >::transfer(), ) } else { + >::check_token_immediate_ownership(self, token, &from)?; Ok(().into()) } } @@ -360,6 +362,8 @@ >::transfer_from(), ) } else { + >::check_allowed(self, &sender, &from, token, nesting_budget)?; + Ok(().into()) } } @@ -380,6 +384,8 @@ >::burn_from(), ) } else { + >::check_allowed(self, &sender, &from, token, nesting_budget)?; + Ok(().into()) } } --- a/pallets/nonfungible/src/lib.rs +++ b/pallets/nonfungible/src/lib.rs @@ -814,6 +814,20 @@ >::set_property_permission(collection, sender, permission) } + pub fn check_token_immediate_ownership( + collection: &NonfungibleHandle, + token: TokenId, + possible_owner: &T::CrossAccountId, + ) -> DispatchResult { + let token_data = + >::get((collection.id, token)).ok_or(>::TokenNotFound)?; + ensure!( + &token_data.owner == possible_owner, + >::NoPermission + ); + Ok(()) + } + /// Transfer NFT token from one account to another. /// /// `from` account stops being the owner and `to` account becomes the owner of the token. --- a/pallets/refungible/src/erc.rs +++ b/pallets/refungible/src/erc.rs @@ -34,6 +34,7 @@ CollectionHandle, CollectionPropertyPermissions, CommonCollectionOperations, erc::{CommonEvmHandler, CollectionCall, static_property::key}, eth::EthCrossAccount, + Error as CommonError, }; use pallet_evm::{account::CrossAccountId, PrecompileHandle}; use pallet_evm_coder_substrate::{call, dispatch_to_evm}; @@ -508,6 +509,13 @@ ) -> Result<()> { collection.consume_store_reads(1)?; let total_supply = >::get((collection.id, token)); + + if owner_balance == 0 { + return Err(dispatch_to_evm::( + >::MustBeTokenOwner.into(), + )); + } + if total_supply != owner_balance { return Err("token has multiple owners".into()); } --- a/pallets/refungible/src/lib.rs +++ b/pallets/refungible/src/lib.rs @@ -452,6 +452,10 @@ token: TokenId, amount: u128, ) -> DispatchResult { + if >::get((collection.id, token, owner)) == 0 { + return Err(>::TokenValueTooLow.into()); + } + let total_supply = >::get((collection.id, token)) .checked_sub(amount) .ok_or(>::TokenValueTooLow)?; @@ -739,12 +743,17 @@ >::ensure_correct_receiver(to)?; let initial_balance_from = >::get((collection.id, token, from)); + + if initial_balance_from == 0 { + return Err(>::TokenValueTooLow.into()); + } + let updated_balance_from = initial_balance_from .checked_sub(amount) .ok_or(>::TokenValueTooLow)?; let mut create_target = false; let from_to_differ = from != to; - let updated_balance_to = if from != to { + let updated_balance_to = if from != to && amount != 0 { let old_balance = >::get((collection.id, token, to)); if old_balance == 0 { create_target = true; @@ -786,16 +795,17 @@ // ========= - >::nest_if_sent_to_token( - from.clone(), - to, - collection.id, - token, - nesting_budget, - )?; + if let Some(updated_balance_to) = updated_balance_to { + // from != to && amount != 0 + + >::nest_if_sent_to_token( + from.clone(), + to, + collection.id, + token, + nesting_budget, + )?; - if let Some(updated_balance_to) = updated_balance_to { - // from != to if updated_balance_from == 0 { >::remove((collection.id, token, from)); >::unnest_if_nested(from, collection.id, token); --- a/tests/src/burnItem.test.ts +++ b/tests/src/burnItem.test.ts @@ -140,6 +140,31 @@ await expect(token.burn(bob)).to.be.rejectedWith('common.NoPermission'); }); + itSub.ifWithPallets('RFT: cannot burn non-owned token pieces', [Pallets.ReFungible], async ({helper}) => { + const collection = await helper.rft.mintCollection(alice); + const aliceToken = await collection.mintToken(alice, 10n, {Substrate: alice.address}); + const bobToken = await collection.mintToken(alice, 10n, {Substrate: bob.address}); + + // 1. Cannot burn non-owned token: + await expect(bobToken.burn(alice, 0n)).to.be.rejectedWith('common.TokenValueTooLow'); + await expect(bobToken.burn(alice, 5n)).to.be.rejectedWith('common.TokenValueTooLow'); + // 2. Cannot burn non-existing token: + await expect(helper.rft.burnToken(alice, 99999, 10)).to.be.rejectedWith('common.CollectionNotFound'); + await expect(helper.rft.burnToken(alice, collection.collectionId, 99999)).to.be.rejectedWith('common.TokenValueTooLow'); + // 3. Can burn zero amount of owned tokens (EIP-20) + await aliceToken.burn(alice, 0n); + + // 4. Storage is not corrupted: + expect(await aliceToken.getTop10Owners()).to.deep.eq([{Substrate: alice.address}]); + expect(await bobToken.getTop10Owners()).to.deep.eq([{Substrate: bob.address}]); + + // 4.1 Tokens can be transfered: + await aliceToken.transfer(alice, {Substrate: bob.address}, 10n); + await bobToken.transfer(bob, {Substrate: alice.address}, 10n); + expect(await aliceToken.getTop10Owners()).to.deep.eq([{Substrate: bob.address}]); + expect(await bobToken.getTop10Owners()).to.deep.eq([{Substrate: alice.address}]); + }); + itSub('Transfer a burned token', async ({helper}) => { const collection = await helper.nft.mintCollection(alice); const token = await collection.mintToken(alice); @@ -155,4 +180,48 @@ await expect(collection.burnTokens(alice, 11n)).to.be.rejectedWith('common.TokenValueTooLow'); expect(await collection.getBalance({Substrate: alice.address})).to.eq(10n); }); + + itSub('Zero burn NFT', async ({helper}) => { + const collection = await helper.nft.mintCollection(alice, {name: 'Coll', description: 'Desc', tokenPrefix: 'T'}); + const tokenAlice = await collection.mintToken(alice, {Substrate: alice.address}); + const tokenBob = await collection.mintToken(alice, {Substrate: bob.address}); + + // 1. Zero burn of own tokens allowed: + await helper.executeExtrinsic(alice, 'api.tx.unique.burnItem', [collection.collectionId, tokenAlice.tokenId, 0]); + // 2. Zero burn of non-owned tokens not allowed: + await expect(helper.executeExtrinsic(alice, 'api.tx.unique.burnItem', [collection.collectionId, tokenBob.tokenId, 0])).to.be.rejectedWith('common.NoPermission'); + // 3. Zero burn of non-existing tokens not allowed: + await expect(helper.executeExtrinsic(alice, 'api.tx.unique.burnItem', [collection.collectionId, 9999, 0])).to.be.rejectedWith('common.TokenNotFound'); + expect(await tokenAlice.doesExist()).to.be.true; + expect(await tokenAlice.getOwner()).to.deep.eq({Substrate: alice.address}); + expect(await tokenBob.getOwner()).to.deep.eq({Substrate: bob.address}); + // 4. Storage is not corrupted: + await tokenAlice.transfer(alice, {Substrate: bob.address}); + await tokenBob.transfer(bob, {Substrate: alice.address}); + expect(await tokenAlice.getOwner()).to.deep.eq({Substrate: bob.address}); + expect(await tokenBob.getOwner()).to.deep.eq({Substrate: alice.address}); + }); + + itSub('zero burnFrom NFT', async ({helper}) => { + const collection = await helper.nft.mintCollection(alice, {name: 'Zero', description: 'Zero transfer', tokenPrefix: 'TF'}); + const notApprovedNft = await collection.mintToken(alice, {Substrate: bob.address}); + const approvedNft = await collection.mintToken(alice, {Substrate: bob.address}); + await approvedNft.approve(bob, {Substrate: alice.address}); + + // 1. Zero burnFrom of non-existing tokens not allowed: + await expect(helper.executeExtrinsic(alice, 'api.tx.unique.burnFrom', [collection.collectionId, {Substrate: bob.address}, 9999, 0])).to.be.rejectedWith('common.ApprovedValueTooLow'); + // 2. Zero burnFrom of not approved tokens not allowed: + await expect(helper.executeExtrinsic(alice, 'api.tx.unique.burnFrom', [collection.collectionId, {Substrate: bob.address}, notApprovedNft.tokenId, 0])).to.be.rejectedWith('common.ApprovedValueTooLow'); + // 3. Zero burnFrom of approved tokens allowed: + await helper.executeExtrinsic(alice, 'api.tx.unique.burnFrom', [collection.collectionId, {Substrate: bob.address}, approvedNft.tokenId, 0]); + + // 4.1 approvedNft still approved: + expect(await approvedNft.isApproved({Substrate: alice.address})).to.be.true; + // 4.2 bob is still the owner: + expect(await approvedNft.getOwner()).to.deep.eq({Substrate: bob.address}); + expect(await notApprovedNft.getOwner()).to.deep.eq({Substrate: bob.address}); + // 4.3 Alice can burn approved nft: + await approvedNft.burnFrom(alice, {Substrate: bob.address}); + expect(await approvedNft.doesExist()).to.be.false; + }); }); --- a/tests/src/eth/fungible.test.ts +++ b/tests/src/eth/fungible.test.ts @@ -277,7 +277,7 @@ } }); - itEth('Cannot transferCross() more than have', async ({helper}) => { + ['transfer', 'transferCross'].map(testCase => itEth(`Cannot ${testCase} incorrect amount`, async ({helper}) => { const sender = await helper.eth.createAccountWithBalance(donor); const receiverEth = await helper.eth.createAccountWithBalance(donor); const receiverCrossEth = helper.ethCrossAccount.fromAddress(receiverEth); @@ -289,8 +289,13 @@ const collectionAddress = helper.ethAddress.fromCollectionId(collection.collectionId); const collectionEvm = helper.ethNativeContract.collection(collectionAddress, 'ft', sender); - await expect(collectionEvm.methods.transferCross(receiverCrossEth, BALANCE_TO_TRANSFER).send({from: sender})).to.be.rejected; - }); + // 1. Cannot transfer more than have + const receiver = testCase === 'transfer' ? receiverEth : receiverCrossEth; + await expect(collectionEvm.methods[testCase](receiver, BALANCE_TO_TRANSFER).send({from: sender})).to.be.rejected; + // 2. Zero transfer allowed (EIP-20): + await collectionEvm.methods[testCase](receiver, 0n).send({from: sender}); + })); + itEth('Can perform transfer()', async ({helper}) => { const owner = await helper.eth.createAccountWithBalance(donor); --- a/tests/src/eth/nonFungible.test.ts +++ b/tests/src/eth/nonFungible.test.ts @@ -517,6 +517,26 @@ expect(receiverBalance).to.contain(tokenId); } }); + + ['transfer', 'transferCross'].map(testCase => itEth(`Cannot ${testCase} non-owned token`, async ({helper}) => { + const sender = await helper.eth.createAccountWithBalance(donor); + const tokenOwner = await helper.eth.createAccountWithBalance(donor); + const receiverSub = minter; + const receiverCrossSub = helper.ethCrossAccount.fromKeyringPair(minter); + + const collection = await helper.nft.mintCollection(minter, {}); + const collectionAddress = helper.ethAddress.fromCollectionId(collection.collectionId); + const collectionEvm = helper.ethNativeContract.collection(collectionAddress, 'nft', sender); + + await collection.mintToken(minter, {Ethereum: sender}); + const nonSendersToken = await collection.mintToken(minter, {Ethereum: tokenOwner}); + + // Cannot transferCross someone else's token: + const receiver = testCase === 'transfer' ? helper.address.substrateToEth(receiverSub.address) : receiverCrossSub; + await expect(collectionEvm.methods[testCase](receiver, nonSendersToken.tokenId).send({from: sender})).to.be.rejected; + // Cannot transfer token if it does not exist: + await expect(collectionEvm.methods[testCase](receiver, 999999).send({from: sender})).to.be.rejected; + })); }); describe('NFT: Fees', () => { --- a/tests/src/eth/reFungible.test.ts +++ b/tests/src/eth/reFungible.test.ts @@ -413,9 +413,10 @@ } }); - itEth.skip('Cannot transferCross with invalid params', async ({helper}) => { + ['transfer', 'transferCross'].map(testCase => itEth(`Cannot ${testCase} non-owned token`, async ({helper}) => { const sender = await helper.eth.createAccountWithBalance(donor); const tokenOwner = await helper.eth.createAccountWithBalance(donor); + const receiverSub = minter; const receiverCrossSub = helper.ethCrossAccount.fromKeyringPair(minter); const collection = await helper.rft.mintCollection(minter, {}); @@ -423,12 +424,14 @@ const collectionEvm = helper.ethNativeContract.collection(collectionAddress, 'rft', sender); await collection.mintToken(minter, 50n, {Ethereum: sender}); - const notSendersToken = await collection.mintToken(minter, 50n, {Ethereum: tokenOwner}); + const nonSendersToken = await collection.mintToken(minter, 50n, {Ethereum: tokenOwner}); + // Cannot transferCross someone else's token: - await expect(collectionEvm.methods.transferCross(receiverCrossSub, notSendersToken.tokenId).send({from: sender})).to.be.rejected; - // FIXME: (transaction successful): Cannot transfer token if it does not exist: - await expect(collectionEvm.methods.transferCross(receiverCrossSub, 999999).send({from: sender})).to.be.rejected; - }); + const receiver = testCase === 'transfer' ? helper.address.substrateToEth(receiverSub.address) : receiverCrossSub; + await expect(collectionEvm.methods[testCase](receiver, nonSendersToken.tokenId).send({from: sender})).to.be.rejected; + // Cannot transfer token if it does not exist: + await expect(collectionEvm.methods[testCase](receiver, 999999).send({from: sender})).to.be.rejected; + })); itEth('transfer event on transfer from partial ownership to full ownership', async ({helper}) => { const caller = await helper.eth.createAccountWithBalance(donor); --- a/tests/src/eth/reFungibleToken.test.ts +++ b/tests/src/eth/reFungibleToken.test.ts @@ -227,6 +227,46 @@ } }); + [ + 'transfer', + // 'transferCross', // TODO + ].map(testCase => + itEth(`Cannot ${testCase}() non-owned token`, async ({helper}) => { + const owner = await helper.eth.createAccountWithBalance(donor); + const receiver = await helper.eth.createAccountWithBalance(donor); + const collection = await helper.rft.mintCollection(alice); + const rftOwner = await collection.mintToken(alice, 10n, {Ethereum: owner}); + const rftReceiver = await collection.mintToken(alice, 10n, {Ethereum: receiver}); + const tokenIdNonExist = 9999999; + + const tokenAddress1 = helper.ethAddress.fromTokenId(collection.collectionId, rftOwner.tokenId); + const tokenAddress2 = helper.ethAddress.fromTokenId(collection.collectionId, rftReceiver.tokenId); + const tokenAddressNonExist = helper.ethAddress.fromTokenId(collection.collectionId, tokenIdNonExist); + const tokenEvmOwner = helper.ethNativeContract.rftToken(tokenAddress1, owner); + const tokenEvmReceiver = helper.ethNativeContract.rftToken(tokenAddress2, owner); + const tokenEvmNonExist = helper.ethNativeContract.rftToken(tokenAddressNonExist, owner); + + // 1. Can transfer zero amount (EIP-20): + await tokenEvmOwner.methods[testCase](receiver, 0).send({from: owner}); + // 2. Cannot transfer non-owned token: + await expect(tokenEvmReceiver.methods[testCase](owner, 0).send({from: owner})).to.be.rejected; + await expect(tokenEvmReceiver.methods[testCase](owner, 5).send({from: owner})).to.be.rejected; + // 3. Cannot transfer non-existing token: + await expect(tokenEvmNonExist.methods[testCase](owner, 0).send({from: owner})).to.be.rejected; + await expect(tokenEvmNonExist.methods[testCase](owner, 5).send({from: owner})).to.be.rejected; + + // 4. Storage is not corrupted: + expect(await rftOwner.getTop10Owners()).to.deep.eq([{Ethereum: owner.toLowerCase()}]); + expect(await rftReceiver.getTop10Owners()).to.deep.eq([{Ethereum: receiver.toLowerCase()}]); + expect(await helper.rft.getTokenTop10Owners(collection.collectionId, tokenIdNonExist)).to.deep.eq([]); // TODO + + // 4.1 Tokens can be transferred: + await tokenEvmOwner.methods[testCase](receiver, 10).send({from: owner}); + await tokenEvmReceiver.methods[testCase](owner, 10).send({from: receiver}); + expect(await rftOwner.getTop10Owners()).to.deep.eq([{Ethereum: receiver.toLowerCase()}]); + expect(await rftReceiver.getTop10Owners()).to.deep.eq([{Ethereum: owner.toLowerCase()}]); + })); + itEth('Can perform repartition()', async ({helper}) => { const owner = await helper.eth.createAccountWithBalance(donor); const receiver = await helper.eth.createAccountWithBalance(donor); --- a/tests/src/fungible.test.ts +++ b/tests/src/fungible.test.ts @@ -15,7 +15,7 @@ // along with Unique Network. If not, see . import {IKeyringPair} from '@polkadot/types/types'; -import {itSub, usingPlaygrounds, expect} from './util'; +import {itSub, usingPlaygrounds, expect, requirePalletsOrSkip, Pallets} from './util'; const U128_MAX = (1n << 128n) - 1n; @@ -145,3 +145,42 @@ expect(await collection.getBalance(ethAcc)).to.be.equal(10n); }); }); + +describe('Fungible negative tests', () => { + let donor: IKeyringPair; + let alice: IKeyringPair; + let bob: IKeyringPair; + let charlie: IKeyringPair; + + before(async function() { + await usingPlaygrounds(async (helper, privateKey) => { + requirePalletsOrSkip(this, helper, [Pallets.Fungible]); + + donor = await privateKey({filename: __filename}); + [alice, bob, charlie] = await helper.arrange.createAccounts([100n, 100n, 100n], donor); + }); + }); + + itSub('Cannot transfer incorrect amount of tokens', async ({helper}) => { + const collection = await helper.ft.mintCollection(alice, {name: 'test', description: 'test', tokenPrefix: 'test'}); + const nonExistingCollection = helper.ft.getCollectionObject(99999); + await collection.mint(alice, 10n, {Substrate: bob.address}); + + // 1. Alice cannot transfer more than 0 tokens if balance low: + await expect(collection.transfer(alice, {Substrate: charlie.address}, 1n)).to.be.rejectedWith('common.TokenValueTooLow'); + await expect(collection.transfer(alice, {Substrate: charlie.address}, 100n)).to.be.rejectedWith('common.TokenValueTooLow'); + + // 2. Alice cannot transfer non-existing token: + await expect(nonExistingCollection.transfer(alice, {Substrate: charlie.address}, 0n)).to.be.rejectedWith('common.CollectionNotFound'); + await expect(nonExistingCollection.transfer(alice, {Substrate: charlie.address}, 1n)).to.be.rejectedWith('common.CollectionNotFound'); + + // 3. Zero transfer allowed (EIP-20): + await collection.transfer(bob, {Substrate: charlie.address}, 0n); + // 3.1 even if the balance = 0 + await collection.transfer(alice, {Substrate: charlie.address}, 0n); + + expect(await collection.getBalance({Substrate: alice.address})).to.eq(0n); + expect(await collection.getBalance({Substrate: bob.address})).to.eq(10n); + expect(await collection.getBalance({Substrate: charlie.address})).to.eq(0n); + }); +}); --- a/tests/src/refungible.test.ts +++ b/tests/src/refungible.test.ts @@ -255,3 +255,43 @@ }); }); +describe('Refungible negative tests', () => { + let donor: IKeyringPair; + let alice: IKeyringPair; + let bob: IKeyringPair; + let charlie: IKeyringPair; + + before(async function() { + await usingPlaygrounds(async (helper, privateKey) => { + requirePalletsOrSkip(this, helper, [Pallets.ReFungible]); + + donor = await privateKey({filename: __filename}); + [alice, bob, charlie] = await helper.arrange.createAccounts([100n, 100n, 100n], donor); + }); + }); + + itSub('Cannot transfer incorrect amount of token pieces', async ({helper}) => { + const collection = await helper.rft.mintCollection(alice, {name: 'test', description: 'test', tokenPrefix: 'test'}); + const tokenAlice = await collection.mintToken(alice, 10n, {Substrate: alice.address}); + const tokenBob = await collection.mintToken(alice, 10n, {Substrate: bob.address}); + + // 1. Alice cannot transfer Bob's token: + await expect(tokenBob.transfer(alice, {Substrate: charlie.address}, 0n)).to.be.rejectedWith('common.TokenValueTooLow'); + await expect(tokenBob.transfer(alice, {Substrate: charlie.address}, 1n)).to.be.rejectedWith('common.TokenValueTooLow'); + await expect(tokenBob.transfer(alice, {Substrate: charlie.address}, 10n)).to.be.rejectedWith('common.TokenValueTooLow'); + await expect(tokenBob.transfer(alice, {Substrate: charlie.address}, 100n)).to.be.rejectedWith('common.TokenValueTooLow'); + + // 2. Alice cannot transfer non-existing token: + await expect(collection.transferToken(alice, 100, {Substrate: charlie.address}, 0n)).to.be.rejectedWith('common.TokenValueTooLow'); + await expect(collection.transferToken(alice, 100, {Substrate: charlie.address}, 1n)).to.be.rejectedWith('common.TokenValueTooLow'); + + // 3. Zero transfer allowed (EIP-20): + await tokenAlice.transfer(alice, {Substrate: charlie.address}, 0n); + + expect(await tokenAlice.getTop10Owners()).to.deep.eq([{Substrate: alice.address}]); + expect(await tokenBob.getTop10Owners()).to.deep.eq([{Substrate: bob.address}]); + expect(await tokenAlice.getBalance({Substrate: alice.address})).to.eq(10n); + expect(await tokenBob.getBalance({Substrate: bob.address})).to.eq(10n); + expect(await tokenBob.getBalance({Substrate: charlie.address})).to.eq(0n); + }); +}); --- a/tests/src/transfer.test.ts +++ b/tests/src/transfer.test.ts @@ -122,6 +122,7 @@ }); }); + itSub('[nft] Transfer with not existed collection_id', async ({helper}) => { const collectionId = (1 << 32) - 1; await expect(helper.nft.transferToken(alice, collectionId, 1, {Substrate: bob.address})) @@ -191,6 +192,25 @@ .to.be.rejectedWith(/common\.TokenValueTooLow/); }); + itSub('Zero transfer NFT', async ({helper}) => { + const collection = await helper.nft.mintCollection(alice, {name: 'Transfer-Neg-3-NFT', description: '', tokenPrefix: 'T'}); + const tokenAlice = await collection.mintToken(alice, {Substrate: alice.address}); + const tokenBob = await collection.mintToken(alice, {Substrate: bob.address}); + // 1. Zero transfer of own tokens allowed: + await helper.executeExtrinsic(alice, 'api.tx.unique.transfer', [{Substrate: bob.address}, collection.collectionId, tokenAlice.tokenId, 0]); + // 2. Zero transfer of non-owned tokens not allowed: + await expect(helper.executeExtrinsic(alice, 'api.tx.unique.transfer', [{Substrate: alice.address}, collection.collectionId, tokenBob.tokenId, 0])).to.be.rejectedWith('common.NoPermission'); + // 3. Zero transfer of non-existing tokens not allowed: + await expect(helper.executeExtrinsic(alice, 'api.tx.unique.transfer', [{Substrate: alice.address}, collection.collectionId, 10, 0])).to.be.rejectedWith('common.TokenNotFound'); + expect(await tokenAlice.getOwner()).to.deep.eq({Substrate: alice.address}); + expect(await tokenBob.getOwner()).to.deep.eq({Substrate: bob.address}); + // 4. Storage is not corrupted: + await tokenAlice.transfer(alice, {Substrate: bob.address}); + await tokenBob.transfer(bob, {Substrate: alice.address}); + expect(await tokenAlice.getOwner()).to.deep.eq({Substrate: bob.address}); + expect(await tokenBob.getOwner()).to.deep.eq({Substrate: alice.address}); + }); + itSub('[nft] Transfer with deleted item_id', async ({helper}) => { const collection = await helper.nft.mintCollection(alice, {name: 'Transfer-Neg-3-NFT', description: '', tokenPrefix: 'T'}); const nft = await collection.mintToken(alice); --- a/tests/src/transferFrom.test.ts +++ b/tests/src/transferFrom.test.ts @@ -349,4 +349,27 @@ {Substrate: charlie.address}, )).to.be.rejectedWith(/common\.ApprovedValueTooLow/); }); + + itSub('zero transfer NFT', async ({helper}) => { + const collection = await helper.nft.mintCollection(alice, {name: 'Zero', description: 'Zero transfer', tokenPrefix: 'TF'}); + const notApprovedNft = await collection.mintToken(alice, {Substrate: bob.address}); + const approvedNft = await collection.mintToken(alice, {Substrate: bob.address}); + await approvedNft.approve(bob, {Substrate: alice.address}); + + // 1. Cannot zero transferFrom (non-existing token) + await expect(helper.executeExtrinsic(alice, 'api.tx.unique.transferFrom', [{Substrate: bob.address}, {Substrate: alice.address}, collection.collectionId, 9999, 0])).to.be.rejectedWith('common.ApprovedValueTooLow'); + // 2. Cannot zero transferFrom (not approved token) + await expect(helper.executeExtrinsic(alice, 'api.tx.unique.transferFrom', [{Substrate: bob.address}, {Substrate: alice.address}, collection.collectionId, notApprovedNft.tokenId, 0])).to.be.rejectedWith('common.ApprovedValueTooLow'); + // 3. Can zero transferFrom (approved token): + await helper.executeExtrinsic(alice, 'api.tx.unique.transferFrom', [{Substrate: bob.address}, {Substrate: alice.address}, collection.collectionId, approvedNft.tokenId, 0]); + + // 4.1 approvedNft still approved: + expect(await approvedNft.isApproved({Substrate: alice.address})).to.be.true; + // 4.2 bob is still the owner: + expect(await approvedNft.getOwner()).to.deep.eq({Substrate: bob.address}); + expect(await notApprovedNft.getOwner()).to.deep.eq({Substrate: bob.address}); + // 4.3 Alice can transfer approved nft: + await approvedNft.transferFrom(alice, {Substrate: bob.address}, {Substrate: alice.address}); + expect(await approvedNft.getOwner()).to.deep.eq({Substrate: alice.address}); + }); });