From Blockchain to Database: Synchronizing Soroban with PHP
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));
}
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);
}
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]);
}
}
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;
}
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(),
};
}
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;
}
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,
};
}
The Complete Flow in Perspective
Let's see the entire process at a glance:
- API Request → The user sends their investment data
- Validation and Preparation → We verify the contract and wallet
- Base Entity Creation → We save an initial record in the DB
- Transaction Wait → We wait for Soroban confirmation
- XDR Decoding → We convert blockchain format to PHP
- Data Mapping → We transform the result into entity properties
- 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)
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))
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
Advantages of This Approach
- Separation of Concerns: Each class has a single, well-defined responsibility
- Testability: We can mock each step of the process
- Resilience: If the transaction fails, we capture and log the error
- Traceability: We save both successful and failed transactions
- 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.