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.
The one idea to read first
Section titled “The one idea to read first”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.
What the XML parser will not process
Section titled “What the XML parser will not process”A bank rail is where an XXE fetch and an entity-expansion bomb arrive, so both are refused structurally rather than bounded:
| Refused | Why 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 references | Only 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 instructions | Nothing 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 ASCII | quick-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 name | x:k="1" y:k="2" would leave attribute("k") answering with whichever came first. |
| External resolution | The 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.
What the subset does not carry
Section titled “What the subset does not carry”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.
Virtual accounts
Section titled “Virtual accounts”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.
Statement entry references
Section titled “Statement entry references”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.
Compliance without identity
Section titled “Compliance without identity”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
xthat is not in the list committed to byroot.
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.
Why it needs a sorted tree
Section titled “Why it needs a sorted tree”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.
Why bytes and not field elements
Section titled “Why bytes and not field elements”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.
What it does not prove
Section titled “What it does not prove”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.
Digests are not secrets
Section titled “Digests are not secrets”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.
Status
Section titled “Status”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.