In this post, I will try to show you how we could build a dapp using php and a house purchase smart contract created using soroban platform and the rust sdk.
In order to access and invoke the contract we will use the stellar php sdk which comes with support for interacting with soroban.
You can see the contract code here and read about how it works.
Pre-requisites
In order to follow this tutorial, you should install soroban, soroban rust sdk and php stellar sdk. To install soroban and soroban rust sdk follow the instructions here.
To install php sdk follow the instructions here.
The Dapp
This dapp model follows the custodial one. It means that the app will be in charge of managing user wallets. So, after the platform receives a fiat transfer from the user, the platform should mint the user with the equivalent of the token the platform operates with and the user should be able to query its balance.
As we can see in the above figure, there is an "app" part and a "dapp" part. The dapp part is the one that interacts with the contract and, consequently, with the stellar blockchain. The app part deals with common features like managing user accounts, allowing users to create offers, houses for sale etc.
This post, will delve into the "dapp" part showing the reader how it could work.
The users
As users will interact with a soroban contract, in addition to creating an account in the "app" part, we must create an stellar account for the user and keep keys safe.
There are many ways to keep the keys safe but we are not going to deal with them in this post since it is an "app" part responsability.
Generating a key pair using the php stellar sdk is pretty straightforward:
$keyPair = KeyPair::random();
$accountId = $keyPair->getAccountId();
FuturenetFriendBot::fundTestAccount($accountId);
// Store keys .....
Notice that here we are using Futurenet. This means we are not working in a production network but in a test one so, in order to fund keys with XLM, we only have to use FuturenetFriendBot.
This contract does not use the authentication framework, that is, there are no uses of require_auth or require_auth_for_args functions. This means that the authentication relies on the "app" part. When the user logs in to the platform he/her trusts the platform to invoke contract functions in his/her name.
The owner of the contracts
In addition to user accounts, the platform should have its own keys and stellar account id. This account will be used to deploy and install contracts and invoke functions. It means that the platform is the owner of the contracts. To get the account we can do as following:
$keyPair = KeyPair::fromSeed('<P key of owner>');
$accountId = $keyPair->getAccountId();
FuturenetFriendBot::fundTestAccount($accountId);
$sdk = StellarSDK::getFutureNetInstance();
$account = $sdk->requestAccount($accountId);
You can go to stellar laboratory to create and fund the account.
The token
The contract transfers the payments between buyer and seller using a soroban token (a token that implements the soroban token interface). The token contract id has to be sent to the initialize house contract method among other parameters. So, first of all, we must get a token contract address.
We can achieve it following two ways:
- We can use the address of an existing soroban token
- We can deploy and install our soroban token. For instance, we could use the soroban token example.
Whether we use 1 or 2, we will have to mint our users with new tokens.
Let's do it by deploying and installing soroban token example:
Deploy and install the token
First of all, download soroban-examples using git:
git clone https://github.com/stellar/soroban-examples.git
Then, go to soroban-examples/token directory in your terminal and execute:
soroban contract build
After executing this, a wasm file should have been generated here: target/wasm32-unknown-unknown/release/. Copy the wasm file to your project.
Now, using php sdk let's deploy the wasm so that we can get a wasm id:
$uploadContractHostFunction = new UploadContractWasmHostFunction(<your_token_wams_path>);
$builder = new InvokeHostFunctionOperationBuilder($uploadContractHostFunction);
$operation = $builder->build();
$transaction = (new TransactionBuilder($account))->addOperation($operation)->build();
$simulateResponse = $server->simulateTransaction($transaction);
$transactionData = $simulateResponse->transactionData;
$minResourceFee = $simulateResponse->minResourceFee;
$transaction->setSorobanTransactionData($transactionData);
$transaction->addResourceFee($minResourceFee);
$transaction->sign($keyPair, Network::futurenet());
$sendResponse = $server->sendTransaction($transaction);
if($sendResponse->status === 'ERROR') {
// process error
}
if($sendResponse->error){
// process error
}
sleep(15);
$transactionResponse = $server->getTransaction($sendResponse->hash);
if($transactionResponse->status == GetTransactionResponse::STATUS_SUCCESS) {
$wasmId = $transactionResponse->getWasmId();
}
else{
// process error
}
The first five lines build the operation to be sent (Deploy wasm), build the transaction using the operation and simulate it.
The next six lines get the required data from the simulation response (transaction data and resource fee), sign the transaction and send it.
The last lines, check whether de $sendResponse has errors. If so we will have to process them. Otherwise, we wait 15 seconds to give time for the transaction to be processed. Then we get the transaction data using the hash and, if the status is SUCCESS we get the wasmId.
In a real application, we should execute this in the background and notify the users after the deployment have been executed.
Now we have the wamsId, let's install it so we can get a contract id.
$createContractHostFunction = new CreateContractHostFunction(Address::fromAccountId($keyPair->getAccountId()), 'c1b8e39933c3842b729f527e902fddccfe96af63c46b3d85d2b92d8897971ae0');
$builder = new InvokeHostFunctionOperationBuilder($createContractHostFunction);
$operation = $builder->build();
$transaction = (new TransactionBuilder($account))->addOperation($operation)->build();
$simulateResponse = $server->simulateTransaction($transaction);
$transaction->setSorobanTransactionData($simulateResponse->transactionData);
$transaction->setSorobanAuth($simulateResponse->getSorobanAuth());
$transaction->addResourceFee($simulateResponse->minResourceFee);
$transaction->sign($keyPair, Network::futurenet());
$sendResponse = $server->sendTransaction($transaction);
// Here we execute the same code as before, check for errors and wait 15 seconds
$transactionResponse = $server->getTransaction($sendResponse->hash);
if($transactionResponse->status == GetTransactionResponse::STATUS_SUCCESS) {
$contractId = $transactionResponse->getCreatedContractId();
}
else{
// process error
}
As you can see, the code for install is similar to the one for deploy. In this case, after simulating de response we also addes soroban auth to avoid call being trapped. Then we get the contract id if the transaction status is successful.
Initzialitze the token
We already have a contract id for the token. Now we have to initiate it. Calling initialize function we set the token administrator, decimals to use, a name for the token and a symbol. Let's do it:
$invokeContractHostFunction = new InvokeContractHostFunction(
'<your_token_contract_id>',
"initialize", [
Address::fromAccountId('<your_admin_address>')->toXdrSCVal(),
XdrSCVal::forU32(4),
XdrSCVal::forString('House Purchase Token'),
XdrSCVal::forString('HTP')
]);
$builder = new InvokeHostFunctionOperationBuilder($invokeContractHostFunction);
$operation = $builder->build();
$transaction = (new TransactionBuilder($account))->addOperation($operation)->build();
$simulateResponse = $server->simulateTransaction($transaction);
$transactionData = $simulateResponse->transactionData;
$minResourceFee = $simulateResponse->minResourceFee;
$transaction->setSorobanTransactionData($transactionData);
$transaction->addResourceFee($minResourceFee);
$transaction->sign($keyPair, Network::futurenet());
$sendResponse = $server->sendTransaction($transaction);
// check for errors and wait 15 seconds
$transactionResponse = $server->getTransaction($sendResponse->hash);
if($transactionResponse->status == GetTransactionResponse::STATUS_SUCCESS) {
$invocationresult = $transactionResponse->getResultValue()
}
else{
// process error
}
In this case, we use InvokeContractHostFunction to indicate which contract we are going to call (using the contract id), which function we want to invoke and which params we pass to it. The params have to match with the function params of the contract. The rest of the code is the same as the other examples but, in this case, if the transaction status is successful we get the invocation result using the method getResultValue.
Mint users
After having the token initialized, we have to mint both buyer and seller with tokens so the contract can do the transfers properly. To do it, we must invoke token mint function.
Before minting users, your application should allow registered users (especially those who will act as buyers) to "buy" the tokens since the contract does not transfer FIAT currencies. The steps would be the following:
- The registered user transfers the fiat amount to the platform.
- After the platform receives de amount in fiat, the platform mints the user amount with the equivalent in tokens.
$invokeContractHostFunction = new InvokeContractHostFunction(
'<your_token_contract_id>',
"mint",
[
Address::fromAccountId(<user_account_id>)->toXdrSCVal(),
XdrSCVal::forI128(new XdrInt128Parts(10000000, 00)),
]
);
$builder = new InvokeHostFunctionOperationBuilder($invokeContractHostFunction);
$operation = $builder->build();
/* The rest of the code is the same as when invoking *initialize* but in this case we hace to add soroban authentication since *mint* function requires it */
$transaction->setSorobanAuth($simulateResponse->getSorobanAuth());
In this case, only the function and params to pass change. The rest of the code is the same as when initialized. The function mint receives the address we want to mint and the amount tokens.
Deploying the house purchase contract
To deploy the house purchase contract we have to follow the same steps as we followed with the token contract:
- Download the contract using git
- Build it to generate wasm file
- Deploy wasm to get a wasm id
Install the house purchase contract
As the house purchase contract manages the "relation" between a buyer and a seller, we should get a contract for every purchase offer, that is, a contract for a single purchase offer.
The application must hold buyer and seller user types and allow buyers to find houses for sale and make offers and sellers to review offers and accept or reject them. When a seller accepts an offer, the application must generate a contract id for the offer.
Initialize the contract.
After generating a contract id for the offer, the contract must be initialized so that offer data can be stored. To do it, we have to invoke the contract initialize function which will receive the following params:
- buyer: The address of the buyer
- seller: The address of the seller
- token: The address of the token by which the transfers will be done. Remember that we deployed, installed and initialized the token contract in the previous sections.
- first_payment: The amount that buyer will transfer to the seller before setting a meeting date. In that meeting the buyer should transfer the rest of the payment and the seller should give the keys to the buyer.
- amount: The total amount to transfer to the seller.
- key: The key to identifying the offer.
/*
Here we need the address of the buyer, the seller and the token contract id. We construct an Address object using the account id (retrieved from the keyPair object) for the buyer and seller and using the token contract id for the token
*/
$buyerAddress = Address::fromAccountId($buyerKeyPair->getAccountId());
$sellerAddress = Address::fromAccountId($sellerKeyPair->getAccountId());
$tokenAddress = Address::fromContractId('<your_token_contract_id>');
$invokeContractHostFunction = new InvokeContractHostFunction(
'<your_contract_id>', "initialize", [
$buyerAddress->toXdrSCVal(),
$sellerAddress->toXdrSCVal(),
$tokenAddress->toXdrSCVal(),
XdrSCVal::forI128(new XdrInt128Parts(5000, 5000)),
XdrSCVal::forI128(new XdrInt128Parts(50000, 50000)),
XdrSCVal::forSymbol('hyystth')
]);
$builder = new InvokeHostFunctionOperationBuilder($invokeContractHostFunction);
$operation = $builder->build();
$transaction = (new TransactionBuilder($account))->addOperation($operation)->build();
$simulateResponse = $server->simulateTransaction($transaction);
For simplicity, we avoid the rest of the code since it is the same as when we initialized the token. Take this code as a reference since the only thing that change is the function and params we are invoking.
After invoking initialize function, the contract has the purchase offer stored and we are ready to send the first payment to the seller.
Transfer the first payment
Now, it's time to transfer the first payment to the seller so the seller can propose a meeting date to the buyer to finish the process. If the buyer tries to transfer the first payment
without having initialized the contract, the invocation will return an error and the first payment will not be transferred.
$invokeContractHostFunction = new InvokeContractHostFunction(
'<your_token_contract>',
"transfer_first_payment"
);
$builder = new InvokeHostFunctionOperationBuilder($invokeContractHostFunction);
$operation = $builder->build();
$transaction = (new TransactionBuilder($account))->addOperation($operation)->build();
$simulateResponse = $server->simulateTransaction($transaction);
In this case, the invocation needs no arguments since the data required for making the transfer is stored in the contract.
Proposing a meeting date.
To propose a meeting date, the seller has to invoke the seller_propose_meeting method passing as an argument the meeting timestamp.
$invokeContractHostFunction = new InvokeContractHostFunction(
'<your_token_contract>',
"seller_propose_meeting",
[
XdrSCVal::forU64(1692551541)
]
);
If there is already a meeting, the meeting date is lower than the current date or the first payment cannot be transferred yet, the invocation will return an error.
If the meeting date is proposed successfully, the contract publishes an event on the topic MP. Events can be read using php sdk as it's shown in the code below:
$topicFilter = new TopicFilter( XdrSCVal::forSymbol("MP")->toBase64Xdr()]);
$topicFilters = new TopicFilters($topicFilter);
$eventFilter = new EventFilter("contract", [<your_contract_id>], $topicFilters);
$eventFilters = new EventFilters();
$eventFilters->add($eventFilter);
$request = new GetEventsRequest($startLedger, $eventFilters);
$response = $server->getEvents($request);
Then, we should look at the last event which will hold the timestamp.
The platform should allow role buyer users to read the topic MP events and show the meeting proposed date by the sellers.
Accepting or rejecting the meeting date
A buyer who reviews a seller's proposed meeting date can accept or reject it. To achieve this, he/her has to invoke buyer_review_meeting contract function. The params required are the timestamp proposed and a boolean which indicates if the buyer accepts (true) or rejects (false) the meeting.
$invokeContractHostFunction = new InvokeContractHostFunction(
'<your_token_contract>',
"buyer_review_meeting",
[
XdrSCVal::forU64(1692551541),
XdrSCVal::forBool(true)
]
);
As shown above, the buyer accepts the meeting and the contract stores the timestamp.
Transfer the rest of the payment
The meeting date has arrived and the buyer invokes transfer_rest_of_payment. This function transfers the rest of the payment (amount - first_payment) to the seller and sets the buyer as the new proprietary.
$invokeContractHostFunction = new InvokeContractHostFunction(
'<your_token_contract>',
"transfer_rest_of_payment"
);
Since the contract has offer data stored, invocation call needs no params. This function will fail whether:
- The current date is lower than the meeting date.
- The meeting date has not been set.
A complete example on php to deploy a contract.
In this last section, let's show how to create a php project and execute the required code to deploy a wasm file.
Install composer
Composer is the most extended tool for dependency management used by php developers. To be able to use the php stellar sdk library you will need composer to install it.
If you do not have composer running on your computer, download and install it following the instructions here.
Start a composer project
To start a composer project, create a folder for your project, move into the folder and execute the following command:
composer init
Follow the steps of the interactive output. When "composer init" command finishes, install php stellar sdk using the following command:
composer require soneso/stellar-php-sdk:1.2.3
Wait until the installation finishes and then, open the project folder with your favorite ide. You will see a src folder. Create a file Deploy.php into that folder and paste the following code:
require 'vendor/autoload.php';
use Soneso\StellarSDK\Crypto\KeyPair;
use Soneso\StellarSDK\Soroban\SorobanServer;
use Soneso\StellarSDK\StellarSDK;
use Soneso\StellarSDK\Util\FuturenetFriendBot;
use Soneso\StellarSDK\UploadContractWasmHostFunction;
use Soneso\StellarSDK\InvokeHostFunctionOperationBuilder;
use Soneso\StellarSDK\TransactionBuilder;
use Soneso\StellarSDK\Network;
use Soneso\StellarSDK\Soroban\Responses\GetTransactionResponse;
// Creating the server and set experimental flag
$server = new SorobanServer("https://rpc-futurenet.stellar.org:443");
$server->acknowledgeExperimental = true;
// Generate a key pair, use friendbot to fund account and get account object from futurenet
$keyPair = KeyPair::random();
$accountId = $keyPair->getAccountId();
FuturenetFriendBot::fundTestAccount($accountId);
$sdk = StellarSDK::getFutureNetInstance();
$account = $sdk->requestAccount($accountId);
// Create deploy call and simulate the transaction
$uploadContractHostFunction = new UploadContractWasmHostFunction(file_get_contents(__DIR__ . '/house_purchase.wasm'));
$builder = new InvokeHostFunctionOperationBuilder($uploadContractHostFunction);
$operation = $builder->build();
$transaction = (new TransactionBuilder($account))->addOperation($operation)->build();
$simulateResponse = $server->simulateTransaction($transaction);
$transactionData = $simulateResponse->transactionData;
$minResourceFee = $simulateResponse->minResourceFee;
// Add data from simulation to the transaction object and send it
$transaction->setSorobanTransactionData($transactionData);
$transaction->addResourceFee($minResourceFee);
$transaction->sign($keyPair, Network::futurenet());
$sendResponse = $server->sendTransaction($transaction);
// if there are errors, show them and die
if($sendResponse->status === 'ERROR') {
var_dump($sendResponse->jsonResponse);
die;
}
if($sendResponse->error){
echo 'Error: ' . $sendResponse->error->getMessage() . PHP_EOL;
die;
}
// Wait until transaction has been completed
echo $sendResponse->status . PHP_EOL;
echo 'Waiting 15 seconds .....' . PHP_EOL;
sleep(15);
// Get transaction data
$transactionResponse = $server->getTransaction($sendResponse->hash);
// If transaction is successful, get wasm id. Otherwise, dump status
if($transactionResponse->status == GetTransactionResponse::STATUS_SUCCESS) {
echo 'WasmId: ' . $transactionResponse->getWasmId() . PHP_EOL;
}
else{
var_dump($transactionResponse->status);
}
Copy the wasm file into the src folder too.
Then, execute the following command from the project root folder:
php src/Deploy.php
And you should see the following output:
PENDING
Waiting 15 seconds .....
WasmId: c1b8e39933c3842b729f527e902fddccfe96af63c46b3d85d2b92d8897971ae0
Last words
This post goal is to serve as a base for other developers so that they can use it as a guide to develop their own ideas about how an application could interact with a smart contract.