previewnet · Tezos X test network — tokens have no real value
3Route Tezos X · SDK demo
buy/sell NFT · swap ERC20/XTZ
Docs
⚠️ Not production-ready. This app only demonstrates how to integrate the @baking-bad/free-route-tezos-x SDK — it is not audited or hardened for real use. Running it against mainnet is entirely at your own risk.

Demo docs

A reference integration of @baking-bad/free-route-tezos-x — an SDK that turns free-route swaps on Tezos X into ready-to-sign Tezos operations. This app exercises two flows: a standalone any-token ↔ any-token swap (the Bridge), and paying any ERC20 for an XTZ-priced asset (e.g. an objkt NFT), composed into ready-to-sign ops. Either flow can be driven from either side — a Michelson op-group (Temple) or an EVM tx batch (MetaMask).

How the SDK works

Tezos X is one chain with two interfaces — Michelson (Tezlink) and EVM (Etherlink) — that can call each other atomically in a single transaction. The SDK prepares the calls for whichever side signs:

  • Michelson-native (your tz1 signs) — the op-group calls the EVM-side router via call_evm as your evm alias; the swap’s native-XTZ output auto-forwards to your tz1, which then funds a Michelson op (e.g. the marketplace fulfill). One atomic, single-signature group. Before the wallet prompt the group is sized on the node with estimateOperation — the call_evm ops need a margin over a bare wallet estimate, and a group that would revert (e.g. the ask is gone) fails here, before signing.
  • EVM-native (your 0x signs) — the /swap response is a raw EVM tx you send directly; to reach a Michelson contract you call callMichelson on the gateway as your michelson alias (a KT1), so the NFT lands there. A wallet batches approve + swap + fulfill via EIP-5792, or sends them sequentially.

Either way the SDK only prepares the ops — you sign and broadcast with your own wallet.

Keeping the API key server-side

For brevity the per-page samples build the client inline. A real dApp — this one included — keeps the keyed free-route reads on its own server and proxies them, so the API key never reaches the browser. The SDK ships the pieces for exactly this split: a reads-only FreeRouteClient for the server, serialize* / parse* DTO helpers so quotes and swaps cross the HTTP boundary without losing their bigint fields, and a FreeRouteApi interface the browser implements as a thin, keyless client over those endpoints. The gateway-bound op builders (createMichelsonOpsBuilder / createEvmOpsBuilder) then turn those keyless reads into ready-to-sign ops in the browser — see the tail of the Client sample. See the source.

The per-page samples use the bundled facade (FreeRouteTezosX, one place holds the key) for brevity. This app uses the split above — keyed FreeRouteClient on the server, keyless reads + builders on the client. The SDK README covers all three tiers (facade · client+builder split · low-level builders).

Server
// lib/server/freeRoute.ts -- the free-route API key lives here, never in the browser
import 'server-only';
import { FreeRouteClient, tezosXPreviewnet, serializeQuote, serializeSwap } from '@baking-bad/free-route-tezos-x';

export const freeRoute = new FreeRouteClient({
  baseUrl: process.env.FREE_ROUTE_API!,
  chainId: tezosXPreviewnet.chainId,
  apiKey: process.env.FREE_ROUTE_API_KEY!, // server-only env, never NEXT_PUBLIC
});

// thin proxy endpoints -- each route.ts exports a GET over the keyed client above
import type { NextRequest } from 'next/server';
import { parseQuoteQuery, parseSwapQuery } from '@baking-bad/free-route-tezos-x'; // SDK codec: validate untrusted params

// app/api/free-route/tokens/route.ts -- plain JSON, no bigints, no serialize step
export async function GET() {
  const tokens = await freeRoute.getTokens();
  return Response.json(tokens);
}

// app/api/free-route/quote/route.ts -- serialize* turns the model into a wire DTO (JSON can't carry bigint)
export async function GET(req: NextRequest) {
  const query = parseQuoteQuery(req.nextUrl.searchParams); // validate untrusted params
  const quote = await freeRoute.getQuote(query);
  return Response.json(serializeQuote(quote));
}

// app/api/free-route/swap/route.ts
export async function GET(req: NextRequest) {
  const query = parseSwapQuery(req.nextUrl.searchParams);
  const swap = await freeRoute.getSwap(query);
  return Response.json(serializeSwap(swap));
}
Client
// lib/freeRoute.ts -- a keyless client implementing the SDK's FreeRouteApi, via our proxy
import {
  parseQuote, parseSwap, serializeQuoteQuery, serializeSwapQuery,
  type FreeRouteApi, type FreeRouteToken, type QuoteResponseDto, type SwapResponseDto,
} from '@baking-bad/free-route-tezos-x';

// same-origin fetch to our proxy endpoints (the key is injected server-side)
async function get<T>(path: string, params?: URLSearchParams): Promise<T> {
  const qs = params ? '?' + params : '';
  const res = await fetch('/api/free-route/' + path + qs);
  return res.json();
}

