Minka Ledger Docs
TutorialsAdvanced Bank Integrations

Bank integration tutorial (open banking) with Bridge SDK

DateResponsibleChanges
April 19, 2023@Branko DurdevicInitial version
May 22, 2023@Omar MonterreyRemoved schema: rest from bridge summary when updating
June 1, 2023@Branko DurdevicFixed inconsistency
June 1, 2023@Juan Pablo OttavianoFixed type imports from @minka/bridge-sdk
June 2, 2023@Juan Pablo OttavianoFixed Typescript casts and update sdk types.
October 21, 2024@Paula CastellanosRemoved one paragraph and removed keys from .env file and explained how to get the data.

Introduction

This tutorial shows you how to connect a bank to a cloud based ACH network built using the Minka Ledger. For this purpose we will be implementing a bridge service which connects the bank’s core systems with the cloud Ledger. We will be using the @minka/bridge-sdk library in order to make the integration process faster.

Like always when building a production-ready service, follow your usual best practices on application design and security.

The code is written using TypeScript, but you can also use JavaScript.

Here is a diagram that will help you understand the role of the Bridge in the overall architecture of the system.

Setup

Prerequisites

We will need the following tools in order to develop and run the demo. You may already have some of them installed from the Making cross-ledger payments.

This tutorial is an extension of the Making cross-ledger payments . If you have not already, please complete it first to get familiar with basic Ledger concepts. The following steps assume that you already have a bank setup which is done as part of the previous tutorial.

Node.js and npm

https://nodejs.org/en/download/

Minka CLI tools

https://www.npmjs.com/package/@minka/cli

After installing Node.js and npm, you can install Minka CLI tools by running the following command.

$ npm install -g @minka/cli

Docker

https://docs.docker.com/get-docker/

Ngrok can be used to host and test the API service.

https://ngrok.com/docs/getting-started

If you are on a Mac and have brew installed, you can just run the command below. Otherwise, check out the getting started docs above

$ brew install ngrok/ngrok/ngrok

Curl will be used to test the API, but you can also use Postman or another tool you prefer.

Sending money from account to account

Setting up the Bridge service

The simplest case of sending money between two banks is a direct account to account transfer.

This means that we know both the source and target account number and the corresponding banks. An account in this context represents any account whose balance is managed outside of the ledger also known as external accounts.

This includes checking and savings accounts as well as loan and credit card accounts. Of course, other types of products are also possible. You can find more details in the reference documentation.

We will now start implementing the Bridge component that we will use to communicate with the Ledger. For now, payment initiation is going to be performed using the CLI tool.

Setup

You will need to have the NodeJS and npm prerequisites installed locally.

First, we will create an empty nodeJS project with a src directory.

$ mkdir demo-bridge
$ cd demo-bridge
$ mkdir src
$ npm init -y

Now that we have an empty node project, the next step is to install typescript and ts-node.

We can do that using npm.

$ npm install -g typescript ts-node

We will now initialize the TypeScript project:

$ tsc --init

Now, you should have a file tsconfig.json make sure that "target" field is "es2018" , if not, please update it. This version is required for regular expressions in the extractor.ts

Now we can test if everything is setup correctly by creating a simple hello world route in src/main.ts.

console.log(`Demo bank running`)

To test our code we need to start the service.

$ ts-node src/main.ts
Demo bank running

If you want to avoid having to restart the service after every change, you can install a tool like nodemon that will monitor your project for changes and automatically restart the service.

To install nodemon run:

$ npm install -g nodemon

Then you should run the application using:

$ nodemon src/main.ts

Setting up Bridge SDK

To develop our bridge service, we will be using the @minka/bridge-sdk library. It uses a PostgreSQL database so let’s start by setting it up. Let’s start by creating a docker-compose.yaml file with the following contents:

version: '3.3'
 
volumes:
  postgres-bridge-volume:
 
services:
  postgres-bridge:
    restart: always
    image: postgres:15.1-alpine
    volumes:
      - postgres-bridge-volume:/var/lib/postgresql/data
    ports:
      - '5433:5432'
    environment:
      - POSTGRES_USER=bridge-service
      - POSTGRES_PASSWORD=bridge-service
      - POSTGRES_DB=bridge-service

We will use the following command to start our database:

$ docker compose up

To stop it, just stop press Ctrl + C. If you want to continue using the same terminal, you can run the same command and add the -d flag to detach the container from the terminal. In that case, in order to stop the container, you will need to run docker compose down. The docker commands need to be run in the same directory where docker-compose.yaml is located, otherwise, you will need to use the -f filename argument.

Now that we have the database up and running, let’s set up a simple bridge service with default endpoints. We will first need to install the @minka/bridge-sdk and @minka/ledger-sdk libraries.

$ npm install @minka/bridge-sdk
$ npm install @minka/ledger-sdk

Since the library takes care of almost everything for us, the only thing that is left for us to do is to configure it. We will do that by using environment variables and the envalid library. To make things easier, we will store the environment variables in a .env file and use the dotenv library to load it. Let’s first install the libraries:

$ npm install envalid dotenv

We will now create the src/config.ts file and add the following contents:

import { cleanEnv, host, num, port, str } from 'envalid'
 
export const config = cleanEnv(process.env, {
  BRIDGE_PUBLIC_KEY: str({ desc: 'Bridge public key' }),
  BRIDGE_SECRET_KEY: str({ desc: 'Bridge private key' }),
  LEDGER_HANDLE: str({ desc: 'Ledger name' }),
  LEDGER_SERVER: str({ desc: 'Ledger URL' }),
  LEDGER_PUBLIC_KEY: str({ desc: 'Ledger public key' }),
  PORT: port({ default: 3000, desc: 'HTTP listen port' }),
  TYPEORM_HOST: host({ desc: 'Database connection host' }),
  TYPEORM_PORT: port({ desc: 'Database connection port' }),
  TYPEORM_USERNAME: str({ desc: 'Database connection username' }),
  TYPEORM_PASSWORD: str({ desc: 'Database connection password' }),
  TYPEORM_DATABASE: str({ desc: 'Database name' }),
  TYPEORM_CONNECTION_LIMIT: num({
    default: 100,
    desc: 'Database connections limit',
  }),
})

We should also create the src/.env file with all the necessary environment variables:

LEDGER_HANDLE=[ledger name]
LEDGER_SERVER=[ledger server]
LEDGER_PUBLIC_KEY="" #SYSTEM
BRIDGE_PUBLIC_KEY="" 
BRIDGE_SECRET_KEY="" 
PORT=3001
TYPEORM_HOST=127.0.0.1
TYPEORM_PORT=5433
TYPEORM_USERNAME=bridge-service
TYPEORM_PASSWORD=bridge-service
TYPEORM_DATABASE=bridge-service
TYPEORM_CONNECTION_LIMIT=100

To get LEDGER_PUBLIC_KEY value, execute:

