Bank integration tutorial (open banking) with Bridge SDK
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:
Endpoint | Request | Description |
---|---|---|
POST /v2/credits | prepare | Called 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/commit | commit | Called 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/abort | abort | Called 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/debits | prepare | Called 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/commit | commit | Called during two-phase commit when Bridge needs to commit a debit Entry. Same as for credit. |
POST /v2/debits/:handle/abort | abort | Called 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/:handle | update | Called 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/v2
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.
State | Description |
---|---|
processing-prepare | This is the first state that an Entry can be in. It is triggered by receiving a prepare request for credit or debit Entries. |
prepared | If a processing-prepare step is successful. We need to notify Ledger of this status. |
failed | If a processing-prepare step fails. We need to notify Ledger of this status. |
processing-commit | If all participants report prepared , Ledger will send commit debit and commit credit requests which will put us into this state. |
committed | If processing-commit step is successful. We need to notify Ledger of this status. |
processing-abort | If Ledger sends an abort request for credit or debit Entries. |
aborted | If processing-abort step is successful. We need to notify Ledger of this status. |
suspended | If 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.