DEV Community

Cover image for From Blockchain to Database: Synchronizing Soroban with PHP
Nacho Colomina Torregrosa
Nacho Colomina Torregrosa

Posted on • Originally published at docs.equillar.com

From Blockchain to Database: Synchronizing Soroban with PHP

Introduction

One of the most interesting challenges when working with blockchain is maintaining precise synchronization between on-chain transactions and our off-chain system. In this article, I'll share how i've solved this challenge in Equillar: how I convert the result of a Soroban smart contract call into a PHP Doctrine entity, achieving an exact replica of the blockchain state in our database.

The Journey of a Transaction

Imagine a user wants to create an investment. From the moment they click the button until the data is perfectly stored in our database, a chain of events takes place. Let's follow that flow step by step.

1. The Entry Point: The Controller

Everything begins in the createUserContract endpoint:

#[Route('/create-user-investment', name: 'post_create_user_contract_investment', methods: ['POST'])]
#[IsGranted('ROLE_USER')]
public function createUserContract(
    #[MapRequestPayload] CreateUserContractDtoInput $createUserContractDtoInput, 
    CreateUserContractService $createUserContractService
): JsonResponse
{
    $user = $this->getUser();
    return $this->json($createUserContractService->createUserContract($createUserContractDtoInput, $user));
}
Enter fullscreen mode Exit fullscreen mode

Simple and straightforward: we receive the user's data, validate they're authenticated, and delegate the business logic to the corresponding service.

2. Setting the Stage: CreateUserContractService

The CreateUserContractService service has a clear mission: prepare all the pieces before executing the blockchain transaction.

public function createUserContract(CreateUserContractDtoInput $createUserContractDtoInput, User $user): UserContractDtoOutput
{
    // 1. Get the contract from the blockchain by its address
    $contract = $this->contractStorage->getContractByAddress(
        StrKey::decodeContractIdHex($createUserContractDtoInput->contractAddress)
    );

    // 2. Verify or create the user's wallet
    $userWallet = $this->userWalletStorage->getWalletByAddress($createUserContractDtoInput->fromAddress);
    if(!$userWallet) {
        $userWallet = $this->userWalletEntityTransformer->fromUserAndAddressToUserWalletEntity(
            $user, 
            $createUserContractDtoInput->fromAddress
        );
        $this->persistor->persist($userWallet);
    }

    // 3. Create the UserContract entity (still without smart contract data)
    $userContract = $this->userContractEntityTransformer->fromCreateUserContractInvestmentDtoToEntity(
        $createUserContractDtoInput, 
        $contract, 
        $userWallet
    );
    $this->persistor->persist($userContract);
    $this->persistor->flush();

    // 4. (This is the blockchain part) - Process the blockchain transaction
    $this->processUserContractService->processUserContractTransaction($userContract);

    return $this->userContractEntityTransformer->fromEntityToOutputDto($userContract);
}
Enter fullscreen mode Exit fullscreen mode

At this point, we've created the record in our database, but it still doesn't contain the real blockchain data.

3. The Smart Contract Call: ProcessUserContractService

This is where we actually connect with the Stellar/Soroban blockchain:

public function processUserContractTransaction(UserContract $userContract): void
{
    $contractTransaction = null;

    try {
        // 1. Wait for the transaction to be confirmed on the blockchain
        $transactionResponse = $this->processTransactionService->waitForTransaction($userContract->getHash());

        // 2. Use Soneso / PHP Stellar SDK to transform XDR transacion result to PHP types
        $trxResult = $this->scContractResultBuilder->getResultDataFromTransactionResponse($transactionResponse);

        // 3. Map the result to our entity
        $this->userInvestmentTrxResultMapper->mapToEntity($trxResult, $userContract);

        // 4. Save the successful transaction
        $contractTransaction = $this->contractTransactionEntityTransformer->fromSuccessfulTransaction(
            $userContract->getContract()->getAddress(),
            ContractNames::INVESTMENT->value,
            ContractFunctions::invest->name,
            $trxResult,
            $transactionResponse->getTxHash(),
            $transactionResponse->getCreatedAt()
        );

        // 5. Dispatch an event to update the contract balance
        $this->bus->dispatch(new CheckContractBalanceMessage(
            $userContract->getContract()->getId(), 
            $transactionResponse->getLedger()
        ));

    } catch (GetTransactionException $ex) {
        // If something goes wrong, log the error
        $userContract->setStatus($ex->getStatus());
        $contractTransaction = $this->contractTransactionEntityTransformer->fromFailedTransaction(
            $userContract->getContract()->getAddress(),
            ContractNames::INVESTMENT->value,
            ContractFunctions::invest->name,
            $ex
        );
    } finally {
        // Always persist the final state
        $this->persistor->persistAndFlush([$userContract, $contractTransaction]);
    }
}
Enter fullscreen mode Exit fullscreen mode

