I have previously explored Sera Protocol through its architecture, GraphQL API, smart contracts, a React frontend, an MCP server, and an Agent Skill.
For the next step, I wanted to move from “calling an API” to building something that a real user could operate without a dedicated trading interface. The result is a Telegram bot for stablecoin FX.
This article is for developers who know the basics of TypeScript and Web3. You do not need prior experience with Sera Protocol. We will cover the complete architecture, Sera REST API v2, wallet creation with Privy, EIP-712 signing, and the confirmation boundary that prevents the AI agent from moving funds by itself.
Here are the demo and source code first:
https://github.com/mashharuki/SeraProtocol-Sample/tree/main/telegram-bot
Note: This article reflects the implementation as of July 2026. Sera APIs, supported currencies, and contract addresses may change. Check the official documentation and the live response from
GET /configbefore integrating it into your own application.
What I built
The Sera FX Telegram Bot is an onchain trading bot that uses Sera Protocol REST API v2 and Privy server wallets to perform stablecoin FX from Telegram.
Users do not need to open a separate web app or connect a browser extension wallet. Running /start creates an Ethereum wallet through Privy and takes the user directly into the guided trading flow.
The bot has four core characteristics:
- Seed-phrase-free onboarding
- English and Japanese support on Ethereum Mainnet and Sepolia
- Swaps, cross-currency transfers, limit orders, and liquidity provision inside Telegram
- The AI can prepare an action, but only the user's Confirm tap can trigger signing
The last point is the most important design decision in this project.
Giving an LLM direct access to a private key or unrestricted signing method would create an unacceptable security boundary. I separated the agent that interprets natural language from the deterministic code that authorizes and submits transactions.
Feature overview
| Category | Command | What it does |
|---|---|---|
| Onboarding | /start |
Select a language, create a Privy wallet, and issue a Sera API key |
| Assets |
/wallet, /balance
|
Show the address, deposit QR code, wallet balance, and Vault balance |
| Market data |
/rate, /liquidity
|
Show FX rates and probe which major pairs currently have executable liquidity |
| Instant trade | /swap |
Execute an instant swap from a gas-inclusive quote |
| Transfer | /send |
Convert a stablecoin and deliver the output to another address |
| Limit orders |
/order, /orders
|
Create, inspect, and cancel resting orders |
| Liquidity | /provide |
Quote multiple markets with a Virtual Liquidity batch |
| Vault | /deposit |
Approve a token and deposit it into the Sera Vault |
| Wallet keys |
/exportkey, /importkey
|
Export or import a private key through the guarded Privy flow |
| Settings |
/network, /language
|
Switch between Mainnet/Sepolia and English/Japanese |
| AI | Free-form text | Ask the Mastra agent for an explanation or to prepare an action |
/swap returns the output to the user's own wallet. /send uses the same swap flow but changes recipient to another address. This makes a request such as “convert 100 USDC to EURC and send it to this address” one signed Sera Intent.
Testnet warning: Sepolia liquidity can be thin. A
NO_LIQUIDITYresponse does not necessarily indicate a bot failure. Use/liquidityto discover executable pairs, or test the/deposit→/orderflow instead.
Technology stack
| Layer | Technology | Role |
|---|---|---|
| Runtime | Bun + TypeScript | Bot, API server, and tests |
| Telegram | grammY 1.44 | Commands, sessions, and inline keyboards |
| HTTP API | Hono 4.12 + Zod OpenAPI | Webhook, health endpoint, and Swagger UI |
| Trading | Sera REST API v2 | Rates, swaps, orders, Vault, and Virtual Liquidity |
| Wallet | Privy Node SDK 0.25 | Ethereum wallets, EIP-712 signatures, and transaction signing |
| Web3 | viem 2.55 | Addresses, typed data, and unit conversion |
| AI agent | Mastra 1.x + Claude | Natural-language interpretation and read/prepare tools |
| Database | LibSQL / Turso | Users, API keys, orders, pending actions, and agent memory |
| Validation | Zod 4 | Environment and API response validation |
| Quality | Bun Test, TypeScript, Biome | Tests, type checking, and linting |
| Deployment | Docker + Cloud Run | Polling locally and webhook mode in production |
The bot is built with grammY and runs a Hono HTTP server in the same process. Local development uses Telegram polling, while Cloud Run uses webhooks.
The HTTP server also exposes read-only public endpoints and Swagger UI. User-specific data and every fund-moving operation remain inside the Telegram flow.
Architecture
The main data flow looks like this:
Telegram user
|
v
grammY Bot ---------------------> Mastra Agent
| |
v v
Command / Flow Layer Read / Prepare Tools
| |
+---------------+------------------+
v
Service Layer
/ | \
v v v
Sera REST API Privy LibSQL / Turso
|
v
EIP-712 / Tx signing
Command handlers and Mastra tools call the same service layer. Rate lookup, swap preparation, order management, deposits, and liquidity logic all live there.
The critical property is what the diagram does not contain: there is no direct route from the Mastra agent to PrivySigner.
The AI can read data and create a pending action. Signing only becomes reachable when the user taps Confirm on a Telegram confirmation card.
How the bot uses Sera REST API v2
My earlier Sera examples used the GraphQL subgraph and the v1 Router, OrderBook, and PriceBook contracts.
This bot uses a different integration surface: Sera REST API v2. In v2, users sign an EIP-712 Order or Intent, matching happens offchain, and settlement uses the onchain Vault and Sera contracts.
For a new application, the REST API removes a large amount of integration work by constructing order typed data, routing swaps, and building transactions.
Main endpoints
| Purpose | Endpoint |
|---|---|
| System |
GET /health, /system/time, /config
|
| Currencies and markets |
GET /tokens, /markets, /fx/rate
|
| API keys | POST /api-keys |
| Swaps |
POST /swap/quote, /swap
|
| Limit orders |
POST /orders/preview, /orders, /orders/cancel
|
| Order status | GET /orders/{order_id} |
| Virtual Liquidity |
POST /orders/vl/batch, /orders/vl/cancel
|
| Balances | GET /balances |
| Vault deposits |
POST /approve, /deposit, /tx/send
|
A typed API client
All Sera access goes through SeraClient. Its shared request method handles API-key authentication, rate-limit retries, JSON parsing, and Zod response validation.
// src/sera/client.ts
private async request<T>(
path: string,
schema: z.ZodType<T>,
opts: RequestOptions = {},
): Promise<T> {
const url = new URL(this.baseUrl + path);
for (const [key, value] of Object.entries(opts.query ?? {})) {
if (value !== undefined) url.searchParams.set(key, String(value));
}
const headers: Record<string, string> = { accept: "application/json" };
if (opts.body !== undefined) headers["content-type"] = "application/json";
if (opts.auth) {
if (!this.apiKey) throw new Error("API key not configured");
headers.authorization = `Bearer ${this.apiKey.key}:${this.apiKey.secret}`;
}
let res: Response;
for (let attempt = 0; ; attempt++) {
res = await this.fetchImpl(url, {
method: opts.method ?? (opts.body !== undefined ? "POST" : "GET"),
headers,
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
});
if (res.status !== 429 || attempt >= 2) break;
const retryAfter = Number(res.headers.get("retry-after"));
const waitSec = Number.isFinite(retryAfter) ? retryAfter : 1;
await new Promise((resolve) => setTimeout(resolve, waitSec * 1000 + 100));
}
const text = await res.text();
const json = text ? JSON.parse(text) : {};
if (!res.ok) {
throw new SeraApiError(res.status, extractErrorCode(json), text, path);
}
const parsed = schema.safeParse(json);
if (!parsed.success) {
throw new SeraApiError(
res.status,
"SCHEMA_MISMATCH",
parsed.error.message,
path,
);
}
return parsed.data;
}
GET /config is cached for ten minutes. Chain IDs, contract addresses, and the EIP-712 domain are not hardcoded because they can differ by network and deployment.
Address casing is another subtle integration detail:
// src/sera/client.ts
async getBalances(ownerAddress: string): Promise<SeraBalanceRow[]> {
const res = await this.request("/balances", balancesResponseSchema, {
auth: true,
query: { owner_address: ownerAddress.toLowerCase() },
});
return res.balances;
}
Read endpoints expect lowercase owner_address values. Signed payloads use the values supplied by the API or a checksummed address. Do not run both through the same normalization function.
From quote to swap execution
The swap flow is the core of the bot:
User Bot / Service Sera / Privy
| | |
| /swap | |
|--------------------->| POST /swap/quote |
| |----------------------->|
| | uuid + route_params |
| |<-----------------------|
| Quote + Confirm | |
|<---------------------| |
| Confirm | |
|--------------------->| atomic consume |
| | EIP-712 sign via Privy |
| |----------------------->|
| | POST /swap |
| |----------------------->|
| Result | |
|<---------------------| |
Step 1: Request a quote
The bot validates the user's amount, converts it to raw units, reads Sera's server time, and calls POST /swap/quote.
// src/services/swap-service.ts
async prepareSwap(user: UserRow, input: PrepareSwapInput): Promise<SwapCard> {
const [fromToken, toToken] = await Promise.all([
this.rateService.findToken(user.network, input.fromSymbol),
this.rateService.findToken(user.network, input.toSymbol),
]);
if (!fromToken || !toToken) throw new Error("Unknown token");
const check = validateAmount(input.amount, fromToken.decimals);
if (!check.ok) throw new Error(`Invalid amount (${check.reason})`);
const rawAmount = toRawUnits(input.amount, fromToken.decimals);
const sera = this.publicSera(user.network);
const serverTime = await sera.getSystemTime();
const quote = await sera.swapQuote({
from_token: fromToken.address,
to_token: toToken.address,
from_amount: rawAmount.toString(),
owner_address: user.walletAddress,
recipient: input.recipient ?? user.walletAddress,
expiration: serverTime + 300,
gas_mode: "receive_less",
});
return this.quoteToCard(user, quote, {
fromSymbol: fromToken.symbol,
toSymbol: toToken.symbol,
fromAmount: input.amount,
toDecimals: toToken.decimals,
recipient: input.recipient,
});
}
Using GET /system/time prevents local clock drift from invalidating the signature deadline.
With gas_mode: "receive_less", execution cost is reflected in the quoted output. A swap user does not need a separate ETH balance for gas. Depositing into the Vault for limit orders is a different flow and does require ETH.
No signing or asset movement happens here. The bot stores the quote's route_params, uuid, and expiration in a pending action, then sends a confirmation card.
Step 2: Sign only after confirmation
Privy is called only after the user taps Confirm.
// src/services/swap-service.ts
async executeSwap(user: UserRow, payload: SwapActionPayload) {
const sera = this.publicSera(user.network);
const config = await sera.getConfig();
const signature = await this.signer.signTypedData(user.walletId, {
domain: config.eip712_domain as Record<string, unknown>,
types: INTENT_EIP712_TYPES,
primaryType: "Intent",
message: payload.routeParams,
});
const permitSignature = payload.permitEip712
? await this.signer.signTypedData(user.walletId, payload.permitEip712)
: undefined;
try {
await sera.submitSwap({
uuid: payload.uuid,
signature,
...(permitSignature && payload.permitDeadline
? {
permit_signature: permitSignature,
permit_deadline: payload.permitDeadline,
}
: {}),
});
return { status: "success" as const };
} catch (error) {
if (error instanceof SeraApiError && error.isStaleQuote) {
return {
status: "requoted" as const,
card: await this.prepareSwap(user, {
fromSymbol: payload.fromSymbol,
toSymbol: payload.toSymbol,
amount: payload.fromAmount,
recipient: payload.recipient,
}),
};
}
throw error;
}
}
There is an important wire-format distinction here:
Signing warning:
route_paramsfrom/swap/quoteis theIntentmessage, not a complete typed-data envelope. The bot adds the domain fromGET /config, plus the knownIntenttypes and primary type. In contrast,permit.eip712is a complete permit payload and must be signed without reconstruction.
Treating every API response as the same typed-data shape will break signatures. The implementation preserves the signature field values exactly as returned.
If the quote expires, the bot requests a new quote. It never silently executes at the new price; it sends a fresh confirmation card and asks the user to confirm again.
Why I chose Privy
Wallet connection is awkward in a chat interface. A web app can redirect to WalletConnect or a browser extension, but repeatedly switching applications makes a Telegram flow difficult to use.
The bot creates an Ethereum server wallet for each Telegram user with the Privy Node SDK:
// src/privy/signer.ts
async createWallet(telegramUserId: number): Promise<CreatedWallet> {
const externalId = `tg-${telegramUserId}`;
const wallet = await this.privy.wallets().create({
chain_type: "ethereum",
external_id: externalId,
display_name: `Telegram user ${telegramUserId}`,
idempotency_key: `create-${externalId}`,
...(this.walletAuth
? { owner: { public_key: this.walletAuth.publicKeySpki } }
: {}),
});
return { walletId: wallet.id, address: wallet.address };
}
external_id creates a durable mapping to the Telegram user. idempotency_key prevents a double tap or retry from creating duplicate wallets.
The database stores walletId and the address. The normal trading path never retrieves or persists the private key.
EIP-712 signing is delegated to Privy:
// src/privy/signer.ts
async signTypedData(
walletId: string,
typedData: SeraTypedDataPayload,
): Promise<string> {
const result = await this.privy
.wallets()
.ethereum()
.signTypedData(walletId, {
params: {
typed_data: {
domain: typedData.domain,
types: typedData.types,
primary_type: typedData.primaryType,
message: typedData.message,
},
},
});
if (!result.signature) throw new Error("No signature returned");
return result.signature;
}
An optional P-256 authorization key can be configured as the wallet owner to enable /exportkey and /importkey. Those flows use Privy's high-level SDK methods and apply additional Telegram safeguards:
- An exported key message is deleted after 60 seconds
- An import message is deleted immediately
- Private keys are never stored in the database or logs
- Key transfer is disabled when the authorization key is not configured
The backend authorization key is highly sensitive. It belongs in a managed secret store, not source control or a committed .env file.
Limit orders and the Vault
Swaps can spend from the wallet directly. Limit orders first require funds in the Sera Vault.
The order flow is:
- Read
tick_precisionandquantity_precisionfromGET /markets - Validate price and amount without silently rounding
- Check the available Vault balance
- Call
POST /orders/previewto obtain the EIP-712 Order payload - Sign the API-provided typed data through Privy
- Submit it to
POST /orders - Persist both
order_idanduuid_int
The client creates the order UUID. A retry reuses the same order_id, allowing the server to deduplicate the request. uuid_int is the uint256 value bound into the onchain signature and must match the encoding of order_id.
When the Vault balance is insufficient, /deposit executes this sequence:
POST /approve -> unsigned approve transaction
Privy signs it
POST /tx/send -> broadcast approve
POST /deposit -> unsigned deposit transaction
Privy signs it
POST /tx/send -> broadcast deposit
A swap can include gas cost in its quote and therefore does not require the user to hold ETH. Vault
approveanddepositare transactions from the user's wallet and require a small amount of ETH.
Order cancellation has a cooldown. The bot stores the placement time and reports the remaining wait instead of repeatedly submitting a request that the API will reject.
Virtual Liquidity across multiple markets
/provide uses Sera v2 Virtual Liquidity.
Suppose a market maker wants to place three orders, each spending up to 1,000 USDC:
1,000 USDC -> EURC/USDC bid
1,000 USDC -> XSGD/USDC bid
1,000 USDC -> JPYC/USDC bid
Shared collateral budget: 1,000 USDC
A Virtual Liquidity batch groups 2–50 orders across distinct markets under one collateral budget. The Vault freezes the largest single leg rather than the sum. When one order consumes budget, sibling orders are automatically reduced.
Each leg shares a VL group ID and receives a distinct leg_id inside uuid_int. The preview endpoint accepts standalone encodings, so the implementation replaces only the uuid field with the VL encoding before signing. Every other API-provided EIP-712 field remains unchanged.
The pending-action security boundary
Every operation that can move funds follows one path:
Prepare action
-> store pending action
-> render Telegram confirmation card
-> user taps Confirm
-> atomically consume the one-time action
-> Privy signs
-> submit to Sera
A pending action stores the payload, Telegram user ID, network, expiration, and consumption timestamp.
// src/services/pending-actions.ts
async consume<T>(
id: string,
telegramUserId: number,
): Promise<ConsumeResult<T>> {
const row = await this.repo.find(id);
if (!row) return { status: "not_found" };
if (row.telegramUserId !== telegramUserId) return { status: "wrong_user" };
if (row.consumedAt !== null) return { status: "already_used" };
if (row.expiresAt < Date.now()) return { status: "expired" };
const won = await this.repo.consume(id);
if (!won) return { status: "already_used" };
return {
status: "ok",
payload: JSON.parse(row.payload) as T,
row,
};
}
repo.consume() updates consumed_at only when the row is still unused. Even if Telegram resends a callback or the user taps repeatedly, only the request that wins the atomic update continues.
The action ID is a short random value that fits inside Telegram's callback_data limit. The payload stays in the database rather than being embedded in the button.
Prepared --Confirm--> Executing --success--> Success
| |
| +--stale quote--> Requoted --> Prepared
+--Cancel-----------> Cancelled
+--TTL elapsed------> Expired
This boundary rejects:
- An action confirmed by a different Telegram user
- An expired quote
- A double Confirm tap
- Reuse of a cancelled action
- An AI-prepared action that the user never approved
Integrating a Mastra agent without giving it a wallet
Free-form messages are sent to a Mastra agent. For example, “swap 100 USDC to EURC” causes the agent to select a tool and prepare a confirmation card.
Only these tool categories are registered:
- Read: FX rate, markets, liquidity, balances, and orders
- Prepare: swap, send, and limit order
The prepare tool looks like this:
// src/mastra/tools/prepare-tools.ts
export const prepareSwapTool = createTool({
id: "prepare-swap",
description: "Prepare a swap. The user must tap Confirm before execution.",
inputSchema: z.object({
fromSymbol: z.string(),
toSymbol: z.string(),
amount: z.string(),
}),
outputSchema: z.object({ result: z.string() }),
execute: async (input, context) => {
const resolved = await requireUser(context?.requestContext);
if ("error" in resolved) return { result: resolved.error };
const card = await resolved.services.swaps.prepareSwap(
resolved.user,
input,
);
getCardCollector(context?.requestContext)?.push({
kind: "swap",
actionId: card.actionId,
card,
});
return {
result: "Swap prepared. Ask the user to review and tap Confirm.",
};
},
});
The bot places the verified Telegram user ID, language, network, and wallet address into requestContext. Identity never comes from model-generated tool arguments.
Cards created by the agent use the same confirmation callback as command-driven cards. There is no special AI signing route.
The AI prepares a trade. The human approves it. Deterministic application code performs the signature and submission.
This separation preserves the convenience of natural language while reducing the chance that a model error or prompt injection becomes an unauthorized asset movement.
Without ANTHROPIC_API_KEY, free-form chat is disabled but every guided command continues to work.
Setup
Prerequisites
- Git
- Bun 1.x
- A Telegram Bot token
- A Privy App ID and App Secret
- Sepolia ETH and supported test tokens when using testnet
1. Clone and install
git clone https://github.com/mashharuki/SeraProtocol-Sample.git
cd SeraProtocol-Sample/telegram-bot
bun install
cp .env.example .env
2. Configure the required environment variables
# From Telegram @BotFather
TELEGRAM_BOT_TOKEN=
# From the Privy Dashboard
PRIVY_APP_ID=
PRIVY_APP_SECRET=
DEFAULT_NETWORK=sepolia
The AI chat, persistent production database, and key import/export flows are optional:
ANTHROPIC_API_KEY=
# Local SQLite; use Turso or another persistent LibSQL service on Cloud Run
DATABASE_URL=file:./data/bot.db
DATABASE_AUTH_TOKEN=
# Generate with: bun scripts/gen-wallet-auth-key.ts
WALLET_AUTH_PRIVATE_KEY=
WALLET_AUTH_PUBLIC_KEY=
Security warning:
.envcontains a Telegram token, Privy secret, and potentially an authorization private key. Never commit it. Use a managed secret store in production.
3. Start the bot
bun run dev
Polling mode is the default. Open the bot in Telegram and send /start.
4. Walk through the demo
- Run
/start, select a language, and create a wallet - Run
/walletto inspect the address and deposit QR code - Fund the wallet with Sepolia ETH and supported test tokens
- Check
/balance - Inspect a currency pair with
/rate - Use
/swapor/orderto create a confirmation card - Review the details and tap Confirm
- Send a free-form message to test the agent's prepare-only flow
Start with small amounts if you switch to Mainnet.
5. Verify the project
bun run typecheck
bun test
bun run check
The test suite covers precision rules, UUID encoding, pending-action single use, stale quotes, orders, deposits, Virtual Liquidity, and wallet key import/export.
Run with Docker
docker compose up --build -d
docker compose logs -f sera-fx-bot
Local SQLite data is mounted at ./data. A file: database on Cloud Run is ephemeral, so production should use a persistent LibSQL service such as Turso.
Lessons from the implementation
Signed payloads differ by endpoint
/orders/preview returns a complete EIP-712 payload. /swap/quote returns route_params as an Intent message. Similar-looking responses are not necessarily interchangeable.
Never “clean up” EIP-712 values
Formatting an address or numeric field changes the signed message. Display formatting and signing payloads must remain separate.
Design for quote expiration
A user may leave a confirmation card open for several minutes. Requoting and asking for confirmation again creates a safer experience than either failing permanently or silently accepting a new price.
Telegram is a constrained UI
callback_data must stay short, and thousands of markets cannot fit in an inline keyboard. Guided buttons focus on major currencies, while the agent can search the wider market set.
Cloud Run scale-out needs shared session state
Orders, users, and pending actions are stored in LibSQL, but part of an in-progress guided flow is currently in memory. The deployment therefore uses max-instances=1. Multi-instance deployment would require moving session state into shared storage.
Conclusion
This project combines Sera REST API v2, Privy, grammY, and Mastra to create a Telegram interface for onchain stablecoin FX.
The main takeaways are:
- Sera REST API v2 makes it possible to integrate swaps, transfers, limit orders, Vault operations, and Virtual Liquidity from TypeScript
- Privy provides server-wallet creation and EIP-712 signing without putting private keys in the normal application database
- A one-time confirmation action separates AI assistance from actual authorization
The goal should not be “an AI that can trade whenever it wants.” A safer division of responsibilities is:
The AI prepares. The human confirms. Trusted deterministic code signs and executes.
Check out the demo and repository if you want to experiment with the implementation:
https://github.com/mashharuki/SeraProtocol-Sample/tree/main/telegram-bot