Migrating from BaaS

Guide for financial institutions moving an existing integration from the baas bank adapter to the bridge-core protocol

If you have a working v1 bank adapter integration (the synchronous, baas REST surface), this is how you move it to bridge-core — Minka Ledger's network-agnostic participant bridge.

This is not a rename — you are replacing a synchronous, HTTP surface with an asynchronous one. The concepts map directly, but the wire format and flow are new.

Audience

ReaderStart here
VP or delivery lead sizing effortExecutive summary, Scope delta
Architect scoping the integrationC4 diagrams, Protocol shift, sections 5 to 9
Backend developer writing codeEndpoint mapping through Worked example
QA or certification leadTesting strategy delta

Executive summary

What changes

Dimensionv1 bank adapter todaybridge-core target
Payment networkTransfiya (Colombia) onlyNetwork-agnostic
Response styleSynchronous { code, message, data } envelope with coreId and status inlineAsynchronous: 202 Accepted ack, real result arrives in a signed proof
HTTP verbsPOST /debit, POST /credit, POST /refund, GET /status/:coreId, POST /status (notify)POST /v2/debits, POST /v2/debits/{h}/commit, POST /v2/debits/{h}/abort (plus credit twins), POST /v2/effects/{h} (via effects)
2PC locationImplicit: /debit is one-shot at the REST edge. 2PC only exists on the IBankAdapter SDK path.Explicit: every financial institution REST call is a distinct phase (prepare, commit, abort).
Amount formatDecimal string: "200000.00"Integer minor units: 442120. Minor-unit count follows ISO 4217 decimals (COP = 2 decimals, so 200000.00 COP → 20000000).
CurrencyColombia-only, $tin (COP)ISO 4217 lowercase via symbol.handle: cop, usd, brl
HandlesWallet $573504242424, signer wS1EU... (28 char)deb_*, cre_*, $int.*; account is <type>:<number>@<bank-domain>
Auth (inbound)x-api-key, OAuth2 Bearer, HMAC-SHA256 x-minka-signature on webhookEdDSA JWT signed by the Hub on every call
Auth (outbound)None (sync responses carry the result)Pick ed25519-jwt (self-signed) or oauth-client-credentials (Hub-issued RS256)
Error codesNumeric (99, 101, 121, 122, 128, 315, 323, ...)String-prefixed: bridge.account-insufficient-balance, core.bridge-prepare-failed, record.not-found
CryptographyHMAC on webhook, Ed25519 signing via SDK on bridge responsesEd25519 end to end: RFC 8785 canonical JSON, SHA-256 double-hash digest, signed proof envelope on every phase
AnchorsIn scope: legacy /api/bridge/v2/onboard and regulated /api/anchors/* (DICE, SPI)Out of scope.
Actions, IOUsIn scope: UPLOAD, DOWNLOAD signingOut of scope.
RefundPOST /refund endpointOut of scope. Reversals are modelled as a new intent upstream.
Reference implsJava (Spring Boot), Node (NestJS)Java (Spring Boot), Node (NestJS), .NET (ASP.NET Core 8)

What stays the same

  • TLS 1.2 minimum on all public surfaces.
  • Ed25519 is still the signing primitive. Your financial institution still holds one Ed25519 keypair.
  • PII redaction obligations (firstName, lastName, documentNumber, accountRef).
  • IdempotencyRecord still governs replay safety. Only the keying changes.
  • The financial institution's core system is untouched. Both adapters are façades.

What you can reuse

The mock-core ledger carries over unchanged — the operation set (hold, settle, release, reserveCredit, applyCredit, releaseCredit) is identical across both adapters. What changes is the layer above it: controllers, DTOs, idempotency, auth, signing, and error mapping. Each piece maps cleanly from v1 to bridge-core, and the reference implementations cover the full surface.

C4 diagrams

Context

The adapter talks to one system — the Ledger. The Ledger routes to whichever external network applies. The adapter has no opinion about which one.

Container, bridge-core

Differences at the container level:

  • AnchorConsumer and ActionConsumer are gone. Those surfaces are not part of this adapter. Delete them.
  • One BankEndpointsController with seven routes splits into two structural controllers (DebitCtrl, CreditCtrl) plus an EffectCtrl for receiving ledger effect signals. Each side owns three phases instead of one terminal verb.
  • The three-guard stack (JWT, x-api-key, HMAC) collapses into a single AuthFilter that verifies the Hub's EdDSA JWT on every inbound call. No API key, no HMAC webhook signature.
  • ProofSubmitter is new and load-bearing: an outbound HTTP client with a queue, retry, and crypto. In v1 the outbound path was "return from the controller." Here the controller acks 202 and the submitter completes the story later.
  • The Crypto module is a first-class container now. v1 delegated signing to the SDK. Here the financial institution owns RFC 8785 canonical JSON, SHA-256 double-hash, and Ed25519 sign-of-digest. The library helps, but the financial institution makes the calls.
  • An AuthStrategy port (not drawn) lets you pick ed25519-jwt or oauth-client-credentials per tenant. One more thing to configure.

Scope delta

In scope for both

  • Move funds between accounts on the financial institution's core via a 2-phase commit.
  • Idempotent under retry on (handle, phase) or (transactionId, ...) keys.
  • TLS 1.2+.
  • Ed25519 signing of outbound messages.
  • Mock financial institution core for certification.
  • Multi-impl parity (Java and Node share a contract test suite).

Dropped by bridge-core

v1 surfaceWhy it is gone
Legacy anchor onboarding (/api/bridge/v2/onboard, default signer, signer removal)Anchor management is not part of the financial-institution-side bridge contract.
Regulated anchor onboarding (/api/anchors/*, DICE lookup, timestamps)Alias resolution and anchor lifecycle are out of scope.
Action consumer (IOU signing for cloud-balance movements)Cloud-balance IOUs are a ledger-upstream concern. The financial-institution-side bridge only covers core movements.
POST /refundReversals are a second intent upstream. No dedicated refund endpoint at the financial institution bridge.
GET /status/:coreId (financial-institution-side status)Status propagation flows through effects — register an effect to receive intent status changes via POST /v2/effects/{handle} or a webhook. There is no financial-institution-read status surface.
GET /account/verification/:accountIdValidation happens inline during prepare. If the account is inactive, the prepare proof returns status: failed with reason: bridge.account-inactive.
GET /user/info/:userIdKYC exposure is not part of the bridge contract.
/login, /transfiya/oauth/token as inbound routes hosted by the financial institutionOutbound POST /v2/oauth/token against the Hub replaces this. The financial institution calls the Hub's token endpoint; it does not host a login endpoint of its own.
Reconciliation endpoints (GET /api/bridge/v2/actions, /transfers/actions)Out of scope of the bridge contract.

New in bridge-core

SurfacePurpose
POST /v2/debits/{handle}/commit and .../abortExplicit second phase of 2PC as HTTP endpoints. v1 had no equivalent. Its 2PC lived behind the IBankAdapter SDK boundary and /debit was one-shot.
POST /v2/credits/{handle}/commit and .../abortSame, for the credit side.
POST /v2/intents/{handle}/proofs (outbound from the financial institution)The financial institution posts a signed Ed25519 proof to the Hub for every phase. This is the real result channel. The 202 Accepted is just an ack.
POST /v2/effects/{handle} (inbound, via events trait)Ledger effects deliver intent status events (completed, rejected) to the bridge. Replaces POST /status with the standard effects delivery mechanism. Register an effect that filters on intent status changes during bridge setup.
oauth-client-credentials transport-auth modeNew configuration path. Previously the financial institution did not exchange credentials with the Hub at all.
RFC 8785 canonical JSON, SHA-256 double-hashExplicit, financial-institution-implemented digest construction. v1 hid this inside the SDK.

Changed shape

Conceptv1 shapebridge-core shape
Response envelope{ code, message, data } or { error: { code, message } }202 Accepted empty body. Real result in a proof { method, public, digest, result, custom: { moment, handle, status, coreId, reason, detail } }.
AmountDecimal string "200000.00"Integer minor units 20000000 (the cents)
CurrencyImplicit COP ($tin)Explicit symbol.handle: "cop" (ISO 4217 lowercase)
Idempotency keytransactionId, originalTransactionId, tx_iddata.handle (deb_*, cre_*) for prepare. Path {handle} for commit/abort. (handle, status) for notifications.
Status valuesCREATED → INITIATED → PENDING → ACCEPTED → COMPLETED or REJECTED via ERROR (transfer-level, 7 states)created → pending → prepared → committed → completed or aborted → rejected (intent-level, 7 states)
Error codesNumeric (Transfiya table)String reasons with prefix bridge.*, core.*, record.*
Inbound Hub-to-financial-institution authOAuth2 Bearer token from financial institution's own /loginEdDSA JWT the Hub self-signs per request
Outbound financial-institution-to-Hub authNonePer-request self-signed EdDSA JWT, or Hub-issued RS256 access token

Protocol shift, synchronous to asynchronous

The core conceptual change. Once you understand this, the rest of the migration follows naturally.

v1, synchronous and terminal

The HTTP response is the result. Whatever happened on the core is already decided when the response goes on the wire. If the Hub retries, your idempotency store returns the same envelope verbatim.

bridge-core, asynchronous with signed-proof completion

Six async steps follow the sync 202. The 202 is just "got it, working on it." The Hub's state machine only advances when it receives the signed proof at POST /v2/intents/{handle}/proofs.

What this means for your design

  • All validation moves into prepare. Commit must succeed if prepare did. If your v1 code validated in the response path of /debit, that logic has to move up into the prepare stage.
  • You need a worker or queue. The HTTP thread acks 202 and hands off. The background worker does the core call and then submits the signed proof. On restart, the queue has to be persistent or the handle has to be re-discoverable. Persistence model is a per-impl decision: mock-core mode does not survive restart; postgres mode does.
  • Failures land in the proof, not the HTTP response. status: failed is valid only in prepare proofs. Core rejections surface as a failed proof rather than a synchronous error.
  • Retries are Hub-driven and open-ended. The Hub retries POST /v2/debits with the same data.handle until it gets 202. Your idempotency key is the handle, not the HTTP request.
  • POST /v2/intents/{handle}/proofs must retry on 5xx. Exponential backoff: 1 s initial, doubling, 60 s cap, 10 attempts, dead-letter plus operator alert on exhaustion. The reference implementations include this out of the box.

Endpoint mapping

Wire each existing v1 handler to its bridge-core counterpart, or drop it.

v1 routebridge-core counterpartNotes
POST /debitPOST /v2/debits (prepare), POST /v2/debits/{h}/commitOne synchronous endpoint becomes two async endpoints plus a signed proof per phase. Validation moves to prepare, mechanical settle moves to commit.
POST /creditPOST /v2/credits (prepare), POST /v2/credits/{h}/commitSame pattern as debit. Prepare validates the target. Commit applies the credit.
POST /refundNoneDelete. Reversals are modelled upstream by issuing a new intent with flipped source and target. If your financial institution workflow needs a refund concept, surface it in your internal API but do not expose it to the Hub.
GET /status/:coreIdNoneDelete. Status flows through effects — register an effect to receive notifications via POST /v2/effects/{h} or a webhook. You do not publish a read endpoint.
GET /account/verification/:accountIdInlined into POST /v2/debits and POST /v2/credits prepareDelete the endpoint. Run the same check inline during prepare; surface via status: failed, reason: bridge.account-inactive or bridge.account-not-found.
GET /user/info/:userIdNoneDelete. KYC is not a bridge concern.
POST /status (webhook)POST /v2/effects/{handle} (via effect registration)Register an effect that filters on intent status changes. The effect delivers to your bridge via POST /v2/effects/{handle} (if you declare the events trait) or to any webhook URL. Body contains the event payload with intent data and status. Auth changes from HMAC x-minka-signature to EdDSA JWT.
POST /login (financial institution hosts)RemovedThe financial institution no longer hosts an auth endpoint. In oauth-client-credentials mode you call the Hub's POST /v2/oauth/token.
POST /transfiya/oauth/token (financial institution hosts)RemovedSame.
/api/bridge/v2/onboard (legacy anchor)Out of scopeDelete. Anchor concerns leave this adapter.
/api/anchors/* (regulated DICE, SPI)Out of scopeDelete. Same.
Action consumer (sign IOU)Out of scopeDelete.

New outbound calls from the financial institution, which have no v1 equivalent:

New outbound routePurpose
POST /v2/intents/{handle}/proofs (financial institution to Hub)Submit the signed Ed25519 proof result of every prepare, commit, or abort. This is how the Hub learns the real outcome.
POST /v2/oauth/token (financial institution to Hub, only in oauth-client-credentials mode)Exchange clientId and clientSecret for a short-lived accessToken. Cached per clientId, refreshed ~60 s before expiry.

Request/response shape delta

Debit, before

POST /debit
Authorization: Bearer <token>
x-api-key: <key>
Content-Type: application/json

{
  "transactionId": "tx-abc-123",
  "target": "CHECKING:1234567@banco",
  "amount": "200000.00",
  "description": "Pago de servicios",
  "transferId": "tfr-456"
}
200 OK

{
  "code": "00",
  "message": "Acción realizada con éxito",
  "data": { "coreId": "TRA-TY-7788", "amount": "200000.00", "status": "SUCCESS" }
}

Debit, after (prepare phase only)

POST /v2/debits
Authorization: Bearer <eddsa-jwt or oauth-access-token>
x-ledger: <tenant handle>
Content-Type: application/json

{
  "data": {
    "luid": "$deb.7788",
    "amount": 20000000,
    "handle": "deb_01u9RGCevt4rEkRMV",
    "inputs": [0],
    "intent": {
      "data": {
        "access": [{ "action": "any", "signer": { "public": "<base64 hub key>" } }],
        "handle": "<intent handle>",
        "claims": [
          {
            "action": "transfer",
            "amount": 20000000,
            "source": { "handle": "checking:1234567@banco", "custom": { "name": "Acme SA", "entityType": "business", "documentType": "txid", "documentNumber": "900123456" } },
            "symbol": { "handle": "cop" },
            "target": { "handle": "checking:9876543@other-bank", "custom": { "firstName": "Julio", "lastName": "Guillén", "accountRef": "wgS5e2QvCXkLgAf7sZ642w8q4HjZVQG6qY", "entityType": "individual", "documentType": "cc", "documentNumber": "4407906192" } }
          }
        ],
        "custom": { "description": "Pago de servicios", "paymentNetwork": "TFY", "useCase": "send.b2p" },
        "schema": "payment"
      },
      "hash": "<hex sha256 of canonical(intent.data)>",
      "luid": "$int.7788",
      "meta": {
        "moment": "2026-04-17T10:22:11.000-05:00",
        "owners": ["<base64 hub key>"],
        "proofs": [],
        "status": "pending",
        "thread": "<thread id>"
      }
    },
    "schema": "debit",
    "source": { "handle": "checking:1234567@banco", "custom": { "name": "Acme SA", "entityType": "business", "documentType": "txid", "documentNumber": "900123456" } },
    "symbol": { "handle": "cop" },
    "target": { "handle": "checking:9876543@other-bank", "custom": { "firstName": "Julio", "lastName": "Guillén", "accountRef": "wgS5e2QvCXkLgAf7sZ642w8q4HjZVQG6qY", "entityType": "individual", "documentType": "cc", "documentNumber": "4407906192" } }
  },
  "hash": "<hex sha256 of canonical(data)>",
  "meta": {
    "proofs": [
      { "method": "ed25519-v2", "public": "<base64 hub key>", "digest": "<hex>", "result": "<base64 hub signature>", "custom": { "moment": "2026-04-17T10:22:11.000-05:00" } }
    ]
  }
}
202 Accepted

Later, on the worker thread:

POST /v2/intents/{intentHandle}/proofs
Authorization: Bearer <eddsa-jwt or oauth-access-token>

{
  "method": "ed25519-v2",
  "public": "<base64 ed25519-raw public key>",
  "digest": "<hex sha256 double-hash>",
  "result": "<base64 ed25519 signature of digest>",
  "custom": {
    "moment": "2026-04-17T10:22:11.000-05:00",
    "handle": "deb_01u9RGCevt4rEkRMV",
    "status": "prepared",
    "coreId": "TRA-TY-7788"
  }
}

Field-level migration crib

v1bridge-coreNotes
transactionId (request body)data.handle (request body), e.g. deb_01u9RGCevt4rEkRMV (debit) or cre_01u9RGCevt4rEkRMV (credit)Handle is Hub-generated, prefix deb_ or cre_ plus ~17 alphanumeric chars. You consume it; you do not mint it.
transferIddata.intent.data.handle or data.intent.luidIntent ID on the Hub side. Appears in the intent sub-object, not at top level.
target (string account id)data.target.handle format <accountType>:<accountNumber>@<bank-domain>Structured. accountType is checking, savings, etc.
amount (decimal string, e.g. "200000.00")data.amount (integer minor units, e.g. 20000000)No more string-to-decimal parsing. No more float risk.
descriptiondata.intent.data.custom.descriptionMoves under intent.custom.
Response data.coreId (sync)Proof custom.coreId (async)Travels in the POST /proofs body, not the inbound HTTP response.
Response data.status: "SUCCESS" or "FAILED" (sync)Proof custom.status: "prepared" or "failed" (async, prepare only), or "committed" / "aborted" (commit, abort)Four phase-specific values replace the binary SUCCESS/FAILED.
Error response error.code: 315 (numeric)Proof custom.reason: "bridge.account-insufficient-balance" (string)Full mapping in Error model.
Status notify POST /status body status: "COMPLETED"Effect event payload with intent meta.status: "completed" via POST /v2/effects/{h}Lowercase. ACCEPTED has no direct counterpart at the intent level. Delivered through effects, not a dedicated bridge endpoint.

Authentication changes

Inbound, Hub calls the financial institution

Dimensionv1bridge-core
Primary credentialx-api-key header plus OAuth2 Bearer access_token issued by the financial institution's own /loginEdDSA JWT the Hub self-signs per request
Webhook integrityHMAC-SHA256 x-minka-signature over raw body, shared hashKeySame EdDSA JWT, no separate webhook signature
VerificationCompare API key; verify JWT against financial institution's own HS256 secret; HMAC compare for webhooksVerify JWT signature against Hub's registered public key (EdDSA). Check aud, iss, iat, exp.
What the financial institution storesAPI keys, user credentials, webhook hash keys, Hub's public key for response signingHub's public key only, plus the financial institution's own Ed25519 keypair for outbound

Rip out the three-guard stack. A single AuthFilter verifies an EdDSA JWT plus optional x-ledger: <tenant handle>. That's it for inbound.

Outbound, financial institution calls the Hub

New. v1 had no outbound auth to manage.

Pick one mode per tenant via adapterConfig.auth.mode:

ModeGood forCost per requestCredential rotation
ed25519-jwtFinancial institution already holds Ed25519 keys, no IdP, wants direct non-repudiationOne Ed25519 sign (~µs)Rotate by reissuing the keypair and re-registering the public key with the Hub
oauth-client-credentialsFinancial institution has an enterprise IdP or secret vault, ops prefers client-secret rotationPeriodic token exchange (every 3600 s), cached token between callsRotate clientSecret centrally via Hub admin UI. No adapter change.

Both modes still require the Ed25519 keypair. Proofs are body-level signed regardless of transport auth. OAuth only replaces the Authorization: Bearer header source.

Configuration shape:

{
  "adapterConfig": {
    "auth": {
      "mode": "ed25519-jwt", // or "oauth-client-credentials"
      "ed25519Jwt": { "expirySeconds": 60, "skewSeconds": 5 },
      "oauth": {
        "tokenEndpoint": "/v2/oauth/token",
        "clientIdRef": "vault://bridge-core/<tenant>/oauth/client-id",
        "clientSecretRef": "vault://bridge-core/<tenant>/oauth/client-secret",
        "refreshSkewSeconds": 60,
        "exchangeRetry": { "maxAttempts": 5, "maxBackoffSeconds": 30 }
      }
    },
    "signer": {
      "handle": "bank-acme",
      "publicKey": "<base64 ed25519-raw>",
      "privateKeyRef": "vault://bridge-core/<tenant>/ed25519/private-key"
    }
  }
}

Inline clientSecret is rejected at config-load. Vault refs only. Same for the Ed25519 private key.

Idempotency model

Keys

v1bridge-core
transactionId (debit, credit, status-notify)data.handle (prepare), path {handle} (commit, abort)
originalTransactionId (refund)N/A
labels.tx_id (transfer)N/A
(transferId, transactionId) on the SDK path(handle, signal) for POST /v2/effects/{h}

Record shape

Both adapters store idempotency records for 24h. bridge-core also stores the submitted proof body, so retries skip both the core call and the proof submission.

interface ProofBody {
  method: 'ed25519-v2';
  public: string;        // base64 ed25519-raw
  digest: string;        // hex sha256 double-hash
  result: string;        // base64 ed25519 signature
  custom: {
    moment: string;      // ISO 8601
    handle: string;      // deb_* or cre_*
    status: 'prepared' | 'committed' | 'aborted' | 'failed';
    coreId?: string;
    reason?: string;     // bridge.* / core.* / record.* — only when status=failed
    detail?: string;     // only when status=failed
  };
}

interface IdempotencyEntry {
  handle: string;          // deb_* or cre_*
  phase: 'prepare' | 'commit' | 'abort';
  outcome: 'success' | 'failed';
  proofPayload: ProofBody; // the proof already submitted
  coreId?: string;
  createdAt: timestamp;
  ttl: 24h;
}

Replay semantics

  • v1: a duplicate POST /debit returns the stored response envelope verbatim. Core is never touched twice.
  • bridge-core: a duplicate POST /v2/debits returns 202 Accepted again. Core is never touched twice, and the proof is not re-submitted. The first proof is authoritative.

State machines

v1, transfer

CREATED → INITIATED → PENDING → ACCEPTED → COMPLETED

                                 ERROR → REJECTED

Terminal: COMPLETED, REJECTED. 7 total states.

bridge-core, intent

created → pending → prepared → committed → completed

                    aborted → rejected

Terminal: completed, rejected. 7 total states (including created, which is Hub-internal).

bridge-core, per-handle (debit or credit)

received → prepared → committed
    ↓         ↓
  failed    aborted

Each debit or credit handle has its own local lifecycle. The intent lifecycle is the join of both handles.

Mapping

v1 transfer statebridge-core equivalent
CREATEDcreated (Hub-only)
INITIATEDpending (Hub-only)
PENDINGpending on the intent. Per-handle received.
ACCEPTEDprepared (when both sides have prepared)
COMPLETEDcommitted on each handle, completed on the intent
ERRORNo direct state. A failed proof on prepare causes the intent to move toward aborted or rejected.
REJECTEDrejected

Anchor and account status are gone. They belong to other adapters.

Error model

Shape

v1bridge-core
Wire format{ "error": { "code": 315, "message": "Insufficient funds" } } in sync HTTP body"custom": { "status": "failed", "reason": "bridge.account-insufficient-balance", "detail": "..." } inside the signed proof
TransportSync HTTP status + JSON body202 Accepted sync. Async proof carries the failure.
Code spaceNumeric (Transfiya)String prefixed bridge.*, core.*, record.*
Where it is legalAny phaseOnly in prepare proofs. Commit and abort cannot be failed. If commit cannot proceed, retry the core call indefinitely and alert ops.

Code mapping crib

v1 numericbridge-core string reasonWhen
315 Insufficient fundsbridge.account-insufficient-balanceSource < amount at prepare
307 Inactive accountbridge.account-inactiveSource or target suspended
309 Account does not existbridge.account-not-foundTarget account missing at credit prepare
308 Cancelled account, 306 Seizurebridge.account-inactive (generalised)Terminal account states
118 Schema validation errorrecord.schema-invalidBody fails canonical JSON or schema check. Returned as sync 400, not a proof.
121 Resource does not existrecord.not-foundGeneric upstream lookup miss
122 Duplicate resourceN/A (idempotent replay is silent)Replay no longer surfaces as an error. It is a no-op that returns the cached proof.
125 Already processedN/ASame. Idempotency is silent.
128 Invalid state for requestN/Abridge-core does not report illegal transitions to the Hub. The first commit or abort wins, subsequent calls are replays.
300 Transfer timeout, 323 Transfer expiredHandled upstreamIntent TTL is a Hub concern. The financial institution receives a rejected status via effects.
325 Bank refused init, 330 Cannot connect to financial entitycore.bridge-prepare-failedWrapper around any core failure. Adapter retries core up to 3× before failing.
332 Unexpected financial entity errorcore.bridge-prepare-failedSame.
99 Unexpected server errorrecord.schema-invalid or bridge.entry-rejected depending on root causeCase-by-case.
101 AuthSync 401 with no proofAuth fails before the adapter creates any state.
144 Rate limitSync 429 with Retry-After headerTransport-level. No proof involved.
313, 329 User or transaction limitbridge.entry-rejectedManual or policy-driven rejection. Record reason in audit log.

Do not invent new reason strings. The Hub parses them.

Cryptography

What v1 did

  • Bridge responses (crossing back to the ledger) signed via SDK Ed25519. The financial institution did not compute digests. The SDK did.
  • Webhook bodies authenticated via HMAC-SHA256 with a shared hashKey delivered out of band at webhook creation.

What bridge-core requires

Every proof follows the same recipe, and you own every step:

  1. Build the custom object (moment, handle, status, coreId, reason, detail).
  2. Compute the digest: sha256(primaryHashHex + canonicalJSON(custom)).
    • primaryHashHex is the hash field of the parent object (intent hash for proofs submitted to POST /v2/intents/{h}/proofs).
    • canonicalJSON is RFC 8785: alphabetically sorted keys, no whitespace.
    • Direct concatenation, no separator. Empty custom becomes empty string.
  3. Sign the digest bytes (hex-decoded) with Ed25519. On Node, the first arg to crypto.sign is undefined.
  4. Serialise the proof:
{
  "method": "ed25519-v2",
  "public": "<base64 ed25519-raw>",
  "digest": "<hex>",
  "result": "<base64 signature>",
  "custom": { }
}

Only ed25519-v2 is accepted. Keys are base64 ed25519-raw. Do not extend the proof envelope with fields outside custom.*.

Reference Node proof builder (illustrative):

import { createHash, sign } from 'node:crypto';
import canonicalize from 'canonicalize'; // RFC 8785 canonical JSON

interface ProofCustom {
  moment: string;        // ISO 8601
  handle: string;        // deb_* or cre_*
  status: 'prepared' | 'committed' | 'aborted' | 'failed';
  coreId?: string;
  reason?: string;       // bridge.* / core.* / record.* — only when status=failed
  detail?: string;       // only when status=failed
}

export function buildProof(
  primaryHashHex: string,
  custom: ProofCustom,
  privateKey: Buffer,    // ed25519-raw, 32 bytes
  publicKey: Buffer,     // ed25519-raw, 32 bytes
) {
  const customJson = canonicalize(custom) ?? '';
  const digestHex = createHash('sha256')
    .update(primaryHashHex + customJson)
    .digest('hex');

  // Node Ed25519: first arg to crypto.sign is undefined (no hash — raw digest)
  const signature = sign(undefined, Buffer.from(digestHex, 'hex'), {
    key: privateKey,
    format: 'der',
  });

  return {
    method: 'ed25519-v2',
    public: publicKey.toString('base64'),
    digest: digestHex,
    result: signature.toString('base64'),
    custom,
  };
}

HMAC webhook signing is gone. Everything goes through EdDSA JWT now.

Mock core contract

Your mock-core ledger operations are byte-compatible between adapters. This is the one place you probably do not need to rewrite.

Operationv1bridge-core
hold(account, amount)coreIdsamesame
settle(coreId)coreIdsamesame
release(coreId)samesame
reserveCredit(account, amount)coreIdsamesame
applyCredit(coreId)coreIdsamesame
releaseCredit(coreId)samesame
reverse(originalTransactionId)used by POST /refunddrop, no refund surface

Seeding differs slightly. bridge-core uses CSV-driven scenario seeding so all three reference impls (Java, Node, .NET) share identical starting state. v1 scenario replay is driven by fixtures from the Transfiya contract examples.

Step-by-step migration plan

For an existing v1 deployment. A senior engineer with an existing contract-test harness and CI can work through these phases in roughly 15–25 days per impl, depending on test depth and how much of the reference implementation you reuse.

Phase 0, alignment (1–2 days)

  1. Align on the bridge-core API contracts, flows, and authentication strategies before writing code. See the protocol reference for details.
  2. Decide with ops and security: which auth mode (ed25519-jwt or oauth-client-credentials)? Which impl stack (Java, Node, .NET)?
  3. Anchors and actions do not migrate here. Plan them separately if you still need them.

Phase 1, keypair and Hub registration (1 day)

  1. Generate an Ed25519 keypair (ed25519-raw). Store the private half in a secret vault.
  2. Register the public half with the Hub as a participant signer.
  3. If oauth-client-credentials:
    • Ask the Hub admin to create an oauth-client-credentials factor on your signer. The Hub returns clientId and clientSecret with include: ['meta.secret'] (secret retrievable once at creation).
    • Store clientId and clientSecret as vault refs (vault://...). Inline secrets fail config validation.

Phase 2, rewrite controllers (5–8 days)

  1. Delete: POST /refund, GET /status/:coreId, GET /account/verification/:accountId, GET /user/info/:userId, all anchor routes (/api/bridge/v2/onboard, /api/anchors/*), action consumer code, POST /login, POST /transfiya/oauth/token, reconciliation routes.
  2. Split: the single POST /debit handler into a prepare controller, a commit controller, and an abort controller. Same for POST /credit.
  3. Replace: POST /status (webhook) with an effect controller at POST /v2/effects/{handle}. Register an effect on the ledger that filters on intent status changes and delivers to your bridge (requires the events trait). The effect event payload carries the intent data and final status.
  4. Add: a proof-submitter component (queue, retry, Ed25519 signing) and wire every prepare, commit, and abort success path to enqueue a proof for POST /v2/intents/{handle}/proofs.

Phase 3, rewrite idempotency (2–4 days)

  1. Replace transactionId keys with data.handle (for prepare) and path {handle} (for commit, abort).
  2. Store the already-submitted proof payload in the entry, not the HTTP response body.
  3. Add a (handle, signal) idempotency scope for POST /v2/effects/{handle}.
  4. Keep 24h TTL.

Phase 4, rewrite auth (3–5 days)

  1. Delete: the three-guard stack (api-key, bearer, HMAC).
  2. Add: a single AuthFilter that verifies an EdDSA JWT against the Hub's registered public key.
  3. Add: AuthStrategy port with two implementations: Ed25519JwtAuthStrategy, OAuthClientCredentialsAuthStrategy. Wire the proof submitter and any outbound HTTP via the port only.
  4. Add: OAuth token cache (keyed on clientId) with single-flight refresh, 60 s pre-expiry skew, exponential backoff on 5xx (1 s initial, 30 s max, 5 attempts).

Phase 5, rewrite error mapping (2–3 days)

  1. Replace the numeric error table with string reasons: bridge.*, core.*, record.*.
  2. Move all validation into prepare. Commit and abort must never produce status: failed proofs.
  3. Schema-validation failures still return sync 400 with reason: record.schema-invalid. Everything else flows through the proof.

Phase 6, rewrite crypto (3–5 days)

The individual pieces are straightforward — tested libraries handle canonical JSON and Ed25519 for each platform. Most of the time here goes into getting the digest to match byte-for-byte against the Hub's expectation, which the reference implementation and contract tests make easy to verify.

  1. Implement RFC 8785 canonical JSON via a tested library (@noble/ed25519 plus a canonical-JSON lib on Node; jose, com.google.crypto.tink, or a canonical-JSON lib on Java; NSec.Cryptography on .NET).
  2. Implement the double-hash digest sha256(primaryHashHex + canonicalJSON(custom)).
  3. Implement Ed25519 sign-of-digest (digest bytes hex-decoded, then Ed25519-signed).
  4. Centralise the proof-building code in one module. Every controller calls through it.

Phase 7, amounts, handles, currency (1–2 days)

  1. Convert amount handling from decimal strings to integer minor units. Grep for "amount" and every route parser.
  2. Convert account handles from bare strings to <accountType>:<accountNumber>@<bank-domain>.
  3. Propagate symbol.handle (ISO 4217 lowercase) instead of implicit COP. Mock core must key accounts by (handle, symbol).

Phase 8, tests (5–8 days)

  1. Rewrite contract fixtures to bridge-core shapes. Drop anchor and action fixtures.
  2. Port scenario replay to cover: happy path, payer-side failure (insufficient funds), payee-side failure (target inactive), proof retry, OAuth 401 recovery (if applicable), proof-submitter dead-letter.
  3. Keep mutation-test coverage ≥ 80% per impl.
  4. Add a byte-for-byte parity test between impls if you are shipping more than one.

Phase 9, observability, operations, rollout (3–5 days)

Your v1 dashboards watched HTTP 5xx rate — that is no longer the primary signal. Rebuild around proof-submission latency (target p95 ≤ 1.5 s), inbound endpoint ack latency (p95 ≤ 250 ms), and dead-letter count. Suggested metric names:

MetricTypeAlert threshold
bridge_inbound_ack_latency_secondshistogramp95 > 250 ms for 5 min
bridge_proof_submit_latency_secondshistogramp95 > 1.5 s for 5 min
bridge_proof_submit_retry_totalcounterrate > 0.1 / s for 10 min
bridge_proof_deadletter_totalcounterany increment pages ops
bridge_oauth_token_refresh_secondshistogramp95 > 2 s for 5 min
bridge_oauth_token_refresh_failures_totalcounterany increment alerts ops
bridge_core_call_latency_secondshistogram (by op)p95 > 500 ms for 5 min
bridge_idempotency_replay_totalcounter (by phase)informational

Update runbooks. The error path is now asynchronous — ops learns about failures from dead-letter alerts and the proof audit log instead of 5xx spikes. Document: graceful shutdown behaviour (does the worker flush in-flight proofs? recovery from idempotency store?), key rotation procedure (both auth modes), and OAuth clientSecret rotation.

Plan a blue-green rollout. The adapter is a new binary. You do not flip a feature flag.

Worked example, debit happy path side by side

v1

POST /debit  (Hub to financial institution)
Authorization: Bearer eyJhbGci...
x-api-key: banco-acme-key

{ "transactionId": "tx-001", "target": "CHK:12345@acme",
  "amount": "200000.00", "transferId": "tfr-001" }
Financial institution validates api-key and bearer.
Financial institution loads mock core, settles debit, coreId=TRA-TY-7788.
200 OK

{ "code": "00", "message": "Acción realizada con éxito",
  "data": { "coreId": "TRA-TY-7788", "amount": "200000.00", "status": "SUCCESS" } }

Done. Three log lines, one HTTP round trip.

bridge-core

POST /v2/debits  (Hub to financial institution)
Authorization: Bearer <eddsa-jwt>
x-ledger: acme

{ "data": { "luid": "$deb.001", "amount": 20000000,
            "handle": "deb_01u9RGCevt4rEkRMV",
            "schema": "debit",
            "source": { "handle": "checking:12345@acme" },
            "symbol": { "handle": "cop" },
            "target": { "handle": "checking:98765@other" },
            "intent": { "data": {}, "hash": "...", "luid": "$int.X", "meta": {} } },
  "hash": "<hex>", "meta": { "proofs": [ ] } }
202 Accepted
Worker picks up deb_01u9RGCevt4rEkRMV.
Financial institution verifies hash and Hub's EdDSA proof.
Financial institution validates account, amount, currency, limits.
Financial institution calls mock core: hold(checking:12345@acme, 20000000, handle=deb_01u...)
  coreId=TRA-TY-7788.
Financial institution builds { moment, handle, status: "prepared", coreId: "TRA-TY-7788" }.
Financial institution computes digest = sha256(intentHash + canonicalJSON(custom)).
Financial institution signs digest with Ed25519 private key.
POST /v2/intents/$int.X/proofs  (financial institution to Hub)
Authorization: Bearer <eddsa-jwt or oauth-token>

{ "method": "ed25519-v2",
  "public": "<base64>",
  "digest": "<hex>",
  "result": "<base64 sig>",
  "custom": { "moment": "2026-04-17T10:22:11Z",
              "handle": "deb_01u9RGCevt4rEkRMV",
              "status": "prepared",
              "coreId": "TRA-TY-7788" } }
200 OK  (from Hub)

The Hub now knows Financial institution A has prepared. It sends the same shape to Financial institution B for credit prepare.

Once both sides are prepared, the Hub fires POST /v2/debits/deb_01u.../commit (202 ack plus committed proof) and POST /v2/credits/cre_.../commit (202 ack plus committed proof). After both commits complete, effects fire — each financial institution that registered an intent-status effect receives a POST /v2/effects/{handle} with the completed status.

Round-trip count per side: two inbound (prepare plus commit), two outbound proofs, plus an async effect notification for the final status. More exchanges than v1, but each one is simpler and independently verifiable.

Testing strategy delta

Test tierv1bridge-core
UnitPer impl: mock core, idempotency, guards, status mappersSame, plus AuthStrategy implementations, canonical JSON, digest builder, proof builder
ContractShared JSON fixtures lifted from Transfiya contract examples. Java and Node pass.Shared JSON fixtures across debit, credit, signing, and notification suites. Java, Node, and .NET pass byte for byte.
Integration, E2E40+ certification scenarios from Transfiya about-certification-process.md, replay harnessFake Hub, exercises happy, payer-fail, payee-fail, retry, OAuth exchange
ParityImplicit — both impls run against the same contract suite, no diffing runnerExplicit — one runner diffs Java, Node, .NET wire output byte for byte
Mutation≥ 80%≥ 80% (Stryker on JS, PIT on Java, Stryker.NET on .NET)

v1 certification evidence was Transfiya-specific. bridge-core's is protocol-level and applies to any network the Hub fronts. If you're on Transfiya, network-specific certification still happens — it just lives outside the adapter now.

Rollback considerations

v1 and bridge-core cannot run in parallel in front of the same ledger tenant. The Hub speaks one protocol per tenant. Rollback is therefore deployment-level:

  • Keep the v1 build deployable for the duration of the cutover.
  • Register a second tenant on the Hub for bridge-core integration testing.
  • Flip the live tenant's adapter once parity tests pass.
  • If rollback is needed, re-point the live tenant at the old deployment. The Hub side has no persisted state that assumes either protocol, beyond proofs already accepted, and those are immutable history either way.

Do not translate at the edge between v1-shaped Hub calls and bridge-core-shaped core calls. The state-machine mismatch (synchronous terminal vs. asynchronous phase) will produce phantom state and double-spends. The honest move is a real cutover.

Open questions for the architect

Before cutting over, confirm with Minka:

  1. OAuth retry budget on the Hub token endpoint. Default is 5 attempts, 30 s max backoff. Aligned with Hub SLAs?
  2. Proof retry budget on POST /v2/intents/{h}/proofs. Default 10 attempts, 60 s max. Dead-letter routing after exhaustion: who receives the alert?
  3. Clock skew tolerance on inbound JWT exp. Default 5 s. Sufficient for your NTP posture?
  4. coreId cardinality. If your core does the operation in two internal steps, can one proof carry both? Current design allows one optional coreId string per proof.
  5. Reversal flow. Your v1 POST /refund users: do they expect the financial institution to still surface a "refund" verb internally? If yes, translate it into a new intent upstream of the adapter. The adapter itself has no refund concept.
  6. Effect registration. To receive intent status notifications, register an effect that filters on intent status changes and delivers to your bridge (via the events trait) or to a webhook URL. Confirm the effect is active via the Hub operator UI before cutover.

Reference

On this page