Minka Ledger Docs
Tutorials

Bank integration tutorial (open banking)

DateResponsibleChanges
December 23, 2022@Branko DurdevicInitial version
February 10, 2023@Branko DurdevicSupport for two phase commit protocol
February 14, 2023@Branko DurdevicAdd persistence and simplify code examples
February 27, 2023@Omar Monterrey• Added version to Ledger URL (On SDK Initialization)
• Refactored signer.schema-> signer.format, signature.schemasignature.method, meta.signaturesmeta.proofs
March 8, 2023@Branko DurdevicSynced Tutorial with new Ledger version
May 22, 2023@Omar MonterreyRemoved schema: rest from bridge summary when updating

Introduction

This tutorial will show you how to connect a bank to a cloud based system built using the Minka Ledger. For this purpose we will be implementing a bridge component that connects the bank’s core systems with the cloud Ledger.

This tutorial shows how to implement a service used to explain the main steps involved in building a bridge, this is not a guide on how to build a production ready application.

When building a service that is going to be deployed to production, follow your usual best practices on application design, security etc. Basic knowledge of the Ledger v2 - docs is expected.

The code is not written as idiomatic JavaScript code, but in a way that makes it easy to understand even for developers not familiar with 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.

Repository

The accompanying git repository can be found at the following URL.

https://github.com/minkainc/demo-bridge

If at any point during the tutorial you get stuck or your code gets out of sync with the tutorial, you can simply check-out the appropriate commit and continue from there.

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

Now that we have an empty node project, the next step is to install express in order to expose several REST APIs to simulate common bank operations.

We can install express using npm.

$ npm install express@^4.18.2 --save

It is important to note here that we will be using version 4 of express, please be careful not to use version 5.

We will be using a newer import syntax in our project, so we need to add a "type": "module" option to our package.json to enable this. We will also put our source files under src so edit main to say "main": "src/index.js".

{
  "name": "demo-bridge",
  "version": "1.0.0",
  "description": "",
  "main": "src/index.js",
  "type": "module",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "",
  "license": "ISC",
  "dependencies": {
    "express": "^4.18.2"
  }
}

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

import express from 'express'
 
const app = express()
const bankName = 'Demo bank'
const port = 3001
 
app.get('/', (req, res) => {
  res.send(`${bankName} is running!`)
})
 
app.listen(port, () => {
  console.log(`${bankName} running on port ${port}`)
})

To test our code we need to start the app.

$ node index.js
Demo bank running on port 3001

If we open localhost:3001 in a browser or simply run the command below in a new terminal, we should see that our default route is being called and the message we return is there.

$ curl -XGET localhost:3001
Demo bank is 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 index.js

Interfaces

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.

Logging requests and errors

Let’s first just log the requests to see what they look like. To do that we will create a new file named middleware/logging.js where we will implement our request logging middleware.

We start by creating a new directory.

$ mkdir middleware

Then we create the src/middleware/logging.js file and add the logRequest function.

// This function will get called before all of our request
// handlers and log the request.
export function logRequest(req, res, next) {
  console.log(`RECEIVED ${req.method} ${req.url}`)
  console.log(JSON.stringify(req.body, null, 2))
  next()
}

We will also add simple error handling to make debugging easier, and we will keep the helper functions in src/middleware/errors.js. You don’t need to concern yourself with the details of this, it is enough to know that we will be wrapping our handlers in asyncErrorWrapper and that the handleErrors function will get called for all errors that bubble up to the top of the execution stack. At the moment we will just be returning 500 Internal Server Error for all errors to the client.

// Necessary to properly handle async errors
export function asyncErrorWrapper(func) {
  return async (req, res, next) => {
    try {
      return await func(req, res, next)
    } catch (error) {
      next(error)
    }
  }
}
 
// This needs to go after all route handlers to log the errors
// and send the appropriate response to the client.
export function handleErrors(err, req, res, next) {
  console.log(err)
  res.sendStatus(500)
}

index.js should now be edited to look like this.

import express from 'express'
import { logRequest } from './middleware/logging.js'
import { handleErrors } from './middleware/errors.js'
 
const bankName = 'Demo bank'
const port = 3001
 
const app = express()
 
// express.json() is used to parse incoming JSON data
app.use(express.json())
app.use(logRequest)
 
app.get('/', (req, res) => {
  res.send(`${bankName} is running!`)
})
 
// Error handler needs to go after all the routes
app.use(handleErrors)
 
app.listen(port, () => {
  console.log(`${bankName} running on port ${port}`)
})

Test endpoints

After restarting the bridge service, we can issue simple curl commands in a separate window to check that our endpoints are accepting requests.

$ curl -X POST \
	-H "Content-Type: application/json" \
	http://localhost:3001/v2/credits \
	-d'{"testField": 1}'

In the terminal where we have restarted the service we should see something like this.

[nodemon] restarting due to changes...
[nodemon] starting `node index.js`
Demo bank running on port 3001
POST /v2/credits
{
  "testField": 1
}

Processing a credit Entry

Now that we are set up and receiving requests, 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.

