Skip to content

The ISO 20022 Bridge

Bank-rail messages in, a statement out, and a compliance check that costs no anonymity.

  • crates/iso20022/ — the messages. Chain-free, so the XML decoder fuzzes alone.
  • crates/iso20022/src/amount.rs — the decimal conversion, and why it refuses rather than rounds.
  • crates/iso20022/src/xml.rs — every bound, and what the parser will not process at all.
  • crates/iso20022/src/bridge.rs — the refusal that keeps this off a chain holding value.
  • crates/zk-stark/src/sanctions.rs — the non-membership AIR.
  • crates/node/src/iso20022_bridge.rs — virtual accounts, and statements from committed blocks.
  • crates/node/tests/iso20022_tests.rs, fuzz/fuzz_targets/iso20022_decode.rs.

A sealed payment’s confidentiality is classical, and the envelope is on chain forever.

The sealed mempool is a KEM/DEM construction whose KEM half is Ristretto ElGamal. That is deliberate — there is no ML-KEM analogue of threshold ElGamal, and the threshold property is the whole point — but it means the secrecy of anything sealed rests on the discrete log problem, on a chain whose every signature and every transport handshake is post-quantum precisely because that assumption is expected to fail.

For an ordinary transaction that is a bounded loss: the envelope opens a block or two later and its contents are public anyway. For a bank payment it is not. A pacs.008 carries a debtor, a creditor, their account numbers and an amount, and those are still sensitive in twenty years. Sealing one writes it down in a form that is readable later, at a time of the adversary’s choosing, and no later fix reaches an envelope already on chain.

So bridge::check_chain refuses a value-bearing chain while CONFIDENTIALITY_IS_POST_QUANTUM is false, exactly as maya_zkml::srs::check_chain refuses one while its SRS is untrusted. The flag is flipped when the sealing is post-quantum, not when the bridge is “ready”.

The refusal is inside intents_from_pacs008 and intents_from_pacs009 rather than beside them, so a caller cannot reach the intents without passing the chain it means to submit them to. A guard a new entry point can forget is a guard that runs until somebody writes a new entry point.


Amounts: the rounding decision that is not taken

Section titled “Amounts: the rounding decision that is not taken”

ActiveCurrencyAndAmount is a decimal with a currency attribute:

<IntrBkSttlmAmt Ccy="EUR">1234.56</IntrBkSttlmAmt>

Maya2C amounts are u64 base units, and this codebase has no decimals constant anywhere — crates/node/src/rpc/market.rs says so explicitly, because saying “whole coins” in one place and “base units” everywhere else is how a listing form ends up wrong by a power of ten. So there is no scale to convert through, and one had to be chosen.

One compiled-in exponent, and an amount that does not divide exactly is an error, never a rounded value.

That is invariant 20’s rule in another costume: a rounding decision inside a value that moves money is a rounding decision two implementations can disagree on. Rounding 0.005 leaves a payment short or long by a sub-unit, and a bank rail reconciles to the unit or it does not reconcile. Refusing is a message somebody fixes; rounding is a discrepancy somebody finds in a month.

What the parser therefore refuses, each because some sender emits it and some parser accepts it: a sign (-5 — ISO 20022 carries direction in CdtDbtInd, never in the amount), exponent notation, thousands separators, surrounding whitespace, an empty integer part, a trailing point, and trailing zeros past the scale — 1.500 claiming three places is a sender working in a different scale, whose next message may carry a digit that is not a zero.

There is deliberately no per-currency ISO 4217 exponent table. A faithful table is the right thing for a multi-currency rail, but the moment such a table can influence what a block contains it is consensus data, and a bridge that quietly disagreed with its counterparty about BHD would be a fork rather than a rejected message.


A bank rail is where an XXE fetch and an entity-expansion bomb arrive, so both are refused structurally rather than bounded:

RefusedWhy structurally
<!DOCTYPE …>It is where an entity would be declared. Without a doctype, entity expansion is not something this crate limits — it is something the document cannot express, so there is no expansion limit to tune.
Undeclared entity referencesOnly the five predefined entities and character references resolve; &xxe; is an error rather than an empty string — which is the reading that would let a payload slip a field past a check. A reference longer than 16 bytes is refused before resolution.
Processing instructionsNothing acts on one, and a parser that silently drops what it does not understand is a parser two implementations disagree about.
Names that are not [prefix:]local in ASCIIquick-xml’s reader does not validate names, and <a'b> read as an element no writer could render back — a tree whose re-rendered form does not parse. Found by fuzz/fuzz_targets/iso20022_decode.rs on its first local run. Names are letters, digits, _, -, ., at most 128 bytes.
Two attributes with one local namex:k="1" y:k="2" would leave attribute("k") answering with whichever came first.
External resolutionThe crate opens no files and makes no network calls. There is no resolver to point at a URL.

Depth, breadth, text length and attribute count are bounded rather than refused, because those cost memory and time without needing a doctype at all. The document size is checked before the first byte is parsed, which is what makes the bounded-tree design safe: the tree costs memory proportional to the document, so the document is bounded first. The walk is iterative, so ten thousand nested elements is a clean Error::Bound rather than a stack overflow.

