diff --git a/.Lib9c.Tests/Action/TransferItemTest.cs b/.Lib9c.Tests/Action/TransferItemTest.cs new file mode 100644 index 0000000000..c9c0a4d013 --- /dev/null +++ b/.Lib9c.Tests/Action/TransferItemTest.cs @@ -0,0 +1,373 @@ +namespace Lib9c.Tests.Action +{ + using System; + using System.Collections.Generic; + using System.Collections.Immutable; + using System.Linq; + using Bencodex.Types; + using Lib9c.Model.Order; + using Libplanet; + using Libplanet.Action; + using Libplanet.Assets; + using Libplanet.Crypto; + using Nekoyume; + using Nekoyume.Action; + using Nekoyume.Model; + using Nekoyume.Model.Item; + using Nekoyume.Model.Mail; + using Nekoyume.Model.State; + using Serilog; + using Xunit; + using Xunit.Abstractions; + using static Lib9c.SerializeKeys; + + public class TransferItemTest + { + private const long ProductPrice = 100; + + private readonly Address _agentAddress; + private readonly Address _agent2Address; + private readonly Address _avatarAddress; + private readonly Address _avatar2Address; + private readonly Currency _currency; + private readonly AvatarState _avatarState; + private readonly AvatarState _avatar2State; + private readonly TableSheets _tableSheets; + private readonly GoldCurrencyState _goldCurrencyState; + private IAccountStateDelta _initialState; + + public TransferItemTest(ITestOutputHelper outputHelper) + { + Log.Logger = new LoggerConfiguration() + .MinimumLevel.Verbose() + .WriteTo.TestOutput(outputHelper) + .CreateLogger(); + + _initialState = new State(); + var sheets = TableSheetsImporter.ImportSheets(); + foreach (var (key, value) in sheets) + { + _initialState = _initialState + .SetState(Addresses.TableSheet.Derive(key), value.Serialize()); + } + + _tableSheets = new TableSheets(sheets); + + _currency = new Currency("NCG", 2, minters: null); + _goldCurrencyState = new GoldCurrencyState(_currency); + + var shopState = new ShopState(); + + _agentAddress = new PrivateKey().ToAddress(); + var agentState = new AgentState(_agentAddress); + _avatarAddress = new PrivateKey().ToAddress(); + var rankingMapAddress = new PrivateKey().ToAddress(); + _avatarState = new AvatarState( + _avatarAddress, + _agentAddress, + 0, + _tableSheets.GetAvatarSheets(), + new GameConfigState(), + rankingMapAddress) + { + worldInformation = new WorldInformation( + 0, + _tableSheets.WorldSheet, + GameConfig.RequireClearedStageLevel.ActionsInShop), + }; + agentState.avatarAddresses[0] = _avatarAddress; + + _agent2Address = new PrivateKey().ToAddress(); + var agent2State = new AgentState(_agent2Address); + _avatar2Address = new PrivateKey().ToAddress(); + var rankingMap2Address = new PrivateKey().ToAddress(); + _avatar2State = new AvatarState( + _avatar2Address, + _agent2Address, + 0, + _tableSheets.GetAvatarSheets(), + new GameConfigState(), + rankingMapAddress) + { + worldInformation = new WorldInformation( + 0, + _tableSheets.WorldSheet, + GameConfig.RequireClearedStageLevel.ActionsInShop), + }; + agent2State.avatarAddresses[0] = _avatar2Address; + + _initialState = _initialState + .SetState(GoldCurrencyState.Address, _goldCurrencyState.Serialize()) + .SetState(Addresses.Shop, shopState.Serialize()) + .SetState(_agentAddress, agentState.Serialize()) + .SetState(_avatarAddress, _avatarState.Serialize()) + .SetState(_agent2Address, agent2State.Serialize()) + .SetState(_avatar2Address, _avatar2State.Serialize()) + .MintAsset(_agentAddress, _goldCurrencyState.Currency * 10000); + } + + [Theory] + [InlineData(ItemType.Consumable, 1, true)] + [InlineData(ItemType.Costume, 1, false)] + [InlineData(ItemType.Equipment, 1, true)] + [InlineData(ItemType.Material, 1, false)] + public void Execute( + ItemType itemType, + int itemCount, + bool backward + ) + { + var avatarState = _initialState.GetAvatarState(_avatarAddress); + var avatar2State = _initialState.GetAvatarState(_avatar2Address); + + ITradableItem tradableItem; + switch (itemType) + { + case ItemType.Consumable: + tradableItem = ItemFactory.CreateItemUsable( + _tableSheets.ConsumableItemSheet.First, + Guid.NewGuid(), + 0); + break; + case ItemType.Costume: + tradableItem = ItemFactory.CreateCostume( + _tableSheets.CostumeItemSheet.First, + Guid.NewGuid()); + break; + case ItemType.Equipment: + tradableItem = ItemFactory.CreateItemUsable( + _tableSheets.EquipmentItemSheet.First, + Guid.NewGuid(), + 0); + break; + case ItemType.Material: + var tradableMaterialRow = _tableSheets.MaterialItemSheet.OrderedList + .First(row => row.ItemSubType == ItemSubType.Hourglass); + tradableItem = ItemFactory.CreateTradableMaterial(tradableMaterialRow); + break; + default: + throw new ArgumentOutOfRangeException(nameof(itemType), itemType, null); + } + + Assert.Equal(0, tradableItem.RequiredBlockIndex); + avatarState.inventory.AddItem((ItemBase)tradableItem, itemCount); + + var previousStates = _initialState; + if (backward) + { + previousStates = previousStates.SetState(_avatarAddress, avatarState.Serialize()) + .SetState(_avatar2Address, avatar2State.Serialize()); + } + else + { + previousStates = previousStates + .SetState(_avatarAddress.Derive(LegacyInventoryKey), avatarState.inventory.Serialize()) + .SetState(_avatarAddress.Derive(LegacyWorldInformationKey), avatarState.worldInformation.Serialize()) + .SetState(_avatarAddress.Derive(LegacyQuestListKey), avatarState.questList.Serialize()) + .SetState(_avatarAddress, avatarState.SerializeV2()) + .SetState(_avatar2Address.Derive(LegacyInventoryKey), avatar2State.inventory.Serialize()) + .SetState(_avatar2Address.Derive(LegacyWorldInformationKey), avatar2State.worldInformation.Serialize()) + .SetState(_avatar2Address.Derive(LegacyQuestListKey), avatar2State.questList.Serialize()) + .SetState(_avatar2Address, avatar2State.SerializeV2()); + } + + var currencyState = previousStates.GetGoldCurrency(); + var price = new FungibleAssetValue(currencyState, ProductPrice, 0); + var orderId = new Guid("6f460c1a755d48e4ad6765d5f519dbc8"); + var orderAddress = Order.DeriveAddress(orderId); + var shardedShopAddress = ShardedShopStateV2.DeriveAddress( + tradableItem.ItemSubType, + orderId); + long blockIndex = 1; + Assert.Null(previousStates.GetState(shardedShopAddress)); + + var transferItemAction = new TransferItem + { + SenderAvatarAddress = _avatarAddress, + ItemId = tradableItem.TradableId, + RecipientAvatarAddress = _avatar2Address, + }; + var nextState = transferItemAction.Execute( + new ActionContext + { + BlockIndex = blockIndex, + PreviousStates = previousStates, + Rehearsal = false, + Signer = _agentAddress, + Random = new TestRandom(), + }); + + var nextAvatarState = nextState.GetAvatarStateV2(_avatarAddress); + var nextAvatar2State = nextState.GetAvatarStateV2(_avatar2Address); + Assert.Single(nextAvatar2State.inventory.Items); + + Assert.Empty(nextAvatarState.inventory.Items); + //Assert.True(nextAvatarState.inventory.TryGetLockedItem(new OrderLock(orderId), out var inventoryItem)); + Assert.False(nextAvatarState.inventory.TryGetTradableItems(tradableItem.TradableId, blockIndex, itemCount, out _)); + Assert.False(nextAvatarState.inventory.TryGetTradableItems(tradableItem.TradableId, blockIndex, itemCount, out _)); + Assert.True(nextAvatar2State.inventory.TryGetTradableItems(tradableItem.TradableId, blockIndex, itemCount, out _)); + Assert.True(nextAvatar2State.inventory.TryGetTradableItems(tradableItem.TradableId, blockIndex, itemCount, out _)); + //ITradableItem nextTradableItem = (ITradableItem)inventoryItem.item; + //Assert.Equal(expiredBlockIndex, nextTradableItem.RequiredBlockIndex); + + // Check ShardedShopState + //var nextSerializedShardedShopState = nextState.GetState(shardedShopAddress); + //Assert.NotNull(nextSerializedShardedShopState); + //var nextShardedShopState = + // new ShardedShopStateV2((Dictionary)nextSerializedShardedShopState); + //Assert.Single(nextShardedShopState.OrderDigestList); + //var orderDigest = nextShardedShopState.OrderDigestList.First(o => o.OrderId.Equals(orderId)); + //Assert.Equal(price, orderDigest.Price); + //Assert.Equal(blockIndex, orderDigest.StartedBlockIndex); + //Assert.Equal(expiredBlockIndex, orderDigest.ExpiredBlockIndex); + //Assert.Equal(((ItemBase)tradableItem).Id, orderDigest.ItemId); + //Assert.Equal(tradableItem.TradableId, orderDigest.TradableId); + + //var serializedOrder = nextState.GetState(orderAddress); + //Assert.NotNull(serializedOrder); + //var serializedItem = nextState.GetState(Addresses.GetItemAddress(tradableItem.TradableId)); + //Assert.NotNull(serializedItem); + + //var order = OrderFactory.Deserialize((Dictionary)serializedOrder); + //ITradableItem orderItem = (ITradableItem)ItemFactory.Deserialize((Dictionary)serializedItem); + + //Assert.Equal(price, order.Price); + //Assert.Equal(orderId, order.OrderId); + //Assert.Equal(tradableItem.TradableId, order.TradableId); + //Assert.Equal(blockIndex, order.StartedBlockIndex); + //Assert.Equal(expiredBlockIndex, order.ExpiredBlockIndex); + //Assert.Equal(_agentAddress, order.SellerAgentAddress); + //Assert.Equal(_avatarAddress, order.SellerAvatarAddress); + //Assert.Equal(expiredBlockIndex, orderItem.RequiredBlockIndex); + + //var receiptDict = nextState.GetState(OrderDigestListState.DeriveAddress(_avatarAddress)); + //Assert.NotNull(receiptDict); + //var orderDigestList = new OrderDigestListState((Dictionary)receiptDict); + //Assert.Single(orderDigestList.OrderDigestList); + //OrderDigest orderDigest2 = orderDigestList.OrderDigestList.First(); + //Assert.Equal(orderDigest, orderDigest2); + } + + [Fact] + public void Execute_Throw_FailedLoadStateException() + { + var action = new TransferItem + { + SenderAvatarAddress = _avatarAddress, + ItemId = default, + RecipientAvatarAddress = _avatar2Address, + }; + + Assert.Throws(() => action.Execute(new ActionContext + { + BlockIndex = 0, + PreviousStates = new State(), + Signer = _agentAddress, + })); + } + + [Fact] + public void Execute_Throw_NotEnoughClearedStageLevelException() + { + var avatarState = new AvatarState(_avatarState) + { + worldInformation = new WorldInformation( + 0, + _tableSheets.WorldSheet, + 0 + ), + }; + + _initialState = _initialState.SetState(_avatarAddress, avatarState.Serialize()); + + var action = new TransferItem + { + SenderAvatarAddress = _avatarAddress, + ItemId = default, + RecipientAvatarAddress = _avatar2Address, + }; + + Assert.Throws(() => action.Execute(new ActionContext + { + BlockIndex = 0, + PreviousStates = _initialState, + Signer = _agentAddress, + })); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Execute_Throw_ItemDoesNotExistException(bool isLock) + { + var tradableId = Guid.NewGuid(); + if (isLock) + { + var tradableItem = ItemFactory.CreateItemUsable( + _tableSheets.EquipmentItemSheet.First, + tradableId, + 0); + var orderLock = new OrderLock(Guid.NewGuid()); + _avatarState.inventory.AddItem(tradableItem, 1, orderLock); + Assert.True(_avatarState.inventory.TryGetLockedItem(orderLock, out _)); + _initialState = _initialState.SetState( + _avatarAddress.Derive(LegacyInventoryKey), + _avatarState.inventory.Serialize() + ); + } + + var action = new TransferItem + { + SenderAvatarAddress = _avatarAddress, + ItemId = tradableId, + RecipientAvatarAddress = _avatar2Address, + }; + + Assert.Throws(() => action.Execute(new ActionContext + { + BlockIndex = 0, + PreviousStates = _initialState, + Signer = _agentAddress, + Random = new TestRandom(), + })); + } + + [Fact] + public void Rehearsal() + { + Guid tradableId = Guid.NewGuid(); + Guid orderId = Guid.NewGuid(); + var action = new TransferItem + { + SenderAvatarAddress = _avatarAddress, + ItemId = tradableId, + ItemCount = 1, + RecipientAvatarAddress = _avatar2Address, + }; + + var updatedAddresses = new List
() + { + _agentAddress, + _avatarAddress.Derive(LegacyInventoryKey), + _avatarAddress.Derive(LegacyWorldInformationKey), + _avatarAddress.Derive(LegacyQuestListKey), + Addresses.GetItemAddress(tradableId), + _avatar2Address.Derive(LegacyInventoryKey), + _avatar2Address.Derive(LegacyWorldInformationKey), + _avatar2Address.Derive(LegacyQuestListKey), + }; + + var state = new State(); + + var nextState = action.Execute(new ActionContext() + { + PreviousStates = state, + Signer = _agentAddress, + BlockIndex = 0, + Rehearsal = true, + }); + + Assert.Equal(updatedAddresses.ToImmutableHashSet(), nextState.UpdatedAddresses); + } + } +} diff --git a/Lib9c/Action/TransferItem.cs b/Lib9c/Action/TransferItem.cs new file mode 100644 index 0000000000..88fbb98987 --- /dev/null +++ b/Lib9c/Action/TransferItem.cs @@ -0,0 +1,257 @@ +using Bencodex; +using Bencodex.Types; +using Libplanet; +using Libplanet.Action; +using Libplanet.Assets; +using Nekoyume.Model.State; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.Serialization; +using Nekoyume.Model; +using static Lib9c.SerializeKeys; +using Nekoyume.Model.Item; +using Nekoyume.TableData; +using Nekoyume.Battle; + +namespace Nekoyume.Action +{ + /// + /// Hard forked at https://github.com/planetarium/lib9c/pull/636 + /// Updated at https://github.com/planetarium/lib9c/pull/957 + /// + [Serializable] + [ActionType("transfer_equipment")] + public class TransferItem : ActionBase, ISerializable + { + private const int MemoMaxLength = 80; + private const int transferFee = 10; + + + public TransferItem() + { + } + + public TransferItem(Address sender, Address recipient, Guid itemId, int itemCount = 1, string memo = null) + { + SenderAvatarAddress = sender; + RecipientAvatarAddress = recipient; + ItemId = itemId; + ItemCount = itemCount; + CheckMemoLength(memo); + Memo = memo; + } + + protected TransferItem(SerializationInfo info, StreamingContext context) + { + var rawBytes = (byte[])info.GetValue("serialized", typeof(byte[])); + Dictionary pv = (Dictionary) new Codec().Decode(rawBytes); + + LoadPlainValue(pv); + } + + public Address SenderAvatarAddress { get; set; } + public Address RecipientAvatarAddress { get; set; } + public Guid ItemId { get; set; } + public int ItemCount { get; set; } + public string Memo { get; private set; } + + public override IValue PlainValue + { + get + { + IEnumerable> pairs = new[] + { + new KeyValuePair((Text) "sender", SenderAvatarAddress.Serialize()), + new KeyValuePair((Text) "recipient", RecipientAvatarAddress.Serialize()), + new KeyValuePair((Text) "itemId", ItemId.Serialize()), + new KeyValuePair((Text) "itemCount", ItemCount.Serialize()), + }; + + if (!(Memo is null)) + { + pairs = pairs.Append(new KeyValuePair((Text) "memo", Memo.Serialize())); + } + + return new Dictionary(pairs); + } + } + + public override IAccountStateDelta Execute(IActionContext context) + { + var states = context.PreviousStates; + var recipientInventoryAddress = RecipientAvatarAddress.Derive(LegacyInventoryKey); + var recipientWorldInformationAddress = RecipientAvatarAddress.Derive(LegacyWorldInformationKey); + var recipientQuestListAddress = RecipientAvatarAddress.Derive(LegacyQuestListKey); + var senderInventoryAddress = SenderAvatarAddress.Derive(LegacyInventoryKey); + var senderWorldInformationAddress = SenderAvatarAddress.Derive(LegacyWorldInformationKey); + var senderQuestListAddress = SenderAvatarAddress.Derive(LegacyQuestListKey); + if (context.Rehearsal) + { + return states + .SetState(Addresses.GetItemAddress(ItemId), MarkChanged) + .SetState(recipientInventoryAddress, MarkChanged) + .SetState(recipientQuestListAddress, MarkChanged) + .SetState(recipientWorldInformationAddress, MarkChanged) + .SetState(senderInventoryAddress, MarkChanged) + .SetState(senderWorldInformationAddress, MarkChanged) + .SetState(senderQuestListAddress, MarkChanged) + .MarkBalanceChanged(GoldCurrencyMock, context.Signer); + } + + int count = ItemCount; + Address recipientAddress = RecipientAvatarAddress.Derive(ActivationKey.DeriveKey); + + // Check new type of activation first. + if (states.GetState(recipientAddress) is null && states.GetState(Addresses.ActivatedAccount) is Dictionary asDict ) + { + var activatedAccountsState = new ActivatedAccountsState(asDict); + var activatedAccounts = activatedAccountsState.Accounts; + // if ActivatedAccountsState is empty, all user is activate. + if (activatedAccounts.Count != 0 + && !activatedAccounts.Contains(RecipientAvatarAddress)) + { + throw new InvalidTransferUnactivatedRecipientException(SenderAvatarAddress, RecipientAvatarAddress); + } + } + + var addressesHex = GetSignerAndOtherAddressesHex(context, RecipientAvatarAddress); + AvatarState senderAvatarState; + + if (!states.TryGetAvatarStateV2(context.Signer, SenderAvatarAddress, out senderAvatarState, out var senderMigrationRequired)) + { + throw new FailedLoadStateException( + $"Aborted as the avatar state of the sender ({senderAvatarState}) was failed to load."); + } + var recipientMigrationRequired = false; + AvatarState recipientAvatarState; + try + { + recipientAvatarState = states.GetAvatarStateV2(RecipientAvatarAddress); + } + // BackWard compatible. + catch (FailedLoadStateException) + { + recipientAvatarState = states.GetAvatarState(RecipientAvatarAddress); + recipientMigrationRequired = true; + } + if (recipientAvatarState is null) + { + throw new FailedLoadStateException( + $"Aborted as the avatar state of the sender ({recipientAvatarState}) was failed to load."); + } + + if (!recipientAvatarState.worldInformation.IsStageCleared(GameConfig.RequireClearedStageLevel.ActionsInShop)) + { + recipientAvatarState.worldInformation.TryGetLastClearedStageId(out var current); + throw new NotEnoughClearedStageLevelException(addressesHex, + GameConfig.RequireClearedStageLevel.ActionsInShop, current); + } + + if (!senderAvatarState.worldInformation.IsStageCleared(GameConfig.RequireClearedStageLevel.ActionsInShop)) + { + senderAvatarState.worldInformation.TryGetLastClearedStageId(out var current); + throw new NotEnoughClearedStageLevelException(addressesHex, + GameConfig.RequireClearedStageLevel.ActionsInShop, current); + } + + if(senderAvatarState.agentAddress != context.Signer) + { + throw new InvalidAddressException("Signer doesn't match sending agent address"); + } + + if(!senderAvatarState.inventory.TryGetTradableItem(ItemId,context.BlockIndex, count, out var item)) + { + throw new ItemDoesNotExistException("Unable to get item from inventory"); + } + if (item.Locked) + { + throw new ItemDoesNotExistException("Item is current locked, unable to send while on the market"); + } + + int baseFee = 1000; + if(item.item is INonFungibleItem nonFungibleItem) + { + nonFungibleItem.RequiredBlockIndex = context.BlockIndex; + senderAvatarState.inventory.RemoveNonFungibleItem(nonFungibleItem); + if (nonFungibleItem is Costume costume) + { + recipientAvatarState.UpdateFromAddCostume(costume, false); + } + else + { + recipientAvatarState.UpdateFromAddItem((ItemUsable)nonFungibleItem, false); + baseFee = CPHelper.GetCP((ItemUsable)nonFungibleItem)/10; + if (baseFee == 0) baseFee = 1; + } + + } + else if(item.item is ITradableFungibleItem tradable) + { + tradable.RequiredBlockIndex = context.BlockIndex; + senderAvatarState.inventory.RemoveTradableItem(tradable, count); + recipientAvatarState.UpdateFromAddItem(item.item, count, false); + baseFee = 100; + } + else + { + throw new InvalidItemTypeException("Unable to load item to send"); + } + + //Transfer fee + var arenaSheet = states.GetSheet(); + var arenaData = arenaSheet.GetRoundByBlockIndex(context.BlockIndex); + var feeStoreAddress = Addresses.GetShopFeeAddress(arenaData.ChampionshipId, arenaData.Round); + var goldCurrency = states.GetGoldCurrency(); + + var fee = baseFee * goldCurrency; + states = states.TransferAsset(context.Signer, feeStoreAddress, fee); + + if (senderMigrationRequired) + { + states = states + .SetState(senderWorldInformationAddress, senderAvatarState.worldInformation.Serialize()) + .SetState(senderQuestListAddress, senderAvatarState.questList.Serialize()); + } + + if (recipientMigrationRequired) + { + states = states + .SetState(recipientWorldInformationAddress, recipientAvatarState.worldInformation.Serialize()) + .SetState(recipientQuestListAddress, recipientAvatarState.questList.Serialize()); + } + + states = states + .SetState(senderInventoryAddress, senderAvatarState.inventory.Serialize()) + .SetState(recipientInventoryAddress, recipientAvatarState.inventory.Serialize()); + return states;//.TransferAsset(Sender, RecipientAvatarAddress, Amount); + } + + public override void LoadPlainValue(IValue plainValue) + { + var asDict = (Dictionary) plainValue; + + SenderAvatarAddress = asDict["sender"].ToAddress(); + RecipientAvatarAddress = asDict["recipient"].ToAddress(); + ItemId = asDict["itemid"].ToGuid(); + Memo = asDict.TryGetValue((Text) "memo", out IValue memo) ? memo.ToDotnetString() : null; + + CheckMemoLength(Memo); + } + + public void GetObjectData(SerializationInfo info, StreamingContext context) + { + info.AddValue("serialized", new Codec().Encode(PlainValue)); + } + + private void CheckMemoLength(string memo) + { + if (memo?.Length > MemoMaxLength) + { + string msg = $"The length of the memo, {memo.Length}, " + + $"is overflowed than the max length, {MemoMaxLength}."; + throw new MemoLengthOverflowException(msg); + } + } + } +} diff --git a/Lib9c/Model/Item/Inventory.cs b/Lib9c/Model/Item/Inventory.cs index 0119eb3821..366bdf409e 100644 --- a/Lib9c/Model/Item/Inventory.cs +++ b/Lib9c/Model/Item/Inventory.cs @@ -589,7 +589,7 @@ public bool TryGetTradableItem(Guid tradeId, long blockIndex, int count, out Ite outItem = _items.FirstOrDefault(i => i.item is ITradableItem item && item.TradableId.Equals(tradeId) && - item.RequiredBlockIndex == blockIndex && + item.RequiredBlockIndex <= blockIndex && i.count >= count ); return !(outItem is null);