Build an FX Anchor

By AnchorFactory ·

An FX anchor is the simplest anchor type — it accepts swap requests, returns price estimates, and executes currency exchanges. This recipe walks through building one from scratch.

What You'll Build

A REST API with four endpoints:

Endpoint

Method

Purpose

/getEstimate

POST

Return estimated output for a given input (unsigned)

/getQuote

POST

Return a cryptographically signed quote

/createExchange

POST

Execute a swap using a signed quote + user's signed block

/getExchangeStatus/:id

GET

Check the status of a pending exchange

Prerequisites

  • Node.js 18+

  • A Keeta account with a seed (for signing quotes)

  • @keetanetwork/keetanet-client and @keetanetwork/anchor installed

Project Setup

mkdir my-fx-anchor && cd my-fx-anchor
npm init -y
npm install express @keetanetwork/keetanet-client

Since the anchor SDK is ESM-only, set your project to ESM:

// package.json
{
  "type": "module",
  "scripts": {
    "start": "node src/index.js",
    "dev": "node --watch src/index.js"
  }
}

Project Structure

my-fx-anchor/
├── src/
│   ├── index.js          # Express server + routes
│   ├── pricing.js         # Your pricing logic
│   └── auth.js            # Signature verification middleware
├── package.json
└── .env                   # SEED (never commit this)

Step 1: Server Skeleton

// src/index.js
import express from 'express';
import KeetaNet from '@keetanetwork/keetanet-client';
import { getEstimate, getQuote, createExchange, getExchangeStatus } from './pricing.js';

const app = express();
app.use(express.json());

// Derive your anchor's signing account from a seed
const seed = Buffer.from(process.env.SEED, 'hex');
const signerAccount = KeetaNet.lib.Account.fromSeed(seed, 0);

console.log('Anchor account:', signerAccount.publicKeyString.get());

// --- Routes ---

app.post('/getEstimate', async (req, res) => {
  try {
    const estimate = await getEstimate(req.body.request);
    res.json({ ok: true, estimate });
  } catch (err) {
    res.json({ ok: false, error: err.message });
  }
});

app.post('/getQuote', async (req, res) => {
  try {
    const quote = await getQuote(req.body.request, signerAccount);
    res.json({ ok: true, quote });
  } catch (err) {
    res.json({ ok: false, error: err.message });
  }
});

app.post('/createExchange', async (req, res) => {
  try {
    const result = await createExchange(req.body.request, signerAccount);
    res.json({ ok: true, ...result });
  } catch (err) {
    res.json({ ok: false, error: err.message });
  }
});

app.get('/getExchangeStatus/:id', async (req, res) => {
  try {
    const status = await getExchangeStatus(req.params.id);
    res.json({ ok: true, ...status });
  } catch (err) {
    res.json({ ok: false, error: err.message });
  }
});

app.listen(3000, () => console.log('FX Anchor running on :3000'));

Step 2: Pricing Logic

This is the core of your anchor — where you define exchange rates and fees. Here's a simple fixed-rate example:

// src/pricing.js

// Dynamic import — anchor SDK is ESM only
const { SignData, VerifySignedData } = await import(
  '@keetanetwork/anchor/lib/utils/signing.js'
);

// Your supported pairs and rates
// In production, fetch these from an oracle, DEX pool, or your own liquidity
const PAIRS = {
  'keeta_tokenA': {
    'keeta_tokenB': { rate: 0.99, feeRate: 0.003 }  // 0.3% fee
  }
};

// In-memory exchange store (use a database in production)
const exchanges = new Map();

export async function getEstimate(request) {
  const { from, to, amount, affinity } = request;
  const pair = PAIRS[from]?.[to];
  if (!pair) throw new Error('Unsupported pair');

  const inputAmount = BigInt(amount);
  const fee = inputAmount * BigInt(Math.floor(pair.feeRate * 10000)) / 10000n;
  const afterFee = inputAmount - fee;
  const convertedAmount = afterFee * BigInt(Math.floor(pair.rate * 1e18)) / BigInt(1e18);

  return {
    request: { from, to, amount: amount.toString(), affinity },
    convertedAmount: convertedAmount.toString(),
    expectedCost: {
      min: fee.toString(),
      max: fee.toString(),
      token: from
    }
  };
}

export async function getQuote(request, signerAccount) {
  // First compute the estimate
  const estimate = await getEstimate(request);

  // Sign the quote fields — this proves the quote came from your anchor
  const fields = [
    estimate.request.from,
    estimate.request.to,
    estimate.request.amount,
    estimate.convertedAmount,
    estimate.expectedCost.max
  ];
  const signed = await SignData(signerAccount, fields);

  return {
    request: estimate.request,
    account: signerAccount.publicKeyString.get(),
    convertedAmount: estimate.convertedAmount,
    cost: {
      amount: estimate.expectedCost.max,
      token: estimate.expectedCost.token
    },
    signed  // { nonce, timestamp, signature }
  };
}