RECEIVED POST /v2/credits
{
  "hash": "7eebae144f14356aad42b40907da96ebcdc3d3341b1c1ada361b7853db3030e3",
  "data": {
    "handle": "cre_TJl-oczgpI_6JDhEi",
    "schema": "credit",
    "symbol": {
      "handle": "usd"
    },
    "source": {},
    "target": {
      "handle": "account:1@mint"
    },
    "amount": 1000,
    "intent": {
      "hash": "29e8a74c036e6739afb29433d651b9a6348ccdd5b2100b47f1c39dbff13a3868",
      "data": {
        "handle": "tfkPgUiuUjQjRB9KJ",
        "claims": [
          {
            "action": "transfer",
            "amount": 1000,
            "source": "account:73514@tesla",
            "symbol": "usd",
            "target": "account:1@mint"
          }
        ],
        "access": [
          {
            "action": "any",
            "signer": {
              "public": "84pzs6N53c+A3Eq4LIi9dW500UqYEsMf4IziCUQospY="
            }
          },
          {
            "action": "read",
            "bearer": {
              "$signer": {
                "public": "84pzs6N53c+A3Eq4LIi9dW500UqYEsMf4IziCUQospY="
              }
            }
          }
        ]
      },
      "meta": {
        "proofs": [
          {
            "custom": {
              "moment": "2023-03-08T20:14:48.431Z",
              "status": "created"
            },
            "digest": "a7eba41b7c42975d500863cf68e090272106b19a549c382a616a74206cd951d7",
            "method": "ed25519-v2",
            "public": "84pzs6N53c+A3Eq4LIi9dW500UqYEsMf4IziCUQospY=",
            "result": "gNlWGosnlEl0YGC4ezjHkdLtSReSM5nGuoBu8XhAfH7sTI1AvEOmsU96zjPoaXFv60xZq4nmNsdeV+agJA2JDg=="
          }
        ],
        "status": "pending",
        "thread": "5EeR1VIKcFVyJPQKS",
        "moment": "2023-03-08T20:14:48.728Z"
      }
    }
  },
  "meta": {
    "proofs": [
      {
        "method": "ed25519-v2",
        "public": "dONtBtsN2DUUpd6ia5dODR4JFdyZG0u1Kfv/JITwvDg=",
        "digest": "0be8ad5411f4411a36d296435d78f025050f7072862e81224f701b9be4a04c2d",
        "result": "TYrOLzWWA1/6Q1+ZlKHjA8JOiQmUAqdUH00Lb15KR2gNDR8xrpU4PpLpKg9iVr03swpb3gv+Zkt9nZaICtA5Dw=="
      }
    ]
  }
}

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.

To process this request, we need to:

Save the request

We need this to properly handle idempotency and retries, but it is also valuable information we can use to debug any issues we encounter. We will just save it to a map for now, but will add a database later. Ledger may retry the request in case of issues like timeouts so we could get the same request multiple times.

Respond to Ledger with 202 Accepted

If we get the same request again, we should respond the same way. After this point the responsibility is on us to notify Ledger on how to proceed. If we don’t do it in time, transfer abort will be triggered.

Validate the request This means checking that the hash and proofs are correct and that the public key belongs to the Ledger.

Process the request Here 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.

To do this we will create a mock banking core which should be replaced by calls to the actual core in the real service.

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

After saving the result to our database, 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, even though it’s still not notified of the result. This is important because we may get the same request multiple times and another request may have it’s response served from the DB sooner.

Notify Ledger We need to ensure that the Ledger is fully aware of the outcome of the processing and if it is possible to move forward with the Intent.

Credit Entry

We will first create a new directory for our request handlers.

$ mkdir handlers

Now we can create credits.js where we will keep all of our credit handlers. Let’s start by adding the prepareCredit function to the file and just responding with 202 Accepted.

export async function prepareCredit(req, res) {
	res.sendStatus(202)
}

We will also need to register the POST /v2/credits route in index.js. Notice where we placed the handler and that we wrapped it with asyncErrorWrapper defined earlier. Don’t forget to update the imports too.

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.

import express from 'express'
import { logRequest } from './middleware/logging.js'
import { asyncErrorWrapper, handleErrors } from './middleware/errors.js'
import { prepareCredit } from './handlers/credits.js'
 
const bankName = 'Demo bank'
const port = 3001
 
const app = express()
 
app.use(express.json())
 
app.use(logRequest)
 
app.get('/', (req, res) => {
  res.send(`${bankName} is running!`)
})
 
app.post('/v2/credits', asyncErrorWrapper(prepareCredit))
 
app.use(handleErrors)
 
app.listen(port, () => {
  console.log(`${bankName} running on port ${port}`)
})

You can try the same request as earlier and you should still be receiving requests from Ledger. We will now proceed in bigger steps and explain the code in detail. Unfortunately, the biggest step is the first one because we have a lot of preparation to get through so bare with me. Let’s start with updating the prepareCredit function according to the processing steps we wrote above. We will replace the entire file with the contents below.

import { beginActionNew, endAction, saveIntent } from './common.js'
import { updateEntry } from '../persistence.js'
import { ledgerSigner, notifyLedger } from '../ledger.js'
import {
  extractAndValidateData,
  validateAction,
  validateEntity,
} from '../validators.js'
import core from '../core.js'
 
export async function prepareCredit(req, res) {
  const action = 'prepare'
 
  // Begin Action processing for new Entry which will also save it.
  let { alreadyRunning, entry } = await beginActionNew({
    request: req,
    action,
  })
 
  // The Entry is already saved, so we can return 202 Accepted
  // to Ledger so that it stops redelivering the Action.
  res.sendStatus(202)
 
  // If the Action is already being processed, skip processing.
  if (!alreadyRunning) {
    await processPrepareCredit(entry)
 
    // Stop Action processing and save the result.
    await endAction(entry)
  }
 
  // If Entry is in final state, return the result to Ledger
  await notifyLedger(entry, action, ['prepared', 'failed'])
}
 
