Building A DeFi FX Network From Scratch

What It Taught Me About the Future of Cross-Border Payments

Cross-border payments are the last big piece of finance that still feels… haunted.

Domestically, a payment feels like calling an API. Cross-border, it still feels like sending a fax across a chain of middlemen who may or may not be awake, liquid, or using the same calendar as you.

I’ve been thinking a lot about permissioned DeFi for cross-border payments, and there’s a system design that makes sense in theory, but I didn’t feel like it really clicked.

So instead of trying to mentally process phrases like atomic settlement and KYC-gated liquidity, I opened a new repo and tried to build a miniature version of that model — end-to-end — on my laptop.

No real banks. No real money. Just:

  • A fake “bank ledger” in Postgres

  • A permissioned AMM on a local EVM chain (Anvil)

  • Tokenized USD/EUR

  • A backend that orchestrates KYC, on-ramp, AMM swaps, and off-ramp

  • A front-end that shows the whole cross-border flow as a story

This is the write-up of that experiment: what I built, how it works, and what it taught me about where cross-border payments are going.

Why Cross-Border Payments Are Still Weird

On paper, sending money from a business in New York to a supplier in Berlin is simple:

“Debit USD here, credit EUR there.”

In reality, it looks more like this:

  • The sending bank and receiving bank may not have a direct relationship.

  • Funds move through a chain of correspondent banks holding Nostro/Vostro accounts.

  • FX might be done by a completely separate desk.

  • Settlement happens in batches, during market hours, under different time zones.

  • Each hop charges a fee and logs its own version of the truth.

  • Compliance teams stare at CSVs, logs, and Swift MT/MX messages and hope nothing slipped through.

The result:

  • Days of delay.

  • Hidden FX spreads.

  • Reconciliation nightmares.

  • A lot of trust in opaque ledgers you don’t control.

This model replaces that chain of bilateral relationships with a shared, programmable ledger:

  • Tokenized money (stablecoins, tokenized deposits, CBDCs)

  • KYC-gated wallets in a permissioned environment

  • AMMs acting as on-chain FX desks

  • Atomic settlement, so either the whole thing completes or nothing moves

  • On-/off-ramps that bridge the blockchain world to bank balance sheets

Conceptually, it’s clean. Practically, it raises a question:

What does it actually take to build something like this, even in toy form?

What I Wanted This Prototype to Prove

Before writing a line of code, I wrote down the minimum story I wanted the system to tell:

  1. A user passes KYC and becomes eligible to use the network.

  2. They have a fiat balance in USD in a “bank ledger.”

  3. They on-ramp: USD → a USD token (USDx) on a blockchain.

  4. They send a cross-border payment to someone who wants EUR.

  5. Under the hood, an AMM converts USDx → EURx atomically.

  6. The receiver can off-ramp: EURx → EUR in their “bank ledger.”

  7. Every step is observable — KYC, on-ramp, swap, off-ramp — not a black box.

I constrained the world intentionally:

  • Only two currencies: USD and EUR

  • Only a handful of event types

  • A simple KYC lifecycle

The state machine is simple. A user moves from KYC pending → approved, their money moves from fiat → token → FX swap → fiat, and each step becomes an EventType you can audit later.

The System Architecture: Bank on the Left, DeFi on the Right

I ended up with four conceptual layers:

  1. Frontend UI – Demo interface that shows balances and lets you initiate flows.

  2. Backend API – Manages users, KYC, fiat ledger, and orchestrates cross-border payments.

  3. Smart Contracts – Tokenized USD/EUR, an AMM, and (eventually) whitelist/ wrapping contracts.

  4. Local Network – Anvil, a local Ethereum-compatible chain via Foundry.

On the backend, everything is wired together in a single Fastify server:

const fastify = Fastify({ logger: { level: process.env.LOG_LEVEL || info } });

// CORS
fastify.register(cors, { origin: true });

// Initialize services
const prisma = new PrismaClient();
const blockchain = new BlockchainService(
  process.env.RPC_URL || http://127.0.0.1:8545”,
  process.env.PRIVATE_KEY || “…anvil default key…”
);
const fiatLedger = new FiatLedgerService(prisma);

// Attach services
fastify.decorate(prisma, prisma);
fastify.decorate(blockchain, blockchain);
fastify.decorate(fiatLedger, fiatLedger);

// Register routes
fastify.register(userRoutes);
fastify.register(onrampRoutes);
fastify.register(offrampRoutes);
fastify.register(paymentRoutes);
fastify.register(accountRoutes);