export async function createExchange(request, signerAccount) {
  const { quote, block } = request;

  // Verify the quote signature — ensure it hasn't been tampered with
  const fields = [
    quote.request.from,
    quote.request.to,
    quote.request.amount,
    quote.convertedAmount,
    quote.cost.amount
  ];
  const valid = await VerifySignedData(signerAccount, fields, quote.signed);
  if (!valid) throw new Error('Invalid quote signature');

  // In production: parse the user's signed block, validate token flows,
  // build matching blocks, publish to blockchain
  const exchangeID = crypto.randomUUID();
  exchanges.set(exchangeID, { status: 'pending', quote });

  // TODO: Queue blockchain processing
  // This is where you'd publish the swap blocks to Keeta

  return { exchangeID, status: 'pending' };
}

export async function getExchangeStatus(id) {
  const exchange = exchanges.get(id);
  if (!exchange) throw new Error('Exchange not found');
  return { exchangeID: id, status: exchange.status };
}

Step 3: Authentication Middleware

For endpoints that modify state, verify the caller's wallet signature:

// src/auth.js
import KeetaNet from '@keetanetwork/keetanet-client';

const { VerifySignedData } = await import(
  '@keetanetwork/anchor/lib/utils/signing.js'
);

export async function verifyWalletSignature(req, res, next) {
  try {
    const { account: address, signed, fields } = req.body;
    if (!address || !signed) {
      return res.status(401).json({ error: 'Missing signature' });
    }

    const account = KeetaNet.lib.Account.fromPublicKeyString(address);
    const valid = await VerifySignedData(account, fields, signed);

    if (!valid) {
      return res.status(401).json({ error: 'Invalid signature' });
    }

    req.walletAddress = address;
    next();
  } catch (err) {
    res.status(401).json({ error: 'Authentication failed' });
  }
}

Step 4: Run It

SEED=your_hex_seed_here npm run dev

Test with curl:

# Get an estimate
curl -X POST http://localhost:3000/getEstimate \
  -H "Content-Type: application/json" \
  -d '{
    "request": {
      "from": "keeta_tokenA_address",
      "to": "keeta_tokenB_address",
      "amount": "1000000000000000000",
      "affinity": "from"
    }
  }'

The Swap Block Flow

Understanding the full flow is critical for the createExchange implementation:

1. User's wallet fetches a signed quote from your anchor
2. Wallet builds a swap block:
   - SEND(amountIn, tokenIn) → to your anchor account
   - RECEIVE(minAmountOut, tokenOut) → from your anchor account
3. Wallet signs the block with user's private key
4. Wallet submits: { quote, block (base64) } to /createExchange
5. Your anchor:
   a. Verifies quote signature (proves it's YOUR quote, not forged)
   b. Parses user's block (extracts operations, validates amounts)
   c. Builds matching blocks on YOUR account chain
   d. Publishes all blocks atomically in one staple
6. User polls /getExchangeStatus until 'completed'

Production Considerations

Pricing Sources

The example above uses fixed rates. In production, your getConversionRateAndFee should:

  • Pull from a liquidity pool (AMM formula: amountOut = y * dx / (x + dx))

  • Query external price feeds (oracles, CEX APIs)

  • Aggregate across multiple sources

  • Include slippage protection

State Management

  • Store pending exchanges in a database (not in-memory)

  • Implement idempotency — createExchange should be safe to retry

  • Track exchange lifecycle: pending → processing → completed | failed

Metadata Registration

For your anchor to be discoverable, publish metadata on-chain. See Publishing Metadata.

Error Handling

Common failure modes:

  • LEDGER_SUCCESSOR_VOTE_EXISTS — block chain fork. Recover account, retry once.

  • Stale quote — check quote timestamp, reject if too old (e.g., >60 seconds)

  • Insufficient liquidity — validate reserves before accepting exchanges

Using the Demo Package

The @keetanetwork/demo-fx-anchor npm package provides a ready-made server class:

npm install @keetanetwork/demo-fx-anchor
import { KeetaNetFXAnchorHTTPServer } from '@keetanetwork/demo-fx-anchor';

const server = new KeetaNetFXAnchorHTTPServer({
  accounts: accountSet,
  signer: signerAccount,
  quoteSigner: signerAccount,
  fx: {
    from: [
      { asset: '$KTA', network: 'chain:keeta:21378' },
      { asset: '$USDC', network: 'chain:keeta:21378' }
    ],
    getConversionRateAndFee: async (request, context) => {
      // YOUR PRICING LOGIC HERE
      return {
        rate: 0.99,
        fee: 500000n,
        feeToken: request.from
      };
    },
    performExchanges: true
  },
  client: userClient
});

// Mount on Express
app.use('/api', server.router);

This handles all the boilerplate — signing, verification, block parsing, and publishing. You only implement getConversionRateAndFee.

Next Steps

More in Anchor Guides · All documentation