async function processPrepareCredit(entry) {
  const action = entry.actions[entry.processingAction]
  try {
    // Parse data from the Entry and validate it.
    validateEntity(
      { hash: entry.hash, data: entry.data, meta: entry.meta },
      ledgerSigner,
    )
    validateEntity(entry.data?.intent)
    validateAction(action.action, entry.processingAction)
 
    const { address, symbol, amount } = extractAndValidateData({
      entry,
      schema: 'credit',
    })
 
    // Save extracted data into Entry, we will need this for other Actions.
    entry.schema = 'credit'
    entry.account = address.account
    entry.symbol = symbol
    entry.amount = amount
 
    // Save Entry.
    await updateEntry(entry)
 
    // Save Intent from Entry.
    await saveIntent(entry.data.intent)
 
    // Processing prepare Action for Credit Entry in the core is simple and
    // only checks if the account exists and is active. If something is wrong,
    // an Error will be thrown, and we will catch it later.
    const coreAccount = core.getAccount(Number(entry.account))
    coreAccount.assertIsActive()
 
    action.state = 'prepared'
  } catch (error) {
    console.log(error)
    action.state = 'failed'
    action.error = {
      reason: 'bridge.unexpected-error',
      detail: error.message,
      failId: undefined,
    }
  }
}

As you can see there are a lot of things here that we haven’t yet implemented so let’s do that now. First of all, let’s create the src/handlers/common.js file with the following content.

import {
  createEntry,
  getEntry,
  updateEntry,
  upsertIntent,
} from '../persistence.js'
 
export async function beginActionNew({ request, action }) {
  const handle = request.body?.data?.handle
 
  // We don't check many things before we start processing, but we
  // will need the handle, so we just check its existence.
  if (!handle) {
    throw new Error('Invalid handle')
  }
 
  let entry = await getEntry(handle)
 
  // If the Entry does not exist already, create it.
  if (!entry) {
    entry = await createEntry({
      handle,
      hash: request.body.hash,
      data: request.body.data,
      meta: request.body.meta,
      schema: null,
      account: null,
      symbol: null,
      amount: null,
      state: null,
      previousState: null,
      actions: {},
      processingAction: null,
      processingStart: null,
    })
  }
 
  const alreadyRunning = !!entry.processingAction
 
  // If we are already processing an Action for this Entry, and it's
  // not the same Action, something has gone wrong, or we are not ready
  // to process this action, so we throw an Error.
  if (alreadyRunning) {
    if (entry.processingAction !== request.body?.action) {
      throw new Error('Already processing another action.')
    } else {
      return { alreadyRunning: true, entry }
    }
  }
 
  // We update the Entry and Action to indicate that processing has started.
  const processingStart = new Date()
 
  // It may not be immediately obvious where we will need some of these properties
  // right now, but it will become clear as we progress with the tutorial.
  entry.previousState = entry.state
  entry.state = `processing-${action}`
  entry.actions[action] = {
    hash: undefined,
    data: undefined,
    meta: undefined,
    action: action,
    state: 'processing',
    coreId: undefined,
    error: {
      reason: undefined,
      detail: undefined,
      failId: undefined,
    },
    processingStart,
    processingEnd: null,
  }
  entry.processingAction = action
  entry.processingStart = processingStart
 
  // Save the result.
  entry = await updateEntry(entry)
  return { alreadyRunning: false, entry }
}
 
export async function endAction(entry) {
  const currentAction = entry.actions[entry.processingAction]
 
  // Mark the Entry processing as completed and save the results.
  entry.previousState = entry.state
  entry.state = currentAction.state
  entry.processingAction = null
  entry.processingStart = null
 
  currentAction.processingEnd = new Date()
 
  entry = await updateEntry(entry)
  return entry
}
 
export async function saveIntent(intent) {
  await upsertIntent({ handle: intent?.data?.handle, ...intent })
}

The code above is used to start and end Action processing. In addition, the saveIntent action is used to save the Intent we get as part of a new Entry.

Saving requests

Next, let’s create src/persistence.js. You will see that we are just using a map to implement get and create operations so our persistence is not very persistent at the moment, but we will come back to this later.

let entries = new Map()
let intents = new Map()
 
export async function getEntry(handle) {
  return entries[handle]
}
 
export async function createEntry(entry) {
  entries[entry.handle] = entry
  return entry
}
 
export async function updateEntry(entry) {
  entries[entry.handle] = entry
  return entry
}
 
export async function upsertIntent(intent) {
  intents[intent.handle] = intent
  return intent
}

Notifying Ledger

In order to notify Ledger of the status of processing, we will use @minka/ledger-sdk to sign the Intent with the state. To do that we will create ledger.js with the notifyLedger function which we will use to let Ledger know about processing results. You will need to populate the bankKeyPair with your Signer, ledgerSigner with Ledger public key and server with the Ledger URL.

import ledgerSdk from '@minka/ledger-sdk'
 
const { LedgerSdk } = ledgerSdk
 
// Populate this object with bank keys you have created previously
const bankKeyPair = {
  format: 'ed25519-raw',
  public: '3wollw7xH061u4a+BZFvJknGeJcY1wKuhbWA3/0ritM=',
  secret: 'UoJlh2+3VGtZuNvA7Ao8u5yvjcjalBSXRZLSM/UIOJI=',
}
 
// Populate with Ledger public key data.
export const ledgerSigner = {
  format: 'ed25519-raw',
  public: 'XhjxNOor+jocpF7YrMTiNdeNbwgqvG3EicLO61cyfZU='
}
 
// Configure the Ledger SDK.
const ledger = new LedgerSdk({
  // This is the ledger instance we are going to connect to.
  ledger: 'demo',
  server: 'http://localhost:3000/v2',
  secure: {
    aud: 'demo',
    iss: 'mint',
    keyPair: bankKeyPair,
    sub: bankKeyPair.public,
    exp: 3600
  },
})
 
// This function is used to notify Ledger of Entry processing final statuses.
export async function notifyLedger(entry, action, notifyStates) {
  const notifyAction = entry.actions[action]
 
  if (!notifyStates.includes(notifyAction.state)) {
    return
  }
 
  const custom = {
    handle: entry.handle,
    status: notifyAction.state,
    coreId: notifyAction.coreId,
    reason: notifyAction.error.reason,
    detail: notifyAction.error.detail,
    failId: notifyAction.error.failId,
  }
  const ledgerResponse = await ledger.intent
    .from(entry.data.intent)
    .hash()
    .sign([
      {
        keyPair: bankKeyPair,
        custom,
      },
    ])
    .send()
  console.log(`SENT signature to Ledger\n${JSON.stringify(custom, null, 2)}`)
}

At this point, we will also need to install @minka/ledger-sdk.

$ npm install @minka/ledger-sdk

Validation

To validate requests, we will use a couple of functions which we will put into src/validators.js.

// Populate this with the wallet handle you created
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 validateEntity(entity, signer) {}
 
export function extractAndValidateAddress(address) {
  const result = ADDRESS_REGEX.exec(address)
  if (!result) {
    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) {
  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) {
  // 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 validateAction(action, expected) {
  if (action !== expected) {
    throw new Error(`Action ${expected} expected, got ${action}`)
  }
}
 
export function validateSchema(schema, expected) {
  if (schema !== expected) {
    throw new Error(`Schema ${expected} expected, got ${schema}`)
  }
}
 
export function extractAndValidateData({ entry, schema }) {
  const data = entry?.data
 
  validateSchema(data?.schema, schema)
 
  const rawAddress = data?.schema === 'credit' ? data.target.handle : data.source.handle
  const address = extractAndValidateAddress(rawAddress)
  const amount = extractAndValidateAmount(data.amount)
  const symbol = extractAndValidateSymbol(data.symbol.handle)
 
  return {
    address,
    amount,
    symbol,
  }
}

Simulate banking core

Another thing we are missing at this point is a banking core. To simulate it 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 demonstrative 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 reserve method will reserve client balances so that they can’t be spent and release will make them available for spending.

export class CoreError extends Error {
  constructor(message) {
    super(message)
    this.name = 'CoreError'
    this.code = '100'
  }
}
 
export class InsufficientBalanceError extends CoreError {
  constructor(message) {
    super(message)
    this.name = 'InsufficientBalanceError'
    this.code = '101'
  }
}
 
export class InactiveAccountError extends CoreError {
  constructor(message) {
    super(message)
    this.name = 'InactiveAccountError'
    this.code = '102'
  }
}
 
export class UnknownAccountError extends CoreError {
  constructor(message) {
    super(message)
    this.name = 'UnknownAccountError'
    this.code = '103'
  }
}
 
export class Account {
  constructor(id, active = true) {
    this.id = id
    this.active = active
    this.balance = 0
    this.onHold = 0
  }
 
  debit(amount) {
    this.assertIsActive()
 
    if (this.getAvailableBalance() < amount) {
      throw new InsufficientBalanceError(
        `Insufficient available balance in account ${this.id}`,
      )
    }
 
    this.balance = this.balance - amount
  }
 
  credit(amount) {
    this.assertIsActive()
 
    this.balance = this.balance + amount
  }
 
  hold(amount) {
    this.assertIsActive()
 
    if (this.getAvailableBalance() < amount) {
      throw new InsufficientBalanceError(
        `Insufficient available balance in account ${this.id}`,
      )
    }
 
    this.onHold = this.onHold + amount
  }
 
  release(amount) {
    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) {
    this.active = active
  }
}
 
export class Transaction {
  constructor({ id, type, account, amount, status, idempotencyToken }) {
    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()
  transactions = []
 
  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) {
    let account = this.accounts.get(accountId)
    if (!account) {
      throw new UnknownAccountError(`Account ${accountId} does not exist`)
    }
    return account
  }
 
  processTransaction(type, accountId, amount, idempotencyToken) {
    if (idempotencyToken) {
      const existing = this.transactions.filter(
        (t) => t.idempotencyToken === idempotencyToken,
      )[0]
      if (existing) {
        return existing
      }
    }
 
    let nextTransactionId = this.transactions.length
    let transaction = new Transaction({
      id: nextTransactionId,
      type,
      account: accountId,
      amount,
      status: 'PENDING',
      idempotencyToken,
    })
    this.transactions[nextTransactionId] = transaction
    try {
      let 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) {
      transaction.errorReason = error.message
      transaction.errorCode = error.code
      transaction.status = 'FAILED'
      return transaction
    }
    transaction.status = 'COMPLETED'
    return transaction
  }
 
  credit(accountId, amount, idempotencyToken) {
    return this.processTransaction(
      'CREDIT',
      accountId,
      amount,
      idempotencyToken,
    )
  }
 
  debit(accountId, amount, idempotencyToken) {
    return this.processTransaction('DEBIT', accountId, amount, idempotencyToken)
  }
 
  hold(accountId, amount, idempotencyToken) {
    return this.processTransaction('HOLD', accountId, amount, idempotencyToken)
  }
 
  release(accountId, amount, idempotencyToken) {
    return this.processTransaction(
      'RELEASE',
      accountId,
      amount,
      idempotencyToken,
    )
  }
 
  activate(accountId) {
    return this.getAccount(accountId).setActive(true)
  }
 
  inactivate(accountId) {
    return this.getAccount(accountId).setActive(false)
  }
 
  printAccountTransactions(accountId) {
    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) {
    let account = this.getAccount(accountId)
    console.log(
      JSON.stringify(
        {
          ...account,
          balance: account.getBalance(),
          availableBalance: account.getAvailableBalance(),
        },
        null,
        2,
      ),
    )
  }
}
 
const ledger = new Ledger()
 
export default ledger

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
...

If the output above is too verbose for you, you can comment out the second logging line in the logRequest function.

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 create the commitCredit function accordingly and add processCommitCredit to src/handlers/credits.js.

export async function commitCredit(req, res) {
  const action = 'commit'
  let { alreadyRunning, entry } = await beginActionExisting({
    request: req,
    action,
    previousStates: ['prepared'],
  })
 
  res.sendStatus(202)
 
  if (!alreadyRunning) {
    await processCommitCredit(entry)
    await endAction(entry)
  }
 
  await notifyLedger(entry, action, ['committed'])
}
 
async function processCommitCredit(entry) {
  const action = entry.actions[entry.processingAction]
  let transaction
  try {
    validateEntity(
        { hash: action.hash, data: action.data, meta: action.meta },
        ledgerSigner,
    )
    validateAction(action.action, entry.processingAction)
 
    transaction = core.credit(
        Number(entry.account),
        entry.amount,
        `${entry.handle}-credit`,
    )
    action.coreId = transaction.id.toString()
 
    if (transaction.status !== 'COMPLETED') {
      throw new Error(transaction.errorReason)
    }
 
    action.state = 'committed'
  } catch (error) {
    console.log(error)
    action.state = 'error'
    action.error = {
      reason: 'bridge.unexpected-error',
      detail: error.message,
      failId: undefined,
    }
  }
}

We can notice a couple of differences, first of all we are using beginActionExisting which expects the Entry to already exist.

Secondly, we only notify Ledger if we end up in the committed state. As you know from About Intents, we are not permitted to fail the commit request so if something happens during processing, we set the state to error and this is something we will need to fix manually.

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 will now add the beginActionExisting function immediately below beginActionNew to src/common.js.

export async function beginActionExisting({ request, action, previousStates }) {
  const handle = request.body?.data?.handle
 
  if (!handle) {
    throw new Error('Invalid handle')
  }
 
  if (request.params.handle && request.params.handle !== handle) {
    throw new Error('Request parameter handle not equal to entry handle.')
  }
 
  let entry = await getEntry(handle)
 
  if (!entry) {
    throw new Error('Entry does not exist.')
  }
 
  const alreadyRunning = !!entry.processingAction
 
  if (alreadyRunning) {
    if (entry.processingAction !== request.body?.action) {
      throw new Error('Already processing another action.')
    } else {
      return { alreadyRunning: true, entry }
    }
  }
 
  if (!previousStates.includes(entry.state)) {
    throw new Error(
        `Invalid previous state (${entry.state}) for action ${action}.`,
    )
  }
 
  const processingStart = new Date()
 
  entry.previousState = entry.state
  entry.state = `processing-${action}`
  entry.actions[action] = {
    hash: request.body?.hash,
    data: request.body?.data,
    meta: request.body?.meta,
    action: request.body?.data?.action,
    state: 'processing',
    coreId: undefined,
    error: {
      reason: undefined,
      detail: undefined,
      failId: undefined,
    },
    processingStart,
    processingEnd: null,
  }
  entry.processingAction = action
  entry.processingStart = processingStart
 
  entry = await updateEntry(entry)
  return { alreadyRunning: false, entry }
}

We will also need to import this in src/handlers/credits.js.

import {
  beginActionExisting,
  beginActionNew,
  endAction,
  saveIntent,
} from './common.js'

And finally add an import and a route to src/index.js.

import { commitCredit, prepareCredit } from './handlers/credits.js'
 
// ...
 
app.post('/v2/credits', asyncErrorWrapper(prepareCredit))
app.post('/v2/credits/:handle/commit', asyncErrorWrapper(commitCredit))

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"
}
RECEIVED PUT /v2/intents/5B4UKbhv9N3S96qDd
...

