diff --git a/.Lib9c.Tests/Action/Garages/UnloadFromMyGaragesTest.cs b/.Lib9c.Tests/Action/Garages/UnloadFromMyGaragesTest.cs new file mode 100644 index 0000000000..cffd8f34b8 --- /dev/null +++ b/.Lib9c.Tests/Action/Garages/UnloadFromMyGaragesTest.cs @@ -0,0 +1,440 @@ +namespace Lib9c.Tests.Action.Garages +{ +#nullable enable + using System; + using System.Collections.Generic; + using System.Linq; + using System.Security.Cryptography; + using Bencodex.Types; + using Lib9c.Abstractions; + using Lib9c.Tests.Util; + using Libplanet; + using Libplanet.Action; + using Libplanet.Assets; + using Libplanet.Crypto; + using Libplanet.State; + using Nekoyume; + using Nekoyume.Action; + using Nekoyume.Action.Garages; + using Nekoyume.Exceptions; + using Nekoyume.Model.Garages; + using Nekoyume.Model.Item; + using Xunit; + + public class UnloadFromMyGaragesTest + { + private static readonly Address AgentAddr = new PrivateKey().ToAddress(); + private static readonly int AvatarIndex = 0; + + private static readonly Address AvatarAddr = + Addresses.GetAvatarAddress(AgentAddr, AvatarIndex); + + private readonly TableSheets _tableSheets; + private readonly IAccountStateDelta _initialStatesWithAvatarStateV2; + private readonly Currency _ncg; + private readonly (Address balanceAddr, FungibleAssetValue value)[] _fungibleAssetValues; + private readonly Address? _inventoryAddr; + private readonly (HashDigest fungibleId, int count)[] _fungibleIdAndCounts; + private readonly ITradableFungibleItem[] _tradableFungibleItems; + private readonly IAccountStateDelta _previousStates; + + public UnloadFromMyGaragesTest() + { + // NOTE: Garage actions does not consider the avatar state v1. + ( + _tableSheets, + _, + _, + _, + _initialStatesWithAvatarStateV2 + ) = InitializeUtil.InitializeStates( + agentAddr: AgentAddr, + avatarIndex: AvatarIndex); + _ncg = _initialStatesWithAvatarStateV2.GetGoldCurrency(); + ( + _fungibleAssetValues, + _inventoryAddr, + _fungibleIdAndCounts, + _tradableFungibleItems, + _previousStates + ) = GetSuccessfulPreviousStatesWithPlainValue(); + } + + public static IEnumerable Get_Sample_PlainValue() => + LoadIntoMyGaragesTest.Get_Sample_PlainValue(); + + [Theory] + [MemberData(nameof(Get_Sample_PlainValue))] + public void Serialize( + IEnumerable<(Address balanceAddr, FungibleAssetValue value)>? fungibleAssetValues, + Address? inventoryAddr, + IEnumerable<(HashDigest fungibleId, int count)>? fungibleIdAndCounts, + string? memo) + { + var actions = new[] + { + new UnloadFromMyGarages(), + new UnloadFromMyGarages( + fungibleAssetValues, + inventoryAddr, + fungibleIdAndCounts, + memo), + }; + foreach (var action in actions) + { + var ser = action.PlainValue; + var des = new UnloadFromMyGarages(); + des.LoadPlainValue(ser); + Assert.True(action.FungibleAssetValues?.SequenceEqual(des.FungibleAssetValues!) ?? + des.FungibleAssetValues is null); + Assert.Equal(action.InventoryAddr, des.InventoryAddr); + Assert.True(action.FungibleIdAndCounts?.SequenceEqual(des.FungibleIdAndCounts!) ?? + des.FungibleIdAndCounts is null); + Assert.Equal(action.Memo, des.Memo); + + Assert.Equal(ser, des.PlainValue); + + var actionInter = (IUnloadFromMyGarages)action; + var desInter = (IUnloadFromMyGarages)des; + Assert.True( + actionInter.FungibleAssetValues?.SequenceEqual(desInter.FungibleAssetValues!) ?? + desInter.FungibleAssetValues is null); + Assert.Equal(actionInter.InventoryAddr, desInter.InventoryAddr); + Assert.True( + actionInter.FungibleIdAndCounts?.SequenceEqual(desInter.FungibleIdAndCounts!) ?? + desInter.FungibleIdAndCounts is null); + Assert.Equal(actionInter.Memo, desInter.Memo); + } + } + + [Fact] + public void Execute_Success() + { + var (action, nextStates) = Execute( + AgentAddr, + 0, + _previousStates, + new TestRandom(), + _fungibleAssetValues, + _inventoryAddr, + _fungibleIdAndCounts, + "memo"); + var garageBalanceAddr = + Addresses.GetGarageBalanceAddress(AgentAddr); + if (action.FungibleAssetValues is { }) + { + foreach (var (balanceAddr, value) in action.FungibleAssetValues) + { + Assert.Equal( + value, + nextStates.GetBalance(balanceAddr, value.Currency)); + Assert.Equal( + value.Currency * 0, + nextStates.GetBalance(garageBalanceAddr, value.Currency)); + } + } + + if (action.InventoryAddr is null || + action.FungibleIdAndCounts is null) + { + return; + } + + var inventoryState = nextStates.GetState(action.InventoryAddr.Value)!; + var inventory = new Inventory((List)inventoryState); + foreach (var (fungibleId, count) in action.FungibleIdAndCounts) + { + var garageAddr = Addresses.GetGarageAddress( + AgentAddr, + fungibleId); + Assert.True(nextStates.GetState(garageAddr) is Null); + Assert.True(inventory.HasTradableFungibleItem( + fungibleId, + requiredBlockIndex: null, + blockIndex: 0, + count)); + } + } + + [Fact] + public void Execute_Throws_InvalidActionFieldException() + { + // FungibleAssetValues and FungibleIdAndCounts are null. + Assert.Throws(() => Execute( + AgentAddr, + 0, + _previousStates, + new TestRandom(), + null, + _inventoryAddr, + null)); + + // FungibleAssetValues contains negative value. + var negativeFungibleAssetValues = _fungibleAssetValues.Select(tuple => + (tuple.balanceAddr, tuple.value * -1)); + Assert.Throws(() => Execute( + AgentAddr, + 0, + _previousStates, + new TestRandom(), + negativeFungibleAssetValues, + _inventoryAddr, + null)); + + // InventoryAddr is null when FungibleIdAndCounts is not null. + Assert.Throws(() => Execute( + AgentAddr, + 0, + _previousStates, + new TestRandom(), + null, + null, + _fungibleIdAndCounts)); + + // Count of fungible id is negative. + var negativeFungibleIdAndCounts = _fungibleIdAndCounts.Select(tuple => ( + tuple.fungibleId, + tuple.count * -1)); + Assert.Throws(() => Execute( + AgentAddr, + 0, + _previousStates, + new TestRandom(), + null, + _inventoryAddr, + negativeFungibleIdAndCounts)); + } + + [Fact] + public void Execute_Throws_Exception() + { + // Agent's FungibleAssetValue garages does not have enough balance. + var previousStatesWithEmptyBalances = _previousStates; + var garageBalanceAddress = Addresses.GetGarageBalanceAddress(AgentAddr); + foreach (var (_, value) in _fungibleAssetValues) + { + previousStatesWithEmptyBalances = previousStatesWithEmptyBalances + .BurnAsset(garageBalanceAddress, value); + } + + Assert.Throws(() => Execute( + AgentAddr, + 0, + previousStatesWithEmptyBalances, + new TestRandom(), + _fungibleAssetValues, + null, + null)); + + // Inventory state is null. + var previousStatesWithNullInventoryState = + _previousStates.SetState(_inventoryAddr!.Value, Null.Value); + Assert.Throws(() => Execute( + AgentAddr, + 0, + previousStatesWithNullInventoryState, + new TestRandom(), + null, + _inventoryAddr, + _fungibleIdAndCounts)); + + // The state in InventoryAddr is not Inventory. + foreach (var invalidInventoryState in new IValue[] + { + new Integer(0), + Dictionary.Empty, + }) + { + var previousStatesWithInvalidInventoryState = + _previousStates.SetState(_inventoryAddr.Value, invalidInventoryState); + Assert.Throws(() => Execute( + AgentAddr, + 0, + previousStatesWithInvalidInventoryState, + new TestRandom(), + null, + _inventoryAddr, + _fungibleIdAndCounts)); + } + + // Agent's fungible item garage state is null. + foreach (var (fungibleId, _) in _fungibleIdAndCounts) + { + var garageAddr = Addresses.GetGarageAddress( + AgentAddr, + fungibleId); + var previousStatesWithNullGarageState = + _previousStates.SetState(garageAddr, Null.Value); + Assert.Throws(() => Execute( + AgentAddr, + 0, + previousStatesWithNullGarageState, + new TestRandom(), + null, + _inventoryAddr, + _fungibleIdAndCounts)); + } + + // Agent's fungible item garage does not contain enough items. + foreach (var (fungibleId, _) in _fungibleIdAndCounts) + { + var garageAddr = Addresses.GetGarageAddress( + AgentAddr, + fungibleId); + var garageState = _previousStates.GetState(garageAddr); + var garage = new FungibleItemGarage(garageState); + garage.Unload(1); + var previousStatesWithNotEnoughCountOfGarageState = + _previousStates.SetState(garageAddr, garage.Serialize()); + if (garage.Count == 0) + { + Assert.Throws(() => Execute( + AgentAddr, + 0, + previousStatesWithNotEnoughCountOfGarageState, + new TestRandom(), + null, + _inventoryAddr, + _fungibleIdAndCounts)); + } + else + { + Assert.Throws(() => Execute( + AgentAddr, + 0, + previousStatesWithNotEnoughCountOfGarageState, + new TestRandom(), + null, + _inventoryAddr, + _fungibleIdAndCounts)); + } + } + + // Inventory can be overflowed. + for (var i = 0; i < _fungibleIdAndCounts.Length; i++) + { + var item = _tradableFungibleItems[i]; + var inventory = _previousStates.GetInventory(_inventoryAddr.Value); + inventory.AddTradableFungibleItem(item, int.MaxValue); + var previousStatesWithInvalidGarageState = + _previousStates.SetState(_inventoryAddr.Value, inventory.Serialize()); + Assert.Throws(() => Execute( + AgentAddr, + 0, + previousStatesWithInvalidGarageState, + new TestRandom(), + null, + _inventoryAddr, + _fungibleIdAndCounts)); + } + } + + private static (UnloadFromMyGarages action, IAccountStateDelta nextStates) Execute( + Address signer, + long blockIndex, + IAccountStateDelta previousStates, + IRandom random, + IEnumerable<(Address balanceAddr, FungibleAssetValue value)>? fungibleAssetValues, + Address? inventoryAddr, + IEnumerable<(HashDigest fungibleId, int count)>? fungibleIdAndCounts, + string? memo = null) + { + var action = new UnloadFromMyGarages( + fungibleAssetValues, + inventoryAddr, + fungibleIdAndCounts, + memo); + return ( + action, + action.Execute(new ActionContext + { + Signer = signer, + BlockIndex = blockIndex, + Rehearsal = false, + PreviousStates = previousStates, + Random = random, + })); + } + + private static (Address balanceAddr, FungibleAssetValue value)[] + GetFungibleAssetValues( + Address agentAddr, + Address avatarAddr) + { + return CurrenciesTest.GetSampleCurrencies() + .Select(objects => (FungibleAssetValue)objects[0]) + .Where(fav => fav.Sign > 0) + .Select(fav => + { + if (Currencies.IsRuneTicker(fav.Currency.Ticker) || + Currencies.IsSoulstoneTicker(fav.Currency.Ticker)) + { + return (avatarAddr, fav); + } + + return (agentAddr, fav); + }) + .ToArray(); + } + + private ( + (Address balanceAddr, FungibleAssetValue value)[] fungibleAssetValues, + Address? inventoryAddr, + (HashDigest fungibleId, int count)[] fungibleIdAndCounts, + ITradableFungibleItem[] _tradableFungibleItems, + IAccountStateDelta previousStates) + GetSuccessfulPreviousStatesWithPlainValue() + { + var previousStates = _initialStatesWithAvatarStateV2; + var garageBalanceAddress = Addresses.GetGarageBalanceAddress(AgentAddr); + var fungibleAssetValues = GetFungibleAssetValues( + AgentAddr, + AvatarAddr); + foreach (var (_, value) in fungibleAssetValues) + { + if (value.Currency.Equals(_ncg)) + { + previousStates = previousStates.TransferAsset( + Addresses.Admin, + garageBalanceAddress, + value); + continue; + } + + previousStates = previousStates.MintAsset( + garageBalanceAddress, + value); + } + + var fungibleItemAndCounts = _tableSheets.MaterialItemSheet.OrderedList! + .Take(3) + .Select(ItemFactory.CreateTradableMaterial) + .Select((tradableMaterial, index) => + { + var garageAddr = Addresses.GetGarageAddress( + AgentAddr, + tradableMaterial.FungibleId); + var count = index + 1; + var garage = new FungibleItemGarage(tradableMaterial, count); + previousStates = previousStates.SetState( + garageAddr, + garage.Serialize()); + + return ( + tradableFungibleItem: (ITradableFungibleItem)tradableMaterial, + count); + }).ToArray(); + return ( + fungibleAssetValues, + inventoryAddr: Addresses.GetInventoryAddress( + AgentAddr, + AvatarIndex), + fungibleItemAndCounts + .Select(tuple => (tuple.tradableFungibleItem.FungibleId, tuple.count)) + .ToArray(), + fungibleItemAndCounts.Select(tuple => tuple.tradableFungibleItem).ToArray(), + previousStates + ); + } + } +} diff --git a/Lib9c.Abstractions/IUnloadFromMyGarages.cs b/Lib9c.Abstractions/IUnloadFromMyGarages.cs new file mode 100644 index 0000000000..1adb8cad16 --- /dev/null +++ b/Lib9c.Abstractions/IUnloadFromMyGarages.cs @@ -0,0 +1,21 @@ +#nullable enable + +using System.Linq; +using System.Security.Cryptography; +using Libplanet; +using Libplanet.Assets; + +namespace Lib9c.Abstractions +{ + public interface IUnloadFromMyGarages + { + IOrderedEnumerable<(Address balanceAddr, FungibleAssetValue value)>? FungibleAssetValues + { + get; + } + + Address? InventoryAddr { get; } + IOrderedEnumerable<(HashDigest fungibleId, int count)>? FungibleIdAndCounts { get; } + string? Memo { get; } + } +} diff --git a/Lib9c/Action/Garages/UnloadFromMyGarages.cs b/Lib9c/Action/Garages/UnloadFromMyGarages.cs new file mode 100644 index 0000000000..646a5aa4cc --- /dev/null +++ b/Lib9c/Action/Garages/UnloadFromMyGarages.cs @@ -0,0 +1,205 @@ +#nullable enable + +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Security.Cryptography; +using Bencodex.Types; +using Lib9c.Abstractions; +using Libplanet; +using Libplanet.Action; +using Libplanet.Assets; +using Libplanet.State; +using Nekoyume.Exceptions; +using Nekoyume.Model.Item; +using Nekoyume.Model.State; + +namespace Nekoyume.Action.Garages +{ + [ActionType("unload_from_my_garages")] + public class UnloadFromMyGarages : GameAction, IUnloadFromMyGarages, IAction + { + public IOrderedEnumerable<(Address balanceAddr, FungibleAssetValue value)>? + FungibleAssetValues { get; private set; } + + /// + /// This address does not need to consider its owner. + /// If the avatar state is v1, there is no separate inventory, + /// so it should be execute another action first to migrate the avatar state to v2. + /// And then, the inventory address will be set. + /// + public Address? InventoryAddr { get; private set; } + + public IOrderedEnumerable<(HashDigest fungibleId, int count)>? + FungibleIdAndCounts { get; private set; } + + public string? Memo { get; private set; } + + protected override IImmutableDictionary PlainValueInternal => + new Dictionary + { + { + "l", + new List( + FungibleAssetValues is null + ? (IValue)Null.Value + : new List(FungibleAssetValues.Select(tuple => new List( + tuple.balanceAddr.Serialize(), + tuple.value.Serialize()))), + InventoryAddr is null + ? Null.Value + : InventoryAddr.Serialize(), + FungibleIdAndCounts is null + ? (IValue)Null.Value + : new List(FungibleIdAndCounts.Select(tuple => new List( + tuple.fungibleId.Serialize(), + (Integer)tuple.count))), + Memo is null + ? (IValue)Null.Value + : (Text)Memo) + } + }.ToImmutableDictionary(); + + public UnloadFromMyGarages( + IEnumerable<(Address balanceAddr, FungibleAssetValue value)>? fungibleAssetValues, + Address? inventoryAddr, + IEnumerable<(HashDigest fungibleId, int count)>? fungibleIdAndCounts, + string? memo) + { + ( + FungibleAssetValues, + InventoryAddr, + FungibleIdAndCounts, + Memo + ) = GarageUtils.MergeAndSort( + fungibleAssetValues, + inventoryAddr, + fungibleIdAndCounts, + memo); + } + + public UnloadFromMyGarages() + { + } + + protected override void LoadPlainValueInternal( + IImmutableDictionary plainValue) + { + ( + FungibleAssetValues, + InventoryAddr, + FungibleIdAndCounts, + Memo + ) = GarageUtils.Deserialize(plainValue["l"]); + } + + public override IAccountStateDelta Execute(IActionContext context) + { + var states = context.PreviousStates; + if (context.Rehearsal) + { + return states; + } + + var addressesHex = GetSignerAndOtherAddressesHex(context); + ValidateFields(addressesHex); + states = TransferFungibleAssetValues( + context.Signer, + states); + return TransferFungibleItems( + context.Signer, + states); + } + + private void ValidateFields(string addressesHex) + { + if (FungibleAssetValues is null && + FungibleIdAndCounts is null) + { + throw new InvalidActionFieldException( + $"[{addressesHex}] Either FungibleAssetValues or FungibleIdAndCounts " + + "must be set."); + } + + if (FungibleAssetValues != null) + { + foreach (var (_, value) in FungibleAssetValues) + { + if (value.Sign < 0) + { + throw new InvalidActionFieldException( + $"[{addressesHex}] FungibleAssetValue.Sign must be positive."); + } + } + } + + if (FungibleIdAndCounts is null) + { + return; + } + + if (!InventoryAddr.HasValue) + { + throw new InvalidActionFieldException( + $"[{addressesHex}] {nameof(InventoryAddr)} is required when " + + $"{nameof(FungibleIdAndCounts)} is set."); + } + + foreach (var (fungibleId, count) in FungibleIdAndCounts) + { + if (count < 0) + { + throw new InvalidActionFieldException( + $"[{addressesHex}] Count of fungible id must be positive." + + $" {fungibleId}, {count}"); + } + } + } + + private IAccountStateDelta TransferFungibleAssetValues( + Address signer, + IAccountStateDelta states) + { + if (FungibleAssetValues is null) + { + return states; + } + + var garageBalanceAddress = + Addresses.GetGarageBalanceAddress(signer); + foreach (var (balanceAddr, value) in FungibleAssetValues) + { + states = states.TransferAsset(garageBalanceAddress, balanceAddr, value); + } + + return states; + } + + private IAccountStateDelta TransferFungibleItems( + Address signer, + IAccountStateDelta states) + { + if (InventoryAddr is null || + FungibleIdAndCounts is null) + { + return states; + } + + var inventory = states.GetInventory(InventoryAddr.Value); + var fungibleItemTuples = GarageUtils.WithGarageTuples( + signer, + states, + FungibleIdAndCounts); + foreach (var (_, count, garageAddr, garage) in fungibleItemTuples) + { + garage.Unload(count); + inventory.AddTradableFungibleItem( + (ITradableFungibleItem)garage.Item, + count); + states = states.SetState(garageAddr, garage.Serialize()); + } + + return states.SetState(InventoryAddr.Value, inventory.Serialize()); + } + } +}