Introduction
In October 2025, Spiko launched its tokenized funds on Stellar. Stellar is a Layer 1 blockchain that has been live since 2015, and in 2024, Soroban—a new smart contract platform—was deployed on top of it.
At Spiko, we aim to be blockchain agnostic. As described in our previous article, our infrastructure is designed to scale across networks with minimal friction. Each chain comes with its own specificities, and Stellar is no exception—from a unique storage model with built-in TTLs to sequence number constraints that limit transaction throughput, we had to rethink several patterns that we take for granted on EVM chains.
This article covers how we tackled these challenges: implementing our smart contracts on Soroban, building a reliable indexer despite RPC limitations, and achieving high throughput through channel accounts and fee bump transactions.
Smart Contracts
Soroban smart contracts are written in Rust. We followed the same architecture as our EVM contracts (source code available on GitHub), with two key adjustments:
- No forwarder pattern: The forwarder pattern doesn’t exist in Stellar, so token contracts directly reference and call the redemption contract when needed
- Simplified role management: Roles are hardcoded in the smart contract rather than using a sophisticated role management system
The main philosophy behind Soroban is that explicit is better than implicit. This design principle is reflected throughout the platform: storage architecture, time management, authentication, and more. Let’s explore Soroban’s specificities.
Storage Architecture
Unlike EVM’s single storage model, Stellar provides three distinct storage types.
Instance Storage
Instance storage is designed for contract configuration and frequently accessed data. It lives with the contract instance and is automatically managed. We use it, for example, to store the address of the permission contract:
e.storage().instance().get(&PERMISSION_MANAGER_KEY)
Persistent Storage
Persistent storage is for long-term data that needs to survive across contract upgrades. We use it to store redemption statuses, and OpenZeppelin’s token contract uses it under the hood to store user balances.
e.storage().persistent().set(redemption_hash, &status)
The key difference from EVM: persistent storage has a TTL. Once the TTL expires, the data is archived. Archived data can be restored with a special transaction, but the TTL must be chosen carefully as it impacts transaction costs.
To maintain an EVM-like experience, we:
- Set the TTL to 60 days (the maximum allowed by Soroban)
- Extend the TTL on every data access
- Run a daily cron job to extend the TTL of data points approaching expiration
e.storage().persistent().extend_ttl(&key, BALANCE_TTL_THRESHOLD, BALANCE_EXTEND_AMOUNT);
Temporary Storage
Temporary storage is for short-lived data that can be cleaned up automatically. We use it to store idempotency keys:
e.storage().temporary().set(idempotency_key, &true)
Idempotency and Nonce Management
Stellar has no proper nonce management. While there is a sequence number mechanism, it’s not designed for direct use and cannot guarantee operation uniqueness at our throughput levels.
Our solution combines:
- A unique idempotency key for each operation, stored in the smart contract
- A TTL on the idempotency key to automatically clean up expired entries
Multicall
There’s no built-in multicall pattern in Stellar—every batch operation must be explicitly implemented in the smart contract. We added batch versions of our main functions (mint, burn, grant_role, etc.).
One important constraint: transaction payloads must remain small. In practice, we batch only 10 operations at a time.
Time Management
Stellar doesn’t use block timestamps. Instead, time is measured in ledgers, where one ledger ≈ 5 seconds. This means all time-related logic in the smart contracts—TTLs, expiration checks—operates in ledger units rather than Unix timestamps.
Authentication
Stellar’s require_auth() macro provides built-in authentication, verifying that the caller is the expected account. Unlike EVM where msg.sender is implicitly available, Stellar doesn’t expose the transaction caller—the caller address must be passed explicitly as a parameter. This fits Soroban’s “explicit is better than implicit” philosophy.
Operations
Indexing
As described in our previous article, we use a custom indexer to capture on-chain events. For Stellar, we use the getEvents RPC endpoint.
Here’s the catch: RPC only retains data for the last 7 days. For older historical data—whether for initial indexing or error recovery—you need an archive RPC. These archive nodes only support the getLedgers endpoint, which returns entire ledger data. Extracting events requires filtering and parsing the raw ledger output to find your contract’s events.
Stellar provides an event ID that combines ledger number, transaction position, and event index into a single sortable identifier, ensuring event uniqueness.
The main challenge we faced: getEvents is occasionally flaky for recent events and doesn’t always return complete results. We implemented a retry mechanism that re-indexes the past hour’s events regularly to ensure data completeness.
Relaying
Fee Sponsoring With Fee Bump Transactions
Our operational wallets that execute smart contract calls (minting, burning, etc.) shouldn’t need to hold XLM for fees. Managing XLM balances across dozens of wallets adds operational complexity and creates failures.
Stellar provides a native primitive called fee bump transactions. A fee bump transaction wraps an existing signed transaction and allows a different account to pay the fees. It allows to have a dedicated sponsor wallet that pays for all transaction fees, while operational wallets only handle smart contract logic.
The flow works as follows:
-
Build and sign the inner transaction: The operational wallet builds the smart contract call transaction and signs it.
-
Wrap in a fee bump: A sponsor account wraps the signed transaction in a fee bump envelope, specifying a higher fee:
const feeBumpTx = TransactionBuilder.buildFeeBumpTransaction(
sponsor.address,
(MAX_FEE * 100).toString(),
signedTx,
STELLAR_NETWORK_PASSPHRASES[network]
);
- Sign and submit the fee bump: The sponsor signs the fee bump envelope and submits it to the network. The network deducts fees from the sponsor, not from the inner transaction’s source account.
Managing Transaction Throughput
The Sequence Number
On Stellar, every account has a sequence number that increments with each transaction. It can be seen as the EVM equivalent of a nonce, but it is not aimed to be manipulated explicitely. Transactions must be submitted with the next expected sequence number, and they must be confirmed in order. This means a single account can only have one in-flight transaction at a time—if you submit a second transaction before the first is confirmed, it will be rejected.
For Spiko, this is a major bottleneck. We regularly need to execute dozens of operations in rapid succession: processing hundreds of transfers, minting tokens for multiple investors, granting roles, etc. Waiting for each transaction to confirm before sending the next one would make our throughput too slow.
Channel Accounts
One way to tackle this problem in Stellar is channel accounts. A channel account is a separate Stellar account that acts as the transaction source—it provides the sequence number—while the original account remains the authorized caller of the smart contract operation.
The key insight: since the sequence number is tied to the source account of the transaction, using multiple source accounts gives you multiple independent sequence number lanes. With N channel accounts, you can have N transactions in flight simultaneously.
Here’s how it works in practice:
- Channel account creation: Creating a channel account means deploying it on-chain like any other account, but with the main account as an additional signer. We also sponsor the reserve fees so we don’t need to transfer XLM to the channel account before deployment.
const transaction = new TransactionBuilder(account, { fee, networkPassphrase })
.addOperation(
Operation.beginSponsoringFutureReserves({
sponsoredId: channelAddress,
source: sender.address,
})
)
.addOperation(
Operation.createAccount({
destination: channelAddress,
startingBalance: "0",
source: sender.address,
})
)
.addOperation(
Operation.setOptions({
signer: { ed25519PublicKey: sender.address, weight: 1 },
source: channelAddress,
})
)
.addOperation(
Operation.endSponsoringFutureReserves({ source: channelAddress })
)
.build();
- Transaction building: When sending a smart contract call, the transaction is built using the channel account as the source (providing the sequence number), but the smart contract invocation is authorized by the main wallet through Soroban’s auth entry signing mechanism. After simulating the transaction, we iterate over the auth entries returned by the RPC, identify the ones that require our wallet’s signature, and sign them:
for (const entry of simulatedTx.result?.auth || []) {
const requiredSigner = Address.account(
entry.credentials().address().address().accountId().value()
).toString();
if (requiredSigner === address) {
const preimage = xdr.HashIdPreimage.envelopeTypeSorobanAuthorization(
new xdr.HashIdPreimageSorobanAuthorization({
networkId: stellarHash(
Buffer.from(STELLAR_NETWORK_PASSPHRASES[network])
),
nonce: entry.credentials().address().nonce(),
signatureExpirationLedger: validUntilLedgerSeq,
invocation: entry.rootInvocation(),
})
);
const signature = signHash(mainWallet, stellarHash(preimage.toXDR()));
updatedEntry.credentials().address().signature();
signedEntries.push(updatedEntry);
}
}
These signed auth entries are then injected into the rebuilt transaction (sourced from the channel account), proving to the smart contract that the main wallet authorized the operation—even though the transaction itself comes from a different account.
- Random channel selection: When converting a created transaction to a pending one, we randomly pick an available channel account. This distributes the load across channels:
const channels = yield * findAllStellarChannelWallets({ source: wallet.id });
const channel = channels[yield * Random.nextIntBetween(0, channels.length)];
We can create as many channel accounts as needed per wallet. In practice, this gives us the parallelism required for high-throughput operations while keeping the authorization model clean.
Putting It All Together
In practice, we combine fee bumps and channel accounts to achieve both high throughput and fee sponsorship:
- The channel account provides the sequence number (enabling parallel transactions)
- The main wallet signs the Soroban auth entries (authorizing the smart contract call)
- The sponsor wallet wraps everything in a fee bump (paying the fees)
This three-account architecture gives us maximum throughput, clean authorization, and centralized fee management—all in a single transaction submission.
Final Thoughts
Integrating Stellar into Spiko’s stack was the most challenging network integration we’ve done so far. Soroban is fundamentally different from the EVM platforms we typically operate on: storage has TTLs, there’s no implicit caller, sequence numbers prevent parallel transactions out of the box, and RPC data retention is limited to 7 days.
What made it work? Channel accounts and fee bump transactions. Together with Soroban’s explicit authentication model, they form an elegant three-account pattern: channel for parallelism, caller for authorization, and sponsor for fees. This gives us the same throughput and operational simplicity we have on EVM chains.
The biggest win has been proving that our multi-chain architecture scales. Adding Stellar took a few weeks of development rather than a full architectural rethink—validating the design decisions we described in our previous article. We’re now well positioned for future network integrations.