Conceptually:

  • FiatLedgerService = mock core banking system (off-chain).

  • BlockchainService = gateway to the DeFi side (on-chain).

  • The route handlers are just use cases: on-ramp, off-ramp, cross-border payment.

It looks suspiciously like something a real bank might build if they wanted to bolt a permissioned DeFi rail onto their ledger.

The “Bank”: A Boring, Programmable Ledger

On the left side of the system, I wanted something intentionally dull, like a real bank core.

A FiatLedgerService sits on top of Postgres via Prisma and gives me exactly three primitives:

  • Get a balance

  • Credit an account

  • Debit an account

export class FiatLedgerService {
  constructor(private prisma: PrismaClient) {}

  async getBalance(userId: string, currency: Currency): Promise<number> {
    const account = await this.prisma.fiatAccount.findUnique({
      where: { userId_currency: { userId, currency } },
    });
    return account ? account.balance.toNumber() : 0;
  }

  async creditAccount(userId: string, currency: Currency, amount: number) {
    return this.prisma.fiatAccount.upsert({
      where: { userId_currency: { userId, currency } },
      update: { balance: { increment: amount } },
      create: { userId, currency, balance: amount },
    });
  }
}

When you “on-ramp” $100:

  1. The fiat ledger debits $100 from your USD account.

  2. The backend then mints 100 units of USDx to your wallet on the chain.

When you “off-ramp” €92:

  1. The backend burns 92 EURx from your wallet.

  2. The fiat ledger credits your EUR balance with 92.

Having a real database and a ledger abstraction forces you to think like a bank, not like a DeFi app: balances are not “whatever the blockchain says,” they’re an explicit, auditable balance sheet.

The DeFi Side: A Service, Not a Mystery Box

On the right side of the system is the “permissioned DeFi” world: tokens, AMM, smart contracts. Instead of sprinkling RPC calls all over the codebase, I wrapped it in a BlockchainService using viem:

