Table of Contents

  1. Architecture
  2. Quick Setup
  3. Plugin Architecture
  4. Core Components
  5. App Context Pattern
  6. External Signing Module
  7. Error Handling
  8. Testing
  9. Deployment

Architecture

Service Surface

29 Entities: NetworkInformation, Blocks, Epochs, Pools, Dreps, Transactions, TransactionInputs, TransactionOutputs, TransactionInputAssets, TransactionOutputAssets, TransactionMetadata, Accounts, Addresses, AddressAssets, AddressUTxOs, AddressTransactions, UTxOAssets, LedgerProtocolParameters, TransactionBuilds, TransactionBuildInputs, TransactionBuildOutputs, TransactionBuildInputAssets, TransactionBuildOutputAssets, TransactionSubmissions, TransactionSubmissionErrors, SigningRequests, SignatureVerifications, AddressSigningRequests, AddressTransactionBuilds

15 Read Actions: GetNetworkInformation, GetBlockByHash, GetEpochByNumber, GetPoolById, GetDrepById, GetAccountByStakeAddress, GetTransactionByHash, GetMetadataByTxHash, GetAddressByBech32, GetUTxOsByAddress, GetAssetsByAddress, GetLatestTransactionsByAddress, GetLatestBlock, GetLatestEpoch, GetLedgerProtocolParameters

11 Transaction Actions: BuildSimpleAdaTransaction, BuildTransactionWithMetadata, BuildMultiAssetTransaction, BuildMintTransaction, SubmitTransaction, SubmitSignedTransaction, GetBuildDetails, CheckSubmissionStatus, BuildPlutusSpendTransaction, SetCollateral, GetTransactionBuildsByAddress

8 External Signing Actions: CreateSigningRequest, GetSigningRequest, GetSigningRequestsByAddress, VerifySignature, SubmitVerifiedTransaction, SignWithHsm, SignAndSubmitWithHsm, GetHsmStatus

Layered Architecture

HTTP Client → OData Service (cardano-service.ts / cardano-tx-service.ts / cardano-sign-service.ts)

App Context (server.ts: getCardanoIndexer(), getCardanoClient())

CardanoIndexer + CardanoTransactionBuilder

CardanoClient (Multi-Backend Orchestrator)

Backends: Ogmios (live) + Blockfrost → Koios Fallback

Transaction Builders: CSL / Buildooor

External Signing Module: ExternalSignerModule + SignatureVerifier

SQLite Cache (temporal entities)

Data Flow

1. HTTP Request → Service Handler
2. Input Validation (isTxHash, isBech32Address, etc.)
3. Cache Check (SELECT from DB)
4. On miss: Fetch from blockchain (Blockfrost/Koios)
5. Transform & Store (mappers → UPSERT)
6. Return OData response

Quick Setup

# Install
git clone https://github.com/ODATANO/ODATANO
cd ODATANO
npm ci

# Configure
cp .env.example .env
# Edit .env: Set BLOCKFROST_KEY, NETWORK=preview

# Development (TypeScript, live reload)
npm run cds:watch

# Production (compiles TypeScript → JavaScript)
npm start

# Testing
npm test             # Run tests
npm run test:coverage # Coverage report

Development vs Production:

  • npm run cds:watch - Development mode using ts-node, runs TypeScript directly, auto-reloads on changes
  • npm start - Production mode, compiles TypeScript to JavaScript (.js files gitignored), optimized for deployment

Key Files

srv/
  server.ts                     # App Context initialization (M3)
  cardano-service.cds           # Read entity/action definitions
  cardano-service.ts            # Read handler implementations
  cardano-tx-service.cds        # Transaction service definitions (build, submit)
  cardano-tx-service.ts         # Transaction handler implementations (build, submit)
  cardano-sign-service.cds      # Signing service definitions (signing requests, verification)
  cardano-sign-service.ts       # Signing handler implementations
  blockchain/
    cardano-client.ts           # Multi-backend orchestrator
    cardano-indexer.ts          # Lazy indexing & caching
    cardano-tx-builder.ts       # Transaction builder coordinator (M2)
    backends/
      blockfrost-backend.ts     # Historical provider
      koios-backend.ts          # Fallback provider
      ogmios-backend.ts         # Live WebSocket provider (M2)
    transaction-building/       # M2 Transaction Builders
      csl-tx.ts                 # Cardano Serialization Lib builder
      buildooor-tx.ts           # Buildooor builder
      tx-builder-registry.ts    # Builder factory
    signing/                    # M3 External Signing
      external-signer.ts        # Signing request creation & workflow
      signature-verifier.ts     # Cryptographic signature verification
  utils/
    validators.ts               # Input validation (10+ functions)
    errors.ts                   # Error hierarchy (11 classes)
    mappers.ts                  # API → OData transformations
    tx-build-helper.ts          # Transaction utilities (M2)
    signing-helper.ts           # CIP-30 witness combination (M3)
    backend-request-handler.ts  # DB transaction wrapper