We can see that Ledger continued processing the intent and PUT /v2/intents/:handle was called. The last request gets called when the status of the intent has changed. We can use it to update the status of the Intent in our database if we decide to save it, but we will skip it for now.

Processing a debit Entry

Now we will do the same thing we did above, but for debit endpoints. To implement POST /v2/debits, we will create a file named src/handlers/debits.js and add the following code to it.

import { beginActionNew, endAction, saveIntent } from './common.js'
import { ledgerSigner, notifyLedger } from '../ledger.js'
import {
  extractAndValidateData,
  validateAction,
  validateEntity,
} from '../validators.js'
import { updateEntry } from '../persistence.js'
import core from '../core.js'
 
export async function prepareDebit(req, res) {
  const action = 'prepare'
 
  let { alreadyRunning, entry } = await beginActionNew({
    request: req,
    action,
  })
 
  res.sendStatus(202)
 
  if (!alreadyRunning) {
    await processPrepareDebit(entry)
    await endAction(entry)
  }
 
  await notifyLedger(entry, action, ['prepared', 'failed'])
}
 
async function processPrepareDebit(entry) {
  const action = entry.actions[entry.processingAction]
  let transaction
  try {
    validateEntity(
      { hash: entry.hash, data: entry.data, meta: entry.meta },
      ledgerSigner,
    )
    validateEntity(entry.data?.intent)
    validateAction(action.action, entry.processingAction)
 
    const { address, symbol, amount } = extractAndValidateData({
      entry,
      schema: 'debit',
    })
    entry.schema = 'debit'
    entry.account = address.account
    entry.symbol = symbol
    entry.amount = amount
 
    await updateEntry(entry)
    await saveIntent(entry.data.intent)
 
    // Process the entry
    // Prepare for debit needs to check if the account exists, is active and hold the funds.
    // Since the core will throw an Error if the amount can not be put on hold for any reason, we
    // can try to hold the amount and catch the Error.
    transaction = core.hold(
      Number(entry.account),
      entry.amount,
      `${entry.handle}-hold`,
    )
    action.coreId = transaction.id.toString()
 
    if (transaction.status !== 'COMPLETED') {
      throw new Error(transaction.errorReason)
    }
 
    action.state = 'prepared'
  } catch (error) {
    console.log(error)
    action.state = 'failed'
    action.error = {
      reason: 'bridge.unexpected-error',
      detail: error.message,
      failId: undefined,
    }
  }
}

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.

