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 |
|---|---|---|
| POST | Return estimated output for a given input (unsigned) |
| POST | Return a cryptographically signed quote |
| POST | Execute a swap using a signed quote + user's signed block |
| GET | Check the status of a pending exchange |
Prerequisites
Node.js 18+
A Keeta account with a seed (for signing quotes)
@keetanetwork/keetanet-clientand@keetanetwork/anchorinstalled
Project Setup
mkdir my-fx-anchor && cd my-fx-anchor
npm init -y
npm install express @keetanetwork/keetanet-clientSince 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 devTest 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 —
createExchangeshould be safe to retryTrack 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-anchorimport { 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
Wallet Signatures — deep dive into the authentication pattern
Publishing Metadata — make your anchor discoverable
Common Pitfalls — avoid production gotchas