db/schema.cds                   # 29 entities with temporal support
config/config.ts                # Timeouts, network, TTL, builders
test/                           # 25 test files (integration + unit)

Plugin Architecture

ODATANO is published as @odatano/core — a standard SAP CAP plugin that any CAP project can install and auto-configure.

How It Works

CAP automatically detects packages with a cds-plugin.js file at their root. When a consumer installs @odatano/core, the following happens at startup:

1. CAP finds cds-plugin.js in node_modules/@odatano/core/
2. cds-plugin.js requires src/plugin.js
3. src/plugin.ts registers the 'odatano-core' service kind
4. cds.on('served') → src/index.ts initialize() → srv/server.ts initializeFromConfig()
5. CDS models from db/schema.cds and srv/*.cds are merged into the consumer's model
6. Both OData services become available automatically

Plugin File Structure

@odatano/core (npm package)
├── cds-plugin.js                # CAP entry point → require('./src/plugin')
├── src/
│   ├── plugin.ts                # Register kind, cds.on('served'), cds.on('shutdown')
│   └── index.ts                 # Public API: initialize(), shutdown(), getStatus()
├── srv/
│   ├── cardano-service.cds      # CardanoODataService definition
│   ├── cardano-service.js       # Compiled handler
│   ├── cardano-tx-service.cds   # CardanoTransactionService definition
│   ├── cardano-tx-service.js    # Compiled handler
│   ├── cardano-sign-service.cds # CardanoSignService definition
│   ├── cardano-sign-service.js  # Compiled handler
│   ├── server.js                # AppContext, loadConfigFromEnv()
│   ├── blockchain/              # Backends, indexer, tx builder, signing
│   └── utils/                   # Validators, errors, mappers
├── db/
│   └── schema.cds               # 29 entities (namespace: odatano.cardano)
└── config/                      # Network genesis configurations

Plugin Bootstrap (src/plugin.ts)

import cds from '@sap/cds';
let initialized = false;

// Register the 'odatano-core' service kind
cds.env.requires.kinds['odatano-core'] = { impl: '@odatano/core' };

// Initialize on served
cds.on('served', async () => {
  if (initialized) return;
  try {
    const core = await import('./index');
    await core.initialize();
    initialized = true;
  } catch (err) {
    logger.error('Failed to initialize plugin:', err);
  }
});

// Graceful shutdown (closes Ogmios WebSocket, etc.)
cds.on('shutdown', async () => {
  if (!initialized) return;
  const core = await import('./index');
  await core.shutdown();
});

Dual-Mode Config Loading (srv/server.ts)

loadConfigFromEnv() supports both plugin and standalone modes:

Priority: cds.env.requires["odatano-core"].X  >  process.env.X  >  default value

Plugin mode — consumer configures via package.json:

{
  "cds": {
    "requires": {
      "odatano-core": {
        "network": "preview",
        "backends": ["blockfrost", "koios"],
        "blockfrostApiKey": "preview_KEY",
        "txBuilders": ["csl"],
        "primaryTimeoutMs": 30000,
        "fallbackTimeoutMs": 60000,
        "indexTtlMs": 3600000
      }
    }
  }
}

Standalone mode — configured via environment variables (unchanged from previous behavior):

NETWORK=preview
BACKENDS=blockfrost,koios
BLOCKFROST_API_KEY=preview_KEY
TX_BUILDERS=csl

Dual-Mode Initialization Guard

The cds.on('served') hook in srv/server.ts has a guard to prevent double-initialization:

cds.on('served', async () => {
  if (appContext) return;  // Already initialized by plugin
  if (env.SKIP_AUTO_INIT === 'true') return;  // Tests
  // ... normal standalone initialization
});

When running as a plugin, src/plugin.ts hooks served first (plugins load before app code), sets appContext via initializeFromConfig(), and then server.ts’s hook sees appContext is already set and skips.

Public API (src/index.ts)

import { initialize, shutdown, getStatus } from '@odatano/core';
import { getCardanoClient, getCardanoIndexer, getCardanoTxBuilder } from '@odatano/core';
import type { CardanoClientConfig, Network, BackendName } from '@odatano/core';
ExportDescription
initialize()Load config and start blockchain components
shutdown()Close all backend connections
getStatus()Returns status object with initialized and network fields
getCardanoClient()Multi-backend orchestrator instance
getCardanoIndexer()Lazy indexing instance
getCardanoTxBuilder()Transaction builder instance
CardanoClientClient class (for advanced use)
CardanoIndexerIndexer class
CardanoTransactionBuilderTx builder class
loadConfigFromEnv()Config loader (dual CDS/env)

Building & Publishing the Plugin

# Build (compiles TS in-place for @impl resolution)
npm run build:plugin

# Dry-run to check contents
npm pack --dry-run

# Publish to npm
npm publish --access public

The tsconfig.build.json uses outDir: "." so compiled .js files sit alongside .cds files — this is required for CAP’s @impl annotation resolution when installed from node_modules.


Core Components

1. Service Handler (srv/cardano-service.ts)

Entity Handler Pattern:

this.on('READ', Transactions, async (req: Request) => {
    const hash = req.data?.hash;

    // Validate input
    if (hash && !isTxHash(hash)) {
        return rejectInvalid(req, 'Transactions', 'Invalid hash format', 'hash');
    }

    // Use handleRequest wrapper for automatic error handling
    return handleRequest(req, async (db) => {
        if (hash) {
            const existing = await db.run(SELECT.one.from(Transactions).where({ hash }));
            if (existing) return existing; // Cache hit
            return await indexer.indexTransaction(db, hash); // Index from blockchain
        }
        return db.run(req.query);
    });
});

Action Handler Pattern:

this.on('GetTransactionByHash', async (req: Request) => {
    const hash = req.data?.hash;

    if (!hash) return rejectMissing(req, 'Transactions', 'hash');
    if (!isTxHash(hash)) return rejectInvalid(req, 'Transactions', 'Invalid hash', 'hash');

    return handleRequest(req, async (db) => {
        let row = await db.run(SELECT.one.from(Transactions).where({ hash }));
        if (!row) row = await indexer.indexTransaction(db, hash);
        return row;
    });
});

2. Input Validators (srv/utils/validators.ts)

// Transaction hash: 64 hex characters
export const isTxHash = (s: string): boolean => /^[a-fA-F0-9]{64}$/.test(s);

// Bech32 address validation
export const isBech32Address = (s: string): boolean =>
    /^(addr1|stake1|addr_test1|stake_test1)[0-9a-z]+$/.test(s);

// Policy ID: 56 hex characters
export const isPolicyId = (s: string): boolean => /^[a-fA-F0-9]{56}$/.test(s);

Usage: Always validate input BEFORE blockchain calls to prevent unnecessary API requests.

3. Error Hierarchy (srv/utils/errors.ts)

BackendError                            // Base class (500)
├── NotFoundError                       // Resource not found (404)
├── ProviderUnavailableError            // Timeout/unavailable (503)
├── RateLimitError                      // Rate limit exceeded (429)
├── ConfigError                         // Configuration error (500)
├── BackendInitError                    // Init failed (500)
├── AllBackendsFailedError              // All backends failed (503)
├── AllBackendsInitFailedError          // All init failed (500)
├── InsufficientFundsError              // Not enough UTxOs (400) - M2
├── TransactionValidationError          // Invalid signature/CBOR (400) - M2
└── TransactionAlreadySubmittedError    // Duplicate TX (409) - M2

// Helper functions
rejectMissing(req, entity, field)       // Missing parameter (400)
rejectInvalid(req, entity, msg, field)  // Invalid input (400)

4. Indexing Layer (srv/blockchain/cardano-indexer.ts)

Lazy On-Demand Indexing:

  • Data fetched from blockchain only when first requested
  • Temporal entities (Addresses, Accounts) with TTL-based refresh
  • Non-temporal entities (Transactions, Blocks) persist permanently
  • UPSERT operations for automatic insert/update
  • Related entities indexed atomically

App Context Pattern

Overview (srv/server.ts)

M3 introduced a centralized App Context pattern that manages all blockchain components as a singleton:

interface AppContext {
  cardanoClient: CardanoClient;
  cardanoIndexer: CardanoIndexer;
  cardanoTxBuilder: CardanoTransactionBuilder;
}

Key Functions

// Get the singleton context (must be called after CAP bootstrap)
getAppContext(): AppContext

// Convenience functions for services
getCardanoIndexer(): CardanoIndexer
getCardanoClient(): CardanoClient

// Test utilities
createTestContext(backends, txBuilderName?, protocolParams?): Promise<AppContext>
resetAppContext(context: AppContext | null): void
shutdownAppContext(): Promise<void>

Usage in Services

// In cardano-service.ts or cardano-tx-service.ts
import { getCardanoIndexer, getCardanoClient } from './server';

srv.on('GetTransactionByHash', async (req: Request) => {
  return handleRequest(req, async (db) => {
    // Use shared indexer instance
    return await getCardanoIndexer().indexTransaction(db, hash);
  });
});

// In cardano-sign-service.ts (CardanoSignService)
srv.on('SubmitVerifiedTransaction', async (req: Request) => {
  return handleRequest(req, async (db) => {
    // Use shared client instance
    await getCardanoClient().submitTransaction(signedTxCbor);
  });
});

Bootstrap Process

The context is automatically initialized when CAP starts:

cds.on('served', async () => {
  if (env.SKIP_AUTO_INIT === 'true') return; // For tests

  const config: CardanoClientConfig = {
    network: env.NETWORK || 'preview',
    backends: env.BACKENDS?.split(',') || ['koios'],
    // ... other config from environment
  };

  appContext = await initializeAppContext(config);
});

Test Context Management

// In test setup
beforeAll(async () => {
  const testContext = await createTestContext(['koios'], 'csl');
  resetAppContext(testContext);
});

afterAll(async () => {
  await shutdownAppContext(); // Clean up connections
});

External Signing Module

Overview (srv/blockchain/signing/)

M3 provides complete external signing workflow with private key isolation:

ExternalSignerModule

// Create signing request for external signing
createSigningRequest(buildId, unsignedTxCbor, txBodyHash, network, message): UnsignedTxExportPayload

// Verify signed transaction cryptographically
verifySignedTransaction(signedTxCbor, expectedTxBodyHash): SignatureVerificationResult

// Workflow state management
createWorkflowState(request): SigningWorkflowState
markAsSigned(state, signedTxCbor): SigningWorkflowState
markAsVerified(state, result): SigningWorkflowState
markAsSubmitted(state, txHash): SigningWorkflowState

SignatureVerifier

// Verify without throwing
verify(signedTxCbor, options?): SignatureVerificationResult

// Verify with throwing on failure
verifyOrThrow(signedTxCbor, options?): SignatureVerificationResult

// Utility functions
extractTxBodyHash(txCbor): string
isSigned(txCbor): boolean
getWitnessCount(txCbor): number

Signing Workflow States

enum SigningStatus {
  PENDING = 'pending',      // Request created, awaiting signing
  SIGNED = 'signed',        // Transaction signed externally
  VERIFIED = 'verified',    // Signature cryptographically verified
  SUBMITTED = 'submitted',  // Transaction submitted to blockchain
  EXPIRED = 'expired',      // TTL exceeded (30 minutes default)
  FAILED = 'failed',        // Signing or verification failed
}

CIP-30 Wallet Support (srv/utils/signing-helper.ts)

// Combine unsigned TX with CIP-30 wallet witness set
combineTransactionWithWitnesses(unsignedTxCbor, witnessSetCbor): string

// Detect if CBOR is witness set (CIP-30) or full transaction
isWitnessSetCbor(cborHex): boolean

5. Multi-Backend Failover (srv/blockchain/cardano-client.ts)

async getTransaction(hash: string): Promise<Transaction> {
    await this.ensureInitialized();
    const errors: BackendError[] = [];

    for (let i = 0; i < this.backends.length; i++) {
        const backend = this.backends[i];
        const timeout = i === 0 ? PRIMARY_TIMEOUT_MS : FALLBACK_TIMEOUT_MS;

        try {
            return await Promise.race([
                backend.getTransaction(hash),
                new Promise((_, reject) => setTimeout(() =>
                    reject(new ProviderUnavailableError(`Timeout ${timeout}ms`)), timeout))
            ]);
        } catch (err) {
            errors.push(normalizeBackendError(err, backend.constructor.name));
        }
    }
    throw new AllBackendsFailedError('getTransaction', errors);
}

Error Handling

Status Code Mapping

HTTPScenarioExample
200SuccessTransaction found
400Bad RequestInvalid hash format, missing parameter
404Not FoundTransaction/address doesn’t exist
429Rate LimitToo many requests
500Internal ErrorConfiguration error
503Service UnavailableProvider timeout, all backends failed

Error Flow

HTTP Request

Input Valid? → NO → 400 (rejectInvalid/rejectMissing)
    ↓ YES
Cache Hit? → YES → 200 OK
    ↓ NO
Provider Request
    ├─→ 200 OK (success)
    ├─→ 404 Not Found
    ├─→ 429 Rate Limit
    ├─→ 503 Timeout/Unavailable
    └─→ 500 Internal Error

Implementation

// Validation errors (400)
if (!hash) return rejectMissing(req, 'Transactions', 'hash');
if (!isTxHash(hash)) return rejectInvalid(req, 'Transactions', 'Invalid hash', 'hash');

// Backend errors (automatic via handleRequest wrapper)
return handleRequest(req, async (db) => {
    // All BackendErrors caught and normalized automatically
    const tx = await indexer.indexTransaction(db, hash);
    return tx;
});

Testing

Test Example

import cds from '@sap/cds';

describe('CardanoODataService', () => {
    let GET: Function, POST: Function;

    beforeAll(async () => {
        const server = await cds.test('serve', '--in-memory');
        ({ GET, POST } = server);
    });

    test('GetTransactionByHash - valid tx', async () => {
        const { data } = await POST('/odata/v4/cardano-odata/GetTransactionByHash', {
            hash: '2b8216b428b5292a4b13075cf37b26434f890a4ffcce1f75da1f85d2297efe83'
        });

        expect(data.hash).toBe('2b8216b428b5292a4b13075cf37b26434f890a4ffcce1f75da1f85d2297efe83');
        expect(data.fee).toBeDefined();
    });

    test('GetTransactionByHash - invalid hash', async () => {
        try {
            await POST('/odata/v4/cardano-odata/GetTransactionByHash', { hash: 'invalid' });
            fail('Should have thrown error');
        } catch (error) {
            expect(error.response.data.error.message).toContain('Invalid');
            expect(error.response.status).toBe(400);
        }
    });
});

Deployment

Environment Configuration

.env (Development)

LOG_LEVEL=debug
NETWORK=preview
BLOCKFROST_KEY=your_preview_key
PRIMARY_TIMEOUT_MS=8000
FALLBACK_TIMEOUT_MS=10000
INDEX_TTL_MS=60000

.env (Production)

LOG_LEVEL=info
NETWORK=mainnet
BLOCKFROST_KEY=your_mainnet_key
PRIMARY_TIMEOUT_MS=8000
FALLBACK_TIMEOUT_MS=10000
PORT=4004

Production Build

npm run build
NODE_ENV=production npm start

Docker (via docker-compose.yml)

# Start
docker-compose up -d

# Logs
docker-compose logs -f

# Stop
docker-compose down

See Docker Deployment Guide for details.


Troubleshooting

Common Issues

Port 4004 in use:

# Windows
netstat -ano | findstr :4004
taskkill /PID <PID> /F

# Linux/Mac
lsof -i :4004
kill -9 <PID>

Tests failing:

# Ensure server is running
npm run cds:watch  # Terminal 1
npm test           # Terminal 2 (wait 3s)

Slow responses:

BLOCKFROST_KEY not set:

  • Koios tests run without key (always available)
  • Blockfrost tests require valid key
  • Get key: https://blockfrost.io

Development Best Practices

  1. Always validate input before calling providers
  2. Use handleRequest wrapper for automatic error handling
  3. Test edge cases including error scenarios
  4. Log important operations for debugging
  5. Implement timeouts to prevent hanging requests
  6. Check cache/db first for immutable data to reduce API calls
  7. Document changes in relevant files

Additional Resources: