Build a Banking Core Bridge

Step-by-step tutorial — connect your banking core to Minka Ledger

This tutorial walks you through building a bridge that connects a banking core to the Minka Ledger. By the end, you will have a working service that receives debit and credit operations from the ledger, validates them against bank accounts, and reports results through signed proofs.

The bridge uses the two-phase commit protocol described in About Orchestrating Systems. If you have not read that page, read the conceptual overview first — this tutorial focuses on implementation.

The code is written in TypeScript using the @minka/bridge-sdk and @minka/ledger-sdk libraries. You can also use plain JavaScript.

Prerequisites

This tutorial extends Cross-Ledger Payments. Complete that tutorial first to set up a ledger instance, signers, wallets, and a basic bank configuration.

You will need:

Scaffold the project

The fastest way to start is with the Minka CLI, which generates a bridge project with built-in scheduling, retries, idempotency, and persistence:

$ minka bridge init bridge@mintbank.dev
? Handle (bridge@mintbank.dev): bridge@mintbank.dev
? Signer (bridge@mintbank.dev): bridge@mintbank.dev
? Signer password for bridge@mintbank.dev: [hidden]

Target directory: <your_directory>

? Do you want to proceed? Yes

✔ Downloading bridge code... Downloaded
✔ Extracting bridge files... Downloaded
✔ Configuring environment... Configured
✔ Installing dependencies... Installed

✅ Bridge project created successfully.
To run the created bridge type the following:
 > minka bridge start

The generated project is open source. You can modify any part of it to fit your integration needs.

Project structure

├── src/
│   ├── adapters/
│   │   ├── credit.adapter.ts   # Your credit logic goes here
│   │   ├── debit.adapter.ts    # Your debit logic goes here
│   │   └── README.md
│   ├── core-sdk/               # Mock banking core (replace with yours)
│   │   ├── account.ts
│   │   ├── transaction.ts
│   │   └── index.ts
│   ├── config.ts               # Loads and validates .env
│   └── main.ts                 # Entry point — bootstraps server + processor
├── .env                        # Signers, DB connection, config
├── package.json
└── tsconfig.json

The @minka/bridge-sdk handles communication with the ledger, data persistence, idempotency, and retries. Your job is to implement the adapters — two files that map 2PC operations to your banking core.

The core-sdk/ directory contains a mock in-memory banking core for demonstration. In production, replace it with your actual banking core SDK or API client.

Configure the environment

The scaffold generates a .env file that you must populate before running the bridge. Here are the key variables:

# Ledger connection
LEDGER_SERVER=https://your-ledger-host/api/v2
LEDGER_HANDLE=your-ledger-handle

# Bridge identity
BRIDGE_HANDLE=bridge@mintbank.dev
BRIDGE_PUBLIC_KEY=<your-bridge-signer-public-key>
BRIDGE_PRIVATE_KEY=<your-bridge-signer-private-key>

# Server
PORT=4042

# Database (SQLite by default; configure Postgres for production)
DATABASE_URL=./bridge.db

The BRIDGE_PUBLIC_KEY and BRIDGE_PRIVATE_KEY are generated when you create a bridge signer with minka signer create. The LEDGER_SERVER and LEDGER_HANDLE are the connection details for your Minka Ledger instance.

Never commit .env to version control. Use your organization's secret management system in production.

Entry point (main.ts) bootstraps two components:

// Server: Express app exposing REST APIs
const server = ServerBuilder.init()
  .useDataSource({ ...dataSource, migrate: true })
  .useLedger(ledger)
  .build()

await server.start({ port: config.PORT, routePrefix: 'v2' })

// Processor: background workers for async processing
const processor = ProcessorBuilder.init()
  .useDataSource(dataSource)
  .useLedger(ledger)
  .useCreditAdapter(new CreditBankAdapter())
  .useDebitAdapter(new DebitBankAdapter())
  .build()

await processor.start({ handle })

Run the bridge locally

Start the bridge with the CLI:

$ minka bridge start
Local bridge configuration detected:
 - handle: bridge@mintbank.dev
 - server: <serverURL>
 - ledger: ach
 - registerWithLedger: true