// FreeRouteApi is the SDK's read surface; serialize the query, parse each wire DTO back into a typed model.
export const freeRoute: FreeRouteApi = {
  getTokens: () => get<FreeRouteToken[]>('tokens'),
  getQuote: async (q) => parseQuote(await get<QuoteResponseDto>('quote', serializeQuoteQuery(q))), // bigints restored
  getSwap: async (q) => parseSwap(await get<SwapResponseDto>('swap', serializeSwapQuery(q))),
};

// ── elsewhere (browser): turn those keyless reads into ops with a network-keyed builder.
//    The builder needs only the gateway, so no API key leaves the server. This is lib/opsMichelson.ts. ──
import { createMichelsonOpsBuilder, tezosXPreviewnet } from '@baking-bad/free-route-tezos-x';

const michelson = createMichelsonOpsBuilder(tezosXPreviewnet.michelsonGateway); // pure builder, runs anywhere

const swap = await freeRoute.getSwap({ src, dst, amount, isExactOut: true, from, receiver });
const swapOps = michelson.buildSwapOperation({ swap, srcAddress: src });
// sign swapOps with your Beacon/Taquito wallet — or compose with a marketplace op (see the Buyer example).
// The EVM side mirrors this: createEvmOpsBuilder(tezosXPreviewnet.evmGateway) -> evm.buildSwapTransaction(...).

Pages

Buyer

/buyer

Pay any EVM ERC20 for an XTZ-priced objkt NFT — swap to XTZ, then fulfill the ask, composed into one signed batch. From Temple it’s a single-signature Michelson op-group; from MetaMask, an EVM tx batch that reaches objkt via callMichelson. The NFT goes to the signer by default, or to any Michelson address you pass as the objkt proxy_for (the optional recipient).

Example
import { TezosToolkit } from '@taquito/taquito';
import { InMemorySigner } from '@taquito/signer';
import {
  FreeRouteTezosX, tezosXMainnet, XTZ, toEvmUnits, targetForMinOut,
  michelsonToEvmAlias, resolveApproval, objkt, buildBatchTransaction,
} from '@baking-bad/free-route-tezos-x';

// your Taquito toolkit + signer (a Beacon wallet in a browser dApp)
const tezos = new TezosToolkit(MICHELSON_RPC);
tezos.setSignerProvider(new InMemorySigner(SECRET_KEY));

const freeRoute = new FreeRouteTezosX({
  baseUrl: FREE_ROUTE_API,
  network: tezosXMainnet,
  apiKey: FREE_ROUTE_API_KEY, // free-route API key
});

const buyerAddress = await tezos.signer.publicKeyHash();   // your Michelson (tz1) address
const buyerAlias = michelsonToEvmAlias(buyerAddress);      // its EVM identity (holds the ERC20)
const payToken = (await freeRoute.getTokens()).find((token) => token.symbol === 'USDC')!;

const priceMutez = 4_000n; // the objkt ask price (read it from the marketplace)
const slippageBps = 200;   // 2%

// exact-out: size the XTZ out so the on-chain floor still covers the price
const minOutTarget = targetForMinOut(priceMutez, slippageBps);
const swapAmount = toEvmUnits(minOutTarget, XTZ.address); // mutez -> wei for the EVM API
const swap = await freeRoute.getSwap({
  src: payToken.address,
  dst: XTZ.address,
  amount: swapAmount,
  isExactOut: true,
  from: buyerAlias,
  receiver: buyerAlias,
  slippageBps,
});

// read the on-chain allowance -> pick the minimal safe approval mode (none / approve / reset+approve)
const approval = await resolveApproval({
  evmRpc: EVM_RPC,
  token: payToken.address,
  owner: buyerAlias,
  spender: swap.tx.to,
  amount: swap.srcAmount,
});

// approve(s) + swap, composed with the objkt fulfill -> one atomic group
const swapOps = freeRoute.michelson.buildSwapOperation({
  swap,
  srcAddress: payToken.address,
  approval,
});
const fulfill = objkt.buildMichelsonFulfillAskOperation({
  marketplace: OBJKT_MARKETPLACE,
  askId: '1',
  editions: 1,
  amountMutez: priceMutez,
  // recipient: 'tz1… | KT1…', // optional: send the NFT to another Michelson address (objkt proxy_for)
});

const ops = buildBatchTransaction(swapOps, fulfill);
// size the group on the node (call_evm margin + end-to-end check before signing)
const readyOps = await freeRoute.michelson.estimateOperation({ tezos, ops });
const op = await tezos.contract.batch().with(readyOps).send(); // a single signature
await op.confirmation();

Bridge

/bridge

A standalone swap of any token to any token (XTZ ↔ ERC20, ERC20 ↔ ERC20), exact-input. The same builders as the buy, minus the marketplace op. The output lands on the signer by default, or on any EVM address you pass as the receiver.

Example
import { TezosToolkit } from '@taquito/taquito';
import { InMemorySigner } from '@taquito/signer';
import {
  FreeRouteTezosX, tezosXMainnet, XTZ, toEvmUnits, isXtz,
  michelsonToEvmAlias, resolveApproval,
} from '@baking-bad/free-route-tezos-x';