We also need to add an import and a route to src/index.js.

import { prepareDebit } from './handlers/debits.js'
 
// ...
 
app.post('/v2/credits/:handle/commit', asyncErrorWrapper(commitCredit))
 
app.post('/v2/debits', asyncErrorWrapper(prepareDebit))

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 POST /v2/debits/:handle/commit endpoint by adding the following code to src/handlers/debits.js

import {
  beginActionExisting,
  beginActionNew,
  endAction,
  saveIntent,
} from './common.js'
 
// ...
 
export async function commitDebit(req, res) {
  const action = 'commit'
  let { alreadyRunning, entry } = await beginActionExisting({
    request: req,
    action,
    previousStates: ['prepared'],
  })
 
  res.sendStatus(202)
 
  if (!alreadyRunning) {
    await processCommitDebit(entry)
    await endAction(entry)
  }
 
  await notifyLedger(entry, action, ['committed'])
}
 
async function processCommitDebit(entry) {
  const action = entry.actions[entry.processingAction]
  let transaction
  try {
    validateEntity(
      { hash: action.hash, data: action.data, meta: action.meta },
      ledgerSigner,
    )
    validateAction(action.action, entry.processingAction)
 
    transaction = core.release(
      Number(entry.account),
      entry.amount,
      `${entry.handle}-release`,
    )
    action.coreId = transaction.id.toString()
 
    if (transaction.status !== 'COMPLETED') {
      throw new Error(transaction.errorReason)
    }
 
    transaction = core.debit(
      Number(entry.account),
      entry.amount,
      `${entry.handle}-debit`,
    )
    action.coreId = transaction.id.toString()
 
    if (transaction.status !== 'COMPLETED') {
      throw new Error(transaction.errorReason)
    }
 
    action.state = 'committed'
  } catch (error) {
    console.log(error)
    action.state = 'error'
    action.error = {
      reason: 'bridge.unexpected-error',
      detail: error.message,
      failId: undefined,
    }
  }
}

We also need to add an import and a route to src/index.js.

import { commitDebit, prepareDebit } from './handlers/debits.js'
 
// ...
 
app.post('/v2/debits', asyncErrorWrapper(prepareDebit))
app.post('/v2/debits/:handle/commit', asyncErrorWrapper(commitDebit))

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"
}
RECEIVED PUT /v2/intents/K6FFlxPHduPEY5VuF
...

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"
}
RECEIVED PUT /v2/intents/knJOdFbP9K_WIr6ZN
...

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, depending on the request and what happened. commit and abort requests can not fail and therefore end up in the error 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.
errorIf 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 (by not returning 202 Accepted). We could also save this information, respond with 202 Accepted and process it after the current step finishes processing or even better (because we could do it faster) 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. First we will add the following code to the end of src/handlers/debits.js.

export async function abortDebit(req, res) {
  const action = 'abort'
  let { alreadyRunning, entry } = await beginActionExisting({
    request: req,
    action,
    previousStates: ['prepared', 'failed'],
  })
 
  res.sendStatus(202)
 
  if (!alreadyRunning) {
    await processAbortDebit(entry)
    await endAction(entry)
  }
 
  await notifyLedger(entry, action, ['aborted'])
}
 
async function processAbortDebit(entry) {
  const action = entry.actions[entry.processingAction]
  let transaction
  try {
    validateEntity(
      { hash: action.hash, data: action.data, meta: action.meta },
      ledgerSigner,
    )
    validateAction(action.action, entry.processingAction)
 
    if (entry.previousState === 'prepared') {
      transaction = core.release(
        Number(entry.account),
        entry.amount,
        `${entry.handle}-release`,
      )
      action.coreId = transaction.id.toString()
 
      if (transaction.status !== 'COMPLETED') {
        throw new Error(transaction.errorReason)
      }
    }
 
    action.state = 'aborted'
  } catch (error) {
    console.log(error)
    action.state = 'error'
    action.error = {
      reason: 'bridge.unexpected-error',
      detail: error.message,
      failId: undefined,
    }
  }
}

And like before we have to add the import and route handler to src/index.js.

import { abortDebit, commitDebit, prepareDebit } from './handlers/debits.js'
 
// ...
 
app.post('/v2/debits/:handle/commit', asyncErrorWrapper(commitDebit))
app.post('/v2/debits/:handle/abort', asyncErrorWrapper(abortDebit))

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"
}
RECEIVED PUT /v2/intents/3CwfJc5wMlXqPoL_C
...

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 POST /v2/credit/:handle/abort the same way we did for debit. We will add the following code to src/handlers/credits.js.