minka signer show system

If you used the CLI to generate bridge public and private keys, execute the following command to get BRIDGE_PUBLIC_KEY and BRIDGE_SECRET_KEY values

minka signer show [signer] -s

Now we can edit src/main.ts to configure the Bridge SDK. The code is pretty self-explanatory, it loads the configuration for the service and uses it to build the ServerService and ProcessorService from @minka/bridge-sdk. The ServerService will create all the necessary endpoints that we need and accept processing requests from Ledger. The ProcessorService will process those requests and notify Ledger of the results.

// load .env file
import dotenv from 'dotenv'
dotenv.config({ path: `${__dirname}/.env` })
 
// parse and validate configuration
import { config } from './config'
 
import {
  DataSourceOptions,
  LedgerClientOptions,
  ProcessorBuilder,
  ProcessorOptions,
  ServerBuilder,
  ServerOptions,
} from '@minka/bridge-sdk'
 
// import our adapters
import { SyncCreditBankAdapter } from './adapters/credit.adapter'
import { SyncDebitBankAdapter } from './adapters/debit.adapter'
 
import sleep from 'sleep-promise'
 
// set up data source configuration
const dataSource: DataSourceOptions = {
  host: config.TYPEORM_HOST,
  port: config.TYPEORM_PORT,
  database: config.TYPEORM_DATABASE,
  username: config.TYPEORM_USERNAME,
  password: config.TYPEORM_PASSWORD,
  connectionLimit: config.TYPEORM_CONNECTION_LIMIT,
  migrate: false,
}
 
// set up ledger configuration
const ledger: LedgerClientOptions = {
  ledger: {
    handle: config.LEDGER_HANDLE,
    signer: {
      format: 'ed25519-raw',
      public: config.LEDGER_PUBLIC_KEY,
    },
  },
  server: config.LEDGER_SERVER,
  bridge: {
    signer: {
      format: 'ed25519-raw',
      public: config.BRIDGE_PUBLIC_KEY,
      secret: config.BRIDGE_SECRET_KEY,
    },
    handle: 'mint',
  },
}
 
// configure server for bridge service
const bootstrapServer = async () => {
  const server = ServerBuilder.init()
    .useDataSource({ ...dataSource, migrate: true })
    .useLedger(ledger)
    .build()
 
  const options: ServerOptions = {
    port: config.PORT,
    routePrefix: 'v2',
  }
 
  await server.start(options)
}
 
// configure processor for bridge-service
const bootstrapProcessor = async (handle: string) => {
  const processor = ProcessorBuilder.init()
    .useDataSource(dataSource)
    .useLedger(ledger)
    .useCreditAdapter(new SyncCreditBankAdapter())
    .useDebitAdapter(new SyncDebitBankAdapter())
    .build()
 
  const options: ProcessorOptions = {
    handle
  }
 
  await processor.start(options)
}
 
// configure Bridge SDK
const boostrap = async () => {
  const processors = ['proc-0']
 
  await bootstrapServer(processors)
 
  // wait for migrations to execute
  await sleep(2000)
 
  for (const handle of processors) {
    await bootstrapProcessor(handle)
  }
}
 
boostrap()

Bridge Interface

We will build our bridge in this tutorial by adding functionality as we need it, step by step. Just to have a high level overview of what we will get in the end, here are all the endpoints that we will implement:

EndpointRequestDescription
POST /v2/creditsprepareCalled during two-phase commit when Bridge needs to prepare a credit Entry. The Bridge should check if the account exists, is active and do other checks necessary to ensure the transfer intent can proceed.
POST /v2/credits/:handle/commitcommitCalled during two-phase commit when Bridge needs to commit a credit Entry. This request must succeed and is considered successful even if there is an error while processing. In that case, the Bank needs to fix the issue manually.
POST /v2/credits/:handle/abortabortCalled during two-phase commit when Bridge needs to abort a credit Entry. This request gets called if there is an issue while processing the transfer. It can not fail just like commit and must completely reverse the transfer.
POST /v2/debitsprepareCalled during two-phase commit when Bridge needs to prepare a debit Entry. Same as for credit, but needs to also hold or reserve the necessary funds on source account.
POST /v2/debits/:handle/commitcommitCalled during two-phase commit when Bridge needs to commit a debit Entry. Same as for credit.
POST /v2/debits/:handle/abortabortCalled during two-phase commit when Bridge needs to abort a debit Entry. Same as for credit, but potentially also needs to release the funds.
PUT /v2/intents/:handleupdateCalled during two-phase commit when the Intent state updates. This can be used to update the Intent in local database so the Bank knows when it is fully processed.

As you can see, the Bridge side of the Ledger - Bridge interface consists of 3 main routes and 7 endpoints in total. We will explore them one at a time in the next few sections.

Next, we are going to create two adapters, they contain the only custom logic we need to implement to connect to our core. Let’s create them and only log incoming requests for now.

$ mkdir adapters
import {
  AbortResult,
  CommitResult,
  IBankAdapter,
  ResultStatus,
  PrepareResult,
  TransactionContext,
} from '@minka/bridge-sdk'
import { LedgerErrorReason } from '@minka/bridge-sdk/errors'
 
import * as extractor from '../extractor'
import core from '../core'
 
export class SyncCreditBankAdapter extends IBankAdapter {
  prepare(context: TransactionContext): Promise<PrepareResult> {
    console.log('RECEIVED POST /v2/credits')
 
    let result: PrepareResult
 
    result = {
      status: ResultStatus.Prepared,
    }
 
    return Promise.resolve(result)
  }
 
  abort(context: TransactionContext): Promise<AbortResult> {
    console.log('RECEIVED POST /v2/credits/abort')
 
    let result: AbortResult 
 
    result = {
      status: ResultStatus.Aborted,
    }
 
    return Promise.resolve(result)
  }
 
  commit(context: TransactionContext): Promise<CommitResult> {
    console.log('RECEIVED POST /v2/credits/commit')
 
    let result: CommitResult
 
    result = {
      status: ResultStatus.Committed,
    }
 
    return Promise.resolve(result)
  }
  
  getEntry(context: TransactionContext):{schema:string,target:string, source:string, amount:number,symbol:string}{
    return {
        schema: context.entry.schema,
        target: context.entry.target!.handle,
        source: context.entry.source!.handle,
        amount: context.entry.amount,
        symbol: context.entry.symbol.handle
        }
}
}
import {
  AbortResult,
  CommitResult,
  IBankAdapter,
  ResultStatus,
  PrepareResult,
  TransactionContext,
} from '@minka/bridge-sdk'
import { LedgerErrorReason } from '@minka/bridge-sdk/errors'
 
import * as extractor from '../extractor'
import core from '../core'
 
export class SyncDebitBankAdapter extends IBankAdapter {
  prepare(context: TransactionContext): Promise<PrepareResult> {
    console.log('RECEIVED POST /v2/debits')
 
    let result: PrepareResult
 
    result = {
      status: ResultStatus.Prepared,
    }
 
    return Promise.resolve(result)
  }
 
