Asset Life Cycle Management
Introduction
In the real world, assets typically have specific life cycles and statuses that they must follow. In order to mirror this, the life cycle logic can be directly embedded into an Asset’s smart contract to match the required business logic.
Smart contracts typically use Finite State Machines (FSMs) to track, manage and complete transitions between one state to another (one status to another). Combining multiple transitions between statuses tracks the asset's life cycle. This state management is typically combined with data updates that happen at the same time as the status update. The Asset Framework provides a FSM library for developers to implement their own asset life cycles.
Generally a state machine works first by defining all the possible states (or statuses) that an asset can have (in processing, being shipped, completed, etc.). Then define a number of events that can occur in the asset’s life cycle: (created, processed, delivered, canceled, etc.). Finally to create the full state machine, we connect the events with the states by defining all valid state transitions. A state transition is a set containing a starting state, an event, and an ending state. An FSM is a collection of multiple state transitions, where each one in it is a valid transition for this particular FSM. If a state transition is not in the FSM, then it is not valid - the Asset’s status can never move between states with the given event. Example transition: (shipping, delivered, completed) allows an asset with a state of “shipping” to be “delivered” and as a result it will now have the state of “completed”.
This flow can be visualized using the diagram below:
Here is a sample of how an Asset FSM might be defined using the previous example states. It uses the FSM
contract included in the Asset Framework as a base that includes all of the necessary logic for making transitions between states with events.
// define the Asset states
contract AssetState {
enum AssetStateEnum {
NULL,
PROCESSING,
SHIPPING,
COMPLETED,
FAILED,
MAX
}
}
// define the events that move an asset between states
contract AssetEvent {
enum {
NULL,
PROCESSED,
SHIPPED,
FAILED,
MAX
}
}
contract AssetFSM is FSM, AssetState, AssetEvent {
constructor() public {
// add all the valid state transitions between states,
// and what events cause those transitions
addTransition(
AssetStateEnum.PROCESSING,
AssetEventEnum.PROCESSED,
AssetStateEnum.SHIPPING
);
addTransition(
AssetStateEnum.SHIPPING,
AssetEventEnum.SHIPPED,
AssetStateEnum.COMPLETE
);
addTransition(
AssetStateEnum.SHIPPING,
AssetEventEnum.FAIL,
AssetStateEnum.FAILED
);
}
function handleEvent(AssetStateEnum _state, AssetEventEnum _event) public returns (AssetStateEnum) {
uint temp = super.handleEvent(uint(_state), uint(_event));
return AssetStateEnum(temp);
}
function addTransition(
AssetStateEnum _state,
AssetEventEnum _event,
AssetStateEnum _newState
) internal {
super.addTransition(
uint(_state),
uint(_event),
uint(_newState)
);
}
}
Finally we use the “handleEvent” function in each asset update function that corresponds to that state transition. The handleEvent function will return the NULL state when an invalid state transition occurs, so the Asset contract just needs to check for that NULL state and return from the function when that occurs.
contract Asset is AssetFSM, AssetState, AssetEvent {
AssetFSM fsm;
AssetState state;
...
constructor() {
fsm = new AssetFSM();
// initialize the asset in the "processing" state
// the fsm is not needed at this point since no transition has occurred
state = AssetStateEnum.PROCESSING;
}
function moveToShipping(...) {
// hardcode the event corresponding with this function call as "processed"
// determine the state from the fsm, given the current state
AssetState tempState = fsm.handleEvent(state, AssetEventEnum.PROCESSED);
// check the result of the FSM
require(tempState != AssetEventEnum.NULL);
// Advance the state of the asset according to the FSM
state = tempState;
...
}
}
Advanced
If you are curious and want to know how the base FSM that is referenced above works, read on.
The base FSM operates using three simple functions: addTransition
, handleEvent
, and calculateKey
.
State/Event Enums
Enums are a way to translate human-identifiable concepts like state and event names with more machine-readable values, which are int
s. The base FSM operates purely on int
s, and expects users of the contract to have a level of abstraction on top of it that translates the application specific enum
s to int
s. This allows the FSM to abstract itself away from specific business logic, such as state and event names, and make it composable with any other application.
This is possible since enum
s can be directly casted as int
s in Solidity. (Enums are basically wrappers around int
s for convenience of naming.)
In the next sections, it describes states and events as int
s for ease of explanation, however know that these can be substituted for Solidity enum values defined in your app for states and and events.
For example:
contract AssetState {
enum AssetStateEnum {
NULL, // == 0
PROCESSING, // == 1
SHIPPING, // == 2
COMPLETED, // == 3
FAILED, // == 4
MAX // == 5
}
}
// define the events that move an asset between states
contract AssetEvent {
enum {
NULL, // == 0
PROCESSED, // == 1
SHIPPED, // == 2
FAILED, // == 3
MAX // == 4
}
}
And then within the application's specific FSM, it must cast these readable Asset states and events into their corresponding int
values using int(enumVal)
.
Calculating Keys
The FSM operates using the basic principle of a assigning a unique key to a specific set of one starting state and one event. Therefore every grouping of state
and event
generates a unique key
. This is done since there can never be more than one ending state from a given starting state and event.
This calculation is done using:
key = (state * 1000) + event
Therefore for every state, there can be 1000 events that happen with this starting state, and any number of starting states.
As an example, the key
for a starting state of 2
and an event 6
is 2 * 1000 + 6 = 2006
.
To show the limitation of 1000 events per starting state, lets take a look at what would happen if there were more than that:
Lets say there are two states, 1
and 2
, however we define 1001 events from state 1
, and any number from state 2
. The 1001st key for the 1
state would be:
key = 1 * 1000 + 1001 = 2001
key = 2 * 1000 + 1 = 2001
Therefore both state and events have the same key, resulting in a collision.
For most common use cases, state machines will never come close to approaching this limitiation, and for all intents and purposes, any real-world application can use this FSM.
In the next section, we will see how these keys are used.
Adding Transitions
addTransition
is used to setup and construct the FSM into a working manner. It uses the unique key
of a starting state and event to store a resulting end state, indexed by the key
. Therefore, we can access the valid end-state given a starting state and event.
As an example, if we wanted to add 2 valid transitions: (1
, 2
, 3
) and (1
, 6
, 9
), we would store the values 3
and 9
to the FSM mapping
at indices 1002
and 1006
respectively. This is used later to check if a given event is valid or not.
Example contents of the FSM mapping
{
1002: 3,
1006: 9,
}
Handling Events
Finally, when we want to actually use the FSM, we essentially just check for the existence of a key
in the FSM mapping
. When an application wants to make a state transition from an Asset's current state and a given event, the transition is only valid if the currentState
and event
's unique key exists in the mapping
. If it does, then the new state should be the value stored in the mapping at index key
.
When the key
does not exist, it will default to returning the 0
value as the resulting state, which should always be reserved in your application as the NULL state, indicating an invalid transition.
// valid transition
key1 = 1 * 1000 + 6 = 1006
stateMachine[key1] == 3;
//invalid transition
key2 = 1 * 1000 + 2 = 1002
stateMachine[key2] == 0
FSM Contract
contract FSM {
struct Transition {
uint state;
uint evt;
uint newState;
}
// expose the transitions to the outside world
Transition[] public transitions;
mapping (uint => uint) stateMachine;
function FSM(){
}
function handleEvent(uint _state, uint _event) returns (uint) {
return stateMachine[calculateKey(_state,_event)];
}
function addTransition(uint _state, uint _event, uint _newState) {
stateMachine[calculateKey(_state, _event)] = _newState;
transitions.push(Transition(_state, _event, _newState));
}
function calculateKey(uint _state, uint _event) returns (uint){
return (_state * 1000) + _event;
}
}