From Blockchain to Database: Synchronizing Soroban with PHP
Source: Dev.to
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 solved this challenge in Equillar: converting 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 prepares all 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 have a record in the database, but it still lacks the real blockchain data.
3. The Smart Contract Call: ProcessUserContractService
Here 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 transaction 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 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 SDK’s XdrSCVal class represents Soroban smart contract values, allowing us to map them directly to native PHP types and store them in our Doctrine entities.