  abort(context: TransactionContext): Promise<AbortResult> {
    console.log('RECEIVED POST /v2/debits/abort')
 
    let result: AbortResult
 
    result = {
      status: ResultStatus.Aborted,
    }
 
    return Promise.resolve(result)
  }
 
  commit(context: TransactionContext): Promise<CommitResult> {
    console.log('RECEIVED POST /v2/debits/commit')
 
    let result: CommitResult
 
    result = {
      status: ResultStatus.Committed,
    }
 
    return Promise.resolve(result)
  }
  
 getEntry(context: TransactionContext):{schema:string,target:string, source:string, amount:number,symbol:string}{
      return {
          schema: context.entry.schema,
          target: context.entry.target!.handle,
          source: context.entry.source!.handle,
          amount: context.entry.amount,
          symbol: context.entry.symbol.handle
          }
  }
}

The log is here just so that it looks the same as the old tutorial, the library has its own logging which is disabled on this branch.

We are just logging that the request happened and suspending further processing. We will build out the methods one at a time, however, before we do that we will need a mock banking core and some helper functions to extract all the data we need from requests.

Simulate banking core

To simulate a banking core we will create a simple module that will hold balances of client accounts.

It will have similar high-level concepts as a real ledger, but is rudimentary and just for demonstration purposes.

In the real world this would be replaced by API calls to the actual core.

The internals of this module are not important so feel free to copy/paste it. The thing to note is that this is an in-memory mock ledger with some accounts set up in advance that will enable us to simulate different cases.

It exposes a few methods and will throw errors in case of eg. insufficient balance. The credit and debit methods credit and debit client accounts respectively. The hold method will hold client balances so that they can’t be spent and release will make them available for spending. We will later use the preconfigured accounts in various scenarios.

export class CoreError extends Error {
    protected code: string
  
    constructor(message: string) {
      super(message)
      this.name = 'CoreError'
      this.code = '100'
    }
  }
  
  export class InsufficientBalanceError extends CoreError {
    constructor(message: string) {
      super(message)
      this.name = 'InsufficientBalanceError'
      this.code = '101'
    }
  }
  
  export class InactiveAccountError extends CoreError {
    constructor(message: string) {
      super(message)
      this.name = 'InactiveAccountError'
      this.code = '102'
    }
  }
  
  export class UnknownAccountError extends CoreError {
    constructor(message: string) {
      super(message)
      this.name = 'UnknownAccountError'
      this.code = '103'
    }
  }
  
  export class Account {
    public id: string
    public active: boolean
    public balance: number
    public onHold: number
  
    constructor(id: string, active = true) {
      this.id = id
      this.active = active
      this.balance = 0
      this.onHold = 0
    }
  
    debit(amount: number) {
      this.assertIsActive()
  
      if (this.getAvailableBalance() < amount) {
        throw new InsufficientBalanceError(
          `Insufficient available balance in account ${this.id}`,
        )
      }
  
      this.balance = this.balance - amount
    }
  
    credit(amount: number) {
      this.assertIsActive()
  
      this.balance = this.balance + amount
    }
  
    hold(amount: number) {
      this.assertIsActive()
  
      if (this.getAvailableBalance() < amount) {
        throw new InsufficientBalanceError(
          `Insufficient available balance in account ${this.id}`,
        )
      }
  
      this.onHold = this.onHold + amount
    }
  
    release(amount: number) {
      this.assertIsActive()
  
      if (this.onHold < amount) {
        throw new InsufficientBalanceError(
          `Insufficient balance on hold in account ${this.id}`,
        )
      }
  
      this.onHold = this.onHold - amount
    }
  
    getOnHold() {
      return this.onHold
    }
  
    getBalance() {
      return this.balance
    }
  
    getAvailableBalance() {
      return this.balance - this.onHold
    }
  
    isActive() {
      return this.active
    }
  
    assertIsActive() {
      if (!this.active) {
        throw new InactiveAccountError(`Account ${this.id} is inactive`)
      }
    }
  
    setActive(active: boolean) {
      this.active = active
    }
  }
  
  export class Transaction {
    public id: string
    public type: string
    public account: string
    public amount: number
    public status: string
    public idempotencyToken?: string
    public errorReason?: string
    public errorCode?: string
  
    constructor({ id, type, account, amount, status, idempotencyToken }: {
        id: string
        type: string
        account: string
        amount: number
        status: string
        idempotencyToken?: string
    }) {
      this.id = id
      this.type = type
      this.account = account
      this.amount = amount
      this.status = status
      this.errorReason = undefined
      this.errorCode = undefined
      this.idempotencyToken = idempotencyToken
    }
  }
  
  export class Ledger {
    accounts = new Map<string,Account>()
    transactions: Transaction[] = []
  
    constructor() {
      // account with no balance
      this.accounts.set('1', new Account('1'))
  
      // account with available balance 70
      this.accounts.set('2', new Account('2'))
      this.credit('2', 100)
      this.debit('2', 10)
      this.hold('2', 20)
  
      // account with no available balance 0
      this.accounts.set('3', new Account('3'))
      this.credit('3', 300)
      this.debit('3', 200)
      this.hold('3', 100)
  
      // inactive account
      this.accounts.set('4', new Account('4'))
      this.credit('4', 200)
      this.debit('4', 20)
      this.inactivate('4')
    }
  
    getAccount(accountId: string) {
      const account = this.accounts.get(accountId)
      if (!account) {
        throw new UnknownAccountError(`Account ${accountId} does not exist`)
      }
      return account
    }
  
    processTransaction(type: string, accountId: string, amount: number, idempotencyToken?: string) {
      if (idempotencyToken) {
        const existing = this.transactions.filter(
          (t) => t.idempotencyToken === idempotencyToken,
        )[0]
        if (existing) {
          return existing
        }
      }
  
      const nextTransactionId = this.transactions.length
      const transaction = new Transaction({
        id: nextTransactionId.toString(),
        type,
        account: accountId,
        amount,
        status: 'PENDING',
        idempotencyToken,
      })
      this.transactions[nextTransactionId] = transaction
      try {
        const account = this.getAccount(accountId)
        switch (type) {
          case 'CREDIT':
            account.credit(amount)
            break
          case 'DEBIT':
            account.debit(amount)
            break
          case 'HOLD':
            account.hold(amount)
            break
          case 'RELEASE':
            account.release(amount)
            break
        }
      } catch (error: any) {
        transaction.errorReason = error.message
        transaction.errorCode = error.code
        transaction.status = 'FAILED'
        return transaction
      }
      transaction.status = 'COMPLETED'
      return transaction
    }
  
    credit(accountId: string, amount: number, idempotencyToken?: string) {
      return this.processTransaction(
        'CREDIT',
        accountId,
        amount,
        idempotencyToken,
      )
    }
  
    debit(accountId: string, amount: number, idempotencyToken?: string) {
      return this.processTransaction('DEBIT', accountId, amount, idempotencyToken)
    }
  
    hold(accountId: string, amount: number, idempotencyToken?: string) {
      return this.processTransaction('HOLD', accountId, amount, idempotencyToken)
    }
  
    release(accountId: string, amount: number, idempotencyToken: string) {
      return this.processTransaction(
        'RELEASE',
        accountId,
        amount,
        idempotencyToken,
      )
    }
  
    activate(accountId: string) {
      return this.getAccount(accountId).setActive(true)
    }
  
    inactivate(accountId: string) {
      return this.getAccount(accountId).setActive(false)
    }
  
    printAccountTransactions(accountId: string) {
      console.log(
        `Id\t\tType\t\tAccount\t\tAmount\t\tStatus\t\t\tError Reason\t\tIdempotency Token`,
      )
      this.transactions
        .filter((t) => t.account === accountId)
        .forEach((t) =>
          console.log(
            `${t.id}\t\t${t.type}\t\t${t.account}\t\t${t.amount}\t\t${
              t.status
            }\t\t${t.errorReason || '-'}\t\t${t.idempotencyToken || '-'}`,
          ),
        )
    }
  
    printAccount(accountId: string) {
      const account = this.getAccount(accountId)
      console.log(
        JSON.stringify(
          {
            ...account,
            balance: account.getBalance(),
            availableBalance: account.getAvailableBalance(),
          },
          null,
          2,
        ),
      )
    }
  }
  
  const ledger = new Ledger()
  
  export default ledger

