diff --git a/Libplanet.SDK.Action.Tests/Libplanet.SDK.Action.Tests.csproj b/Libplanet.SDK.Action.Tests/Libplanet.SDK.Action.Tests.csproj
new file mode 100644
index 00000000000..56e89f87852
--- /dev/null
+++ b/Libplanet.SDK.Action.Tests/Libplanet.SDK.Action.Tests.csproj
@@ -0,0 +1,32 @@
+
+
+
+ net6.0
+ enable
+ enable
+
+ false
+
+ Libplanet.SDK.Tests
+
+
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
+
+
+
diff --git a/Libplanet.SDK.Action.Tests/MockActionContext.cs b/Libplanet.SDK.Action.Tests/MockActionContext.cs
new file mode 100644
index 00000000000..a45f78098d9
--- /dev/null
+++ b/Libplanet.SDK.Action.Tests/MockActionContext.cs
@@ -0,0 +1,60 @@
+using Libplanet.Action;
+using Libplanet.Action.State;
+using Libplanet.Crypto;
+using Libplanet.Types.Evidence;
+using Libplanet.Types.Tx;
+
+namespace Libplanet.SDK.Action.Tests
+{
+ public class MockActionContext : IActionContext
+ {
+ public MockActionContext(
+ Address signer,
+ Address miner,
+ IWorld previousState)
+ {
+ Signer = signer;
+ Miner = miner;
+ PreviousState = previousState;
+ }
+
+ public Address Signer { get; }
+
+ public TxId? TxId =>
+ throw new NotSupportedException();
+
+ public Address Miner { get; }
+
+ public long BlockIndex =>
+ throw new NotSupportedException();
+
+ public int BlockProtocolVersion =>
+ throw new NotSupportedException();
+
+ public IWorld PreviousState { get; }
+
+ public int RandomSeed =>
+ throw new NotSupportedException();
+
+ public bool IsPolicyAction =>
+ throw new NotSupportedException();
+
+ public IReadOnlyList Txs =>
+ throw new NotSupportedException();
+
+ public IReadOnlyList Evidence =>
+ throw new NotSupportedException();
+
+ public void UseGas(long gas) =>
+ throw new NotSupportedException();
+
+ public IRandom GetRandom() =>
+ throw new NotSupportedException();
+
+ public long GasUsed() =>
+ throw new NotSupportedException();
+
+ public long GasLimit() =>
+ throw new NotSupportedException();
+ }
+}
diff --git a/Libplanet.SDK.Action.Tests/SimpleRPG/Actions/AvatarAction.cs b/Libplanet.SDK.Action.Tests/SimpleRPG/Actions/AvatarAction.cs
new file mode 100644
index 00000000000..c829d5db068
--- /dev/null
+++ b/Libplanet.SDK.Action.Tests/SimpleRPG/Actions/AvatarAction.cs
@@ -0,0 +1,46 @@
+using Bencodex.Types;
+using Libplanet.Action;
+using Libplanet.Crypto;
+using Libplanet.SDK.Action.Attributes;
+using Libplanet.SDK.Action.Tests.SimpleRPG.Models;
+
+namespace Libplanet.SDK.Action.Tests.SimpleRPG.Actions
+{
+ [ActionType("Avatar")]
+ public class AvatarAction : ActionBase
+ {
+ // This has no IAccount associated with its domain.
+ public override Address StorageAddress => default;
+
+ [Executable]
+ public void Create(Text args)
+ {
+ string name = args;
+ Call(nameof(InfoAction.Create), new object?[] { name });
+ Call(nameof(InventoryAction.Create));
+ }
+
+ [Callable]
+ public Avatar GetAvatar(Address address)
+ {
+ Info info = Call(
+ nameof(InfoAction.GetInfo),
+ new object?[] { address });
+ Inventory inventory = Call(
+ nameof(InventoryAction.GetInventory),
+ new object?[] { address });
+ return new Avatar(info, inventory);
+ }
+
+ [Callable]
+ public void SetAvatar(Address address, Avatar avatar)
+ {
+ Call(
+ nameof(InfoAction.SetInfo),
+ new object?[] { address, avatar.Info });
+ Call(
+ nameof(InventoryAction.SetInventory),
+ new object?[] { address, avatar.Inventory });
+ }
+ }
+}
diff --git a/Libplanet.SDK.Action.Tests/SimpleRPG/Actions/FarmAction.cs b/Libplanet.SDK.Action.Tests/SimpleRPG/Actions/FarmAction.cs
new file mode 100644
index 00000000000..a03e90b04b4
--- /dev/null
+++ b/Libplanet.SDK.Action.Tests/SimpleRPG/Actions/FarmAction.cs
@@ -0,0 +1,31 @@
+using Bencodex.Types;
+using Libplanet.Action;
+using Libplanet.Crypto;
+using Libplanet.SDK.Action.Attributes;
+using Libplanet.SDK.Action.Tests.SimpleRPG.Models;
+
+namespace Libplanet.SDK.Action.Tests.SimpleRPG.Actions
+{
+ [ActionType("Farm")]
+ public class FarmAction : ActionBase
+ {
+ public const int ExpPerFarm = 10;
+ public const int GoldPerFarm = 20;
+
+ // This has no IAccount associated with its domain.
+ public override Address StorageAddress => default;
+
+ [Executable]
+ public void Farm()
+ {
+ Avatar avatar = Call(
+ nameof(AvatarAction.GetAvatar),
+ new object?[] { Signer });
+ avatar.Info.AddExp(ExpPerFarm);
+ avatar.Inventory.AddGold(GoldPerFarm);
+ Call(
+ nameof(AvatarAction.SetAvatar),
+ new object?[] { Signer, avatar });
+ }
+ }
+}
diff --git a/Libplanet.SDK.Action.Tests/SimpleRPG/Actions/InfoAction.cs b/Libplanet.SDK.Action.Tests/SimpleRPG/Actions/InfoAction.cs
new file mode 100644
index 00000000000..b308f1373aa
--- /dev/null
+++ b/Libplanet.SDK.Action.Tests/SimpleRPG/Actions/InfoAction.cs
@@ -0,0 +1,33 @@
+using Libplanet.Crypto;
+using Libplanet.SDK.Action.Attributes;
+using Libplanet.SDK.Action.Tests.SimpleRPG.Models;
+
+namespace Libplanet.SDK.Action.Tests.SimpleRPG.Actions
+{
+ public class InfoAction : ActionBase
+ {
+ public override Address StorageAddress =>
+ new Address("0x1000000000000000000000000000000000000001");
+
+ [Callable]
+ public Info Create(string name)
+ {
+ if (GetState(Signer) is { } value)
+ {
+ throw new InvalidOperationException("Info already exists.");
+ }
+
+ Info info = new Info(name, 0);
+ SetInfo(Signer, info);
+ return info;
+ }
+
+ [Callable]
+ public Info GetInfo(Address address) =>
+ new Info(GetState(address) ?? throw new NullReferenceException());
+
+ [Callable]
+ public void SetInfo(Address address, Info info) =>
+ SetState(address, info.Serialized);
+ }
+}
diff --git a/Libplanet.SDK.Action.Tests/SimpleRPG/Actions/InventoryAction.cs b/Libplanet.SDK.Action.Tests/SimpleRPG/Actions/InventoryAction.cs
new file mode 100644
index 00000000000..788ab3c11a7
--- /dev/null
+++ b/Libplanet.SDK.Action.Tests/SimpleRPG/Actions/InventoryAction.cs
@@ -0,0 +1,33 @@
+using Libplanet.Crypto;
+using Libplanet.SDK.Action.Attributes;
+using Libplanet.SDK.Action.Tests.SimpleRPG.Models;
+
+namespace Libplanet.SDK.Action.Tests.SimpleRPG.Actions
+{
+ public class InventoryAction : ActionBase
+ {
+ public override Address StorageAddress =>
+ new Address("0x1000000000000000000000000000000000000002");
+
+ [Callable]
+ public Inventory Create()
+ {
+ if (GetState(Signer) is { })
+ {
+ throw new InvalidOperationException("Inventory already exists.");
+ }
+
+ Inventory inventory = new Inventory();
+ SetInventory(Signer, inventory);
+ return inventory;
+ }
+
+ [Callable]
+ public Inventory GetInventory(Address address) =>
+ new Inventory(GetState(address) ?? throw new NullReferenceException());
+
+ [Callable]
+ public void SetInventory(Address address, Inventory inventory) =>
+ SetState(address, inventory.Serialized);
+ }
+}
diff --git a/Libplanet.SDK.Action.Tests/SimpleRPG/Models/Avatar.cs b/Libplanet.SDK.Action.Tests/SimpleRPG/Models/Avatar.cs
new file mode 100644
index 00000000000..e43a65ef4d7
--- /dev/null
+++ b/Libplanet.SDK.Action.Tests/SimpleRPG/Models/Avatar.cs
@@ -0,0 +1,14 @@
+namespace Libplanet.SDK.Action.Tests.SimpleRPG.Models
+{
+ public class Avatar
+ {
+ public Info Info { get; }
+ public Inventory Inventory { get; }
+
+ public Avatar(Info info, Inventory inventory)
+ {
+ Info = info;
+ Inventory = inventory;
+ }
+ }
+}
diff --git a/Libplanet.SDK.Action.Tests/SimpleRPG/Models/Info.cs b/Libplanet.SDK.Action.Tests/SimpleRPG/Models/Info.cs
new file mode 100644
index 00000000000..6f880a36609
--- /dev/null
+++ b/Libplanet.SDK.Action.Tests/SimpleRPG/Models/Info.cs
@@ -0,0 +1,38 @@
+using Bencodex.Types;
+
+namespace Libplanet.SDK.Action.Tests.SimpleRPG.Models
+{
+ public class Info
+ {
+ public string Name { get; }
+
+ public int Exp { get; private set; }
+
+ public int Level => (Exp / 100);
+
+ public Info(string name)
+ : this(name, 0)
+ {
+ }
+
+ public Info(IValue value)
+ : this((Text)((List)value)[0], (Integer)((List)value)[1])
+ {
+ }
+
+ public Info(string name, int exp)
+ {
+ Name = name;
+ Exp = exp;
+ }
+
+ public IValue Serialized => List.Empty
+ .Add(Name)
+ .Add(Exp);
+
+ public void AddExp(int exp)
+ {
+ Exp = Exp + exp;
+ }
+ }
+}
diff --git a/Libplanet.SDK.Action.Tests/SimpleRPG/Models/Inventory.cs b/Libplanet.SDK.Action.Tests/SimpleRPG/Models/Inventory.cs
new file mode 100644
index 00000000000..4ad64a15560
--- /dev/null
+++ b/Libplanet.SDK.Action.Tests/SimpleRPG/Models/Inventory.cs
@@ -0,0 +1,31 @@
+using Bencodex.Types;
+
+namespace Libplanet.SDK.Action.Tests.SimpleRPG.Models
+{
+ public class Inventory
+ {
+ public int Gold { get; private set; }
+
+ public Inventory()
+ : this(0)
+ {
+ }
+
+ public Inventory(IValue value)
+ : this((int)(Integer)value)
+ {
+ }
+
+ public Inventory(int gold)
+ {
+ Gold = gold;
+ }
+
+ public IValue Serialized => new Integer(Gold);
+
+ public void AddGold(int gold)
+ {
+ Gold = Gold + gold;
+ }
+ }
+}
diff --git a/Libplanet.SDK.Action.Tests/SimpleRPG/SimpleRPGActionsTest.cs b/Libplanet.SDK.Action.Tests/SimpleRPG/SimpleRPGActionsTest.cs
new file mode 100644
index 00000000000..ed5d91b33bc
--- /dev/null
+++ b/Libplanet.SDK.Action.Tests/SimpleRPG/SimpleRPGActionsTest.cs
@@ -0,0 +1,118 @@
+using System.Collections.Immutable;
+using System.Reflection;
+using Bencodex.Types;
+using Libplanet.Action;
+using Libplanet.Action.Loader;
+using Libplanet.Action.State;
+using Libplanet.Crypto;
+using Libplanet.SDK.Action.Tests.SimpleRPG.Actions;
+using Libplanet.SDK.Action.Tests.SimpleRPG.Models;
+using Libplanet.Store;
+using Libplanet.Store.Trie;
+using Libplanet.Types.Blocks;
+using Xunit;
+
+namespace Libplanet.SDK.Action.Tests.Sample
+{
+ public class SimpleRPGActionsTest
+ {
+ private TypedActionLoader _loader;
+ private IStateStore _stateStore;
+ private IWorld _world;
+
+ public SimpleRPGActionsTest()
+ {
+ _loader = new TypedActionLoader(
+ ImmutableDictionary.Empty
+ .Add(new Text("Avatar"), typeof(AvatarAction))
+ .Add(new Text("Farm"), typeof(FarmAction)));
+
+ _stateStore = new TrieStateStore(new MemoryKeyValueStore());
+
+ ITrie trie = _stateStore.GetStateRoot(null);
+ trie = trie.SetMetadata(new TrieMetadata(Block.CurrentProtocolVersion));
+ trie = _stateStore.Commit(trie);
+ _world = new World(new WorldBaseState(trie, _stateStore));
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public void Scenario(bool commit)
+ {
+ IValue plainValue = Dictionary.Empty
+ .Add("type_id", "Avatar")
+ .Add("exec", "Create")
+ .Add("args", List.Empty.Add("Hero"));
+ IAction action = Assert.IsType(_loader.LoadAction(0, plainValue));
+ Address signer = new PrivateKey().Address;
+ IWorld world = _world;
+
+ world = action.Execute(new MockActionContext(signer, signer, world));
+ world = commit ? _stateStore.CommitWorld(world) : world;
+ Assert.Equal(
+ new Info("Hero").Serialized,
+ world
+ .GetAccountState(new Address("0x1000000000000000000000000000000000000001"))
+ .GetState(signer));
+ Assert.Equal(
+ new Inventory().Serialized,
+ world
+ .GetAccountState(new Address("0x1000000000000000000000000000000000000002"))
+ .GetState(signer));
+
+ const int repeat = 3;
+ foreach (var _ in Enumerable.Range(0, repeat))
+ {
+ plainValue = Dictionary.Empty
+ .Add("type_id", "Farm")
+ .Add("exec", "Farm")
+ .Add("args", List.Empty);
+ action = Assert.IsType(_loader.LoadAction(0, plainValue));
+ world = action.Execute(new MockActionContext(signer, signer, world));
+ world = commit ? _stateStore.CommitWorld(world) : world;
+ }
+
+ Assert.Equal(
+ new Info("Hero", FarmAction.ExpPerFarm * repeat).Serialized,
+ world
+ .GetAccountState(new Address("0x1000000000000000000000000000000000000001"))
+ .GetState(signer));
+ Assert.Equal(
+ new Inventory(FarmAction.GoldPerFarm * repeat).Serialized,
+ world
+ .GetAccountState(new Address("0x1000000000000000000000000000000000000002"))
+ .GetState(signer));
+ }
+
+ [Fact]
+ public void CannotCreateTwice()
+ {
+ IValue plainValue = Dictionary.Empty
+ .Add("type_id", "Avatar")
+ .Add("exec", "Create")
+ .Add("args", List.Empty.Add("Hero"));
+ IAction action = Assert.IsType(_loader.LoadAction(0, plainValue));
+ Address signer = new PrivateKey().Address;
+ IWorld world = _world;
+
+ world = action.Execute(new MockActionContext(signer, signer, world));
+ world = _stateStore.CommitWorld(world);
+
+ plainValue = Dictionary.Empty
+ .Add("type_id", "Avatar")
+ .Add("exec", "Create")
+ .Add("args", List.Empty.Add("Princess"));
+ action = Assert.IsType(_loader.LoadAction(0, plainValue));
+ Assert.Contains(
+ "Info already exists",
+ Assert.IsType(
+ Assert.IsType(
+ Assert.Throws(() =>
+ action.Execute(new MockActionContext(signer, signer, world)))
+ .InnerException)
+ .InnerException)
+ .Message);
+ }
+ }
+}
diff --git a/Libplanet.SDK.Action.Tests/SimpleTools/Actions/CalculatorAction.cs b/Libplanet.SDK.Action.Tests/SimpleTools/Actions/CalculatorAction.cs
new file mode 100644
index 00000000000..e6588a07cc5
--- /dev/null
+++ b/Libplanet.SDK.Action.Tests/SimpleTools/Actions/CalculatorAction.cs
@@ -0,0 +1,57 @@
+using Bencodex.Types;
+using Libplanet.Action;
+using Libplanet.Crypto;
+using Libplanet.SDK.Action.Attributes;
+
+namespace Libplanet.SDK.Action.Tests.SimpleTools.Actions
+{
+ [ActionType("Calc")]
+ public class CalculatorAction : SimpleToolsActionBase
+ {
+ public override Address StorageAddress =>
+ new Address("0x1000000000000000000000000000000000000001");
+
+ [Executable("Adds two numbers.")]
+ public void Add(
+ [Parameter("The first operand.")]Integer x,
+ [Parameter("The second operand.")]Integer y)
+ {
+ Integer result = x + y;
+ Call(nameof(HistoryAction.LogInteger), new object[] { result });
+ SetState(Signer, result);
+ }
+
+ [Executable]
+ public void Subtract(Integer x, Integer y)
+ {
+ Integer result = x - y;
+ Call(nameof(HistoryAction.LogInteger), new object[] { result });
+ SetState(Signer, result);
+ }
+
+ [Executable]
+ public void Multiply(Integer x, Integer y)
+ {
+ Integer result = x * y;
+ Call(nameof(HistoryAction.LogInteger), new object[] { result });
+ SetState(Signer, result);
+ }
+
+ [Executable("Squares the number.")]
+ public void Square(
+ [Parameter("The number to sqaure.")]Integer x)
+ {
+ Integer result = x * x;
+ Call(nameof(HistoryAction.LogInteger), new object[] { result });
+ SetState(Signer, result);
+ }
+
+ [Executable]
+ public void LogLength()
+ {
+ Integer result = Call(nameof(HistoryAction.LogLength));
+ Call(nameof(HistoryAction.LogInteger), new object[] { result });
+ SetState(Signer, result);
+ }
+ }
+}
diff --git a/Libplanet.SDK.Action.Tests/SimpleTools/Actions/HistoryAction.cs b/Libplanet.SDK.Action.Tests/SimpleTools/Actions/HistoryAction.cs
new file mode 100644
index 00000000000..6f71c5ef178
--- /dev/null
+++ b/Libplanet.SDK.Action.Tests/SimpleTools/Actions/HistoryAction.cs
@@ -0,0 +1,45 @@
+using Bencodex.Types;
+using Libplanet.Crypto;
+using Libplanet.SDK.Action.Attributes;
+
+namespace Libplanet.SDK.Action.Tests.SimpleTools.Actions
+{
+ public class HistoryAction : SimpleToolsActionBase
+ {
+ public override Address StorageAddress =>
+ new Address("0x1000000000000000000000000000000000000002");
+
+ [Callable]
+ public void LogInteger(Integer result)
+ {
+ Text stored = GetState(Signer) is IValue value
+ ? (Text)value
+ : new Text(string.Empty);
+ Text formatted = stored.Value.Length == 0
+ ? new Text($"{result.Value}")
+ : new Text(stored.Value + $", {result.Value}");
+ SetState(Signer, formatted);
+ }
+
+ [Callable]
+ public void LogText(Text result)
+ {
+ Text stored = GetState(Signer) is IValue value
+ ? (Text)value
+ : new Text(string.Empty);
+ Text formatted = stored.Value.Length == 0
+ ? new Text($"{result.Value}")
+ : new Text(stored.Value + $", {result.Value}");
+ SetState(Signer, formatted);
+ }
+
+ [Callable]
+ public Integer LogLength()
+ {
+ Text stored = GetState(Signer) is IValue value
+ ? (Text)value
+ : new Text(string.Empty);
+ return new Integer(stored.Value.Length);
+ }
+ }
+}
diff --git a/Libplanet.SDK.Action.Tests/SimpleTools/Actions/SimpleToolsActionBase.cs b/Libplanet.SDK.Action.Tests/SimpleTools/Actions/SimpleToolsActionBase.cs
new file mode 100644
index 00000000000..a2adbc0a840
--- /dev/null
+++ b/Libplanet.SDK.Action.Tests/SimpleTools/Actions/SimpleToolsActionBase.cs
@@ -0,0 +1,6 @@
+namespace Libplanet.SDK.Action.Tests.SimpleTools.Actions
+{
+ public abstract class SimpleToolsActionBase : ActionBase
+ {
+ }
+}
diff --git a/Libplanet.SDK.Action.Tests/SimpleTools/Actions/TextAction.cs b/Libplanet.SDK.Action.Tests/SimpleTools/Actions/TextAction.cs
new file mode 100644
index 00000000000..acdf4aaf06b
--- /dev/null
+++ b/Libplanet.SDK.Action.Tests/SimpleTools/Actions/TextAction.cs
@@ -0,0 +1,30 @@
+using Bencodex.Types;
+using Libplanet.Action;
+using Libplanet.Crypto;
+using Libplanet.SDK.Action.Attributes;
+
+namespace Libplanet.SDK.Action.Tests.SimpleTools.Actions
+{
+ [ActionType("Text")]
+ public class TextAction : SimpleToolsActionBase
+ {
+ public override Address StorageAddress =>
+ new Address("0x1000000000000000000000000000000000000003");
+
+ [Executable("Converts a string to uppercase.")]
+ public void ToUpper(Text text)
+ {
+ Text result = new Text(text.Value.ToUpper());
+ Call(nameof(HistoryAction.LogText), new object[] { result });
+ SetState(Signer, new Text(result));
+ }
+
+ [Executable]
+ public void ToLower(Text text)
+ {
+ Text result = new Text(text.Value.ToLower());
+ Call(nameof(HistoryAction.LogText), new object[] { result });
+ SetState(Signer, new Text(result));
+ }
+ }
+}
diff --git a/Libplanet.SDK.Action.Tests/SimpleTools/SimpleTools.cs b/Libplanet.SDK.Action.Tests/SimpleTools/SimpleTools.cs
new file mode 100644
index 00000000000..281562ad9c8
--- /dev/null
+++ b/Libplanet.SDK.Action.Tests/SimpleTools/SimpleTools.cs
@@ -0,0 +1,360 @@
+using Bencodex.Types;
+using Libplanet.Action;
+using Libplanet.Action.Loader;
+using Libplanet.Action.State;
+using Libplanet.Crypto;
+using Libplanet.SDK.Action.Tests.SimpleTools.Actions;
+using Libplanet.Store;
+using Libplanet.Store.Trie;
+using Libplanet.Types.Blocks;
+using Xunit;
+
+namespace Libplanet.SDK.Action.Tests.Calculator
+{
+ public class SimpleToolsTest
+ {
+ private TypedActionLoader _loader;
+ private IStateStore _stateStore;
+ private IWorld _world;
+
+ public SimpleToolsTest()
+ {
+ _loader = TypedActionLoader.Create(baseType: typeof(SimpleToolsActionBase));
+ _stateStore = new TrieStateStore(new MemoryKeyValueStore());
+
+ ITrie trie = _stateStore.GetStateRoot(null);
+ trie = trie.SetMetadata(new TrieMetadata(Block.CurrentProtocolVersion));
+ trie = _stateStore.Commit(trie);
+ _world = new World(new WorldBaseState(trie, _stateStore));
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public void Execute(bool commit)
+ {
+ Address signer = new PrivateKey().Address;
+ IWorld world = _world;
+ IValue plainValue;
+ IAction action;
+
+ plainValue = Dictionary.Empty
+ .Add("type_id", "Calc")
+ .Add("exec", "Add")
+ .Add("args", List.Empty.Add(5).Add(3));
+ action = Assert.IsType(_loader.LoadAction(0, plainValue));
+ world = action.Execute(new MockActionContext(signer, signer, world));
+ world = commit ? _stateStore.CommitWorld(world) : world;
+ Assert.Equal(
+ new Integer(8),
+ world.GetAccountState(((CalculatorAction)action).StorageAddress).GetState(signer));
+ Assert.Equal(
+ new Text("8"),
+ world
+ .GetAccountState(new Address("0x1000000000000000000000000000000000000002"))
+ .GetState(signer));
+
+ plainValue = Dictionary.Empty
+ .Add("type_id", "Calc")
+ .Add("exec", "Subtract")
+ .Add("args", List.Empty.Add(17).Add(13));
+ action = Assert.IsType(_loader.LoadAction(0, plainValue));
+ world = action.Execute(new MockActionContext(signer, signer, world));
+ world = commit ? _stateStore.CommitWorld(world) : world;
+ Assert.Equal(
+ new Integer(4),
+ world.GetAccountState(((CalculatorAction)action).StorageAddress).GetState(signer));
+ Assert.Equal(
+ new Text("8, 4"),
+ world
+ .GetAccountState(new Address("0x1000000000000000000000000000000000000002"))
+ .GetState(signer));
+
+ plainValue = Dictionary.Empty
+ .Add("type_id", "Calc")
+ .Add("exec", "Multiply")
+ .Add("args", List.Empty.Add(2).Add(-3));
+ action = Assert.IsType(_loader.LoadAction(0, plainValue));
+ world = action.Execute(new MockActionContext(signer, signer, world));
+ world = commit ? _stateStore.CommitWorld(world) : world;
+ Assert.Equal(
+ new Integer(-6),
+ world.GetAccountState(((CalculatorAction)action).StorageAddress).GetState(signer));
+ Assert.Equal(
+ new Text("8, 4, -6"),
+ world
+ .GetAccountState(new Address("0x1000000000000000000000000000000000000002"))
+ .GetState(signer));
+
+ plainValue = Dictionary.Empty
+ .Add("type_id", "Calc")
+ .Add("exec", "Square")
+ .Add("args", List.Empty.Add(7));
+ action = Assert.IsType(_loader.LoadAction(0, plainValue));
+ world = action.Execute(new MockActionContext(signer, signer, world));
+ world = commit ? _stateStore.CommitWorld(world) : world;
+ Assert.Equal(
+ new Integer(49),
+ world.GetAccountState(((CalculatorAction)action).StorageAddress).GetState(signer));
+ Assert.Equal(
+ new Text("8, 4, -6, 49"),
+ world
+ .GetAccountState(new Address("0x1000000000000000000000000000000000000002"))
+ .GetState(signer));
+
+ plainValue = Dictionary.Empty
+ .Add("type_id", "Text")
+ .Add("exec", "ToUpper")
+ .Add("args", List.Empty.Add(new Text("Hello")));
+ action = Assert.IsType(_loader.LoadAction(0, plainValue));
+ world = action.Execute(new MockActionContext(signer, signer, world));
+ world = commit ? _stateStore.CommitWorld(world) : world;
+ Assert.Equal(
+ new Text("HELLO"),
+ world.GetAccountState(((TextAction)action).StorageAddress).GetState(signer));
+ Assert.Equal(
+ new Text("8, 4, -6, 49, HELLO"),
+ world
+ .GetAccountState(new Address("0x1000000000000000000000000000000000000002"))
+ .GetState(signer));
+
+ plainValue = Dictionary.Empty
+ .Add("type_id", "Text")
+ .Add("exec", "ToLower")
+ .Add("args", List.Empty.Add(new Text("World")));
+ action = Assert.IsType(_loader.LoadAction(0, plainValue));
+ world = action.Execute(new MockActionContext(signer, signer, world));
+ world = commit ? _stateStore.CommitWorld(world) : world;
+ Assert.Equal(
+ new Text("world"),
+ world.GetAccountState(((TextAction)action).StorageAddress).GetState(signer));
+ Assert.Equal(
+ new Text("8, 4, -6, 49, HELLO, world"),
+ world
+ .GetAccountState(new Address("0x1000000000000000000000000000000000000002"))
+ .GetState(signer));
+
+ plainValue = Dictionary.Empty
+ .Add("type_id", "Calc")
+ .Add("exec", "LogLength")
+ .Add("args", List.Empty);
+ action = Assert.IsType(_loader.LoadAction(0, plainValue));
+ world = action.Execute(new MockActionContext(signer, signer, world));
+ world = commit ? _stateStore.CommitWorld(world) : world;
+ Assert.Equal(
+ new Integer(26),
+ world.GetAccountState(((CalculatorAction)action).StorageAddress).GetState(signer));
+ Assert.Equal(
+ new Text("8, 4, -6, 49, HELLO, world, 26"),
+ world
+ .GetAccountState(new Address("0x1000000000000000000000000000000000000002"))
+ .GetState(signer));
+ }
+
+ [Fact]
+ public void ValidatePlainValue()
+ {
+ IValue plainValue;
+ plainValue = Dictionary.Empty
+ .Add("type_id", "Calc")
+ .Add("exec", nameof(CalculatorAction.Add))
+ .Add("args", List.Empty.Add(5).Add(3));
+ ActionBase.ValidatePlainValue(plainValue);
+
+ // Wrong type_id.
+ plainValue = Dictionary.Empty
+ .Add("type_id", "Invalid")
+ .Add("exec", nameof(CalculatorAction.Add))
+ .Add("args", List.Empty.Add(5).Add(3));
+ Assert.Contains(
+ "\"type_id\" for plainValue does not match the expected",
+ Assert.Throws(() =>
+ ActionBase.ValidatePlainValue(plainValue)).Message);
+
+ // Unknown exec.
+ plainValue = Dictionary.Empty
+ .Add("type_id", "Calc")
+ .Add("exec", "Divide")
+ .Add("args", List.Empty.Add(5).Add(3));
+ Assert.Contains(
+ "Method named Divide cannot be found",
+ Assert.Throws(() =>
+ ActionBase.ValidatePlainValue(plainValue)).Message);
+
+ // Invalid argument type.
+ plainValue = Dictionary.Empty
+ .Add("type_id", "Calc")
+ .Add("exec", nameof(CalculatorAction.Add))
+ .Add("args", List.Empty.Add("Hello").Add("World"));
+ Assert.Contains(
+ $"The argument at ",
+ Assert.Throws(() =>
+ ActionBase.ValidatePlainValue(plainValue)).Message);
+
+ // Invalid arguments length.
+ plainValue = Dictionary.Empty
+ .Add("type_id", "Calc")
+ .Add("exec", nameof(CalculatorAction.Add))
+ .Add("args", List.Empty.Add(5));
+ Assert.Contains(
+ $"The length of \"args\" for plainValue should be",
+ Assert.Throws(() =>
+ ActionBase.ValidatePlainValue(plainValue)).Message);
+ }
+
+ [Fact]
+ public void GenerateMethodConstraintsSchema()
+ {
+ var expected = List.Empty.Add(
+ Dictionary.Empty
+ .Add(
+ "if",
+ Dictionary.Empty
+ .Add(
+ "properties",
+ Dictionary.Empty
+ .Add("type_id", Dictionary.Empty.Add("const", "Calc"))
+ .Add(
+ "exec",
+ Dictionary.Empty
+ .Add("const", "Add")
+ .Add("description", "Adds two numbers."))))
+ .Add(
+ "then",
+ Dictionary.Empty
+ .Add(
+ "properties",
+ Dictionary.Empty
+ .Add(
+ "args",
+ Dictionary.Empty
+ .Add("type", "list")
+ .Add(
+ "prefixItems",
+ List.Empty
+ .Add(
+ Dictionary.Empty
+ .Add("type", "integer")
+ .Add("title", "x")
+ .Add(
+ "description",
+ "The first operand."))
+ .Add(
+ Dictionary.Empty
+ .Add("type", "integer")
+ .Add("title", "y")
+ .Add(
+ "description",
+ "The second operand.")))
+ .Add("minItems", 2)
+ .Add("maxItems", 2)))));
+ var generated = ActionBase.GenerateMethodConstraintsSchema(
+ typeof(CalculatorAction), nameof(CalculatorAction.Add));
+ Assert.Equal(expected, generated);
+
+ expected = List.Empty.Add(
+ Dictionary.Empty
+ .Add(
+ "if",
+ Dictionary.Empty
+ .Add(
+ "properties",
+ Dictionary.Empty
+ .Add("type_id", Dictionary.Empty.Add("const", "Calc"))
+ .Add("exec", Dictionary.Empty.Add("const", "LogLength"))))
+ .Add(
+ "then",
+ Dictionary.Empty
+ .Add(
+ "properties",
+ Dictionary.Empty
+ .Add(
+ "args",
+ Dictionary.Empty
+ .Add("type", "list")
+ .Add("prefixItems", List.Empty)
+ .Add("minItems", 0)
+ .Add("maxItems", 0)))));
+ generated = ActionBase.GenerateMethodConstraintsSchema(
+ typeof(CalculatorAction), nameof(CalculatorAction.LogLength));
+ Assert.Equal(expected, generated);
+ }
+
+ [Fact]
+ public void GenerateClassConstraintsSchema()
+ {
+ var expected = List.Empty.Add(
+ Dictionary.Empty
+ .Add(
+ "if",
+ Dictionary.Empty
+ .Add(
+ "properties",
+ Dictionary.Empty
+ .Add("type_id", Dictionary.Empty.Add("const", "Calc"))))
+ .Add(
+ "then",
+ Dictionary.Empty
+ .Add(
+ "properties",
+ Dictionary.Empty
+ .Add(
+ "exec",
+ Dictionary.Empty
+ .Add(
+ "enum",
+ List.Empty
+ .Add("Add")
+ .Add("Subtract")
+ .Add("Multiply")
+ .Add("Square")
+ .Add("LogLength"))))));
+ List methodNames = new List()
+ {
+ "Add", "Subtract", "Multiply", "Square", "LogLength"
+ };
+ foreach (var constraints in methodNames.Select(
+ methodName => ActionBase.GenerateMethodConstraintsSchema(
+ typeof(CalculatorAction), methodName)))
+ {
+ foreach (var constraint in constraints)
+ {
+ expected = expected.Add(constraint);
+ }
+ };
+
+ var generated = ActionBase.GenerateClassConstraintsSchema(typeof(CalculatorAction));
+ Assert.Equal(expected, generated);
+ }
+
+ [Fact]
+ public void GenerateConstraintsSchema()
+ {
+ var expected = List.Empty.Add(
+ Dictionary.Empty
+ .Add(
+ "properties",
+ Dictionary.Empty
+ .Add(
+ "type_id",
+ Dictionary.Empty
+ .Add("enum", List.Empty.Add("Calc").Add("Text")))));
+ List actionTypes = new List()
+ {
+ typeof(CalculatorAction), typeof(TextAction)
+ };
+ foreach (var constraints in actionTypes.Select(
+ actionType => ActionBase.GenerateClassConstraintsSchema(
+ actionType)))
+ {
+ foreach (var constraint in constraints)
+ {
+ expected = expected.Add(constraint);
+ }
+ };
+
+ var generated = ActionBase.GenerateConstraintsSchema(typeof(SimpleToolsActionBase));
+ Assert.Equal(expected, generated);
+ }
+ }
+}
diff --git a/Libplanet.SDK.Action/Action/ActionBase.API.cs b/Libplanet.SDK.Action/Action/ActionBase.API.cs
new file mode 100644
index 00000000000..4caf41a56c3
--- /dev/null
+++ b/Libplanet.SDK.Action/Action/ActionBase.API.cs
@@ -0,0 +1,400 @@
+using System.Reflection;
+using Bencodex.Types;
+using Libplanet.Action;
+using Libplanet.Crypto;
+using Libplanet.SDK.Action.Attributes;
+
+namespace Libplanet.SDK.Action
+{
+ public partial class ActionBase
+ {
+ public static void ValidatePlainValue(IValue plainValue)
+ where T : ActionBase
+ {
+ ActionTypeAttribute actionType = typeof(T).GetCustomAttribute() ??
+ throw new ArgumentException(
+ $"Type is missing a {nameof(ActionTypeAttribute)}.");
+
+ // NOTE: Check type.
+ if (!(plainValue is Dictionary dict))
+ {
+ throw new ArgumentException(
+ $"Given {nameof(plainValue)} must be of " +
+ $"type {nameof(Dictionary)}: {plainValue.GetType()}",
+ nameof(plainValue));
+ }
+
+ // NOTE: Check type id.
+ if (!dict.ContainsKey("type_id"))
+ {
+ throw new ArgumentException(
+ $"Given dictionary {nameof(plainValue)} must contain \"type_id\" key",
+ nameof(plainValue));
+ }
+
+ if (!actionType.TypeIdentifier.Equals(dict["type_id"]))
+ {
+ throw new ArgumentException(
+ $"The value of \"type_id\" for {nameof(plainValue)} does not match " +
+ $"the expected value {actionType.TypeIdentifier} specified " +
+ $"by {nameof(ActionTypeAttribute)}: {dict["type_id"]}");
+ }
+
+ // NODE: Check exec.
+ if (!dict.ContainsKey("exec"))
+ {
+ throw new ArgumentException(
+ $"Given dictionary {nameof(plainValue)} must contain \"exec\" key",
+ nameof(plainValue));
+ }
+
+ string methodName = dict["exec"] is Text methodNameText
+ ? methodNameText.Value
+ : throw new ArgumentException(
+ $"The value of \"exec\" for {nameof(plainValue)} is expected to be " +
+ $"of type {nameof(Text)}: {dict["exec"].GetType()}");
+ MethodInfo methodInfo = typeof(T).GetMethod(methodName) ??
+ throw new ArgumentException(
+ $"Method named {methodName} cannot be found for {typeof(T)}.",
+ nameof(plainValue));
+ if (methodInfo.GetCustomAttribute() is null)
+ {
+ throw new ArgumentException(
+ $"Target method is missing a {nameof(ExecutableAttribute)}.",
+ nameof(methodName));
+ }
+
+ // NOTE: Check arguments.
+ if (!dict.ContainsKey("args"))
+ {
+ throw new ArgumentException(
+ $"Given dictionary {nameof(plainValue)} must contain \"args\" key",
+ nameof(plainValue));
+ }
+
+ List arguments = dict["args"] is List list
+ ? list
+ : throw new ArgumentException(
+ $"The value of \"args\" for {nameof(plainValue)} is expected to be " +
+ $"of type {nameof(Text)}: {dict["args"].GetType()}");
+
+ ParameterInfo[] paramInfos = methodInfo.GetParameters();
+ if (paramInfos.Length != arguments.Count)
+ {
+ throw new ArgumentException(
+ $"The length of \"args\" for {nameof(plainValue)} should be " +
+ $"{paramInfos.Length}: {arguments.Count}",
+ nameof(plainValue));
+ }
+
+ foreach (((ParameterInfo paramInfo, IValue argument), int index) in
+ paramInfos.Zip(arguments).Select((pair, i) => (pair, i)))
+ {
+ Type expectedType = paramInfo.ParameterType;
+ Type actualType = argument.GetType();
+ if (!paramInfo.ParameterType.Equals(argument.GetType()))
+ {
+ throw new ArgumentException(
+ $"The argument at {index} of \"args\" for {nameof(plainValue)} " +
+ $"should be {expectedType}: {actualType}",
+ nameof(plainValue));
+ }
+ }
+ }
+
+ public static List GenerateMethodConstraintsSchema(Type actionType, string methodName)
+ {
+ if (!typeof(ActionBase).IsAssignableFrom(actionType))
+ {
+ throw new ArgumentException(
+ $"Given {nameof(actionType)} is not assignable to " +
+ $"{nameof(ActionBase)}: {actionType}",
+ nameof(actionType));
+ }
+
+ ActionTypeAttribute actionTypeAttribute =
+ actionType.GetCustomAttribute() ??
+ throw new ArgumentException(
+ $"Type is missing a {nameof(ActionTypeAttribute)}.",
+ nameof(actionType));
+ Text typeId = actionTypeAttribute.TypeIdentifier is Text text
+ ? text
+ : throw new ArgumentException(
+ $"Type of {nameof(ActionTypeAttribute.TypeIdentifier)} is expected " +
+ $"to be {nameof(Text)}: {actionTypeAttribute.TypeIdentifier.GetType()}");
+ MethodInfo methodInfo = actionType.GetMethod(methodName) ??
+ throw new ArgumentException(
+ $"Method named {methodName} cannot be found for {actionType}.",
+ nameof(methodName));
+ ExecutableAttribute executableAttribute =
+ methodInfo.GetCustomAttribute() ??
+ throw new ArgumentException(
+ $"Method named {methodName} is missing a {nameof(ExecutableAttribute)}.",
+ nameof(methodName));
+ ParameterInfo[] paramInfos = methodInfo.GetParameters();
+
+ int minItems = paramInfos.Length, maxItems = paramInfos.Length;
+ var prefixItems = List.Empty;
+ foreach (var paramInfo in paramInfos)
+ {
+ Dictionary prefixItem = Dictionary.Empty;
+ if (paramInfo.ParameterType.Equals(typeof(Bencodex.Types.Boolean)))
+ {
+ prefixItem = prefixItem.Add("type", "boolean");
+ }
+ else if (paramInfo.ParameterType.Equals(typeof(Bencodex.Types.Integer)))
+ {
+ prefixItem = prefixItem.Add("type", "integer");
+ }
+ else if (paramInfo.ParameterType.Equals(typeof(Bencodex.Types.Text)))
+ {
+ prefixItem = prefixItem.Add("type", "text");
+ }
+ else if (paramInfo.ParameterType.Equals(typeof(Bencodex.Types.List)))
+ {
+ prefixItem = prefixItem.Add("type", "list");
+ }
+ else if (paramInfo.ParameterType.Equals(typeof(Bencodex.Types.Dictionary)))
+ {
+ prefixItem = prefixItem.Add("type", "dictionary");
+ }
+ else
+ {
+ throw new ArgumentException(
+ $"Method named {methodName} has a parameter named {paramInfo.Name}" +
+ $"that has an invalid type: {paramInfo.ParameterType}");
+ }
+
+ prefixItem = prefixItem.Add("title", paramInfo.Name!);
+ if (paramInfo.GetCustomAttribute() is { } parameterAttribute)
+ {
+ prefixItem = prefixItem.Add("description", parameterAttribute.Description);
+ }
+
+ prefixItems = prefixItems.Add(prefixItem);
+ }
+
+ Dictionary typeIdValue = Dictionary.Empty;
+ Dictionary execValue = Dictionary.Empty;
+ typeIdValue = typeIdValue.Add("const", typeId);
+ execValue = execValue.Add("const", methodInfo.Name);
+ execValue = executableAttribute.Description is { } executalbeDescription
+ ? execValue.Add("description", new Text(executalbeDescription))
+ : execValue;
+ Dictionary argsConstraints = Dictionary.Empty
+ .Add(
+ "if",
+ Dictionary.Empty
+ .Add(
+ "properties",
+ Dictionary.Empty
+ .Add("type_id", typeIdValue)
+ .Add("exec", execValue)))
+ .Add(
+ "then",
+ Dictionary.Empty
+ .Add(
+ "properties",
+ Dictionary.Empty
+ .Add(
+ "args",
+ Dictionary.Empty
+ .Add("type", "list")
+ .Add("prefixItems", prefixItems)
+ .Add("minItems", minItems)
+ .Add("maxItems", maxItems))));
+ return List.Empty.Add(argsConstraints);
+ }
+
+ public static List GenerateClassConstraintsSchema(Type actionType)
+ {
+ if (!typeof(ActionBase).IsAssignableFrom(actionType))
+ {
+ throw new ArgumentException(
+ $"Given {nameof(actionType)} is not assignable to " +
+ $"{nameof(ActionBase)}: {actionType}",
+ nameof(actionType));
+ }
+
+ ActionTypeAttribute actionTypeAttribute =
+ actionType.GetCustomAttribute() ??
+ throw new ArgumentException(
+ $"Type is missing a {nameof(ActionTypeAttribute)}: {actionType}",
+ nameof(actionType));
+ Text typeId = actionTypeAttribute.TypeIdentifier is Text text
+ ? text
+ : throw new ArgumentException(
+ $"Type of {nameof(ActionTypeAttribute.TypeIdentifier)} is expected " +
+ $"to be {nameof(Text)}: {actionTypeAttribute.TypeIdentifier.GetType()}");
+
+ MethodInfo[] methodInfos = actionType
+ .GetMethods()
+ .Where(methodInfo => methodInfo.IsPublic)
+ .Where(methodInfo => methodInfo.GetCustomAttribute() is { })
+ .ToArray();
+
+ Dictionary classConstraints = Dictionary.Empty
+ .Add(
+ "if",
+ Dictionary.Empty
+ .Add(
+ "properties",
+ Dictionary.Empty
+ .Add(
+ "type_id",
+ Dictionary.Empty.Add("const", typeId))))
+ .Add(
+ "then",
+ Dictionary.Empty
+ .Add(
+ "properties",
+ Dictionary.Empty
+ .Add(
+ "exec",
+ Dictionary.Empty.Add(
+ "enum",
+ new List(methodInfos.Select(methodInfo => methodInfo.Name)
+ .ToList())))));
+ List result = List.Empty;
+ result = result.Add(classConstraints);
+ foreach (var methodInfo in methodInfos)
+ {
+ var methodConstraints =
+ GenerateMethodConstraintsSchema(actionType, methodInfo.Name);
+ foreach (var methodConstraint in methodConstraints)
+ {
+ result = result.Add(methodConstraint);
+ }
+ }
+
+ return result;
+ }
+
+ public static List GenerateConstraintsSchema(Assembly assembly, Type baseType)
+ {
+ List targetTypes = assembly
+ .GetTypes()
+ .Where(type => baseType.IsAssignableFrom(type))
+ .Where(type => type.GetCustomAttribute() is { })
+ .ToList();
+#pragma warning disable MEN002
+ List result = List.Empty
+ .Add(
+ Dictionary.Empty
+ .Add(
+ "properties",
+ Dictionary.Empty
+ .Add(
+ "type_id",
+ Dictionary.Empty
+ .Add(
+ "enum",
+ new List(targetTypes.Select(targetType => targetType.GetCustomAttribute()!.TypeIdentifier))))));
+#pragma warning restore MEN002
+ foreach (var targetType in targetTypes)
+ {
+ var constraints = GenerateClassConstraintsSchema(targetType);
+ foreach (var constraint in constraints)
+ {
+ result = result.Add(constraint);
+ }
+ }
+
+ return result;
+ }
+
+ public static List GenerateConstraintsSchema(Assembly assembly) =>
+ GenerateConstraintsSchema(assembly, typeof(ActionBase));
+
+ public static List GenerateConstraintsSchema(Type baseType) =>
+ GenerateConstraintsSchema(baseType.Assembly, baseType);
+
+ public static Dictionary GenerateSchema(Assembly assembly, Type baseType)
+ {
+ Dictionary result = Dictionary.Empty
+ .Add("type", "dictionary")
+ .Add(
+ "properties",
+ Dictionary.Empty
+ .Add("type_id", Dictionary.Empty.Add("type", "text"))
+ .Add("exec", Dictionary.Empty.Add("type", "text"))
+ .Add("args", Dictionary.Empty.Add("type", "list")))
+ .Add("required", List.Empty.Add("type_id").Add("exec").Add("args"));
+ List constraints = GenerateConstraintsSchema(assembly, baseType);
+ return result.Add("allOf", constraints);
+ }
+
+ public static Dictionary GenerateSchema(Assembly assembly) =>
+ GenerateSchema(assembly, typeof(ActionBase));
+
+ public static Dictionary GenerateSchema(Type baseType) =>
+ GenerateSchema(baseType.Assembly, baseType);
+
+ protected IValue? GetState(Address address)
+ => World.GetAccount(StorageAddress).GetState(address);
+
+ protected void SetState(Address address, IValue value)
+ {
+ _world = World.SetAccount(
+ StorageAddress,
+ World.GetAccount(StorageAddress).SetState(address, value));
+ }
+
+ protected void Call(string methodName, object?[]? args = null)
+ where T : ActionBase
+ {
+ if (Activator.CreateInstance(typeof(T)) is not T calledAction)
+ {
+ throw new Exception("Action cannot be found.");
+ }
+
+ calledAction.LoadContext(ActionContext, World);
+
+ MethodInfo methodInfo = typeof(T).GetMethod(methodName) ??
+ throw new ArgumentException(
+ $"Method named {methodName} cannot be found.");
+ if (methodInfo.GetCustomAttribute() is null)
+ {
+ throw new ArgumentException(
+ $"Target method {methodName} is missing a {nameof(CallableAttribute)}");
+ }
+
+ methodInfo.Invoke(calledAction, args);
+
+ _world = calledAction._world;
+ _actionContext = calledAction._actionContext;
+ }
+
+ protected TR Call(string methodName, object?[]? args = null)
+ where TA : ActionBase
+ {
+ if (Activator.CreateInstance(typeof(TA)) is not TA calledAction)
+ {
+ throw new Exception("Action cannot be found.");
+ }
+
+ calledAction.LoadContext(ActionContext, World);
+
+ MethodInfo methodInfo = typeof(TA).GetMethod(methodName) ??
+ throw new ArgumentException(
+ $"Method named {methodName} cannot be found.");
+ if (methodInfo.GetCustomAttribute() is null)
+ {
+ throw new ArgumentException(
+ $"Target method {methodName} is missing a {nameof(CallableAttribute)}");
+ }
+
+ var result = methodInfo.Invoke(calledAction, args);
+ if (result is not TR typedResult)
+ {
+ throw new Exception(
+ $"Return type is expected to be {typeof(TR)}: {result?.GetType()}");
+ }
+
+ _world = calledAction._world;
+ _actionContext = calledAction._actionContext;
+
+ return typedResult;
+ }
+ }
+}
diff --git a/Libplanet.SDK.Action/Action/ActionBase.Context.cs b/Libplanet.SDK.Action/Action/ActionBase.Context.cs
new file mode 100644
index 00000000000..24f6a6d9d26
--- /dev/null
+++ b/Libplanet.SDK.Action/Action/ActionBase.Context.cs
@@ -0,0 +1,11 @@
+using Libplanet.Crypto;
+
+namespace Libplanet.SDK.Action
+{
+ public partial class ActionBase
+ {
+ protected Address Signer => ActionContext.Signer;
+
+ protected Address Miner => ActionContext.Miner;
+ }
+}
diff --git a/Libplanet.SDK.Action/Action/ActionBase.Fields.cs b/Libplanet.SDK.Action/Action/ActionBase.Fields.cs
new file mode 100644
index 00000000000..c3cbcf8a234
--- /dev/null
+++ b/Libplanet.SDK.Action/Action/ActionBase.Fields.cs
@@ -0,0 +1,20 @@
+using System.Security;
+using Bencodex.Types;
+using Libplanet.Action;
+using Libplanet.Action.State;
+
+namespace Libplanet.SDK.Action
+{
+ public partial class ActionBase
+ {
+ private string? _name = null;
+ private IValue? _args = null;
+ private string? _exec = null;
+
+ [SecurityCritical]
+ private IActionContext? _actionContext = null;
+
+ [SecurityCritical]
+ private IWorld? _world = null;
+ }
+}
diff --git a/Libplanet.SDK.Action/Action/ActionBase.Methods.cs b/Libplanet.SDK.Action/Action/ActionBase.Methods.cs
new file mode 100644
index 00000000000..5a58e3c4ae2
--- /dev/null
+++ b/Libplanet.SDK.Action/Action/ActionBase.Methods.cs
@@ -0,0 +1,14 @@
+using Libplanet.Action;
+using Libplanet.Action.State;
+
+namespace Libplanet.SDK.Action
+{
+ public partial class ActionBase
+ {
+ private void LoadContext(IActionContext context, IWorld world)
+ {
+ _actionContext = context;
+ _world = world;
+ }
+ }
+}
diff --git a/Libplanet.SDK.Action/Action/ActionBase.Params.cs b/Libplanet.SDK.Action/Action/ActionBase.Params.cs
new file mode 100644
index 00000000000..ee57e57aea8
--- /dev/null
+++ b/Libplanet.SDK.Action/Action/ActionBase.Params.cs
@@ -0,0 +1,27 @@
+using System.Reflection;
+using Libplanet.Action;
+using Libplanet.Action.State;
+using Libplanet.Crypto;
+using Libplanet.SDK.Action.Attributes;
+
+namespace Libplanet.SDK.Action
+{
+ public partial class ActionBase
+ {
+ public abstract Address StorageAddress { get; }
+
+ private MethodInfo[] ExecutableMethods => GetType()
+ .GetMethods()
+ .Where(methodInfo => methodInfo.IsPublic)
+ .Where(methodInfo => methodInfo.GetCustomAttribute() is { })
+ .ToArray();
+
+ private IActionContext ActionContext => _actionContext ??
+ throw new InvalidOperationException("ActionContext is not set.");
+
+ private IWorld World => _world ??
+ throw new InvalidOperationException("State is not set.");
+
+ private bool Loaded => _args is { } && _exec is { } && _name is { };
+ }
+}
diff --git a/Libplanet.SDK.Action/Action/ActionBase.cs b/Libplanet.SDK.Action/Action/ActionBase.cs
new file mode 100644
index 00000000000..beee0d36e26
--- /dev/null
+++ b/Libplanet.SDK.Action/Action/ActionBase.cs
@@ -0,0 +1,66 @@
+using System.Reflection;
+using System.Security;
+using Bencodex.Types;
+using Libplanet.Action;
+using Libplanet.Action.State;
+
+[assembly: SecurityTransparent]
+
+namespace Libplanet.SDK.Action
+{
+ public abstract partial class ActionBase : IAction
+ {
+ public IValue PlainValue => Dictionary.Empty
+ .Add("type_id", _name ?? throw new NullReferenceException())
+ .Add("exec", _exec ?? throw new NullReferenceException())
+ .Add("args", _args ?? throw new NullReferenceException());
+
+ public void LoadPlainValue(IValue plainValue)
+ {
+ Dictionary dict = (Dictionary)plainValue;
+ _name = (Text)dict["type_id"];
+ _exec = (Text)dict["exec"];
+ _args = dict["args"];
+ }
+
+ public IWorld Execute(IActionContext context)
+ {
+ if (!Loaded)
+ {
+ throw new InvalidOperationException("Action is not loaded.");
+ }
+
+ _actionContext = context;
+ _world = context.PreviousState;
+
+ MethodInfo method = ExecutableMethods.FirstOrDefault(m => m.Name == _exec) ??
+ throw new InvalidOperationException($"Method {_exec} is not found.");
+ ParameterInfo[] paramInfos = method.GetParameters();
+ object[] args = GetArgs(paramInfos, _args);
+ method.Invoke(this, args);
+
+ return World;
+ }
+
+ private static object[] GetArgs(ParameterInfo[] paramInfos, IValue? args)
+ {
+ if (args is List list)
+ {
+ if (paramInfos.Length != list.Count)
+ {
+ throw new ArgumentException(
+ $"Given {nameof(args)} must be of " +
+ $"length {paramInfos.Length}: {list.Count}");
+ }
+
+ return list.ToArray();
+ }
+ else
+ {
+ throw new ArgumentException(
+ $"Given {nameof(args)} must be of type {nameof(List)}: {args.GetType()}",
+ nameof(args));
+ }
+ }
+ }
+}
diff --git a/Libplanet.SDK.Action/Action/ActionSchema.json b/Libplanet.SDK.Action/Action/ActionSchema.json
new file mode 100644
index 00000000000..7a23c6b2570
--- /dev/null
+++ b/Libplanet.SDK.Action/Action/ActionSchema.json
@@ -0,0 +1,8 @@
+{
+ "type_id": "Bar",
+ "contract_address": "",
+ "values": {
+ "call": "HelloWorld",
+ "args": ["Alice", "1000"]
+ }
+}
diff --git a/Libplanet.SDK.Action/Attributes/CallableAttribute.cs b/Libplanet.SDK.Action/Attributes/CallableAttribute.cs
new file mode 100644
index 00000000000..8d0a925defe
--- /dev/null
+++ b/Libplanet.SDK.Action/Attributes/CallableAttribute.cs
@@ -0,0 +1,7 @@
+namespace Libplanet.SDK.Action.Attributes
+{
+ [AttributeUsage(AttributeTargets.Method, AllowMultiple = false)]
+ public class CallableAttribute : Attribute
+ {
+ }
+}
diff --git a/Libplanet.SDK.Action/Attributes/ExecutableAttribute.cs b/Libplanet.SDK.Action/Attributes/ExecutableAttribute.cs
new file mode 100644
index 00000000000..f0dda2ff052
--- /dev/null
+++ b/Libplanet.SDK.Action/Attributes/ExecutableAttribute.cs
@@ -0,0 +1,13 @@
+namespace Libplanet.SDK.Action.Attributes
+{
+ [AttributeUsage(AttributeTargets.Method, AllowMultiple = false)]
+ public class ExecutableAttribute : Attribute
+ {
+ public ExecutableAttribute(string? description = null)
+ {
+ Description = description;
+ }
+
+ public string? Description { get; set; }
+ }
+}
diff --git a/Libplanet.SDK.Action/Attributes/ParameterAttribute.cs b/Libplanet.SDK.Action/Attributes/ParameterAttribute.cs
new file mode 100644
index 00000000000..fa77d37564c
--- /dev/null
+++ b/Libplanet.SDK.Action/Attributes/ParameterAttribute.cs
@@ -0,0 +1,13 @@
+namespace Libplanet.SDK.Action.Attributes
+{
+ [AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false)]
+ public class ParameterAttribute : Attribute
+ {
+ public ParameterAttribute(string description)
+ {
+ Description = description;
+ }
+
+ public string Description { get; set; }
+ }
+}
diff --git a/Libplanet.SDK.Action/Libplanet.SDK.Action.csproj b/Libplanet.SDK.Action/Libplanet.SDK.Action.csproj
new file mode 100644
index 00000000000..58fc7c5edc7
--- /dev/null
+++ b/Libplanet.SDK.Action/Libplanet.SDK.Action.csproj
@@ -0,0 +1,17 @@
+
+
+
+ net6.0
+ enable
+ enable
+ Libplanet.SDK
+
+
+
+
+ false
+ runtime
+
+
+
+
diff --git a/Libplanet.sln b/Libplanet.sln
index 889e77da657..279bc17bd3d 100644
--- a/Libplanet.sln
+++ b/Libplanet.sln
@@ -69,6 +69,18 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{B9C00FAF-3
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tools", "tools", "{88E7FAF4-CEEC-48B6-9114-71CFE3FC0F50}"
EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "sdk", "sdk", "{AA37E05B-D531-4546-A259-CDB5AC188E8A}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Libplanet.SDK.Action", "Libplanet.SDK.Action\Libplanet.SDK.Action.csproj", "{0697DCE3-6225-421B-8593-9199C3C15A35}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{33FAC033-5754-46C4-ADD5-C35EF8C786E4}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Libplanet.SDK.Action.Tests", "Libplanet.SDK.Action.Tests\Libplanet.SDK.Action.Tests.csproj", "{60147F82-4879-4C65-AC05-9CA80333F7E3}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{231873FC-1BBB-4E9A-BF14-9E0E885DB554}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "examples", "examples", "{96149248-DA70-495F-BD37-2EF0DAB77322}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -613,6 +625,42 @@ Global
{46C1A70D-D1DE-4173-A8C0-00F680F026E3}.ReleaseMono|x64.Build.0 = Debug|Any CPU
{46C1A70D-D1DE-4173-A8C0-00F680F026E3}.ReleaseMono|x86.ActiveCfg = Debug|Any CPU
{46C1A70D-D1DE-4173-A8C0-00F680F026E3}.ReleaseMono|x86.Build.0 = Debug|Any CPU
+ {0697DCE3-6225-421B-8593-9199C3C15A35}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {0697DCE3-6225-421B-8593-9199C3C15A35}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {0697DCE3-6225-421B-8593-9199C3C15A35}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {0697DCE3-6225-421B-8593-9199C3C15A35}.Debug|x64.Build.0 = Debug|Any CPU
+ {0697DCE3-6225-421B-8593-9199C3C15A35}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {0697DCE3-6225-421B-8593-9199C3C15A35}.Debug|x86.Build.0 = Debug|Any CPU
+ {0697DCE3-6225-421B-8593-9199C3C15A35}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {0697DCE3-6225-421B-8593-9199C3C15A35}.Release|Any CPU.Build.0 = Release|Any CPU
+ {0697DCE3-6225-421B-8593-9199C3C15A35}.Release|x64.ActiveCfg = Release|Any CPU
+ {0697DCE3-6225-421B-8593-9199C3C15A35}.Release|x64.Build.0 = Release|Any CPU
+ {0697DCE3-6225-421B-8593-9199C3C15A35}.Release|x86.ActiveCfg = Release|Any CPU
+ {0697DCE3-6225-421B-8593-9199C3C15A35}.Release|x86.Build.0 = Release|Any CPU
+ {0697DCE3-6225-421B-8593-9199C3C15A35}.ReleaseMono|Any CPU.ActiveCfg = Debug|Any CPU
+ {0697DCE3-6225-421B-8593-9199C3C15A35}.ReleaseMono|Any CPU.Build.0 = Debug|Any CPU
+ {0697DCE3-6225-421B-8593-9199C3C15A35}.ReleaseMono|x64.ActiveCfg = Debug|Any CPU
+ {0697DCE3-6225-421B-8593-9199C3C15A35}.ReleaseMono|x64.Build.0 = Debug|Any CPU
+ {0697DCE3-6225-421B-8593-9199C3C15A35}.ReleaseMono|x86.ActiveCfg = Debug|Any CPU
+ {0697DCE3-6225-421B-8593-9199C3C15A35}.ReleaseMono|x86.Build.0 = Debug|Any CPU
+ {60147F82-4879-4C65-AC05-9CA80333F7E3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {60147F82-4879-4C65-AC05-9CA80333F7E3}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {60147F82-4879-4C65-AC05-9CA80333F7E3}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {60147F82-4879-4C65-AC05-9CA80333F7E3}.Debug|x64.Build.0 = Debug|Any CPU
+ {60147F82-4879-4C65-AC05-9CA80333F7E3}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {60147F82-4879-4C65-AC05-9CA80333F7E3}.Debug|x86.Build.0 = Debug|Any CPU
+ {60147F82-4879-4C65-AC05-9CA80333F7E3}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {60147F82-4879-4C65-AC05-9CA80333F7E3}.Release|Any CPU.Build.0 = Release|Any CPU
+ {60147F82-4879-4C65-AC05-9CA80333F7E3}.Release|x64.ActiveCfg = Release|Any CPU
+ {60147F82-4879-4C65-AC05-9CA80333F7E3}.Release|x64.Build.0 = Release|Any CPU
+ {60147F82-4879-4C65-AC05-9CA80333F7E3}.Release|x86.ActiveCfg = Release|Any CPU
+ {60147F82-4879-4C65-AC05-9CA80333F7E3}.Release|x86.Build.0 = Release|Any CPU
+ {60147F82-4879-4C65-AC05-9CA80333F7E3}.ReleaseMono|Any CPU.ActiveCfg = Debug|Any CPU
+ {60147F82-4879-4C65-AC05-9CA80333F7E3}.ReleaseMono|Any CPU.Build.0 = Debug|Any CPU
+ {60147F82-4879-4C65-AC05-9CA80333F7E3}.ReleaseMono|x64.ActiveCfg = Debug|Any CPU
+ {60147F82-4879-4C65-AC05-9CA80333F7E3}.ReleaseMono|x64.Build.0 = Debug|Any CPU
+ {60147F82-4879-4C65-AC05-9CA80333F7E3}.ReleaseMono|x86.ActiveCfg = Debug|Any CPU
+ {60147F82-4879-4C65-AC05-9CA80333F7E3}.ReleaseMono|x86.Build.0 = Debug|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -648,6 +696,11 @@ Global
{CF31204A-12CF-43C0-9054-B9AF98EC83BD} = {AC908E33-B856-4E23-9F81-B7F7C97A07F9}
{97F29346-636E-4BCA-B33D-6D0DB26A5AA6} = {B9C00FAF-36CF-463A-83FA-43E6B974AE2E}
{46C1A70D-D1DE-4173-A8C0-00F680F026E3} = {B9C00FAF-36CF-463A-83FA-43E6B974AE2E}
+ {33FAC033-5754-46C4-ADD5-C35EF8C786E4} = {AA37E05B-D531-4546-A259-CDB5AC188E8A}
+ {60147F82-4879-4C65-AC05-9CA80333F7E3} = {33FAC033-5754-46C4-ADD5-C35EF8C786E4}
+ {231873FC-1BBB-4E9A-BF14-9E0E885DB554} = {AA37E05B-D531-4546-A259-CDB5AC188E8A}
+ {0697DCE3-6225-421B-8593-9199C3C15A35} = {231873FC-1BBB-4E9A-BF14-9E0E885DB554}
+ {96149248-DA70-495F-BD37-2EF0DAB77322} = {AA37E05B-D531-4546-A259-CDB5AC188E8A}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {DB552D2A-94E1-4A1C-9F3E-E0097C6158CD}
diff --git a/src/Libplanet.Action/AssemblyInfo.cs b/src/Libplanet.Action/AssemblyInfo.cs
index 3b64ad1989d..10e9dd66f57 100644
--- a/src/Libplanet.Action/AssemblyInfo.cs
+++ b/src/Libplanet.Action/AssemblyInfo.cs
@@ -4,3 +4,4 @@
[assembly: InternalsVisibleTo("Libplanet.Action.Tests")]
[assembly: InternalsVisibleTo("Libplanet.Explorer.Tests")]
[assembly: InternalsVisibleTo("Libplanet.Mocks")]
+[assembly: InternalsVisibleTo("Libplanet.SDK.Action.Tests")]
diff --git a/src/Libplanet.Action/Loader/IActionLoader.cs b/src/Libplanet.Action/Loader/IActionLoader.cs
index f280017439a..74d00ebc487 100644
--- a/src/Libplanet.Action/Loader/IActionLoader.cs
+++ b/src/Libplanet.Action/Loader/IActionLoader.cs
@@ -1,4 +1,6 @@
+using System.Security.Cryptography;
using Bencodex.Types;
+using Libplanet.Common;
namespace Libplanet.Action.Loader
{
diff --git a/test/Libplanet.Net.Tests/Consensus/ContextTest.cs b/test/Libplanet.Net.Tests/Consensus/ContextTest.cs
index f21cc5615fd..9e92286f8f6 100644
--- a/test/Libplanet.Net.Tests/Consensus/ContextTest.cs
+++ b/test/Libplanet.Net.Tests/Consensus/ContextTest.cs
@@ -475,6 +475,7 @@ public async Task CanPreCommitOnEndCommit()
/// receiving message from peer C or D.
///
///
+ /// A representing the asynchronous unit test.
[Fact(Timeout = Timeout)]
public async Task CanReplaceProposal()
{