Meridian's core security property is simple: the API never holds or sees a private key.
It builds an unsigned Soroban transaction, returns it as a base64 XDR string, and the
browser hands that XDR to the user's connected wallet for signing, via the WalletAdapter
interface (isInstalled / isAuthorized / connect / sign) described below. The signed
XDR is then relayed back through the API only to be forwarded to the Stellar network — the
API still never signs anything itself.
This document is the reference for how the XDR builder and the wallet adapter fit together. Implement against this doc; you should not need to read the Soroban auth docs to get the flow right.
sequenceDiagram
actor User
participant Browser as Browser (React app)
participant API as Meridian API (/api/v1/tx)
participant Wallet as Connected wallet (WalletAdapter)
participant RPC as Stellar Soroban RPC
User->>Browser: Click "Deposit 100 USDC"
Browser->>API: POST /api/v1/tx/deposit { walletAddress, vaultId, amount }
API->>RPC: simulateTransaction (estimate fee + footprint)
RPC-->>API: simulation result (minResourceFee)
API-->>Browser: { xdr, fee } ← UNSIGNED, no signatures attached
Browser->>Wallet: wallet.sign(xdr, networkPassphrase)
Note over Wallet: User reviews & approves.<br/>Private key never leaves the wallet.
Wallet-->>Browser: signedTxXdr
Browser->>API: POST /api/v1/tx/submit { xdr: signedTxXdr }
API->>RPC: sendTransaction(signedTxXdr)
RPC-->>API: { hash, status }
API-->>Browser: { hash }
Browser-->>User: Toast: "Deposited 100 USDC"
- No custody, no liability. The API has no access to a signing key, so a compromised API server cannot move user funds. The worst a malicious/compromised build endpoint can do is return a transaction the user can inspect and reject in their wallet.
- The wallet is the trust boundary. The connected wallet shows the user the operations and asset amounts before they approve. Signing happens inside the wallet (e.g. a browser extension); the decrypted key never enters the page or the network.
- The
/submitendpoint only relays. It accepts an already-signed XDR and forwards it to Soroban RPC viasendTransaction. It does not (and cannot) add signatures. Submitting could also be done straight from the browser to RPC; routing it through the API just centralises endpoint configuration and error handling.
All bodies are JSON. walletAddress is a 56-character Stellar public key (G...).
Amounts (amount, shares) are decimal strings with up to 7 fractional digits
(e.g. "100", "12.5000000") — never numbers, to avoid float precision loss.
Builds an unsigned deposit transaction. Asserts the wallet holds the required USDC/mUSDC
trustlines first; if not, it throws and the frontend prompts the user to add them via
POST /api/v1/tx/add-trustline.
Request:
{
"walletAddress": "GABC...XYZ",
"vaultId": "blend-usdc",
"amount": "100"
}Response 200:
{
"xdr": "AAAAAg...base64-unsigned-envelope...",
"fee": "10732"
}xdr— base64 transaction envelope, unsigned, already prepared viaassembleTransaction(Soroban footprint + resource fee included).fee— the simulatedminResourceFeein stroops, for display.
Builds an unsigned withdraw transaction. shares is the amount of vault share token (mUSDC)
to redeem.
Request:
{
"walletAddress": "GABC...XYZ",
"vaultId": "blend-usdc",
"shares": "50"
}Response 200: same shape as deposit — { "xdr": "...", "fee": "..." }.
Both endpoints return 400 with { "error": "<message>" } for validation failures
(bad public key, malformed amount, missing fields), and 500 with the
simulation/build error message for anything else that throws — including a vault whose
contract address is not configured, which surfaces as
Vault not configured: <vaultId>. Add it to KNOWN_POOLS with a contractId.
There is no 503 on these two endpoints: handleDepositRequest/handleWithdrawRequest
in packages/api-core/src/tx.ts wrap the whole build
call in one try/catch that always answers 500.
- Resolve the network passphrase for the active network
(
"Test SDF Network ; September 2015"for testnet,"Public Global Stellar Network ; September 2015"for mainnet). This must match the network the XDR was built for, or the wallet will produce an invalid signature. - Sign by calling
wallet.sign(xdr, networkPassphrase)on the connectedWalletAdapter(seeapps/web/src/lib/wallet.ts). Each adapter implements this against the underlying wallet's own API and normalizes the result to a signed XDR string; the caller doesn't need to know which wallet is behind it. The user rejecting the signing prompt is a normal, non-fatal outcome — adapters and callers swallow cancel/reject errors rather than surfacing them. - Submit the signed XDR via
POST /api/v1/tx/submitwith body{ "xdr": signedTxXdr }. The response is{ "hash": "<tx-hash>" }. - Refresh state — invalidate the user's
["positions", publicKey]query so the new balance is reflected, and surface success/error to the user.
Frontend code never talks to a wallet's own SDK directly — it depends on a common
WalletAdapter interface (defined in
apps/web/src/lib/wallet.ts) that every supported wallet
implements:
interface WalletAdapter {
isInstalled(): Promise<boolean>;
isAuthorized(): Promise<boolean>; // has the user granted this site access?
connect(): Promise<string>; // resolves the connected public key
sign(xdr: string, networkPassphrase: string): Promise<string>; // resolves the signed XDR
}useWalletConnect calls wallet.isInstalled() to detect the wallet and wallet.connect() to
request access; useWalletStore calls wallet.isAuthorized() on mount and on window focus to
re-validate a persisted key, clearing it if the wallet is gone or access was revoked. Signing a
transaction (this doc's flow) goes through wallet.sign().
The header provides a wallet picker (#611)
letting users choose between three wired-up adapters: FreighterWallet, LobstrWallet,
and XBullWallet. An AlbedoWallet adapter is also implemented and tested (#674),
though not yet exposed in the picker UI. The active wallet: WalletAdapter export dispatches
dynamically to whichever adapter the user selected (getWalletAdapter(getSelectedWalletId())).
See apps/docs/architecture/frontend.md
for the full registry and selection mechanics.
The examples in this doc use Freighter as the concrete illustration since it's the wallet
actually in use today, but nothing in the flow is Freighter-specific — any WalletAdapter
implementation follows the same sequence.
The browser-side wrappers live in
apps/web/src/lib/wallet.ts (signing) and
apps/web/src/lib/api.ts (build/submit calls); the orchestration
is in apps/web/src/hooks/useVaultActions.ts.