Extracting request data

To extract data from requests, we will use a couple of functions which we will put into src/extractor.ts. Of course request data should be validated as well.

// Populate this with the wallet handle you created, should be env var
const BANK_WALLET = "mint";
 
// Factor for usd is 100
const USD_FACTOR = 100;
 
// Address regex used for validation and component extraction
const ADDRESS_REGEX =
  /^(((?<schema>[a-zA-Z0-9_\-+.]+):)?(?<handle>[a-zA-Z0-9_\-+.]+))(@(?<parent>[a-zA-Z0-9_\-+.]+))?$/;
 
export function extractAndValidateAddress(address: string) {
  const result = ADDRESS_REGEX.exec(address);
  if (!result?.groups) {
    throw new Error(`Invalid address, got ${address}`);
  }
  const { schema, handle: account, parent } = result.groups;
 
  if (parent !== BANK_WALLET) {
    throw new Error(
      `Expected address parent to be ${BANK_WALLET}, got ${parent}`
    );
  }
  if (schema !== "account") {
    throw new Error(`Expected address schema to be account, got ${schema}`);
  }
  if (!account || account.length === 0) {
    throw new Error("Account missing from credit request");
  }
 
  return {
    schema,
    account,
    parent,
  };
}
 
export function extractAndValidateAmount(rawAmount: number) {
  const amount = Number(rawAmount);
  if (!Number.isInteger(amount) || amount <= 0) {
    throw new Error(`Positive integer amount expected, got ${amount}`);
  }
  return amount / USD_FACTOR;
}
 
export function extractAndValidateSymbol(symbol: string) {
  // In general symbols other than usd are possible, but
  // we only support usd in the tutorial
  if (symbol !== "usd") {
    throw new Error(`Symbol usd expected, got ${symbol}`);
  }
  return symbol;
}
 
export function extractAndValidateData(entry: {
  schema: string;
  target: string;
  source: string;
  amount: number;
  symbol: string;
}) {
  const rawAddress = entry?.schema === "credit" ? entry.target : entry.source;
	if(!rawAddress) {
    throw new Error("Address missing from entry");
  }
  const address = extractAndValidateAddress(rawAddress);
  const amount = extractAndValidateAmount(entry.amount);
  const symbol = extractAndValidateSymbol(entry.symbol);
 
  return {
    address,
    amount,
    symbol,
  };
}

Processing a credit Entry

Now that we are set up, we can create an intent and we should see some requests coming from the Ledger. Here we will use the signer, wallet and bridge that we created in the previous tutorial. We will use the same mint signer, wallet and bridge that you created in the previous tutorial and we will start by registering the Bridge with the Ledger. It should already exist so we will just update the URL to point to us. Before that, however, you will need a public URL that Ledger can target. If you don’t have one, you can use ngrok.

Start by opening a dedicated terminal for ngrok and running the following command.

$ ngrok http 3001
 
ngrok (Ctrl+C to quit)
 
Check which logged users are accessing your tunnels in real time https://ngrok.com...
 
Session Status                online
Account                       <user account>
Version                       3.1.1
Region                        Europe (eu)
Latency                       32ms
Web Interface                 http://127.0.0.1:4040
Forwarding                    <bridge URL> -> http://localhost
 
Connections                   ttl     opn     rt1     rt5     p50     p90
                              0       0       0.00    0.00    0.00    0.00

If this is not your first time running ngrok, it is still possible that your URL changed so please check because you may need to update it anyway.

Leave ngrok running and continue the tutorial in a new terminal. You should now have three terminal windows open, one for the Bridge service, another one for ngrok and a new one for CLI commands.

Next, run the following command, substituting mint for your own bridge. When prompted, update the Server field and set it to <bridge URL> from the command above.

$ minka bridge update mint
 
Bridge summary:
---------------------------------------------------------------------------
Handle: mint
Server: http://old.url:1234
 
Access rules:
#0
  - Action: any
  - Signer: 3wollw7xH061u4a+BZFvJknGeJcY1wKuhbWA3/0ritM=
#1
  - Action: any
  - Bearer:
    - sub: 3wollw7xH061u4a+BZFvJknGeJcY1wKuhbWA3/0ritM=
 
Updates:
------------------------------------------
? Select the field to update, select Finish to save changes. Server
? Server: <bridge URL>
? Select the field to update, select Finish to save changes. Finish
Updates received.
? Signer: mint
 
✅ Bridge updated successfully:
Handle: mint
Signer: 3wollw7xH061u4a+BZFvJknGeJcY1wKuhbWA3/0ritM= (mint)

We will now finally create an intent using the CLI tool like this.

$ minka intent create
? Handle: tfkPgUiuUjQjRB9KJ
? Action: transfer
? Source: account:73514@tesla
? Target: account:1@mint
? Symbol: usd
? Amount: 10
? Add another action? No
? Add custom data? No
? Signers: tesla
? Key password for tesla: [hidden]
 
Intent summary:
------------------------------------------------------------------------
Handle: tfkPgUiuUjQjRB9KJ
 
Action: transfer
 - Source: account:73514@tesla
 - Target: account:1@mint
 - Symbol: usd
 - Amount: $10.00
 
? Sign this intent using signer tesla? Yes
 