export async function abortCredit(req, res) {
  const action = 'abort'
  let { alreadyRunning, entry } = await beginActionExisting({
    request: req,
    action,
    previousStates: ['prepared', 'failed'],
  })
 
  res.sendStatus(202)
 
  if (!alreadyRunning) {
    await processAbortCredit(entry)
    await endAction(entry)
  }
 
  await notifyLedger(entry, action, ['aborted'])
}
 
async function processAbortCredit(entry) {
  const action = entry.actions[entry.processingAction]
  try {
    validateEntity(
      { hash: action.hash, data: action.data, meta: action.meta },
      ledgerSigner,
    )
    validateAction(action.action, entry.processingAction)
 
    action.state = 'aborted'
  } catch (error) {
    console.log(error)
    action.state = 'error'
    action.error = {
      reason: 'bridge.unexpected-error',
      detail: error.message,
      failId: undefined,
    }
  }
} 

And of course add the following to src/index.js.

import { abortCredit, commitCredit, prepareCredit } from './handlers/credits.js'
 
// ...
 
app.post('/v2/credits/:handle/commit', asyncErrorWrapper(commitCredit))
app.post('/v2/credits/:handle/abort', asyncErrorWrapper(abortCredit))

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 error 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"
}
RECEIVED PUT /v2/intents/6KTFrr3wo8RcBmxN4
...

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"
}
RECEIVED PUT /v2/intents/lNSiqukb7w-V9BY2z
...
SENT signature to Ledger
{
  "handle": "deb_6HmVZcs4Ef0iOQ6X4",
  "status": "committed",
  "coreId": "10",
  "moment": "2023-03-09T11:11:45.592Z"
}
RECEIVED PUT /v2/intents/askaMCb31lCk02k0B
...

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"
}
RECEIVED PUT /v2/intents/3fKkb1OQm39LYXAUt
...
SENT signature to Ledger
{
  "handle": "deb_cndbsdDiWz-Yn8vmd",
  "status": "aborted",
  "coreId": "13",
  "moment": "2023-03-09T11:15:50.901Z"
}
RECEIVED PUT /v2/intents/G9t-eiXaNX86-TBuo
...

Updating Intents

When completing each of the examples above, we always got a PUT /v2/intents/:handle request in the end that allows us to update the Intent so let’s finally do that.

We’ll create a new file called src/handlers/intents.js.

import { validateEntity } from '../validators.js'
import { saveIntent } from './common.js'
 
export async function updateIntent(req, res) {
  validateEntity(req.body)
  const handle = req.params.handle
 
  if (handle !== req.body?.data?.handle) {
    throw new Error('Request parameter handle not equal to entry handle.')
  }
  await saveIntent(req.body)
 
  res.sendStatus(200)
}

And add the following import and route to src/index.js.

import { updateIntent } from './handlers/intents.js'
 
// ...
 
app.post('/v2/debits/:handle/abort', asyncErrorWrapper(abortDebit))
 
app.put('/v2/intents/:handle', asyncErrorWrapper(updateIntent))

And that’s it, we have implemented the entire Ledger - Bridge interface! 😅

Database persistence

Introduction

Until now our Bridge was completely in-memory. This means that every time we restart it, we loose all data. This also makes it impossible for us to recover from a crash of our service or other situations. We also lose all debugging and reconciliation data. Besides, it is impossible to have more than one instance of the service. Of course, we need persistence, so let’s finally add it.

We will use PostgreSQL through Docker where we will save Bridge data. We will still lose our in-memory core data, but that is not the focus of this tutorial.

Setting up PostgreSQL using Docker

We will be using Docker to set everything up so we will first open a new terminal (yes, we are up to 4), create a docker directory and create two files, src/docker/init.sql and src/docker/docker-compose.yaml.

$ mkdir demo-bridge
$ cd docker
CREATE TABLE "entries" (
    "handle" character varying NOT NULL,
    "hash" character varying NOT NULL,
    "data" jsonb NOT NULL,
    "meta" jsonb NOT NULL,
    "schema" character varying NULL,
    "account" character varying NULL,
    "symbol" character varying NULL,
    "amount" DECIMAL(19,2) NULL,
    "state" character varying NULL,
    "previousState" character varying NULL,
    "actions" jsonb NOT NULL,
    "processingAction" character varying NULL,
    "processingStart" TIMESTAMP WITHOUT TIME ZONE NULL,
    "created" TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT (NOW() AT TIME ZONE 'utc'),
    CONSTRAINT "pk_entries_handle" PRIMARY KEY ("handle")
);
 
CREATE TABLE "intents" (
    "handle" character varying NOT NULL,
    "hash" character varying NOT NULL,
    "data" jsonb NOT NULL,
    "meta" jsonb NOT NULL,
    "created" TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT (NOW() AT TIME ZONE 'utc'),
    CONSTRAINT "pk_intents_handle" PRIMARY KEY ("handle")
);
version: "3.3"
services:
  bridge-postgres:
    restart: always
    image: postgres:15.1-alpine
    volumes:
      - ./var/pgdata:/var/lib/postgresql/data
      - ./init.sql:/docker-entrypoint-initdb.d/init.sql
    ports:
      - "5433:5432"
    environment:
      - POSTGRES_USER=bridge-service
      - POSTGRES_PASSWORD=bridge-service
      - POSTGRES_DB=bridge-service

To start the database we can run and leave the terminal open.

$ docker compose up

Ctrl+C will stop the database or you can run the following command.

$ docker compose down

Of course in a production system you would probably have a dedicated database server and migration scripts in the application, but for us this will do.

Saving to database

We will now replace the entire contents of src/persistence.js with the following.

import pg from 'pg'
 
let pool
 
