DEV Community

Cover image for How Equillar Ensures Payment Capacity for Investment Contracts
Nacho Colomina Torregrosa
Nacho Colomina Torregrosa

Posted on • Originally published at docs.equillar.com

How Equillar Ensures Payment Capacity for Investment Contracts

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
Enter fullscreen mode Exit fullscreen mode

This command (CheckContractPaymentAvailabilityCommand) is the entry point of our verification system. Its logic is simple and focused:

  1. 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
  2. Creates a verification record: For each selected contract, the system creates a ContractPaymentAvailability entity. 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
  3. 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
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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()
    );
Enter fullscreen mode Exit fullscreen mode

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()
    );
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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>
)}
Enter fullscreen mode Exit fullscreen mode

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
        );
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
aerendir profile image
Adamo Crespi

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”.