✅ Intent signed and sent to ledger sandbox
Intent status: pending

We should see the first Entry record in Bridge logs. In all of the methods we are implementing, you can log the context to see more details or just context.entry and context.command to see the requests.

RECEIVED POST /v2/credits

Processing requests

We see that we got a request to the POST /v2/credits endpoint.

This request is part of the Ledger two-phase commit protocol, please read more about it in About Intents.

The Bridge SDK does most of the heavy lifting for us:

  • saving the request
  • idempotency
  • some errors
  • validation of signatures
  • notifying ledger of entry processing
  • retries
  • coordinating request processing
  • process final intent updates

We will only need to write 6 methods to actually process the request in the banking core. It is possible to have both sync and async request handlers, but we will stick with sync for this tutorial.

We need to prepare for crediting the client account which involves things like checking that it exists, that it’s active and similar in the banking core.

In case of debit, we will also need to check that the client has enough funds and put a hold on those funds.

After a prepare succeeds, we are no longer allowed to fail on commit. At that point the responsibility to commit or abort the transfer is passed on to Ledger.

Credit Entry

The already existing code will always be grayed out in this tutorial and unless otherwise specified, you just need to add the new code in red.

Let’s start with updating the prepare credit function. We need to add the following code.

// add these imports
import { LedgerErrorReason } from '@minka/errors'
 
import * as extractor from '../extractor'
import core from '../core'  
 
  prepare(context: TransactionContext): Promise<PrepareResult> {
    console.log('RECEIVED POST /v2/debits')
 
    let result: PrepareResult
      try {
        const extractedData = extractor.extractAndValidateData(this.getEntry(context))
 
      const coreAccount = core.getAccount(
        extractedData.address?.account,
      )
      coreAccount.assertIsActive()
 
      result = {
        status: ResultStatus.Prepared,
      }
    } catch (e: any) {
      result = {
        status: ResultStatus.Failed,
        error: {
          reason: LedgerErrorReason.BridgeUnexpectedCoreError,
          detail: e.message,
          failId: undefined,
        },
      }
    }
 
    return Promise.resolve(result)
  }

Now when we run Test 1 again, we should get:

RECEIVED POST /v2/credits
SENT signature to Ledger
{
  "handle": "cre_xzeDmFpyR5_GHWO35",
  "status": "prepared",
  "moment": "2023-03-09T08:39:54.932Z"
}
RECEIVED POST /v2/credits/cre_xzeDmFpyR5_GHWO35/commit

Commit credit

We have successfully processed the prepare request for a credit Entry and Ledger continued processing up to the point where we got POST /v2/credits/:handle/commit. Now we will need to implement the commit request to continue.

We will use the same pattern as we did to process prepare so we will edit the commit credit function accordingly.

  commit(context: TransactionContext): Promise<CommitResult> {
    console.log('RECEIVED POST /v2/credits/commit')
 
    let result: CommitResult
    let transaction
    try {
      const extractedData = extractor.extractAndValidateData(this.getEntry(context))
 
      transaction = core.credit(
        extractedData.address.account,
        extractedData.amount,
        `${context.entry.handle}-credit`,
      )
 
      if (transaction.status !== 'COMPLETED') {
        throw new Error(transaction.errorReason)
      }
 
      result = {
        status: ResultStatus.Committed,
        coreId: transaction.id.toString(),
      }
    } catch (e) {
      result = {
        status: ResultStatus.Suspended,
      }
    }
 
    return Promise.resolve(result)
  }

As you know from About Intents, we are not permitted to fail the commit request so if something happens during processing, we just suspend processing for now.

For all intents and purposes, the Entry is already committed when we get the commit, it was just not yet processed by the Bank.

We can now run Test 1 again and we should get something like this.

RECEIVED POST /v2/credits
SENT signature to Ledger
{
  "handle": "cre_iJnI2KGWCI7CIfeWL",
  "status": "prepared",
  "moment": "2023-03-09T09:13:22.725Z"
}
RECEIVED POST /v2/credits/cre_iJnI2KGWCI7CIfeWL/commit
SENT signature to Ledger
{
  "handle": "cre_iJnI2KGWCI7CIfeWL",
  "status": "committed",
  "coreId": "8",
  "moment": "2023-03-09T09:13:23.201Z"
}

At this point, Ledger will also update the intent with final status using PUT /v2/intents/:handle, but we don’t see that here because the SDK handles it for us.

Processing a debit Entry

Now we will do the same thing we did above, but for debit endpoints. Lets start with the prepare debit handler.

  prepare(context: TransactionContext): Promise<PrepareResult> {
    console.log('RECEIVED POST /v2/debits')
 
    let result: PrepareResult
    let transaction
 
    try {
      const extractedData = extractor.extractAndValidateData(this.getEntry(context))
 
      transaction = core.hold(
        extractedData.address.account,
        extractedData.amount,
        `${context.entry.handle}-hold`,
      )
 
      if (transaction.status !== 'COMPLETED') {
        throw new Error(transaction.errorReason)
      }
 
      result = {
        status: ResultStatus.Prepared,
        coreId: transaction.id.toString(),
      }
    } catch (e: any) {
      result = {
        status: ResultStatus.Failed,
        error: {
          reason: LedgerErrorReason.BridgeUnexpectedCoreError,
          detail: e.message,
          failId: undefined,
        },
      }
    }
 
    return Promise.resolve(result)
  }

You can notice that processing for the prepare action is a little more complex than that of the credit counterpart because we have to hold the funds.

If we run into issues, we set the state to failed and notify Ledger which will initiate an abort. If we instead notify Ledger that we are prepared, we can no longer fail when we get the commit action.

Prepare debit test

To test the code above, we will create a new intent that will send money from account:1@mint to account:73514@tesla.

$ minka intent create
? Handle: yKnJ6gRKEy5voiXb6
? Action: transfer
? Source: account:2@mint
? Target: account:73514@tesla
? Symbol: usd
? Amount: 10
? Add another action? No
? Add custom data? No
? Signers: mint
? Key password for tesla: [hidden]
 
Intent summary:
------------------------------------------------------------------------
Handle: yKnJ6gRKEy5voiXb6
 
Action: transfer
 - Source: account:2@mint
 - Target: account:73514@tesla
 - Symbol: usd
 - Amount: $10.00
 
? Sign this intent using signer mint? Yes
 
✅ Intent signed and sent to ledger sandbox
Intent status: pending

We should now be seeing something like this.

RECEIVED POST /v2/debits
SENT signature to Ledger
{
  "handle": "deb_eqBQaDkpBxymG21hU",
  "status": "prepared",
  "coreId": "9",
  "moment": "2023-03-09T09:28:06.044Z"
}
RECEIVED POST /v2/debits/deb_eqBQaDkpBxymG21hU/commit

Commit debit

We will now implement the commit debit endpoint.