const anvil = defineChain({
  id: 31337,
  name: Anvil,
  nativeCurrency: { decimals: 18, name: Ether, symbol: ETH },
  rpcUrls: { default: { http: [http://127.0.0.1:8545”] } },
});

export class BlockchainService {
  private publicClient;
  private walletClient;
  private addresses;

  constructor(rpcUrl: string, privateKey: string) {
    const account = privateKeyToAccount(privateKey as `0x${string}`);

    this.publicClient = createPublicClient({
      chain: anvil,
      transport: http(rpcUrl),
    });

    this.walletClient = createWalletClient({
      account,
      chain: anvil,
      transport: http(rpcUrl),
    });

    this.addresses = loadContractAddresses();
  }

  async mintToken(tokenAddress: Address, to: Address, amount: bigint) {
    const hash = await this.walletClient.writeContract({
      address: tokenAddress,
      abi: ERC20_ABI,
      functionName: mint,
      args: [to, amount],
    });
    await this.publicClient.waitForTransactionReceipt({ hash });
    return hash;
  }
}

From the backend’s perspective, this is just another dependency:

  • mintToken → issue tokenized money

  • swap → call the AMM

  • transfer → move tokens between parties

This is one of the big mental shifts in permissioned DeFi:
DeFi is really just a different settlement engine behind your service layer.

Tokenized Money: USDx and EURx

On chain, I represent fiat deposits as simple ERC-20s with controlled mint/burn:

/**
 * @title USDx
 * @notice Mock USD tokenized deposit/stablecoin
 */
contract USDx is ERC20, Ownable {
    constructor(address initialOwner)
        ERC20(Mock USD Token, USDx)
        Ownable(initialOwner)
    {}

    function mint(address to, uint256 amount) external onlyOwner {
        _mint(to, amount);
    }

    function burn(address from, uint256 amount) external onlyOwner {
        _burn(from, amount);
    }
}

EURx is identical, just with a different name/symbol.

The important bit is onlyOwner:

  • Only the backend’s signer — the same service that debits/credits the Postgres ledger — can mint or burn tokens.

  • This matches how tokenized deposits or bank-issued stablecoins work: the token is a representation of a real liability that lives on the bank’s books.

The chain is not the source of truth for money; it’s the source of truth for where the tokenized representation is.

The AMM: An Autonomous FX Desk in 60 Lines

Now for the fun part: FX.

I implemented a simple constant-product AMM that holds two tokens — USDx as token0 and EURx as token1 — and charges a 0.1% fee:

contract PermissionedAMM is ERC20 {
    IERC20 public immutable token0; // USDx
    IERC20 public immutable token1; // EURx

    uint256 public reserve0;
    uint256 public reserve1;
    uint256 public constant FEE_BPS = 10; // 0.1% fee

    // ...
}

Liquidity providers deposit both sides:

function addLiquidity(uint256 amount0, uint256 amount1) external returns (uint256 liquidity) {
    require(amount0 > 0 && amount1 > 0, Amounts must be > 0);

    uint256 _totalSupply = totalSupply();
    if (_totalSupply == 0) {
        liquidity = sqrt(amount0 * amount1); // initial LP
    } else {
        uint256 liquidity0 = (amount0 * _totalSupply) / reserve0;
        uint256 liquidity1 = (amount1 * _totalSupply) / reserve1;
        liquidity = liquidity0 < liquidity1 ? liquidity0 : liquidity1;
    }

    token0.transferFrom(msg.sender, address(this), amount0);
    token1.transferFrom(msg.sender, address(this), amount1);

    reserve0 += amount0;
    reserve1 += amount1;

    _mint(msg.sender, liquidity);
}

And a USD→EUR swap looks like this:

function swapUSDxForEURx(uint256 amountIn, uint256 minAmountOut)
    external
    returns (uint256 amountOut)
{
    require(amountIn > 0, Amount in must be > 0);
    require(reserve0 > 0 && reserve1 > 0, Insufficient liquidity);

    uint256 amountInWithFee = amountIn * (10000 - FEE_BPS) / 10000;
    amountOut = (amountInWithFee * reserve1) / (reserve0 + amountInWithFee);

    require(amountOut >= minAmountOut, Slippage too high);
    require(amountOut < reserve1, Insufficient reserve);

    token0.transferFrom(msg.sender, address(this), amountIn);
    token1.transfer(msg.sender, amountOut);

    reserve0 += amountIn;
    reserve1 -= amountOut;
}

There’s no order book, no quotes, no request-for-streaming-price. The price is whatever the pool’s reserves say it is.

In English:

  • The AMM is a robotic FX desk.

  • Liquidity providers collectively act as the “bank.”

  • The pool’s reserves determine the price.

  • The fee (in basis points) is the spread.

In a production permissioned DeFi network, the LPs would be banks, PSPs, maybe stablecoin issuers. In my apartment, it’s just me seeding initial liquidity on Anvil.

Orchestrating a Cross-Border Payment (End-to-End)

The heart of the system is a single endpoint:

fastify.post(/payments/cross-border, async (request, reply) => {
  const {
    senderUserId,
    receiverUserId,
    fromCurrency,
    toCurrency,
    amount,
    autoOfframp = false,
  } = request.body;

  const steps: Array<{ step: string; status: string; txHash?: string; data?: any }> = [];

  // Step 1: KYC check
  // Step 2: Ensure on-chain balance (on-ramp if needed)
  // Step 3: Transfer tokens to AMM operator
  // Step 4: Approve AMM
  // Step 5: Execute AMM swap
  // Step 6: Transfer to receiver (+ optional off-ramp)
});

That steps array basically the narrative structure of the whole system.

A typical USD→EUR payment looks like this:

  1. KYC Check
    The backend checks that the sender’s kycStatus is APPROVED. If not, we stop immediately:

const sender = await prisma.user.findUnique({ where: { id: senderUserId } });

if (!sender || sender.kycStatus !== KYCStatus.APPROVED) {
  steps.push({ step: KYC Check, status: failed });
  return reply.code(400).send({ error: Sender KYC not approved, steps });
}

steps.push({ step: KYC Check, status: completed });
  1. On-Ramp (if needed)
    If the sender doesn’t have enough USDx on-chain, the backend:

    • Debits their USD fiat account

    • Mints USDx to their wallet

    and appends an ONRAMP event.

  2. Swap via AMM
    The backend (acting as an AMM operator) pulls in USDx, calls swapUSDxForEURx with slippage protection, and returns a transaction hash.

  3. Credit Receiver On-Chain
    The swapped EURx is sent to the receiver’s wallet.

  4. Optional Off-Ramp
    If autoOfframp is true, the backend burns the receiver’s EURx and credits their EUR fiat ledger.

  5. Audit Log
    For every step, an AuditLog row is written with the EventType and metadata.

On the frontend, the CrossBorderPayment component replays those steps back to the user:

{steps.length > 0 && (
  <div className=”mt-4 space-y-2>
    <h4 className=”font-semibold text-black>Payment Status:</h4>
    {steps.map((step, index) => (
      <div key={index} className=”flex items-center space-x-2>
        <span>{step.step}</span>
        {step.txHash && (
          <span className=”text-xs font-mono ml-2 break-all>
            {step.txHash}
          </span>
        )}
      </div>
    ))}
  </div>
)}

In a SWIFT world, these steps are scattered across institutions, logs, and message formats.
Here, they’re a JSON array.

That’s the promise of this model: cross-border as an explainable, observable pipeline, not a mystery.

What I Learned Building This

A few reflections after wiring all this together:

1. The diagrams understate the plumbing

On a slide, permissioned DeFi FX looks like:

“KYC → token → AMM → token → KYC.”

In code, it’s:

  • Database models for users, KYC, fiat accounts, audit logs

  • RPC clients, ABIs, chain configuration

  • Mint/burn semantics

  • Slippage and failure states

  • UI that tells a coherent story to humans

  • Error handling that doesn’t leave you with “half-completed” operations

Even in a toy system, getting a clear picture of who holds what, where was non-trivial. That gave me a new appreciation for how messy today’s cross-border stack really is.

2. AMMs make FX feel like a function call

Once the AMM was wired, doing FX felt like calling a deterministic function:

const amountOut = await blockchain.swapUsdToEur(amountIn, minOut);

Behind that, reserves move, prices update, events emit. But as a consumer, it’s:

“I gave the system X USD and got back Y EUR in a single transaction hash.”

That’s a massive mental shift from “submit instruction, wait for correspondent chain, hope nothing breaks.”

3. Tokenization reframes “reconciliation”

When tokens are minted and burned only in response to changes in a core ledger, reconciliation becomes simpler:

  • Either the token supply matches the bank’s liabilities, or it doesn’t.

  • Either this wallet holds 92 EURx, or it doesn’t.

  • Every asset is visible, on-chain, in a common format.

Reconciliation becomes an assertion against state, not an archaeological dig through siloed systems.

4. KYC wants to live in both worlds

In this prototype, KYC lives in Postgres and is enforced at the API:

if (sender.kycStatus !== KYCStatus.APPROVED) {
  // reject
}

In a real permissioned DeFi system, that probably becomes:

  • An on-chain whitelist contract,

  • Fed by off-chain KYC processes,

  • Queried by AMMs and other protocol contracts.

The hard part isn’t the whitelist contract; it’s the governance model and attestation format behind it.

What This Prototype Doesn’t Do (Yet)

To be very clear: this is a learning artifact, not a product.

It does not:

  • Price FX from real markets.. the AMM curve is math, not market-calibrated.

  • Model credit risk between institutions.

  • Implement KYT/transaction-level AML.

  • Use MPC or HSM for key management.

  • Handle multi-chain bridging or chain abstraction for real.

  • Touch real stablecoins, CBDCs, or bank integrations.

If anything, building this reminded me of the operational, legal, and regulatory work needed to ship this in production at a bank.

Why I’m Convinced This Is the Direction of Travel

Even in its toy form, this little system revealed something important:

  • Banks want predictable settlement.

  • Corporates want lower fees and fewer mysteries.

  • Regulators want visibility into flows.

  • Engineers want systems they can reason about.

A permissioned DeFi FX network with:

  • KYC-gated wallets

  • Tokenized deposits

  • AMM-based FX

  • Atomic settlement

  • On- and off-ramps into fiat ledgers

…is the first architecture I’ve seen that credibly gives all of those stakeholders what they want at once.

This prototype is just my apartment-sized version of that future. If I were to take this further, the steps for me would be:

  • Adding a real on-chain whitelist for wallets

  • Simulating multiple AMMs and simple “liquidity routing”

  • Exploring how chain abstraction would change the architecture

  • Possibly wiring in real FX price data just to see what breaks

If you’re a fintech builder, payments PM, or bank engineer, I think the right move is the same: don’t just read the whitepapers.

Try to build your own version of the future rails, even if it only runs on localhost:8545. That’s where the real understanding lives.

Enjoying this? Share it with your fintech engineering friends — and don’t forget to subscribe.

This newsletter was made possible by Trio. Trio helps fintech teams ship faster with engineers who already understand payments, compliance, and financial infrastructure.

Subscribe to Ledger Drift for high-signal insights into how modern fintech is built, from systems to code to teams.