4. Waiting for Confirmation: waitForTransaction

The blockchain isn't instantaneous. When we send a transaction, it must be included in a ledger and confirmed. The waitForTransaction method implements a polling system:

public function waitForTransaction(string $hash, int $maxIterations = 10, ?int $microseconds = null): GetTransactionResponse
{
    $counter = 0;
    do {
        // Wait a moment between each check
        ($microseconds > 0) ? usleep($microseconds) : sleep(1);

        // Query the transaction status
        $transactionResponse = $this->server->getTransaction($hash);
        $status = $transactionResponse->status;
        ++$counter;

    } while ($counter < $maxIterations && 
             !in_array($status, [GetTransactionResponse::STATUS_SUCCESS, GetTransactionResponse::STATUS_FAILED]));

    if (GetTransactionResponse::STATUS_SUCCESS !== $status) {
        throw new GetTransactionException($transactionResponse);
    }

    return $transactionResponse;
}
Enter fullscreen mode Exit fullscreen mode

This pattern is crucial: we repeatedly check the status until the transaction is confirmed or fails. It's like refreshing package tracking until we see "Delivered".

5. Decoding XDR: ScContractResultBuilder

When the smart contract responds, it does so in a format called XDR (External Data Representation), a serialization standard used in Stellar. We need to translate this format into something PHP can understand.

For this task, we rely on the Soneso Stellar PHP SDK, which provides all the necessary tools to decode XDR structures into native PHP types:

public function getResultDataFromTransactionResponse(GetTransactionResponse $transactionResponse): mixed
{
    $xdrResult = $transactionResponse->getResultValue();
    return $this->getValueFromXdrResult($xdrResult, $transactionResponse->getTxHash());
}

private function getValueFromXdrResult(XdrSCVal $xdrResult, string $hash): mixed
{
    return match ($xdrResult->type->value) {
        XdrSCValType::SCV_VOID => null,
        XdrSCValType::SCV_BOOL => $xdrResult->getB(),
        XdrSCValType::SCV_ERROR => $this->processFunctionCallError($xdrResult->getError(), $hash),
        XdrSCValType::SCV_I128 => $xdrResult->getI128(),
        XdrSCValType::SCV_MAP => $this->generateForMap($xdrResult->getMap()),  // This is the key!
        XdrSCValType::SCV_U32 => $xdrResult->getU32(),
        XdrSCValType::SCV_STRING => $xdrResult->getStr(),
        default => $xdrResult->encode(),
    };
}
Enter fullscreen mode Exit fullscreen mode

The Soneso SDK provides the XdrSCVal class that represents Soroban smart contract values, along with methods to extract each type safely. In our case, the smart contract returns a map (type SCV_MAP) with key-value pairs containing all the investment information:

private function generateForMap(array $map): array
{
    $entryMap = [];
    foreach ($map as $entry) {
        $value = match ($entry->val->type->value) {
            XdrSCValType::SCV_I128 => $entry->val->getI128(),  // Large numbers (128 bits)
            XdrSCValType::SCV_U64 => $entry->val->getU64(),    // Timestamps
            XdrSCValType::SCV_U32 => $entry->val->getU32(),    // Small numbers
            XdrSCValType::SCV_STRING => $entry->val->getStr(), // Text strings
            default => null,
        };

        $entryMap[$entry->key->sym] = $value;
    }

    return $entryMap;
}
Enter fullscreen mode Exit fullscreen mode

The result is a PHP associative array with keys like 'deposited', 'accumulated_interests', 'status', etc.