commit(context: TransactionContext): Promise<CommitResult> {
    console.log('RECEIVED POST /v2/debits/commit')
 
    let result: CommitResult
    let transaction
 
    try {
      const extractedData = extractor.extractAndValidateData(context.entry)
 
      transaction = core.release(
        extractedData.address.account,
        extractedData.amount,
        `${context.entry.handle}-release`,
      )
 
      if (transaction.status !== 'COMPLETED') {
        throw new Error(transaction.errorReason)
      }
 
      transaction = core.debit(
        extractedData.address.account,
        extractedData.amount,
        `${context.entry.handle}-debit`,
      )
 
      if (transaction.status !== 'COMPLETED') {
        throw new Error(transaction.errorReason)
      }
 
      result = {
        status: ResultStatus.Committed,
        coreId: transaction.id.toString(),
      }
    } catch (e) {
      result = {
        status: ResultStatus.Suspended,
      }
    }
 
    return Promise.resolve(result)
  }

Again, you can see that the processing for commit debit is a little more complex than for commit credit because we first need to release the funds and then execute a debit operation. Depending on how transfer orders are implemented, it is possible that in an real core this may be a single operation.

After running Test 2 again, we should get the following.

RECEIVED POST /v2/debits
SENT signature to Ledger
{
  "handle": "deb_dd6AXq9DBtm2fdtaK",
  "status": "prepared",
  "coreId": "8",
  "moment": "2023-03-09T09:38:43.852Z"
}
RECEIVED POST /v2/debits/deb_dd6AXq9DBtm2fdtaK/commit
SENT signature to Ledger
{
  "handle": "deb_dd6AXq9DBtm2fdtaK",
  "status": "committed",
  "coreId": "10",
  "moment": "2023-03-09T09:38:44.296Z"
}

Combining credits and debits

We are now ready to play with our new Bridge 🥳. To do that, we have 4 different accounts preconfigured in our core:

1 - no balance available because it’s a new account

2 - 70 USD available

3 - no available balance because everything is either spent or on hold

4 - inactive account

Let’s try sending some money from account 2 to account 1. In reality we would probably process this transfer internally since it is an intrabank transfer, but here for now it will be processed by Ledger.

$ minka intent create
? Handle: knJOdFbP9K_WIr6ZN
? Action: transfer
? Source: account:2@mint
? Target: account:1@mint
? Symbol: usd
? Amount: 10
? Add another action? No
? Add custom data? No
? Signers: mint
? Key password for tesla: [hidden]
 
Intent summary:
------------------------------------------------------------------------
Handle: knJOdFbP9K_WIr6ZN
 
Action: transfer
 - Source: account:2@mint
 - Target: account:1@mint
 - Symbol: usd
 - Amount: $10.00
 
? Sign this intent using signer mint? Yes
 
✅ Intent signed and sent to ledger sandbox
Intent status: pending

We should now see both credit and debit requests in the logs like this.

RECEIVED POST /v2/debits
SENT signature to Ledger
{
  "handle": "deb_lWO5y5j6UrIctPqmZ",
  "status": "prepared",
  "coreId": "11",
  "moment": "2023-03-09T09:44:46.990Z"
}
RECEIVED POST /v2/credits
SENT signature to Ledger
{
  "handle": "cre_D1wJoSypAsF2XX_kW",
  "status": "prepared",
  "moment": "2023-03-09T09:44:47.512Z"
}
RECEIVED POST /v2/debits/deb_lWO5y5j6UrIctPqmZ/commit
RECEIVED POST /v2/credits/cre_D1wJoSypAsF2XX_kW/commit
SENT signature to Ledger
{
  "handle": "deb_lWO5y5j6UrIctPqmZ",
  "status": "committed",
  "coreId": "13",
  "moment": "2023-03-09T09:44:47.908Z"
}
SENT signature to Ledger
{
  "handle": "cre_D1wJoSypAsF2XX_kW",
  "status": "committed",
  "coreId": "14",
  "moment": "2023-03-09T09:44:47.919Z"
}

Aborting a request

This may be a good time to show the state diagram for processing eg. credit Entries. Ledger sends prepare, commit or abort requests and we respond with prepared, failed, committed or aborted statuses, depending on the request and what happened. commit and abort requests can not fail and therefore end up in the suspended state that will have to be fixed internally in the bank. The state diagram for debit Entries looks the same.

StateDescription
processing-prepareThis is the first state that an Entry can be in. It is triggered by receiving a prepare request for credit or debit Entries.
preparedIf a processing-prepare step is successful. We need to notify Ledger of this status.
failedIf a processing-prepare step fails. We need to notify Ledger of this status.
processing-commitIf all participants report prepared, Ledger will send commit debit and commit credit requests which will put us into this state.
committedIf processing-commit step is successful. We need to notify Ledger of this status.
processing-abortIf Ledger sends an abort request for credit or debit Entries.
abortedIf processing-abort step is successful. We need to notify Ledger of this status.
suspendedIf processing-commit or processing-abort steps fail. This should not be possible so we have a special status for this. The step is considered successful even if there is an error so it is up to the Bank to fix all steps that end up in this status.

It is important to note that the abort request may arrive while we are in processing-prepare or prepared states, and will definitely happen if we are in the failed state after we notify Ledger of the failure. If it happens while we are in prepared or failed states, we can process the request (we just need to take into account what state we were in before), but if the abort request arrives while we are in processing-prepare, we have set up our code to refuse the request and let Ledger send it again. We could also save this information, respond with 202 Accepted and process it after the current step finishes processing or even better preempt the processing-prepare step if possible.

Insufficient balance

To begin with, let’s try creating a request that should fail because there is not enough balance in the account.

$ minka intent create
? Handle: huz1JQ-sWTxP1A_Lc
? Action: transfer
? Source: account:3@mint
? Target: account:1@mint
? Symbol: usd
? Amount: 10
? Add another action? No
? Add custom data? No
? Signers: mint
 
Intent summary:
------------------------------------------------------------------------
Handle: huz1JQ-sWTxP1A_Lc
 
Action: transfer
 - Source: account:3@mint
 - Target: account:1@mint
 - Symbol: usd
 - Amount: $10.00
 
? Sign this intent using signer mint? Yes
 
✅ Intent signed and sent to ledger sandbox
Intent status: pending

We see that we get the error we expected: Insufficient available balance in account 3 and after notifying Ledger of the failure POST /v2/debits/:handle/abort was called in the Bridge.