Bridge is running on port 4042.
Waiting for new requests...

The CLI starts a local server, creates a tunnel, and registers the bridge with the ledger automatically.

Leave the bridge running and continue in a new terminal.

Implement the credit adapter

The credit adapter handles incoming money — when someone sends a payment to a wallet backed by your bridge. Open src/adapters/credit.adapter.ts.

Prepare — validate the target account

During prepare, your bridge validates that the credit operation can succeed. No money moves yet. The default adapter accepts everything — let's add real validation:

import {
  AbortResult, CommitResult, CoreAdapter,
  PrepareResult, ResultStatus, TransactionContext
} from '@minka/bridge-sdk'
import {
  BridgeAccountInactive, BridgeAccountNotFound,
  BridgeEntryRejected, LedgerAddress, LedgerAmount, LedgerSdk
} from '@minka/ledger-sdk'
import { config } from '../config'
import { coreSdk } from '../core-sdk'

export class CreditBankAdapter extends CoreAdapter {
  constructor(protected readonly ledgerSdk: LedgerSdk) {
    super()
  }

  async prepare(context: TransactionContext): Promise<PrepareResult> {
    const entry = context.entry.data

    // Parse the target address (credit = money arriving at our bank)
    const {
      schema: accountType,
      handle: accountNumber,
      domain: bank,
    } = LedgerAddress.parse(entry.target.handle)

    // Validate this intent is for our bank
    if (bank !== this.getConfiguredDomain()) {
      throw new BridgeEntryRejected('Invalid target domain.')
    }

    // Only support transactional accounts
    if (accountType !== 'tran') {
      throw new BridgeEntryRejected(
        'Only transactional accounts are supported.'
      )
    }

    // Validate currency
    if (entry.symbol.handle !== 'usd') {
      throw new BridgeEntryRejected(
        `Expected symbol usd, but got ${entry.symbol.handle}.`
      )
    }

    // Check account exists and is active
    const account = coreSdk.getAccount(accountNumber)
    if (!account) {
      throw new BridgeAccountNotFound(
        `Account not found: ${accountNumber}`
      )
    }
    if (!account.isActive()) {
      throw new BridgeAccountInactive('Account disabled.')
    }

    return { status: ResultStatus.Prepared }
  }

The Bridge SDK automatically performs technical validations — verifying data consistency and checking the ledger's signature. Your adapter only needs to implement business logic.

When validation fails, throw a typed error (BridgeAccountNotFound, BridgeEntryRejected, etc.). The SDK translates these into a failed proof with the appropriate reason code and sends it to the ledger. The ledger then aborts the entire intent.

Commit — credit the account

When commit arrives, all participants have agreed the operation can proceed. This is where you actually credit the bank account:

  async commit(context: TransactionContext): Promise<CommitResult> {
    const entry = context.entry.data
    const { handle: accountNumber } =
      LedgerAddress.parse(entry.target.handle)

    // Convert integer ledger amount to decimal
    // The divisor (100) matches the symbol's decimal places (USD has 2 decimals, so 100 = 10^2).
    // Check your symbol definition for the correct divisor — for COP with 0 decimals, use 1.
    const decimalAmount =
      await LedgerAmount.toDecimal(entry.amount, 100)

    // Credit the account in the banking core
    const transaction = coreSdk.credit(
      accountNumber,
      decimalAmount,
      `${entry.handle}-credit`,
    )

    if (transaction.status !== 'COMPLETED') {
      // Commit must not fail — suspend for manual investigation
      console.error(
        `Transaction failed in commit phase. ` +
        `Manual intervention required. ID: ${transaction.id}`
      )
      return {
        status: ResultStatus.Suspended,
        state: {
          transactionId: transaction.id,
          errorCode: transaction.errorCode,
        },
      }
    }

    return {
      status: ResultStatus.Committed,
      coreId: `${this.getConfiguredDomain()}.${transaction.id}`,
    }
  }

The coreId returned here is the banking core's transaction ID. It gets included in the proof submitted to the ledger and becomes the primary reconciliation key between the two systems.

Commit must always succeed. If the banking core returns an error, suspend processing for manual investigation rather than failing. See Handle Errors and Retries.

Abort — clean up

For credits, the prepare phase only performs validation — no money moves. So there is nothing to reverse in abort. The default implementation that confirms the abort is sufficient:

  async abort(context: TransactionContext): Promise<AbortResult> {
    return { status: ResultStatus.Aborted }
  }

  private getConfiguredDomain(): string {
    return config.BRIDGE_HANDLE.split('@')[1]
  }
}

Implement the debit adapter

The debit adapter handles outgoing money — when your bank initiates a payment. Open src/adapters/debit.adapter.ts.

The key difference from credits: debits move money in the prepare phase (deducting from the source account), while credits move money in the commit phase. This is because the debit side needs to reserve or deduct funds before the ledger can ask the credit side to prepare.

This tutorial uses immediate debit during prepare — funds are deducted as soon as prepare is called. This simplifies the implementation but means the debit happens before the credit is confirmed. An alternative approach is to place a hold during prepare (reserving funds without deducting them) and finalize the debit during commit. Use the hold pattern when your banking core supports reservations, as it provides cleaner rollback semantics.

Prepare — validate and debit

import {
  AbortResult, CommitResult, CoreAdapter,
  PrepareResult, ResultStatus, TransactionContext
} from '@minka/bridge-sdk'
import {
  BridgeAccountInactive, BridgeAccountInsufficientBalance,
  BridgeAccountNotFound, BridgeEntryRejected,
  BridgeUnexpectedCoreError, LedgerAddress,
  LedgerAmount, LedgerSdk
} from '@minka/ledger-sdk'
import { config } from '../config'
import { coreSdk } from '../core-sdk'

export class DebitBankAdapter extends CoreAdapter {
  constructor(protected readonly ledgerSdk: LedgerSdk) {
    super()
  }

  async prepare(context: TransactionContext): Promise<PrepareResult> {
    // Verify that our bridge created this intent
    try {
      await this.ledgerSdk.proofs
        .ledger()
        .expect({
          custom: { status: 'created' },
          public: config.BRIDGE_PUBLIC_KEY,
        })
        .verify(context.intent)
    } catch (error) {
      throw new BridgeEntryRejected(
        error.detail || error.message, error.custom
      )
    }

    const entry = context.entry.data
    // For debits, source is our bank's account
    const {
      schema: accountType,
      handle: accountNumber,
      domain: bank,
    } = LedgerAddress.parse(entry.source.handle)

    // Standard validations (same as credit adapter)
    if (bank !== this.getConfiguredDomain()) {
      throw new BridgeEntryRejected('Invalid source domain.')
    }
    if (accountType !== 'tran') {
      throw new BridgeEntryRejected(
        'Only transactional accounts are supported.'
      )
    }
    if (entry.symbol.handle !== 'usd') {
      throw new BridgeEntryRejected(
        `Expected symbol usd, got ${entry.symbol.handle}.`
      )
    }

    const account = coreSdk.getAccount(accountNumber)
    if (!account) {
      throw new BridgeAccountNotFound(accountNumber)
    }
    if (!account.isActive()) {
      throw new BridgeAccountInactive('Account disabled.')
    }

    // Debit the account NOW (during prepare)
    const decimalAmount =
      await LedgerAmount.toDecimal(entry.amount, 100)

    const transaction = coreSdk.debit(
      accountNumber,
      decimalAmount,
      `${entry.handle}-debit`,
    )

    if (transaction.status !== 'COMPLETED') {
      if (transaction.errorCode === '101') {
        throw new BridgeAccountInsufficientBalance(
          `Insufficient balance: ${accountNumber}`
        )
      }
      throw new BridgeUnexpectedCoreError(
        transaction.errorReason,
        { failId: transaction.errorCode }
      )
    }

    return {
      status: ResultStatus.Prepared,
      coreId: `${this.getConfiguredDomain()}.${transaction.id}`,
    }
  }

The proof verification step (ledgerSdk.proofs.verify) checks that the intent was created by your bridge's signer. This prevents unauthorized third parties from initiating debits against your accounts. For third-party payment initiation, maintain a list of authorized public keys.

Commit — no-op (already debited)

Since funds were deducted in prepare, there is nothing to do in commit. The default confirmation is enough:

  async commit(context: TransactionContext): Promise<CommitResult> {
    return { status: ResultStatus.Committed }
  }

Banks may use this endpoint to record final transaction statuses, notify users, or perform bookkeeping.

Abort — reverse the debit

If the intent is aborted after prepare, you must reverse the debit transaction. This is the most critical error-handling code in your bridge:

  async abort(context: TransactionContext): Promise<AbortResult> {
    try {
      const entry = context.entry.data
      const { handle: accountNumber } =
        LedgerAddress.parse(entry.source.handle)
      const decimalAmount =
        await LedgerAmount.toDecimal(entry.amount, 100)

      // Find the original debit transaction
      const original = coreSdk.findTransactionByToken(
        `${entry.handle}-debit`
      )

      if (original && original.status === 'COMPLETED') {
        // Reverse it by crediting the same amount back
        const reversal = coreSdk.credit(
          accountNumber,
          decimalAmount,
          `${entry.handle}-reverse-debit`,
        )

        if (reversal.status !== 'COMPLETED') {
          throw new BridgeUnexpectedCoreError(
            reversal.errorReason,
            { failId: reversal.errorCode }
          )
        }

        return {
          status: ResultStatus.Aborted,
          coreId:
            `${this.getConfiguredDomain()}.${reversal.id}`,
        }
      }

      // No original transaction found — nothing to reverse
      return { status: ResultStatus.Aborted }
    } catch (error) {
      // Abort must not fail — retry after 1 minute
      const suspendedUntil = new Date()
      suspendedUntil.setMinutes(
        suspendedUntil.getMinutes() + 1
      )
      return {
        status: ResultStatus.Suspended,
        suspendedUntil,
      }
    }
  }

  private getConfiguredDomain(): string {
    return config.BRIDGE_HANDLE.split('@')[1]
  }
}

Abort must always succeed. If the banking core is temporarily unavailable, suspend and retry. Never let an abort fail permanently — use the bridge scheduler's built-in retry mechanism for production.

Register and test

With both adapters implemented, restart the bridge:

$ minka bridge start

The bridge registers itself with the ledger automatically. To test, create a payment intent that involves your bridge's wallet:

$ minka intent create -a
? Handle: testIntent001
? Schema: transfer
? Action: transfer
? Source: svgs:1001001212@teslabank.io
? Target: tran:1001001001@mintbank.dev
? Symbol: usd
? Amount: 4
? Signers: teslabank

✅ Intent signed and sent to ledger
Intent status: pending

In the bridge console, you should see the prepare-credit and commit-credit flow:

prepare-credit for intent testIntent001 received:
 - Source: svgs:1001001212@teslabank.io
 - Target: tran:1001001001@mintbank.dev
 - Amount: $4.00

Credit prepared, reply sent to ledger

commit-credit for intent testIntent001 received:
Transaction successful, new balance: 4.00
Credit committed, reply sent to ledger
Core Id: mintbank.dev.7294729

To test error handling, try sending to an inactive account (tran:1001001004@mintbank.dev) — the prepare should fail and the intent should be aborted.

Production considerations

The bridge built in this tutorial demonstrates the integration pattern but is not production-ready. Before deploying:

Replace the mock banking core with your actual core banking SDK or API client. Map internal error codes to the ledger's error taxonomy (BridgeAccountNotFound, BridgeAccountInsufficientBalance, etc.).

Implement robust retry logic for commit and abort operations. The demo uses simple 1-minute suspensions. Production bridges should use exponential backoff with configurable limits. See Handle Errors and Retries.

Secure your secrets. The .env file contains private keys and passwords. Use your organization's secret management tooling (vault, KMS, etc.) and never commit .env to version control.

Set up monitoring and alerting for suspended operations — these indicate commit or abort failures that require investigation.

Run performance tests. The async processing model scales well, but your banking core integration will have its own throughput limits. Test under realistic load before going live.

Configure authentication for ledger-to-bridge communication. See Bridge Authentication for header-based, OAuth2, and mTLS options.

On this page