Building a Safe Telegram FX Bot with the Sera Protocol API and Privy

typescript dev.to

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 /config before 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_LIQUIDITY response does not necessarily indicate a bot failure. Use /liquidity to discover executable pairs, or test the /deposit/order flow 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
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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

There is an important wire-format distinction here:

Signing warning: route_params from /swap/quote is the Intent message, not a complete typed-data envelope. The bot adds the domain from GET /config, plus the known Intent types and primary type. In contrast, permit.eip712 is 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 };
}
Enter fullscreen mode Exit fullscreen mode

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

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:

  1. Read tick_precision and quantity_precision from GET /markets
  2. Validate price and amount without silently rounding
  3. Check the available Vault balance
  4. Call POST /orders/preview to obtain the EIP-712 Order payload
  5. Sign the API-provided typed data through Privy
  6. Submit it to POST /orders
  7. Persist both order_id and uuid_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
Enter fullscreen mode Exit fullscreen mode

A swap can include gas cost in its quote and therefore does not require the user to hold ETH. Vault approve and deposit are 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
Enter fullscreen mode Exit fullscreen mode

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

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

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

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.",
    };
  },
});
Enter fullscreen mode Exit fullscreen mode

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

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

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

Security warning: .env contains 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
Enter fullscreen mode Exit fullscreen mode

Polling mode is the default. Open the bot in Telegram and send /start.

4. Walk through the demo

  1. Run /start, select a language, and create a wallet
  2. Run /wallet to inspect the address and deposit QR code
  3. Fund the wallet with Sepolia ETH and supported test tokens
  4. Check /balance
  5. Inspect a currency pair with /rate
  6. Use /swap or /order to create a confirmation card
  7. Review the details and tap Confirm
  8. 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
Enter fullscreen mode Exit fullscreen mode

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

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

References

Source: dev.to

arrow_back Back to Tutorials