RECEIVED POST /v2/debits
Error: Insufficient available balance in account 3
    at processPrepareDebit (file:///Users/branko/minka/demo-bridge-test/src/handlers/debits.js:69:13)
    at processTicksAndRejections (node:internal/process/task_queues:96:5)
    at async prepareDebit (file:///Users/branko/minka/demo-bridge-test/src/handlers/debits.js:27:5)
    at async file:///Users/branko/minka/demo-bridge-test/src/middleware/errors.js:5:14
SENT signature to Ledger
{
  "handle": "deb_6uwDWMjA-vXeWsQ3k",
  "status": "failed",
  "coreId": "15",
  "reason": "bridge.unexpected-error",
  "detail": "Insufficient available balance in account 3",
  "moment": "2023-03-09T10:37:19.049Z"
}
RECEIVED POST /v2/debits/deb_6uwDWMjA-vXeWsQ3k/abort

We will now implement this endpoint the same way as the other ones.

abort(context: TransactionContext): Promise<AbortResult> {
    console.log('RECEIVED POST /v2/debits/abort')
 
    let result: AbortResult
    let transaction
 
    try {
      const extractedData = extractor.extractAndValidateData(context.entry)
 
      if (context.previous.job.state.result.status === ResultStatus.Prepared) {
        transaction = core.release(
          extractedData.address.account,
          extractedData.amount,
          `${context.entry.handle}-release`,
        )
 
        if (transaction.status !== 'COMPLETED') {
          throw new Error(transaction.errorReason)
        }
      }
 
      result = {
        status: ResultStatus.Aborted,
        coreId: transaction.id.toString(),
      }
    } catch (e) {
      result = {
        status: ResultStatus.Suspended,
      }
    }
 
    return Promise.resolve(result)
  }

We can see that the processing here depends on the state the Entry was already in. If it was prepared, we need to release the funds, otherwise we don’t have to do anything.

If we now rerun Test 4, we will get the following logs which is exactly what we expected.

RECEIVED POST /v2/debits
Error: Insufficient available balance in account 3
    at processPrepareDebit (file:///Users/branko/minka/demo-bridge-test/src/handlers/debits.js:69:13)
    at processTicksAndRejections (node:internal/process/task_queues:96:5)
    at async prepareDebit (file:///Users/branko/minka/demo-bridge-test/src/handlers/debits.js:27:5)
    at async file:///Users/branko/minka/demo-bridge-test/src/middleware/errors.js:5:14
SENT signature to Ledger
{
  "handle": "deb_B2KIdu1X801Js3bof",
  "status": "failed",
  "coreId": "8",
  "reason": "bridge.unexpected-error",
  "detail": "Insufficient available balance in account 3",
  "moment": "2023-03-09T10:41:17.414Z"
}
RECEIVED POST /v2/debits/deb_B2KIdu1X801Js3bof/abort
SENT signature to Ledger
{
  "handle": "deb_B2KIdu1X801Js3bof",
  "status": "aborted",
  "moment": "2023-03-09T10:41:17.783Z"
}

Account inactive

Finally, let’s create an intent that should fail because the target account is inactive.

$ minka intent create
? Handle: xwt2O_ONnGDsp8TtV
? Action: transfer
? Source: account:2@mint
? Target: account:4@mint
? Symbol: usd
? Amount: 10
? Add another action? No
? Add custom data? No
? Signers: mint
 
Intent summary:
------------------------------------------------------------------------
Handle: xwt2O_ONnGDsp8TtV
 
Action: transfer
 - Source: account:2@mint
 - Target: account:4@mint
 - Symbol: usd
 - Amount: $10.00
 
? Sign this intent using signer mint? Yes
 
✅ Intent signed and sent to ledger sandbox
Intent status: pending

We see that this time, because the error occurred on the credit side, we got two abort requests.

RECEIVED POST /v2/debits
SENT signature to Ledger
{
  "handle": "deb_cvX9c-N4aDrB6DtsF",
  "status": "prepared",
  "coreId": "8",
  "moment": "2023-03-09T10:46:47.754Z"
}
RECEIVED POST /v2/credits
InactiveAccountError: Account 4 is inactive
    at Account.assertIsActive (file:///Users/branko/minka/demo-bridge-test/src/core.js:101:13)
    at processPrepareCredit (file:///Users/branko/minka/demo-bridge-test/src/handlers/credits.js:73:17)
    at processTicksAndRejections (node:internal/process/task_queues:96:5)
    at async prepareCredit (file:///Users/branko/minka/demo-bridge-test/src/handlers/credits.js:31:5)
    at async file:///Users/branko/minka/demo-bridge-test/src/middleware/errors.js:5:14 {
  code: '102'
}
SENT signature to Ledger
{
  "handle": "cre_KWS-5x837ecu5WdpO",
  "status": "failed",
  "reason": "bridge.unexpected-error",
  "detail": "Account 4 is inactive",
  "moment": "2023-03-09T10:46:48.121Z"
}
RECEIVED POST /v2/debits/deb_cvX9c-N4aDrB6DtsF/abort
RECEIVED POST /v2/credits/cre_KWS-5x837ecu5WdpO/abort
SENT signature to Ledger
{
  "handle": "deb_cvX9c-N4aDrB6DtsF",
  "status": "aborted",
  "coreId": "9",
  "moment": "2023-03-09T10:46:48.427Z"
}

Let’s implement abort credit the same way we did for debit, we don’t need to do much here.

  abort(context: TransactionContext): Promise<AbortResult> {
    console.log('RECEIVED POST /v2/credits/abort')
 
    let result: AbortResult 
 
    result = {
      status: ResultStatus.Aborted,
    }
 
    return Promise.resolve(result)
  }

We can again notice that the only state that we can end up in is aborted. If something goes wrong, we will end up in the suspended state and have to fix it internally.

And if we now retry Test 5 we see that it’s fully aborted.

RECEIVED POST /v2/debits
SENT signature to Ledger
{
  "handle": "deb_F-8HdIELqir6yp-g5",
  "status": "prepared",
  "coreId": "8",
  "moment": "2023-03-09T10:58:14.860Z"
}
RECEIVED POST /v2/credits
InactiveAccountError: Account 4 is inactive
    at Account.assertIsActive (file:///Users/branko/minka/demo-bridge-test/src/core.js:101:13)
    at processPrepareCredit (file:///Users/branko/minka/demo-bridge-test/src/handlers/credits.js:73:17)
    at processTicksAndRejections (node:internal/process/task_queues:96:5)
    at async prepareCredit (file:///Users/branko/minka/demo-bridge-test/src/handlers/credits.js:31:5)
    at async file:///Users/branko/minka/demo-bridge-test/src/middleware/errors.js:5:14 {
  code: '102'
}
SENT signature to Ledger
{
  "handle": "cre_bhu9PwG9QAbzRraqX",
  "status": "failed",
  "reason": "bridge.unexpected-error",
  "detail": "Account 4 is inactive",
  "moment": "2023-03-09T10:58:15.212Z"
}
RECEIVED POST /v2/debits/deb_F-8HdIELqir6yp-g5/abort
RECEIVED POST /v2/credits/cre_bhu9PwG9QAbzRraqX/abort
SENT signature to Ledger
{
  "handle": "deb_F-8HdIELqir6yp-g5",
  "status": "aborted",
  "coreId": "9",
  "moment": "2023-03-09T10:58:15.512Z"
}
SENT signature to Ledger
{
  "handle": "cre_bhu9PwG9QAbzRraqX",
  "status": "aborted",
  "moment": "2023-03-09T10:58:15.655Z"
}

We see that prepare failed because Account 4 is inactive so both the credit and debit Entries got the respective abort actions and were aborted.

Sending money to a phone number

Routing money to internal account

Create wallet

Another thing we can try is sending money to a phone number. To do this, we will first set up a phone wallet so that it redirects the money to account:1@mint.

$ minka wallet create
? Handle: tel:1337
? Bridge: [none]
? Add custom data? No
? Add routes? Yes
? Set route filter? Yes
? Field: symbol
? Value: usd
? Add another condition? No
? Route action: forward
? Route target: account:1@mint
? Add another route? No
? Signer: mint
 
✅ Wallet created successfully:
Handle: tel:1337
Signer: 3wollw7xH061u4a+BZFvJknGeJcY1wKuhbWA3/0ritM= (mint)

Create intent

Now let’s try sending some money to this account.

$ minka intent create
? Handle: askaMCb31lCk02k0B
? Action: transfer
? Source: account:2@mint
? Target: tel:1337
? Symbol: usd
? Amount: 10
? Add another action? No
? Add custom data? No
? Signers: mint
 
Intent summary:
------------------------------------------------------------------------
Handle: askaMCb31lCk02k0B
 
Action: transfer
 - Source: account:2@mint
 - Target: tel:1337
 - Symbol: usd
 - Amount: $10.00
 
? Sign this intent using signer mint? Yes
 
✅ Intent signed and sent to ledger sandbox
Intent status: pending

And we should see logs similar to the ones below.

RECEIVED POST /v2/debits
RECEIVED POST /v2/credits
SENT signature to Ledger
{
  "handle": "deb_6HmVZcs4Ef0iOQ6X4",
  "status": "prepared",
  "coreId": "8",
  "moment": "2023-03-09T11:11:44.764Z"
}
SENT signature to Ledger
{
  "handle": "cre_pu4827RPJWtcwrcjy",
  "status": "prepared",
  "moment": "2023-03-09T11:11:45.181Z"
}
RECEIVED POST /v2/debits/deb_6HmVZcs4Ef0iOQ6X4/commit
RECEIVED POST /v2/credits/cre_pu4827RPJWtcwrcjy/commit
SENT signature to Ledger
{
  "handle": "cre_pu4827RPJWtcwrcjy",
  "status": "committed",
  "coreId": "11",
  "moment": "2023-03-09T11:11:45.865Z"
}
SENT signature to Ledger
{
  "handle": "deb_6HmVZcs4Ef0iOQ6X4",
  "status": "committed",
  "coreId": "10",
  "moment": "2023-03-09T11:11:45.592Z"
}

You will notice that, because we have configured the phone wallet to forward the money to account:1@mint, we now see two intents. Both of them belong to the same Thread.

Aborting account to phone transfer

We will finally do the ultimate test where we redirect money to an inactive internal account. We should see two requests from two different intents and both of them should be aborted.

Create wallet

First we need to create a phone account with the correct route.

$ minka wallet create
? Handle: tel:1338
? Bridge: [none]
? Add custom data? No
? Add routes? Yes
? Set route filter? Yes
? Field: symbol
? Value: usd
? Add another condition? No
? Route action: forward
? Route target: account:4@mint
? Add another route? No
? Signer: mint
 
✅ Wallet created successfully:
Handle: tel:1337
Signer: 3wollw7xH061u4a+BZFvJknGeJcY1wKuhbWA3/0ritM= (mint)

Create intent

Next we create a transfer intent to send money to that wallet.

$ minka intent create
? Handle: G9t-eiXaNX86-TBuo
? Action: transfer
? Source: account:2@mint
? Target: tel:1338
? Symbol: usd
? Amount: 10
? Add another action? No
? Add custom data? No
? Signers: mint
 
Intent summary:
------------------------------------------------------------------------
Handle: G9t-eiXaNX86-TBuo
 
Action: transfer
 - Source: account:2@mint
 - Target: tel:1338
 - Symbol: usd
 - Amount: $10.00
 
? Sign this intent using signer mint? Yes
 
✅ Intent signed and sent to ledger sandbox
Intent status: pending

And we get the result we expected! 🍾

RECEIVED POST /v2/debits
RECEIVED POST /v2/credits
InactiveAccountError: Account 4 is inactive
    at Account.assertIsActive (file:///Users/branko/minka/demo-bridge-test/src/core.js:101:13)
    at processPrepareCredit (file:///Users/branko/minka/demo-bridge-test/src/handlers/credits.js:73:17)
    at processTicksAndRejections (node:internal/process/task_queues:96:5)
    at async prepareCredit (file:///Users/branko/minka/demo-bridge-test/src/handlers/credits.js:31:5)
    at async file:///Users/branko/minka/demo-bridge-test/src/middleware/errors.js:5:14 {
  code: '102'
}
SENT signature to Ledger
{
  "handle": "deb_cndbsdDiWz-Yn8vmd",
  "status": "prepared",
  "coreId": "12",
  "moment": "2023-03-09T11:15:50.253Z"
}
SENT signature to Ledger
{
  "handle": "cre_zwLEX9xea5el-hfFU",
  "status": "failed",
  "reason": "bridge.unexpected-error",
  "detail": "Account 4 is inactive",
  "moment": "2023-03-09T11:15:50.606Z"
}
RECEIVED POST /v2/debits/deb_cndbsdDiWz-Yn8vmd/abort
RECEIVED POST /v2/credits/cre_zwLEX9xea5el-hfFU/abort
SENT signature to Ledger
{
  "handle": "cre_zwLEX9xea5el-hfFU",
  "status": "aborted",
  "moment": "2023-03-09T11:15:51.173Z"
}
SENT signature to Ledger
{
  "handle": "deb_cndbsdDiWz-Yn8vmd",
  "status": "aborted",
  "coreId": "13",
  "moment": "2023-03-09T11:15:50.901Z"
}

Error handling

Until now we have mostly ignored errors or just returned generic values. However, we do need to deal with errors at some point. To begin with, you can check out the Error reference.

For us there will be two main types of errors. Ones that we will respond with directly to all the Entry, Action and Intent requests (which includes HTTP status codes and Ledger error codes) and ones that we will respond with after processing (sending signatures).

There can be many different sources of errors for our application like validation errors, application errors or external errors when communicating with the Core or database (network issues, timeouts and similar). Additionally, when communicating with the Core, we may get not just timeouts, but also HTTP status codes indicating issues or successful responses that themselves indicate an error. You will need to cover all of these cases for your particular situation, but we will give some examples of various errors to serve as a guide.

Conclusion

With everything we have covered so far, we have built a real time payments system between bank accounts. You can now check out the payment initiation tutorial too.