export async function init() {
  pool = new pg.Pool({
    user: 'bridge-service',
    host: 'localhost',
    database: 'bridge-service',
    password: 'bridge-service',
    port: 5433,
  })
 
  pool.on('error', (err, client) => {
    console.error('Unexpected error on idle client', err)
    process.exit(-1)
  })
}
 
export async function shutdown() {
  await pool.end()
}
 
export async function getEntry(client, handle) {
  return (
      await client.query({
        text: `SELECT "handle", "hash", "data", "meta", "schema", "account", "symbol", "amount", 
                "state", "previousState", "actions", "processingAction", "processingStart", "created" 
             FROM entries 
             WHERE "handle" = $1`,
        values: [handle],
      })
  ).rows[0]
}
 
export async function createEntry(client, entry) {
  return (
      await client.query({
        text: `INSERT INTO entries("handle", "hash", "data", "meta", "schema", "account", "symbol", "amount", 
                "state", "previousState", "actions", "processingAction", "processingStart") 
             VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) 
             RETURNING *`,
        values: [entry.handle, entry.hash, entry.data, entry.meta, entry.schema, entry.account, entry.symbol, entry.amount,
          entry.state, entry.previousState, entry.actions, entry.processingAction, entry.processingStart],
      })
  ).rows[0]
}
 
export async function getEntryForUpdate(client, handle) {
  return (
      await client.query({
        text: `SELECT "handle", "hash", "data", "meta", "schema", "account", "symbol", "amount", 
                "state", "previousState", "actions", "processingAction", "processingStart", "created" 
             FROM entries 
             WHERE "handle" = $1 
             FOR UPDATE`,
        values: [handle],
      })
  ).rows[0]
}
 
export async function updateEntry(client, entry) {
  return (
      await client.query({
        text: `UPDATE entries SET
        "schema" = $1, 
        "account" = $2, 
        "symbol" = $3, 
        "amount" = $4, 
        "state" = $5,
        "previousState" = $6,
        "actions" = $7,
        "processingAction" = $8,
        "processingStart" = $9 
        WHERE "handle" = $10
        RETURNING *`,
        values: [entry.schema, entry.account, entry.symbol, entry.amount, entry.state, entry.previousState,
          entry.actions, entry.processingAction, entry.processingStart, entry.handle],
      })
  ).rows[0]
}
 
export async function upsertIntent(client, intent) {
  return (
      await client.query({
        text: `INSERT INTO intents ("handle", "hash", "data", "meta") VALUES ($1, $2, $3, $4)
                ON CONFLICT ("handle") DO
                UPDATE
                    SET "handle" = $1, "hash" = $2, "data" = $3, "meta" = $4
                RETURNING *`,
        values: [intent.handle, intent.hash, intent.data, intent.meta],
      })
  ).rows[0]
}
 
export async function transactionWrapper(func) {
  const client = await pool.connect()
  try {
    await client.query('BEGIN')
 
    const result = await func(client)
 
    await client.query('COMMIT')
 
    return result
  } catch (error) {
    await client.query('ROLLBACK')
    throw error
  } finally {
    client.release()
  }
}

You will notice we are using the pg library so let’s install it.

$ npm install pg

Let’s add calls to init and shutdown functions to index.js.

import { updateIntent } from './handlers/intents.js'
import * as persistence from './persistence.js'
 
process.on('exit', async () => {
  await persistence.shutdown()
})
 
await persistence.init()
 
const bankName = 'Demo bank'

The persistence functions we have defined above have the same signatures as the ones we replaced with one small difference. The first parameter in each one of them is client. This is the pg client that we will get from our connection pool and that we will pass to the function. Another thing you will notice is the transactionWrapper function. It gets the client from the pool and passes it to the function it wraps. It will enable us to properly handle database transactions with multiple queries and rollbacks when something goes wrong.

The rest of the code will remain the same, the only thing we need to do is wrap all of our calls to persistence functions in transactionWrapper and add client to the arguments list.

import {
  createEntry,
  getEntry,
  transactionWrapper,
  updateEntry,
  upsertIntent,
} from '../persistence.js'
 
export async function beginActionNew({ request, action }) {
  return await transactionWrapper(async (client) => {
    const handle = request.body?.data?.handle
	
		// ...
 
		let entry = await getEntry(client, handle)
		
		if (!entry) {
		      entry = await createEntry(client, {
 
		// ...
		
		entry = await updateEntry(client, entry)
		    return { alreadyRunning: false, entry }
		})
}
 
export async function beginActionExisting({ request, action, previousStates }) {
  return await transactionWrapper(async (client) => {
    const handle = request.body?.data?.handle
 
		let entry = await getEntry(client, handle)
 
		// ...
 
		entry = await updateEntry(client, entry)
		return { alreadyRunning: false, entry }
	})
}
 
export async function endAction(entry) {
  return await transactionWrapper(async (client) => {
 
		// ...
 
		entry = await updateEntry(client, entry)
	  return entry
	})
}
 
export async function saveIntent(intent) {
  return await transactionWrapper(async (client) => {
    await upsertIntent(client, { handle: intent?.data?.handle, ...intent })
  })
}
import { transactionWrapper, updateEntry } from '../persistence.js'
 
// ...
 
async function processPrepareCredit(entry) {
 
		// ...
 
		// Save Entry.
    await transactionWrapper(async (client) => {
      await updateEntry(client, entry)
    })
import { transactionWrapper, updateEntry } from '../persistence.js'
 
// ...
 
async function processPrepareDebit(entry) {
 
		// ...
 
    await transactionWrapper(async (client) => {
      await updateEntry(client, entry)
    })

And that’s it!

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.