Introduction#
In smart contract applications that involve interactions between multiple systems or contracts, especially in business scenarios where data accuracy is crucial, we need to ensure the atomicity of data throughout the entire business process. Therefore, we need to implement a mechanism similar to two-phase commit at the contract level, which decomposes the state change process in the contract into two stages: pre-commit and formal commit.
This article implements a minimal two-phase commit model using state locks. The complete contract code can be found in TwoPhaseCommit.sol. The following sections will explain the core logic of this contract and strive to adhere to style guides and best practices.
Note: This contract is primarily designed for business purposes in consortium chains and does not optimize for specific factors such as gas fees. It is intended for learning and reference purposes only.
Contract Logic#
Contract Structure#
The two-phase commit scenario includes the following methods:
- set: Two-phase - Pre-commit
- commit: Two-phase - Formal commit
- rollback: Two-phase - Rollback
Due to certain limitations in the Solidity language regarding string length checks/comparisons, to improve the readability of the contract code, this contract provides some auxiliary methods, mainly including the following methods:
- isValidKey: Check if the key is valid
- isValidValue: Check if the value is valid
- isEqualString: Compare two strings for equality
Core Logic of Two-Phase Commit#
In the two-phase commit scenario, this contract provides a set of simple set
, commit
, and rollback
methods to store the key-value pairs passed in the contract call on the chain. We use the mechanism of state locks to achieve atomicity of cross-chain transactions. We define the following data structure:
enum State {
UNLOCKED,
LOCKED
}
struct Payload {
State state;
string value;
string lockValue;
}
Here, State
is an enumeration type that records the lock status of the key value on the chain, and the Payload
structure stores the lock status, current value, and the value being locked. It is bound to the key through the following mapping
structure:
mapping (string => Payload) keyToPayload;
Therefore, we can track the state of each key in the contract call based on keyToPayload
and perform some exception handling in the set
, commit
, and rollback
methods.
set()#
In the set()
method, we check the state of the key. If it is State.LOCKED
, no storage will be performed and an exception will be thrown:
if (keyToPayload[_key].state == State.LOCKED) {
revert TwoPhaseCommit__DataIsLocked();
}
If it is State.UNLOCKED
, we store the value passed in the contract call in lockValue
, set its state to LOCKED
, and wait for the subsequent commit
or rollback
to unlock it.
keyToPayload[_key].state = State.LOCKED;
keyToPayload[_key].lockValue = _value;
commit()#
In the commit()
method, we check the state of the key. If it is State.UNLOCKED
, no operation will be performed on the key, and an exception will be thrown:
if (keyToPayload[_key].state == State.UNLOCKED) {
revert TwoPhaseCommit__DataIsNotLocked();
}
If it is State.LOCKED
, we check if the value passed in the contract call is equal to lockValue
. If they are not equal, an exception will be thrown:
if (!isEqualString(keyToPayload[_key].lockValue, _value)) {
revert TwoPhaseCommit__DataIsInconsistent();
}
If the values are equal, we store the value corresponding to the key on the chain, set the key's state to UNLOCKED
, update the current value value
, and set lockValue
to empty:
store[_key] = _value;
keyToPayload[_key].state = State.UNLOCKED;
keyToPayload[_key].value = _value;
keyToPayload[_key].lockValue = "";
rollback()#
In the rollback()
method, we check the state of the key. If it is State.UNLOCKED
, no operation will be performed on the key, and an exception will be thrown:
if (keyToPayload[_key].state == State.UNLOCKED) {
revert TwoPhaseCommit__DataIsNotLocked();
}
If it is State.LOCKED
, we check if the value passed in the contract call is equal to lockValue
. If they are not equal, an exception will be thrown:
if (!isEqualString(keyToPayload[_key].lockValue, _value)) {
revert TwoPhaseCommit__DataIsInconsistent();
}
If the values are equal, we store the value corresponding to the key on the chain, set the key's state to UNLOCKED
, and set lockValue
to empty:
keyToPayload[_key].state = State.UNLOCKED;
keyToPayload[_key].lockValue = "";
Error Handling Logic#
In exceptional execution scenarios, we throw errors and perform rollbacks. To improve the readability of error messages and facilitate error capture and handling by higher-level application personnel, we use error type definitions. Since I have already included most of the information in the error naming, I did not define additional parameters for error types, and they can be customized as needed.
error TwoPhaseCommit__DataKeyIsNull();
error TwoPhaseCommit__DataValueIsNull();
error TwoPhaseCommit__DataIsNotExist();
error TwoPhaseCommit__DataIsLocked();
error TwoPhaseCommit__DataIsNotLocked();
error TwoPhaseCommit__DataIsInconsistent();
In the specific contract logic, we throw exceptions using the revert
method, such as:
if (!isValidKey(bytes(_key))) {
revert TwoPhaseCommit__DataKeyIsNull();
}
if (!isValidValue(bytes(_value))) {
revert TwoPhaseCommit__DataValueIsNull();
}
if (keyToPayload[_key].state == State.UNLOCKED) {
revert TwoPhaseCommit__DataIsNotLocked();
}
if (!isEqualString(keyToPayload[_key].lockValue, _value)) {
revert TwoPhaseCommit__DataIsInconsistent();
}
General Parameter Validation#
We perform some validity checks on the incoming parameters. To provide extensibility, we independently validate the key and value using the isValidKey()
and isValidValue()
methods:
/**
* @notice Validate the format of the data key
* @param _key Data - Key
*/
function isValidKey(bytes memory _key) private pure returns (bool)
{
bytes memory key = _key;
if (key.length == 0) {
return false;
}
return true;
}
/**
* @notice Validate the format of the data value
* @param _value Data - Value
*/
function isValidValue(bytes memory _value) private pure returns (bool)
{
bytes memory value = _value;
if (value.length == 0) {
return false;
}
return true;
}
This contract only performs non-empty checks, and you can customize the business logic as needed by calling them where validation is required, such as:
if (!isValidKey(bytes(_key))) {
revert TwoPhaseCommit__DataKeyIsNull();
}
if (!isValidValue(bytes(_value))) {
revert TwoPhaseCommit__DataValueIsNull();
}
if (!isValidValue(bytes(store[_key]))) {
revert TwoPhaseCommit__DataIsNotExist();
}
Event Mechanism#
In addition, we define events corresponding to the core methods and set them as indexed for easy listening and handling by higher-level applications.
event setEvent(string indexed key, string indexed value);
event getEvent(string indexed key, string indexed value);
event commitEvent(string indexed key, string indexed value);
event rollbackEvent(string indexed key, string indexed value);
In the contract methods, we throw events using the emit()
method, such as:
emit setEvent(_key, _value);
emit getEvent(_key, _value);
emit commitEvent(_key, _value);
emit rollbackEvent(_key, _value);
Conclusion#
The above is a best practice for my two-phase commit contract. For basic Solidity syntax, please refer to 'Solidity Smart Contract Development - Basics'. In the future, I will practice and explain more contract scenarios, so stay tuned.