Handling Smart Contract Errors in Equillar: From Rust to PHP
Introduction
When building decentralized applications that interact with smart contracts, one of the most critical aspects is proper error handling. In Equillar, we've implemented an error handling strategy that bridges the gap between our Rust smart contracts and the PHP backend application.
In this article, I'll explain how we manage contract errors end-to-end, from definition in Rust to user-friendly messages in our PHP application.
The Challenge
Soroban smart contracts, written in Rust, need to communicate errors back to the calling application. These errors must be:
- Well-defined and predictable
- Easy to handle in the backend
- Translatable to user-friendly messages
Let's analyze how we achieve this in Equillar.
Defining Errors in the Smart Contract
First, we define all possible errors in our Rust smart contract using an enum with the #[contracterror] attribute:
#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)]
#[repr(u32)]
#[contracterror]
pub enum Error {
AddressInsufficientBalance = 1,
ContractInsufficientBalance = 2,
AmountLessOrEqualThan0 = 4,
AddressHasNotInvested = 14,
AddressInvestmentIsNotClaimableYet = 15,
AddressInvestmentIsFinished = 16,
AddressInvestmentNextTransferNotClaimableYet = 17,
WithdrawalUnexpectedSignature = 21,
WithdrawalExpiredSignature = 22,
WithdrawalInvalidAmount = 23,
ProjectBalanceInsufficientAmount = 24,
ContractMustBePausedToRestartAgain = 25,
ContractMustBeActiveToBePaused = 26,
ContractMustBeActiveToInvest = 27,
RecipientCannotReceivePayment = 28,
InvalidPaymentData = 29
}
Each error variant has a unique numeric identifier. This is crucial because Soroban will use these numbers when reporting errors back to our application.
Throwing Errors from Contract Functions
In our contract code, we use a custom macro to make error handling more ergonomic. Here's how we define and use it:
macro_rules! require {
($cond:expr, $err:expr) => {
if !$cond {
return Err($err);
}
};
}
// Usage example
require!(tk.balance(&addr) >= amount, Error::AddressInsufficientBalance);
Understanding the Macro
For those new to Rust macros, let's break this down:
macro_rules! is Rust's declarative macro system. It allows us to define patterns that generate code at compile time.
Our require! macro:
- Takes two arguments: a condition (
$cond:expr) and an error ($err:expr) - Checks if the condition is false
- If false, returns an error immediately
- If true, execution continues
This pattern is similar to assertions in other languages but integrated into Rust's Result-based error handling. It makes our contract code cleaner and more readable:
// Instead of writing:
if tk.balance(&addr) < amount {
return Err(Error::AddressInsufficientBalance);
}
// We can write:
require!(tk.balance(&addr) >= amount, Error::AddressInsufficientBalance);
Mirroring Errors in PHP
On the PHP side, we implement the same error structure using a PHP 8.1+ enum:
enum ContractError: int
{
case AddressInsufficientBalance = 1;
case ContractInsufficientBalance = 2;
case AmountLessOrEqualThan0 = 4;
case AddressHasNotInvested = 14;
case AddressInvestmentIsNotClaimableYet = 15;
case AddressInvestmentIsFinished = 16;
case AddressInvestmentNextTransferNotClaimableYet = 17;
case WithdrawalUnexpectedSignature = 21;
case WithdrawalExpiredSignature = 22;
case WithdrawalInvalidAmount = 23;
case ProjectBalanceInsufficientAmount = 24;
case ContractMustBePausedToRestartAgain = 25;
case ContractMustBeActiveToBePaused = 26;
case ContractMustBeActiveToInvest = 27;
case RecipientCannotReceivePayments = 28;
case InvalidPaymentData = 29;
public function getMessage(): string
{
return match ($this) {
self::AddressInsufficientBalance => 'Address has insufficient balance to perform this operation',
self::ContractInsufficientBalance => 'Contract has insufficient balance to complete this transaction',
self::AmountLessOrEqualThan0 => 'Amount must be greater than zero',
self::AddressHasNotInvested => 'This address has not made any investment in the project',
self::AddressInvestmentIsNotClaimableYet => 'Investment is not yet available to be claimed',
self::AddressInvestmentIsFinished => 'Investment has finished and no more operations can be performed',
self::AddressInvestmentNextTransferNotClaimableYet => 'Next investment transfer is not yet available to be claimed',
self::WithdrawalUnexpectedSignature => 'Withdrawal signature is invalid or does not match the expected one',
self::WithdrawalExpiredSignature => 'Withdrawal signature has expired and is no longer valid',
self::WithdrawalInvalidAmount => 'Withdrawal amount is invalid',
self::ProjectBalanceInsufficientAmount => 'Project does not have sufficient balance to perform this operation',
self::ContractMustBePausedToRestartAgain => 'Contract must be paused in order to be restarted',
self::ContractMustBeActiveToBePaused => 'Contract must be active in order to be paused',
self::ContractMustBeActiveToInvest => 'Contract must be active to make investments',
self::RecipientCannotReceivePayments => 'The recipient address cannot receive payments in the established contract asset',
self::InvalidPaymentData => 'The payment data provided is invalid',
};
}
}
Key Points:
- Identical numeric values: Each PHP enum case uses the same integer as its Rust counterpart
- Human-readable messages: The
getMessage()method provides user-friendly error descriptions
Extracting Contract Errors from Soroban Responses
When a Soroban contract throws an error, it returns a formatted string like:
HostError: Error(Contract, #14)
The number after the # is our error code. We need to parse this and map it to our enum. Here's how we do it:
/**
* Extract and return a ContractError from a raw Soroban error string
* Soroban returns contract errors in the format: "HostError: Error(Contract, #<error_code>)"
*/
public static function fromRawError(string $rawError): ?self
{
if (preg_match('#Error\(Contract,\s*\#(\d+)\)#', $rawError, $matches)) {
$errorCode = (int) $matches[1];
return self::tryFrom($errorCode);
}
return null;
}
The regex pattern #Error\(Contract,\s*\#(\d+)\)# searches for an error text like "Error(Contract, #14)" in the error string and captures the number.
Putting It All Together
Finally, we use this in our exception handling when simulating transactions:
class SimulatedTransactionException extends \RuntimeException
{
public function getError(): string
{
$contractError = $this->getContractError();
return $contractError?->getMessage() ?? 'Unknown error';
}
private function getContractError(): ?ContractError
{
$rawError = match (true) {
!empty($this->simulateTransactionResponse->resultError)
=> $this->simulateTransactionResponse->resultError,
$this->hasErrorMessage()
=> $this->simulateTransactionResponse->getError()->message,
$this->hasErrorData()
=> json_encode($this->simulateTransactionResponse->getError()->data),
default => null,
};
return is_null($rawError) ? null : ContractError::fromRawError($rawError);
}
private function hasErrorMessage(): bool
{
return $this->simulateTransactionResponse->getError() && $this->simulateTransactionResponse->getError()->message;
}
private function hasErrorData(): bool
{
return $this->simulateTransactionResponse->getError() && $this->simulateTransactionResponse->getError()->data;
}
}
The Flow
Here's what happens when a contract error occurs:
- Smart contract validates a condition using the
require!macro - If validation fails, it returns
Err(Error::AddressInsufficientBalance) - Soroban formats this as
"HostError: Error(Contract, #1)" - PHP application receives this error during transaction simulation
- Our exception handler uses regex to extract the error code (
1) - ContractError enum maps code
1toAddressInsufficientBalance - getMessage() returns the user-friendly message
- Frontend displays: "Address has insufficient balance to perform this operation"
Benefits of This Approach
- ✅ Single Source of Truth: Error codes are defined once and mirrored exactly
- ✅ User-Friendly: Technical error codes become readable messages
- ✅ Maintainable: Adding new errors requires updating just two enums
Conclusion
Handling errors across different programming languages and runtime environments can be challenging, but with careful design, we can create a seamless experience. By mirroring our error definitions and using systematic parsing, Equillar ensures that contract errors are communicated clearly from the blockchain all the way to our users.
This pattern can be applied to any decentralized application that needs to bridge smart contract errors with backend application logic, regardless of the specific technologies involved.
If you want to analyze the code in more depth, you can visit the repository on GitHub.