const tezos = new TezosToolkit(MICHELSON_RPC);
tezos.setSignerProvider(new InMemorySigner(SECRET_KEY));

const freeRoute = new FreeRouteTezosX({
  baseUrl: FREE_ROUTE_API,
  network: tezosXMainnet,
  apiKey: FREE_ROUTE_API_KEY, // free-route API key
});

const myAddress = await tezos.signer.publicKeyHash();
const alias = michelsonToEvmAlias(myAddress); // EVM identity that runs the swap
const slippageBps = 50;                        // 0.5%

// pick any pair + the amount in src base units
const tokens = await freeRoute.getTokens();
const src = tokens.find((token) => token.symbol === 'USDC')!;
const dst = XTZ;            // receive native XTZ
const amount = 1_000_000n; // 1 USDC

// exact-in: any token -> any token (XTZ <-> ERC20, ERC20 <-> ERC20)
const swapAmount = toEvmUnits(amount, src.address); // to wei for the EVM API
const swap = await freeRoute.getSwap({
  src: src.address,
  dst: dst.address,
  amount: swapAmount,
  isExactOut: false,
  from: alias,
  receiver: alias, // optional: any EVM 0x address — the swap output lands here (defaults to the sender)
  slippageBps,
});

// native XTZ carries value as msg.value (no approve); an ERC20 picks the minimal safe mode (none / approve / reset+approve)
const approval = isXtz(src.address)
  ? 'none'
  : await resolveApproval({
      evmRpc: EVM_RPC,
      token: src.address,
      owner: alias,
      spender: swap.tx.to,
      amount: swap.srcAmount,
    });

// approve(s) + swap -> one atomic group; native-XTZ output auto-forwards to your Michelson address
const ops = freeRoute.michelson.buildSwapOperation({
  swap,
  srcAddress: src.address,
  approval,
});
const readyOps = await freeRoute.michelson.estimateOperation({ tezos, ops }); // size on the node before signing
const op = await tezos.contract.batch().with(readyOps).send(); // a single signature
await op.confirmation();

Seller

/seller

Mints test NFTs and lists each as an XTZ-priced ask on objkt, so the Buyer page has something to purchase. Supporting flow for the demo.

My NFTs

/owned

A read-only view of the NFTs owned by the connected wallet’s Michelson holder — your tz1 (Temple) or the account’s KT1 alias (MetaMask) — from the test collection. Confirms a completed buy.

EVM (MetaMask) — the same buy, native txs

From a native EVM account the builders return an EvmTxRequest[] batch — approve + swap on the router, then the marketplace fulfill via callMichelson. The NFT lands on the account’s KT1 alias. Send it with one EIP-5792 wallet_sendCalls (or sequentially where unsupported).

import {
  FreeRouteTezosXEvm, tezosXPreviewnet, XTZ, toEvmUnits, targetForMinOut,
  evmToMichelsonAlias, resolveApproval, objkt,
} from '@baking-bad/free-route-tezos-x/evm';

const freeRoute = new FreeRouteTezosXEvm({
  baseUrl: FREE_ROUTE_API,
  network: tezosXPreviewnet,
  apiKey: FREE_ROUTE_API_KEY, // free-route API key
});

const buyerAccount = '0x…';                           // the MetaMask account (holds the ERC20, pays gas)
const buyerAlias = evmToMichelsonAlias(buyerAccount); // the KT1 where the NFT lands
const payToken = (await freeRoute.getTokens()).find((token) => token.symbol === 'USDC')!;

const priceMutez = 4_000n; // the objkt ask price (read it from the marketplace)
const slippageBps = 200;   // 2%

// exact-out: size the XTZ out so the on-chain floor still covers the price
const minOutTarget = targetForMinOut(priceMutez, slippageBps);
const swapAmount = toEvmUnits(minOutTarget, XTZ.address); // mutez -> wei for the EVM API
const swap = await freeRoute.getSwap({
  src: payToken.address,
  dst: XTZ.address,
  amount: swapAmount,
  isExactOut: true,
  from: buyerAccount,
  receiver: buyerAccount,
  slippageBps,
});

// read the on-chain allowance -> pick the minimal safe approval mode (none / approve / reset+approve)
const approval = await resolveApproval({
  evmRpc: EVM_RPC,
  token: payToken.address,
  owner: buyerAccount,
  spender: swap.tx.to,
  amount: swap.srcAmount,
});

// approve(s) + swap, composed with the objkt fulfill (via callMichelson) -> one EvmTxRequest[] batch
const swapTxs = freeRoute.evm.buildSwapTransaction({ swap, srcAddress: payToken.address, approval });
const fulfill = objkt.buildEvmFulfillAskTransaction({
  marketplace: OBJKT_MARKETPLACE,
  askId: '1',
  editions: 1,
  amountMutez: priceMutez,
  // recipient: 'tz1… | KT1…', // optional: send the NFT to another Michelson address (objkt proxy_for)
});

// EIP-5792 batch where supported; otherwise send sequentially (this previewnet). NFT lands on buyerAlias.
await walletClient.sendCalls({ calls: [...swapTxs, fulfill] });

Running on Tezos X previewnet.