Connecting Soroban to a NestJS backend
Part 1 covered the Rust side: a SEP-41 token contract and an investment escrow that distributes tokens proportionally. The contracts are deployed to Stellar testnet. Now the question is how a NestJS backend wires into them.
The existing backend handles deal creation, fiat and EVM crypto payments, KYC, and legal contracts. None of that changes. The Stellar layer is additive — an optional module that activates when a deal is configured for token minting.
The non-obvious part isn’t the SDK. It’s that two different parties sign transactions. The backend is the escrow admin: it deploys contracts, records investments, and closes deals, all with a stored Stellar keypair. But claim_tokens is different — the investor must sign that themselves, with their own wallet. Their private key never crosses the wire.
That split is what drives most of the architecture.
Two network endpoints, not one
@stellar/stellar-sdk exposes two completely separate APIs:
| API | Protocol | Used for |
|---|---|---|
SorobanRpc.Server | JSON-RPC | Deploy contracts, invoke functions, simulate, submit |
Horizon.Server | REST | Read balances, trustlines, account state |
Coming from EVM you’d expect one endpoint. Stellar separates smart contract execution (Soroban RPC) from network reads (Horizon). The StellarService initializes both clients and provides the common signing and submission primitives:
export class StellarService {
private readonly rpc: SorobanRpc.Server;
private readonly horizon: Horizon.Server;
private readonly adminKeypair: Keypair;
constructor(config: ConfigService) {
this.rpc = new SorobanRpc.Server('https://soroban-testnet.stellar.org');
this.horizon = new Horizon.Server('https://horizon-testnet.stellar.org');
this.adminKeypair = Keypair.fromSecret(config.get('STELLAR_ADMIN_SECRET_KEY'));
}
}
The admin keypair lives in an environment variable (STELLAR_ADMIN_SECRET_KEY=S...). In production it’ll be pulled from AWS Secrets Manager, but the structure is the same.
The 4-step transaction lifecycle
Every Soroban contract call — deploy, invoke, everything — follows the same four steps:
async invokeContract(contractId: string, fn: string, args: xdr.ScVal[]) {
const account = await this.rpc.getAccount(this.adminKeypair.publicKey());
// 1. BUILD — construct the transaction in memory
const tx = new TransactionBuilder(account, { fee: BASE_FEE, networkPassphrase: Networks.TESTNET })
.addOperation(
Operation.invokeContractFunction({ contract: contractId, function: fn, args })
)
.setTimeout(30)
.build();
// 2. SIMULATE — dry-run: validates the call and returns the resource fee estimate
const sim = await this.rpc.simulateTransaction(tx);
if (SorobanRpc.Api.isSimulationError(sim)) throw new Error(sim.error);
// 3. SIGN — with the admin keypair
const prepared = SorobanRpc.assembleTransaction(tx, sim).build();
prepared.sign(this.adminKeypair);
// 4. SUBMIT — send to network, poll until confirmed
const result = await this.rpc.sendTransaction(prepared);
return this.pollUntilConfirmed(result.hash);
}
On Ethereum you call estimateGas then sendTransaction. On Soroban, the simulate step is part of the protocol — you can’t skip it. The simulation both validates the transaction and adjusts the resource fee based on actual execution cost. Step 3 uses assembleTransaction to merge the simulation result (updated fees, auth entries) back into the transaction before signing.
The signing matrix
The eight on-chain actions split cleanly between the backend and the investor:
| Action | Signed by |
|---|---|
| Deploy Token Contract | Backend (admin keypair) |
| Deploy Escrow Contract | Backend (admin keypair) |
| Transfer tokens → escrow | Backend (admin keypair) |
record_investment() | Backend (admin keypair) |
close_deal_success() | Backend (admin keypair) |
close_deal_failure() | Backend (admin keypair) |
| Establish trustline | Investor (Freighter / Lobstr) |
claim_tokens() | Investor (Freighter / Lobstr) |
For investor-signed transactions, the pattern flips. The backend still builds and simulates — it constructs the XDR and runs the dry-run — but instead of signing and submitting, it returns the raw XDR base64 to the frontend:
// POST /stellar/claim/:projectId/build-claim → { xdr: string }
async buildClaimTx(projectId: string, investorPublicKey: string): Promise<{ xdr: string }> {
const tx = await this.buildContractCall(escrowContractId, 'claim_tokens', [
nativeToScVal(investorPublicKey, { type: 'address' }),
]);
const sim = await this.rpc.simulateTransaction(tx);
const prepared = SorobanRpc.assembleTransaction(tx, sim).build();
return { xdr: prepared.toXDR() }; // unsigned
}
The frontend passes that XDR to the Freighter wallet extension, the investor approves (“Claim DIMMO tokens?”), and the signed XDR comes back. The frontend POSTs it to POST /stellar/claim/submit and the backend calls rpc.sendTransaction(signedXdr). The admin keypair is never involved; the investor’s private key never leaves their device.
Three lifecycle hooks into existing code
The module hooks into three points in the existing codebase.
Hook 1: deal publication. When a dealmaker publishes a deal with Stellar token minting enabled, the deal service calls onDealPublished(). This fires five sequential transactions: upload the token WASM bytecode, instantiate the token contract, upload the escrow WASM, instantiate the escrow (with deadline, soft cap, and total supply from the deal config), then transfer all tokens from the admin wallet to the escrow. After this, the deal is live on-chain and tokens are locked.
Hook 2: investment confirmation. The investment service handles all payment confirmations. When a payment transitions to COMPLETED (whether fiat bank transfer, credit card, or crypto), the hook fires onInvestmentCompleted(), which calls record_investment(investor, amount) on the escrow contract. If the investor hasn’t connected a Stellar wallet yet at this point, a PENDING record is created in stellar_investment_records — it’ll be processed when they connect.
Hook 3: deal closure. The nightly scheduler closes deals by date. Rather than calling into the Stellar module directly, it emits an event:
// After isDealClosed = true and save()
const effectiveSoftCap = project.softCap || project.raisingAmountGoal;
this.eventEmitter.emit('deal.closed', new DealClosedEvent({
projectId: project.projectId,
isSuccess: amountValidated >= effectiveSoftCap,
closedAt: new Date(),
}));
The StellarLifecycleService listens:
@OnEvent('deal.closed')
async onDealClosed(event: DealClosedEvent) {
if (!project.stellarMintConfig?.isEnabled) return;
if (event.isSuccess) {
await this.contractService.closeDealSuccess(escrowContractId);
} else {
await this.contractService.closeDealFailure(escrowContractId);
// Creates StellarRefundRecord entries in PENDING for each investor
// (actual refund disbursement is a future module)
}
}
The event-driven design for this hook matters: ProjectModule never imports StellarModule. A future RefundModule can subscribe to deal.closed without touching the project scheduler.
The claim flow in detail
The investor claim has more steps than the other flows because it crosses between server and wallet:
GET /stellar/claim/:projectId/check— the backend checks: is the deal inSUCCESSstate, does the investor have a recorded investment, have they connected a Stellar wallet?- If they haven’t established a trustline yet:
POST /build-trustlinereturns unsigned XDR. The frontend callsfreighter.signTransaction(xdr), the user approves, the frontend POSTs signed XDR toPOST /stellar/claim/submit. - Then
POST /build-claim— same pattern, but forclaim_tokens().
The trustline step is Stellar-specific and has no EVM equivalent. Before a wallet can receive a custom token, the owner must explicitly opt in by creating a trustline. It costs 0.5 XLM as a minimum balance reserve — roughly $0.05. This is a user-visible cost that needs clear UX treatment in the frontend.
Tracking state across two systems
Five PostgreSQL tables extend the existing investment data with on-chain state:
stellar_mint_configs— token name, symbol, and supply per dealstellar_deal_contracts— contract addresses, deployment status (PENDING → TOKEN_DEPLOYED → ACTIVE)stellar_wallets— investor Stellar public keys, linked to existing usersstellar_investment_records— one row per investment:recordTxHash,claimTxHash,hasClaimedstellar_refund_records— PENDING entries created on failure (disbursement logic TBD)
These sit alongside the existing investments, projects, and users tables without modifying them. The off-chain system continues to be the source of truth for the investment amounts; the Stellar tables track whether and how those investments are reflected on-chain.
The stellar_investment_records table also matters for reconciliation. The contracts on Soroban have TTL — storage expires after 30 days if not bumped. A background job will periodically re-bump the TTL for any StellarInvestmentRecord that hasn’t claimed yet. An investor who connects their wallet six months after a deal closes needs the on-chain data to still be there.
Where this stands
The contracts are deployed and tested (see Part 1). The architecture documents are written. The NestJS module is currently being implemented — StellarService, StellarContractService, StellarLifecycleService, and the API endpoints.
Part 3 will be the frontend side: connecting Freighter, calling build-trustline and build-claim, handling the wallet popup in the middle of a checkout flow, and what XDR actually looks like from the browser’s perspective.