Introduction
One of the most critical aspects of any investment platform is ensuring that investors receive their payments reliably and on time. At Equillar, we have implemented an automated system that constantly verifies the payment capacity of each active investment contract. In this article, we will explore how this mechanism works internally.
Why is it important to verify payment capacity?
Imagine you have an active investment contract with multiple investors waiting for their monthly returns. The contract might be functioning correctly, receiving funds and processing operations, but what happens if at some point there aren't enough funds in the reserve to cover the next payments?
To avoid these types of situations as much as possible, we have opted for the following approach: we try to detect these problems before they occur, allowing organizations to take corrective action in time.
The verification flow: a three-phase architecture
Our payment capacity verification system operates in three phases, using an architecture based on commands, asynchronous messages, and concrete services.
Phase 1: Identifying contracts to verify
It all starts with a scheduled command that runs periodically:
php bin/console app:contract:check-payment-availability
This command (CheckContractPaymentAvailabilityCommand) is the entry point of our verification system. Its logic is simple and focused:
-
Selects eligible contracts: Not all contracts need constant verification. The command only searches for those in operational states:
-
ACTIVE: Active contracts receiving investments -
FUNDS_REACHED: Contracts that reached their funding goal -
PAUSED: Contracts temporarily paused but still having payment obligations
-
-
Creates a verification record: For each selected contract, the system creates a
ContractPaymentAvailabilityentity. This record acts as a verification "ticket" that stores:- The contract to verify
- When the request was created
- The process status (pending, processed, failed)
- The verification results
Queues the work: Instead of processing everything synchronously (which could block the system for minutes), the command queues each verification as an asynchronous message through Symfony Messenger.
This design is important because it allows the command to finish quickly, while the heavy work is processed in the background. If you have 50 active contracts, the command simply creates 50 "tickets" and queues them, returning control in seconds.
Phase 2: Asynchronous processing
Once the messages are in the queue, Symfony Messenger workers come into play. Each message of type CheckContractPaymentAvailabilityMessage is processed by its corresponding handler (CheckContractPaymentAvailabilityMessageHandler).
The handler acts as a minimalist coordinator:
public function __invoke(CheckContractPaymentAvailabilityMessage $message): void
{
$contractPaymentAvailability = $this->contractPaymentAvailabilityStorage
->getById($message->contractPaymentAvailabilityId);
try {
$this->contractCheckPaymentAvailabilityService
->checkContractAvailability($contractPaymentAvailability);
} catch (ContractExecutionFailedException $e) {
// The exception is handled silently because the state
// has already been recorded in the database
}
}
This pattern of "delegating to the specialized service" is a good practice: the handler only deals with infrastructure (retrieving data, capturing errors), while the business logic lives in the service.
Phase 3: Blockchain interaction
This is where the most interesting part of the process occurs. The ContractCheckPaymentAvailabilityService is the core of our verification system, and it interacts directly with smart contracts on the Stellar blockchain.
The step-by-step process:
1. Smart contract invocation
The service calls a specific smart contract function (check_reserve_balance) that is deployed on the blockchain:
$trxResponse = $this->checkContractPaymentAvailabilityOperation
->checkContractPaymentAvailability($contractPaymentAvailability);
This smart contract function performs on-chain calculations: it checks how many investors there are, what the next pending payments are, how much is in the reserve fund, and calculates if there's a deficit.
The code for this function in the contract (written in Rust for Soroban) is as follows:
pub fn check_reserve_balance(env: Env) -> Result<i128, Error> {
require_admin(&env);
let claims_map: Map<Address, Claim> = get_claims_map_or_new(&env);
let project_balances: ContractBalances = get_balances_or_new(&env);
let mut min_funds: i128 = 0;
for (_addr, next_claim) in claims_map.iter() {
if next_claim.is_claim_next(&env) {
min_funds += next_claim.amount_to_pay;
}
}
if min_funds > 0 {
if project_balances.reserve < min_funds {
let diff_to_contribute: i128 = min_funds - project_balances.reserve;
return Ok(diff_to_contribute);
}
}
Ok(0_i128)
}
The function verifies that the caller is the administrator, then iterates over all pending claims to calculate the total minimum funds needed. If the reserve is insufficient, it returns the difference that needs to be contributed; otherwise, it returns 0.
You can check the complete contract code here: https://github.com/icolomina/soroban-contracts-examples/tree/main/investment
2. Result processing
The smart contract response comes in XDR format (a binary format used in Stellar), which we need to decode using the Soneso PHP Stellar SDK:
$trxResult = $this->scContractResultBuilder
->getResultDataFromTransactionResponse($trxResponse);
$requiredFunds = $this->i128Handler
->fromI128ToPhpFloat(
$trxResult->getLo(),
$trxResult->getHi(),
$contractPaymentAvailability->getContract()->getToken()->getDecimals()
);
The requiredFunds value is crucial: if it's 0, everything is fine. If it's greater than 0, it indicates exactly how much money is missing from the reserve fund to cover the next payments.
3. Transaction recording
Each blockchain interaction is recorded:
$contractTransaction = $this->contractTransactionEntityTransformer
->fromSuccessfulTransaction(
$contractPaymentAvailability->getContract()->getAddress(),
ContractNames::INVESTMENT->value,
ContractFunctions::check_reserve_balance->name,
[$trxResult],
$trxResponse->getTxHash(),
$trxResponse->getCreatedAt()
);
This record (ContractTransaction) gives us:
- Complete traceability: we can audit each verification
- Transaction hash: we can verify it on Stellar
- Exact timestamp: we know when it occurred
- Result: what the smart contract returned
4. State update
If it's detected that funds are missing (requiredFunds > 0), the system takes immediate action:
if ($requiredFunds > 0) {
$contract = $contractPaymentAvailability->getContract();
$this->contractEntityTransformer->updateContractAsBlocked($contract);
$this->persistor->persist($contract);
}
The contract moves to BLOCKED state, which:
- Alerts administrators
- Shows in the interface that the contract requires attention
- Records exactly how much money needs to be added to the reserve fund
When viewing the contract details, the interface displays a warning message indicating the exact amount needed in the reserve fund. This is implemented in assets/react/controllers/Contract/ViewContract.tsx, which shows:
{query.data.requiredReserveFunds && query.data.requiredReserveFunds > 0 ? (
<Typography variant="body1" fontWeight="medium" color="warning.main">
⚠️ The contract requires adding {formatCurrencyFromValueAndTokenContract(
query.data.requiredReserveFunds,
query.data.tokenContract
)} to the reserve fund to ensure investor payments.
</Typography>
) : (
<Typography variant="body1" fontWeight="medium" color="success.main">
✓ The contract has capacity to serve payments.
</Typography>
)}
In future iterations, we plan to implement email notifications to alert administrators as soon as a deficit is detected.
5. Error handling
If something fails (network down, smart contract doesn't respond, execution error), the system records it in a controlled manner:
catch (TransactionExceptionInterface $ex) {
$contractTransaction = $this->contractTransactionEntityTransformer
->fromFailedTransaction(
$contractPaymentAvailability->getContract()->getAddress(),
ContractNames::INVESTMENT->value,
ContractFunctions::check_reserve_balance->name,
$ex
);
// Mark the verification as failed
$this->contractPaymentAvailabilityTransformer
->updateContractPaymentAvalabilityAsFailed(
$contractPaymentAvailability,
$contractTransaction
);
}
The ContractPaymentAvailability entity: the historical record
Each verification is documented in the database through the ContractPaymentAvailability entity. This entity acts as a historical record that stores:
class ContractPaymentAvailability
{
private ?int $id;
private ?Contract $contract; // Which contract was verified?
private ?float $requiredFunds; // How much money is missing (if applicable)?
private ?ContractTransaction $contractTransaction; // Associated blockchain transaction
private ?\DateTimeImmutable $checkedAt; // When was it verified?
private ?\DateTimeImmutable $createdAt; // When was it requested?
private ?string $status; // Status: PENDING, PROCESSED, FAILED
}
This structure allows us to:
- View the complete verification history of a contract
- Identify patterns (does the contract run out of funds frequently?)
- Measure processing times
- Generate reports and metrics
Advantages of this architecture
1. Scalability
Asynchronous processing means we can verify many contracts without affecting the performance of the main application. Each verification is processed in its own context.
2. Resilience
If one verification fails, it doesn't affect the others. The system continues processing the rest of the contracts, and we can retry failed verifications later.
3. Transparency
By interacting with smart contracts on blockchain, the calculations are verifiable and immutable. Anyone can audit the smart contract logic.
4. Separation of concerns
- The command handles identifying work
- The handler handles messaging infrastructure
- The service handles business logic
- The entities handle the data model
Each component has a clear purpose and can evolve independently.
Conclusion
In this article, we've shown a hybrid solution (off-chain / on-chain) that combines Symfony with smart contract interaction to check the payment capacity of investment contracts. The off-chain component handles scheduling, queueing, and state management, while the on-chain component performs the actual financial calculations on the Stellar blockchain.
This approach allows us to leverage the best of both worlds: the flexibility and familiarity of a traditional PHP framework for business logic, and the transparency and immutability of blockchain for critical financial verifications.
In future articles, we will explore other aspects of the Equillar platform, such as payment processing, reserve fund management, and integration with the Stellar network. Stay tuned!
Technical note: This article describes the internal functioning of the system at the time of writing. Implementation details may evolve over time, but the fundamental architecture principles remain.
Top comments (1)
I also adopt the same approach for similar task. The only “rule” I follow is to always flush from the entry point (controller, message handler, command and so on). This ensures I always have control over the flushed entities and the moment it happens, avoiding “side flushed”.