DEV Community

Cover image for How Equillar Uses Stellar Muxed Accounts
Nacho Colomina Torregrosa
Nacho Colomina Torregrosa

Posted on • Originally published at docs.equillar.com

How Equillar Uses Stellar Muxed Accounts

Introduction

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]);
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Click on the reserve fund contribution button
  2. A modal opens (CreateReserveFundContributionModal) displaying the contract's muxed address
  3. 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>
Enter fullscreen mode Exit fullscreen mode

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...
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

Valid Operation Type

Verifies that it's a payment operation (not another type of Stellar operation):

if(!$payment instanceof PaymentOperationResponse) {
    continue;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

Existing Contract

Finds the contract associated with that specific muxed account:

$contract = $this->contractStorage->getContractByMuxedAccount($destinationMuxedAccount);
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)