How Equillar Uses Stellar Muxed Accounts
If you've worked with blockchain payments, you've likely encountered this classic problem: how do you know which payment corresponds to which user or project when everyone sends funds to the same address? In Stellar, the traditional solution has been to use memos, but today we'll tell you how Equillar has evolved toward a more elegant and secure approach using Stellar Muxed Accounts.
The Previous System: Memo-Based Management
Before implementing Muxed Accounts, reserve fund contributions in Equillar followed a workflow that, while functional, required several manual steps and coordination from the user:
Memo-Based Flow
Contribution request
The user (the company) requested to make a contribution to the reserve fund through the platform.
Identifier generation
The system created a record in the database with the details of the requested contribution and generated a unique identifier for that specific contribution.
Instructions to the user
The user was shown:
- The generated identifier.
- The system's global address where funds should be sent.
- Instructions to include the identifier as a Memo in the transaction.
Sending funds
The user performed the transaction from their wallet, exchange, or other means, including the identifier as a memo.
Verification and processing
A periodic process in the system searched for incoming transactions and verified:
- That the transaction's memo matched a pending contribution record.
- That the transaction came from the registered address set by the company for that project.
- That the amount was equal to or greater than what was registered in the request.
Transfer to contract
If all validations were successful, the system extracted the corresponding contract from the contribution record and transferred the amount to the smart-contract using the appropriate function.
Limitations of this approach
While functional, this system had several drawbacks:
- Mandatory pre-registration: You couldn't simply "send funds" to the project; you had to first create a request on the platform
- Error risk: If the user forgot to include the memo or wrote it incorrectly, the funds would be in limbo
- Reconciliation complexity: The system had to maintain a correspondence mapping between identifiers, memos, and contracts
The New System: Muxed Accounts
Stellar's Muxed Accounts have allowed us to simplify this flow, eliminating virtually all friction for the user while maintaining (and even improving) security and traceability.
What Are Muxed Accounts?
A Muxed Account is a virtual address derived from a real Stellar address. Think of it as a "subaccount" that points to the same base address but with a unique identifier embedded. Visually, a muxed account has the format M... instead of the classic G... of common Stellar addresses.
The magic is that you can generate multiple muxed accounts from a single Stellar address, and the protocol allows you to automatically distinguish which one a transaction was sent to.
The Soneso PHP Stellar SDK, that is actively used in Equillar, allows developers to easily use and manage Stellar Muxed Accounts.
Implementation in Equillar
Muxed Account Generation per Contract
When a contract is activated in Equillar, it is automatically assigned a unique muxed account. This occurs in the ContractActivationService:
// After successfully activating the contract...
$muxedId = $this->contractMuxedIdGenerator->generateMuxedId($contract);
$muxedAccount = $this->stellarAccountLoader->generateMuxedAccount($muxedId);
$this->contractEntityTransformer->updateContractWithMuxedAccount($contract, $muxedAccount, $muxedId);
$this->persistor->persistAndFlush([$contractTransaction, $contract]);
The muxed account ID is generated by combining the organization ID and the contract ID:
public function generateMuxedId(Contract $contract): int
{
$orgId = $contract->getOrganzation()->getId();
$contractId = $contract->getId();
$muxedId = $orgId * $contractId;
// Range validations...
return $muxedId;
}
This strategy ensures that each contract has a unique ID without collisions.
Simplified User Interface
The user experience has been radically simplified. Now, when they want to make a contribution to the reserve fund, they simply:
- Click on the reserve fund contribution button
- A modal opens (
CreateReserveFundContributionModal) displaying the contract's muxed address - The user copies that address and sends the funds directly
<Typography variant="body1" sx={{ mt: 2, mb: 3, textAlign: 'center' }}>
To make a contribution to the reserve fund, simply transfer the desired
amount to the following address:
</Typography>
<Box sx={{ /* ... */ }}>
<Typography variant="body2" sx={{ /* ... */ }}>
{props.contract.muxedAccount}
</Typography>
<IconButton onClick={handleCopyAddress}>
<ContentCopyIcon />
</IconButton>
</Box>
No forms to fill out, no need to remember to include a memo, no identifiers to manually copy. Just an address to send funds to.
Automatic Contribution Processing
The ContractCheckReserveFundContributionsCommand runs periodically to detect and process incoming contributions. The ContractReserveFundContributionsProcessorService does the heavy lifting:
public function processIncomingContributons(): array
{
$contributionsResult = [];
$sdk = $this->stellarAccountLoader->getSdk();
// Gets the last 10 payment transactions to the system account
$operationsResponse = $sdk
->payments()
->includeTransactions(true)
->forAccount($this->stellarAccountLoader->getAccount()->getAccountId())
->order('desc')
->limit(10)
->execute();
foreach ($operationsResponse->getOperations() as $payment) {
// Validations...
$destinationMuxedAccount = $payment->getToMuxed();
// Finds the contract associated with this muxed account
$contract = $this->contractStorage->getContractByMuxedAccount($destinationMuxedAccount);
// Validates that the source address is the one registered for the project
if ($contract->getProjectAddress() !== $sourceAccount) {
$contributionsResult[$payment->getTransactionHash()] =
ContractProcessIncomingContributionsResult::fromUnmatchingSourceAccountAndProjectAddress();
continue;
}
// More validations and processing...
}
}
The process performs several critical validations:
Check for a successful transaction
Only processes payments that completed successfully on the blockchain:
if (!$payment->isTransactionSuccessful()) {
$contributionsResult[$payment->getTransactionHash()] =
ContractProcessIncomingContributionsResult::fromInvalidTransaction();
continue;
}
Valid Operation Type
Verifies that it's a payment operation (not another type of Stellar operation):
if(!$payment instanceof PaymentOperationResponse) {
continue;
}
Muxed Account Present
Verifies that the transaction was sent to a muxed account (not to the base address):
$destinationMuxedAccount = $payment->getToMuxed();
if (!$destinationMuxedAccount) {
$contributionsResult[$payment->getTransactionHash()] =
ContractProcessIncomingContributionsResult::fromEmptyDestinationMuxedAccount();
continue;
}
Existing Contract
Finds the contract associated with that specific muxed account:
$contract = $this->contractStorage->getContractByMuxedAccount($destinationMuxedAccount);
If no contract exists with that muxed account, the search will throw an exception or return null, preventing the processing of payments to addresses not associated with projects.
Verified Origin
Confirms that the payment comes from the address registered by the company (security):
$sourceAccount = $payment->getSourceAccount();
if ($contract->getProjectAddress() !== $sourceAccount) {
$contributionsResult[$payment->getTransactionHash()] =
ContractProcessIncomingContributionsResult::fromUnmatchingSourceAccountAndProjectAddress();
continue;
}
This validation is crucial: it prevents anyone from sending arbitrary funds to the project. Only the authorized address can make valid contributions.
No Duplicates
Verifies that the transaction has not been previously processed:
$existingContribution = $this->contractReserveFundContributionStorage
->getByPaymentTransactionHash($payment->getTransactionHash());
if ($existingContribution) {
$contributionsResult[$payment->getTransactionHash()] =
ContractProcessIncomingContributionsResult::fromContributionAlreadyProcessed();
continue;
}
Sufficient Balance
Ensures that the system has enough contract token balance in the account to manage the transfer to the contract:
$tokenBalance = $this->stellarAccountLoader->getTokenBalance($contract->getToken());
if($tokenBalance < (float) $payment->getAmount()) {
$contributionsResult[$payment->getTransactionHash()] =
ContractProcessIncomingContributionsResult::fromSystemAddressNotHoldingEnougthBalance();
continue;
}
If all validations pass, the system proceeds with:
// 1. Creates a contribution record
$contractReserveFundContribution = $this->contractReserveFundContributionTransformer
->fromContractAndAmountToEntity(
$contract,
$payment->getTransactionHash(),
(float) $payment->getAmount()
);
// 2. Persists the record in the database
$this->persistor->persistAndFlush($contractReserveFundContribution);
// 3. Calls the smart contract function to transfer funds to the reserve fund
$this->contractReserveFundContributionTransferService
->processReserveFundContribution($contractReserveFundContribution);
// 4. Marks the contribution as successfully processed
$contributionsResult[$payment->getTransactionHash()] =
ContractProcessIncomingContributionsResult::fromProcessed();
Technical Considerations
ID Generation
Our strategy of multiplying orgId * contractId to generate the muxed ID is simple but effective. Since both organization IDs and contract IDs are sequential and unique, the product will always be unique.
However, it's important to consider range limitations. In our case, we validate that the generated ID is between 1 and 2,147,483,647 (PostgreSQL's INT4_MAX).
Limit Constraint When Calling Horizon API
The current processing command queries the last 10 transactions. In a system with high transaction volume, this limit might need to be dynamically adjusted or implement a cursor to paginate results.
$operationsResponse = $sdk
->payments()
->includeTransactions(true)
->forAccount($this->stellarAccountLoader->getAccount()->getAccountId())
->order('desc')
->limit(10) // ← Might need adjustment, paginating or using streams
->execute();
Conclusion
The adoption of Muxed Accounts in Equillar represents much more than a simple technical change: it's a fundamental improvement in user experience that eliminates friction, reduces errors, and simplifies the system architecture.
While the memo-based system worked, it required manual coordination and multiple steps that could go wrong. Muxed Accounts allow us to leverage a native feature of the Stellar protocol to provide a more fluid and natural user experience.
If you're building an application that needs to receive differentiated payments to the same base account, you should definitely consider Muxed Accounts. We hope this article inspires you to explore their possibilities in your own projects.
Have questions about our implementation or want to know more about how we use Stellar in Equillar? Don't hesitate to contact us or check out our open source code.