6. The Final Mapping: UserInvestmentTrxResultMapper

This is the piece that closes the circle. We take the contract data array and convert it into properties of our Doctrine entity:

public function mapToEntity(array $trxResult, UserContract $userContract): void
{
    $decimals = $userContract->getContract()->getToken()->getDecimals();

    foreach ($trxResult as $key => $value) {
        // Process each value according to its type
        $result = match ($key) {
            // Monetary amounts: convert from I128 to PHP decimal
            'accumulated_interests', 'deposited', 'total', 'paid', 'regular_payment', 'commission' => 
                I128::fromLoAndHi($value->getLo(), $value->getHi())->toPhp($decimals),

            // Timestamp of when it can be claimed
            'claimable_ts' => $value,

            // Last payment: convert UNIX timestamp to DateTime
            'last_transfer_ts' => ($value > 0) 
                ? new \DateTimeImmutable(date('Y-m-d H:i:s', $value)) 
                : null,

            // Contract status: convert number to enum
            'status' => (UserContractStatus::tryFrom($value) ?? UserContractStatus::UNKNOWN)->name,

            default => null,
        };

        // Assign the value to the entity
        $this->setValueToEntity($userContract, $key, $result);
    }
}

private function setValueToEntity(UserContract $userContract, string $key, mixed $value): void
{
    $currentTotalCharged = $userContract->getTotalCharged() ?? 0;

    match ($key) {
        'accumulated_interests' => $userContract->setInterests($value),
        'commission' => $userContract->setCommission($value),
        'deposited' => $userContract->setBalance($value),
        'total' => $userContract->setTotal($value),
        'claimable_ts' => $userContract->setClaimableTs($value),
        'last_transfer_ts' => $userContract->setLastPaymentReceivedAt($value),
        'paid' => $userContract->setTotalCharged($currentTotalCharged + $value),
        'status' => $userContract->setStatus($value),
        'regular_payment' => $userContract->setRegularPayment($value),
        default => null,
    };
}
Enter fullscreen mode Exit fullscreen mode

The Complete Flow in Perspective

Let's see the entire process at a glance:

  1. API Request → The user sends their investment data
  2. Validation and Preparation → We verify the contract and wallet
  3. Base Entity Creation → We save an initial record in the DB
  4. Transaction Wait → We wait for Soroban confirmation
  5. XDR Decoding → We convert blockchain format to PHP
  6. Data Mapping → We transform the result into entity properties
  7. Final Persistence → We save the complete state in the database

Important Technical Details

Handling Large Numbers (I128)

Soroban uses 128-bit numbers to represent amounts with decimals. We can't use PHP's native types directly, so we have an I128 class that converts them:

I128::fromLoAndHi($value->getLo(), $value->getHi())->toPhp($decimals)
Enter fullscreen mode Exit fullscreen mode

This takes the high and low parts of the 128-bit number and converts them to a PHP float, applying the token's decimals.

Timestamps and Dates

Soroban returns UNIX timestamps (seconds since 1970). We convert them to PHP DateTimeImmutable objects:

new \DateTimeImmutable(date('Y-m-d H:i:s', $value))
Enter fullscreen mode Exit fullscreen mode

States as Enums

The contract status comes as a number, but in PHP we want it as a readable enum:

(UserContractStatus::tryFrom($value) ?? UserContractStatus::UNKNOWN)->name
Enter fullscreen mode Exit fullscreen mode

Advantages of This Approach

  1. Separation of Concerns: Each class has a single, well-defined responsibility
  2. Testability: We can mock each step of the process
  3. Resilience: If the transaction fails, we capture and log the error
  4. Traceability: We save both successful and failed transactions
  5. Consistency: The database always reflects the real state of the blockchain

Conclusion

Synchronizing a blockchain with a traditional database is not trivial, but with a well-thought-out architecture it's completely manageable. The key is:

  • Patiently waiting for transaction confirmation
  • Correctly decoding the XDR format with the help of the Soneso SDK
  • Systematically mapping each field to its counterpart in the entity
  • Robustly handling errors

This pattern has worked perfectly for us to keep our off-chain system perfectly synchronized with Soroban. I hope it's useful if you're working on something similar.

Top comments (0)