Skip to content

Repository files navigation

Share Transfer Ledger Service

A backend service for recording and managing share transfers between investors with a strong focus on data integrity, auditability, and correctness under concurrency.

Overview

This service simulates the core responsibility of a Transfer Agent:
ensuring that ownership of shares is tracked accurately, immutably, and transparently.

Key guarantees:

  • Investors cannot transfer shares they do not own
  • All operations are fully auditable
  • The system maintains a complete history, including failed attempts
  • Data is protected against race conditions and double-spending

Tech Stack

  • Node.js (NestJS)
  • MongoDB (Mongoose)

How to Run

1. Clone the repository

git clone https://github.com/alextondello/ledger-service.git
cd ledger-service

2. Install dependencies

yarn

3. Setup database

docker compose up -d
yarn migrate

4. Create a .env file with your local database credentials

MONGO_URI=mongodb://root:rootpass@localhost:27017/app_db

5. Start the service

yarn start:dev

API Design

Asset Endpoints

  • POST /asset - Create a new asset
    • Payload:
      {
        "symbol": "string",
        "total_supply": "number"
      }
  • GET /asset/:symbol - Get asset by symbol

Balance Endpoints

  • POST /balance/initial - Create initial balance for an investor
    • Payload:
      {
        "investor": "string",
        "asset_symbol": "string"
      }
  • GET /balance/investor/:investorId/asset/:assetId - Get investor balance for specific asset

Investor Endpoints

  • POST /investor - Create a new investor
    • Payload:
      {
        "name": "string",
        "document_id": "string"
      }
  • GET /investor/:id - Get investor by ID

Ledger Endpoints

  • GET /ledger/investor/:investorId/asset/:assetSymbol - Get ledger entries for investor and asset

Transfer Endpoints

  • POST /transfer - Execute a transfer between investors
    • Payload:
      {
        "from_investor": "string",
        "to_investor": "string",
        "asset_symbol": "string",
        "amount": "number",
        "idempotency_key": "string"
      }
  • GET /transfer/:id - Get transfer by ID

Data Model

1. Assets

Represents the share/asset being transferred.

{
  "symbol": "BTC",
  "total_supply": 21000000
}

2. Investors

Stores investor identity:

{
  "name": "Alice",
  "document_id": "PassportNumber"
}

3. Ledger Entries (Source of Truth)

Each transfer generates:

  • 1 DEBIT entry (sender)
  • 1 CREDIT entry (receiver)
{
  "transfer_id": "ObjectId",
  "type": "DEBIT | CREDIT",
  "investor": "PassportNumber",
  "asset_symbol": "BTC",
  "amount": 100,
  "balance_after": 900
}

4. Transfers

Represents the business-level operation:

{
  "from_investor": "PassportNumber",
  "to_investor": "PassportNumber",
  "asset_symbol": "BTC",
  "amount": 100,
  "status": "SUCCESS | FAILED",
  "failure_reason": "INSUFFICIENT_BALANCE | SELF_TRANSFER | UNKNOWN",
  "idempotency_key": "IDEM123"
}

5. Balances (Derived Cache)

{
  "investor": "PassportNumber",
  "asset_symbol": "BTC",
  "balance": 1000
}

⚠️ Balances are not the source of truth.
They are a cached representation derived from the ledger.

Core Design Decisions

1. Ledger-Based Architecture

The system is built around an append-only ledger:

  • No updates
  • No deletions
  • Full historical trace

Balances are derived from ledger entries.

2. Immutability

  • Transfers are never modified
  • Failed transfers are recorded
  • Corrections must be done via new transactions, not edits

3. Idempotency

Each request uses an Idempotency-Key:

  • Prevents duplicate transfers on retries
  • Stores and replays previous responses

4. Concurrency Safety

To prevent double-spending:

  • Transfers run inside MongoDB transactions
  • Balance updates use atomic conditional updates

Example:

db.balances.updateOne(
  {
    investor_id,
    asset_id,
    balance: { $gte: amount },
  },
  {
    $inc: { balance: -amount },
  },
);

If no document is updated → insufficient funds

Business Rules

  • Amount must be greater than zero
  • Sender and receiver must be different
  • Sender must have sufficient balance
  • All transfer attempts are recorded (success or failure)

Edge Cases Handled

  • Double-spending under concurrency
  • Duplicate requests (idempotency)
  • Self-transfers
  • Invalid amounts (zero/negative)
  • Missing investors or assets
  • Partial failures (handled via transactions)

Auditability

This system enables:

Full Traceability

  • Every transfer is recorded
  • Every ledger movement is preserved

Reconstructability

Balances can be recomputed using:

SUM(CREDITS) - SUM(DEBITS)

Failure Visibility

  • Failed transfers are explicitly stored
  • Includes failure reasons

Trade-offs

Why store balances?

  • Improves performance for reads and validations
  • Trade-off: requires strict consistency handling

Why MongoDB?

Pros:

  • Flexible schema
  • Native transaction support

Cons:

  • Not as strict as relational systems for financial ledgers

Why not pure event sourcing?

This design already follows event sourcing principles, simplified for clarity and speed of implementation.

Assumptions

  • Single-currency / integer-based shares
  • No fractional shares
  • No external settlement system
  • Trusted internal API (no authentication layer implemented)

Final Notes

This implementation prioritizes:

  • Correctness over convenience
  • Auditability over simplicity
  • Safety over performance shortcuts

The design intentionally avoids common pitfalls such as:

  • mutable balances without history
  • lack of concurrency protection
  • missing audit trail for failures

What Could Be Improved

With more time, I would focus on:

  • Expanding unit test coverage, especially end-to-end tests
  • Implementing event streaming (using the outbox pattern) for external audit systems
  • Improving indexing and pagination strategies
  • Adding role-based access control (e.g., admin and auditor roles)
  • Introducing rate limiting and abuse protection
  • Building reconciliation jobs to verify ledger consistency against balances
  • Supporting multi-asset atomic transfers
  • Setting up monitoring and alerting

Testing the API

Create a valid Transfer:

POST /transfer

Body:

{
  "from_investor": "ID123",
  "to_investor": "ID789",
  "asset_symbol": "BTC",
  "amount": 100,
  "idempotency_key": "IDEM0001" <- Change this for every request
}

Responses:

Success

{
  "transfer_id": "string",
  "status": "SUCCESS"
}

Failure

{
  "transfer_id": "string",
  "status": "FAILED",
  "reason": "INSUFFICIENT_BALANCE"
}

About

Share Transfer Ledger Service

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages