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
| Reader | Start here |
|---|---|
| VP or delivery lead sizing effort | Executive summary, Scope delta |
| Architect scoping the integration | C4 diagrams, Protocol shift, sections 5 to 9 |
| Backend developer writing code | Endpoint mapping through Worked example |
| QA or certification lead | Testing strategy delta |
Executive summary
What changes
| Dimension | v1 bank adapter today | bridge-core target |
|---|---|---|
| Payment network | Transfiya (Colombia) only | Network-agnostic |
| Response style | Synchronous { code, message, data } envelope with coreId and status inline | Asynchronous: 202 Accepted ack, real result arrives in a signed proof |
| HTTP verbs | POST /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 location | Implicit: /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 format | Decimal string: "200000.00" | Integer minor units: 442120. Minor-unit count follows ISO 4217 decimals (COP = 2 decimals, so 200000.00 COP → 20000000). |
| Currency | Colombia-only, $tin (COP) | ISO 4217 lowercase via symbol.handle: cop, usd, brl |
| Handles | Wallet $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 webhook | EdDSA 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 codes | Numeric (99, 101, 121, 122, 128, 315, 323, ...) | String-prefixed: bridge.account-insufficient-balance, core.bridge-prepare-failed, record.not-found |
| Cryptography | HMAC on webhook, Ed25519 signing via SDK on bridge responses | Ed25519 end to end: RFC 8785 canonical JSON, SHA-256 double-hash digest, signed proof envelope on every phase |
| Anchors | In scope: legacy /api/bridge/v2/onboard and regulated /api/anchors/* (DICE, SPI) | Out of scope. |
| Actions, IOUs | In scope: UPLOAD, DOWNLOAD signing | Out of scope. |
| Refund | POST /refund endpoint | Out of scope. Reversals are modelled as a new intent upstream. |
| Reference impls | Java (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). IdempotencyRecordstill 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:
AnchorConsumerandActionConsumerare gone. Those surfaces are not part of this adapter. Delete them.- One
BankEndpointsControllerwith seven routes splits into two structural controllers (DebitCtrl,CreditCtrl) plus anEffectCtrlfor 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 singleAuthFilterthat verifies the Hub's EdDSA JWT on every inbound call. No API key, no HMAC webhook signature. ProofSubmitteris 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 acks202and the submitter completes the story later.- The
Cryptomodule 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
AuthStrategyport (not drawn) lets you picked25519-jwtoroauth-client-credentialsper 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 surface | Why 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 /refund | Reversals 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/:accountId | Validation happens inline during prepare. If the account is inactive, the prepare proof returns status: failed with reason: bridge.account-inactive. |
GET /user/info/:userId | KYC exposure is not part of the bridge contract. |
/login, /transfiya/oauth/token as inbound routes hosted by the financial institution | Outbound 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
| Surface | Purpose |
|---|---|
POST /v2/debits/{handle}/commit and .../abort | Explicit 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 .../abort | Same, 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 mode | New configuration path. Previously the financial institution did not exchange credentials with the Hub at all. |
| RFC 8785 canonical JSON, SHA-256 double-hash | Explicit, financial-institution-implemented digest construction. v1 hid this inside the SDK. |
Changed shape
| Concept | v1 shape | bridge-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 } }. |
| Amount | Decimal string "200000.00" | Integer minor units 20000000 (the cents) |
| Currency | Implicit COP ($tin) | Explicit symbol.handle: "cop" (ISO 4217 lowercase) |
| Idempotency key | transactionId, originalTransactionId, tx_id | data.handle (deb_*, cre_*) for prepare. Path {handle} for commit/abort. (handle, status) for notifications. |
| Status values | CREATED → 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 codes | Numeric (Transfiya table) | String reasons with prefix bridge.*, core.*, record.* |
| Inbound Hub-to-financial-institution auth | OAuth2 Bearer token from financial institution's own /login | EdDSA JWT the Hub self-signs per request |
| Outbound financial-institution-to-Hub auth | None | Per-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
202and 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: failedis 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/debitswith the samedata.handleuntil it gets202. Your idempotency key is the handle, not the HTTP request. POST /v2/intents/{handle}/proofsmust 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 route | bridge-core counterpart | Notes |
|---|---|---|
POST /debit | POST /v2/debits (prepare), POST /v2/debits/{h}/commit | One synchronous endpoint becomes two async endpoints plus a signed proof per phase. Validation moves to prepare, mechanical settle moves to commit. |
POST /credit | POST /v2/credits (prepare), POST /v2/credits/{h}/commit | Same pattern as debit. Prepare validates the target. Commit applies the credit. |
POST /refund | None | Delete. 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/:coreId | None | Delete. 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/:accountId | Inlined into POST /v2/debits and POST /v2/credits prepare | Delete 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/:userId | None | Delete. 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) | Removed | The 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) | Removed | Same. |
/api/bridge/v2/onboard (legacy anchor) | Out of scope | Delete. Anchor concerns leave this adapter. |
/api/anchors/* (regulated DICE, SPI) | Out of scope | Delete. Same. |
Action consumer (sign IOU) | Out of scope | Delete. |
New outbound calls from the financial institution, which have no v1 equivalent:
| New outbound route | Purpose |
|---|---|
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 AcceptedLater, 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
| v1 | bridge-core | Notes |
|---|---|---|
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. |
transferId | data.intent.data.handle or data.intent.luid | Intent 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. |
description | data.intent.data.custom.description | Moves 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
| Dimension | v1 | bridge-core |
|---|---|---|
| Primary credential | x-api-key header plus OAuth2 Bearer access_token issued by the financial institution's own /login | EdDSA JWT the Hub self-signs per request |
| Webhook integrity | HMAC-SHA256 x-minka-signature over raw body, shared hashKey | Same EdDSA JWT, no separate webhook signature |
| Verification | Compare API key; verify JWT against financial institution's own HS256 secret; HMAC compare for webhooks | Verify JWT signature against Hub's registered public key (EdDSA). Check aud, iss, iat, exp. |
| What the financial institution stores | API keys, user credentials, webhook hash keys, Hub's public key for response signing | Hub'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:
| Mode | Good for | Cost per request | Credential rotation |
|---|---|---|---|
ed25519-jwt | Financial institution already holds Ed25519 keys, no IdP, wants direct non-repudiation | One Ed25519 sign (~µs) | Rotate by reissuing the keypair and re-registering the public key with the Hub |
oauth-client-credentials | Financial institution has an enterprise IdP or secret vault, ops prefers client-secret rotation | Periodic token exchange (every 3600 s), cached token between calls | Rotate 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
| v1 | bridge-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 /debitreturns the stored response envelope verbatim. Core is never touched twice. - bridge-core: a duplicate
POST /v2/debitsreturns202 Acceptedagain. 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 → REJECTEDTerminal: COMPLETED, REJECTED. 7 total states.
bridge-core, intent
created → pending → prepared → committed → completed
↓
aborted → rejectedTerminal: completed, rejected. 7 total states (including created, which is Hub-internal).
bridge-core, per-handle (debit or credit)
received → prepared → committed
↓ ↓
failed abortedEach debit or credit handle has its own local lifecycle. The intent lifecycle is the join of both handles.
Mapping
| v1 transfer state | bridge-core equivalent |
|---|---|
CREATED | created (Hub-only) |
INITIATED | pending (Hub-only) |
PENDING | pending on the intent. Per-handle received. |
ACCEPTED | prepared (when both sides have prepared) |
COMPLETED | committed on each handle, completed on the intent |
ERROR | No direct state. A failed proof on prepare causes the intent to move toward aborted or rejected. |
REJECTED | rejected |
Anchor and account status are gone. They belong to other adapters.
Error model
Shape
| v1 | bridge-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 |
| Transport | Sync HTTP status + JSON body | 202 Accepted sync. Async proof carries the failure. |
| Code space | Numeric (Transfiya) | String prefixed bridge.*, core.*, record.* |
| Where it is legal | Any phase | Only 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 numeric | bridge-core string reason | When |
|---|---|---|
315 Insufficient funds | bridge.account-insufficient-balance | Source < amount at prepare |
307 Inactive account | bridge.account-inactive | Source or target suspended |
309 Account does not exist | bridge.account-not-found | Target account missing at credit prepare |
308 Cancelled account, 306 Seizure | bridge.account-inactive (generalised) | Terminal account states |
118 Schema validation error | record.schema-invalid | Body fails canonical JSON or schema check. Returned as sync 400, not a proof. |
121 Resource does not exist | record.not-found | Generic upstream lookup miss |
122 Duplicate resource | N/A (idempotent replay is silent) | Replay no longer surfaces as an error. It is a no-op that returns the cached proof. |
125 Already processed | N/A | Same. Idempotency is silent. |
128 Invalid state for request | N/A | bridge-core does not report illegal transitions to the Hub. The first commit or abort wins, subsequent calls are replays. |
300 Transfer timeout, 323 Transfer expired | Handled upstream | Intent TTL is a Hub concern. The financial institution receives a rejected status via effects. |
325 Bank refused init, 330 Cannot connect to financial entity | core.bridge-prepare-failed | Wrapper around any core failure. Adapter retries core up to 3× before failing. |
332 Unexpected financial entity error | core.bridge-prepare-failed | Same. |
99 Unexpected server error | record.schema-invalid or bridge.entry-rejected depending on root cause | Case-by-case. |
101 Auth | Sync 401 with no proof | Auth fails before the adapter creates any state. |
144 Rate limit | Sync 429 with Retry-After header | Transport-level. No proof involved. |
313, 329 User or transaction limit | bridge.entry-rejected | Manual 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
hashKeydelivered out of band at webhook creation.
What bridge-core requires
Every proof follows the same recipe, and you own every step:
- Build the
customobject (moment, handle, status, coreId, reason, detail). - Compute the digest:
sha256(primaryHashHex + canonicalJSON(custom)).primaryHashHexis thehashfield of the parent object (intent hash for proofs submitted toPOST /v2/intents/{h}/proofs).canonicalJSONis RFC 8785: alphabetically sorted keys, no whitespace.- Direct concatenation, no separator. Empty
custombecomes empty string.
- Sign the digest bytes (hex-decoded) with Ed25519. On Node, the first arg to
crypto.signisundefined. - 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.
| Operation | v1 | bridge-core |
|---|---|---|
hold(account, amount) → coreId | same | same |
settle(coreId) → coreId | same | same |
release(coreId) | same | same |
reserveCredit(account, amount) → coreId | same | same |
applyCredit(coreId) → coreId | same | same |
releaseCredit(coreId) | same | same |
reverse(originalTransactionId) | used by POST /refund | drop, 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)
- Align on the bridge-core API contracts, flows, and authentication strategies before writing code. See the protocol reference for details.
- Decide with ops and security: which auth mode (
ed25519-jwtoroauth-client-credentials)? Which impl stack (Java, Node, .NET)? - Anchors and actions do not migrate here. Plan them separately if you still need them.
Phase 1, keypair and Hub registration (1 day)
- Generate an Ed25519 keypair (
ed25519-raw). Store the private half in a secret vault. - Register the public half with the Hub as a participant signer.
- If
oauth-client-credentials:- Ask the Hub admin to create an
oauth-client-credentialsfactor on your signer. The Hub returnsclientIdandclientSecretwithinclude: ['meta.secret'](secret retrievable once at creation). - Store
clientIdandclientSecretas vault refs (vault://...). Inline secrets fail config validation.
- Ask the Hub admin to create an
Phase 2, rewrite controllers (5–8 days)
- 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. - Split: the single
POST /debithandler into a prepare controller, a commit controller, and an abort controller. Same forPOST /credit. - Replace:
POST /status(webhook) with an effect controller atPOST /v2/effects/{handle}. Register an effect on the ledger that filters on intent status changes and delivers to your bridge (requires theeventstrait). The effect event payload carries the intent data and final status. - 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)
- Replace
transactionIdkeys withdata.handle(for prepare) and path{handle}(for commit, abort). - Store the already-submitted proof payload in the entry, not the HTTP response body.
- Add a
(handle, signal)idempotency scope forPOST /v2/effects/{handle}. - Keep 24h TTL.
Phase 4, rewrite auth (3–5 days)
- Delete: the three-guard stack (api-key, bearer, HMAC).
- Add: a single
AuthFilterthat verifies an EdDSA JWT against the Hub's registered public key. - Add:
AuthStrategyport with two implementations:Ed25519JwtAuthStrategy,OAuthClientCredentialsAuthStrategy. Wire the proof submitter and any outbound HTTP via the port only. - 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)
- Replace the numeric error table with string reasons:
bridge.*,core.*,record.*. - Move all validation into prepare. Commit and abort must never produce
status: failedproofs. - Schema-validation failures still return sync
400withreason: 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.
- Implement RFC 8785 canonical JSON via a tested library (
@noble/ed25519plus a canonical-JSON lib on Node;jose,com.google.crypto.tink, or a canonical-JSON lib on Java;NSec.Cryptographyon .NET). - Implement the double-hash digest
sha256(primaryHashHex + canonicalJSON(custom)). - Implement Ed25519 sign-of-digest (digest bytes hex-decoded, then Ed25519-signed).
- Centralise the proof-building code in one module. Every controller calls through it.
Phase 7, amounts, handles, currency (1–2 days)
- Convert amount handling from decimal strings to integer minor units. Grep for
"amount"and every route parser. - Convert account handles from bare strings to
<accountType>:<accountNumber>@<bank-domain>. - Propagate
symbol.handle(ISO 4217 lowercase) instead of implicit COP. Mock core must key accounts by(handle, symbol).
Phase 8, tests (5–8 days)
- Rewrite contract fixtures to bridge-core shapes. Drop anchor and action fixtures.
- 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.
- Keep mutation-test coverage ≥ 80% per impl.
- 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:
| Metric | Type | Alert threshold |
|---|---|---|
bridge_inbound_ack_latency_seconds | histogram | p95 > 250 ms for 5 min |
bridge_proof_submit_latency_seconds | histogram | p95 > 1.5 s for 5 min |
bridge_proof_submit_retry_total | counter | rate > 0.1 / s for 10 min |
bridge_proof_deadletter_total | counter | any increment pages ops |
bridge_oauth_token_refresh_seconds | histogram | p95 > 2 s for 5 min |
bridge_oauth_token_refresh_failures_total | counter | any increment alerts ops |
bridge_core_call_latency_seconds | histogram (by op) | p95 > 500 ms for 5 min |
bridge_idempotency_replay_total | counter (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 AcceptedWorker 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 tier | v1 | bridge-core |
|---|---|---|
| Unit | Per impl: mock core, idempotency, guards, status mappers | Same, plus AuthStrategy implementations, canonical JSON, digest builder, proof builder |
| Contract | Shared 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, E2E | 40+ certification scenarios from Transfiya about-certification-process.md, replay harness | Fake Hub, exercises happy, payer-fail, payee-fail, retry, OAuth exchange |
| Parity | Implicit — both impls run against the same contract suite, no diffing runner | Explicit — 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:
- OAuth retry budget on the Hub token endpoint. Default is 5 attempts, 30 s max backoff. Aligned with Hub SLAs?
- Proof retry budget on
POST /v2/intents/{h}/proofs. Default 10 attempts, 60 s max. Dead-letter routing after exhaustion: who receives the alert? - Clock skew tolerance on inbound JWT
exp. Default 5 s. Sufficient for your NTP posture? coreIdcardinality. If your core does the operation in two internal steps, can one proof carry both? Current design allows one optionalcoreIdstring per proof.- Reversal flow. Your v1
POST /refundusers: 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. - Effect registration. To receive intent status notifications, register an effect that filters on intent status changes and delivers to your bridge (via the
eventstrait) or to a webhook URL. Confirm the effect is active via the Hub operator UI before cutover.
Reference
- About Orchestrating Systems — two-phase commit protocol
- Build the Credit and Debit Interface — endpoint surface
- Bridge Authentication — EdDSA JWT and OAuth modes
- Handle Errors and Retries — idempotency and retry semantics