Matching is on the local name: <Document> and <ns0:Document> are the same element, because the prefix is a serializer’s choice and every counterparty makes a different one.


Bidirectional, and what that does not mean

Section titled “Bidirectional, and what that does not mean”

It does not mean byte-identical XML. XML has no canonical form — namespace prefixes, inter-element whitespace and the order of optional siblings are all a writer’s choice. A test asserting byte equality would assert that this crate agrees with its own writer, which is true and worth nothing.

So round-trip equality is asserted on the parsed model: the value a document decodes to must survive rendering and reparsing unchanged. That is the property a bank needs, because it is the one that says a payment instruction means the same thing after it has been forwarded.

The camt.053 reader re-derives the closing balance from the entries and refuses a document whose declared balance does not follow: OPBD + Σ(credits − debits) == CLBD. That is the chain’s own value-conservation shape (crates/node/src/state/invariant_guard/conservation.rs) applied to a statement: a total that must equal the sum of what moved, checked rather than assumed, because the failure is silent and expensive.

Charge bearer, regulatory reporting, clearing-system references, settlement method. They are not read and not preserved, and that is stated in each module rather than discovered by a counterparty whose RgltryRptg vanished.


A bank account has no Maya2C keypair, so each one maps to an address derived from its list identifier rather than generated. The same IBAN always maps to the same address, on every node, with nobody storing a table — and a table would be two problems: state that can disagree between nodes, and a thing an operator can edit to redirect a payment.

Nobody holds the private key to a derived address, which is the point: value there moves only through the bridge, because no signature exists that could move it otherwise. It also means value there is unrecoverable if the bridge is switched off.

The gateway signs for payments that arrive with no Maya2C key, so it can mint an instruction the bank never sent. That makes it the chain’s second trusted party after the oracle, and it gets invariant 11’s treatment: absent by default, enabling it is a decision somebody writes down.

NtryRef is Max35Text and no binary-to-text encoding puts a 32-byte transaction id in 35 characters — hex needs 64, base58 needs 44. So the reference is a 16-byte prefix, the longest that fits as hex.

Truncating is right for what the field is and wrong for what it is not. It is a reconciliation handle — one statement line against one block, where 128 bits makes a collision not worth reasoning about. It is not an identifier to resolve a dispute by without checking: the full id is in the block, and anything that matters should read it from there.

This was found by the round-trip test, not by review. The first version emitted a 64-character reference that this crate’s own reader — and every counterparty’s — refuses.


A compliance check and anonymity look like opposites: the check wants to know the party is not sanctioned, anonymity wants nobody to learn who the party is. Revealing the identifier satisfies the first and destroys the second, and doing it per payment would leak every counterparty’s whole customer base to every observer of the chain.

So the statement proved is the negative one:

I know a 32-byte identifier x that is not in the list committed to by root.

root is public; x is not. A verifier learns that a party cleared the list and nothing else — not who they are, not which entry bracketed them.

Membership shows a path. Absence leaves no leaf to show a path to. The construction proves a gap: two adjacent leaves lo and hi with lo < x < hi.

Adjacency is what makes it a proof rather than a claim. Any two listed entries straddling x prove nothing — the list could hold x between them. Only neighbours, at indices i and i + 1 of a sorted list, leave nowhere for x to hide. two_listed_entries_that_are_not_neighbours_prove_nothing is that attack, and it fails only because of the adjacency constraint.

Two sentinels bracket the ends so an identifier below every entry or above every entry is still provable. An entry at a sentinel is refused at construction: it would make everything past it unprovable, which is a denial of service on the honest side.

FpVar has no comparison gadget in ark-r1cs-std 0.6 — ordering field elements means bit-decomposing and hand-rolling a comparator, the kind of code that is subtly wrong in one corner. UInt8 has one, and [T]: CmpGadget gives lexicographic ordering over a slice. So an identifier is 32 bytes big-endian in the circuit, ordered lexicographically — which is exactly <[u8; 32]>::cmp natively. One definition, two places, no drift.

The comparison is strict on both sides. <= would let a listed party prove itself clear by presenting itself as its own neighbour; a_listed_identifier_cannot_be_smuggled_past_the_gap_check hands the circuit that lie with everything else about the witness genuine, so only the strictness can refuse it.

That x is the identifier of the party in the payment — binding a proof to a payment is the caller’s job. And not that the list is the right list: root is a public input, so a verifier that does not already know which root it expects learns nothing.

BLAKE3 of a structured 34-character IBAN is guessable by anyone willing to enumerate, and a published list is published. Privacy here comes from the proof — the verifier never sees the digest — not from the digest being hard to invert. A design that leaked the digest and called it anonymised would be wrong.

Both ends and both agents are checked. A bridge that checked only the creditor would let a sanctioned debtor pay anyone, which is the direction sanctions are usually written to stop.


RESEARCH. Nothing in consensus calls it: the node takes maya-iso20022 for crates/node/src/iso20022_bridge.rs, and the bridge is reached only by a gateway an operator turns on. A chain with no gateway produces the state root it would have had without the subsystem.

Promoting it means, in order: post-quantum sealing, a written decision about the gateway as a trusted party, and a published-and-versioned sanctions root.