diff --git a/.Lib9c.Tests/Action/ClaimMonsterCollectionReward0Test.cs b/.Lib9c.Tests/Action/ClaimMonsterCollectionReward0Test.cs new file mode 100644 index 0000000000..606074798a --- /dev/null +++ b/.Lib9c.Tests/Action/ClaimMonsterCollectionReward0Test.cs @@ -0,0 +1,299 @@ +namespace Lib9c.Tests.Action +{ + using System; + using System.Collections.Generic; + using Bencodex.Types; + using Libplanet.Action.State; + using Libplanet.Crypto; + using Libplanet.Types.Assets; + using Nekoyume; + using Nekoyume.Action; + using Nekoyume.Model.Mail; + using Nekoyume.Model.State; + using Nekoyume.TableData; + using Xunit; + + public class ClaimMonsterCollectionReward0Test + { + private readonly Address _signer; + private readonly Address _avatarAddress; + private readonly TableSheets _tableSheets; + private IAccount _state; + + public ClaimMonsterCollectionReward0Test() + { + _signer = default; + _avatarAddress = _signer.Derive("avatar"); + _state = new Account(MockState.Empty); + Dictionary sheets = TableSheetsImporter.ImportSheets(); + _tableSheets = new TableSheets(sheets); + var rankingMapAddress = new PrivateKey().Address; + var agentState = new AgentState(_signer); + var avatarState = new AvatarState( + _avatarAddress, + _signer, + 0, + _tableSheets.GetAvatarSheets(), + new GameConfigState(), + rankingMapAddress); + agentState.avatarAddresses[0] = _avatarAddress; + +#pragma warning disable CS0618 + // Use of obsolete method Currency.Legacy(): https://github.com/planetarium/lib9c/discussions/1319 + var currency = Currency.Legacy("NCG", 2, null); +#pragma warning restore CS0618 + var goldCurrencyState = new GoldCurrencyState(currency); + + _state = _state + .SetState(_signer, agentState.Serialize()) + .SetState(_avatarAddress, avatarState.Serialize()) + .SetState(Addresses.GoldCurrency, goldCurrencyState.Serialize()); + + foreach ((string key, string value) in sheets) + { + _state = _state + .SetState(Addresses.TableSheet.Derive(key), value.Serialize()); + } + } + + [Theory] + [InlineData(1, 0, 1)] + [InlineData(2, 0, 2)] + [InlineData(2, 1, 3)] + [InlineData(3, 0, 4)] + [InlineData(3, 1, 5)] + [InlineData(3, 2, 6)] + [InlineData(4, 0, 7)] + [InlineData(4, 1, 4)] + [InlineData(4, 2, 5)] + [InlineData(4, 3, 6)] + public void Execute(int rewardLevel, int prevRewardLevel, int collectionLevel) + { + var context = new ActionContext(); + Address collectionAddress = MonsterCollectionState0.DeriveAddress(_signer, 0); + List rewards = _tableSheets.MonsterCollectionRewardSheet[1].Rewards; + MonsterCollectionState0 monsterCollectionState = new MonsterCollectionState0(collectionAddress, 1, 0, _tableSheets.MonsterCollectionRewardSheet); + for (int i = 0; i < prevRewardLevel; i++) + { + int level = i + 1; + MonsterCollectionResult result = new MonsterCollectionResult(Guid.NewGuid(), _avatarAddress, rewards); + monsterCollectionState.UpdateRewardMap(level, result, i * MonsterCollectionState0.RewardInterval); + } + + List collectionRewards = _tableSheets.MonsterCollectionRewardSheet[collectionLevel].Rewards; + monsterCollectionState.Update(collectionLevel, rewardLevel, _tableSheets.MonsterCollectionRewardSheet); + for (long i = rewardLevel; i < 4; i++) + { + Assert.Equal(collectionRewards, monsterCollectionState.RewardLevelMap[i + 1]); + } + + Dictionary rewardExpectedMap = new Dictionary(); + foreach (var (key, value) in monsterCollectionState.RewardLevelMap) + { + if (monsterCollectionState.RewardMap.ContainsKey(key) || key > rewardLevel) + { + continue; + } + + foreach (var info in value) + { + if (rewardExpectedMap.ContainsKey(info.ItemId)) + { + rewardExpectedMap[info.ItemId] += info.Quantity; + } + else + { + rewardExpectedMap[info.ItemId] = info.Quantity; + } + } + } + + AvatarState prevAvatarState = _state.GetAvatarState(_avatarAddress); + Assert.Empty(prevAvatarState.mailBox); + + Currency currency = _state.GetGoldCurrency(); + int collectionRound = _state.GetAgentState(_signer).MonsterCollectionRound; + + _state = _state + .SetState(collectionAddress, monsterCollectionState.Serialize()); + + FungibleAssetValue balance = 0 * currency; + if (rewardLevel == 4) + { + foreach (var row in _tableSheets.MonsterCollectionSheet) + { + if (row.Level <= collectionLevel) + { + balance += row.RequiredGold * currency; + } + } + + collectionRound += 1; + _state = _state + .MintAsset(context, collectionAddress, balance); + } + + Assert.Equal(prevRewardLevel, monsterCollectionState.RewardLevel); + Assert.Equal(0, _state.GetAgentState(_signer).MonsterCollectionRound); + + ClaimMonsterCollectionReward0 action = new ClaimMonsterCollectionReward0 + { + avatarAddress = _avatarAddress, + collectionRound = 0, + }; + + IAccount nextState = action.Execute(new ActionContext + { + PreviousState = _state, + Signer = _signer, + BlockIndex = rewardLevel * MonsterCollectionState0.RewardInterval, + RandomSeed = 0, + }); + + MonsterCollectionState0 nextMonsterCollectionState = new MonsterCollectionState0((Dictionary)nextState.GetState(collectionAddress)); + Assert.Equal(rewardLevel, nextMonsterCollectionState.RewardLevel); + + AvatarState nextAvatarState = nextState.GetAvatarState(_avatarAddress); + foreach (var (itemId, qty) in rewardExpectedMap) + { + Assert.True(nextAvatarState.inventory.HasItem(itemId, qty)); + } + + Assert.Equal(rewardLevel - prevRewardLevel, nextAvatarState.mailBox.Count); + Assert.All(nextAvatarState.mailBox, mail => + { + Assert.IsType(mail); + MonsterCollectionMail monsterCollectionMail = (MonsterCollectionMail)mail; + Assert.IsType(monsterCollectionMail.attachment); + MonsterCollectionResult result = (MonsterCollectionResult)monsterCollectionMail.attachment; + Assert.Equal(result.id, mail.id); + }); + + for (int i = 0; i < nextMonsterCollectionState.RewardLevel; i++) + { + int level = i + 1; + List rewardInfos = _tableSheets.MonsterCollectionRewardSheet[collectionLevel].Rewards; + Assert.Contains(level, nextMonsterCollectionState.RewardMap.Keys); + Assert.Equal(_avatarAddress, nextMonsterCollectionState.RewardMap[level].avatarAddress); + } + + Assert.Equal(0 * currency, nextState.GetBalance(collectionAddress, currency)); + Assert.Equal(balance, nextState.GetBalance(_signer, currency)); + Assert.Equal(collectionRound, nextState.GetAgentState(_signer).MonsterCollectionRound); + Assert.Equal(nextMonsterCollectionState.End, rewardLevel == 4); + } + + [Fact] + public void Execute_Throw_FailedLoadStateException_AgentState() + { + ClaimMonsterCollectionReward0 action = new ClaimMonsterCollectionReward0 + { + avatarAddress = _avatarAddress, + collectionRound = 0, + }; + + Assert.Throws(() => action.Execute(new ActionContext + { + PreviousState = _state, + Signer = new PrivateKey().Address, + BlockIndex = 0, + }) + ); + } + + [Fact] + public void Execute_Throw_FailedLoadStateException_MonsterCollectionState() + { + ClaimMonsterCollectionReward0 action = new ClaimMonsterCollectionReward0 + { + avatarAddress = _avatarAddress, + collectionRound = 1, + }; + + Assert.Throws(() => action.Execute(new ActionContext + { + PreviousState = _state, + Signer = _signer, + BlockIndex = 0, + }) + ); + } + + [Fact] + public void Execute_Throw_MonsterCollectionExpiredException() + { + Address collectionAddress = MonsterCollectionState0.DeriveAddress(_signer, 0); + MonsterCollectionState0 monsterCollectionState = new MonsterCollectionState0(collectionAddress, 1, 0, _tableSheets.MonsterCollectionRewardSheet); + List rewards = _tableSheets.MonsterCollectionRewardSheet[4].Rewards; + MonsterCollectionResult result = new MonsterCollectionResult(Guid.NewGuid(), _avatarAddress, rewards); + monsterCollectionState.UpdateRewardMap(4, result, 0); + _state = _state.SetState(collectionAddress, monsterCollectionState.Serialize()); + + ClaimMonsterCollectionReward0 action = new ClaimMonsterCollectionReward0 + { + avatarAddress = _avatarAddress, + collectionRound = 0, + }; + + Assert.Throws(() => action.Execute(new ActionContext + { + PreviousState = _state, + Signer = _signer, + BlockIndex = 0, + }) + ); + } + + [Theory] + [InlineData(0, -1)] + [InlineData(0, MonsterCollectionState0.RewardInterval - 1)] + public void Execute_Throw_RequiredBlockIndexException(long startedBlockIndex, long blockIndex) + { + Address collectionAddress = MonsterCollectionState0.DeriveAddress(_signer, 0); + MonsterCollectionState0 monsterCollectionState = new MonsterCollectionState0(collectionAddress, 1, startedBlockIndex, _tableSheets.MonsterCollectionRewardSheet); + List rewards = _tableSheets.MonsterCollectionRewardSheet[1].Rewards; + MonsterCollectionResult result = new MonsterCollectionResult(Guid.NewGuid(), _avatarAddress, rewards); + monsterCollectionState.UpdateRewardMap(1, result, 0); + + _state = _state.SetState(collectionAddress, monsterCollectionState.Serialize()); + + ClaimMonsterCollectionReward0 action = new ClaimMonsterCollectionReward0 + { + avatarAddress = _avatarAddress, + collectionRound = 0, + }; + + Assert.Throws(() => action.Execute(new ActionContext + { + PreviousState = _state, + Signer = _signer, + BlockIndex = blockIndex, + }) + ); + } + + [Fact] + public void Execute_Throw_InsufficientBalanceException() + { + Address collectionAddress = MonsterCollectionState0.DeriveAddress(_signer, 0); + MonsterCollectionState0 monsterCollectionState = new MonsterCollectionState0(collectionAddress, 1, 0, _tableSheets.MonsterCollectionRewardSheet); + + _state = _state.SetState(collectionAddress, monsterCollectionState.Serialize()); + + ClaimMonsterCollectionReward0 action = new ClaimMonsterCollectionReward0 + { + avatarAddress = _avatarAddress, + collectionRound = 0, + }; + + Assert.Throws(() => action.Execute(new ActionContext + { + PreviousState = _state, + Signer = _signer, + BlockIndex = MonsterCollectionState0.ExpirationIndex, + RandomSeed = 0, + }) + ); + } + } +} diff --git a/.Lib9c.Tests/Action/ClaimMonsterCollectionReward2Test.cs b/.Lib9c.Tests/Action/ClaimMonsterCollectionReward2Test.cs new file mode 100644 index 0000000000..5f516665cf --- /dev/null +++ b/.Lib9c.Tests/Action/ClaimMonsterCollectionReward2Test.cs @@ -0,0 +1,350 @@ +namespace Lib9c.Tests.Action +{ + using System; + using System.Collections; + using System.Collections.Generic; + using System.Collections.Immutable; + using System.Linq; + using Bencodex.Types; + using Libplanet.Action.State; + using Libplanet.Crypto; + using Libplanet.Types.Assets; + using Nekoyume; + using Nekoyume.Action; + using Nekoyume.Model.Mail; + using Nekoyume.Model.State; + using Serilog; + using Xunit; + using Xunit.Abstractions; + using static Lib9c.SerializeKeys; + using static Nekoyume.Model.Item.Inventory; + + public class ClaimMonsterCollectionReward2Test + { + private readonly Address _signer; + private readonly Address _avatarAddress; + private readonly TableSheets _tableSheets; + private IAccount _state; + + public ClaimMonsterCollectionReward2Test(ITestOutputHelper outputHelper) + { + Log.Logger = new LoggerConfiguration() + .MinimumLevel.Verbose() + .WriteTo.TestOutput(outputHelper) + .CreateLogger(); + + _signer = default; + _avatarAddress = _signer.Derive("avatar"); + _state = new Account(MockState.Empty); + Dictionary sheets = TableSheetsImporter.ImportSheets(); + _tableSheets = new TableSheets(sheets); + var rankingMapAddress = new PrivateKey().Address; + var agentState = new AgentState(_signer); + var avatarState = new AvatarState( + _avatarAddress, + _signer, + 0, + _tableSheets.GetAvatarSheets(), + new GameConfigState(), + rankingMapAddress); + agentState.avatarAddresses[0] = _avatarAddress; + +#pragma warning disable CS0618 + // Use of obsolete method Currency.Legacy(): https://github.com/planetarium/lib9c/discussions/1319 + var currency = Currency.Legacy("NCG", 2, null); +#pragma warning restore CS0618 + var goldCurrencyState = new GoldCurrencyState(currency); + + _state = _state + .SetState(_signer, agentState.Serialize()) + .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(Addresses.GoldCurrency, goldCurrencyState.Serialize()); + + foreach ((string key, string value) in sheets) + { + _state = _state + .SetState(Addresses.TableSheet.Derive(key), value.Serialize()); + } + } + + [Theory] + [ClassData(typeof(ExecuteFixture))] + public void Execute(int collectionLevel, long claimBlockIndex, long? receivedBlockIndex, (int, int)[] expectedRewards, Type exc) + { + Address collectionAddress = MonsterCollectionState.DeriveAddress(_signer, 0); + var monsterCollectionState = new MonsterCollectionState(collectionAddress, collectionLevel, 0); + if (receivedBlockIndex is { } receivedBlockIndexNotNull) + { + monsterCollectionState.Claim(receivedBlockIndexNotNull); + } + + AvatarState prevAvatarState = _state.GetAvatarStateV2(_avatarAddress); + Assert.Empty(prevAvatarState.mailBox); + + Currency currency = _state.GetGoldCurrency(); + + _state = _state.SetState(collectionAddress, monsterCollectionState.Serialize()); + + Assert.Equal(0, _state.GetAgentState(_signer).MonsterCollectionRound); + Assert.Equal(0 * currency, _state.GetBalance(_signer, currency)); + Assert.Equal(0 * currency, _state.GetBalance(collectionAddress, currency)); + + ClaimMonsterCollectionReward2 action = new ClaimMonsterCollectionReward2 + { + avatarAddress = _avatarAddress, + }; + + if (exc is { }) + { + Assert.Throws(exc, () => + { + action.Execute(new ActionContext + { + PreviousState = _state, + Signer = _signer, + BlockIndex = claimBlockIndex, + RandomSeed = 0, + }); + }); + } + else + { + IAccount nextState = action.Execute(new ActionContext + { + PreviousState = _state, + Signer = _signer, + BlockIndex = claimBlockIndex, + RandomSeed = 0, + }); + + var nextMonsterCollectionState = new MonsterCollectionState( + (Dictionary)nextState.GetState(collectionAddress) + ); + Assert.Equal(0, nextMonsterCollectionState.RewardLevel); + + AvatarState nextAvatarState = nextState.GetAvatarStateV2(_avatarAddress); + Assert.Single(nextAvatarState.mailBox); + Mail mail = nextAvatarState.mailBox.First(); + MonsterCollectionMail monsterCollectionMail = Assert.IsType(mail); + MonsterCollectionResult result = + Assert.IsType(monsterCollectionMail.attachment); + Assert.Equal(result.id, mail.id); + Assert.Equal(0, nextMonsterCollectionState.StartedBlockIndex); + Assert.Equal(claimBlockIndex, nextMonsterCollectionState.ReceivedBlockIndex); + Assert.Equal(0 * currency, nextState.GetBalance(_signer, currency)); + Assert.Equal(0, nextState.GetAgentState(_signer).MonsterCollectionRound); + + foreach ((int id, int quantity) in expectedRewards) + { + Assert.True(nextAvatarState.inventory.TryGetItem(id, out Item item)); + Assert.Equal(quantity, item.count); + } + } + } + + [Fact] + public void Execute_Throw_FailedLoadStateException_AgentState() + { + ClaimMonsterCollectionReward2 action = new ClaimMonsterCollectionReward2 + { + avatarAddress = _avatarAddress, + }; + + Assert.Throws(() => action.Execute(new ActionContext + { + PreviousState = _state, + Signer = new PrivateKey().Address, + BlockIndex = 0, + }) + ); + } + + [Fact] + public void Execute_Throw_FailedLoadStateException_MonsterCollectionState() + { + ClaimMonsterCollectionReward2 action = new ClaimMonsterCollectionReward2 + { + avatarAddress = _avatarAddress, + }; + + Assert.Throws(() => action.Execute(new ActionContext + { + PreviousState = _state, + Signer = _signer, + BlockIndex = 0, + }) + ); + } + + [Fact] + public void Execute_Throw_RequiredBlockIndexException() + { + Address collectionAddress = MonsterCollectionState.DeriveAddress(_signer, 0); + var monsterCollectionState = new MonsterCollectionState(collectionAddress, 1, 0); + _state = _state.SetState(collectionAddress, monsterCollectionState.Serialize()); + + ClaimMonsterCollectionReward2 action = new ClaimMonsterCollectionReward2 + { + avatarAddress = _avatarAddress, + }; + + Assert.Throws(() => action.Execute(new ActionContext + { + PreviousState = _state, + Signer = _signer, + BlockIndex = 0, + }) + ); + } + + private class ExecuteFixture : IEnumerable + { + private readonly List _data = new List + { + new object[] + { + 1, + MonsterCollectionState.RewardInterval, + null, + new (int, int)[] + { + (400000, 80), + (500000, 1), + }, + null, + }, + new object[] + { + 2, + MonsterCollectionState.RewardInterval, + null, + new (int, int)[] + { + (400000, 265), + (500000, 2), + }, + null, + }, + new object[] + { + 3, + MonsterCollectionState.RewardInterval, + null, + new (int, int)[] + { + (400000, 1265), + (500000, 5), + }, + null, + }, + new object[] + { + 4, + MonsterCollectionState.RewardInterval, + null, + new (int, int)[] + { + (400000, 8465), + (500000, 31), + }, + null, + }, + new object[] + { + 5, + MonsterCollectionState.RewardInterval, + null, + new (int, int)[] + { + (400000, 45965), + (500000, 161), + }, + null, + }, + new object[] + { + 6, + MonsterCollectionState.RewardInterval, + null, + new (int, int)[] + { + (400000, 120965), + (500000, 361), + }, + null, + }, + new object[] + { + 7, + MonsterCollectionState.RewardInterval, + null, + new (int, int)[] + { + (400000, 350965), + (500000, 1121), + }, + null, + }, + new object[] + { + 1, + MonsterCollectionState.RewardInterval * 2, + null, + new (int, int)[] + { + (400000, 80 * 2), + (500000, 1 * 2), + }, + null, + }, + new object[] + { + 2, + MonsterCollectionState.RewardInterval * 2, + null, + new (int, int)[] + { + (400000, 265 * 2), + (500000, 2 * 2), + }, + null, + }, + new object[] + { + 1, + MonsterCollectionState.RewardInterval * 2, + MonsterCollectionState.RewardInterval * 2 - 1, + new (int, int)[] + { + (400000, 80), + (500000, 1), + }, + null, + }, + new object[] + { + 1, + 1, + null, + new (int, int)[] { }, + typeof(RequiredBlockIndexException), + }, + new object[] + { + 1, + MonsterCollectionState.RewardInterval + 1, + MonsterCollectionState.RewardInterval, + new (int, int)[] { }, + typeof(RequiredBlockIndexException), + }, + }; + + public IEnumerator GetEnumerator() => _data.GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => _data.GetEnumerator(); + } + } +} diff --git a/.Lib9c.Tests/Action/ClaimStakeReward1Test.cs b/.Lib9c.Tests/Action/ClaimStakeReward1Test.cs new file mode 100644 index 0000000000..67e40ac78c --- /dev/null +++ b/.Lib9c.Tests/Action/ClaimStakeReward1Test.cs @@ -0,0 +1,117 @@ +namespace Lib9c.Tests.Action +{ + using System.Linq; + using Libplanet.Action.State; + using Libplanet.Crypto; + using Libplanet.Types.Assets; + using Nekoyume; + using Nekoyume.Action; + using Nekoyume.Model.State; + using Nekoyume.TableData; + using Serilog; + using Xunit; + using Xunit.Abstractions; + using static Lib9c.SerializeKeys; + + public class ClaimStakeReward1Test + { + private readonly IAccount _initialState; + private readonly Currency _currency; + private readonly GoldCurrencyState _goldCurrencyState; + private readonly TableSheets _tableSheets; + private readonly Address _signerAddress; + private readonly Address _avatarAddress; + + public ClaimStakeReward1Test(ITestOutputHelper outputHelper) + { + Log.Logger = new LoggerConfiguration() + .MinimumLevel.Verbose() + .WriteTo.TestOutput(outputHelper) + .CreateLogger(); + + var context = new ActionContext(); + _initialState = new Account(MockState.Empty); + + var sheets = TableSheetsImporter.ImportSheets(); + sheets[nameof(StakeRegularRewardSheet)] = + ClaimStakeReward8.V2.StakeRegularRewardSheetCsv; + foreach (var (key, value) in sheets) + { + _initialState = _initialState + .SetState(Addresses.TableSheet.Derive(key), value.Serialize()); + } + + _tableSheets = new TableSheets(sheets); + +#pragma warning disable CS0618 + // Use of obsolete method Currency.Legacy(): https://github.com/planetarium/lib9c/discussions/1319 + _currency = Currency.Legacy("NCG", 2, null); +#pragma warning restore CS0618 + _goldCurrencyState = new GoldCurrencyState(_currency); + + _signerAddress = new PrivateKey().Address; + var stakeStateAddress = StakeState.DeriveAddress(_signerAddress); + var agentState = new AgentState(_signerAddress); + _avatarAddress = new PrivateKey().Address; + var rankingMapAddress = _avatarAddress.Derive("ranking_map"); + agentState.avatarAddresses.Add(0, _avatarAddress); + var avatarState = new AvatarState( + _avatarAddress, + _signerAddress, + 0, + _tableSheets.GetAvatarSheets(), + new GameConfigState(sheets[nameof(GameConfigSheet)]), + rankingMapAddress + ) + { + level = 100, + }; + _initialState = _initialState + .SetState(_signerAddress, agentState.Serialize()) + .SetState(_avatarAddress, avatarState.SerializeV2()) + .SetState( + _avatarAddress.Derive(LegacyInventoryKey), + avatarState.inventory.Serialize()) + .SetState( + _avatarAddress.Derive(LegacyWorldInformationKey), + avatarState.worldInformation.Serialize()) + .SetState( + _avatarAddress.Derive(LegacyQuestListKey), + avatarState.questList.Serialize()) + .SetState(GoldCurrencyState.Address, _goldCurrencyState.Serialize()) + .SetState(stakeStateAddress, new StakeState(stakeStateAddress, 0).Serialize()) + .MintAsset(context, stakeStateAddress, _currency * 100); + } + + [Fact] + public void Execute() + { + var action = new ClaimStakeReward1(_avatarAddress); + var states = action.Execute(new ActionContext + { + PreviousState = _initialState, + Signer = _signerAddress, + BlockIndex = StakeState.LockupInterval, + }); + + AvatarState avatarState = states.GetAvatarStateV2(_avatarAddress); + // regular (100 / 10) * 4 + Assert.Equal(40, avatarState.inventory.Items.First(x => x.item.Id == 400000).count); + // regular ((100 / 800) + 1) * 4 + // It must be never added into the inventory if the amount is 0. + Assert.Equal(4, avatarState.inventory.Items.First(x => x.item.Id == 500000).count); + + Assert.True(states.TryGetStakeState(_signerAddress, out StakeState stakeState)); + Assert.Equal(StakeState.LockupInterval, stakeState.ReceivedBlockIndex); + } + + [Fact] + public void Serialization() + { + var action = new ClaimStakeReward1(_avatarAddress); + var deserialized = new ClaimStakeReward1(); + deserialized.LoadPlainValue(action.PlainValue); + Assert.Equal(action.AvatarAddress, deserialized.AvatarAddress); + } + } +} diff --git a/.Lib9c.Tests/Action/ClaimStakeReward2Test.cs b/.Lib9c.Tests/Action/ClaimStakeReward2Test.cs new file mode 100644 index 0000000000..3801ccf9c2 --- /dev/null +++ b/.Lib9c.Tests/Action/ClaimStakeReward2Test.cs @@ -0,0 +1,179 @@ +namespace Lib9c.Tests.Action +{ + using System.Linq; + using Libplanet.Action.State; + using Libplanet.Crypto; + using Libplanet.Types.Assets; + using Nekoyume; + using Nekoyume.Action; + using Nekoyume.Model.State; + using Nekoyume.TableData; + using Serilog; + using Xunit; + using Xunit.Abstractions; + using static Lib9c.SerializeKeys; + + public class ClaimStakeReward2Test + { + private readonly IAccount _initialState; + private readonly Currency _currency; + private readonly GoldCurrencyState _goldCurrencyState; + private readonly TableSheets _tableSheets; + private readonly Address _signerAddress; + private readonly Address _avatarAddress; + private readonly Address _avatarAddressForBackwardCompatibility; + + public ClaimStakeReward2Test(ITestOutputHelper outputHelper) + { + Log.Logger = new LoggerConfiguration() + .MinimumLevel.Verbose() + .WriteTo.TestOutput(outputHelper) + .CreateLogger(); + + var context = new ActionContext(); + _initialState = new Account(MockState.Empty); + + var sheets = TableSheetsImporter.ImportSheets(); + sheets[nameof(StakeRegularRewardSheet)] = + ClaimStakeReward8.V2.StakeRegularRewardSheetCsv; + foreach (var (key, value) in sheets) + { + _initialState = _initialState + .SetState(Addresses.TableSheet.Derive(key), value.Serialize()); + } + + _tableSheets = new TableSheets(sheets); + +#pragma warning disable CS0618 + // Use of obsolete method Currency.Legacy(): https://github.com/planetarium/lib9c/discussions/1319 + _currency = Currency.Legacy("NCG", 2, null); +#pragma warning restore CS0618 + _goldCurrencyState = new GoldCurrencyState(_currency); + + _signerAddress = new PrivateKey().Address; + var stakeStateAddress = StakeState.DeriveAddress(_signerAddress); + var agentState = new AgentState(_signerAddress); + _avatarAddress = _signerAddress.Derive("0"); + agentState.avatarAddresses.Add(0, _avatarAddress); + var avatarState = new AvatarState( + _avatarAddress, + _signerAddress, + 0, + _tableSheets.GetAvatarSheets(), + new GameConfigState(sheets[nameof(GameConfigSheet)]), + new PrivateKey().Address + ) + { + level = 100, + }; + + _avatarAddressForBackwardCompatibility = _signerAddress.Derive("1"); + agentState.avatarAddresses.Add(1, _avatarAddressForBackwardCompatibility); + var avatarStateForBackwardCompatibility = new AvatarState( + _avatarAddressForBackwardCompatibility, + _signerAddress, + 0, + _tableSheets.GetAvatarSheets(), + new GameConfigState(sheets[nameof(GameConfigSheet)]), + new PrivateKey().Address + ) + { + level = 100, + }; + + _initialState = _initialState + .SetState(_signerAddress, agentState.Serialize()) + .SetState(_avatarAddress, avatarState.SerializeV2()) + .SetState( + _avatarAddress.Derive(LegacyInventoryKey), + avatarState.inventory.Serialize()) + .SetState( + _avatarAddress.Derive(LegacyWorldInformationKey), + avatarState.worldInformation.Serialize()) + .SetState( + _avatarAddress.Derive(LegacyQuestListKey), + avatarState.questList.Serialize()) + .SetState( + _avatarAddressForBackwardCompatibility, + avatarStateForBackwardCompatibility.Serialize()) + .SetState(GoldCurrencyState.Address, _goldCurrencyState.Serialize()) + .SetState(stakeStateAddress, new StakeState(stakeStateAddress, 0).Serialize()) + .MintAsset(context, stakeStateAddress, _currency * 100); + } + + [Fact] + public void Serialization() + { + var action = new ClaimStakeReward2(_avatarAddress); + var deserialized = new ClaimStakeReward2(); + deserialized.LoadPlainValue(action.PlainValue); + Assert.Equal(action.AvatarAddress, deserialized.AvatarAddress); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void Execute_Success(bool useOldTable) + { + Execute(_avatarAddress, useOldTable); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void Execute_With_Old_AvatarState_Success(bool useOldTable) + { + Execute(_avatarAddressForBackwardCompatibility, useOldTable); + } + + [Fact] + public void Execute_Throw_ActionObsoletedException() + { + var action = new ClaimStakeReward2(_avatarAddress); + Assert.Throws(() => action.Execute(new ActionContext + { + PreviousState = _initialState, + Signer = _signerAddress, + BlockIndex = ClaimStakeReward2.ObsoletedIndex + 1, + })); + } + + private void Execute(Address avatarAddress, bool useOldTable) + { + var state = _initialState; + if (useOldTable) + { + var sheet = @"level,required_gold,item_id,rate +1,50,400000,10 +1,50,500000,800 +2,500,400000,8 +2,500,500000,800 +3,5000,400000,5 +3,5000,500000,800 +4,50000,400000,5 +4,50000,500000,800 +5,500000,400000,5 +5,500000,500000,800".Serialize(); + state = state.SetState(Addresses.GetSheetAddress(), sheet); + } + + var action = new ClaimStakeReward2(avatarAddress); + var states = action.Execute(new ActionContext + { + PreviousState = state, + Signer = _signerAddress, + BlockIndex = StakeState.LockupInterval, + }); + + AvatarState avatarState = states.GetAvatarStateV2(avatarAddress); + // regular (100 / 10) * 4 + Assert.Equal(40, avatarState.inventory.Items.First(x => x.item.Id == 400000).count); + // regular ((100 / 800) + 1) * 4 + // It must be never added into the inventory if the amount is 0. + Assert.Equal(4, avatarState.inventory.Items.First(x => x.item.Id == 500000).count); + + Assert.True(states.TryGetStakeState(_signerAddress, out StakeState stakeState)); + Assert.Equal(StakeState.LockupInterval, stakeState.ReceivedBlockIndex); + } + } +} diff --git a/.Lib9c.Tests/Action/ClaimStakeReward3Test.cs b/.Lib9c.Tests/Action/ClaimStakeReward3Test.cs new file mode 100644 index 0000000000..180b452a78 --- /dev/null +++ b/.Lib9c.Tests/Action/ClaimStakeReward3Test.cs @@ -0,0 +1,246 @@ +namespace Lib9c.Tests.Action +{ +#nullable enable + + using System.Collections.Generic; + using System.Linq; + using Lib9c.Tests.Util; + using Libplanet.Action.State; + using Libplanet.Crypto; + using Libplanet.Types.Assets; + using Nekoyume.Action; + using Nekoyume.Helper; + using Nekoyume.Model.State; + using Nekoyume.TableData; + using Serilog; + using Xunit; + using Xunit.Abstractions; + + public class ClaimStakeReward3Test + { + private const string AgentAddressHex = "0x0000000001000000000100000000010000000001"; + private readonly Address _agentAddr = new Address(AgentAddressHex); + private readonly Address _avatarAddr; + private readonly IAccount _initialStatesWithAvatarStateV1; + private readonly IAccount _initialStatesWithAvatarStateV2; + private readonly Currency _ncg; + + public ClaimStakeReward3Test(ITestOutputHelper outputHelper) + { + Log.Logger = new LoggerConfiguration() + .MinimumLevel.Verbose() + .WriteTo.TestOutput(outputHelper) + .CreateLogger(); + ( + _, + _, + _avatarAddr, + _initialStatesWithAvatarStateV1, + _initialStatesWithAvatarStateV2) = InitializeUtil.InitializeStates( + agentAddr: _agentAddr, + sheetsOverride: new Dictionary + { + { + nameof(StakeRegularRewardSheet), + ClaimStakeReward6.V2.StakeRegularRewardSheetCsv + }, + }); + _ncg = _initialStatesWithAvatarStateV1.GetGoldCurrency(); + } + + [Fact] + public void Serialization() + { + var action = new ClaimStakeReward3(_avatarAddr); + var deserialized = new ClaimStakeReward3(); + deserialized.LoadPlainValue(action.PlainValue); + Assert.Equal(action.AvatarAddress, deserialized.AvatarAddress); + } + + [Theory] + [InlineData(ClaimStakeReward2.ObsoletedIndex)] + [InlineData(ClaimStakeReward2.ObsoletedIndex - 1)] + public void Execute_Throw_ActionUnAvailableException(long blockIndex) + { + var action = new ClaimStakeReward3(_avatarAddr); + Assert.Throws(() => action.Execute(new ActionContext + { + PreviousState = _initialStatesWithAvatarStateV2, + Signer = _agentAddr, + BlockIndex = blockIndex, + })); + } + + [Theory] + [InlineData( + ClaimStakeReward2.ObsoletedIndex, + 100L, + null, + ClaimStakeReward2.ObsoletedIndex + StakeState.LockupInterval, + 40, + 4, + 0 + )] + [InlineData( + ClaimStakeReward2.ObsoletedIndex, + 6000L, + null, + ClaimStakeReward2.ObsoletedIndex + StakeState.LockupInterval, + 4800, + 36, + 4 + )] + // Calculate rune start from hard fork index + [InlineData( + 0L, + 6000L, + 0L, + ClaimStakeReward2.ObsoletedIndex + StakeState.LockupInterval, + 136800, + 1026, + 4 + )] + // Stake reward v2 + // Stake before v2, prev. receive v1, receive v1 & v2 + [InlineData( + StakeState.StakeRewardSheetV2Index - StakeState.RewardInterval * 2, + 50L, + StakeState.StakeRewardSheetV2Index - StakeState.RewardInterval, + StakeState.StakeRewardSheetV2Index + 1, + 5, + 1, + 0 + )] + // Stake before v2, prev. receive v2, receive v2 + [InlineData( + StakeState.StakeRewardSheetV2Index - StakeState.RewardInterval, + 50L, + StakeState.StakeRewardSheetV2Index, + StakeState.StakeRewardSheetV2Index + StakeState.RewardInterval, + 5, + 1, + 0 + )] + // Stake after v2, no prev. receive, receive v2 + [InlineData( + StakeState.StakeRewardSheetV2Index, + 6000L, + null, + StakeState.StakeRewardSheetV2Index + StakeState.RewardInterval, + 1200, + 9, + 1 + )] + // stake after v2, prev. receive v2, receive v2 + [InlineData( + StakeState.StakeRewardSheetV2Index, + 50L, + StakeState.StakeRewardSheetV2Index + StakeState.RewardInterval, + StakeState.StakeRewardSheetV2Index + StakeState.RewardInterval * 2, + 5, + 1, + 0 + )] + // stake before currency as reward, non prev. + [InlineData( + StakeState.CurrencyAsRewardStartIndex - StakeState.RewardInterval * 2, + 10_000_000L, + null, + StakeState.CurrencyAsRewardStartIndex + StakeState.RewardInterval, + 3_000_000, + 37_506, + 4_998 + )] + // stake before currency as reward, prev. + [InlineData( + StakeState.CurrencyAsRewardStartIndex - StakeState.RewardInterval * 2, + 10_000_000L, + StakeState.CurrencyAsRewardStartIndex - StakeState.RewardInterval, + StakeState.CurrencyAsRewardStartIndex + StakeState.RewardInterval, + 2_000_000, + 25_004, + 3_332 + )] + public void Execute_Success( + long startedBlockIndex, + long stakeAmount, + long? previousRewardReceiveIndex, + long blockIndex, + int expectedHourglass, + int expectedApStone, + int expectedRune) + { + Execute( + _initialStatesWithAvatarStateV1, + _agentAddr, + _avatarAddr, + startedBlockIndex, + stakeAmount, + previousRewardReceiveIndex, + blockIndex, + expectedHourglass, + expectedApStone, + expectedRune); + + Execute( + _initialStatesWithAvatarStateV2, + _agentAddr, + _avatarAddr, + startedBlockIndex, + stakeAmount, + previousRewardReceiveIndex, + blockIndex, + expectedHourglass, + expectedApStone, + expectedRune); + } + + private void Execute( + IAccount prevState, + Address agentAddr, + Address avatarAddr, + long startedBlockIndex, + long stakeAmount, + long? previousRewardReceiveIndex, + long blockIndex, + int expectedHourglass, + int expectedApStone, + int expectedRune) + { + var context = new ActionContext(); + var stakeStateAddr = StakeState.DeriveAddress(agentAddr); + var initialStakeState = new StakeState(stakeStateAddr, startedBlockIndex); + if (!(previousRewardReceiveIndex is null)) + { + initialStakeState.Claim((long)previousRewardReceiveIndex); + } + + prevState = prevState + .SetState(stakeStateAddr, initialStakeState.Serialize()) + .MintAsset(context, stakeStateAddr, _ncg * stakeAmount); + + var action = new ClaimStakeReward3(avatarAddr); + var states = action.Execute(new ActionContext + { + PreviousState = prevState, + Signer = agentAddr, + BlockIndex = blockIndex, + }); + + AvatarState avatarState = states.GetAvatarStateV2(avatarAddr); + Assert.Equal( + expectedHourglass, + avatarState.inventory.Items.First(x => x.item.Id == 400000).count); + // It must be never added into the inventory if the amount is 0. + Assert.Equal( + expectedApStone, + avatarState.inventory.Items.First(x => x.item.Id == 500000).count); + Assert.Equal( + expectedRune * RuneHelper.StakeRune, + states.GetBalance(avatarAddr, RuneHelper.StakeRune)); + + Assert.True(states.TryGetStakeState(agentAddr, out StakeState stakeState)); + Assert.Equal(blockIndex, stakeState.ReceivedBlockIndex); + } + } +} diff --git a/.Lib9c.Tests/Action/ClaimStakeReward4Test.cs b/.Lib9c.Tests/Action/ClaimStakeReward4Test.cs new file mode 100644 index 0000000000..0a6d584303 --- /dev/null +++ b/.Lib9c.Tests/Action/ClaimStakeReward4Test.cs @@ -0,0 +1,315 @@ +namespace Lib9c.Tests.Action +{ +#nullable enable + + using System.Collections.Generic; + using System.Linq; + using Lib9c.Tests.Util; + using Libplanet.Action.State; + using Libplanet.Crypto; + using Libplanet.Types.Assets; + using Nekoyume.Action; + using Nekoyume.Helper; + using Nekoyume.Model.State; + using Nekoyume.TableData; + using Serilog; + using Xunit; + using Xunit.Abstractions; + + public class ClaimStakeReward4Test + { + private const string AgentAddressHex = "0x0000000001000000000100000000010000000001"; + private readonly Address _agentAddr = new Address(AgentAddressHex); + private readonly Address _avatarAddr; + private readonly IAccount _initialStatesWithAvatarStateV1; + private readonly IAccount _initialStatesWithAvatarStateV2; + private readonly Currency _ncg; + + public ClaimStakeReward4Test(ITestOutputHelper outputHelper) + { + Log.Logger = new LoggerConfiguration() + .MinimumLevel.Verbose() + .WriteTo.TestOutput(outputHelper) + .CreateLogger(); + ( + _, + _, + _avatarAddr, + _initialStatesWithAvatarStateV1, + _initialStatesWithAvatarStateV2) = InitializeUtil.InitializeStates( + agentAddr: _agentAddr, + sheetsOverride: new Dictionary + { + { + nameof(StakeRegularRewardSheet), + ClaimStakeReward6.V2.StakeRegularRewardSheetCsv + }, + }); + _ncg = _initialStatesWithAvatarStateV1.GetGoldCurrency(); + } + + [Fact] + public void Serialization() + { + var action = new ClaimStakeReward4(_avatarAddr); + var deserialized = new ClaimStakeReward4(); + deserialized.LoadPlainValue(action.PlainValue); + Assert.Equal(action.AvatarAddress, deserialized.AvatarAddress); + } + + [Theory] + [InlineData( + ClaimStakeReward2.ObsoletedIndex, + 100L, + null, + ClaimStakeReward2.ObsoletedIndex + StakeState.LockupInterval, + 40, + 4, + 0, + null, + null, + 0L + )] + [InlineData( + ClaimStakeReward2.ObsoletedIndex, + 6000L, + null, + ClaimStakeReward2.ObsoletedIndex + StakeState.LockupInterval, + 4800, + 36, + 4, + null, + null, + 0L + )] + // Calculate rune start from hard fork index + [InlineData( + 0L, + 6000L, + 0L, + ClaimStakeReward2.ObsoletedIndex + StakeState.LockupInterval, + 136800, + 1026, + 4, + null, + null, + 0L + )] + // Stake reward v2 + // Stake before v2, prev. receive v1, receive v1 & v2 + [InlineData( + StakeState.StakeRewardSheetV2Index - StakeState.RewardInterval * 2, + 50L, + StakeState.StakeRewardSheetV2Index - StakeState.RewardInterval, + StakeState.StakeRewardSheetV2Index + 1, + 5, + 1, + 0, + null, + null, + 0L + )] + // Stake before v2, prev. receive v2, receive v2 + [InlineData( + StakeState.StakeRewardSheetV2Index - StakeState.RewardInterval, + 50L, + StakeState.StakeRewardSheetV2Index, + StakeState.StakeRewardSheetV2Index + StakeState.RewardInterval, + 5, + 1, + 0, + null, + null, + 0L + )] + // Stake after v2, no prev. receive, receive v2 + [InlineData( + StakeState.StakeRewardSheetV2Index, + 6000L, + null, + StakeState.StakeRewardSheetV2Index + StakeState.RewardInterval, + 1200, + 9, + 1, + null, + null, + 0L + )] + // stake after v2, prev. receive v2, receive v2 + [InlineData( + StakeState.StakeRewardSheetV2Index, + 50L, + StakeState.StakeRewardSheetV2Index + StakeState.RewardInterval, + StakeState.StakeRewardSheetV2Index + StakeState.RewardInterval * 2, + 5, + 1, + 0, + null, + null, + 0L + )] + // stake before currency as reward, non prev. + [InlineData( + StakeState.CurrencyAsRewardStartIndex - StakeState.RewardInterval * 2, + 10_000_000L, + null, + StakeState.CurrencyAsRewardStartIndex + StakeState.RewardInterval, + 3_000_000, + 37_506, + 4_998, + AgentAddressHex, + "GARAGE", + 100_000L + )] + // stake before currency as reward, prev. + [InlineData( + StakeState.CurrencyAsRewardStartIndex - StakeState.RewardInterval * 2, + 10_000_000L, + StakeState.CurrencyAsRewardStartIndex - StakeState.RewardInterval, + StakeState.CurrencyAsRewardStartIndex + StakeState.RewardInterval, + 2_000_000, + 25_004, + 3_332, + AgentAddressHex, + "GARAGE", + 100_000L + )] + // test tx(c46cf83c46bc106372015a5020d6b9f15dc733819a6dc3fde37d9b5625fc3d93) + [InlineData( + 7_009_561L, + 10_000_000L, + 7_110_390L, + 7_160_778L, + 0, + 0, + 0, + null, + null, + 0)] + public void Execute_Success( + long startedBlockIndex, + long stakeAmount, + long? previousRewardReceiveIndex, + long blockIndex, + int expectedHourglass, + int expectedApStone, + int expectedRune, + string expectedCurrencyAddrHex, + string expectedCurrencyTicker, + long expectedCurrencyAmount) + { + Execute( + _initialStatesWithAvatarStateV1, + _agentAddr, + _avatarAddr, + startedBlockIndex, + stakeAmount, + previousRewardReceiveIndex, + blockIndex, + expectedHourglass, + expectedApStone, + expectedRune, + expectedCurrencyAddrHex, + expectedCurrencyTicker, + expectedCurrencyAmount); + + Execute( + _initialStatesWithAvatarStateV2, + _agentAddr, + _avatarAddr, + startedBlockIndex, + stakeAmount, + previousRewardReceiveIndex, + blockIndex, + expectedHourglass, + expectedApStone, + expectedRune, + expectedCurrencyAddrHex, + expectedCurrencyTicker, + expectedCurrencyAmount); + } + + private void Execute( + IAccount prevState, + Address agentAddr, + Address avatarAddr, + long startedBlockIndex, + long stakeAmount, + long? previousRewardReceiveIndex, + long blockIndex, + int expectedHourglass, + int expectedApStone, + int expectedRune, + string expectedCurrencyAddrHex, + string expectedCurrencyTicker, + long expectedCurrencyAmount) + { + var stakeStateAddr = StakeState.DeriveAddress(agentAddr); + var initialStakeState = new StakeState(stakeStateAddr, startedBlockIndex); + if (!(previousRewardReceiveIndex is null)) + { + initialStakeState.Claim((long)previousRewardReceiveIndex); + } + + prevState = prevState + .SetState(stakeStateAddr, initialStakeState.Serialize()) + .MintAsset(new ActionContext(), stakeStateAddr, _ncg * stakeAmount); + + var action = new ClaimStakeReward4(avatarAddr); + var states = action.Execute(new ActionContext + { + PreviousState = prevState, + Signer = agentAddr, + BlockIndex = blockIndex, + }); + + var avatarState = states.GetAvatarStateV2(avatarAddr); + if (expectedHourglass > 0) + { + Assert.Equal( + expectedHourglass, + avatarState.inventory.Items.First(x => x.item.Id == 400000).count); + } + else + { + Assert.DoesNotContain(avatarState.inventory.Items, x => x.item.Id == 400000); + } + + if (expectedApStone > 0) + { + Assert.Equal( + expectedApStone, + avatarState.inventory.Items.First(x => x.item.Id == 500000).count); + } + else + { + Assert.DoesNotContain(avatarState.inventory.Items, x => x.item.Id == 500000); + } + + if (expectedRune > 0) + { + Assert.Equal( + expectedRune * RuneHelper.StakeRune, + states.GetBalance(avatarAddr, RuneHelper.StakeRune)); + } + else + { + Assert.Equal( + 0 * RuneHelper.StakeRune, + states.GetBalance(avatarAddr, RuneHelper.StakeRune)); + } + + if (!string.IsNullOrEmpty(expectedCurrencyAddrHex)) + { + var addr = new Address(expectedCurrencyAddrHex); + var currency = Currencies.GetMinterlessCurrency(expectedCurrencyTicker); + Assert.Equal( + expectedCurrencyAmount * currency, + states.GetBalance(addr, currency)); + } + + Assert.True(states.TryGetStakeState(agentAddr, out StakeState stakeState)); + Assert.Equal(blockIndex, stakeState.ReceivedBlockIndex); + } + } +} diff --git a/.Lib9c.Tests/Action/ClaimStakeReward5Test.cs b/.Lib9c.Tests/Action/ClaimStakeReward5Test.cs new file mode 100644 index 0000000000..ad2843b2fc --- /dev/null +++ b/.Lib9c.Tests/Action/ClaimStakeReward5Test.cs @@ -0,0 +1,316 @@ +namespace Lib9c.Tests.Action +{ +#nullable enable + + using System.Collections.Generic; + using System.Linq; + using Lib9c.Tests.Util; + using Libplanet.Action.State; + using Libplanet.Crypto; + using Libplanet.Types.Assets; + using Nekoyume.Action; + using Nekoyume.Helper; + using Nekoyume.Model.State; + using Nekoyume.TableData; + using Serilog; + using Xunit; + using Xunit.Abstractions; + + public class ClaimStakeReward5Test + { + private const string AgentAddressHex = "0x0000000001000000000100000000010000000001"; + private readonly Address _agentAddr = new Address(AgentAddressHex); + private readonly Address _avatarAddr; + private readonly IAccount _initialStatesWithAvatarStateV1; + private readonly IAccount _initialStatesWithAvatarStateV2; + private readonly Currency _ncg; + + public ClaimStakeReward5Test(ITestOutputHelper outputHelper) + { + Log.Logger = new LoggerConfiguration() + .MinimumLevel.Verbose() + .WriteTo.TestOutput(outputHelper) + .CreateLogger(); + ( + _, + _, + _avatarAddr, + _initialStatesWithAvatarStateV1, + _initialStatesWithAvatarStateV2) = InitializeUtil.InitializeStates( + agentAddr: _agentAddr, + sheetsOverride: new Dictionary + { + { + nameof(StakeRegularRewardSheet), + ClaimStakeReward6.V2.StakeRegularRewardSheetCsv + }, + }); + _ncg = _initialStatesWithAvatarStateV1.GetGoldCurrency(); + } + + [Fact] + public void Serialization() + { + var action = new ClaimStakeReward5(_avatarAddr); + var deserialized = new ClaimStakeReward5(); + deserialized.LoadPlainValue(action.PlainValue); + Assert.Equal(action.AvatarAddress, deserialized.AvatarAddress); + } + + [Theory] + [InlineData( + ClaimStakeReward2.ObsoletedIndex, + 100L, + null, + ClaimStakeReward2.ObsoletedIndex + StakeState.LockupInterval, + 40, + 4, + 0, + null, + null, + 0L + )] + [InlineData( + ClaimStakeReward2.ObsoletedIndex, + 6000L, + null, + ClaimStakeReward2.ObsoletedIndex + StakeState.LockupInterval, + 4800, + 36, + 4, + null, + null, + 0L + )] + // Calculate rune start from hard fork index + [InlineData( + 0L, + 6000L, + 0L, + ClaimStakeReward2.ObsoletedIndex + StakeState.LockupInterval, + 136800, + 1026, + 3, + null, + null, + 0L + )] + // Stake reward v2 + // Stake before v2, prev. receive v1, receive v1 & v2 + [InlineData( + StakeState.StakeRewardSheetV2Index - StakeState.RewardInterval * 2, + 50L, + StakeState.StakeRewardSheetV2Index - StakeState.RewardInterval, + StakeState.StakeRewardSheetV2Index + 1, + 5, + 1, + 0, + null, + null, + 0L + )] + // Stake before v2, prev. receive v2, receive v2 + [InlineData( + StakeState.StakeRewardSheetV2Index - StakeState.RewardInterval, + 50L, + StakeState.StakeRewardSheetV2Index, + StakeState.StakeRewardSheetV2Index + StakeState.RewardInterval, + 5, + 1, + 0, + null, + null, + 0L + )] + // Stake after v2, no prev. receive, receive v2 + [InlineData( + StakeState.StakeRewardSheetV2Index, + 6000L, + null, + StakeState.StakeRewardSheetV2Index + StakeState.RewardInterval, + 1200, + 9, + 1, + null, + null, + 0L + )] + // stake after v2, prev. receive v2, receive v2 + [InlineData( + StakeState.StakeRewardSheetV2Index, + 50L, + StakeState.StakeRewardSheetV2Index + StakeState.RewardInterval, + StakeState.StakeRewardSheetV2Index + StakeState.RewardInterval * 2, + 5, + 1, + 0, + null, + null, + 0L + )] + // stake before currency as reward, non prev. + [InlineData( + StakeState.CurrencyAsRewardStartIndex - StakeState.RewardInterval * 2, + 10_000_000L, + null, + StakeState.CurrencyAsRewardStartIndex + StakeState.RewardInterval, + 3_000_000, + 37_506, + 4_998, + AgentAddressHex, + "GARAGE", + 100_000L + )] + // stake before currency as reward, prev. + [InlineData( + StakeState.CurrencyAsRewardStartIndex - StakeState.RewardInterval * 2, + 10_000_000L, + StakeState.CurrencyAsRewardStartIndex - StakeState.RewardInterval, + StakeState.CurrencyAsRewardStartIndex + StakeState.RewardInterval, + 2_000_000, + 25_004, + 3_332, + AgentAddressHex, + "GARAGE", + 100_000L + )] + // test tx(c46cf83c46bc106372015a5020d6b9f15dc733819a6dc3fde37d9b5625fc3d93) + [InlineData( + 7_009_561L, + 10_000_000L, + 7_110_390L, + 7_160_778L, + 1_000_000, + 12_502, + 1_666, + null, + null, + 0)] + public void Execute_Success( + long startedBlockIndex, + long stakeAmount, + long? previousRewardReceiveIndex, + long blockIndex, + int expectedHourglass, + int expectedApStone, + int expectedRune, + string expectedCurrencyAddrHex, + string expectedCurrencyTicker, + long expectedCurrencyAmount) + { + Execute( + _initialStatesWithAvatarStateV1, + _agentAddr, + _avatarAddr, + startedBlockIndex, + stakeAmount, + previousRewardReceiveIndex, + blockIndex, + expectedHourglass, + expectedApStone, + expectedRune, + expectedCurrencyAddrHex, + expectedCurrencyTicker, + expectedCurrencyAmount); + + Execute( + _initialStatesWithAvatarStateV2, + _agentAddr, + _avatarAddr, + startedBlockIndex, + stakeAmount, + previousRewardReceiveIndex, + blockIndex, + expectedHourglass, + expectedApStone, + expectedRune, + expectedCurrencyAddrHex, + expectedCurrencyTicker, + expectedCurrencyAmount); + } + + private void Execute( + IAccount prevState, + Address agentAddr, + Address avatarAddr, + long startedBlockIndex, + long stakeAmount, + long? previousRewardReceiveIndex, + long blockIndex, + int expectedHourglass, + int expectedApStone, + int expectedRune, + string expectedCurrencyAddrHex, + string expectedCurrencyTicker, + long expectedCurrencyAmount) + { + var context = new ActionContext(); + var stakeStateAddr = StakeState.DeriveAddress(agentAddr); + var initialStakeState = new StakeState(stakeStateAddr, startedBlockIndex); + if (!(previousRewardReceiveIndex is null)) + { + initialStakeState.Claim((long)previousRewardReceiveIndex); + } + + prevState = prevState + .SetState(stakeStateAddr, initialStakeState.Serialize()) + .MintAsset(context, stakeStateAddr, _ncg * stakeAmount); + + var action = new ClaimStakeReward5(avatarAddr); + var states = action.Execute(new ActionContext + { + PreviousState = prevState, + Signer = agentAddr, + BlockIndex = blockIndex, + }); + + var avatarState = states.GetAvatarStateV2(avatarAddr); + if (expectedHourglass > 0) + { + Assert.Equal( + expectedHourglass, + avatarState.inventory.Items.First(x => x.item.Id == 400000).count); + } + else + { + Assert.DoesNotContain(avatarState.inventory.Items, x => x.item.Id == 400000); + } + + if (expectedApStone > 0) + { + Assert.Equal( + expectedApStone, + avatarState.inventory.Items.First(x => x.item.Id == 500000).count); + } + else + { + Assert.DoesNotContain(avatarState.inventory.Items, x => x.item.Id == 500000); + } + + if (expectedRune > 0) + { + Assert.Equal( + expectedRune * RuneHelper.StakeRune, + states.GetBalance(avatarAddr, RuneHelper.StakeRune)); + } + else + { + Assert.Equal( + 0 * RuneHelper.StakeRune, + states.GetBalance(avatarAddr, RuneHelper.StakeRune)); + } + + if (!string.IsNullOrEmpty(expectedCurrencyAddrHex)) + { + var addr = new Address(expectedCurrencyAddrHex); + var currency = Currencies.GetMinterlessCurrency(expectedCurrencyTicker); + Assert.Equal( + expectedCurrencyAmount * currency, + states.GetBalance(addr, currency)); + } + + Assert.True(states.TryGetStakeState(agentAddr, out StakeState stakeState)); + Assert.Equal(blockIndex, stakeState.ReceivedBlockIndex); + } + } +} diff --git a/.Lib9c.Tests/Action/ClaimStakeReward6Test.cs b/.Lib9c.Tests/Action/ClaimStakeReward6Test.cs new file mode 100644 index 0000000000..51d8c3c5e0 --- /dev/null +++ b/.Lib9c.Tests/Action/ClaimStakeReward6Test.cs @@ -0,0 +1,319 @@ +#nullable enable + +namespace Lib9c.Tests.Action +{ + using System.Linq; + using Lib9c.Tests.Util; + using Libplanet.Action.State; + using Libplanet.Crypto; + using Libplanet.Types.Assets; + using Nekoyume.Action; + using Nekoyume.Helper; + using Nekoyume.Model.State; + using Serilog; + using Xunit; + using Xunit.Abstractions; + + public class ClaimStakeReward6Test + { + private const string AgentAddressHex = "0x0000000001000000000100000000010000000001"; + private readonly Address _agentAddr = new Address(AgentAddressHex); + private readonly Address _avatarAddr; + private readonly IAccount _initialStatesWithAvatarStateV1; + private readonly IAccount _initialStatesWithAvatarStateV2; + private readonly Currency _ncg; + + public ClaimStakeReward6Test(ITestOutputHelper outputHelper) + { + Log.Logger = new LoggerConfiguration() + .MinimumLevel.Verbose() + .WriteTo.TestOutput(outputHelper) + .CreateLogger(); + ( + _, + _, + _avatarAddr, + _initialStatesWithAvatarStateV1, + _initialStatesWithAvatarStateV2) = InitializeUtil.InitializeStates( + agentAddr: _agentAddr); + _ncg = _initialStatesWithAvatarStateV1.GetGoldCurrency(); + } + + [Fact] + public void Serialization() + { + var action = new ClaimStakeReward6(_avatarAddr); + var deserialized = new ClaimStakeReward6(); + deserialized.LoadPlainValue(action.PlainValue); + Assert.Equal(action.AvatarAddress, deserialized.AvatarAddress); + } + + [Theory] + [InlineData( + ClaimStakeReward2.ObsoletedIndex, + 100L, + null, + ClaimStakeReward2.ObsoletedIndex + StakeState.LockupInterval, + 40, + 4, + 0, + null, + null, + 0L + )] + [InlineData( + ClaimStakeReward2.ObsoletedIndex, + 6000L, + null, + ClaimStakeReward2.ObsoletedIndex + StakeState.LockupInterval, + 4800, + 36, + 4, + null, + null, + 0L + )] + // Calculate rune start from hard fork index + [InlineData( + 0L, + 6000L, + 0L, + ClaimStakeReward2.ObsoletedIndex + StakeState.LockupInterval, + 136800, + 1026, + 3, + null, + null, + 0L + )] + // Stake reward v2 + // Stake before v2, prev. receive v1, receive v1 & v2 + [InlineData( + StakeState.StakeRewardSheetV2Index - StakeState.RewardInterval * 2, + 50L, + StakeState.StakeRewardSheetV2Index - StakeState.RewardInterval, + StakeState.StakeRewardSheetV2Index + 1, + 5, + 1, + 0, + null, + null, + 0L + )] + // Stake before v2, prev. receive v2, receive v2 + [InlineData( + StakeState.StakeRewardSheetV2Index - StakeState.RewardInterval, + 50L, + StakeState.StakeRewardSheetV2Index, + StakeState.StakeRewardSheetV2Index + StakeState.RewardInterval, + 5, + 1, + 0, + null, + null, + 0L + )] + // Stake after v2, no prev. receive, receive v2 + [InlineData( + StakeState.StakeRewardSheetV2Index, + 6000L, + null, + StakeState.StakeRewardSheetV2Index + StakeState.RewardInterval, + 1200, + 9, + 1, + null, + null, + 0L + )] + // stake after v2, prev. receive v2, receive v2 + [InlineData( + StakeState.StakeRewardSheetV2Index, + 50L, + StakeState.StakeRewardSheetV2Index + StakeState.RewardInterval, + StakeState.StakeRewardSheetV2Index + StakeState.RewardInterval * 2, + 5, + 1, + 0, + null, + null, + 0L + )] + // stake before currency as reward, non prev. + [InlineData( + StakeState.CurrencyAsRewardStartIndex - StakeState.RewardInterval * 2, + 10_000_000L, + null, + StakeState.CurrencyAsRewardStartIndex + StakeState.RewardInterval, + 3_000_000, + 37_506, + 4_998, + AgentAddressHex, + "GARAGE", + 100_000L + )] + // stake before currency as reward, prev. + [InlineData( + StakeState.CurrencyAsRewardStartIndex - StakeState.RewardInterval * 2, + 10_000_000L, + StakeState.CurrencyAsRewardStartIndex - StakeState.RewardInterval, + StakeState.CurrencyAsRewardStartIndex + StakeState.RewardInterval, + 2_000_000, + 25_004, + 3_332, + AgentAddressHex, + "GARAGE", + 100_000L + )] + // test tx(c46cf83c46bc106372015a5020d6b9f15dc733819a6dc3fde37d9b5625fc3d93) + [InlineData( + 7_009_561L, + 10_000_000L, + 7_110_390L, + 7_160_778L, + 1_000_000, + 12_502, + 1_666, + null, + null, + 0)] + // test tx(3baf5904b8499975a27d3873e58953ef8d0aa740318e99b2fe6a85428c9eb7aa) + [InlineData( + 5_350_456L, + 500_000L, + 7_576_016L, + 7_625_216L, + 100_000, + 627, + 83, + null, + null, + 0)] + public void Execute_Success( + long startedBlockIndex, + long stakeAmount, + long? previousRewardReceiveIndex, + long blockIndex, + int expectedHourglass, + int expectedApStone, + int expectedRune, + string expectedCurrencyAddrHex, + string expectedCurrencyTicker, + long expectedCurrencyAmount) + { + Execute( + _initialStatesWithAvatarStateV1, + _agentAddr, + _avatarAddr, + startedBlockIndex, + stakeAmount, + previousRewardReceiveIndex, + blockIndex, + expectedHourglass, + expectedApStone, + expectedRune, + expectedCurrencyAddrHex, + expectedCurrencyTicker, + expectedCurrencyAmount); + + Execute( + _initialStatesWithAvatarStateV2, + _agentAddr, + _avatarAddr, + startedBlockIndex, + stakeAmount, + previousRewardReceiveIndex, + blockIndex, + expectedHourglass, + expectedApStone, + expectedRune, + expectedCurrencyAddrHex, + expectedCurrencyTicker, + expectedCurrencyAmount); + } + + private void Execute( + IAccount prevState, + Address agentAddr, + Address avatarAddr, + long startedBlockIndex, + long stakeAmount, + long? previousRewardReceiveIndex, + long blockIndex, + int expectedHourglass, + int expectedApStone, + int expectedRune, + string expectedCurrencyAddrHex, + string expectedCurrencyTicker, + long expectedCurrencyAmount) + { + var context = new ActionContext(); + var stakeStateAddr = StakeState.DeriveAddress(agentAddr); + var initialStakeState = new StakeState(stakeStateAddr, startedBlockIndex); + if (!(previousRewardReceiveIndex is null)) + { + initialStakeState.Claim((long)previousRewardReceiveIndex); + } + + prevState = prevState + .SetState(stakeStateAddr, initialStakeState.Serialize()) + .MintAsset(context, stakeStateAddr, _ncg * stakeAmount); + + var action = new ClaimStakeReward6(avatarAddr); + var states = action.Execute(new ActionContext + { + PreviousState = prevState, + Signer = agentAddr, + BlockIndex = blockIndex, + }); + + var avatarState = states.GetAvatarStateV2(avatarAddr); + if (expectedHourglass > 0) + { + Assert.Equal( + expectedHourglass, + avatarState.inventory.Items.First(x => x.item.Id == 400000).count); + } + else + { + Assert.DoesNotContain(avatarState.inventory.Items, x => x.item.Id == 400000); + } + + if (expectedApStone > 0) + { + Assert.Equal( + expectedApStone, + avatarState.inventory.Items.First(x => x.item.Id == 500000).count); + } + else + { + Assert.DoesNotContain(avatarState.inventory.Items, x => x.item.Id == 500000); + } + + if (expectedRune > 0) + { + Assert.Equal( + expectedRune * RuneHelper.StakeRune, + states.GetBalance(avatarAddr, RuneHelper.StakeRune)); + } + else + { + Assert.Equal( + 0 * RuneHelper.StakeRune, + states.GetBalance(avatarAddr, RuneHelper.StakeRune)); + } + + if (!string.IsNullOrEmpty(expectedCurrencyAddrHex)) + { + var addr = new Address(expectedCurrencyAddrHex); + var currency = Currencies.GetMinterlessCurrency(expectedCurrencyTicker); + Assert.Equal( + expectedCurrencyAmount * currency, + states.GetBalance(addr, currency)); + } + + Assert.True(states.TryGetStakeState(agentAddr, out StakeState stakeState)); + Assert.Equal(blockIndex, stakeState.ReceivedBlockIndex); + } + } +} diff --git a/.Lib9c.Tests/Action/ClaimStakeReward7Test.cs b/.Lib9c.Tests/Action/ClaimStakeReward7Test.cs new file mode 100644 index 0000000000..533b1275aa --- /dev/null +++ b/.Lib9c.Tests/Action/ClaimStakeReward7Test.cs @@ -0,0 +1,295 @@ +#nullable enable + +namespace Lib9c.Tests.Action +{ + using System.Linq; + using Lib9c.Tests.Util; + using Libplanet.Action.State; + using Libplanet.Crypto; + using Libplanet.Types.Assets; + using Nekoyume.Action; + using Nekoyume.Helper; + using Nekoyume.Model.State; + using Serilog; + using Xunit; + using Xunit.Abstractions; + + public class ClaimStakeReward7Test + { + private const string AgentAddressHex = "0x0000000001000000000100000000010000000001"; + private readonly Address _agentAddr = new Address(AgentAddressHex); + private readonly Address _avatarAddr; + private readonly IAccount _initialStatesWithAvatarStateV1; + private readonly IAccount _initialStatesWithAvatarStateV2; + private readonly Currency _ncg; + + public ClaimStakeReward7Test(ITestOutputHelper outputHelper) + { + Log.Logger = new LoggerConfiguration() + .MinimumLevel.Verbose() + .WriteTo.TestOutput(outputHelper) + .CreateLogger(); + ( + _, + _, + _avatarAddr, + _initialStatesWithAvatarStateV1, + _initialStatesWithAvatarStateV2) = InitializeUtil.InitializeStates( + agentAddr: _agentAddr); + _ncg = _initialStatesWithAvatarStateV1.GetGoldCurrency(); + } + + [Fact] + public void Serialization() + { + var action = new ClaimStakeReward7(_avatarAddr); + var deserialized = new ClaimStakeReward7(); + deserialized.LoadPlainValue(action.PlainValue); + Assert.Equal(action.AvatarAddress, deserialized.AvatarAddress); + } + + [Theory] + [InlineData( + ClaimStakeReward2.ObsoletedIndex, + 100L, + null, + ClaimStakeReward2.ObsoletedIndex + StakeState.LockupInterval, + 40, + 4, + 0, + null, + null, + 0L + )] + [InlineData( + ClaimStakeReward2.ObsoletedIndex, + 6000L, + null, + ClaimStakeReward2.ObsoletedIndex + StakeState.LockupInterval, + 4800, + 36, + 4, + null, + null, + 0L + )] + // Calculate rune start from hard fork index + [InlineData( + 0L, + 6000L, + 0L, + ClaimStakeReward2.ObsoletedIndex + StakeState.LockupInterval, + 136800, + 1026, + 3, + null, + null, + 0L + )] + // Stake reward v2 + // Stake before v2, prev. receive v1, receive v1 & v2 + [InlineData( + StakeState.StakeRewardSheetV2Index - StakeState.RewardInterval * 2, + 50L, + StakeState.StakeRewardSheetV2Index - StakeState.RewardInterval, + StakeState.StakeRewardSheetV2Index + 1, + 5, + 1, + 0, + null, + null, + 0L + )] + // Stake before v2, prev. receive v2, receive v2 + [InlineData( + StakeState.StakeRewardSheetV2Index - StakeState.RewardInterval, + 50L, + StakeState.StakeRewardSheetV2Index, + StakeState.StakeRewardSheetV2Index + StakeState.RewardInterval, + 5, + 1, + 0, + null, + null, + 0L + )] + // Stake after v2, no prev. receive, receive v2 + [InlineData( + StakeState.StakeRewardSheetV2Index, + 6000L, + null, + StakeState.StakeRewardSheetV2Index + StakeState.RewardInterval, + 3000, + 17, + 1, + null, + null, + 0L + )] + // stake after v2, prev. receive v2, receive v2 + [InlineData( + StakeState.StakeRewardSheetV2Index, + 50L, + StakeState.StakeRewardSheetV2Index + StakeState.RewardInterval, + StakeState.StakeRewardSheetV2Index + StakeState.RewardInterval * 2, + 5, + 1, + 0, + null, + null, + 0L + )] + // stake before currency as reward, non prev. + [InlineData( + StakeState.CurrencyAsRewardStartIndex - StakeState.RewardInterval * 2, + 10_000_000L, + null, + StakeState.CurrencyAsRewardStartIndex + StakeState.RewardInterval, + 15_000_000, + 75_006, + 4_998, + AgentAddressHex, + "GARAGE", + 100_000L + )] + // stake before currency as reward, prev. + [InlineData( + StakeState.CurrencyAsRewardStartIndex - StakeState.RewardInterval * 2, + 10_000_000L, + StakeState.CurrencyAsRewardStartIndex - StakeState.RewardInterval, + StakeState.CurrencyAsRewardStartIndex + StakeState.RewardInterval, + 10_000_000, + 50_004, + 3_332, + AgentAddressHex, + "GARAGE", + 100_000L + )] + public void Execute_Success( + long startedBlockIndex, + long stakeAmount, + long? previousRewardReceiveIndex, + long blockIndex, + int expectedHourglass, + int expectedApStone, + int expectedRune, + string expectedCurrencyAddrHex, + string expectedCurrencyTicker, + long expectedCurrencyAmount) + { + Execute( + _initialStatesWithAvatarStateV1, + _agentAddr, + _avatarAddr, + startedBlockIndex, + stakeAmount, + previousRewardReceiveIndex, + blockIndex, + expectedHourglass, + expectedApStone, + expectedRune, + expectedCurrencyAddrHex, + expectedCurrencyTicker, + expectedCurrencyAmount); + + Execute( + _initialStatesWithAvatarStateV2, + _agentAddr, + _avatarAddr, + startedBlockIndex, + stakeAmount, + previousRewardReceiveIndex, + blockIndex, + expectedHourglass, + expectedApStone, + expectedRune, + expectedCurrencyAddrHex, + expectedCurrencyTicker, + expectedCurrencyAmount); + } + + private void Execute( + IAccount prevState, + Address agentAddr, + Address avatarAddr, + long startedBlockIndex, + long stakeAmount, + long? previousRewardReceiveIndex, + long blockIndex, + int expectedHourglass, + int expectedApStone, + int expectedRune, + string expectedCurrencyAddrHex, + string expectedCurrencyTicker, + long expectedCurrencyAmount) + { + var context = new ActionContext(); + var stakeStateAddr = StakeState.DeriveAddress(agentAddr); + var initialStakeState = new StakeState(stakeStateAddr, startedBlockIndex); + if (!(previousRewardReceiveIndex is null)) + { + initialStakeState.Claim((long)previousRewardReceiveIndex); + } + + prevState = prevState + .SetState(stakeStateAddr, initialStakeState.Serialize()) + .MintAsset(context, stakeStateAddr, _ncg * stakeAmount); + + var action = new ClaimStakeReward7(avatarAddr); + var states = action.Execute(new ActionContext + { + PreviousState = prevState, + Signer = agentAddr, + BlockIndex = blockIndex, + }); + + var avatarState = states.GetAvatarStateV2(avatarAddr); + if (expectedHourglass > 0) + { + Assert.Equal( + expectedHourglass, + avatarState.inventory.Items.First(x => x.item.Id == 400000).count); + } + else + { + Assert.DoesNotContain(avatarState.inventory.Items, x => x.item.Id == 400000); + } + + if (expectedApStone > 0) + { + Assert.Equal( + expectedApStone, + avatarState.inventory.Items.First(x => x.item.Id == 500000).count); + } + else + { + Assert.DoesNotContain(avatarState.inventory.Items, x => x.item.Id == 500000); + } + + if (expectedRune > 0) + { + Assert.Equal( + expectedRune * RuneHelper.StakeRune, + states.GetBalance(avatarAddr, RuneHelper.StakeRune)); + } + else + { + Assert.Equal( + 0 * RuneHelper.StakeRune, + states.GetBalance(avatarAddr, RuneHelper.StakeRune)); + } + + if (!string.IsNullOrEmpty(expectedCurrencyAddrHex)) + { + var addr = new Address(expectedCurrencyAddrHex); + var currency = Currencies.GetMinterlessCurrency(expectedCurrencyTicker); + Assert.Equal( + expectedCurrencyAmount * currency, + states.GetBalance(addr, currency)); + } + + Assert.True(states.TryGetStakeState(agentAddr, out StakeState stakeState)); + Assert.Equal(blockIndex, stakeState.ReceivedBlockIndex); + } + } +} diff --git a/.Lib9c.Tests/Action/ClaimStakeReward8Test.cs b/.Lib9c.Tests/Action/ClaimStakeReward8Test.cs new file mode 100644 index 0000000000..01e1d72d21 --- /dev/null +++ b/.Lib9c.Tests/Action/ClaimStakeReward8Test.cs @@ -0,0 +1,502 @@ +#nullable enable + +namespace Lib9c.Tests.Action +{ + using System.Linq; + using Lib9c.Tests.Util; + using Libplanet.Action.State; + using Libplanet.Crypto; + using Libplanet.Types.Assets; + using Nekoyume.Action; + using Nekoyume.Helper; + using Nekoyume.Model.State; + using Serilog; + using Xunit; + using Xunit.Abstractions; + + public class ClaimStakeReward8Test + { + private const string AgentAddressHex = "0x0000000001000000000100000000010000000001"; + + // VALUE: 6_692_400L + // - receive v1 reward * 1 + // - receive v2(w/o currency) reward * 4 + // - receive v2(w/ currency) reward * 14 + // - receive v3 reward * n + private const long BlockIndexForTest = + StakeState.StakeRewardSheetV3Index - + ((StakeState.StakeRewardSheetV3Index - StakeState.StakeRewardSheetV2Index) / StakeState.RewardInterval + 1) * + StakeState.RewardInterval; + + private readonly Address _agentAddr = new Address(AgentAddressHex); + private readonly Address _avatarAddr; + private readonly IAccount _initialStatesWithAvatarStateV1; + private readonly IAccount _initialStatesWithAvatarStateV2; + private readonly Currency _ncg; + + public ClaimStakeReward8Test(ITestOutputHelper outputHelper) + { + Log.Logger = new LoggerConfiguration() + .MinimumLevel.Verbose() + .WriteTo.TestOutput(outputHelper) + .CreateLogger(); + ( + _, + _, + _avatarAddr, + _initialStatesWithAvatarStateV1, + _initialStatesWithAvatarStateV2) = InitializeUtil.InitializeStates( + agentAddr: _agentAddr); + _ncg = _initialStatesWithAvatarStateV1.GetGoldCurrency(); + } + + [Fact] + public void Serialization() + { + var action = new ClaimStakeReward8(_avatarAddr); + var deserialized = new ClaimStakeReward8(); + deserialized.LoadPlainValue(action.PlainValue); + Assert.Equal(action.AvatarAddress, deserialized.AvatarAddress); + } + + [Theory] + [InlineData( + ClaimStakeReward2.ObsoletedIndex, + 100L, + null, + ClaimStakeReward2.ObsoletedIndex + StakeState.LockupInterval, + 40, + 4, + 0, + null, + null, + 0L + )] + [InlineData( + ClaimStakeReward2.ObsoletedIndex, + 6000L, + null, + ClaimStakeReward2.ObsoletedIndex + StakeState.LockupInterval, + 4800, + 36, + 4, + null, + null, + 0L + )] + // Calculate rune start from hard fork index + [InlineData( + 0L, + 6000L, + 0L, + ClaimStakeReward2.ObsoletedIndex + StakeState.LockupInterval, + 136800, + 1026, + 3, + null, + null, + 0L + )] + // Stake reward v2 + // Stake before v2, prev. receive v1, receive v1 & v2 + [InlineData( + StakeState.StakeRewardSheetV2Index - StakeState.RewardInterval * 2, + 50L, + StakeState.StakeRewardSheetV2Index - StakeState.RewardInterval, + StakeState.StakeRewardSheetV2Index + 1, + 5, + 1, + 0, + null, + null, + 0L + )] + // Stake before v2, prev. receive v2, receive v2 + [InlineData( + StakeState.StakeRewardSheetV2Index - StakeState.RewardInterval, + 50L, + StakeState.StakeRewardSheetV2Index, + StakeState.StakeRewardSheetV2Index + StakeState.RewardInterval, + 5, + 1, + 0, + null, + null, + 0L + )] + // Stake after v2, no prev. receive, receive v2 + [InlineData( + StakeState.StakeRewardSheetV2Index, + 6000L, + null, + StakeState.StakeRewardSheetV2Index + StakeState.RewardInterval, + 3000, + 17, + 1, + null, + null, + 0L + )] + // stake after v2, prev. receive v2, receive v2 + [InlineData( + StakeState.StakeRewardSheetV2Index, + 50L, + StakeState.StakeRewardSheetV2Index + StakeState.RewardInterval, + StakeState.StakeRewardSheetV2Index + StakeState.RewardInterval * 2, + 5, + 1, + 0, + null, + null, + 0L + )] + // stake before currency as reward, non prev. + // receive v2(w/o currency) * 2, receive v2(w/ currency). check GARAGE. + [InlineData( + StakeState.CurrencyAsRewardStartIndex - StakeState.RewardInterval * 2, + 10_000_000L, + null, + StakeState.CurrencyAsRewardStartIndex + StakeState.RewardInterval, + 15_000_000, + 75_006, + 4_998, + AgentAddressHex, + "GARAGE", + 100_000L + )] + // stake before currency as reward, prev. + // receive v2(w/o currency), receive v2(w/ currency). check GARAGE. + [InlineData( + StakeState.CurrencyAsRewardStartIndex - StakeState.RewardInterval * 2, + 10_000_000L, + StakeState.CurrencyAsRewardStartIndex - StakeState.RewardInterval, + StakeState.CurrencyAsRewardStartIndex + StakeState.RewardInterval, + 10_000_000, + 50_004, + 3_332, + AgentAddressHex, + "GARAGE", + 100_000L + )] + // stake before v3(crystal), non prev. receive v2. check CRYSTAL. + [InlineData( + StakeState.StakeRewardSheetV3Index - 1, + 500L, + null, + StakeState.StakeRewardSheetV3Index - 1 + StakeState.RewardInterval, + 125, + 2, + 0, + AgentAddressHex, + "CRYSTAL", + 0L + )] + // stake after v3(crystal), non prev. receive v3. check CRYSTAL. + [InlineData( + StakeState.StakeRewardSheetV3Index, + 500L, + null, + StakeState.StakeRewardSheetV3Index + StakeState.RewardInterval, + 125, + 2, + 0, + AgentAddressHex, + "CRYSTAL", + 5_000L + )] + // stake before v3(crystal), non prev. receive v2 * 2, receive v3. check CRYSTAL. + [InlineData( + StakeState.StakeRewardSheetV3Index - StakeState.RewardInterval * 2, + 10_000_000L, + null, + StakeState.StakeRewardSheetV3Index + StakeState.RewardInterval, + 35_000_000, + 175_006, + 11_665, + AgentAddressHex, + "CRYSTAL", + 1_000_000_000L + )] + // stake before v3(crystal), prev. receive v2, receive v3. check CRYSTAL. + [InlineData( + StakeState.StakeRewardSheetV3Index - StakeState.RewardInterval * 2, + 10_000_000L, + StakeState.StakeRewardSheetV3Index - StakeState.RewardInterval, + StakeState.StakeRewardSheetV3Index + StakeState.RewardInterval, + 30_000_000, + 150_004, + 9_999, + AgentAddressHex, + "CRYSTAL", + 1_000_000_000L + )] + // stake after v3(crystal), non prev. receive v2 * 2, receive v3. check CRYSTAL. + [InlineData( + StakeState.StakeRewardSheetV3Index, + 10_000_000L, + null, + StakeState.StakeRewardSheetV3Index + StakeState.RewardInterval * 3, + 75_000_000, + 375_006, + 24_999, + AgentAddressHex, + "CRYSTAL", + 3_000_000_000L + )] + // stake after v3(crystal), prev. receive v2, receive v3. check CRYSTAL. + [InlineData( + StakeState.StakeRewardSheetV3Index, + 10_000_000L, + StakeState.StakeRewardSheetV3Index + StakeState.RewardInterval, + StakeState.StakeRewardSheetV3Index + StakeState.RewardInterval * 3, + 50_000_000, + 250_004, + 16_666, + AgentAddressHex, + "CRYSTAL", + 2_000_000_000L + )] + // stake before v2(w/o currency), non prev. + // receive v1. + [InlineData( + BlockIndexForTest, + 500L, + null, + BlockIndexForTest + StakeState.RewardInterval, + 62, + 2, + 0, + null, + null, + 0L + )] + // stake before v2(w/o currency), non prev. + // receive v1, do not receive v2(w/o currency). + [InlineData( + BlockIndexForTest, + 500L, + null, + StakeState.StakeRewardSheetV2Index + StakeState.RewardInterval - 1, + 62, + 2, + 0, + null, + null, + 0L + )] + // stake before v2(w/o currency), non prev. + // receive v1, receive v2(w/o currency). + [InlineData( + BlockIndexForTest, + 500L, + null, + StakeState.StakeRewardSheetV2Index + StakeState.RewardInterval * 2 - 1, + 187, + 4, + 0, + null, + null, + 0L + )] + // stake before v2(w/o currency), non prev. + // receive v1, receive v2(w/o currency) * 3, do not receive v2(w/ currency). + [InlineData( + BlockIndexForTest, + 500L, + null, + StakeState.CurrencyAsRewardStartIndex + StakeState.RewardInterval - 1, + 562, + 10, + 0, + null, + null, + 0L + )] + // stake before v2(w/o currency), non prev. + // receive v1, receive v2(w/o currency) * 3, receive v2(w/ currency). + // check GARAGE is 0 when stake 500. + [InlineData( + BlockIndexForTest, + 500L, + null, + StakeState.CurrencyAsRewardStartIndex + StakeState.RewardInterval * 2 - 1, + 687, + 12, + 0, + AgentAddressHex, + "GARAGE", + 0L + )] + // stake before v2(w/o currency), non prev. + // receive v1, receive v2(w/o currency) * 3, receive v2(w/ currency). + // check GARAGE is 100,000 when stake 10,000,000. + [InlineData( + BlockIndexForTest, + 10_000_000L, + null, + StakeState.CurrencyAsRewardStartIndex + StakeState.RewardInterval * 2 - 1, + 27_000_000, + 137_512, + 9_996, + AgentAddressHex, + "GARAGE", + 100_000L + )] + // stake before v2(w/o currency), non prev. + // receive v1, receive v2(w/o currency) * 3, receive v2(w/ currency) * ???, no receive v3. + // check CRYSTAL is 0. + [InlineData( + BlockIndexForTest, + 500L, + null, + StakeState.StakeRewardSheetV3Index + StakeState.RewardInterval - 1, + 2_312, + 38, + 0, + AgentAddressHex, + "CRYSTAL", + 0L + )] + // stake before v2(w/o currency), non prev. + // receive v1, receive v2(w/o currency) * 3, receive v2(w/ currency) * ???, receive v3. + // check CRYSTAL is ???. + [InlineData( + BlockIndexForTest, + 500L, + null, + StakeState.StakeRewardSheetV3Index + StakeState.RewardInterval * 2 - 1, + 2_437, + 40, + 0, + AgentAddressHex, + "CRYSTAL", + 5_000L + )] + public void Execute_Success( + long startedBlockIndex, + long stakeAmount, + long? previousRewardReceiveIndex, + long blockIndex, + int expectedHourglass, + int expectedApStone, + int expectedRune, + string expectedCurrencyAddrHex, + string expectedCurrencyTicker, + long expectedCurrencyAmount) + { + Execute( + _initialStatesWithAvatarStateV1, + _agentAddr, + _avatarAddr, + startedBlockIndex, + stakeAmount, + previousRewardReceiveIndex, + blockIndex, + expectedHourglass, + expectedApStone, + expectedRune, + expectedCurrencyAddrHex, + expectedCurrencyTicker, + expectedCurrencyAmount); + + Execute( + _initialStatesWithAvatarStateV2, + _agentAddr, + _avatarAddr, + startedBlockIndex, + stakeAmount, + previousRewardReceiveIndex, + blockIndex, + expectedHourglass, + expectedApStone, + expectedRune, + expectedCurrencyAddrHex, + expectedCurrencyTicker, + expectedCurrencyAmount); + } + + private void Execute( + IAccount prevState, + Address agentAddr, + Address avatarAddr, + long startedBlockIndex, + long stakeAmount, + long? previousRewardReceiveIndex, + long blockIndex, + int expectedHourglass, + int expectedApStone, + int expectedRune, + string expectedCurrencyAddrHex, + string expectedCurrencyTicker, + long expectedCurrencyAmount) + { + var context = new ActionContext(); + var stakeStateAddr = StakeState.DeriveAddress(agentAddr); + var initialStakeState = new StakeState(stakeStateAddr, startedBlockIndex); + if (!(previousRewardReceiveIndex is null)) + { + initialStakeState.Claim((long)previousRewardReceiveIndex); + } + + prevState = prevState + .SetState(stakeStateAddr, initialStakeState.Serialize()) + .MintAsset(context, stakeStateAddr, _ncg * stakeAmount); + + var action = new ClaimStakeReward8(avatarAddr); + var states = action.Execute(new ActionContext + { + PreviousState = prevState, + Signer = agentAddr, + BlockIndex = blockIndex, + }); + + var avatarState = states.GetAvatarStateV2(avatarAddr); + if (expectedHourglass > 0) + { + Assert.Equal( + expectedHourglass, + avatarState.inventory.Items.First(x => x.item.Id == 400000).count); + } + else + { + Assert.DoesNotContain(avatarState.inventory.Items, x => x.item.Id == 400000); + } + + if (expectedApStone > 0) + { + Assert.Equal( + expectedApStone, + avatarState.inventory.Items.First(x => x.item.Id == 500000).count); + } + else + { + Assert.DoesNotContain(avatarState.inventory.Items, x => x.item.Id == 500000); + } + + if (expectedRune > 0) + { + Assert.Equal( + expectedRune * RuneHelper.StakeRune, + states.GetBalance(avatarAddr, RuneHelper.StakeRune)); + } + else + { + Assert.Equal( + 0 * RuneHelper.StakeRune, + states.GetBalance(avatarAddr, RuneHelper.StakeRune)); + } + + if (!string.IsNullOrEmpty(expectedCurrencyAddrHex)) + { + var addr = new Address(expectedCurrencyAddrHex); + var currency = Currencies.GetMinterlessCurrency(expectedCurrencyTicker); + Assert.Equal( + expectedCurrencyAmount * currency, + states.GetBalance(addr, currency)); + } + + Assert.True(states.TryGetStakeState(agentAddr, out StakeState stakeState)); + Assert.Equal(blockIndex, stakeState.ReceivedBlockIndex); + } + } +} diff --git a/.Lib9c.Tests/Action/Factory/ClaimStakeRewardFactoryTest.cs b/.Lib9c.Tests/Action/Factory/ClaimStakeRewardFactoryTest.cs new file mode 100644 index 0000000000..6b23856820 --- /dev/null +++ b/.Lib9c.Tests/Action/Factory/ClaimStakeRewardFactoryTest.cs @@ -0,0 +1,80 @@ +#nullable enable + +namespace Lib9c.Tests.Action.Factory +{ + using System; + using System.Collections.Generic; + using System.Linq; + using System.Reflection; + using Bencodex.Types; + using Lib9c.Abstractions; + using Libplanet.Action; + using Libplanet.Crypto; + using Nekoyume.Action; + using Nekoyume.Action.Factory; + using Xunit; + + public class ClaimStakeRewardFactoryTest + { + public static IEnumerable GetAllClaimStakeRewardV1() + { + var arr = Assembly.GetAssembly(typeof(ClaimRaidReward))?.GetTypes() + .Where(type => + type.IsClass && + typeof(IClaimStakeRewardV1).IsAssignableFrom(type)) + .Select(type => + type.GetCustomAttribute()?.TypeIdentifier) + .OfType() + .ToArray() ?? Array.Empty(); + + foreach (var value in arr) + { + var str = (string)(Text)value; + var verStr = str.Replace("claim_stake_reward", string.Empty); + var ver = string.IsNullOrEmpty(verStr) + ? 1 + : int.Parse(verStr); + yield return new object[] { str, ver }; + } + } + + [Theory] + [InlineData(0L, typeof(ClaimStakeReward2))] + [InlineData(ClaimStakeReward2.ObsoletedIndex - 1, typeof(ClaimStakeReward2))] + [InlineData(ClaimStakeReward2.ObsoletedIndex, typeof(ClaimStakeReward2))] + [InlineData(ClaimStakeReward2.ObsoletedIndex + 1, typeof(ClaimStakeReward3))] + [InlineData(ClaimStakeReward3.ObsoleteBlockIndex, typeof(ClaimStakeReward3))] + [InlineData(ClaimStakeReward3.ObsoleteBlockIndex + 1, typeof(ClaimStakeReward4))] + [InlineData(ClaimStakeReward4.ObsoleteBlockIndex, typeof(ClaimStakeReward4))] + [InlineData(ClaimStakeReward4.ObsoleteBlockIndex + 1, typeof(ClaimStakeReward5))] + [InlineData(ClaimStakeReward5.ObsoleteBlockIndex, typeof(ClaimStakeReward5))] + [InlineData(ClaimStakeReward5.ObsoleteBlockIndex + 1, typeof(ClaimStakeReward6))] + [InlineData(ClaimStakeReward6.ObsoleteBlockIndex, typeof(ClaimStakeReward6))] + [InlineData(ClaimStakeReward6.ObsoleteBlockIndex + 1, typeof(ClaimStakeReward7))] + [InlineData(ClaimStakeReward7.ObsoleteBlockIndex, typeof(ClaimStakeReward7))] + [InlineData(ClaimStakeReward7.ObsoleteBlockIndex + 1, typeof(ClaimStakeReward8))] + [InlineData(ClaimStakeReward8.ObsoleteBlockIndex, typeof(ClaimStakeReward8))] + [InlineData(ClaimStakeReward8.ObsoleteBlockIndex + 1, typeof(ClaimStakeReward))] + [InlineData(long.MaxValue, typeof(ClaimStakeReward))] + public void Create_ByBlockIndex_Success( + long blockIndex, + Type type) + { + var addr = new PrivateKey().Address; + var action = ClaimStakeRewardFactory.CreateByBlockIndex(blockIndex, addr); + Assert.Equal(type, action.GetType()); + } + + [Theory] + [MemberData(nameof(GetAllClaimStakeRewardV1))] + public void Create_ByVersion_Success(string expectActionType, int version) + { + var addr = new PrivateKey().Address; + var action = ClaimStakeRewardFactory.CreateByVersion(version, addr); + var actualActionType = action + .GetType() + .GetCustomAttribute()?.TypeIdentifier; + Assert.Equal(new Text(expectActionType), actualActionType); + } + } +} diff --git a/.Lib9c.Tests/Action/Scenario/StakeAndClaimStakeReward2ScenarioTest.cs b/.Lib9c.Tests/Action/Scenario/StakeAndClaimStakeReward2ScenarioTest.cs new file mode 100644 index 0000000000..74afc3852c --- /dev/null +++ b/.Lib9c.Tests/Action/Scenario/StakeAndClaimStakeReward2ScenarioTest.cs @@ -0,0 +1,931 @@ +namespace Lib9c.Tests.Action.Scenario +{ + using System.Collections.Generic; + using System.Linq; + using Libplanet.Action; + using Libplanet.Action.State; + using Libplanet.Crypto; + using Libplanet.Types.Assets; + using Nekoyume; + using Nekoyume.Action; + using Nekoyume.Model.State; + using Nekoyume.TableData; + using Serilog; + using Xunit; + using Xunit.Abstractions; + using static Lib9c.SerializeKeys; + + public class StakeAndClaimStakeReward2ScenarioTest + { + private readonly IAccount _initialState; + private readonly Currency _currency; + private readonly GoldCurrencyState _goldCurrencyState; + private readonly TableSheets _tableSheets; + private readonly Address _signerAddress; + private readonly Address _avatarAddress; + + public StakeAndClaimStakeReward2ScenarioTest(ITestOutputHelper outputHelper) + { + Log.Logger = new LoggerConfiguration() + .MinimumLevel.Verbose() + .WriteTo.TestOutput(outputHelper) + .CreateLogger(); + + _initialState = new Account(MockState.Empty); + + var sheets = TableSheetsImporter.ImportSheets(); + var sheet = @"level,required_gold,item_id,rate +1,50,400000,10 +1,50,500000,800 +2,500,400000,8 +2,500,500000,800 +3,5000,400000,5 +3,5000,500000,800 +4,50000,400000,5 +4,50000,500000,800 +5,500000,400000,5 +5,500000,500000,800".Serialize(); + foreach (var (key, value) in sheets) + { + _initialState = _initialState + .SetState(Addresses.TableSheet.Derive(key), key == nameof(StakeRegularRewardSheet) ? sheet : value.Serialize()); + } + + _tableSheets = new TableSheets(sheets); + +#pragma warning disable CS0618 + // Use of obsolete method Currency.Legacy(): https://github.com/planetarium/lib9c/discussions/1319 + _currency = Currency.Legacy("NCG", 2, null); +#pragma warning restore CS0618 + _goldCurrencyState = new GoldCurrencyState(_currency); + + _signerAddress = new PrivateKey().Address; + var stakeStateAddress = StakeState.DeriveAddress(_signerAddress); + var agentState = new AgentState(_signerAddress); + _avatarAddress = new PrivateKey().Address; + var rankingMapAddress = _avatarAddress.Derive("ranking_map"); + agentState.avatarAddresses.Add(0, _avatarAddress); + var avatarState = new AvatarState( + _avatarAddress, + _signerAddress, + 0, + _tableSheets.GetAvatarSheets(), + new GameConfigState(sheets[nameof(GameConfigSheet)]), + rankingMapAddress + ) + { + level = 100, + }; + _initialState = _initialState + .SetState(_signerAddress, agentState.Serialize()) + .SetState(_avatarAddress, avatarState.SerializeV2()) + .SetState( + _avatarAddress.Derive(LegacyInventoryKey), + avatarState.inventory.Serialize()) + .SetState( + _avatarAddress.Derive(LegacyWorldInformationKey), + avatarState.worldInformation.Serialize()) + .SetState( + _avatarAddress.Derive(LegacyQuestListKey), + avatarState.questList.Serialize()) + .SetState(GoldCurrencyState.Address, _goldCurrencyState.Serialize()); + } + + public static IEnumerable StakeAndClaimStakeRewardTestCases() + { + // 일반적인 보상수령 확인 + // 1단계 수준(50~499NCG)의 deposit save 완료 + // → 최초 보상 시점 (50,400블록 이후) 도달 + // → 지정된 캐릭터 앞으로 이하 보상의 수령이 가능해야 한다. + // (보상내용: 1 hourglass / 10 NCG, 1 ap portion / 800 NCG 소수점 버림, 기존 deposit 유지 확인) + yield return new object[] + { + 50, + new[] + { + (400000, 5), + (500000, 1), + }, + 50400, + }; + yield return new object[] + { + 499, + new[] + { + (400000, 49), + (500000, 1), + }, + 50400, + }; + + // 2단계 수준(500~4,999NCG)의 deposit save 완료 + // → 최초 보상 시점 (50,400블록 이후) 도달 + // → 지정된 캐릭터 앞으로 이하 보상의 수령이 가능해야 한다. + // (보상내용: 1 hourglass / 8 NCG, 1 ap portion / 800 NCG 소수점 버림, 기존 deposit 유지 확인) + yield return new object[] + { + 500, + new[] + { + (400000, 62), + (500000, 2), + }, + 50400, + }; + yield return new object[] + { + 799, + new[] + { + (400000, 99), + (500000, 2), + }, + 50400, + }; + yield return new object[] + { + 4999, + new[] + { + (400000, 624), + (500000, 8), + }, + 50400, + }; + + // 3단계 수준(5,000~49,999NCG)의 deposit save 완료 + // → 최초 보상 시점 (50,400블록 이후) 도달 + // → 지정된 캐릭터 앞으로 이하 보상의 수령이 가능해야 한다. + // (보상내용: 1 hourglass / 5 NCG, 1 ap portion / 800 NCG 소수점 버림, 기존 deposit 유지 확인) + yield return new object[] + { + 5000, + new[] + { + (400000, 1000), + (500000, 8), + }, + 50400, + }; + yield return new object[] + { + 49999, + new[] + { + (400000, 9999), + (500000, 64), + }, + 50400, + }; + + // 4단계 수준(50,000~499,999NCG)의 deposit save 완료 + // → 최초 보상 시점 (50,400블록 이후) 도달 + // → 지정된 캐릭터 앞으로 이하 보상의 수령이 가능해야 한다. + // (보상내용: 1 hourglass / 5 NCG, 1 ap portion / 800 NCG 소수점 버림, 기존 deposit 유지 확인) + yield return new object[] + { + 50000, + new[] + { + (400000, 10000), + (500000, 64), + }, + 50400, + }; + yield return new object[] + { + 499999, + new[] + { + (400000, 99999), + (500000, 626), + }, + 50400, + }; + + // 5단계 수준(500,000~100,000,000NCG)의 deposit save 완료 + // → 최초 보상 시점 (50,400블록 이후) 도달 + // → 지정된 캐릭터 앞으로 이하 보상의 수령이 가능해야 한다. + // (보상내용: 1 hourglass / 5 NCG, 1 ap portion / 800 NCG 소수점 버림, 기존 deposit 유지 확인) + yield return new object[] + { + 500000, + new[] + { + (400000, 100000), + (500000, 627), + }, + 50400, + }; + yield return new object[] + { + 99999999, + new[] + { + (400000, 19999999), + (500000, 125001), + }, + 50400, + }; + + // 지연된 보상수령 확인 + // 1단계 수준(50~499NCG)의 deposit save 완료 + // → 최초 보상 시점 (50,400블록 이후) 도달 + // → 보상을 수령하지 않음 + // → 2번째 보상 시점 (100,800블록 이후) 도달 + // → 이하 보상의 수령이 가능해야 한다. + // (보상내용: 2 hourglass / 50 NCG, 소수점 버림, 기존 deposit 유지 확인) + yield return new object[] + { + 50, + new[] + { + (400000, 45), + (500000, 9), + }, + 500800, + }; + yield return new object[] + { + 499, + new[] + { + (400000, 441), + (500000, 9), + }, + 500800, + }; + + // 2단계 수준(500~4,999NCG)의 deposit save 완료 + // → 최초 보상 시점 (50,400블록 이후) 도달 + // → 보상을 수령하지 않음 + // → 2번째 보상 시점 (100,800블록 이후) 도달 + // → 이하 보상의 수령이 가능해야 한다. + // (보상내용: 2 hourglass / 8 NCG, 2 ap portion / 800 NCG 소수점 버림, 기존 deposit 유지 확인) + yield return new object[] + { + 500, + new[] + { + (400000, 558), + (500000, 18), + }, + 500800, + }; + yield return new object[] + { + 799, + new[] + { + (400000, 891), + (500000, 18), + }, + 500800, + }; + yield return new object[] + { + 4999, + new[] + { + (400000, 5616), + (500000, 72), + }, + 500800, + }; + + // 3단계 수준(5,000~49,999NCG)의 deposit save 완료 + // → 최초 보상 시점 (50,400블록 이후) 도달 + // → 보상을 수령하지 않음 + // → 2번째 보상 시점 (100,800블록 이후) 도달 + // → 이하 보상의 수령이 가능해야 한다. + // (보상내용: 2 hourglass / 5 NCG, 2 ap portion / 180 NCG 소수점 버림, 기존 deposit 유지 확인) + yield return new object[] + { + 5000, + new[] + { + (400000, 9000), + (500000, 72), + }, + 500800, + }; + yield return new object[] + { + 49999, + new[] + { + (400000, 89991), + (500000, 576), + }, + 500800, + }; + + // 4단계 수준(50,000~499,999NCG)의 deposit save 완료 + // → 최초 보상 시점 (50,400블록 이후) 도달 + // → 보상을 수령하지 않음 + // → 2번째 보상 시점 (100,800블록 이후) 도달 + // → 이하 보상의 수령이 가능해야 한다. + // (보상내용: 2 hourglass / 5 NCG, 2 ap portion / 180 NCG 소수점 버림, 기존 deposit 유지 확인) + yield return new object[] + { + 50000, + new[] + { + (400000, 90000), + (500000, 576), + }, + 500800, + }; + yield return new object[] + { + 499999, + new[] + { + (400000, 899991), + (500000, 5634), + }, + 500800, + }; + + // 5단계 수준(500,000~4,999,999NCG)의 deposit save 완료 + // → 최초 보상 시점 (50,400블록 이후) 도달 + // → 지정된 캐릭터 앞으로 이하 보상의 수령이 가능해야 한다. + // (보상내용: 1 hourglass / 5 NCG, 1 ap portion / 160 NCG 소수점 버림, 기존 deposit 유지 확인) + // 5단계 수준(500,000~500,000,000NCG)의 deposit save 완료 + // → 최초 보상 시점 (50,400블록 이후) 도달 + // → 보상을 수령하지 않음 + // → 2번째 보상 시점 (100,800블록 이후) 도달 + // → 이하 보상의 수령이 가능해야 한다. + // (보상내용: 2 hourglass / 5 NCG, 2 ap portion / 160 NCG 소수점 버림, 기존 deposit 유지 확인) + yield return new object[] + { + 500000, + new[] + { + (400000, 900000), + (500000, 5643), + }, + 500800, + }; + yield return new object[] + { + 4999999, + new[] + { + (400000, 8999991), + (500000, 56259), + }, + 500800, + }; + } + + public static IEnumerable StakeLessAfterLockupTestcases() + { + (long ClaimBlockIndex, (int ItemId, int Amount)[])[] BuildEvents( + int hourglassRate, + int apPotionRate, + int apPotionBonus, + long stakeAmount) + { + const int hourglassItemId = 400000, apPotionItemId = 500000; + return new[] + { + (StakeState.RewardInterval, new[] + { + (hourglassItemId, (int)(stakeAmount / hourglassRate)), + (apPotionItemId, (int)(stakeAmount / apPotionRate + apPotionBonus)), + }), + (StakeState.RewardInterval * 2, new[] + { + (hourglassItemId, (int)(stakeAmount / hourglassRate)), + (apPotionItemId, (int)(stakeAmount / apPotionRate + apPotionBonus)), + }), + (StakeState.RewardInterval * 3, new[] + { + (hourglassItemId, (int)(stakeAmount / hourglassRate)), + (apPotionItemId, (int)(stakeAmount / apPotionRate + apPotionBonus)), + }), + (StakeState.RewardInterval * 4, new[] + { + (hourglassItemId, (int)(stakeAmount / hourglassRate)), + (apPotionItemId, (int)(stakeAmount / apPotionRate + apPotionBonus)), + }), + }; + } + + // 1단계 수준(50~499NCG)의 deposit save 완료 + // → 1~3회까지 모든 보상을 수령함 + // → 201,600 블록 도달 이후 + // → 지정된 캐릭터 앞으로 이하 보상의 수령이 가능해야 한다. + // (보상내용: 1 hourglass / 50 NCG, 1 ap portion / 800 NCG 소수점 버림, 기존 deposit 유지 확인) + // → 기존 deposit보다 낮은 금액으로 edit save 한다. + // → 보상 타이머 리셋 확인 + // → (기존 deposit - 현재 deposit 만큼의 ncg 인출 상태 확인) + // → 현재 스테이킹된 NCG를 인출할 수 없다 (스테이킹 추가는 가능) + // → (현재 deposit 묶인 상태 확인) + yield return new object[] + { + 51, + 51, + 50, + BuildEvents(10, 800, 1, 50), + }; + yield return new object[] + { + 499, + 499, + 50, + BuildEvents(10, 800, 1, 499), + }; + + // 현재의 스테이킹된 NCG의 전액 인출을 시도한다(deposit NCG 인출 상태 확인) + // → 스테이킹 완전 소멸 확인 + yield return new object[] + { + 50, + 50, + 0, + BuildEvents(10, 800, 1, 50), + }; + yield return new object[] + { + 499, + 499, + 0, + BuildEvents(10, 800, 1, 499), + }; + + // 2단계 수준(500~4,999NCG)의 deposit save 완료 + // → 1~3회까지 모든 보상을 수령함 + // → 201,600 블록 도달 이후 + // → 지정된 캐릭터 앞으로 이하 보상의 수령이 가능해야 한다. + // (보상내용: 1 hourglass / 8 NCG, 1 ap portion / 800 NCG 소수점 버림, 기존 deposit 유지 확인) + // → 기존 deposit보다 낮은 금액으로 edit save 한다. + // → 보상 타이머 리셋 확인 + // → (기존 deposit - 현재 deposit 만큼의 ncg 인출 상태 확인) + // → 현재 스테이킹된 NCG를 인출할 수 없다 (스테이킹 추가는 가능) + // → (현재 deposit 묶인 상태 확인) + yield return new object[] + { + 500, + 500, + 499, + BuildEvents(8, 800, 2, 500), + }; + yield return new object[] + { + 4999, + 4999, + 500, + BuildEvents(8, 800, 2, 4999), + }; + + // 현재의 스테이킹된 NCG의 전액 인출을 시도한다(deposit NCG 인출 상태 확인) + // → 스테이킹 완전 소멸 확인 + yield return new object[] + { + 500, + 500, + 0, + BuildEvents(8, 800, 2, 500), + }; + yield return new object[] + { + 4999, + 4999, + 0, + BuildEvents(8, 800, 2, 4999), + }; + + // 3단계 수준(5,000~49,999NCG)의 deposit save 완료 + // → 1~3회까지 모든 보상을 수령함 + // → 201,600 블록 도달 이후 + // → 지정된 캐릭터 앞으로 이하 보상의 수령이 가능해야 한다. + // (보상내용: 1 hourglass / 5 NCG, 1 ap portion / 800 NCG 소수점 버림, 기존 deposit 유지 확인) + // → 기존 deposit보다 낮은 금액으로 edit save 한다. + // → 보상 타이머 리셋 확인 + // → (기존 deposit - 현재 deposit 만큼의 ncg 인출 상태 확인) + // → 현재 스테이킹된 NCG를 인출할 수 없다 (스테이킹 추가는 가능) + // → (현재 deposit 묶인 상태 확인) + yield return new object[] + { + 5000, + 5000, + 4999, + BuildEvents(5, 800, 2, 5000), + }; + yield return new object[] + { + 49999, + 49999, + 5000, + BuildEvents(5, 800, 2, 49999), + }; + + // 현재의 스테이킹된 NCG의 전액 인출을 시도한다(deposit NCG 인출 상태 확인) + // → 스테이킹 완전 소멸 확인 + yield return new object[] + { + 5000, + 5000, + 0, + BuildEvents(5, 800, 2, 5000), + }; + yield return new object[] + { + 49999, + 49999, + 0, + BuildEvents(5, 800, 2, 49999), + }; + + // 4단계 수준(50,000~499,999NCG)의 deposit save 완료 + // → 1~3회까지 모든 보상을 수령함 + // → 201,600 블록 도달 이후 + // → 지정된 캐릭터 앞으로 이하 보상의 수령이 가능해야 한다. + // (보상내용: 1 hourglass / 5 NCG, 1 ap portion / 800 NCG 소수점 버림, 기존 deposit 유지 확인) + // → 기존 deposit보다 낮은 금액으로 edit save 한다. + // → 보상 타이머 리셋 확인 + // → (기존 deposit - 현재 deposit 만큼의 ncg 인출 상태 확인) + // → 현재 스테이킹된 NCG를 인출할 수 없다 (스테이킹 추가는 가능) + // → (현재 deposit 묶인 상태 확인) + yield return new object[] + { + 50000, + 50000, + 49999, + BuildEvents(5, 800, 2, 50000), + }; + yield return new object[] + { + 499999, + 499999, + 50000, + BuildEvents(5, 800, 2, 499999), + }; + + // 현재의 스테이킹된 NCG의 전액 인출을 시도한다(deposit NCG 인출 상태 확인) + // → 스테이킹 완전 소멸 확인 + yield return new object[] + { + 50000, + 50000, + 0, + BuildEvents(5, 800, 2, 50000), + }; + yield return new object[] + { + 499999, + 499999, + 0, + BuildEvents(5, 800, 2, 499999), + }; + + // 5단계 수준(500,000~100,000,000NCG)의 deposit save 완료 + // → 1~3회까지 모든 보상을 수령함 + // → 201,600 블록 도달 이후 + // → 지정된 캐릭터 앞으로 이하 보상의 수령이 가능해야 한다. + // (보상내용: 1 hourglass / 5 NCG, 1 ap portion / 800 NCG 소수점 버림, 기존 deposit 유지 확인) + // → 기존 deposit보다 낮은 금액으로 edit save 한다. + // → 보상 타이머 리셋 확인 + // → (기존 deposit - 현재 deposit 만큼의 ncg 인출 상태 확인) + // → 현재 스테이킹된 NCG를 인출할 수 없다 (스테이킹 추가는 가능) + // → (현재 deposit 묶인 상태 확인) + yield return new object[] + { + 500000, + 500000, + 499999, + BuildEvents(5, 800, 2, 500000), + }; + yield return new object[] + { + 500000000, + 500000000, + 500000, + BuildEvents(5, 800, 2, 500000000), + }; + + // 현재의 스테이킹된 NCG의 전액 인출을 시도한다(deposit NCG 인출 상태 확인) + // → 스테이킹 완전 소멸 확인 + yield return new object[] + { + 500000, + 500000, + 0, + BuildEvents(5, 800, 2, 500000), + }; + yield return new object[] + { + 500000000, + 500000000, + 0, + BuildEvents(5, 800, 2, 500000000), + }; + } + + [Theory] + [MemberData(nameof(StakeAndClaimStakeRewardTestCases))] + public void StakeAndClaimStakeReward(long stakeAmount, (int ItemId, int Amount)[] expectedItems, long receiveBlockIndex) + { + var context = new ActionContext(); + var states = _initialState.MintAsset(context, _signerAddress, _currency * stakeAmount); + + IAction action = new Stake2(stakeAmount); + states = action.Execute(new ActionContext + { + PreviousState = states, + Signer = _signerAddress, + BlockIndex = 0, + }); + + Assert.True(states.TryGetStakeState(_signerAddress, out StakeState stakeState)); + Assert.NotNull(stakeState); + + action = new ClaimStakeReward2(_avatarAddress); + states = action.Execute(new ActionContext + { + PreviousState = states, + Signer = _signerAddress, + BlockIndex = receiveBlockIndex, + }); + + // 지정된 캐릭터 앞으로 이하 보상의 수령이 가능해야 한다. + var avatarState = states.GetAvatarStateV2(_avatarAddress); + foreach ((int itemId, int amount) in expectedItems) + { + Assert.True(avatarState.inventory.HasItem(itemId, amount)); + } + + // 기존 deposit 유지 확인 + Assert.Equal( + _currency * stakeAmount, + states.GetBalance(stakeState.address, _currency)); + } + + [Theory] + [InlineData(500, 50, 499, StakeState.LockupInterval - 1)] + [InlineData(500, 499, 500, StakeState.LockupInterval - 1)] + [InlineData(5000, 500, 4999, StakeState.LockupInterval - 1)] + [InlineData(5000, 4999, 5000, StakeState.LockupInterval - 1)] + [InlineData(50000, 5000, 49999, StakeState.LockupInterval - 1)] + [InlineData(50000, 49999, 50000, StakeState.LockupInterval - 1)] + [InlineData(500000, 50000, 499999, StakeState.LockupInterval - 1)] + [InlineData(500000, 499999, 500000, StakeState.LockupInterval - 1)] + [InlineData(500000000, 500000, 500000000, StakeState.LockupInterval - 1)] + public void StakeAndStakeMore(long initialBalance, long stakeAmount, long newStakeAmount, long newStakeBlockIndex) + { + // Validate testcases + Assert.True(newStakeBlockIndex < StakeState.LockupInterval); + Assert.True(stakeAmount < newStakeAmount); + + var context = new ActionContext(); + var states = _initialState.MintAsset(context, _signerAddress, _currency * initialBalance); + + IAction action = new Stake2(stakeAmount); + states = action.Execute(new ActionContext + { + PreviousState = states, + Signer = _signerAddress, + BlockIndex = 0, + }); + + Assert.True(states.TryGetStakeState(_signerAddress, out StakeState stakeState)); + Assert.NotNull(stakeState); + Assert.Equal( + _currency * (initialBalance - stakeAmount), + states.GetBalance(_signerAddress, _currency)); + Assert.Equal( + _currency * stakeAmount, + states.GetBalance(stakeState.address, _currency)); + + action = new ClaimStakeReward2(_avatarAddress); + states = action.Execute(new ActionContext + { + PreviousState = states, + Signer = _signerAddress, + BlockIndex = newStakeBlockIndex, + }); + + action = new Stake2(newStakeAmount); + // 스테이킹 추가는 가능 + // 락업기간 이전에 deposit을 추가해서 save 할 수 있는지 + states = action.Execute(new ActionContext + { + PreviousState = states, + Signer = _signerAddress, + BlockIndex = newStakeBlockIndex, + }); + + Assert.True(states.TryGetStakeState(_signerAddress, out stakeState)); + Assert.NotNull(stakeState); + // 쌓여있던 보상 타이머가 정상적으로 리셋되는지 + Assert.Equal(newStakeBlockIndex, stakeState.StartedBlockIndex); + // 락업기간이 새롭게 201,600으로 갱신되는지 확인 + Assert.Equal( + newStakeBlockIndex + StakeState.LockupInterval, + stakeState.CancellableBlockIndex); + Assert.Equal( + _currency * (initialBalance - newStakeAmount), + states.GetBalance(_signerAddress, _currency)); + // 기존보다 초과해서 설정한 deposit 으로 묶인 상태 갱신된 것 확인 + Assert.Equal( + _currency * newStakeAmount, + states.GetBalance(stakeState.address, _currency)); + } + + [Theory] + [InlineData(500, 51, 50)] + [InlineData(500, 499, 50)] + [InlineData(5000, 500, 499)] + [InlineData(5000, 500, 50)] + [InlineData(5000, 4999, 500)] + [InlineData(50000, 5000, 4999)] + [InlineData(50000, 49999, 5000)] + [InlineData(500000, 50000, 49999)] + [InlineData(500000, 499999, 50000)] + [InlineData(500000000, 500000, 99999)] + [InlineData(500000000, 500000000, 0)] + [InlineData(500000000, 500000000, 99999999)] + public void StakeAndStakeLess(long initialBalance, long stakeAmount, long newStakeAmount) + { + // Validate testcases + Assert.True(initialBalance >= stakeAmount); + Assert.True(newStakeAmount < stakeAmount); + + var context = new ActionContext(); + var states = _initialState.MintAsset(context, _signerAddress, _currency * initialBalance); + + IAction action = new Stake2(stakeAmount); + states = action.Execute(new ActionContext + { + PreviousState = states, + Signer = _signerAddress, + BlockIndex = 0, + }); + + Assert.True(states.TryGetStakeState(_signerAddress, out StakeState stakeState)); + Assert.NotNull(stakeState); + Assert.Equal( + _currency * (initialBalance - stakeAmount), + states.GetBalance(_signerAddress, _currency)); + Assert.Equal( + _currency * stakeAmount, + states.GetBalance(stakeState.address, _currency)); + + action = new ClaimStakeReward2(_avatarAddress); + states = action.Execute(new ActionContext + { + PreviousState = states, + Signer = _signerAddress, + BlockIndex = StakeState.LockupInterval - 1, + }); + + action = new Stake2(newStakeAmount); + // 락업기간 이전에 deposit을 감소해서 save할때 락업되어 거부되는가 + Assert.Throws(() => states = action.Execute(new ActionContext + { + PreviousState = states, + Signer = _signerAddress, + BlockIndex = StakeState.LockupInterval - 1, + })); + + Assert.True(states.TryGetStakeState(_signerAddress, out stakeState)); + Assert.NotNull(stakeState); + Assert.Equal( + _currency * (initialBalance - stakeAmount), + states.GetBalance(_signerAddress, _currency)); + Assert.Equal( + _currency * stakeAmount, + states.GetBalance(stakeState.address, _currency)); + } + + [Theory] + [MemberData(nameof(StakeLessAfterLockupTestcases))] + // 락업기간 종료 이후 deposit을 현재보다 낮게 설정했을때, 설정이 잘되서 새롭게 락업되는지 확인 + // 락업기간 종료 이후 보상 수령하고 락업해제되는지 확인 + public void StakeLessAfterLockup(long initialBalance, long stakeAmount, long newStakeAmount, (long ClaimBlockIndex, (int ItemId, int Amount)[] ExpectedItems)[] claimEvents) + { + StakeState stakeState; + + // Validate testcases + Assert.True(stakeAmount > newStakeAmount); + + var context = new ActionContext(); + var states = _initialState.MintAsset(context, _signerAddress, _currency * initialBalance); + + IAction action = new Stake2(stakeAmount); + states = action.Execute(new ActionContext + { + PreviousState = states, + Signer = _signerAddress, + BlockIndex = 0, + }); + + // 1~3회까지 모든 보상을 수령함 + // 201,600 블록 도달 이후 → 지정된 캐릭터 앞으로 이하 보상의 수령이 가능해야 한다. + foreach ((long claimBlockIndex, (int itemId, int amount)[] expectedItems) in claimEvents) + { + action = new ClaimStakeReward2(_avatarAddress); + states = action.Execute(new ActionContext + { + PreviousState = states, + Signer = _signerAddress, + BlockIndex = claimBlockIndex, + }); + + var avatarState = states.GetAvatarStateV2(_avatarAddress); + foreach ((int itemId, int amount) in expectedItems) + { + Assert.True(avatarState.inventory.HasItem(itemId, amount)); + } + + Assert.True(states.TryGetStakeState(_signerAddress, out stakeState)); + Assert.NotNull(stakeState); + // deposit 유지 확인 + Assert.Equal( + _currency * stakeAmount, + states.GetBalance(stakeState.address, _currency)); + } + + action = new Stake2(newStakeAmount); + states = action.Execute(new ActionContext + { + PreviousState = states, + Signer = _signerAddress, + BlockIndex = StakeState.LockupInterval, + }); + + // Setup staking again. + if (newStakeAmount > 0) + { + Assert.True(states.TryGetStakeState(_signerAddress, out stakeState)); + Assert.NotNull(stakeState); + // 쌓여있던 보상 타이머가 정상적으로 리셋되는지 + Assert.Equal(StakeState.LockupInterval, stakeState.StartedBlockIndex); + // 락업기간이 새롭게 201,600으로 갱신되는지 확인 + Assert.Equal( + StakeState.LockupInterval + StakeState.LockupInterval, + stakeState.CancellableBlockIndex); + // 기존 deposit - 현재 deposit 만큼의 ncg 인출 상태 확인 + Assert.Equal( + _currency * (initialBalance - newStakeAmount), + states.GetBalance(_signerAddress, _currency)); + Assert.Equal( + _currency * newStakeAmount, + states.GetBalance(stakeState.address, _currency)); + + Assert.Throws(() => + { + // 현재 스테이킹된 NCG를 인출할 수 없다 + action = new ClaimStakeReward2(_avatarAddress); + states = action.Execute(new ActionContext + { + PreviousState = states, + Signer = _signerAddress, + BlockIndex = StakeState.LockupInterval + 1, + }); + }); + // 현재 deposit 묶인 상태 확인 + Assert.Equal( + _currency * newStakeAmount, + states.GetBalance(stakeState.address, _currency)); + } + else + { + Assert.Equal( + _currency * initialBalance, + states.GetBalance(_signerAddress, _currency)); + Assert.False(states.TryGetStakeState(_signerAddress, out stakeState)); + Assert.Null(stakeState); + } + } + + [Fact] + public void StakeAndClaimStakeRewardBeforeRewardInterval() + { + var context = new ActionContext(); + var states = _initialState.MintAsset(context, _signerAddress, _currency * 500); + IAction action = new Stake2(500); + states = action.Execute(new ActionContext + { + PreviousState = states, + Signer = _signerAddress, + BlockIndex = 0, + }); + + action = new ClaimStakeReward2(_avatarAddress); + Assert.Throws(() => states = action.Execute(new ActionContext + { + PreviousState = states, + Signer = _signerAddress, + BlockIndex = StakeState.RewardInterval - 1, + })); + + var avatarState = states.GetAvatarStateV2(_avatarAddress); + Assert.Empty(avatarState.inventory.Items.Where(x => x.item.Id == 400000)); + Assert.Empty(avatarState.inventory.Items.Where(x => x.item.Id == 500000)); + } + } +} diff --git a/.Lib9c.Tests/Action/Scenario/StakeAndClaimStakeReward3ScenarioTest.cs b/.Lib9c.Tests/Action/Scenario/StakeAndClaimStakeReward3ScenarioTest.cs new file mode 100644 index 0000000000..98883d9525 --- /dev/null +++ b/.Lib9c.Tests/Action/Scenario/StakeAndClaimStakeReward3ScenarioTest.cs @@ -0,0 +1,909 @@ +namespace Lib9c.Tests.Action.Scenario +{ + using System.Collections.Generic; + using System.Linq; + using Lib9c.Tests.Util; + using Libplanet.Action; + using Libplanet.Action.State; + using Libplanet.Crypto; + using Libplanet.Types.Assets; + using Nekoyume.Action; + using Nekoyume.Helper; + using Nekoyume.Model.State; + using Serilog; + using Xunit; + using Xunit.Abstractions; + + public class StakeAndClaimStakeReward3ScenarioTest + { + private readonly Address _agentAddr; + private readonly Address _avatarAddr; + private readonly IAccount _initialStatesWithAvatarStateV2; + private readonly Currency _ncg; + + public StakeAndClaimStakeReward3ScenarioTest(ITestOutputHelper outputHelper) + { + Log.Logger = new LoggerConfiguration() + .MinimumLevel.Verbose() + .WriteTo.TestOutput(outputHelper) + .CreateLogger(); + + ( + _, + _agentAddr, + _avatarAddr, + _, + _initialStatesWithAvatarStateV2) = InitializeUtil.InitializeStates(); + _ncg = _initialStatesWithAvatarStateV2.GetGoldCurrency(); + } + + public static IEnumerable StakeAndClaimStakeRewardTestCases() + { + // 일반적인 보상수령 확인 + // 1단계 수준(50~499NCG)의 deposit save 완료 + // → 최초 보상 시점 (50,400블록 이후) 도달 + // → 지정된 캐릭터 앞으로 이하 보상의 수령이 가능해야 한다. + // (보상내용: 1 hourglass / 10 NCG, 1 ap portion / 800 NCG 소수점 버림, 기존 deposit 유지 확인) + yield return new object[] + { + 50L, + new[] + { + (400_000, 5), + (500_000, 1), + }, + ClaimStakeReward2.ObsoletedIndex + 50_400L, + 0, + }; + yield return new object[] + { + 499L, + new[] + { + (400_000, 49), + (500_000, 1), + }, + ClaimStakeReward2.ObsoletedIndex + 50_400L, + 0, + }; + + // 2단계 수준(500~4,999NCG)의 deposit save 완료 + // → 최초 보상 시점 (50,400블록 이후) 도달 + // → 지정된 캐릭터 앞으로 이하 보상의 수령이 가능해야 한다. + // (보상내용: 1 hourglass / 8 NCG, 1 ap portion / 800 NCG 소수점 버림, 기존 deposit 유지 확인) + yield return new object[] + { + 500L, + new[] + { + (400_000, 62), + (500_000, 2), + }, + ClaimStakeReward2.ObsoletedIndex + 50_400L, + 0, + }; + yield return new object[] + { + 799L, + new[] + { + (400_000, 99), + (500_000, 2), + }, + ClaimStakeReward2.ObsoletedIndex + 50_400L, + 0, + }; + yield return new object[] + { + 4_999L, + new[] + { + (400_000, 624), + (500_000, 8), + }, + ClaimStakeReward2.ObsoletedIndex + 50_400L, + 0, + }; + + // 3단계 수준(5,000~49,999NCG)의 deposit save 완료 + // → 최초 보상 시점 (50,400블록 이후) 도달 + // → 지정된 캐릭터 앞으로 이하 보상의 수령이 가능해야 한다. + // (보상내용: 1 hourglass / 5 NCG, 1 ap portion / 800 NCG 소수점 버림, 기존 deposit 유지 확인) + yield return new object[] + { + 5_000L, + new[] + { + (400_000, 1000), + (500_000, 8), + }, + ClaimStakeReward2.ObsoletedIndex + 50_400L, + 0, + }; + yield return new object[] + { + 49_999L, + new[] + { + (400_000, 9_999), + (500_000, 64), + }, + ClaimStakeReward2.ObsoletedIndex + 50_400L, + 8, + }; + + // 4단계 수준(50,000~499,999NCG)의 deposit save 완료 + // → 최초 보상 시점 (50,400블록 이후) 도달 + // → 지정된 캐릭터 앞으로 이하 보상의 수령이 가능해야 한다. + // (보상내용: 1 hourglass / 5 NCG, 1 ap portion / 800 NCG 소수점 버림, 기존 deposit 유지 확인) + yield return new object[] + { + 50_000L, + new[] + { + (400_000, 10_000), + (500_000, 64), + }, + ClaimStakeReward2.ObsoletedIndex + 50_400L, + 8, + }; + yield return new object[] + { + 499_999L, + new[] + { + (400_000, 99_999), + (500_000, 626), + }, + ClaimStakeReward2.ObsoletedIndex + 50_400L, + 83, + }; + + // 5단계 수준(500,000~100,000,000NCG)의 deposit save 완료 + // → 최초 보상 시점 (50,400블록 이후) 도달 + // → 지정된 캐릭터 앞으로 이하 보상의 수령이 가능해야 한다. + // (보상내용: 1 hourglass / 5 NCG, 1 ap portion / 800 NCG 소수점 버림, 기존 deposit 유지 확인) + yield return new object[] + { + 500_000L, + new[] + { + (400_000, 100_000), + (500_000, 627), + }, + ClaimStakeReward2.ObsoletedIndex + 50_400L, + 83, + }; + yield return new object[] + { + 99_999_999L, + new[] + { + (400_000, 19_999_999), + (500_000, 125_001), + }, + ClaimStakeReward2.ObsoletedIndex + 50_400L, + 16_666, + }; + + // 지연된 보상수령 확인 + // 1단계 수준(50~499NCG)의 deposit save 완료 + // → 최초 보상 시점 (50,400블록 이후) 도달 + // → 보상을 수령하지 않음 + // → 2번째 보상 시점 (100,800블록 이후) 도달 + // → 이하 보상의 수령이 가능해야 한다. + // (보상내용: 2 hourglass / 50 NCG, 소수점 버림, 기존 deposit 유지 확인) + yield return new object[] + { + 50L, + new[] + { + (400_000, 45), + (500_000, 9), + }, + ClaimStakeReward2.ObsoletedIndex + 500_800L, + 0, + }; + yield return new object[] + { + 499L, + new[] + { + (400_000, 441), + (500_000, 9), + }, + ClaimStakeReward2.ObsoletedIndex + 500_800L, + 0, + }; + + // 2단계 수준(500~4,999NCG)의 deposit save 완료 + // → 최초 보상 시점 (50,400블록 이후) 도달 + // → 보상을 수령하지 않음 + // → 2번째 보상 시점 (100,800블록 이후) 도달 + // → 이하 보상의 수령이 가능해야 한다. + // (보상내용: 2 hourglass / 8 NCG, 2 ap portion / 800 NCG 소수점 버림, 기존 deposit 유지 확인) + yield return new object[] + { + 500L, + new[] + { + (400_000, 558), + (500_000, 18), + }, + ClaimStakeReward2.ObsoletedIndex + 500_800L, + 0, + }; + yield return new object[] + { + 799L, + new[] + { + (400_000, 891), + (500_000, 18), + }, + ClaimStakeReward2.ObsoletedIndex + 500_800L, + 0, + }; + yield return new object[] + { + 4_999L, + new[] + { + (400_000, 5_616), + (500_000, 72), + }, + ClaimStakeReward2.ObsoletedIndex + 500_800L, + 0, + }; + + // 3단계 수준(5,000~49,999NCG)의 deposit save 완료 + // → 최초 보상 시점 (50,400블록 이후) 도달 + // → 보상을 수령하지 않음 + // → 2번째 보상 시점 (100,800블록 이후) 도달 + // → 이하 보상의 수령이 가능해야 한다. + // (보상내용: 2 hourglass / 5 NCG, 2 ap portion / 180 NCG 소수점 버림, 기존 deposit 유지 확인) + yield return new object[] + { + 5_000L, + new[] + { + (400_000, 9_000), + (500_000, 72), + }, + ClaimStakeReward2.ObsoletedIndex + 500_800L, + 0, + }; + yield return new object[] + { + 49_999L, + new[] + { + (400_000, 89_991), + (500_000, 576), + }, + ClaimStakeReward2.ObsoletedIndex + 500_800L, + 72, + }; + + // 4단계 수준(50,000~499,999NCG)의 deposit save 완료 + // → 최초 보상 시점 (50,400블록 이후) 도달 + // → 보상을 수령하지 않음 + // → 2번째 보상 시점 (100,800블록 이후) 도달 + // → 이하 보상의 수령이 가능해야 한다. + // (보상내용: 2 hourglass / 5 NCG, 2 ap portion / 180 NCG 소수점 버림, 기존 deposit 유지 확인) + yield return new object[] + { + 50_000L, + new[] + { + (400_000, 90_000), + (500_000, 576), + }, + ClaimStakeReward2.ObsoletedIndex + 500_800L, + 72, + }; + yield return new object[] + { + 499_999L, + new[] + { + (400_000, 899_991), + (500_000, 5_634), + }, + ClaimStakeReward2.ObsoletedIndex + 500_800L, + 747, + }; + + // 5단계 수준(500,000~4,999,999NCG)의 deposit save 완료 + // → 최초 보상 시점 (50,400블록 이후) 도달 + // → 지정된 캐릭터 앞으로 이하 보상의 수령이 가능해야 한다. + // (보상내용: 1 hourglass / 5 NCG, 1 ap portion / 160 NCG 소수점 버림, 기존 deposit 유지 확인) + // 5단계 수준(500,000~500,000,000NCG)의 deposit save 완료 + // → 최초 보상 시점 (50,400블록 이후) 도달 + // → 보상을 수령하지 않음 + // → 2번째 보상 시점 (100,800블록 이후) 도달 + // → 이하 보상의 수령이 가능해야 한다. + // (보상내용: 2 hourglass / 5 NCG, 2 ap portion / 160 NCG 소수점 버림, 기존 deposit 유지 확인) + yield return new object[] + { + 500_000L, + new[] + { + (400_000, 900_000), + (500_000, 5_643), + }, + ClaimStakeReward2.ObsoletedIndex + 500_800L, + 747, + }; + yield return new object[] + { + 4_999_999L, + new[] + { + (400_000, 8_999_991), + (500_000, 56_259), + }, + ClaimStakeReward2.ObsoletedIndex + 500_800L, + 7_497, + }; + } + + public static IEnumerable StakeLessAfterLockupTestcases() + { + (long ClaimBlockIndex, (int ItemId, int Amount)[])[] BuildEvents( + int hourglassRate, + int apPotionRate, + int apPotionBonus, + long stakeAmount) + { + const int hourglassItemId = 400_000, apPotionItemId = 500_000; + return new[] + { + (ClaimStakeReward2.ObsoletedIndex + StakeState.RewardInterval, new[] + { + (hourglassItemId, (int)(stakeAmount / hourglassRate)), + (apPotionItemId, (int)(stakeAmount / apPotionRate + apPotionBonus)), + }), + (ClaimStakeReward2.ObsoletedIndex + StakeState.RewardInterval * 2, new[] + { + (hourglassItemId, (int)(stakeAmount / hourglassRate)), + (apPotionItemId, (int)(stakeAmount / apPotionRate + apPotionBonus)), + }), + (ClaimStakeReward2.ObsoletedIndex + StakeState.RewardInterval * 3, new[] + { + (hourglassItemId, (int)(stakeAmount / hourglassRate)), + (apPotionItemId, (int)(stakeAmount / apPotionRate + apPotionBonus)), + }), + (ClaimStakeReward2.ObsoletedIndex + StakeState.RewardInterval * 4, new[] + { + (hourglassItemId, (int)(stakeAmount / hourglassRate)), + (apPotionItemId, (int)(stakeAmount / apPotionRate + apPotionBonus)), + }), + }; + } + + // 1단계 수준(50~499NCG)의 deposit save 완료 + // → 1~3회까지 모든 보상을 수령함 + // → 201,600 블록 도달 이후 + // → 지정된 캐릭터 앞으로 이하 보상의 수령이 가능해야 한다. + // (보상내용: 1 hourglass / 50 NCG, 1 ap portion / 800 NCG 소수점 버림, 기존 deposit 유지 확인) + // → 기존 deposit보다 낮은 금액으로 edit save 한다. + // → 보상 타이머 리셋 확인 + // → (기존 deposit - 현재 deposit 만큼의 ncg 인출 상태 확인) + // → 현재 스테이킹된 NCG를 인출할 수 없다 (스테이킹 추가는 가능) + // → (현재 deposit 묶인 상태 확인) + yield return new object[] + { + 51L, + 51L, + 50L, + BuildEvents(10, 800, 1, 50), + }; + yield return new object[] + { + 499L, + 499L, + 50L, + BuildEvents(10, 800, 1, 499), + }; + + // 현재의 스테이킹된 NCG의 전액 인출을 시도한다(deposit NCG 인출 상태 확인) + // → 스테이킹 완전 소멸 확인 + yield return new object[] + { + 50L, + 50L, + 0L, + BuildEvents(10, 800, 1, 50), + }; + yield return new object[] + { + 499L, + 499L, + 0L, + BuildEvents(10, 800, 1, 499), + }; + + // 2단계 수준(500~4,999NCG)의 deposit save 완료 + // → 1~3회까지 모든 보상을 수령함 + // → 201,600 블록 도달 이후 + // → 지정된 캐릭터 앞으로 이하 보상의 수령이 가능해야 한다. + // (보상내용: 1 hourglass / 8 NCG, 1 ap portion / 800 NCG 소수점 버림, 기존 deposit 유지 확인) + // → 기존 deposit보다 낮은 금액으로 edit save 한다. + // → 보상 타이머 리셋 확인 + // → (기존 deposit - 현재 deposit 만큼의 ncg 인출 상태 확인) + // → 현재 스테이킹된 NCG를 인출할 수 없다 (스테이킹 추가는 가능) + // → (현재 deposit 묶인 상태 확인) + yield return new object[] + { + 500L, + 500L, + 499L, + BuildEvents(8, 800, 2, 500), + }; + yield return new object[] + { + 4_999L, + 4_999L, + 500L, + BuildEvents(8, 800, 2, 4_999), + }; + + // 현재의 스테이킹된 NCG의 전액 인출을 시도한다(deposit NCG 인출 상태 확인) + // → 스테이킹 완전 소멸 확인 + yield return new object[] + { + 500L, + 500L, + 0L, + BuildEvents(8, 800, 2, 500), + }; + yield return new object[] + { + 4_999L, + 4_999L, + 0L, + BuildEvents(8, 800, 2, 4_999), + }; + + // 3단계 수준(5,000~49,999NCG)의 deposit save 완료 + // → 1~3회까지 모든 보상을 수령함 + // → 201,600 블록 도달 이후 + // → 지정된 캐릭터 앞으로 이하 보상의 수령이 가능해야 한다. + // (보상내용: 1 hourglass / 5 NCG, 1 ap portion / 800 NCG 소수점 버림, 기존 deposit 유지 확인) + // → 기존 deposit보다 낮은 금액으로 edit save 한다. + // → 보상 타이머 리셋 확인 + // → (기존 deposit - 현재 deposit 만큼의 ncg 인출 상태 확인) + // → 현재 스테이킹된 NCG를 인출할 수 없다 (스테이킹 추가는 가능) + // → (현재 deposit 묶인 상태 확인) + yield return new object[] + { + 5_000L, + 5_000L, + 4_999L, + BuildEvents(5, 800, 2, 5_000), + }; + yield return new object[] + { + 49_999L, + 49_999L, + 5_000L, + BuildEvents(5, 800, 2, 49_999), + }; + + // 현재의 스테이킹된 NCG의 전액 인출을 시도한다(deposit NCG 인출 상태 확인) + // → 스테이킹 완전 소멸 확인 + yield return new object[] + { + 5_000L, + 5_000L, + 0L, + BuildEvents(5, 800, 2, 5_000), + }; + yield return new object[] + { + 49_999L, + 49_999L, + 0L, + BuildEvents(5, 800, 2, 49_999), + }; + + // 4단계 수준(50,000~499,999NCG)의 deposit save 완료 + // → 1~3회까지 모든 보상을 수령함 + // → 201,600 블록 도달 이후 + // → 지정된 캐릭터 앞으로 이하 보상의 수령이 가능해야 한다. + // (보상내용: 1 hourglass / 5 NCG, 1 ap portion / 800 NCG 소수점 버림, 기존 deposit 유지 확인) + // → 기존 deposit보다 낮은 금액으로 edit save 한다. + // → 보상 타이머 리셋 확인 + // → (기존 deposit - 현재 deposit 만큼의 ncg 인출 상태 확인) + // → 현재 스테이킹된 NCG를 인출할 수 없다 (스테이킹 추가는 가능) + // → (현재 deposit 묶인 상태 확인) + yield return new object[] + { + 50_000L, + 50_000L, + 49_999L, + BuildEvents(5, 800, 2, 50_000), + }; + yield return new object[] + { + 499_999L, + 499_999L, + 50_000L, + BuildEvents(5, 800, 2, 499_999), + }; + + // 현재의 스테이킹된 NCG의 전액 인출을 시도한다(deposit NCG 인출 상태 확인) + // → 스테이킹 완전 소멸 확인 + yield return new object[] + { + 50_000L, + 50_000L, + 0L, + BuildEvents(5, 800, 2, 50_000), + }; + yield return new object[] + { + 499_999L, + 499_999L, + 0L, + BuildEvents(5, 800, 2, 499_999), + }; + + // 5단계 수준(500,000~100,000,000NCG)의 deposit save 완료 + // → 1~3회까지 모든 보상을 수령함 + // → 201,600 블록 도달 이후 + // → 지정된 캐릭터 앞으로 이하 보상의 수령이 가능해야 한다. + // (보상내용: 1 hourglass / 5 NCG, 1 ap portion / 800 NCG 소수점 버림, 기존 deposit 유지 확인) + // → 기존 deposit보다 낮은 금액으로 edit save 한다. + // → 보상 타이머 리셋 확인 + // → (기존 deposit - 현재 deposit 만큼의 ncg 인출 상태 확인) + // → 현재 스테이킹된 NCG를 인출할 수 없다 (스테이킹 추가는 가능) + // → (현재 deposit 묶인 상태 확인) + yield return new object[] + { + 500_000L, + 500_000L, + 499_999L, + BuildEvents(5, 800, 2, 500_000), + }; + yield return new object[] + { + 500_000_000L, + 500_000_000L, + 500_000L, + BuildEvents(5, 800, 2, 500_000_000), + }; + + // 현재의 스테이킹된 NCG의 전액 인출을 시도한다(deposit NCG 인출 상태 확인) + // → 스테이킹 완전 소멸 확인 + yield return new object[] + { + 500_000L, + 500_000L, + 0L, + BuildEvents(5, 800, 2, 500_000), + }; + yield return new object[] + { + 500_000_000L, + 500_000_000L, + 0L, + BuildEvents(5, 800, 2, 500_000_000), + }; + } + + [Theory] + [MemberData(nameof(StakeAndClaimStakeRewardTestCases))] + public void StakeAndClaimStakeReward( + long stakeAmount, + (int ItemId, int Amount)[] expectedItems, + long receiveBlockIndex, + int expectedRune) + { + var context = new ActionContext(); + var states = _initialStatesWithAvatarStateV2.MintAsset(context, _agentAddr, _ncg * stakeAmount); + + IAction action = new Stake2(stakeAmount); + states = action.Execute(new ActionContext + { + PreviousState = states, + Signer = _agentAddr, + BlockIndex = ClaimStakeReward2.ObsoletedIndex, + }); + + Assert.True(states.TryGetStakeState(_agentAddr, out StakeState stakeState)); + Assert.NotNull(stakeState); + Assert.Equal(0 * RuneHelper.StakeRune, _initialStatesWithAvatarStateV2.GetBalance(_avatarAddr, RuneHelper.StakeRune)); + + action = new ClaimStakeReward3(_avatarAddr); + states = action.Execute(new ActionContext + { + PreviousState = states, + Signer = _agentAddr, + BlockIndex = receiveBlockIndex, + }); + + // 지정된 캐릭터 앞으로 이하 보상의 수령이 가능해야 한다. + var avatarState = states.GetAvatarStateV2(_avatarAddr); + foreach ((int itemId, int amount) in expectedItems) + { + Assert.True(avatarState.inventory.HasItem(itemId, amount)); + } + + // 기존 deposit 유지 확인 + Assert.Equal( + _ncg * stakeAmount, + states.GetBalance(stakeState.address, _ncg)); + Assert.Equal(expectedRune * RuneHelper.StakeRune, states.GetBalance(_avatarAddr, RuneHelper.StakeRune)); + } + + [Theory] + [InlineData(500L, 50L, 499L)] + [InlineData(500L, 499L, 500L)] + [InlineData(5_000L, 500L, 4_999L)] + [InlineData(5_000L, 4_999L, 5_000L)] + [InlineData(50_000L, 5_000L, 49_999L)] + [InlineData(50_000L, 49_999L, 50_000L)] + [InlineData(500_000L, 50_000L, 499_999L)] + [InlineData(500_000L, 499_999L, 500_000L)] + [InlineData(500_000_000L, 500_000L, 500_000_000L)] + public void StakeAndStakeMore(long initialBalance, long stakeAmount, long newStakeAmount) + { + long newStakeBlockIndex = ClaimStakeReward2.ObsoletedIndex + StakeState.LockupInterval - 1; + // Validate testcases + Assert.True(stakeAmount < newStakeAmount); + + var context = new ActionContext(); + var states = _initialStatesWithAvatarStateV2.MintAsset(context, _agentAddr, _ncg * initialBalance); + + IAction action = new Stake2(stakeAmount); + states = action.Execute(new ActionContext + { + PreviousState = states, + Signer = _agentAddr, + BlockIndex = ClaimStakeReward2.ObsoletedIndex, + }); + + Assert.True(states.TryGetStakeState(_agentAddr, out StakeState stakeState)); + Assert.NotNull(stakeState); + Assert.Equal( + _ncg * (initialBalance - stakeAmount), + states.GetBalance(_agentAddr, _ncg)); + Assert.Equal( + _ncg * stakeAmount, + states.GetBalance(stakeState.address, _ncg)); + + action = new ClaimStakeReward3(_avatarAddr); + states = action.Execute(new ActionContext + { + PreviousState = states, + Signer = _agentAddr, + BlockIndex = newStakeBlockIndex, + }); + + action = new Stake2(newStakeAmount); + // 스테이킹 추가는 가능 + // 락업기간 이전에 deposit을 추가해서 save 할 수 있는지 + states = action.Execute(new ActionContext + { + PreviousState = states, + Signer = _agentAddr, + BlockIndex = newStakeBlockIndex, + }); + + Assert.True(states.TryGetStakeState(_agentAddr, out stakeState)); + Assert.NotNull(stakeState); + // 쌓여있던 보상 타이머가 정상적으로 리셋되는지 + Assert.Equal(newStakeBlockIndex, stakeState.StartedBlockIndex); + // 락업기간이 새롭게 201,600으로 갱신되는지 확인 + Assert.Equal( + newStakeBlockIndex + StakeState.LockupInterval, + stakeState.CancellableBlockIndex); + Assert.Equal( + _ncg * (initialBalance - newStakeAmount), + states.GetBalance(_agentAddr, _ncg)); + // 기존보다 초과해서 설정한 deposit 으로 묶인 상태 갱신된 것 확인 + Assert.Equal( + _ncg * newStakeAmount, + states.GetBalance(stakeState.address, _ncg)); + } + + [Theory] + [InlineData(500L, 51L, 50L)] + [InlineData(500L, 499L, 50L)] + [InlineData(5_000L, 500L, 499L)] + [InlineData(5_000L, 500L, 50L)] + [InlineData(5_000L, 4_999L, 500L)] + [InlineData(50_000L, 5_000L, 4_999L)] + [InlineData(50_000L, 49_999L, 5_000L)] + [InlineData(500_000L, 50_000L, 49_999L)] + [InlineData(500_000L, 499_999L, 50_000L)] + [InlineData(500_000_000L, 500_000L, 99_999L)] + [InlineData(500_000_000L, 500_000_000L, 0L)] + [InlineData(500_000_000L, 500_000_000L, 99_999_999L)] + public void StakeAndStakeLess(long initialBalance, long stakeAmount, long newStakeAmount) + { + // Validate testcases + Assert.True(initialBalance >= stakeAmount); + Assert.True(newStakeAmount < stakeAmount); + + var context = new ActionContext(); + var states = _initialStatesWithAvatarStateV2.MintAsset(context, _agentAddr, _ncg * initialBalance); + + IAction action = new Stake2(stakeAmount); + states = action.Execute(new ActionContext + { + PreviousState = states, + Signer = _agentAddr, + BlockIndex = ClaimStakeReward2.ObsoletedIndex, + }); + + Assert.True(states.TryGetStakeState(_agentAddr, out StakeState stakeState)); + Assert.NotNull(stakeState); + Assert.Equal( + _ncg * (initialBalance - stakeAmount), + states.GetBalance(_agentAddr, _ncg)); + Assert.Equal( + _ncg * stakeAmount, + states.GetBalance(stakeState.address, _ncg)); + + action = new ClaimStakeReward3(_avatarAddr); + states = action.Execute(new ActionContext + { + PreviousState = states, + Signer = _agentAddr, + BlockIndex = ClaimStakeReward2.ObsoletedIndex + StakeState.LockupInterval - 1, + }); + + action = new Stake2(newStakeAmount); + // 락업기간 이전에 deposit을 감소해서 save할때 락업되어 거부되는가 + Assert.Throws(() => states = action.Execute(new ActionContext + { + PreviousState = states, + Signer = _agentAddr, + BlockIndex = ClaimStakeReward2.ObsoletedIndex + StakeState.LockupInterval - 1, + })); + + Assert.True(states.TryGetStakeState(_agentAddr, out stakeState)); + Assert.NotNull(stakeState); + Assert.Equal( + _ncg * (initialBalance - stakeAmount), + states.GetBalance(_agentAddr, _ncg)); + Assert.Equal( + _ncg * stakeAmount, + states.GetBalance(stakeState.address, _ncg)); + } + + [Theory] + [MemberData(nameof(StakeLessAfterLockupTestcases))] + // 락업기간 종료 이후 deposit을 현재보다 낮게 설정했을때, 설정이 잘되서 새롭게 락업되는지 확인 + // 락업기간 종료 이후 보상 수령하고 락업해제되는지 확인 + public void StakeLessAfterLockup( + long initialBalance, + long stakeAmount, + long newStakeAmount, + (long ClaimBlockIndex, (int ItemId, int Amount)[] ExpectedItems)[] claimEvents) + { + StakeState stakeState; + + // Validate testcases + Assert.True(stakeAmount > newStakeAmount); + + var context = new ActionContext(); + var states = _initialStatesWithAvatarStateV2.MintAsset(context, _agentAddr, _ncg * initialBalance); + + IAction action = new Stake2(stakeAmount); + states = action.Execute(new ActionContext + { + PreviousState = states, + Signer = _agentAddr, + BlockIndex = ClaimStakeReward2.ObsoletedIndex, + }); + + // 1~3회까지 모든 보상을 수령함 + // 201,600 블록 도달 이후 → 지정된 캐릭터 앞으로 이하 보상의 수령이 가능해야 한다. + foreach ((long claimBlockIndex, (int itemId, int amount)[] expectedItems) in claimEvents) + { + action = new ClaimStakeReward3(_avatarAddr); + states = action.Execute(new ActionContext + { + PreviousState = states, + Signer = _agentAddr, + BlockIndex = claimBlockIndex, + }); + + var avatarState = states.GetAvatarStateV2(_avatarAddr); + foreach ((int itemId, int amount) in expectedItems) + { + Assert.True(avatarState.inventory.HasItem(itemId, amount)); + } + + Assert.True(states.TryGetStakeState(_agentAddr, out stakeState)); + Assert.NotNull(stakeState); + // deposit 유지 확인 + Assert.Equal( + _ncg * stakeAmount, + states.GetBalance(stakeState.address, _ncg)); + } + + action = new Stake2(newStakeAmount); + states = action.Execute(new ActionContext + { + PreviousState = states, + Signer = _agentAddr, + BlockIndex = ClaimStakeReward2.ObsoletedIndex + StakeState.LockupInterval, + }); + + // Setup staking again. + if (newStakeAmount > 0) + { + Assert.True(states.TryGetStakeState(_agentAddr, out stakeState)); + Assert.NotNull(stakeState); + // 쌓여있던 보상 타이머가 정상적으로 리셋되는지 + Assert.Equal(ClaimStakeReward2.ObsoletedIndex + StakeState.LockupInterval, stakeState.StartedBlockIndex); + // 락업기간이 새롭게 201,600으로 갱신되는지 확인 + Assert.Equal( + ClaimStakeReward2.ObsoletedIndex + StakeState.LockupInterval + StakeState.LockupInterval, + stakeState.CancellableBlockIndex); + // 기존 deposit - 현재 deposit 만큼의 ncg 인출 상태 확인 + Assert.Equal( + _ncg * (initialBalance - newStakeAmount), + states.GetBalance(_agentAddr, _ncg)); + Assert.Equal( + _ncg * newStakeAmount, + states.GetBalance(stakeState.address, _ncg)); + + Assert.Throws(() => + { + // 현재 스테이킹된 NCG를 인출할 수 없다 + action = new ClaimStakeReward3(_avatarAddr); + states = action.Execute(new ActionContext + { + PreviousState = states, + Signer = _agentAddr, + BlockIndex = ClaimStakeReward2.ObsoletedIndex + StakeState.LockupInterval + 1, + }); + }); + // 현재 deposit 묶인 상태 확인 + Assert.Equal( + _ncg * newStakeAmount, + states.GetBalance(stakeState.address, _ncg)); + } + else + { + Assert.Equal( + _ncg * initialBalance, + states.GetBalance(_agentAddr, _ncg)); + Assert.False(states.TryGetStakeState(_agentAddr, out stakeState)); + Assert.Null(stakeState); + } + } + + [Fact] + public void StakeAndClaimStakeRewardBeforeRewardInterval() + { + var context = new ActionContext(); + var states = _initialStatesWithAvatarStateV2.MintAsset(context, _agentAddr, _ncg * 500); + IAction action = new Stake2(500); + states = action.Execute(new ActionContext + { + PreviousState = states, + Signer = _agentAddr, + BlockIndex = ClaimStakeReward2.ObsoletedIndex, + }); + + action = new ClaimStakeReward3(_avatarAddr); + Assert.Throws(() => states = action.Execute(new ActionContext + { + PreviousState = states, + Signer = _agentAddr, + BlockIndex = ClaimStakeReward2.ObsoletedIndex + StakeState.RewardInterval - 1, + })); + + var avatarState = states.GetAvatarStateV2(_avatarAddr); + Assert.Empty(avatarState.inventory.Items.Where(x => x.item.Id == 400000)); + Assert.Empty(avatarState.inventory.Items.Where(x => x.item.Id == 500000)); + } + } +} diff --git a/.Lib9c.Tests/Action/Scenario/StakeAndClaimStakeRewardScenarioTest.cs b/.Lib9c.Tests/Action/Scenario/StakeAndClaimStakeRewardScenarioTest.cs new file mode 100644 index 0000000000..d91a402b83 --- /dev/null +++ b/.Lib9c.Tests/Action/Scenario/StakeAndClaimStakeRewardScenarioTest.cs @@ -0,0 +1,1016 @@ +namespace Lib9c.Tests.Action.Scenario +{ + using System.Collections.Generic; + using System.Linq; + using Lib9c.Tests.Util; + using Libplanet.Action; + using Libplanet.Action.State; + using Libplanet.Crypto; + using Libplanet.Types.Assets; + using Nekoyume.Action; + using Nekoyume.Helper; + using Nekoyume.Model.State; + using Serilog; + using Xunit; + using Xunit.Abstractions; + + // Tests for stake2 and claim_stake_reward{..8} actions + public class StakeAndClaimStakeRewardScenarioTest + { + private const string AgentAddressHex = "0x0000000001000000000100000000010000000001"; + private readonly Address _agentAddr = new Address(AgentAddressHex); + private readonly Address _avatarAddr; + private readonly IAccount _initialStatesWithAvatarStateV2; + private readonly Currency _ncg; + + public StakeAndClaimStakeRewardScenarioTest(ITestOutputHelper outputHelper) + { + Log.Logger = new LoggerConfiguration() + .MinimumLevel.Verbose() + .WriteTo.TestOutput(outputHelper) + .CreateLogger(); + + ( + _, + _, + _avatarAddr, + _, + _initialStatesWithAvatarStateV2) = InitializeUtil.InitializeStates( + agentAddr: _agentAddr); + _ncg = _initialStatesWithAvatarStateV2.GetGoldCurrency(); + } + + public static IEnumerable StakeAndClaimStakeRewardTestCases() + { + // 일반적인 보상수령 확인 + // 1단계 수준(50~499NCG)의 deposit save 완료 + // → 최초 보상 시점 (50,400블록 이후) 도달 + // → 지정된 캐릭터 앞으로 이하 보상의 수령이 가능해야 한다. + // (보상내용: 1 hourglass / 10 NCG, 1 ap portion / 800 NCG 소수점 버림, 기존 deposit 유지 확인) + yield return new object[] + { + 50L, + new[] + { + (400_000, 5), + (500_000, 1), + }, + ClaimStakeReward2.ObsoletedIndex + 50_400L, + 0, + null, + null, + 0L, + }; + yield return new object[] + { + 499L, + new[] + { + (400_000, 49), + (500_000, 1), + }, + ClaimStakeReward2.ObsoletedIndex + 50_400L, + 0, + null, + null, + 0L, + }; + + // 2단계 수준(500~4,999NCG)의 deposit save 완료 + // → 최초 보상 시점 (50,400블록 이후) 도달 + // → 지정된 캐릭터 앞으로 이하 보상의 수령이 가능해야 한다. + // (보상내용: 1 hourglass / 8 NCG, 1 ap portion / 800 NCG 소수점 버림, 기존 deposit 유지 확인) + yield return new object[] + { + 500L, + new[] + { + (400_000, 62), + (500_000, 2), + }, + ClaimStakeReward2.ObsoletedIndex + 50_400L, + 0, + null, + null, + 0L, + }; + yield return new object[] + { + 799L, + new[] + { + (400_000, 99), + (500_000, 2), + }, + ClaimStakeReward2.ObsoletedIndex + 50_400L, + 0, + null, + null, + 0L, + }; + yield return new object[] + { + 4_999L, + new[] + { + (400_000, 624), + (500_000, 8), + }, + ClaimStakeReward2.ObsoletedIndex + 50_400L, + 0, + null, + null, + 0L, + }; + + // 3단계 수준(5,000~49,999NCG)의 deposit save 완료 + // → 최초 보상 시점 (50,400블록 이후) 도달 + // → 지정된 캐릭터 앞으로 이하 보상의 수령이 가능해야 한다. + // (보상내용: 1 hourglass / 5 NCG, 1 ap portion / 800 NCG 소수점 버림, 기존 deposit 유지 확인) + yield return new object[] + { + 5_000L, + new[] + { + (400_000, 1000), + (500_000, 8), + }, + ClaimStakeReward2.ObsoletedIndex + 50_400L, + 0, + null, + null, + 0L, + }; + yield return new object[] + { + 49_999L, + new[] + { + (400_000, 9_999), + (500_000, 64), + }, + ClaimStakeReward2.ObsoletedIndex + 50_400L, + 8, + null, + null, + 0L, + }; + + // 4단계 수준(50,000~499,999NCG)의 deposit save 완료 + // → 최초 보상 시점 (50,400블록 이후) 도달 + // → 지정된 캐릭터 앞으로 이하 보상의 수령이 가능해야 한다. + // (보상내용: 1 hourglass / 5 NCG, 1 ap portion / 800 NCG 소수점 버림, 기존 deposit 유지 확인) + yield return new object[] + { + 50_000L, + new[] + { + (400_000, 10_000), + (500_000, 64), + }, + ClaimStakeReward2.ObsoletedIndex + 50_400L, + 8, + null, + null, + 0L, + }; + yield return new object[] + { + 499_999L, + new[] + { + (400_000, 99_999), + (500_000, 626), + }, + ClaimStakeReward2.ObsoletedIndex + 50_400L, + 83, + null, + null, + 0L, + }; + + // 5단계 수준(500,000~100,000,000NCG)의 deposit save 완료 + // → 최초 보상 시점 (50,400블록 이후) 도달 + // → 지정된 캐릭터 앞으로 이하 보상의 수령이 가능해야 한다. + // (보상내용: 1 hourglass / 5 NCG, 1 ap portion / 800 NCG 소수점 버림, 기존 deposit 유지 확인) + yield return new object[] + { + 500_000L, + new[] + { + (400_000, 100_000), + (500_000, 627), + }, + ClaimStakeReward2.ObsoletedIndex + 50_400L, + 83, + null, + null, + 0L, + }; + yield return new object[] + { + 99_999_999L, + new[] + { + (400_000, 19_999_999), + (500_000, 125_001), + }, + ClaimStakeReward2.ObsoletedIndex + 50_400L, + 16_666, + null, + null, + 0L, + }; + + // 지연된 보상수령 확인 + // 1단계 수준(50~499NCG)의 deposit save 완료 + // → 최초 보상 시점 (50,400블록 이후) 도달 + // → 보상을 수령하지 않음 + // → 2번째 보상 시점 (100,800블록 이후) 도달 + // → 이하 보상의 수령이 가능해야 한다. + // (보상내용: 2 hourglass / 50 NCG, 소수점 버림, 기존 deposit 유지 확인) + yield return new object[] + { + 50L, + new[] + { + (400_000, 45), + (500_000, 9), + }, + ClaimStakeReward2.ObsoletedIndex + 500_800L, + 0, + null, + null, + 0L, + }; + yield return new object[] + { + 499L, + new[] + { + (400_000, 441), + (500_000, 9), + }, + ClaimStakeReward2.ObsoletedIndex + 500_800L, + 0, + null, + null, + 0L, + }; + + // 2단계 수준(500~4,999NCG)의 deposit save 완료 + // → 최초 보상 시점 (50,400블록 이후) 도달 + // → 보상을 수령하지 않음 + // → 2번째 보상 시점 (100,800블록 이후) 도달 + // → 이하 보상의 수령이 가능해야 한다. + // (보상내용: 2 hourglass / 8 NCG, 2 ap portion / 800 NCG 소수점 버림, 기존 deposit 유지 확인) + yield return new object[] + { + 500L, + new[] + { + (400_000, 558), + (500_000, 18), + }, + ClaimStakeReward2.ObsoletedIndex + 500_800L, + 0, + null, + null, + 0L, + }; + yield return new object[] + { + 799L, + new[] + { + (400_000, 891), + (500_000, 18), + }, + ClaimStakeReward2.ObsoletedIndex + 500_800L, + 0, + null, + null, + 0L, + }; + yield return new object[] + { + 4_999L, + new[] + { + (400_000, 5_616), + (500_000, 72), + }, + ClaimStakeReward2.ObsoletedIndex + 500_800L, + 0, + null, + null, + 0L, + }; + + // 3단계 수준(5,000~49,999NCG)의 deposit save 완료 + // → 최초 보상 시점 (50,400블록 이후) 도달 + // → 보상을 수령하지 않음 + // → 2번째 보상 시점 (100,800블록 이후) 도달 + // → 이하 보상의 수령이 가능해야 한다. + // (보상내용: 2 hourglass / 5 NCG, 2 ap portion / 180 NCG 소수점 버림, 기존 deposit 유지 확인) + yield return new object[] + { + 5_000L, + new[] + { + (400_000, 9_000), + (500_000, 72), + }, + ClaimStakeReward2.ObsoletedIndex + 500_800L, + 0, + null, + null, + 0L, + }; + yield return new object[] + { + 49_999L, + new[] + { + (400_000, 89_991), + (500_000, 576), + }, + ClaimStakeReward2.ObsoletedIndex + 500_800L, + 72, + null, + null, + 0L, + }; + + // 4단계 수준(50,000~499,999NCG)의 deposit save 완료 + // → 최초 보상 시점 (50,400블록 이후) 도달 + // → 보상을 수령하지 않음 + // → 2번째 보상 시점 (100,800블록 이후) 도달 + // → 이하 보상의 수령이 가능해야 한다. + // (보상내용: 2 hourglass / 5 NCG, 2 ap portion / 180 NCG 소수점 버림, 기존 deposit 유지 확인) + yield return new object[] + { + 50_000L, + new[] + { + (400_000, 90_000), + (500_000, 576), + }, + ClaimStakeReward2.ObsoletedIndex + 500_800L, + 72, + null, + null, + 0L, + }; + yield return new object[] + { + 499_999L, + new[] + { + (400_000, 899_991), + (500_000, 5_634), + }, + ClaimStakeReward2.ObsoletedIndex + 500_800L, + 747, + null, + null, + 0L, + }; + + // 5단계 수준(500,000~4,999,999NCG)의 deposit save 완료 + // → 최초 보상 시점 (50,400블록 이후) 도달 + // → 지정된 캐릭터 앞으로 이하 보상의 수령이 가능해야 한다. + // (보상내용: 1 hourglass / 5 NCG, 1 ap portion / 160 NCG 소수점 버림, 기존 deposit 유지 확인) + // 5단계 수준(500,000~500,000,000NCG)의 deposit save 완료 + // → 최초 보상 시점 (50,400블록 이후) 도달 + // → 보상을 수령하지 않음 + // → 2번째 보상 시점 (100,800블록 이후) 도달 + // → 이하 보상의 수령이 가능해야 한다. + // (보상내용: 2 hourglass / 5 NCG, 2 ap portion / 160 NCG 소수점 버림, 기존 deposit 유지 확인) + yield return new object[] + { + 500_000L, + new[] + { + (400_000, 900_000), + (500_000, 5_643), + }, + ClaimStakeReward2.ObsoletedIndex + 500_800L, + 747, + null, + null, + 0L, + }; + yield return new object[] + { + 4_999_999L, + new[] + { + (400_000, 8_999_991), + (500_000, 56_259), + }, + ClaimStakeReward2.ObsoletedIndex + 500_800L, + 7_497, + null, + null, + 0L, + }; + } + + public static IEnumerable StakeLessAfterLockupTestcases() + { + (long ClaimBlockIndex, (int ItemId, int Amount)[])[] BuildEvents( + int hourglassRate, + int apPotionRate, + int apPotionBonus, + long stakeAmount) + { + const int hourglassItemId = 400_000, apPotionItemId = 500_000; + return new[] + { + ( + ClaimStakeReward2.ObsoletedIndex + StakeState.RewardInterval, + new[] + { + (hourglassItemId, (int)(stakeAmount / hourglassRate)), + (apPotionItemId, (int)(stakeAmount / apPotionRate + apPotionBonus)), + } + ), + ( + ClaimStakeReward2.ObsoletedIndex + StakeState.RewardInterval * 2, + new[] + { + (hourglassItemId, (int)(stakeAmount / hourglassRate)), + (apPotionItemId, (int)(stakeAmount / apPotionRate + apPotionBonus)), + } + ), + ( + ClaimStakeReward2.ObsoletedIndex + StakeState.RewardInterval * 3, + new[] + { + (hourglassItemId, (int)(stakeAmount / hourglassRate)), + (apPotionItemId, (int)(stakeAmount / apPotionRate + apPotionBonus)), + } + ), + ( + ClaimStakeReward2.ObsoletedIndex + StakeState.RewardInterval * 4, + new[] + { + (hourglassItemId, (int)(stakeAmount / hourglassRate)), + (apPotionItemId, (int)(stakeAmount / apPotionRate + apPotionBonus)), + } + ), + }; + } + + // 1단계 수준(50~499NCG)의 deposit save 완료 + // → 1~3회까지 모든 보상을 수령함 + // → 201,600 블록 도달 이후 + // → 지정된 캐릭터 앞으로 이하 보상의 수령이 가능해야 한다. + // (보상내용: 1 hourglass / 50 NCG, 1 ap portion / 800 NCG 소수점 버림, 기존 deposit 유지 확인) + // → 기존 deposit보다 낮은 금액으로 edit save 한다. + // → 보상 타이머 리셋 확인 + // → (기존 deposit - 현재 deposit 만큼의 ncg 인출 상태 확인) + // → 현재 스테이킹된 NCG를 인출할 수 없다 (스테이킹 추가는 가능) + // → (현재 deposit 묶인 상태 확인) + yield return new object[] + { + 51L, + 51L, + 50L, + BuildEvents(10, 800, 1, 50), + }; + yield return new object[] + { + 499L, + 499L, + 50L, + BuildEvents(10, 800, 1, 499), + }; + + // 현재의 스테이킹된 NCG의 전액 인출을 시도한다(deposit NCG 인출 상태 확인) + // → 스테이킹 완전 소멸 확인 + yield return new object[] + { + 50L, + 50L, + 0L, + BuildEvents(10, 800, 1, 50), + }; + yield return new object[] + { + 499L, + 499L, + 0L, + BuildEvents(10, 800, 1, 499), + }; + + // 2단계 수준(500~4,999NCG)의 deposit save 완료 + // → 1~3회까지 모든 보상을 수령함 + // → 201,600 블록 도달 이후 + // → 지정된 캐릭터 앞으로 이하 보상의 수령이 가능해야 한다. + // (보상내용: 1 hourglass / 8 NCG, 1 ap portion / 800 NCG 소수점 버림, 기존 deposit 유지 확인) + // → 기존 deposit보다 낮은 금액으로 edit save 한다. + // → 보상 타이머 리셋 확인 + // → (기존 deposit - 현재 deposit 만큼의 ncg 인출 상태 확인) + // → 현재 스테이킹된 NCG를 인출할 수 없다 (스테이킹 추가는 가능) + // → (현재 deposit 묶인 상태 확인) + yield return new object[] + { + 500L, + 500L, + 499L, + BuildEvents(8, 800, 2, 500), + }; + yield return new object[] + { + 4_999L, + 4_999L, + 500L, + BuildEvents(8, 800, 2, 4_999), + }; + + // 현재의 스테이킹된 NCG의 전액 인출을 시도한다(deposit NCG 인출 상태 확인) + // → 스테이킹 완전 소멸 확인 + yield return new object[] + { + 500L, + 500L, + 0L, + BuildEvents(8, 800, 2, 500), + }; + yield return new object[] + { + 4_999L, + 4_999L, + 0L, + BuildEvents(8, 800, 2, 4_999), + }; + + // 3단계 수준(5,000~49,999NCG)의 deposit save 완료 + // → 1~3회까지 모든 보상을 수령함 + // → 201,600 블록 도달 이후 + // → 지정된 캐릭터 앞으로 이하 보상의 수령이 가능해야 한다. + // (보상내용: 1 hourglass / 5 NCG, 1 ap portion / 800 NCG 소수점 버림, 기존 deposit 유지 확인) + // → 기존 deposit보다 낮은 금액으로 edit save 한다. + // → 보상 타이머 리셋 확인 + // → (기존 deposit - 현재 deposit 만큼의 ncg 인출 상태 확인) + // → 현재 스테이킹된 NCG를 인출할 수 없다 (스테이킹 추가는 가능) + // → (현재 deposit 묶인 상태 확인) + yield return new object[] + { + 5_000L, + 5_000L, + 4_999L, + BuildEvents(5, 800, 2, 5_000), + }; + yield return new object[] + { + 49_999L, + 49_999L, + 5_000L, + BuildEvents(5, 800, 2, 49_999), + }; + + // 현재의 스테이킹된 NCG의 전액 인출을 시도한다(deposit NCG 인출 상태 확인) + // → 스테이킹 완전 소멸 확인 + yield return new object[] + { + 5_000L, + 5_000L, + 0L, + BuildEvents(5, 800, 2, 5_000), + }; + yield return new object[] + { + 49_999L, + 49_999L, + 0L, + BuildEvents(5, 800, 2, 49_999), + }; + + // 4단계 수준(50,000~499,999NCG)의 deposit save 완료 + // → 1~3회까지 모든 보상을 수령함 + // → 201,600 블록 도달 이후 + // → 지정된 캐릭터 앞으로 이하 보상의 수령이 가능해야 한다. + // (보상내용: 1 hourglass / 5 NCG, 1 ap portion / 800 NCG 소수점 버림, 기존 deposit 유지 확인) + // → 기존 deposit보다 낮은 금액으로 edit save 한다. + // → 보상 타이머 리셋 확인 + // → (기존 deposit - 현재 deposit 만큼의 ncg 인출 상태 확인) + // → 현재 스테이킹된 NCG를 인출할 수 없다 (스테이킹 추가는 가능) + // → (현재 deposit 묶인 상태 확인) + yield return new object[] + { + 50_000L, + 50_000L, + 49_999L, + BuildEvents(5, 800, 2, 50_000), + }; + yield return new object[] + { + 499_999L, + 499_999L, + 50_000L, + BuildEvents(5, 800, 2, 499_999), + }; + + // 현재의 스테이킹된 NCG의 전액 인출을 시도한다(deposit NCG 인출 상태 확인) + // → 스테이킹 완전 소멸 확인 + yield return new object[] + { + 50_000L, + 50_000L, + 0L, + BuildEvents(5, 800, 2, 50_000), + }; + yield return new object[] + { + 499_999L, + 499_999L, + 0L, + BuildEvents(5, 800, 2, 499_999), + }; + + // 5단계 수준(500,000~100,000,000NCG)의 deposit save 완료 + // → 1~3회까지 모든 보상을 수령함 + // → 201,600 블록 도달 이후 + // → 지정된 캐릭터 앞으로 이하 보상의 수령이 가능해야 한다. + // (보상내용: 1 hourglass / 5 NCG, 1 ap portion / 800 NCG 소수점 버림, 기존 deposit 유지 확인) + // → 기존 deposit보다 낮은 금액으로 edit save 한다. + // → 보상 타이머 리셋 확인 + // → (기존 deposit - 현재 deposit 만큼의 ncg 인출 상태 확인) + // → 현재 스테이킹된 NCG를 인출할 수 없다 (스테이킹 추가는 가능) + // → (현재 deposit 묶인 상태 확인) + yield return new object[] + { + 500_000L, + 500_000L, + 499_999L, + BuildEvents(5, 800, 2, 500_000), + }; + yield return new object[] + { + 500_000_000L, + 500_000_000L, + 500_000L, + BuildEvents(5, 800, 2, 500_000_000), + }; + + // 현재의 스테이킹된 NCG의 전액 인출을 시도한다(deposit NCG 인출 상태 확인) + // → 스테이킹 완전 소멸 확인 + yield return new object[] + { + 500_000L, + 500_000L, + 0L, + BuildEvents(5, 800, 2, 500_000), + }; + yield return new object[] + { + 500_000_000L, + 500_000_000L, + 0L, + BuildEvents(5, 800, 2, 500_000_000), + }; + } + + [Theory] + [MemberData(nameof(StakeAndClaimStakeRewardTestCases))] + public void StakeAndClaimStakeReward( + long stakeAmount, + (int ItemId, int Amount)[] expectedItems, + long receiveBlockIndex, + int expectedRune, + string expectedCurrencyAddrHex, + string expectedCurrencyTicker, + long expectedCurrencyAmount) + { + var context = new ActionContext(); + var states = _initialStatesWithAvatarStateV2.MintAsset(context, _agentAddr, _ncg * stakeAmount); + + IAction action = new Stake2(stakeAmount); + states = action.Execute(new ActionContext + { + PreviousState = states, + Signer = _agentAddr, + BlockIndex = ClaimStakeReward2.ObsoletedIndex, + }); + + Assert.True(states.TryGetStakeState(_agentAddr, out var stakeState)); + Assert.NotNull(stakeState); + Assert.Equal( + 0 * RuneHelper.StakeRune, + _initialStatesWithAvatarStateV2.GetBalance(_avatarAddr, RuneHelper.StakeRune)); + + action = new ClaimStakeReward8(_avatarAddr); + states = action.Execute(new ActionContext + { + PreviousState = states, + Signer = _agentAddr, + BlockIndex = receiveBlockIndex, + }); + + // 지정된 캐릭터 앞으로 이하 보상의 수령이 가능해야 한다. + var avatarState = states.GetAvatarStateV2(_avatarAddr); + foreach (var (itemId, amount) in expectedItems) + { + Assert.True(avatarState.inventory.HasItem(itemId, amount)); + } + + // 기존 deposit 유지 확인 + Assert.Equal( + _ncg * stakeAmount, + states.GetBalance(stakeState.address, _ncg)); + Assert.Equal( + expectedRune * RuneHelper.StakeRune, + states.GetBalance(_avatarAddr, RuneHelper.StakeRune)); + + if (!string.IsNullOrEmpty(expectedCurrencyAddrHex)) + { + var addr = new Address(expectedCurrencyAddrHex); + var currency = Currencies.GetMinterlessCurrency(expectedCurrencyTicker); + Assert.Equal( + expectedCurrencyAmount * currency, + states.GetBalance(addr, currency)); + } + } + + [Theory] + [InlineData(500L, 50L, 499L)] + [InlineData(500L, 499L, 500L)] + [InlineData(5_000L, 500L, 4_999L)] + [InlineData(5_000L, 4_999L, 5_000L)] + [InlineData(50_000L, 5_000L, 49_999L)] + [InlineData(50_000L, 49_999L, 50_000L)] + [InlineData(500_000L, 50_000L, 499_999L)] + [InlineData(500_000L, 499_999L, 500_000L)] + [InlineData(500_000_000L, 500_000L, 500_000_000L)] + public void StakeAndStakeMore(long initialBalance, long stakeAmount, long newStakeAmount) + { + var newStakeBlockIndex = + ClaimStakeReward2.ObsoletedIndex + StakeState.LockupInterval - 1; + // Validate testcases + Assert.True(stakeAmount < newStakeAmount); + + var context = new ActionContext(); + var states = + _initialStatesWithAvatarStateV2.MintAsset(context, _agentAddr, _ncg * initialBalance); + + IAction action = new Stake2(stakeAmount); + states = action.Execute(new ActionContext + { + PreviousState = states, + Signer = _agentAddr, + BlockIndex = ClaimStakeReward2.ObsoletedIndex, + }); + + Assert.True(states.TryGetStakeState(_agentAddr, out var stakeState)); + Assert.NotNull(stakeState); + Assert.Equal( + _ncg * (initialBalance - stakeAmount), + states.GetBalance(_agentAddr, _ncg)); + Assert.Equal( + _ncg * stakeAmount, + states.GetBalance(stakeState.address, _ncg)); + + action = new ClaimStakeReward8(_avatarAddr); + states = action.Execute(new ActionContext + { + PreviousState = states, + Signer = _agentAddr, + BlockIndex = newStakeBlockIndex, + }); + + action = new Stake2(newStakeAmount); + // 스테이킹 추가는 가능 + // 락업기간 이전에 deposit을 추가해서 save 할 수 있는지 + states = action.Execute(new ActionContext + { + PreviousState = states, + Signer = _agentAddr, + BlockIndex = newStakeBlockIndex, + }); + + Assert.True(states.TryGetStakeState(_agentAddr, out stakeState)); + Assert.NotNull(stakeState); + // 쌓여있던 보상 타이머가 정상적으로 리셋되는지 + Assert.Equal(newStakeBlockIndex, stakeState.StartedBlockIndex); + // 락업기간이 새롭게 201,600으로 갱신되는지 확인 + Assert.Equal( + newStakeBlockIndex + StakeState.LockupInterval, + stakeState.CancellableBlockIndex); + Assert.Equal( + _ncg * (initialBalance - newStakeAmount), + states.GetBalance(_agentAddr, _ncg)); + // 기존보다 초과해서 설정한 deposit 으로 묶인 상태 갱신된 것 확인 + Assert.Equal( + _ncg * newStakeAmount, + states.GetBalance(stakeState.address, _ncg)); + } + + [Theory] + [InlineData(500L, 51L, 50L)] + [InlineData(500L, 499L, 50L)] + [InlineData(5_000L, 500L, 499L)] + [InlineData(5_000L, 500L, 50L)] + [InlineData(5_000L, 4_999L, 500L)] + [InlineData(50_000L, 5_000L, 4_999L)] + [InlineData(50_000L, 49_999L, 5_000L)] + [InlineData(500_000L, 50_000L, 49_999L)] + [InlineData(500_000L, 499_999L, 50_000L)] + [InlineData(500_000_000L, 500_000L, 99_999L)] + [InlineData(500_000_000L, 500_000_000L, 0L)] + [InlineData(500_000_000L, 500_000_000L, 99_999_999L)] + public void StakeAndStakeLess(long initialBalance, long stakeAmount, long newStakeAmount) + { + // Validate testcases + Assert.True(initialBalance >= stakeAmount); + Assert.True(newStakeAmount < stakeAmount); + + var context = new ActionContext(); + var states = + _initialStatesWithAvatarStateV2.MintAsset(context, _agentAddr, _ncg * initialBalance); + + IAction action = new Stake2(stakeAmount); + states = action.Execute(new ActionContext + { + PreviousState = states, + Signer = _agentAddr, + BlockIndex = ClaimStakeReward2.ObsoletedIndex, + }); + + Assert.True(states.TryGetStakeState(_agentAddr, out var stakeState)); + Assert.NotNull(stakeState); + Assert.Equal( + _ncg * (initialBalance - stakeAmount), + states.GetBalance(_agentAddr, _ncg)); + Assert.Equal( + _ncg * stakeAmount, + states.GetBalance(stakeState.address, _ncg)); + + action = new ClaimStakeReward8(_avatarAddr); + states = action.Execute(new ActionContext + { + PreviousState = states, + Signer = _agentAddr, + BlockIndex = ClaimStakeReward2.ObsoletedIndex + StakeState.LockupInterval - 1, + }); + + action = new Stake2(newStakeAmount); + // 락업기간 이전에 deposit을 감소해서 save할때 락업되어 거부되는가 + Assert.Throws(() => states = action.Execute( + new ActionContext + { + PreviousState = states, + Signer = _agentAddr, + BlockIndex = ClaimStakeReward2.ObsoletedIndex + StakeState.LockupInterval - 1, + })); + + Assert.True(states.TryGetStakeState(_agentAddr, out stakeState)); + Assert.NotNull(stakeState); + Assert.Equal( + _ncg * (initialBalance - stakeAmount), + states.GetBalance(_agentAddr, _ncg)); + Assert.Equal( + _ncg * stakeAmount, + states.GetBalance(stakeState.address, _ncg)); + } + + [Theory] + [MemberData(nameof(StakeLessAfterLockupTestcases))] + // 락업기간 종료 이후 deposit을 현재보다 낮게 설정했을때, 설정이 잘되서 새롭게 락업되는지 확인 + // 락업기간 종료 이후 보상 수령하고 락업해제되는지 확인 + public void StakeLessAfterLockup( + long initialBalance, + long stakeAmount, + long newStakeAmount, + (long ClaimBlockIndex, (int ItemId, int Amount)[] ExpectedItems)[] claimEvents) + { + StakeState stakeState; + + // Validate testcases + Assert.True(stakeAmount > newStakeAmount); + + var context = new ActionContext(); + var states = + _initialStatesWithAvatarStateV2.MintAsset(context, _agentAddr, _ncg * initialBalance); + + IAction action = new Stake2(stakeAmount); + states = action.Execute(new ActionContext + { + PreviousState = states, + Signer = _agentAddr, + BlockIndex = ClaimStakeReward2.ObsoletedIndex, + }); + + // 1~3회까지 모든 보상을 수령함 + // 201,600 블록 도달 이후 → 지정된 캐릭터 앞으로 이하 보상의 수령이 가능해야 한다. + foreach ((var claimBlockIndex, (int itemId, int amount)[] expectedItems) in claimEvents) + { + action = new ClaimStakeReward8(_avatarAddr); + states = action.Execute(new ActionContext + { + PreviousState = states, + Signer = _agentAddr, + BlockIndex = claimBlockIndex, + }); + + var avatarState = states.GetAvatarStateV2(_avatarAddr); + foreach (var (itemId, amount) in expectedItems) + { + Assert.True(avatarState.inventory.HasItem(itemId, amount)); + } + + Assert.True(states.TryGetStakeState(_agentAddr, out stakeState)); + Assert.NotNull(stakeState); + // deposit 유지 확인 + Assert.Equal( + _ncg * stakeAmount, + states.GetBalance(stakeState.address, _ncg)); + } + + action = new Stake2(newStakeAmount); + states = action.Execute(new ActionContext + { + PreviousState = states, + Signer = _agentAddr, + BlockIndex = ClaimStakeReward2.ObsoletedIndex + StakeState.LockupInterval, + }); + + // Setup staking again. + if (newStakeAmount > 0) + { + Assert.True(states.TryGetStakeState(_agentAddr, out stakeState)); + Assert.NotNull(stakeState); + // 쌓여있던 보상 타이머가 정상적으로 리셋되는지 + Assert.Equal( + ClaimStakeReward2.ObsoletedIndex + StakeState.LockupInterval, + stakeState.StartedBlockIndex); + // 락업기간이 새롭게 201,600으로 갱신되는지 확인 + Assert.Equal( + ClaimStakeReward2.ObsoletedIndex + StakeState.LockupInterval + + StakeState.LockupInterval, + stakeState.CancellableBlockIndex); + // 기존 deposit - 현재 deposit 만큼의 ncg 인출 상태 확인 + Assert.Equal( + _ncg * (initialBalance - newStakeAmount), + states.GetBalance(_agentAddr, _ncg)); + Assert.Equal( + _ncg * newStakeAmount, + states.GetBalance(stakeState.address, _ncg)); + + Assert.Throws(() => + { + // 현재 스테이킹된 NCG를 인출할 수 없다 + action = new ClaimStakeReward8(_avatarAddr); + states = action.Execute(new ActionContext + { + PreviousState = states, + Signer = _agentAddr, + BlockIndex = ClaimStakeReward2.ObsoletedIndex + StakeState.LockupInterval + + 1, + }); + }); + // 현재 deposit 묶인 상태 확인 + Assert.Equal( + _ncg * newStakeAmount, + states.GetBalance(stakeState.address, _ncg)); + } + else + { + Assert.Equal( + _ncg * initialBalance, + states.GetBalance(_agentAddr, _ncg)); + Assert.False(states.TryGetStakeState(_agentAddr, out stakeState)); + Assert.Null(stakeState); + } + } + + [Fact] + public void StakeAndClaimStakeRewardBeforeRewardInterval() + { + var context = new ActionContext(); + var states = _initialStatesWithAvatarStateV2.MintAsset(context, _agentAddr, _ncg * 500); + IAction action = new Stake2(500); + states = action.Execute(new ActionContext + { + PreviousState = states, + Signer = _agentAddr, + BlockIndex = ClaimStakeReward2.ObsoletedIndex, + }); + + action = new ClaimStakeReward8(_avatarAddr); + Assert.Throws(() => states = action.Execute( + new ActionContext + { + PreviousState = states, + Signer = _agentAddr, + BlockIndex = ClaimStakeReward2.ObsoletedIndex + StakeState.RewardInterval - 1, + })); + + var avatarState = states.GetAvatarStateV2(_avatarAddr); + Assert.Empty(avatarState.inventory.Items.Where(x => x.item.Id == 400000)); + Assert.Empty(avatarState.inventory.Items.Where(x => x.item.Id == 500000)); + } + } +} diff --git a/.Lib9c.Tests/Action/TransferAsset2Test.cs b/.Lib9c.Tests/Action/TransferAsset2Test.cs new file mode 100644 index 0000000000..8bf8a7c724 --- /dev/null +++ b/.Lib9c.Tests/Action/TransferAsset2Test.cs @@ -0,0 +1,348 @@ +namespace Lib9c.Tests.Action +{ + using System.Collections.Generic; + using System.Collections.Immutable; + using System.IO; + using System.Linq; + using System.Runtime.Serialization.Formatters.Binary; + using Bencodex.Types; + using Libplanet.Action.State; + using Libplanet.Crypto; + using Libplanet.Types.Assets; + using Nekoyume; + using Nekoyume.Action; + using Nekoyume.Model; + using Nekoyume.Model.State; + using Xunit; + + public class TransferAsset2Test + { + private static readonly Address _sender = new Address( + new byte[] + { + 0x01, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, + } + ); + + private static readonly Address _recipient = new Address(new byte[] + { + 0x02, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, + } + ); + +#pragma warning disable CS0618 + // Use of obsolete method Currency.Legacy(): https://github.com/planetarium/lib9c/discussions/1319 + private static readonly Currency _currency = Currency.Legacy("NCG", 2, null); +#pragma warning restore CS0618 + + [Fact] + public void Constructor_ThrowsMemoLengthOverflowException() + { + Assert.Throws(() => + new TransferAsset2(_sender, _recipient, _currency * 100, new string(' ', 100))); + } + + [Fact] + public void Execute() + { + var prevState = new Account( + MockState.Empty + .SetState(_recipient.Derive(ActivationKey.DeriveKey), true.Serialize()) + .SetBalance(_sender, _currency * 1000) + .SetBalance(_recipient, _currency * 10)); + var action = new TransferAsset2( + sender: _sender, + recipient: _recipient, + amount: _currency * 100 + ); + IAccount nextState = action.Execute(new ActionContext() + { + PreviousState = prevState, + Signer = _sender, + BlockIndex = 1, + }); + + Assert.Equal(_currency * 900, nextState.GetBalance(_sender, _currency)); + Assert.Equal(_currency * 110, nextState.GetBalance(_recipient, _currency)); + } + + [Fact] + public void ExecuteWithInvalidSigner() + { + var prevState = new Account( + MockState.Empty + .SetBalance(_sender, _currency * 1000) + .SetBalance(_recipient, _currency * 10)); + var action = new TransferAsset2( + sender: _sender, + recipient: _recipient, + amount: _currency * 100 + ); + + var exc = Assert.Throws(() => + { + _ = action.Execute(new ActionContext() + { + PreviousState = prevState, + // 송금자가 직접 사인하지 않으면 실패해야 합니다. + Signer = _recipient, + BlockIndex = 1, + }); + }); + + Assert.Equal(exc.Sender, _sender); + Assert.Equal(exc.Recipient, _recipient); + Assert.Equal(exc.TxSigner, _recipient); + } + + [Fact] + public void ExecuteWithInvalidRecipient() + { + var prevState = new Account( + MockState.Empty + .SetBalance(_sender, _currency * 1000)); + // Should not allow TransferAsset2 with same sender and recipient. + var action = new TransferAsset2( + sender: _sender, + recipient: _sender, + amount: _currency * 100 + ); + + // No exception should be thrown when its index is less then 380000. + _ = action.Execute(new ActionContext() + { + PreviousState = prevState, + Signer = _sender, + BlockIndex = 1, + }); + + var exc = Assert.Throws(() => + { + _ = action.Execute(new ActionContext() + { + PreviousState = prevState, + Signer = _sender, + BlockIndex = 380001, + }); + }); + + Assert.Equal(exc.Sender, _sender); + Assert.Equal(exc.Recipient, _sender); + } + + [Fact] + public void ExecuteWithInsufficientBalance() + { + var prevState = new Account( + MockState.Empty + .SetBalance(_sender, _currency * 1000) + .SetBalance(_recipient, _currency * 10)); + var action = new TransferAsset2( + sender: _sender, + recipient: _recipient, + amount: _currency * 100000 + ); + + Assert.Throws(() => + { + action.Execute(new ActionContext() + { + PreviousState = prevState, + Signer = _sender, + BlockIndex = 1, + }); + }); + } + + [Fact] + public void ExecuteWithMinterAsSender() + { +#pragma warning disable CS0618 + // Use of obsolete method Currency.Legacy(): https://github.com/planetarium/lib9c/discussions/1319 + var currencyBySender = Currency.Legacy("NCG", 2, _sender); +#pragma warning restore CS0618 + var prevState = new Account( + MockState.Empty + .SetBalance(_sender, currencyBySender * 1000) + .SetBalance(_recipient, currencyBySender * 10)); + var action = new TransferAsset2( + sender: _sender, + recipient: _recipient, + amount: currencyBySender * 100 + ); + var ex = Assert.Throws(() => + { + action.Execute(new ActionContext() + { + PreviousState = prevState, + Signer = _sender, + BlockIndex = 1, + }); + }); + + Assert.Equal(new[] { _sender }, ex.Minters); + Assert.Equal(_sender, ex.Sender); + Assert.Equal(_recipient, ex.Recipient); + } + + [Fact] + public void ExecuteWithMinterAsRecipient() + { +#pragma warning disable CS0618 + // Use of obsolete method Currency.Legacy(): https://github.com/planetarium/lib9c/discussions/1319 + var currencyByRecipient = Currency.Legacy("NCG", 2, _sender); +#pragma warning restore CS0618 + var prevState = new Account( + MockState.Empty + .SetBalance(_sender, currencyByRecipient * 1000) + .SetBalance(_sender, currencyByRecipient * 10)); + var action = new TransferAsset2( + sender: _sender, + recipient: _recipient, + amount: currencyByRecipient * 100 + ); + var ex = Assert.Throws(() => + { + action.Execute(new ActionContext() + { + PreviousState = prevState, + Signer = _sender, + BlockIndex = 1, + }); + }); + + Assert.Equal(new[] { _sender }, ex.Minters); + Assert.Equal(_sender, ex.Sender); + Assert.Equal(_recipient, ex.Recipient); + } + + [Fact] + public void ExecuteWithUnactivatedRecipient() + { + var activatedAddress = new ActivatedAccountsState().AddAccount(new PrivateKey().Address); + var prevState = new Account( + MockState.Empty + .SetState(_sender.Derive(ActivationKey.DeriveKey), true.Serialize()) + .SetState(Addresses.ActivatedAccount, activatedAddress.Serialize()) + .SetBalance(_sender, _currency * 1000) + .SetBalance(_recipient, _currency * 10)); + var action = new TransferAsset2( + sender: _sender, + recipient: _recipient, + amount: _currency * 100 + ); + var ex = Assert.Throws(() => + { + action.Execute(new ActionContext() + { + PreviousState = prevState, + Signer = _sender, + BlockIndex = 1, + }); + }); + Assert.Equal(_sender, ex.Sender); + Assert.Equal(_recipient, ex.Recipient); + } + + [Theory] + [InlineData(null)] + [InlineData("Nine Chronicles")] + public void PlainValue(string memo) + { + var action = new TransferAsset2(_sender, _recipient, _currency * 100, memo); + Dictionary plainValue = (Dictionary)action.PlainValue; + Dictionary values = (Dictionary)plainValue["values"]; + + Assert.Equal((Text)"transfer_asset2", plainValue["type_id"]); + Assert.Equal(_sender, values["sender"].ToAddress()); + Assert.Equal(_recipient, values["recipient"].ToAddress()); + Assert.Equal(_currency * 100, values["amount"].ToFungibleAssetValue()); + if (!(memo is null)) + { + Assert.Equal(memo, values["memo"].ToDotnetString()); + } + } + + [Theory] + [InlineData(null)] + [InlineData("Nine Chronicles")] + public void LoadPlainValue(string memo) + { + IEnumerable> pairs = new[] + { + new KeyValuePair((Text)"sender", _sender.Serialize()), + new KeyValuePair((Text)"recipient", _recipient.Serialize()), + new KeyValuePair((Text)"amount", (_currency * 100).Serialize()), + }; + if (!(memo is null)) + { + pairs = pairs.Append(new KeyValuePair((Text)"memo", memo.Serialize())); + } + + var plainValue = Dictionary.Empty + .Add("type_id", "transfer_asset2") + .Add("values", new Dictionary(pairs)); + var action = new TransferAsset2(); + action.LoadPlainValue(plainValue); + + Assert.Equal(_sender, action.Sender); + Assert.Equal(_recipient, action.Recipient); + Assert.Equal(_currency * 100, action.Amount); + Assert.Equal(memo, action.Memo); + } + + [Fact] + public void LoadPlainValue_ThrowsMemoLengthOverflowException() + { + var action = new TransferAsset2(); + var plainValue = Dictionary.Empty + .Add("type_id", "transfer_asset2") + .Add("values", new Dictionary(new[] + { + new KeyValuePair((Text)"sender", _sender.Serialize()), + new KeyValuePair((Text)"recipient", _recipient.Serialize()), + new KeyValuePair((Text)"amount", (_currency * 100).Serialize()), + new KeyValuePair((Text)"memo", new string(' ', 81).Serialize()), + })); + + Assert.Throws(() => action.LoadPlainValue(plainValue)); + } + + [Theory] + [InlineData(null)] + [InlineData("Nine Chronicles")] + public void SerializeWithDotnetAPI(string memo) + { + var formatter = new BinaryFormatter(); + var action = new TransferAsset2(_sender, _recipient, _currency * 100, memo); + + using var ms = new MemoryStream(); + formatter.Serialize(ms, action); + + ms.Seek(0, SeekOrigin.Begin); + var deserialized = (TransferAsset2)formatter.Deserialize(ms); + + Assert.Equal(_sender, deserialized.Sender); + Assert.Equal(_recipient, deserialized.Recipient); + Assert.Equal(_currency * 100, deserialized.Amount); + Assert.Equal(memo, deserialized.Memo); + } + + [Fact] + public void CheckObsolete() + { + var action = new TransferAsset2(_sender, _recipient, _currency * 1); + Assert.Throws(() => + { + action.Execute(new ActionContext() + { + PreviousState = new Account(MockState.Empty), + Signer = _sender, + BlockIndex = TransferAsset3.CrystalTransferringRestrictionStartIndex, + }); + }); + } + } +} diff --git a/.Lib9c.Tests/Action/TransferAsset3Test.cs b/.Lib9c.Tests/Action/TransferAsset3Test.cs new file mode 100644 index 0000000000..cb74a2f454 --- /dev/null +++ b/.Lib9c.Tests/Action/TransferAsset3Test.cs @@ -0,0 +1,379 @@ +namespace Lib9c.Tests.Action +{ + using System.Collections.Generic; + using System.Collections.Immutable; + using System.IO; + using System.Linq; + using System.Numerics; + using System.Runtime.Serialization.Formatters.Binary; + using Bencodex.Types; + using Libplanet.Action; + using Libplanet.Action.State; + using Libplanet.Crypto; + using Libplanet.Types.Assets; + using Nekoyume; + using Nekoyume.Action; + using Nekoyume.Helper; + using Nekoyume.Model; + using Nekoyume.Model.State; + using Xunit; + + public class TransferAsset3Test + { + private static readonly Address _sender = new Address( + new byte[] + { + 0x01, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, + } + ); + + private static readonly Address _recipient = new Address(new byte[] + { + 0x02, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, + } + ); + +#pragma warning disable CS0618 + // Use of obsolete method Currency.Legacy(): https://github.com/planetarium/lib9c/discussions/1319 + private static readonly Currency _currency = Currency.Legacy("NCG", 2, null); +#pragma warning restore CS0618 + + [Fact] + public void Constructor_ThrowsMemoLengthOverflowException() + { + Assert.Throws(() => + new TransferAsset3(_sender, _recipient, _currency * 100, new string(' ', 100))); + } + + [Theory] + // activation by derive address. + [InlineData(true, false, false)] + // activation by ActivatedAccountsState. + [InlineData(false, true, false)] + // state exist. + [InlineData(false, false, true)] + public void Execute(bool activate, bool legacyActivate, bool stateExist) + { + var mockState = MockState.Empty + .SetBalance(_sender, _currency * 1000) + .SetBalance(_recipient, _currency * 10); + + if (activate) + { + mockState = mockState.SetState(_recipient.Derive(ActivationKey.DeriveKey), true.Serialize()); + } + + if (legacyActivate) + { + var activatedAccountState = new ActivatedAccountsState(); + activatedAccountState = activatedAccountState.AddAccount(_recipient); + mockState = mockState.SetState(activatedAccountState.address, activatedAccountState.Serialize()); + } + + if (stateExist) + { + mockState = mockState.SetState(_recipient, new AgentState(_recipient).Serialize()); + } + + var prevState = new Account(mockState); + var action = new TransferAsset3( + sender: _sender, + recipient: _recipient, + amount: _currency * 100 + ); + IAccount nextState = action.Execute(new ActionContext() + { + PreviousState = prevState, + Signer = _sender, + BlockIndex = 1, + }); + + Assert.Equal(_currency * 900, nextState.GetBalance(_sender, _currency)); + Assert.Equal(_currency * 110, nextState.GetBalance(_recipient, _currency)); + } + + [Fact] + public void ExecuteWithInvalidSigner() + { + var prevState = new Account( + MockState.Empty + .SetBalance(_sender, _currency * 1000) + .SetBalance(_recipient, _currency * 10)); + var action = new TransferAsset3( + sender: _sender, + recipient: _recipient, + amount: _currency * 100 + ); + + var exc = Assert.Throws(() => + { + _ = action.Execute(new ActionContext() + { + PreviousState = prevState, + // 송금자가 직접 사인하지 않으면 실패해야 합니다. + Signer = _recipient, + BlockIndex = 1, + }); + }); + + Assert.Equal(exc.Sender, _sender); + Assert.Equal(exc.Recipient, _recipient); + Assert.Equal(exc.TxSigner, _recipient); + } + + [Fact] + public void ExecuteWithInvalidRecipient() + { + var balance = ImmutableDictionary<(Address, Currency), FungibleAssetValue>.Empty + .Add((_sender, _currency), _currency * 1000); + var prevState = new Account( + MockState.Empty + .SetBalance(_sender, _currency * 1000)); + + // Should not allow TransferAsset with same sender and recipient. + var action = new TransferAsset3( + sender: _sender, + recipient: _sender, + amount: _currency * 100 + ); + + var exc = Assert.Throws(() => + { + _ = action.Execute(new ActionContext() + { + PreviousState = prevState, + Signer = _sender, + BlockIndex = 1, + }); + }); + + Assert.Equal(exc.Sender, _sender); + Assert.Equal(exc.Recipient, _sender); + } + + [Fact] + public void ExecuteWithInsufficientBalance() + { + var prevState = new Account( + MockState.Empty + .SetBalance(_sender, _currency * 1000) + .SetBalance(_recipient, _currency * 10)) + .SetState(_recipient, new AgentState(_recipient).Serialize()); + var action = new TransferAsset3( + sender: _sender, + recipient: _recipient, + amount: _currency * 100000 + ); + + Assert.Throws(() => + { + action.Execute(new ActionContext() + { + PreviousState = prevState, + Signer = _sender, + BlockIndex = 1, + }); + }); + } + + [Fact] + public void ExecuteWithMinterAsSender() + { +#pragma warning disable CS0618 + // Use of obsolete method Currency.Legacy(): https://github.com/planetarium/lib9c/discussions/1319 + var currencyBySender = Currency.Legacy("NCG", 2, _sender); +#pragma warning restore CS0618 + var prevState = new Account( + MockState.Empty + .SetState(_recipient, new AgentState(_recipient).Serialize()) + .SetBalance(_sender, currencyBySender * 1000) + .SetBalance(_recipient, currencyBySender * 10)); + var action = new TransferAsset3( + sender: _sender, + recipient: _recipient, + amount: currencyBySender * 100 + ); + var ex = Assert.Throws(() => + { + action.Execute(new ActionContext() + { + PreviousState = prevState, + Signer = _sender, + BlockIndex = 1, + }); + }); + + Assert.Equal(new[] { _sender }, ex.Minters); + Assert.Equal(_sender, ex.Sender); + Assert.Equal(_recipient, ex.Recipient); + } + + [Fact] + public void ExecuteWithMinterAsRecipient() + { +#pragma warning disable CS0618 + // Use of obsolete method Currency.Legacy(): https://github.com/planetarium/lib9c/discussions/1319 + var currencyByRecipient = Currency.Legacy("NCG", 2, _sender); +#pragma warning restore CS0618 + var prevState = new Account( + MockState.Empty + .SetBalance(_sender, currencyByRecipient * 1000) + .SetBalance(_recipient, currencyByRecipient * 10) + .SetState(_recipient, new AgentState(_recipient).Serialize())); + var action = new TransferAsset3( + sender: _sender, + recipient: _recipient, + amount: currencyByRecipient * 100 + ); + var ex = Assert.Throws(() => + { + action.Execute(new ActionContext() + { + PreviousState = prevState, + Signer = _sender, + BlockIndex = 1, + }); + }); + + Assert.Equal(new[] { _sender }, ex.Minters); + Assert.Equal(_sender, ex.Sender); + Assert.Equal(_recipient, ex.Recipient); + } + + [Fact] + public void ExecuteWithUnactivatedRecipient() + { + var activatedAddress = new ActivatedAccountsState().AddAccount(new PrivateKey().Address); + var prevState = new Account( + MockState.Empty + .SetState(_sender.Derive(ActivationKey.DeriveKey), true.Serialize()) + .SetState(Addresses.ActivatedAccount, activatedAddress.Serialize()) + .SetBalance(_sender, _currency * 1000) + .SetBalance(_recipient, _currency * 10)); + var action = new TransferAsset3( + sender: _sender, + recipient: _recipient, + amount: _currency * 100 + ); + var ex = Assert.Throws(() => + { + action.Execute(new ActionContext() + { + PreviousState = prevState, + Signer = _sender, + BlockIndex = 1, + }); + }); + Assert.Equal(_sender, ex.Sender); + Assert.Equal(_recipient, ex.Recipient); + } + + [Theory] + [InlineData(null)] + [InlineData("Nine Chronicles")] + public void PlainValue(string memo) + { + var action = new TransferAsset3(_sender, _recipient, _currency * 100, memo); + Dictionary plainValue = (Dictionary)action.PlainValue; + Dictionary values = (Dictionary)plainValue["values"]; + + Assert.Equal("transfer_asset3", (Text)plainValue["type_id"]); + Assert.Equal(_sender, values["sender"].ToAddress()); + Assert.Equal(_recipient, values["recipient"].ToAddress()); + Assert.Equal(_currency * 100, values["amount"].ToFungibleAssetValue()); + if (!(memo is null)) + { + Assert.Equal(memo, values["memo"].ToDotnetString()); + } + } + + [Theory] + [InlineData(null)] + [InlineData("Nine Chronicles")] + public void LoadPlainValue(string memo) + { + IEnumerable> pairs = new[] + { + new KeyValuePair((Text)"sender", _sender.Serialize()), + new KeyValuePair((Text)"recipient", _recipient.Serialize()), + new KeyValuePair((Text)"amount", (_currency * 100).Serialize()), + }; + if (!(memo is null)) + { + pairs = pairs.Append(new KeyValuePair((Text)"memo", memo.Serialize())); + } + + var plainValue = Dictionary.Empty + .Add("type_id", "transfer_asset3") + .Add("values", new Dictionary(pairs)); + var action = new TransferAsset3(); + action.LoadPlainValue(plainValue); + + Assert.Equal(_sender, action.Sender); + Assert.Equal(_recipient, action.Recipient); + Assert.Equal(_currency * 100, action.Amount); + Assert.Equal(memo, action.Memo); + } + + [Fact] + public void Execute_Throw_InvalidTransferCurrencyException() + { + var crystal = CrystalCalculator.CRYSTAL; + var prevState = new Account( + MockState.Empty + .SetState(_recipient.Derive(ActivationKey.DeriveKey), true.Serialize()) + .SetBalance(_sender, crystal * 1000)); + var action = new TransferAsset3( + sender: _sender, + recipient: _recipient, + amount: 1000 * crystal + ); + Assert.Throws(() => action.Execute(new ActionContext() + { + PreviousState = prevState, + Signer = _sender, + BlockIndex = TransferAsset3.CrystalTransferringRestrictionStartIndex, + })); + } + + [Fact] + public void LoadPlainValue_ThrowsMemoLengthOverflowException() + { + var action = new TransferAsset3(); + var values = new Dictionary(new[] + { + new KeyValuePair((Text)"sender", _sender.Serialize()), + new KeyValuePair((Text)"recipient", _recipient.Serialize()), + new KeyValuePair((Text)"amount", (_currency * 100).Serialize()), + new KeyValuePair((Text)"memo", new string(' ', 81).Serialize()), + }); + var plainValue = Dictionary.Empty + .Add("type_id", "transfer_asset3") + .Add("values", values); + + Assert.Throws(() => action.LoadPlainValue(plainValue)); + } + + [Theory] + [InlineData(null)] + [InlineData("Nine Chronicles")] + public void SerializeWithDotnetAPI(string memo) + { + var formatter = new BinaryFormatter(); + var action = new TransferAsset3(_sender, _recipient, _currency * 100, memo); + + using var ms = new MemoryStream(); + formatter.Serialize(ms, action); + + ms.Seek(0, SeekOrigin.Begin); + var deserialized = (TransferAsset3)formatter.Deserialize(ms); + + Assert.Equal(_sender, deserialized.Sender); + Assert.Equal(_recipient, deserialized.Recipient); + Assert.Equal(_currency * 100, deserialized.Amount); + Assert.Equal(memo, deserialized.Memo); + } + } +} diff --git a/.Lib9c.Tests/Action/TransferAsset4Test.cs b/.Lib9c.Tests/Action/TransferAsset4Test.cs new file mode 100644 index 0000000000..1877f65a4d --- /dev/null +++ b/.Lib9c.Tests/Action/TransferAsset4Test.cs @@ -0,0 +1,301 @@ +namespace Lib9c.Tests.Action +{ + using System.Collections.Generic; + using System.Collections.Immutable; + using System.IO; + using System.Linq; + using System.Runtime.Serialization.Formatters.Binary; + using Bencodex.Types; + using Libplanet.Action.State; + using Libplanet.Crypto; + using Libplanet.Types.Assets; + using Nekoyume; + using Nekoyume.Action; + using Nekoyume.Helper; + using Nekoyume.Model; + using Nekoyume.Model.State; + using Xunit; + + public class TransferAsset4Test + { + private static readonly Address _sender = new Address( + new byte[] + { + 0x01, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, + } + ); + + private static readonly Address _recipient = new Address(new byte[] + { + 0x02, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, + } + ); + +#pragma warning disable CS0618 + // Use of obsolete method Currency.Legacy(): https://github.com/planetarium/lib9c/discussions/1319 + private static readonly Currency _currency = Currency.Legacy("NCG", 2, null); +#pragma warning restore CS0618 + + [Fact] + public void Constructor_ThrowsMemoLengthOverflowException() + { + Assert.Throws(() => + new TransferAsset4(_sender, _recipient, _currency * 100, new string(' ', 100))); + } + + [Fact] + public void Execute() + { + var contractAddress = _sender.Derive(nameof(RequestPledge)); + var patronAddress = new PrivateKey().Address; + var prevState = new Account( + MockState.Empty + .SetBalance(_sender, _currency * 1000) + .SetBalance(_recipient, _currency * 10)); + var action = new TransferAsset4( + sender: _sender, + recipient: _recipient, + amount: _currency * 100 + ); + IAccount nextState = action.Execute(new ActionContext() + { + PreviousState = prevState, + Signer = _sender, + BlockIndex = 1, + }); + + Assert.Equal(_currency * 900, nextState.GetBalance(_sender, _currency)); + Assert.Equal(_currency * 110, nextState.GetBalance(_recipient, _currency)); + } + + [Fact] + public void Execute_Throw_InvalidTransferSignerException() + { + var prevState = new Account( + MockState.Empty + .SetBalance(_sender, _currency * 1000) + .SetBalance(_recipient, _currency * 10) + .SetBalance(_sender, Currencies.Mead * 1)); + var action = new TransferAsset4( + sender: _sender, + recipient: _recipient, + amount: _currency * 100 + ); + + var exc = Assert.Throws(() => + { + _ = action.Execute(new ActionContext() + { + PreviousState = prevState, + // 송금자가 직접 사인하지 않으면 실패해야 합니다. + Signer = _recipient, + BlockIndex = 1, + }); + }); + + Assert.Equal(exc.Sender, _sender); + Assert.Equal(exc.Recipient, _recipient); + Assert.Equal(exc.TxSigner, _recipient); + } + + [Fact] + public void Execute_Throw_InvalidTransferRecipientException() + { + var prevState = new Account( + MockState.Empty + .SetBalance(_sender, _currency * 1000) + .SetBalance(_sender, Currencies.Mead * 1)); + // Should not allow TransferAsset with same sender and recipient. + var action = new TransferAsset4( + sender: _sender, + recipient: _sender, + amount: _currency * 100 + ); + + var exc = Assert.Throws(() => + { + _ = action.Execute(new ActionContext() + { + PreviousState = prevState, + Signer = _sender, + BlockIndex = 1, + }); + }); + + Assert.Equal(exc.Sender, _sender); + Assert.Equal(exc.Recipient, _sender); + } + + [Fact] + public void Execute_Throw_InsufficientBalanceException() + { + var prevState = new Account( + MockState.Empty + .SetState(_recipient, new AgentState(_recipient).Serialize()) + .SetBalance(_sender, _currency * 1000) + .SetBalance(_recipient, _currency * 10)); + var action = new TransferAsset4( + sender: _sender, + recipient: _recipient, + amount: _currency * 100000 + ); + + InsufficientBalanceException exc = Assert.Throws(() => + { + action.Execute(new ActionContext() + { + PreviousState = prevState, + Signer = _sender, + BlockIndex = 1, + }); + }); + + Assert.Equal(_sender, exc.Address); + Assert.Equal(_currency, exc.Balance.Currency); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void Execute_Throw_InvalidTransferMinterException(bool minterAsSender) + { + Address minter = minterAsSender ? _sender : _recipient; +#pragma warning disable CS0618 + // Use of obsolete method Currency.Legacy(): https://github.com/planetarium/lib9c/discussions/1319 + var currencyBySender = Currency.Legacy("NCG", 2, minter); +#pragma warning restore CS0618 + var prevState = new Account( + MockState.Empty + .SetState(_recipient, new AgentState(_recipient).Serialize()) + .SetBalance(_sender, currencyBySender * 1000) + .SetBalance(_recipient, currencyBySender * 10) + .SetBalance(_sender, Currencies.Mead * 1)); + var action = new TransferAsset4( + sender: _sender, + recipient: _recipient, + amount: currencyBySender * 100 + ); + var ex = Assert.Throws(() => + { + action.Execute(new ActionContext() + { + PreviousState = prevState, + Signer = _sender, + BlockIndex = 1, + }); + }); + + Assert.Equal(new[] { minter }, ex.Minters); + Assert.Equal(_sender, ex.Sender); + Assert.Equal(_recipient, ex.Recipient); + } + + [Theory] + [InlineData(null)] + [InlineData("Nine Chronicles")] + public void PlainValue(string memo) + { + var action = new TransferAsset4(_sender, _recipient, _currency * 100, memo); + Dictionary plainValue = (Dictionary)action.PlainValue; + Dictionary values = (Dictionary)plainValue["values"]; + + Assert.Equal((Text)"transfer_asset4", plainValue["type_id"]); + Assert.Equal(_sender, values["sender"].ToAddress()); + Assert.Equal(_recipient, values["recipient"].ToAddress()); + Assert.Equal(_currency * 100, values["amount"].ToFungibleAssetValue()); + if (!(memo is null)) + { + Assert.Equal(memo, values["memo"].ToDotnetString()); + } + } + + [Theory] + [InlineData(null)] + [InlineData("Nine Chronicles")] + public void LoadPlainValue(string memo) + { + IEnumerable> pairs = new[] + { + new KeyValuePair((Text)"sender", _sender.Serialize()), + new KeyValuePair((Text)"recipient", _recipient.Serialize()), + new KeyValuePair((Text)"amount", (_currency * 100).Serialize()), + }; + if (!(memo is null)) + { + pairs = pairs.Append(new KeyValuePair((Text)"memo", memo.Serialize())); + } + + var plainValue = Dictionary.Empty + .Add("type_id", "transfer_asset3") + .Add("values", new Dictionary(pairs)); + var action = new TransferAsset4(); + action.LoadPlainValue(plainValue); + + Assert.Equal(_sender, action.Sender); + Assert.Equal(_recipient, action.Recipient); + Assert.Equal(_currency * 100, action.Amount); + Assert.Equal(memo, action.Memo); + } + + [Fact] + public void Execute_Throw_InvalidTransferCurrencyException() + { + var crystal = CrystalCalculator.CRYSTAL; + var prevState = new Account( + MockState.Empty + .SetState(_recipient.Derive(ActivationKey.DeriveKey), true.Serialize()) + .SetBalance(_sender, crystal * 1000) + .SetBalance(_sender, Currencies.Mead * 1)); + var action = new TransferAsset4( + sender: _sender, + recipient: _recipient, + amount: 1000 * crystal + ); + Assert.Throws(() => action.Execute(new ActionContext() + { + PreviousState = prevState, + Signer = _sender, + BlockIndex = TransferAsset3.CrystalTransferringRestrictionStartIndex, + })); + } + + [Fact] + public void LoadPlainValue_ThrowsMemoLengthOverflowException() + { + var action = new TransferAsset4(); + var plainValue = Dictionary.Empty + .Add("type_id", "transfer_asset3") + .Add("values", new Dictionary(new[] + { + new KeyValuePair((Text)"sender", _sender.Serialize()), + new KeyValuePair((Text)"recipient", _recipient.Serialize()), + new KeyValuePair((Text)"amount", (_currency * 100).Serialize()), + new KeyValuePair((Text)"memo", new string(' ', 81).Serialize()), + })); + + Assert.Throws(() => action.LoadPlainValue(plainValue)); + } + + [Theory] + [InlineData(null)] + [InlineData("Nine Chronicles")] + public void SerializeWithDotnetAPI(string memo) + { + var formatter = new BinaryFormatter(); + var action = new TransferAsset4(_sender, _recipient, _currency * 100, memo); + + using var ms = new MemoryStream(); + formatter.Serialize(ms, action); + + ms.Seek(0, SeekOrigin.Begin); + var deserialized = (TransferAsset4)formatter.Deserialize(ms); + + Assert.Equal(_sender, deserialized.Sender); + Assert.Equal(_recipient, deserialized.Recipient); + Assert.Equal(_currency * 100, deserialized.Amount); + Assert.Equal(memo, deserialized.Memo); + } + } +} diff --git a/.Lib9c.Tests/Action/TransferAssetTest0.cs b/.Lib9c.Tests/Action/TransferAssetTest0.cs new file mode 100644 index 0000000000..46cfb33ed6 --- /dev/null +++ b/.Lib9c.Tests/Action/TransferAssetTest0.cs @@ -0,0 +1,302 @@ +namespace Lib9c.Tests.Action +{ + using System.Collections.Generic; + using System.Collections.Immutable; + using System.IO; + using System.Linq; + using System.Runtime.Serialization.Formatters.Binary; + using Bencodex.Types; + using Libplanet.Action.State; + using Libplanet.Crypto; + using Libplanet.Types.Assets; + using Nekoyume; + using Nekoyume.Action; + using Nekoyume.Model.State; + using Xunit; + + public class TransferAssetTest0 + { + private static readonly Address _sender = new Address( + new byte[] + { + 0x01, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, + } + ); + + private static readonly Address _recipient = new Address(new byte[] + { + 0x02, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, + } + ); + +#pragma warning disable CS0618 + // Use of obsolete method Currency.Legacy(): https://github.com/planetarium/lib9c/discussions/1319 + private static readonly Currency _currency = Currency.Legacy("NCG", 2, null); +#pragma warning restore CS0618 + + [Fact] + public void Constructor_ThrowsMemoLengthOverflowException() + { + Assert.Throws(() => + new TransferAsset0(_sender, _recipient, _currency * 100, new string(' ', 100))); + } + + [Fact] + public void Execute() + { + var prevState = new Account( + MockState.Empty + .SetBalance(_sender, _currency * 1000) + .SetBalance(_recipient, _currency * 10)); + var action = new TransferAsset0( + sender: _sender, + recipient: _recipient, + amount: _currency * 100 + ); + IAccount nextState = action.Execute(new ActionContext() + { + PreviousState = prevState, + Signer = _sender, + BlockIndex = 1, + }); + + Assert.Equal(_currency * 900, nextState.GetBalance(_sender, _currency)); + Assert.Equal(_currency * 110, nextState.GetBalance(_recipient, _currency)); + } + + [Fact] + public void ExecuteWithInvalidSigner() + { + var prevState = new Account( + MockState.Empty + .SetBalance(_sender, _currency * 1000) + .SetBalance(_recipient, _currency * 10)); + var action = new TransferAsset0( + sender: _sender, + recipient: _recipient, + amount: _currency * 100 + ); + + var exc = Assert.Throws(() => + { + _ = action.Execute(new ActionContext() + { + PreviousState = prevState, + // 송금자가 직접 사인하지 않으면 실패해야 합니다. + Signer = _recipient, + BlockIndex = 1, + }); + }); + + Assert.Equal(exc.Sender, _sender); + Assert.Equal(exc.Recipient, _recipient); + Assert.Equal(exc.TxSigner, _recipient); + } + + [Fact] + public void ExecuteWithInvalidRecipient() + { + var prevState = new Account( + MockState.Empty + .SetBalance(_sender, _currency * 1000)); + // Should not allow TransferAsset with same sender and recipient. + var action = new TransferAsset0( + sender: _sender, + recipient: _sender, + amount: _currency * 100 + ); + + // No exception should be thrown when its index is less then 380000. + _ = action.Execute(new ActionContext() + { + PreviousState = prevState, + Signer = _sender, + BlockIndex = 1, + }); + + var exc = Assert.Throws(() => + { + _ = action.Execute(new ActionContext() + { + PreviousState = prevState, + Signer = _sender, + BlockIndex = 380001, + }); + }); + + Assert.Equal(exc.Sender, _sender); + Assert.Equal(exc.Recipient, _sender); + } + + [Fact] + public void ExecuteWithInsufficientBalance() + { + var prevState = new Account( + MockState.Empty + .SetBalance(_sender, _currency * 1000) + .SetBalance(_recipient, _currency * 10)); + var action = new TransferAsset0( + sender: _sender, + recipient: _recipient, + amount: _currency * 100000 + ); + + Assert.Throws(() => + { + action.Execute(new ActionContext() + { + PreviousState = prevState, + Signer = _sender, + BlockIndex = 1, + }); + }); + } + + [Fact] + public void ExecuteWithMinterAsSender() + { +#pragma warning disable CS0618 + // Use of obsolete method Currency.Legacy(): https://github.com/planetarium/lib9c/discussions/1319 + var currencyBySender = Currency.Legacy("NCG", 2, _sender); +#pragma warning restore CS0618 + var prevState = new Account( + MockState.Empty + .SetBalance(_sender, currencyBySender * 1000) + .SetBalance(_recipient, currencyBySender * 10)); + var action = new TransferAsset0( + sender: _sender, + recipient: _recipient, + amount: currencyBySender * 100 + ); + var ex = Assert.Throws(() => + { + action.Execute(new ActionContext() + { + PreviousState = prevState, + Signer = _sender, + BlockIndex = 1, + }); + }); + + Assert.Equal(new[] { _sender }, ex.Minters); + Assert.Equal(_sender, ex.Sender); + Assert.Equal(_recipient, ex.Recipient); + } + + [Fact] + public void ExecuteWithMinterAsRecipient() + { +#pragma warning disable CS0618 + // Use of obsolete method Currency.Legacy(): https://github.com/planetarium/lib9c/discussions/1319 + var currencyByRecipient = Currency.Legacy("NCG", 2, _sender); +#pragma warning restore CS0618 + var prevState = new Account( + MockState.Empty + .SetBalance(_sender, currencyByRecipient * 1000) + .SetBalance(_recipient, currencyByRecipient * 10)); + var action = new TransferAsset0( + sender: _sender, + recipient: _recipient, + amount: currencyByRecipient * 100 + ); + var ex = Assert.Throws(() => + { + action.Execute(new ActionContext() + { + PreviousState = prevState, + Signer = _sender, + BlockIndex = 1, + }); + }); + + Assert.Equal(new[] { _sender }, ex.Minters); + Assert.Equal(_sender, ex.Sender); + Assert.Equal(_recipient, ex.Recipient); + } + + [Theory] + [InlineData(null)] + [InlineData("Nine Chronicles")] + public void PlainValue(string memo) + { + var action = new TransferAsset0(_sender, _recipient, _currency * 100, memo); + Dictionary plainValue = (Dictionary)action.PlainValue; + Dictionary values = (Dictionary)plainValue["values"]; + + Assert.Equal(_sender, values["sender"].ToAddress()); + Assert.Equal(_recipient, values["recipient"].ToAddress()); + Assert.Equal(_currency * 100, values["amount"].ToFungibleAssetValue()); + if (!(memo is null)) + { + Assert.Equal(memo, values["memo"].ToDotnetString()); + } + } + + [Theory] + [InlineData(null)] + [InlineData("Nine Chronicles")] + public void LoadPlainValue(string memo) + { + IEnumerable> pairs = new[] + { + new KeyValuePair((Text)"sender", _sender.Serialize()), + new KeyValuePair((Text)"recipient", _recipient.Serialize()), + new KeyValuePair((Text)"amount", (_currency * 100).Serialize()), + }; + if (!(memo is null)) + { + pairs = pairs.Append(new KeyValuePair((Text)"memo", memo.Serialize())); + } + + var plainValue = Dictionary.Empty + .Add("type_id", "transfer_asset") + .Add("values", new Dictionary(pairs)); + var action = new TransferAsset0(); + action.LoadPlainValue(plainValue); + + Assert.Equal(_sender, action.Sender); + Assert.Equal(_recipient, action.Recipient); + Assert.Equal(_currency * 100, action.Amount); + Assert.Equal(memo, action.Memo); + } + + [Fact] + public void LoadPlainValue_ThrowsMemoLengthOverflowException() + { + var action = new TransferAsset0(); + var plainValue = Dictionary.Empty + .Add("type_id", "transfer_asset") + .Add("values", new Dictionary(new[] + { + new KeyValuePair((Text)"sender", _sender.Serialize()), + new KeyValuePair((Text)"recipient", _recipient.Serialize()), + new KeyValuePair((Text)"amount", (_currency * 100).Serialize()), + new KeyValuePair((Text)"memo", new string(' ', 81).Serialize()), + })); + + Assert.Throws(() => action.LoadPlainValue(plainValue)); + } + + [Theory] + [InlineData(null)] + [InlineData("Nine Chronicles")] + public void SerializeWithDotnetAPI(string memo) + { + var formatter = new BinaryFormatter(); + var action = new TransferAsset0(_sender, _recipient, _currency * 100, memo); + + using var ms = new MemoryStream(); + formatter.Serialize(ms, action); + + ms.Seek(0, SeekOrigin.Begin); + var deserialized = (TransferAsset0)formatter.Deserialize(ms); + + Assert.Equal(_sender, deserialized.Sender); + Assert.Equal(_recipient, deserialized.Recipient); + Assert.Equal(_currency * 100, deserialized.Amount); + Assert.Equal(memo, deserialized.Memo); + } + } +} diff --git a/.Lib9c.Tests/Action/TransferAssets0Test.cs b/.Lib9c.Tests/Action/TransferAssets0Test.cs new file mode 100644 index 0000000000..c792511094 --- /dev/null +++ b/.Lib9c.Tests/Action/TransferAssets0Test.cs @@ -0,0 +1,452 @@ +namespace Lib9c.Tests.Action +{ + using System; + using System.Collections.Generic; + using System.Collections.Immutable; + using System.IO; + using System.Linq; + using System.Numerics; + using System.Runtime.Serialization.Formatters.Binary; + using Bencodex.Types; + using Libplanet.Action; + using Libplanet.Action.State; + using Libplanet.Crypto; + using Libplanet.Types.Assets; + using Nekoyume; + using Nekoyume.Action; + using Nekoyume.Helper; + using Nekoyume.Model; + using Nekoyume.Model.State; + using Xunit; + + public class TransferAssets0Test + { + private static readonly Address _sender = new Address( + new byte[] + { + 0x01, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, + } + ); + + private static readonly Address _recipient = new Address(new byte[] + { + 0x02, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, + } + ); + + private static readonly Address _recipient2 = new Address(new byte[] + { + 0x03, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, + } + ); + +#pragma warning disable CS0618 + // Use of obsolete method Currency.Legacy(): https://github.com/planetarium/lib9c/discussions/1319 + private static readonly Currency _currency = Currency.Legacy("NCG", 2, null); +#pragma warning restore CS0618 + + [Fact] + public void Constructor_ThrowsMemoLengthOverflowException() + { + Assert.Throws(() => + new TransferAssets0( + _sender, + new List<(Address, FungibleAssetValue)>() + { + (_recipient, _currency * 100), + }, + new string(' ', 100) + ) + ); + } + + [Theory] + // activation by derive address. + [InlineData(true, false, false)] + // activation by ActivatedAccountsState. + [InlineData(false, true, false)] + // state exist. + [InlineData(false, false, true)] + public void Execute(bool activate, bool legacyActivate, bool stateExist) + { + var mockState = MockState.Empty + .SetBalance(_sender, _currency * 1000) + .SetBalance(_recipient, _currency * 10); + if (activate) + { + mockState = mockState + .SetState(_recipient.Derive(ActivationKey.DeriveKey), true.Serialize()) + .SetState(_recipient2.Derive(ActivationKey.DeriveKey), true.Serialize()); + } + + if (legacyActivate) + { + var activatedAccountState = new ActivatedAccountsState(); + activatedAccountState = activatedAccountState + .AddAccount(_recipient) + .AddAccount(_recipient2); + mockState = mockState.SetState(activatedAccountState.address, activatedAccountState.Serialize()); + } + + if (stateExist) + { + mockState = mockState + .SetState(_recipient, new AgentState(_recipient).Serialize()) + .SetState(_recipient2, new AgentState(_recipient2).Serialize()); + } + + var prevState = new Account(mockState); + var action = new TransferAssets0( + sender: _sender, + new List<(Address, FungibleAssetValue)> + { + (_recipient, _currency * 100), + (_recipient2, _currency * 100), + } + ); + IAccount nextState = action.Execute(new ActionContext() + { + PreviousState = prevState, + Signer = _sender, + BlockIndex = 1, + }); + + Assert.Equal(_currency * 800, nextState.GetBalance(_sender, _currency)); + Assert.Equal(_currency * 110, nextState.GetBalance(_recipient, _currency)); + Assert.Equal(_currency * 100, nextState.GetBalance(_recipient2, _currency)); + } + + [Fact] + public void ExecuteWithInvalidSigner() + { + var prevState = new Account( + MockState.Empty + .SetBalance(_sender, _currency * 1000) + .SetBalance(_recipient, _currency * 10)); + var action = new TransferAssets0( + sender: _sender, + new List<(Address, FungibleAssetValue)> + { + (_recipient, _currency * 100), + } + ); + + var exc = Assert.Throws(() => + { + _ = action.Execute(new ActionContext() + { + PreviousState = prevState, + // 송금자가 직접 사인하지 않으면 실패해야 합니다. + Signer = _recipient, + BlockIndex = 1, + }); + }); + + Assert.Equal(exc.Sender, _sender); + Assert.Equal(exc.Recipient, _recipient); + Assert.Equal(exc.TxSigner, _recipient); + } + + [Fact] + public void ExecuteWithInvalidRecipient() + { + var prevState = new Account( + MockState.Empty + .SetBalance(_sender, _currency * 1000)); + // Should not allow TransferAsset with same sender and recipient. + var action = new TransferAssets0( + sender: _sender, + new List<(Address, FungibleAssetValue)> + { + (_sender, _currency * 100), + } + ); + + var exc = Assert.Throws(() => + { + _ = action.Execute(new ActionContext() + { + PreviousState = prevState, + Signer = _sender, + BlockIndex = 1, + }); + }); + + Assert.Equal(exc.Sender, _sender); + Assert.Equal(exc.Recipient, _sender); + } + + [Fact] + public void ExecuteWithInsufficientBalance() + { + var prevState = new Account( + MockState.Empty + .SetState(_recipient, new AgentState(_recipient).Serialize()) + .SetBalance(_sender, _currency * 1000) + .SetBalance(_recipient, _currency * 10)); + var action = new TransferAssets0( + sender: _sender, + new List<(Address, FungibleAssetValue)> + { + (_recipient, _currency * 100000), + } + ); + + Assert.Throws(() => + { + action.Execute(new ActionContext() + { + PreviousState = prevState, + Signer = _sender, + BlockIndex = 1, + }); + }); + } + + [Fact] + public void ExecuteWithMinterAsSender() + { +#pragma warning disable CS0618 + // Use of obsolete method Currency.Legacy(): https://github.com/planetarium/lib9c/discussions/1319 + var currencyBySender = Currency.Legacy("NCG", 2, _sender); +#pragma warning restore CS0618 + var prevState = new Account( + MockState.Empty + .SetState(_recipient, new AgentState(_recipient).Serialize()) + .SetBalance(_sender, currencyBySender * 1000) + .SetBalance(_recipient, currencyBySender * 10)); + var action = new TransferAssets0( + sender: _sender, + new List<(Address, FungibleAssetValue)> + { + (_recipient, currencyBySender * 100), + } + ); + var ex = Assert.Throws(() => + { + action.Execute(new ActionContext() + { + PreviousState = prevState, + Signer = _sender, + BlockIndex = 1, + }); + }); + + Assert.Equal(new[] { _sender }, ex.Minters); + Assert.Equal(_sender, ex.Sender); + Assert.Equal(_recipient, ex.Recipient); + } + + [Fact] + public void ExecuteWithMinterAsRecipient() + { +#pragma warning disable CS0618 + // Use of obsolete method Currency.Legacy(): https://github.com/planetarium/lib9c/discussions/1319 + var currencyByRecipient = Currency.Legacy("NCG", 2, _sender); +#pragma warning restore CS0618 + var prevState = new Account( + MockState.Empty + .SetState(_recipient, new AgentState(_recipient).Serialize()) + .SetBalance(_sender, currencyByRecipient * 1000) + .SetBalance(_recipient, currencyByRecipient * 10)); + var action = new TransferAssets0( + sender: _sender, + new List<(Address, FungibleAssetValue)> + { + (_recipient, currencyByRecipient * 100), + } + ); + var ex = Assert.Throws(() => + { + action.Execute(new ActionContext() + { + PreviousState = prevState, + Signer = _sender, + BlockIndex = 1, + }); + }); + + Assert.Equal(new[] { _sender }, ex.Minters); + Assert.Equal(_sender, ex.Sender); + Assert.Equal(_recipient, ex.Recipient); + } + + [Fact] + public void ExecuteWithUnactivatedRecipient() + { + var activatedAddress = new ActivatedAccountsState().AddAccount(new PrivateKey().Address); + var prevState = new Account( + MockState.Empty + .SetState(_sender.Derive(ActivationKey.DeriveKey), true.Serialize()) + .SetState(Addresses.ActivatedAccount, activatedAddress.Serialize()) + .SetBalance(_sender, _currency * 1000) + .SetBalance(_recipient, _currency * 10)); + var action = new TransferAssets0( + sender: _sender, + new List<(Address, FungibleAssetValue)> + { + (_recipient, _currency * 100), + } + ); + var ex = Assert.Throws(() => + { + action.Execute(new ActionContext() + { + PreviousState = prevState, + Signer = _sender, + BlockIndex = 1, + }); + }); + Assert.Equal(_sender, ex.Sender); + Assert.Equal(_recipient, ex.Recipient); + } + + [Theory] + [InlineData(null)] + [InlineData("Nine Chronicles")] + public void PlainValue(string memo) + { + var action = new TransferAssets0( + _sender, + new List<(Address, FungibleAssetValue)> + { + (_recipient, _currency * 100), + }, + memo + ); + + Dictionary plainValue = (Dictionary)action.PlainValue; + var values = (Dictionary)plainValue["values"]; + var recipients = (List)values["recipients"]; + var info = (List)recipients[0]; + Assert.Equal(_sender, values["sender"].ToAddress()); + Assert.Equal(_recipient, info[0].ToAddress()); + Assert.Equal(_currency * 100, info[1].ToFungibleAssetValue()); + if (!(memo is null)) + { + Assert.Equal(memo, values["memo"].ToDotnetString()); + } + } + + [Theory] + [InlineData(null)] + [InlineData("Nine Chronicles")] + public void LoadPlainValue(string memo) + { + IEnumerable> pairs = new[] + { + new KeyValuePair((Text)"sender", _sender.Serialize()), + new KeyValuePair((Text)"recipients", List.Empty.Add(List.Empty.Add(_recipient.Serialize()).Add((_currency * 100).Serialize()))), + }; + if (!(memo is null)) + { + pairs = pairs.Append(new KeyValuePair((Text)"memo", memo.Serialize())); + } + + var values = new Dictionary(pairs); + var plainValue = Dictionary.Empty + .Add("type_id", "transfer_assets") + .Add("values", values); + var action = new TransferAssets0(); + action.LoadPlainValue(plainValue); + + Assert.Equal(_sender, action.Sender); + Assert.Equal(_recipient, action.Recipients.Single().recipient); + Assert.Equal(_currency * 100, action.Recipients.Single().amount); + Assert.Equal(memo, action.Memo); + } + + [Fact] + public void LoadPlainValue_ThrowsMemoLengthOverflowException() + { + var action = new TransferAssets0(); + var values = new Dictionary(new[] + { + new KeyValuePair((Text)"sender", _sender.Serialize()), + new KeyValuePair((Text)"recipients", List.Empty.Add(List.Empty.Add(_recipient.Serialize()).Add((_currency * 100).Serialize()))), + new KeyValuePair((Text)"memo", new string(' ', 81).Serialize()), + }); + var plainValue = Dictionary.Empty + .Add("type_id", "transfer_assets") + .Add("values", values); + + Assert.Throws(() => action.LoadPlainValue(plainValue)); + } + + [Theory] + [InlineData(null)] + [InlineData("Nine Chronicles")] + public void SerializeWithDotnetAPI(string memo) + { + var formatter = new BinaryFormatter(); + var action = new TransferAssets0( + _sender, + new List<(Address, FungibleAssetValue)> + { + (_recipient, _currency * 100), + }, + memo + ); + + using var ms = new MemoryStream(); + formatter.Serialize(ms, action); + + ms.Seek(0, SeekOrigin.Begin); + var deserialized = (TransferAssets0)formatter.Deserialize(ms); + + Assert.Equal(_sender, deserialized.Sender); + Assert.Equal(_recipient, deserialized.Recipients.Single().recipient); + Assert.Equal(_currency * 100, deserialized.Recipients.Single().amount); + Assert.Equal(memo, deserialized.Memo); + } + + [Fact] + public void Execute_Throw_ArgumentOutOfRangeException() + { + var recipients = new List<(Address, FungibleAssetValue)>(); + + for (int i = 0; i < TransferAssets0.RecipientsCapacity + 1; i++) + { + recipients.Add((_recipient, _currency * 100)); + } + + var action = new TransferAssets0(_sender, recipients); + Assert.Throws(() => + { + action.Execute(new ActionContext() + { + PreviousState = new Account(MockState.Empty), + Signer = _sender, + BlockIndex = 1, + }); + }); + } + + [Fact] + public void Execute_Throw_InvalidTransferCurrencyException() + { + var crystal = CrystalCalculator.CRYSTAL; + var prevState = new Account( + MockState.Empty + .SetState(_recipient.Derive(ActivationKey.DeriveKey), true.Serialize()) + .SetBalance(_sender, crystal * 1000)); + var action = new TransferAssets0( + sender: _sender, + recipients: new List<(Address, FungibleAssetValue)> + { + (_recipient, 1000 * crystal), + (_recipient, 100 * _currency), + } + ); + Assert.Throws(() => action.Execute(new ActionContext() + { + PreviousState = prevState, + Signer = _sender, + BlockIndex = TransferAsset3.CrystalTransferringRestrictionStartIndex, + })); + } + } +} diff --git a/.Lib9c.Tests/Action/TransferAssets2Test.cs b/.Lib9c.Tests/Action/TransferAssets2Test.cs new file mode 100644 index 0000000000..1bd683d9de --- /dev/null +++ b/.Lib9c.Tests/Action/TransferAssets2Test.cs @@ -0,0 +1,364 @@ +namespace Lib9c.Tests.Action +{ + using System; + using System.Collections.Generic; + using System.Collections.Immutable; + using System.IO; + using System.Linq; + using System.Runtime.Serialization.Formatters.Binary; + using Bencodex.Types; + using Libplanet.Action.State; + using Libplanet.Crypto; + using Libplanet.Types.Assets; + using Nekoyume; + using Nekoyume.Action; + using Nekoyume.Helper; + using Nekoyume.Model; + using Nekoyume.Model.State; + using Xunit; + + public class TransferAssets2Test + { + private static readonly Address _sender = new Address( + new byte[] + { + 0x01, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, + } + ); + + private static readonly Address _recipient = new Address(new byte[] + { + 0x02, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, + } + ); + + private static readonly Address _recipient2 = new Address(new byte[] + { + 0x03, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, + } + ); + +#pragma warning disable CS0618 + // Use of obsolete method Currency.Legacy(): https://github.com/planetarium/lib9c/discussions/1319 + private static readonly Currency _currency = Currency.Legacy("NCG", 2, null); +#pragma warning restore CS0618 + + [Fact] + public void Constructor_ThrowsMemoLengthOverflowException() + { + Assert.Throws(() => + new TransferAssets2( + _sender, + new List<(Address, FungibleAssetValue)>() + { + (_recipient, _currency * 100), + }, + new string(' ', 100) + ) + ); + } + + [Fact] + public void Execute() + { + var contractAddress = _sender.Derive(nameof(RequestPledge)); + var patronAddress = new PrivateKey().Address; + var prevState = new Account( + MockState.Empty + .SetBalance(_sender, _currency * 1000) + .SetBalance(_recipient, _currency * 10)); + var action = new TransferAssets2( + sender: _sender, + new List<(Address, FungibleAssetValue)> + { + (_recipient, _currency * 100), + (_recipient2, _currency * 100), + } + ); + IAccount nextState = action.Execute(new ActionContext() + { + PreviousState = prevState, + Signer = _sender, + BlockIndex = 1, + }); + + Assert.Equal(_currency * 800, nextState.GetBalance(_sender, _currency)); + Assert.Equal(_currency * 110, nextState.GetBalance(_recipient, _currency)); + Assert.Equal(_currency * 100, nextState.GetBalance(_recipient2, _currency)); + Assert.Equal(Currencies.Mead * 0, nextState.GetBalance(_sender, Currencies.Mead)); + Assert.Equal(Currencies.Mead * 0, nextState.GetBalance(patronAddress, Currencies.Mead)); + } + + [Fact] + public void Execute_Throw_InvalidTransferSignerException() + { + var prevState = new Account( + MockState.Empty + .SetBalance(_sender, _currency * 1000) + .SetBalance(_recipient, _currency * 10)); + var action = new TransferAssets2( + sender: _sender, + new List<(Address, FungibleAssetValue)> + { + (_recipient, _currency * 100), + } + ); + + var exc = Assert.Throws(() => + { + _ = action.Execute(new ActionContext() + { + PreviousState = prevState, + // 송금자가 직접 사인하지 않으면 실패해야 합니다. + Signer = _recipient, + BlockIndex = 1, + }); + }); + + Assert.Equal(exc.Sender, _sender); + Assert.Equal(exc.Recipient, _recipient); + Assert.Equal(exc.TxSigner, _recipient); + } + + [Fact] + public void Execute_Throw_InvalidTransferRecipientException() + { + var prevState = new Account( + MockState.Empty + .SetBalance(_sender, _currency * 1000)); + // Should not allow TransferAsset with same sender and recipient. + var action = new TransferAssets2( + sender: _sender, + new List<(Address, FungibleAssetValue)> + { + (_sender, _currency * 100), + } + ); + + var exc = Assert.Throws(() => + { + _ = action.Execute(new ActionContext() + { + PreviousState = prevState, + Signer = _sender, + BlockIndex = 1, + }); + }); + + Assert.Equal(exc.Sender, _sender); + Assert.Equal(exc.Recipient, _sender); + } + + [Fact] + public void Execute_Throw_InsufficientBalanceException() + { + var prevState = new Account( + MockState.Empty + .SetState(_recipient, new AgentState(_recipient).Serialize()) + .SetBalance(_sender, _currency * 1000) + .SetBalance(_recipient, _currency * 10)); + var action = new TransferAssets2( + sender: _sender, + new List<(Address, FungibleAssetValue)> + { + (_recipient, _currency * 100000), + } + ); + + InsufficientBalanceException exc = Assert.Throws(() => + { + action.Execute(new ActionContext() + { + PreviousState = prevState, + Signer = _sender, + BlockIndex = 1, + }); + }); + + Assert.Equal(_sender, exc.Address); + Assert.Equal(_currency, exc.Balance.Currency); + } + + [Fact] + public void Execute_Throw_InvalidTransferMinterException() + { +#pragma warning disable CS0618 + // Use of obsolete method Currency.Legacy(): https://github.com/planetarium/lib9c/discussions/1319 + var currencyBySender = Currency.Legacy("NCG", 2, _sender); +#pragma warning restore CS0618 + var prevState = new Account( + MockState.Empty + .SetState(_recipient, new AgentState(_recipient).Serialize()) + .SetBalance(_sender, currencyBySender * 1000) + .SetBalance(_recipient, currencyBySender * 10)); + var action = new TransferAssets2( + sender: _sender, + new List<(Address, FungibleAssetValue)> + { + (_recipient, currencyBySender * 100), + } + ); + var ex = Assert.Throws(() => + { + action.Execute(new ActionContext() + { + PreviousState = prevState, + Signer = _sender, + BlockIndex = 1, + }); + }); + + Assert.Equal(new[] { _sender }, ex.Minters); + Assert.Equal(_sender, ex.Sender); + Assert.Equal(_recipient, ex.Recipient); + } + + [Theory] + [InlineData(null)] + [InlineData("Nine Chronicles")] + public void PlainValue(string memo) + { + var action = new TransferAssets2( + _sender, + new List<(Address, FungibleAssetValue)> + { + (_recipient, _currency * 100), + }, + memo + ); + + Dictionary plainValue = (Dictionary)action.PlainValue; + Dictionary values = (Dictionary)plainValue["values"]; + + var recipients = (List)values["recipients"]; + var info = (List)recipients[0]; + Assert.Equal((Text)"transfer_assets2", plainValue["type_id"]); + Assert.Equal(_sender, values["sender"].ToAddress()); + Assert.Equal(_recipient, info[0].ToAddress()); + Assert.Equal(_currency * 100, info[1].ToFungibleAssetValue()); + if (!(memo is null)) + { + Assert.Equal(memo, values["memo"].ToDotnetString()); + } + } + + [Theory] + [InlineData(null)] + [InlineData("Nine Chronicles")] + public void LoadPlainValue(string memo) + { + IEnumerable> pairs = new[] + { + new KeyValuePair((Text)"sender", _sender.Serialize()), + new KeyValuePair((Text)"recipients", List.Empty.Add(List.Empty.Add(_recipient.Serialize()).Add((_currency * 100).Serialize()))), + }; + if (!(memo is null)) + { + pairs = pairs.Append(new KeyValuePair((Text)"memo", memo.Serialize())); + } + + var plainValue = Dictionary.Empty + .Add("type_id", "transfer_assets") + .Add("values", new Dictionary(pairs)); + var action = new TransferAssets2(); + action.LoadPlainValue(plainValue); + + Assert.Equal(_sender, action.Sender); + Assert.Equal(_recipient, action.Recipients.Single().recipient); + Assert.Equal(_currency * 100, action.Recipients.Single().amount); + Assert.Equal(memo, action.Memo); + } + + [Fact] + public void LoadPlainValue_ThrowsMemoLengthOverflowException() + { + var action = new TransferAssets2(); + var plainValue = Dictionary.Empty + .Add("type_id", "transfer_assets") + .Add("values", new Dictionary(new[] + { + new KeyValuePair((Text)"sender", _sender.Serialize()), + new KeyValuePair((Text)"recipients", List.Empty.Add(List.Empty.Add(_recipient.Serialize()).Add((_currency * 100).Serialize()))), + new KeyValuePair((Text)"memo", new string(' ', 81).Serialize()), + })); + + Assert.Throws(() => action.LoadPlainValue(plainValue)); + } + + [Theory] + [InlineData(null)] + [InlineData("Nine Chronicles")] + public void SerializeWithDotnetAPI(string memo) + { + var formatter = new BinaryFormatter(); + var action = new TransferAssets2( + _sender, + new List<(Address, FungibleAssetValue)> + { + (_recipient, _currency * 100), + }, + memo + ); + + using var ms = new MemoryStream(); + formatter.Serialize(ms, action); + + ms.Seek(0, SeekOrigin.Begin); + var deserialized = (TransferAssets2)formatter.Deserialize(ms); + + Assert.Equal(_sender, deserialized.Sender); + Assert.Equal(_recipient, deserialized.Recipients.Single().recipient); + Assert.Equal(_currency * 100, deserialized.Recipients.Single().amount); + Assert.Equal(memo, deserialized.Memo); + } + + [Fact] + public void Execute_Throw_ArgumentOutOfRangeException() + { + var recipients = new List<(Address, FungibleAssetValue)>(); + + for (int i = 0; i < TransferAssets2.RecipientsCapacity + 1; i++) + { + recipients.Add((_recipient, _currency * 100)); + } + + var action = new TransferAssets2(_sender, recipients); + Assert.Throws(() => + { + action.Execute(new ActionContext() + { + PreviousState = new Account(MockState.Empty), + Signer = _sender, + BlockIndex = 1, + }); + }); + } + + [Fact] + public void Execute_Throw_InvalidTransferCurrencyException() + { + var crystal = CrystalCalculator.CRYSTAL; + var prevState = new Account( + MockState.Empty + .SetState(_recipient.Derive(ActivationKey.DeriveKey), true.Serialize()) + .SetBalance(_sender, crystal * 1000)); + var action = new TransferAssets2( + sender: _sender, + recipients: new List<(Address, FungibleAssetValue)> + { + (_recipient, 1000 * crystal), + (_recipient, 100 * _currency), + } + ); + Assert.Throws(() => action.Execute(new ActionContext() + { + PreviousState = prevState, + Signer = _sender, + BlockIndex = TransferAsset3.CrystalTransferringRestrictionStartIndex, + })); + } + } +} diff --git a/.Lib9c.Tests/Action/UpdateSell0Test.cs b/.Lib9c.Tests/Action/UpdateSell0Test.cs new file mode 100644 index 0000000000..3819505da5 --- /dev/null +++ b/.Lib9c.Tests/Action/UpdateSell0Test.cs @@ -0,0 +1,337 @@ +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.Action.State; + using Libplanet.Crypto; + using Libplanet.Types.Assets; + using Nekoyume; + using Nekoyume.Action; + using Nekoyume.Model; + using Nekoyume.Model.Item; + using Nekoyume.Model.State; + using Serilog; + using Xunit; + using Xunit.Abstractions; + using static Lib9c.SerializeKeys; + + public class UpdateSell0Test + { + private const long ProductPrice = 100; + private readonly Address _agentAddress; + private readonly Address _avatarAddress; + private readonly Currency _currency; + private readonly AvatarState _avatarState; + private readonly TableSheets _tableSheets; + private readonly GoldCurrencyState _goldCurrencyState; + private IAccount _initialState; + + public UpdateSell0Test(ITestOutputHelper outputHelper) + { + Log.Logger = new LoggerConfiguration() + .MinimumLevel.Verbose() + .WriteTo.TestOutput(outputHelper) + .CreateLogger(); + + _initialState = new Account(MockState.Empty); + var sheets = TableSheetsImporter.ImportSheets(); + foreach (var (key, value) in sheets) + { + _initialState = _initialState + .SetState(Addresses.TableSheet.Derive(key), value.Serialize()); + } + + _tableSheets = new TableSheets(sheets); + +#pragma warning disable CS0618 + // Use of obsolete method Currency.Legacy(): https://github.com/planetarium/lib9c/discussions/1319 + _currency = Currency.Legacy("NCG", 2, null); +#pragma warning restore CS0618 + _goldCurrencyState = new GoldCurrencyState(_currency); + + var shopState = new ShopState(); + + _agentAddress = new PrivateKey().Address; + var agentState = new AgentState(_agentAddress); + _avatarAddress = new PrivateKey().Address; + var rankingMapAddress = new PrivateKey().Address; + _avatarState = new AvatarState( + _avatarAddress, + _agentAddress, + 0, + _tableSheets.GetAvatarSheets(), + new GameConfigState(), + rankingMapAddress) + { + worldInformation = new WorldInformation( + 0, + _tableSheets.WorldSheet, + GameConfig.RequireClearedStageLevel.ActionsInShop), + }; + agentState.avatarAddresses[0] = _avatarAddress; + + _initialState = _initialState + .SetState(GoldCurrencyState.Address, _goldCurrencyState.Serialize()) + .SetState(Addresses.Shop, shopState.Serialize()) + .SetState(_agentAddress, agentState.Serialize()) + .SetState(_avatarAddress, _avatarState.Serialize()); + } + + [Theory] + [InlineData(ItemType.Equipment, "F9168C5E-CEB2-4faa-B6BF-329BF39FA1E4", 1, 1, 1, true, true)] + [InlineData(ItemType.Costume, "936DA01F-9ABD-4d9d-80C7-02AF85C822A8", 1, 1, 1, true, true)] + [InlineData(ItemType.Material, "15396359-04db-68d5-f24a-d89c18665900", 1, 1, 1, true, true)] + [InlineData(ItemType.Material, "15396359-04db-68d5-f24a-d89c18665900", 2, 1, 2, true, true)] + [InlineData(ItemType.Material, "15396359-04db-68d5-f24a-d89c18665900", 2, 2, 3, true, true)] + [InlineData(ItemType.Equipment, "F9168C5E-CEB2-4faa-B6BF-329BF39FA1E4", 1, 1, 1, false, true)] + [InlineData(ItemType.Costume, "936DA01F-9ABD-4d9d-80C7-02AF85C822A8", 1, 1, 1, false, true)] + [InlineData(ItemType.Material, "15396359-04db-68d5-f24a-d89c18665900", 1, 1, 1, false, true)] + [InlineData(ItemType.Material, "15396359-04db-68d5-f24a-d89c18665900", 2, 1, 2, false, true)] + [InlineData(ItemType.Material, "15396359-04db-68d5-f24a-d89c18665900", 2, 2, 3, false, true)] + [InlineData(ItemType.Equipment, "F9168C5E-CEB2-4faa-B6BF-329BF39FA1E4", 1, 1, 1, true, false)] + [InlineData(ItemType.Costume, "936DA01F-9ABD-4d9d-80C7-02AF85C822A8", 1, 1, 1, true, false)] + [InlineData(ItemType.Material, "15396359-04db-68d5-f24a-d89c18665900", 1, 1, 1, true, false)] + [InlineData(ItemType.Material, "15396359-04db-68d5-f24a-d89c18665900", 2, 1, 2, true, false)] + [InlineData(ItemType.Material, "15396359-04db-68d5-f24a-d89c18665900", 2, 2, 3, true, false)] + [InlineData(ItemType.Equipment, "F9168C5E-CEB2-4faa-B6BF-329BF39FA1E4", 1, 1, 1, false, false)] + [InlineData(ItemType.Costume, "936DA01F-9ABD-4d9d-80C7-02AF85C822A8", 1, 1, 1, false, false)] + [InlineData(ItemType.Material, "15396359-04db-68d5-f24a-d89c18665900", 1, 1, 1, false, false)] + [InlineData(ItemType.Material, "15396359-04db-68d5-f24a-d89c18665900", 2, 1, 2, false, false)] + [InlineData(ItemType.Material, "15396359-04db-68d5-f24a-d89c18665900", 2, 2, 3, false, false)] + public void Execute( + ItemType itemType, + string guid, + int itemCount, + int inventoryCount, + int expectedCount, + bool fromPreviousAction, + bool legacy + ) + { + var avatarState = _initialState.GetAvatarState(_avatarAddress); + ITradableItem tradableItem; + var itemId = new Guid(guid); + var orderId = Guid.NewGuid(); + var updateSellOrderId = Guid.NewGuid(); + ItemSubType itemSubType; + const long requiredBlockIndex = Order.ExpirationInterval; + switch (itemType) + { + case ItemType.Equipment: + { + var itemUsable = ItemFactory.CreateItemUsable( + _tableSheets.EquipmentItemSheet.First, + itemId, + requiredBlockIndex); + tradableItem = (ITradableItem)itemUsable; + itemSubType = itemUsable.ItemSubType; + break; + } + + case ItemType.Costume: + { + var costume = ItemFactory.CreateCostume(_tableSheets.CostumeItemSheet.First, itemId); + costume.Update(requiredBlockIndex); + tradableItem = costume; + itemSubType = costume.ItemSubType; + break; + } + + default: + { + var material = ItemFactory.CreateTradableMaterial( + _tableSheets.MaterialItemSheet.OrderedList.First(r => r.ItemSubType == ItemSubType.Hourglass)); + itemSubType = material.ItemSubType; + material.RequiredBlockIndex = requiredBlockIndex; + tradableItem = material; + break; + } + } + + var shardedShopAddress = ShardedShopStateV2.DeriveAddress(itemSubType, orderId); + var shopState = new ShardedShopStateV2(shardedShopAddress); + var order = OrderFactory.Create( + _agentAddress, + _avatarAddress, + orderId, + new FungibleAssetValue(_goldCurrencyState.Currency, 100, 0), + tradableItem.TradableId, + requiredBlockIndex, + itemSubType, + itemCount + ); + + var orderDigestList = new OrderDigestListState(OrderDigestListState.DeriveAddress(_avatarAddress)); + var prevState = _initialState; + + if (inventoryCount > 1) + { + for (int i = 0; i < inventoryCount; i++) + { + // Different RequiredBlockIndex for divide inventory slot. + if (tradableItem is ITradableFungibleItem tradableFungibleItem) + { + var tradable = (TradableMaterial)tradableFungibleItem.Clone(); + tradable.RequiredBlockIndex = tradableItem.RequiredBlockIndex - i; + avatarState.inventory.AddItem2(tradable, 2 - i); + } + } + } + else + { + avatarState.inventory.AddItem2((ItemBase)tradableItem, itemCount); + } + + var sellItem = legacy ? order.Sell2(avatarState) : order.Sell3(avatarState); + var orderDigest = legacy ? order.Digest2(avatarState, _tableSheets.CostumeStatSheet) + : order.Digest(avatarState, _tableSheets.CostumeStatSheet); + shopState.Add(orderDigest, requiredBlockIndex); + orderDigestList.Add(orderDigest); + + if (legacy) + { + Assert.True(avatarState.inventory.TryGetTradableItems(itemId, requiredBlockIndex * 2, itemCount, out _)); + } + else + { + Assert.True(avatarState.inventory.TryGetLockedItem(new OrderLock(orderId), out _)); + } + + Assert.Equal(inventoryCount, avatarState.inventory.Items.Count); + Assert.Equal(expectedCount, avatarState.inventory.Items.Sum(i => i.count)); + + Assert.Single(shopState.OrderDigestList); + Assert.Single(orderDigestList.OrderDigestList); + + Assert.Equal(requiredBlockIndex * 2, sellItem.RequiredBlockIndex); + + if (fromPreviousAction) + { + prevState = prevState.SetState(_avatarAddress, avatarState.Serialize()); + } + else + { + prevState = prevState + .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()); + } + + prevState = prevState + .SetState(Addresses.GetItemAddress(itemId), sellItem.Serialize()) + .SetState(Order.DeriveAddress(order.OrderId), order.Serialize()) + .SetState(orderDigestList.Address, orderDigestList.Serialize()) + .SetState(shardedShopAddress, shopState.Serialize()); + + var currencyState = prevState.GetGoldCurrency(); + var price = new FungibleAssetValue(currencyState, ProductPrice, 0); + var action = new UpdateSell0 + { + orderId = orderId, + updateSellOrderId = updateSellOrderId, + tradableId = itemId, + sellerAvatarAddress = _avatarAddress, + itemSubType = itemSubType, + price = price, + count = itemCount, + }; + var nextState = action.Execute(new ActionContext + { + BlockIndex = 101, + PreviousState = prevState, + RandomSeed = 0, + Signer = _agentAddress, + }); + + var updateSellShopAddress = ShardedShopStateV2.DeriveAddress(itemSubType, updateSellOrderId); + var nextShopState = new ShardedShopStateV2((Dictionary)nextState.GetState(updateSellShopAddress)); + Assert.Equal(1, nextShopState.OrderDigestList.Count); + Assert.NotEqual(orderId, nextShopState.OrderDigestList.First().OrderId); + Assert.Equal(updateSellOrderId, nextShopState.OrderDigestList.First().OrderId); + Assert.Equal(itemId, nextShopState.OrderDigestList.First().TradableId); + Assert.Equal(requiredBlockIndex + 101, nextShopState.OrderDigestList.First().ExpiredBlockIndex); + } + + [Fact] + public void Execute_Throw_FailedLoadStateException() + { + var action = new UpdateSell0 + { + orderId = default, + updateSellOrderId = default, + tradableId = default, + sellerAvatarAddress = _avatarAddress, + itemSubType = ItemSubType.Food, + price = 0 * _currency, + count = 1, + }; + + Assert.Throws(() => action.Execute(new ActionContext + { + BlockIndex = 0, + PreviousState = new Account(MockState.Empty), + Signer = _agentAddress, + })); + } + + [Fact] + public void Execute_Throw_InvalidPriceException() + { + var action = new UpdateSell0 + { + orderId = default, + updateSellOrderId = default, + tradableId = default, + sellerAvatarAddress = _avatarAddress, + itemSubType = default, + price = -1 * _currency, + count = 1, + }; + + Assert.Throws(() => action.Execute(new ActionContext + { + BlockIndex = 0, + PreviousState = _initialState, + 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 UpdateSell0 + { + updateSellOrderId = default, + orderId = default, + tradableId = default, + sellerAvatarAddress = _avatarAddress, + itemSubType = ItemSubType.Food, + price = 0 * _currency, + count = 1, + }; + + Assert.Throws(() => action.Execute(new ActionContext + { + BlockIndex = 0, + PreviousState = _initialState, + Signer = _agentAddress, + })); + } + } +} diff --git a/.Lib9c.Tests/Action/UpdateSell2Test.cs b/.Lib9c.Tests/Action/UpdateSell2Test.cs new file mode 100644 index 0000000000..e34a73d722 --- /dev/null +++ b/.Lib9c.Tests/Action/UpdateSell2Test.cs @@ -0,0 +1,318 @@ +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.Action.State; + using Libplanet.Crypto; + using Libplanet.Types.Assets; + using Nekoyume; + using Nekoyume.Action; + using Nekoyume.Model; + using Nekoyume.Model.Item; + using Nekoyume.Model.State; + using Serilog; + using Xunit; + using Xunit.Abstractions; + using static SerializeKeys; + + public class UpdateSell2Test + { + private const long ProductPrice = 100; + private readonly Address _agentAddress; + private readonly Address _avatarAddress; + private readonly Currency _currency; + private readonly AvatarState _avatarState; + private readonly TableSheets _tableSheets; + private readonly GoldCurrencyState _goldCurrencyState; + private IAccount _initialState; + + public UpdateSell2Test(ITestOutputHelper outputHelper) + { + Log.Logger = new LoggerConfiguration() + .MinimumLevel.Verbose() + .WriteTo.TestOutput(outputHelper) + .CreateLogger(); + + _initialState = new Account(MockState.Empty); + var sheets = TableSheetsImporter.ImportSheets(); + foreach (var (key, value) in sheets) + { + _initialState = _initialState + .SetState(Addresses.TableSheet.Derive(key), value.Serialize()); + } + + _tableSheets = new TableSheets(sheets); + +#pragma warning disable CS0618 + // Use of obsolete method Currency.Legacy(): https://github.com/planetarium/lib9c/discussions/1319 + _currency = Currency.Legacy("NCG", 2, null); +#pragma warning restore CS0618 + _goldCurrencyState = new GoldCurrencyState(_currency); + + var shopState = new ShopState(); + + _agentAddress = new PrivateKey().Address; + var agentState = new AgentState(_agentAddress); + _avatarAddress = new PrivateKey().Address; + var rankingMapAddress = new PrivateKey().Address; + _avatarState = new AvatarState( + _avatarAddress, + _agentAddress, + 0, + _tableSheets.GetAvatarSheets(), + new GameConfigState(), + rankingMapAddress) + { + worldInformation = new WorldInformation( + 0, + _tableSheets.WorldSheet, + GameConfig.RequireClearedStageLevel.ActionsInShop), + }; + agentState.avatarAddresses[0] = _avatarAddress; + + _initialState = _initialState + .SetState(GoldCurrencyState.Address, _goldCurrencyState.Serialize()) + .SetState(Addresses.Shop, shopState.Serialize()) + .SetState(_agentAddress, agentState.Serialize()) + .SetState(_avatarAddress, _avatarState.Serialize()); + } + + [Theory] + [InlineData(ItemType.Equipment, "F9168C5E-CEB2-4faa-B6BF-329BF39FA1E4", 1, 1, 1, true)] + [InlineData(ItemType.Costume, "936DA01F-9ABD-4d9d-80C7-02AF85C822A8", 1, 1, 1, true)] + [InlineData(ItemType.Material, "15396359-04db-68d5-f24a-d89c18665900", 1, 1, 1, true)] + [InlineData(ItemType.Material, "15396359-04db-68d5-f24a-d89c18665900", 2, 1, 2, true)] + [InlineData(ItemType.Material, "15396359-04db-68d5-f24a-d89c18665900", 2, 2, 3, true)] + [InlineData(ItemType.Equipment, "F9168C5E-CEB2-4faa-B6BF-329BF39FA1E4", 1, 1, 1, false)] + [InlineData(ItemType.Costume, "936DA01F-9ABD-4d9d-80C7-02AF85C822A8", 1, 1, 1, false)] + [InlineData(ItemType.Material, "15396359-04db-68d5-f24a-d89c18665900", 1, 1, 1, false)] + [InlineData(ItemType.Material, "15396359-04db-68d5-f24a-d89c18665900", 2, 1, 2, false)] + [InlineData(ItemType.Material, "15396359-04db-68d5-f24a-d89c18665900", 2, 2, 3, false)] + public void Execute( + ItemType itemType, + string guid, + int itemCount, + int inventoryCount, + int expectedCount, + bool fromPreviousAction + ) + { + var avatarState = _initialState.GetAvatarState(_avatarAddress); + ITradableItem tradableItem; + var itemId = new Guid(guid); + var orderId = Guid.NewGuid(); + var updateSellOrderId = Guid.NewGuid(); + ItemSubType itemSubType; + const long requiredBlockIndex = Order.ExpirationInterval; + switch (itemType) + { + case ItemType.Equipment: + { + var itemUsable = ItemFactory.CreateItemUsable( + _tableSheets.EquipmentItemSheet.First, + itemId, + requiredBlockIndex); + tradableItem = (ITradableItem)itemUsable; + itemSubType = itemUsable.ItemSubType; + break; + } + + case ItemType.Costume: + { + var costume = ItemFactory.CreateCostume(_tableSheets.CostumeItemSheet.First, itemId); + costume.Update(requiredBlockIndex); + tradableItem = costume; + itemSubType = costume.ItemSubType; + break; + } + + default: + { + var material = ItemFactory.CreateTradableMaterial( + _tableSheets.MaterialItemSheet.OrderedList.First(r => r.ItemSubType == ItemSubType.Hourglass)); + itemSubType = material.ItemSubType; + material.RequiredBlockIndex = requiredBlockIndex; + tradableItem = material; + break; + } + } + + var shardedShopAddress = ShardedShopStateV2.DeriveAddress(itemSubType, orderId); + var shopState = new ShardedShopStateV2(shardedShopAddress); + var order = OrderFactory.Create( + _agentAddress, + _avatarAddress, + orderId, + new FungibleAssetValue(_goldCurrencyState.Currency, 100, 0), + tradableItem.TradableId, + requiredBlockIndex, + itemSubType, + itemCount + ); + + var orderDigestList = new OrderDigestListState(OrderDigestListState.DeriveAddress(_avatarAddress)); + var prevState = _initialState; + + if (inventoryCount > 1) + { + for (int i = 0; i < inventoryCount; i++) + { + // Different RequiredBlockIndex for divide inventory slot. + if (tradableItem is ITradableFungibleItem tradableFungibleItem) + { + var tradable = (TradableMaterial)tradableFungibleItem.Clone(); + tradable.RequiredBlockIndex = tradableItem.RequiredBlockIndex - i; + avatarState.inventory.AddItem(tradable, 2 - i); + } + } + } + else + { + avatarState.inventory.AddItem((ItemBase)tradableItem, itemCount); + } + + var sellItem = order.Sell(avatarState); + var orderDigest = order.Digest(avatarState, _tableSheets.CostumeStatSheet); + shopState.Add(orderDigest, requiredBlockIndex); + orderDigestList.Add(orderDigest); + + Assert.True(avatarState.inventory.TryGetLockedItem(new OrderLock(orderId), out _)); + + Assert.Equal(inventoryCount, avatarState.inventory.Items.Count); + Assert.Equal(expectedCount, avatarState.inventory.Items.Sum(i => i.count)); + + Assert.Single(shopState.OrderDigestList); + Assert.Single(orderDigestList.OrderDigestList); + + Assert.Equal(requiredBlockIndex * 2, sellItem.RequiredBlockIndex); + + if (fromPreviousAction) + { + prevState = prevState.SetState(_avatarAddress, avatarState.Serialize()); + } + else + { + prevState = prevState + .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()); + } + + prevState = prevState + .SetState(Addresses.GetItemAddress(itemId), sellItem.Serialize()) + .SetState(Order.DeriveAddress(order.OrderId), order.Serialize()) + .SetState(orderDigestList.Address, orderDigestList.Serialize()) + .SetState(shardedShopAddress, shopState.Serialize()); + + var currencyState = prevState.GetGoldCurrency(); + var price = new FungibleAssetValue(currencyState, ProductPrice, 0); + var action = new UpdateSell2 + { + orderId = orderId, + updateSellOrderId = updateSellOrderId, + tradableId = itemId, + sellerAvatarAddress = _avatarAddress, + itemSubType = itemSubType, + price = price, + count = itemCount, + }; + var nextState = action.Execute(new ActionContext + { + BlockIndex = 101, + PreviousState = prevState, + RandomSeed = 0, + Signer = _agentAddress, + }); + + var updateSellShopAddress = ShardedShopStateV2.DeriveAddress(itemSubType, updateSellOrderId); + var nextShopState = new ShardedShopStateV2((Dictionary)nextState.GetState(updateSellShopAddress)); + Assert.Equal(1, nextShopState.OrderDigestList.Count); + Assert.NotEqual(orderId, nextShopState.OrderDigestList.First().OrderId); + Assert.Equal(updateSellOrderId, nextShopState.OrderDigestList.First().OrderId); + Assert.Equal(itemId, nextShopState.OrderDigestList.First().TradableId); + Assert.Equal(requiredBlockIndex + 101, nextShopState.OrderDigestList.First().ExpiredBlockIndex); + } + + [Fact] + public void Execute_Throw_FailedLoadStateException() + { + var action = new UpdateSell2 + { + orderId = default, + updateSellOrderId = default, + tradableId = default, + sellerAvatarAddress = _avatarAddress, + itemSubType = ItemSubType.Food, + price = 0 * _currency, + count = 1, + }; + + Assert.Throws(() => action.Execute(new ActionContext + { + BlockIndex = 0, + PreviousState = new Account(MockState.Empty), + Signer = _agentAddress, + })); + } + + [Fact] + public void Execute_Throw_InvalidPriceException() + { + var action = new UpdateSell2 + { + orderId = default, + updateSellOrderId = default, + tradableId = default, + sellerAvatarAddress = _avatarAddress, + itemSubType = default, + price = -1 * _currency, + count = 1, + }; + + Assert.Throws(() => action.Execute(new ActionContext + { + BlockIndex = 0, + PreviousState = _initialState, + 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 UpdateSell2 + { + updateSellOrderId = default, + orderId = default, + tradableId = default, + sellerAvatarAddress = _avatarAddress, + itemSubType = ItemSubType.Food, + price = 0 * _currency, + count = 1, + }; + + Assert.Throws(() => action.Execute(new ActionContext + { + BlockIndex = 0, + PreviousState = _initialState, + Signer = _agentAddress, + })); + } + } +} diff --git a/.Lib9c.Tests/Action/UpdateSell3Test.cs b/.Lib9c.Tests/Action/UpdateSell3Test.cs new file mode 100644 index 0000000000..91e0376e6b --- /dev/null +++ b/.Lib9c.Tests/Action/UpdateSell3Test.cs @@ -0,0 +1,389 @@ +namespace Lib9c.Tests.Action +{ + using System; + using System.Collections.Generic; + using System.Linq; + using Bencodex.Types; + using Lib9c.Model.Order; + using Libplanet.Action.State; + using Libplanet.Crypto; + using Libplanet.Types.Assets; + using Nekoyume; + using Nekoyume.Action; + using Nekoyume.Battle; + using Nekoyume.Model; + using Nekoyume.Model.Item; + using Nekoyume.Model.State; + using Serilog; + using Xunit; + using Xunit.Abstractions; + using static Lib9c.SerializeKeys; + + public class UpdateSell3Test + { + private const long ProductPrice = 100; + private readonly Address _agentAddress; + private readonly Address _avatarAddress; + private readonly Currency _currency; + private readonly AvatarState _avatarState; + private readonly TableSheets _tableSheets; + private readonly GoldCurrencyState _goldCurrencyState; + private IAccount _initialState; + + public UpdateSell3Test(ITestOutputHelper outputHelper) + { + Log.Logger = new LoggerConfiguration() + .MinimumLevel.Verbose() + .WriteTo.TestOutput(outputHelper) + .CreateLogger(); + + _initialState = new Account(MockState.Empty); + var sheets = TableSheetsImporter.ImportSheets(); + foreach (var (key, value) in sheets) + { + _initialState = _initialState + .SetState(Addresses.TableSheet.Derive(key), value.Serialize()); + } + + _tableSheets = new TableSheets(sheets); + +#pragma warning disable CS0618 + // Use of obsolete method Currency.Legacy(): https://github.com/planetarium/lib9c/discussions/1319 + _currency = Currency.Legacy("NCG", 2, null); +#pragma warning restore CS0618 + _goldCurrencyState = new GoldCurrencyState(_currency); + + var shopState = new ShopState(); + + _agentAddress = new PrivateKey().Address; + var agentState = new AgentState(_agentAddress); + _avatarAddress = new PrivateKey().Address; + var rankingMapAddress = new PrivateKey().Address; + _avatarState = new AvatarState( + _avatarAddress, + _agentAddress, + 0, + _tableSheets.GetAvatarSheets(), + new GameConfigState(), + rankingMapAddress) + { + worldInformation = new WorldInformation( + 0, + _tableSheets.WorldSheet, + GameConfig.RequireClearedStageLevel.ActionsInShop), + }; + agentState.avatarAddresses[0] = _avatarAddress; + + _initialState = _initialState + .SetState(GoldCurrencyState.Address, _goldCurrencyState.Serialize()) + .SetState(Addresses.Shop, shopState.Serialize()) + .SetState(_agentAddress, agentState.Serialize()) + .SetState(_avatarAddress, _avatarState.Serialize()); + } + + [Theory] + [InlineData(ItemType.Equipment, "F9168C5E-CEB2-4faa-B6BF-329BF39FA1E4", 1, 1, 1, true)] + [InlineData(ItemType.Costume, "936DA01F-9ABD-4d9d-80C7-02AF85C822A8", 1, 1, 1, true)] + [InlineData(ItemType.Material, "15396359-04db-68d5-f24a-d89c18665900", 1, 1, 1, true)] + [InlineData(ItemType.Material, "15396359-04db-68d5-f24a-d89c18665900", 2, 1, 2, true)] + [InlineData(ItemType.Material, "15396359-04db-68d5-f24a-d89c18665900", 2, 2, 3, true)] + [InlineData(ItemType.Equipment, "F9168C5E-CEB2-4faa-B6BF-329BF39FA1E4", 1, 1, 1, false)] + [InlineData(ItemType.Costume, "936DA01F-9ABD-4d9d-80C7-02AF85C822A8", 1, 1, 1, false)] + [InlineData(ItemType.Material, "15396359-04db-68d5-f24a-d89c18665900", 1, 1, 1, false)] + [InlineData(ItemType.Material, "15396359-04db-68d5-f24a-d89c18665900", 2, 1, 2, false)] + [InlineData(ItemType.Material, "15396359-04db-68d5-f24a-d89c18665900", 2, 2, 3, false)] + public void Execute( + ItemType itemType, + string guid, + int itemCount, + int inventoryCount, + int expectedCount, + bool fromPreviousAction + ) + { + var avatarState = _initialState.GetAvatarState(_avatarAddress); + ITradableItem tradableItem; + var itemId = new Guid(guid); + var orderId = Guid.NewGuid(); + var updateSellOrderId = Guid.NewGuid(); + ItemSubType itemSubType; + const long requiredBlockIndex = Order.ExpirationInterval; + switch (itemType) + { + case ItemType.Equipment: + { + var itemUsable = ItemFactory.CreateItemUsable( + _tableSheets.EquipmentItemSheet.First, + itemId, + requiredBlockIndex); + tradableItem = (ITradableItem)itemUsable; + itemSubType = itemUsable.ItemSubType; + break; + } + + case ItemType.Costume: + { + var costume = ItemFactory.CreateCostume(_tableSheets.CostumeItemSheet.First, itemId); + costume.Update(requiredBlockIndex); + tradableItem = costume; + itemSubType = costume.ItemSubType; + break; + } + + default: + { + var material = ItemFactory.CreateTradableMaterial( + _tableSheets.MaterialItemSheet.OrderedList.First(r => r.ItemSubType == ItemSubType.Hourglass)); + itemSubType = material.ItemSubType; + material.RequiredBlockIndex = requiredBlockIndex; + tradableItem = material; + break; + } + } + + var shardedShopAddress = ShardedShopStateV2.DeriveAddress(itemSubType, orderId); + var shopState = new ShardedShopStateV2(shardedShopAddress); + var order = OrderFactory.Create( + _agentAddress, + _avatarAddress, + orderId, + new FungibleAssetValue(_goldCurrencyState.Currency, 100, 0), + tradableItem.TradableId, + requiredBlockIndex, + itemSubType, + itemCount + ); + + var orderDigestList = new OrderDigestListState(OrderDigestListState.DeriveAddress(_avatarAddress)); + var prevState = _initialState; + + if (inventoryCount > 1) + { + for (int i = 0; i < inventoryCount; i++) + { + // Different RequiredBlockIndex for divide inventory slot. + if (tradableItem is ITradableFungibleItem tradableFungibleItem) + { + var tradable = (TradableMaterial)tradableFungibleItem.Clone(); + tradable.RequiredBlockIndex = tradableItem.RequiredBlockIndex - i; + avatarState.inventory.AddItem(tradable, 2 - i); + } + } + } + else + { + avatarState.inventory.AddItem((ItemBase)tradableItem, itemCount); + } + + var sellItem = order.Sell(avatarState); + var orderDigest = order.Digest(avatarState, _tableSheets.CostumeStatSheet); + shopState.Add(orderDigest, requiredBlockIndex); + orderDigestList.Add(orderDigest); + + Assert.True(avatarState.inventory.TryGetLockedItem(new OrderLock(orderId), out _)); + + Assert.Equal(inventoryCount, avatarState.inventory.Items.Count); + Assert.Equal(expectedCount, avatarState.inventory.Items.Sum(i => i.count)); + + Assert.Single(shopState.OrderDigestList); + Assert.Single(orderDigestList.OrderDigestList); + + Assert.Equal(requiredBlockIndex * 2, sellItem.RequiredBlockIndex); + + if (fromPreviousAction) + { + prevState = prevState.SetState(_avatarAddress, avatarState.Serialize()); + } + else + { + prevState = prevState + .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()); + } + + prevState = prevState + .SetState(Addresses.GetItemAddress(itemId), sellItem.Serialize()) + .SetState(Order.DeriveAddress(order.OrderId), order.Serialize()) + .SetState(orderDigestList.Address, orderDigestList.Serialize()) + .SetState(shardedShopAddress, shopState.Serialize()); + + var currencyState = prevState.GetGoldCurrency(); + var price = new FungibleAssetValue(currencyState, ProductPrice, 0); + + var updateSellInfo = new UpdateSellInfo( + orderId, + updateSellOrderId, + itemId, + itemSubType, + price, + itemCount + ); + + var action = new UpdateSell3 + { + sellerAvatarAddress = _avatarAddress, + updateSellInfos = new[] { updateSellInfo }, + }; + + var nextState = action.Execute(new ActionContext + { + BlockIndex = 101, + PreviousState = prevState, + RandomSeed = 0, + Signer = _agentAddress, + }); + + var updateSellShopAddress = ShardedShopStateV2.DeriveAddress(itemSubType, updateSellOrderId); + var nextShopState = new ShardedShopStateV2((Dictionary)nextState.GetState(updateSellShopAddress)); + Assert.Equal(1, nextShopState.OrderDigestList.Count); + Assert.NotEqual(orderId, nextShopState.OrderDigestList.First().OrderId); + Assert.Equal(updateSellOrderId, nextShopState.OrderDigestList.First().OrderId); + Assert.Equal(itemId, nextShopState.OrderDigestList.First().TradableId); + Assert.Equal(requiredBlockIndex + 101, nextShopState.OrderDigestList.First().ExpiredBlockIndex); + } + + [Fact] + public void Execute_Throw_ListEmptyException() + { + var action = new UpdateSell3 + { + sellerAvatarAddress = _avatarAddress, + updateSellInfos = new List(), + }; + + Assert.Throws(() => action.Execute(new ActionContext + { + BlockIndex = 0, + PreviousState = new Account(MockState.Empty), + Signer = _agentAddress, + })); + } + + [Fact] + public void Execute_Throw_FailedLoadStateException() + { + var updateSellInfo = new UpdateSellInfo( + default, + default, + default, + ItemSubType.Food, + 0 * _currency, + 1); + + var action = new UpdateSell3 + { + sellerAvatarAddress = _avatarAddress, + updateSellInfos = new[] { updateSellInfo }, + }; + + Assert.Throws(() => action.Execute(new ActionContext + { + BlockIndex = 0, + PreviousState = new Account(MockState.Empty), + 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 updateSellInfo = new UpdateSellInfo( + default, + default, + default, + ItemSubType.Food, + 0 * _currency, + 1); + + var action = new UpdateSell3 + { + sellerAvatarAddress = _avatarAddress, + updateSellInfos = new[] { updateSellInfo }, + }; + + Assert.Throws(() => action.Execute(new ActionContext + { + BlockIndex = 0, + PreviousState = _initialState, + Signer = _agentAddress, + })); + } + + [Fact] + public void Execute_Throw_InvalidPriceException() + { + var avatarState = new AvatarState(_avatarState) + { + worldInformation = new WorldInformation( + 0, + _tableSheets.WorldSheet, + GameConfig.RequireClearedStageLevel.ActionsInShop + ), + }; + var digestListAddress = OrderDigestListState.DeriveAddress(_avatarAddress); + var digestList = new OrderDigestListState(digestListAddress); + _initialState = _initialState + .SetState(_avatarAddress, avatarState.Serialize()) + .SetState(digestListAddress, digestList.Serialize()); + + var updateSellInfo = new UpdateSellInfo( + default, + default, + default, + default, + -1 * _currency, + 1); + + var action = new UpdateSell3 + { + sellerAvatarAddress = _avatarAddress, + updateSellInfos = new[] { updateSellInfo }, + }; + + Assert.Throws(() => action.Execute(new ActionContext + { + BlockIndex = 0, + PreviousState = _initialState, + Signer = _agentAddress, + })); + } + + [Fact] + public void Execute_ActionObsoletedException() + { + var updateSellInfo = new UpdateSellInfo( + default, + default, + default, + default, + -1 * _currency, + 1); + + var action = new UpdateSell3 + { + sellerAvatarAddress = _avatarAddress, + updateSellInfos = new[] { updateSellInfo }, + }; + + Assert.Throws(() => action.Execute(new ActionContext + { + BlockIndex = ActionObsoleteConfig.V100320ObsoleteIndex + 1, + PreviousState = _initialState, + Signer = _agentAddress, + })); + } + } +} diff --git a/.Lib9c.Tests/Action/UpdateSell4Test.cs b/.Lib9c.Tests/Action/UpdateSell4Test.cs new file mode 100644 index 0000000000..15a963f373 --- /dev/null +++ b/.Lib9c.Tests/Action/UpdateSell4Test.cs @@ -0,0 +1,408 @@ +namespace Lib9c.Tests.Action +{ + using System; + using System.Collections.Generic; + using System.Linq; + using Bencodex.Types; + using Lib9c.Model.Order; + using Libplanet.Action.State; + using Libplanet.Crypto; + using Libplanet.Types.Assets; + using Nekoyume; + using Nekoyume.Action; + using Nekoyume.Battle; + using Nekoyume.Model; + using Nekoyume.Model.Item; + using Nekoyume.Model.State; + using Serilog; + using Xunit; + using Xunit.Abstractions; + using static Lib9c.SerializeKeys; + + public class UpdateSell4Test + { + private const long ProductPrice = 100; + private readonly Address _agentAddress; + private readonly Address _avatarAddress; + private readonly Currency _currency; + private readonly AvatarState _avatarState; + private readonly TableSheets _tableSheets; + private readonly GoldCurrencyState _goldCurrencyState; + private IAccount _initialState; + + public UpdateSell4Test(ITestOutputHelper outputHelper) + { + Log.Logger = new LoggerConfiguration() + .MinimumLevel.Verbose() + .WriteTo.TestOutput(outputHelper) + .CreateLogger(); + + _initialState = new Account(MockState.Empty); + var sheets = TableSheetsImporter.ImportSheets(); + foreach (var (key, value) in sheets) + { + _initialState = _initialState + .SetState(Addresses.TableSheet.Derive(key), value.Serialize()); + } + + _tableSheets = new TableSheets(sheets); + +#pragma warning disable CS0618 + // Use of obsolete method Currency.Legacy(): https://github.com/planetarium/lib9c/discussions/1319 + _currency = Currency.Legacy("NCG", 2, null); +#pragma warning restore CS0618 + _goldCurrencyState = new GoldCurrencyState(_currency); + + var shopState = new ShopState(); + + _agentAddress = new PrivateKey().Address; + var agentState = new AgentState(_agentAddress); + _avatarAddress = new PrivateKey().Address; + var rankingMapAddress = new PrivateKey().Address; + _avatarState = new AvatarState( + _avatarAddress, + _agentAddress, + 0, + _tableSheets.GetAvatarSheets(), + new GameConfigState(), + rankingMapAddress) + { + worldInformation = new WorldInformation( + 0, + _tableSheets.WorldSheet, + GameConfig.RequireClearedStageLevel.ActionsInShop), + }; + agentState.avatarAddresses[0] = _avatarAddress; + + _initialState = _initialState + .SetState(GoldCurrencyState.Address, _goldCurrencyState.Serialize()) + .SetState(Addresses.Shop, shopState.Serialize()) + .SetState(_agentAddress, agentState.Serialize()) + .SetState(_avatarAddress, _avatarState.Serialize()); + } + + [Theory] + [InlineData(ItemType.Equipment, "F9168C5E-CEB2-4faa-B6BF-329BF39FA1E4", 1, 1, 1, true)] + [InlineData(ItemType.Costume, "936DA01F-9ABD-4d9d-80C7-02AF85C822A8", 1, 1, 1, true)] + [InlineData(ItemType.Material, "15396359-04db-68d5-f24a-d89c18665900", 1, 1, 1, true)] + [InlineData(ItemType.Material, "15396359-04db-68d5-f24a-d89c18665900", 2, 1, 2, true)] + [InlineData(ItemType.Material, "15396359-04db-68d5-f24a-d89c18665900", 2, 2, 3, true)] + [InlineData(ItemType.Equipment, "F9168C5E-CEB2-4faa-B6BF-329BF39FA1E4", 1, 1, 1, false)] + [InlineData(ItemType.Costume, "936DA01F-9ABD-4d9d-80C7-02AF85C822A8", 1, 1, 1, false)] + [InlineData(ItemType.Material, "15396359-04db-68d5-f24a-d89c18665900", 1, 1, 1, false)] + [InlineData(ItemType.Material, "15396359-04db-68d5-f24a-d89c18665900", 2, 1, 2, false)] + [InlineData(ItemType.Material, "15396359-04db-68d5-f24a-d89c18665900", 2, 2, 3, false)] + public void Execute( + ItemType itemType, + string guid, + int itemCount, + int inventoryCount, + int expectedCount, + bool fromPreviousAction + ) + { + var avatarState = _initialState.GetAvatarState(_avatarAddress); + ITradableItem tradableItem; + var itemId = new Guid(guid); + var orderId = Guid.NewGuid(); + var updateSellOrderId = Guid.NewGuid(); + ItemSubType itemSubType; + const long requiredBlockIndex = Order.ExpirationInterval; + switch (itemType) + { + case ItemType.Equipment: + { + var itemUsable = ItemFactory.CreateItemUsable( + _tableSheets.EquipmentItemSheet.First, + itemId, + requiredBlockIndex); + tradableItem = (ITradableItem)itemUsable; + itemSubType = itemUsable.ItemSubType; + break; + } + + case ItemType.Costume: + { + var costume = ItemFactory.CreateCostume(_tableSheets.CostumeItemSheet.First, itemId); + costume.Update(requiredBlockIndex); + tradableItem = costume; + itemSubType = costume.ItemSubType; + break; + } + + default: + { + var material = ItemFactory.CreateTradableMaterial( + _tableSheets.MaterialItemSheet.OrderedList.First(r => r.ItemSubType == ItemSubType.Hourglass)); + itemSubType = material.ItemSubType; + material.RequiredBlockIndex = requiredBlockIndex; + tradableItem = material; + break; + } + } + + var shardedShopAddress = ShardedShopStateV2.DeriveAddress(itemSubType, orderId); + var shopState = new ShardedShopStateV2(shardedShopAddress); + var order = OrderFactory.Create( + _agentAddress, + _avatarAddress, + orderId, + new FungibleAssetValue(_goldCurrencyState.Currency, 100, 0), + tradableItem.TradableId, + requiredBlockIndex, + itemSubType, + itemCount + ); + + var orderDigestList = new OrderDigestListState(OrderDigestListState.DeriveAddress(_avatarAddress)); + var prevState = _initialState; + + if (inventoryCount > 1) + { + for (int i = 0; i < inventoryCount; i++) + { + // Different RequiredBlockIndex for divide inventory slot. + if (tradableItem is ITradableFungibleItem tradableFungibleItem) + { + var tradable = (TradableMaterial)tradableFungibleItem.Clone(); + tradable.RequiredBlockIndex = tradableItem.RequiredBlockIndex - i; + avatarState.inventory.AddItem(tradable, 2 - i); + } + } + } + else + { + avatarState.inventory.AddItem((ItemBase)tradableItem, itemCount); + } + + var sellItem = order.Sell(avatarState); + var orderDigest = order.Digest(avatarState, _tableSheets.CostumeStatSheet); + shopState.Add(orderDigest, requiredBlockIndex); + orderDigestList.Add(orderDigest); + + Assert.True(avatarState.inventory.TryGetLockedItem(new OrderLock(orderId), out _)); + + Assert.Equal(inventoryCount, avatarState.inventory.Items.Count); + Assert.Equal(expectedCount, avatarState.inventory.Items.Sum(i => i.count)); + + Assert.Single(shopState.OrderDigestList); + Assert.Single(orderDigestList.OrderDigestList); + + Assert.Equal(requiredBlockIndex * 2, sellItem.RequiredBlockIndex); + + if (fromPreviousAction) + { + prevState = prevState.SetState(_avatarAddress, avatarState.Serialize()); + } + else + { + prevState = prevState + .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()); + } + + prevState = prevState + .SetState(Addresses.GetItemAddress(itemId), sellItem.Serialize()) + .SetState(Order.DeriveAddress(order.OrderId), order.Serialize()) + .SetState(orderDigestList.Address, orderDigestList.Serialize()) + .SetState(shardedShopAddress, shopState.Serialize()); + + var currencyState = prevState.GetGoldCurrency(); + var price = new FungibleAssetValue(currencyState, ProductPrice, 0); + + var updateSellInfo = new UpdateSellInfo( + orderId, + updateSellOrderId, + itemId, + itemSubType, + price, + itemCount + ); + + var action = new UpdateSell4 + { + sellerAvatarAddress = _avatarAddress, + updateSellInfos = new[] { updateSellInfo }, + }; + + var nextState = action.Execute(new ActionContext + { + BlockIndex = 101, + PreviousState = prevState, + RandomSeed = 0, + Signer = _agentAddress, + }); + + var updateSellShopAddress = ShardedShopStateV2.DeriveAddress(itemSubType, updateSellOrderId); + var nextShopState = new ShardedShopStateV2((Dictionary)nextState.GetState(updateSellShopAddress)); + Assert.Equal(1, nextShopState.OrderDigestList.Count); + Assert.NotEqual(orderId, nextShopState.OrderDigestList.First().OrderId); + Assert.Equal(updateSellOrderId, nextShopState.OrderDigestList.First().OrderId); + Assert.Equal(itemId, nextShopState.OrderDigestList.First().TradableId); + Assert.Equal(requiredBlockIndex + 101, nextShopState.OrderDigestList.First().ExpiredBlockIndex); + } + + [Fact] + public void Execute_Throw_ListEmptyException() + { + var action = new UpdateSell4 + { + sellerAvatarAddress = _avatarAddress, + updateSellInfos = new List(), + }; + + Assert.Throws(() => action.Execute(new ActionContext + { + BlockIndex = 0, + PreviousState = new Account(MockState.Empty), + Signer = _agentAddress, + })); + } + + [Fact] + public void Execute_Throw_FailedLoadStateException() + { + var updateSellInfo = new UpdateSellInfo( + default, + default, + default, + ItemSubType.Food, + 0 * _currency, + 1); + + var action = new UpdateSell4 + { + sellerAvatarAddress = _avatarAddress, + updateSellInfos = new[] { updateSellInfo }, + }; + + Assert.Throws(() => action.Execute(new ActionContext + { + BlockIndex = 0, + PreviousState = new Account(MockState.Empty), + 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 updateSellInfo = new UpdateSellInfo( + default, + default, + default, + ItemSubType.Food, + 0 * _currency, + 1); + + var action = new UpdateSell4 + { + sellerAvatarAddress = _avatarAddress, + updateSellInfos = new[] { updateSellInfo }, + }; + + Assert.Throws(() => action.Execute(new ActionContext + { + BlockIndex = 0, + PreviousState = _initialState, + Signer = _agentAddress, + })); + } + + [Fact] + public void Execute_Throw_InvalidPriceException() + { + var avatarState = new AvatarState(_avatarState) + { + worldInformation = new WorldInformation( + 0, + _tableSheets.WorldSheet, + GameConfig.RequireClearedStageLevel.ActionsInShop + ), + }; + var digestListAddress = OrderDigestListState.DeriveAddress(_avatarAddress); + var digestList = new OrderDigestListState(digestListAddress); + _initialState = _initialState + .SetState(_avatarAddress, avatarState.Serialize()) + .SetState(digestListAddress, digestList.Serialize()); + + var updateSellInfo = new UpdateSellInfo( + default, + default, + default, + default, + -1 * _currency, + 1); + + var action = new UpdateSell4 + { + sellerAvatarAddress = _avatarAddress, + updateSellInfos = new[] { updateSellInfo }, + }; + + Assert.Throws(() => action.Execute(new ActionContext + { + BlockIndex = 0, + PreviousState = _initialState, + Signer = _agentAddress, + })); + } + + [Theory] + [InlineData(100, false)] + [InlineData(1, false)] + [InlineData(101, true)] + public void PurchaseInfos_Capacity(int count, bool exc) + { + var updateSellInfo = new UpdateSellInfo( + default, + default, + default, + default, + -1 * _currency, + 1); + var updateSellInfos = new List(); + for (int i = 0; i < count; i++) + { + updateSellInfos.Add(updateSellInfo); + } + + var action = new UpdateSell4 + { + sellerAvatarAddress = _avatarAddress, + updateSellInfos = updateSellInfos, + }; + if (exc) + { + Assert.Throws(() => action.Execute(new ActionContext + { + BlockIndex = 0, + PreviousState = _initialState, + Signer = _agentAddress, + })); + } + else + { + Assert.Throws(() => action.Execute(new ActionContext + { + BlockIndex = 0, + PreviousState = _initialState, + Signer = _agentAddress, + })); + } + } + } +} diff --git a/.Lib9c.Tests/Lib9c.Tests.csproj b/.Lib9c.Tests/Lib9c.Tests.csproj index dae09cd2ea..fb877391a1 100644 --- a/.Lib9c.Tests/Lib9c.Tests.csproj +++ b/.Lib9c.Tests/Lib9c.Tests.csproj @@ -58,8 +58,4 @@ - - - - diff --git a/Lib9c/Action/ClaimMonsterCollectionReward0.cs b/Lib9c/Action/ClaimMonsterCollectionReward0.cs new file mode 100644 index 0000000000..52692d5a4b --- /dev/null +++ b/Lib9c/Action/ClaimMonsterCollectionReward0.cs @@ -0,0 +1,123 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using Bencodex.Types; +using Lib9c.Abstractions; +using Libplanet.Action; +using Libplanet.Action.State; +using Libplanet.Crypto; +using Libplanet.Types.Assets; +using Nekoyume.Model.Item; +using Nekoyume.Model.Mail; +using Nekoyume.Model.State; +using Nekoyume.TableData; +using static Lib9c.SerializeKeys; + +namespace Nekoyume.Action +{ + [Serializable] + [ActionObsolete(ActionObsoleteConfig.V200020AccidentObsoleteIndex)] + [ActionType("claim_monster_collection_reward")] + public class ClaimMonsterCollectionReward0 : GameAction, IClaimMonsterCollectionRewardV1 + { + public Address avatarAddress; + public int collectionRound; + + Address IClaimMonsterCollectionRewardV1.AvatarAddress => avatarAddress; + int IClaimMonsterCollectionRewardV1.CollectionRound => collectionRound; + + public override IAccount Execute(IActionContext context) + { + context.UseGas(1); + IAccount states = context.PreviousState; + Address collectionAddress = MonsterCollectionState0.DeriveAddress(context.Signer, collectionRound); + + CheckObsolete(ActionObsoleteConfig.V100080ObsoleteIndex, context); + + if (!states.TryGetAgentAvatarStates(context.Signer, avatarAddress, out AgentState agentState, out AvatarState avatarState)) + { + throw new FailedLoadStateException($"Aborted as the avatar state of the signer failed to load."); + } + + if (!states.TryGetState(collectionAddress, out Dictionary stateDict)) + { + throw new FailedLoadStateException($"Aborted as the monster collection state failed to load."); + } + + MonsterCollectionState0 monsterCollectionState = new MonsterCollectionState0(stateDict); + if (monsterCollectionState.End) + { + throw new MonsterCollectionExpiredException($"{collectionAddress} has already expired on {monsterCollectionState.ExpiredBlockIndex}"); + } + + if (!monsterCollectionState.CanReceive(context.BlockIndex)) + { + throw new RequiredBlockIndexException( + $"{collectionAddress} is not available yet; it will be available after {Math.Max(monsterCollectionState.StartedBlockIndex, monsterCollectionState.ReceivedBlockIndex) + MonsterCollectionState0.RewardInterval}"); + } + + long rewardLevel = monsterCollectionState.GetRewardLevel(context.BlockIndex); + ItemSheet itemSheet = states.GetItemSheet(); + var random = context.GetRandom(); + for (int i = 0; i < rewardLevel; i++) + { + int level = i + 1; + if (level <= monsterCollectionState.RewardLevel) + { + continue; + } + + List rewards = monsterCollectionState.RewardLevelMap[level]; + Guid id = random.GenerateRandomGuid(); + MonsterCollectionResult result = new MonsterCollectionResult(id, avatarAddress, rewards); + MonsterCollectionMail mail = new MonsterCollectionMail(result, context.BlockIndex, id, context.BlockIndex); + avatarState.Update(mail); + foreach (var rewardInfo in rewards) + { + var row = itemSheet[rewardInfo.ItemId]; + var item = row is MaterialItemSheet.Row materialRow + ? ItemFactory.CreateTradableMaterial(materialRow) + : ItemFactory.CreateItem(row, random); + avatarState.inventory.AddItem2(item, rewardInfo.Quantity); + } + monsterCollectionState.UpdateRewardMap(level, result, context.BlockIndex); + } + + // Return gold at the end of monster collect. + if (rewardLevel == 4) + { + MonsterCollectionSheet monsterCollectionSheet = states.GetSheet(); + Currency currency = states.GetGoldCurrency(); + // Set default gold value. + FungibleAssetValue gold = currency * 0; + for (int i = 0; i < monsterCollectionState.Level; i++) + { + int level = i + 1; + gold += currency * monsterCollectionSheet[level].RequiredGold; + } + agentState.IncreaseCollectionRound(); + states = states.SetState(context.Signer, agentState.Serialize()); + if (gold > currency * 0) + { + states = states.TransferAsset(context, collectionAddress, context.Signer, gold); + } + } + + return states + .SetState(avatarAddress, avatarState.Serialize()) + .SetState(collectionAddress, monsterCollectionState.Serialize()); + } + + protected override IImmutableDictionary PlainValueInternal => new Dictionary + { + [AvatarAddressKey] = avatarAddress.Serialize(), + [MonsterCollectionRoundKey] = collectionRound.Serialize(), + }.ToImmutableDictionary(); + + protected override void LoadPlainValueInternal(IImmutableDictionary plainValue) + { + avatarAddress = plainValue[AvatarAddressKey].ToAddress(); + collectionRound = plainValue[MonsterCollectionRoundKey].ToInteger(); + } + } +} diff --git a/Lib9c/Action/ClaimMonsterCollectionReward2.cs b/Lib9c/Action/ClaimMonsterCollectionReward2.cs new file mode 100644 index 0000000000..00d2ef3603 --- /dev/null +++ b/Lib9c/Action/ClaimMonsterCollectionReward2.cs @@ -0,0 +1,97 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using Bencodex.Types; +using Lib9c.Abstractions; +using Libplanet.Action; +using Libplanet.Action.State; +using Libplanet.Crypto; +using Libplanet.Types.Assets; +using Nekoyume.Model.Item; +using Nekoyume.Model.Mail; +using Nekoyume.Model.State; +using Nekoyume.TableData; +using static Lib9c.SerializeKeys; + +namespace Nekoyume.Action +{ + [Serializable] + [ActionObsolete(ActionObsoleteConfig.V200020AccidentObsoleteIndex)] + [ActionType("claim_monster_collection_reward2")] + public class ClaimMonsterCollectionReward2 : GameAction, IClaimMonsterCollectionRewardV2 + { + public Address avatarAddress; + + Address IClaimMonsterCollectionRewardV2.AvatarAddress => avatarAddress; + + public override IAccount Execute(IActionContext context) + { + context.UseGas(1); + IAccount states = context.PreviousState; + Address inventoryAddress = avatarAddress.Derive(LegacyInventoryKey); + Address worldInformationAddress = avatarAddress.Derive(LegacyWorldInformationKey); + Address questListAddress = avatarAddress.Derive(LegacyQuestListKey); + + CheckObsolete(ActionObsoleteConfig.V100080ObsoleteIndex, context); + + if (!states.TryGetAgentAvatarStatesV2(context.Signer, avatarAddress, out AgentState agentState, out AvatarState avatarState, out _)) + { + throw new FailedLoadStateException($"Aborted as the avatar state of the signer failed to load."); + } + + Address collectionAddress = MonsterCollectionState.DeriveAddress(context.Signer, agentState.MonsterCollectionRound); + + if (!states.TryGetState(collectionAddress, out Dictionary stateDict)) + { + throw new FailedLoadStateException($"Aborted as the monster collection state failed to load."); + } + + var monsterCollectionState = new MonsterCollectionState(stateDict); + List rewards = + monsterCollectionState.CalculateRewards( + states.GetSheet(), + context.BlockIndex + ); + + if (rewards.Count == 0) + { + throw new RequiredBlockIndexException($"{collectionAddress} is not available yet"); + } + + var random = context.GetRandom(); + Guid id = random.GenerateRandomGuid(); + var result = new MonsterCollectionResult(id, avatarAddress, rewards); + var mail = new MonsterCollectionMail(result, context.BlockIndex, id, context.BlockIndex); + avatarState.Update(mail); + + ItemSheet itemSheet = states.GetItemSheet(); + foreach (MonsterCollectionRewardSheet.RewardInfo rewardInfo in rewards) + { + ItemSheet.Row row = itemSheet[rewardInfo.ItemId]; + ItemBase item = row is MaterialItemSheet.Row materialRow + ? ItemFactory.CreateTradableMaterial(materialRow) + : ItemFactory.CreateItem(row, random); + avatarState.inventory.AddItem2(item, rewardInfo.Quantity); + } + monsterCollectionState.Claim(context.BlockIndex); + + return states + .SetState(avatarAddress, avatarState.SerializeV2()) + .SetState(inventoryAddress, avatarState.inventory.Serialize()) + .SetState(worldInformationAddress, avatarState.worldInformation.Serialize()) + .SetState(questListAddress, avatarState.questList.Serialize()) + .SetState(collectionAddress, monsterCollectionState.Serialize()); + } + + protected override IImmutableDictionary PlainValueInternal => new Dictionary + { + [AvatarAddressKey] = avatarAddress.Serialize(), + }.ToImmutableDictionary(); + + protected override void LoadPlainValueInternal(IImmutableDictionary plainValue) + { + avatarAddress = plainValue[AvatarAddressKey].ToAddress(); + } + } +} diff --git a/Lib9c/Action/ClaimStakeReward1.cs b/Lib9c/Action/ClaimStakeReward1.cs new file mode 100644 index 0000000000..350f7a3672 --- /dev/null +++ b/Lib9c/Action/ClaimStakeReward1.cs @@ -0,0 +1,116 @@ +using System.Collections.Immutable; +using Bencodex.Types; +using Lib9c.Abstractions; +using Libplanet.Action; +using Libplanet.Action.State; +using Libplanet.Crypto; +using Nekoyume.Extensions; +using Nekoyume.Model.Item; +using Nekoyume.Model.State; +using Nekoyume.TableData; +using static Lib9c.SerializeKeys; + +namespace Nekoyume.Action +{ + [ActionType("claim_stake_reward")] + [ActionObsolete(ObsoleteIndex)] + public class ClaimStakeReward1 : GameAction, IClaimStakeReward, IClaimStakeRewardV1 + { + public const long ObsoleteIndex = ActionObsoleteConfig.V200030ObsoleteIndex; + + internal Address AvatarAddress { get; private set; } + + Address IClaimStakeRewardV1.AvatarAddress => AvatarAddress; + + public ClaimStakeReward1(Address avatarAddress) + { + AvatarAddress = avatarAddress; + } + + public ClaimStakeReward1() : base() + { + } + + public override IAccount Execute(IActionContext context) + { + context.UseGas(1); + CheckObsolete(ObsoleteIndex, context); + var states = context.PreviousState; + if (!states.TryGetStakeState(context.Signer, out StakeState stakeState)) + { + throw new FailedLoadStateException(nameof(StakeState)); + } + + var sheets = states.GetSheets(sheetTypes: new[] + { + typeof(StakeRegularRewardSheet), + typeof(ConsumableItemSheet), + typeof(CostumeItemSheet), + typeof(EquipmentItemSheet), + typeof(MaterialItemSheet), + }); + + var stakeRegularRewardSheet = sheets.GetSheet(); + + var currency = states.GetGoldCurrency(); + var stakedAmount = states.GetBalance(stakeState.address, currency); + + if (!stakeState.IsClaimable(context.BlockIndex)) + { + throw new RequiredBlockIndexException(); + } + + var avatarState = states.GetAvatarStateV2(AvatarAddress); + int level = stakeRegularRewardSheet.FindLevelByStakedAmount(context.Signer, stakedAmount); + var rewards = stakeRegularRewardSheet[level].Rewards; + ItemSheet itemSheet = sheets.GetItemSheet(); + var accumulatedRewards = stakeState.CalculateAccumulatedItemRewardsV1(context.BlockIndex); + var random = context.GetRandom(); + foreach (var reward in rewards) + { + var (quantity, _) = stakedAmount.DivRem(currency * reward.Rate); + if (quantity < 1) + { + // If the quantity is zero, it doesn't add the item into inventory. + continue; + } + + ItemSheet.Row row = itemSheet[reward.ItemId]; + ItemBase item = row is MaterialItemSheet.Row materialRow + ? ItemFactory.CreateTradableMaterial(materialRow) + : ItemFactory.CreateItem(row, random); + avatarState.inventory.AddItem(item, (int) quantity * accumulatedRewards); + } + + if (states.TryGetSheet( + out var stakeRegularFixedRewardSheet)) + { + var fixedRewards = stakeRegularFixedRewardSheet[level].Rewards; + foreach (var reward in fixedRewards) + { + ItemSheet.Row row = itemSheet[reward.ItemId]; + ItemBase item = row is MaterialItemSheet.Row materialRow + ? ItemFactory.CreateTradableMaterial(materialRow) + : ItemFactory.CreateItem(row, random); + avatarState.inventory.AddItem(item, reward.Count * accumulatedRewards); + } + } + + stakeState.Claim(context.BlockIndex); + return states.SetState(stakeState.address, stakeState.Serialize()) + .SetState(avatarState.address, avatarState.SerializeV2()) + .SetState( + avatarState.address.Derive(LegacyInventoryKey), + avatarState.inventory.Serialize()); + } + + protected override IImmutableDictionary PlainValueInternal => + ImmutableDictionary.Empty + .Add(AvatarAddressKey, AvatarAddress.Serialize()); + + protected override void LoadPlainValueInternal(IImmutableDictionary plainValue) + { + AvatarAddress = plainValue[AvatarAddressKey].ToAddress(); + } + } +} diff --git a/Lib9c/Action/ClaimStakeReward3.cs b/Lib9c/Action/ClaimStakeReward3.cs new file mode 100644 index 0000000000..d70517af59 --- /dev/null +++ b/Lib9c/Action/ClaimStakeReward3.cs @@ -0,0 +1,302 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using Bencodex.Types; +using Lib9c.Abstractions; +using Libplanet.Action; +using Libplanet.Action.State; +using Libplanet.Crypto; +using Libplanet.Types.Assets; +using Nekoyume.Extensions; +using Nekoyume.Helper; +using Nekoyume.Model.Item; +using Nekoyume.Model.State; +using Nekoyume.TableData; +using static Lib9c.SerializeKeys; + +namespace Nekoyume.Action +{ + /// + /// Hard forked at https://github.com/planetarium/lib9c/pull/1458 + /// + [ActionType(ActionTypeText)] + [ActionObsolete(ObsoleteBlockIndex)] + public class ClaimStakeReward3 : GameAction, IClaimStakeReward, IClaimStakeRewardV1 + { + private const string ActionTypeText = "claim_stake_reward3"; + public const long ObsoleteBlockIndex = ActionObsoleteConfig.V200030ObsoleteIndex; + + /// + /// This is the version 1 of the stake reward sheet. + /// The version 1 is used for calculating the reward for the stake + /// that is accumulated before the table patch. + /// + public static class V1 + { + public const int MaxLevel = 5; + public const string StakeRegularRewardSheetCsv = + @"level,required_gold,item_id,rate,type +1,50,400000,10,Item +1,50,500000,800,Item +1,50,20001,6000,Rune +2,500,400000,8,Item +2,500,500000,800,Item +2,500,20001,6000,Rune +3,5000,400000,5,Item +3,5000,500000,800,Item +3,5000,20001,6000,Rune +4,50000,400000,5,Item +4,50000,500000,800,Item +4,50000,20001,6000,Rune +5,500000,400000,5,Item +5,500000,500000,800,Item +5,500000,20001,6000,Rune"; + + public const string StakeRegularFixedRewardSheetCsv = + @"level,required_gold,item_id,count +1,50,500000,1 +2,500,500000,2 +3,5000,500000,2 +4,50000,500000,2 +5,500000,500000,2"; + } + + // NOTE: Use this when the or + // is patched. + // public static class V2 + // { + // } + + private readonly ImmutableSortedDictionary< + string, + ImmutableSortedDictionary> _stakeRewardHistoryDict; + + internal Address AvatarAddress { get; private set; } + + Address IClaimStakeRewardV1.AvatarAddress => AvatarAddress; + + public ClaimStakeReward3(Address avatarAddress) : this() + { + AvatarAddress = avatarAddress; + } + + public ClaimStakeReward3() + { + var regularRewardSheetV1 = new StakeRegularRewardSheet(); + regularRewardSheetV1.Set(V1.StakeRegularRewardSheetCsv); + var fixedRewardSheetV1 = new StakeRegularFixedRewardSheet(); + fixedRewardSheetV1.Set(V1.StakeRegularFixedRewardSheetCsv); + _stakeRewardHistoryDict = + new Dictionary> + { + { + "StakeRegularRewardSheet", new Dictionary + { + { 1, regularRewardSheetV1 }, + }.ToImmutableSortedDictionary() + }, + { + "StakeRegularFixedRewardSheet", + new Dictionary + { + { 1, fixedRewardSheetV1 } + }.ToImmutableSortedDictionary() + }, + }.ToImmutableSortedDictionary(); + } + + private IAccount ProcessReward( + IActionContext context, + IAccount states, + ref AvatarState avatarState, + ItemSheet itemSheet, + FungibleAssetValue stakedAmount, + int itemRewardStep, + int runeRewardStep, + List fixedReward, + List regularReward) + { + var stakedCurrency = stakedAmount.Currency; + + // Regular Reward + var random = context.GetRandom(); + foreach (var reward in regularReward) + { + switch (reward.Type) + { + case StakeRegularRewardSheet.StakeRewardType.Item: + var (quantity, _) = stakedAmount.DivRem(stakedCurrency * reward.Rate); + if (quantity < 1) + { + // If the quantity is zero, it doesn't add the item into inventory. + continue; + } + + ItemSheet.Row row = itemSheet[reward.ItemId]; + ItemBase item = row is MaterialItemSheet.Row materialRow + ? ItemFactory.CreateTradableMaterial(materialRow) + : ItemFactory.CreateItem(row, random); + avatarState.inventory.AddItem(item, (int)quantity * itemRewardStep); + break; + case StakeRegularRewardSheet.StakeRewardType.Rune: + var runeReward = runeRewardStep * + RuneHelper.CalculateStakeReward(stakedAmount, reward.Rate); + if (runeReward < 1 * RuneHelper.StakeRune) + { + continue; + } + + states = states.MintAsset(context, AvatarAddress, runeReward); + break; + default: + break; + } + } + + // Fixed Reward + foreach (var reward in fixedReward) + { + ItemSheet.Row row = itemSheet[reward.ItemId]; + ItemBase item = row is MaterialItemSheet.Row materialRow + ? ItemFactory.CreateTradableMaterial(materialRow) + : ItemFactory.CreateItem(row, random); + avatarState.inventory.AddItem(item, reward.Count * itemRewardStep); + } + + return states; + } + + public override IAccount Execute(IActionContext context) + { + context.UseGas(1); + + CheckActionAvailable(ClaimStakeReward2.ObsoletedIndex, context); + CheckObsolete(ObsoleteBlockIndex, context); + + var states = context.PreviousState; + var addressesHex = GetSignerAndOtherAddressesHex(context, AvatarAddress); + if (!states.TryGetStakeState(context.Signer, out StakeState stakeState)) + { + throw new FailedLoadStateException( + ActionTypeText, + addressesHex, + typeof(StakeState), + StakeState.DeriveAddress(context.Signer)); + } + + if (!stakeState.IsClaimable(context.BlockIndex, out _, out _)) + { + throw new RequiredBlockIndexException( + ActionTypeText, + addressesHex, + context.BlockIndex); + } + + if (!states.TryGetAvatarStateV2( + context.Signer, + AvatarAddress, + out var avatarState, + out var migrationRequired)) + { + throw new FailedLoadStateException( + ActionTypeText, + addressesHex, + typeof(AvatarState), + AvatarAddress); + } + + var sheets = states.GetSheets(sheetTypes: new[] + { + typeof(StakeRegularRewardSheet), + typeof(ConsumableItemSheet), + typeof(CostumeItemSheet), + typeof(EquipmentItemSheet), + typeof(MaterialItemSheet), + }); + + var currency = states.GetGoldCurrency(); + var stakedAmount = states.GetBalance(stakeState.address, currency); + var stakeRegularRewardSheet = sheets.GetSheet(); + var level = + stakeRegularRewardSheet.FindLevelByStakedAmount(context.Signer, stakedAmount); + var itemSheet = sheets.GetItemSheet(); + stakeState.CalculateAccumulatedItemRewardsV1( + context.BlockIndex, + out var itemV1Step, + out var itemV2Step); + stakeState.CalculateAccumulatedRuneRewardsV1( + context.BlockIndex, + out var runeV1Step, + out var runeV2Step); + if (itemV1Step > 0) + { + var v1Level = Math.Min(level, V1.MaxLevel); + var regularFixedSheetV1Row = (StakeRegularFixedRewardSheet)_stakeRewardHistoryDict[ + "StakeRegularFixedRewardSheet"][1]; + var fixedRewardV1 = regularFixedSheetV1Row[v1Level].Rewards; + var regularSheetV1Row = (StakeRegularRewardSheet)_stakeRewardHistoryDict[ + "StakeRegularRewardSheet"][1]; + var regularRewardV1 = regularSheetV1Row[v1Level].Rewards; + states = ProcessReward( + context, + states, + ref avatarState, + itemSheet, + stakedAmount, + itemV1Step, + runeV1Step, + fixedRewardV1, + regularRewardV1); + } + + if (itemV2Step > 0) + { + var regularFixedReward = + states.TryGetSheet(out var fixedRewardSheet) + ? fixedRewardSheet[level].Rewards + : new List(); + var regularReward = sheets.GetSheet()[level].Rewards; + states = ProcessReward( + context, + states, + ref avatarState, + itemSheet, + stakedAmount, + itemV2Step, + runeV2Step, + regularFixedReward, + regularReward); + } + + stakeState.Claim(context.BlockIndex); + + if (migrationRequired) + { + states = states + .SetState(avatarState.address, avatarState.SerializeV2()) + .SetState( + avatarState.address.Derive(LegacyWorldInformationKey), + avatarState.worldInformation.Serialize()) + .SetState( + avatarState.address.Derive(LegacyQuestListKey), + avatarState.questList.Serialize()); + } + + return states + .SetState(stakeState.address, stakeState.Serialize()) + .SetState( + avatarState.address.Derive(LegacyInventoryKey), + avatarState.inventory.Serialize()); + } + + protected override IImmutableDictionary PlainValueInternal => + ImmutableDictionary.Empty + .Add(AvatarAddressKey, AvatarAddress.Serialize()); + + protected override void LoadPlainValueInternal( + IImmutableDictionary plainValue) + { + AvatarAddress = plainValue[AvatarAddressKey].ToAddress(); + } + } +} diff --git a/Lib9c/Action/ClaimStakeReward4.cs b/Lib9c/Action/ClaimStakeReward4.cs new file mode 100644 index 0000000000..b308bfe3d2 --- /dev/null +++ b/Lib9c/Action/ClaimStakeReward4.cs @@ -0,0 +1,330 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using Bencodex.Types; +using Lib9c; +using Lib9c.Abstractions; +using Libplanet.Action; +using Libplanet.Action.State; +using Libplanet.Crypto; +using Libplanet.Types.Assets; +using Nekoyume.Extensions; +using Nekoyume.Helper; +using Nekoyume.Model.Item; +using Nekoyume.Model.State; +using Nekoyume.TableData; +using static Lib9c.SerializeKeys; + +namespace Nekoyume.Action +{ + /// + /// Hard forked at https://github.com/planetarium/lib9c/pull/1458 + /// + [ActionType(ActionTypeText)] + [ActionObsolete(ObsoleteBlockIndex)] + public class ClaimStakeReward4 : GameAction, IClaimStakeReward, IClaimStakeRewardV1 + { + private const string ActionTypeText = "claim_stake_reward4"; + public const long ObsoleteBlockIndex = ActionObsoleteConfig.V200031ObsoleteIndex; + + /// + /// This is the version 1 of the stake reward sheet. + /// The version 1 is used for calculating the reward for the stake + /// that is accumulated before the table patch. + /// + public static class V1 + { + public const int MaxLevel = 5; + + public const string StakeRegularRewardSheetCsv = + @"level,required_gold,item_id,rate,type +1,50,400000,10,Item +1,50,500000,800,Item +1,50,20001,6000,Rune +2,500,400000,8,Item +2,500,500000,800,Item +2,500,20001,6000,Rune +3,5000,400000,5,Item +3,5000,500000,800,Item +3,5000,20001,6000,Rune +4,50000,400000,5,Item +4,50000,500000,800,Item +4,50000,20001,6000,Rune +5,500000,400000,5,Item +5,500000,500000,800,Item +5,500000,20001,6000,Rune"; + + public const string StakeRegularFixedRewardSheetCsv = + @"level,required_gold,item_id,count +1,50,500000,1 +2,500,500000,2 +3,5000,500000,2 +4,50000,500000,2 +5,500000,500000,2"; + + private static StakeRegularRewardSheet _stakeRegularRewardSheet; + private static StakeRegularFixedRewardSheet _stakeRegularFixedRewardSheet; + + public static StakeRegularRewardSheet StakeRegularRewardSheet + { + get + { + if (_stakeRegularRewardSheet is null) + { + _stakeRegularRewardSheet = new StakeRegularRewardSheet(); + _stakeRegularRewardSheet.Set(StakeRegularRewardSheetCsv); + } + + return _stakeRegularRewardSheet; + } + } + + public static StakeRegularFixedRewardSheet StakeRegularFixedRewardSheet + { + get + { + if (_stakeRegularFixedRewardSheet is null) + { + _stakeRegularFixedRewardSheet = new StakeRegularFixedRewardSheet(); + _stakeRegularFixedRewardSheet.Set(StakeRegularFixedRewardSheetCsv); + } + + return _stakeRegularFixedRewardSheet; + } + } + } + + // NOTE: Use this when the or + // is patched. + // public static class V2 + // { + // } + + internal Address AvatarAddress { get; private set; } + + Address IClaimStakeRewardV1.AvatarAddress => AvatarAddress; + + public ClaimStakeReward4(Address avatarAddress) : this() + { + AvatarAddress = avatarAddress; + } + + public ClaimStakeReward4() + { + } + + protected override IImmutableDictionary PlainValueInternal => + ImmutableDictionary.Empty + .Add(AvatarAddressKey, AvatarAddress.Serialize()); + + protected override void LoadPlainValueInternal( + IImmutableDictionary plainValue) + { + AvatarAddress = plainValue[AvatarAddressKey].ToAddress(); + } + + public override IAccount Execute(IActionContext context) + { + CheckObsolete(ObsoleteBlockIndex, context); + context.UseGas(1); + var states = context.PreviousState; + var addressesHex = GetSignerAndOtherAddressesHex(context, AvatarAddress); + if (!states.TryGetStakeState(context.Signer, out var stakeState)) + { + throw new FailedLoadStateException( + ActionTypeText, + addressesHex, + typeof(StakeState), + StakeState.DeriveAddress(context.Signer)); + } + + if (!stakeState.IsClaimable(context.BlockIndex, out _, out _)) + { + throw new RequiredBlockIndexException( + ActionTypeText, + addressesHex, + context.BlockIndex); + } + + if (!states.TryGetAvatarStateV2( + context.Signer, + AvatarAddress, + out var avatarState, + out var migrationRequired)) + { + throw new FailedLoadStateException( + ActionTypeText, + addressesHex, + typeof(AvatarState), + AvatarAddress); + } + + var sheets = states.GetSheets(sheetTypes: new[] + { + typeof(StakeRegularRewardSheet), + typeof(ConsumableItemSheet), + typeof(CostumeItemSheet), + typeof(EquipmentItemSheet), + typeof(MaterialItemSheet), + }); + + var currency = states.GetGoldCurrency(); + var stakedAmount = states.GetBalance(stakeState.address, currency); + var stakeRegularRewardSheet = sheets.GetSheet(); + var level = + stakeRegularRewardSheet.FindLevelByStakedAmount(context.Signer, stakedAmount); + var itemSheet = sheets.GetItemSheet(); + stakeState.CalculateAccumulatedItemRewardsV2( + context.BlockIndex, + out var itemV1Step, + out var itemV2Step); + stakeState.CalculateAccumulatedRuneRewardsV2( + context.BlockIndex, + out var runeV1Step, + out var runeV2Step); + stakeState.CalculateAccumulatedCurrencyRewardsV1( + context.BlockIndex, + out var currencyV1Step, + out var currencyV2Step); + if (itemV1Step > 0) + { + var v1Level = Math.Min(level, V1.MaxLevel); + var fixedRewardV1 = V1.StakeRegularFixedRewardSheet[v1Level].Rewards; + var regularRewardV1 = V1.StakeRegularRewardSheet[v1Level].Rewards; + states = ProcessReward( + context, + states, + ref avatarState, + itemSheet, + stakedAmount, + itemV1Step, + runeV1Step, + currencyV1Step, + fixedRewardV1, + regularRewardV1); + } + + if (itemV2Step > 0) + { + var regularFixedReward = + states.TryGetSheet(out var fixedRewardSheet) + ? fixedRewardSheet[level].Rewards + : new List(); + var regularReward = sheets.GetSheet()[level].Rewards; + states = ProcessReward( + context, + states, + ref avatarState, + itemSheet, + stakedAmount, + itemV2Step, + runeV2Step, + currencyV2Step, + regularFixedReward, + regularReward); + } + + stakeState.Claim(context.BlockIndex); + + if (migrationRequired) + { + states = states + .SetState(avatarState.address, avatarState.SerializeV2()) + .SetState( + avatarState.address.Derive(LegacyWorldInformationKey), + avatarState.worldInformation.Serialize()) + .SetState( + avatarState.address.Derive(LegacyQuestListKey), + avatarState.questList.Serialize()); + } + + return states + .SetState(stakeState.address, stakeState.Serialize()) + .SetState( + avatarState.address.Derive(LegacyInventoryKey), + avatarState.inventory.Serialize()); + } + + private IAccount ProcessReward( + IActionContext context, + IAccount states, + ref AvatarState avatarState, + ItemSheet itemSheet, + FungibleAssetValue stakedAmount, + int itemRewardStep, + int runeRewardStep, + int currencyRewardStep, + List fixedReward, + List regularReward) + { + var stakedCurrency = stakedAmount.Currency; + + // Regular Reward + var random = context.GetRandom(); + foreach (var reward in regularReward) + { + switch (reward.Type) + { + case StakeRegularRewardSheet.StakeRewardType.Item: + var (quantity, _) = stakedAmount.DivRem(stakedCurrency * reward.Rate); + if (quantity < 1) + { + // If the quantity is zero, it doesn't add the item into inventory. + continue; + } + + ItemSheet.Row row = itemSheet[reward.ItemId]; + ItemBase item = row is MaterialItemSheet.Row materialRow + ? ItemFactory.CreateTradableMaterial(materialRow) + : ItemFactory.CreateItem(row, random); + avatarState.inventory.AddItem(item, (int)quantity * itemRewardStep); + break; + case StakeRegularRewardSheet.StakeRewardType.Rune: + var runeReward = runeRewardStep * + RuneHelper.CalculateStakeReward(stakedAmount, reward.Rate); + if (runeReward < 1 * RuneHelper.StakeRune) + { + continue; + } + + states = states.MintAsset(context, AvatarAddress, runeReward); + break; + case StakeRegularRewardSheet.StakeRewardType.Currency: + if (string.IsNullOrEmpty(reward.CurrencyTicker)) + { + throw new NullReferenceException("currency ticker is null or empty"); + } + + var rewardCurrency = + Currencies.GetMinterlessCurrency(reward.CurrencyTicker); + var rewardCurrencyQuantity = + stakedAmount.DivRem(reward.Rate * stakedAmount.Currency).Quotient; + if (rewardCurrencyQuantity <= 0) + { + continue; + } + + states = states.MintAsset( + context, + context.Signer, + rewardCurrencyQuantity * currencyRewardStep * rewardCurrency); + break; + default: + break; + } + } + + // Fixed Reward + foreach (var reward in fixedReward) + { + ItemSheet.Row row = itemSheet[reward.ItemId]; + ItemBase item = row is MaterialItemSheet.Row materialRow + ? ItemFactory.CreateTradableMaterial(materialRow) + : ItemFactory.CreateItem(row, random); + avatarState.inventory.AddItem(item, reward.Count * itemRewardStep); + } + + return states; + } + } +} diff --git a/Lib9c/Action/ClaimStakeReward5.cs b/Lib9c/Action/ClaimStakeReward5.cs new file mode 100644 index 0000000000..e9c25f8303 --- /dev/null +++ b/Lib9c/Action/ClaimStakeReward5.cs @@ -0,0 +1,330 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using Bencodex.Types; +using Lib9c; +using Lib9c.Abstractions; +using Libplanet.Action; +using Libplanet.Action.State; +using Libplanet.Crypto; +using Libplanet.Types.Assets; +using Nekoyume.Extensions; +using Nekoyume.Helper; +using Nekoyume.Model.Item; +using Nekoyume.Model.State; +using Nekoyume.TableData; +using static Lib9c.SerializeKeys; + +namespace Nekoyume.Action +{ + /// + /// Hard forked at https://github.com/planetarium/lib9c/pull/1458 + /// + [ActionType(ActionTypeText)] + [ActionObsolete(ObsoleteBlockIndex)] + public class ClaimStakeReward5 : GameAction, IClaimStakeReward, IClaimStakeRewardV1 + { + private const string ActionTypeText = "claim_stake_reward5"; + public const long ObsoleteBlockIndex = ActionObsoleteConfig.V200060ObsoleteIndex; + + /// + /// This is the version 1 of the stake reward sheet. + /// The version 1 is used for calculating the reward for the stake + /// that is accumulated before the table patch. + /// + public static class V1 + { + public const int MaxLevel = 5; + + public const string StakeRegularRewardSheetCsv = + @"level,required_gold,item_id,rate,type +1,50,400000,10,Item +1,50,500000,800,Item +1,50,20001,6000,Rune +2,500,400000,8,Item +2,500,500000,800,Item +2,500,20001,6000,Rune +3,5000,400000,5,Item +3,5000,500000,800,Item +3,5000,20001,6000,Rune +4,50000,400000,5,Item +4,50000,500000,800,Item +4,50000,20001,6000,Rune +5,500000,400000,5,Item +5,500000,500000,800,Item +5,500000,20001,6000,Rune"; + + public const string StakeRegularFixedRewardSheetCsv = + @"level,required_gold,item_id,count +1,50,500000,1 +2,500,500000,2 +3,5000,500000,2 +4,50000,500000,2 +5,500000,500000,2"; + + private static StakeRegularRewardSheet _stakeRegularRewardSheet; + private static StakeRegularFixedRewardSheet _stakeRegularFixedRewardSheet; + + public static StakeRegularRewardSheet StakeRegularRewardSheet + { + get + { + if (_stakeRegularRewardSheet is null) + { + _stakeRegularRewardSheet = new StakeRegularRewardSheet(); + _stakeRegularRewardSheet.Set(StakeRegularRewardSheetCsv); + } + + return _stakeRegularRewardSheet; + } + } + + public static StakeRegularFixedRewardSheet StakeRegularFixedRewardSheet + { + get + { + if (_stakeRegularFixedRewardSheet is null) + { + _stakeRegularFixedRewardSheet = new StakeRegularFixedRewardSheet(); + _stakeRegularFixedRewardSheet.Set(StakeRegularFixedRewardSheetCsv); + } + + return _stakeRegularFixedRewardSheet; + } + } + } + + // NOTE: Use this when the or + // is patched. + // public static class V2 + // { + // } + + internal Address AvatarAddress { get; private set; } + + Address IClaimStakeRewardV1.AvatarAddress => AvatarAddress; + + public ClaimStakeReward5(Address avatarAddress) : this() + { + AvatarAddress = avatarAddress; + } + + public ClaimStakeReward5() + { + } + + protected override IImmutableDictionary PlainValueInternal => + ImmutableDictionary.Empty + .Add(AvatarAddressKey, AvatarAddress.Serialize()); + + protected override void LoadPlainValueInternal( + IImmutableDictionary plainValue) + { + AvatarAddress = plainValue[AvatarAddressKey].ToAddress(); + } + + public override IAccount Execute(IActionContext context) + { + CheckObsolete(ObsoleteBlockIndex, context); + context.UseGas(1); + var states = context.PreviousState; + var addressesHex = GetSignerAndOtherAddressesHex(context, AvatarAddress); + if (!states.TryGetStakeState(context.Signer, out var stakeState)) + { + throw new FailedLoadStateException( + ActionTypeText, + addressesHex, + typeof(StakeState), + StakeState.DeriveAddress(context.Signer)); + } + + if (!stakeState.IsClaimable(context.BlockIndex, out _, out _)) + { + throw new RequiredBlockIndexException( + ActionTypeText, + addressesHex, + context.BlockIndex); + } + + if (!states.TryGetAvatarStateV2( + context.Signer, + AvatarAddress, + out var avatarState, + out var migrationRequired)) + { + throw new FailedLoadStateException( + ActionTypeText, + addressesHex, + typeof(AvatarState), + AvatarAddress); + } + + var sheets = states.GetSheets(sheetTypes: new[] + { + typeof(StakeRegularRewardSheet), + typeof(ConsumableItemSheet), + typeof(CostumeItemSheet), + typeof(EquipmentItemSheet), + typeof(MaterialItemSheet), + }); + + var currency = states.GetGoldCurrency(); + var stakedAmount = states.GetBalance(stakeState.address, currency); + var stakeRegularRewardSheet = sheets.GetSheet(); + var level = + stakeRegularRewardSheet.FindLevelByStakedAmount(context.Signer, stakedAmount); + var itemSheet = sheets.GetItemSheet(); + stakeState.CalculateAccumulatedItemRewardsV3( + context.BlockIndex, + out var itemV1Step, + out var itemV2Step); + stakeState.CalculateAccumulatedRuneRewardsV3( + context.BlockIndex, + out var runeV1Step, + out var runeV2Step); + stakeState.CalculateAccumulatedCurrencyRewardsV2( + context.BlockIndex, + out var currencyV1Step, + out var currencyV2Step); + if (itemV1Step > 0) + { + var v1Level = Math.Min(level, V1.MaxLevel); + var fixedRewardV1 = V1.StakeRegularFixedRewardSheet[v1Level].Rewards; + var regularRewardV1 = V1.StakeRegularRewardSheet[v1Level].Rewards; + states = ProcessReward( + context, + states, + ref avatarState, + itemSheet, + stakedAmount, + itemV1Step, + runeV1Step, + currencyV1Step, + fixedRewardV1, + regularRewardV1); + } + + if (itemV2Step > 0) + { + var regularFixedReward = + states.TryGetSheet(out var fixedRewardSheet) + ? fixedRewardSheet[level].Rewards + : new List(); + var regularReward = sheets.GetSheet()[level].Rewards; + states = ProcessReward( + context, + states, + ref avatarState, + itemSheet, + stakedAmount, + itemV2Step, + runeV2Step, + currencyV2Step, + regularFixedReward, + regularReward); + } + + stakeState.Claim(context.BlockIndex); + + if (migrationRequired) + { + states = states + .SetState(avatarState.address, avatarState.SerializeV2()) + .SetState( + avatarState.address.Derive(LegacyWorldInformationKey), + avatarState.worldInformation.Serialize()) + .SetState( + avatarState.address.Derive(LegacyQuestListKey), + avatarState.questList.Serialize()); + } + + return states + .SetState(stakeState.address, stakeState.Serialize()) + .SetState( + avatarState.address.Derive(LegacyInventoryKey), + avatarState.inventory.Serialize()); + } + + private IAccount ProcessReward( + IActionContext context, + IAccount states, + ref AvatarState avatarState, + ItemSheet itemSheet, + FungibleAssetValue stakedAmount, + int itemRewardStep, + int runeRewardStep, + int currencyRewardStep, + List fixedReward, + List regularReward) + { + var stakedCurrency = stakedAmount.Currency; + + // Regular Reward + var random = context.GetRandom(); + foreach (var reward in regularReward) + { + switch (reward.Type) + { + case StakeRegularRewardSheet.StakeRewardType.Item: + var (quantity, _) = stakedAmount.DivRem(stakedCurrency * reward.Rate); + if (quantity < 1) + { + // If the quantity is zero, it doesn't add the item into inventory. + continue; + } + + ItemSheet.Row row = itemSheet[reward.ItemId]; + ItemBase item = row is MaterialItemSheet.Row materialRow + ? ItemFactory.CreateTradableMaterial(materialRow) + : ItemFactory.CreateItem(row, random); + avatarState.inventory.AddItem(item, (int)quantity * itemRewardStep); + break; + case StakeRegularRewardSheet.StakeRewardType.Rune: + var runeReward = runeRewardStep * + RuneHelper.CalculateStakeReward(stakedAmount, reward.Rate); + if (runeReward < 1 * RuneHelper.StakeRune) + { + continue; + } + + states = states.MintAsset(context, AvatarAddress, runeReward); + break; + case StakeRegularRewardSheet.StakeRewardType.Currency: + if (string.IsNullOrEmpty(reward.CurrencyTicker)) + { + throw new NullReferenceException("currency ticker is null or empty"); + } + + var rewardCurrency = + Currencies.GetMinterlessCurrency(reward.CurrencyTicker); + var rewardCurrencyQuantity = + stakedAmount.DivRem(reward.Rate * stakedAmount.Currency).Quotient; + if (rewardCurrencyQuantity <= 0) + { + continue; + } + + states = states.MintAsset( + context, + context.Signer, + rewardCurrencyQuantity * currencyRewardStep * rewardCurrency); + break; + default: + break; + } + } + + // Fixed Reward + foreach (var reward in fixedReward) + { + ItemSheet.Row row = itemSheet[reward.ItemId]; + ItemBase item = row is MaterialItemSheet.Row materialRow + ? ItemFactory.CreateTradableMaterial(materialRow) + : ItemFactory.CreateItem(row, random); + avatarState.inventory.AddItem(item, reward.Count * itemRewardStep); + } + + return states; + } + } +} diff --git a/Lib9c/Action/ClaimStakeReward6.cs b/Lib9c/Action/ClaimStakeReward6.cs new file mode 100644 index 0000000000..0cd05be353 --- /dev/null +++ b/Lib9c/Action/ClaimStakeReward6.cs @@ -0,0 +1,474 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Globalization; +using Bencodex.Types; +using Lib9c; +using Lib9c.Abstractions; +using Libplanet.Action; +using Libplanet.Action.State; +using Libplanet.Crypto; +using Libplanet.Types.Assets; +using Nekoyume.Extensions; +using Nekoyume.Helper; +using Nekoyume.Model.Item; +using Nekoyume.Model.State; +using Nekoyume.TableData; +using static Lib9c.SerializeKeys; + +namespace Nekoyume.Action +{ + /// + /// Hard forked at https://github.com/planetarium/lib9c/pull/2071 + /// + [ActionType(ActionTypeText)] + [ActionObsolete(ObsoleteBlockIndex)] + public class ClaimStakeReward6 : GameAction, IClaimStakeReward, IClaimStakeRewardV1 + { + private const string ActionTypeText = "claim_stake_reward6"; + public const long ObsoleteBlockIndex = ActionObsoleteConfig.V200061ObsoleteIndex; + + /// + /// This is the version 1 of the stake reward sheet. + /// The version 1 is used for calculating the reward for the stake + /// that is accumulated before the table patch. + /// + public static class V1 + { + public const int MaxLevel = 5; + + public const string StakeRegularRewardSheetCsv = + @"level,required_gold,item_id,rate,type,currency_ticker,currency_decimal_places,decimal_rate +1,50,400000,,Item,,,10 +1,50,500000,,Item,,,800 +1,50,20001,,Rune,,,6000 +2,500,400000,,Item,,,8 +2,500,500000,,Item,,,800 +2,500,20001,,Rune,,,6000 +3,5000,400000,,Item,,,5 +3,5000,500000,,Item,,,800 +3,5000,20001,,Rune,,,6000 +4,50000,400000,,Item,,,5 +4,50000,500000,,Item,,,800 +4,50000,20001,,Rune,,,6000 +5,500000,400000,,Item,,,5 +5,500000,500000,,Item,,,800 +5,500000,20001,,Rune,,,6000"; + + public const string StakeRegularFixedRewardSheetCsv = + @"level,required_gold,item_id,count +1,50,500000,1 +2,500,500000,2 +3,5000,500000,2 +4,50000,500000,2 +5,500000,500000,2"; + + private static StakeRegularRewardSheet _stakeRegularRewardSheet; + private static StakeRegularFixedRewardSheet _stakeRegularFixedRewardSheet; + + public static StakeRegularRewardSheet StakeRegularRewardSheet + { + get + { + if (_stakeRegularRewardSheet is null) + { + _stakeRegularRewardSheet = new StakeRegularRewardSheet(); + _stakeRegularRewardSheet.Set(StakeRegularRewardSheetCsv); + } + + return _stakeRegularRewardSheet; + } + } + + public static StakeRegularFixedRewardSheet StakeRegularFixedRewardSheet + { + get + { + if (_stakeRegularFixedRewardSheet is null) + { + _stakeRegularFixedRewardSheet = new StakeRegularFixedRewardSheet(); + _stakeRegularFixedRewardSheet.Set(StakeRegularFixedRewardSheetCsv); + } + + return _stakeRegularFixedRewardSheet; + } + } + } + + /// + /// This is the version 2 of the stake reward sheet. + /// The version 2 is used for calculating the reward for the stake + /// that is accumulated before the table patch. + /// + public static class V2 + { + public const int MaxLevel = 7; + + public const string StakeRegularRewardSheetCsv = + @"level,required_gold,item_id,rate,type,currency_ticker +1,50,400000,10,Item, +1,50,500000,800,Item, +1,50,20001,6000,Rune, +2,500,400000,8,Item, +2,500,500000,800,Item, +2,500,20001,6000,Rune, +3,5000,400000,5,Item, +3,5000,500000,800,Item, +3,5000,20001,6000,Rune, +4,50000,400000,5,Item, +4,50000,500000,800,Item, +4,50000,20001,6000,Rune, +5,500000,400000,5,Item, +5,500000,500000,800,Item, +5,500000,20001,6000,Rune, +6,5000000,400000,10,Item, +6,5000000,500000,800,Item, +6,5000000,20001,6000,Rune, +6,5000000,800201,100,Item, +7,10000000,400000,10,Item, +7,10000000,500000,800,Item, +7,10000000,20001,6000,Rune, +7,10000000,600201,50,Item, +7,10000000,800201,50,Item, +7,10000000,,100,Currency,GARAGE +"; + + public const string StakeRegularFixedRewardSheetCsv = + @"level,required_gold,item_id,count +1,50,500000,1 +2,500,500000,2 +3,5000,500000,2 +4,50000,500000,2 +5,500000,500000,2 +6,5000000,500000,2 +7,10000000,500000,2 +"; + + private static StakeRegularRewardSheet _stakeRegularRewardSheet; + private static StakeRegularFixedRewardSheet _stakeRegularFixedRewardSheet; + + public static StakeRegularRewardSheet StakeRegularRewardSheet + { + get + { + if (_stakeRegularRewardSheet is null) + { + _stakeRegularRewardSheet = new StakeRegularRewardSheet(); + _stakeRegularRewardSheet.Set(StakeRegularRewardSheetCsv); + } + + return _stakeRegularRewardSheet; + } + } + + public static StakeRegularFixedRewardSheet StakeRegularFixedRewardSheet + { + get + { + if (_stakeRegularFixedRewardSheet is null) + { + _stakeRegularFixedRewardSheet = new StakeRegularFixedRewardSheet(); + _stakeRegularFixedRewardSheet.Set(StakeRegularFixedRewardSheetCsv); + } + + return _stakeRegularFixedRewardSheet; + } + } + } + + internal Address AvatarAddress { get; private set; } + + Address IClaimStakeRewardV1.AvatarAddress => AvatarAddress; + + public ClaimStakeReward6(Address avatarAddress) : this() + { + AvatarAddress = avatarAddress; + } + + public ClaimStakeReward6() + { + } + + protected override IImmutableDictionary PlainValueInternal => + ImmutableDictionary.Empty + .Add(AvatarAddressKey, AvatarAddress.Serialize()); + + protected override void LoadPlainValueInternal( + IImmutableDictionary plainValue) + { + AvatarAddress = plainValue[AvatarAddressKey].ToAddress(); + } + + public override IAccount Execute(IActionContext context) + { + context.UseGas(1); + var states = context.PreviousState; + var addressesHex = GetSignerAndOtherAddressesHex(context, AvatarAddress); + if (!states.TryGetStakeState(context.Signer, out var stakeState)) + { + throw new FailedLoadStateException( + ActionTypeText, + addressesHex, + typeof(StakeState), + StakeState.DeriveAddress(context.Signer)); + } + + if (!stakeState.IsClaimable(context.BlockIndex, out _, out _)) + { + throw new RequiredBlockIndexException( + ActionTypeText, + addressesHex, + context.BlockIndex); + } + + if (!states.TryGetAvatarStateV2( + context.Signer, + AvatarAddress, + out var avatarState, + out var migrationRequired)) + { + throw new FailedLoadStateException( + ActionTypeText, + addressesHex, + typeof(AvatarState), + AvatarAddress); + } + + var sheets = states.GetSheets(sheetTypes: new[] + { + typeof(StakeRegularRewardSheet), + typeof(ConsumableItemSheet), + typeof(CostumeItemSheet), + typeof(EquipmentItemSheet), + typeof(MaterialItemSheet), + }); + + var currency = states.GetGoldCurrency(); + var stakedAmount = states.GetBalance(stakeState.address, currency); + var stakeRegularRewardSheet = sheets.GetSheet(); + var level = + stakeRegularRewardSheet.FindLevelByStakedAmount(context.Signer, stakedAmount); + var itemSheet = sheets.GetItemSheet(); + stakeState.CalculateAccumulatedItemRewards( + context.BlockIndex, + out var itemV1Step, + out var itemV2Step, + out var itemV3Step); + stakeState.CalculateAccumulatedRuneRewards( + context.BlockIndex, + out var runeV1Step, + out var runeV2Step, + out var runeV3Step); + stakeState.CalculateAccumulatedCurrencyRewards( + context.BlockIndex, + out var currencyV1Step, + out var currencyV2Step, + out var currencyV3Step); + stakeState.CalculateAccumulatedCurrencyCrystalRewards( + context.BlockIndex, + out var currencyCrystalV1Step, + out var currencyCrystalV2Step, + out var currencyCrystalV3Step); + if (itemV1Step > 0) + { + var v1Level = Math.Min(level, V1.MaxLevel); + var fixedRewardV1 = V1.StakeRegularFixedRewardSheet[v1Level].Rewards; + var regularRewardV1 = V1.StakeRegularRewardSheet[v1Level].Rewards; + states = ProcessReward( + context, + states, + ref avatarState, + itemSheet, + stakedAmount, + itemV1Step, + runeV1Step, + currencyV1Step, + currencyCrystalV1Step, + fixedRewardV1, + regularRewardV1); + } + + if (itemV2Step > 0) + { + var v2Level = Math.Min(level, V2.MaxLevel); + var fixedRewardV2 = V2.StakeRegularFixedRewardSheet[v2Level].Rewards; + var regularRewardV2 = V2.StakeRegularRewardSheet[v2Level].Rewards; + states = ProcessReward( + context, + states, + ref avatarState, + itemSheet, + stakedAmount, + itemV2Step, + runeV2Step, + currencyV2Step, + currencyCrystalV2Step, + fixedRewardV2, + regularRewardV2); + } + + if (itemV3Step > 0) + { + var regularFixedReward = GetRegularFixedRewardInfos(states, level); + var regularReward = sheets.GetSheet()[level].Rewards; + states = ProcessReward( + context, + states, + ref avatarState, + itemSheet, + stakedAmount, + itemV3Step, + runeV3Step, + currencyV3Step, + currencyCrystalV3Step, + regularFixedReward, + regularReward); + } + + stakeState.Claim(context.BlockIndex); + + if (migrationRequired) + { + states = states + .SetState(avatarState.address, avatarState.SerializeV2()) + .SetState( + avatarState.address.Derive(LegacyWorldInformationKey), + avatarState.worldInformation.Serialize()) + .SetState( + avatarState.address.Derive(LegacyQuestListKey), + avatarState.questList.Serialize()); + } + + return states + .SetState(stakeState.address, stakeState.Serialize()) + .SetState( + avatarState.address.Derive(LegacyInventoryKey), + avatarState.inventory.Serialize()); + } + + private static List GetRegularFixedRewardInfos( + IAccountState states, + int level) + { + return states.TryGetSheet(out var fixedRewardSheet) + ? fixedRewardSheet[level].Rewards + : new List(); + } + + private IAccount ProcessReward( + IActionContext context, + IAccount states, + ref AvatarState avatarState, + ItemSheet itemSheet, + FungibleAssetValue stakedFav, + int itemRewardStep, + int runeRewardStep, + int currencyRewardStep, + int currencyCrystalRewardStep, + List fixedReward, + List regularReward) + { + // Regular Reward + var random = context.GetRandom(); + foreach (var reward in regularReward) + { + var rateFav = FungibleAssetValue.Parse( + stakedFav.Currency, + reward.DecimalRate.ToString(CultureInfo.InvariantCulture)); + var rewardQuantityForSingleStep = stakedFav.DivRem(rateFav, out _); + if (rewardQuantityForSingleStep <= 0) + { + continue; + } + + switch (reward.Type) + { + case StakeRegularRewardSheet.StakeRewardType.Item: + { + if (itemRewardStep == 0) + { + continue; + } + + var itemRow = itemSheet[reward.ItemId]; + var item = itemRow is MaterialItemSheet.Row materialRow + ? ItemFactory.CreateTradableMaterial(materialRow) + : ItemFactory.CreateItem(itemRow, random); + var majorUnit = (int)rewardQuantityForSingleStep * itemRewardStep; + if (majorUnit < 1) + { + continue; + } + + avatarState.inventory.AddItem(item, majorUnit); + break; + } + case StakeRegularRewardSheet.StakeRewardType.Rune: + { + if (runeRewardStep == 0) + { + continue; + } + + var majorUnit = rewardQuantityForSingleStep * runeRewardStep; + if (majorUnit < 1) + { + continue; + } + + var runeReward = RuneHelper.StakeRune * majorUnit; + states = states.MintAsset(context, AvatarAddress, runeReward); + break; + } + case StakeRegularRewardSheet.StakeRewardType.Currency: + { + if (string.IsNullOrEmpty(reward.CurrencyTicker)) + { + throw new NullReferenceException("currency ticker is null or empty"); + } + + var isCrystal = reward.CurrencyTicker == Currencies.Crystal.Ticker; + if (isCrystal + ? currencyCrystalRewardStep == 0 + : currencyRewardStep == 0) + { + continue; + } + + var rewardCurrency = reward.CurrencyDecimalPlaces == null + ? Currencies.GetMinterlessCurrency(reward.CurrencyTicker) + : Currency.Uncapped( + reward.CurrencyTicker, + Convert.ToByte(reward.CurrencyDecimalPlaces.Value), + minters: null); + var majorUnit = isCrystal + ? rewardQuantityForSingleStep * currencyCrystalRewardStep + : rewardQuantityForSingleStep * currencyRewardStep; + var rewardFav = rewardCurrency * majorUnit; + states = states.MintAsset( + context, + context.Signer, + rewardFav); + break; + } + default: + throw new ArgumentException( + $"Can't handle reward type: {reward.Type}", + nameof(regularReward)); + } + } + + // Fixed Reward + foreach (var reward in fixedReward) + { + var itemRow = itemSheet[reward.ItemId]; + var item = itemRow is MaterialItemSheet.Row materialRow + ? ItemFactory.CreateTradableMaterial(materialRow) + : ItemFactory.CreateItem(itemRow, random); + avatarState.inventory.AddItem(item, reward.Count * itemRewardStep); + } + + return states; + } + } +} diff --git a/Lib9c/Action/ClaimStakeReward7.cs b/Lib9c/Action/ClaimStakeReward7.cs new file mode 100644 index 0000000000..a69f215ab7 --- /dev/null +++ b/Lib9c/Action/ClaimStakeReward7.cs @@ -0,0 +1,475 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Globalization; +using Bencodex.Types; +using Lib9c; +using Lib9c.Abstractions; +using Libplanet.Action; +using Libplanet.Action.State; +using Libplanet.Crypto; +using Libplanet.Types.Assets; +using Nekoyume.Extensions; +using Nekoyume.Helper; +using Nekoyume.Model.Item; +using Nekoyume.Model.State; +using Nekoyume.TableData; +using static Lib9c.SerializeKeys; + +namespace Nekoyume.Action +{ + /// + /// Hard forked at https://github.com/planetarium/lib9c/pull/2083 + /// + [ActionType(ActionTypeText)] + [ActionObsolete(ObsoleteBlockIndex)] + public class ClaimStakeReward7 : GameAction, IClaimStakeReward, IClaimStakeRewardV1 + { + private const string ActionTypeText = "claim_stake_reward7"; + public const long ObsoleteBlockIndex = ActionObsoleteConfig.V200063ObsoleteIndex; + + /// + /// This is the version 1 of the stake reward sheet. + /// The version 1 is used for calculating the reward for the stake + /// that is accumulated before the table patch. + /// + public static class V1 + { + public const int MaxLevel = 5; + + public const string StakeRegularRewardSheetCsv = + @"level,required_gold,item_id,rate,type,currency_ticker,currency_decimal_places,decimal_rate +1,50,400000,,Item,,,10 +1,50,500000,,Item,,,800 +1,50,20001,,Rune,,,6000 +2,500,400000,,Item,,,8 +2,500,500000,,Item,,,800 +2,500,20001,,Rune,,,6000 +3,5000,400000,,Item,,,5 +3,5000,500000,,Item,,,800 +3,5000,20001,,Rune,,,6000 +4,50000,400000,,Item,,,5 +4,50000,500000,,Item,,,800 +4,50000,20001,,Rune,,,6000 +5,500000,400000,,Item,,,5 +5,500000,500000,,Item,,,800 +5,500000,20001,,Rune,,,6000"; + + public const string StakeRegularFixedRewardSheetCsv = + @"level,required_gold,item_id,count +1,50,500000,1 +2,500,500000,2 +3,5000,500000,2 +4,50000,500000,2 +5,500000,500000,2"; + + private static StakeRegularRewardSheet _stakeRegularRewardSheet; + private static StakeRegularFixedRewardSheet _stakeRegularFixedRewardSheet; + + public static StakeRegularRewardSheet StakeRegularRewardSheet + { + get + { + if (_stakeRegularRewardSheet is null) + { + _stakeRegularRewardSheet = new StakeRegularRewardSheet(); + _stakeRegularRewardSheet.Set(StakeRegularRewardSheetCsv); + } + + return _stakeRegularRewardSheet; + } + } + + public static StakeRegularFixedRewardSheet StakeRegularFixedRewardSheet + { + get + { + if (_stakeRegularFixedRewardSheet is null) + { + _stakeRegularFixedRewardSheet = new StakeRegularFixedRewardSheet(); + _stakeRegularFixedRewardSheet.Set(StakeRegularFixedRewardSheetCsv); + } + + return _stakeRegularFixedRewardSheet; + } + } + } + + /// + /// This is the version 2 of the stake reward sheet. + /// The version 2 is used for calculating the reward for the stake + /// that is accumulated before the table patch. + /// + public static class V2 + { + public const int MaxLevel = 7; + + public const string StakeRegularRewardSheetCsv = + @"level,required_gold,item_id,rate,type,currency_ticker +1,50,400000,10,Item, +1,50,500000,800,Item, +1,50,20001,6000,Rune, +2,500,400000,4,Item, +2,500,500000,600,Item, +2,500,20001,6000,Rune, +3,5000,400000,2,Item, +3,5000,500000,400,Item, +3,5000,20001,6000,Rune, +4,50000,400000,2,Item, +4,50000,500000,400,Item, +4,50000,20001,6000,Rune, +5,500000,400000,2,Item, +5,500000,500000,400,Item, +5,500000,20001,6000,Rune, +6,5000000,400000,2,Item, +6,5000000,500000,400,Item, +6,5000000,20001,6000,Rune, +6,5000000,800201,50,Item, +7,10000000,400000,2,Item, +7,10000000,500000,400,Item, +7,10000000,20001,6000,Rune, +7,10000000,600201,50,Item, +7,10000000,800201,50,Item, +7,10000000,,100,Currency,GARAGE +"; + + public const string StakeRegularFixedRewardSheetCsv = + @"level,required_gold,item_id,count +1,50,500000,1 +2,500,500000,2 +3,5000,500000,2 +4,50000,500000,2 +5,500000,500000,2 +6,5000000,500000,2 +7,10000000,500000,2 +"; + + private static StakeRegularRewardSheet _stakeRegularRewardSheet; + private static StakeRegularFixedRewardSheet _stakeRegularFixedRewardSheet; + + public static StakeRegularRewardSheet StakeRegularRewardSheet + { + get + { + if (_stakeRegularRewardSheet is null) + { + _stakeRegularRewardSheet = new StakeRegularRewardSheet(); + _stakeRegularRewardSheet.Set(StakeRegularRewardSheetCsv); + } + + return _stakeRegularRewardSheet; + } + } + + public static StakeRegularFixedRewardSheet StakeRegularFixedRewardSheet + { + get + { + if (_stakeRegularFixedRewardSheet is null) + { + _stakeRegularFixedRewardSheet = new StakeRegularFixedRewardSheet(); + _stakeRegularFixedRewardSheet.Set(StakeRegularFixedRewardSheetCsv); + } + + return _stakeRegularFixedRewardSheet; + } + } + } + + internal Address AvatarAddress { get; private set; } + + Address IClaimStakeRewardV1.AvatarAddress => AvatarAddress; + + public ClaimStakeReward7(Address avatarAddress) : this() + { + AvatarAddress = avatarAddress; + } + + public ClaimStakeReward7() + { + } + + protected override IImmutableDictionary PlainValueInternal => + ImmutableDictionary.Empty + .Add(AvatarAddressKey, AvatarAddress.Serialize()); + + protected override void LoadPlainValueInternal( + IImmutableDictionary plainValue) + { + AvatarAddress = plainValue[AvatarAddressKey].ToAddress(); + } + + public override IAccount Execute(IActionContext context) + { + CheckObsolete(ObsoleteBlockIndex, context); + context.UseGas(1); + var states = context.PreviousState; + var addressesHex = GetSignerAndOtherAddressesHex(context, AvatarAddress); + if (!states.TryGetStakeState(context.Signer, out var stakeState)) + { + throw new FailedLoadStateException( + ActionTypeText, + addressesHex, + typeof(StakeState), + StakeState.DeriveAddress(context.Signer)); + } + + if (!stakeState.IsClaimable(context.BlockIndex, out _, out _)) + { + throw new RequiredBlockIndexException( + ActionTypeText, + addressesHex, + context.BlockIndex); + } + + if (!states.TryGetAvatarStateV2( + context.Signer, + AvatarAddress, + out var avatarState, + out var migrationRequired)) + { + throw new FailedLoadStateException( + ActionTypeText, + addressesHex, + typeof(AvatarState), + AvatarAddress); + } + + var sheets = states.GetSheets(sheetTypes: new[] + { + typeof(StakeRegularRewardSheet), + typeof(ConsumableItemSheet), + typeof(CostumeItemSheet), + typeof(EquipmentItemSheet), + typeof(MaterialItemSheet), + }); + + var currency = states.GetGoldCurrency(); + var stakedAmount = states.GetBalance(stakeState.address, currency); + var stakeRegularRewardSheet = sheets.GetSheet(); + var level = + stakeRegularRewardSheet.FindLevelByStakedAmount(context.Signer, stakedAmount); + var itemSheet = sheets.GetItemSheet(); + stakeState.CalculateAccumulatedItemRewards( + context.BlockIndex, + out var itemV1Step, + out var itemV2Step, + out var itemV3Step); + stakeState.CalculateAccumulatedRuneRewards( + context.BlockIndex, + out var runeV1Step, + out var runeV2Step, + out var runeV3Step); + stakeState.CalculateAccumulatedCurrencyRewards( + context.BlockIndex, + out var currencyV1Step, + out var currencyV2Step, + out var currencyV3Step); + stakeState.CalculateAccumulatedCurrencyCrystalRewards( + context.BlockIndex, + out var currencyCrystalV1Step, + out var currencyCrystalV2Step, + out var currencyCrystalV3Step); + if (itemV1Step > 0) + { + var v1Level = Math.Min(level, V1.MaxLevel); + var fixedRewardV1 = V1.StakeRegularFixedRewardSheet[v1Level].Rewards; + var regularRewardV1 = V1.StakeRegularRewardSheet[v1Level].Rewards; + states = ProcessReward( + context, + states, + ref avatarState, + itemSheet, + stakedAmount, + itemV1Step, + runeV1Step, + currencyV1Step, + currencyCrystalV1Step, + fixedRewardV1, + regularRewardV1); + } + + if (itemV2Step > 0) + { + var v2Level = Math.Min(level, V2.MaxLevel); + var fixedRewardV2 = V2.StakeRegularFixedRewardSheet[v2Level].Rewards; + var regularRewardV2 = V2.StakeRegularRewardSheet[v2Level].Rewards; + states = ProcessReward( + context, + states, + ref avatarState, + itemSheet, + stakedAmount, + itemV2Step, + runeV2Step, + currencyV2Step, + currencyCrystalV2Step, + fixedRewardV2, + regularRewardV2); + } + + if (itemV3Step > 0) + { + var regularFixedReward = GetRegularFixedRewardInfos(states, level); + var regularReward = sheets.GetSheet()[level].Rewards; + states = ProcessReward( + context, + states, + ref avatarState, + itemSheet, + stakedAmount, + itemV3Step, + runeV3Step, + currencyV3Step, + currencyCrystalV3Step, + regularFixedReward, + regularReward); + } + + stakeState.Claim(context.BlockIndex); + + if (migrationRequired) + { + states = states + .SetState(avatarState.address, avatarState.SerializeV2()) + .SetState( + avatarState.address.Derive(LegacyWorldInformationKey), + avatarState.worldInformation.Serialize()) + .SetState( + avatarState.address.Derive(LegacyQuestListKey), + avatarState.questList.Serialize()); + } + + return states + .SetState(stakeState.address, stakeState.Serialize()) + .SetState( + avatarState.address.Derive(LegacyInventoryKey), + avatarState.inventory.Serialize()); + } + + private static List GetRegularFixedRewardInfos( + IAccountState states, + int level) + { + return states.TryGetSheet(out var fixedRewardSheet) + ? fixedRewardSheet[level].Rewards + : new List(); + } + + private IAccount ProcessReward( + IActionContext context, + IAccount states, + ref AvatarState avatarState, + ItemSheet itemSheet, + FungibleAssetValue stakedFav, + int itemRewardStep, + int runeRewardStep, + int currencyRewardStep, + int currencyCrystalRewardStep, + List fixedReward, + List regularReward) + { + // Regular Reward + var random = context.GetRandom(); + foreach (var reward in regularReward) + { + var rateFav = FungibleAssetValue.Parse( + stakedFav.Currency, + reward.DecimalRate.ToString(CultureInfo.InvariantCulture)); + var rewardQuantityForSingleStep = stakedFav.DivRem(rateFav, out _); + if (rewardQuantityForSingleStep <= 0) + { + continue; + } + + switch (reward.Type) + { + case StakeRegularRewardSheet.StakeRewardType.Item: + { + if (itemRewardStep == 0) + { + continue; + } + + var itemRow = itemSheet[reward.ItemId]; + var item = itemRow is MaterialItemSheet.Row materialRow + ? ItemFactory.CreateTradableMaterial(materialRow) + : ItemFactory.CreateItem(itemRow, random); + var majorUnit = (int)rewardQuantityForSingleStep * itemRewardStep; + if (majorUnit < 1) + { + continue; + } + + avatarState.inventory.AddItem(item, majorUnit); + break; + } + case StakeRegularRewardSheet.StakeRewardType.Rune: + { + if (runeRewardStep == 0) + { + continue; + } + + var majorUnit = rewardQuantityForSingleStep * runeRewardStep; + if (majorUnit < 1) + { + continue; + } + + var runeReward = RuneHelper.StakeRune * majorUnit; + states = states.MintAsset(context, AvatarAddress, runeReward); + break; + } + case StakeRegularRewardSheet.StakeRewardType.Currency: + { + if (string.IsNullOrEmpty(reward.CurrencyTicker)) + { + throw new NullReferenceException("currency ticker is null or empty"); + } + + var isCrystal = reward.CurrencyTicker == Currencies.Crystal.Ticker; + if (isCrystal + ? currencyCrystalRewardStep == 0 + : currencyRewardStep == 0) + { + continue; + } + + var rewardCurrency = reward.CurrencyDecimalPlaces == null + ? Currencies.GetMinterlessCurrency(reward.CurrencyTicker) + : Currency.Uncapped( + reward.CurrencyTicker, + Convert.ToByte(reward.CurrencyDecimalPlaces.Value), + minters: null); + var majorUnit = isCrystal + ? rewardQuantityForSingleStep * currencyCrystalRewardStep + : rewardQuantityForSingleStep * currencyRewardStep; + var rewardFav = rewardCurrency * majorUnit; + states = states.MintAsset( + context, + context.Signer, + rewardFav); + break; + } + default: + throw new ArgumentException( + $"Can't handle reward type: {reward.Type}", + nameof(regularReward)); + } + } + + // Fixed Reward + foreach (var reward in fixedReward) + { + var itemRow = itemSheet[reward.ItemId]; + var item = itemRow is MaterialItemSheet.Row materialRow + ? ItemFactory.CreateTradableMaterial(materialRow) + : ItemFactory.CreateItem(itemRow, random); + avatarState.inventory.AddItem(item, reward.Count * itemRewardStep); + } + + return states; + } + } +} diff --git a/Lib9c/Action/ClaimStakeReward8.cs b/Lib9c/Action/ClaimStakeReward8.cs new file mode 100644 index 0000000000..3c8f08503a --- /dev/null +++ b/Lib9c/Action/ClaimStakeReward8.cs @@ -0,0 +1,503 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Globalization; +using Bencodex.Types; +using Lib9c; +using Lib9c.Abstractions; +using Libplanet.Action; +using Libplanet.Action.State; +using Libplanet.Crypto; +using Libplanet.Types.Assets; +using Nekoyume.Extensions; +using Nekoyume.Helper; +using Nekoyume.Model.Item; +using Nekoyume.Model.State; +using Nekoyume.TableData; +using static Lib9c.SerializeKeys; + +namespace Nekoyume.Action +{ + /// + /// Hard forked at https://github.com/planetarium/lib9c/pull/2106 + /// + [ActionType(ActionTypeText)] + [ActionObsolete(ObsoleteBlockIndex)] + public class ClaimStakeReward8 : GameAction, IClaimStakeReward, IClaimStakeRewardV1 + { + private const string ActionTypeText = "claim_stake_reward8"; + public const long ObsoleteBlockIndex = ActionObsoleteConfig.V200080ObsoleteIndex; + + /// + /// This is the version 1 of the stake reward sheet. + /// The version 1 is used for calculating the reward for the stake + /// that is accumulated before the table patch. + /// + public static class V1 + { + public const int MaxLevel = 5; + + public const string StakeRegularRewardSheetCsv = + @"level,required_gold,item_id,rate,type,currency_ticker,currency_decimal_places,decimal_rate +1,50,400000,,Item,,,10 +1,50,500000,,Item,,,800 +1,50,20001,,Rune,,,6000 +2,500,400000,,Item,,,8 +2,500,500000,,Item,,,800 +2,500,20001,,Rune,,,6000 +3,5000,400000,,Item,,,5 +3,5000,500000,,Item,,,800 +3,5000,20001,,Rune,,,6000 +4,50000,400000,,Item,,,5 +4,50000,500000,,Item,,,800 +4,50000,20001,,Rune,,,6000 +5,500000,400000,,Item,,,5 +5,500000,500000,,Item,,,800 +5,500000,20001,,Rune,,,6000"; + + public const string StakeRegularFixedRewardSheetCsv = + @"level,required_gold,item_id,count +1,50,500000,1 +2,500,500000,2 +3,5000,500000,2 +4,50000,500000,2 +5,500000,500000,2"; + + private static StakeRegularRewardSheet _stakeRegularRewardSheet; + private static StakeRegularFixedRewardSheet _stakeRegularFixedRewardSheet; + + public static StakeRegularRewardSheet StakeRegularRewardSheet + { + get + { + if (_stakeRegularRewardSheet is null) + { + _stakeRegularRewardSheet = new StakeRegularRewardSheet(); + _stakeRegularRewardSheet.Set(StakeRegularRewardSheetCsv); + } + + return _stakeRegularRewardSheet; + } + } + + public static StakeRegularFixedRewardSheet StakeRegularFixedRewardSheet + { + get + { + if (_stakeRegularFixedRewardSheet is null) + { + _stakeRegularFixedRewardSheet = new StakeRegularFixedRewardSheet(); + _stakeRegularFixedRewardSheet.Set(StakeRegularFixedRewardSheetCsv); + } + + return _stakeRegularFixedRewardSheet; + } + } + } + + /// + /// This is the version 2 of the stake reward sheet. + /// The version 2 is used for calculating the reward for the stake + /// that is accumulated before the table patch. + /// + public static class V2 + { + public const int MaxLevel = 7; + + public const string StakeRegularRewardSheetCsv = + @"level,required_gold,item_id,rate,type,currency_ticker +1,50,400000,10,Item, +1,50,500000,800,Item, +1,50,20001,6000,Rune, +2,500,400000,4,Item, +2,500,500000,600,Item, +2,500,20001,6000,Rune, +3,5000,400000,2,Item, +3,5000,500000,400,Item, +3,5000,20001,6000,Rune, +4,50000,400000,2,Item, +4,50000,500000,400,Item, +4,50000,20001,6000,Rune, +5,500000,400000,2,Item, +5,500000,500000,400,Item, +5,500000,20001,6000,Rune, +6,5000000,400000,2,Item, +6,5000000,500000,400,Item, +6,5000000,20001,6000,Rune, +6,5000000,800201,50,Item, +7,10000000,400000,2,Item, +7,10000000,500000,400,Item, +7,10000000,20001,6000,Rune, +7,10000000,600201,50,Item, +7,10000000,800201,50,Item, +7,10000000,,100,Currency,GARAGE +"; + + public const string StakeRegularFixedRewardSheetCsv = + @"level,required_gold,item_id,count +1,50,500000,1 +2,500,500000,2 +3,5000,500000,2 +4,50000,500000,2 +5,500000,500000,2 +6,5000000,500000,2 +7,10000000,500000,2 +"; + + private static StakeRegularRewardSheet _stakeRegularRewardSheet; + private static StakeRegularFixedRewardSheet _stakeRegularFixedRewardSheet; + + public static StakeRegularRewardSheet StakeRegularRewardSheet + { + get + { + if (_stakeRegularRewardSheet is null) + { + _stakeRegularRewardSheet = new StakeRegularRewardSheet(); + _stakeRegularRewardSheet.Set(StakeRegularRewardSheetCsv); + } + + return _stakeRegularRewardSheet; + } + } + + public static StakeRegularFixedRewardSheet StakeRegularFixedRewardSheet + { + get + { + if (_stakeRegularFixedRewardSheet is null) + { + _stakeRegularFixedRewardSheet = new StakeRegularFixedRewardSheet(); + _stakeRegularFixedRewardSheet.Set(StakeRegularFixedRewardSheetCsv); + } + + return _stakeRegularFixedRewardSheet; + } + } + } + + internal Address AvatarAddress { get; private set; } + + Address IClaimStakeRewardV1.AvatarAddress => AvatarAddress; + + public ClaimStakeReward8(Address avatarAddress) : this() + { + AvatarAddress = avatarAddress; + } + + public ClaimStakeReward8() + { + } + + protected override IImmutableDictionary PlainValueInternal => + ImmutableDictionary.Empty + .Add(AvatarAddressKey, AvatarAddress.Serialize()); + + protected override void LoadPlainValueInternal( + IImmutableDictionary plainValue) + { + AvatarAddress = plainValue[AvatarAddressKey].ToAddress(); + } + + public override IAccount Execute(IActionContext context) + { + context.UseGas(1); + var states = context.PreviousState; + var addressesHex = GetSignerAndOtherAddressesHex(context, AvatarAddress); + if (!states.TryGetStakeState(context.Signer, out var stakeState)) + { + throw new FailedLoadStateException( + ActionTypeText, + addressesHex, + typeof(StakeState), + StakeState.DeriveAddress(context.Signer)); + } + + if (!stakeState.IsClaimable(context.BlockIndex, out _, out _)) + { + throw new RequiredBlockIndexException( + ActionTypeText, + addressesHex, + context.BlockIndex); + } + + if (!states.TryGetAvatarStateV2( + context.Signer, + AvatarAddress, + out var avatarState, + out var migrationRequired)) + { + throw new FailedLoadStateException( + ActionTypeText, + addressesHex, + typeof(AvatarState), + AvatarAddress); + } + + var sheets = states.GetSheets(sheetTypes: new[] + { + typeof(StakeRegularRewardSheet), + typeof(ConsumableItemSheet), + typeof(CostumeItemSheet), + typeof(EquipmentItemSheet), + typeof(MaterialItemSheet), + }); + + var currency = states.GetGoldCurrency(); + var stakedAmount = states.GetBalance(stakeState.address, currency); + var stakeRegularRewardSheet = sheets.GetSheet(); + var level = + stakeRegularRewardSheet.FindLevelByStakedAmount(context.Signer, stakedAmount); + var itemSheet = sheets.GetItemSheet(); + stakeState.CalculateAccumulatedItemRewards( + context.BlockIndex, + out var itemV1Step, + out var itemV2Step, + out var itemV3Step); + stakeState.CalculateAccumulatedRuneRewards( + context.BlockIndex, + out var runeV1Step, + out var runeV2Step, + out var runeV3Step); + stakeState.CalculateAccumulatedCurrencyRewards( + context.BlockIndex, + out var currencyV1Step, + out var currencyV2Step, + out var currencyV3Step); + stakeState.CalculateAccumulatedCurrencyCrystalRewards( + context.BlockIndex, + out var currencyCrystalV1Step, + out var currencyCrystalV2Step, + out var currencyCrystalV3Step); + if (itemV1Step > 0) + { + var v1Level = Math.Min(level, V1.MaxLevel); + var fixedRewardV1 = V1.StakeRegularFixedRewardSheet[v1Level].Rewards; + var regularRewardV1 = V1.StakeRegularRewardSheet[v1Level].Rewards; + states = ProcessReward( + context, + states, + ref avatarState, + itemSheet, + stakedAmount, + itemV1Step, + runeV1Step, + currencyV1Step, + currencyCrystalV1Step, + fixedRewardV1, + regularRewardV1); + } + + if (itemV2Step > 0) + { + var v2Level = Math.Min(level, V2.MaxLevel); + var fixedRewardV2 = V2.StakeRegularFixedRewardSheet[v2Level].Rewards; + var regularRewardV2 = V2.StakeRegularRewardSheet[v2Level].Rewards; + states = ProcessReward( + context, + states, + ref avatarState, + itemSheet, + stakedAmount, + itemV2Step, + runeV2Step, + currencyV2Step, + currencyCrystalV2Step, + fixedRewardV2, + regularRewardV2); + } + + if (itemV3Step > 0) + { + var regularFixedReward = GetRegularFixedRewardInfos(states, level); + var regularReward = sheets.GetSheet()[level].Rewards; + states = ProcessReward( + context, + states, + ref avatarState, + itemSheet, + stakedAmount, + itemV3Step, + runeV3Step, + currencyV3Step, + currencyCrystalV3Step, + regularFixedReward, + regularReward); + } + + stakeState.Claim(context.BlockIndex); + + if (migrationRequired) + { + states = states + .SetState(avatarState.address, avatarState.SerializeV2()) + .SetState( + avatarState.address.Derive(LegacyWorldInformationKey), + avatarState.worldInformation.Serialize()) + .SetState( + avatarState.address.Derive(LegacyQuestListKey), + avatarState.questList.Serialize()); + } + + return states + .SetState(stakeState.address, stakeState.Serialize()) + .SetState( + avatarState.address.Derive(LegacyInventoryKey), + avatarState.inventory.Serialize()); + } + + private static List GetRegularFixedRewardInfos( + IAccountState states, + int level) + { + return states.TryGetSheet(out var fixedRewardSheet) + ? fixedRewardSheet[level].Rewards + : new List(); + } + + private IAccount ProcessReward( + IActionContext context, + IAccount states, + ref AvatarState avatarState, + ItemSheet itemSheet, + FungibleAssetValue stakedFav, + int itemRewardStep, + int runeRewardStep, + int currencyRewardStep, + int currencyCrystalRewardStep, + List fixedReward, + List regularReward) + { + // Regular Reward + var random = context.GetRandom(); + foreach (var reward in regularReward) + { + var rateFav = FungibleAssetValue.Parse( + stakedFav.Currency, + reward.DecimalRate.ToString(CultureInfo.InvariantCulture)); + var rewardQuantityForSingleStep = stakedFav.DivRem(rateFav, out _); + if (rewardQuantityForSingleStep <= 0) + { + continue; + } + + switch (reward.Type) + { + case StakeRegularRewardSheet.StakeRewardType.Item: + { + if (itemRewardStep == 0) + { + continue; + } + + var itemRow = itemSheet[reward.ItemId]; + var item = itemRow is MaterialItemSheet.Row materialRow + ? ItemFactory.CreateTradableMaterial(materialRow) + : ItemFactory.CreateItem(itemRow, random); + var majorUnit = (int)rewardQuantityForSingleStep * itemRewardStep; + if (majorUnit < 1) + { + continue; + } + + avatarState.inventory.AddItem(item, majorUnit); + break; + } + case StakeRegularRewardSheet.StakeRewardType.Rune: + { + if (runeRewardStep == 0) + { + continue; + } + + var majorUnit = rewardQuantityForSingleStep * runeRewardStep; + if (majorUnit < 1) + { + continue; + } + + var runeReward = RuneHelper.StakeRune * majorUnit; + states = states.MintAsset(context, AvatarAddress, runeReward); + break; + } + case StakeRegularRewardSheet.StakeRewardType.Currency: + { + if (string.IsNullOrEmpty(reward.CurrencyTicker)) + { + throw new NullReferenceException("currency ticker is null or empty"); + } + + var isCrystal = reward.CurrencyTicker == Currencies.Crystal.Ticker; + if (isCrystal + ? currencyCrystalRewardStep == 0 + : currencyRewardStep == 0) + { + continue; + } + + // NOTE: prepare reward currency. + Currency rewardCurrency; + // NOTE: this line covers the reward.CurrencyTicker is following cases: + // - Currencies.Crystal.Ticker + // - Currencies.Garage.Ticker + // - lower case is starting with "rune_" or "runestone_" + // - lower case is starting with "soulstone_" + try + { + rewardCurrency = + Currencies.GetMinterlessCurrency(reward.CurrencyTicker); + } + // NOTE: throw exception if reward.CurrencyTicker is null or empty. + catch (ArgumentNullException) + { + throw; + } + // NOTE: handle the case that reward.CurrencyTicker isn't covered by + // Currencies.GetMinterlessCurrency(). + catch (ArgumentException) + { + // NOTE: throw exception if reward.CurrencyDecimalPlaces is null. + if (reward.CurrencyDecimalPlaces is null) + { + throw new ArgumentException( + $"Decimal places of {reward.CurrencyTicker} is null"); + } + + // NOTE: new currency is created as uncapped currency. + rewardCurrency = Currency.Uncapped( + reward.CurrencyTicker, + Convert.ToByte(reward.CurrencyDecimalPlaces.Value), + minters: null); + } + + var majorUnit = isCrystal + ? rewardQuantityForSingleStep * currencyCrystalRewardStep + : rewardQuantityForSingleStep * currencyRewardStep; + var rewardFav = rewardCurrency * majorUnit; + states = states.MintAsset( + context, + context.Signer, + rewardFav); + break; + } + default: + throw new ArgumentException( + $"Can't handle reward type: {reward.Type}", + nameof(regularReward)); + } + } + + // Fixed Reward + foreach (var reward in fixedReward) + { + var itemRow = itemSheet[reward.ItemId]; + var item = itemRow is MaterialItemSheet.Row materialRow + ? ItemFactory.CreateTradableMaterial(materialRow) + : ItemFactory.CreateItem(itemRow, random); + avatarState.inventory.AddItem(item, reward.Count * itemRewardStep); + } + + return states; + } + } +} diff --git a/Lib9c/Action/Factory/ClaimStakeRewardFactory.cs b/Lib9c/Action/Factory/ClaimStakeRewardFactory.cs new file mode 100644 index 0000000000..d2b4810fcc --- /dev/null +++ b/Lib9c/Action/Factory/ClaimStakeRewardFactory.cs @@ -0,0 +1,73 @@ +using System; +using Libplanet.Crypto; + +namespace Nekoyume.Action.Factory +{ + public static class ClaimStakeRewardFactory + { + // NOTE: This method does not return a type of `ClaimStakeReward1`. + // Because it is not obsoleted yet. + public static IClaimStakeReward CreateByBlockIndex( + long blockIndex, + Address avatarAddress) + { + if (blockIndex > ClaimStakeReward8.ObsoleteBlockIndex) + { + return new ClaimStakeReward(avatarAddress); + } + + if (blockIndex > ClaimStakeReward7.ObsoleteBlockIndex) + { + return new ClaimStakeReward8(avatarAddress); + } + + if (blockIndex > ClaimStakeReward6.ObsoleteBlockIndex) + { + return new ClaimStakeReward7(avatarAddress); + } + + if (blockIndex > ClaimStakeReward5.ObsoleteBlockIndex) + { + return new ClaimStakeReward6(avatarAddress); + } + + if (blockIndex > ClaimStakeReward4.ObsoleteBlockIndex) + { + return new ClaimStakeReward5(avatarAddress); + } + + if (blockIndex > ClaimStakeReward3.ObsoleteBlockIndex) + { + return new ClaimStakeReward4(avatarAddress); + } + + if (blockIndex > ClaimStakeReward2.ObsoletedIndex) + { + return new ClaimStakeReward3(avatarAddress); + } + + // FIXME: This method should consider the starting block index of + // `claim_stake_reward2`. And if the `blockIndex` is less than + // the starting block index, it should throw an exception. + // default: Version 2 + return new ClaimStakeReward2(avatarAddress); + } + + public static IClaimStakeReward CreateByVersion( + int version, + Address avatarAddress) => version switch + { + 1 => new ClaimStakeReward1(avatarAddress), + 2 => new ClaimStakeReward2(avatarAddress), + 3 => new ClaimStakeReward3(avatarAddress), + 4 => new ClaimStakeReward4(avatarAddress), + 5 => new ClaimStakeReward5(avatarAddress), + 6 => new ClaimStakeReward6(avatarAddress), + 7 => new ClaimStakeReward7(avatarAddress), + 8 => new ClaimStakeReward8(avatarAddress), + 9 => new ClaimStakeReward(avatarAddress), + _ => throw new ArgumentOutOfRangeException( + $"Invalid version: {version}"), + }; + } +} diff --git a/Lib9c/Action/TransferAsset2.cs b/Lib9c/Action/TransferAsset2.cs new file mode 100644 index 0000000000..8ccdf99b7e --- /dev/null +++ b/Lib9c/Action/TransferAsset2.cs @@ -0,0 +1,162 @@ +using Bencodex; +using Bencodex.Types; +using Libplanet.Action; +using Libplanet.Action.State; +using Libplanet.Crypto; +using Libplanet.Types.Assets; +using Nekoyume.Model.State; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.Serialization; +using Lib9c.Abstractions; +using Nekoyume.Model; +using Serilog; + +namespace Nekoyume.Action +{ + /// + /// Hard forked at https://github.com/planetarium/lib9c/pull/636 + /// Updated at https://github.com/planetarium/lib9c/pull/957 + /// + [Serializable] + [ActionObsolete(TransferAsset3.CrystalTransferringRestrictionStartIndex - 1)] + [ActionType("transfer_asset2")] + public class TransferAsset2 : ActionBase, ISerializable, ITransferAsset, ITransferAssetV1 + { + private const int MemoMaxLength = 80; + + public TransferAsset2() + { + } + + public TransferAsset2(Address sender, Address recipient, FungibleAssetValue amount, string memo = null) + { + Sender = sender; + Recipient = recipient; + Amount = amount; + + CheckMemoLength(memo); + Memo = memo; + } + + protected TransferAsset2(SerializationInfo info, StreamingContext context) + { + var rawBytes = (byte[])info.GetValue("serialized", typeof(byte[])); + Dictionary pv = (Dictionary) new Codec().Decode(rawBytes); + + LoadPlainValue(pv); + } + + public Address Sender { get; private set; } + public Address Recipient { get; private set; } + public FungibleAssetValue Amount { get; private set; } + public string Memo { get; private set; } + + Address ITransferAssetV1.Sender => Sender; + Address ITransferAssetV1.Recipient => Recipient; + FungibleAssetValue ITransferAssetV1.Amount => Amount; + string ITransferAssetV1.Memo => Memo; + + public override IValue PlainValue + { + get + { + IEnumerable> pairs = new[] + { + new KeyValuePair((Text) "sender", Sender.Serialize()), + new KeyValuePair((Text) "recipient", Recipient.Serialize()), + new KeyValuePair((Text) "amount", Amount.Serialize()), + }; + + if (!(Memo is null)) + { + pairs = pairs.Append(new KeyValuePair((Text) "memo", Memo.Serialize())); + } + + return Dictionary.Empty + .Add("type_id", "transfer_asset2") + .Add("values", new Dictionary(pairs)); + } + } + + public override IAccount Execute(IActionContext context) + { + context.UseGas(4); + var state = context.PreviousState; + + CheckObsolete(TransferAsset3.CrystalTransferringRestrictionStartIndex - 1, context); + var addressesHex = GetSignerAndOtherAddressesHex(context, context.Signer); + var started = DateTimeOffset.UtcNow; + Log.Debug("{AddressesHex}TransferAsset2 exec started", addressesHex); + if (Sender != context.Signer) + { + throw new InvalidTransferSignerException(context.Signer, Sender, Recipient); + } + + // This works for block after 380000. Please take a look at + // https://github.com/planetarium/libplanet/pull/1133 + if (context.BlockIndex > 380000 && Sender == Recipient) + { + throw new InvalidTransferRecipientException(Sender, Recipient); + } + + Address recipientAddress = Recipient.Derive(ActivationKey.DeriveKey); + + // Check new type of activation first. + if (state.GetState(recipientAddress) is null && state.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(Recipient)) + { + throw new InvalidTransferUnactivatedRecipientException(Sender, Recipient); + } + } + + Currency currency = Amount.Currency; + if (!(currency.Minters is null) && + (currency.Minters.Contains(Sender) || currency.Minters.Contains(Recipient))) + { + throw new InvalidTransferMinterException( + currency.Minters, + Sender, + Recipient + ); + } + + var ended = DateTimeOffset.UtcNow; + Log.Debug("{AddressesHex}TransferAsset2 Total Executed Time: {Elapsed}", addressesHex, ended - started); + return state.TransferAsset(context, Sender, Recipient, Amount); + } + + public override void LoadPlainValue(IValue plainValue) + { + var asDict = (Dictionary)((Dictionary)plainValue)["values"]; + + Sender = asDict["sender"].ToAddress(); + Recipient = asDict["recipient"].ToAddress(); + Amount = asDict["amount"].ToFungibleAssetValue(); + 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/Action/TransferAsset4.cs b/Lib9c/Action/TransferAsset4.cs new file mode 100644 index 0000000000..0f8bc34a4f --- /dev/null +++ b/Lib9c/Action/TransferAsset4.cs @@ -0,0 +1,150 @@ +using Bencodex; +using Bencodex.Types; +using Libplanet.Action; +using Libplanet.Action.State; +using Libplanet.Crypto; +using Libplanet.Types.Assets; +using Nekoyume.Model.State; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.Serialization; +using Lib9c; +using Lib9c.Abstractions; +using Nekoyume.Helper; +using Nekoyume.Model; +using Serilog; + +namespace Nekoyume.Action +{ + /// + /// Hard forked at https://github.com/planetarium/lib9c/pull/636 + /// Updated at https://github.com/planetarium/lib9c/pull/2143 + /// + [Serializable] + [ActionType(TypeIdentifier)] + [ActionObsolete(ObsoleteBlockIndex)] + public class TransferAsset4 : ActionBase, ISerializable, ITransferAsset, ITransferAssetV1 + { + private const int MemoMaxLength = 80; + public const string TypeIdentifier = "transfer_asset4"; + public const long ObsoleteBlockIndex = ActionObsoleteConfig.V200080ObsoleteIndex; + + public TransferAsset4() + { + } + + public TransferAsset4(Address sender, Address recipient, FungibleAssetValue amount, string memo = null) + { + Sender = sender; + Recipient = recipient; + Amount = amount; + + CheckMemoLength(memo); + Memo = memo; + } + + protected TransferAsset4(SerializationInfo info, StreamingContext context) + { + var rawBytes = (byte[])info.GetValue("serialized", typeof(byte[])); + Dictionary pv = (Dictionary) new Codec().Decode(rawBytes); + + LoadPlainValue(pv); + } + + public Address Sender { get; private set; } + public Address Recipient { get; private set; } + public FungibleAssetValue Amount { get; private set; } + public string Memo { get; private set; } + + Address ITransferAssetV1.Sender => Sender; + Address ITransferAssetV1.Recipient => Recipient; + FungibleAssetValue ITransferAssetV1.Amount => Amount; + string ITransferAssetV1.Memo => Memo; + + public override IValue PlainValue + { + get + { + IEnumerable> pairs = new[] + { + new KeyValuePair((Text) "sender", Sender.Serialize()), + new KeyValuePair((Text) "recipient", Recipient.Serialize()), + new KeyValuePair((Text) "amount", Amount.Serialize()), + }; + + if (!(Memo is null)) + { + pairs = pairs.Append(new KeyValuePair((Text) "memo", Memo.Serialize())); + } + + return Dictionary.Empty + .Add("type_id", TypeIdentifier) + .Add("values", new Dictionary(pairs)); + } + } + + public override IAccount Execute(IActionContext context) + { + context.UseGas(4); + Address signer = context.Signer; + var state = context.PreviousState; + + var addressesHex = GetSignerAndOtherAddressesHex(context, signer); + var started = DateTimeOffset.UtcNow; + Log.Debug("{AddressesHex}TransferAsset4 exec started", addressesHex); + if (Sender != signer) + { + throw new InvalidTransferSignerException(signer, Sender, Recipient); + } + + if (Sender == Recipient) + { + throw new InvalidTransferRecipientException(Sender, Recipient); + } + + Currency currency = Amount.Currency; + if (!(currency.Minters is null) && + (currency.Minters.Contains(Sender) || currency.Minters.Contains(Recipient))) + { + throw new InvalidTransferMinterException( + currency.Minters, + Sender, + Recipient + ); + } + + TransferAsset3.CheckCrystalSender(currency, context.BlockIndex, Sender); + var ended = DateTimeOffset.UtcNow; + Log.Debug("{AddressesHex}TransferAsset4 Total Executed Time: {Elapsed}", addressesHex, ended - started); + return state.TransferAsset(context, Sender, Recipient, Amount); + } + + public override void LoadPlainValue(IValue plainValue) + { + var asDict = (Dictionary)((Dictionary)plainValue)["values"]; + + Sender = asDict["sender"].ToAddress(); + Recipient = asDict["recipient"].ToAddress(); + Amount = asDict["amount"].ToFungibleAssetValue(); + 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/Action/TransferAssets0.cs b/Lib9c/Action/TransferAssets0.cs new file mode 100644 index 0000000000..641c1070d5 --- /dev/null +++ b/Lib9c/Action/TransferAssets0.cs @@ -0,0 +1,186 @@ +using Bencodex; +using Bencodex.Types; +using Libplanet.Action; +using Libplanet.Action.State; +using Libplanet.Crypto; +using Libplanet.Types.Assets; +using Nekoyume.Model.State; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.Serialization; +using Lib9c.Abstractions; +using Nekoyume.Model; +using Serilog; + +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_assets")] + [ActionObsolete(ActionObsoleteConfig.V200030ObsoleteIndex)] + public class TransferAssets0 : ActionBase, ISerializable, ITransferAssets, ITransferAssetsV1 + { + public const int RecipientsCapacity = 100; + private const int MemoMaxLength = 80; + + public TransferAssets0() + { + } + + public TransferAssets0(Address sender, List<(Address, FungibleAssetValue)> recipients, string memo = null) + { + Sender = sender; + Recipients = recipients; + + CheckMemoLength(memo); + Memo = memo; + } + + protected TransferAssets0(SerializationInfo info, StreamingContext context) + { + var rawBytes = (byte[])info.GetValue("serialized", typeof(byte[])); + Dictionary pv = (Dictionary) new Codec().Decode(rawBytes); + + LoadPlainValue(pv); + } + + public Address Sender { get; private set; } + public List<(Address recipient, FungibleAssetValue amount)> Recipients { get; private set; } + public string Memo { get; private set; } + + Address ITransferAssetsV1.Sender => Sender; + + List<(Address recipient, FungibleAssetValue amount)> ITransferAssetsV1.Recipients => + Recipients; + string ITransferAssetsV1.Memo => Memo; + + public override IValue PlainValue + { + get + { + IEnumerable> pairs = new[] + { + new KeyValuePair((Text) "sender", Sender.Serialize()), + new KeyValuePair((Text) "recipients", Recipients.Aggregate(List.Empty, (list, t) => list.Add(List.Empty.Add(t.recipient.Serialize()).Add(t.amount.Serialize())))), + }; + + if (!(Memo is null)) + { + pairs = pairs.Append(new KeyValuePair((Text) "memo", Memo.Serialize())); + } + + return Dictionary.Empty + .Add("type_id", "transfer_assets") + .Add("values", new Dictionary(pairs)); + } + } + + public override IAccount Execute(IActionContext context) + { + context.UseGas(4); + var state = context.PreviousState; + + CheckObsolete(ActionObsoleteConfig.V200030ObsoleteIndex, context); + if (Recipients.Count > RecipientsCapacity) + { + throw new ArgumentOutOfRangeException($"{nameof(Recipients)} must be less than or equal {RecipientsCapacity}."); + } + var addressesHex = GetSignerAndOtherAddressesHex(context, context.Signer); + var started = DateTimeOffset.UtcNow; + Log.Debug("{AddressesHex}transfer_assets exec started", addressesHex); + + var activatedAccountsState = state.GetState(Addresses.ActivatedAccount) is Dictionary asDict + ? new ActivatedAccountsState(asDict) + : new ActivatedAccountsState(); + + state = Recipients.Aggregate(state, (current, t) => Transfer(context, current, context.Signer, t.recipient, t.amount, activatedAccountsState, context.BlockIndex)); + var ended = DateTimeOffset.UtcNow; + Log.Debug("{AddressesHex}transfer_assets Total Executed Time: {Elapsed}", addressesHex, ended - started); + + return state; + } + + public override void LoadPlainValue(IValue plainValue) + { + var asDict = (Dictionary)((Dictionary)plainValue)["values"]; + + Sender = asDict["sender"].ToAddress(); + var rawMap = (List)asDict["recipients"]; + Recipients = new List<(Address recipient, FungibleAssetValue amount)>(); + foreach (var iValue in rawMap) + { + var list = (List) iValue; + Recipients.Add((list[0].ToAddress(), list[1].ToFungibleAssetValue())); + } + 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); + } + } + + private IAccount Transfer( + IActionContext context, IAccount state, Address signer, Address recipient, FungibleAssetValue amount, ActivatedAccountsState activatedAccountsState, long blockIndex) + { + if (Sender != signer) + { + throw new InvalidTransferSignerException(signer, Sender, recipient); + } + + if (Sender == recipient) + { + throw new InvalidTransferRecipientException(Sender, recipient); + } + + Address recipientAddress = recipient.Derive(ActivationKey.DeriveKey); + + // Check new type of activation first. + // If result of GetState is not null, it is assumed that it has been activated. + if ( + state.GetState(recipientAddress) is null && + state.GetState(recipient) is null + ) + { + var activatedAccounts = activatedAccountsState.Accounts; + // if ActivatedAccountsState is empty, all user is activate. + if (activatedAccounts.Count != 0 + && !activatedAccounts.Contains(recipient) + && state.GetState(recipient) is null) + { + throw new InvalidTransferUnactivatedRecipientException(Sender, recipient); + } + } + + Currency currency = amount.Currency; + if (!(currency.Minters is null) && + (currency.Minters.Contains(Sender) || currency.Minters.Contains(recipient))) + { + throw new InvalidTransferMinterException( + currency.Minters, + Sender, + recipient + ); + } + + TransferAsset3.CheckCrystalSender(currency, blockIndex, Sender); + return state.TransferAsset(context, Sender, recipient, amount); + } + } +} diff --git a/Lib9c/Action/TransferAssets2.cs b/Lib9c/Action/TransferAssets2.cs new file mode 100644 index 0000000000..b86c6a137c --- /dev/null +++ b/Lib9c/Action/TransferAssets2.cs @@ -0,0 +1,164 @@ +using Bencodex; +using Bencodex.Types; +using Libplanet.Action; +using Libplanet.Action.State; +using Libplanet.Crypto; +using Libplanet.Types.Assets; +using Nekoyume.Model.State; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.Serialization; +using Lib9c.Abstractions; +using Nekoyume.Model; +using Serilog; + +namespace Nekoyume.Action +{ + /// + /// Hard forked at https://github.com/planetarium/lib9c/pull/636 + /// Updated at https://github.com/planetarium/lib9c/pull/957 + /// + [Serializable] + [ActionType(TypeIdentifier)] + [ActionObsolete(ActionObsoleteConfig.V200090ObsoleteIndex)] + public class TransferAssets2 : ActionBase, ISerializable, ITransferAssets, ITransferAssetsV1 + { + public const string TypeIdentifier = "transfer_assets2"; + public const int RecipientsCapacity = 100; + private const int MemoMaxLength = 80; + + public TransferAssets2() + { + } + + public TransferAssets2(Address sender, List<(Address, FungibleAssetValue)> recipients, string memo = null) + { + Sender = sender; + Recipients = recipients; + + CheckMemoLength(memo); + Memo = memo; + } + + protected TransferAssets2(SerializationInfo info, StreamingContext context) + { + var rawBytes = (byte[])info.GetValue("serialized", typeof(byte[])); + Dictionary pv = (Dictionary) new Codec().Decode(rawBytes); + + LoadPlainValue(pv); + } + + public Address Sender { get; private set; } + public List<(Address recipient, FungibleAssetValue amount)> Recipients { get; private set; } + public string Memo { get; private set; } + + Address ITransferAssetsV1.Sender => Sender; + + List<(Address recipient, FungibleAssetValue amount)> ITransferAssetsV1.Recipients => + Recipients; + string ITransferAssetsV1.Memo => Memo; + + public override IValue PlainValue + { + get + { + IEnumerable> pairs = new[] + { + new KeyValuePair((Text) "sender", Sender.Serialize()), + new KeyValuePair((Text) "recipients", Recipients.Aggregate(List.Empty, (list, t) => list.Add(List.Empty.Add(t.recipient.Serialize()).Add(t.amount.Serialize())))), + }; + + if (!(Memo is null)) + { + pairs = pairs.Append(new KeyValuePair((Text) "memo", Memo.Serialize())); + } + + return Dictionary.Empty + .Add("type_id", TypeIdentifier) + .Add("values", new Dictionary(pairs)); + } + } + + public override IAccount Execute(IActionContext context) + { + context.UseGas(4); + var state = context.PreviousState; + CheckObsolete(ActionObsoleteConfig.V200090ObsoleteIndex, context); + + if (Recipients.Count > RecipientsCapacity) + { + throw new ArgumentOutOfRangeException($"{nameof(Recipients)} must be less than or equal {RecipientsCapacity}."); + } + var addressesHex = GetSignerAndOtherAddressesHex(context, context.Signer); + var started = DateTimeOffset.UtcNow; + Log.Debug("{AddressesHex}{ActionName} exec started", addressesHex, TypeIdentifier); + + state = Recipients.Aggregate(state, (current, t) => Transfer(context, current, context.Signer, t.recipient, t.amount, context.BlockIndex)); + var ended = DateTimeOffset.UtcNow; + Log.Debug("{AddressesHex}{ActionName} Total Executed Time: {Elapsed}", addressesHex, TypeIdentifier, ended - started); + + return state; + } + + public override void LoadPlainValue(IValue plainValue) + { + var asDict = (Dictionary)((Dictionary)plainValue)["values"]; + + Sender = asDict["sender"].ToAddress(); + var rawMap = (List)asDict["recipients"]; + Recipients = new List<(Address recipient, FungibleAssetValue amount)>(); + foreach (var iValue in rawMap) + { + var list = (List) iValue; + Recipients.Add((list[0].ToAddress(), list[1].ToFungibleAssetValue())); + } + 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); + } + } + + private IAccount Transfer( + IActionContext context, IAccount state, Address signer, Address recipient, FungibleAssetValue amount, long blockIndex) + { + if (Sender != signer) + { + throw new InvalidTransferSignerException(signer, Sender, recipient); + } + + if (Sender == recipient) + { + throw new InvalidTransferRecipientException(Sender, recipient); + } + + Currency currency = amount.Currency; + if (!(currency.Minters is null) && + (currency.Minters.Contains(Sender) || currency.Minters.Contains(recipient))) + { + throw new InvalidTransferMinterException( + currency.Minters, + Sender, + recipient + ); + } + + TransferAsset3.CheckCrystalSender(currency, blockIndex, Sender); + return state.TransferAsset(context, Sender, recipient, amount); + } + } +} diff --git a/Lib9c/Action/UpdateSell0.cs b/Lib9c/Action/UpdateSell0.cs new file mode 100644 index 0000000000..74ecaa4fa6 --- /dev/null +++ b/Lib9c/Action/UpdateSell0.cs @@ -0,0 +1,213 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Diagnostics; +using System.Linq; +using Bencodex.Types; +using Lib9c.Abstractions; +using Lib9c.Model.Order; +using Libplanet.Action; +using Libplanet.Action.State; +using Libplanet.Crypto; +using Libplanet.Types.Assets; +using Nekoyume.Model.Item; +using Nekoyume.Model.Mail; +using Nekoyume.Model.State; +using Nekoyume.TableData; +using Serilog; +using static Lib9c.SerializeKeys; +using BxDictionary = Bencodex.Types.Dictionary; +using BxList = Bencodex.Types.List; + +namespace Nekoyume.Action +{ + [Serializable] + [ActionObsolete(ActionObsoleteConfig.V200020AccidentObsoleteIndex)] + [ActionType("update_sell")] + public class UpdateSell0 : GameAction, IUpdateSellV1 + { + public Guid orderId; + public Guid updateSellOrderId; + public Guid tradableId; + public Address sellerAvatarAddress; + public ItemSubType itemSubType; + public FungibleAssetValue price; + public int count; + + Guid IUpdateSellV1.OrderId => orderId; + Guid IUpdateSellV1.UpdateSellOrderId => updateSellOrderId; + Guid IUpdateSellV1.TradableId => tradableId; + Address IUpdateSellV1.SellerAvatarAddress => sellerAvatarAddress; + string IUpdateSellV1.ItemSubType => itemSubType.ToString(); + FungibleAssetValue IUpdateSellV1.Price => price; + int IUpdateSellV1.Count => count; + + protected override IImmutableDictionary PlainValueInternal => + new Dictionary + { + [OrderIdKey] = orderId.Serialize(), + [updateSellOrderIdKey] = updateSellOrderId.Serialize(), + [ItemIdKey] = tradableId.Serialize(), + [SellerAvatarAddressKey] = sellerAvatarAddress.Serialize(), + [ItemSubTypeKey] = itemSubType.Serialize(), + [PriceKey] = price.Serialize(), + [ItemCountKey] = count.Serialize(), + }.ToImmutableDictionary(); + + protected override void LoadPlainValueInternal(IImmutableDictionary plainValue) + { + orderId = plainValue[OrderIdKey].ToGuid(); + updateSellOrderId = plainValue[updateSellOrderIdKey].ToGuid(); + tradableId = plainValue[ItemIdKey].ToGuid(); + sellerAvatarAddress = plainValue[SellerAvatarAddressKey].ToAddress(); + itemSubType = plainValue[ItemSubTypeKey].ToEnum(); + price = plainValue[PriceKey].ToFungibleAssetValue(); + count = plainValue[ItemCountKey].ToInteger(); + } + + public override IAccount Execute(IActionContext context) + { + context.UseGas(1); + var states = context.PreviousState; + var inventoryAddress = sellerAvatarAddress.Derive(LegacyInventoryKey); + var worldInformationAddress = sellerAvatarAddress.Derive(LegacyWorldInformationKey); + var questListAddress = sellerAvatarAddress.Derive(LegacyQuestListKey); + var shopAddress = ShardedShopStateV2.DeriveAddress(itemSubType, orderId); + var updateSellShopAddress = ShardedShopStateV2.DeriveAddress(itemSubType, updateSellOrderId); + var updateSellOrderAddress = Order.DeriveAddress(updateSellOrderId); + var itemAddress = Addresses.GetItemAddress(tradableId); + var orderReceiptAddress = OrderDigestListState.DeriveAddress(sellerAvatarAddress); + + CheckObsolete(ActionObsoleteConfig.V100080ObsoleteIndex, context); + + // common + var addressesHex = GetSignerAndOtherAddressesHex(context, sellerAvatarAddress); + var sw = new Stopwatch(); + sw.Start(); + var started = DateTimeOffset.UtcNow; + Log.Verbose("{AddressesHex} updateSell exec started", addressesHex); + + if (price.Sign < 0) + { + throw new InvalidPriceException( + $"{addressesHex} Aborted as the price is less than zero: {price}."); + } + + if (!states.TryGetAvatarStateV2(context.Signer, sellerAvatarAddress, out var avatarState, out _)) + { + throw new FailedLoadStateException( + $"{addressesHex} Aborted as the avatar state of the signer was failed to load."); + } + sw.Stop(); + + Log.Verbose("{AddressesHex} Sell Get AgentAvatarStates: {Elapsed}", addressesHex, sw.Elapsed); + sw.Restart(); + if (!avatarState.worldInformation.IsStageCleared( + GameConfig.RequireClearedStageLevel.ActionsInShop)) + { + avatarState.worldInformation.TryGetLastClearedStageId(out var current); + throw new NotEnoughClearedStageLevelException( + addressesHex, + GameConfig.RequireClearedStageLevel.ActionsInShop, + current); + } + sw.Stop(); + + avatarState.updatedAt = context.BlockIndex; + avatarState.blockIndex = context.BlockIndex; + + // for sell cancel + Log.Verbose("{AddressesHex} UpdateSell IsStageCleared: {Elapsed}", addressesHex, sw.Elapsed); + sw.Restart(); + if (!states.TryGetState(shopAddress, out BxDictionary shopStateDict)) + { + throw new FailedLoadStateException($"{addressesHex}failed to load {nameof(ShardedShopStateV2)}({shopAddress})."); + } + sw.Stop(); + + Log.Verbose("{AddressesHex} UpdateSell Sell Cancel Get ShopState: {Elapsed}", addressesHex, sw.Elapsed); + sw.Restart(); + if (!states.TryGetState(Order.DeriveAddress(orderId), out Dictionary orderDict)) + { + throw new FailedLoadStateException($"{addressesHex} failed to load {nameof(Order)}({Order.DeriveAddress(orderId)})."); + } + + var orderOnSale = OrderFactory.Deserialize(orderDict); + var fromPreviousAction = false; + try + { + orderOnSale.ValidateCancelOrder(avatarState, tradableId); + } + catch (Exception) + { + orderOnSale.ValidateCancelOrder2(avatarState, tradableId); + fromPreviousAction = true; + } + + var itemOnSale = fromPreviousAction + ? orderOnSale.Cancel2(avatarState, context.BlockIndex) + : orderOnSale.Cancel(avatarState, context.BlockIndex); + if (context.BlockIndex < orderOnSale.ExpiredBlockIndex) + { + var shardedShopState = new ShardedShopStateV2(shopStateDict); + shardedShopState.Remove(orderOnSale, context.BlockIndex); + states = states.SetState(shopAddress, shardedShopState.Serialize()); + } + + if (!states.TryGetState(orderReceiptAddress, out Dictionary rawList)) + { + throw new FailedLoadStateException($"{addressesHex} failed to load {nameof(OrderDigest)}({orderReceiptAddress})."); + } + var digestList = new OrderDigestListState(rawList); + digestList.Remove(orderOnSale.OrderId); + states = states.SetState(itemAddress, itemOnSale.Serialize()) + .SetState(orderReceiptAddress, digestList.Serialize()); + sw.Stop(); + + var expirationMail = avatarState.mailBox.OfType() + .FirstOrDefault(m => m.OrderId.Equals(orderId)); + if (!(expirationMail is null)) + { + avatarState.mailBox.Remove(expirationMail); + } + + // for updateSell + var updateSellShopState = states.TryGetState(updateSellShopAddress, out Dictionary serializedState) + ? new ShardedShopStateV2(serializedState) + : new ShardedShopStateV2(updateSellShopAddress); + + Log.Verbose("{AddressesHex} UpdateSell Get ShardedShopState: {Elapsed}", addressesHex, sw.Elapsed); + sw.Restart(); + var newOrder = OrderFactory.Create(context.Signer, sellerAvatarAddress, updateSellOrderId, price, tradableId, + context.BlockIndex, itemSubType, count); + newOrder.Validate(avatarState, count); + + var tradableItem = newOrder.Sell3(avatarState); + var costumeStatSheet = states.GetSheet(); + var orderDigest = newOrder.Digest(avatarState, costumeStatSheet); + updateSellShopState.Add(orderDigest, context.BlockIndex); + + digestList.Add(orderDigest); + states = states.SetState(orderReceiptAddress, digestList.Serialize()); + states = states.SetState(inventoryAddress, avatarState.inventory.Serialize()) + .SetState(worldInformationAddress, avatarState.worldInformation.Serialize()) + .SetState(questListAddress, avatarState.questList.Serialize()) + .SetState(sellerAvatarAddress, avatarState.SerializeV2()); + sw.Stop(); + + Log.Verbose("{AddressesHex} UpdateSell Set AvatarState: {Elapsed}", addressesHex, sw.Elapsed); + sw.Restart(); + states = states + .SetState(itemAddress, tradableItem.Serialize()) + .SetState(updateSellOrderAddress, newOrder.Serialize()) + .SetState(updateSellShopAddress, updateSellShopState.Serialize()); + sw.Stop(); + + var ended = DateTimeOffset.UtcNow; + Log.Verbose("{AddressesHex} UpdateSell Set ShopState: {Elapsed}", addressesHex, sw.Elapsed); + Log.Verbose("{AddressesHex} UpdateSell Total Executed Time: {Elapsed}", addressesHex, ended - started); + + return states; + } + } +} diff --git a/Lib9c/Action/UpdateSell2.cs b/Lib9c/Action/UpdateSell2.cs new file mode 100644 index 0000000000..49e45acde4 --- /dev/null +++ b/Lib9c/Action/UpdateSell2.cs @@ -0,0 +1,212 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Diagnostics; +using System.Linq; +using Bencodex.Types; +using Lib9c.Abstractions; +using Lib9c.Model.Order; +using Libplanet.Action; +using Libplanet.Action.State; +using Libplanet.Crypto; +using Libplanet.Types.Assets; +using Nekoyume.Model.Item; +using Nekoyume.Model.Mail; +using Nekoyume.Model.State; +using Nekoyume.TableData; +using Serilog; +using static Lib9c.SerializeKeys; +using BxDictionary = Bencodex.Types.Dictionary; +using BxList = Bencodex.Types.List; + +namespace Nekoyume.Action +{ + /// + /// Hard forked at https://github.com/planetarium/lib9c/pull/602 + /// Updated at https://github.com/planetarium/lib9c/pull/1022 + /// + [Serializable] + [ActionObsolete(ActionObsoleteConfig.V200020AccidentObsoleteIndex)] + [ActionType("update_sell2")] + public class UpdateSell2 : GameAction, IUpdateSellV1 + { + public Guid orderId; + public Guid updateSellOrderId; + public Guid tradableId; + public Address sellerAvatarAddress; + public ItemSubType itemSubType; + public FungibleAssetValue price; + public int count; + + Guid IUpdateSellV1.OrderId => orderId; + Guid IUpdateSellV1.UpdateSellOrderId => updateSellOrderId; + Guid IUpdateSellV1.TradableId => tradableId; + Address IUpdateSellV1.SellerAvatarAddress => sellerAvatarAddress; + string IUpdateSellV1.ItemSubType => itemSubType.ToString(); + FungibleAssetValue IUpdateSellV1.Price => price; + int IUpdateSellV1.Count => count; + + protected override IImmutableDictionary PlainValueInternal => + new Dictionary + { + [OrderIdKey] = orderId.Serialize(), + [updateSellOrderIdKey] = updateSellOrderId.Serialize(), + [ItemIdKey] = tradableId.Serialize(), + [SellerAvatarAddressKey] = sellerAvatarAddress.Serialize(), + [ItemSubTypeKey] = itemSubType.Serialize(), + [PriceKey] = price.Serialize(), + [ItemCountKey] = count.Serialize(), + }.ToImmutableDictionary(); + + protected override void LoadPlainValueInternal(IImmutableDictionary plainValue) + { + orderId = plainValue[OrderIdKey].ToGuid(); + updateSellOrderId = plainValue[updateSellOrderIdKey].ToGuid(); + tradableId = plainValue[ItemIdKey].ToGuid(); + sellerAvatarAddress = plainValue[SellerAvatarAddressKey].ToAddress(); + itemSubType = plainValue[ItemSubTypeKey].ToEnum(); + price = plainValue[PriceKey].ToFungibleAssetValue(); + count = plainValue[ItemCountKey].ToInteger(); + } + + public override IAccount Execute(IActionContext context) + { + context.UseGas(1); + var states = context.PreviousState; + var inventoryAddress = sellerAvatarAddress.Derive(LegacyInventoryKey); + var worldInformationAddress = sellerAvatarAddress.Derive(LegacyWorldInformationKey); + var questListAddress = sellerAvatarAddress.Derive(LegacyQuestListKey); + var shopAddress = ShardedShopStateV2.DeriveAddress(itemSubType, orderId); + var updateSellShopAddress = ShardedShopStateV2.DeriveAddress(itemSubType, updateSellOrderId); + var updateSellOrderAddress = Order.DeriveAddress(updateSellOrderId); + var itemAddress = Addresses.GetItemAddress(tradableId); + var digestListAddress = OrderDigestListState.DeriveAddress(sellerAvatarAddress); + + CheckObsolete(ActionObsoleteConfig.V100270ObsoleteIndex, context); + + // common + var addressesHex = GetSignerAndOtherAddressesHex(context, sellerAvatarAddress); + var sw = new Stopwatch(); + sw.Start(); + var started = DateTimeOffset.UtcNow; + Log.Verbose("{AddressesHex} updateSell exec started", addressesHex); + + if (price.Sign < 0) + { + throw new InvalidPriceException( + $"{addressesHex} Aborted as the price is less than zero: {price}."); + } + + if (!states.TryGetAvatarStateV2(context.Signer, sellerAvatarAddress, out var avatarState, out _)) + { + throw new FailedLoadStateException( + $"{addressesHex} Aborted as the avatar state of the signer was failed to load."); + } + sw.Stop(); + + Log.Verbose("{AddressesHex} Sell Get AgentAvatarStates: {Elapsed}", addressesHex, sw.Elapsed); + sw.Restart(); + if (!avatarState.worldInformation.IsStageCleared( + GameConfig.RequireClearedStageLevel.ActionsInShop)) + { + avatarState.worldInformation.TryGetLastClearedStageId(out var current); + throw new NotEnoughClearedStageLevelException( + addressesHex, + GameConfig.RequireClearedStageLevel.ActionsInShop, + current); + } + sw.Stop(); + + avatarState.updatedAt = context.BlockIndex; + avatarState.blockIndex = context.BlockIndex; + + if (!states.TryGetState(digestListAddress, out Dictionary rawList)) + { + throw new FailedLoadStateException($"{addressesHex} failed to load {nameof(OrderDigest)}({digestListAddress})."); + } + var digestList = new OrderDigestListState(rawList); + + // migration method + avatarState.inventory.UnlockInvalidSlot(digestList, context.Signer, sellerAvatarAddress); + avatarState.inventory.ReconfigureFungibleItem(digestList, tradableId); + avatarState.inventory.LockByReferringToDigestList(digestList, tradableId, context.BlockIndex); + // + + // for sell cancel + Log.Verbose("{AddressesHex} UpdateSell IsStageCleared: {Elapsed}", addressesHex, sw.Elapsed); + sw.Restart(); + if (!states.TryGetState(shopAddress, out BxDictionary shopStateDict)) + { + throw new FailedLoadStateException($"{addressesHex}failed to load {nameof(ShardedShopStateV2)}({shopAddress})."); + } + sw.Stop(); + + Log.Verbose("{AddressesHex} UpdateSell Sell Cancel Get ShopState: {Elapsed}", addressesHex, sw.Elapsed); + sw.Restart(); + if (!states.TryGetState(Order.DeriveAddress(orderId), out Dictionary orderDict)) + { + throw new FailedLoadStateException($"{addressesHex} failed to load {nameof(Order)}({Order.DeriveAddress(orderId)})."); + } + + var orderOnSale = OrderFactory.Deserialize(orderDict); + orderOnSale.ValidateCancelOrder(avatarState, tradableId); + var itemOnSale = orderOnSale.Cancel(avatarState, context.BlockIndex); + if (context.BlockIndex < orderOnSale.ExpiredBlockIndex) + { + var shardedShopState = new ShardedShopStateV2(shopStateDict); + shardedShopState.Remove(orderOnSale, context.BlockIndex); + states = states.SetState(shopAddress, shardedShopState.Serialize()); + } + + digestList.Remove(orderOnSale.OrderId); + states = states.SetState(itemAddress, itemOnSale.Serialize()) + .SetState(digestListAddress, digestList.Serialize()); + sw.Stop(); + + var expirationMail = avatarState.mailBox.OfType() + .FirstOrDefault(m => m.OrderId.Equals(orderId)); + if (!(expirationMail is null)) + { + avatarState.mailBox.Remove(expirationMail); + } + + // for updateSell + var updateSellShopState = states.TryGetState(updateSellShopAddress, out Dictionary serializedState) + ? new ShardedShopStateV2(serializedState) + : new ShardedShopStateV2(updateSellShopAddress); + + Log.Verbose("{AddressesHex} UpdateSell Get ShardedShopState: {Elapsed}", addressesHex, sw.Elapsed); + sw.Restart(); + var newOrder = OrderFactory.Create(context.Signer, sellerAvatarAddress, updateSellOrderId, price, tradableId, + context.BlockIndex, itemSubType, count); + newOrder.Validate(avatarState, count); + + var tradableItem = newOrder.Sell4(avatarState); + var costumeStatSheet = states.GetSheet(); + var orderDigest = newOrder.Digest(avatarState, costumeStatSheet); + updateSellShopState.Add(orderDigest, context.BlockIndex); + + digestList.Add(orderDigest); + states = states.SetState(digestListAddress, digestList.Serialize()); + states = states.SetState(inventoryAddress, avatarState.inventory.Serialize()) + .SetState(worldInformationAddress, avatarState.worldInformation.Serialize()) + .SetState(questListAddress, avatarState.questList.Serialize()) + .SetState(sellerAvatarAddress, avatarState.SerializeV2()); + sw.Stop(); + + Log.Verbose("{AddressesHex} UpdateSell Set AvatarState: {Elapsed}", addressesHex, sw.Elapsed); + sw.Restart(); + states = states + .SetState(itemAddress, tradableItem.Serialize()) + .SetState(updateSellOrderAddress, newOrder.Serialize()) + .SetState(updateSellShopAddress, updateSellShopState.Serialize()); + sw.Stop(); + + var ended = DateTimeOffset.UtcNow; + Log.Verbose("{AddressesHex} UpdateSell Set ShopState: {Elapsed}", addressesHex, sw.Elapsed); + Log.Verbose("{AddressesHex} UpdateSell Total Executed Time: {Elapsed}", addressesHex, ended - started); + + return states; + } + } +} diff --git a/Lib9c/Action/UpdateSell3.cs b/Lib9c/Action/UpdateSell3.cs new file mode 100644 index 0000000000..036caa0c55 --- /dev/null +++ b/Lib9c/Action/UpdateSell3.cs @@ -0,0 +1,211 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Diagnostics; +using System.Linq; +using Bencodex.Types; +using Lib9c.Abstractions; +using Lib9c.Model.Order; +using Libplanet.Action; +using Libplanet.Action.State; +using Libplanet.Crypto; +using Libplanet.Types.Assets; +using Nekoyume.Battle; +using Nekoyume.Model.Mail; +using Nekoyume.Model.State; +using Nekoyume.TableData; +using Serilog; +using static Lib9c.SerializeKeys; +using BxDictionary = Bencodex.Types.Dictionary; +using BxList = Bencodex.Types.List; + +namespace Nekoyume.Action +{ + /// + /// Hard forked at https://github.com/planetarium/lib9c/pull/1022 + /// Updated at https://github.com/planetarium/lib9c/pull/1022 + /// + [Serializable] + [ActionObsolete(ActionObsoleteConfig.V200020AccidentObsoleteIndex)] + [ActionType("update_sell3")] + public class UpdateSell3 : GameAction, IUpdateSellV2 + { + public Address sellerAvatarAddress; + public IEnumerable updateSellInfos; + + Address IUpdateSellV2.SellerAvatarAddress => sellerAvatarAddress; + IEnumerable IUpdateSellV2.UpdateSellInfos => + updateSellInfos.Select(x => x.Serialize()); + + protected override IImmutableDictionary PlainValueInternal => + new Dictionary + { + [SellerAvatarAddressKey] = sellerAvatarAddress.Serialize(), + [UpdateSellInfoKey] = updateSellInfos.Select(info => info.Serialize()).Serialize(), + }.ToImmutableDictionary(); + + protected override void LoadPlainValueInternal( + IImmutableDictionary plainValue) + { + sellerAvatarAddress = plainValue[SellerAvatarAddressKey].ToAddress(); + updateSellInfos = plainValue[UpdateSellInfoKey] + .ToEnumerable(info => new UpdateSellInfo((List)info)); + } + + public override IAccount Execute(IActionContext context) + { + context.UseGas(1); + var states = context.PreviousState; + var inventoryAddress = sellerAvatarAddress.Derive(LegacyInventoryKey); + var worldInformationAddress = sellerAvatarAddress.Derive(LegacyWorldInformationKey); + var questListAddress = sellerAvatarAddress.Derive(LegacyQuestListKey); + var digestListAddress = OrderDigestListState.DeriveAddress(sellerAvatarAddress); + + CheckObsolete(ActionObsoleteConfig.V100320ObsoleteIndex, context); + + // common + var addressesHex = GetSignerAndOtherAddressesHex(context, sellerAvatarAddress); + var sw = new Stopwatch(); + sw.Start(); + var started = DateTimeOffset.UtcNow; + Log.Debug("{AddressesHex} updateSell exec started", addressesHex); + + if (!updateSellInfos.Any()) + { + throw new ListEmptyException($"{addressesHex} List - UpdateSell infos was empty."); + } + if (!states.TryGetAvatarStateV2(context.Signer, sellerAvatarAddress, out var avatarState, out _)) + { + throw new FailedLoadStateException( + $"{addressesHex} Aborted as the avatar state of the signer was failed to load."); + } + sw.Stop(); + Log.Verbose("{AddressesHex} Sell Get AgentAvatarStates: {Elapsed}", addressesHex, sw.Elapsed); + sw.Restart(); + if (!avatarState.worldInformation.IsStageCleared(GameConfig.RequireClearedStageLevel.ActionsInShop)) + { + avatarState.worldInformation.TryGetLastClearedStageId(out var current); + throw new NotEnoughClearedStageLevelException( + addressesHex, + GameConfig.RequireClearedStageLevel.ActionsInShop, + current); + } + sw.Stop(); + Log.Verbose("{AddressesHex} UpdateSell IsStageCleared: {Elapsed}", addressesHex, sw.Elapsed); + + avatarState.updatedAt = context.BlockIndex; + avatarState.blockIndex = context.BlockIndex; + + var costumeStatSheet = states.GetSheet(); + + if (!states.TryGetState(digestListAddress, out Dictionary rawList)) + { + throw new FailedLoadStateException( + $"{addressesHex} failed to load {nameof(OrderDigest)}({digestListAddress})."); + } + var digestList = new OrderDigestListState(rawList); + + foreach (var updateSellInfo in updateSellInfos) + { + if (updateSellInfo.price.Sign < 0) + { + throw new InvalidPriceException($"{addressesHex} Aborted as the price is less than zero: {updateSellInfo.price}."); + } + + var shopAddress = ShardedShopStateV2.DeriveAddress(updateSellInfo.itemSubType, updateSellInfo.orderId); + var updateSellShopAddress = ShardedShopStateV2.DeriveAddress(updateSellInfo.itemSubType, updateSellInfo.updateSellOrderId); + var updateSellOrderAddress = Order.DeriveAddress(updateSellInfo.updateSellOrderId); + var itemAddress = Addresses.GetItemAddress(updateSellInfo.tradableId); + + // migration method + avatarState.inventory.UnlockInvalidSlot(digestList, context.Signer, sellerAvatarAddress); + avatarState.inventory.ReconfigureFungibleItem(digestList, updateSellInfo.tradableId); + avatarState.inventory.LockByReferringToDigestList(digestList, updateSellInfo.tradableId, + context.BlockIndex); + + // for sell cancel + sw.Restart(); + if (!states.TryGetState(shopAddress, out BxDictionary shopStateDict)) + { + throw new FailedLoadStateException($"{addressesHex}failed to load {nameof(ShardedShopStateV2)}({shopAddress})."); + } + + sw.Stop(); + Log.Verbose("{AddressesHex} UpdateSell Sell Cancel Get ShopState: {Elapsed}", addressesHex, sw.Elapsed); + sw.Restart(); + if (!states.TryGetState(Order.DeriveAddress(updateSellInfo.orderId), out Dictionary orderDict)) + { + throw new FailedLoadStateException($"{addressesHex} failed to load {nameof(Order)}({Order.DeriveAddress(updateSellInfo.orderId)})."); + } + + var orderOnSale = OrderFactory.Deserialize(orderDict); + orderOnSale.ValidateCancelOrder(avatarState, updateSellInfo.tradableId); + orderOnSale.Cancel(avatarState, context.BlockIndex); + if (context.BlockIndex < orderOnSale.ExpiredBlockIndex) + { + var shardedShopState = new ShardedShopStateV2(shopStateDict); + shardedShopState.Remove(orderOnSale, context.BlockIndex); + states = states.SetState(shopAddress, shardedShopState.Serialize()); + } + + digestList.Remove(orderOnSale.OrderId); + sw.Stop(); + + var expirationMail = avatarState.mailBox.OfType() + .FirstOrDefault(m => m.OrderId.Equals(updateSellInfo.orderId)); + if (!(expirationMail is null)) + { + avatarState.mailBox.Remove(expirationMail); + } + + // for updateSell + var updateSellShopState = + states.TryGetState(updateSellShopAddress, out Dictionary serializedState) + ? new ShardedShopStateV2(serializedState) + : new ShardedShopStateV2(updateSellShopAddress); + + Log.Verbose("{AddressesHex} UpdateSell Get ShardedShopState: {Elapsed}", addressesHex, sw.Elapsed); + sw.Restart(); + var newOrder = OrderFactory.Create( + context.Signer, + sellerAvatarAddress, + updateSellInfo.updateSellOrderId, + updateSellInfo.price, + updateSellInfo.tradableId, + context.BlockIndex, + updateSellInfo.itemSubType, + updateSellInfo.count + ); + + newOrder.Validate(avatarState, updateSellInfo.count); + + var tradableItem = newOrder.Sell4(avatarState); + var orderDigest = newOrder.Digest(avatarState, costumeStatSheet); + updateSellShopState.Add(orderDigest, context.BlockIndex); + + digestList.Add(orderDigest); + + states = states + .SetState(itemAddress, tradableItem.Serialize()) + .SetState(updateSellOrderAddress, newOrder.Serialize()) + .SetState(updateSellShopAddress, updateSellShopState.Serialize()); + sw.Stop(); + Log.Verbose("{AddressesHex} UpdateSell Set ShopState: {Elapsed}", addressesHex, sw.Elapsed); + } + + sw.Restart(); + states = states.SetState(inventoryAddress, avatarState.inventory.Serialize()) + .SetState(worldInformationAddress, avatarState.worldInformation.Serialize()) + .SetState(questListAddress, avatarState.questList.Serialize()) + .SetState(sellerAvatarAddress, avatarState.SerializeV2()) + .SetState(digestListAddress, digestList.Serialize()); + sw.Stop(); + Log.Verbose("{AddressesHex} UpdateSell Set AvatarState: {Elapsed}", addressesHex, sw.Elapsed); + + var ended = DateTimeOffset.UtcNow; + Log.Debug("{AddressesHex} UpdateSell Total Executed Time: {Elapsed}", addressesHex, ended - started); + + return states; + } + } +} diff --git a/Lib9c/Action/UpdateSell4.cs b/Lib9c/Action/UpdateSell4.cs new file mode 100644 index 0000000000..1b6ccb1014 --- /dev/null +++ b/Lib9c/Action/UpdateSell4.cs @@ -0,0 +1,216 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Diagnostics; +using System.Linq; +using Bencodex.Types; +using Lib9c.Abstractions; +using Lib9c.Model.Order; +using Libplanet.Action; +using Libplanet.Action.State; +using Libplanet.Crypto; +using Libplanet.Types.Assets; +using Nekoyume.Battle; +using Nekoyume.Model.Mail; +using Nekoyume.Model.State; +using Nekoyume.TableData; +using Serilog; +using static Lib9c.SerializeKeys; +using BxDictionary = Bencodex.Types.Dictionary; +using BxList = Bencodex.Types.List; + +namespace Nekoyume.Action +{ + /// + /// Hard forked at https://github.com/planetarium/lib9c/pull/1022 + /// Updated at https://github.com/planetarium/lib9c/pull/1022 + /// + [Serializable] + [ActionType("update_sell4")] + [ActionObsolete(ActionObsoleteConfig.V200020AccidentObsoleteIndex)] + public class UpdateSell4 : GameAction, IUpdateSellV2 + { + private const int UpdateCapacity = 100; + public Address sellerAvatarAddress; + public IEnumerable updateSellInfos; + + Address IUpdateSellV2.SellerAvatarAddress => sellerAvatarAddress; + IEnumerable IUpdateSellV2.UpdateSellInfos => + updateSellInfos.Select(x => x.Serialize()); + + protected override IImmutableDictionary PlainValueInternal => + new Dictionary + { + [SellerAvatarAddressKey] = sellerAvatarAddress.Serialize(), + [UpdateSellInfoKey] = updateSellInfos.Select(info => info.Serialize()).Serialize(), + }.ToImmutableDictionary(); + + protected override void LoadPlainValueInternal( + IImmutableDictionary plainValue) + { + sellerAvatarAddress = plainValue[SellerAvatarAddressKey].ToAddress(); + updateSellInfos = plainValue[UpdateSellInfoKey] + .ToEnumerable(info => new UpdateSellInfo((List)info)); + } + + public override IAccount Execute(IActionContext context) + { + context.UseGas(1); + var states = context.PreviousState; + var inventoryAddress = sellerAvatarAddress.Derive(LegacyInventoryKey); + var worldInformationAddress = sellerAvatarAddress.Derive(LegacyWorldInformationKey); + var questListAddress = sellerAvatarAddress.Derive(LegacyQuestListKey); + var digestListAddress = OrderDigestListState.DeriveAddress(sellerAvatarAddress); + + CheckObsolete(ActionObsoleteConfig.V100351ObsoleteIndex, context); + + if (updateSellInfos.Count() > UpdateCapacity) + { + throw new ArgumentOutOfRangeException($"{nameof(updateSellInfos)} must be less than or equal 100."); + } + // common + var addressesHex = GetSignerAndOtherAddressesHex(context, sellerAvatarAddress); + var sw = new Stopwatch(); + sw.Start(); + var started = DateTimeOffset.UtcNow; + Log.Debug("{AddressesHex} updateSell exec started", addressesHex); + + if (!updateSellInfos.Any()) + { + throw new ListEmptyException($"{addressesHex} List - UpdateSell infos was empty."); + } + if (!states.TryGetAvatarStateV2(context.Signer, sellerAvatarAddress, out var avatarState, out _)) + { + throw new FailedLoadStateException( + $"{addressesHex} Aborted as the avatar state of the signer was failed to load."); + } + sw.Stop(); + Log.Verbose("{AddressesHex} Sell Get AgentAvatarStates: {Elapsed}", addressesHex, sw.Elapsed); + sw.Restart(); + if (!avatarState.worldInformation.IsStageCleared(GameConfig.RequireClearedStageLevel.ActionsInShop)) + { + avatarState.worldInformation.TryGetLastClearedStageId(out var current); + throw new NotEnoughClearedStageLevelException( + addressesHex, + GameConfig.RequireClearedStageLevel.ActionsInShop, + current); + } + sw.Stop(); + Log.Verbose("{AddressesHex} UpdateSell IsStageCleared: {Elapsed}", addressesHex, sw.Elapsed); + + avatarState.updatedAt = context.BlockIndex; + avatarState.blockIndex = context.BlockIndex; + + var costumeStatSheet = states.GetSheet(); + + if (!states.TryGetState(digestListAddress, out Dictionary rawList)) + { + throw new FailedLoadStateException( + $"{addressesHex} failed to load {nameof(OrderDigest)}({digestListAddress})."); + } + var digestList = new OrderDigestListState(rawList); + + foreach (var updateSellInfo in updateSellInfos) + { + if (updateSellInfo.price.Sign < 0) + { + throw new InvalidPriceException($"{addressesHex} Aborted as the price is less than zero: {updateSellInfo.price}."); + } + + var shopAddress = ShardedShopStateV2.DeriveAddress(updateSellInfo.itemSubType, updateSellInfo.orderId); + var updateSellShopAddress = ShardedShopStateV2.DeriveAddress(updateSellInfo.itemSubType, updateSellInfo.updateSellOrderId); + var updateSellOrderAddress = Order.DeriveAddress(updateSellInfo.updateSellOrderId); + var itemAddress = Addresses.GetItemAddress(updateSellInfo.tradableId); + + // migration method + avatarState.inventory.UnlockInvalidSlot(digestList, context.Signer, sellerAvatarAddress); + avatarState.inventory.ReconfigureFungibleItem(digestList, updateSellInfo.tradableId); + avatarState.inventory.LockByReferringToDigestList(digestList, updateSellInfo.tradableId, + context.BlockIndex); + + // for sell cancel + sw.Restart(); + if (!states.TryGetState(shopAddress, out BxDictionary shopStateDict)) + { + throw new FailedLoadStateException($"{addressesHex}failed to load {nameof(ShardedShopStateV2)}({shopAddress})."); + } + + sw.Stop(); + Log.Verbose("{AddressesHex} UpdateSell Sell Cancel Get ShopState: {Elapsed}", addressesHex, sw.Elapsed); + sw.Restart(); + if (!states.TryGetState(Order.DeriveAddress(updateSellInfo.orderId), out Dictionary orderDict)) + { + throw new FailedLoadStateException($"{addressesHex} failed to load {nameof(Order)}({Order.DeriveAddress(updateSellInfo.orderId)})."); + } + + var orderOnSale = OrderFactory.Deserialize(orderDict); + orderOnSale.ValidateCancelOrder(avatarState, updateSellInfo.tradableId); + orderOnSale.Cancel(avatarState, context.BlockIndex); + if (context.BlockIndex < orderOnSale.ExpiredBlockIndex) + { + var shardedShopState = new ShardedShopStateV2(shopStateDict); + shardedShopState.Remove(orderOnSale, context.BlockIndex); + states = states.SetState(shopAddress, shardedShopState.Serialize()); + } + + digestList.Remove(orderOnSale.OrderId); + sw.Stop(); + + var expirationMail = avatarState.mailBox.OfType() + .FirstOrDefault(m => m.OrderId.Equals(updateSellInfo.orderId)); + if (!(expirationMail is null)) + { + avatarState.mailBox.Remove(expirationMail); + } + + // for updateSell + var updateSellShopState = + states.TryGetState(updateSellShopAddress, out Dictionary serializedState) + ? new ShardedShopStateV2(serializedState) + : new ShardedShopStateV2(updateSellShopAddress); + + Log.Verbose("{AddressesHex} UpdateSell Get ShardedShopState: {Elapsed}", addressesHex, sw.Elapsed); + sw.Restart(); + var newOrder = OrderFactory.Create( + context.Signer, + sellerAvatarAddress, + updateSellInfo.updateSellOrderId, + updateSellInfo.price, + updateSellInfo.tradableId, + context.BlockIndex, + updateSellInfo.itemSubType, + updateSellInfo.count + ); + + newOrder.Validate(avatarState, updateSellInfo.count); + + var tradableItem = newOrder.Sell4(avatarState); + var orderDigest = newOrder.Digest(avatarState, costumeStatSheet); + updateSellShopState.Add(orderDigest, context.BlockIndex); + + digestList.Add(orderDigest); + + states = states + .SetState(itemAddress, tradableItem.Serialize()) + .SetState(updateSellOrderAddress, newOrder.Serialize()) + .SetState(updateSellShopAddress, updateSellShopState.Serialize()); + sw.Stop(); + Log.Verbose("{AddressesHex} UpdateSell Set ShopState: {Elapsed}", addressesHex, sw.Elapsed); + } + + sw.Restart(); + states = states.SetState(inventoryAddress, avatarState.inventory.Serialize()) + .SetState(worldInformationAddress, avatarState.worldInformation.Serialize()) + .SetState(questListAddress, avatarState.questList.Serialize()) + .SetState(sellerAvatarAddress, avatarState.SerializeV2()) + .SetState(digestListAddress, digestList.Serialize()); + sw.Stop(); + Log.Verbose("{AddressesHex} UpdateSell Set AvatarState: {Elapsed}", addressesHex, sw.Elapsed); + + var ended = DateTimeOffset.UtcNow; + Log.Debug("{AddressesHex} UpdateSell Total Executed Time: {Elapsed}", addressesHex, ended - started); + + return states; + } + } +} diff --git a/Lib9c/Model/State/StakeState.cs b/Lib9c/Model/State/StakeState.cs index 59a6d88324..288f9a96f7 100644 --- a/Lib9c/Model/State/StakeState.cs +++ b/Lib9c/Model/State/StakeState.cs @@ -60,7 +60,7 @@ public void Achieve(int level, int step) // Because we need to make sure that the reward sheet V3 is applied // after the `ClaimStakeReward5` action is deprecated. // And we expect the index will be 7_650_000L. - public const long StakeRewardSheetV3Index = ActionObsoleteConfig.V200060ObsoleteIndex + 1; + public const long StakeRewardSheetV3Index = ClaimStakeReward5.ObsoleteBlockIndex + 1; public long CancellableBlockIndex { get; private set; } public long StartedBlockIndex { get; private set; }